• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

moeyensj / thor / 22634960315

03 Mar 2026 05:28PM UTC coverage: 59.552% (-0.03%) from 59.577%
22634960315

push

github

web-flow
 Experimental phase space coverage, covariance-aware clustering, and pipeline improvements (#168)

* Phase space test orbit generation — New phase_space package with HEALPix-based test orbit generation, recursive phase space splitting, coverage analysis across 6D orbital element bounds, and nearest-test-orbit proximity matching.

* Covariance propagation — Propagate observation and test orbit uncertainties through the gnomonic tangent plane, enabling Mahalanobis-distance-based observation filtering (TestOrbitMahalanobisObservationFilter) and covariance-derived adaptive clustering parameters.

* Cluster fitting — Replace simple clustering output with FittedClusters carrying polynomial motion models (position, velocity, acceleration, chi2). Adds fit_clusters(), per-observation residuals, and min_nights filtering.

* Pipeline changes — Add generate_ephemeris as an explicit pipeline stage (reusing ephemeris from filtering). Add stop_after_stage for early termination, yield_paths mode for reduced memory usage, and experimental link_test_orbits with recursive splitting.

* Analysis and plotting — New analysis.py (difi v2.0-powered linkage purity/completeness/findability metrics) and plotting.py (Plotly-based interactive visualization of observations and gnomonic-plane detections).

* Dependencies and cleanup — Upgrade to adam-core>=0.5.4, adam-assist>=0.3.6, rebound 4.6.0. Add plotly, healpy, difi. Remove pydantic (config uses dataclasses). Drop Python 3.10.

---------

Co-authored-by: Alec Koumjian <akoumjian@gmail.com>
Co-authored-by: Kathleen Kiker <kathleen@b612foundation.org>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

1216 of 2975 new or added lines in 28 files covered. (40.87%)

352 existing lines in 16 files now uncovered.

3856 of 6475 relevant lines covered (59.55%)

0.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/thor/plotting.py
NEW
1
from typing import Optional
×
2

NEW
3
import numpy as np
×
NEW
4
import plotly.graph_objects as go
×
NEW
5
import pyarrow.compute as pc
×
6

NEW
7
from .analysis import ObservationLabels
×
NEW
8
from .observations import Observations
×
NEW
9
from .range_and_transform import TransformedDetections
×
10

NEW
11
__all__ = ["plot_transformed_detections"]
×
12

13

NEW
14
COLOR_PALETTE = [
×
15
    "blue",
16
    "green",
17
    "purple",
18
    "orange",
19
    "cyan",
20
    "magenta",
21
    "lime",
22
    "navy",
23
    "teal",
24
    "aqua",
25
    "fuchsia",
26
    "gold",
27
    "indigo",
28
    "turquoise",
29
    "violet",
30
    "lavender",
31
    "chartreuse",
32
    "steelblue",
33
    "slateblue",
34
    "mediumblue",
35
    "dodgerblue",
36
    "deepskyblue",
37
    "lightskyblue",
38
    "cadetblue",
39
    "darkturquoise",
40
    "mediumturquoise",
41
    "darkseagreen",
42
    "mediumseagreen",
43
    "seagreen",
44
    "forestgreen",
45
]
46

47

NEW
48
def plot_observations(
×
49
    observations: Observations,
50
    labels: Optional[ObservationLabels] = None,
51
    include_unlabeled: bool = False,
52
    connect_by_time: bool = True,
53
    labeled_marker_size: int = 2,
54
    labeled_line_width: int = 2,
55
    unlabeled_marker_size: int = 1,
56
    include_time: bool = True,
57
) -> go.Figure:
58
    """
59
    Plots observations (RA/Dec) and labels.
60

61
    Parameters
62
    ----------
63
    observations : Observations
64
        The observations to plot.
65
    labels : ObservationLabels, optional
66
        The labels to plot, can be empty if no labels are available.
67
    include_unlabeled : bool, optional
68
        Whether to include the unlabeled observations.
69
    connect_by_time : bool, optional
70
        Whether to connect the observations by time.
71
    labeled_marker_size : int, optional
72
        The size of the labeled markers.
73
    labeled_line_width : int, optional
74
        The width of the labeled lines.
75
    unlabeled_marker_size : int, optional
76
        The size of the unlabeled markers.
77
    include_time : bool, optional
78
        Whether to include the time axis.
79

80
    Returns
81
    -------
82
    fig : go.Figure
83
        The figure containing the plotted observations and labels.
84
    """
NEW
85
    if labels is not None:
×
NEW
86
        labels = labels.apply_mask(pc.is_in(labels.obs_id, observations.id))
×
87

NEW
88
        null_labels = labels.apply_mask(pc.is_null(labels.object_id))
×
NEW
89
        mask = pc.is_in(observations.id, null_labels.obs_id)
×
90

NEW
91
        observations_unlabeled = observations.apply_mask(mask)
×
92

NEW
93
        object_ids = labels.apply_mask(pc.invert(pc.is_null(labels.object_id))).object_id.unique()
×
NEW
94
        observations_labeled = observations.apply_mask(pc.invert(mask))
×
95
    else:
NEW
96
        observations_unlabeled = observations
×
NEW
97
        observations_labeled = observations
×
NEW
98
        object_ids = []
×
99

NEW
100
    fig = go.Figure()
×
101

NEW
102
    if len(object_ids) > 0:
×
NEW
103
        for idx, object_id in enumerate(object_ids.to_pylist()):
×
104

NEW
105
            obs_ids = labels.select("object_id", object_id).obs_id
×
NEW
106
            object_observations = observations_labeled.apply_mask(pc.is_in(observations_labeled.id, obs_ids))
×
NEW
107
            object_observations = object_observations.sort_by(
×
108
                ["coordinates.time.days", "coordinates.time.nanos"]
109
            )
110

NEW
111
            o = np.full(len(object_observations), object_id)
×
NEW
112
            t = object_observations.coordinates.time.mjd().to_numpy(zero_copy_only=False)
×
NEW
113
            ra = object_observations.coordinates.lon.to_numpy(zero_copy_only=False)
×
NEW
114
            dec = object_observations.coordinates.lat.to_numpy(zero_copy_only=False)
×
NEW
115
            i = object_observations.id.to_numpy(zero_copy_only=False)
×
116

NEW
117
            customdata = np.stack([o, i, t], axis=1)
×
NEW
118
            hovertemplate = (
×
119
                "object_id=%{customdata[0]}<br>"
120
                "obs_id=%{customdata[1]}<br>"
121
                "mjd=%{customdata[2]:.5f}<br>"
122
                "RA=%{x:.6f}°<br>"
123
                "Dec=%{y:.6f}°<extra></extra>"
124
            )
125

126
            # Assign color from palette, cycling if needed
NEW
127
            color = COLOR_PALETTE[idx % len(COLOR_PALETTE)]
×
128

NEW
129
            if include_time:
×
NEW
130
                fig.add_trace(
×
131
                    go.Scatter3d(
132
                        x=ra,
133
                        y=dec,
134
                        z=t,
135
                        mode="markers+lines" if connect_by_time else "markers",
136
                        name=str(object_id),
137
                        marker=dict(size=labeled_marker_size, color=color),
138
                        line=dict(width=labeled_line_width, color=color),
139
                        customdata=customdata,
140
                        hovertemplate=hovertemplate,
141
                    )
142
                )
143
            else:
NEW
144
                fig.add_trace(
×
145
                    go.Scattergl(
146
                        x=ra,
147
                        y=dec,
148
                        mode="markers+lines" if connect_by_time else "markers",
149
                        name=str(object_id),
150
                        marker=dict(size=labeled_marker_size, color=color),
151
                        line=dict(width=labeled_line_width, color=color),
152
                        customdata=customdata,
153
                        hovertemplate=hovertemplate,
154
                    )
155
                )
156

NEW
157
    if include_unlabeled:
×
158

NEW
159
        ra = observations_unlabeled.coordinates.lon.to_numpy(zero_copy_only=False)
×
NEW
160
        dec = observations_unlabeled.coordinates.lat.to_numpy(zero_copy_only=False)
×
NEW
161
        t = observations_unlabeled.coordinates.time.mjd().to_numpy(zero_copy_only=False)
×
NEW
162
        i = observations_unlabeled.id.to_numpy(zero_copy_only=False)
×
163

NEW
164
        customdata = np.stack([i, t], axis=1)
×
NEW
165
        hovertemplate = (
×
166
            "obs_id=%{customdata[0]}<br>"
167
            "mjd=%{customdata[1]:.5f}<br>"
168
            "RA=%{x:.6f}°<br>"
169
            "Dec=%{y:.6f}°<extra></extra>"
170
        )
171

NEW
172
        if include_time:
×
NEW
173
            fig.add_trace(
×
174
                go.Scatter3d(
175
                    x=ra,
176
                    y=dec,
177
                    z=t,
178
                    mode="markers",
179
                    marker=dict(size=unlabeled_marker_size, color="lightcoral"),
180
                    customdata=customdata,
181
                    hovertemplate=hovertemplate,
182
                    name="Unknown",
183
                )
184
            )
185
        else:
NEW
186
            fig.add_trace(
×
187
                go.Scattergl(
188
                    x=ra,
189
                    y=dec,
190
                    mode="markers",
191
                    marker=dict(size=unlabeled_marker_size, color="lightcoral"),
192
                    customdata=customdata,
193
                    hovertemplate=hovertemplate,
194
                    name="Unknown",
195
                )
196
            )
197

NEW
198
    title = "Observations (RA, Dec)"
×
199

NEW
200
    if include_time:
×
NEW
201
        fig.update_layout(
×
202
            title=title,
203
            paper_bgcolor="black",
204
            width=1000,
205
            height=1000,
206
            title_font=dict(color="white"),
207
            legend=dict(font=dict(color="white"), bgcolor="rgba(0,0,0,0)"),
208
            scene=dict(
209
                bgcolor="black",
210
                xaxis=dict(
211
                    title=dict(text="RA [deg]"),
212
                    showbackground=False,
213
                    showgrid=True,
214
                    gridcolor="rgba(255,255,255,0.10)",
215
                    zerolinecolor="white",
216
                    linecolor="white",
217
                    color="white",
218
                    title_font=dict(color="white"),
219
                ),
220
                yaxis=dict(
221
                    title=dict(text="Dec [deg]"),
222
                    showbackground=False,
223
                    showgrid=True,
224
                    gridcolor="rgba(255,255,255,0.10)",
225
                    zerolinecolor="white",
226
                    linecolor="white",
227
                    color="white",
228
                    title_font=dict(color="white"),
229
                ),
230
                zaxis=dict(
231
                    title="Time [MJD]",
232
                    showbackground=False,
233
                    showgrid=True,
234
                    gridcolor="rgba(255,255,255,0.10)",
235
                    zerolinecolor="white",
236
                    linecolor="white",
237
                    color="white",
238
                    title_font=dict(color="white"),
239
                ),
240
            ),
241
        )
242
    else:
NEW
243
        fig.update_layout(
×
244
            title=title,
245
            paper_bgcolor="black",
246
            plot_bgcolor="black",
247
            width=1000,
248
            height=1000,
249
            title_font=dict(color="white"),
250
            legend=dict(font=dict(color="white"), bgcolor="rgba(0,0,0,0)"),
251
            xaxis=dict(
252
                title=dict(text="RA [deg]"),
253
                showgrid=True,
254
                gridcolor="rgba(255,255,255,0.15)",
255
                zeroline=False,
256
                linecolor="white",
257
                color="white",
258
                title_font=dict(color="white"),
259
                tickfont=dict(color="white"),
260
                mirror=True,
261
            ),
262
            yaxis=dict(
263
                title=dict(text="Dec [deg]"),
264
                showgrid=True,
265
                gridcolor="rgba(255,255,255,0.15)",
266
                zeroline=False,
267
                linecolor="white",
268
                color="white",
269
                title_font=dict(color="white"),
270
                tickfont=dict(color="white"),
271
                mirror=True,
272
                scaleanchor="x",
273
                scaleratio=1,
274
            ),
275
        )
NEW
276
    return fig
×
277

278

NEW
279
def plot_transformed_detections(
×
280
    transformed_detections: TransformedDetections,
281
    labels: Optional[ObservationLabels] = None,
282
    include_unlabeled: bool = False,
283
    connect_by_time: bool = True,
284
    labeled_marker_size: int = 2,
285
    labeled_line_width: int = 2,
286
    unlabeled_marker_size: int = 1,
287
    include_time: bool = True,
288
) -> go.Figure:
289
    """
290
    Plots the transformed detections and labels.
291

292
    Parameters
293
    ----------
294
    transformed_detections : TransformedDetections
295
        The transformed detections to plot.
296
    labels : ObservationLabels, optional
297
        The labels to plot, can be empty if no labels are available.
298
    include_unlabeled : bool, optional
299
        Whether to include the unlabeled detections.
300
    connect_by_time : bool, optional
301
        Whether to connect the detections by time.
302
    labeled_marker_size : int, optional
303
        The size of the labeled markers.
304
    labeled_line_width : int, optional
305
        The width of the labeled lines.
306
    unlabeled_marker_size : int, optional
307
        The size of the unlabeled markers.
308
    include_time : bool, optional
309
        Whether to include the time axis.
310

311
    Returns
312
    -------
313
    fig : go.Figure
314
        The figure containing the plotted detections and labels.
315
    """
NEW
316
    if labels is not None:
×
NEW
317
        labels = labels.apply_mask(pc.is_in(labels.obs_id, transformed_detections.id))
×
318

NEW
319
        null_labels = labels.apply_mask(pc.is_null(labels.object_id))
×
NEW
320
        mask = pc.is_in(transformed_detections.id, null_labels.obs_id)
×
321

NEW
322
        transformed_detections_unlabeled = transformed_detections.apply_mask(mask)
×
323

NEW
324
        object_ids = labels.apply_mask(pc.invert(pc.is_null(labels.object_id))).object_id.unique()
×
NEW
325
        transformed_detections_labeled = transformed_detections.apply_mask(pc.invert(mask))
×
326
    else:
NEW
327
        transformed_detections_unlabeled = transformed_detections
×
NEW
328
        transformed_detections_labeled = transformed_detections
×
NEW
329
        object_ids = []
×
330

NEW
331
    fig = go.Figure()
×
332

NEW
333
    if len(object_ids) > 0:
×
NEW
334
        for idx, object_id in enumerate(object_ids.to_pylist()):
×
335

NEW
336
            obs_ids = labels.select("object_id", object_id).obs_id
×
NEW
337
            object_detections = transformed_detections_labeled.apply_mask(
×
338
                pc.is_in(transformed_detections_labeled.id, obs_ids)
339
            )
NEW
340
            object_detections = object_detections.sort_by(["coordinates.time.days", "coordinates.time.nanos"])
×
341

NEW
342
            o = np.full(len(object_detections), object_id)
×
NEW
343
            t = object_detections.coordinates.time.mjd().to_numpy(zero_copy_only=False)
×
NEW
344
            x = object_detections.coordinates.theta_x.to_numpy(zero_copy_only=False)
×
NEW
345
            y = object_detections.coordinates.theta_y.to_numpy(zero_copy_only=False)
×
NEW
346
            n = object_detections.night.to_numpy(zero_copy_only=False)
×
NEW
347
            i = object_detections.id.to_numpy(zero_copy_only=False)
×
348

NEW
349
            customdata = np.stack([o, i, t, n], axis=1)
×
NEW
350
            hovertemplate = (
×
351
                "object_id=%{customdata[0]}<br>"
352
                "obs_id=%{customdata[1]}<br>"
353
                "mjd=%{customdata[2]:.5f}<br>"
354
                "night=%{customdata[3]}<br>"
355
                "θx=%{x:.6f}°<br>"
356
                "θy=%{y:.6f}°<extra></extra>"
357
            )
358

359
            # Assign color from palette, cycling if needed
NEW
360
            color = COLOR_PALETTE[idx % len(COLOR_PALETTE)]
×
361

NEW
362
            if include_time:
×
NEW
363
                fig.add_trace(
×
364
                    go.Scatter3d(
365
                        x=x,
366
                        y=y,
367
                        z=t,
368
                        mode="markers+lines" if connect_by_time else "markers",
369
                        name=str(object_id),
370
                        marker=dict(size=labeled_marker_size, color=color),
371
                        line=dict(width=labeled_line_width, color=color),
372
                        customdata=customdata,
373
                        hovertemplate=hovertemplate,
374
                    )
375
                )
376
            else:
NEW
377
                fig.add_trace(
×
378
                    go.Scattergl(
379
                        x=x,
380
                        y=y,
381
                        mode="markers+lines" if connect_by_time else "markers",
382
                        name=str(object_id),
383
                        marker=dict(size=labeled_marker_size, color=color),
384
                        line=dict(width=labeled_line_width, color=color),
385
                        customdata=customdata,
386
                        hovertemplate=hovertemplate,
387
                    )
388
                )
389

NEW
390
    if include_unlabeled:
×
391

NEW
392
        x = transformed_detections_unlabeled.coordinates.theta_x.to_numpy(zero_copy_only=False)
×
NEW
393
        y = transformed_detections_unlabeled.coordinates.theta_y.to_numpy(zero_copy_only=False)
×
NEW
394
        t = transformed_detections_unlabeled.coordinates.time.mjd().to_numpy(zero_copy_only=False)
×
NEW
395
        n = transformed_detections_unlabeled.night.to_numpy(zero_copy_only=False)
×
NEW
396
        i = transformed_detections_unlabeled.id.to_numpy(zero_copy_only=False)
×
397

NEW
398
        customdata = np.stack([i, t, n], axis=1)
×
NEW
399
        hovertemplate = (
×
400
            "obs_id=%{customdata[0]}<br>"
401
            "mjd=%{customdata[1]:.5f}<br>"
402
            "night=%{customdata[2]}<br>"
403
            "θx=%{x:.6f}°<br>"
404
            "θy=%{y:.6f}°<extra></extra>"
405
        )
406

NEW
407
        if include_time:
×
NEW
408
            fig.add_trace(
×
409
                go.Scatter3d(
410
                    x=x,
411
                    y=y,
412
                    z=t,
413
                    mode="markers",
414
                    marker=dict(size=unlabeled_marker_size, color="lightcoral"),
415
                    customdata=customdata,
416
                    hovertemplate=hovertemplate,
417
                    name="Unknown",
418
                )
419
            )
420
        else:
NEW
421
            fig.add_trace(
×
422
                go.Scattergl(
423
                    x=x,
424
                    y=y,
425
                    mode="markers",
426
                    marker=dict(size=unlabeled_marker_size, color="lightcoral"),
427
                    customdata=customdata,
428
                    hovertemplate=hovertemplate,
429
                    name="Unknown",
430
                )
431
            )
432

NEW
433
    title = r"Transformed Detections (θ<sub>X</sub>, θ<sub>Y</sub>)"
×
434

NEW
435
    if include_time:
×
NEW
436
        fig.update_layout(
×
437
            title=title,
438
            paper_bgcolor="black",
439
            width=1000,
440
            height=1000,
441
            title_font=dict(color="white"),
442
            legend=dict(font=dict(color="white"), bgcolor="rgba(0,0,0,0)"),
443
            scene=dict(
444
                bgcolor="black",
445
                xaxis=dict(
446
                    title=dict(text="θ<sub>X</sub> [deg]"),
447
                    showbackground=False,
448
                    showgrid=True,
449
                    gridcolor="rgba(255,255,255,0.10)",
450
                    zerolinecolor="white",
451
                    linecolor="white",
452
                    color="white",
453
                    title_font=dict(color="white"),
454
                ),
455
                yaxis=dict(
456
                    title=dict(text="θ<sub>Y</sub> [deg]"),
457
                    showbackground=False,
458
                    showgrid=True,
459
                    gridcolor="rgba(255,255,255,0.10)",
460
                    zerolinecolor="white",
461
                    linecolor="white",
462
                    color="white",
463
                    title_font=dict(color="white"),
464
                ),
465
                zaxis=dict(
466
                    title="Time [MJD]",
467
                    showbackground=False,
468
                    showgrid=True,
469
                    gridcolor="rgba(255,255,255,0.10)",
470
                    zerolinecolor="white",
471
                    linecolor="white",
472
                    color="white",
473
                    title_font=dict(color="white"),
474
                ),
475
            ),
476
        )
477
    else:
NEW
478
        fig.update_layout(
×
479
            title=title,
480
            paper_bgcolor="black",
481
            plot_bgcolor="black",
482
            width=1000,
483
            height=1000,
484
            title_font=dict(color="white"),
485
            legend=dict(font=dict(color="white"), bgcolor="rgba(0,0,0,0)"),
486
            xaxis=dict(
487
                title=dict(text="θ<sub>X</sub> [deg]"),
488
                showgrid=True,
489
                gridcolor="rgba(255,255,255,0.15)",
490
                zeroline=False,
491
                linecolor="white",
492
                color="white",
493
                title_font=dict(color="white"),
494
                tickfont=dict(color="white"),
495
                mirror=True,
496
            ),
497
            yaxis=dict(
498
                title=dict(text="θ<sub>Y</sub> [deg]"),
499
                showgrid=True,
500
                gridcolor="rgba(255,255,255,0.15)",
501
                zeroline=False,
502
                linecolor="white",
503
                color="white",
504
                title_font=dict(color="white"),
505
                tickfont=dict(color="white"),
506
                mirror=True,
507
                scaleanchor="x",
508
                scaleratio=1,
509
            ),
510
        )
NEW
511
    return fig
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc