///|
/// A row of measured process data with optional operating metadata.
pub(all) struct MeasurementRow {
id : String
x : Double
y : Double
temperature_kelvin : Double
pressure_pascal : Double
replicate : Int
quality : DataQuality
} derive(Debug, Eq, ToJson)
///|
pub(all) enum DataQuality {
Accepted
Suspect(reason~ : String)
Rejected(reason~ : String)
} derive(Debug, Eq, ToJson)
///|
pub fn MeasurementRow::new(
id~ : String,
x~ : Double,
y~ : Double,
temperature_kelvin~ : Double,
pressure_pascal~ : Double,
replicate? : Int = 1,
) -> MeasurementRow raise @domain.ExtractionError {
if id.trim().length() == 0 {
raise @domain.ExtractionError::InvalidArgument(
message="measurement id cannot be blank",
)
}
@domain.require_finite("measurement x", x)
@domain.require_finite("measurement y", y)
@domain.require_positive("measurement temperature", temperature_kelvin)
@domain.require_positive("measurement pressure", pressure_pascal)
if replicate < 1 {
raise @domain.ExtractionError::InvalidArgument(
message="replicate number must be positive",
)
}
{
id,
x,
y,
temperature_kelvin,
pressure_pascal,
replicate,
quality: Accepted,
}
}
///|
pub fn MeasurementRow::mark(
self : MeasurementRow,
quality : DataQuality,
) -> MeasurementRow {
{
id: self.id,
x: self.x,
y: self.y,
temperature_kelvin: self.temperature_kelvin,
pressure_pascal: self.pressure_pascal,
replicate: self.replicate,
quality,
}
}
///|
pub fn MeasurementRow::is_usable(self : MeasurementRow) -> Bool {
match self.quality {
Accepted => true
Suspect(_) => true
Rejected(_) => false
}
}
///|
pub fn MeasurementRow::relative_volatility(self : MeasurementRow) -> Double {
if self.x <= 0.0 {
0.0
} else {
self.y / self.x
}
}
///|
pub fn MeasurementRow::temperature_celsius(self : MeasurementRow) -> Double {
self.temperature_kelvin - 273.15
}
///|
pub(all) struct MeasurementTable {
name : String
rows : Array[MeasurementRow]
x_label : String
y_label : String
} derive(Debug, Eq, ToJson)
///|
pub fn MeasurementTable::new(
name~ : String,
rows~ : ArrayView[MeasurementRow],
x_label? : String = "x",
y_label? : String = "y",
) -> MeasurementTable raise @domain.ExtractionError {
if name.trim().length() == 0 {
raise @domain.ExtractionError::InvalidArgument(
message="measurement table name cannot be blank",
)
}
if rows.length() == 0 {
raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
}
if x_label.trim().length() == 0 || y_label.trim().length() == 0 {
raise @domain.ExtractionError::InvalidArgument(
message="measurement labels cannot be blank",
)
}
{ name, rows: rows.to_owned(), x_label, y_label }
}
///|
pub fn MeasurementTable::name(self : MeasurementTable) -> String {
self.name
}
///|
pub fn MeasurementTable::rows(self : MeasurementTable) -> Array[MeasurementRow] {
self.rows.copy()
}
///|
pub fn MeasurementTable::usable_rows(
self : MeasurementTable,
) -> Array[MeasurementRow] {
[
for row in self.rows if row.is_usable() => row
]
}
///|
pub fn MeasurementTable::accepted_count(self : MeasurementTable) -> Int {
self.rows.fold(init=0, (count, row) => {
if row.quality == Accepted {
count + 1
} else {
count
}
})
}
///|
pub fn MeasurementTable::rejected_count(self : MeasurementTable) -> Int {
self.rows.fold(init=0, (count, row) => {
match row.quality {
Rejected(_) => count + 1
_ => count
}
})
}
///|
pub fn MeasurementTable::x_values(self : MeasurementTable) -> Array[Double] {
[
for row in self.rows => row.x
]
}
///|
pub fn MeasurementTable::y_values(self : MeasurementTable) -> Array[Double] {
[
for row in self.rows => row.y
]
}
///|
pub fn MeasurementTable::filter_x(
self : MeasurementTable,
lower~ : Double,
upper~ : Double,
) -> MeasurementTable raise @domain.ExtractionError {
@domain.require_finite("filter lower", lower)
@domain.require_finite("filter upper", upper)
if upper < lower {
raise @domain.ExtractionError::InvalidArgument(
message="filter upper bound is below lower bound",
)
}
let selected = [
for row in self.rows if row.x >= lower && row.x <= upper => row
]
MeasurementTable::new(
name=self.name,
rows=selected,
x_label=self.x_label,
y_label=self.y_label,
)
}
///|
pub fn MeasurementTable::mark_outliers(
self : MeasurementTable,
y_lower~ : Double,
y_upper~ : Double,
) -> MeasurementTable raise @domain.ExtractionError {
@domain.require_finite("outlier lower", y_lower)
@domain.require_finite("outlier upper", y_upper)
if y_upper < y_lower {
raise @domain.ExtractionError::InvalidArgument(
message="outlier upper bound is below lower bound",
)
}
let rows = [
for row in self.rows => {
if row.y < y_lower {
row.mark(Suspect(reason="below accepted response range"))
} else if row.y > y_upper {
row.mark(Suspect(reason="above accepted response range"))
} else {
row
}
}
]
MeasurementTable::new(
name=self.name,
rows~,
x_label=self.x_label,
y_label=self.y_label,
)
}
///|
fn insertion_sort_rows(rows : Array[MeasurementRow]) -> Array[MeasurementRow] {
let sorted : Array[MeasurementRow] = []
for row in rows {
let mut position = sorted.length()
for index, other in sorted {
if row.x < other.x {
position = index
break
}
}
sorted.push(row)
let mut index = sorted.length() - 1
while index > position {
let value = sorted[index]
sorted[index] = sorted[index - 1]
sorted[index - 1] = value
index -= 1
}
}
sorted
}
///|
pub fn MeasurementTable::sorted_by_x(
self : MeasurementTable,
) -> MeasurementTable raise @domain.ExtractionError {
MeasurementTable::new(
name=self.name,
rows=insertion_sort_rows(self.rows.copy()),
x_label=self.x_label,
y_label=self.y_label,
)
}
///|
pub fn MeasurementTable::deduplicate_x(
self : MeasurementTable,
tolerance? : Double = 1.0e-12,
) -> MeasurementTable raise @domain.ExtractionError {
@domain.require_positive("deduplication tolerance", tolerance)
let sorted = insertion_sort_rows(self.rows.copy())
let result : Array[MeasurementRow] = []
for row in sorted {
if result.length() == 0 ||
(row.x - result[result.length() - 1].x).abs() > tolerance {
result.push(row)
} else {
let previous = result[result.length() - 1]
let average = (previous.y + row.y) / 2.0
result[result.length() - 1] = {
id: previous.id + "+" + row.id,
x: (previous.x + row.x) / 2.0,
y: average,
temperature_kelvin: (
previous.temperature_kelvin + row.temperature_kelvin
) /
2.0,
pressure_pascal: (previous.pressure_pascal + row.pressure_pascal) / 2.0,
replicate: previous.replicate + row.replicate,
quality: Suspect(reason="duplicate x values averaged"),
}
}
}
MeasurementTable::new(
name=self.name,
rows=result,
x_label=self.x_label,
y_label=self.y_label,
)
}
///|
pub(all) struct ColumnStatistics {
count : Int
minimum : Double
maximum : Double
mean : Double
standard_deviation : Double
missing_count : Int
} derive(Debug, Eq, ToJson)
///|
fn statistics(
values : ArrayView[Double],
) -> ColumnStatistics raise @domain.ExtractionError {
if values.length() == 0 {
raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
}
let mut minimum = values[0]
let mut maximum = values[0]
let mut sum = 0.0
for value in values {
@domain.require_finite("column value", value)
minimum = minimum.min(value)
maximum = maximum.max(value)
sum += value
}
let mean = sum / values.length().to_double()
let variance = values.fold(init=0.0, (total, value) => {
total + (value - mean) * (value - mean)
}) /
values.length().to_double()
{
count: values.length(),
minimum,
maximum,
mean,
standard_deviation: variance.sqrt(),
missing_count: 0,
}
}
///|
pub fn MeasurementTable::x_statistics(
self : MeasurementTable,
) -> ColumnStatistics raise @domain.ExtractionError {
statistics(self.x_values())
}
///|
pub fn MeasurementTable::y_statistics(
self : MeasurementTable,
) -> ColumnStatistics raise @domain.ExtractionError {
statistics(self.y_values())
}
///|
pub(all) struct TableQuality {
rows : Int
usable_rows : Int
rejected_rows : Int
duplicate_x_groups : Int
monotone_x : Bool
finite_values : Bool
message : String
} derive(Debug, Eq, ToJson)
///|
pub fn MeasurementTable::quality_report(
self : MeasurementTable,
) -> TableQuality {
let mut monotone = true
let mut finite = true
let mut duplicate_groups = 0
let sorted = insertion_sort_rows(self.rows.copy())
for index, row in sorted {
if row.x.is_nan() || row.x.is_inf() || row.y.is_nan() || row.y.is_inf() {
finite = false
}
if index > 0 {
if row.x < sorted[index - 1].x {
monotone = false
}
if row.x == sorted[index - 1].x {
duplicate_groups += 1
}
}
}
let usable = self.usable_rows().length()
let message = if !finite {
"non-finite values detected"
} else if !monotone {
"x coordinates are not monotone"
} else if duplicate_groups > 0 {
"duplicate x coordinates detected"
} else {
"table is internally consistent"
}
{
rows: self.rows.length(),
usable_rows: usable,
rejected_rows: self.rejected_count(),
duplicate_x_groups: duplicate_groups,
monotone_x: monotone,
finite_values: finite,
message,
}
}
///|
pub fn MeasurementTable::moving_average(
self : MeasurementTable,
window~ : Int,
) -> Array[Double] raise @domain.ExtractionError {
if window < 1 || window > self.rows.length() {
raise @domain.ExtractionError::InvalidArgument(
message="moving-average window must fit table",
)
}
let values = self.y_values()
[
for index in 0.. {
let start = (index + 1 - window).max(0)
let stop = index + 1
let mut sum = 0.0
for cursor in start.. Double raise @domain.ExtractionError {
let xs = self.x_values()
let ys = self.y_values()
let x_mean = statistics(xs).mean
let y_mean = statistics(ys).mean
let mut numerator = 0.0
let mut x_sum = 0.0
let mut y_sum = 0.0
for index in 0.. String {
let lines = [
"id,\{self.x_label},\{self.y_label},temperature_kelvin,pressure_pascal,replicate,quality",
]
for row in self.rows {
let quality = match row.quality {
Accepted => "accepted"
Suspect(reason~) => "suspect:\{reason}"
Rejected(reason~) => "rejected:\{reason}"
}
lines.push(
"\{row.id},\{row.x},\{row.y},\{row.temperature_kelvin},\{row.pressure_pascal},\{row.replicate},\{quality}",
)
}
lines.join("\n")
}
///|
/// Resample a monotone table onto evenly spaced x coordinates.
pub fn resample_linear(
table : MeasurementTable,
lower~ : Double,
upper~ : Double,
count~ : Int,
) -> Array[MeasurementRow] raise @domain.ExtractionError {
if count < 2 {
raise @domain.ExtractionError::InsufficientData(required=2, actual=count)
}
let sorted = table.sorted_by_x()
let rows = sorted.rows
if upper <= lower || lower < rows[0].x || upper > rows[rows.length() - 1].x {
raise @domain.ExtractionError::OutOfRange(
name="resampling range",
value=lower.min(upper),
lower=rows[0].x,
upper=rows[rows.length() - 1].x,
)
}
[
for index in 0.. {
let x = lower +
(upper - lower) * index.to_double() / (count - 1).to_double()
let mut right = 1
while right < rows.length() && rows[right].x < x {
right += 1
}
let left = right - 1
let fraction = if rows[right].x == rows[left].x {
0.0
} else {
(x - rows[left].x) / (rows[right].x - rows[left].x)
}
let y = rows[left].y + fraction * (rows[right].y - rows[left].y)
MeasurementRow::new(
id="resampled-{index}",
x~,
y~,
temperature_kelvin=rows[left].temperature_kelvin +
fraction *
(rows[right].temperature_kelvin - rows[left].temperature_kelvin),
pressure_pascal=rows[left].pressure_pascal +
fraction * (rows[right].pressure_pascal - rows[left].pressure_pascal),
)
}
]
}
///|
pub fn merge_tables(
first : MeasurementTable,
second : MeasurementTable,
name~ : String,
) -> MeasurementTable raise @domain.ExtractionError {
let rows = first.rows.copy()
for row in second.rows {
rows.push(row)
}
MeasurementTable::new(
name~,
rows~,
x_label=first.x_label,
y_label=first.y_label,
)
}