sim: add output trace recording
This commit is contained in:
parent
f56ec891a0
commit
2ebd85a434
|
|
@ -198,6 +198,9 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apsis-output"
|
name = "apsis-output"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"apsis-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apsis-resource"
|
name = "apsis-resource"
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,4 @@ publish = false
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
apsis-core.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1 +1,45 @@
|
||||||
//! Simulation output recording.
|
//! 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;
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
|
mod record;
|
||||||
|
mod recorder;
|
||||||
|
mod trace;
|
||||||
|
|
||||||
|
pub use record::{Record, RecordKind};
|
||||||
|
pub use recorder::Recorder;
|
||||||
|
pub use trace::{SeriesSummary, Trace};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
//! One recorded value.
|
||||||
|
|
||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
/// What a record is about.
|
||||||
|
///
|
||||||
|
/// The three kinds separate the things a caller usually wants to tell apart
|
||||||
|
/// when it reads a trace back: a value the model measured, a decision it
|
||||||
|
/// made, and a problem it wants to surface.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum RecordKind {
|
||||||
|
/// A value the model measured, such as a range or a rate.
|
||||||
|
Observation,
|
||||||
|
/// A change the model decided on, such as entering eclipse.
|
||||||
|
State,
|
||||||
|
/// A condition worth surfacing, such as a value outside its bounds.
|
||||||
|
Warning,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordKind {
|
||||||
|
/// Returns a short name for the kind.
|
||||||
|
pub const fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Observation => "observation",
|
||||||
|
Self::State => "state",
|
||||||
|
Self::Warning => "warning",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the kind is a warning.
|
||||||
|
pub const fn is_warning(self) -> bool {
|
||||||
|
matches!(self, Self::Warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RecordKind {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(self.name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One recorded value.
|
||||||
|
///
|
||||||
|
/// A record is small and copied by value, so a trace holds a flat list rather
|
||||||
|
/// than a tree of references.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Record {
|
||||||
|
/// The simulation time the record was made at, in seconds.
|
||||||
|
pub time: f64,
|
||||||
|
/// What the record is about.
|
||||||
|
pub kind: RecordKind,
|
||||||
|
/// What the value is about.
|
||||||
|
pub label: String,
|
||||||
|
/// The value recorded.
|
||||||
|
pub value: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Record {
|
||||||
|
/// Creates a record.
|
||||||
|
pub fn new(time: f64, kind: RecordKind, label: impl Into<String>, value: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
time,
|
||||||
|
kind,
|
||||||
|
label: label.into(),
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an observation record.
|
||||||
|
pub fn observation(time: f64, label: impl Into<String>, value: f64) -> Self {
|
||||||
|
Self::new(time, RecordKind::Observation, label, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a state record.
|
||||||
|
pub fn state(time: f64, label: impl Into<String>, value: f64) -> Self {
|
||||||
|
Self::new(time, RecordKind::State, label, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a warning record.
|
||||||
|
pub fn warning(time: f64, label: impl Into<String>, value: f64) -> Self {
|
||||||
|
Self::new(time, RecordKind::Warning, label, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the record is usable.
|
||||||
|
///
|
||||||
|
/// A record whose time or value is not finite has no place in a trace,
|
||||||
|
/// because it cannot be ordered or summarized.
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.time.is_finite() && self.value.is_finite() && !self.label.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Record {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{} {} {} = {}",
|
||||||
|
self.time, self.kind, self.label, self.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
//! Building a trace as a simulation runs.
|
||||||
|
|
||||||
|
use crate::record::{Record, RecordKind};
|
||||||
|
use crate::trace::Trace;
|
||||||
|
|
||||||
|
/// Collects records as a simulation runs.
|
||||||
|
///
|
||||||
|
/// The recorder is the write side of a trace: a handler calls one of its
|
||||||
|
/// methods for each value it wants to keep, and asks for the trace when the
|
||||||
|
/// run finishes. The two are separate so that a handler can hold a recorder
|
||||||
|
/// while the trace is read elsewhere.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use apsis_output::Recorder;
|
||||||
|
///
|
||||||
|
/// let mut recorder = Recorder::new();
|
||||||
|
/// recorder.observe(0.0, "range", 1_000.0);
|
||||||
|
/// recorder.observe(10.0, "range", 950.0);
|
||||||
|
///
|
||||||
|
/// let summary = recorder.trace().summarize("range").expect("the series exists");
|
||||||
|
/// assert_eq!(summary.count, 2);
|
||||||
|
/// assert_eq!(summary.change, -50.0);
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Recorder {
|
||||||
|
trace: Trace,
|
||||||
|
refused: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Recorder {
|
||||||
|
/// Creates an empty recorder.
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
trace: Trace::new(),
|
||||||
|
refused: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a value of a given kind.
|
||||||
|
///
|
||||||
|
/// Returns `false` when the record is not usable, which a caller can
|
||||||
|
/// ignore when the value is known to be finite.
|
||||||
|
pub fn record(
|
||||||
|
&mut self,
|
||||||
|
time: f64,
|
||||||
|
kind: RecordKind,
|
||||||
|
label: impl Into<String>,
|
||||||
|
value: f64,
|
||||||
|
) -> bool {
|
||||||
|
let accepted = self.trace.push(Record::new(time, kind, label, value));
|
||||||
|
if !accepted {
|
||||||
|
self.refused = self.refused.wrapping_add(1);
|
||||||
|
}
|
||||||
|
accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records an observation.
|
||||||
|
pub fn observe(&mut self, time: f64, label: impl Into<String>, value: f64) -> bool {
|
||||||
|
self.record(time, RecordKind::Observation, label, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a state change.
|
||||||
|
pub fn note(&mut self, time: f64, label: impl Into<String>, value: f64) -> bool {
|
||||||
|
self.record(time, RecordKind::State, label, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a warning.
|
||||||
|
pub fn warn(&mut self, time: f64, label: impl Into<String>, value: f64) -> bool {
|
||||||
|
self.record(time, RecordKind::Warning, label, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of records made.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.trace.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when no records have been made.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.trace.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of records refused because they were not usable.
|
||||||
|
///
|
||||||
|
/// A caller that records values from a model checks this to see whether
|
||||||
|
/// any value was dropped, rather than comparing counts by hand.
|
||||||
|
pub const fn refused(&self) -> u64 {
|
||||||
|
self.refused
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the trace recorded so far.
|
||||||
|
pub const fn trace(&self) -> &Trace {
|
||||||
|
&self.trace
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the trace, leaving an empty one behind.
|
||||||
|
///
|
||||||
|
/// This is what a caller does between runs: the finished trace goes to
|
||||||
|
/// whoever reads it, and the recorder starts fresh. The refused count is
|
||||||
|
/// not reset, because it records the whole life of the recorder.
|
||||||
|
pub fn take_trace(&mut self) -> Trace {
|
||||||
|
core::mem::take(&mut self.trace)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes every record without changing the refused count.
|
||||||
|
pub fn clear(&mut self) -> usize {
|
||||||
|
self.trace.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
//! The trace and its summaries.
|
||||||
|
|
||||||
|
use crate::record::{Record, RecordKind};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// A list of records in the order they were made.
|
||||||
|
///
|
||||||
|
/// The trace preserves insertion order rather than sorting by time, because
|
||||||
|
/// the order a model recorded in is part of what it decided. A caller that
|
||||||
|
/// needs time order asks for [`Trace::in_time_order`], which leaves the
|
||||||
|
/// original alone.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct Trace {
|
||||||
|
records: Vec<Record>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Trace {
|
||||||
|
/// Creates an empty trace.
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
records: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a trace from a list of records.
|
||||||
|
pub fn from_records(records: Vec<Record>) -> Self {
|
||||||
|
Self { records }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of records.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.records.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when there are no records.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.records.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the records in the order they were made.
|
||||||
|
pub fn records(&self) -> &[Record] {
|
||||||
|
&self.records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a record, returning `false` when it is not usable.
|
||||||
|
///
|
||||||
|
/// A record whose time or value is not finite is refused rather than
|
||||||
|
/// stored, so a trace never holds a value that cannot be summarized.
|
||||||
|
pub fn push(&mut self, record: Record) -> bool {
|
||||||
|
if !record.is_valid() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.records.push(record);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when no record has a time or value that is not finite.
|
||||||
|
pub fn is_stable(&self) -> bool {
|
||||||
|
self.records.iter().all(Record::is_valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the records in time order, earliest first.
|
||||||
|
///
|
||||||
|
/// Records at the same instant keep their insertion order, so the sort is
|
||||||
|
/// stable and the result does not depend on the sort algorithm.
|
||||||
|
pub fn in_time_order(&self) -> Vec<Record> {
|
||||||
|
let mut ordered = self.records.clone();
|
||||||
|
ordered.sort_by(|left, right| {
|
||||||
|
left.time
|
||||||
|
.partial_cmp(&right.time)
|
||||||
|
.unwrap_or(core::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the records of one kind, in order.
|
||||||
|
pub fn of_kind(&self, kind: RecordKind) -> Vec<&Record> {
|
||||||
|
self.records
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.kind == kind)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the records with a label, in order.
|
||||||
|
pub fn with_label(&self, label: &str) -> Vec<&Record> {
|
||||||
|
self.records
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.label == label)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the value of the last record with a label.
|
||||||
|
///
|
||||||
|
/// This is what a caller asks for after a run: the final value a series
|
||||||
|
/// reached. Returns `None` when no record has the label.
|
||||||
|
pub fn value_of(&self, label: &str) -> Option<f64> {
|
||||||
|
self.records
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|record| record.label == label)
|
||||||
|
.map(|record| record.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the time of the last record with a label.
|
||||||
|
pub fn last_time_of(&self, label: &str) -> Option<f64> {
|
||||||
|
self.records
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|record| record.label == label)
|
||||||
|
.map(|record| record.time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the labels present, in sorted order.
|
||||||
|
///
|
||||||
|
/// The order is sorted rather than first seen, so that two traces with
|
||||||
|
/// the same labels list them the same way.
|
||||||
|
pub fn labels(&self) -> Vec<&str> {
|
||||||
|
let mut labels: Vec<&str> = self
|
||||||
|
.records
|
||||||
|
.iter()
|
||||||
|
.map(|record| record.label.as_str())
|
||||||
|
.collect();
|
||||||
|
labels.sort_unstable();
|
||||||
|
labels.dedup();
|
||||||
|
labels
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a summary of the series recorded under a label.
|
||||||
|
///
|
||||||
|
/// Returns `None` when no record has the label.
|
||||||
|
pub fn summarize(&self, label: &str) -> Option<SeriesSummary> {
|
||||||
|
let values: Vec<f64> = self
|
||||||
|
.records
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.label == label)
|
||||||
|
.map(|record| record.value)
|
||||||
|
.collect();
|
||||||
|
if values.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let count = values.len();
|
||||||
|
let sum: f64 = values.iter().sum();
|
||||||
|
let min = values.iter().copied().fold(f64::INFINITY, f64::min);
|
||||||
|
let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
let first = values[0];
|
||||||
|
let last = values[count - 1];
|
||||||
|
Some(SeriesSummary {
|
||||||
|
label: label.to_string(),
|
||||||
|
count,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
mean: sum / count as f64,
|
||||||
|
first,
|
||||||
|
last,
|
||||||
|
change: last - first,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of warnings in the trace.
|
||||||
|
pub fn warning_count(&self) -> usize {
|
||||||
|
self.records
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.kind.is_warning())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a summary of every label, in sorted label order.
|
||||||
|
pub fn summaries(&self) -> Vec<SeriesSummary> {
|
||||||
|
self.labels()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|label| self.summarize(label))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the greatest number of records that share a label.
|
||||||
|
///
|
||||||
|
/// This is the length of the longest series, which a caller uses to size
|
||||||
|
/// a table of the trace.
|
||||||
|
pub fn longest_series(&self) -> usize {
|
||||||
|
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
|
||||||
|
for record in &self.records {
|
||||||
|
*counts.entry(record.label.as_str()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
counts.values().copied().max().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes every record and returns how many were discarded.
|
||||||
|
pub fn clear(&mut self) -> usize {
|
||||||
|
let count = self.records.len();
|
||||||
|
self.records.clear();
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A summary of one series of records.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct SeriesSummary {
|
||||||
|
/// The label the series was recorded under.
|
||||||
|
pub label: String,
|
||||||
|
/// The number of records in the series.
|
||||||
|
pub count: usize,
|
||||||
|
/// The smallest value recorded.
|
||||||
|
pub min: f64,
|
||||||
|
/// The greatest value recorded.
|
||||||
|
pub max: f64,
|
||||||
|
/// The mean of the values.
|
||||||
|
pub mean: f64,
|
||||||
|
/// The first value recorded.
|
||||||
|
pub first: f64,
|
||||||
|
/// The last value recorded.
|
||||||
|
pub last: f64,
|
||||||
|
/// The difference between the last and first values.
|
||||||
|
pub change: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SeriesSummary {
|
||||||
|
/// Returns the spread between the greatest and smallest values.
|
||||||
|
pub fn range(&self) -> f64 {
|
||||||
|
self.max - self.min
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the series never changed.
|
||||||
|
pub fn is_constant(&self) -> bool {
|
||||||
|
self.max == self.min
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
//! Tests for the output trace and recorder.
|
||||||
|
|
||||||
|
use apsis_output::{Record, RecordKind, Recorder, Trace};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_trace_has_nothing() {
|
||||||
|
let trace = Trace::new();
|
||||||
|
assert!(trace.is_empty());
|
||||||
|
assert_eq!(trace.len(), 0);
|
||||||
|
assert!(trace.is_stable());
|
||||||
|
assert!(trace.records().is_empty());
|
||||||
|
assert_eq!(trace.labels(), Vec::<&str>::new());
|
||||||
|
assert_eq!(trace.value_of("anything"), None);
|
||||||
|
assert_eq!(trace.summarize("anything"), None);
|
||||||
|
assert_eq!(trace.warning_count(), 0);
|
||||||
|
assert_eq!(trace.longest_series(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_trace_keeps_insertion_order() {
|
||||||
|
// The order a model recorded in is part of what it decided, so the trace
|
||||||
|
// does not sort.
|
||||||
|
let mut trace = Trace::new();
|
||||||
|
trace.push(Record::observation(10.0, "b", 1.0));
|
||||||
|
trace.push(Record::observation(5.0, "a", 2.0));
|
||||||
|
let labels: Vec<&str> = trace
|
||||||
|
.records()
|
||||||
|
.iter()
|
||||||
|
.map(|record| record.label.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(labels, vec!["b", "a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn time_order_is_available_on_request() {
|
||||||
|
let mut trace = Trace::new();
|
||||||
|
trace.push(Record::observation(10.0, "b", 1.0));
|
||||||
|
trace.push(Record::observation(5.0, "a", 2.0));
|
||||||
|
trace.push(Record::observation(7.0, "c", 3.0));
|
||||||
|
let ordered = trace.in_time_order();
|
||||||
|
let times: Vec<f64> = ordered.iter().map(|record| record.time).collect();
|
||||||
|
assert_eq!(times, vec![5.0, 7.0, 10.0]);
|
||||||
|
// The original is unchanged.
|
||||||
|
assert_eq!(trace.records()[0].time, 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn time_order_is_stable_for_equal_times() {
|
||||||
|
let mut trace = Trace::new();
|
||||||
|
trace.push(Record::observation(5.0, "first", 1.0));
|
||||||
|
trace.push(Record::observation(5.0, "second", 2.0));
|
||||||
|
trace.push(Record::observation(5.0, "third", 3.0));
|
||||||
|
let ordered = trace.in_time_order();
|
||||||
|
let labels: Vec<&str> = ordered.iter().map(|record| record.label.as_str()).collect();
|
||||||
|
assert_eq!(labels, vec!["first", "second", "third"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn records_can_be_filtered_by_kind() {
|
||||||
|
let mut trace = Trace::new();
|
||||||
|
trace.push(Record::observation(1.0, "a", 1.0));
|
||||||
|
trace.push(Record::warning(2.0, "b", 2.0));
|
||||||
|
trace.push(Record::state(3.0, "c", 3.0));
|
||||||
|
trace.push(Record::warning(4.0, "d", 4.0));
|
||||||
|
|
||||||
|
assert_eq!(trace.of_kind(RecordKind::Observation).len(), 1);
|
||||||
|
assert_eq!(trace.of_kind(RecordKind::Warning).len(), 2);
|
||||||
|
assert_eq!(trace.of_kind(RecordKind::State).len(), 1);
|
||||||
|
assert_eq!(trace.warning_count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn records_can_be_filtered_by_label() {
|
||||||
|
let mut trace = Trace::new();
|
||||||
|
trace.push(Record::observation(1.0, "range", 1_000.0));
|
||||||
|
trace.push(Record::observation(2.0, "range", 900.0));
|
||||||
|
trace.push(Record::observation(3.0, "rate", 5.0));
|
||||||
|
assert_eq!(trace.with_label("range").len(), 2);
|
||||||
|
assert_eq!(trace.with_label("rate").len(), 1);
|
||||||
|
assert_eq!(trace.with_label("absent").len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_last_value_of_a_label_is_reported() {
|
||||||
|
let mut trace = Trace::new();
|
||||||
|
trace.push(Record::observation(1.0, "range", 1_000.0));
|
||||||
|
trace.push(Record::observation(5.0, "range", 900.0));
|
||||||
|
trace.push(Record::observation(9.0, "range", 950.0));
|
||||||
|
assert_eq!(trace.value_of("range"), Some(950.0));
|
||||||
|
assert_eq!(trace.last_time_of("range"), Some(9.0));
|
||||||
|
assert_eq!(trace.value_of("absent"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_labels_are_unique_and_sorted() {
|
||||||
|
let mut trace = Trace::new();
|
||||||
|
trace.push(Record::observation(1.0, "zeta", 1.0));
|
||||||
|
trace.push(Record::observation(2.0, "alpha", 2.0));
|
||||||
|
trace.push(Record::observation(3.0, "zeta", 3.0));
|
||||||
|
assert_eq!(trace.labels(), vec!["alpha", "zeta"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_series_summarizes_its_values() {
|
||||||
|
let mut trace = Trace::new();
|
||||||
|
for (time, value) in [(0.0, 10.0), (1.0, 20.0), (2.0, 30.0), (3.0, 5.0)] {
|
||||||
|
trace.push(Record::observation(time, "x", value));
|
||||||
|
}
|
||||||
|
let summary = trace.summarize("x").expect("the series exists");
|
||||||
|
assert_eq!(summary.label, "x");
|
||||||
|
assert_eq!(summary.count, 4);
|
||||||
|
assert_eq!(summary.min, 5.0);
|
||||||
|
assert_eq!(summary.max, 30.0);
|
||||||
|
assert_eq!(summary.first, 10.0);
|
||||||
|
assert_eq!(summary.last, 5.0);
|
||||||
|
assert_eq!(summary.change, -5.0);
|
||||||
|
assert_eq!(summary.mean, 65.0 / 4.0);
|
||||||
|
assert_eq!(summary.range(), 25.0);
|
||||||
|
assert!(!summary.is_constant());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_constant_series_is_recognized() {
|
||||||
|
let mut trace = Trace::new();
|
||||||
|
for time in 0..3 {
|
||||||
|
trace.push(Record::observation(f64::from(time), "x", 7.0));
|
||||||
|
}
|
||||||
|
let summary = trace.summarize("x").expect("the series exists");
|
||||||
|
assert!(summary.is_constant());
|
||||||
|
assert_eq!(summary.range(), 0.0);
|
||||||
|
assert_eq!(summary.change, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_summaries_cover_every_label_in_order() {
|
||||||
|
let mut trace = Trace::new();
|
||||||
|
trace.push(Record::observation(1.0, "b", 1.0));
|
||||||
|
trace.push(Record::observation(1.0, "a", 2.0));
|
||||||
|
trace.push(Record::observation(2.0, "b", 3.0));
|
||||||
|
let summaries = trace.summaries();
|
||||||
|
assert_eq!(summaries.len(), 2);
|
||||||
|
assert_eq!(summaries[0].label, "a");
|
||||||
|
assert_eq!(summaries[1].label, "b");
|
||||||
|
assert_eq!(summaries[1].count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_longest_series_is_reported() {
|
||||||
|
let mut trace = Trace::new();
|
||||||
|
for index in 0..5 {
|
||||||
|
trace.push(Record::observation(f64::from(index), "x", 1.0));
|
||||||
|
}
|
||||||
|
for index in 0..2 {
|
||||||
|
trace.push(Record::observation(f64::from(index), "y", 1.0));
|
||||||
|
}
|
||||||
|
assert_eq!(trace.longest_series(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_finite_record_is_refused() {
|
||||||
|
let mut trace = Trace::new();
|
||||||
|
assert!(!trace.push(Record::observation(f64::NAN, "x", 1.0)));
|
||||||
|
assert!(!trace.push(Record::observation(1.0, "x", f64::INFINITY)));
|
||||||
|
assert!(!trace.push(Record::observation(1.0, "", 1.0)));
|
||||||
|
assert!(trace.is_empty());
|
||||||
|
|
||||||
|
// A finite record is accepted.
|
||||||
|
assert!(trace.push(Record::observation(1.0, "x", 1.0)));
|
||||||
|
assert_eq!(trace.len(), 1);
|
||||||
|
assert!(trace.is_stable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clearing_empties_the_trace() {
|
||||||
|
let mut trace = Trace::new();
|
||||||
|
trace.push(Record::observation(1.0, "x", 1.0));
|
||||||
|
trace.push(Record::observation(2.0, "x", 2.0));
|
||||||
|
assert_eq!(trace.clear(), 2);
|
||||||
|
assert!(trace.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_recorder_collects_records_of_each_kind() {
|
||||||
|
let mut recorder = Recorder::new();
|
||||||
|
assert!(recorder.observe(1.0, "range", 100.0));
|
||||||
|
assert!(recorder.note(2.0, "eclipse", 1.0));
|
||||||
|
assert!(recorder.warn(3.0, "temperature", 90.0));
|
||||||
|
|
||||||
|
assert_eq!(recorder.len(), 3);
|
||||||
|
assert!(!recorder.is_empty());
|
||||||
|
let trace = recorder.trace();
|
||||||
|
assert_eq!(trace.of_kind(RecordKind::Observation).len(), 1);
|
||||||
|
assert_eq!(trace.of_kind(RecordKind::State).len(), 1);
|
||||||
|
assert_eq!(trace.of_kind(RecordKind::Warning).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_recorder_counts_what_it_refused() {
|
||||||
|
let mut recorder = Recorder::new();
|
||||||
|
assert!(!recorder.observe(f64::NAN, "x", 1.0));
|
||||||
|
assert!(!recorder.observe(1.0, "x", f64::NAN));
|
||||||
|
assert!(recorder.observe(1.0, "x", 1.0));
|
||||||
|
assert_eq!(recorder.refused(), 2);
|
||||||
|
assert_eq!(recorder.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn taking_the_trace_leaves_the_recorder_empty() {
|
||||||
|
let mut recorder = Recorder::new();
|
||||||
|
recorder.observe(1.0, "x", 1.0);
|
||||||
|
recorder.observe(2.0, "x", 2.0);
|
||||||
|
|
||||||
|
let trace = recorder.take_trace();
|
||||||
|
assert_eq!(trace.len(), 2);
|
||||||
|
assert!(recorder.is_empty());
|
||||||
|
// The refused count belongs to the recorder and is not reset.
|
||||||
|
recorder.observe(f64::NAN, "x", 1.0);
|
||||||
|
assert_eq!(recorder.refused(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_recorder_can_be_cleared_without_taking() {
|
||||||
|
let mut recorder = Recorder::new();
|
||||||
|
recorder.observe(1.0, "x", 1.0);
|
||||||
|
assert_eq!(recorder.clear(), 1);
|
||||||
|
assert!(recorder.is_empty());
|
||||||
|
assert_eq!(recorder.trace().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_trace_can_be_built_from_records() {
|
||||||
|
let records = vec![
|
||||||
|
Record::observation(1.0, "a", 1.0),
|
||||||
|
Record::observation(2.0, "b", 2.0),
|
||||||
|
];
|
||||||
|
let trace = Trace::from_records(records);
|
||||||
|
assert_eq!(trace.len(), 2);
|
||||||
|
assert_eq!(trace.value_of("b"), Some(2.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_displays_its_shape() {
|
||||||
|
let record = Record::warning(3.5, "temperature", 90.0);
|
||||||
|
let text = record.to_string();
|
||||||
|
assert!(text.contains("warning"), "{text}");
|
||||||
|
assert!(text.contains("temperature"), "{text}");
|
||||||
|
assert!(text.contains("90"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_record_kinds_report_themselves() {
|
||||||
|
assert_eq!(RecordKind::Observation.name(), "observation");
|
||||||
|
assert_eq!(RecordKind::State.name(), "state");
|
||||||
|
assert_eq!(RecordKind::Warning.name(), "warning");
|
||||||
|
assert!(RecordKind::Warning.is_warning());
|
||||||
|
assert!(!RecordKind::State.is_warning());
|
||||||
|
assert_eq!(RecordKind::State.to_string(), "state");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_reports_whether_it_is_usable() {
|
||||||
|
assert!(Record::observation(1.0, "x", 1.0).is_valid());
|
||||||
|
assert!(!Record::observation(f64::NAN, "x", 1.0).is_valid());
|
||||||
|
assert!(!Record::observation(1.0, "x", f64::NAN).is_valid());
|
||||||
|
assert!(!Record::observation(1.0, "", 1.0).is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_trace_default_is_empty() {
|
||||||
|
let trace = Trace::default();
|
||||||
|
assert!(trace.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_recorder_default_is_empty() {
|
||||||
|
let recorder = Recorder::default();
|
||||||
|
assert!(recorder.is_empty());
|
||||||
|
assert_eq!(recorder.refused(), 0);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue