ballistics-engine 0.44.0

High-performance ballistics trajectory engine with professional physics
Documentation
//! The single input taxonomy for the decision-support train.
//!
//! Two levels: `InputAxis` leaves (what MBA-1347 propagates individually) grouped
//! into `InputGroup` buckets (what MBA-1345 attributes by). Defined once so the two
//! features cannot drift apart.

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InputGroup {
    ProjectileDrag,
    MuzzleVelocity,
    ZeroSightGeometry,
    Atmosphere,
    Wind,
    ShotGeometry,
    Effects,
}

impl InputGroup {
    pub const ALL: &'static [InputGroup] = &[
        InputGroup::ProjectileDrag,
        InputGroup::MuzzleVelocity,
        InputGroup::ZeroSightGeometry,
        InputGroup::Atmosphere,
        InputGroup::Wind,
        InputGroup::ShotGeometry,
        InputGroup::Effects,
    ];
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InputAxis {
    Mass,
    Diameter,
    Length,
    BallisticCoefficient,
    TwistRate,
    TwistDirection,
    DragModel,
    MuzzleVelocityMps,
    SightHeight,
    ZeroDistance,
    ZeroPoiUp,
    ZeroPoiRight,
    SightOffsetLateral,
    MuzzleHeight,
    MuzzleAngle,
    Altitude,
    Temperature,
    Pressure,
    RelativeHumidity,
    Latitude,
    WindSpeed,
    WindDirection,
    WindVertical,
    TargetDistance,
    ShootingAngle,
    Cant,
    ShotAzimuth,
    AimAzimuth,
    TargetHeight,
    MagnusEnabled,
    CoriolisEnabled,
    EnhancedSpinDriftEnabled,
}

impl InputAxis {
    pub const ALL: &'static [InputAxis] = &[
        InputAxis::Mass,
        InputAxis::Diameter,
        InputAxis::Length,
        InputAxis::BallisticCoefficient,
        InputAxis::TwistRate,
        InputAxis::TwistDirection,
        InputAxis::DragModel,
        InputAxis::MuzzleVelocityMps,
        InputAxis::SightHeight,
        InputAxis::ZeroDistance,
        InputAxis::ZeroPoiUp,
        InputAxis::ZeroPoiRight,
        InputAxis::SightOffsetLateral,
        InputAxis::MuzzleHeight,
        InputAxis::MuzzleAngle,
        InputAxis::Altitude,
        InputAxis::Temperature,
        InputAxis::Pressure,
        InputAxis::RelativeHumidity,
        InputAxis::Latitude,
        InputAxis::WindSpeed,
        InputAxis::WindDirection,
        InputAxis::WindVertical,
        InputAxis::TargetDistance,
        InputAxis::ShootingAngle,
        InputAxis::Cant,
        InputAxis::ShotAzimuth,
        InputAxis::AimAzimuth,
        InputAxis::TargetHeight,
        InputAxis::MagnusEnabled,
        InputAxis::CoriolisEnabled,
        InputAxis::EnhancedSpinDriftEnabled,
    ];
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AxisKind {
    /// A real-valued input. `default_rel_step`/`min_abs_step` follow the existing
    /// convention h = (|x| * rel).max(min_abs) from src/truing.rs:1266.
    Continuous {
        unit: &'static str,
        default_rel_step: f64,
        min_abs_step: f64,
    },
    /// A boolean or enumerated input. Participates in counterfactuals; never differentiated.
    Categorical,
}

#[derive(Debug, Clone, Copy)]
pub struct AxisMeta {
    pub group: InputGroup,
    pub kind: AxisKind,
    /// True when changing this axis invalidates the zero, so the perturbed solve must
    /// re-zero before it is comparable. This flag is the cost control for the kernel.
    pub requires_rezero: bool,
}

const fn cont(unit: &'static str, rel: f64, min_abs: f64) -> AxisKind {
    AxisKind::Continuous {
        unit,
        default_rel_step: rel,
        min_abs_step: min_abs,
    }
}

