Skip to content

Star-Field Sensor Simulation

In this example we'll simulate a star tracker's field of view sweeping across the sky as a satellite orbits Earth. We'll propagate a sun-synchronous low Earth orbit for one orbital period, point a 30° full-angle sensor along the velocity vector (along-track), and test the Hipparcos catalog's naked-eye-bright stars against the sensor cone at each time step. The result is an animated 3D scene showing which stars enter and leave the field of view as the satellite moves.


Setup

First, we import the required modules and define the sensor and orbit configuration. The sensor half-angle sets a 30° full field of view, and stars are displayed on a fixed shell at three Earth radii so they stay clear of the orbit and Earth sphere:

# Sensor and star-catalog configuration
HALF_ANGLE_DEG = 15.0  # Sensor half-angle (30 deg full field of view)
STAR_MAG_LIMIT = 5.2  # Naked-eye-bright Hipparcos stars
STAR_SHELL_RADIUS = 3.0 * bh.R_EARTH  # Display radius for the star sphere
CONE_LENGTH = 3000e3  # FOV cone visualization length, meters
CONE_SEGMENTS = 24  # Cone base polygon resolution
PROPAGATION_STEP = (
    20.0  # Propagation/animation frame step, seconds (smaller = smoother)
)

epoch = bh.Epoch.from_datetime(2026, 1, 1, 0, 0, 0.0, 0.0, bh.TimeSystem.UTC)
oe = np.array([bh.R_EARTH + 500e3, 0.001, 97.4, 0.0, 0.0, 0.0])
state0 = bh.state_koe_to_eci(oe, bh.AngleFormat.DEGREES)
period = bh.orbital_period(oe[0])

# Propagate one orbital period with the Keplerian propagator, one animation
# frame per PROPAGATION_STEP (a fine step keeps the boresight/star motion smooth)
prop = bh.KeplerianPropagator.from_eci(epoch, state0, PROPAGATION_STEP)
prop.propagate_to(epoch + period)
traj = prop.trajectory

states = traj.to_matrix()  # [n_frames, 6]: ECI position (m) and velocity (m/s)
positions = states[:, 0:3]
velocities = states[:, 3:6]
n_frames = positions.shape[0]

# The sensor boresight points along the velocity vector (along-track)
boresights = velocities / np.linalg.norm(velocities, axis=1, keepdims=True)

# Roll reference for the sensor frame: the orbit normal (r x v). It is
# perpendicular to the boresight everywhere on the orbit, so using it as the
# sensor "up" direction gives an (u, v) basis that rotates continuously as the
# boresight sweeps around the orbit -- with no singularity or sign flip at the
# plane crossings. This keeps every star moving in one consistent direction in
# the sensor view over the full orbital period.
orbit_normals = np.cross(positions, velocities)
orbit_normals /= np.linalg.norm(orbit_normals, axis=1, keepdims=True)

state_koe_to_eci converts the Keplerian elements [a, e, i, raan, argp, mean_anomaly] to a Cartesian ECI state, which seeds a KeplerianPropagator. Propagating to epoch + period with a 60 s step produces one frame per step for the full orbit. The sensor boresight is simply the normalized velocity vector at each step — the sensor points along-track.

Load the Star Catalog

Star Catalog Cache

Star catalogs are downloaded from remote servers and cached permanently at ~/.cache/brahe/star_catalogs/. Subsequent runs read from the cache and do not make any network requests. To force a re-download, delete the cached catalog files.

Next we download (or load from cache) the Hipparcos catalog and filter to naked-eye-bright stars. Each record's unit_vector() gives its direction in the same inertial frame as the propagated orbit, so no additional frame transformation is needed:

1
2
3
4
5
6
7
8
9
# Load the Hipparcos catalog and keep naked-eye-bright stars
hipparcos = bh.datasets.star_catalogs.get_hipparcos()
bright_stars = hipparcos.filter_by_magnitude(STAR_MAG_LIMIT)
star_records = bright_stars.records()

star_names = [record.name() or record.id() for record in star_records]
star_vmags = np.array([record.vmag for record in star_records])
star_unit_vectors = np.array([record.unit_vector() for record in star_records])
star_positions = star_unit_vectors * STAR_SHELL_RADIUS

Note

