touchstone 0.14.1

Touchstone (s2p, etc.) file parser, plotter, and more
Documentation
use std::fs;

use touchstone::{Network, ReferenceImpedance, TouchstoneError, TouchstoneWarning};

const TWO_PORT_RI: &str = "# GHz S RI R 50\n1.0 0.1 0.0 4.0 0.0 0.01 0.0 0.2 0.0\n";
const THREE_PORT_RI_DATA: &str =
    "1.0 0.11 0.0 0.12 0.0 0.13 0.0 0.21 0.0 0.22 0.0 0.23 0.0 0.31 0.0 0.32 0.0 0.33 0.0\n";

#[test]
fn new_reads_existing_touchstone_file() {
    let network = Network::new("files/ntwk1.s2p").unwrap();

    assert_eq!(network.name, "files/ntwk1.s2p");
    assert_eq!(network.rank, 2);
    assert!(!network.f.is_empty());
}

#[test]
fn from_str_parses_uploaded_touchstone_data_without_file() {
    let network = Network::from_str("uploaded.s2p", TWO_PORT_RI).unwrap();

    assert_eq!(network.name, "uploaded.s2p");
    assert_eq!(network.rank, 2);
    assert_eq!(network.frequency_unit, "GHz");
    assert_eq!(network.z0, 50.0);
    assert_eq!(network.f, vec![1.0e9]);
    assert_eq!(network.s_ri(2, 1)[0].s_ri.0, 4.0);
    assert_eq!(network.s_ri(1, 2)[0].s_ri.0, 0.01);
}

#[test]
fn from_str_reports_common_reference_impedance_from_option_line() {
    let network = Network::from_str("uploaded.s1p", "# GHz S RI R 75\n1.0 0.5 0.0\n").unwrap();

    assert_eq!(network.z0, 75.0);
    assert_eq!(
        network.reference_impedance(),
        ReferenceImpedance::Common(75.0)
    );
}

#[test]
fn from_str_parses_common_reference_keyword_over_option_line() {
    let network = Network::from_str(
        "uploaded.s2p",
        "[Version] 2.1\n# GHz S RI R 50\n[Number of Ports] 2\n[Reference] 75\n[Network Data]\n1.0 0.1 0.0 4.0 0.0 0.01 0.0 0.2 0.0\n[End]\n",
    )
    .unwrap();

    assert_eq!(network.z0, 75.0);
    assert_eq!(
        network.reference_impedance(),
        ReferenceImpedance::Common(75.0)
    );
}

#[test]
fn from_str_parses_per_port_reference_keyword() {
    let contents = format!(
        "[Version] 2.1\n# GHz S RI R 50\n[Number of Ports] 3\n[Reference]\n45 50 55\n[Network Data]\n{THREE_PORT_RI_DATA}[End]\n"
    );

    let network = Network::from_str("uploaded.s3p", &contents).unwrap();

    assert_eq!(network.z0, 45.0);
    assert_eq!(
        network.reference_impedance(),
        ReferenceImpedance::PerPort(vec![45.0, 50.0, 55.0])
    );
}

#[test]
fn to_touchstone_string_round_trips_per_port_reference_keyword() {
    let contents = format!(
        "[Version] 2.1\n# GHz S RI R 50\n[Number of Ports] 3\n[Reference] 45 50 55\n[Network Data]\n{THREE_PORT_RI_DATA}[End]\n"
    );
    let network = Network::from_str("uploaded.s3p", &contents).unwrap();

    let serialized = network.to_touchstone_string().unwrap();
    assert!(serialized.contains("[Reference] 45 50 55\n"));

    let reparsed = Network::from_str("uploaded.s3p", &serialized).unwrap();
    assert_eq!(reparsed.z0, 45.0);
    assert_eq!(
        reparsed.reference_impedance(),
        ReferenceImpedance::PerPort(vec![45.0, 50.0, 55.0])
    );
    assert_eq!(reparsed.points().unwrap(), network.points().unwrap());
}

