ecological-model-core 0.8.0

Shared ecological initial states, interactions, trajectory observation, and terminal products
Documentation
use ecological_model_core::terminal_state::{
    StopReason, TerminalClassification, TerminationSignal,
};
use ecological_model_core::trajectory::{
    AbundanceView, DetectionPolicy, EquilibriumEvidence, EquilibriumPolicy, PeriodicOrbitPolicy,
    ResidualTolerance, TerminalPolicy, TrajectoryObservation, TrajectoryObservationPolicy,
    TrajectoryObserver,
};

fn observation<'a>(iteration: u64, abundance: &'a [f64]) -> TrajectoryObservation<'a> {
    TrajectoryObservation {
        iteration,
        physical_time: Some(iteration as f64),
        abundance: AbundanceView::Continuous(abundance),
        detector_observable: None,
        equilibrium_evidence: EquilibriumEvidence::Unavailable,
    }
}

#[test]
fn disabled_constructs_no_observer() {
    assert!(
        TrajectoryObserver::from_policy(TrajectoryObservationPolicy::Disabled)
            .unwrap()
            .is_none()
    );
}

#[test]
fn terminal_only_forces_an_off_cadence_final_sample() {
    let mut observer = TrajectoryObserver::from_policy(TrajectoryObservationPolicy::TerminalOnly(
        TerminalPolicy {
            sample_interval_iterations: 2,
            trailing_window_samples: 2,
        },
    ))
    .unwrap()
    .unwrap();
    let states = [[3.0, 1.0], [2.0, 2.0], [1.0, 3.0], [4.0, 1.0]];
    for (iteration, state) in states.iter().enumerate() {
        assert!(
            observer
                .observe(observation(iteration as u64, state))
                .unwrap()
                .is_none()
        );
    }
    let terminal = observer
        .finish(observation(3, &states[3]), StopReason::MaximumIterations)
        .unwrap();
    assert_eq!(
        terminal.classification(),
        TerminalClassification::TrailingAverage
    );
    assert_eq!(terminal.first_sample_iteration(), 2);
    assert_eq!(terminal.last_sample_iteration(), 3);
    assert_eq!(terminal.composition(), &[0.525, 0.475]);
}

#[test]
fn staged_equilibrium_requires_authoritative_evidence() {
    let policy = DetectionPolicy {
        terminal: TerminalPolicy {
            sample_interval_iterations: 1,
            trailing_window_samples: 3,
        },
        start_after_iteration: 0,
        equilibrium: Some(EquilibriumPolicy {
            base_window_samples: 2,
            confirmation_window_multipliers: vec![1, 2],
            maximum_observable_distance: 1.0e-12,
            maximum_relative_mass_range: Some(0.0),
            support_threshold: 0.0,
            residual_tolerance: ResidualTolerance {
                absolute: 1.0e-8,
                relative: 1.0e-6,
            },
        }),
        periodic_orbit: None,
        detect_absorbing_state: true,
    };
    let mut observer = TrajectoryObserver::from_policy(TrajectoryObservationPolicy::Detect(policy))
        .unwrap()
        .unwrap();
    let state = [1.0, 1.0];
    let mut signal = None;
    for iteration in 0..6 {
        let mut value = observation(iteration, &state);
        value.equilibrium_evidence = EquilibriumEvidence::MaximumScaledResidual { value: 0.5 };
        signal = observer.observe(value).unwrap();
    }
    assert!(matches!(signal, Some(TerminationSignal::Equilibrium(_))));
}

#[test]
fn recurrent_nonconstant_states_form_a_periodic_orbit() {
    let policy = DetectionPolicy {
        terminal: TerminalPolicy {
            sample_interval_iterations: 1,
            trailing_window_samples: 4,
        },
        start_after_iteration: 0,
        equilibrium: None,
        periodic_orbit: Some(PeriodicOrbitPolicy {
            minimum_period_samples: 2,
            maximum_period_samples: 2,
            repeated_cycles: 2,
            maximum_recurrence_distance: 0.0,
            minimum_orbit_amplitude: 0.1,
        }),
        detect_absorbing_state: false,
    };
    let mut observer = TrajectoryObserver::from_policy(TrajectoryObservationPolicy::Detect(policy))
        .unwrap()
        .unwrap();
    let states = [[0.9, 0.1], [0.1, 0.9]];
    let mut signal = None;
    for iteration in 0..5 {
        signal = observer
            .observe(observation(iteration, &states[iteration as usize % 2]))
            .unwrap();
    }
    assert!(matches!(signal, Some(TerminationSignal::PeriodicOrbit(_))));
}

#[test]
fn explicit_absorption_yields_the_exact_count_composition() {
    let policy = DetectionPolicy {
        terminal: TerminalPolicy {
            sample_interval_iterations: 1,
            trailing_window_samples: 2,
        },
        start_after_iteration: 0,
        equilibrium: None,
        periodic_orbit: None,
        detect_absorbing_state: true,
    };
    let mut observer = TrajectoryObserver::from_policy(TrajectoryObservationPolicy::Detect(policy))
        .unwrap()
        .unwrap();
    let counts = [1_usize, 3];
    let value = TrajectoryObservation {
        iteration: 4,
        physical_time: None,
        abundance: AbundanceView::Counts(&counts),
        detector_observable: None,
        equilibrium_evidence: EquilibriumEvidence::AbsorbingState,
    };
    let signal = observer.observe(value).unwrap().unwrap();
    let terminal = observer
        .finish(value, StopReason::Detected(signal))
        .unwrap();
    assert_eq!(
        terminal.classification(),
        TerminalClassification::AbsorbingState
    );
    assert_eq!(terminal.composition(), &[0.25, 0.75]);
    let decoded = ecological_model_core::terminal_state::TerminalState::from_json_bytes(
        &terminal.to_json_bytes().unwrap(),
    )
    .unwrap();
    assert_eq!(decoded, terminal);
}