A star's unit_vector() is computed directly from its cataloged right ascension and declination — see RA/Dec Transformations for how state_inertial_to_radec and related functions convert between Cartesian directions and RA/Dec. Proper motion is not applied here since the catalog epoch (J1991.25) is close enough to the simulation epoch that the shift is negligible for a field-of-view demonstration.

Visibility Test

A star is inside the field of view when the angle between the boresight and the star direction is smaller than the sensor half-angle, i.e. dot(star_hat, boresight_hat) > cos(half_angle). Computing the full dot product matrix up front (stars × frames) lets us evaluate visibility for every frame in one vectorized operation:

# A star is inside the field of view when the angle between the boresight
# and the star direction is smaller than the sensor half-angle
cos_half_angle = np.cos(np.radians(HALF_ANGLE_DEG))
star_boresight_dot = star_unit_vectors @ boresights.T  # [n_stars, n_frames]
visible_mask = star_boresight_dot > cos_half_angle

visible_counts = visible_mask.sum(axis=0)
print(f"Loaded {len(star_records)} stars brighter than Vmag {STAR_MAG_LIMIT}")
print(f"Frames: {n_frames}")
print(
    "Visible stars per frame: "
    f"min={int(visible_counts.min())}, "
    f"median={int(np.median(visible_counts))}, "
    f"max={int(visible_counts.max())}"
)

Field of View Cone

The sensor cone is rendered as a Mesh3d triangle fan: the apex sits at the satellite position, and a 24-segment base circle is built from two vectors orthogonal to the boresight, positioned CONE_LENGTH along the boresight direction:

def boresight_basis(boresight, up_reference):
    """Build an orthonormal (u, v) basis spanning the plane perpendicular
    to the boresight direction.

    ``v`` is aligned with ``up_reference`` (its component perpendicular to the
    boresight) and ``u`` completes the right-handed frame. Passing the orbit
    normal as ``up_reference`` -- which stays perpendicular to a velocity-aligned
    boresight over the whole orbit -- makes the basis vary continuously as the
    boresight sweeps around, so the projected star field never flips direction.
    """
    v = up_reference - np.dot(up_reference, boresight) * boresight
    v /= np.linalg.norm(v)
    u = np.cross(v, boresight)
    return u, v


def fov_cone_mesh(apex, boresight, up_reference, length, half_angle_deg, n_segments):
    """Build FOV cone mesh vertices and triangle faces for `go.Mesh3d`.

    The cone apex sits at the satellite position and its axis is aligned
    with the boresight direction; vertex 0 is the apex and vertices
    1..n_segments form the base circle.
    """
    u, v = boresight_basis(boresight, up_reference)

    radius = length * np.tan(np.radians(half_angle_deg))
    theta = np.linspace(0.0, 2 * np.pi, n_segments, endpoint=False)
    base_center = apex + boresight * length
    base = (
        base_center
        + radius * np.outer(np.cos(theta), u)
        + radius * np.outer(np.sin(theta), v)
    )

    vertices = np.vstack([apex, base])
    idx = np.arange(n_segments)
    i_faces = np.zeros(n_segments, dtype=int)
    j_faces = 1 + idx
    k_faces = 1 + (idx + 1) % n_segments
    return vertices, i_faces, j_faces, k_faces


def marker_sizes(vmags):
    """Map visual magnitude to marker size so brighter stars render larger."""
    return np.clip(9.0 - 1.2 * vmags, 3.0, 9.0)

Animation

The figure combines a static Earth sphere and orbit path with three traces that update every frame: the satellite marker, the FOV cone, and the currently visible stars. Marker size scales with each star's visual magnitude so brighter stars render larger. Only the three changing traces are included in each go.Frame, keeping the animation payload small even with nearly 100 frames.

The animation starts playing automatically; use the Play/Pause button or drag the slider to scrub through the orbit. Hover over any star marker to see its name:

Across the 96 frames of this scenario, the number of visible stars ranges from 15 to 93 (median 27) as the 30° cone sweeps through regions of varying star density.

Sensor-Frame View

The 3D scene above shows the sensor cone in its orbital context; the animation below shows only what the sensor itself would see. Each visible star's unit vector is projected into boresight-relative angular coordinates - a cross-boresight offset (x) and an elevation-like offset (y), both in degrees - using the same (u, v) basis as the FOV cone, so a constant angular separation from the boresight traces an exact circle. The dotted circle marks the 15° half-angle FOV boundary; the axes are fixed at ±15° so the star field's apparent motion through the sensor is directly comparable frame to frame.

