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
ErrorKindis marked#[non_exhaustive], so that adding a failure category does not break a match downstream. All fifteenErrorKindenumerations carry the attribute. - An error enum that carries data is marked too, including one whose
variants wrap a lower error, such as
apsis-orbit'sEphemerisSamplingError. - A state enum that is not an error is not marked. A domain state such as
TaskState,LinkState,TimeScale,HandoverDecisionor the antennaPatterngains from being matchable exhaustively inside a single repository: when a variant is added, the compiler points at everymatchthat 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 willmatchthe enum and take a default on some branches: if so, mark it; if not, leave it. kindis matched,detailis read. A caller matches onkindto write recovery logic and logsdetail. Becausedetailtakes no part in matching, its wording may change freely.Displayprints the kind, followed by": detail"when there is one, so a log line always begins with the category.core::error::Erroris implemented, so an error can nest as thesourceof another. All fifteen error types implement it.- A bare
StringorBox<dyn Error>is never a public error. The first cannot be matched and the second loses the category. A private helper may pass aStringas a reason internally, but the public boundary is an enum. Theprecedence_floorinapsis-scheduleris an instance: its privateStringends up inside the publicTaskState::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
Optionmeans that having no value is an ordinary outcome rather than a failure, such as an instant outside the covered interval, whichEphemeris::state_atreports asNone, or a tangent that is undefined at an angle.Resultmeans 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
kindof 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 warningskeeps anunwraporexpectfrom reaching library code unnoticed.