///|
fn normalize_matrix_line(line : String) -> String {
  line
  .replace_all(old=",", new=" ")
  .replace_all(old="\t", new=" ")
  .replace_all(old=";", new=" ")
}

///|
fn parse_temperature_token(
  token : String,
  row~ : Int,
  col~ : Int,
) -> Double raise ThermalError {
  let mut sign = 1.0
  let mut value = 0.0
  let mut scale = 1.0
  let mut after_dot = false
  let mut saw_digit = false
  for i, ch in token {
    match ch {
      '-' if i == 0 => sign = -1.0
      '+' if i == 0 => sign = 1.0
      '.' if !after_dot => after_dot = true
      '0'..='9' => {
        saw_digit = true
        let digit = digit_value(ch).to_double()
        if after_dot {
          scale = scale * 10.0
          value = value + digit / scale
        } else {
          value = value * 10.0 + digit
        }
      }
      _ => raise ThermalError::ParseNumber(token~, row~, col~)
    }
  }
  if !saw_digit {
    raise ThermalError::ParseNumber(token~, row~, col~)
  }
  sign * value
}

///|
fn digit_value(ch : Char) -> Int {
  match ch {
    '0' => 0
    '1' => 1
    '2' => 2
    '3' => 3
    '4' => 4
    '5' => 5
    '6' => 6
    '7' => 7
    '8' => 8
    _ => 9
  }
}

///|
pub fn parse_temperature_matrix(
  text : String,
) -> ThermalMatrix raise ThermalError {
  let rows : Array[Array[Double]] = []
  for raw_row_index, raw_line in text.split("\n") {
    let line = normalize_matrix_line(raw_line.to_owned()).trim().to_owned()
    if !line.is_empty() {
      let row : Array[Double] = []
      for token_index, raw_token in line.split(" ") {
        let token = raw_token.trim().to_owned()
        if !token.is_empty() {
          row.push(
            parse_temperature_token(token, row=raw_row_index, col=token_index),
          )
        }
      }
      if !row.is_empty() {
        rows.push(row)
      }
    }
  }
  ThermalMatrix::from_rows(rows)
}

///|
pub fn ThermalMatrix::to_csv(matrix : ThermalMatrix) -> String {
  let lines : Array[String] = []
  for y in 0..