Full Code Example

Full Code
star_field_simulation.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 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
166
167
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
247
248
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
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
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
# Sensor and star-catalog configuration
HALF_ANGLE_DEG = 15.0  # Sensor half-angle (30 deg full field of view)
STAR_MAG_LIMIT = 5.2  # Naked-eye-bright Hipparcos stars
STAR_SHELL_RADIUS = 3.0 * bh.R_EARTH  # Display radius for the star sphere
CONE_LENGTH = 3000e3  # FOV cone visualization length, meters
CONE_SEGMENTS = 24  # Cone base polygon resolution
PROPAGATION_STEP = (
    20.0  # Propagation/animation frame step, seconds (smaller = smoother)
)

epoch = bh.Epoch.from_datetime(2026, 1, 1, 0, 0, 0.0, 0.0, bh.TimeSystem.UTC)
oe = np.array([bh.R_EARTH + 500e3, 0.001, 97.4, 0.0, 0.0, 0.0])
state0 = bh.state_koe_to_eci(oe, bh.AngleFormat.DEGREES)
period = bh.orbital_period(oe[0])

# Propagate one orbital period with the Keplerian propagator, one animation
# frame per PROPAGATION_STEP (a fine step keeps the boresight/star motion smooth)
prop = bh.KeplerianPropagator.from_eci(epoch, state0, PROPAGATION_STEP)
prop.propagate_to(epoch + period)
traj = prop.trajectory

states = traj.to_matrix()  # [n_frames, 6]: ECI position (m) and velocity (m/s)
positions = states[:, 0:3]
velocities = states[:, 3:6]
n_frames = positions.shape[0]

# The sensor boresight points along the velocity vector (along-track)
boresights = velocities / np.linalg.norm(velocities, axis=1, keepdims=True)

# Roll reference for the sensor frame: the orbit normal (r x v). It is
# perpendicular to the boresight everywhere on the orbit, so using it as the
# sensor "up" direction gives an (u, v) basis that rotates continuously as the
# boresight sweeps around the orbit -- with no singularity or sign flip at the
# plane crossings. This keeps every star moving in one consistent direction in
# the sensor view over the full orbital period.
orbit_normals = np.cross(positions, velocities)
orbit_normals /= np.linalg.norm(orbit_normals, axis=1, keepdims=True)

# Load the Hipparcos catalog and keep naked-eye-bright stars
hipparcos = bh.datasets.star_catalogs.get_hipparcos()
bright_stars = hipparcos.filter_by_magnitude(STAR_MAG_LIMIT)
star_records = bright_stars.records()

star_names = [record.name() or record.id() for record in star_records]
star_vmags = np.array([record.vmag for record in star_records])
star_unit_vectors = np.array([record.unit_vector() for record in star_records])
star_positions = star_unit_vectors * STAR_SHELL_RADIUS

# A star is inside the field of view when the angle between the boresight
# and the star direction is smaller than the sensor half-angle
cos_half_angle = np.cos(np.radians(HALF_ANGLE_DEG))
star_boresight_dot = star_unit_vectors @ boresights.T  # [n_stars, n_frames]
visible_mask = star_boresight_dot > cos_half_angle

visible_counts = visible_mask.sum(axis=0)
print(f"Loaded {len(star_records)} stars brighter than Vmag {STAR_MAG_LIMIT}")
print(f"Frames: {n_frames}")
print(
    "Visible stars per frame: "
    f"min={int(visible_counts.min())}, "
    f"median={int(np.median(visible_counts))}, "
    f"max={int(visible_counts.max())}"
)

# Display quantities (km) for scene axes; visibility/geometry above stays in
# the library's native SI units (meters)
positions_km = positions * 1e-3
star_positions_km = star_positions * 1e-3
cone_length_km = CONE_LENGTH * 1e-3
star_shell_radius_km = STAR_SHELL_RADIUS * 1e-3