#[test]
fn from_str_rejects_reference_keyword_count_mismatch() {
    let contents = format!(
        "[Version] 2.1\n# GHz S RI R 50\n[Number of Ports] 3\n[Reference] 45 50\n[Network Data]\n{THREE_PORT_RI_DATA}[End]\n"
    );

    let error = Network::from_str("uploaded.s3p", &contents).unwrap_err();

    assert!(matches!(
        error.root_cause(),
        TouchstoneError::InvalidReferenceImpedanceCount {
            ports: 3,
            actual: 2
        }
    ));
    assert_eq!(error.context().unwrap().line_number, Some(4));
}

#[test]
fn from_str_reports_missing_reference_following_line_at_reference_keyword() {
    let contents = format!(
        "[Version] 2.1\n# GHz S RI R 50\n[Reference]\n[Network Data]\n{THREE_PORT_RI_DATA}[End]\n"
    );

    let error = Network::from_str("uploaded.s3p", &contents).unwrap_err();

    assert!(matches!(
        error.root_cause(),
        TouchstoneError::InvalidReferenceImpedanceCount {
            ports: 3,
            actual: 0
        }
    ));
    assert_eq!(error.context().unwrap().line_number, Some(3));
    assert_eq!(
        error.context().unwrap().line.as_deref(),
        Some("[Reference]")
    );
}

#[test]
fn from_str_rejects_non_positive_reference_keyword_value() {
    let error = Network::from_str(
        "uploaded.s1p",
        "[Version] 2.1\n# GHz S RI R 50\n[Reference] 0\n[Network Data]\n1.0 0.5 0.0\n[End]\n",
    )
    .unwrap_err();

    assert!(matches!(
        error.root_cause(),
        TouchstoneError::InvalidReferenceImpedance { z0: 0.0 }
    ));
    assert_eq!(error.context().unwrap().line_number, Some(3));
}

#[test]
fn from_bytes_parses_uploaded_touchstone_data_without_file() {
    let network = Network::from_bytes("uploaded.s2p", TWO_PORT_RI.as_bytes()).unwrap();

    assert_eq!(network.name, "uploaded.s2p");
    assert_eq!(network.rank, 2);
    assert_eq!(network.s_ri(2, 1)[0].s_ri.0, 4.0);
    assert_eq!(network.s_ri(1, 2)[0].s_ri.0, 0.01);
}

#[test]
fn from_memory_matches_new_for_s1p_file() {
    assert_from_memory_matches_new("files/hfss_oneport.s1p");
}

#[test]
fn from_memory_matches_new_for_s3p_file() {
    assert_from_memory_matches_new("files/hfss_18.2.s3p");
}

#[test]
fn from_bytes_returns_utf8_errors() {
    let error = Network::from_bytes("uploaded.s1p", &[0xff]).unwrap_err();

    assert!(matches!(error, TouchstoneError::InvalidUtf8(_)));
}

#[test]
fn from_str_returns_unsupported_file_type_errors() {
    let error = Network::from_str("uploaded.txt", TWO_PORT_RI).unwrap_err();

    assert!(matches!(
        error,
        TouchstoneError::UnsupportedFileType { file_type } if file_type == "txt"
    ));
}

#[test]
fn from_str_returns_malformed_data_errors() {
    let error = Network::from_str("uploaded.s1p", "# GHz S RI R 50\n1.0 0.1\n").unwrap_err();

    assert!(matches!(
        error.root_cause(),
        TouchstoneError::InvalidDataLineParts {
            expected: 3,
            actual: 2
        }
    ));

    let context = error.context().unwrap();
    assert_eq!(context.source_name, "uploaded.s1p");
    assert_eq!(context.line_number, Some(2));
    assert_eq!(context.line.as_deref(), Some("1.0 0.1"));
    assert!(error.to_string().contains("uploaded.s1p:2"));
}

