From 1f59d817afec3be79c9fd54d6fc7c6bca7d53537 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 1 Oct 2026 23:24:56 +0800 Subject: [PATCH] web: read the formulas the documents already write, and take the crates from the manifest The documents write their formulas as plain text in a fenced block, `delta_v = v_e * ln (m_0 / m_f)`, which was shown as source. They were written before the dollar form existed. A plain block holding equations is now read as a formula: identifiers with subscripts and powers, roots, functions, quotients, a group raised to a power, and a note set beside the equation. A block that is not an equation, such as a matrix or a worked example in prose, stays as it was written. The interface catalog named fifteen crates in the generator, which was written when only the foundation, physics and orbit crates existed. The workspace has since grown to forty-one, and every network, radio and operations crate was missing from the documentation without anything saying so. The crates now come from the workspace manifest, so a crate cannot be left out by omission again. The count went from 424 items to 1202, and the index now shows all seven layers. A crate with no layer is the facade, which re-exports every layer. It is left without one rather than filed under the foundation, and it is left out of the crate structure diagram, where it would belong to no layer. --- content/api.json | 5655 +++++++++++++++++++++++++++++++++++++++- scripts/extract_api.py | 126 +- src/docs/api.rs | 5 +- src/docs/markdown.rs | 21 +- src/docs/mathml.rs | 566 +++- src/graph/mod.rs | 11 +- 6 files changed, 6339 insertions(+), 45 deletions(-) diff --git a/content/api.json b/content/api.json index 0e5a3b5..c76677e 100644 --- a/content/api.json +++ b/content/api.json @@ -14,7 +14,33 @@ "crates/orbit/apsis-kepler/src", "crates/orbit/apsis-ephemeris/src", "crates/orbit/apsis-maneuver/src", - "crates/orbit/apsis-orbit/src" + "crates/orbit/apsis-orbit/src", + "crates/network/apsis-constellation/src", + "crates/network/apsis-coverage/src", + "crates/network/apsis-access/src", + "crates/network/apsis-topology/src", + "crates/network/apsis-isl/src", + "crates/network/apsis-routing/src", + "crates/network/apsis-handover/src", + "crates/network/apsis-mesh/src", + "crates/rf/apsis-rf/src", + "crates/rf/apsis-antenna/src", + "crates/rf/apsis-doppler/src", + "crates/rf/apsis-atmosphere/src", + "crates/rf/apsis-noise/src", + "crates/rf/apsis-interference/src", + "crates/rf/apsis-link/src", + "crates/ops/apsis-resource/src", + "crates/ops/apsis-task/src", + "crates/ops/apsis-scheduler/src", + "crates/ops/apsis-conflict/src", + "crates/ops/apsis-schedule/src", + "crates/sim/apsis-sim-engine/src", + "crates/sim/apsis-scenario/src", + "crates/sim/apsis-event/src", + "crates/sim/apsis-output/src", + "crates/sim/apsis-sim/src", + "crates/apsis-rs/src" ], "crates": [ { @@ -561,6 +587,34 @@ "doc": "Returns `true` when all nine entries are finite.", "associated": true }, + { + "kind": "fn", + "name": "thread_count", + "signature": "pub fn thread_count() -> usize {", + "doc": "The number of threads to use for a piece of work.\n\nThis is the machine's parallelism as reported by the standard library, at\nleast one. It is the number the callers use to decide whether a parallel\nrun is worth starting.", + "associated": false + }, + { + "kind": "fn", + "name": "threads_for", + "signature": "pub fn threads_for(len: usize) -> usize {", + "doc": "The number of threads that should run a loop over `len` items.\n\nA loop shorter than the thread count would leave most threads with nothing\nto do while still paying to start them, so this returns one thread until\nthere is enough work for a second. The threshold is a per item cost of\nroughly a microsecond, below which starting a thread cannot pay for\nitself.", + "associated": false + }, + { + "kind": "fn", + "name": "for_each_index", + "signature": "pub fn for_each_index(len: usize, operation: F) where F: Fn(usize) + Sync, {", + "doc": "Runs a closure once for each index of a range, spread over several threads.\n\nThe range is split into one contiguous chunk per thread and each thread\nruns the closure for the indices in its chunk. The closure may be called\nfrom any thread, so it must be `Send` and `Sync`; anything it captures is\nshared rather than copied. The call returns only after every thread has\nfinished, so the data the closure wrote is safe to read afterwards.\n\nThe closure is handed the index rather than the element, which keeps the\nsignature independent of the collection: the caller reads the input and\nwrites the output itself, and the two never overlap.\n\n# Panics\n\nIf the closure panics, the panic is resumed on the calling thread after\nthe other threads have been joined, so a failure surfaces at the call\nrather than in a detached thread.", + "associated": false + }, + { + "kind": "fn", + "name": "map_index", + "signature": "pub fn map_index(len: usize, operation: F) -> Vec where T: Send, F: Fn(usize) -> T + Sync, {", + "doc": "Collects one value for each index of a range, spread over several threads.\n\nThis is [`for_each_index`] for the common case where the result of the\nwork is a value rather than a write into a shared array. The output is\nreturned in index order regardless of which thread produced which element,\nso it does not depend on the scheduling.\n\nThe closure is handed the index and returns the value for that index. As\nwith [`for_each_index`], it may run on any thread, so captured data must be\n`Sync`.", + "associated": false + }, { "kind": "struct", "name": "Quat", @@ -2016,6 +2070,34 @@ "layer": "orbit", "summary": "The SGP4 and SDP4 analytical orbit propagator. The propagator takes the mean orbital elements of a two-line element set and returns the position and velocity of the satellite at a requested instant. It is the model that the element sets are fitted to, so it is the correct way to use them; a general purpose numerical integrator given the same elements would drift away from the published positions. Two branches exist and the propagator selects between them by orbital period. A satellite with a period below 225 minutes is propagated with SGP4, which models the secular effect of the Earth's oblateness and a drag term. A satellite at or above that period is propagated with SDP4, which adds the resonance and the luni-solar perturbations that dominate at that altitude. Inputs and outputs: - Elements are the mean elements from [`apsis_tle::TwoLineElement`]. Angles in the element set are in degrees and mean motion is in revolutions per day; this crate converts to radians and radians per minute internally. - The output is expressed in the True Equator Mean Equinox (TEME) frame, with position in kilometers and velocity in kilometers per second. TEME is the frame the model defines; converting it to an Earth-fixed frame requires the Earth rotation and the equation of the equinoxes and is not part of this crate. - Time is measured from the element set epoch, in minutes. Source: the algorithm follows Vallado et al., *Revisiting Spacetrack Report #3*, AIAA 2006-6753, 2006, and the revised implementation distributed with that paper. ``` use apsis_sgp4::{GravityModel, Satellite}; use apsis_tle::TwoLineElement; let text = concat!( \"1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927\\r\\n\", \"2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537\\r\\n\", ); let element = TwoLineElement::parse(text).expect(\"sample is well formed\"); let satellite = Satellite::from_element(&element, GravityModel::WGS72) .expect(\"sample elements are propagatable\"); // Propagate to the epoch of the elements. let state = satellite.propagate_minutes(0.0).expect(\"epoch state exists\"); // The position of a low Earth satellite is a few thousand kilometers // from the center of the Earth. assert!(state.position.norm() > 6_600.0); assert!(state.position.norm() < 7_200.0); ```", "items": [ + { + "kind": "fn", + "name": "propagate_minutes_at", + "signature": "pub fn propagate_minutes_at(satellite: &Satellite, minutes: &[f64]) -> Vec> {", + "doc": "Propagates a satellite at a list of minutes from its epoch, in order.\n\nThe sequential form, provided for comparison and for small lists where\nstarting a thread would cost more than the work.", + "associated": false + }, + { + "kind": "fn", + "name": "propagate_minutes_at_parallel", + "signature": "pub fn propagate_minutes_at_parallel(satellite: &Satellite, minutes: &[f64]) -> Vec> {", + "doc": "Propagates a satellite at a list of minutes from its epoch, across several\nthreads.\n\nThe result matches [`propagate_minutes_at`] element for element and in the\nsame order. A minute at which the model reports a decay yields `None`, as\nit does in the sequential form; the threads do not stop at the first such\nminute, because a caller usually wants the whole track and will decide for\nitself what a decay means.\n\nPropagation is deterministic and depends only on the minutes, so the\nresult does not depend on how the work was split.\n\nThe gain depends on how much work the list holds, because starting the\nthreads costs a fixed amount that a short list cannot repay. A day at a\none minute step is fourteen hundred states and gains little; a month is\nforty three thousand and gains several fold. A caller with many short\ntracks is better served by the per satellite form below, which takes the\nwhole list in one call.", + "associated": false + }, + { + "kind": "fn", + "name": "propagate_at", + "signature": "pub fn propagate_at(satellites: &[Satellite], tai_mjd: f64) -> Vec> {", + "doc": "Propagates several satellites at one instant, in order.\n\nThe per satellite work is independent, so a shell's state at a single\ninstant is a natural batch. The satellites are read only, so they are\nshared across the threads by reference.", + "associated": false + }, + { + "kind": "fn", + "name": "propagate_at_parallel", + "signature": "pub fn propagate_at_parallel(satellites: &[Satellite], tai_mjd: f64) -> Vec> {", + "doc": "Propagates several satellites at one instant, across several threads.\n\nThe result matches [`propagate_at`] element for element and in the same\norder.\n\nThis is the form a coverage run wants: it holds the whole shell in one\ncall, so the threads start once for every satellite rather than once per\nsatellite, and even a shell of a few hundred gains.", + "associated": false + }, { "kind": "enum", "name": "PropagationErrorKind", @@ -3088,6 +3170,5577 @@ "associated": false } ] + }, + { + "name": "apsis-constellation", + "layer": "network", + "summary": "Constellation definition and instantiation. A constellation is a set of satellites described by a small number of parameters rather than as a list of individual orbits. This crate models two forms of construction that together cover the constellations in operational use: - A **Walker constellation**, in which satellites are spread evenly over a set of orbital planes that share an inclination and a semi-major axis, with a fixed phase relationship between adjacent planes. - A **custom constellation**, where each shell is described separately so that a design that does not follow the Walker pattern can still be instantiated. Both forms produce a list of [`KeplerianElements`] at a reference time, which the orbit layer can propagate. The constellations in use for satellite internet are Walker patterns at several altitudes, so the Walker constructor is the one most callers need. The Walker notation is written `i: t/p/f`, where `i` is the inclination in degrees, `t` the total number of satellites, `p` the number of planes, and `f` the phase factor. The satellites are placed so that `t / p` satellites share each plane and adjacent planes are offset in mean anomaly by `f * 360 / t` degrees. Source: the Walker notation and the phasing rule are the ones defined in Walker, J. G., *Circular Orbit Patterns Providing Continuous Whole Earth Coverage*, Royal Aircraft Establishment Technical Report 70211, 1970. ``` use apsis_constellation::{Constellation, WalkerPattern}; // A Walker 53 degree pattern with 24 satellites in 3 planes. let pattern = WalkerPattern { total_satellites: 24, planes: 3, phase_factor: 1, inclination: 53.0_f64.to_radians(), semi_major_axis: 6_878.137, }; let constellation = Constellation::walker(pattern, 0.0).expect(\"the pattern is consistent\"); assert_eq!(constellation.satellites().len(), 24); ```", + "items": [ + { + "kind": "enum", + "name": "ConstellationErrorKind", + "signature": "pub enum ConstellationErrorKind {", + "doc": "The category of a constellation error.", + "associated": false + }, + { + "kind": "struct", + "name": "ConstellationError", + "signature": "pub struct ConstellationError {", + "doc": "A failure while building a constellation.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(kind: ConstellationErrorKind) -> Self {", + "doc": "Creates an error with the given category.", + "associated": true + }, + { + "kind": "fn", + "name": "with_detail", + "signature": "pub fn with_detail(mut self, detail: impl Into) -> Self {", + "doc": "Attaches a description of the values that caused the failure.", + "associated": true + }, + { + "kind": "fn", + "name": "kind", + "signature": "pub fn kind(&self) -> ConstellationErrorKind {", + "doc": "Returns the category of this failure.", + "associated": true + }, + { + "kind": "fn", + "name": "detail", + "signature": "pub fn detail(&self) -> Option<&str> {", + "doc": "Returns the detail text attached to this error, if any.", + "associated": true + }, + { + "kind": "struct", + "name": "WalkerPattern", + "signature": "pub struct WalkerPattern {", + "doc": "A Walker delta pattern.\n\nA Walker pattern places a number of satellites evenly around the Earth in\na number of planes that share an inclination and a semi-major axis. The\nrelative phasing of adjacent planes is set by the phase factor, so the\nwhole constellation is described by five numbers.\n\nThe pattern is written `i: t/p/f`, where `i` is the inclination, `t` the\ntotal number of satellites, `p` the number of planes, and `f` the phase\nfactor. This structure stores the same quantities, with the inclination\nin radians and the semi-major axis in kilometers.", + "associated": false + }, + { + "kind": "fn", + "name": "satellites_per_plane", + "signature": "pub fn satellites_per_plane(self) -> Option {", + "doc": "Returns the number of satellites in each plane.\n\nReturns `None` when the satellite count is not a multiple of the\nplane count.", + "associated": true + }, + { + "kind": "fn", + "name": "plane_spacing", + "signature": "pub fn plane_spacing(self) -> Option {", + "doc": "Returns the spacing between adjacent planes in right ascension, in\nradians.\n\nReturns the spacing regardless of whether the pattern is consistent,\nso that a caller can report the value it would use.", + "associated": true + }, + { + "kind": "fn", + "name": "phase_offset", + "signature": "pub fn phase_offset(self) -> Option {", + "doc": "Returns the phase offset applied between adjacent planes, in radians.\n\nThis is `f * 360 / t` degrees, where `f` is the phase factor and `t`\nthe total satellite count.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub fn is_valid(self) -> bool {", + "doc": "Returns `true` when the pattern can be instantiated.", + "associated": true + }, + { + "kind": "fn", + "name": "notation", + "signature": "pub fn notation(self) -> String {", + "doc": "Returns the Walker notation for this pattern, such as `53: 24/3/1`.\n\nThe inclination is rounded to one decimal place, which is the\nprecision the notation is normally quoted to.", + "associated": true + }, + { + "kind": "fn", + "name": "instantiate", + "signature": "pub fn instantiate(self) -> Result, ConstellationError> {", + "doc": "Instantiates the pattern as a list of orbital elements.\n\nThe mean anomaly of the reference satellite of the first plane is\nzero. Each further satellite in a plane is advanced by\n`360 / (t / p)` degrees, and each further plane is advanced by the\nphase offset.\n\nReturns an error describing why the pattern cannot be instantiated.\n\nSource: the phasing rule is the one defined in Walker, J. G.,\n*Circular Orbit Patterns Providing Continuous Whole Earth Coverage*,\nRoyal Aircraft Establishment Technical Report 70211, 1970.", + "associated": true + }, + { + "kind": "struct", + "name": "Satellite", + "signature": "pub struct Satellite {", + "doc": "One satellite in an instantiated constellation.", + "associated": false + }, + { + "kind": "struct", + "name": "Constellation", + "signature": "pub struct Constellation {", + "doc": "A constellation instantiated at a reference time.\n\nThe elements are the ones at the reference time passed to the\nconstructor. Advancing them in time is the job of the orbit layer, which\npropagates each satellite independently.", + "associated": false + }, + { + "kind": "fn", + "name": "walker", + "signature": "pub fn walker(pattern: WalkerPattern, reference_mjd: f64) -> Result {", + "doc": "Instantiates a Walker pattern at a reference time.\n\nThe reference time is a Modified Julian Date on the TAI scale. It is\nstored with the constellation and returned unchanged; the elements\nare the ones at that instant.\n\nReturns an error when the pattern cannot be instantiated.", + "associated": true + }, + { + "kind": "fn", + "name": "from_satellites", + "signature": "pub fn from_satellites( elements: &[KeplerianElements], reference_mjd: f64, ) -> Result {", + "doc": "Builds a constellation from a list of satellites.\n\nThis is the constructor for a constellation that does not follow the\nWalker pattern. The plane index of each satellite is derived from the\nright ascension of its elements, so satellites sharing a plane are\ngrouped.\n\nReturns an error when the list is empty, when the reference time is\nnot finite, or when any element set is not a valid orbit.", + "associated": true + }, + { + "kind": "fn", + "name": "satellites", + "signature": "pub fn satellites(&self) -> &[Satellite] {", + "doc": "Returns the satellites in the constellation.", + "associated": true + }, + { + "kind": "fn", + "name": "len", + "signature": "pub fn len(&self) -> usize {", + "doc": "Returns the number of satellites.", + "associated": true + }, + { + "kind": "fn", + "name": "is_empty", + "signature": "pub fn is_empty(&self) -> bool {", + "doc": "Returns `true` when the constellation is empty.\n\nA constellation built through either constructor is never empty, so\nthis always returns `false`; it exists because a collection type\nshould offer it alongside [`Constellation::len`].", + "associated": true + }, + { + "kind": "fn", + "name": "plane_count", + "signature": "pub fn plane_count(&self) -> usize {", + "doc": "Returns the number of orbital planes.", + "associated": true + }, + { + "kind": "fn", + "name": "pattern", + "signature": "pub fn pattern(&self) -> Option {", + "doc": "Returns the Walker pattern, when the constellation was built from\none.", + "associated": true + }, + { + "kind": "fn", + "name": "reference_mjd", + "signature": "pub fn reference_mjd(&self) -> f64 {", + "doc": "Returns the reference time as a Modified Julian Date on the TAI\nscale.", + "associated": true + }, + { + "kind": "fn", + "name": "plane", + "signature": "pub fn plane(&self, plane: usize) -> Vec {", + "doc": "Returns the satellites of one plane.\n\nReturns an empty slice when the plane index is out of range.", + "associated": true + }, + { + "kind": "fn", + "name": "altitude_above", + "signature": "pub fn altitude_above(&self, body_radius_km: f64) -> Option {", + "doc": "Returns the altitude of the constellation above the given radius, in\nkilometers.\n\nEvery satellite of a Walker pattern shares a semi-major axis, so a\nsingle altitude describes the whole constellation. For a custom\nconstellation this is the altitude of the first satellite.\n\nReturns `None` when the constellation is empty.", + "associated": true + }, + { + "kind": "fn", + "name": "plane_populations", + "signature": "pub fn plane_populations(&self) -> Vec {", + "doc": "Returns the count of satellites in each plane.\n\nThe result has one entry per plane, in plane order.", + "associated": true + } + ] + }, + { + "name": "apsis-coverage", + "layer": "network", + "summary": "Ground coverage analysis. A satellite covers the part of the Earth's surface from which it can be seen above a minimum elevation angle. That part is a circle, called the footprint, whose size depends on the satellite altitude and on the elevation mask. This crate computes the footprint geometry, tests whether a ground point lies inside a footprint, and measures how much of a region is covered. The spherical model in [`geometry`](self) treats the Earth as a sphere. That is accurate enough for constellation design, where the quantities wanted are the footprint radius to a few parts in ten thousand and the number of satellites in view. The exact line-of-sight test in [`line_of_sight`] uses the actual positions, so it accounts for the flattening and for a ground point above the ellipsoid. The geometry of the satellite, the ground point, and the centre of the Earth is a triangle. Its three angles are the elevation at the ground point, the nadir angle at the satellite, and the central angle at the centre, and they sum to a right angle: ```text elevation + nadir + central = 90 degrees ``` Source: the relations used here are the standard satellite geometry, as given for example in Vallado, D. A., *Fundamentals of Astrodynamics and Applications*, 4th edition, microcosm Press, 2013, section 5.3. ``` use apsis_coverage::{footprint_central_angle, slant_range}; // A satellite at 550 km altitude sees to about fifteen degrees of // central angle at a ten degree elevation mask, which is a footprint // radius of some 1665 km along the surface. let central = footprint_central_angle(6_928.137, 6_378.137, 10.0_f64.to_radians()) .expect(\"valid geometry\"); assert!((central.to_degrees() - 14.96).abs() < 0.01); let range = slant_range(6_928.137, 6_378.137, 10.0_f64.to_radians()) .expect(\"valid geometry\"); assert!((range - 1_815.65).abs() < 0.01); ```", + "items": [ + { + "kind": "struct", + "name": "Footprint", + "signature": "pub struct Footprint {", + "doc": "A circular ground footprint.\n\nThe footprint is the part of the Earth's surface from which a satellite\nis visible above a given elevation mask. On a sphere it is an exact\ncircle, described here by the central angle from the sub-satellite point\nand the radius of the body.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(central_angle: f64, body_radius: f64) -> Self {", + "doc": "Creates a footprint from its central angle and body radius.", + "associated": true + }, + { + "kind": "fn", + "name": "surface_radius", + "signature": "pub fn surface_radius(&self) -> f64 {", + "doc": "Returns the footprint radius measured along the surface, in\nkilometers.", + "associated": true + }, + { + "kind": "fn", + "name": "area", + "signature": "pub fn area(&self) -> f64 {", + "doc": "Returns the area of the footprint, in square kilometers.\n\nThe area is the part of a sphere cut off by a cone,\n\n```text\nA = 2 pi R^2 (1 - cos(central))\n```\n\nwhich is exact for a spherical body.", + "associated": true + }, + { + "kind": "fn", + "name": "fraction_of_surface", + "signature": "pub fn fraction_of_surface(&self) -> f64 {", + "doc": "Returns the fraction of the whole sphere covered by the footprint.\n\nThe value is between zero and one half, because a footprint from a\npoint above the surface can never cover more than a hemisphere.", + "associated": true + }, + { + "kind": "fn", + "name": "contains_central_angle", + "signature": "pub fn contains_central_angle(&self, central_angle: f64) -> bool {", + "doc": "Returns `true` when a ground point at the given central angle from\nthe sub-satellite point lies inside the footprint.\n\nThe comparison is inclusive, so a point exactly on the edge is\ninside.", + "associated": true + }, + { + "kind": "fn", + "name": "footprint_central_angle", + "signature": "pub fn footprint_central_angle( satellite_radius: f64, body_radius: f64, elevation_mask: f64, ) -> Option {", + "doc": "Returns the central half angle of the footprint, in radians.\n\nThis is the largest central angle at which the satellite is still above\nthe elevation mask.\n\nReturns `None` when the geometry is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "footprint", + "signature": "pub fn footprint( satellite_radius: f64, body_radius: f64, elevation_mask: f64, ) -> Option {", + "doc": "Returns the footprint for a satellite and an elevation mask.\n\nReturns `None` when the geometry is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "footprint_area", + "signature": "pub fn footprint_area(satellite_radius: f64, body_radius: f64, elevation_mask: f64) -> Option {", + "doc": "Returns the area of a footprint, in square kilometers.\n\nThis is a free function form of [`Footprint::area`].\n\nReturns `None` when the geometry is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "footprint_fraction", + "signature": "pub fn footprint_fraction( satellite_radius: f64, body_radius: f64, elevation_mask: f64, ) -> Option {", + "doc": "Returns the fraction of the Earth's surface covered by a footprint.\n\nReturns `None` when the geometry is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "central_angle_between", + "signature": "pub fn central_angle_between(first: Vec3, second: Vec3) -> Option {", + "doc": "Returns the angle between two surface points, in radians.\n\nThe vectors must be positions relative to the centre of the body, in the\nsame frame. The angle is the central angle between them, as seen from the\ncentre.\n\nReturns `None` when either vector has zero length or a non-finite\ncomponent.", + "associated": false + }, + { + "kind": "fn", + "name": "point_in_footprint", + "signature": "pub fn point_in_footprint( footprint: &Footprint, point: Vec3, sub_satellite_point: Vec3, ) -> Option {", + "doc": "Returns `true` when a ground point lies inside a footprint.\n\nThe point and the sub-satellite point are positions relative to the\ncentre of the body, in kilometers. The footprint supplies the body radius\nand the central half angle.\n\nReturns `None` when either vector is degenerate or not finite.", + "associated": false + }, + { + "kind": "fn", + "name": "footprint_edge_elevation", + "signature": "pub fn footprint_edge_elevation(satellite_radius: f64, footprint: &Footprint) -> Option {", + "doc": "Returns the elevation at which a footprint edge is seen from the ground,\nin radians.\n\nThis is the inverse of [`footprint_central_angle`], useful when a\nfootprint is known and the mask it corresponds to is wanted.\n\nReturns `None` when the geometry is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "nadir_angle", + "signature": "pub fn nadir_angle(satellite_radius: f64, body_radius: f64, elevation: f64) -> Option {", + "doc": "Returns the nadir angle at the satellite, in radians.\n\nThe nadir angle is measured at the satellite between the direction to the\ncentre of the Earth and the direction to the ground point. It is zero when\nthe ground point is directly below the satellite and grows as the point\nmoves toward the horizon.\n\nReturns `None` when the radii are not usable, when the body radius\nexceeds the satellite radius, or when the elevation is not finite.", + "associated": false + }, + { + "kind": "fn", + "name": "earth_central_angle", + "signature": "pub fn earth_central_angle(satellite_radius: f64, body_radius: f64, elevation: f64) -> Option {", + "doc": "Returns the earth central angle between the sub-satellite point and the\nground point, in radians.\n\nThe central angle is the angle at the centre of the Earth between the two\nradial directions. It is the distance along the surface, divided by the\nbody radius.\n\nReturns `None` when the geometry is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "slant_range", + "signature": "pub fn slant_range(satellite_radius: f64, body_radius: f64, elevation: f64) -> Option {", + "doc": "Returns the slant range between the satellite and the ground point, in\nkilometers.\n\nThe range is the straight line distance, not the distance along the\nsurface. It is the quantity a link budget needs, because it sets the free\nspace path loss.\n\nThe relation is the law of cosines applied to the triangle formed by the\nsatellite, the ground point, and the centre of the Earth,\n\n```text\nrange^2 = satellite_radius^2 + body_radius^2\n - 2 satellite_radius body_radius cos(central)\n```\n\nThe law of cosines is used rather than the equivalent form\n`Rs sin(central) / cos(elevation)` because the latter is singular at the\nzenith, where the elevation is a right angle and its cosine is zero,\neven though the range there is simply the altitude.\n\nReturns `None` when the geometry is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "elevation_from_central_angle", + "signature": "pub fn elevation_from_central_angle( satellite_radius: f64, body_radius: f64, central_angle: f64, ) -> Option {", + "doc": "Returns the elevation angle for a central angle, in radians.\n\nThis inverts [`earth_central_angle`]. It is used when the position of a\nground point relative to the sub-satellite point is known and the\nelevation at that point is wanted.\n\nThe relation is\n\n```text\ntan(elevation) = (cos(central) - body_radius / satellite_radius)\n / sin(central)\n```\n\nReturns `None` when the radii are not usable or when the central angle is\nnot finite.", + "associated": false + }, + { + "kind": "struct", + "name": "GroundPoint", + "signature": "pub struct GroundPoint {", + "doc": "A ground station, given as a geodetic position on an ellipsoid.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(position: Geodetic, elevation_mask: f64) -> Self {", + "doc": "Creates a ground point on the WGS 84 ellipsoid with a given mask.", + "associated": true + }, + { + "kind": "fn", + "name": "ecef", + "signature": "pub fn ecef(&self) -> Vec3 {", + "doc": "Returns the position of the station in the Earth-fixed Cartesian\nframe, in kilometers.\n\nThe geodetic conversion yields meters, so the result is divided by a\nthousand to match the frame the orbit layer uses.", + "associated": true + }, + { + "kind": "fn", + "name": "radius", + "signature": "pub fn radius(&self) -> f64 {", + "doc": "Returns the distance from the centre of the Earth, in kilometers.\n\nThis is the local radius of the ellipsoid at the station's latitude\nplus its height, so it includes the effect of the flattening.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub fn is_valid(&self) -> bool {", + "doc": "Returns `true` when the station and its mask describe a usable\nsensor.", + "associated": true + }, + { + "kind": "struct", + "name": "Sensor", + "signature": "pub struct Sensor {", + "doc": "A sensor at a ground point, with a pointing direction.\n\nThe direction is a unit vector in the Earth-fixed frame. It is optional,\nbecause a station that can point anywhere with sufficient gain is\nmodelled by its elevation mask alone; the direction matters only when the\nstation has a fixed beam.", + "associated": false + }, + { + "kind": "fn", + "name": "omnidirectional", + "signature": "pub const fn omnidirectional(ground: GroundPoint) -> Self {", + "doc": "Creates a station that can point anywhere above its mask.", + "associated": true + }, + { + "kind": "fn", + "name": "directional", + "signature": "pub const fn directional(ground: GroundPoint, pointing: Vec3) -> Self {", + "doc": "Creates a station with a fixed pointing direction.\n\nThe direction is normalized when it is used, so the caller may pass\nan unnormalized vector.", + "associated": true + }, + { + "kind": "struct", + "name": "SubSatellitePoint", + "signature": "pub struct SubSatellitePoint {", + "doc": "The point on the Earth's surface directly below a satellite.", + "associated": false + }, + { + "kind": "fn", + "name": "sub_satellite_point", + "signature": "pub fn sub_satellite_point( position_ecef_km: Vec3, ellipsoid: Ellipsoid, ) -> Option {", + "doc": "Returns the sub-satellite point for a position in the Earth-fixed frame.\n\nThe satellite position is in kilometers. The sub-satellite point is the\nintersection of the line from the centre of the Earth through the\nsatellite with the ellipsoid, which is the geodetic position of the\nsatellite with its height set to zero at the same latitude and longitude.\n\nThis is the geocentric projection, not the point at which the ellipsoid\nnormal through the satellite meets the surface. The difference is at most\nabout twenty kilometers for a low Earth satellite, and the geocentric\nform is the one the footprint geometry uses.\n\nReturns `None` when the position is not finite or lies on the polar axis,\nwhere no longitude is defined.", + "associated": false + }, + { + "kind": "fn", + "name": "elevation_angle", + "signature": "pub fn elevation_angle(satellite_ecef_km: Vec3, ground: &GroundPoint) -> Option {", + "doc": "Returns the elevation of a satellite as seen from a station, in radians.\n\nThe satellite position is in the Earth-fixed frame, in kilometers. The\nelevation is measured from the local horizon, which is the plane\nperpendicular to the ellipsoid normal at the station.\n\nA positive value means the satellite is above the horizon, a value of a\nright angle means it is at the zenith, and a negative value means it is\nbelow the horizon.\n\nReturns `None` when either position is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "local_up", + "signature": "pub fn local_up(ground: &GroundPoint) -> Vec3 {", + "doc": "Returns the unit vector along the ellipsoid normal at a station.\n\nThis is the local vertical, the direction a plumb line points. For a\ngeodetic position it follows from the latitude and longitude, because the\nnormal of a biaxial ellipsoid lies in the meridian plane and makes the\ngeodetic latitude with the equatorial plane.", + "associated": false + }, + { + "kind": "fn", + "name": "line_of_sight", + "signature": "pub fn line_of_sight(satellite_ecef_km: Vec3, ground: &GroundPoint) -> Option {", + "doc": "Returns the elevation of a satellite as seen from a station, in radians.\n\nThis is the same as [`elevation_angle`], named for symmetry with the\nother visibility functions.\n\nReturns `None` when either position is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "visible_from", + "signature": "pub fn visible_from(satellite_ecef_km: Vec3, sensor: &Sensor) -> Option {", + "doc": "Returns `true` when a satellite is visible from a sensor.\n\nThe satellite is visible when its elevation at the station is at least\nthe sensor's mask, and, for a directional sensor, when the direction to\nthe satellite is within a right angle of the pointing direction, so that\nthe satellite is in the forward hemisphere of the beam.\n\nReturns `None` when the satellite position or the sensor is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "visible_from_geodetic", + "signature": "pub fn visible_from_geodetic( satellite_ecef_km: Vec3, position: Geodetic, elevation_mask: f64, ) -> Option {", + "doc": "Returns the elevation of a satellite as seen from a geodetic station.\n\nThis is a convenience for a station given directly as a geodetic\nposition, without building a [`GroundPoint`].\n\nReturns `None` when the satellite position or the station is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "central_angle_to_station", + "signature": "pub fn central_angle_to_station( satellite_ecef_km: Vec3, ground: &GroundPoint, elevation: f64, ) -> Option {", + "doc": "Returns the central angle between a satellite and a ground station, in\nradians.\n\nThis uses the spherical relation between the two radii and the elevation,\nso it is consistent with the footprint geometry. The satellite position\nis used only for its radius.\n\nReturns `None` when the geometry is not usable.", + "associated": false + } + ] + }, + { + "name": "apsis-access", + "layer": "network", + "summary": "Ground station access windows. A satellite is accessible from a ground station during the intervals when it rises above the station's elevation mask. Each such interval is a pass, or access window, and the sequence of windows over a period is the access schedule. This crate finds those windows from a propagated orbit and reports their start, end, duration, and the geometry at their peak. The windows are found by sampling the elevation angle at a fixed step and refining each crossing where the elevation passes the mask. The sampling step sets the shortest pass that can be found, and the refinement gives the crossing times to a tolerance independent of the step. The elevation is evaluated from the satellite position in the Earth-fixed frame, so the caller supplies the transformation from the orbit frame. In this workspace that is the conversion in `apsis-coord` with the Earth rotation angle from `apsis-frames`. ``` use apsis_access::{AccessFinder, Pass}; use apsis_coverage::GroundPoint; use apsis_geodesy::Geodetic; // A station on the equator with a ten degree mask. let station = GroundPoint::new(Geodetic::new(0.0, 0.0, 0.0), 10.0_f64.to_radians()); let finder = AccessFinder { station, sample_step_seconds: 60.0, crossing_tolerance_seconds: 0.001, }; assert!(finder.is_valid()); ```", + "items": [ + { + "kind": "fn", + "name": "find_all", + "signature": "pub fn find_all( finder: &AccessFinder, tracks: &[Vec], ) -> Vec> {", + "doc": "Searches access for many tracks, in order.\n\nThe sequential form, provided for comparison and for small sets where\nstarting a thread would cost more than the search.", + "associated": false + }, + { + "kind": "fn", + "name": "find_all_parallel", + "signature": "pub fn find_all_parallel( finder: &AccessFinder, tracks: &[Vec], ) -> Vec> {", + "doc": "Searches access for many tracks, across several threads.\n\nThe result matches [`find_all`] element for element and in the same order.\nA track that cannot be searched yields its error in the same position the\nsequential form would put it, so one bad track does not hide the rest.", + "associated": false + }, + { + "kind": "enum", + "name": "AccessErrorKind", + "signature": "pub enum AccessErrorKind {", + "doc": "The category of an access error.", + "associated": false + }, + { + "kind": "struct", + "name": "AccessError", + "signature": "pub struct AccessError {", + "doc": "A failure while finding access windows.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(kind: AccessErrorKind) -> Self {", + "doc": "Creates an error with the given category.", + "associated": true + }, + { + "kind": "fn", + "name": "with_detail", + "signature": "pub fn with_detail(mut self, detail: impl Into) -> Self {", + "doc": "Attaches a description of the values that caused the failure.", + "associated": true + }, + { + "kind": "fn", + "name": "kind", + "signature": "pub fn kind(&self) -> AccessErrorKind {", + "doc": "Returns the category of this failure.", + "associated": true + }, + { + "kind": "fn", + "name": "detail", + "signature": "pub fn detail(&self) -> Option<&str> {", + "doc": "Returns the detail text attached to this error, if any.", + "associated": true + }, + { + "kind": "struct", + "name": "OrbitSample", + "signature": "pub struct OrbitSample {", + "doc": "One sample of a satellite track, as the finder consumes it.\n\nThe position is in the Earth-fixed frame, in kilometers, and the time is\nin seconds from the caller's origin. The caller supplies a sequence of\nthese, which decouples the finder from the propagation and the frame\ntransformation.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(seconds: f64, position_ecef_km: Vec3) -> Self {", + "doc": "Creates a sample from a time and a position.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub fn is_valid(&self) -> bool {", + "doc": "Returns `true` when the sample is usable.", + "associated": true + }, + { + "kind": "struct", + "name": "AccessFinder", + "signature": "pub struct AccessFinder {", + "doc": "Finds access windows for one station.\n\nThe finder holds the station and the numerical settings of the search.\nThe settings are separated from the search so that the same finder can be\napplied to many satellites.", + "associated": false + }, + { + "kind": "fn", + "name": "elevation_of", + "signature": "pub fn elevation_of(&self, sample: &OrbitSample) -> Option {", + "doc": "Returns the elevation of a sample, in radians.\n\nReturns `None` when the sample or the station is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "find", + "signature": "pub fn find(&self, samples: &[OrbitSample]) -> Result {", + "doc": "Finds the access windows in an ordered sequence of samples.\n\nThe samples must be ordered by time and cover the interval of\ninterest. The finder assumes the elevation is continuous between\nsamples, so the step must be short enough that the satellite does not\nrise and set within one step. A low Earth satellite is above a\nten degree mask for several minutes, so a step of a minute or less\nsatisfies that.\n\nThe crossing times are found by interpolating the satellite position\nlinearly between the two samples that straddle the crossing. The\nchord between two samples departs from the true arc by an amount of\nthe second order in the step, so a ten second step gives a crossing\naccurate to about a tenth of a second and a one second step to about\na millisecond. Use [`AccessFinder::find_with`] when the propagator is\navailable and the crossing is wanted to the full tolerance.\n\nA pass that is already in progress at the first sample is reported\nwith its rise at the first sample time, and one still in progress at\nthe last sample is reported with its set at the last sample time.\nThat convention means the pass is clipped to the interval searched\nrather than extrapolated beyond it.\n\nReturns an error when the settings are not usable, when the sample\nsequence has fewer than two entries, or when a sample is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "find_with", + "signature": "pub fn find_with( &self, start_seconds: f64, end_seconds: f64, mut propagator: F, ) -> Result where F: FnMut(f64) -> Option, {", + "doc": "Finds the access windows over an interval, calling a propagator for\neach sample.\n\nThe propagator receives a time in seconds from the origin of the\nsearch and returns the satellite position in the Earth-fixed frame.\nThis adapts the finder to any orbit source without this crate\ndepending on one.\n\nReturns an error when the settings are not usable, when the interval\nis not ordered, or when the propagator fails at a sample.", + "associated": true + }, + { + "kind": "struct", + "name": "Pass", + "signature": "pub struct Pass {", + "doc": "One continuous interval during which a satellite is above a station's\nelevation mask.\n\nTimes are seconds from an origin chosen by the caller, so the same\nstructure serves a search over a scenario clock and one over elapsed\ntime.", + "associated": false + }, + { + "kind": "fn", + "name": "duration", + "signature": "pub fn duration(&self) -> f64 {", + "doc": "Returns the duration of the pass, in seconds.", + "associated": true + }, + { + "kind": "fn", + "name": "rise_to_peak", + "signature": "pub fn rise_to_peak(&self) -> f64 {", + "doc": "Returns the time from the rise to the peak, in seconds.", + "associated": true + }, + { + "kind": "fn", + "name": "contains", + "signature": "pub fn contains(&self, seconds: f64) -> bool {", + "doc": "Returns the elevation at a time within the pass, when the caller can\nsupply it.\n\nThe pass stores only the peak, so this helper reports the stored\nvalues at the three defining times and defers to the caller's\nelevation function otherwise.", + "associated": true + }, + { + "kind": "fn", + "name": "maximum_elevation", + "signature": "pub fn maximum_elevation(&self) -> f64 {", + "doc": "Returns the maximum elevation of the pass, in radians.\n\nThis is the same as the peak elevation, named for readability at a\ncall site that compares passes.", + "associated": true + }, + { + "kind": "struct", + "name": "PassSummary", + "signature": "pub struct PassSummary {", + "doc": "A summary of the access windows over an interval.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(passes: Vec, interval_seconds: f64) -> Self {", + "doc": "Creates a summary from a list of passes and the interval length.", + "associated": true + }, + { + "kind": "fn", + "name": "count", + "signature": "pub fn count(&self) -> usize {", + "doc": "Returns the number of passes.", + "associated": true + }, + { + "kind": "fn", + "name": "total_access_seconds", + "signature": "pub fn total_access_seconds(&self) -> f64 {", + "doc": "Returns the total access time, in seconds.\n\nThe passes are disjoint, so the durations add.", + "associated": true + }, + { + "kind": "fn", + "name": "access_fraction", + "signature": "pub fn access_fraction(&self) -> Option {", + "doc": "Returns the fraction of the interval during which the satellite was\naccessible.\n\nReturns `None` when the interval length is not positive.", + "associated": true + }, + { + "kind": "fn", + "name": "mean_duration_seconds", + "signature": "pub fn mean_duration_seconds(&self) -> Option {", + "doc": "Returns the mean pass duration, in seconds.\n\nReturns `None` when there are no passes.", + "associated": true + }, + { + "kind": "fn", + "name": "longest", + "signature": "pub fn longest(&self) -> Option<&Pass> {", + "doc": "Returns the longest pass, if there is one.", + "associated": true + }, + { + "kind": "fn", + "name": "highest", + "signature": "pub fn highest(&self) -> Option<&Pass> {", + "doc": "Returns the highest pass, if there is one.", + "associated": true + }, + { + "kind": "fn", + "name": "gap_seconds", + "signature": "pub fn gap_seconds(&self, first: usize, second: usize) -> Option {", + "doc": "Returns the gap between two consecutive passes, in seconds.\n\nReturns `None` when either index is out of range or the two indices\nare not consecutive and increasing.", + "associated": true + }, + { + "kind": "fn", + "name": "longest_gap_seconds", + "signature": "pub fn longest_gap_seconds(&self) -> Option {", + "doc": "Returns the largest gap between consecutive passes, in seconds.\n\nThis is the longest time a station waits between contacts.\nReturns `None` when there are fewer than two passes.", + "associated": true + } + ] + }, + { + "name": "apsis-topology", + "layer": "network", + "summary": "Time-varying network topology. A satellite network is not a single graph. The set of usable links changes as the satellites move, so the network is a sequence of graphs indexed by time, and this crate provides the data structures for that sequence. The model is deliberately simple. A [`Topology`] is a graph with a fixed set of nodes, which are satellites or ground stations, and a set of edges that may be present or absent. A [`TopologySeries`] is a sequence of topologies at increasing times, together with the interval each one applies to. The series is a step function: a topology holds from its own time until the time of the next entry. That is the standard discrete time model for a time-varying graph, and it is what routing over the series expects. Edges carry a cost as well as their presence, so that a routing algorithm has something to minimise. The cost is a plain number, so a caller may put a delay, a hop count, or anything else in it, as long as the routing algorithm and the cost agree on the meaning. ``` use apsis_topology::{Topology, TopologySample, TopologySeries}; // Two satellites linked to each other. let mut topology = Topology::new(2); topology.add_edge(0, 1, 1.0).expect(\"the nodes exist\"); assert!(topology.has_edge(0, 1)); let series = TopologySeries::new(vec![TopologySample::new(0.0, topology)]) .expect(\"the series is not empty\"); assert_eq!(series.len(), 1); assert!(series.topology_at(0.0).expect(\"a topology exists\").has_edge(0, 1)); ```", + "items": [ + { + "kind": "enum", + "name": "TopologyErrorKind", + "signature": "pub enum TopologyErrorKind {", + "doc": "The category of a topology error.", + "associated": false + }, + { + "kind": "struct", + "name": "TopologyError", + "signature": "pub struct TopologyError {", + "doc": "A failure while building or querying a topology.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(kind: TopologyErrorKind) -> Self {", + "doc": "Creates an error with the given category.", + "associated": true + }, + { + "kind": "fn", + "name": "with_detail", + "signature": "pub fn with_detail(mut self, detail: impl Into) -> Self {", + "doc": "Attaches a description of the values that caused the failure.", + "associated": true + }, + { + "kind": "fn", + "name": "kind", + "signature": "pub fn kind(&self) -> TopologyErrorKind {", + "doc": "Returns the category of this failure.", + "associated": true + }, + { + "kind": "fn", + "name": "detail", + "signature": "pub fn detail(&self) -> Option<&str> {", + "doc": "Returns the detail text attached to this error, if any.", + "associated": true + }, + { + "kind": "struct", + "name": "Edge", + "signature": "pub struct Edge {", + "doc": "One link in a topology.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(from: usize, to: usize, cost: f64) -> Self {", + "doc": "Creates an edge.", + "associated": true + }, + { + "kind": "fn", + "name": "reversed", + "signature": "pub const fn reversed(self) -> Self {", + "doc": "Returns the same edge with its endpoints exchanged.\n\nA link between two satellites carries traffic both ways, so most\ntopologies add both directions.", + "associated": true + }, + { + "kind": "struct", + "name": "Topology", + "signature": "pub struct Topology {", + "doc": "A graph of nodes and links at one instant.\n\nThe node count is fixed at construction and nodes are identified by their\nindex. Edges are directed: adding an edge from one node to another does\nnot add the reverse, so a caller that wants a bidirectional link adds\nboth directions. This is deliberate, because an inter-satellite link may\nhave different capacity in each direction and a ground link is often\nasymmetric.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(node_count: usize) -> Self {", + "doc": "Creates an empty topology with the given number of nodes.\n\nThis is not a `const fn` because it sizes the adjacency index, which\nallocates.", + "associated": true + }, + { + "kind": "fn", + "name": "node_count", + "signature": "pub const fn node_count(&self) -> usize {", + "doc": "Returns the number of nodes.", + "associated": true + }, + { + "kind": "fn", + "name": "edge_count", + "signature": "pub fn edge_count(&self) -> usize {", + "doc": "Returns the number of edges.", + "associated": true + }, + { + "kind": "fn", + "name": "is_empty", + "signature": "pub fn is_empty(&self) -> bool {", + "doc": "Returns `true` when there are no edges.", + "associated": true + }, + { + "kind": "fn", + "name": "edges", + "signature": "pub fn edges(&self) -> &[Edge] {", + "doc": "Returns the edges.", + "associated": true + }, + { + "kind": "fn", + "name": "add_edge", + "signature": "pub fn add_edge(&mut self, from: usize, to: usize, cost: f64) -> Result<(), TopologyError> {", + "doc": "Adds a directed edge.\n\nReturns an error when either node index is out of range, when the\ncost is not finite and positive, or when the edge already exists.\nRejecting a duplicate rather than replacing it keeps a topology free\nof silent contradictions about the cost of a link.", + "associated": true + }, + { + "kind": "fn", + "name": "add_bidirectional_edge", + "signature": "pub fn add_bidirectional_edge( &mut self, first: usize, second: usize, cost: f64, ) -> Result<(), TopologyError> {", + "doc": "Adds an edge in both directions with the same cost.\n\nIf the second direction fails after the first succeeds, the first is\nrolled back, so the topology is left unchanged when the call reports\nan error.", + "associated": true + }, + { + "kind": "fn", + "name": "has_edge", + "signature": "pub fn has_edge(&self, from: usize, to: usize) -> bool {", + "doc": "Returns `true` when an edge exists from one node to another.", + "associated": true + }, + { + "kind": "fn", + "name": "cost", + "signature": "pub fn cost(&self, from: usize, to: usize) -> Option {", + "doc": "Returns the cost of the edge between two nodes.\n\nReturns `None` when there is no such edge.", + "associated": true + }, + { + "kind": "fn", + "name": "remove_edge", + "signature": "pub fn remove_edge(&mut self, from: usize, to: usize) -> Result<(), TopologyError> {", + "doc": "Removes the edge between two nodes.\n\nReturns an error when there is no such edge.", + "associated": true + }, + { + "kind": "fn", + "name": "neighbours", + "signature": "pub fn neighbours(&self, node: usize) -> Vec {", + "doc": "Returns the indices of the nodes reachable from a node in one hop.\n\nThe result is in the order the edges were added, so it is\ndeterministic for a given construction sequence.", + "associated": true + }, + { + "kind": "fn", + "name": "out_degree", + "signature": "pub fn out_degree(&self, node: usize) -> usize {", + "doc": "Returns the number of edges leaving a node.", + "associated": true + }, + { + "kind": "fn", + "name": "in_degree", + "signature": "pub fn in_degree(&self, node: usize) -> usize {", + "doc": "Returns the number of edges arriving at a node.", + "associated": true + }, + { + "kind": "fn", + "name": "adjacency", + "signature": "pub fn adjacency(&self) -> Vec> {", + "doc": "Returns the adjacency of every node.\n\nThe result has one entry per node, in node order, each listing the\nneighbours of that node.", + "associated": true + }, + { + "kind": "fn", + "name": "connected_components", + "signature": "pub fn connected_components(&self) -> usize {", + "doc": "Returns the number of connected components, treating the edges as\nundirected.\n\nThis answers how many separate networks the topology forms, which is\nthe first thing to check about a constellation snapshot: a\nconstellation that has split into pieces can still carry traffic\nwithin each piece but not between them.", + "associated": true + }, + { + "kind": "fn", + "name": "is_connected", + "signature": "pub fn is_connected(&self) -> bool {", + "doc": "Returns `true` when every node can reach every other node, treating\nthe edges as undirected.", + "associated": true + }, + { + "kind": "fn", + "name": "total_cost", + "signature": "pub fn total_cost(&self) -> f64 {", + "doc": "Returns the total cost of every edge.\n\nThis is a crude measure of the network's extent, useful for comparing\nsnapshots rather than for routing.", + "associated": true + }, + { + "kind": "struct", + "name": "TopologySample", + "signature": "pub struct TopologySample {", + "doc": "One topology and the time from which it applies.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(seconds: f64, topology: Topology) -> Self {", + "doc": "Creates a sample.", + "associated": true + }, + { + "kind": "struct", + "name": "TopologySeries", + "signature": "pub struct TopologySeries {", + "doc": "A sequence of topologies over time.\n\nThe sequence is a step function. The entry at index `i` applies from its\nown time until the time of the entry at index `i + 1`, and the last entry\napplies for all later times. Querying a time before the first entry\nreturns the first topology, so the series behaves as if it extends\nbackwards as well as forwards.\n\nThe times must be strictly increasing. Two entries at the same time would\nmake the topology at that instant ambiguous.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(samples: Vec) -> Result {", + "doc": "Creates a series from a list of samples.\n\nReturns an error when the list is empty, when a time is not finite,\nor when the times are not strictly increasing.", + "associated": true + }, + { + "kind": "fn", + "name": "from_parts", + "signature": "pub fn from_parts(times: Vec, topologies: Vec) -> Result {", + "doc": "Creates a series from times and topologies as separate lists.\n\nThe two lists must have the same length.\n\nReturns an error when the lists differ in length or when the series\nitself is not valid.", + "associated": true + }, + { + "kind": "fn", + "name": "samples", + "signature": "pub fn samples(&self) -> &[TopologySample] {", + "doc": "Returns the samples.", + "associated": true + }, + { + "kind": "fn", + "name": "len", + "signature": "pub fn len(&self) -> usize {", + "doc": "Returns the number of samples.", + "associated": true + }, + { + "kind": "fn", + "name": "start_seconds", + "signature": "pub fn start_seconds(&self) -> f64 {", + "doc": "Returns the first time in the series.", + "associated": true + }, + { + "kind": "fn", + "name": "end_seconds", + "signature": "pub fn end_seconds(&self) -> f64 {", + "doc": "Returns the last time in the series.", + "associated": true + }, + { + "kind": "fn", + "name": "index_at", + "signature": "pub fn index_at(&self, seconds: f64) -> Option {", + "doc": "Returns the index of the sample that applies at a time.\n\nThe index is the last entry whose time is at or before the query, so\na time before the series returns zero and a time after it returns the\nlast index.\n\nReturns `None` when the time is not finite or the series is empty.", + "associated": true + }, + { + "kind": "fn", + "name": "topology_at", + "signature": "pub fn topology_at(&self, seconds: f64) -> Option<&Topology> {", + "doc": "Returns the topology in force at a time.\n\nReturns `None` when the time is not finite or the series is empty.", + "associated": true + }, + { + "kind": "fn", + "name": "interval_of", + "signature": "pub fn interval_of(&self, index: usize) -> Option<(f64, f64)> {", + "doc": "Returns the interval over which the sample at an index applies.\n\nThe upper bound is the time of the next sample, or positive infinity\nfor the last entry.\n\nReturns `None` when the index is out of range.", + "associated": true + }, + { + "kind": "fn", + "name": "change_count", + "signature": "pub fn change_count(&self) -> usize {", + "doc": "Returns the number of times the topology changes.\n\nThis is the number of samples less one, since each change introduces\na new entry. It measures how dynamic the network is: a series with\nmany changes needs routing that accounts for them.", + "associated": true + }, + { + "kind": "fn", + "name": "mean_interval_seconds", + "signature": "pub fn mean_interval_seconds(&self) -> Option {", + "doc": "Returns the mean interval between changes, in seconds.\n\nThe last entry has unbounded duration, so it is excluded from the\nmean and the result covers the changes that have a measured interval.\n\nReturns `None` when there is only one sample.", + "associated": true + }, + { + "kind": "fn", + "name": "edge_changes", + "signature": "pub fn edge_changes(&self) -> usize {", + "doc": "Returns the number of edge changes across the series.\n\nEach topology is compared with the one before it, counting the edges\nthat appear and the edges that disappear. A topology that differs by\none link contributes two, since one link changed.\n\nReturns zero for a series of one sample.", + "associated": true + }, + { + "kind": "fn", + "name": "node_count", + "signature": "pub fn node_count(&self) -> usize {", + "doc": "Returns the number of nodes in the series.\n\nEvery entry is expected to describe the same set of nodes, so the\nvalue is taken from the first entry. A caller that changes the node\ncount between entries is comparing two different networks and should\nbuild two series instead.", + "associated": true + } + ] + }, + { + "name": "apsis-isl", + "layer": "network", + "summary": "Inter-satellite link geometry and visibility. Two satellites can form a link when the straight line between them does not pass through the Earth. This crate decides that condition, gives the geometry a link budget needs, and reports the range and the angles at which each satellite must point. The blocking test is whether the line segment between the two positions has any point inside the body. For a spherical body that is a perpendicular distance test, which is exact and cheap, so it is the one used here. An optical link adds a second condition: the Sun must not be within the field of view of either terminal, because the background would swamp the signal. That is a separate test, offered as [`sun_blocks_link`], which takes the direction to the Sun. Lengths are in kilometers and angles in radians. The positions are in a frame in which the Earth is a sphere of the given radius, which for link geometry is the same frame the orbit layer produces. ``` use apsis_isl::{LinkGeometry, link_geometry, mutual_visibility}; use apsis_math::Vec3; // Two satellites on the same side of the Earth see each other. let first = Vec3::new(6_928.137, 0.0, 0.0); let second = Vec3::new(6_868.137, 2_500.0, 0.0); assert_eq!(mutual_visibility(first, second, 6_378.137), Some(true)); // Two satellites on opposite sides do not. let opposite = Vec3::new(-6_928.137, 0.0, 0.0); assert_eq!(mutual_visibility(first, opposite, 6_378.137), Some(false)); ```", + "items": [ + { + "kind": "fn", + "name": "visibility_matrix", + "signature": "pub fn visibility_matrix(positions: &[Vec3], body_radius: f64) -> Vec {", + "doc": "Returns the visibility of every ordered pair of positions, in order.\n\nThe result is row major: the entry at `row * positions.len() + column` is\nthe visibility from `positions[row]` to `positions[column]`. A satellite is\nalways visible to itself, which is the caller's cue to skip the diagonal.\n\nA pair whose test is undefined, because a position is not finite or the\nbody radius is not positive, yields `false`.", + "associated": false + }, + { + "kind": "fn", + "name": "visibility_matrix_parallel", + "signature": "pub fn visibility_matrix_parallel(positions: &[Vec3], body_radius: f64) -> Vec {", + "doc": "Returns the visibility of every ordered pair of positions, across several\nthreads.\n\nThe result matches [`visibility_matrix`] element for element and in the\nsame order. The pairs are independent, so splitting the flat index space\nacross threads needs no coordination and produces the same matrix however\nthe work is divided.\n\nThe gain from threading is modest here and shrinks as the matrix shrinks.\nA single visibility test is a few nanoseconds, so the cost of starting the\nthreads is only repaid by a large matrix: on a sixteen core machine a few\nhundred satellites already gain, while a few dozen lose. The sequential\nform is the better choice for a small shell, and this one exists for the\nlarge ones where the quadratic sweep dominates.", + "associated": false + }, + { + "kind": "fn", + "name": "closest_approach_to_centre", + "signature": "pub fn closest_approach_to_centre(first: Vec3, second: Vec3) -> Option {", + "doc": "Returns the closest distance from the origin to the line segment between\ntwo points.\n\nThis is the perpendicular distance when the closest point lies between\nthe two ends, and the distance to the nearer end otherwise. For an\ninter-satellite link the first case is the one that matters, because the\nblocking body lies between the terminals.\n\nReturns `None` when either point is not finite.", + "associated": false + }, + { + "kind": "fn", + "name": "mutual_visibility", + "signature": "pub fn mutual_visibility(first: Vec3, second: Vec3, body_radius: f64) -> Option {", + "doc": "Returns `true` when the two satellites can see each other past the body.\n\nThe test is whether the line segment between the two positions stays\noutside a sphere of the given radius. A link that grazes the surface\nexactly is treated as clear, because the limit is the geometric horizon.\n\nReturns `None` when either position is not finite or the body radius is\nnot positive.", + "associated": false + }, + { + "kind": "fn", + "name": "link_geometry", + "signature": "pub fn link_geometry(first: Vec3, second: Vec3, body_radius: f64) -> Option {", + "doc": "Returns the full geometry of a link between two satellites.\n\nThe directions are unit vectors from each satellite toward the other. The\nclosest approach to the centre is included so that the caller can judge\nhow much margin the link has.\n\nReturns `None` when either position is not finite, when the two coincide,\nor when the body radius is not usable. A cell that reports its own\nposition for both ends has no link, so that case is rejected rather than\nreturned with a zero range.", + "associated": false + }, + { + "kind": "fn", + "name": "sun_blocks_link", + "signature": "pub fn sun_blocks_link( link: &LinkGeometry, sun_direction: Vec3, field_of_view: f64, ) -> Option {", + "doc": "Returns `true` when the Sun is within the field of view of an optical\nlink.\n\nThe test is the angle between the link direction and the direction to the\nSun. If that angle is within the field of view at either terminal, the\nSun is in the beam and the link cannot be used.\n\nThe field of view is given as a half angle in radians, which is the\nacceptance cone of the telescope. A typical optical terminal has a half\nangle of a few microradians.\n\nThe direction to the Sun is a unit vector in the same frame as the\npositions. This crate does not compute it, because that needs an\nephemeris of the Sun, which the orbit layer does not currently provide; a\ncaller with such an ephemeris supplies the direction.\n\nReturns `None` when the directions or the field of view are not usable.", + "associated": false + }, + { + "kind": "struct", + "name": "LinkGeometry", + "signature": "pub struct LinkGeometry {", + "doc": "The geometry of a link between two satellites.\n\nThe range is the straight line distance, which a link budget uses for the\nfree space path loss. The two pointing vectors are the directions each\nterminal must aim, expressed in the frame the positions were given in.", + "associated": false + }, + { + "kind": "fn", + "name": "surface_clearance", + "signature": "pub fn surface_clearance(&self, body_radius: f64) -> f64 {", + "doc": "Returns the grazing margin of the link, in kilometers.\n\nThis is how far the line between the two satellites clears the\nsurface, measured at the closest point. A negative value means the\nlink is blocked.", + "associated": true + }, + { + "kind": "fn", + "name": "is_clear", + "signature": "pub fn is_clear(&self, body_radius: f64) -> bool {", + "doc": "Returns `true` when the link clears the body.", + "associated": true + }, + { + "kind": "enum", + "name": "LinkState", + "signature": "pub enum LinkState {", + "doc": "The state of a link at one instant.\n\nA link is either carrying traffic or not. The reasons a link can be\nunavailable are kept separate, because routing across a constellation has\nto distinguish a link that is geometrically blocked from one that is\nmerely not established yet.", + "associated": false + }, + { + "kind": "fn", + "name": "is_usable", + "signature": "pub const fn is_usable(self) -> bool {", + "doc": "Returns `true` when the link may carry traffic.", + "associated": true + }, + { + "kind": "fn", + "name": "name", + "signature": "pub const fn name(self) -> &'static str {", + "doc": "Returns a short name for the state.", + "associated": true + } + ] + }, + { + "name": "apsis-routing", + "layer": "network", + "summary": "Routing over time-varying satellite topologies. The topology of a satellite network changes as the satellites move, so a route is only valid over an interval. This crate computes least cost routes through a [`Topology`] and reports the route together with the time it remains valid. Two forms of routing are provided: - [`shortest_path`] finds the least cost route through the topology in force at one instant. - [`route_over_series`] finds a route that is valid for a whole interval, by taking the intersection of the topologies across that interval. The second form is the one a store and forward network needs. A route through a topology that changes halfway along it may not exist in the new topology, so a route is only useful if its every link survives the interval it is planned for. The algorithm is Dijkstra's, with a binary heap. Costs must be positive, which the topology layer enforces, so the algorithm is exact: the first time a node is removed from the heap its distance is final. Source: Dijkstra, E. W., *A note on two problems in connexion with graphs*, Numerische Mathematik 1, 1959, and the binary heap formulation as given in Cormen et al., *Introduction to Algorithms*, 3rd edition, MIT Press, 2009, chapter 24. ``` use apsis_routing::shortest_path; use apsis_topology::Topology; // A three node line: 0 - 1 - 2, with the direct link 0 - 2 absent. let mut topology = Topology::new(3); topology.add_edge(0, 1, 1.0).expect(\"the nodes exist\"); topology.add_edge(1, 2, 1.0).expect(\"the nodes exist\"); let route = shortest_path(&topology, 0, 2).expect(\"a route exists\"); assert_eq!(route.nodes, vec![0, 1, 2]); assert!((route.cost - 2.0).abs() < 1e-12); ```", + "items": [ + { + "kind": "struct", + "name": "PathTree", + "signature": "pub struct PathTree {", + "doc": "The result of a single source shortest path computation.\n\nThe distances are to every node reachable from the source, and the\npredecessor map records the edge by which each node was reached. Both are\nwhat a routing table needs, and a single computation fills a whole row of\nit.", + "associated": false + }, + { + "kind": "fn", + "name": "distance", + "signature": "pub fn distance(&self, node: usize) -> Option {", + "doc": "Returns the distance to a node.\n\nReturns `None` when the node index is out of range or the node is\nunreachable.", + "associated": true + }, + { + "kind": "fn", + "name": "is_reachable", + "signature": "pub fn is_reachable(&self, node: usize) -> bool {", + "doc": "Returns `true` when the node is reachable from the source.", + "associated": true + }, + { + "kind": "fn", + "name": "reachable_count", + "signature": "pub fn reachable_count(&self) -> usize {", + "doc": "Returns the number of reachable nodes, including the source itself.", + "associated": true + }, + { + "kind": "fn", + "name": "route_to", + "signature": "pub fn route_to(&self, destination: usize, topology: &Topology) -> Result {", + "doc": "Reconstructs the route to a node.\n\nReturns an error when the node is out of range or unreachable.", + "associated": true + }, + { + "kind": "fn", + "name": "shortest_paths_from", + "signature": "pub fn shortest_paths_from(topology: &Topology, source: usize) -> Result {", + "doc": "Computes the shortest paths from one source to every reachable node.\n\nReturns an error when the source node is out of range.\n\nSource: Dijkstra, E. W., *A note on two problems in connexion with\ngraphs*, Numerische Mathematik 1, 1959.", + "associated": false + }, + { + "kind": "fn", + "name": "shortest_path", + "signature": "pub fn shortest_path( topology: &Topology, source: usize, destination: usize, ) -> Result {", + "doc": "Returns the shortest route between two nodes.\n\nReturns an error when either node is out of range, when they are the\nsame node, or when no route exists.", + "associated": false + }, + { + "kind": "fn", + "name": "route_over_series", + "signature": "pub fn route_over_series( series: &TopologySeries, source: usize, destination: usize, start_seconds: f64, end_seconds: f64, ) -> Result {", + "doc": "Returns the shortest route valid across an interval of a topology series.\n\nThe route is computed over the links that are present throughout the\ninterval, so it remains valid for the whole of it. The interval is taken\nfrom the start of the sample in force at `start_seconds` to the start of\nthe sample in force at `end_seconds`, and the links of every sample in\nthat span are intersected.\n\nThe returned leg records the interval the route is valid for, which is\nbounded by the next topology change after `end_seconds`.\n\nReturns an error when either node is out of range or the same, when the\ninterval is not ordered, or when no route survives the whole interval.\n\nSource: the store and forward routing model over a time-varying graph is\nthe one described in Fraire, J. A. et al., *Routing in Delay-Tolerant\nNetworks Comprising Heterogeneous Populations of Deterministic Link\nDynamics*, IEEE Transactions on Mobile Computing, 2020.", + "associated": false + }, + { + "kind": "enum", + "name": "RoutingErrorKind", + "signature": "pub enum RoutingErrorKind {", + "doc": "The category of a routing error.", + "associated": false + }, + { + "kind": "struct", + "name": "RoutingError", + "signature": "pub struct RoutingError {", + "doc": "A failure while routing.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(kind: RoutingErrorKind) -> Self {", + "doc": "Creates an error with the given category.", + "associated": true + }, + { + "kind": "fn", + "name": "with_detail", + "signature": "pub fn with_detail(mut self, detail: impl Into) -> Self {", + "doc": "Attaches a description of the values that caused the failure.", + "associated": true + }, + { + "kind": "fn", + "name": "kind", + "signature": "pub fn kind(&self) -> RoutingErrorKind {", + "doc": "Returns the category of this failure.", + "associated": true + }, + { + "kind": "fn", + "name": "detail", + "signature": "pub fn detail(&self) -> Option<&str> {", + "doc": "Returns the detail text attached to this error, if any.", + "associated": true + }, + { + "kind": "struct", + "name": "RouteStep", + "signature": "pub struct RouteStep {", + "doc": "One hop of a route.\n\nA step records the link traversed and the cost of that link, so a caller\ncan attribute a route's total cost to its parts.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(from: usize, to: usize, cost: f64) -> Self {", + "doc": "Creates a step.", + "associated": true + }, + { + "kind": "struct", + "name": "Route", + "signature": "pub struct Route {", + "doc": "A route between two nodes.\n\nThe node list includes both endpoints, so a route of one hop has two\nnodes and one step, and a route from a node to itself has one node and no\nsteps.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(nodes: Vec, steps: Vec) -> Self {", + "doc": "Creates a route from its nodes and steps.\n\nThe cost is taken as the sum of the step costs, so a caller cannot\nmake the total disagree with the parts.", + "associated": true + }, + { + "kind": "fn", + "name": "hop_count", + "signature": "pub fn hop_count(&self) -> usize {", + "doc": "Returns the number of hops.", + "associated": true + }, + { + "kind": "fn", + "name": "source", + "signature": "pub fn source(&self) -> Option {", + "doc": "Returns the source node.\n\nReturns `None` when the route has no nodes.", + "associated": true + }, + { + "kind": "fn", + "name": "destination", + "signature": "pub fn destination(&self) -> Option {", + "doc": "Returns the destination node.\n\nReturns `None` when the route has no nodes.", + "associated": true + }, + { + "kind": "fn", + "name": "reversed", + "signature": "pub fn reversed(&self) -> Self {", + "doc": "Returns the route traversed in the opposite direction.\n\nThe costs are unchanged, because a route reversed traverses the same\nlinks. This is only correct when every link is present in both\ndirections with the same cost, which a caller must ensure.", + "associated": true + }, + { + "kind": "fn", + "name": "passes_through", + "signature": "pub fn passes_through(&self, node: usize) -> bool {", + "doc": "Returns `true` when the route passes through a node.", + "associated": true + }, + { + "kind": "fn", + "name": "position_of", + "signature": "pub fn position_of(&self, node: usize) -> Option {", + "doc": "Returns the position of a node in the route, if it is on it.", + "associated": true + }, + { + "kind": "struct", + "name": "RouteLeg", + "signature": "pub struct RouteLeg {", + "doc": "A route with the interval over which it is valid.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(route: Route, start_seconds: f64, end_seconds: f64) -> Self {", + "doc": "Creates a route leg.", + "associated": true + }, + { + "kind": "fn", + "name": "duration_seconds", + "signature": "pub fn duration_seconds(&self) -> f64 {", + "doc": "Returns the duration for which the route holds, in seconds.\n\nReturns infinity when the route is valid for all later times.", + "associated": true + } + ] + }, + { + "name": "apsis-handover", + "layer": "network", + "summary": "Handover decision logic for mobile terminals. A terminal that can be served by more than one satellite must choose one, and it must change its choice when the current server becomes unsuitable. This crate makes that decision. It does not compute access windows, which belong to the access layer; it takes a list of candidate satellites with their quality and decides whether to hand over. Two effects oppose each other and both must be in the policy: - **Handing over too late** loses the link. If the current server drops below the terminal's minimum, the link fails before the switch happens. - **Handing over too often** wastes capacity and time. Each handover costs signalling and interrupts the data flow, so a terminal that switches on every small fluctuation performs poorly even though each individual choice looked better. The policy therefore has three parts: a threshold at which the current server is considered unusable, a margin by which a candidate must beat the current server before it is worth switching to, and a minimum time between handovers. The margin is what suppresses oscillation, and it is the hysteresis of the decision. Source: the threshold, margin, and dwell time structure is the one used for handover in cellular and satellite networks, as described for example in ITU-R Recommendation M.1184 on the technical and operational characteristics of mobile satellite systems, and in the general treatment of handover hysteresis in mobile radio. ``` use apsis_handover::{Candidate, HandoverPolicy, HandoverDecision, decide}; let policy = HandoverPolicy { minimum_quality: 0.2, handover_margin: 0.1, minimum_dwell_seconds: 5.0, }; // The current server is fine and the alternative is only slightly // better, so nothing happens. let candidates = [ Candidate::new(1, 0.8, 100.0), Candidate::new(2, 0.85, 90.0), ]; let decision = decide(&policy, Some(1), &candidates).expect(\"valid input\"); assert_eq!(decision, HandoverDecision::Stay); ```", + "items": [ + { + "kind": "struct", + "name": "Candidate", + "signature": "pub struct Candidate {", + "doc": "A satellite that could serve the terminal.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(satellite: usize, quality: f64, remaining_seconds: f64) -> Self {", + "doc": "Creates a candidate.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub fn is_valid(&self) -> bool {", + "doc": "Returns `true` when the candidate is usable.", + "associated": true + }, + { + "kind": "enum", + "name": "HandoverDecision", + "signature": "pub enum HandoverDecision {", + "doc": "What the terminal should do at one instant.", + "associated": false + }, + { + "kind": "fn", + "name": "is_change", + "signature": "pub const fn is_change(self) -> bool {", + "doc": "Returns `true` when the decision changes the serving satellite.", + "associated": true + }, + { + "kind": "fn", + "name": "resulting_satellite", + "signature": "pub const fn resulting_satellite(self) -> Option {", + "doc": "Returns the satellite the terminal serves after the decision.\n\nReturns `None` for [`HandoverDecision::Drop`] and for\n[`HandoverDecision::Stay`] with no current server.", + "associated": true + }, + { + "kind": "fn", + "name": "name", + "signature": "pub const fn name(self) -> &'static str {", + "doc": "Returns a short name for the decision.", + "associated": true + }, + { + "kind": "fn", + "name": "decide", + "signature": "pub fn decide( policy: &HandoverPolicy, current: Option, candidates: &[Candidate], ) -> Result {", + "doc": "Returns the decision implied by the policy, without a dwell time.\n\nThis is the form to use when the terminal makes an independent decision\nat each instant and the caller tracks the history itself. Use\n[`decide_with_history`] when the dwell time must be enforced.\n\nThe rules, in the order they are applied:\n\n1. With no current server, acquire the best candidate, or drop when none\n meets the minimum quality.\n2. When the current server is not among the candidates, it has become\n unavailable, so switch to the best candidate or drop when none meets\n the minimum quality.\n3. When the current server is below the minimum quality, abandon it for\n the best candidate, or drop when none meets the minimum quality.\n4. Otherwise stay, unless a candidate exceeds the current server's\n quality by more than the margin.\n\nReturns an error when the candidates or the policy are not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "decide_with_history", + "signature": "pub fn decide_with_history( policy: &HandoverPolicy, current: Option, last_handover_seconds: Option, candidates: &[Candidate], seconds: f64, ) -> Result {", + "doc": "Returns the decision implied by the policy, enforcing the dwell time.\n\nThis adds one rule to those of [`decide`]. When the time since the last\nhandover is less than the dwell time, a handover that would otherwise\nhappen for a quality gain is refused and the terminal stays. A handover\nthat is forced, because the current server has become unavailable or has\nfallen below the minimum quality, is allowed regardless: refusing it\nwould drop the link, which is worse than the churn the dwell time\nprevents.\n\nReturns an error when the candidates or the policy are not usable.", + "associated": false + }, + { + "kind": "enum", + "name": "HandoverErrorKind", + "signature": "pub enum HandoverErrorKind {", + "doc": "The category of a handover error.", + "associated": false + }, + { + "kind": "struct", + "name": "HandoverError", + "signature": "pub struct HandoverError {", + "doc": "A failure while making a handover decision.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(kind: HandoverErrorKind) -> Self {", + "doc": "Creates an error with the given category.", + "associated": true + }, + { + "kind": "fn", + "name": "with_detail", + "signature": "pub fn with_detail(mut self, detail: impl Into) -> Self {", + "doc": "Attaches a description of the values that caused the failure.", + "associated": true + }, + { + "kind": "fn", + "name": "kind", + "signature": "pub fn kind(&self) -> HandoverErrorKind {", + "doc": "Returns the category of this failure.", + "associated": true + }, + { + "kind": "fn", + "name": "detail", + "signature": "pub fn detail(&self) -> Option<&str> {", + "doc": "Returns the detail text attached to this error, if any.", + "associated": true + }, + { + "kind": "struct", + "name": "HandoverPolicy", + "signature": "pub struct HandoverPolicy {", + "doc": "The parameters of a handover policy.\n\nThe three parameters work together. The minimum quality decides when the\ncurrent server must be abandoned, the margin decides when a better\ncandidate is worth the switch, and the dwell time limits how often\nswitches may happen at all.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new( minimum_quality: f64, handover_margin: f64, minimum_dwell_seconds: f64, ) -> Self {", + "doc": "Creates a policy from its three parameters.", + "associated": true + }, + { + "kind": "fn", + "name": "validate", + "signature": "pub fn validate(&self) -> Result<(), HandoverError> {", + "doc": "Validates the policy, returning the reason when it is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "best", + "signature": "pub fn best<'a>(&self, candidates: &'a [Candidate]) -> Option<&'a Candidate> {", + "doc": "Returns the best candidate under the policy.\n\nThe best candidate is the one with the greatest quality, with ties\nbroken by the lower satellite identifier so that the result is\ndeterministic.", + "associated": true + }, + { + "kind": "struct", + "name": "HandoverRecord", + "signature": "pub struct HandoverRecord {", + "doc": "A record of one handover that has occurred.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new( from: usize, to: usize, seconds: f64, from_quality: f64, to_quality: f64, ) -> Self {", + "doc": "Creates a record.", + "associated": true + }, + { + "kind": "fn", + "name": "quality_gain", + "signature": "pub fn quality_gain(&self) -> f64 {", + "doc": "Returns the gain in quality that the handover achieved.", + "associated": true + }, + { + "kind": "struct", + "name": "HandoverTracker", + "signature": "pub struct HandoverTracker {", + "doc": "Tracks the serving satellite and the history of handovers.\n\nThe tracker owns the policy and the decision history, so a caller only\nhas to supply the candidates at each step. It is the form a simulation or\na control loop uses, where the decision made at one step affects the one\nat the next through the dwell time.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(policy: HandoverPolicy) -> Self {", + "doc": "Creates a tracker with no serving satellite.", + "associated": true + }, + { + "kind": "fn", + "name": "serving", + "signature": "pub fn serving(policy: HandoverPolicy, satellite: usize) -> Self {", + "doc": "Creates a tracker that is already serving a satellite.", + "associated": true + }, + { + "kind": "fn", + "name": "policy", + "signature": "pub fn policy(&self) -> HandoverPolicy {", + "doc": "Returns the policy.", + "associated": true + }, + { + "kind": "fn", + "name": "current", + "signature": "pub fn current(&self) -> Option {", + "doc": "Returns the satellite currently being served, if any.", + "associated": true + }, + { + "kind": "fn", + "name": "last_handover_seconds", + "signature": "pub fn last_handover_seconds(&self) -> Option {", + "doc": "Returns the time of the last handover, if there has been one.", + "associated": true + }, + { + "kind": "fn", + "name": "history", + "signature": "pub fn history(&self) -> &[HandoverRecord] {", + "doc": "Returns the handover history, in time order.", + "associated": true + }, + { + "kind": "fn", + "name": "handover_count", + "signature": "pub fn handover_count(&self) -> usize {", + "doc": "Returns the number of handovers that have occurred.", + "associated": true + }, + { + "kind": "fn", + "name": "step", + "signature": "pub fn step( &mut self, seconds: f64, candidates: &[Candidate], ) -> Result {", + "doc": "Applies the policy to a set of candidates at a time.\n\nThe tracker updates its state according to the decision and returns\nthat decision, so a caller can both react to it and rely on the\ntracker having recorded it.\n\nReturns an error when the candidates or the policy are not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "mean_interval_seconds", + "signature": "pub fn mean_interval_seconds(&self) -> Option {", + "doc": "Returns the mean time between handovers, in seconds.\n\nReturns `None` when fewer than two handovers have occurred, because a\nsingle interval is not a mean.", + "associated": true + }, + { + "kind": "fn", + "name": "handover_rate_per_second", + "signature": "pub fn handover_rate_per_second(&self) -> Option {", + "doc": "Returns the number of handovers per unit time over the history.\n\nThe rate is measured across the whole history, from the first\nhandover to the last. Returns `None` when there are fewer than two\nhandovers or when they all occur at the same instant.", + "associated": true + }, + { + "kind": "fn", + "name": "total_quality_gain", + "signature": "pub fn total_quality_gain(&self) -> f64 {", + "doc": "Returns the total quality gained by handing over.\n\nThe value is the sum over the history, which can be negative when a\nhandover was forced by a falling quality rather than chosen for a\ngain.", + "associated": true + } + ] + }, + { + "name": "apsis-mesh", + "layer": "network", + "summary": "Mesh connectivity abstractions. A satellite mesh is a set of terminals that reach each other through links, some of which are satellite links and some of which are ground links. This crate provides the abstractions that a mesh application needs on top of a topology: which terminals can reach which others, how many independent ways there are to do so, and which links are critical. The three questions this crate answers: - **Reachability**: the set of terminals reachable from a given one. - **Redundancy**: how many independent paths join two terminals, which is the number of links that can fail independently. This is the edge-connectivity of the graph, by Menger's theorem. - **Criticality**: the links whose removal would separate the mesh, which are the ones a network operator must protect. The redundancy computation is exact for the small graphs a satellite mesh presents. It uses the edge version of Menger's theorem, computing the connectivity from a sequence of maximum flow problems, each of unit capacity. Source: Menger's theorem, and the reduction of edge connectivity to maximum flow, as given in Cormen et al., *Introduction to Algorithms*, 3rd edition, MIT Press, 2009, chapter 26. ``` use apsis_mesh::Mesh; use apsis_topology::Topology; // Three terminals in a ring, so each pair has two independent paths. let mut topology = Topology::new(3); topology.add_bidirectional_edge(0, 1, 1.0).expect(\"the nodes exist\"); topology.add_bidirectional_edge(1, 2, 1.0).expect(\"the nodes exist\"); topology.add_bidirectional_edge(2, 0, 1.0).expect(\"the nodes exist\"); let mesh = Mesh::new(topology); assert!(mesh.is_connected()); assert_eq!(mesh.edge_connectivity(0, 1), Some(2)); ```", + "items": [ + { + "kind": "fn", + "name": "reachable_from", + "signature": "pub fn reachable_from(topology: &Topology, source: usize) -> Vec {", + "doc": "Returns the set of nodes reachable from a node, following edges in their\ndirection.\n\nThe start node is included, since a node reaches itself by a path of no\nhops. The result is in node order.\n\nReturns an empty vector when the node is out of range.", + "associated": false + }, + { + "kind": "fn", + "name": "edge_connectivity", + "signature": "pub fn edge_connectivity(topology: &Topology, source: usize, destination: usize) -> Option {", + "doc": "Returns the number of independent paths between two nodes.\n\nTwo paths are independent when they share no edge. By Menger's theorem\nthat number is the minimum number of edges whose removal disconnects the\ntwo nodes, so it is the number of link failures the connection survives.\n\nThe value is computed as a maximum flow of unit capacity between the two\nnodes, using the Edmonds-Karp variant of the Ford-Fulkerson method, which\naugments along a shortest path at each step. For the small graphs of a\nsatellite mesh this is fast, and it is exact.\n\nReturns `Some(0)` when no path exists, and `None` when either node is out\nof range or the two nodes are the same, for which the question is not\ndefined.", + "associated": false + }, + { + "kind": "fn", + "name": "strongly_connected_components", + "signature": "pub fn strongly_connected_components(topology: &Topology) -> usize {", + "doc": "Returns the number of strongly connected components.\n\nA component is strongly connected when every node in it can reach every\nother following the edges in their direction. This is the count of\nseparate networks that can route internally, which for a satellite mesh\nwith directed inter-satellite links is the right measure of how many\npieces it has.\n\nThe algorithm is Kosaraju's: a depth first search records the order in\nwhich nodes finish, and a second search over the reversed edges, taking\nthe nodes in reverse finishing order, emits the components.\n\nSource: Kosaraju's algorithm as described in Cormen et al.,\n*Introduction to Algorithms*, 3rd edition, MIT Press, 2009, chapter 22.", + "associated": false + }, + { + "kind": "struct", + "name": "CriticalLink", + "signature": "pub struct CriticalLink {", + "doc": "A link whose removal would change the connectivity of the mesh.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(from: usize, to: usize, cost: f64, components_without: usize) -> Self {", + "doc": "Creates a critical link record.", + "associated": true + }, + { + "kind": "struct", + "name": "MeshSummary", + "signature": "pub struct MeshSummary {", + "doc": "A summary of the connectivity of a mesh.", + "associated": false + }, + { + "kind": "struct", + "name": "Mesh", + "signature": "pub struct Mesh {", + "doc": "A mesh of terminals joined by links.\n\nThe mesh wraps a topology and adds the connectivity measures a mesh\napplication needs. It does not own a schedule or a traffic model; those\nbelong to the caller.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(topology: Topology) -> Self {", + "doc": "Creates a mesh over a topology.", + "associated": true + }, + { + "kind": "fn", + "name": "topology", + "signature": "pub const fn topology(&self) -> &Topology {", + "doc": "Returns the underlying topology.", + "associated": true + }, + { + "kind": "fn", + "name": "node_count", + "signature": "pub fn node_count(&self) -> usize {", + "doc": "Returns the number of terminals.", + "associated": true + }, + { + "kind": "fn", + "name": "edge_count", + "signature": "pub fn edge_count(&self) -> usize {", + "doc": "Returns the number of links.", + "associated": true + }, + { + "kind": "fn", + "name": "is_connected", + "signature": "pub fn is_connected(&self) -> bool {", + "doc": "Returns `true` when every terminal reaches every other, treating the\nlinks as undirected.", + "associated": true + }, + { + "kind": "fn", + "name": "reachable_from", + "signature": "pub fn reachable_from(&self, source: usize) -> Vec {", + "doc": "Returns the terminals reachable from a terminal.", + "associated": true + }, + { + "kind": "fn", + "name": "edge_connectivity", + "signature": "pub fn edge_connectivity(&self, source: usize, destination: usize) -> Option {", + "doc": "Returns the number of independent paths between two terminals.\n\nReturns `None` when either terminal is out of range or the two are\nthe same.", + "associated": true + }, + { + "kind": "fn", + "name": "minimum_connectivity", + "signature": "pub fn minimum_connectivity(&self) -> Option {", + "doc": "Returns the smallest number of independent paths between any two\ndistinct terminals.\n\nReturns `None` when there are fewer than two terminals.", + "associated": true + }, + { + "kind": "fn", + "name": "critical_links", + "signature": "pub fn critical_links(&self) -> Vec {", + "doc": "Returns the links whose removal would increase the number of\nconnected components, treating each link as undirected.\n\nThese are the bridges of the undirected graph underlying the mesh. A\nlink is reported once, even when it is present in both directions,\nbecause a failure takes out the whole link rather than one direction\nof it.\n\nEach candidate is found by removing the link in both directions and\ncomparing the component count, which is exact but costs one traversal\nper link. For the size of graph a satellite mesh presents that is\nacceptable, and it is easier to verify than a linear time bridge\nfinding algorithm.\n\nLinks that are not bridges are omitted, so an empty result means\nevery link lies on a cycle and the mesh survives any single link\nfailure without splitting.", + "associated": true + }, + { + "kind": "fn", + "name": "survives_single_link_failure", + "signature": "pub fn survives_single_link_failure(&self) -> bool {", + "doc": "Returns `true` when the mesh survives the failure of any single link\nwithout splitting.\n\nThis is the condition for the mesh to have a single link failure\ntolerance, which is the minimum redundancy a network carrying traffic\nwithout interruption should have.", + "associated": true + }, + { + "kind": "fn", + "name": "strongly_connected_components", + "signature": "pub fn strongly_connected_components(&self) -> usize {", + "doc": "Returns the number of strongly connected components.\n\nFor a mesh of directed links this counts the pieces that can route\ninternally. A value above one means some terminals cannot be reached\nfrom some others.", + "associated": true + }, + { + "kind": "fn", + "name": "summarise", + "signature": "pub fn summarise(&self) -> MeshSummary {", + "doc": "Returns a summary of the mesh connectivity.", + "associated": true + } + ] + }, + { + "name": "apsis-rf", + "layer": "rf", + "summary": "Core radio frequency primitives. This crate holds the quantities that the rest of the radio frequency layer is written in: power, gain, and the decibel scale that both are usually quoted on, together with the two relations that every link calculation starts from, the wavelength of a frequency and the free space path loss. Units are SI throughout. Power is in watts, frequency in hertz, distance in meters, and temperature in kelvin. Decibel values are power ratios, computed as ten times the base ten logarithm of the ratio. Two conventions are worth stating because they are easy to mix up: - A decibel is a ratio between two powers. A gain expressed in `dBi` is a ratio to an isotropic radiator and one in `dBd` is a ratio to a half wave dipole, which is 2.15 dB more directive than an isotropic radiator. [`Gain`] carries the ratio and both named forms. - The free space path loss increases with frequency as well as with distance, because both the effective aperture of a fixed gain antenna and the solid angle it subtends shrink as the wavelength shortens. ``` use apsis_rf::{Power, wavelength}; // A noise power of -204 dBW is about 4e-21 W. let noise = Power::from_dbw(-204.0).expect(\"the reading is finite\"); assert!(noise.watts() > 0.0); // A one meter wavelength corresponds to a frequency of 299.792 MHz. let lambda = wavelength(299_792_458.0).expect(\"the frequency is positive\"); assert!((lambda - 1.0).abs() < 1e-12); ```", + "items": [ + { + "kind": "struct", + "name": "Decibel", + "signature": "pub struct Decibel(f64);", + "doc": "A ratio expressed in decibels.\n\nA decibel is ten times the base ten logarithm of a power ratio, so a\nvalue of zero is unity gain or loss, a value of three is very close to a\ndoubling, and a value of minus three is very close to a halving.\n\nThe type stores the decibel value rather than the ratio, because a chain\nof gains and losses is then a sum, which is both faster and less prone to\noverflow than a product of ratios.", + "associated": false + }, + { + "kind": "const", + "name": "UNITY", + "signature": "pub const UNITY: Self = Self(0.0);", + "doc": "The value for a ratio of one, which is zero decibels.", + "associated": true + }, + { + "kind": "const", + "name": "DOUBLING", + "signature": "pub const DOUBLING: Self = Self(3.010_299_956_639_812);", + "doc": "The value for a ratio of two, which is about 3.0103 decibels.", + "associated": true + }, + { + "kind": "fn", + "name": "from_db", + "signature": "pub const fn from_db(decibels: f64) -> Self {", + "doc": "Creates a value from a decibel reading.", + "associated": true + }, + { + "kind": "fn", + "name": "from_ratio", + "signature": "pub fn from_ratio(ratio: f64) -> Option {", + "doc": "Creates a value from a power ratio.\n\nReturns `None` when the ratio is not positive and finite, because the\nlogarithm is not defined there.", + "associated": true + }, + { + "kind": "fn", + "name": "from_amplitude_ratio", + "signature": "pub fn from_amplitude_ratio(ratio: f64) -> Option {", + "doc": "Creates a value from an amplitude ratio.\n\nAn amplitude, unlike a power, scales the decibel value by twenty\nrather than ten, because power is proportional to amplitude squared.\nUse this for a voltage or field ratio.\n\nReturns `None` when the ratio is not positive and finite.", + "associated": true + }, + { + "kind": "fn", + "name": "db", + "signature": "pub const fn db(self) -> f64 {", + "doc": "Returns the reading in decibels.", + "associated": true + }, + { + "kind": "fn", + "name": "ratio", + "signature": "pub fn ratio(self) -> f64 {", + "doc": "Returns the power ratio this reading represents.", + "associated": true + }, + { + "kind": "fn", + "name": "amplitude_ratio", + "signature": "pub fn amplitude_ratio(self) -> f64 {", + "doc": "Returns the amplitude ratio this reading represents.", + "associated": true + }, + { + "kind": "fn", + "name": "plus", + "signature": "pub const fn plus(self, other: Self) -> Self {", + "doc": "Returns the sum of two readings.\n\nAdding decibel values multiplies the ratios they represent, which is\nhow a cascade of gains and losses is combined.", + "associated": true + }, + { + "kind": "fn", + "name": "minus", + "signature": "pub const fn minus(self, other: Self) -> Self {", + "doc": "Returns the difference of two readings.\n\nSubtracting decibel values divides the ratios they represent, which\nis how a loss is removed from a gain.", + "associated": true + }, + { + "kind": "fn", + "name": "negated", + "signature": "pub const fn negated(self) -> Self {", + "doc": "Returns this reading negated, which inverts the ratio.", + "associated": true + }, + { + "kind": "fn", + "name": "is_finite", + "signature": "pub const fn is_finite(self) -> bool {", + "doc": "Returns `true` when the reading is finite.", + "associated": true + }, + { + "kind": "fn", + "name": "wavelength", + "signature": "pub fn wavelength(frequency: f64) -> Option {", + "doc": "Returns the wavelength of a frequency in vacuum, in meters.\n\nThe relation is `lambda = c / f`, with `c` exact by the definition of the\nmetre.\n\nReturns `None` when the frequency is not positive and finite.", + "associated": false + }, + { + "kind": "fn", + "name": "free_space_path_loss", + "signature": "pub fn free_space_path_loss(distance: f64, frequency: f64) -> Option {", + "doc": "Returns the free space path loss between two isotropic antennas, as a\nratio in decibels.\n\nThe ratio is positive, so it represents a loss: the received power is the\ntransmitted power divided by this ratio. The relation is\n\n```text\nL = (4 pi d / lambda)^2\n```\n\nwhich is the spreading loss of a spherical wave over a distance `d`\ncombined with the effective aperture of an isotropic receive antenna.\n\nThe loss grows with frequency at a fixed distance, because the effective\naperture of the receive antenna falls as the square of the wavelength.\n\nThe distance must be positive. A zero distance is not a case this\nexpression covers: the ratio would be zero, which is infinite attenuation\nin decibels, and the underlying far field assumption does not hold.\n\nReturns `None` when the distance is not positive, or when either argument\nis not finite.", + "associated": false + }, + { + "kind": "fn", + "name": "free_space_received_ratio", + "signature": "pub fn free_space_received_ratio( distance: f64, frequency: f64, transmitter: Gain, receiver: Gain, ) -> Option {", + "doc": "Returns the received power ratio between two antennas in free space.\n\nThe received power is the transmitted power multiplied by the two antenna\ngains and divided by the free space path loss, which in decibels is the\nsum\n\n```text\nP_r / P_t = G_t G_r / L\n```\n\nThe result is the ratio in decibels, so a positive value means the\nreceive antenna collects more than was transmitted, which happens when\nthe two gains exceed the spreading loss.\n\nReturns `None` when the distance or frequency is not usable.", + "associated": false + }, + { + "kind": "const", + "name": "DIPOLE_OVER_ISOTROPIC_DB", + "signature": "pub const DIPOLE_OVER_ISOTROPIC_DB: f64 = 2.15;", + "doc": "The gain of a half wave dipole relative to an isotropic radiator, in\ndecibels.\n\nThe exact directivity of an ideal half wave dipole is 1.6409, which is\n2.1509 dB. Engineering practice rounds this to 2.15 dB, and that rounded\nvalue is what the `dBd` and `dBi` conventions are defined against. This\ncrate uses the conventional value so that a conversion between the two\nnamed forms is exact, and it derives the linear ratio from it.", + "associated": false + }, + { + "kind": "const", + "name": "HALF_WAVE_DIPOLE_RATIO", + "signature": "pub const HALF_WAVE_DIPOLE_RATIO: f64 = 1.640_589_773_199_539_4;", + "doc": "The linear gain ratio of a half wave dipole, `10^(2.15 / 10)`.\n\nThis is about 1.64059, within 0.02 percent of the exact directivity of an\nideal dipole, which is 1.6409.", + "associated": false + }, + { + "kind": "struct", + "name": "Gain", + "signature": "pub struct Gain(f64);", + "doc": "An antenna gain expressed as a ratio to an isotropic radiator.\n\nThe type stores the linear ratio. The two named decibel forms are offered\nas conversions, because the choice is a matter of convention rather than\nof the gain itself:\n\n- `dBi` is the ratio to an isotropic radiator, which radiates equally in\n every direction.\n- `dBd` is the ratio to a half wave dipole, which is\n [`DIPOLE_OVER_ISOTROPIC_DB`] decibels above an isotropic radiator.", + "associated": false + }, + { + "kind": "const", + "name": "ISOTROPIC", + "signature": "pub const ISOTROPIC: Self = Self(1.0);", + "doc": "Unit gain, as an isotropic radiator has.", + "associated": true + }, + { + "kind": "const", + "name": "HALF_WAVE_DIPOLE", + "signature": "pub const HALF_WAVE_DIPOLE: Self = Self(HALF_WAVE_DIPOLE_RATIO);", + "doc": "The gain of a half wave dipole, about 1.6406.", + "associated": true + }, + { + "kind": "fn", + "name": "from_ratio", + "signature": "pub const fn from_ratio(ratio: f64) -> Self {", + "doc": "Creates a gain from a linear ratio.", + "associated": true + }, + { + "kind": "fn", + "name": "from_dbi", + "signature": "pub fn from_dbi(dbi: f64) -> Option {", + "doc": "Creates a gain from a value in decibels relative to an isotropic\nradiator.\n\nA gain ratio is never negative, so any finite reading is accepted.", + "associated": true + }, + { + "kind": "fn", + "name": "from_dbd", + "signature": "pub fn from_dbd(dbd: f64) -> Option {", + "doc": "Creates a gain from a value in decibels relative to a half wave\ndipole.\n\nReturns `None` when the reading is not finite.", + "associated": true + }, + { + "kind": "fn", + "name": "ratio", + "signature": "pub const fn ratio(self) -> f64 {", + "doc": "Returns the linear ratio to an isotropic radiator.", + "associated": true + }, + { + "kind": "fn", + "name": "dbi", + "signature": "pub fn dbi(self) -> Option {", + "doc": "Returns this gain in decibels relative to an isotropic radiator.\n\nReturns `None` when the ratio is not positive.", + "associated": true + }, + { + "kind": "fn", + "name": "dbd", + "signature": "pub fn dbd(self) -> Option {", + "doc": "Returns this gain in decibels relative to a half wave dipole.\n\nReturns `None` when the ratio is not positive.", + "associated": true + }, + { + "kind": "fn", + "name": "as_decibel", + "signature": "pub fn as_decibel(self) -> Option {", + "doc": "Returns this gain as a ratio in decibels.\n\nReturns `None` when the ratio is not positive.", + "associated": true + }, + { + "kind": "fn", + "name": "effective_aperture", + "signature": "pub fn effective_aperture(self, wavelength: f64) -> Option {", + "doc": "Returns the effective aperture of an antenna with this gain at the\ngiven wavelength, in square meters.\n\nThe relation is `A = G lambda^2 / (4 pi)`. It follows from the\nrequirement that an antenna in a thermal bath receive as much power\nas it radiates.\n\nReturns `None` when the wavelength is not positive and finite.", + "associated": true + }, + { + "kind": "fn", + "name": "from_effective_aperture", + "signature": "pub fn from_effective_aperture(aperture: f64, wavelength: f64) -> Option {", + "doc": "Returns the gain of an antenna with this effective aperture at the\ngiven wavelength.\n\nThis is the inverse of [`Gain::effective_aperture`].\n\nReturns `None` when the wavelength is not positive and finite, or\nwhen the aperture is negative.", + "associated": true + }, + { + "kind": "const", + "name": "DBW_REFERENCE", + "signature": "pub const DBW_REFERENCE: f64 = 1.0;", + "doc": "The reference power of the decibel watt, one watt.\n\nA power quoted in dBW is a ratio to this value.", + "associated": false + }, + { + "kind": "const", + "name": "DBM_REFERENCE", + "signature": "pub const DBM_REFERENCE: f64 = 1.0e-3;", + "doc": "The reference power of the decibel milliwatt, one milliwatt.\n\nA power quoted in dBm is a ratio to this value, so a value in dBm is\nthirty decibels above the same value in dBW.", + "associated": false + }, + { + "kind": "struct", + "name": "Power", + "signature": "pub struct Power(f64);", + "doc": "A power level in watts.\n\nThe type stores watts, which is the unit the decibel references are\ndefined against, and offers conversion to and from the two decibel forms\nused in link budgets.", + "associated": false + }, + { + "kind": "const", + "name": "WATT", + "signature": "pub const WATT: Self = Self(1.0);", + "doc": "The reference power of zero dBW, one watt.", + "associated": true + }, + { + "kind": "const", + "name": "MILLIWATT", + "signature": "pub const MILLIWATT: Self = Self(1.0e-3);", + "doc": "The reference power of zero dBm, one milliwatt.", + "associated": true + }, + { + "kind": "fn", + "name": "from_watts", + "signature": "pub const fn from_watts(watts: f64) -> Self {", + "doc": "Creates a power level from a value in watts.\n\nA power may be zero, which represents no power, but not negative.", + "associated": true + }, + { + "kind": "fn", + "name": "from_milliwatts", + "signature": "pub const fn from_milliwatts(milliwatts: f64) -> Self {", + "doc": "Creates a power level from a value in milliwatts.", + "associated": true + }, + { + "kind": "fn", + "name": "from_dbw", + "signature": "pub fn from_dbw(decibel_watts: f64) -> Option {", + "doc": "Creates a power level from a value in decibel watts.\n\nA zero or negative reading cannot be expressed as a finite power, so\nno validation is needed.", + "associated": true + }, + { + "kind": "fn", + "name": "from_dbm", + "signature": "pub fn from_dbm(decibel_milliwatts: f64) -> Option {", + "doc": "Creates a power level from a value in decibel milliwatts.\n\nReturns `None` when the reading is not finite.", + "associated": true + }, + { + "kind": "fn", + "name": "watts", + "signature": "pub const fn watts(self) -> f64 {", + "doc": "Returns this power in watts.", + "associated": true + }, + { + "kind": "fn", + "name": "milliwatts", + "signature": "pub const fn milliwatts(self) -> f64 {", + "doc": "Returns this power in milliwatts.", + "associated": true + }, + { + "kind": "fn", + "name": "dbw", + "signature": "pub fn dbw(self) -> Option {", + "doc": "Returns this power in decibel watts.\n\nReturns `None` when the power is not positive, because the logarithm\nis not defined there.", + "associated": true + }, + { + "kind": "fn", + "name": "dbm", + "signature": "pub fn dbm(self) -> Option {", + "doc": "Returns this power in decibel milliwatts.\n\nReturns `None` when the power is not positive.", + "associated": true + }, + { + "kind": "fn", + "name": "ratio_in_decibels", + "signature": "pub fn ratio_in_decibels(self, rhs: Self) -> Option {", + "doc": "Returns the ratio of `self` to `rhs`, in decibels.\n\nThis is the form used in a link budget to express a loss or a gain\nbetween two power levels.\n\nReturns `None` when either power is not positive.", + "associated": true + }, + { + "kind": "fn", + "name": "scaled", + "signature": "pub fn scaled(self, ratio: Decibel) -> Option {", + "doc": "Returns this power scaled by a ratio in decibels.\n\nReturns `None` when the resulting power is not finite, which happens\nfor a very large gain.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub const fn is_valid(self) -> bool {", + "doc": "Returns `true` when the power is finite and not negative.", + "associated": true + } + ] + }, + { + "name": "apsis-antenna", + "layer": "rf", + "summary": "Antenna radiation patterns and gain models. An antenna does not radiate equally in every direction. This crate models how the gain varies with angle for the two shapes that appear in almost every satellite link: a parabolic reflector, whose beam is a narrow pencil, and an idealised element with a broad pattern. Three beam width conventions are used in practice and they are not interchangeable: - The half power beam width is the angle between the two directions where the gain has fallen to half its peak, which is three decibels down. - The null to null beam width is the angle between the first two zeros on either side of the main lobe. It is roughly twice the half power width for a uniformly illuminated aperture. - The first sidelobe level is the peak gain outside the main lobe. For a uniformly illuminated circular aperture it is about minus 17.6 dB. Every function here takes or returns angles in radians and gains as [`apsis_rf::Gain`], so a result can be fed straight into a link budget. Sources: - Parabolic aperture gain: `G = eta (pi D / lambda)^2`, where `eta` is the aperture efficiency, typically between 0.55 and 0.70 for a well adjusted dish. - Half power beam width of a circular aperture: `theta = k lambda / D` with `k` about 1.02 for uniform illumination and close to 1.2 for typical illumination tapers. - The co-polar reference pattern of a simple Earth station antenna, which falls as the square of the off-axis angle down to a sidelobe floor. This is the form used by ITU-R Recommendation S.465 to bound interference from one satellite system into another. ``` use apsis_antenna::ParabolicAperture; // A three meter dish at twelve gigahertz has a peak gain near 50 dBi. let dish = ParabolicAperture { diameter: 3.0, efficiency: 0.65, }; let peak = dish.peak_gain(0.025).expect(\"valid wavelength\"); assert!(peak.dbi().expect(\"positive\") > 45.0); assert!(peak.dbi().expect(\"positive\") < 50.0); ```", + "items": [ + { + "kind": "struct", + "name": "ParabolicAperture", + "signature": "pub struct ParabolicAperture {", + "doc": "A parabolic reflector described by its diameter and aperture efficiency.\n\nThe aperture efficiency collects every loss between the power delivered\nto the feed and the power that leaves the aperture as a uniform plane\nwave: illumination taper, spillover, blockage by the feed and its\nsupports, and surface error. A well adjusted dish reaches 0.65 to 0.70,\nand a small or simply built one is nearer 0.55.", + "associated": false + }, + { + "kind": "fn", + "name": "area", + "signature": "pub fn area(self) -> f64 {", + "doc": "Returns the physical area of the aperture, in square meters.", + "associated": true + }, + { + "kind": "fn", + "name": "effective_area", + "signature": "pub fn effective_area(self) -> f64 {", + "doc": "Returns the effective area, in square meters.\n\nThis is the physical area scaled by the aperture efficiency. It is\nthe area that, if it collected uniformly, would deliver the observed\npower.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub fn is_valid(self) -> bool {", + "doc": "Returns `true` when the aperture describes a usable dish.", + "associated": true + }, + { + "kind": "fn", + "name": "peak_gain", + "signature": "pub fn peak_gain(self, wavelength: f64) -> Option {", + "doc": "Returns the peak gain on boresight at the given wavelength.\n\nThe relation is\n\n```text\nG = eta (pi D / lambda)^2\n```\n\nwhich follows from the effective area through\n[`Gain::from_effective_aperture`]. Returns `None` for an invalid\naperture or wavelength.", + "associated": true + }, + { + "kind": "fn", + "name": "uniform_beam_width", + "signature": "pub fn uniform_beam_width(self, wavelength: f64) -> Option {", + "doc": "Returns the half power beam width at the given wavelength, in\nradians.\n\nThis uses the uniform illumination relation, which is the narrowest\na dish of this diameter can be. A real dish with an illumination\ntaper is wider; use [`ParabolicAperture::half_power_beam_width`] to\ninclude the taper.\n\nReturns `None` for an invalid aperture or wavelength.", + "associated": true + }, + { + "kind": "fn", + "name": "half_power_beam_width", + "signature": "pub fn half_power_beam_width(self, wavelength: f64) -> Option {", + "doc": "Returns the half power beam width at the given wavelength, in\nradians, allowing for an illumination taper.\n\nA tapered illumination reduces the sidelobes at the cost of a wider\nmain lobe. The factor `k` in `theta = k lambda / D` rises from about\n1.02 for uniform illumination to about 1.2 for the taper that\nminimises sidelobes. A value of 1.15 is representative of a\nwell designed dish.\n\nReturns `None` for an invalid aperture or wavelength.", + "associated": true + }, + { + "kind": "fn", + "name": "beam_width", + "signature": "pub fn beam_width(diameter: f64, wavelength: f64, k: f64) -> Option {", + "doc": "Returns the half power beam width of a circular aperture, in radians.\n\nThe relation is `theta = k lambda / D`, where `k` depends on how the\naperture is illuminated. Uniform illumination gives the narrowest beam,\nwith `k` about 1.02, and the taper that suppresses sidelobes gives the\nwidest, with `k` about 1.2.\n\nThe result is the full width between the two three decibel points, not\nthe half width. It is a small angle approximation, so the caller should\nconfirm that the resulting width is small compared to a radian.\n\nReturns `None` when the diameter or wavelength is not positive, or when\n`k` is not positive.", + "associated": false + }, + { + "kind": "fn", + "name": "floor_angle", + "signature": "pub fn floor_angle(beam_width: f64, floor_db: f64) -> Option {", + "doc": "Returns the angle at which the squared angle law of\n[`off_axis_attenuation`](crate::off_axis_attenuation) reaches the\nsidelobe floor.\n\nBeyond this angle the model holds the gain at the floor rather than\nletting it fall without bound. The angle follows from setting the\nattenuation `12 (theta / theta_0)^2` equal to the floor, which gives\n`theta = theta_0 sqrt(floor / 12)`.\n\nReturns `None` when the beam width or floor is not positive and finite.", + "associated": false + }, + { + "kind": "fn", + "name": "off_axis_attenuation", + "signature": "pub fn off_axis_attenuation( peak: Gain, beam_width: f64, off_axis_angle: f64, floor_db: f64, ) -> Option {", + "doc": "Returns the gain of an antenna at an angle from boresight, given a peak\ngain and a half power beam width.\n\nThis is the squared angle model that several interference assessments\nuse, expressed in terms of the half power beam width so that any antenna\ncan be described by the two numbers a link budget has to hand. In\ndecibels,\n\n```text\nG(theta) = G_peak - min(12 (theta / theta_0)^2, G_floor)\n```\n\nwhere `theta_0` is the half power beam width and `G_floor` is the\nsidelobe level to fall to, in decibels below the peak. The squared angle\nlaw comes from the first term of the Airy pattern of a uniformly\nilluminated aperture, which is a good approximation down to the first\nsidelobe.\n\nThe coefficient twelve is chosen so that the attenuation reaches three\ndecibels at `theta_0 / 2`, which is the definition of the half power beam\nwidth. A pattern taken from a recommendation should be used in place of\nthis one where the recommendation applies: see\n[`reference_pattern_gain`](crate::reference_pattern_gain) for the ITU-R\nS.465 pattern, which differs from this model in the main lobe and also in\nthe shape of its sidelobe region.\n\nReturns `None` when the peak gain has no decibel form, or when the beam\nwidth or floor is not positive and finite.", + "associated": false + }, + { + "kind": "fn", + "name": "mask_gain", + "signature": "pub fn mask_gain(diameter_over_wavelength: f64, off_axis_angle: f64) -> Option {", + "doc": "Returns the gain that an antenna is required not to exceed at an angle\nfrom boresight, in decibels relative to an isotropic radiator.\n\nThis is the reference pattern of the Radio Regulations Appendix 8, used\nto check whether an Earth station meets the off-axis emission limits that\nprotect neighbouring satellites. The pattern is piecewise:\n\n```text\nfor D/lambda >= 100:\n G = 29 - 25 log10(theta) for 1 <= theta < 36 degrees\n G = -10 for 36 <= theta <= 180 degrees\nfor D/lambda < 100:\n G = 32 - 25 log10(theta) for 1 <= theta < 48 degrees\n G = -10 for 48 <= theta <= 180 degrees\n G = 32 - 25 log10(48) for theta < 1 degree\n```\n\nThe angle is in radians and the diameter to wavelength ratio decides\nwhich branch applies, because a small antenna has a broader main lobe\nand therefore a wider range over which the pattern must be controlled.\n\nThis function returns the limit, that is the largest gain permitted. An\nantenna is compliant when its actual off-axis gain is at or below it.\n\nReturns `None` when the angle is negative, when the ratio is not\npositive, or when the angle is not finite.\n\nSource: ITU, *Radio Regulations*, Appendix 8, which reproduces the\nreference pattern of ITU-R Recommendation S.465.", + "associated": false + }, + { + "kind": "enum", + "name": "Pattern", + "signature": "pub enum Pattern {", + "doc": "The shape of an antenna pattern.\n\nA pattern is either described by its geometry, in which case the peak\ngain and beam width are derived from the aperture, or given directly as\nmeasured or published values. The enum lets a link budget accept either.", + "associated": false + }, + { + "kind": "fn", + "name": "beam_width", + "signature": "pub fn beam_width(self, wavelength: f64) -> Option {", + "doc": "Returns the half power beam width of this pattern at the given\nwavelength, in radians.", + "associated": true + }, + { + "kind": "struct", + "name": "ElementPattern", + "signature": "pub struct ElementPattern {", + "doc": "A gain pattern constructed from a peak gain and a half power beam width.\n\nThis is the form a link budget needs: the two numbers that describe the\nbeam, plus the model that gives the gain at any angle from boresight.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(peak: Gain, beam_width: f64) -> Self {", + "doc": "Creates a pattern from a peak gain and a half power beam width.\n\nThe sidelobe floor is set to the value of the Radio Regulations\nreference pattern, 32 decibels below the peak, which is a common\nworst case for a small Earth station.", + "associated": true + }, + { + "kind": "fn", + "name": "from_parabolic", + "signature": "pub fn from_parabolic(aperture: ParabolicAperture, wavelength: f64) -> Option {", + "doc": "Creates a pattern from a parabolic aperture at a wavelength.\n\nReturns `None` when the aperture or wavelength is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "with_floor", + "signature": "pub const fn with_floor(mut self, floor_db: f64) -> Self {", + "doc": "Returns this pattern with a different sidelobe floor.", + "associated": true + }, + { + "kind": "fn", + "name": "gain_at", + "signature": "pub fn gain_at(self, off_axis_angle: f64) -> Option {", + "doc": "Returns the gain at an angle from boresight.\n\nReturns `None` when the pattern is not usable or the angle is not\nfinite.", + "associated": true + }, + { + "kind": "const", + "name": "PEAK_GAIN_OFFSET_DB", + "signature": "pub const PEAK_GAIN_OFFSET_DB: f64 = 7.7;", + "doc": "The peak gain offset of the reference pattern, in decibels.\n\nThe recommendation gives the peak gain as `20 log10(D / lambda) + 7.7`,\nwhich corresponds to an aperture efficiency near 0.6. It is slightly\nbelow what a well adjusted dish reaches, because the reference pattern\nrepresents a typical rather than an optimal antenna.", + "associated": false + }, + { + "kind": "fn", + "name": "reference_peak_gain", + "signature": "pub fn reference_peak_gain(diameter_over_wavelength: f64) -> Option {", + "doc": "Returns the peak gain of the reference pattern, in decibels relative to an\nisotropic radiator.\n\nThe expression is `20 log10(D / lambda) + 7.7`.\n\nReturns `None` when the ratio is not positive and finite.", + "associated": false + }, + { + "kind": "fn", + "name": "main_lobe_break_angle", + "signature": "pub fn main_lobe_break_angle(diameter_over_wavelength: f64) -> Option {", + "doc": "Returns the angle at which the main lobe meets the sidelobe region, in\ndegrees.\n\nThe two expressions intersect at this angle, and there is no closed form,\nso the value is found by bisection. The main lobe falls as the square of\nthe angle while the sidelobe region falls as its logarithm, so the\ndifference has a single zero in the interval searched, which makes\nbisection reliable.\n\nThe result lies between zero and the floor angle of the sidelobe region.", + "associated": false + }, + { + "kind": "fn", + "name": "reference_pattern_gain", + "signature": "pub fn reference_pattern_gain(diameter_over_wavelength: f64, off_axis_angle: f64) -> Option {", + "doc": "Returns the gain of the S.465 reference pattern at an off-axis angle.\n\nThe angle is in radians. The three regions are\n\n```text\nG = G_max - 2.5e-3 (D/lambda phi_deg)^2 for 0 <= phi < phi_break\nG = head - 25 log10(phi_deg) for phi_break <= phi < phi_floor\nG = -10 for phi_floor <= phi <= 180\n```\n\nwhere `phi_break` is the angle returned by [`main_lobe_break_angle`], the\nhead is 29 for a large aperture and 32 for a small one, and the floor\nangle is 36 or 48 degrees accordingly.\n\nThe falling sidelobe expression does not reach exactly minus ten decibels\nat the floor angle, so the pattern has a step of a few hundredths of a\ndecibel there. This is a property of the recommendation, which defines\nthe regions separately, and it is reproduced here rather than smoothed\nover.\n\nReturns `None` when the ratio or the angle is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "reference_pattern_decibel", + "signature": "pub fn reference_pattern_decibel( diameter_over_wavelength: f64, off_axis_angle: f64, ) -> Option {", + "doc": "Returns the gain of the S.465 reference pattern as a decibel value.\n\nReturns `None` when the ratio or the angle is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "diameter_over_wavelength_from_peak_gain", + "signature": "pub fn diameter_over_wavelength_from_peak_gain(peak_gain_dbi: f64) -> Option {", + "doc": "Returns the diameter to wavelength ratio of an antenna with a given peak\ngain.\n\nThis inverts the peak gain expression, which is useful when a station is\nspecified by its gain rather than its size.\n\nReturns `None` when the gain is not finite.", + "associated": false + }, + { + "kind": "fn", + "name": "reference_beam_width", + "signature": "pub fn reference_beam_width(diameter_over_wavelength: f64) -> Option {", + "doc": "Returns the half power beam width of the reference pattern main lobe, in\nradians.\n\nThe main lobe falls to three decibels at\n`phi = sqrt(3 / 2.5e-3) / (D / lambda)` degrees from boresight, so the\nfull width between the two three decibel points is twice that value.\n\nReturns `None` when the ratio is not positive and finite.", + "associated": false + } + ] + }, + { + "name": "apsis-doppler", + "layer": "rf", + "summary": "Doppler shift computation. A satellite and a ground station move relative to each other, so the frequency that arrives at the receiver differs from the frequency that left the transmitter. This crate computes that shift from the relative velocity. Two effects are present and they are separate. The classical shift is proportional to the range rate divided by the speed of light, and it is what a Doppler tracker compensates. The relativistic time dilation between the two clocks is a second order correction that is far smaller for orbital velocities, and it is offered separately so that a caller can include it when the application needs it. The sign convention used here is that a positive range rate, meaning the two terminals are moving apart, produces a negative shift and therefore a lower received frequency. This matches the everyday observation that a receding source sounds lower. ``` use apsis_doppler::{DopplerShift, range_rate_from_velocity}; use apsis_math::Vec3; // A satellite at the first axis moving away along it. let range_rate = range_rate_from_velocity( Vec3::new(7_000_000.0, 0.0, 0.0), Vec3::new(1_000.0, 0.0, 0.0), ); assert_eq!(range_rate, Some(1_000.0)); let shift = DopplerShift::from_range_rate(12.0e9, 1_000.0).expect(\"valid\"); assert!(shift.shifted_frequency() < 12.0e9); ```", + "items": [ + { + "kind": "fn", + "name": "range_rate_from_velocity", + "signature": "pub fn range_rate_from_velocity(relative_position: Vec3, relative_velocity: Vec3) -> Option {", + "doc": "Returns the range rate between two terminals, in meters per second.\n\nThe range rate is the component of the relative velocity along the line\nbetween the terminals. A positive value means the separation is\nincreasing.\n\nThe first argument is the position of the remote terminal relative to the\nlocal one, so that its magnitude is the separation. The second is the\nvelocity of the remote terminal relative to the local one. Both are in\nSI units.\n\nReturns `None` when the separation is zero, because no line of sight is\ndefined there, or when either argument is not finite.", + "associated": false + }, + { + "kind": "fn", + "name": "classical_shift", + "signature": "pub fn classical_shift(carrier_frequency: f64, range_rate: f64) -> Option {", + "doc": "Returns the classical Doppler shift of a frequency for a range rate, in\nhertz.\n\nThe relation is\n\n```text\ndelta f = -f_0 (v_r / c)\n```\n\nwhere `v_r` is the range rate, positive when receding. The shift is\nnegative for a receding terminal, so the received frequency is lower than\nthe transmitted one.\n\nThe magnitude of the shift is small compared to the carrier: a range rate\nof 7 km/s at 12 GHz gives about 280 kHz, which is two parts in a hundred\nthousand. The caller must therefore treat the frequency as a double, and\nthe shifted frequency is returned separately rather than by adding a\nsmall shift to a large number.\n\nReturns `None` when the frequency is not positive and finite, or when the\nrange rate is not finite.", + "associated": false + }, + { + "kind": "fn", + "name": "relativistic_doppler_factor", + "signature": "pub fn relativistic_doppler_factor(range_rate: f64) -> Option {", + "doc": "Returns the relativistic Doppler factor for a range rate.\n\nThe factor is `sqrt((1 - beta) / (1 + beta))` with `beta = v_r / c`.\nMultiplying the transmitted frequency by it gives the received frequency\nto all orders in `beta`, so it is not a small correction to the classical\nshift but the quantity the classical shift approximates.\n\nIts expansion is `1 - beta + beta^2 / 2 - ...`. The first two terms are\nthe classical result, which is `1 - beta` to within a factor that is\nitself second order, and the remaining terms are the difference between\nthe two treatments. For a satellite at orbital velocity, `beta` is about\n`2.3e-5` and the second order term is about `2.7e-10` of the carrier, so\nthe two agree to a few parts in ten to the tenth.\n\nReturns `None` when the range rate is not finite, or when its magnitude\nreaches the speed of light, where the expression has no real value.", + "associated": false + }, + { + "kind": "struct", + "name": "DopplerShift", + "signature": "pub struct DopplerShift {", + "doc": "A Doppler shift applied to a carrier.\n\nThe type carries the carrier frequency and the range rate, so the\ntransmitted and received frequencies and the shift itself are all\navailable without ambiguity about which sign convention was used.", + "associated": false + }, + { + "kind": "fn", + "name": "from_range_rate", + "signature": "pub fn from_range_rate(carrier_frequency: f64, range_rate: f64) -> Option {", + "doc": "Creates a shift from a carrier frequency and a range rate.\n\nReturns `None` when the frequency is not positive and finite, or when\nthe range rate is not finite.", + "associated": true + }, + { + "kind": "fn", + "name": "carrier_frequency", + "signature": "pub const fn carrier_frequency(self) -> f64 {", + "doc": "Returns the transmitted frequency, in hertz.", + "associated": true + }, + { + "kind": "fn", + "name": "range_rate", + "signature": "pub const fn range_rate(self) -> f64 {", + "doc": "Returns the range rate, in meters per second, positive when receding.", + "associated": true + }, + { + "kind": "fn", + "name": "shift", + "signature": "pub fn shift(self) -> f64 {", + "doc": "Returns the shift, in hertz.\n\nThe value is negative when the terminals are receding.", + "associated": true + }, + { + "kind": "fn", + "name": "shifted_frequency", + "signature": "pub fn shifted_frequency(self) -> f64 {", + "doc": "Returns the received frequency, in hertz.", + "associated": true + }, + { + "kind": "fn", + "name": "shift_including_relativity", + "signature": "pub fn shift_including_relativity(self) -> Option {", + "doc": "Returns the shift including the relativistic clock correction, in\nhertz.\n\nThe received frequency is the carrier multiplied by the relativistic\nfactor, so the shift is `f_0 (factor - 1)`.\n\nThis is not the classical shift plus a small correction: the factor\nalready contains the first order term, and its expansion is\n`1 - beta + beta^2 / 2 - ...`, whose leading term is exactly the\nclassical shift. The classical value is the first order\napproximation to this one, and the two differ only by the second and\nhigher order terms, of order `beta^2`, which for a satellite is a few\nparts in ten to the tenth of the carrier.\n\nThe factor is evaluated first and the carrier multiplied afterwards,\nbecause the difference from one is tiny and would lose precision if\nthe shift were added to the carrier before the correction.\n\nReturns `None` when the range rate reaches the speed of light.", + "associated": true + }, + { + "kind": "fn", + "name": "shift_in_ppm", + "signature": "pub fn shift_in_ppm(self) -> f64 {", + "doc": "Returns the shift in parts per million of the carrier.\n\nThis is a convenient way to compare the shift of links on different\ncarriers.", + "associated": true + }, + { + "kind": "fn", + "name": "shift_rate", + "signature": "pub fn shift_rate(self, range_acceleration: f64) -> Option {", + "doc": "Returns the rate of change of the shift, in hertz per second.\n\nThis is what a Doppler tracker must follow, and it follows from the\nrange acceleration.\n\nReturns `None` when the acceleration is not finite.", + "associated": true + }, + { + "kind": "fn", + "name": "is_receding", + "signature": "pub fn is_receding(self) -> bool {", + "doc": "Returns `true` when the shift is a redshift, meaning the terminals\nare receding and the received frequency is lower.", + "associated": true + }, + { + "kind": "fn", + "name": "angle_from_range_rate", + "signature": "pub fn angle_from_range_rate(velocity: Vec3, range_rate: f64) -> Option {", + "doc": "Returns the angle between the line of sight and the velocity, in radians.\n\nThis is the complement of the usual elevation angle within the plane\ncontaining both, and it is useful when the range rate is known and the\nvelocity from the orbit is known: the angle follows from the ratio of the\ntwo.\n\nThe result lies in `[0, pi]`.\n\nReturns `None` when the velocity has zero length or either argument is\nnot finite.", + "associated": false + }, + { + "kind": "fn", + "name": "maximum_shift", + "signature": "pub fn maximum_shift(carrier_frequency: f64, speed: f64) -> Option {", + "doc": "Returns the largest possible shift magnitude for a carrier and a speed,\nin hertz.\n\nThis is the shift when the motion is entirely along the line of sight,\nwhich is the width of the acquisition range a receiver must cover. It is\nhalf the total span between the approach and the recession extremes.\n\nThe result is returned as a positive magnitude. Returns `None` when the\ncarrier or speed is not positive and finite.", + "associated": false + }, + { + "kind": "fn", + "name": "acquisition_span", + "signature": "pub fn acquisition_span(carrier_frequency: f64, speed: f64) -> Option {", + "doc": "Returns the total span of frequencies a receiver must accept to cover\nevery range rate up to the given speed.\n\nApproach and recession give shifts of opposite sign, so the span is twice\nthe maximum shift.\n\nReturns `None` when the carrier or speed is not usable.", + "associated": false + } + ] + }, + { + "name": "apsis-atmosphere", + "layer": "rf", + "summary": "Atmospheric attenuation and refraction models. A satellite signal passing through the atmosphere is weakened by two mechanisms that are treated separately because they behave differently with frequency, elevation, and weather. Gaseous absorption comes from the absorption lines of oxygen and water vapour. Oxygen has a dense group of lines between 50 and 70 GHz and an isolated line at 118.75 GHz, and water vapour has a strong line at 22.235 GHz and a much stronger one at 183.31 GHz. The attenuation is the line-by-line sum over those lines, which makes it negligible below about 10 GHz and severe in the absorption bands. Rain attenuation is caused by scattering and absorption in precipitation. It is negligible below about 4 GHz, grows as a power of the rain rate, and depends strongly on elevation and polarisation. Both are functions of the elevation angle through the same path geometry: a signal at a low elevation crosses more atmosphere. Sources: - Gaseous specific attenuation and equivalent heights: ITU-R Recommendation P.676-12, *Attenuation by atmospheric gases*. - Rain specific attenuation coefficients: ITU-R Recommendation P.838-3, *Specific attenuation model for rain for use in prediction methods*. - Rain path and elevation dependence: ITU-R Recommendation P.618-13, *Propagation data and prediction methods required for the design of Earth-space telecommunication systems*. ``` use apsis_atmosphere::{Atmosphere, slant_path_attenuation, zenith_attenuation}; // At the reference atmosphere the 60 GHz oxygen complex is heavily // attenuated while a Ku band link is barely affected. let ku = zenith_attenuation(12.0, Atmosphere::REFERENCE).expect(\"valid\"); let oxygen = zenith_attenuation(60.0, Atmosphere::REFERENCE).expect(\"valid\"); assert!(ku < 0.1); assert!(oxygen > 100.0); // A lower elevation crosses more atmosphere. let zenith = zenith_attenuation(12.0, Atmosphere::REFERENCE).expect(\"valid\"); let low = slant_path_attenuation(12.0, 0.2, Atmosphere::REFERENCE).expect(\"valid\"); assert!(low > zenith); ```", + "items": [ + { + "kind": "struct", + "name": "Atmosphere", + "signature": "pub struct Atmosphere {", + "doc": "The atmospheric state that the gaseous attenuation depends on.\n\nThe pressure is the dry air pressure, as the recommendation specifies. The\nwater vapour density is separate because the partial pressure of water\nvapour is derived from it.", + "associated": false + }, + { + "kind": "const", + "name": "REFERENCE", + "signature": "pub const REFERENCE: Self = Self {", + "doc": "The ITU-R reference atmosphere at mean sea level.\n\nThe values are the ones the recommendation uses for its reference\ncurves: a dry air pressure of 1013.25 hPa, a water vapour density of\n7.5 g/m^3, and a temperature of 288.15 K.", + "associated": true + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(pressure_hpa: f64, water_vapour_density: f64, temperature_k: f64) -> Self {", + "doc": "Creates an atmosphere from its three parameters.", + "associated": true + }, + { + "kind": "fn", + "name": "water_vapour_partial_pressure", + "signature": "pub fn water_vapour_partial_pressure(self) -> f64 {", + "doc": "Returns the partial pressure of water vapour, in hectopascals.\n\nThe relation is `e = rho T / 216.7`, which follows from the ideal gas\nlaw with the molar masses of water and dry air.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub fn is_valid(self) -> bool {", + "doc": "Returns `true` when the state describes a physical atmosphere.", + "associated": true + }, + { + "kind": "fn", + "name": "specific_attenuation_dry_air", + "signature": "pub fn specific_attenuation_dry_air(frequency_ghz: f64, atmosphere: Atmosphere) -> Option {", + "doc": "Returns the specific attenuation of dry air, in decibels per kilometer.\n\nThis is the oxygen contribution, computed by the line-by-line sum of\nequation 1 of ITU-R P.676-12, plus the non-resonant Debye term that\naccounts for the far wings of all the lines together.\n\nThe frequency is in gigahertz.\n\nReturns `None` when the frequency or atmosphere is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "specific_attenuation_water_vapour", + "signature": "pub fn specific_attenuation_water_vapour( frequency_ghz: f64, atmosphere: Atmosphere, ) -> Option {", + "doc": "Returns the specific attenuation of water vapour, in decibels per\nkilometer.\n\nThis is the water vapour contribution, computed by the line-by-line sum of\nequation 1 of ITU-R P.676-12.\n\nThe frequency is in gigahertz.\n\nReturns `None` when the frequency or atmosphere is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "specific_attenuation", + "signature": "pub fn specific_attenuation(frequency_ghz: f64, atmosphere: Atmosphere) -> Option {", + "doc": "Returns the total specific attenuation of the atmosphere, in decibels per\nkilometer.\n\nThis is the sum of the dry air and water vapour contributions.\n\nReturns `None` when the frequency or atmosphere is not usable.", + "associated": false + }, + { + "kind": "struct", + "name": "EquivalentHeights", + "signature": "pub struct EquivalentHeights {", + "doc": "The equivalent height of dry air and of water vapour, in kilometers.", + "associated": false + }, + { + "kind": "fn", + "name": "equivalent_heights", + "signature": "pub fn equivalent_heights(frequency_ghz: f64, atmosphere: Atmosphere) -> Option {", + "doc": "Returns the equivalent heights of dry air and water vapour.\n\nThe heights are the lengths that, multiplied by the surface specific\nattenuation, give the zenith attenuation. The dry air height is limited\nto a pressure dependent bound below 70 GHz, where the oxygen complex is\nnarrow enough that the height would otherwise be overestimated.\n\nThe frequency is in gigahertz.\n\nReturns `None` when the frequency or atmosphere is not usable.\n\nSource: ITU-R Recommendation P.676-12, equations 30 through 38.", + "associated": false + }, + { + "kind": "fn", + "name": "zenith_attenuation", + "signature": "pub fn zenith_attenuation(frequency_ghz: f64, atmosphere: Atmosphere) -> Option {", + "doc": "Returns the zenith attenuation of the atmosphere, in decibels.\n\nThis is the specific attenuation at the surface multiplied by the\nequivalent height, summed over the two constituents.\n\nThe frequency is in gigahertz.\n\nReturns `None` when the frequency or atmosphere is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "slant_path_attenuation", + "signature": "pub fn slant_path_attenuation( frequency_ghz: f64, elevation: f64, atmosphere: Atmosphere, ) -> Option {", + "doc": "Returns the attenuation along a slant path at an elevation angle, in\ndecibels.\n\nThe zenith attenuation is divided by the sine of the elevation. The\nplane parallel approximation this uses is the one the recommendation\napplies for the approximate method; it is accurate above about five\ndegrees of elevation.\n\nThe elevation is in radians.\n\nReturns `None` when the frequency or atmosphere is not usable, or when\nthe elevation is at or below the horizon.", + "associated": false + }, + { + "kind": "fn", + "name": "slant_path_attenuation_exact", + "signature": "pub fn slant_path_attenuation_exact( frequency_ghz: f64, elevation: f64, atmosphere: Atmosphere, ) -> Option {", + "doc": "Returns the attenuation along a slant path by the exact method of\nRecommendation P.676.\n\nThe exact method divides the atmosphere into a stack of layers whose\nthickness grows exponentially with height, evaluates the specific\nattenuation in each, and sums the contribution of each layer weighted by\nthe ray path length through it. Because the ray is refracted, the path\nlength is found by ray tracing with Snell's law applied to the ratio of\nthe refractive indices of adjacent layers, which the approximate method\nignores.\n\nThis is the form to use at low elevation, where the plane parallel\napproximation that [`slant_path_attenuation`] uses is inaccurate. It is\nfar more expensive, so the approximate form remains available for the\nhigh elevation case where it is adequate.\n\nThe frequency is in gigahertz, the elevation in radians, and the surface\natmosphere supplies the pressure, the water vapour density, and the\ntemperature at ground level. The surface water vapour density scales the\nexponential vapour profile, so the atmosphere defines the whole stack.\n\nReturns `None` when the frequency, elevation, or atmosphere is not\nusable.\n\nSource: ITU-R Recommendation P.676-12, equations 13 through 19, with the\nlayered atmosphere of ITU-R Recommendation P.835-6.", + "associated": false + }, + { + "kind": "fn", + "name": "elevation_from_zenith", + "signature": "pub fn elevation_from_zenith(zenith_angle: f64) -> f64 {", + "doc": "Returns the elevation angle for a zenith angle.\n\nThe elevation is measured from the horizon and the zenith angle from the\nvertical, so the two add to a right angle.", + "associated": false + }, + { + "kind": "fn", + "name": "zenith_to_elevation", + "signature": "pub fn zenith_to_elevation(elevation: f64) -> f64 {", + "doc": "Returns the zenith angle for an elevation angle.", + "associated": false + }, + { + "kind": "fn", + "name": "air_mass", + "signature": "pub fn air_mass(elevation: f64) -> f64 {", + "doc": "Returns the air mass along a slant path at the given elevation angle.\n\nThe air mass is the ratio of the path length through a flat atmosphere to\nthe vertical path length. For a plane parallel atmosphere it is the\ncosecant of the elevation,\n\n```text\nm = 1 / sin(elevation)\n```\n\nso the value is one at the zenith and grows without bound at the horizon.\nThe plane parallel assumption fails below about ten degrees elevation,\nwhere the curvature of the Earth shortens the path compared with this\nexpression and the true air mass is smaller.\n\nThe value is not clamped at low elevation. A caller that needs a finite\nbound near the horizon should impose one; this function reports the plane\nparallel geometry it models.\n\nReturns infinity at or below the horizon, where no direct path exists.", + "associated": false + }, + { + "kind": "fn", + "name": "geopotential_height", + "signature": "pub fn geopotential_height(geometric_height_km: f64) -> f64 {", + "doc": "Returns the geopotential height for a geometric height, in kilometers.\n\nThe relation is\n\n```text\nh_p = R H / (R + H)\n```\n\nwith `R` the fixed Earth radius of the standard atmosphere and `H` the\ngeometric height. The geopotential height is always the smaller of the\ntwo.", + "associated": false + }, + { + "kind": "fn", + "name": "standard_temperature", + "signature": "pub fn standard_temperature(geometric_height_km: f64) -> f64 {", + "doc": "Returns the temperature of the reference atmosphere, in kelvin.\n\nThe profile is piecewise linear in geopotential height up to 84.852 km\nand is then defined by two further segments that extend to 100 km. Above\n100 km the recommendation does not define a global profile and this\nfunction returns the value at 100 km.\n\nThe geometric height is in kilometers.", + "associated": false + }, + { + "kind": "fn", + "name": "standard_pressure", + "signature": "pub fn standard_pressure(geometric_height_km: f64) -> f64 {", + "doc": "Returns the pressure of the reference atmosphere, in hectopascals.\n\nThe profile is piecewise, continuous at the segment boundaries, and\nfollows from the hydrostatic relation applied to each temperature\nsegment.\n\nThe geometric height is in kilometers.", + "associated": false + }, + { + "kind": "fn", + "name": "standard_water_vapour_density", + "signature": "pub fn standard_water_vapour_density(geometric_height_km: f64, surface_density: f64) -> f64 {", + "doc": "Returns the water vapour density of the reference atmosphere, in grams\nper cubic meter.\n\nThe profile is exponential with a scale height of two kilometers, scaled\nso that the density at the surface is the value given.\n\nThe geometric height is in kilometers.", + "associated": false + }, + { + "kind": "fn", + "name": "radio_refractive_index", + "signature": "pub fn radio_refractive_index( dry_pressure_hpa: f64, vapour_pressure_hpa: f64, temperature_k: f64, ) -> f64 {", + "doc": "Returns the radio refractive index of moist air, dimensionless.\n\nThe relation is\n\n```text\nN = 77.6 P_d / T + 72 e / T + 3.75e5 e / T^2\nn = 1 + N 1e-6\n```\n\nwith `P_d` the dry air pressure and `e` the water vapour partial\npressure, both in hectopascals, and `T` the temperature in kelvin. The\nfirst two terms are the non-dispersive refractivity and the third is the\ndipole term that gives water vapour its strong effect.\n\nSource: ITU-R Recommendation P.453, which gives this expression for the\nrefractive index of moist air.", + "associated": false + }, + { + "kind": "const", + "name": "RAIN_HEIGHT_OFFSET_KM", + "signature": "pub const RAIN_HEIGHT_OFFSET_KM: f64 = 0.36;", + "doc": "The offset added to the isotherm height to obtain the rain height, in\nkilometers.\n\nThe recommendation states that the rain height is the isotherm height\nplus 0.36 km, which represents the depth of the melting layer below the\nisotherm.", + "associated": false + }, + { + "kind": "fn", + "name": "zero_isotherm_height", + "signature": "pub fn zero_isotherm_height(latitude: f64, longitude: f64) -> Option {", + "doc": "Returns the height of the zero degree isotherm at a location, in\nkilometers above mean sea level.\n\nThe latitude is in degrees, positive north, and the longitude in degrees,\npositive east. The value is interpolated bilinearly from the tabulated\ngrid, and longitudes are reduced into `[0, 360)` first so that any\nconvention is accepted.\n\nReturns `None` when either coordinate is not finite, or when the latitude\nis outside the range of the grid.\n\nSource: ITU-R Recommendation P.839-4, equation 1 and the tabulated\nisotherm height.", + "associated": false + }, + { + "kind": "fn", + "name": "rain_height", + "signature": "pub fn rain_height(latitude: f64, longitude: f64) -> Option {", + "doc": "Returns the rain height at a location, in kilometers above mean sea level.\n\nThis is the zero degree isotherm height plus\n[`RAIN_HEIGHT_OFFSET_KM`], as the recommendation defines it.\n\nReturns `None` when the coordinates are not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "grid_step_degrees", + "signature": "pub const fn grid_step_degrees() -> f64 {", + "doc": "Returns the grid resolution of the tabulated isotherm height, in degrees.", + "associated": false + }, + { + "kind": "const", + "name": "DEFAULT_RAIN_HEIGHT_KM", + "signature": "pub const DEFAULT_RAIN_HEIGHT_KM: f64 = 3.0;", + "doc": "A mid latitude rain height, in kilometers above sea level.\n\nRecommendation P.618 takes the rain height from the zero degree isotherm\nheight of Recommendation P.839, which is tabulated as a function of\nlatitude and longitude. Prefer [`rain_attenuation_at`], which looks the\nvalue up, over [`rain_attenuation`] with this constant.", + "associated": false + }, + { + "kind": "struct", + "name": "RainAttenuation", + "signature": "pub struct RainAttenuation {", + "doc": "A rain attenuation result at a given elevation.", + "associated": false + }, + { + "kind": "fn", + "name": "attenuation", + "signature": "pub fn attenuation(self) -> Decibel {", + "doc": "Returns the total attenuation.", + "associated": true + }, + { + "kind": "fn", + "name": "rain_coefficients", + "signature": "pub fn rain_coefficients(frequency: f64, polarisation: f64, elevation: f64) -> Option<(f64, f64)> {", + "doc": "Returns the P.838-3 coefficients of the specific attenuation power law.\n\nThe polarisation is an angle in radians between the electric field and\nthe horizontal, so zero is horizontal and a right angle is vertical. The\nelevation is in radians.\n\nThe result is a pair of the coefficient `k` and the exponent `alpha` of\nthe relation `gamma = k R^alpha`, with the rain rate in millimeters per\nhour and the result in decibels per kilometer.\n\nReturns `None` when the frequency, polarisation, or elevation is not\nusable. The recommendation is stated for frequencies from 1 GHz to\n1000 GHz.\n\nSource: ITU-R Recommendation P.838-3, equations 2 and 3.", + "associated": false + }, + { + "kind": "fn", + "name": "specific_rain_attenuation", + "signature": "pub fn specific_rain_attenuation( rain_rate: f64, frequency: f64, polarisation: f64, elevation: f64, ) -> Option {", + "doc": "Returns the specific attenuation in rain, in decibels per kilometer.\n\nThe rain rate is in millimeters per hour.\n\nReturns `None` when the rain rate is negative, or when the coefficients\nare not available.", + "associated": false + }, + { + "kind": "fn", + "name": "rain_attenuation", + "signature": "pub fn rain_attenuation( rain_rate: f64, frequency: f64, polarisation: f64, elevation: f64, station_height_km: f64, rain_height_km: f64, latitude: f64, ) -> Option {", + "doc": "Returns the total rain attenuation along a slant path.\n\nThe rain rate is the value exceeded for the percentage of time of\ninterest, in millimeters per hour. The station height above sea level and\nthe rain layer height are in kilometers; pass [`DEFAULT_RAIN_HEIGHT_KM`]\nfor the latter when no local value is available.\n\nThe method is the one of Recommendation P.618. The slant path length\nfollows from the rain height, the station height, and the elevation, with\na correction for the curvature of the Earth. Two reduction factors then\naccount for the non-uniformity of the rain: a horizontal factor that\ndepends on the specific attenuation, the frequency, and the horizontal\nprojection of the path, and a vertical factor that depends on the\nlatitude through the angle at which the path crosses the rain layer.\n\nReturns `None` when the rain rate, frequency, or elevation is not usable,\nwhen the station is above the rain layer, or when the elevation is at or\nbelow the horizon.\n\nSource: ITU-R Recommendation P.618-13, equations 1 through 10.", + "associated": false + }, + { + "kind": "fn", + "name": "polarisation_tilt", + "signature": "pub const fn polarisation_tilt(degrees_from_horizontal: f64) -> f64 {", + "doc": "Returns the angle in radians for a polarisation described by a tilt from\nhorizontal.\n\nThis is a convenience for the two cases that are quoted by name.", + "associated": false + }, + { + "kind": "fn", + "name": "rain_attenuation_at", + "signature": "pub fn rain_attenuation_at( rain_rate: f64, frequency: f64, polarisation: f64, elevation: f64, station_height_km: f64, latitude: f64, longitude: f64, ) -> Option {", + "doc": "Returns the total rain attenuation along a slant path, taking the rain\nheight from the Recommendation P.839 model.\n\nThis is the same calculation as [`rain_attenuation`], with the rain layer\nheight looked up from the tabulated zero degree isotherm height at the\ngiven latitude and longitude rather than supplied by the caller. It is\nthe form to use unless a locally measured isotherm height is available.\n\nThe station height must be given; pass zero for a station at sea level.\n\nReturns `None` when the coordinates are not usable, or when any of the\nother arguments is rejected by [`rain_attenuation`].\n\nSource: the rain height is from ITU-R Recommendation P.839-4 and the path\nmethod from ITU-R Recommendation P.618-13.", + "associated": false + } + ] + }, + { + "name": "apsis-noise", + "layer": "rf", + "summary": "Noise temperature and noise figure models. Noise enters a receiver from three places: the source, the antenna and its feed, and the receiver itself. They are combined by adding noise temperatures rather than powers, because a temperature is a power referred to a fixed bandwidth through the Boltzmann relation, and a cascade of stages at different gains does not simply add powers. The central relations are: - Thermal noise power in a bandwidth, `P = k T B`, with `k` the Boltzmann constant. - Noise figure as a temperature, `T = T_0 (F - 1)`, with `T_0` the reference temperature of 290 K. - The Friis cascade, which weights each stage's noise by the gain that precedes it. Sources: - The reference temperature of 290 K and the noise figure definition are from the IEEE standard definition of noise figure, IEEE Std 161. - The cascade is the Friis formula for noise, as given in any receiver design text. ``` use apsis_noise::{NoiseTemperature, thermal_noise_power}; // A 36 MHz bandwidth at 290 K carries about -128 dBW of thermal noise. let temperature = NoiseTemperature::from_kelvin(290.0); let power = thermal_noise_power(temperature, 36.0e6).expect(\"valid arguments\"); assert!((power.dbw().expect(\"positive\") + 128.0).abs() < 1.0); ```", + "items": [ + { + "kind": "struct", + "name": "Stage", + "signature": "pub struct Stage {", + "doc": "One stage of a receiver chain.\n\nA stage is described by the noise it adds, referred to its own input, and\nthe gain it applies. The noise may be given as a temperature or as a\nnoise figure; both are converted to a temperature before the cascade is\nevaluated, because temperatures add across the chain while decibel\nfigures do not.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(temperature: NoiseTemperature, gain: Gain) -> Self {", + "doc": "Creates a stage from a noise temperature and a gain.", + "associated": true + }, + { + "kind": "fn", + "name": "from_noise_figure", + "signature": "pub fn from_noise_figure(noise_figure_db: f64, gain_dbi: f64) -> Option {", + "doc": "Creates a stage from a noise figure in decibels and a gain in\ndecibels.\n\nReturns `None` when either value is not finite.", + "associated": true + }, + { + "kind": "fn", + "name": "ideal", + "signature": "pub const fn ideal(gain: Gain) -> Self {", + "doc": "Creates a noiseless stage with the given gain.\n\nAn ideal amplifier adds no noise, so it contributes nothing to the\ncascade except its gain.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub fn is_valid(self) -> bool {", + "doc": "Returns `true` when the stage is usable.", + "associated": true + }, + { + "kind": "fn", + "name": "cascade_temperature", + "signature": "pub fn cascade_temperature(stages: &[Stage]) -> Option {", + "doc": "Returns the noise temperature of a cascade of stages, referred to the\ninput of the first stage.\n\nThe Friis formula weights each stage's contribution by the gain that\nprecedes it:\n\n```text\nT = T_1 + T_2 / G_1 + T_3 / (G_1 G_2) + ...\n```\n\nThe consequence is that the first stage dominates: a noisy second stage\nmatters much less when the first stage has gain. The total temperature is\nthat of the chain, so it can be compared directly with the available\ngain to find the noise figure of the chain.\n\nReturns `None` when any stage is not usable, which includes a gain ratio\nthat is not positive.", + "associated": false + }, + { + "kind": "fn", + "name": "noise_figure_to_temperature", + "signature": "pub fn noise_figure_to_temperature(noise_figure_db: f64) -> Option {", + "doc": "Returns the noise temperature that corresponds to a noise figure in\ndecibels, using the standard reference temperature.\n\nThis is a free function form of\n[`NoiseTemperature::from_noise_figure_db`](crate::NoiseTemperature::from_noise_figure_db).\n\nReturns `None` when the figure is not finite.", + "associated": false + }, + { + "kind": "fn", + "name": "cascade_noise_figure_db", + "signature": "pub fn cascade_noise_figure_db(stages: &[Stage]) -> Option {", + "doc": "Returns the noise figure of a cascade, in decibels.\n\nThe cascade is evaluated as a temperature and then converted to a figure\nusing the standard reference temperature. This is the form in which a\nreceiver chain is usually specified.\n\nReturns `None` when any stage is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "reference_temperature", + "signature": "pub const fn reference_temperature() -> f64 {", + "doc": "Returns the reference temperature used by the noise figure definition.", + "associated": false + }, + { + "kind": "const", + "name": "REFERENCE_TEMPERATURE_K", + "signature": "pub const REFERENCE_TEMPERATURE_K: f64 = 290.0;", + "doc": "The reference temperature used to define the noise figure, in kelvin.\n\nThe value is 290 K, which is the standard reference temperature of the\nIEEE definition of noise figure. It is a convention rather than a\nphysical temperature, and the choice affects the noise figure quoted for\na given noise temperature.", + "associated": false + }, + { + "kind": "struct", + "name": "NoiseTemperature", + "signature": "pub struct NoiseTemperature(f64);", + "doc": "A noise temperature in kelvin.\n\nA noise temperature is the temperature of a resistor that would deliver\nthe same noise power in the same bandwidth. It is a convenient way to\nexpress any noise source, including one that is not thermal in origin,\nbecause temperatures add across a cascade while powers do not.", + "associated": false + }, + { + "kind": "const", + "name": "REFERENCE", + "signature": "pub const REFERENCE: Self = Self(REFERENCE_TEMPERATURE_K);", + "doc": "The standard reference temperature of 290 K.", + "associated": true + }, + { + "kind": "fn", + "name": "from_kelvin", + "signature": "pub const fn from_kelvin(kelvin: f64) -> Self {", + "doc": "Creates a noise temperature from a value in kelvin.", + "associated": true + }, + { + "kind": "fn", + "name": "from_noise_figure_db", + "signature": "pub fn from_noise_figure_db(decibels: f64) -> Option {", + "doc": "Creates a noise temperature from a noise figure in decibels.\n\nThe relation is `T = T_0 (F - 1)` with `F` the linear noise factor,\nso a noise figure of zero decibels gives a temperature of zero.\n\nReturns `None` when the figure is not finite.", + "associated": true + }, + { + "kind": "fn", + "name": "from_noise_factor", + "signature": "pub fn from_noise_factor(factor: f64) -> Option {", + "doc": "Creates a noise temperature from a linear noise factor.\n\nReturns `None` when the factor is not finite or is below one, which\nwould describe a stage that removes noise.", + "associated": true + }, + { + "kind": "fn", + "name": "kelvin", + "signature": "pub const fn kelvin(self) -> f64 {", + "doc": "Returns this temperature in kelvin.", + "associated": true + }, + { + "kind": "fn", + "name": "noise_factor", + "signature": "pub fn noise_factor(self) -> f64 {", + "doc": "Returns this temperature as a linear noise factor.\n\nThe factor is `T / T_0 + 1`, which is one for zero noise.", + "associated": true + }, + { + "kind": "fn", + "name": "noise_figure_db", + "signature": "pub fn noise_figure_db(self) -> Option {", + "doc": "Returns this temperature as a noise figure in decibels.\n\nReturns `None` when the temperature is not finite or is negative,\nwhich would give a factor below one.", + "associated": true + }, + { + "kind": "fn", + "name": "plus", + "signature": "pub const fn plus(self, other: Self) -> Self {", + "doc": "Returns the sum of two temperatures.\n\nNoise temperatures of independent sources add, which is the reason\nthe quantity is useful.", + "associated": true + }, + { + "kind": "fn", + "name": "scaled", + "signature": "pub const fn scaled(self, factor: f64) -> Self {", + "doc": "Returns this temperature scaled by a gain ratio.\n\nA source followed by a gain contributes its temperature divided by\nthat gain when referred to the input, so the scaling for a\ncontribution referred to the input is the reciprocal.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub const fn is_valid(self) -> bool {", + "doc": "Returns `true` when the temperature is finite and not negative.", + "associated": true + }, + { + "kind": "fn", + "name": "thermal_noise_power", + "signature": "pub fn thermal_noise_power(temperature: NoiseTemperature, bandwidth: f64) -> Option {", + "doc": "Returns the thermal noise power in a bandwidth at a noise temperature.\n\nThe relation is `P = k T B`, with `k` the Boltzmann constant. This is the\npower that a matched resistor at the given temperature would deliver to a\nload over the stated bandwidth.\n\nThe bandwidth must be positive. Returns `None` for an invalid\ntemperature or bandwidth.", + "associated": false + } + ] + }, + { + "name": "apsis-interference", + "layer": "rf", + "summary": "Interference and carrier-to-interference computation. A wanted signal rarely arrives alone. This crate combines the wanted carrier with one or more interfering carriers and with noise, and reports the ratios that decide whether the wanted link is usable. The figures computed here are: - The carrier to interference ratio, over one interferer or several. - The carrier to noise plus interference ratio, which is what a receiver actually sees, because it cannot separate the two. - The equivalent noise temperature of an interferer, which lets an interference contribution be folded into the link budget as if it were noise. Interference from a second satellite sharing the same frequency is described by the angle between the two satellites as seen from the ground station. The receive antenna has a sidelobe response in that direction, and the ratio of the interferer's power to the wanted power follows from the two gains and the two path losses. This crate takes the two gains as inputs, so it composes with the pattern models in `apsis-antenna`. Source: the definitions of carrier to interference and carrier to noise plus interference, and the practice of expressing interference as an equivalent noise temperature, are standard in satellite link design and appear for example in the ITU-R Handbook on Satellite Communications. ``` use apsis_interference::{Interferer, carrier_to_interference, combine}; use apsis_rf::{Decibel, Power}; let wanted = Power::from_dbw(-130.0).expect(\"finite\"); let interferer = Interferer { power: Power::from_dbw(-140.0).expect(\"finite\"), }; // One interferer ten decibels below the carrier leaves a C/I of 10 dB. let ratio = carrier_to_interference(wanted, &[interferer]).expect(\"valid\"); assert!((ratio.db() - 10.0).abs() < 1e-9); // Combining with noise can only lower the result. let combined = combine(ratio, Decibel::from_db(15.0)).expect(\"valid\"); assert!(combined.db() < ratio.db()); ```", + "items": [ + { + "kind": "struct", + "name": "Interferer", + "signature": "pub struct Interferer {", + "doc": "One interfering carrier seen at the receiver input.\n\nThe power is the interfering power that reaches the receiver, already\nincluding the transmit power, the transmit gain, the path loss, the\nreceive antenna gain in the direction of the interferer, and any\npropagation loss. This crate combines the powers; it does not compute the\ngeometry.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(power: Power) -> Self {", + "doc": "Creates an interferer from a power at the receiver input.", + "associated": true + }, + { + "kind": "struct", + "name": "InterferenceSummary", + "signature": "pub struct InterferenceSummary {", + "doc": "The combined result of a wanted carrier and its interference.", + "associated": false + }, + { + "kind": "fn", + "name": "interference_to_carrier", + "signature": "pub fn interference_to_carrier(self) -> Decibel {", + "doc": "Returns the interference to carrier ratio.\n\nThis is the reciprocal of the carrier to interference ratio and is\nthe form some specifications use.", + "associated": true + }, + { + "kind": "fn", + "name": "total_interference", + "signature": "pub fn total_interference(interferers: &[Interferer]) -> Option {", + "doc": "Returns the total interfering power from a set of interferers.\n\nInterfering powers add, because they are independent signals and their\npower is what matters, not their amplitudes. Two equal interferers\ntherefore give twice the power of one, which is three decibels and not\nsix.\n\nReturns `None` when any interferer has an invalid power.", + "associated": false + }, + { + "kind": "fn", + "name": "carrier_to_interference", + "signature": "pub fn carrier_to_interference(carrier: Power, interferers: &[Interferer]) -> Option {", + "doc": "Returns the carrier to interference ratio for a carrier and a set of\ninterferers.\n\nThe ratio is the wanted carrier power divided by the sum of the\ninterfering powers. A large ratio means the interferers are weak.\n\nReturns `None` when the carrier is not positive, when any interferer is\ninvalid, or when the total interference is zero, which would give an\ninfinite ratio.", + "associated": false + }, + { + "kind": "fn", + "name": "carrier_to_interference_single", + "signature": "pub fn carrier_to_interference_single(carrier: Power, interferer: Interferer) -> Option {", + "doc": "Returns the carrier to interference ratio for a single interferer.\n\nThis is a convenience for the common case of one dominant interferer.\n\nReturns `None` when either power is not positive.", + "associated": false + }, + { + "kind": "fn", + "name": "combine", + "signature": "pub fn combine(first: Decibel, second: Decibel) -> Option {", + "doc": "Combines a ratio with another ratio of the same kind.\n\nWhen two independent impairments act together, their effects add as\nreciprocal ratios, which in decibels means\n\n```text\n1 / R = 1 / R_1 + 1 / R_2\n```\n\nThe result is always below the smaller of the two, so adding a second\nimpairment can only lower the combined ratio. This is how a carrier to\nnoise ratio and a carrier to interference ratio are combined into a\ncarrier to noise plus interference ratio.\n\nReturns `None` when either value is not finite.", + "associated": false + }, + { + "kind": "fn", + "name": "carrier_to_noise_plus_interference", + "signature": "pub fn carrier_to_noise_plus_interference( carrier_to_noise: Decibel, carrier_to_interference: Decibel, ) -> Option {", + "doc": "Returns the carrier to noise plus interference ratio.\n\nThis combines a carrier to noise ratio and a carrier to interference\nratio through [`combine`]. It is the ratio a receiver actually\nexperiences, because it cannot separate thermal noise from a competing\ncarrier.\n\nReturns `None` when either ratio is not finite.", + "associated": false + }, + { + "kind": "fn", + "name": "interference_to_noise_ratio", + "signature": "pub fn interference_to_noise_ratio(interference: Power, noise: Power) -> Option {", + "doc": "Returns the interference to noise ratio.\n\nThis compares the interfering power with the thermal noise power in the\nreceiver. A value well below zero decibels means the interference is\nnegligible against the noise and can be ignored; a value near or above\nzero means it must be accounted for.\n\nReturns `None` when either power is not positive.", + "associated": false + }, + { + "kind": "fn", + "name": "equivalent_noise_temperature", + "signature": "pub fn equivalent_noise_temperature(interference: Power, bandwidth: f64) -> Option {", + "doc": "Returns the noise temperature equivalent to an interfering power.\n\nAn interferer that a receiver treats as noise can be folded into the link\nbudget as an increase in system noise temperature. In temperature the\nrelation is\n\n```text\nT = P / (k B)\n```\n\nwith `P` the interfering power, `k` the Boltzmann constant, and `B` the\nreceiver noise bandwidth. The temperature is the value that, added to the\nsystem temperature, would raise the noise by the same amount as the\ninterferer.\n\nReturns `None` when the power is not positive or the bandwidth is not\npositive and finite.", + "associated": false + }, + { + "kind": "fn", + "name": "summarise", + "signature": "pub fn summarise( carrier: Power, interferers: &[Interferer], noise: Option, ) -> Option {", + "doc": "Returns a summary of a carrier with its interference.\n\nWhen a noise power is given, the carrier to noise plus interference ratio\nis computed as well. Pass `None` for the noise power when only the\ninterference is of interest.\n\nReturns `None` when the carrier is not positive, when any interferer is\ninvalid, or when the total interference is zero.", + "associated": false + } + ] + }, + { + "name": "apsis-link", + "layer": "rf", + "summary": "Link budget computation. A link budget adds up every gain and loss between a transmitter and a receiver and compares the resulting carrier power with the noise that competes with it. This crate computes that sum and the figures derived from it: the carrier to noise ratio, the energy per bit to noise density ratio, and the link margin. The order of operations matters, so the chain is stated explicitly: 1. The transmitter power is reduced by the transmit losses and increased by the transmit antenna gain, giving the equivalent isotropically radiated power. 2. That is divided by the free space path loss and increased by the receive antenna gain, giving the carrier power at the receiver input. 3. The system noise temperature is converted to a noise density by multiplying by the Boltzmann constant. 4. The carrier to noise density ratio is the carrier power divided by that density, and the carrier to noise ratio divides it further by the receiver bandwidth. Sources: - The link equation in this form is the one used throughout satellite communications practice, as described for example in the ITU-R Handbook on Satellite Communications. - The Boltzmann constant and the speed of light come from the SI definitions, as re-exported by `apsis-rf`. ``` use apsis_link::{LinkBudget, LinkGeometry}; use apsis_rf::{Gain, Power}; let budget = LinkBudget { transmit_power: Power::from_dbw(10.0).expect(\"finite\"), transmit_gain: Gain::from_dbi(30.0).expect(\"finite\"), transmit_loss: apsis_rf::Decibel::from_db(1.0), receive_gain: Gain::from_dbi(35.0).expect(\"finite\"), receive_loss: apsis_rf::Decibel::from_db(0.5), system_temperature: apsis_noise::NoiseTemperature::from_kelvin(150.0), bandwidth: 36.0e6, geometry: LinkGeometry { distance: 38_000_000.0, frequency: 12.0e9, }, }; // The carrier to noise density ratio is available directly. For this // budget it is about 74.7 dB, which is typical of a Ku band downlink. let ratio = budget.carrier_to_noise_density().expect(\"valid budget\"); assert!(ratio.db() > 70.0); assert!(ratio.db() < 80.0); ```", + "items": [ + { + "kind": "struct", + "name": "LinkGeometry", + "signature": "pub struct LinkGeometry {", + "doc": "The geometric part of a link: how far apart the terminals are and at what\nfrequency they operate.", + "associated": false + }, + { + "kind": "fn", + "name": "path_loss", + "signature": "pub fn path_loss(self) -> Option {", + "doc": "Returns the free space path loss for this geometry.\n\nReturns `None` when the distance or frequency is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub fn is_valid(self) -> bool {", + "doc": "Returns `true` when the geometry is usable.", + "associated": true + }, + { + "kind": "struct", + "name": "LinkBudget", + "signature": "pub struct LinkBudget {", + "doc": "A complete link budget for one direction of a radio link.\n\nThe losses are given as positive decibel values that are subtracted,\nwhich is the convention in link budgets: a three decibel loss is entered\nas three decibels, not as minus three.", + "associated": false + }, + { + "kind": "fn", + "name": "eirp", + "signature": "pub fn eirp(&self) -> Option {", + "doc": "Returns the equivalent isotropically radiated power, in watts.\n\nThis is the transmit power reduced by the transmit losses and\nincreased by the transmit antenna gain. It is the power that an\nisotropic radiator would have to radiate to produce the same flux\ndensity in the direction of the receiver.\n\nReturns `None` when any input is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "received_power", + "signature": "pub fn received_power(&self) -> Option {", + "doc": "Returns the carrier power at the receiver input, in watts.\n\nReturns `None` when any input is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "noise_density", + "signature": "pub fn noise_density(&self) -> Option {", + "doc": "Returns the noise power density at the receiver input, in watts per\nhertz.\n\nThis is `k T`, the noise temperature converted to a power per unit\nbandwidth. It does not depend on the receiver bandwidth, which is\nwhat makes the carrier to noise density ratio a property of the link\nrather than of the receiver.\n\nReturns `None` when the system temperature is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "carrier_to_noise_density", + "signature": "pub fn carrier_to_noise_density(&self) -> Option {", + "doc": "Returns the carrier to noise density ratio.\n\nThis is the carrier power divided by the noise power in one hertz.\nIt is the form used to compare a link against the energy per bit\nrequirement of a modulation, because the data rate is accounted for\nseparately.\n\nReturns `None` when any input is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "carrier_to_noise", + "signature": "pub fn carrier_to_noise(&self) -> Option {", + "doc": "Returns the carrier to noise ratio in the receiver bandwidth.\n\nThis is the carrier to noise density ratio reduced by the bandwidth.\nUnlike the density ratio it depends on the receiver, which is why the\nsame link can show different carrier to noise ratios in receivers of\ndifferent bandwidth.\n\nReturns `None` when any input is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "energy_per_bit_over_noise", + "signature": "pub fn energy_per_bit_over_noise(&self, data_rate: f64) -> Option {", + "doc": "Returns the energy per bit to noise density ratio for a data rate.\n\nThis is the carrier to noise density ratio reduced by the data rate,\nbecause energy per bit is carrier power divided by bit rate.\n\nReturns `None` when the data rate is not positive, or when the\ncarrier to noise density ratio is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "with_added_noise", + "signature": "pub fn with_added_noise(&self, added: NoiseTemperature) -> Option {", + "doc": "Returns the rise in system noise caused by the receive antenna\nviewing a noise source of the given temperature.\n\nThe antenna noise is added to the system temperature in power, which\nin temperature is a sum. This is the form used to account for the\nEarth, the Sun, or a rain cell in the beam.\n\nReturns `None` when the added temperature is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "carrier_wavelength", + "signature": "pub fn carrier_wavelength(&self) -> Option {", + "doc": "Returns the wavelength of the carrier, in meters.\n\nReturns `None` when the frequency is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub fn is_valid(&self) -> bool {", + "doc": "Returns `true` when every input describes a usable link.", + "associated": true + }, + { + "kind": "struct", + "name": "LinkMargin", + "signature": "pub struct LinkMargin {", + "doc": "The result of comparing a link against a requirement.", + "associated": false + }, + { + "kind": "fn", + "name": "margin", + "signature": "pub fn margin(self) -> Decibel {", + "doc": "Returns the margin, in decibels.\n\nA positive margin means the link closes with room to spare; a\nnegative margin means it does not.", + "associated": true + }, + { + "kind": "fn", + "name": "closes", + "signature": "pub fn closes(self) -> bool {", + "doc": "Returns `true` when the margin is not negative.", + "associated": true + }, + { + "kind": "fn", + "name": "link_margin", + "signature": "pub fn link_margin(achieved: Decibel, required: Decibel) -> LinkMargin {", + "doc": "Returns the margin between an achieved ratio and a required one.\n\nThis is the comparison that decides whether a link closes.", + "associated": false + }, + { + "kind": "fn", + "name": "carrier_to_noise_density", + "signature": "pub fn carrier_to_noise_density(budget: &LinkBudget) -> Option {", + "doc": "Returns the carrier to noise density ratio for a budget.\n\nThis is a free function form of\n[`LinkBudget::carrier_to_noise_density`].\n\nReturns `None` when the budget is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "gain_to_temperature", + "signature": "pub fn gain_to_temperature(gain: Gain, temperature: NoiseTemperature) -> Option {", + "doc": "Returns the gain to system temperature ratio of a receive chain, in\nreciprocal kelvin.\n\nThis figure of merit is written `G / T` and is the standard way to\ncompare receive stations. It combines the receive antenna gain with the\nsystem noise temperature, so a station with a bigger dish and a quieter\nreceiver scores higher.\n\nExpressed in decibels, `G / T` is the receive gain in dBi minus ten times\nthe logarithm of the temperature, which is the form usually quoted.\n\nReturns `None` when the gain or temperature is not usable.", + "associated": false + } + ] + }, + { + "name": "apsis-resource", + "layer": "ops", + "summary": "Resource modeling for satellite operations. A resource is anything a task can occupy: a satellite, a ground station, a transmitter, an onboard recorder. This crate models the two things a scheduler needs to know about one: when it is available, and how much of it a task consumes. Availability is expressed as a set of disjoint intervals on a single time axis, called windows. A resource is usable at an instant when that instant falls inside one of its windows. The windows may come from anywhere: the access analysis in `apsis-access` produces them for a ground station, and an operator enters maintenance periods by hand. Capacity is a positive number whose meaning is the caller's. A satellite that can run one observation at a time has a capacity of one; a recorder has a capacity in megabits; a power bus has one in watts. Two tasks conflict only when their demands together exceed the capacity, which lets the same type express exclusive and shared resources. Times are in seconds from an origin chosen by the caller, so the same model serves a scenario clock, an elapsed time, and a Julian date offset. ``` use apsis_resource::{Resource, ResourceId, Window}; // A station available from 100 to 200 seconds. let mut resource = Resource::new(ResourceId::new(0), 1.0); resource.add_window(Window::new(100.0, 200.0)).expect(\"the window is usable\"); assert!(resource.is_available_at(150.0)); assert!(!resource.is_available_at(250.0)); assert!(resource.is_available_over(120.0, 80.0)); assert!(!resource.is_available_over(150.0, 100.0)); ```", + "items": [ + { + "kind": "enum", + "name": "ResourceErrorKind", + "signature": "pub enum ResourceErrorKind {", + "doc": "The category of a resource error.", + "associated": false + }, + { + "kind": "struct", + "name": "ResourceError", + "signature": "pub struct ResourceError {", + "doc": "A failure while building or querying a resource.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(kind: ResourceErrorKind) -> Self {", + "doc": "Creates an error with the given category.", + "associated": true + }, + { + "kind": "fn", + "name": "with_detail", + "signature": "pub fn with_detail(mut self, detail: impl Into) -> Self {", + "doc": "Attaches a description of the values that caused the failure.", + "associated": true + }, + { + "kind": "fn", + "name": "kind", + "signature": "pub fn kind(&self) -> ResourceErrorKind {", + "doc": "Returns the category of this failure.", + "associated": true + }, + { + "kind": "fn", + "name": "detail", + "signature": "pub fn detail(&self) -> Option<&str> {", + "doc": "Returns the detail text attached to this error, if any.", + "associated": true + }, + { + "kind": "struct", + "name": "ResourceId", + "signature": "pub struct ResourceId(pub usize);", + "doc": "An identifier for a resource.\n\nThe identifier is an index chosen by the caller. It is deliberately opaque\nto this crate so that a caller can use whatever numbering its own model\nprovides.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(index: usize) -> Self {", + "doc": "Creates an identifier.", + "associated": true + }, + { + "kind": "fn", + "name": "index", + "signature": "pub const fn index(self) -> usize {", + "doc": "Returns the index.", + "associated": true + }, + { + "kind": "struct", + "name": "Resource", + "signature": "pub struct Resource {", + "doc": "A resource with availability windows and a capacity.\n\nThe capacity is a positive number whose unit the caller chooses. A task\nthat occupies more than the capacity of a resource cannot be scheduled on\nit at all; two tasks conflict when their demands together exceed the\ncapacity at some instant.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(id: ResourceId, capacity: f64) -> Self {", + "doc": "Creates a resource with no availability and the given capacity.\n\nA resource with no windows is never available, so a caller adds\nwindows before using it. That is the safe default: a resource whose\navailability was forgotten is unavailable rather than always\navailable.", + "associated": true + }, + { + "kind": "fn", + "name": "capacity", + "signature": "pub const fn capacity(&self) -> f64 {", + "doc": "Returns the capacity.", + "associated": true + }, + { + "kind": "fn", + "name": "set_capacity", + "signature": "pub fn set_capacity(&mut self, capacity: f64) -> Result<(), ResourceError> {", + "doc": "Sets the capacity.\n\nReturns an error when the capacity is not positive and finite.", + "associated": true + }, + { + "kind": "fn", + "name": "has_valid_capacity", + "signature": "pub fn has_valid_capacity(&self) -> bool {", + "doc": "Returns `true` when the capacity is usable.", + "associated": true + }, + { + "kind": "fn", + "name": "windows", + "signature": "pub fn windows(&self) -> &[Window] {", + "doc": "Returns the availability windows, merged and in time order.", + "associated": true + }, + { + "kind": "fn", + "name": "window_count", + "signature": "pub fn window_count(&self) -> usize {", + "doc": "Returns the number of availability windows.", + "associated": true + }, + { + "kind": "fn", + "name": "is_empty", + "signature": "pub fn is_empty(&self) -> bool {", + "doc": "Returns `true` when the resource has no availability at all.", + "associated": true + }, + { + "kind": "fn", + "name": "set_windows", + "signature": "pub fn set_windows(&mut self, windows: &[Window]) -> Result<(), ResourceError> {", + "doc": "Replaces the availability with a list of windows.\n\nThe windows are merged, so the stored form is canonical whatever order\nthe caller supplies them in.\n\nReturns an error when a window is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "add_window", + "signature": "pub fn add_window(&mut self, window: Window) -> Result<(), ResourceError> {", + "doc": "Adds one availability window.\n\nThe window is merged into the existing set.\n\nReturns an error when the window is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "remove_window", + "signature": "pub fn remove_window(&mut self, removed: Window) -> Result<(), ResourceError> {", + "doc": "Removes an availability window.\n\nThe window is subtracted from the existing set, which may split a\nstored window in two.\n\nReturns an error when the window is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "is_available_at", + "signature": "pub fn is_available_at(&self, time: f64) -> bool {", + "doc": "Returns `true` when the resource is available at an instant.\n\nAn instantaneous query is a query of zero duration, which is satisfied\nwhen the instant is inside a window. At the end of a window the\nresource is not available, because the window is half-open.", + "associated": true + }, + { + "kind": "fn", + "name": "is_available_over", + "signature": "pub fn is_available_over(&self, start: f64, duration: f64) -> bool {", + "doc": "Returns `true` when the resource is available for a whole interval.\n\nA resource can satisfy a request of zero duration whenever it is\navailable at the requested instant, so a zero duration is checked as\nan instant.", + "associated": true + }, + { + "kind": "fn", + "name": "availability_within", + "signature": "pub fn availability_within(&self, query: Interval) -> Vec {", + "doc": "Returns the availability that falls inside an interval.\n\nThe result is the intersection of the stored windows with the query,\nwhich is what a scheduler needs to know when it asks what part of its\nplanning horizon a resource is usable for.", + "associated": true + }, + { + "kind": "fn", + "name": "total_availability", + "signature": "pub fn total_availability(&self) -> f64 {", + "doc": "Returns the total availability, in seconds.\n\nThe windows are disjoint, so the durations add.", + "associated": true + }, + { + "kind": "fn", + "name": "earliest_fit", + "signature": "pub fn earliest_fit(&self, duration: f64) -> Option {", + "doc": "Returns the first window that can hold a request.\n\nThis is the earliest time at which the resource can satisfy a request\nof the given duration, which is what a greedy scheduler asks. Returns\n`None` when no window is long enough.", + "associated": true + }, + { + "kind": "fn", + "name": "earliest_fit_after", + "signature": "pub fn earliest_fit_after(&self, not_before: f64, duration: f64) -> Option {", + "doc": "Returns the first window that can hold a request starting no earlier\nthan a given time.\n\nReturns `None` when no window is both late enough and long enough.", + "associated": true + }, + { + "kind": "fn", + "name": "busy_until", + "signature": "pub fn busy_until(&self, time: f64) -> Option {", + "doc": "Returns the time at which the resource stops being available.\n\nReturns `None` when the resource is not available at the given\ninstant, and infinity when the window that contains it is unbounded.", + "associated": true + }, + { + "kind": "struct", + "name": "ResourceSet", + "signature": "pub struct ResourceSet {", + "doc": "A collection of resources, indexed by identifier.\n\nThe set keeps the resources in the order they were added and provides the\nlookups a scheduler needs: by identifier, and by the resource that has a\nwindow covering an instant.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new() -> Self {", + "doc": "Creates an empty set.", + "associated": true + }, + { + "kind": "fn", + "name": "from_resources", + "signature": "pub fn from_resources(resources: Vec) -> Result {", + "doc": "Creates a set from a list of resources.\n\nReturns an error when two resources share an identifier, because a\nlookup would then be ambiguous.", + "associated": true + }, + { + "kind": "fn", + "name": "insert", + "signature": "pub fn insert(&mut self, resource: Resource) -> Result<(), ResourceError> {", + "doc": "Inserts a resource.\n\nReturns an error when the identifier is already present.", + "associated": true + }, + { + "kind": "fn", + "name": "get", + "signature": "pub fn get(&self, id: ResourceId) -> Option<&Resource> {", + "doc": "Returns the resource with the given identifier.", + "associated": true + }, + { + "kind": "fn", + "name": "get_mut", + "signature": "pub fn get_mut(&mut self, id: ResourceId) -> Option<&mut Resource> {", + "doc": "Returns the resource with the given identifier for modification.", + "associated": true + }, + { + "kind": "fn", + "name": "remove", + "signature": "pub fn remove(&mut self, id: ResourceId) -> Result {", + "doc": "Removes a resource.\n\nReturns an error when no resource has the identifier.", + "associated": true + }, + { + "kind": "fn", + "name": "resources", + "signature": "pub fn resources(&self) -> &[Resource] {", + "doc": "Returns the resources in the order they were added.", + "associated": true + }, + { + "kind": "fn", + "name": "len", + "signature": "pub fn len(&self) -> usize {", + "doc": "Returns the number of resources.", + "associated": true + }, + { + "kind": "fn", + "name": "available_at", + "signature": "pub fn available_at(&self, time: f64) -> Vec {", + "doc": "Returns the identifiers of the resources available at an instant.\n\nThe result is in the order the resources were added.", + "associated": true + }, + { + "kind": "fn", + "name": "available_over", + "signature": "pub fn available_over(&self, start: f64, duration: f64) -> Vec {", + "doc": "Returns the identifiers of the resources available for a whole\ninterval.", + "associated": true + }, + { + "kind": "struct", + "name": "Interval", + "signature": "pub struct Interval {", + "doc": "A half-open interval of time, `[start, end)`.\n\nThe half-open convention is the one that makes intervals tile without\noverlap or gap: an interval that ends where another begins shares only the\ninstant at the boundary, which belongs to the second. Two intervals are\ntherefore disjoint when the end of the first is at or before the start of\nthe second.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(start: f64, end: f64) -> Self {", + "doc": "Creates an interval.", + "associated": true + }, + { + "kind": "fn", + "name": "duration", + "signature": "pub fn duration(&self) -> f64 {", + "doc": "Returns the duration of the interval, in seconds.\n\nThe value is zero for an empty interval and positive otherwise.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub fn is_valid(&self) -> bool {", + "doc": "Returns `true` when the interval is finite and ordered.", + "associated": true + }, + { + "kind": "fn", + "name": "contains", + "signature": "pub fn contains(&self, time: f64) -> bool {", + "doc": "Returns `true` when an instant lies inside the interval.\n\nThe start is included and the end is excluded, so a boundary instant\nbelongs to the interval that begins there.", + "associated": true + }, + { + "kind": "fn", + "name": "contains_interval", + "signature": "pub fn contains_interval(&self, other: &Self) -> bool {", + "doc": "Returns `true` when the interval fully contains another.\n\nAn empty interval is contained by any interval that contains its\ninstant, so a zero length query inside the range is satisfied.", + "associated": true + }, + { + "kind": "fn", + "name": "overlaps", + "signature": "pub fn overlaps(&self, other: &Self) -> bool {", + "doc": "Returns `true` when two intervals share any time.\n\nTwo intervals that meet at a point do not overlap, because the point\nbelongs to only one of them under the half-open convention.", + "associated": true + }, + { + "kind": "fn", + "name": "intersection", + "signature": "pub fn intersection(&self, other: &Self) -> Option {", + "doc": "Returns the intersection of two intervals, or `None` when they do not\noverlap.", + "associated": true + }, + { + "kind": "fn", + "name": "hull", + "signature": "pub fn hull(&self, other: &Self) -> Self {", + "doc": "Returns the shortest interval containing both.", + "associated": true + }, + { + "kind": "fn", + "name": "is_adjacent_to", + "signature": "pub fn is_adjacent_to(&self, other: &Self) -> bool {", + "doc": "Returns `true` when the two intervals touch or overlap, so that they\ncan be merged without leaving a gap.", + "associated": true + }, + { + "kind": "struct", + "name": "Window", + "signature": "pub struct Window {", + "doc": "An availability window of a resource.\n\nThis is an [`Interval`] under a name that reads better where the caller is\ndescribing when a resource can be used rather than doing interval algebra.", + "associated": false + }, + { + "kind": "fn", + "name": "from_interval", + "signature": "pub const fn from_interval(interval: Interval) -> Self {", + "doc": "Creates a window from an interval.", + "associated": true + }, + { + "kind": "fn", + "name": "start", + "signature": "pub const fn start(&self) -> f64 {", + "doc": "Returns the start of the window, in seconds.", + "associated": true + }, + { + "kind": "fn", + "name": "end", + "signature": "pub const fn end(&self) -> f64 {", + "doc": "Returns the end of the window, in seconds.", + "associated": true + }, + { + "kind": "fn", + "name": "validate", + "signature": "pub fn validate(&self) -> Result<(), ResourceError> {", + "doc": "Validates the window, returning the reason when it is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "merge_windows", + "signature": "pub fn merge_windows(windows: &[Window]) -> Result, ResourceError> {", + "doc": "Merges a list of windows into a list of disjoint windows in time order.\n\nWindows that overlap or touch are combined, and empty windows are dropped.\nThe result is the canonical form of a set of availability windows: sorted,\ndisjoint, and non-empty, so that two sets can be compared directly.\n\nReturns an error when a window is not usable.", + "associated": false + }, + { + "kind": "fn", + "name": "subtract_window", + "signature": "pub fn subtract_window(windows: &[Window], removed: Window) -> Result, ResourceError> {", + "doc": "Removes a window from each of a list of windows.\n\nThe result is the same set of availability with the removed window taken\nout. Windows that are entirely inside the removed one disappear, and one\nthat contains it is split in two.\n\nReturns an error when any input window is not usable.", + "associated": false + } + ] + }, + { + "name": "apsis-task", + "layer": "ops", + "summary": "Task modeling for satellite operations. A task is a unit of work that a scheduler places on a set of resources. It carries everything a scheduler needs to decide whether and where to run it: how long it takes, when it may run, which resources it needs and in what amount, and what it is worth. A task requires all of its resources at the same time, for its whole duration. That is the model an observation has: the satellite and the ground station must both be available while the data is taken. A task that needs resources in sequence is expressed as two tasks joined by a precedence constraint in a [`PrecedenceGraph`], which says that one task must finish before the other starts. Tasks are immutable once built. The state a scheduler assigns to one is kept separately, in the schedule, so that the same task can be considered for several placements without being modified. ``` use apsis_resource::ResourceId; use apsis_task::{Requirement, Task, TaskId}; // An observation of sixty seconds that needs a satellite and a station. let task = Task::new(TaskId::new(0), 60.0) .with_window(100.0, 1_000.0) .with_priority(5.0) .try_require(ResourceId::new(0), 1.0) .expect(\"the satellite is not yet required\") .try_require(ResourceId::new(1), 1.0) .expect(\"the station is not yet required\"); assert_eq!(task.duration, 60.0); assert_eq!(task.requirements.len(), 2); assert!(task.is_valid()); ```", + "items": [ + { + "kind": "enum", + "name": "TaskErrorKind", + "signature": "pub enum TaskErrorKind {", + "doc": "The category of a task error.", + "associated": false + }, + { + "kind": "struct", + "name": "TaskError", + "signature": "pub struct TaskError {", + "doc": "A failure while building or querying a task.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(kind: TaskErrorKind) -> Self {", + "doc": "Creates an error with the given category.", + "associated": true + }, + { + "kind": "fn", + "name": "with_detail", + "signature": "pub fn with_detail(mut self, detail: impl Into) -> Self {", + "doc": "Attaches a description of the values that caused the failure.", + "associated": true + }, + { + "kind": "fn", + "name": "kind", + "signature": "pub fn kind(&self) -> TaskErrorKind {", + "doc": "Returns the category of this failure.", + "associated": true + }, + { + "kind": "fn", + "name": "detail", + "signature": "pub fn detail(&self) -> Option<&str> {", + "doc": "Returns the detail text attached to this error, if any.", + "associated": true + }, + { + "kind": "struct", + "name": "PrecedenceGraph", + "signature": "pub struct PrecedenceGraph {", + "doc": "A directed acyclic graph of precedence constraints.\n\nAn edge from one task to another means the first must finish before the\nsecond starts. The order of the tasks themselves is the caller's: the\ngraph only records which pairs are related.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new() -> Self {", + "doc": "Creates an empty graph.", + "associated": true + }, + { + "kind": "fn", + "name": "edge_count", + "signature": "pub fn edge_count(&self) -> usize {", + "doc": "Returns the number of edges.", + "associated": true + }, + { + "kind": "fn", + "name": "is_empty", + "signature": "pub fn is_empty(&self) -> bool {", + "doc": "Returns `true` when there are no edges.", + "associated": true + }, + { + "kind": "fn", + "name": "has_constraint", + "signature": "pub fn has_constraint(&self, before: TaskId, after: TaskId) -> bool {", + "doc": "Returns `true` when the constraint from `before` to `after` exists.", + "associated": true + }, + { + "kind": "fn", + "name": "add", + "signature": "pub fn add(&mut self, before: TaskId, after: TaskId) -> Result<(), TaskError> {", + "doc": "Adds a constraint that `before` must finish before `after` starts.\n\nReturns an error when the two identifiers are the same, because a task\ncannot precede itself, or when the constraint would close a cycle.\n\nAdding a constraint that already exists is accepted and has no effect,\nso a caller may assemble a graph from overlapping sources without\nhaving to deduplicate.", + "associated": true + }, + { + "kind": "fn", + "name": "is_reachable", + "signature": "pub fn is_reachable(&self, from: TaskId, to: TaskId) -> bool {", + "doc": "Returns `true` when `to` is reachable from `from` following the\nconstraints.", + "associated": true + }, + { + "kind": "fn", + "name": "predecessors_of", + "signature": "pub fn predecessors_of(&self, task: TaskId) -> Vec {", + "doc": "Returns the tasks that must finish before a task starts.\n\nThe result is in the order the constraints were added. It is the\ndirect predecessors only, not the transitive closure.", + "associated": true + }, + { + "kind": "fn", + "name": "successors_of", + "signature": "pub fn successors_of(&self, task: TaskId) -> Vec {", + "doc": "Returns the tasks that must wait for a task to finish.\n\nThe result is in the order the constraints were added. It is the\ndirect successors only, not the transitive closure.", + "associated": true + }, + { + "kind": "fn", + "name": "roots", + "signature": "pub fn roots(&self) -> Vec {", + "doc": "Returns the tasks with no predecessor.", + "associated": true + }, + { + "kind": "fn", + "name": "leaves", + "signature": "pub fn leaves(&self) -> Vec {", + "doc": "Returns the tasks with no successor.", + "associated": true + }, + { + "kind": "fn", + "name": "topological_order", + "signature": "pub fn topological_order(&self, tasks: &[TaskId]) -> Result, TaskError> {", + "doc": "Returns a topological order of the given tasks.\n\nThe order respects every constraint: a task appears after all of its\npredecessors. Ties are broken by the position of the task in the\nsupplied list, so that a caller can impose its own preference among\ntasks that are otherwise unordered. That is what lets a planner place\nthe more valuable tasks first within the freedom the constraints\nallow.\n\nTasks in the list that have no constraints still appear.\n\nReturns an error when the graph contains a cycle among the given\ntasks. The graph rejects cycles as they are added, so this can only\nhappen when a caller builds the graph from one task set and orders\nanother, or when the tasks are not all in the graph.", + "associated": true + }, + { + "kind": "fn", + "name": "task_count", + "signature": "pub fn task_count(&self) -> usize {", + "doc": "Returns the number of tasks in the graph.", + "associated": true + }, + { + "kind": "struct", + "name": "TaskId", + "signature": "pub struct TaskId(pub usize);", + "doc": "An identifier for a task.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(index: usize) -> Self {", + "doc": "Creates an identifier.", + "associated": true + }, + { + "kind": "fn", + "name": "index", + "signature": "pub const fn index(self) -> usize {", + "doc": "Returns the index.", + "associated": true + }, + { + "kind": "struct", + "name": "Requirement", + "signature": "pub struct Requirement {", + "doc": "A demand that a task makes on one resource.\n\nThe demand is the amount of the resource's capacity the task consumes\nwhile it runs. A demand that exceeds the resource's capacity makes the\ntask impossible to schedule there, which a scheduler discovers by\ncomparing the two.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(resource: ResourceId, demand: f64) -> Self {", + "doc": "Creates a requirement.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub fn is_valid(&self) -> bool {", + "doc": "Returns `true` when the demand is positive and finite.", + "associated": true + }, + { + "kind": "struct", + "name": "Task", + "signature": "pub struct Task {", + "doc": "A unit of work to be scheduled.\n\nA task is immutable after construction. The builder methods take `self` by\nvalue and return a new task, so a caller can share a base task and derive\nvariants from it without the variants interfering.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(id: TaskId, duration: f64) -> Self {", + "doc": "Creates a task with the given duration and no requirements.\n\nThe task may start at any finite time, because it has no window. It\nhas a priority of zero and is not mandatory.", + "associated": true + }, + { + "kind": "fn", + "name": "with_window", + "signature": "pub fn with_window(mut self, start: f64, end: f64) -> Self {", + "doc": "Returns a copy of this task with a time window.\n\nThe window is the interval in which the whole task must fit, so the\ntask may start no earlier than the window start and must end no later\nthan the window end.", + "associated": true + }, + { + "kind": "fn", + "name": "with_priority", + "signature": "pub fn with_priority(mut self, priority: f64) -> Self {", + "doc": "Returns a copy of this task with a priority.", + "associated": true + }, + { + "kind": "fn", + "name": "with_mandatory", + "signature": "pub fn with_mandatory(mut self, mandatory: bool) -> Self {", + "doc": "Returns a copy of this task marked mandatory or not.", + "associated": true + }, + { + "kind": "fn", + "name": "require", + "signature": "pub fn require(mut self, resource: ResourceId, demand: f64) -> Self {", + "doc": "Returns a copy of this task with an additional resource requirement.\n\nAdding the same resource twice is allowed at this point, because a\nbuilder chain cannot report an error. [`Task::validate`] rejects it,\nand [`Task::try_require`] reports it immediately.", + "associated": true + }, + { + "kind": "fn", + "name": "try_require", + "signature": "pub fn try_require(mut self, resource: ResourceId, demand: f64) -> Result {", + "doc": "Returns a copy of this task with an additional resource requirement,\nreporting a duplicate or an invalid demand immediately.", + "associated": true + }, + { + "kind": "fn", + "name": "demand_for", + "signature": "pub fn demand_for(&self, resource: ResourceId) -> Option {", + "doc": "Returns the demand the task makes on a resource.\n\nReturns `None` when the task does not require the resource.", + "associated": true + }, + { + "kind": "fn", + "name": "requires", + "signature": "pub fn requires(&self, resource: ResourceId) -> bool {", + "doc": "Returns `true` when the task requires a resource.", + "associated": true + }, + { + "kind": "fn", + "name": "requirement_count", + "signature": "pub fn requirement_count(&self) -> usize {", + "doc": "Returns the number of resources the task requires.", + "associated": true + }, + { + "kind": "fn", + "name": "resources", + "signature": "pub fn resources(&self) -> Vec {", + "doc": "Returns the identifiers of the required resources.", + "associated": true + }, + { + "kind": "fn", + "name": "window_length", + "signature": "pub fn window_length(&self) -> f64 {", + "doc": "Returns the length of the window in which the task must fit, in\nseconds.\n\nReturns infinity when the task has no restriction at either end, and\nzero or a negative value when the window cannot hold the task at all.", + "associated": true + }, + { + "kind": "fn", + "name": "window_is_too_short", + "signature": "pub fn window_is_too_short(&self) -> bool {", + "doc": "Returns `true` when the window cannot hold the task.", + "associated": true + }, + { + "kind": "fn", + "name": "earliest_possible_end", + "signature": "pub fn earliest_possible_end(&self) -> f64 {", + "doc": "Returns the earliest time the task could end, given its window.\n\nReturns infinity when the task has no upper bound, and negative\ninfinity when it has no lower bound.", + "associated": true + }, + { + "kind": "fn", + "name": "fits_window", + "signature": "pub fn fits_window(&self) -> bool {", + "doc": "Returns `true` when the task fits its window.", + "associated": true + }, + { + "kind": "fn", + "name": "has_duplicate_requirements", + "signature": "pub fn has_duplicate_requirements(&self) -> bool {", + "doc": "Returns `true` when the task uses any resource more than once, or\ndemands a non-positive amount.", + "associated": true + }, + { + "kind": "fn", + "name": "validate", + "signature": "pub fn validate(&self) -> Result<(), TaskError> {", + "doc": "Validates the task, returning the reason when it is not usable.\n\nA task is usable when its duration is finite and positive, its window\nis finite at the ends it states and holds the duration, every demand\nis positive and finite and names a distinct resource, and the weights\nare finite.", + "associated": true + }, + { + "kind": "fn", + "name": "weighted_value", + "signature": "pub fn weighted_value(&self) -> f64 {", + "doc": "Returns the value of the task, which is its priority weighted by its\nduration.\n\nThis is the quantity a scheduler maximises when it is asked to make\nthe most of the available time: a long task of high priority is worth\nmore than a short one of the same priority.", + "associated": true + }, + { + "kind": "enum", + "name": "TaskState", + "signature": "pub enum TaskState {", + "doc": "The state a scheduler has assigned to a task.\n\nThe state is kept apart from the task so that the same task can be\nconsidered for several placements without being modified. A task that has\nbeen placed records when and where, so that a schedule can be examined\nwithout a second lookup.", + "associated": false + }, + { + "kind": "fn", + "name": "is_scheduled", + "signature": "pub const fn is_scheduled(&self) -> bool {", + "doc": "Returns `true` when the task has been placed.", + "associated": true + }, + { + "kind": "fn", + "name": "start", + "signature": "pub const fn start(&self) -> Option {", + "doc": "Returns the start time of the placement, if there is one.", + "associated": true + }, + { + "kind": "fn", + "name": "end", + "signature": "pub fn end(&self, duration: f64) -> Option {", + "doc": "Returns the end time of the placement, if there is one.", + "associated": true + }, + { + "kind": "fn", + "name": "name", + "signature": "pub const fn name(&self) -> &'static str {", + "doc": "Returns a short name for the state.", + "associated": true + }, + { + "kind": "struct", + "name": "TaskSet", + "signature": "pub struct TaskSet {", + "doc": "A collection of tasks, indexed by identifier.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new() -> Self {", + "doc": "Creates an empty set.", + "associated": true + }, + { + "kind": "fn", + "name": "from_tasks", + "signature": "pub fn from_tasks(tasks: Vec) -> Result {", + "doc": "Creates a set from a list of tasks.\n\nReturns an error when two tasks share an identifier or when any task\nis not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "insert", + "signature": "pub fn insert(&mut self, task: Task) -> Result<(), TaskError> {", + "doc": "Inserts a task.\n\nReturns an error when the identifier is already present or when the\ntask is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "get", + "signature": "pub fn get(&self, id: TaskId) -> Option<&Task> {", + "doc": "Returns the task with the given identifier.", + "associated": true + }, + { + "kind": "fn", + "name": "remove", + "signature": "pub fn remove(&mut self, id: TaskId) -> Result {", + "doc": "Removes a task.\n\nReturns an error when no task has the identifier.", + "associated": true + }, + { + "kind": "fn", + "name": "tasks", + "signature": "pub fn tasks(&self) -> &[Task] {", + "doc": "Returns the tasks in the order they were added.", + "associated": true + }, + { + "kind": "fn", + "name": "len", + "signature": "pub fn len(&self) -> usize {", + "doc": "Returns the number of tasks.", + "associated": true + }, + { + "kind": "fn", + "name": "total_duration", + "signature": "pub fn total_duration(&self) -> f64 {", + "doc": "Returns the total duration of every task, in seconds.\n\nThis is the load the tasks would place on the schedule if each used\none unit of resource for its whole duration.", + "associated": true + }, + { + "kind": "fn", + "name": "total_weighted_value", + "signature": "pub fn total_weighted_value(&self) -> f64 {", + "doc": "Returns the total weighted value of every task.", + "associated": true + }, + { + "kind": "fn", + "name": "tasks_requiring", + "signature": "pub fn tasks_requiring(&self, resource: ResourceId) -> Vec {", + "doc": "Returns the identifiers of the tasks that require a resource.", + "associated": true + }, + { + "kind": "fn", + "name": "mandatory", + "signature": "pub fn mandatory(&self) -> Vec {", + "doc": "Returns the identifiers of the mandatory tasks.", + "associated": true + }, + { + "kind": "fn", + "name": "task_order", + "signature": "pub fn task_order(tasks: &[Task]) -> Vec {", + "doc": "Returns a task ordering suitable for a greedy scheduler.\n\nThe order is by decreasing weighted value, which is an efficient\nheuristic: placing the tasks that contribute most first leaves the least\nvaluable ones to fill the gaps. Ties are broken by the earlier deadline\nand then by the identifier, so that the order is deterministic for a given\nset.\n\nThis is not guaranteed to find the best possible schedule. Scheduling\nwith resource constraints is intractable in general, so a heuristic is the\npractical choice and the caller may supply its own order instead.", + "associated": false + } + ] + }, + { + "name": "apsis-scheduler", + "layer": "ops", + "summary": "Scheduling algorithms for satellite task planning. A scheduler takes a list of tasks and a set of resources and produces a schedule. This crate provides a greedy scheduler and the pieces needed to build others. Scheduling with resource constraints is intractable in general: deciding whether every task fits is a bin packing problem, which is NP-hard. A satellite planning problem is small, with hundreds of tasks rather than millions, but an exact search is still impractical for a routine planning cycle. The scheduler here is therefore a greedy heuristic. The algorithm sorts the tasks and places each in the earliest time at which all of its resources are free together. Tasks are considered in decreasing order of weighted value, which is a good heuristic: the most valuable work is placed first and the least valuable fills the remaining gaps. Mandatory tasks are placed first regardless of their value, because a task that must run is worth more than any task that may. The result is not guaranteed to be optimal, and the crate reports what it could not place rather than dropping it silently, so that an operator can see the cost of the plan. ``` use apsis_resource::{Resource, ResourceId, ResourceSet, Window}; use apsis_scheduler::Planner; use apsis_task::{Task, TaskId}; let mut station = Resource::new(ResourceId::new(0), 1.0); station.set_windows(&[Window::new(0.0, 1_000.0)]).expect(\"usable\"); let resources = ResourceSet::from_resources(vec![station]).expect(\"distinct\"); let tasks = vec![Task::new(TaskId::new(0), 60.0) .try_require(ResourceId::new(0), 1.0) .expect(\"the resource is new\")]; let planner = Planner::new(); let result = planner.schedule(&tasks, &resources); assert_eq!(result.schedule.len(), 1); assert!(result.is_complete()); ```", + "items": [ + { + "kind": "fn", + "name": "resources_have_capacity", + "signature": "pub fn resources_have_capacity(task: &Task, resources: &ResourceSet) -> bool {", + "doc": "Returns `true` when every resource of a task has the capacity to hold its\ndemand.\n\nA task that demands more of a resource than the resource has can never be\nscheduled on it, so this is checked before the search for a time. Returns\n`false` when a required resource does not exist.", + "associated": false + }, + { + "kind": "fn", + "name": "demand_at", + "signature": "pub fn demand_at( schedule: &Schedule, tasks: &[Task], resources: &ResourceSet, time: f64, ) -> Vec<(ResourceId, f64)> {", + "doc": "Returns the total demand placed on each resource at an instant, given a\nschedule and the tasks it contains.\n\nThe result is in the order the resources appear in the set. A resource\nthat is not in the set is omitted.", + "associated": false + }, + { + "kind": "fn", + "name": "resources_free_at", + "signature": "pub fn resources_free_at( schedule: &Schedule, tasks: &[Task], resources: &ResourceSet, time: f64, ) -> Vec {", + "doc": "Returns the identifiers of the resources that are free at an instant.\n\nA resource is free when its availability windows cover the instant and\nthe demand already placed on it leaves room for one more unit. This is\nthe query a watermark scheduler makes when it looks for a satellite with\nspare capacity.", + "associated": false + }, + { + "kind": "fn", + "name": "feasible_at", + "signature": "pub fn feasible_at( task: &Task, tasks: &[Task], schedule: &Schedule, resources: &ResourceSet, start: f64, ) -> bool {", + "doc": "Returns `true` when a task placed at a time is feasible.\n\nThe check is that every required resource is available for the whole\nduration and that the demand already placed on it, plus the new demand,\nstays within the capacity at every instant of the placement.", + "associated": false + }, + { + "kind": "fn", + "name": "infeasible_tasks", + "signature": "pub fn infeasible_tasks(tasks: &[Task], resources: &ResourceSet) -> Vec {", + "doc": "Returns the identifiers of the tasks that a set of resources cannot\naccommodate at all.\n\nA task is infeasible when it demands more of a resource than the resource\nhas, when a resource it requires does not exist, or when its window is\nshorter than its duration. These cannot be repaired by rescheduling and\nare worth reporting separately from the tasks that were merely left out.", + "associated": false + }, + { + "kind": "struct", + "name": "ImprovementSettings", + "signature": "pub struct ImprovementSettings {", + "doc": "The settings of the improvement pass.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(max_attempts_per_task: usize) -> Self {", + "doc": "Creates settings with the given bound.", + "associated": true + }, + { + "kind": "fn", + "name": "disabled", + "signature": "pub const fn disabled() -> Self {", + "doc": "Returns settings that do not run the pass.", + "associated": true + }, + { + "kind": "struct", + "name": "ImprovementOutcome", + "signature": "pub struct ImprovementOutcome {", + "doc": "What the improvement pass changed.", + "associated": false + }, + { + "kind": "fn", + "name": "placed_count", + "signature": "pub fn placed_count(&self) -> usize {", + "doc": "Returns the number of tasks the pass placed.", + "associated": true + }, + { + "kind": "fn", + "name": "is_empty", + "signature": "pub fn is_empty(&self) -> bool {", + "doc": "Returns `true` when the pass changed nothing.", + "associated": true + }, + { + "kind": "fn", + "name": "improve", + "signature": "pub fn improve( tasks: &[Task], resources: &ResourceSet, deferred: &[TaskId], schedule: &mut Schedule, states: &mut [TaskState], settings: ImprovementSettings, ) -> ImprovementOutcome {", + "doc": "Tries to place deferred tasks by relocating the placements that block them.\n\n`deferred` are the tasks the greedy pass could not place, in the order to\nconsider them. `states` holds the state of every task indexed by\nidentifier and is updated in place, so the caller sees the final\nassignment. The schedule is modified in place.\n\nReturns a record of what the pass placed and moved.", + "associated": false + }, + { + "kind": "struct", + "name": "PlanOutcome", + "signature": "pub struct PlanOutcome {", + "doc": "What happened to one task during planning.\n\nThe outcome pairs a task with the state the planner assigned, which is the\ninformation an operator needs to see. It is separate from the schedule\nbecause a task that was not placed has no placement but still has an\noutcome worth reporting.", + "associated": false + }, + { + "kind": "fn", + "name": "is_scheduled", + "signature": "pub fn is_scheduled(&self) -> bool {", + "doc": "Returns `true` when the task was placed.", + "associated": true + }, + { + "kind": "fn", + "name": "is_infeasible", + "signature": "pub fn is_infeasible(&self) -> bool {", + "doc": "Returns `true` when the task could not be placed for a structural\nreason, rather than being left out for want of time.", + "associated": true + }, + { + "kind": "struct", + "name": "SchedulingResult", + "signature": "pub struct SchedulingResult {", + "doc": "The result of planning.", + "associated": false + }, + { + "kind": "fn", + "name": "outcome_of", + "signature": "pub fn outcome_of(&self, task: TaskId) -> Option<&PlanOutcome> {", + "doc": "Returns the outcome of a task.", + "associated": true + }, + { + "kind": "fn", + "name": "scheduled", + "signature": "pub fn scheduled(&self) -> Vec {", + "doc": "Returns the identifiers of the tasks that were placed.", + "associated": true + }, + { + "kind": "fn", + "name": "deferred", + "signature": "pub fn deferred(&self) -> Vec {", + "doc": "Returns the identifiers of the tasks that were left out.", + "associated": true + }, + { + "kind": "fn", + "name": "infeasible", + "signature": "pub fn infeasible(&self) -> Vec {", + "doc": "Returns the identifiers of the tasks that cannot be placed at all.", + "associated": true + }, + { + "kind": "fn", + "name": "is_complete", + "signature": "pub fn is_complete(&self) -> bool {", + "doc": "Returns `true` when every offered task was placed.", + "associated": true + }, + { + "kind": "fn", + "name": "honours_mandatory", + "signature": "pub fn honours_mandatory(&self, tasks: &[Task]) -> bool {", + "doc": "Returns `true` when every mandatory task was placed.\n\nA plan that leaves a mandatory task unplaced is not usable, however\nmuch optional work it found room for.", + "associated": true + }, + { + "kind": "fn", + "name": "honours_precedence", + "signature": "pub fn honours_precedence(&self, graph: &PrecedenceGraph) -> bool {", + "doc": "Returns `true` when every precedence constraint is satisfied.\n\nA constraint is satisfied when the second task starts at or after the\nend of the first. A constraint whose tasks are not both placed is\ntrivially satisfied, because nothing has been promised about them.", + "associated": true + }, + { + "kind": "struct", + "name": "Planner", + "signature": "pub struct Planner {", + "doc": "A greedy planner.\n\nThe planner holds the settings of the search. The settings are separated\nfrom the schedule so that the same planner can be applied to many task\nsets with the same policy.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new() -> Self {", + "doc": "Creates a planner with the default settings.\n\nThe defaults are the ones [`Planner::default`] uses: mandatory tasks\nfirst and the improvement pass enabled with four attempts per task.", + "associated": true + }, + { + "kind": "fn", + "name": "with_mandatory_first", + "signature": "pub const fn with_mandatory_first(mut self, mandatory_first: bool) -> Self {", + "doc": "Returns a copy of this planner with the mandatory placement rule set.", + "associated": true + }, + { + "kind": "fn", + "name": "with_improvement", + "signature": "pub const fn with_improvement(mut self, improvement: ImprovementSettings) -> Self {", + "doc": "Returns a copy of this planner with the improvement pass configured.", + "associated": true + }, + { + "kind": "fn", + "name": "order", + "signature": "pub fn order(&self, tasks: &[Task]) -> Vec {", + "doc": "Returns the order in which the planner considers the tasks.\n\nWithout a precedence graph the order is mandatory tasks first, when\nthe planner is configured that way, and then decreasing weighted\nvalue with ties broken by deadline and identifier.\n\nWith a precedence graph the order is a topological order of the\ngraph, using the value order to break ties. That places a task as\nearly as its constraints allow and the most valuable one first among\nthe tasks that are free.", + "associated": true + }, + { + "kind": "fn", + "name": "order_with_precedence", + "signature": "pub fn order_with_precedence( &self, tasks: &[Task], precedence: Option<&PrecedenceGraph>, ) -> Vec {", + "doc": "Returns the order in which the planner considers the tasks, honouring\na precedence graph.\n\nA graph whose constraints contain a cycle produces the value order\nunchanged, because the constraints then have no satisfiable\ninterpretation. The caller should validate the graph before\nplanning; the order is a fallback so that planning still produces\nsomething rather than failing outright.", + "associated": true + }, + { + "kind": "fn", + "name": "schedule", + "signature": "pub fn schedule(&self, tasks: &[Task], resources: &ResourceSet) -> SchedulingResult {", + "doc": "Plans a set of tasks on a set of resources.\n\nEvery task is considered once, in the order the planner chooses. A\ntask is placed at the earliest time at which all of its resources are\nfree together. A task that cannot be placed is reported as deferred,\nor as infeasible when no placement could ever exist.\n\nThe schedule that is returned is feasible by construction, because\neach placement is checked against the capacity before it is made. The\ncaller may confirm that with `apsis-conflict`.", + "associated": true + }, + { + "kind": "fn", + "name": "schedule_with_precedence", + "signature": "pub fn schedule_with_precedence( &self, tasks: &[Task], resources: &ResourceSet, precedence: &PrecedenceGraph, ) -> SchedulingResult {", + "doc": "Plans a set of tasks, honouring a precedence graph.\n\nA task is placed only after every task that must precede it has been\nplaced, so the search for its start begins no earlier than the latest\nend among its predecessors. A predecessor that could not be placed\nmakes the successor deferred as well, because the constraint cannot be\nmet.\n\nThe improvement pass is not applied to a plan with precedence\nconstraints, because relocating a placement could break a constraint\nthat the pass does not consider. A caller that needs both should\nimprove the plan and then verify it with\n[`SchedulingResult::honours_precedence`].", + "associated": true + }, + { + "kind": "fn", + "name": "earliest_feasible_start", + "signature": "pub fn earliest_feasible_start( task: &Task, tasks: &[Task], schedule: &Schedule, resources: &ResourceSet, ) -> Option {", + "doc": "Returns the earliest time at which a task can be placed.\n\nThe earliest feasible start is always one of a small set of candidate\ntimes: the start of the task's window, the start of an availability\nwindow of one of its resources, or the end of a placement already on one\nof those resources. Feasibility cannot change at any other instant,\nbecause the demand on every resource is constant between those events and\nthe availability is constant between them too.\n\nReturns `None` when the task cannot be placed anywhere in its window.", + "associated": false + } + ] + }, + { + "name": "apsis-conflict", + "layer": "ops", + "summary": "Resource and time conflict detection. A schedule can be wrong in several ways, and this crate finds them. It reports conflicts rather than refusing them, so that a plan loaded from a file can be checked in full and every problem listed at once instead of one per attempt. Five kinds of conflict are detected: - **Capacity**: two placements need more of a resource at the same instant than the resource has. - **Window**: a placement starts before its task may, or ends after it must. - **Requirement**: a placement's task is not known, or its duration does not match the schedule. - **Availability**: a placement falls outside the availability windows of a resource it needs, so the resource would not actually be there. - **Precedence**: a task that must follow another starts before it ends. The distinction between a capacity conflict and an availability conflict matters in practice: the first says the plan is over committed, the second says the plan asks for a resource at a time when it is switched off or out of view, which is a different repair. ``` use apsis_resource::{Resource, ResourceId, ResourceSet, Window}; use apsis_schedule::Schedule; use apsis_task::{Task, TaskId}; use apsis_conflict::{ConflictKind, detect}; let mut station = Resource::new(ResourceId::new(0), 1.0); station.set_windows(&[Window::new(0.0, 1_000.0)]).expect(\"usable\"); let resources = ResourceSet::from_resources(vec![station]).expect(\"distinct\"); let task = Task::new(TaskId::new(0), 60.0) .try_require(ResourceId::new(0), 1.0) .expect(\"the resource is new\"); let mut schedule = Schedule::new(); schedule.place(&task, 100.0).expect(\"the task fits\"); let report = detect(&schedule, &[task], &resources); assert!(report.is_clear()); assert_eq!(report.conflicts().len(), 0); let _ = ConflictKind::Capacity; ```", + "items": [ + { + "kind": "enum", + "name": "ConflictKind", + "signature": "pub enum ConflictKind {", + "doc": "The kind of problem a conflict describes.", + "associated": false + }, + { + "kind": "struct", + "name": "Conflict", + "signature": "pub struct Conflict {", + "doc": "One problem found in a schedule.\n\nThe conflict names the tasks involved and, where it applies, the resource\nand the instant at which the problem occurs. That is enough for an\noperator to locate the placement without re-deriving the analysis.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(kind: ConflictKind, task: TaskId, at: f64, detail: impl Into) -> Self {", + "doc": "Creates a conflict.", + "associated": true + }, + { + "kind": "fn", + "name": "with_other", + "signature": "pub fn with_other(mut self, other: TaskId) -> Self {", + "doc": "Returns a copy of this conflict involving another task.", + "associated": true + }, + { + "kind": "fn", + "name": "with_resource", + "signature": "pub fn with_resource(mut self, resource: ResourceId) -> Self {", + "doc": "Returns a copy of this conflict involving a resource.", + "associated": true + }, + { + "kind": "fn", + "name": "is_movable", + "signature": "pub const fn is_movable(&self) -> bool {", + "doc": "Returns `true` when the conflict concerns a placement that should be\nmoved rather than one that should be dropped.\n\nA capacity or availability conflict can often be repaired by moving\nthe placement, while a requirement conflict means the placement does\nnot belong in the schedule at all.", + "associated": true + }, + { + "kind": "struct", + "name": "ConflictReport", + "signature": "pub struct ConflictReport {", + "doc": "The conflicts found in a schedule.\n\nThe report is ordered by time and then by kind, so that reading it\nfollows the plan. The counts by kind are provided because a planner\nusually wants to know how many of each problem there are before deciding\nwhat to do.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(mut conflicts: Vec, offered_tasks: usize, placed_tasks: usize) -> Self {", + "doc": "Creates a report from a list of conflicts and the task counts.\n\nThe conflicts are sorted by time, then by kind, then by task, so that\nthe order does not depend on the order they were found.", + "associated": true + }, + { + "kind": "fn", + "name": "conflicts", + "signature": "pub fn conflicts(&self) -> &[Conflict] {", + "doc": "Returns the conflicts, in time order.", + "associated": true + }, + { + "kind": "fn", + "name": "len", + "signature": "pub fn len(&self) -> usize {", + "doc": "Returns the number of conflicts.", + "associated": true + }, + { + "kind": "fn", + "name": "is_empty", + "signature": "pub fn is_empty(&self) -> bool {", + "doc": "Returns `true` when there are no conflicts.\n\nThis is the conventional spelling of [`ConflictReport::is_clear`], and\nthe two always agree.", + "associated": true + }, + { + "kind": "fn", + "name": "is_clear", + "signature": "pub fn is_clear(&self) -> bool {", + "doc": "Returns `true` when there are no conflicts.\n\nA clear report means the schedule is feasible with respect to every\ncheck this crate performs, not that it is optimal.", + "associated": true + }, + { + "kind": "fn", + "name": "has_conflicts", + "signature": "pub fn has_conflicts(&self) -> bool {", + "doc": "Returns `true` when there is at least one conflict.", + "associated": true + }, + { + "kind": "fn", + "name": "count_of", + "signature": "pub fn count_of(&self, kind: ConflictKind) -> usize {", + "doc": "Returns the number of conflicts of one kind.", + "associated": true + }, + { + "kind": "fn", + "name": "of_kind", + "signature": "pub fn of_kind(&self, kind: ConflictKind) -> Vec<&Conflict> {", + "doc": "Returns the conflicts of one kind, in time order.", + "associated": true + }, + { + "kind": "fn", + "name": "conflicting_tasks", + "signature": "pub fn conflicting_tasks(&self) -> Vec {", + "doc": "Returns the tasks involved in at least one conflict.\n\nA task appears once even when it has several conflicts, which is what\na repair pass wants: the set of tasks to reconsider.", + "associated": true + }, + { + "kind": "fn", + "name": "offered_tasks", + "signature": "pub fn offered_tasks(&self) -> usize {", + "doc": "Returns the number of tasks that were offered to the schedule.", + "associated": true + }, + { + "kind": "fn", + "name": "placed_tasks", + "signature": "pub fn placed_tasks(&self) -> usize {", + "doc": "Returns the number of tasks that were placed.", + "associated": true + }, + { + "kind": "fn", + "name": "summary", + "signature": "pub fn summary(&self) -> String {", + "doc": "Returns a one line summary of the report.", + "associated": true + }, + { + "kind": "struct", + "name": "Precedence", + "signature": "pub struct Precedence {", + "doc": "A precedence constraint: one task must finish before another starts.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(before: TaskId, after: TaskId) -> Self {", + "doc": "Creates a precedence constraint.", + "associated": true + }, + { + "kind": "fn", + "name": "placements_of", + "signature": "pub fn placements_of(schedule: &Schedule, task: TaskId) -> Option<&apsis_schedule::Placement> {", + "doc": "Returns the placements of a task, which is at most one.", + "associated": false + }, + { + "kind": "fn", + "name": "detect", + "signature": "pub fn detect(schedule: &Schedule, tasks: &[Task], resources: &ResourceSet) -> ConflictReport {", + "doc": "Returns every conflict in a schedule.\n\nThe checks are performed in full: the report contains every problem found,\nnot just the first. That is deliberate, because a planner that is handed a\nplan usually wants to know how bad it is before deciding whether to repair\nor rebuild.\n\nThe checks are:\n\n- every placement's task exists in the list, and the durations agree;\n- every placement falls inside its task's window;\n- no resource exceeds its capacity at any instant;\n- every placement falls inside the availability windows of each resource\n it requires.\n\nThe resource sweep examines the boundaries of the placements, because the\ntotal demand on a resource is constant between them, so the check is exact\nand takes time proportional to the number of placements rather than to the\nlength of the plan.", + "associated": false + }, + { + "kind": "fn", + "name": "detect_precedence", + "signature": "pub fn detect_precedence(schedule: &Schedule, constraints: &[Precedence]) -> Vec {", + "doc": "Returns the precedence conflicts among a set of constraints.\n\nA precedence conflict occurs when the task that must come second starts\nbefore the one that must come first has finished. A constraint whose\ntasks are not both placed is not a conflict, because nothing has been\npromised about them.\n\nThe constraints are checked as given, so they must be acyclic. A cycle is\na contradiction in the caller's own model and is not detected here.", + "associated": false + } + ] + }, + { + "name": "apsis-schedule", + "layer": "ops", + "summary": "Schedule representation and evaluation. A schedule is the answer a scheduler produces: for each task, when it runs and on which resources. This crate holds that answer and evaluates it. It does not decide the placements, which is the job of `apsis-scheduler`; keeping the two apart lets a schedule be read from a file, produced by a scheduler, or written by hand, and evaluated the same way. A placement records the task, its start time, and the resources it holds. The resources are checked against the task's requirements when the placement is made, so a schedule cannot describe a task running on a resource it did not ask for. The evaluation reports what an operator asks of a finished plan: how many tasks were placed, how much of each resource is used at its busiest, and how much of the available time was actually spent. Those figures are what a planner compares between candidate schedules. ``` use apsis_resource::ResourceId; use apsis_schedule::Schedule; use apsis_task::{Task, TaskId}; let task = Task::new(TaskId::new(0), 60.0) .try_require(ResourceId::new(0), 1.0) .expect(\"the resource is not yet required\"); let mut schedule = Schedule::new(); schedule.place(&task, 100.0).expect(\"the task fits\"); assert_eq!(schedule.len(), 1); assert!(schedule.is_placed(TaskId::new(0))); ```", + "items": [ + { + "kind": "enum", + "name": "ScheduleErrorKind", + "signature": "pub enum ScheduleErrorKind {", + "doc": "The category of a schedule error.", + "associated": false + }, + { + "kind": "struct", + "name": "ScheduleError", + "signature": "pub struct ScheduleError {", + "doc": "A failure while building or evaluating a schedule.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(kind: ScheduleErrorKind) -> Self {", + "doc": "Creates an error with the given category.", + "associated": true + }, + { + "kind": "fn", + "name": "with_detail", + "signature": "pub fn with_detail(mut self, detail: impl Into) -> Self {", + "doc": "Attaches a description of the values that caused the failure.", + "associated": true + }, + { + "kind": "fn", + "name": "kind", + "signature": "pub fn kind(&self) -> ScheduleErrorKind {", + "doc": "Returns the category of this failure.", + "associated": true + }, + { + "kind": "fn", + "name": "detail", + "signature": "pub fn detail(&self) -> Option<&str> {", + "doc": "Returns the detail text attached to this error, if any.", + "associated": true + }, + { + "kind": "struct", + "name": "Placement", + "signature": "pub struct Placement {", + "doc": "One task placed on the schedule.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new(task: TaskId, start: f64, duration: f64) -> Self {", + "doc": "Creates a placement.", + "associated": true + }, + { + "kind": "fn", + "name": "end", + "signature": "pub fn end(&self) -> f64 {", + "doc": "Returns the end time of the placement, in seconds.", + "associated": true + }, + { + "kind": "fn", + "name": "covers", + "signature": "pub fn covers(&self, time: f64) -> bool {", + "doc": "Returns `true` when the placement covers an instant.\n\nThe interval is half-open, so a placement covers its start and not its\nend. That convention lets two back to back placements share a boundary\nwithout overlapping.", + "associated": true + }, + { + "kind": "fn", + "name": "overlaps", + "signature": "pub fn overlaps(&self, other: &Self) -> bool {", + "doc": "Returns `true` when the placement overlaps another.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub fn is_valid(&self) -> bool {", + "doc": "Returns `true` when the placement is usable.", + "associated": true + }, + { + "kind": "struct", + "name": "ResourceUsage", + "signature": "pub struct ResourceUsage {", + "doc": "How much of one resource a schedule uses.", + "associated": false + }, + { + "kind": "fn", + "name": "utilisation", + "signature": "pub fn utilisation(&self, interval_seconds: f64) -> Option {", + "doc": "Returns the utilisation of the resource over an interval.\n\nThe value is the busy time divided by the length of the interval. It\nis a time utilisation, not a capacity utilisation: a resource that is\nonly half occupied by demand still counts as busy.\n\nReturns `None` when the interval is not positive.", + "associated": true + }, + { + "kind": "struct", + "name": "Evaluation", + "signature": "pub struct Evaluation {", + "doc": "The evaluation of a whole schedule.", + "associated": false + }, + { + "kind": "fn", + "name": "makespan", + "signature": "pub fn makespan(&self) -> f64 {", + "doc": "Returns the length of the planning horizon the schedule covers, in\nseconds.\n\nThis is the time from the earliest start to the latest end, which is\nthe makespan of the plan.", + "associated": true + }, + { + "kind": "fn", + "name": "fill_rate", + "signature": "pub fn fill_rate(&self) -> Option {", + "doc": "Returns the fraction of the offered tasks that were placed.\n\nReturns `None` when no tasks were offered.", + "associated": true + }, + { + "kind": "fn", + "name": "is_complete", + "signature": "pub fn is_complete(&self) -> bool {", + "doc": "Returns `true` when every offered task was placed.", + "associated": true + }, + { + "kind": "fn", + "name": "usage_of", + "signature": "pub fn usage_of(&self, resource: ResourceId) -> Option<&ResourceUsage> {", + "doc": "Returns the usage of one resource.", + "associated": true + }, + { + "kind": "struct", + "name": "Schedule", + "signature": "pub struct Schedule {", + "doc": "A set of task placements.\n\nThe schedule stores placements in the order they were made and keeps an\nindex from task to placement, so that a lookup by task is direct. A task\nmay be placed at most once: placing it again is an error rather than a\nsilent overwrite, because a schedule that contains two placements for one\ntask is not a schedule.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new() -> Self {", + "doc": "Creates an empty schedule.", + "associated": true + }, + { + "kind": "fn", + "name": "placements", + "signature": "pub fn placements(&self) -> &[Placement] {", + "doc": "Returns the placements in the order they were made.", + "associated": true + }, + { + "kind": "fn", + "name": "len", + "signature": "pub fn len(&self) -> usize {", + "doc": "Returns the number of placements.", + "associated": true + }, + { + "kind": "fn", + "name": "is_empty", + "signature": "pub fn is_empty(&self) -> bool {", + "doc": "Returns `true` when there are no placements.", + "associated": true + }, + { + "kind": "fn", + "name": "is_placed", + "signature": "pub fn is_placed(&self, task: TaskId) -> bool {", + "doc": "Returns `true` when a task has been placed.", + "associated": true + }, + { + "kind": "fn", + "name": "placement_of", + "signature": "pub fn placement_of(&self, task: TaskId) -> Option<&Placement> {", + "doc": "Returns the placement of a task.", + "associated": true + }, + { + "kind": "fn", + "name": "place", + "signature": "pub fn place( &mut self, task: &apsis_task::Task, start: f64, ) -> Result<&Placement, ScheduleError> {", + "doc": "Places a task at a time.\n\nThe placement is checked against the task's window. The resources of\nthe task are recorded with the placement through the task itself, so\nthis method does not need them; the capacity check is performed by\n[`Schedule::validate_capacity`], which needs the resources.\n\nReturns an error when the task is already placed, when the start time\nis not finite, or when the placement falls outside the task's window.", + "associated": true + }, + { + "kind": "fn", + "name": "unplace", + "signature": "pub fn unplace(&mut self, task: TaskId) -> Result {", + "doc": "Removes the placement of a task.\n\nReturns an error when the task is not placed.", + "associated": true + }, + { + "kind": "fn", + "name": "placements_on", + "signature": "pub fn placements_on( &self, resource: ResourceId, tasks: &[apsis_task::Task], ) -> Vec {", + "doc": "Returns the placements that use a resource, in time order.", + "associated": true + }, + { + "kind": "fn", + "name": "has_overlapping_placements", + "signature": "pub fn has_overlapping_placements(&self) -> bool {", + "doc": "Returns `true` when two placements of different tasks overlap.\n\nThis checks the schedule against itself, ignoring the resources. Two\ntasks on disjoint resources may legitimately overlap, so a caller that\nwants to know whether a resource is overbooked should use\n[`Schedule::validate_capacity`] instead.", + "associated": true + }, + { + "kind": "fn", + "name": "scheduled_seconds", + "signature": "pub fn scheduled_seconds(&self) -> f64 {", + "doc": "Returns the total placed duration, in seconds.", + "associated": true + }, + { + "kind": "fn", + "name": "earliest_start", + "signature": "pub fn earliest_start(&self) -> Option {", + "doc": "Returns the earliest start of any placement.\n\nReturns `None` when the schedule is empty.", + "associated": true + }, + { + "kind": "fn", + "name": "latest_end", + "signature": "pub fn latest_end(&self) -> Option {", + "doc": "Returns the latest end of any placement.\n\nReturns `None` when the schedule is empty.", + "associated": true + }, + { + "kind": "fn", + "name": "validate_capacity", + "signature": "pub fn validate_capacity( &self, tasks: &[apsis_task::Task], resources: &apsis_resource::ResourceSet, ) -> Result<(), ScheduleError> {", + "doc": "Validates a schedule against a set of resources.\n\nThe check has two parts. First every placement must agree with the\nrequirements of its task, which catches a schedule built with a\ndifferent task list. Then, for each resource, the total demand placed\nat any instant must not exceed the capacity.\n\nThe second check walks the placement boundaries of each resource in\ntime order, which is exact because the total demand is constant\nbetween boundaries.\n\nReturns an error describing the first violation.", + "associated": true + }, + { + "kind": "fn", + "name": "evaluate", + "signature": "pub fn evaluate( &self, tasks: &[apsis_task::Task], resources: &apsis_resource::ResourceSet, ) -> Evaluation {", + "doc": "Evaluates the schedule against a task list and a resource set.\n\nThe evaluation reports the counts, the makespan, and the usage of each\nresource. The peak demand of a resource is found by the same event\nsweep that the capacity check uses, so the two agree.", + "associated": true + }, + { + "kind": "fn", + "name": "in_time_order", + "signature": "pub fn in_time_order(&self) -> Vec {", + "doc": "Returns the schedule sorted by start time, leaving the original alone.\n\nA schedule is stored in the order it was built, which is the order a\nscheduler considered the tasks. A caller that wants to read the plan\nin time order uses this.", + "associated": true + }, + { + "kind": "fn", + "name": "resource_busy_until", + "signature": "pub fn resource_busy_until( &self, resource: ResourceId, tasks: &[apsis_task::Task], time: f64, ) -> Option {", + "doc": "Returns the time at which a resource becomes free if it is busy at a\ngiven instant.\n\nReturns `None` when the resource is idle there. This is what a\nsequential scheduler asks when it looks for the next gap.", + "associated": true + } + ] + }, + { + "name": "apsis-sim-engine", + "layer": "sim", + "summary": "Discrete-event simulation engine. The engine advances a clock by jumping from one event to the next. It holds the event queue, the current simulation time, and the statistics a caller needs to judge a run. What an event means is the caller's: it supplies a handler that receives the event and may schedule more events, and the engine only sequences the calls. The engine keeps the simulation deterministic. Events at the same instant are delivered in the order they were scheduled, which the event queue guarantees, so a run with the same inputs produces the same output. That is what makes a result reproducible and a cross-check against another implementation meaningful. The engine stops for one of four reasons, reported as a [`StopReason`]: the queue empties, the simulation reaches its time limit, the caller asks it to stop, or an event limit is reached. The last two exist so that a model that schedules events forever cannot run without bound. ``` use apsis_sim_engine::Engine; let mut engine = Engine::new(); engine.schedule(10.0, \"hello\"); let mut seen = Vec::new(); let outcome = engine.run(|engine, event| { seen.push(event.payload); // The handler schedules the next event, which the engine delivers // after this one. It schedules it only once, so the queue drains. if event.payload == \"hello\" { engine.schedule(20.0, \"world\"); } }); assert_eq!(seen, vec![\"hello\", \"world\"]); assert_eq!(outcome.executed, 2); assert_eq!(engine.now(), 20.0); ```", + "items": [ + { + "kind": "struct", + "name": "Engine", + "signature": "pub struct Engine {", + "doc": "A discrete-event simulation engine.\n\nThe engine owns the event queue and the clock. A run repeatedly takes the\nearliest event and passes it to a handler, which may schedule more\nevents. The engine stops when the queue empties or when one of the limits\nis reached.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new() -> Self {", + "doc": "Creates an engine with the clock at zero and no limits.\n\nAn engine with no limits runs until the queue empties, which is the\nright default for a model that terminates. A model that schedules\nwithout bound needs a limit, which is set with\n[`Engine::with_time_limit`] or [`Engine::with_event_limit`].", + "associated": true + }, + { + "kind": "fn", + "name": "with_time_limit", + "signature": "pub const fn with_time_limit(mut self, limit: f64) -> Self {", + "doc": "Sets the latest simulation time the engine will process.\n\nAn event scheduled beyond the limit is left in the queue rather than\nexecuted, so the caller can examine what remained. A limit that is\nnegative or not finite disables the limit.", + "associated": true + }, + { + "kind": "fn", + "name": "with_event_limit", + "signature": "pub const fn with_event_limit(mut self, limit: u64) -> Self {", + "doc": "Sets the greatest number of events the engine will execute.\n\nA limit of zero disables the rule, because a run that executes no\nevents is never useful.", + "associated": true + }, + { + "kind": "fn", + "name": "now", + "signature": "pub const fn now(&self) -> f64 {", + "doc": "Returns the current simulation time, in seconds.", + "associated": true + }, + { + "kind": "fn", + "name": "set_now", + "signature": "pub fn set_now(&mut self, time: f64) {", + "doc": "Sets the current simulation time.\n\nThis is used to start a run at an offset, for example at a scenario\nepoch rather than at zero. It does not reschedule pending events, so a\ncaller that moves the clock forward past a pending event will execute\nthat event late. The engine has no way to detect the mistake, so the\ncaller is responsible for keeping the clock consistent with the\nqueue.", + "associated": true + }, + { + "kind": "fn", + "name": "executed", + "signature": "pub const fn executed(&self) -> u64 {", + "doc": "Returns the number of events executed so far.", + "associated": true + }, + { + "kind": "fn", + "name": "pending", + "signature": "pub fn pending(&self) -> usize {", + "doc": "Returns the number of pending events.", + "associated": true + }, + { + "kind": "fn", + "name": "is_idle", + "signature": "pub fn is_idle(&self) -> bool {", + "doc": "Returns `true` when no events are pending.", + "associated": true + }, + { + "kind": "fn", + "name": "next_time", + "signature": "pub fn next_time(&self) -> Option {", + "doc": "Returns the time of the next pending event, if there is one.", + "associated": true + }, + { + "kind": "fn", + "name": "state", + "signature": "pub fn state(&self) -> EngineState {", + "doc": "Returns what the engine currently holds.", + "associated": true + }, + { + "kind": "fn", + "name": "schedule", + "signature": "pub fn schedule(&mut self, time: f64, payload: T) -> Option {", + "doc": "Schedules an event at a time.\n\nReturns the identifier of the new event, or `None` when the time is\nnot usable.", + "associated": true + }, + { + "kind": "fn", + "name": "schedule_after", + "signature": "pub fn schedule_after(&mut self, delay: f64, payload: T) -> Option {", + "doc": "Schedules an event at a delay from the current simulation time.\n\nReturns the identifier of the new event, or `None` when the delay is\nnot usable.", + "associated": true + }, + { + "kind": "fn", + "name": "request_stop", + "signature": "pub fn request_stop(&mut self) {", + "doc": "Asks the engine to stop after the current event.\n\nThe handler calls this when its own model says the run is over, for\nexample when a stopping condition that the queue cannot express is\nmet. The pending events are left in the queue.", + "associated": true + }, + { + "kind": "fn", + "name": "clear_stop", + "signature": "pub fn clear_stop(&mut self) {", + "doc": "Clears the stop request, so that a subsequent run continues.", + "associated": true + }, + { + "kind": "fn", + "name": "clear", + "signature": "pub fn clear(&mut self) -> usize {", + "doc": "Removes every pending event and returns how many were discarded.", + "associated": true + }, + { + "kind": "fn", + "name": "queue", + "signature": "pub const fn queue(&self) -> &EventQueue {", + "doc": "Returns the queue, for a caller that needs to inspect or manipulate it\ndirectly.", + "associated": true + }, + { + "kind": "fn", + "name": "queue_mut", + "signature": "pub const fn queue_mut(&mut self) -> &mut EventQueue {", + "doc": "Returns the queue for modification.", + "associated": true + }, + { + "kind": "fn", + "name": "run", + "signature": "pub fn run(&mut self, mut handler: F) -> EngineOutcome where F: FnMut(&mut Engine, Event), {", + "doc": "Runs the engine until it stops.\n\nEach iteration takes the earliest event, advances the clock to its\ntime, and passes it to the handler. The handler may schedule further\nevents, including at the same instant, which are delivered after the\nevents already scheduled for that instant.\n\nReturns the outcome of the run. The clock is left at the time of the\nlast event executed, not at a limit, so a caller that records the\nclock sees the last instant the model actually reached.", + "associated": true + }, + { + "kind": "enum", + "name": "StopReason", + "signature": "pub enum StopReason {", + "doc": "Why a run stopped.\n\nThe reason is reported rather than inferred from the event count, because\nthe same count can arise from an empty queue or from a limit, and a\ncaller that records the outcome of a run needs to distinguish them.", + "associated": false + }, + { + "kind": "fn", + "name": "is_natural", + "signature": "pub const fn is_natural(self) -> bool {", + "doc": "Returns `true` when the run ended because the model had no more work\nto do rather than because a limit intervened.", + "associated": true + }, + { + "kind": "fn", + "name": "name", + "signature": "pub const fn name(self) -> &'static str {", + "doc": "Returns a short name for the reason.", + "associated": true + }, + { + "kind": "struct", + "name": "EngineOutcome", + "signature": "pub struct EngineOutcome {", + "doc": "What a run produced.\n\nThe outcome records the counts and the clock, so a caller can record the\nresult of a run without reaching back into the engine.", + "associated": false + }, + { + "kind": "fn", + "name": "duration", + "signature": "pub fn duration(&self) -> f64 {", + "doc": "Returns the time the run covered, in seconds.", + "associated": true + }, + { + "kind": "fn", + "name": "event_rate", + "signature": "pub fn event_rate(&self) -> Option {", + "doc": "Returns the mean number of events per second of simulation time.\n\nThis measures how busy the model is rather than how long the run took\non the wall clock. Returns `None` when the run covered no time.", + "associated": true + }, + { + "kind": "fn", + "name": "is_natural", + "signature": "pub fn is_natural(&self) -> bool {", + "doc": "Returns `true` when the run finished because the model had no more\nwork rather than because a limit stopped it.", + "associated": true + }, + { + "kind": "struct", + "name": "EngineState", + "signature": "pub struct EngineState {", + "doc": "What the engine currently holds.", + "associated": false + }, + { + "kind": "fn", + "name": "is_idle", + "signature": "pub const fn is_idle(&self) -> bool {", + "doc": "Returns `true` when no events are pending.", + "associated": true + } + ] + }, + { + "name": "apsis-scenario", + "layer": "sim", + "summary": "Scenario definition and loading. A scenario is a simulation's initial conditions: the resources a plan may use, the tasks it must place, the precedence constraints between those tasks, and the limits the run should respect. It is the input a run is built from, and keeping it in one place means a run can be reproduced from a single description rather than reconstructed from scattered arguments. The crate offers two ways to build one. The builder methods are what Rust code uses, and they check each addition as it is made. The text format is what a stored scenario uses, and [`Scenario::parse`] reads it back. The two agree: a scenario written with [`Scenario::to_text`] parses to an equal scenario, so a scenario can be stored, edited, and reloaded without drift. The text format is line oriented and carries no third party dependency. A blank line and a line whose first non-space character is `#` are ignored. Every other line is a keyword followed by its arguments: ```text scenario leo downlink epoch 0 time_limit 3600 event_limit 1000000 resource 0 capacity 1 window 0 0 600 window 0 1200 1800 task 0 duration 60 task_window 0 100 1000 task_priority 0 5 task_mandatory 0 true require 0 0 1 precedence 0 1 ``` `epoch` is the time the simulation clock starts at, in seconds. `time_limit` and `event_limit` are the two run limits; either may be omitted, which leaves that limit unset. ``` use apsis_scenario::Scenario; let text = \"\\ scenario demo resource 0 capacity 1 window 0 0 600 task 0 duration 60 require 0 0 1 \"; let scenario = Scenario::parse(text).expect(\"the text is well formed\"); assert_eq!(scenario.name(), \"demo\"); assert_eq!(scenario.resources().len(), 1); assert_eq!(scenario.tasks().len(), 1); assert_eq!(scenario.validate(), Ok(())); ```", + "items": [ + { + "kind": "enum", + "name": "ScenarioErrorKind", + "signature": "pub enum ScenarioErrorKind {", + "doc": "The category of a scenario error.", + "associated": false + }, + { + "kind": "struct", + "name": "ScenarioError", + "signature": "pub struct ScenarioError {", + "doc": "A failure while building, loading, or validating a scenario.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(kind: ScenarioErrorKind) -> Self {", + "doc": "Creates an error with the given category.", + "associated": true + }, + { + "kind": "fn", + "name": "with_detail", + "signature": "pub fn with_detail(mut self, detail: impl Into) -> Self {", + "doc": "Attaches a description of the values that caused the failure.", + "associated": true + }, + { + "kind": "fn", + "name": "with_line", + "signature": "pub fn with_line(mut self, line: usize) -> Self {", + "doc": "Attaches the one based line number a load failure came from.", + "associated": true + }, + { + "kind": "fn", + "name": "kind", + "signature": "pub fn kind(&self) -> ScenarioErrorKind {", + "doc": "Returns the category of this failure.", + "associated": true + }, + { + "kind": "fn", + "name": "detail", + "signature": "pub fn detail(&self) -> Option<&str> {", + "doc": "Returns the detail text attached to this error, if any.", + "associated": true + }, + { + "kind": "fn", + "name": "line", + "signature": "pub fn line(&self) -> Option {", + "doc": "Returns the line number this failure came from, if any.", + "associated": true + }, + { + "kind": "struct", + "name": "Scenario", + "signature": "pub struct Scenario {", + "doc": "A simulation's initial conditions.\n\nA scenario holds the resources a plan may use, the tasks it must place,\nand the constraints between them, together with the limits a run should\nrespect. It is built either with the builder methods or by parsing text,\nand both routes apply the same checks, so a scenario that parses is one\nthat would have been accepted by the builders.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(name: impl Into) -> Self {", + "doc": "Creates an empty scenario with an epoch at zero.", + "associated": true + }, + { + "kind": "fn", + "name": "name", + "signature": "pub fn name(&self) -> &str {", + "doc": "Returns the name of the scenario.", + "associated": true + }, + { + "kind": "fn", + "name": "epoch", + "signature": "pub fn epoch(&self) -> f64 {", + "doc": "Returns the time the simulation clock starts at, in seconds.", + "associated": true + }, + { + "kind": "fn", + "name": "time_limit", + "signature": "pub fn time_limit(&self) -> Option {", + "doc": "Returns the latest time the run will process, if one is set.", + "associated": true + }, + { + "kind": "fn", + "name": "event_limit", + "signature": "pub const fn event_limit(&self) -> Option {", + "doc": "Returns the greatest number of events the run will execute, if one is\nset.", + "associated": true + }, + { + "kind": "fn", + "name": "resources", + "signature": "pub fn resources(&self) -> &[Resource] {", + "doc": "Returns the resources in the order they were added.", + "associated": true + }, + { + "kind": "fn", + "name": "resources_mut", + "signature": "pub fn resources_mut(&mut self) -> &mut Vec {", + "doc": "Returns the resources for modification.\n\nA caller that changes a resource is responsible for keeping the\nscenario consistent; [`Scenario::validate`] checks it afterwards.", + "associated": true + }, + { + "kind": "fn", + "name": "parse", + "signature": "pub fn parse(text: &str) -> Result {", + "doc": "Reads a scenario from its text form.\n\nThe format is described on the crate documentation. A scenario written\nwith [`Scenario::to_text`] parses back to an equal scenario.", + "associated": true + }, + { + "kind": "fn", + "name": "tasks", + "signature": "pub fn tasks(&self) -> &[Task] {", + "doc": "Returns the tasks in the order they were added.", + "associated": true + }, + { + "kind": "fn", + "name": "precedence", + "signature": "pub const fn precedence(&self) -> &PrecedenceGraph {", + "doc": "Returns the precedence constraints.", + "associated": true + }, + { + "kind": "fn", + "name": "with_epoch", + "signature": "pub fn with_epoch(mut self, epoch: f64) -> Self {", + "doc": "Sets the time the simulation clock starts at.\n\nReturns the scenario unchanged in value but not in type; the caller\nuses the returned value. The epoch must be finite and non-negative.", + "associated": true + }, + { + "kind": "fn", + "name": "with_time_limit", + "signature": "pub const fn with_time_limit(mut self, limit: f64) -> Self {", + "doc": "Sets the latest time the run will process.\n\nA negative or non-finite limit clears the limit rather than storing\nit, matching the engine, which ignores a limit it cannot use.", + "associated": true + }, + { + "kind": "fn", + "name": "with_event_limit", + "signature": "pub const fn with_event_limit(mut self, limit: u64) -> Self {", + "doc": "Sets the greatest number of events the run will execute.\n\nA limit of zero clears the limit rather than storing it, matching the\nengine, which treats zero as no limit.", + "associated": true + }, + { + "kind": "fn", + "name": "set_epoch", + "signature": "pub fn set_epoch(&mut self, epoch: f64) -> Result<(), ScenarioError> {", + "doc": "Sets the epoch, reporting a value the clock cannot use.", + "associated": true + }, + { + "kind": "fn", + "name": "set_time_limit", + "signature": "pub fn set_time_limit(&mut self, limit: f64) -> Result<(), ScenarioError> {", + "doc": "Sets the time limit, reporting a value a run cannot use.", + "associated": true + }, + { + "kind": "fn", + "name": "add_resource", + "signature": "pub fn add_resource(&mut self, resource: Resource) -> Result<(), ScenarioError> {", + "doc": "Adds a resource, reporting one whose identifier is already used.\n\nThe resource itself is also checked, so a scenario cannot hold a\nresource that a scheduler would refuse.", + "associated": true + }, + { + "kind": "fn", + "name": "add_task", + "signature": "pub fn add_task(&mut self, task: Task) -> Result<(), ScenarioError> {", + "doc": "Adds a task, reporting one whose identifier is already used.\n\nThe task's requirements must name resources the scenario holds, and\nthe task itself must be usable.", + "associated": true + }, + { + "kind": "fn", + "name": "add_precedence", + "signature": "pub fn add_precedence(&mut self, before: TaskId, after: TaskId) -> Result<(), ScenarioError> {", + "doc": "Adds a precedence constraint, reporting a cycle or a missing task.", + "associated": true + }, + { + "kind": "fn", + "name": "resource_set", + "signature": "pub fn resource_set(&self) -> Result {", + "doc": "Returns the resources as a set, for a scheduler.", + "associated": true + }, + { + "kind": "fn", + "name": "validate", + "signature": "pub fn validate(&self) -> Result<(), ScenarioError> {", + "doc": "Checks the whole scenario at once.\n\nA scenario built with the builder methods is already checked as it is\nbuilt; this is for one that was assembled field by field or read from\ntext, and it reports the first problem found.", + "associated": true + }, + { + "kind": "fn", + "name": "summary", + "signature": "pub fn summary(&self) -> ScenarioSummary {", + "doc": "Returns a summary of what the scenario holds.", + "associated": true + }, + { + "kind": "fn", + "name": "to_text", + "signature": "pub fn to_text(&self) -> String {", + "doc": "Renders the scenario in the text form [`Scenario::parse`] reads.\n\nThe output parses to an equal scenario, which is what lets a scenario\nbe stored and reloaded without drift.", + "associated": true + }, + { + "kind": "struct", + "name": "ScenarioSummary", + "signature": "pub struct ScenarioSummary {", + "doc": "What a scenario holds, as counts and totals.", + "associated": false + } + ] + }, + { + "name": "apsis-event", + "layer": "sim", + "summary": "Simulation event types and event queue. A discrete-event simulation advances by jumping from one event to the next rather than by stepping through every instant. This crate provides the queue that makes that possible: a priority queue ordered by time, with a well defined order among events that fall at the same instant. The tie-break matters more than it first appears. Two events scheduled for the same instant must be delivered in a repeatable order, or the simulation produces a different result on each run and a cross-check against another implementation is impossible. Each event therefore carries a sequence number assigned when it is scheduled, and events at equal times are ordered by that number, so the order is the order in which they were scheduled. That is the convention used by every discrete-event simulator. Times are in seconds from the start of the simulation, so the queue holds no knowledge of calendars or time scales. ``` use apsis_event::EventQueue; let mut queue = EventQueue::new(); queue.schedule(10.0, \"second\"); queue.schedule(5.0, \"first\"); // The earliest event comes out first. let event = queue.pop().expect(\"the queue is not empty\"); assert_eq!(event.time, 5.0); assert_eq!(event.payload, \"first\"); ```", + "items": [ + { + "kind": "struct", + "name": "EventId", + "signature": "pub struct EventId {", + "doc": "An identifier for a scheduled event.\n\nThe identifier combines the time at which the event is due with the order\nin which it was scheduled. Two events that fall at the same instant are\ntherefore distinguishable, which is what makes the delivery order\nrepeatable.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(time: f64, sequence: u64) -> Option {", + "doc": "Creates an identifier from a time and a sequence number.\n\nThe time must be finite and non-negative. A caller that has a time\noutside that range cannot use this type, which is deliberate: a\nsimulation clock that goes backwards or becomes infinite has no\nmeaningful order.", + "associated": true + }, + { + "kind": "fn", + "name": "time", + "signature": "pub fn time(self) -> f64 {", + "doc": "Returns the scheduled time, in seconds.", + "associated": true + }, + { + "kind": "fn", + "name": "sequence", + "signature": "pub const fn sequence(self) -> u64 {", + "doc": "Returns the order in which the event was scheduled.", + "associated": true + }, + { + "kind": "struct", + "name": "Event", + "signature": "pub struct Event {", + "doc": "One scheduled event.\n\nThe payload is the caller's: it names what should happen. The queue does\nnot interpret it, so the same queue serves any model.", + "associated": false + }, + { + "kind": "fn", + "name": "is_due", + "signature": "pub fn is_due(&self, now: f64) -> bool {", + "doc": "Returns `true` when the event is due at or before a time.", + "associated": true + }, + { + "kind": "fn", + "name": "with_payload", + "signature": "pub fn with_payload(self, payload: U) -> Event {", + "doc": "Returns a copy of this event with a different payload.", + "associated": true + }, + { + "kind": "fn", + "name": "reschedule", + "signature": "pub fn reschedule(&self, time: f64, sequence: u64) -> Option> where T: Clone, {", + "doc": "Returns a copy of this event scheduled at a different time.\n\nReturns `None` when the new time is not usable, which leaves the\noriginal event available for the caller to keep using.", + "associated": true + }, + { + "kind": "struct", + "name": "QueueState", + "signature": "pub struct QueueState {", + "doc": "What the queue currently holds.\n\nThe state is exposed so that a simulation loop can decide whether to\nadvance the clock or to stop without popping an event first.", + "associated": false + }, + { + "kind": "fn", + "name": "is_idle", + "signature": "pub const fn is_idle(&self) -> bool {", + "doc": "Returns `true` when no events are pending.", + "associated": true + }, + { + "kind": "struct", + "name": "EventQueue", + "signature": "pub struct EventQueue {", + "doc": "A priority queue of events ordered by time and then by schedule order.\n\nThe queue assigns the sequence numbers, so a caller cannot accidentally\ncreate two events with the same identifier. Sequence numbers start at\nzero and increase by one with every event scheduled, including events\nscheduled from inside the handling of another event.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new() -> Self {", + "doc": "Creates an empty queue.", + "associated": true + }, + { + "kind": "fn", + "name": "len", + "signature": "pub fn len(&self) -> usize {", + "doc": "Returns the number of pending events.", + "associated": true + }, + { + "kind": "fn", + "name": "is_empty", + "signature": "pub fn is_empty(&self) -> bool {", + "doc": "Returns `true` when no events are pending.", + "associated": true + }, + { + "kind": "fn", + "name": "delivered", + "signature": "pub const fn delivered(&self) -> u64 {", + "doc": "Returns the number of events delivered so far.", + "associated": true + }, + { + "kind": "fn", + "name": "state", + "signature": "pub fn state(&self) -> QueueState {", + "doc": "Returns what the queue currently holds.", + "associated": true + }, + { + "kind": "fn", + "name": "next_time", + "signature": "pub fn next_time(&self) -> Option {", + "doc": "Returns the time of the earliest pending event.\n\nReturns `None` when the queue is empty.", + "associated": true + }, + { + "kind": "fn", + "name": "schedule", + "signature": "pub fn schedule(&mut self, time: f64, payload: T) -> Option {", + "doc": "Schedules an event at a time.\n\nThe sequence number is assigned by the queue, so the delivery order\namong events at the same instant is the order they were scheduled.\n\nReturns the identifier of the new event, or `None` when the time is\nnot usable: a simulation clock runs forward from zero, so a negative\nor non-finite time is refused rather than stored.", + "associated": true + }, + { + "kind": "fn", + "name": "schedule_after", + "signature": "pub fn schedule_after(&mut self, now: f64, delay: f64, payload: T) -> Option {", + "doc": "Schedules an event at a time relative to a base instant.\n\nThis is the form a handler uses when it schedules a follow-up: the\ndelay is relative to the current simulation time.\n\nReturns `None` when the resulting time is not usable.", + "associated": true + }, + { + "kind": "fn", + "name": "pop", + "signature": "pub fn pop(&mut self) -> Option> {", + "doc": "Removes and returns the earliest event.\n\nReturns `None` when the queue is empty. The payload is taken from the\nqueue, so the slot is left empty; scheduling a later event reuses the\nslot.", + "associated": true + }, + { + "kind": "fn", + "name": "peek", + "signature": "pub fn peek(&self) -> Option<&T> {", + "doc": "Returns a reference to the payload of the earliest event without\nremoving it.", + "associated": true + }, + { + "kind": "fn", + "name": "clear", + "signature": "pub fn clear(&mut self) -> usize {", + "doc": "Removes every pending event and returns how many were discarded.\n\nThe delivered count is left alone, because it records history rather\nthan queue contents.", + "associated": true + }, + { + "kind": "fn", + "name": "drain", + "signature": "pub fn drain(&mut self) -> Vec> {", + "doc": "Returns every pending event in delivery order, leaving the queue\nempty.\n\nThe order is the order the events would have been delivered in, which\nis what a caller wants when it records the remaining schedule at the\nend of a run.", + "associated": true + } + ] + }, + { + "name": "apsis-output", + "layer": "sim", + "summary": "Simulation output recording. A simulation produces more output than a caller can act on at once: event times, the values a model computes as it runs, and the warnings it raises. This crate collects those into one trace, so that a run can be examined after it finishes rather than only as it happens. A record has a time, a kind, a label, and a value. The time is the simulation instant the record was made at, which is what makes a trace orderable and joinable against the event log. The kind separates the three things a caller usually wants to tell apart: an observation the model made, a state change it decided on, and a warning it wants to surface. The label names what the value is about, and the value is a single number, which covers the great majority of what a simulation records without imposing a schema the caller has to design. The trace keeps the records in the order they were made, not in time order. That distinction matters: a handler may record a value for an event it is handling after recording one for a follow-up it just scheduled, and the order the model reasoned in is worth keeping. A caller that wants time order asks for it with [`Trace::in_time_order`]. ``` use apsis_output::{Recorder, RecordKind}; let mut recorder = Recorder::new(); recorder.observe(10.0, \"altitude\", 550.0); recorder.note(10.0, \"eclipse\", 1.0); assert_eq!(recorder.len(), 2); assert!(recorder.trace().is_stable()); assert_eq!(recorder.trace().value_of(\"altitude\"), Some(550.0)); let _ = RecordKind::Warning; ```", + "items": [ + { + "kind": "enum", + "name": "RecordKind", + "signature": "pub enum RecordKind {", + "doc": "What a record is about.\n\nThe three kinds separate the things a caller usually wants to tell apart\nwhen it reads a trace back: a value the model measured, a decision it\nmade, and a problem it wants to surface.", + "associated": false + }, + { + "kind": "fn", + "name": "name", + "signature": "pub const fn name(self) -> &'static str {", + "doc": "Returns a short name for the kind.", + "associated": true + }, + { + "kind": "fn", + "name": "is_warning", + "signature": "pub const fn is_warning(self) -> bool {", + "doc": "Returns `true` when the kind is a warning.", + "associated": true + }, + { + "kind": "struct", + "name": "Record", + "signature": "pub struct Record {", + "doc": "One recorded value.\n\nA record is small and copied by value, so a trace holds a flat list rather\nthan a tree of references.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(time: f64, kind: RecordKind, label: impl Into, value: f64) -> Self {", + "doc": "Creates a record.", + "associated": true + }, + { + "kind": "fn", + "name": "observation", + "signature": "pub fn observation(time: f64, label: impl Into, value: f64) -> Self {", + "doc": "Creates an observation record.", + "associated": true + }, + { + "kind": "fn", + "name": "state", + "signature": "pub fn state(time: f64, label: impl Into, value: f64) -> Self {", + "doc": "Creates a state record.", + "associated": true + }, + { + "kind": "fn", + "name": "warning", + "signature": "pub fn warning(time: f64, label: impl Into, value: f64) -> Self {", + "doc": "Creates a warning record.", + "associated": true + }, + { + "kind": "fn", + "name": "is_valid", + "signature": "pub fn is_valid(&self) -> bool {", + "doc": "Returns `true` when the record is usable.\n\nA record whose time or value is not finite has no place in a trace,\nbecause it cannot be ordered or summarized.", + "associated": true + }, + { + "kind": "struct", + "name": "Recorder", + "signature": "pub struct Recorder {", + "doc": "Collects records as a simulation runs.\n\nThe recorder is the write side of a trace: a handler calls one of its\nmethods for each value it wants to keep, and asks for the trace when the\nrun finishes. The two are separate so that a handler can hold a recorder\nwhile the trace is read elsewhere.\n\n```\nuse apsis_output::Recorder;\n\nlet mut recorder = Recorder::new();\nrecorder.observe(0.0, \"range\", 1_000.0);\nrecorder.observe(10.0, \"range\", 950.0);\n\nlet summary = recorder.trace().summarize(\"range\").expect(\"the series exists\");\nassert_eq!(summary.count, 2);\nassert_eq!(summary.change, -50.0);\n```", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub const fn new() -> Self {", + "doc": "Creates an empty recorder.", + "associated": true + }, + { + "kind": "fn", + "name": "record", + "signature": "pub fn record( &mut self, time: f64, kind: RecordKind, label: impl Into, value: f64, ) -> bool {", + "doc": "Records a value of a given kind.\n\nReturns `false` when the record is not usable, which a caller can\nignore when the value is known to be finite.", + "associated": true + }, + { + "kind": "fn", + "name": "observe", + "signature": "pub fn observe(&mut self, time: f64, label: impl Into, value: f64) -> bool {", + "doc": "Records an observation.", + "associated": true + }, + { + "kind": "fn", + "name": "note", + "signature": "pub fn note(&mut self, time: f64, label: impl Into, value: f64) -> bool {", + "doc": "Records a state change.", + "associated": true + }, + { + "kind": "fn", + "name": "warn", + "signature": "pub fn warn(&mut self, time: f64, label: impl Into, value: f64) -> bool {", + "doc": "Records a warning.", + "associated": true + }, + { + "kind": "fn", + "name": "len", + "signature": "pub fn len(&self) -> usize {", + "doc": "Returns the number of records made.", + "associated": true + }, + { + "kind": "fn", + "name": "is_empty", + "signature": "pub fn is_empty(&self) -> bool {", + "doc": "Returns `true` when no records have been made.", + "associated": true + }, + { + "kind": "fn", + "name": "refused", + "signature": "pub const fn refused(&self) -> u64 {", + "doc": "Returns the number of records refused because they were not usable.\n\nA caller that records values from a model checks this to see whether\nany value was dropped, rather than comparing counts by hand.", + "associated": true + }, + { + "kind": "fn", + "name": "trace", + "signature": "pub const fn trace(&self) -> &Trace {", + "doc": "Returns the trace recorded so far.", + "associated": true + }, + { + "kind": "fn", + "name": "take_trace", + "signature": "pub fn take_trace(&mut self) -> Trace {", + "doc": "Returns the trace, leaving an empty one behind.\n\nThis is what a caller does between runs: the finished trace goes to\nwhoever reads it, and the recorder starts fresh. The refused count is\nnot reset, because it records the whole life of the recorder.", + "associated": true + }, + { + "kind": "fn", + "name": "clear", + "signature": "pub fn clear(&mut self) -> usize {", + "doc": "Removes every record without changing the refused count.", + "associated": true + }, + { + "kind": "struct", + "name": "Trace", + "signature": "pub struct Trace {", + "doc": "A list of records in the order they were made.\n\nThe trace preserves insertion order rather than sorting by time, because\nthe order a model recorded in is part of what it decided. A caller that\nneeds time order asks for [`Trace::in_time_order`], which leaves the\noriginal alone.", + "associated": false + }, + { + "kind": "fn", + "name": "from_records", + "signature": "pub fn from_records(records: Vec) -> Self {", + "doc": "Creates a trace from a list of records.", + "associated": true + }, + { + "kind": "fn", + "name": "records", + "signature": "pub fn records(&self) -> &[Record] {", + "doc": "Returns the records in the order they were made.", + "associated": true + }, + { + "kind": "fn", + "name": "push", + "signature": "pub fn push(&mut self, record: Record) -> bool {", + "doc": "Adds a record, returning `false` when it is not usable.\n\nA record whose time or value is not finite is refused rather than\nstored, so a trace never holds a value that cannot be summarized.", + "associated": true + }, + { + "kind": "fn", + "name": "is_stable", + "signature": "pub fn is_stable(&self) -> bool {", + "doc": "Returns `true` when no record has a time or value that is not finite.", + "associated": true + }, + { + "kind": "fn", + "name": "in_time_order", + "signature": "pub fn in_time_order(&self) -> Vec {", + "doc": "Returns the records in time order, earliest first.\n\nRecords at the same instant keep their insertion order, so the sort is\nstable and the result does not depend on the sort algorithm.", + "associated": true + }, + { + "kind": "fn", + "name": "of_kind", + "signature": "pub fn of_kind(&self, kind: RecordKind) -> Vec<&Record> {", + "doc": "Returns the records of one kind, in order.", + "associated": true + }, + { + "kind": "fn", + "name": "with_label", + "signature": "pub fn with_label(&self, label: &str) -> Vec<&Record> {", + "doc": "Returns the records with a label, in order.", + "associated": true + }, + { + "kind": "fn", + "name": "value_of", + "signature": "pub fn value_of(&self, label: &str) -> Option {", + "doc": "Returns the value of the last record with a label.\n\nThis is what a caller asks for after a run: the final value a series\nreached. Returns `None` when no record has the label.", + "associated": true + }, + { + "kind": "fn", + "name": "last_time_of", + "signature": "pub fn last_time_of(&self, label: &str) -> Option {", + "doc": "Returns the time of the last record with a label.", + "associated": true + }, + { + "kind": "fn", + "name": "labels", + "signature": "pub fn labels(&self) -> Vec<&str> {", + "doc": "Returns the labels present, in sorted order.\n\nThe order is sorted rather than first seen, so that two traces with\nthe same labels list them the same way.", + "associated": true + }, + { + "kind": "fn", + "name": "summarize", + "signature": "pub fn summarize(&self, label: &str) -> Option {", + "doc": "Returns a summary of the series recorded under a label.\n\nReturns `None` when no record has the label.", + "associated": true + }, + { + "kind": "fn", + "name": "warning_count", + "signature": "pub fn warning_count(&self) -> usize {", + "doc": "Returns the number of warnings in the trace.", + "associated": true + }, + { + "kind": "fn", + "name": "summaries", + "signature": "pub fn summaries(&self) -> Vec {", + "doc": "Returns a summary of every label, in sorted label order.", + "associated": true + }, + { + "kind": "fn", + "name": "longest_series", + "signature": "pub fn longest_series(&self) -> usize {", + "doc": "Returns the greatest number of records that share a label.\n\nThis is the length of the longest series, which a caller uses to size\na table of the trace.", + "associated": true + }, + { + "kind": "struct", + "name": "SeriesSummary", + "signature": "pub struct SeriesSummary {", + "doc": "A summary of one series of records.", + "associated": false + }, + { + "kind": "fn", + "name": "range", + "signature": "pub fn range(&self) -> f64 {", + "doc": "Returns the spread between the greatest and smallest values.", + "associated": true + }, + { + "kind": "fn", + "name": "is_constant", + "signature": "pub fn is_constant(&self) -> bool {", + "doc": "Returns `true` when the series never changed.", + "associated": true + } + ] + }, + { + "name": "apsis-sim", + "layer": "sim", + "summary": "Simulation facade that integrates the simulation layer. This crate is the single entry point to the simulation layer. It ties the four pieces together: a [`Scenario`] describes the initial conditions, the ops layer plans it, the engine runs the resulting timeline, and a [`Recorder`] collects what the run produced. The sequence a caller follows is short: 1. [`Simulation::prepare`] checks a scenario and plans it, producing a schedule. 2. [`Simulation::start`] turns that schedule into a timeline of start and end events and hands back a [`Session`]. 3. [`Session::run`] drives the engine, calling a handler at each event, and returns a [`RunReport`] holding the engine outcome and the trace. The plan is a greedy heuristic, described in `apsis-scheduler`, so a run may leave tasks unplaced. The facade reports that rather than hiding it: [`Simulation::plan`] exposes the full planning result, and [`Simulation::has_full_plan`] answers the common question directly. The timeline is the schedule, not a physical model. A start event fires when a task begins and an end event when it ends, which is what a caller needs to record state over time; the handler supplies whatever physics or bookkeeping the run requires. ``` use apsis_sim::{Activity, Scenario, Simulation}; let text = \"\\ scenario demo resource 0 capacity 1 window 0 0 600 task 0 duration 60 require 0 0 1 \"; let scenario = Scenario::parse(text).expect(\"the text is well formed\"); let simulation = Simulation::prepare(scenario).expect(\"the scenario is usable\"); let mut session = simulation.start(); let mut starts = 0_u32; let report = session.run(|_engine, _recorder, event| { if matches!(event.payload, Activity::Start { .. }) { starts += 1; } }); assert!(simulation.has_full_plan()); assert_eq!(starts, 1); assert_eq!(report.outcome.executed, 2); ```", + "items": [ + { + "kind": "enum", + "name": "Activity", + "signature": "pub enum Activity {", + "doc": "One thing that happens in the timeline.\n\nThe timeline is derived from a schedule: a placed task contributes a start\nand an end, and the handler is called at each. The two are separate events\nrather than one interval so that the engine orders them with everything\nelse on the clock, which is what lets two tasks overlap correctly.", + "associated": false + }, + { + "kind": "fn", + "name": "start", + "signature": "pub const fn start(task: TaskId) -> Self {", + "doc": "Creates a start activity.", + "associated": true + }, + { + "kind": "fn", + "name": "end", + "signature": "pub const fn end(task: TaskId) -> Self {", + "doc": "Creates an end activity.", + "associated": true + }, + { + "kind": "fn", + "name": "task", + "signature": "pub const fn task(self) -> TaskId {", + "doc": "Returns the task the activity is about.", + "associated": true + }, + { + "kind": "fn", + "name": "is_start", + "signature": "pub const fn is_start(self) -> bool {", + "doc": "Returns `true` when the activity is a start.", + "associated": true + }, + { + "kind": "fn", + "name": "is_end", + "signature": "pub const fn is_end(self) -> bool {", + "doc": "Returns `true` when the activity is an end.", + "associated": true + }, + { + "kind": "enum", + "name": "SimulationErrorKind", + "signature": "pub enum SimulationErrorKind {", + "doc": "The category of a simulation error.", + "associated": false + }, + { + "kind": "struct", + "name": "SimulationError", + "signature": "pub struct SimulationError {", + "doc": "A failure while preparing or starting a simulation.", + "associated": false + }, + { + "kind": "fn", + "name": "new", + "signature": "pub fn new(kind: SimulationErrorKind) -> Self {", + "doc": "Creates an error with the given category.", + "associated": true + }, + { + "kind": "fn", + "name": "with_detail", + "signature": "pub fn with_detail(mut self, detail: impl Into) -> Self {", + "doc": "Attaches a description of what caused the failure.", + "associated": true + }, + { + "kind": "fn", + "name": "kind", + "signature": "pub const fn kind(&self) -> SimulationErrorKind {", + "doc": "Returns the category of this failure.", + "associated": true + }, + { + "kind": "fn", + "name": "detail", + "signature": "pub fn detail(&self) -> Option<&str> {", + "doc": "Returns the detail text attached to this error, if any.", + "associated": true + }, + { + "kind": "struct", + "name": "RunReport", + "signature": "pub struct RunReport {", + "doc": "What a run produced.\n\nThe report pairs the engine's own outcome with the output trace, so a\ncaller has everything a run produced without reaching back into the\nsession.", + "associated": false + }, + { + "kind": "fn", + "name": "executed", + "signature": "pub const fn executed(&self) -> u64 {", + "doc": "Returns the number of activities the run executed.", + "associated": true + }, + { + "kind": "fn", + "name": "is_complete", + "signature": "pub fn is_complete(&self) -> bool {", + "doc": "Returns `true` when the run finished because the timeline was\nexhausted rather than because a limit stopped it.", + "associated": true + }, + { + "kind": "fn", + "name": "warning_count", + "signature": "pub fn warning_count(&self) -> usize {", + "doc": "Returns the number of warnings recorded.", + "associated": true + }, + { + "kind": "struct", + "name": "Session", + "signature": "pub struct Session {", + "doc": "One run of a prepared simulation.\n\nA session owns the engine and the recorder for a single run, so it can be\ndriven from one place without the caller holding either. It is created by\n[`Simulation::start`].", + "associated": false + }, + { + "kind": "fn", + "name": "engine", + "signature": "pub const fn engine(&self) -> &Engine {", + "doc": "Returns the engine, for a caller that needs its state between calls.", + "associated": true + }, + { + "kind": "fn", + "name": "engine_mut", + "signature": "pub const fn engine_mut(&mut self) -> &mut Engine {", + "doc": "Returns the engine for modification.", + "associated": true + }, + { + "kind": "fn", + "name": "recorder", + "signature": "pub const fn recorder(&self) -> &Recorder {", + "doc": "Returns the recorder, for a caller that reads the trace as it grows.", + "associated": true + }, + { + "kind": "fn", + "name": "recorder_mut", + "signature": "pub const fn recorder_mut(&mut self) -> &mut Recorder {", + "doc": "Returns the recorder for modification.", + "associated": true + }, + { + "kind": "fn", + "name": "now", + "signature": "pub const fn now(&self) -> f64 {", + "doc": "Returns the current simulation time, in seconds.", + "associated": true + }, + { + "kind": "fn", + "name": "run", + "signature": "pub fn run(&mut self, mut handler: F) -> RunReport where F: FnMut(&mut Engine, &mut Recorder, apsis_event::Event), {", + "doc": "Runs the engine, calling the handler at each activity.\n\nThe handler receives the engine, the recorder, and the activity. It\nnormally records something for the activity and may schedule further\nevents; the engine delivers them in time order alongside the timeline.\n\nReturns a report holding the engine outcome and the trace made during\nthis call. The recorder is left empty for the next call.", + "associated": true + }, + { + "kind": "fn", + "name": "note", + "signature": "pub fn note(&mut self, time: f64, label: impl Into, value: f64) -> bool {", + "doc": "Records an activity as a state change without running anything.\n\nThis is for a caller that drives the engine itself and only wants the\nrecorder.", + "associated": true + }, + { + "kind": "fn", + "name": "record", + "signature": "pub fn record( &mut self, time: f64, kind: RecordKind, label: impl Into, value: f64, ) -> bool {", + "doc": "Records a value of a given kind without running anything.", + "associated": true + }, + { + "kind": "struct", + "name": "Simulation", + "signature": "pub struct Simulation {", + "doc": "A scenario prepared for a run.\n\nA simulation holds the scenario, the plan that was made from it, and the\ntimeline that plan implies. Preparing is separate from running so that a\ncaller can inspect the plan, and reject it, before a run commits to it.", + "associated": false + }, + { + "kind": "fn", + "name": "prepare", + "signature": "pub fn prepare(scenario: Scenario) -> Result {", + "doc": "Checks a scenario and plans it.\n\nThe scenario is validated, the tasks are planned against the\nresources, and the resulting schedule is turned into a timeline of\nstart and end events. The plan is greedy, so a scenario may be\nprepared successfully and still leave tasks unplaced; that is reported\nby [`Simulation::has_full_plan`] rather than being an error.", + "associated": true + }, + { + "kind": "fn", + "name": "from_plan", + "signature": "pub fn from_plan( scenario: Scenario, result: SchedulingResult, ) -> Result {", + "doc": "Builds a simulation from an already planned result.\n\nThis is for a caller that has planned with its own settings and wants\nthe rest of the facade: the timeline and the run loop. The result must\nbelong to the scenario, which is checked.", + "associated": true + }, + { + "kind": "fn", + "name": "scenario", + "signature": "pub const fn scenario(&self) -> &Scenario {", + "doc": "Returns the scenario.", + "associated": true + }, + { + "kind": "fn", + "name": "resources", + "signature": "pub const fn resources(&self) -> &ResourceSet {", + "doc": "Returns the resources in the scenario.", + "associated": true + }, + { + "kind": "fn", + "name": "plan", + "signature": "pub const fn plan(&self) -> &SchedulingResult {", + "doc": "Returns the planning result.", + "associated": true + }, + { + "kind": "fn", + "name": "schedule", + "signature": "pub const fn schedule(&self) -> &Schedule {", + "doc": "Returns the schedule.", + "associated": true + }, + { + "kind": "fn", + "name": "tasks", + "signature": "pub fn tasks(&self) -> &[Task] {", + "doc": "Returns the tasks in the scenario.", + "associated": true + }, + { + "kind": "fn", + "name": "timeline", + "signature": "pub fn timeline(&self) -> &[(f64, Activity)] {", + "doc": "Returns the timeline as time and activity pairs, in time order.\n\nEach placed task contributes a start and an end. Ties are ordered so\nthat an end precedes a start at the same instant, which lets two back\nto back tasks share a boundary without the reader seeing them overlap.", + "associated": true + }, + { + "kind": "fn", + "name": "activity_count", + "signature": "pub fn activity_count(&self) -> usize {", + "doc": "Returns the number of start and end events in the timeline.", + "associated": true + }, + { + "kind": "fn", + "name": "has_full_plan", + "signature": "pub fn has_full_plan(&self) -> bool {", + "doc": "Returns `true` when every offered task was placed.", + "associated": true + }, + { + "kind": "fn", + "name": "honours_mandatory", + "signature": "pub fn honours_mandatory(&self) -> bool {", + "doc": "Returns `true` when every mandatory task was placed.", + "associated": true + }, + { + "kind": "fn", + "name": "honours_precedence", + "signature": "pub fn honours_precedence(&self) -> bool {", + "doc": "Returns `true` when every precedence constraint is satisfied.", + "associated": true + }, + { + "kind": "fn", + "name": "deferred", + "signature": "pub fn deferred(&self) -> Vec {", + "doc": "Returns the tasks that were not placed for want of time.", + "associated": true + }, + { + "kind": "fn", + "name": "infeasible", + "signature": "pub fn infeasible(&self) -> Vec {", + "doc": "Returns the tasks that cannot be placed at all.", + "associated": true + }, + { + "kind": "fn", + "name": "evaluate", + "signature": "pub fn evaluate(&self) -> Evaluation {", + "doc": "Evaluates the schedule against the scenario.", + "associated": true + }, + { + "kind": "fn", + "name": "start", + "signature": "pub fn start(&self) -> Session {", + "doc": "Starts a run.\n\nThe returned session owns its own engine and recorder, so several\nsessions may run from one prepared simulation without interfering. The\nclock starts at the scenario epoch and the run limits come from the\nscenario.", + "associated": true + } + ] + }, + { + "name": "apsis-rs", + "layer": null, + "summary": "ApsisRS: computational kernels for commercial spaceflight and satellite internet systems. The workspace is organized into dependency-ordered layers: ```text foundation -> physics -> orbit -> rf -> network -> ops -> sim ``` Dependencies flow in one direction only. A layer may use the layers to its left but never the layers to its right. This crate is the public facade named `apsis-rs`. Downstream projects depend on it directly and enable the feature that matches the layers they need. Each feature re-exports the crates of one layer and pulls in the features of the layers below it. For example, the `orbit` feature enables `physics` and re-exports [`apsis_orbit`], [`apsis_tle`], [`apsis_sgp4`], [`apsis_kepler`], and [`apsis_ephemeris`]. The `full` feature enables every layer, including `sim`. Because `sim` aggregates all other layers and is only needed by simulation workloads, projects that do not run simulations can enable `ops` instead, which enables every layer except `sim`.", + "items": [] } ] } diff --git a/scripts/extract_api.py b/scripts/extract_api.py index ddf8e9d..4180d5b 100755 --- a/scripts/extract_api.py +++ b/scripts/extract_api.py @@ -21,40 +21,82 @@ import os import re import sys -# Crates of the layers that the enabled `apsis-rs` features pull in, in -# dependency order. The order is the one the workspace layer rule defines. -LAYERS = [ - ("foundation", ["apsis-core", "apsis-units", "apsis-math", "apsis-macros"]), - ("physics", ["apsis-time", "apsis-leap", "apsis-coord", "apsis-geodesy", "apsis-frames"]), - ("orbit", [ - "apsis-tle", - "apsis-sgp4", - "apsis-kepler", - "apsis-ephemeris", - "apsis-maneuver", - "apsis-orbit", - ]), +# The layer of a crate is the directory it sits under, so the catalog is +# grouped the same way the workspace is laid out. +LAYER_ORDER = [ + "foundation", + "physics", + "orbit", + "network", + "rf", + "ops", + "sim", ] -# Directories of the workspace, relative to the workspace root, keyed by -# crate name. -CRATE_DIRS = { - "apsis-core": "crates/foundation/apsis-core", - "apsis-units": "crates/foundation/apsis-units", - "apsis-math": "crates/foundation/apsis-math", - "apsis-macros": "crates/foundation/apsis-macros", - "apsis-time": "crates/physics/apsis-time", - "apsis-leap": "crates/physics/apsis-leap", - "apsis-coord": "crates/physics/apsis-coord", - "apsis-geodesy": "crates/physics/apsis-geodesy", - "apsis-frames": "crates/physics/apsis-frames", - "apsis-tle": "crates/orbit/apsis-tle", - "apsis-sgp4": "crates/orbit/apsis-sgp4", - "apsis-kepler": "crates/orbit/apsis-kepler", - "apsis-ephemeris": "crates/orbit/apsis-ephemeris", - "apsis-maneuver": "crates/orbit/apsis-maneuver", - "apsis-orbit": "crates/orbit/apsis-orbit", -} + +def manifest_crates(workspace): + """Return (layer, name, directory) for every crate of the workspace. + + The crates come from the workspace manifest rather than from a list kept + here. A list goes stale the moment a crate is added, and a stale list is + silent: the catalog is simply short of the interface and every page + built from it is quietly incomplete. The manifest is the one place the + workspace states which crates there are, and each member path carries its + layer in the directory above the crate name. + + The order is the one the workspace defines, which is also dependency + order within each layer. + """ + manifest = manifest_path(workspace) + members = [] + inside = False + with open(manifest, "r", encoding="utf-8") as handle: + for line in handle: + stripped = line.strip() + if stripped.startswith("members"): + inside = True + continue + if inside: + if stripped.startswith("]"): + break + # A member is a quoted path, possibly with a trailing comma. + entry = stripped.split("#")[0].strip().strip(",").strip('"') + if entry: + members.append(entry) + + crates = [] + for directory in members: + parts = directory.split("/") + if len(parts) < 2: + continue + name = parts[-1] + # A member that sits directly in `crates` is the facade, which + # re-exports every layer and so belongs to none of them. It is left + # without a layer rather than given the wrong one, and the page that + # lists crates by layer shows it apart from the layers. + layer = parts[-2] if len(parts) >= 3 else None + crates.append((layer, name, directory)) + return crates + + +def manifest_path(workspace): + """The workspace manifest path.""" + return os.path.join(workspace, "Cargo.toml") + + +def ordered(crates): + """Order the crates by layer, keeping the workspace order within a layer.""" + rank = {layer: index for index, layer in enumerate(LAYER_ORDER)} + return sorted( + enumerate(crates), + # The facade goes last: it re-exports what the layers define, so a + # reader reaches it after the crate that defines an item. + key=lambda pair: ( + rank.get(pair[1][0], len(rank) + 1) if pair[1][0] else len(rank), + pair[0], + ), + ) + ITEM = re.compile( r"^(?P\s*)pub (?:(?Pconst|unsafe) )?" @@ -214,15 +256,21 @@ def main(): sys.stderr.write("apsis-rs workspace not found at %s\n" % workspace) return 1 + discovered = manifest_crates(workspace) + if not discovered: + sys.stderr.write("no crates found in %s\n" % manifest_path(workspace)) + return 1 + crates = [] sources = [] - for layer, names in LAYERS: - for name in names: - directory = CRATE_DIRS[name] - entry = collect_crate(workspace, name, directory) - entry["layer"] = layer - crates.append(entry) - sources.append(os.path.join(directory, "src")) + for _, (layer, name, directory) in ordered(discovered): + if not os.path.isdir(os.path.join(workspace, directory)): + sys.stderr.write("skipping %s: no such directory\n" % directory) + continue + entry = collect_crate(workspace, name, directory) + entry["layer"] = layer + crates.append(entry) + sources.append(os.path.join(directory, "src")) document = { "generated_from": sources, diff --git a/src/docs/api.rs b/src/docs/api.rs index b8cc812..ba88e30 100644 --- a/src/docs/api.rs +++ b/src/docs/api.rs @@ -215,10 +215,13 @@ fn parse_crate( item_texts: Option<&Value>, ) -> Option { let name = value.get("name")?.as_str()?.to_string(); + // A crate that belongs to no layer, which is the facade that re-exports + // every layer, is left empty. Defaulting it to a layer would file it + // under a subject it does not belong to. let layer = value .get("layer") .and_then(Value::as_str) - .unwrap_or("foundation") + .unwrap_or_default() .to_string(); let summary = value .get("summary") diff --git a/src/docs/markdown.rs b/src/docs/markdown.rs index c7ed96e..098e72e 100644 --- a/src/docs/markdown.rs +++ b/src/docs/markdown.rs @@ -68,7 +68,13 @@ pub fn render_with(markdown: &str, items: Option<&std::collections::HashSet output.push_str(&rendered), + None => output.push_str(&render_code_block(language, &code)), + } continue; } @@ -228,6 +234,19 @@ fn collect_table<'a>(lines: &[&'a str], start: usize) -> (Vec<&'a str>, usize) { } /// Renders a fenced code block. +/// Renders a fenced block that holds equations, when it does. +/// +/// Only a block marked `text`, or marked with nothing at all, is considered. +/// A block marked as a language is source code by the author's statement, and +/// a shell transcript or a listing is not a formula however many equals signs +/// it happens to contain. +fn render_formula_block(language: &str, code: &str) -> Option { + if !language.is_empty() && language != "text" { + return None; + } + crate::docs::mathml::render_plain(code).map(|math| format!("{math}\n")) +} + fn render_code_block(language: &str, code: &str) -> String { let class = if language.is_empty() { String::new() diff --git a/src/docs/mathml.rs b/src/docs/mathml.rs index e9082f6..18b3b9a 100644 --- a/src/docs/mathml.rs +++ b/src/docs/mathml.rs @@ -106,6 +106,10 @@ enum Node { Subscript(Box, Box), /// A subscript and a superscript on one base. Scripts(Box, Box, Box), + /// A quotient drawn with a slash rather than stacked. + Slash(Box, Box), + /// A subscript on a subscript, as the `1` in the `v_1` of `delta_v_1`. + Nested(Box, Box), /// A named function such as `sin`. Function(String), } @@ -498,7 +502,11 @@ fn emit(node: &Node) -> String { (Some(single), None) if single.is_numeric() => { format!("{}", escape(text)) } - (Some(single), None) if single.is_alphabetic() => { + // A name of more than one letter is an identifier too. Bare + // text inside a row is dropped by the browser, which is why + // a left-hand side such as `delta_v` was showing as nothing at + // all rather than as its name. + _ if text.chars().all(|c| c.is_alphabetic()) => { format!("{}", escape(text)) } // An operator is written as one rather than as bare text. A @@ -551,9 +559,467 @@ fn emit(node: &Node) -> String { emit(bottom), emit(top) ), + // A stacked fraction would be as tall as the line it is in, so a + // quotient inside a formula is drawn with a slash and takes the + // height of its parts. + Node::Slash(top, bottom) => format!( + "{}{}", + emit(top), + emit(bottom) + ), + Node::Nested(base, inner) => format!( + "{}{}", + emit(base), + emit(inner) + ), } } +/// Renders a formula written in plain ASCII notation. +/// +/// The documents were written before the formula support existed, and they +/// wrote formulas the way a plain text file allows: a fenced block holding +/// `delta_v_1 = sqrt( mu (2/r_1 - 1/a_t) )`. Rendering those as formulas is +/// what makes them formulas, so this reads that notation and produces the +/// same MathML as the dollar form. +/// +/// The notation understood is the one the documents use: +/// +/// - an identifier or a number, which may carry a subscript and a +/// superscript, as in `a_t^2` or `e^2`; +/// - `sqrt(...)` for a root, and `sin`, `cos`, `tan`, `ln`, `exp`, `floor` +/// and `arctan` among others for the functions; +/// - `*` for a product, which is written by juxtaposition; +/// - `/` for a quotient, which is drawn with a slash rather than stacked, so +/// that `2/r_1` does not become a fraction as tall as the line it is in; +/// - `+ - = < >` as they are. +/// +/// The reading is a plain precedence climb: a sum, then a product, then a +/// factor. That is what makes `2/r_1 - 1/a_t` two quotients rather than one +/// quotient of the whole difference. +/// +/// Returns `None` when the text is not a formula. A block holding a matrix, or +/// prose, or something not fully read is not a formula and must stay as it was +/// written: showing part of a formula that was not understood would be worse +/// than showing the source. +pub fn render_plain(text: &str) -> Option { + let lines: Vec<&str> = text + .lines() + .map(str::trim_end) + .filter(|line| !line.trim().is_empty()) + .collect(); + if lines.is_empty() { + return None; + } + + // A block qualifies as a formula when at least one of its lines is an + // equation. Brackets in these documents are a matrix or a vector, which + // this renderer does not lay out, so such a block stays a code block. + if lines + .iter() + .any(|line| line.contains('[') || line.contains(']')) + || !lines.iter().any(|line| line.contains('=')) + { + return None; + } + + let mut rows = String::new(); + let mut read = 0; + for line in &lines { + let (equation, note) = split_trailing_note(line); + let tokens = tokenize_ascii(equation); + let mut cursor = 0; + let nodes = parse_sum(&tokens, &mut cursor); + // A line that could not be read whole is still shown, but as the text + // it was written. A block of equations often carries a continuation + // line with no equals sign on it, and losing that would lose the + // equation above it. + if cursor != tokens.len() { + rows.push_str(""); + rows.push_str(&escape(&clean_note(equation))); + rows.push_str(""); + continue; + } + read += 1; + rows.push_str(""); + rows.push_str(&emit(&Node::Row(nodes))); + if let Some(note) = note { + // The note is a phrase about the equation rather than part of it, + // so it is set beside the formula and not inside it. + rows.push_str(""); + rows.push_str(&escape(&clean_note(note))); + rows.push_str(""); + } + rows.push_str(""); + } + + // Nothing in the block could be read, so the caller is better served by + // the source than by a block of text set as mathematics. + if read == 0 { + return None; + } + + Some(format!( + "\ + \ + {rows}", + escape_attr(text.trim()) + )) +} + +/// Tidies a note read out of an equation line. +/// +/// The note was written in the same block as the equation, so it carries the +/// alignment of the block and runs on several lines; what is left after the +/// spaces are collapsed is a phrase. +fn clean_note(note: &str) -> String { + note.split_whitespace().collect::>().join(" ") +} + +/// Splits a trailing parenthetical note from an equation. +/// +/// A note is a phrase rather than part of the equation, so +/// `N = a / sqrt(1 - e^2 sin^2 phi) (prime vertical radius)` is an equation +/// with a note and not an equation with a bracket. A parenthesis holding a +/// space is read as a note, because the expressions in these documents do not +/// write a space inside their own brackets. +fn split_trailing_note(line: &str) -> (&str, Option<&str>) { + if !line.ends_with(')') { + return (line, None); + } + match line.rfind('(') { + Some(start) if start > 0 => { + let inside = &line[start + 1..line.len() - 1]; + let is_phrase = inside.contains(' ') + && !inside.contains(['=', '/', '^', '_', '(', '+']) + && !inside.chars().any(|c| c.is_ascii_digit()); + if is_phrase { + return (&line[..start], Some(inside)); + } + (line, None) + } + _ => (line, None), + } +} + +/// One unit of a plain formula. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Ascii { + /// An identifier: a run of letters, digits and underscores. + Word(String), + /// A number, possibly with a decimal point or an exponent. + Number(String), + /// An operator, a bracket or a mark. + Sign(char), +} + +/// Reads a plain formula into tokens. +fn tokenize_ascii(text: &str) -> Vec { + let characters: Vec = text.chars().collect(); + let mut tokens: Vec = Vec::with_capacity(characters.len()); + let mut index = 0; + + while index < characters.len() { + let character = characters[index]; + if character.is_whitespace() { + index += 1; + continue; + } + if character.is_alphabetic() { + let start = index; + while index < characters.len() + && (characters[index].is_alphanumeric() + || characters[index] == '_' + || characters[index] == '\'') + { + index += 1; + } + tokens.push(Ascii::Word(characters[start..index].iter().collect())); + continue; + } + if character.is_ascii_digit() || character == '.' { + let start = index; + while index < characters.len() && characters[index].is_ascii_digit() { + index += 1; + } + if index < characters.len() && characters[index] == '.' { + index += 1; + while index < characters.len() && characters[index].is_ascii_digit() { + index += 1; + } + } + // An exponent is part of the number, as in `1e-6`. + if index < characters.len() + && matches!(characters[index], 'e' | 'E') + && characters + .get(index + 1) + .is_some_and(|next| next.is_ascii_digit() || *next == '-') + { + index += 1; + if characters[index] == '-' { + index += 1; + } + while index < characters.len() && characters[index].is_ascii_digit() { + index += 1; + } + } + tokens.push(Ascii::Number(characters[start..index].iter().collect())); + continue; + } + tokens.push(Ascii::Sign(character)); + index += 1; + } + tokens +} + +/// The functions the documents write in plain ASCII. +fn ascii_function(name: &str) -> bool { + matches!( + name, + "sin" + | "cos" + | "tan" + | "cot" + | "sec" + | "csc" + | "asin" + | "acos" + | "atan" + | "arctan" + | "sinh" + | "cosh" + | "tanh" + | "ln" + | "log" + | "log10" + | "exp" + | "floor" + | "ceil" + | "abs" + ) +} + +/// Parses a sum: factors joined by `+`, `-`, `=` or a comparison. +fn parse_sum(tokens: &[Ascii], cursor: &mut usize) -> Vec { + let mut row = parse_product(tokens, cursor); + while let Some(Ascii::Sign(operator @ ('+' | '-' | '=' | '<' | '>' | ','))) = + tokens.get(*cursor) + { + let operator = *operator; + *cursor += 1; + let right = parse_product(tokens, cursor); + row.push(Node::Text(match operator { + '=' => " = ".to_string(), + '<' => " < ".to_string(), + '>' => " > ".to_string(), + '+' => " + ".to_string(), + // Two equations are often written on one line, separated by a + // comma, as in `r = p / (1 + e cos nu), p = a (1 - e^2)`. + ',' => ", ".to_string(), + _ => " − ".to_string(), + })); + row.extend(right); + } + row +} + +/// Parses a product: factors joined by `/`, by `*`, or by nothing at all. +/// +/// A product written in these documents is usually juxtaposition, as in +/// `2 f` or `mu (2/r_1 - 1/a_t)`, so a factor that follows another with +/// nothing between them continues the product. The star, where it is written, +/// is dropped. +fn parse_product(tokens: &[Ascii], cursor: &mut usize) -> Vec { + let mut row = parse_factor(tokens, cursor); + loop { + match tokens.get(*cursor).cloned() { + Some(Ascii::Sign('*')) => { + *cursor += 1; + } + Some(Ascii::Sign('/')) => { + *cursor += 1; + let bottom = parse_factor(tokens, cursor); + // The quotient takes the factor before it as its top and the + // one after it as its bottom, so `2/r_1` is one quotient and + // not a quotient of whatever happened to precede it. + let top = row.split_off(row.len() - 1); + row.push(Node::Slash( + Box::new(Node::Row(top)), + Box::new(Node::Row(bottom)), + )); + } + // Two factors with nothing between them are a product. + Some(Ascii::Word(_)) | Some(Ascii::Number(_)) | Some(Ascii::Sign('(')) => { + row.extend(parse_factor(tokens, cursor)); + } + _ => return row, + } + } +} + +/// Parses one factor: a value with its scripts, a root, a call, or a group. +fn parse_factor(tokens: &[Ascii], cursor: &mut usize) -> Vec { + match tokens.get(*cursor).cloned() { + Some(Ascii::Sign(open @ ('(' | '{'))) => { + let close = if open == '(' { ')' } else { '}' }; + *cursor += 1; + let inner = parse_sum(tokens, cursor); + if matches!(tokens.get(*cursor), Some(Ascii::Sign(closed)) if *closed == close) { + *cursor += 1; + } + // A brace holds a group without showing brackets; a parenthesis + // shows them, because it is written in the source. + if open == '{' { + vec![Node::Row(inner)] + } else { + vec![Node::Row(vec![ + Node::Text("(".to_string()), + Node::Row(inner), + Node::Text(")".to_string()), + ])] + } + } + // A word in a script that is not the Latin one is a label written in + // the line's own language, as in `近地点半径 r_p = a (1 - e)`. It is set + // upright, because a label is not a variable. + Some(Ascii::Word(word)) if !word.is_ascii() => { + *cursor += 1; + vec![Node::Text(word)] + } + Some(Ascii::Word(word)) => { + *cursor += 1; + if word == "sqrt" { + let radicand = parse_group(tokens, cursor); + return vec![attach_power( + Node::Root(Box::new(radicand), None), + tokens, + cursor, + )]; + } + if ascii_function(&word) { + let inner = parse_group(tokens, cursor); + let argument = match inner { + Node::Row(nodes) => Node::Row(nodes), + other => Node::Row(vec![other]), + }; + let call = Node::Row(vec![ + Node::Function(word), + Node::Text(" ".to_string()), + Node::Row(vec![ + Node::Text("(".to_string()), + argument, + Node::Text(")".to_string()), + ]), + ]); + // A function may be raised, as in `sin^2 phi`. The power + // belongs to the whole function rather than to its argument. + return vec![attach_power(call, tokens, cursor)]; + } + let base = ascii_atom(&word, tokens, cursor); + vec![base] + } + Some(Ascii::Number(number)) => { + *cursor += 1; + let base = ascii_atom(&number, tokens, cursor); + vec![base] + } + other => { + // Any other token is read past, so that the parse always makes + // progress rather than stalling on it. + *cursor += 1; + if let Some(Ascii::Sign(character)) = other { + vec![Node::Text(character.to_string())] + } else { + Vec::new() + } + } + } +} + +/// Raises a value to the power that follows it, if there is one. +fn attach_power(value: Node, tokens: &[Ascii], cursor: &mut usize) -> Node { + match tokens.get(*cursor) { + Some(Ascii::Sign('^')) => { + *cursor += 1; + Node::Superscript(Box::new(value), Box::new(parse_group(tokens, cursor))) + } + _ => value, + } +} + +/// Reads a bracketed argument, or a single token when there are no brackets. +fn parse_group(tokens: &[Ascii], cursor: &mut usize) -> Node { + match tokens.get(*cursor).cloned() { + Some(Ascii::Sign(open @ ('(' | '{'))) => { + let close = if open == '(' { ')' } else { '}' }; + *cursor += 1; + let inner = parse_sum(tokens, cursor); + if matches!(tokens.get(*cursor), Some(Ascii::Sign(closed)) if *closed == close) { + *cursor += 1; + } + Node::Row(inner) + } + Some(Ascii::Word(word)) => { + *cursor += 1; + Node::Text(word) + } + Some(Ascii::Number(number)) => { + *cursor += 1; + Node::Text(number) + } + _ => Node::Text(String::new()), + } +} + +/// Builds the node for one identifier or number, with its scripts. +/// +/// A name is written with an underscore between its parts, as in `delta_v_1` +/// or `right_ascension`, so the name is read up to the first underscore and +/// what follows is the subscript. The subscript may carry its own subscript, +/// as `v_1` does. +fn ascii_atom(word: &str, tokens: &[Ascii], cursor: &mut usize) -> Node { + let (base, subscript) = match word.find('_') { + Some(index) => ( + word[..index].to_string(), + Some(word[index + 1..].to_string()), + ), + None => (word.to_string(), None), + }; + let base = Node::Text(base); + + // `E_{n+1}` leaves the subscript to a brace rather than to the name, so an + // underscore with nothing after it is completed from the next token, which + // is the brace group that follows it. + let subscript = match subscript { + Some(text) if text.is_empty() => Some(match parse_group(tokens, cursor) { + Node::Row(nodes) => Node::Row(nodes), + other => Node::Row(vec![other]), + }), + Some(text) => Some(match text.rfind('_') { + Some(index) => Node::Nested( + Box::new(Node::Text(text[..index].to_string())), + Box::new(Node::Text(text[index + 1..].to_string())), + ), + None => Node::Text(text), + }), + None => None, + }; + + let superscript = match tokens.get(*cursor) { + Some(Ascii::Sign('^')) => { + *cursor += 1; + Some(parse_group(tokens, cursor)) + } + _ => None, + }; + + match (subscript, superscript) { + (None, None) => base, + (Some(bottom), None) => Node::Subscript(Box::new(base), Box::new(bottom)), + (None, Some(top)) => Node::Superscript(Box::new(base), Box::new(top)), + (Some(bottom), Some(top)) => Node::Scripts(Box::new(base), Box::new(bottom), Box::new(top)), + } +} #[cfg(test)] mod tests { use super::*; @@ -638,4 +1104,102 @@ mod tests { // The page keeps the source so the formula can be copied back. assert!(render("x^2", false).contains("data-tex=\"x^2\"")); } + + #[test] + fn the_rocket_equation_is_read() { + // The form the documents write. + let html = render_plain("delta_v = v_e * ln (m_0 / m_f)").expect("a formula"); + assert!(html.contains(""), "no root here"); + } + + #[test] + fn a_subscript_within_a_subscript_is_read() { + // `delta_v_1` is delta with a subscript v, itself carrying 1. + let html = render_plain("delta_v_1 = 3").expect("a formula"); + assert!(html.contains("delta"), "{html}"); + assert!(html.contains(""), "the subscript was lost: {html}"); + } + + #[test] + fn a_power_is_read() { + let html = render_plain("e^2 = 2 f - f^2").expect("a formula"); + assert!(html.contains(""), "the power was lost: {html}"); + } + + #[test] + fn a_root_is_read() { + let html = render_plain("x = sqrt( mu / r )").expect("a formula"); + assert!(html.contains(""), "the root was lost: {html}"); + } + + #[test] + fn a_quotient_is_taken_as_its_own() { + // `2/r_1 - 1/a_t` is two quotients, not one quotient of a difference. + let html = render_plain("d = 2/r_1 - 1/a_t").expect("a formula"); + assert_eq!(html.matches("").count(), 3, "{html}"); + } + + #[test] + fn a_matrix_stays_as_written() { + // A matrix is not a formula this renderer lays out. + assert!(render_plain("R = [ 1 0\n 0 1 ]").is_none()); + } + + #[test] + fn prose_stays_as_written() { + assert!(render_plain("e_max such that the elevation is arctan").is_none()); + assert!(render_plain("just some text").is_none()); + } + + #[test] + fn a_trailing_note_is_not_part_of_the_equation() { + // The parenthesis is a phrase, not a bracket in the expression. + let html = render_plain("N = a / sqrt(1 - e^2 sin^2 phi) (prime vertical radius)") + .expect("a formula"); + // The note is set beside the formula rather than inside it, and it is + // kept rather than dropped. + assert!( + html.contains("prime vertical radius"), + "the note was lost: {html}" + ); + assert!( + html.contains(""), "{html}"); + } + + #[test] + fn a_brace_holds_a_subscript_of_several_symbols() { + // The Newton iteration, written the way the documents write it. + let html = render_plain("E_{n+1} = E_n - f(E_n)").expect("a formula"); + assert!(html.contains(""), "the subscript was lost: {html}"); + } + + #[test] + fn a_continuation_line_is_kept_though_it_is_not_an_equation() { + // A block of equations often carries a line with no equals sign on it. + // Losing that line would lose the equation above it. + let html = render_plain("JDN = day + 365 y\n + 153 m").expect("a formula"); + assert!(html.contains("JDN"), "{html}"); + assert!(html.contains("153 m"), "the continuation was lost: {html}"); + } + + #[test] + fn a_block_with_no_readable_equation_is_left_alone() { + assert!(render_plain("leap(y) = (y mod 4 == 0) and (y mod 100 != 0").is_none()); + } } diff --git a/src/graph/mod.rs b/src/graph/mod.rs index 05d21c6..f1adf5e 100644 --- a/src/graph/mod.rs +++ b/src/graph/mod.rs @@ -336,6 +336,13 @@ pub fn crate_structure(catalog: &ApiCatalog) -> Graph { let mut present: Vec<&str> = Vec::new(); for krate in &catalog.crates { + // A crate that belongs to no layer has no place in a diagram of the + // layer structure: the workspace facade re-exports every layer and + // would sit under a layer it is not part of. It stays on the index, + // where it is a crate like any other. + if krate.layer.is_empty() { + continue; + } nodes.push(Node { id: krate.name.clone(), label: krate.name.clone(), @@ -363,8 +370,8 @@ pub fn crate_structure(catalog: &ApiCatalog) -> Graph { } } - // One node per layer that the enabled feature set contains, so the graph - // describes this build rather than the whole workspace. + // One node per layer that the catalog contains, so the graph describes + // this build rather than the whole workspace. for layer in layers { if !present.contains(&layer) { continue;