def boresight_basis(boresight, up_reference):
    """Build an orthonormal (u, v) basis spanning the plane perpendicular
    to the boresight direction.

    ``v`` is aligned with ``up_reference`` (its component perpendicular to the
    boresight) and ``u`` completes the right-handed frame. Passing the orbit
    normal as ``up_reference`` -- which stays perpendicular to a velocity-aligned
    boresight over the whole orbit -- makes the basis vary continuously as the
    boresight sweeps around, so the projected star field never flips direction.
    """
    v = up_reference - np.dot(up_reference, boresight) * boresight
    v /= np.linalg.norm(v)
    u = np.cross(v, boresight)
    return u, v


def fov_cone_mesh(apex, boresight, up_reference, length, half_angle_deg, n_segments):
    """Build FOV cone mesh vertices and triangle faces for `go.Mesh3d`.

    The cone apex sits at the satellite position and its axis is aligned
    with the boresight direction; vertex 0 is the apex and vertices
    1..n_segments form the base circle.
    """
    u, v = boresight_basis(boresight, up_reference)

    radius = length * np.tan(np.radians(half_angle_deg))
    theta = np.linspace(0.0, 2 * np.pi, n_segments, endpoint=False)
    base_center = apex + boresight * length
    base = (
        base_center
        + radius * np.outer(np.cos(theta), u)
        + radius * np.outer(np.sin(theta), v)
    )

    vertices = np.vstack([apex, base])
    idx = np.arange(n_segments)
    i_faces = np.zeros(n_segments, dtype=int)
    j_faces = 1 + idx
    k_faces = 1 + (idx + 1) % n_segments
    return vertices, i_faces, j_faces, k_faces


def marker_sizes(vmags):
    """Map visual magnitude to marker size so brighter stars render larger."""
    return np.clip(9.0 - 1.2 * vmags, 3.0, 9.0)




def sensor_frame_angles(star_unit_vectors, boresight, up_reference):
    """Project star unit vectors into boresight-relative angular offsets.

    Decomposes each star's angular separation from the boresight into a
    cross-boresight (x) and an "elevation-like" (y) component, both in
    degrees, using the same (u, v) basis as the FOV cone. The orbit-normal
    axis maps to x and the in-orbit-plane axis to y, so the along-track sweep
    of the boresight scrolls the star field along the elevation axis rather
    than sideways. A constant angular separation from the boresight (the FOV
    boundary) is therefore an exact circle of that radius in (x, y).
    """
    u, v = boresight_basis(boresight, up_reference)
    cos_theta = np.clip(star_unit_vectors @ boresight, -1.0, 1.0)
    theta_deg = np.degrees(np.arccos(cos_theta))
    # v is the orbit normal (-> x, cross-boresight); u lies in the orbit plane
    # (-> y, elevation-like), which is the direction the boresight sweeps.
    phi = np.arctan2(star_unit_vectors @ u, star_unit_vectors @ v)
    return theta_deg * np.cos(phi), theta_deg * np.sin(phi)


def animation_controls(n_frames):
    """Shared Play/Pause buttons + scrub slider for the animated figures."""
    updatemenus = [
        {
            "type": "buttons",
            "showactive": False,
            "x": 0.05,
            "y": 0.02,
            "xanchor": "left",
            "yanchor": "bottom",
            "buttons": [
                {
                    "label": "Play",
                    "method": "animate",
                    "args": [
                        None,
                        {
                            "frame": {"duration": 40, "redraw": True},
                            "fromcurrent": True,
                            "transition": {"duration": 0},
                        },
                    ],
                },
                {
                    "label": "Pause",
                    "method": "animate",
                    "args": [
                        [None],
                        {
                            "frame": {"duration": 0, "redraw": False},
                            "mode": "immediate",
                        },
                    ],
                },
            ],
        }
    ]
    sliders = [
        {
            "active": 0,
            "x": 0.15,
            "len": 0.85,
            "currentvalue": {"visible": False},
            "ticklen": 0,
            "steps": [
                {
                    "label": "",
                    "method": "animate",
                    "args": [
                        [str(k)],
                        {"frame": {"duration": 0, "redraw": True}, "mode": "immediate"},
                    ],
                }
                for k in range(n_frames)
            ],
        }
    ]
    return updatemenus, sliders




