Skip to main content

bhtune_core/
direction.rs

1//! Controller action direction: whether increasing the manipulated variable increases or
2//! decreases the process variable. Determines which way the relay steps during MRFT.
3
4use serde::{Deserialize, Serialize};
5
6/// Direct-acting (increasing MV increases PV) or Reverse-acting (increasing MV decreases
7/// PV).
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
10#[serde(rename_all = "snake_case")]
11pub enum ControllerDirection {
12    Direct,
13    Reverse,
14}
15
16impl ControllerDirection {
17    /// Resolves a direction read live from a DCS tag. The tag's raw string value means
18    /// Direct exactly when it matches the active template's
19    /// `controller_action_direct_value`; any other value means Reverse.
20    pub fn from_raw_tag_value(
21        raw_value: &str,
22        controller_action_direct_value: &str,
23    ) -> ControllerDirection {
24        if raw_value == controller_action_direct_value {
25            ControllerDirection::Direct
26        } else {
27            ControllerDirection::Reverse
28        }
29    }
30
31    /// The sign flip applied throughout the relay-feedback math (`SetActionMultiplier` in
32    /// the legacy app): `-1` for Direct-acting loops, `1` for Reverse-acting. Used both by
33    /// [`crate::mrft::MrftEngine`] (to decide which way the relay steps) and
34    /// `core-tuning-math` (to interpret `mv_sign_init` when deciding which of a completed
35    /// run's peaks/troughs is the "extra" one) — kept as one method rather than duplicated
36    /// in both modules so the mapping can't silently drift between them.
37    pub fn action_multiplier(self) -> i8 {
38        match self {
39            ControllerDirection::Direct => -1,
40            ControllerDirection::Reverse => 1,
41        }
42    }
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48
49    #[test]
50    fn matches_template_direct_value() {
51        assert_eq!(
52            ControllerDirection::from_raw_tag_value("0", "0"),
53            ControllerDirection::Direct
54        );
55    }
56
57    #[test]
58    fn action_multiplier_is_negative_one_for_direct_and_one_for_reverse() {
59        assert_eq!(ControllerDirection::Direct.action_multiplier(), -1);
60        assert_eq!(ControllerDirection::Reverse.action_multiplier(), 1);
61    }
62
63    #[test]
64    fn anything_else_is_reverse() {
65        assert_eq!(
66            ControllerDirection::from_raw_tag_value("1", "0"),
67            ControllerDirection::Reverse
68        );
69        assert_eq!(
70            ControllerDirection::from_raw_tag_value("", "0"),
71            ControllerDirection::Reverse
72        );
73        assert_eq!(
74            ControllerDirection::from_raw_tag_value("garbage", "0"),
75            ControllerDirection::Reverse
76        );
77    }
78
79    #[test]
80    fn serde_round_trip() {
81        for dir in [ControllerDirection::Direct, ControllerDirection::Reverse] {
82            let json = serde_json::to_string(&dir).unwrap();
83            let back: ControllerDirection = serde_json::from_str(&json).unwrap();
84            assert_eq!(dir, back);
85        }
86    }
87
88    #[test]
89    fn serde_uses_snake_case() {
90        assert_eq!(
91            serde_json::to_string(&ControllerDirection::Reverse).unwrap(),
92            "\"reverse\""
93        );
94    }
95}