Skip to main content

bhtune_runtime/tune/
quality.rs

1#![allow(rustdoc::broken_intra_doc_links)]
2
3use std::collections::HashMap;
4
5use bhtune_core::{ControllerDirection, DcsTemplate, TagOrValue};
6use bhtune_db::models::SampleQuality;
7use bhtune_driver::{Driver, TagValue, TagWrite};
8
9/// Enforces the live-run quality policy: `Quality::Bad` is never accepted;
10/// `Quality::Uncertain` is accepted only when the global Config > OPC quality policy
11/// (`allow_uncertain_quality` in TOML) permits it, and each use of it is logged so a run
12/// executed under relaxed rules is not silently indistinguishable from a normal one.
13/// `Quality::Good` always passes.
14pub(super) fn check_quality(
15    tag: &str,
16    quality: bhtune_driver::Quality,
17    allow_uncertain: bool,
18) -> anyhow::Result<()> {
19    match quality {
20        bhtune_driver::Quality::Good => Ok(()),
21        bhtune_driver::Quality::Uncertain if allow_uncertain => {
22            tracing::warn!(
23                tag,
24                "accepting Uncertain-quality reading because Config > OPC quality policy \
25                 (allow_uncertain_quality) permits it"
26            );
27            Ok(())
28        }
29        bhtune_driver::Quality::Uncertain => {
30            anyhow::bail!(
31                "tag '{tag}' reported OPC quality Uncertain; refusing to trust it for a \
32                 tuning-critical reading (set Config > OPC quality policy \
33                 `allow_uncertain_quality = true` to accept Uncertain readings; Bad is never \
34                 accepted)"
35            )
36        }
37        bhtune_driver::Quality::Bad => {
38            anyhow::bail!(
39                "tag '{tag}' reported OPC quality Bad; refusing to trust it for a \
40                 tuning-critical reading"
41            )
42        }
43    }
44}
45/// Maps the driver's live [`bhtune_driver::Quality`] to the database's persisted
46/// [`SampleQuality`] -- two separate enums (rather than one shared type) because
47/// `bhtune-driver` and `bhtune-db` are sibling crates that each depend only on
48/// `bhtune-core`, not on each other; only a crate depending on both needs this mapping, so
49/// it lives here rather than forcing a new cross-dependency onto either crate. `pub` (not
50/// just used by [`run_polling_loop`] below) because `bhtune-server`'s `routes::opc` reuses
51/// it verbatim for `GET /api/opc/read`'s quality field, rather than a second copy of the
52/// same three-arm match.
53pub fn sample_quality_from_driver(quality: bhtune_driver::Quality) -> SampleQuality {
54    match quality {
55        bhtune_driver::Quality::Good => SampleQuality::Good,
56        bhtune_driver::Quality::Uncertain => SampleQuality::Uncertain,
57        bhtune_driver::Quality::Bad => SampleQuality::Bad,
58    }
59}
60pub(super) async fn read_raw(
61    driver: &dyn Driver,
62    tag: &str,
63    allow_uncertain: bool,
64) -> anyhow::Result<String> {
65    let values = driver.read(&[tag.to_string()]).await?;
66    let value = values
67        .into_iter()
68        .next()
69        .ok_or_else(|| anyhow::anyhow!("driver returned no value for tag '{tag}'"))?;
70    check_quality(tag, value.quality, allow_uncertain)?;
71    Ok(value.value)
72}
73pub(super) async fn read_f32(
74    driver: &dyn Driver,
75    tag: &str,
76    allow_uncertain: bool,
77) -> anyhow::Result<f32> {
78    let raw = read_raw(driver, tag, allow_uncertain).await?;
79    parse_f32_value(tag, &raw)
80}
81pub(super) async fn resolve_f32(
82    driver: &dyn Driver,
83    tag_or_value: &TagOrValue<f32>,
84    allow_uncertain: bool,
85) -> anyhow::Result<f32> {
86    match tag_or_value {
87        TagOrValue::Value(v) => {
88            if !v.is_finite() {
89                anyhow::bail!("value {v} is not a finite number");
90            }
91            Ok(*v)
92        }
93        TagOrValue::Tag(tag) => read_f32(driver, tag, allow_uncertain).await,
94    }
95}
96pub(super) async fn resolve_direction(
97    driver: &dyn Driver,
98    tag_or_value: &TagOrValue<ControllerDirection>,
99    template: &DcsTemplate,
100    allow_uncertain: bool,
101) -> anyhow::Result<ControllerDirection> {
102    match tag_or_value {
103        TagOrValue::Value(d) => Ok(*d),
104        TagOrValue::Tag(tag) => {
105            let raw = read_raw(driver, tag, allow_uncertain).await?;
106            Ok(ControllerDirection::from_raw_tag_value(
107                &raw,
108                &template.controller_action_direct_value,
109            ))
110        }
111    }
112}
113pub(super) fn parse_f32_value(tag: &str, raw: &str) -> anyhow::Result<f32> {
114    let value: f32 = raw
115        .trim()
116        .parse::<f32>()
117        .map_err(|_| anyhow::anyhow!("tag '{tag}' value '{raw}' is not a number"))?;
118    if !value.is_finite() {
119        anyhow::bail!("tag '{tag}' value '{raw}' is not a finite number");
120    }
121    Ok(value)
122}
123pub(super) fn read_batch_raw(
124    values: &HashMap<String, TagValue>,
125    tag: &str,
126    allow_uncertain: bool,
127) -> anyhow::Result<String> {
128    let value = values
129        .get(tag)
130        .ok_or_else(|| anyhow::anyhow!("driver returned no value for tag '{tag}'"))?;
131    check_quality(tag, value.quality, allow_uncertain)?;
132    Ok(value.value.clone())
133}
134pub(super) fn read_batch_f32(
135    values: &HashMap<String, TagValue>,
136    tag: &str,
137    allow_uncertain: bool,
138) -> anyhow::Result<f32> {
139    let raw = read_batch_raw(values, tag, allow_uncertain)?;
140    parse_f32_value(tag, &raw)
141}
142pub(super) async fn resolve_f32_from_batch(
143    driver: &dyn Driver,
144    values: &HashMap<String, TagValue>,
145    tag_or_value: &TagOrValue<f32>,
146    allow_uncertain: bool,
147) -> anyhow::Result<f32> {
148    match tag_or_value {
149        TagOrValue::Value(_) => resolve_f32(driver, tag_or_value, allow_uncertain).await,
150        TagOrValue::Tag(tag) => read_batch_f32(values, tag, allow_uncertain),
151    }
152}
153pub(super) async fn resolve_direction_from_batch(
154    driver: &dyn Driver,
155    values: &HashMap<String, TagValue>,
156    tag_or_value: &TagOrValue<ControllerDirection>,
157    template: &DcsTemplate,
158    allow_uncertain: bool,
159) -> anyhow::Result<ControllerDirection> {
160    match tag_or_value {
161        TagOrValue::Value(_) => {
162            resolve_direction(driver, tag_or_value, template, allow_uncertain).await
163        }
164        TagOrValue::Tag(tag) => {
165            let raw = read_batch_raw(values, tag, allow_uncertain)?;
166            Ok(ControllerDirection::from_raw_tag_value(
167                &raw,
168                &template.controller_action_direct_value,
169            ))
170        }
171    }
172}
173/// Test-only single-tag PV reader that preserves raw quality alongside the value. The production
174/// polling path uses [`read_poll_batch`] so pending OPC relay checks can share one read with PV
175/// sampling. This helper remains for focused tests of the single-tag parsing behavior and still
176/// hard-fails on non-numeric/non-finite values regardless of quality, exactly like [`read_f32`],
177/// since that's a data-shape problem no quality policy can excuse.
178#[cfg(test)]
179pub(super) async fn read_pv_sample(
180    driver: &dyn Driver,
181    tag: &str,
182) -> anyhow::Result<(f32, bhtune_driver::Quality)> {
183    read_numeric_sample(driver, tag).await
184}
185pub(super) async fn read_numeric_sample(
186    driver: &dyn Driver,
187    tag: &str,
188) -> anyhow::Result<(f32, bhtune_driver::Quality)> {
189    let values = driver.read(&[tag.to_string()]).await?;
190    let value = values
191        .into_iter()
192        .next()
193        .ok_or_else(|| anyhow::anyhow!("driver returned no value for tag '{tag}'"))?;
194    let numeric: f32 = value
195        .value
196        .trim()
197        .parse::<f32>()
198        .map_err(|_| anyhow::anyhow!("tag '{tag}' value '{}' is not a number", value.value))?;
199    if !numeric.is_finite() {
200        anyhow::bail!("tag '{tag}' value '{}' is not a finite number", value.value);
201    }
202    Ok((numeric, value.quality))
203}
204pub(super) async fn read_poll_batch(
205    driver: &dyn Driver,
206    pv_tag: &str,
207    mv_tag: Option<&str>,
208) -> anyhow::Result<HashMap<String, TagValue>> {
209    let mut requested_tags = vec![pv_tag.to_string()];
210    if let Some(mv_tag) = mv_tag
211        && mv_tag != pv_tag
212    {
213        requested_tags.push(mv_tag.to_string());
214    }
215
216    Ok(driver
217        .read(&requested_tags)
218        .await?
219        .into_iter()
220        .map(|value| (value.tag.clone(), value))
221        .collect())
222}
223pub(super) fn read_numeric_from_batch(
224    values: &HashMap<String, TagValue>,
225    tag: &str,
226) -> anyhow::Result<(f32, bhtune_driver::Quality)> {
227    let value = values
228        .get(tag)
229        .ok_or_else(|| anyhow::anyhow!("driver returned no value for tag '{tag}'"))?;
230    let numeric = parse_f32_value(tag, &value.value)?;
231    Ok((numeric, value.quality))
232}
233pub(super) async fn write_raw(driver: &dyn Driver, tag: &str, value: String) -> anyhow::Result<()> {
234    let outcome = driver.write(&tag.to_string(), TagWrite::Raw(value)).await?;
235    if outcome.success {
236        Ok(())
237    } else {
238        anyhow::bail!(
239            "write to '{tag}' was rejected: {}",
240            outcome
241                .error_message
242                .unwrap_or_else(|| "unknown reason".to_string())
243        )
244    }
245}
246pub(super) async fn write_value(driver: &dyn Driver, tag: &str, value: f32) -> anyhow::Result<()> {
247    let outcome = driver
248        .write(&tag.to_string(), TagWrite::Float(value))
249        .await?;
250    if outcome.success {
251        Ok(())
252    } else {
253        anyhow::bail!(
254            "write to '{tag}' was rejected: {}",
255            outcome
256                .error_message
257                .unwrap_or_else(|| "unknown reason".to_string())
258        )
259    }
260}