extern crate std;
use std::prelude::v1::*;
use std::{format, vec};
use std::collections::BTreeMap;
use super::curve::{CurveData, CurveDef, DefinitionsFile};
use super::transfer::{TransferData, TransferDef};
pub fn generate(
curves_file: &DefinitionsFile,
value_type: &str,
lut_size: usize,
) -> Result<String, String> {
let mut curves: Vec<(&String, &CurveDef)> = curves_file.curves.iter().collect();
curves.sort_by_key(|(name, _)| *name);
let mut transfers: Vec<(&String, &TransferDef)> = curves_file.transfers.iter().collect();
transfers.sort_by_key(|(name, _)| *name);
let const_names = emitted_const_names(&curves, &transfers)?;
let mut out = String::new();
out.push_str("// Auto-generated by ph-curves-gen. Do not edit.\n\n");
if !curves_file.curves.is_empty() {
out.push_str("use ph_curves::{CurveLut, MonotonicCurveLut};\n\n");
}
if !curves_file.transfers.is_empty() {
out.push_str(
"use ph_curves::{BoundaryBehavior, MonotonicDirection, \
PiecewiseLinearTransfer, TransferMetadata};\n\n",
);
}
if !curves_file.curves.is_empty() {
out.push_str(&format!(
"type Lut = CurveLut<{value_type}, {value_type}, {lut_size}>;\n",
));
out.push_str(&format!(
"type MonoLut = MonotonicCurveLut<{value_type}, {value_type}, {lut_size}>;\n\n",
));
}
for (name, def) in &curves {
let data = super::curve::build(name, def, lut_size)?;
let const_name = &const_names[*name];
emit_curve(&mut out, name, const_name, def, &data, value_type, lut_size);
}
for (name, def) in &transfers {
let data = super::transfer::build(name, def)?;
let const_name = &const_names[*name];
emit_transfer(&mut out, name, const_name, def, &data);
}
Ok(out)
}
fn emit_curve(
out: &mut String,
name: &str,
const_name: &str,
def: &CurveDef,
data: &CurveData,
value_type: &str,
lut_size: usize,
) {
out.push_str(&format!(
"static {const_name}_FWD: [{vt}; {lut_size}] = {arr};\n",
vt = value_type,
arr = format_array(&data.fwd),
));
if let Some(inv) = &data.inv {
out.push_str(&format!(
"static {const_name}_INV: [{vt}; {lut_size}] = {arr};\n",
vt = value_type,
arr = format_array(inv),
));
}
if def.monotonic {
out.push_str(&format!(
"/// {name:?} \u{2014} monotonic curve.\n\
pub const {const_name}: MonoLut = \
MonoLut::new(&{const_name}_FWD, &{const_name}_INV);\n\n",
));
} else {
out.push_str(&format!(
"/// {name:?} \u{2014} curve (non-monotonic).\n\
pub const {const_name}: Lut = \
Lut::new(&{const_name}_FWD, None);\n\n",
));
}
}
fn emit_transfer(
out: &mut String,
name: &str,
const_name: &str,
def: &TransferDef,
data: &TransferData,
) {
let knot_count = data.inputs.len();
let direction = match data.direction {
crate::MonotonicDirection::Increasing => "MonotonicDirection::Increasing",
crate::MonotonicDirection::Decreasing => "MonotonicDirection::Decreasing",
};
let domain_min = data.inputs[0];
let domain_max = *data.inputs.last().unwrap();
out.push_str(&format!(
"#[rustfmt::skip]\n\
static {const_name}_INPUTS: [u16; {knot_count}] = {inputs};\n",
inputs = format_u16_array(&data.inputs),
));
out.push_str(&format!(
"#[rustfmt::skip]\n\
static {const_name}_OUTPUTS: [i32; {knot_count}] = {outputs};\n",
outputs = format_i32_array(&data.outputs),
));
out.push_str(&format!(
"/// {name:?} sparse physical transfer function.\n\
///\n\
/// Source: {provenance:?}.\n\
/// Domain: {domain_min}..={domain_max} {input_unit:?}; output: {output_unit:?} x {output_scale}.\n\
/// Knots: {knot_count} ({payload} bytes array payload).\n\
/// Exhaustive numerical error: requested <= {requested}, achieved {achieved:.6} output quanta\n\
/// (conservative metadata bound {achieved_bound}) at input {worst}.\n\
/// This is table/quantization error against the configured ideal source, not total sensor accuracy.\n\
#[rustfmt::skip]\n\
pub const {const_name}: PiecewiseLinearTransfer<{knot_count}> =\n\
PiecewiseLinearTransfer::new(&{const_name}_INPUTS, &{const_name}_OUTPUTS, {direction})\n\
.with_boundaries({below}, {above});\n",
provenance = data.provenance,
input_unit = def.input_unit,
output_unit = def.output_unit,
output_scale = def.output_scale,
payload = knot_count * 6,
requested = def.max_interpolation_error,
achieved = data.achieved_max_error_exact,
achieved_bound = data.achieved_max_error,
worst = data.worst_case_input,
below = def.below.rust_name(),
above = def.above.rust_name(),
));
let (range_min, range_max) = {
let first = data.outputs[0];
let last = *data.outputs.last().unwrap();
if first <= last {
(first, last)
} else {
(last, first)
}
};
let flat_segment_count = data
.outputs
.windows(2)
.filter(|pair| pair[0] == pair[1])
.count();
let strictly_monotonic = flat_segment_count == 0;
let achieved_max_inverse_code_error =
super::transfer::measure_inverse_code_error(&data.inputs, &data.outputs, data.direction);
out.push_str(&format!(
"/// Metadata for [`{const_name}`].\n\
pub const {const_name}_METADATA: TransferMetadata = TransferMetadata {{\n\
\x20 input_unit: {input_unit:?},\n\
\x20 output_unit: {output_unit:?},\n\
\x20 output_scale: {output_scale},\n\
\x20 domain_min: {domain_min},\n\
\x20 domain_max: {domain_max},\n\
\x20 range_min: {range_min},\n\
\x20 range_max: {range_max},\n\
\x20 direction: {direction},\n\
\x20 knot_count: {knot_count},\n\
\x20 strictly_monotonic: {strictly_monotonic},\n\
\x20 flat_segment_count: {flat_segment_count},\n\
\x20 requested_max_error: {requested},\n\
\x20 achieved_max_error: {achieved},\n\
\x20 worst_case_input: {worst},\n\
\x20 achieved_max_inverse_code_error: {inverse_error},\n\
}};\n\n",
input_unit = def.input_unit,
output_unit = def.output_unit,
output_scale = def.output_scale,
requested = def.max_interpolation_error,
achieved = data.achieved_max_error,
worst = data.worst_case_input,
inverse_error = achieved_max_inverse_code_error,
));
}
fn emitted_const_names(
curves: &[(&String, &CurveDef)],
transfers: &[(&String, &TransferDef)],
) -> Result<BTreeMap<String, String>, String> {
let mut names = BTreeMap::new();
let mut base_sources = BTreeMap::new();
for name in curves
.iter()
.map(|(name, _)| *name)
.chain(transfers.iter().map(|(name, _)| *name))
{
let const_name = to_const_name(name)?;
if let Some(previous) = base_sources.insert(const_name.clone(), name.as_str()) {
return Err(format!(
"names `{previous}` and `{name}` both normalize to Rust identifier \
`{const_name}`; rename one entry"
));
}
names.insert(name.clone(), const_name);
}
let mut emitted_sources = BTreeMap::new();
for (name, def) in curves {
let const_name = &names[*name];
let mut identifiers = vec![const_name.clone(), format!("{const_name}_FWD")];
if def.monotonic {
identifiers.push(format!("{const_name}_INV"));
}
reserve_identifiers(name, identifiers, &mut emitted_sources)?;
}
for (name, _) in transfers {
let const_name = &names[*name];
reserve_identifiers(
name,
[
const_name.clone(),
format!("{const_name}_INPUTS"),
format!("{const_name}_OUTPUTS"),
format!("{const_name}_METADATA"),
],
&mut emitted_sources,
)?;
}
Ok(names)
}
fn reserve_identifiers<'a>(
name: &'a str,
identifiers: impl IntoIterator<Item = String>,
emitted_sources: &mut BTreeMap<String, &'a str>,
) -> Result<(), String> {
for identifier in identifiers {
if let Some(previous) = emitted_sources.insert(identifier.clone(), name) {
return Err(format!(
"names `{previous}` and `{name}` generate duplicate Rust identifier \
`{identifier}`; rename one entry"
));
}
}
Ok(())
}
fn to_const_name(name: &str) -> Result<String, String> {
let mut out = String::new();
let mut prev_underscore = false;
let mut has_alphanumeric = false;
for ch in name.chars() {
let next = if ch.is_ascii_alphanumeric() {
has_alphanumeric = true;
ch.to_ascii_uppercase()
} else {
'_'
};
if next == '_' {
if !prev_underscore {
out.push('_');
prev_underscore = true;
}
} else {
out.push(next);
prev_underscore = false;
}
}
if !has_alphanumeric {
return Err(format!(
"name `{name}` cannot be normalized to a Rust identifier; \
include at least one ASCII letter or digit"
));
}
if out.as_bytes()[0].is_ascii_digit() {
out.insert_str(0, "CURVE_");
}
Ok(out)
}
fn format_array(values: &[u32]) -> String {
let mut out = String::from("[");
for (idx, value) in values.iter().enumerate() {
if idx > 0 {
out.push_str(", ");
}
out.push_str(&value.to_string());
}
out.push(']');
out
}
fn format_u16_array(values: &[u16]) -> String {
format_values(values)
}
fn format_i32_array(values: &[i32]) -> String {
format_values(values)
}
fn format_values<T: core::fmt::Display>(values: &[T]) -> String {
let mut out = String::from("[");
for (idx, value) in values.iter().enumerate() {
if idx > 0 {
out.push_str(", ");
}
out.push_str(&value.to_string());
}
out.push(']');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::r#gen::transfer::{BoundaryDef, PhysicalPoint, TransferDef};
use std::collections::BTreeMap;
#[test]
fn const_name_simple() {
assert_eq!(to_const_name("linear").unwrap(), "LINEAR");
}
#[test]
fn const_name_with_underscores() {
assert_eq!(to_const_name("ease_in_quad").unwrap(), "EASE_IN_QUAD");
}
#[test]
fn const_name_collapses_multiple_non_alnum() {
assert_eq!(to_const_name("a--b").unwrap(), "A_B");
}
#[test]
fn const_name_rejects_empty() {
let error = to_const_name("").unwrap_err();
assert!(error.contains("cannot be normalized"));
assert!(error.contains("ASCII letter or digit"));
}
#[test]
fn const_name_rejects_special_chars_only() {
let error = to_const_name("---").unwrap_err();
assert!(error.contains("cannot be normalized"));
assert!(error.contains("Rust identifier"));
}
#[test]
fn const_name_prefixes_leading_digit() {
assert_eq!(to_const_name("2bit").unwrap(), "CURVE_2BIT");
assert_eq!(to_const_name("123 curve").unwrap(), "CURVE_123_CURVE");
}
#[test]
fn format_array_small() {
assert_eq!(format_array(&[0, 1, 2, 3]), "[0, 1, 2, 3]");
}
#[test]
fn format_array_single() {
assert_eq!(format_array(&[42]), "[42]");
}
#[test]
fn format_array_empty() {
assert_eq!(format_array(&[]), "[]");
}
#[test]
fn generate_monotonic_curve() {
let mut curves = BTreeMap::new();
curves.insert(
"linear".to_string(),
CurveDef {
builtin: Some("linear".into()),
formula: None,
points: None,
monotonic: true,
},
);
let cf = DefinitionsFile {
curves,
transfers: BTreeMap::new(),
};
let out = generate(&cf, "u8", 256).unwrap();
assert!(out.contains("AUTO_LINEAR_FWD") || out.contains("LINEAR_FWD"));
assert!(out.contains("LINEAR_INV"));
assert!(out.contains("MonoLut"));
assert!(out.contains(r#"/// "linear" — monotonic curve."#));
}
#[test]
fn generate_non_monotonic_curve() {
let mut curves = BTreeMap::new();
curves.insert(
"wave".to_string(),
CurveDef {
builtin: None,
formula: None,
points: Some(vec![[0, 0], [64, 200], [192, 50], [255, 255]]),
monotonic: false,
},
);
let cf = DefinitionsFile {
curves,
transfers: BTreeMap::new(),
};
let out = generate(&cf, "u8", 256).unwrap();
assert!(out.contains("WAVE_FWD"));
assert!(!out.contains("WAVE_INV"));
assert!(out.contains("Lut::new"));
}
#[test]
fn generate_u16_lut() {
let mut curves = BTreeMap::new();
curves.insert(
"lin".to_string(),
CurveDef {
builtin: Some("linear".into()),
formula: None,
points: None,
monotonic: true,
},
);
let cf = DefinitionsFile {
curves,
transfers: BTreeMap::new(),
};
let out = generate(&cf, "u16", 10).unwrap();
assert!(out.contains("u16"));
assert!(out.contains("10"));
}
#[test]
fn generate_rejects_normalization_collisions() {
let mut curves = BTreeMap::new();
for name in ["ease-in", "ease_in"] {
curves.insert(
name.to_string(),
CurveDef {
builtin: Some("linear".into()),
formula: None,
points: None,
monotonic: true,
},
);
}
let error = generate(
&DefinitionsFile {
curves,
transfers: BTreeMap::new(),
},
"u8",
256,
)
.unwrap_err();
assert!(error.contains("both normalize"));
assert!(error.contains("`EASE_IN`"));
}
#[test]
fn generate_rejects_curve_companion_collisions() {
let mut curves = BTreeMap::new();
for name in ["linear", "linear_fwd"] {
curves.insert(
name.to_string(),
CurveDef {
builtin: Some("linear".into()),
formula: None,
points: None,
monotonic: true,
},
);
}
let error = generate(
&DefinitionsFile {
curves,
transfers: BTreeMap::new(),
},
"u8",
256,
)
.unwrap_err();
assert!(error.contains("duplicate Rust identifier"));
assert!(error.contains("`LINEAR_FWD`"));
}
#[test]
fn generate_escapes_curve_names_in_doc_comments() {
let mut curves = BTreeMap::new();
curves.insert(
"line\nbreak".to_string(),
CurveDef {
builtin: Some("linear".into()),
formula: None,
points: None,
monotonic: true,
},
);
let out = generate(
&DefinitionsFile {
curves,
transfers: BTreeMap::new(),
},
"u8",
256,
)
.unwrap();
assert!(out.contains(r#"/// "line\nbreak" — monotonic curve."#));
assert!(!out.contains("/// \"line\nbreak"));
}
#[test]
fn generate_rejects_curve_transfer_name_collisions() {
let mut curves = BTreeMap::new();
curves.insert(
"sensor".to_string(),
CurveDef {
builtin: Some("linear".into()),
formula: None,
points: None,
monotonic: true,
},
);
let mut transfers = BTreeMap::new();
transfers.insert(
"sensor".to_string(),
TransferDef {
input_unit: "adc_code".into(),
output_unit: "volt".into(),
output_scale: 1000,
max_interpolation_error: 1,
max_knots: 256,
below: BoundaryDef::Error,
above: BoundaryDef::Error,
points: Some(vec![
PhysicalPoint {
input: 0,
output: 0.0,
},
PhysicalPoint {
input: 10,
output: 1.0,
},
]),
formula: None,
model: None,
domain: None,
output_range: None,
},
);
let error = generate(&DefinitionsFile { curves, transfers }, "u8", 256).unwrap_err();
assert!(error.contains("both normalize"));
assert!(error.contains("`SENSOR`"));
}
#[test]
fn generate_rejects_transfer_companion_collisions() {
let mut transfers = BTreeMap::new();
for name in ["ntc", "ntc_inputs"] {
transfers.insert(
name.to_string(),
TransferDef {
input_unit: "adc_code".into(),
output_unit: "volt".into(),
output_scale: 1000,
max_interpolation_error: 1,
max_knots: 256,
below: BoundaryDef::Error,
above: BoundaryDef::Error,
points: Some(vec![
PhysicalPoint {
input: 0,
output: 0.0,
},
PhysicalPoint {
input: 10,
output: 1.0,
},
]),
formula: None,
model: None,
domain: None,
output_range: None,
},
);
}
let error = generate(
&DefinitionsFile {
curves: BTreeMap::new(),
transfers,
},
"u8",
256,
)
.unwrap_err();
assert!(error.contains("duplicate Rust identifier"));
assert!(error.contains("`NTC_INPUTS`"));
}
#[test]
fn generate_escapes_transfer_fields_in_doc_comments() {
let mut transfers = BTreeMap::new();
transfers.insert(
"line\nbreak".to_string(),
TransferDef {
input_unit: "adc\ncode".into(),
output_unit: "volt\nunit".into(),
output_scale: 1000,
max_interpolation_error: 1,
max_knots: 256,
below: BoundaryDef::Error,
above: BoundaryDef::Error,
formula: Some("x\n* 0.5".into()),
points: None,
model: None,
domain: Some([0, 10]),
output_range: None,
},
);
let out = generate(
&DefinitionsFile {
curves: BTreeMap::new(),
transfers,
},
"u8",
256,
)
.unwrap();
assert!(out.contains(r#"/// "line\nbreak" sparse physical transfer function."#));
assert!(out.contains(r#"Source: "formula y = x\n* 0.5"."#));
assert!(out.contains(r#""adc\ncode""#));
assert!(out.contains(r#""volt\nunit""#));
assert!(!out.contains("/// \"line\nbreak"));
}
#[test]
fn reference_ntc_matches_golden_output() {
let definition: DefinitionsFile =
toml::from_str(include_str!("../../assets/transfers.toml")).unwrap();
let output = generate(&definition, "u8", 256).unwrap();
let expected = include_str!("../../tests/fixtures/ntc_generated.rs").replace("\r\n", "\n");
assert_eq!(output.trim_end(), expected.trim_end());
assert!(!output.contains("f32"));
assert!(!output.contains("f64"));
}
#[test]
fn custom_transfer_examples_generate_integer_firmware_code() {
let definition: DefinitionsFile =
toml::from_str(include_str!("../../assets/custom-transfers.toml")).unwrap();
let output = generate(&definition, "u8", 256).unwrap();
assert!(output.contains("pub const PRESSURE_100KPA"));
assert!(output.contains("pub const TANK_LEVEL"));
assert!(output.contains("PiecewiseLinearTransfer"));
assert!(!output.contains("f32"));
assert!(!output.contains("f64"));
}
}