empyrean.evaluate_plan

evaluate_plan(orbit, planned, config=None, orbit_id=None)[source]

Rank candidate follow-up observations by how much each would tighten an orbit.

The orbit’s covariance is the information prior; each candidate is folded into it in turn, and the result reports the marginal gain per candidate plus the prior / posterior covariance summary for the campaign as a whole.

Optical candidates contribute sky-plane (RA/Dec) information; radar candidates contribute the line-of-sight range and range-rate that angles-only astrometry cannot supply. A radar candidate’s measurement σ is the Cramér-Rao bound set by the waveform bandwidth and the effective SNR — supply the SNR, or leave PlannedObservation.radar_snr at None to have it derived from a link budget over the target’s physical properties.

Parameters:
  • orbit (CartesianOrbits | KeplerianOrbits | CometaryOrbits | SphericalOrbits) –

    Single orbit (exactly one row) carrying a 6×6 Cartesian covariance, with its origin at the Solar System barycenter. The frame is free.

    Both requirements are hard: the covariance is consumed as a Cartesian state prior and is never converted, so elements in another representation would be silently reinterpreted. A fit in another basis — a determine() result is heliocentric, and may be cometary or Keplerian — converts in one call with transform_coordinates(fit.orbit.coordinates, CartesianCoordinates, origin="SSB"). The origin half of that is a pure translation, so the covariance and every metric below it are unchanged by it.

  • planned (Sequence[PlannedObservation]) – Candidate observations. Build them with PlannedObservation.optical() / PlannedObservation.radar(). Must not be empty.

  • config (PlanningConfig | None) – Configuration. Defaults to PlanningConfig defaults (Standard force model).

  • orbit_id (str | None) – Label carried through onto PlanResult.orbit_id. Defaults to the input orbit’s own orbit_id.

Return type:

PlanResult

Returns:

PlanResult – The two-row PlanMetrics table bracketing the campaign, the PlanCandidates table of per-candidate gains, and the PlanEphemeris table of predicted sky positions.

Raises:
  • ValueError – The orbit is not exactly one row, carries no covariance, is not Cartesian, is not barycentric, or the candidate list is empty.

  • RuntimeError – The engine rejected the plan — a singular prior covariance, an unregistered observatory code, a defunct radar dish, a non-positive bandwidth for a delay measurement, or a link budget missing a property it needs. The message names the cause.

Notes

Candidates are evaluated in ascending epoch order regardless of the order they were supplied in, and each is folded into the covariance that already contains every earlier one. The marginal gains are therefore conditional on that sequence: two identical observations do not score identically, and PlanCandidates.best_by_information_gain() ranks contributions within one campaign rather than standalone candidate value. Evaluate a one-candidate plan per candidate to compare them head to head.

What this entry point does not expose, recorded so the omissions are not mistaken for oversights: the engine also offers a non-gravitational planning variant that solves over state ⊕ (A1, A2, A3) and reports the σ(A2) tightening a radar campaign buys, a visibility survey over a time window, batch evaluation across many orbits, and an encounter-B-plane characterization. None of them is reachable from this package.

An orbit carrying non-gravitational parameters — a Yarkovsky fit, say — is accepted here and evaluated state-only. The non-gravitational acceleration still acts in the dynamics, so the predicted trajectory and sky positions account for it, but the solve-for set stays 6×6 (PlanResult.active_width reports 6), the A1/A2/A3 columns are not folded, and no σ(A2) is reported. The plan prices what the observations buy for the state under that force model, not what they buy for the non-gravitational parameters themselves.

This function is the single-object form, while evaluate_plan is the batch name one layer down in the engine. That is deliberate: the C symbol empyrean_evaluate_plan has carried single-object semantics since v0.7.0 and is frozen, and this package mirrors the C ABI it wraps. If a batch form is exposed later it follows the migration precedent empyrean.transform_coordinates() set — the batch takes the plain name and the single-object form gains a _single suffix — rather than renaming this function out from under callers now.

Examples

Two optical nights and one Goldstone radar run against a fitted orbit, shifted to the barycentre first:

>>> from empyrean import (
...     CartesianCoordinates,
...     CartesianOrbits,
...     PlannedObservation,
...     evaluate_plan,
...     transform_coordinates,
... )
>>> coords = transform_coordinates(
...     fit.orbit.coordinates, CartesianCoordinates, origin="SSB"
... )
>>> orbit = fit.orbit.set_column("coordinates", coords)
>>> t0 = float(coords.epoch.to_numpy()[0])
>>> plan = evaluate_plan(
...     orbit,
...     [
...         PlannedObservation.optical(t0 + 30.0, "F51", (0.2, 0.2)),
...         PlannedObservation.optical(t0 + 31.0, "F51", (0.2, 0.2)),
...         PlannedObservation.radar(
...             t0 + 45.0,
...             radar_bandwidth_hz=1.0e5,
...             radar_freq_resolution_hz=0.1,
...             radar_snr=50.0,
...         ),
...     ],
... )
>>> plan.metrics.stage.to_pylist()
['prior', 'posterior']
>>> before = plan.metrics.prior().position_sigma_km[0].as_py()
>>> after = plan.metrics.posterior().position_sigma_km[0].as_py()
>>> after <= before
True