tekhsi_rs 0.1.1

High-performance client for Tektronix TekHSI enabled oscilloscopes
Documentation
use std::io::{BufRead, BufReader};
use std::path::Path;

use matfile::{MatFile, NumericData};
use num_complex::Complex64;
use tekhsi_rs::TekHsiClient;
use tokio::time::timeout;

#[path = "common/config.rs"]
mod common_address;
#[path = "common/data.rs"]
mod common_csv;

use common_address::test_address;
use common_csv::csv_path_for_symbol;
use tekhsi_rs::SubscribeOptions;
use tekhsi_rs::data::{ChannelData, DigitalSamples, Waveform};

pub const ANALOG_TOLERANCE: f64 = 1e-15; // Less than 1 femto units
const IQ_TOLERANCE: f64 = 1e-6;

fn read_csv_analog_values(path: &Path) -> Result<Vec<f64>, Box<dyn std::error::Error>> {
    let file = std::fs::File::open(path)?;
    let reader = BufReader::new(file);
    let mut header: Option<Vec<String>> = None;
    let mut value_idx = None;
    let mut values = Vec::new();

    for line in reader.lines() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        if header.is_none() {
            if !line.trim_start().starts_with("TIME") {
                continue;
            }
            let cols: Vec<String> = line.split(',').map(|col| col.trim().to_string()).collect();
            let idx = cols
                .iter()
                .enumerate()
                .find(|(_, name)| *name != "TIME" && !name.is_empty())
                .map(|(idx, _)| idx)
                .ok_or("missing analog data column")?;
            header = Some(cols);
            value_idx = Some(idx);
            continue;
        }

        let cols: Vec<&str> = line.split(',').map(|col| col.trim()).collect();
        let time = cols.get(0).and_then(|col| col.parse::<f64>().ok());
        if time.is_none() {
            continue;
        }
        let idx = value_idx.ok_or("missing analog column index")?;
        if let Some(value) = cols.get(idx).and_then(|col| col.parse::<f64>().ok()) {
            values.push(value);
        }
    }

    Ok(values)
}

fn read_csv_digital_bytes(path: &Path) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    let file = std::fs::File::open(path)?;
    let reader = BufReader::new(file);
    let mut bit_indices: Option<[usize; 8]> = None;
    let mut values = Vec::new();

    for line in reader.lines() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        if bit_indices.is_none() {
            if !line.trim_start().starts_with("TIME") {
                continue;
            }
            let cols: Vec<String> = line.split(',').map(|col| col.trim().to_string()).collect();
            let mut indices = [None; 8];
            for (idx, name) in cols.iter().enumerate() {
                let name_upper = name.to_ascii_uppercase();
                if name_upper.ends_with("_D7") {
                    indices[0] = Some(idx);
                } else if name_upper.ends_with("_D6") {
                    indices[1] = Some(idx);
                } else if name_upper.ends_with("_D5") {
                    indices[2] = Some(idx);
                } else if name_upper.ends_with("_D4") {
                    indices[3] = Some(idx);
                } else if name_upper.ends_with("_D3") {
                    indices[4] = Some(idx);
                } else if name_upper.ends_with("_D2") {
                    indices[5] = Some(idx);
                } else if name_upper.ends_with("_D1") {
                    indices[6] = Some(idx);
                } else if name_upper.ends_with("_D0") {
                    indices[7] = Some(idx);
                }
            }
            let mut resolved = [0usize; 8];
            for (idx, entry) in indices.iter().enumerate() {
                resolved[idx] = entry.ok_or("missing digital bit columns")?;
            }
            bit_indices = Some(resolved);
            continue;
        }

        let cols: Vec<&str> = line.split(',').map(|col| col.trim()).collect();
        let time = cols.get(0).and_then(|col| col.parse::<f64>().ok());
        if time.is_none() {
            continue;
        }
        let mut byte = 0u8;
        let indices = bit_indices.ok_or("missing digital column indices")?;
        for (bit_idx, col_idx) in indices.iter().enumerate() {
            let bit = cols
                .get(*col_idx)
                .and_then(|col| col.parse::<u8>().ok())
                .unwrap_or(0)
                & 1;
            byte |= bit << (7 - bit_idx);
        }
        values.push(byte);
    }

    Ok(values)
}

fn read_mat_iq_values(path: &Path) -> Result<Vec<Complex64>, Box<dyn std::error::Error>> {
    let file = std::fs::File::open(path)?;
    let mat = MatFile::parse(file)?;
    let array = mat.find_by_name("IQ").ok_or("missing IQ array")?;
    match array.data() {
        NumericData::Double { real, imag } => {
            let imag = imag.as_ref().ok_or("IQ imag data missing")?;
            if real.len() != imag.len() {
                return Err("IQ real/imag length mismatch".into());
            }
            Ok(real
                .iter()
                .zip(imag.iter())
                .map(|(re, im)| Complex64::new(*re, *im))
                .collect())
        }
        NumericData::Single { real, imag } => {
            let imag = imag.as_ref().ok_or("IQ imag data missing")?;
            if real.len() != imag.len() {
                return Err("IQ real/imag length mismatch".into());
            }
            Ok(real
                .iter()
                .zip(imag.iter())
                .map(|(re, im)| Complex64::new(f64::from(*re), f64::from(*im)))
                .collect())
        }
        _ => Err("IQ array is not a floating-point complex array".into()),
    }
}