pub fn axis_meta(axis: InputAxis) -> AxisMeta {
    use InputAxis::*;
    use InputGroup::*;
    let (group, kind, requires_rezero) = match axis {
        Mass => (ProjectileDrag, cont("kg", 1e-3, 1e-7), true),
        Diameter => (ProjectileDrag, cont("m", 1e-3, 1e-7), true),
        Length => (ProjectileDrag, cont("m", 1e-3, 1e-6), false),
        BallisticCoefficient => (ProjectileDrag, cont("", 1e-3, 1e-4), true),
        TwistRate => (ProjectileDrag, cont("m/turn", 1e-3, 1e-5), false),
        TwistDirection => (ProjectileDrag, AxisKind::Categorical, false),
        DragModel => (ProjectileDrag, AxisKind::Categorical, true),
        MuzzleVelocityMps => (MuzzleVelocity, cont("m/s", 1e-3, 0.15), true),
        SightHeight => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
        ZeroDistance => (ZeroSightGeometry, cont("m", 1e-3, 0.1), true),
        ZeroPoiUp => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
        ZeroPoiRight => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
        SightOffsetLateral => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
        MuzzleHeight => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
        MuzzleAngle => (ZeroSightGeometry, cont("rad", 1e-3, 1e-4), false),
        Altitude => (Atmosphere, cont("m", 1e-3, 1.0), false),
        Temperature => (Atmosphere, cont("K", 1e-3, 0.05), false),
        Pressure => (Atmosphere, cont("Pa", 1e-3, 10.0), false),
        RelativeHumidity => (Atmosphere, cont("", 1e-3, 1e-3), false),
        Latitude => (Atmosphere, cont("rad", 1e-3, 1e-5), false),
        WindSpeed => (Wind, cont("m/s", 1e-3, 0.05), false),
        WindDirection => (Wind, cont("rad", 1e-3, 1e-4), false),
        WindVertical => (Wind, cont("m/s", 1e-3, 0.05), false),
        TargetDistance => (ShotGeometry, cont("m", 1e-3, 0.5), false),
        ShootingAngle => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
        Cant => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
        ShotAzimuth => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
        AimAzimuth => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
        TargetHeight => (ShotGeometry, cont("m", 1e-3, 1e-3), false),
        MagnusEnabled => (Effects, AxisKind::Categorical, false),
        CoriolisEnabled => (Effects, AxisKind::Categorical, false),
        EnhancedSpinDriftEnabled => (Effects, AxisKind::Categorical, false),
    };
    AxisMeta {
        group,
        kind,
        requires_rezero,
    }
}

pub fn axes_in_group(group: InputGroup) -> &'static [InputAxis] {
    use InputAxis::*;
    match group {
        InputGroup::ProjectileDrag => &[
            Mass,
            Diameter,
            Length,
            BallisticCoefficient,
            TwistRate,
            TwistDirection,
            DragModel,
        ],
        InputGroup::MuzzleVelocity => &[MuzzleVelocityMps],
        // MuzzleAngle is listed FIRST, unlike every other group's more arbitrary order (0.33.0
        // decision-support Task 9, MBA-1345 review C1). On a RESOLVED request, muzzle_angle_rad
        // is not an independent input whenever zero_distance_m is also present -- it is the
        // ALREADY-SEARCHED elevation, a function of muzzle velocity and atmosphere as much as of
        // anything in this group. A caller that applies a group's axes in turn and re-resolves
        // between writes (explain.rs's swap_group/plan_exclusions) needs a LATER
        // requires_rezero axis's own re-zero to overwrite whatever MuzzleAngle wrote, with a
        // freshly re-derived, destination-consistent angle -- not the source's baked-in one.
        // Putting MuzzleAngle first guarantees SightHeight (always present, requires_rezero,
        // listed second) does exactly that. For an angle-only request (zero_distance_m absent
        // on the destination), nothing later clears it, so MuzzleAngle's own write correctly
        // stands as the swapped input in that case -- see explain.rs's module doc for the full
        // account of why this needed fixing and how it was verified.
        InputGroup::ZeroSightGeometry => &[
            MuzzleAngle,
            SightHeight,
            ZeroDistance,
            ZeroPoiUp,
            ZeroPoiRight,
            SightOffsetLateral,
            MuzzleHeight,
        ],
        InputGroup::Atmosphere => &[Altitude, Temperature, Pressure, RelativeHumidity, Latitude],
        InputGroup::Wind => &[WindSpeed, WindDirection, WindVertical],
        InputGroup::ShotGeometry => &[
            TargetDistance,
            ShootingAngle,
            Cant,
            ShotAzimuth,
            AimAzimuth,
            TargetHeight,
        ],
        InputGroup::Effects => &[MagnusEnabled, CoriolisEnabled, EnhancedSpinDriftEnabled],
    }
}

