mod cff;
mod cfg;
mod dat;
mod types;
mod writer;
pub use types::{
AnalogChannel, ComtradeRecord, ComtradeTimestamp, DataFormat, DigitalChannel, RevYear, Sample,
SampleRate, ScalingFlag,
};
pub use writer::{
to_cfg_string, to_dat_ascii_string, to_dat_binary, write_comtrade, write_comtrade_cff,
};
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum ComtradeError {
#[error("I/O error: {0}")]
Io(String),
#[error(".cfg file too short ({0} lines, need at least 4)")]
CfgTooShort(usize),
#[error("bad revision year: '{0}' (expected 1991, 1999, or 2013)")]
BadRevYear(String),
#[error("bad channel count line: '{0}'")]
BadChannelLine(String),
#[error("bad analog channel definition: '{0}'")]
BadAnalogChannel(String),
#[error("bad digital channel definition: '{0}'")]
BadDigitalChannel(String),
#[error("bad data format: '{0}' (expected ASCII, BINARY, BINARY32, or FLOAT32)")]
BadDataFormat(String),
#[error("bad timestamp: '{0}'")]
BadTimestamp(String),
#[error("unexpected end of file while reading {0}")]
UnexpectedEof(&'static str),
#[error("bad float value for {0}: '{1}'")]
BadFloat(&'static str, String),
#[error("bad integer value for {0}: '{1}'")]
BadInt(&'static str, String),
#[error(".dat line {line}: expected {expected} fields, got {got}")]
BadDatLine {
line: usize,
expected: usize,
got: usize,
},
#[error("binary .dat size mismatch: {total} bytes not divisible by record size {record_size}")]
BadBinarySize { total: usize, record_size: usize },
#[error("CFF file missing required section: {0}")]
CffMissingSection(&'static str),
#[error("CFF with binary dat is not supported (binary data cannot be embedded in text CFF)")]
CffBinaryNotSupported,
}
pub fn parse_comtrade(cfg_path: &Path) -> Result<ComtradeRecord, ComtradeError> {
let cfg_text = std::fs::read_to_string(cfg_path)
.map_err(|e| ComtradeError::Io(format!("{}: {e}", cfg_path.display())))?;
let cfg_data = cfg::parse_cfg(&cfg_text)?;
let stem = cfg_path.with_extension("");
let dat_path = find_companion(&stem, "dat").ok_or_else(|| {
ComtradeError::Io(format!("cannot find .dat file for {}", cfg_path.display()))
})?;
let samples = match cfg_data.data_format {
DataFormat::Ascii => {
let dat_text = std::fs::read_to_string(&dat_path)
.map_err(|e| ComtradeError::Io(format!("{}: {e}", dat_path.display())))?;
dat::parse_dat_ascii(
&dat_text,
&cfg_data.analog_channels,
&cfg_data.digital_channels,
)?
}
DataFormat::Binary16 => {
let dat_bytes = std::fs::read(&dat_path)
.map_err(|e| ComtradeError::Io(format!("{}: {e}", dat_path.display())))?;
dat::parse_dat_binary16(
&dat_bytes,
&cfg_data.analog_channels,
&cfg_data.digital_channels,
)?
}
DataFormat::Binary32 => {
let dat_bytes = std::fs::read(&dat_path)
.map_err(|e| ComtradeError::Io(format!("{}: {e}", dat_path.display())))?;
dat::parse_dat_binary32(
&dat_bytes,
&cfg_data.analog_channels,
&cfg_data.digital_channels,
)?
}
DataFormat::Float32 => {
let dat_bytes = std::fs::read(&dat_path)
.map_err(|e| ComtradeError::Io(format!("{}: {e}", dat_path.display())))?;
dat::parse_dat_float32(
&dat_bytes,
&cfg_data.analog_channels,
&cfg_data.digital_channels,
)?
}
};
let header_text = find_companion(&stem, "hdr")
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| {
let trimmed = s.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
});
let info = find_companion(&stem, "inf")
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|s| cff::parse_inf(&s));
Ok(ComtradeRecord {
station_name: cfg_data.station_name,
rec_dev_id: cfg_data.rec_dev_id,
rev_year: cfg_data.rev_year,
frequency: cfg_data.frequency,
start_time: cfg_data.start_time,
trigger_time: cfg_data.trigger_time,
analog_channels: cfg_data.analog_channels,
digital_channels: cfg_data.digital_channels,
sample_rates: cfg_data.sample_rates,
data_format: cfg_data.data_format,
time_mult: cfg_data.time_mult,
samples,
header_text,
info,
})
}
pub fn parse_comtrade_cff(cff_path: &Path) -> Result<ComtradeRecord, ComtradeError> {
let text = std::fs::read_to_string(cff_path)
.map_err(|e| ComtradeError::Io(format!("{}: {e}", cff_path.display())))?;
cff::parse_cff(&text)
}
pub fn parse_comtrade_bytes(
cfg_text: &str,
dat_text: Option<&str>,
dat_bytes: Option<&[u8]>,
) -> Result<ComtradeRecord, ComtradeError> {
let cfg_data = cfg::parse_cfg(cfg_text)?;
let samples = match cfg_data.data_format {
DataFormat::Ascii => {
let text = dat_text.ok_or(ComtradeError::Io(
"ASCII format requires dat_text".to_string(),
))?;
dat::parse_dat_ascii(text, &cfg_data.analog_channels, &cfg_data.digital_channels)?
}
DataFormat::Binary16 => {
let bytes = dat_bytes.ok_or(ComtradeError::Io(
"Binary format requires dat_bytes".to_string(),
))?;
dat::parse_dat_binary16(bytes, &cfg_data.analog_channels, &cfg_data.digital_channels)?
}
DataFormat::Binary32 => {
let bytes = dat_bytes.ok_or(ComtradeError::Io(
"Binary format requires dat_bytes".to_string(),
))?;
dat::parse_dat_binary32(bytes, &cfg_data.analog_channels, &cfg_data.digital_channels)?
}
DataFormat::Float32 => {
let bytes = dat_bytes.ok_or(ComtradeError::Io(
"Binary format requires dat_bytes".to_string(),
))?;
dat::parse_dat_float32(bytes, &cfg_data.analog_channels, &cfg_data.digital_channels)?
}
};
Ok(ComtradeRecord {
station_name: cfg_data.station_name,
rec_dev_id: cfg_data.rec_dev_id,
rev_year: cfg_data.rev_year,
frequency: cfg_data.frequency,
start_time: cfg_data.start_time,
trigger_time: cfg_data.trigger_time,
analog_channels: cfg_data.analog_channels,
digital_channels: cfg_data.digital_channels,
sample_rates: cfg_data.sample_rates,
data_format: cfg_data.data_format,
time_mult: cfg_data.time_mult,
samples,
header_text: None,
info: None,
})
}
fn find_companion(stem: &Path, ext: &str) -> Option<PathBuf> {
let target_stem = stem.file_name()?;
let parent = stem.parent().unwrap_or_else(|| Path::new("."));
let entries = std::fs::read_dir(parent).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.file_stem() != Some(target_stem) {
continue;
}
let candidate_ext = path.extension().and_then(|value| value.to_str());
if candidate_ext.is_some_and(|value| value.eq_ignore_ascii_case(ext)) {
return Some(path);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn sample_record() -> ComtradeRecord {
ComtradeRecord {
station_name: "TEST_SUB".to_string(),
rec_dev_id: "DFR_1".to_string(),
rev_year: RevYear::Y1999,
frequency: 60.0,
start_time: ComtradeTimestamp {
day: 1,
month: 1,
year: 2025,
hour: 12,
minute: 0,
second: 0.0,
},
trigger_time: ComtradeTimestamp {
day: 1,
month: 1,
year: 2025,
hour: 12,
minute: 0,
second: 0.001,
},
analog_channels: vec![AnalogChannel {
index: 1,
name: "VA".to_string(),
phase: "A".to_string(),
circuit_component: "LINE1".to_string(),
units: "kV".to_string(),
multiplier: 1.0,
offset: 0.0,
skew: 0.0,
min_value: -99999.0,
max_value: 99999.0,
primary_ratio: 132.0,
secondary_ratio: 0.11,
scaling: ScalingFlag::Primary,
}],
digital_channels: vec![DigitalChannel {
index: 2,
name: "TRIP".to_string(),
phase: String::new(),
circuit_component: "LINE1".to_string(),
normal_state: 0,
}],
sample_rates: vec![SampleRate {
rate_hz: 4000.0,
last_sample: 1,
}],
data_format: DataFormat::Ascii,
time_mult: 1.0,
samples: vec![Sample {
number: 1,
timestamp_us: 0.0,
analog: vec![100.0],
digital: vec![false],
}],
header_text: Some("Test fault event".to_string()),
info: Some(HashMap::from([(
"model".to_string(),
"SEL-421".to_string(),
)])),
}
}
#[test]
fn test_parse_comtrade_finds_mixed_case_companion_extensions() {
let rec = sample_record();
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("fault.cfg");
write_comtrade(&rec, &cfg_path).unwrap();
std::fs::rename(dir.path().join("fault.dat"), dir.path().join("fault.DaT")).unwrap();
std::fs::rename(dir.path().join("fault.hdr"), dir.path().join("fault.HdR")).unwrap();
std::fs::rename(dir.path().join("fault.inf"), dir.path().join("fault.InF")).unwrap();
let parsed = parse_comtrade(&cfg_path).unwrap();
assert_eq!(parsed.samples.len(), 1);
assert_eq!(parsed.header_text.as_deref(), Some("Test fault event"));
assert_eq!(
parsed
.info
.as_ref()
.and_then(|info| info.get("model").map(String::as_str)),
Some("SEL-421")
);
}
}