async fn validate_symbol_waveform(symbol: &str) -> Result<(), Box<dyn std::error::Error>> {
    let client = TekHsiClient::connect(&test_address()).await?;
    let mut receiver = client.subscribe(vec![symbol.to_string()], SubscribeOptions::default())?;
    let acquisition = match timeout(std::time::Duration::from_secs(5), receiver.recv()).await {
        Ok(Ok(acquisition)) => acquisition,
        Ok(Err(err)) => return Err(format!("subscribe failed: {err}").into()),
        Err(_) => return Err("timed out waiting for acquisition".into()),
    };

    let channel_data = acquisition
        .get_by_symbol(symbol)
        .ok_or_else(|| format!("missing waveform for {symbol}"))?;

    if symbol.ends_with("_iq") {
        let mat_path = Path::new("tests/data").join(format!("{symbol}.mat"));
        if !mat_path.exists() {
            return Err(format!("missing mat for {symbol}").into());
        }
        let expected = read_mat_iq_values(&mat_path)?;
        let actual: Vec<_> = match channel_data {
            ChannelData::Waveform {
                waveform: Waveform::Iq(iq),
                ..
            } => iq.iter_normalized_values().collect(),
            ChannelData::Waveform { .. } => {
                return Err(format!("expected iq waveform for {symbol}").into());
            }
            ChannelData::DecodeError { .. } => {
                return Err(format!("failed to decode waveform for {symbol}").into());
            }
            ChannelData::AcquisitionError { .. } => {
                return Err(format!("failed to acquire waveform for {symbol}").into());
            }
        };
        if actual.len() != expected.len() {
            return Err(format!(
                "iq mat length mismatch for {symbol}: mat={}, waveform={}",
                expected.len(),
                actual.len()
            )
            .into());
        }
        for idx in 0..expected.len() {
            let expected_value = expected[idx];
            let actual_value = actual[idx] / 2.0; // NOTE: iq.iter_complex_scaled() applies a scaling factor of 2.0
            let diff_re = (expected_value.re - actual_value.re).abs();
            let diff_im = (expected_value.im - actual_value.im).abs();
            if diff_re > IQ_TOLERANCE || diff_im > IQ_TOLERANCE {
                return Err(format!(
                    "iq mat mismatch for {symbol} at {idx}: expected {:?}, got {:?}",
                    expected_value, actual_value
                )
                .into());
            }
        }
    } else if symbol.ends_with("_dall") {
        let csv_path =
            csv_path_for_symbol(symbol).ok_or_else(|| format!("missing csv for {symbol}"))?;
        let expected = read_csv_digital_bytes(&csv_path)?;
        let actual = match channel_data {
            ChannelData::Waveform {
                waveform: Waveform::Digital(digital),
                ..
            } => match &digital.y_axis_bytes {
                DigitalSamples::I16(_) => digital
                    .y_axis_bytes
                    .as_scope_digital8()
                    .ok_or_else(|| format!("missing scope mapping for {symbol}"))?,
                DigitalSamples::I8(values) => {
                    values.iter().map(|value| value.to_le_bytes()[0]).collect()
                }
            },
            ChannelData::Waveform { .. } => {
                return Err(format!("expected digital waveform for {symbol}").into());
            }
            ChannelData::DecodeError { .. } => {
                return Err(format!("failed to decode waveform for {symbol}").into());
            }
            ChannelData::AcquisitionError { .. } => {
                return Err(format!("failed to acquire waveform for {symbol}").into());
            }
        };
        if actual.len() != expected.len() {
            return Err(format!(
                "digital csv length mismatch for {symbol}: csv={}, waveform={}",
                expected.len(),
                actual.len()
            )
            .into());
        }
        if actual != expected {
            if let Some((idx, (left, right))) = actual
                .iter()
                .zip(expected.iter())
                .enumerate()
                .find(|(_, (left, right))| left != right)
            {
                return Err(format!(
                    "digital csv mismatch for {symbol} at {idx}: expected {right}, got {left}"
                )
                .into());
            }
            return Err(format!("digital csv mismatch for {symbol}").into());
        }
    } else {
        let csv_path =
            csv_path_for_symbol(symbol).ok_or_else(|| format!("missing csv for {symbol}"))?;
        let expected = read_csv_analog_values(&csv_path)?;
        let actual: Vec<_> = match channel_data {
            ChannelData::Waveform {
                waveform: Waveform::Analog(analog),
                ..
            } => analog.iter_normalized_values().collect(),
            ChannelData::Waveform { .. } => {
                return Err(format!("expected analog waveform for {symbol}").into());
            }
            ChannelData::DecodeError { .. } => {
                return Err(format!("failed to decode waveform for {symbol}").into());
            }
            ChannelData::AcquisitionError { .. } => {
                return Err(format!("failed to acquire waveform for {symbol}").into());
            }
        };
        if actual.len() != expected.len() {
            return Err(format!(
                "analog csv length mismatch for {symbol}: csv={}, waveform={}",
                expected.len(),
                actual.len()
            )
            .into());
        }
        for idx in 0..expected.len() {
            let diff = (actual[idx] - expected[idx]).abs();
            if diff > ANALOG_TOLERANCE {
                return Err(format!(
                    "analog csv mismatch for {symbol} at {idx}: expected {}, got {}",
                    expected[idx], actual[idx]
                )
                .into());
            }
        }
    }

    client.disconnect().await?;
    Ok(())
}

include!(concat!(env!("OUT_DIR"), "/generated_waveform_tests.rs"));