def create_figure(theme):
    colors = get_theme_colors(theme)
    earth_color = "#a9c6e8" if theme == "light" else "#2f4f6f"
    # Stars sit on the solid (axis-free) background: the pale gold reads well on
    # the dark theme, but the white light-theme background needs a darker gold.
    star_color = colors["quaternary"] if theme == "dark" else "#8a6d1f"

    r_earth_km = bh.R_EARTH * 1e-3
    lon = np.linspace(0, 2 * np.pi, 60)
    lat = np.linspace(0, np.pi, 30)
    earth_x = r_earth_km * np.outer(np.cos(lon), np.sin(lat))
    earth_y = r_earth_km * np.outer(np.sin(lon), np.sin(lat))
    earth_z = r_earth_km * np.outer(np.ones_like(lon), np.cos(lat))

    fig = go.Figure()

    # Trace 0: Earth sphere (static)
    fig.add_trace(
        go.Surface(
            x=earth_x,
            y=earth_y,
            z=earth_z,
            colorscale=[[0, earth_color], [1, earth_color]],
            showscale=False,
            showlegend=True,
            opacity=0.55,
            name="Earth",
            hoverinfo="skip",
        )
    )

    # Trace 1: full orbit path (static)
    fig.add_trace(
        go.Scatter3d(
            x=positions_km[:, 0],
            y=positions_km[:, 1],
            z=positions_km[:, 2],
            mode="lines",
            line={"color": colors["primary"], "width": 3},
            name="Orbit",
            hoverinfo="skip",
        )
    )

    # Trace 2: satellite marker (animated)
    fig.add_trace(
        go.Scatter3d(
            x=[positions_km[0, 0]],
            y=[positions_km[0, 1]],
            z=[positions_km[0, 2]],
            mode="markers",
            marker={"size": 5, "color": colors["secondary"]},
            name="Satellite",
            hoverinfo="skip",
        )
    )

    # Trace 3: FOV cone (animated)
    cone_vertices, cone_i, cone_j, cone_k = fov_cone_mesh(
        positions_km[0],
        boresights[0],
        orbit_normals[0],
        cone_length_km,
        HALF_ANGLE_DEG,
        CONE_SEGMENTS,
    )
    fig.add_trace(
        go.Mesh3d(
            x=cone_vertices[:, 0],
            y=cone_vertices[:, 1],
            z=cone_vertices[:, 2],
            i=cone_i,
            j=cone_j,
            k=cone_k,
            color=colors["accent"],
            opacity=0.25,
            flatshading=True,
            showlegend=True,
            name="Field of View",
            hoverinfo="skip",
        )
    )

    # Trace 4: visible stars (animated)
    frame0_idx = np.nonzero(visible_mask[:, 0])[0]
    fig.add_trace(
        go.Scatter3d(
            x=star_positions_km[frame0_idx, 0],
            y=star_positions_km[frame0_idx, 1],
            z=star_positions_km[frame0_idx, 2],
            mode="markers",
            marker={
                "size": marker_sizes(star_vmags[frame0_idx]),
                "color": star_color,
            },
            text=[star_names[i] for i in frame0_idx],
            hovertemplate="%{text}<extra></extra>",
            name="Visible Stars",
        )
    )

    # Animation frames: only the satellite, cone, and visible-star traces change
    frames = []
    for k in range(n_frames):
        idx = np.nonzero(visible_mask[:, k])[0]
        cone_vertices, cone_i, cone_j, cone_k = fov_cone_mesh(
            positions_km[k],
            boresights[k],
            orbit_normals[k],
            cone_length_km,
            HALF_ANGLE_DEG,
            CONE_SEGMENTS,
        )
        frames.append(
            go.Frame(
                name=str(k),
                traces=[2, 3, 4],
                data=[
                    go.Scatter3d(
                        x=[positions_km[k, 0]],
                        y=[positions_km[k, 1]],
                        z=[positions_km[k, 2]],
                    ),
                    go.Mesh3d(
                        x=cone_vertices[:, 0],
                        y=cone_vertices[:, 1],
                        z=cone_vertices[:, 2],
                        i=cone_i,
                        j=cone_j,
                        k=cone_k,
                    ),
                    go.Scatter3d(
                        x=star_positions_km[idx, 0],
                        y=star_positions_km[idx, 1],
                        z=star_positions_km[idx, 2],
                        marker={"size": marker_sizes(star_vmags[idx])},
                        text=[star_names[i] for i in idx],
                    ),
                ],
            )
        )
    fig.frames = frames

    updatemenus, sliders = animation_controls(n_frames)
    axis_range = [-1.05 * star_shell_radius_km, 1.05 * star_shell_radius_km]
    fig.update_layout(
        title="Star-Field Sensor Simulation (SSO, One Orbital Period)",
        scene={
            # Hide the axes, ticks, labels, and grid panes so the stars sit on
            # a clean solid background rather than the default light-grey cube
            "xaxis": {"visible": False, "range": axis_range},
            "yaxis": {"visible": False, "range": axis_range},
            "zaxis": {"visible": False, "range": axis_range},
            "aspectmode": "cube",
            "camera": {"eye": {"x": 1.4, "y": 1.4, "z": 0.9}},
        },
        updatemenus=updatemenus,
        sliders=sliders,
    )

    return fig




