///|
/// The trial profile is intentionally narrower than IEEE C37.111: revision
/// 1999 declaration, ASCII DAT, one positive sampling-rate segment, zero
/// analog skew, finite bounded values, and binary status values only.
pub fn parse(cfg : String, dat : String) -> Recording raise ComtradeError {
if cfg.length() > 33554432 ||
dat.length() > 33554432 ||
cfg.length() > 33554432 - dat.length() {
raise Rejected("capacity", 0, "CFG+DAT exceeds 32 MiB text budget")
}
let cfg_lines = normalized_lines(cfg, "cfg", 300)
if cfg_lines.length() < 8 {
raise Rejected("cfg", 0, "truncated configuration")
}
let mut pos = 0
let header_line = cfg_lines[pos]
pos += 1
let header = csv_fields(header_line, "cfg", pos)
expect_fields(header, 3, "cfg", pos)
let station_name = header[0]
let device_id = header[1]
if parse_int_field(header[2], "cfg", pos) != 1999 {
raise Rejected("cfg", pos, "only revision 1999 is supported")
}
let count_line = cfg_lines[pos]
pos += 1
let counts = csv_fields(count_line, "cfg", pos)
expect_fields(counts, 3, "cfg", pos)
let total_channels = parse_int_field(counts[0], "cfg", pos)
let analog_count = channel_count(counts[1], "A", pos)
let status_count = channel_count(counts[2], "D", pos)
if analog_count > 256 || status_count > 256 {
raise Rejected("cfg", pos, "analog/status channel count exceeds 256")
}
if total_channels <= 0 || total_channels > 256 {
raise Rejected("cfg", pos, "channel total must be in 1..256")
}
if analog_count + status_count != total_channels {
raise Rejected("cfg", pos, "channel totals are inconsistent")
}
let analog : Array[AnalogChannel] = []
let analog_seen : Map[Int, Bool] = Map([])
for _ in 0..= cfg_lines.length() {
raise Rejected("cfg", pos + 1, "truncated analog channel declarations")
}
let line = cfg_lines[pos]
pos += 1
let fields = csv_fields(line, "cfg", pos)
expect_fields(fields, 13, "cfg", pos)
let number = parse_int_field(fields[0], "cfg", pos)
if number <= 0 || analog_seen.contains(number) {
raise Rejected(
"cfg", pos, "analog channel number must be positive and unique",
)
}
analog_seen[number] = true
let scale_a = parse_number(fields[5], "cfg", pos)
let offset_b = parse_number(fields[6], "cfg", pos)
let skew = parse_number(fields[7], "cfg", pos)
let minimum = parse_number(fields[8], "cfg", pos)
let maximum = parse_number(fields[9], "cfg", pos)
let primary = parse_number(fields[10], "cfg", pos)
let secondary = parse_number(fields[11], "cfg", pos)
let pors = fields[12].to_upper()
if skew != 0.0 {
raise Rejected("cfg", pos, "nonzero analog skew is unsupported")
}
if minimum > maximum {
raise Rejected("cfg", pos, "analog min exceeds max")
}
if primary <= 0.0 || secondary <= 0.0 {
raise Rejected("cfg", pos, "primary/secondary ratios must be positive")
}
if pors != "P" && pors != "S" {
raise Rejected("cfg", pos, "P/S side indicator must be P or S")
}
analog.push({
number,
number_raw: fields[0],
name: fields[1],
phase: fields[2],
circuit: fields[3],
unit: fields[4],
scale_a,
scale_a_raw: fields[5],
offset_b,
offset_b_raw: fields[6],
minimum,
minimum_raw: fields[8],
maximum,
maximum_raw: fields[9],
primary,
primary_raw: fields[10],
secondary,
secondary_raw: fields[11],
pors,
cfg_line_raw: line,
})
}
let digital : Array[StatusChannel] = []
let digital_seen : Map[Int, Bool] = Map([])
for _ in 0..= cfg_lines.length() {
raise Rejected("cfg", pos + 1, "truncated status channel declarations")
}
let line = cfg_lines[pos]
pos += 1
let fields = csv_fields(line, "cfg", pos)
expect_fields(fields, 5, "cfg", pos)
let number = parse_int_field(fields[0], "cfg", pos)
if number <= 0 || digital_seen.contains(number) {
raise Rejected(
"cfg", pos, "status channel number must be positive and unique",
)
}
digital_seen[number] = true
let initial = parse_int_field(fields[4], "cfg", pos)
if initial != 0 && initial != 1 {
raise Rejected("cfg", pos, "status normal state must be 0 or 1")
}
digital.push({
number,
number_raw: fields[0],
name: fields[1],
phase: fields[2],
circuit: fields[3],
initial_value: initial,
normal_state_raw: fields[4],
cfg_line_raw: line,
})
}
if pos >= cfg_lines.length() {
raise Rejected("cfg", pos + 1, "missing nominal frequency")
}
let frequency_line = cfg_lines[pos]
pos += 1
let frequency_fields = csv_fields(frequency_line, "cfg", pos)
expect_fields(frequency_fields, 1, "cfg", pos)
let nominal_frequency_hz = parse_number(frequency_fields[0], "cfg", pos)
if nominal_frequency_hz <= 0.0 {
raise Rejected("cfg", pos, "nominal frequency must be positive")
}
if pos >= cfg_lines.length() {
raise Rejected("cfg", pos + 1, "missing sampling-rate segment count")
}
let segments_line = cfg_lines[pos]
pos += 1
let segments_fields = csv_fields(segments_line, "cfg", pos)
expect_fields(segments_fields, 1, "cfg", pos)
if parse_int_field(segments_fields[0], "cfg", pos) != 1 {
raise Rejected("cfg", pos, "exactly one sampling-rate segment is supported")
}
if pos >= cfg_lines.length() {
raise Rejected("cfg", pos + 1, "missing sampling-rate segment")
}
let rate_line = cfg_lines[pos]
pos += 1
let rate_fields = csv_fields(rate_line, "cfg", pos)
expect_fields(rate_fields, 2, "cfg", pos)
let sampling_rate_hz = parse_number(rate_fields[0], "cfg", pos)
let row_count = parse_int_field(rate_fields[1], "cfg", pos)
if sampling_rate_hz <= 0.0 {
raise Rejected("cfg", pos, "sampling rate must be positive")
}
if row_count <= 0 || row_count > 200000 {
raise Rejected("cfg", pos, "declared row count must be in 1..200000")
}
// Reject decoded-value expansion before creating row/channel arrays.
if row_count * total_channels > 2000000 {
raise Rejected(
"capacity", pos, "record exceeds 2,000,000 channel-value budget",
)
}
if pos + 4 > cfg_lines.length() {
raise Rejected(
"cfg",
pos + 1,
"truncated time, format, or multiplier declarations",
)
}
let start_line = cfg_lines[pos]
pos += 1
let start_fields = csv_fields(start_line, "cfg", pos)
expect_fields(start_fields, 2, "cfg", pos)
if start_fields[0].is_empty() || start_fields[1].is_empty() {
raise Rejected("cfg", pos, "start date/time fields cannot be empty")
}
let trigger_line = cfg_lines[pos]
pos += 1
let trigger_fields = csv_fields(trigger_line, "cfg", pos)
expect_fields(trigger_fields, 2, "cfg", pos)
if trigger_fields[0].is_empty() || trigger_fields[1].is_empty() {
raise Rejected("cfg", pos, "trigger date/time fields cannot be empty")
}
let format_line = cfg_lines[pos]
pos += 1
let format_fields = csv_fields(format_line, "cfg", pos)
expect_fields(format_fields, 1, "cfg", pos)
if format_fields[0].to_upper() != "ASCII" {
raise Rejected("cfg", pos, "only ASCII DAT is supported")
}
let multiplier_line = cfg_lines[pos]
pos += 1
let multiplier_fields = csv_fields(multiplier_line, "cfg", pos)
expect_fields(multiplier_fields, 1, "cfg", pos)
let time_multiplier = parse_number(multiplier_fields[0], "cfg", pos)
if time_multiplier <= 0.0 {
raise Rejected("cfg", pos, "time multiplier must be positive")
}
let tick_seconds = time_multiplier / 1000000.0
let expected_interval = 1.0 / sampling_rate_hz
if !finite(tick_seconds) ||
tick_seconds <= 0.0 ||
!finite(expected_interval) ||
expected_interval <= 0.0 {
raise Rejected(
"cfg", pos, "time scale or sample interval is not representable",
)
}
if pos != cfg_lines.length() {
raise Rejected("cfg", pos + 1, "trailing CFG lines are unsupported")
}
let dat_lines = normalized_lines(dat, "dat", 200002)
if dat_lines.length() != row_count {
raise Rejected("dat", 0, "DAT row count does not match CFG final sample")
}
let rows : Array[Sample] = []
let expected_fields = 2 + total_channels
let mut previous_ticks : Int64? = None
let mut previous_time : Double? = None
for row_index in 0.. tolerance {
raise Rejected(
"dat", line_no, "timestamp spacing disagrees with single-rate CFG by more than one tick",
)
}
}
previous_ticks = Some(raw_ticks)
let time_seconds = raw_ticks.to_double() * time_multiplier / 1000000.0
if !finite(time_seconds) {
raise Rejected("dat", line_no, "DAT time is non-finite")
}
if previous_time is Some(previous) && time_seconds <= previous {
raise Rejected(
"dat", line_no, "DAT time is not strictly increasing as Double seconds",
)
}
previous_time = Some(time_seconds)
let analog_raw : Array[Double] = []
for channel_index in 0.. channel.maximum {
raise Rejected("dat", line_no, "analog raw value outside CFG min/max")
}
let scaled = channel.scale_a * raw + channel.offset_b
if !finite(scaled) {
raise Rejected("dat", line_no, "scaled analog value is non-finite")
}
analog_raw.push(raw)
}
let status : Array[Bool] = []
for channel_index in 0.. Array[String] raise ComtradeError {
let builder = StringBuilder()
let mut physical_lines = 1
let mut line_width = 0
let mut previous_cr = false
for c in text {
if c == '\r' {
physical_lines += 1
if physical_lines > max_lines + 1 {
raise Rejected(section, 0, "line count exceeds profile budget")
}
builder.write_char('\n')
line_width = 0
previous_cr = true
} else if c == '\n' {
if !previous_cr {
physical_lines += 1
if physical_lines > max_lines + 1 {
raise Rejected(section, 0, "line count exceeds profile budget")
}
builder.write_char('\n')
line_width = 0
}
previous_cr = false
} else {
previous_cr = false
if c != '\t' && (c < ' ' || c > '~') {
raise Rejected(
section, 0, "only printable ASCII, tabs, and line endings are supported",
)
}
line_width += 1
if line_width > 65536 {
raise Rejected(
section, 0, "line exceeds 65,536-character profile budget",
)
}
builder.write_char(c)
}
}
let normalized = builder.to_string()
let body = if normalized.length() > 0 &&
normalized[normalized.length() - 1] == '\n' {
normalized[:normalized.length() - 1].to_owned()
} else {
normalized
}
body.split("\n").map(line => line.to_owned()).to_array()
}
///|
fn csv_fields(
line : String,
section : String,
line_no : Int,
) -> Array[String] raise ComtradeError {
let chars = line.to_array()
let fields : Array[String] = []
let buffer = StringBuilder()
let mut quoted = false
let mut after_quote = false
let mut index = 0
while index < chars.length() {
let c = chars[index]
if quoted {
if c == '"' {
if index + 1 < chars.length() && chars[index + 1] == '"' {
buffer.write_char('"')
index += 2
} else {
quoted = false
after_quote = true
index += 1
}
} else {
buffer.write_char(c)
index += 1
}
} else if after_quote {
if c == ',' {
fields.push(buffer.to_string())
buffer.reset()
after_quote = false
index += 1
} else if c == ' ' || c == '\t' {
index += 1
} else {
raise Rejected(
section, line_no, "unexpected character after quoted CSV field",
)
}
} else if c == ',' {
fields.push(buffer.to_string().trim().to_owned())
buffer.reset()
index += 1
} else if c == '"' {
if !buffer.to_string().trim().is_empty() {
raise Rejected(section, line_no, "quote inside unquoted CSV field")
}
buffer.reset()
quoted = true
index += 1
} else {
buffer.write_char(c)
index += 1
}
}
if quoted {
raise Rejected(section, line_no, "unterminated quoted CSV field")
}
fields.push(
if after_quote {
buffer.to_string()
} else {
buffer.to_string().trim().to_owned()
},
)
fields
}
///|
fn expect_fields(
fields : Array[String],
expected : Int,
section : String,
line_no : Int,
) -> Unit raise ComtradeError {
if fields.length() != expected {
raise Rejected(
section,
line_no,
"expected " +
expected.to_string() +
" fields, found " +
fields.length().to_string(),
)
}
}
///|
fn channel_count(
text : String,
suffix : String,
line_no : Int,
) -> Int raise ComtradeError {
let value = text.trim().to_owned().to_upper()
if value.length() < 2 ||
(suffix == "A" && value[value.length() - 1] != 'A') ||
(suffix == "D" && value[value.length() - 1] != 'D') {
raise Rejected("cfg", line_no, "channel count must end in " + suffix)
}
let count = parse_int_field(
value[:value.length() - 1].to_owned(),
"cfg",
line_no,
)
if count < 0 {
raise Rejected("cfg", line_no, "channel count cannot be negative")
}
count
}
///|
fn parse_int_field(
text : String,
section : String,
line_no : Int,
) -> Int raise ComtradeError {
let value = text.trim().to_owned()
if value.is_empty() {
raise Rejected(section, line_no, "empty integer field")
}
for c in value {
if (c < '0' || c > '9') && c != '+' && c != '-' {
raise Rejected(section, line_no, "invalid integer field")
}
}
@string.parse_int(value, base=10) catch {
_ =>
raise Rejected(
section, line_no, "integer field is invalid or out of range",
)
}
}
///|
fn parse_nonnegative_int64(
text : String,
section : String,
line_no : Int,
) -> Int64 raise ComtradeError {
let value = text.trim().to_owned()
if value.is_empty() {
raise Rejected(section, line_no, "empty timestamp tick field")
}
for c in value {
if c < '0' || c > '9' {
raise Rejected(
section, line_no, "timestamp ticks must be nonnegative decimal integers",
)
}
}
@string.parse_int64(value, base=10) catch {
_ => raise Rejected(section, line_no, "timestamp ticks exceed Int64 range")
}
}
///|
fn parse_number(
text : String,
section : String,
line_no : Int,
) -> Double raise ComtradeError {
let value = text.trim().to_owned()
if value.is_empty() {
raise Rejected(section, line_no, "empty floating-point field")
}
let mut has_digit = false
for c in value {
if c >= '0' && c <= '9' {
has_digit = true
} else if c != '+' && c != '-' && c != '.' && c != 'e' && c != 'E' {
raise Rejected(section, line_no, "invalid decimal floating-point field")
}
}
if !has_digit {
raise Rejected(section, line_no, "floating-point field has no digits")
}
let number = @string.parse_double(value) catch {
_ =>
raise Rejected(
section, line_no, "invalid or out-of-range floating-point field",
)
}
if !finite(number) {
raise Rejected(section, line_no, "floating-point field is non-finite")
}
number
}
///|
fn finite(value : Double) -> Bool {
!value.is_nan() && !value.is_inf()
}