Skip to main content

bhtune_runtime/
driver.rs

1//! Constructs the selected [`bhtune_driver::Driver`] implementation from a [`TuneRequest`].
2
3use bhtune_driver::{Driver, FopdtConfig, OpcDaDriver, SimulatorDriver};
4
5use crate::tune::{DriverKind, TuneRequest};
6
7/// The two tag names [`SimulatorDriver`] is configured with — fixed rather than derived
8/// from `--tagname`/a template, since the simulator has no DCS suffix convention.
9pub const SIMULATOR_PV_TAG: &str = "Sim.PV";
10pub const SIMULATOR_MV_TAG: &str = "Sim.MV";
11
12/// Builds the driver `args` selects. For `--driver simulator`, `args.tagname` is ignored;
13/// the caller must build its [`bhtune_core::LoopTags`] using [`SIMULATOR_PV_TAG`]/
14/// [`SIMULATOR_MV_TAG`] instead of deriving from a template.
15pub async fn build_with_poll_interval(
16    args: &TuneRequest,
17    poll_interval_ms: u64,
18) -> anyhow::Result<Box<dyn Driver>> {
19    match args.driver {
20        DriverKind::Opcda => {
21            let server = args
22                .server
23                .clone()
24                .ok_or_else(|| anyhow::anyhow!("--server is required with --driver opcda"))?;
25            // By the time `build` runs, `tune::prepare` has already resolved
26            // `args.bridge_host` through `crate::config::resolve_bridge_host` (CLI > env >
27            // config file > default), so this `unwrap_or` is a defensive fallback for
28            // direct/test callers that bypass that resolution step, not the primary
29            // precedence mechanism.
30            let bridge_host = args
31                .bridge_host
32                .as_deref()
33                .unwrap_or(crate::config::DEFAULT_BRIDGE_HOST);
34            tracing::info!(bridge_host, server = %server, "connecting to opcda-bridge gateway");
35            let driver = OpcDaDriver::connect(bridge_host, server).await?;
36            Ok(Box::new(driver))
37        }
38        DriverKind::Simulator => {
39            tracing::info!(
40                gain = args.sim_gain,
41                tau = args.sim_tau,
42                dead_time = args.sim_dead_time,
43                "constructing simulator driver"
44            );
45            let config = FopdtConfig::new(
46                args.sim_gain,
47                args.sim_tau,
48                args.sim_dead_time,
49                poll_interval_ms as f32 / 1000.0,
50            )
51            .with_noise_amplitude(args.sim_noise);
52            let driver = SimulatorDriver::new(
53                SIMULATOR_PV_TAG,
54                SIMULATOR_MV_TAG,
55                config,
56                args.sim_initial_pv,
57                args.sim_initial_mv,
58                args.sim_seed,
59            );
60            Ok(Box::new(driver))
61        }
62    }
63}
64
65#[cfg(test)]
66pub async fn build(args: &TuneRequest) -> anyhow::Result<Box<dyn Driver>> {
67    build_with_poll_interval(args, 800).await
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use bhtune_core::{ControllerDirection, ControllerType, ProcessType};
74    use bhtune_driver::TagWrite;
75
76    fn sim_args() -> TuneRequest {
77        TuneRequest {
78            tagname: "ignored".to_string(),
79            template: "Yokogawa CentumVP".to_string(),
80            process_type: ProcessType::Flow,
81            controller_type: ControllerType::Pi,
82            relay_amp: 10.0,
83            cycles_skip: None,
84            cycles_count: None,
85            noise_protection_secs: None,
86            mrft_delay: 0,
87            poll_interval_ms: 800,
88            timeout_secs: 3600,
89            op_timeout_secs: 30,
90            restore_timeout_secs: 30,
91            driver: DriverKind::Simulator,
92            bridge_host: None,
93            server: None,
94            sim_gain: 1.0,
95            sim_tau: 2.0,
96            sim_dead_time: 5.0,
97            sim_noise: 0.0,
98            sim_seed: 0,
99            sim_initial_pv: 50.0,
100            sim_initial_mv: 50.0,
101            pv_range_high: Some(100.0),
102            pv_range_low: Some(0.0),
103            mv_range_high: Some(100.0),
104            mv_range_low: Some(0.0),
105            direction: Some(ControllerDirection::Reverse),
106            tag_overrides: None,
107            notes: None,
108            yes: false,
109            write_pid: None,
110        }
111    }
112
113    #[tokio::test]
114    async fn builds_a_working_simulator_driver() {
115        let driver = build(&sim_args()).await.unwrap();
116        let values = driver.read(&[SIMULATOR_MV_TAG.to_string()]).await.unwrap();
117        assert_eq!(values[0].value, "50");
118    }
119
120    #[tokio::test]
121    async fn simulator_uses_poll_interval_in_seconds_for_process_dynamics() {
122        let mut args = sim_args();
123        args.sim_tau = 1_000.0;
124        args.sim_dead_time = 0.0;
125        args.sim_initial_pv = 0.0;
126        args.sim_initial_mv = 0.0;
127        let driver = build_with_poll_interval(&args, 800).await.unwrap();
128        let outcome = driver
129            .write(&SIMULATOR_MV_TAG.to_string(), TagWrite::Float(100.0))
130            .await
131            .unwrap();
132        assert!(outcome.success);
133
134        let values = driver.read(&[SIMULATOR_PV_TAG.to_string()]).await.unwrap();
135        let pv = values[0].value.parse::<f32>().unwrap();
136        let expected = 100.0 * (1.0 - (-0.8_f32 / 1_000.0).exp());
137        assert!(
138            (pv - expected).abs() < 0.001,
139            "expected one 800 ms process step ({expected}), got {pv}"
140        );
141    }
142
143    #[tokio::test]
144    async fn opcda_driver_requires_a_server_flag() {
145        let mut args = sim_args();
146        args.driver = DriverKind::Opcda;
147        args.bridge_host = Some("127.0.0.1:1".to_string());
148        args.server = None;
149        let result = build(&args).await;
150        assert!(result.is_err());
151        assert!(result.err().unwrap().to_string().contains("--server"));
152    }
153
154    #[tokio::test]
155    async fn opcda_driver_falls_back_to_the_default_bridge_host_when_unset() {
156        // `build()` is normally only reached after `tune::prepare` has already
157        // resolved `bridge_host` via `crate::config::resolve_bridge_host`, so a `None` here
158        // only happens for a direct/test caller -- confirms the fallback constant is used
159        // rather than e.g. an empty host string.
160        let mut args = sim_args();
161        args.driver = DriverKind::Opcda;
162        args.bridge_host = None;
163        args.server = Some("MockServer".to_string());
164        let err = build(&args).await.err().unwrap();
165        // `DEFAULT_BRIDGE_HOST` ("localhost:7600") has nothing listening in CI, so this
166        // still fails -- what matters is that it attempted the default host, not a blank one.
167        assert!(!err.to_string().is_empty());
168    }
169
170    #[tokio::test]
171    async fn opcda_driver_connects_and_reads_through_a_mock_bridge() {
172        use crate::test_support::{MockBridgeService, start_mock_server};
173        use opcda_bridge_proto::bridge::{ReadResponse, TagValue as ProtoTagValue};
174
175        let (host, server) = start_mock_server(MockBridgeService {
176            read_response: ReadResponse {
177                values: vec![ProtoTagValue {
178                    tag_id: "Sim.MV".to_string(),
179                    value: "50".to_string(),
180                    quality: "Good".to_string(),
181                    timestamp: "2024-01-15 10:23:45".to_string(),
182                }],
183            },
184            ..Default::default()
185        })
186        .await;
187
188        let mut args = sim_args();
189        args.driver = DriverKind::Opcda;
190        args.bridge_host = Some(host);
191        args.server = Some("MockServer".to_string());
192
193        // Reaching a real read confirms `build()`'s OPC DA branch actually returned a
194        // connected, working `OpcDaDriver`, not just that `connect()` didn't error.
195        let driver = build(&args).await.unwrap();
196        let values = driver.read(&["Sim.MV".to_string()]).await.unwrap();
197        assert_eq!(values[0].value, "50");
198
199        server.shutdown().await;
200    }
201}