def create_sensor_view_figure(theme):
    """2D sensor-frame view: only the visible stars, in boresight-relative
    angular coordinates, with the fixed FOV boundary drawn as a circle.
    """
    colors = get_theme_colors(theme)
    star_color = colors["quaternary"] if theme == "dark" else "#8a6d1f"

    fig = go.Figure()

    # Trace 0: FOV boundary (static) - a constant angular separation from
    # the boresight is an exact circle of this radius in (x, y)
    boundary_theta = np.linspace(0.0, 2 * np.pi, 100)
    fig.add_trace(
        go.Scatter(
            x=HALF_ANGLE_DEG * np.cos(boundary_theta),
            y=HALF_ANGLE_DEG * np.sin(boundary_theta),
            mode="lines",
            line={"color": colors["accent"], "width": 2, "dash": "dot"},
            name=f"Field of View ({HALF_ANGLE_DEG:.0f}°)",
            hoverinfo="skip",
        )
    )

    # Trace 1: visible stars (animated). The orbit-normal roll reference in
    # sensor_frame_angles keeps the (u, v) basis continuous over the orbit, so
    # each star drifts smoothly and consistently frame-to-frame; stars only
    # appear/disappear as they cross the field-of-view boundary.
    # Each star carries a stable catalog-index id. Plotly matches points across
    # frames by id (object constancy), so a star that stays in view glides to
    # its new position while stars crossing the field-of-view boundary fade in
    # and out -- rather than markers being reassigned by list position when the
    # visible-star count changes, which makes unrelated stars appear to jump.
    frame0_idx = np.nonzero(visible_mask[:, 0])[0]
    x0, y0 = sensor_frame_angles(
        star_unit_vectors[frame0_idx], boresights[0], orbit_normals[0]
    )
    fig.add_trace(
        go.Scatter(
            x=x0,
            y=y0,
            ids=[str(i) for i in frame0_idx],
            mode="markers",
            marker={
                "size": marker_sizes(star_vmags[frame0_idx]),
                "color": star_color,
            },
            text=[star_names[i] for i in frame0_idx],
            hovertemplate="%{text}<extra></extra>",
            name="Visible Stars",
        )
    )

    # Animation frames: only the visible-star trace changes
    frames = []
    for k in range(n_frames):
        idx = np.nonzero(visible_mask[:, k])[0]
        x_k, y_k = sensor_frame_angles(
            star_unit_vectors[idx], boresights[k], orbit_normals[k]
        )
        frames.append(
            go.Frame(
                name=str(k),
                traces=[1],
                data=[
                    go.Scatter(
                        x=x_k,
                        y=y_k,
                        ids=[str(i) for i in idx],
                        marker={"size": marker_sizes(star_vmags[idx])},
                        text=[star_names[i] for i in idx],
                    )
                ],
            )
        )
    fig.frames = frames

    updatemenus, sliders = animation_controls(n_frames)
    axis_range = [-1.05 * HALF_ANGLE_DEG, 1.05 * HALF_ANGLE_DEG]
    fig.update_layout(
        title="Star-Field Sensor View (SSO, One Orbital Period)",
        xaxis={
            "title": "Cross-Boresight Offset (deg)",
            "range": axis_range,
            "zeroline": False,
        },
        yaxis={
            "title": "Elevation-Like Offset (deg)",
            "range": axis_range,
            "zeroline": False,
            "scaleanchor": "x",
            "scaleratio": 1,
        },
        updatemenus=updatemenus,
        sliders=sliders,
    )

    return fig

See Also