#[test]
fn from_str_records_missing_option_line_warning() {
    let network = Network::from_str("uploaded.s1p", "1.0 0.1 0.0\n").unwrap();

    assert_eq!(network.frequency_unit, "GHz");
    assert_eq!(network.format, "MA");
    assert_eq!(network.z0, 50.0);
    assert!(matches!(
        network.warnings.as_slice(),
        [TouchstoneWarning::MissingOptionLine { source_name }] if source_name == "uploaded.s1p"
    ));
}

#[test]
fn from_str_records_ignored_extra_option_line_warning() {
    let network = Network::from_str(
        "uploaded.s1p",
        "# GHz S RI R 50\n# MHz S RI R 75\n1.0 0.1 0.0\n",
    )
    .unwrap();

    assert_eq!(network.frequency_unit, "GHz");
    assert_eq!(network.z0, 50.0);
    assert!(matches!(
        network.warnings.as_slice(),
        [TouchstoneWarning::AdditionalOptionLineIgnored {
            source_name,
            line_number: 2,
            line,
        }] if source_name == "uploaded.s1p" && line == "# MHz S RI R 75"
    ));
}

#[test]
fn from_str_records_unknown_keyword_warning() {
    let network = Network::from_str(
        "uploaded.s1p",
        "[Version] 2.1\n[Unsupported Keyword] ignored\n# GHz S RI R 50\n[Network Data]\n1.0 0.1 0.0\n[End]\n",
    )
    .unwrap();

    assert!(matches!(
        network.warnings.as_slice(),
        [TouchstoneWarning::UnknownKeywordIgnored {
            source_name,
            line_number: 2,
            keyword,
        }] if source_name == "uploaded.s1p" && keyword == "unsupported keyword"
    ));
}

fn assert_from_memory_matches_new(path: &str) {
    let contents = fs::read_to_string(path).unwrap();
    let bytes = fs::read(path).unwrap();

    let from_file = Network::new(path).unwrap();
    let from_str = Network::from_str(path, &contents).unwrap();
    let from_bytes = Network::from_bytes(path, &bytes).unwrap();

    assert_same_network_shape(path, &from_file, &from_str);
    assert_same_network_shape(path, &from_file, &from_bytes);
}

fn assert_same_network_shape(path: &str, left: &Network, right: &Network) {
    assert_eq!(left.name, right.name, "{path}");
    assert_eq!(left.rank, right.rank, "{path}");
    assert_eq!(left.frequency_unit, right.frequency_unit, "{path}");
    assert_eq!(left.parameter, right.parameter, "{path}");
    assert_eq!(left.format, right.format, "{path}");
    assert_eq!(left.resistance_string, right.resistance_string, "{path}");
    assert_eq!(left.z0, right.z0, "{path}");
    assert_eq!(
        left.reference_impedance(),
        right.reference_impedance(),
        "{path}"
    );
    assert_eq!(left.comments, right.comments, "{path}");
    assert_eq!(
        left.comments_after_option_line, right.comments_after_option_line,
        "{path}"
    );
    assert_eq!(left.warnings, right.warnings, "{path}");
    assert_eq!(left.f, right.f, "{path}");
    assert_eq!(left.s.len(), right.s.len(), "{path}");

    for j in 1..=left.rank {
        for k in 1..=left.rank {
            let left_s = left.s_ri(j as i8, k as i8);
            let right_s = right.s_ri(j as i8, k as i8);
            assert_eq!(left_s.len(), right_s.len(), "{path} S{j}{k}");
            for (left_point, right_point) in left_s.iter().zip(right_s) {
                assert_eq!(
                    left_point.frequency, right_point.frequency,
                    "{path} S{j}{k}"
                );
                assert_eq!(left_point.s_ri, right_point.s_ri, "{path} S{j}{k}");
            }
        }
    }
}