apsis-rs/docs/design/errors.md

8.0 KiB

Errors and tolerances

English | 简体中文

This file records how errors are expressed across crates and how numerical tolerances are chosen. The error enumeration of a single crate is documented in its error.rs.

Expressing an error

A domain error

Every crate with a domain specific failure defines its own error type, named and shaped consistently:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum FooErrorKind {
    /// Each variant names a concrete condition a caller may want to tell apart.
    ReversedInterval,
    DuplicateResource,
    // ...
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FooError {
    kind: FooErrorKind,
    detail: Option<String>,
}

impl FooError {
    pub fn new(kind: FooErrorKind) -> Self;
    pub fn with_detail(self, detail: impl Into<String>) -> Self;
    pub fn kind(&self) -> FooErrorKind;
    pub fn detail(&self) -> Option<&str>;
}

impl fmt::Display for FooError { /* "kind", or "kind: detail" */ }
impl core::error::Error for FooError {}

The conventions:

  • An ErrorKind is marked #[non_exhaustive], so that adding a failure category does not break a match downstream. All fifteen ErrorKind enumerations carry the attribute.
  • An error enum that carries data is marked too, including one whose variants wrap a lower error, such as apsis-orbit's EphemerisSamplingError.
  • A state enum that is not an error is not marked. A domain state such as TaskState, LinkState, TimeScale, HandoverDecision or the antenna Pattern gains from being matchable exhaustively inside a single repository: when a variant is added, the compiler points at every match that needs updating. The reasoning is the opposite of an error's, because one more error category should let a caller fall through to a default branch, while one more state usually has to be handled explicitly. The test is whether a caller will match the enum and take a default on some branches: if so, mark it; if not, leave it.
  • kind is matched, detail is read. A caller matches on kind to write recovery logic and logs detail. Because detail takes no part in matching, its wording may change freely.
  • Display prints the kind, followed by ": detail" when there is one, so a log line always begins with the category.
  • core::error::Error is implemented, so an error can nest as the source of another. All fifteen error types implement it.
  • A bare String or Box<dyn Error> is never a public error. The first cannot be matched and the second loses the category. A private helper may pass a String as a reason internally, but the public boundary is an enum. The precedence_floor in apsis-scheduler is an instance: its private String ends up inside the public TaskState::Infeasible { reason }.

The general error

apsis-core::Error and ErrorKind are a general purpose fallback carrying a coarse category such as InvalidInput or NotFound. They are not the error surface of the layers: no layer uses them, because a coarse category loses the distinction a caller needs. InvalidInput cannot tell a reversed interval from a duplicate identifier.

They are kept for two reasons: to give a function whose failure has no interesting structure a ready return type, and to give the workspace one extensible fallback shape. New code whose failure has a domain meaning should define a domain error.

Failure against the absence of a value

  • Option means that having no value is an ordinary outcome rather than a failure, such as an instant outside the covered interval, which Ephemeris::state_at reports as None, or a tangent that is undefined at an angle.
  • Result means an operation failed for a diagnosable reason, such as a reversed interval, a duplicate resource, or a value outside the domain the model covers.

The test is whether the caller needs to know why. If it only needs to know that there is nothing, use Option; if it needs to tell the failures apart and recover, use Result.

Numerical tolerances

Convergence of an iteration

An iterative solve, whether of Kepler's equation, a nutation series or a ray trace, is governed by a private constant inside its crate:

Crate Constant Value Meaning
apsis-kepler TOLERANCE 1e-14 the step of the Newton iteration for the eccentric anomaly
apsis-sgp4 KEPLER_TOLERANCE 1e-12 the residual of the Kepler equation iteration

These values trade floating point precision against iteration count: they sit close to the machine precision of an f64 while still guaranteeing that the iteration terminates, each with its own MAX_ITERATIONS bound.

Tolerances of the cross-checks

A cross-check sets its tolerance by the physical quantity rather than by one number for everything:

Quantity Tolerance
Position < 1e-3 km
Velocity < 1e-6 km/s
Time < 1e-6 s
Coverage angle 1e-12 rad
Coverage area and fraction 1e-12 relative

A tolerance sits far above the disagreement actually observed, which is usually at the floating point level, and far below the accuracy of the model itself. That way it catches a mistyped radius or a missing unit conversion without reporting on the last bit of a floating point value. The table in validation/README.zh.md lists them per script.

Comparing exactly

A tolerance should be avoided where it is not needed. An integer index, a count or a flag is compared for equality. An angle reduction such as wrap_to_pi uses a half-open interval at the boundary rather than an epsilon. A floating point comparison is for a quantity that is genuinely a continuous approximation.

expect in library code

The workspace convention is that library code uses no unwrap, expect or panic!. The intent of the rule is that they must not handle a failure on external input, which has to become a Result or an Option the caller can act on.

Allowing no expect at all would push the code towards unwrap, which is worse, or into long hand written branches. So one case is permitted: an expect on an internal invariant that a check has already guarded. Such an assertion does not turn untrusted data into a panic; it turns "if this were empty it would be a bug in this crate" into a readable failure message.

The test is whether a guard making the branch unreachable sits on the line above, or whether the value was just built by this function. All thirteen current sites satisfy it:

Site Invariant Guard
apsis-math/src/parallel.rs every slot was filled by one thread the loop assigns each index exactly once
apsis-schedule/src/schedule.rs the placement just pushed exists the line above
apsis-scheduler/src/improve.rs the value whose is_none() was just checked is not none the is_none() branch above
apsis-scheduler/src/planner.rs an identifier in the order is in the task list the order is built from the task list
apsis-topology/src/series.rs the time is finite validated on construction
apsis-handover/src/decision.rs the candidate list is not empty the validate call above
apsis-constellation/src/walker.rs the plane count and the satellites per plane are positive the divisor is validated before
apsis-kepler/src/elements.rs the node, the radius and the angular momentum are non-zero the norm() == 0.0 branch just above
apsis-event/src/queue.rs a popped entry has its payload guaranteed by the construction order of the queue

Removing a guard means the matching expect must become a Result; the two change together.

Checks

  • Each crate's tests cover every kind of its error, including the path that constructs it and the path that recovers from it.
  • A cross-check reports through its exit code: zero to pass, one to exceed the tolerance, two for a missing input.
  • cargo clippy -- -D warnings keeps an unwrap or expect from reaching library code unnoticed.