use std::collections::HashMap;
use std::io::Write;
use crate::error::{Mf4Error, Result};
use crate::export::{array_index_suffixes, element_columns};
use crate::model::SignalValues;
use crate::time_ops::SignalSeries;
const MI_DOUBLE: u32 = 0;
const MI_SINGLE: u32 = 1;
const MI_INT32: u32 = 2;
const MI_INT16: u32 = 3;
const MI_UINT16: u32 = 4;
const MI_UINT8: u32 = 5;
const MX_FULL_CLASS: u32 = 0;
const MX_CHAR_CLASS: u32 = 1;
macro_rules! le_bytes {
($v:expr) => {
$v.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<u8>>()
};
}
struct FlattenedMatV4 {
name: String,
precision: u32,
matrix_type: u32,
rows: usize,
cols: usize,
data: Vec<u8>,
}
pub fn write_mat_v4<W: Write>(series: &[SignalSeries], out: &mut W) -> Result<()> {
let mut names = UniqueNames::default();
for (group_index, group) in time_groups(series).into_iter().enumerate() {
let timestamps = series[group[0]].timestamps();
write_matrix(
out,
&names.claim(&format!("DGM{group_index}_timestamps")),
MI_DOUBLE,
MX_FULL_CLASS,
timestamps.len(),
1,
0,
&le_bytes!(timestamps),
)?;
for &index in &group {
let s = &series[index];
let mats = flatten_for_mat_v4(s)?;
for item in mats {
write_matrix(
out,
&names.claim(&format!("DG{group_index}_{}", item.name)),
item.precision,
item.matrix_type,
item.rows,
item.cols,
0,
&item.data,
)?;
if let Some(validity) = s.validity() {
let mask: Vec<u8> = validity.iter().map(|&valid| u8::from(!valid)).collect();
write_matrix(
out,
&names.claim(&format!("DG{group_index}_{}_invalid", item.name)),
MI_UINT8,
MX_FULL_CLASS,
mask.len(),
1,
0,
&mask,
)?;
}
}
}
}
Ok(())
}
fn time_groups(series: &[SignalSeries]) -> Vec<Vec<usize>> {
let mut groups: Vec<Vec<usize>> = Vec::new();
for (index, s) in series.iter().enumerate() {
match groups.iter_mut().rev().find(|group| {
let known = series[group[0]].timestamps();
let candidate = s.timestamps();
known.len() == candidate.len()
&& known.first() == candidate.first()
&& known.last() == candidate.last()
&& known == candidate
}) {
Some(group) => group.push(index),
None => groups.push(vec![index]),
}
}
groups
}
fn flatten_for_mat_v4(series: &SignalSeries) -> Result<Vec<FlattenedMatV4>> {
let refuse = |kind: &str| {
Err(Mf4Error::unsupported(
"MAT v4 export",
format!(
"channel '{}' holds {kind} samples, which a numeric or text MATLAB matrix cannot \
represent; export it to Parquet, or drop it from the selection",
series.name()
),
))
};
Ok(match series.values() {
SignalValues::U8(v) => vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_UINT8,
matrix_type: MX_FULL_CLASS,
rows: v.len(),
cols: 1,
data: v.clone(),
}],
SignalValues::I8(v) => {
let vals: Vec<f64> = v.iter().map(|&x| x as f64).collect();
vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_DOUBLE,
matrix_type: MX_FULL_CLASS,
rows: v.len(),
cols: 1,
data: le_bytes!(vals),
}]
}
SignalValues::U16(v) => vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_UINT16,
matrix_type: MX_FULL_CLASS,
rows: v.len(),
cols: 1,
data: le_bytes!(v),
}],
SignalValues::I16(v) => vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_INT16,
matrix_type: MX_FULL_CLASS,
rows: v.len(),
cols: 1,
data: le_bytes!(v),
}],
SignalValues::U32(v) => {
let vals: Vec<f64> = v.iter().map(|&x| x as f64).collect();
vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_DOUBLE,
matrix_type: MX_FULL_CLASS,
rows: v.len(),
cols: 1,
data: le_bytes!(vals),
}]
}
SignalValues::I32(v) => vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_INT32,
matrix_type: MX_FULL_CLASS,
rows: v.len(),
cols: 1,
data: le_bytes!(v),
}],
SignalValues::U64(v) => {
let vals: Vec<f64> = v.iter().map(|&x| x as f64).collect();
vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_DOUBLE,
matrix_type: MX_FULL_CLASS,
rows: v.len(),
cols: 1,
data: le_bytes!(vals),
}]
}
SignalValues::I64(v) => {
let vals: Vec<f64> = v.iter().map(|&x| x as f64).collect();
vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_DOUBLE,
matrix_type: MX_FULL_CLASS,
rows: v.len(),
cols: 1,
data: le_bytes!(vals),
}]
}
SignalValues::F32(v) => vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_SINGLE,
matrix_type: MX_FULL_CLASS,
rows: v.len(),
cols: 1,
data: le_bytes!(v),
}],
SignalValues::F64(v) => vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_DOUBLE,
matrix_type: MX_FULL_CLASS,
rows: v.len(),
cols: 1,
data: le_bytes!(v),
}],
SignalValues::Complex { re, im } => vec![
FlattenedMatV4 {
name: format!("{}.re", series.name()),
precision: MI_DOUBLE,
matrix_type: MX_FULL_CLASS,
rows: re.len(),
cols: 1,
data: le_bytes!(re),
},
FlattenedMatV4 {
name: format!("{}.im", series.name()),
precision: MI_DOUBLE,
matrix_type: MX_FULL_CLASS,
rows: im.len(),
cols: 1,
data: le_bytes!(im),
},
],
SignalValues::CanopenDate(v) => {
let nanos: Vec<f64> = v.iter().map(|d| d.to_unix_nanos() as f64).collect();
vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_DOUBLE,
matrix_type: MX_FULL_CLASS,
rows: nanos.len(),
cols: 1,
data: le_bytes!(nanos),
}]
}
SignalValues::CanopenTime(v) => {
let nanos: Vec<f64> = v.iter().map(|t| t.to_unix_nanos() as f64).collect();
vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_DOUBLE,
matrix_type: MX_FULL_CLASS,
rows: nanos.len(),
cols: 1,
data: le_bytes!(nanos),
}]
}
SignalValues::Array {
values,
elements_per_sample,
} => {
let n = series.len();
let eps = *elements_per_sample;
let suffixes = array_index_suffixes(series.channel.array_shape.as_deref(), eps);
let mut mats = Vec::with_capacity(eps);
for (elem_vals, suffix) in element_columns(values, eps).into_iter().zip(suffixes) {
mats.push(FlattenedMatV4 {
name: format!("{}{suffix}", series.name()),
precision: MI_DOUBLE,
matrix_type: MX_FULL_CLASS,
rows: n,
cols: 1,
data: le_bytes!(elem_vals),
});
}
mats
}
SignalValues::ArrayVarLen { .. } => {
return Err(Mf4Error::unsupported(
"MAT v4 export",
format!(
"channel '{}' holds variable-length array samples, which have no fixed column shape and cannot be exported to a tabular format",
series.name()
),
));
}
SignalValues::Str(v) => {
let mut chars: Vec<Vec<u8>> = Vec::with_capacity(v.len());
for text in v {
let mut codes = Vec::with_capacity(text.len());
for ch in text.chars() {
match u8::try_from(u32::from(ch)) {
Ok(code) => codes.push(code),
Err(_) => {
return Err(Mf4Error::unsupported(
"MAT v4 export",
format!(
"channel '{}' holds text with {ch:?}, outside the Latin-1 \
range a MAT v4 char matrix can hold; export it to MAT \
level 5, which stores UTF-8",
series.name()
),
))
}
}
}
chars.push(codes);
}
let rows = chars.len();
let cols = chars.iter().map(Vec::len).max().unwrap_or(0);
let mut data = Vec::with_capacity(rows * cols);
for col in 0..cols {
for codes in &chars {
data.push(codes.get(col).copied().unwrap_or(b' '));
}
}
vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_UINT8,
matrix_type: MX_CHAR_CLASS,
rows,
cols,
data,
}]
}
SignalValues::Bytes { data: bytes, width } => {
let n = series.len();
let mut data = Vec::with_capacity(n * width);
for c in 0..*width {
for r in 0..n {
data.push(bytes.get(r * width + c).copied().unwrap_or(0));
}
}
vec![FlattenedMatV4 {
name: series.name().to_string(),
precision: MI_UINT8,
matrix_type: MX_FULL_CLASS,
rows: n,
cols: *width,
data,
}]
}
SignalValues::VarBytes { .. } => return refuse("variable-length byte"),
})
}
#[allow(clippy::too_many_arguments)]
fn write_matrix<W: Write>(
out: &mut W,
name: &str,
precision: u32,
matrix_type: u32,
rows: usize,
cols: usize,
imagf: u32,
data: &[u8],
) -> Result<()> {
let rows_i32 = i32::try_from(rows).map_err(|_| {
Mf4Error::write_error(format!(
"channel '{name}' has more samples than a MAT v4 dimension can hold"
))
})?;
let cols_i32 = i32::try_from(cols).map_err(|_| {
Mf4Error::write_error(format!(
"channel '{name}' has more columns than a MAT v4 dimension can hold"
))
})?;
let type_val: i32 = (precision * 10 + matrix_type) as i32;
let namlen_i32 = i32::try_from(name.len() + 1).map_err(|_| {
Mf4Error::write_error(format!("variable name '{name}' is too long for MAT v4"))
})?;
let mut header = [0u8; 20];
header[0..4].copy_from_slice(&type_val.to_le_bytes());
header[4..8].copy_from_slice(&rows_i32.to_le_bytes());
header[8..12].copy_from_slice(&cols_i32.to_le_bytes());
header[12..16].copy_from_slice(&(imagf as i32).to_le_bytes());
header[16..20].copy_from_slice(&namlen_i32.to_le_bytes());
out.write_all(&header)?;
out.write_all(name.as_bytes())?;
out.write_all(b"\0")?;
out.write_all(data)?;
Ok(())
}
#[derive(Default)]
struct UniqueNames {
seen: HashMap<String, usize>,
}
impl UniqueNames {
fn claim(&mut self, name: &str) -> String {
let base = matlab_compatible(name);
match self.seen.get_mut(&base) {
None => {
self.seen.insert(base.clone(), 0);
base
}
Some(count) => {
*count += 1;
format!("{base}_{count}")
}
}
}
}
fn matlab_compatible(name: &str) -> String {
let mut out: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect();
if !out.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
out.insert_str(0, "M_");
}
out.truncate(60);
out
}