// KNOWN LIMITATIONS: Several axis/mode combinations require guards in a later task (not this one —
// this task is data only):
//
// (a) Altitude when the original request used QNH pressure: the rebuilt request carries an
//     absolute station pressure, so perturbing altitude changes density-by-altitude but NOT
//     station pressure, which is the opposite of what a QNH-entering user means.
//
// (b) ShotAzimuth when the original request used compass-referenced wind: the rebuilt request
//     carries shooter-relative wind, so the wind rotates WITH the rifle instead of staying
//     earth-fixed — the counterfactual is physically inverted.
//
// (c) WindSpeed/WindDirection/WindVertical when the original request used segmented wind: there
//     is no single scalar to read or perturb. These axes return None from read_axis under
//     segmented wind and the kernel treats them as absent.
//
// (d) Magnus + EnhancedSpinDrift together: validate_effects (src/solve_json.rs:1544-1552)
//     rejects magnus: true + enhanced_spin_drift: true as ConflictingFields. A future with_axis
//     that flips one to true while the other is already true produces a request that fails
//     validation. This cross-category constraint cannot be expressed purely in the taxonomy.

#[cfg(test)]
mod tests {
    use super::*;

    /// Every axis belongs to exactly one group, and every group lists it.
    /// Set equality: InputAxis::ALL == union of axes_in_group over all groups.
    #[test]
    fn taxonomy_is_a_partition() {
        let mut seen = Vec::new();
        for g in InputGroup::ALL {
            for a in axes_in_group(*g) {
                assert_eq!(
                    axis_meta(*a).group,
                    *g,
                    "{a:?} listed under the wrong group"
                );
                assert!(!seen.contains(a), "{a:?} appears in more than one group");
                seen.push(*a);
            }
        }
        // Set equality: every axis in ALL is listed exactly once, and every listed axis is in ALL.
        assert_eq!(
            seen.len(),
            InputAxis::ALL.len(),
            "axes_in_group() covers {} axes; InputAxis::ALL has {}",
            seen.len(),
            InputAxis::ALL.len()
        );
        for axis in InputAxis::ALL {
            assert!(
                seen.contains(axis),
                "{axis:?} is in InputAxis::ALL but not in any group"
            );
        }
        // Also verify axis_meta is callable for every member of ALL.
        for axis in InputAxis::ALL {
            let _ = axis_meta(*axis);
        }
    }

    /// Effects are boolean toggles and must never be differentiated (spec D7).
    #[test]
    fn effects_are_categorical() {
        for a in axes_in_group(InputGroup::Effects) {
            assert!(
                matches!(axis_meta(*a).kind, AxisKind::Categorical),
                "{a:?} must be Categorical"
            );
        }
    }

    /// Cost control: only zero-affecting axes force a re-zero.
    #[test]
    fn only_zero_affecting_axes_require_rezero() {
        assert!(axis_meta(InputAxis::SightHeight).requires_rezero);
        assert!(axis_meta(InputAxis::ZeroDistance).requires_rezero);
        assert!(!axis_meta(InputAxis::WindSpeed).requires_rezero);
        assert!(!axis_meta(InputAxis::TargetDistance).requires_rezero);
    }
}