///|
/// A reusable validation rule for measured equilibrium data.
pub(all) enum DataRule {
XRange(lower~ : Double, upper~ : Double)
YRange(lower~ : Double, upper~ : Double)
XStrictlyIncreasing
NoRejectedRows
MaximumAbsoluteSlope(value~ : Double)
MaximumRelativeJump(value~ : Double)
MinimumRows(count~ : Int)
} derive(Debug, Eq, ToJson)
///|
pub(all) struct RuleResult {
rule_name : String
passed : Bool
violations : Int
first_row : Int
message : String
} derive(Debug, Eq, ToJson)
///|
fn rule_name(rule : DataRule) -> String {
match rule {
XRange(_) => "x-range"
YRange(_) => "y-range"
XStrictlyIncreasing => "x-increasing"
NoRejectedRows => "rejected-rows"
MaximumAbsoluteSlope(_) => "maximum-slope"
MaximumRelativeJump(_) => "maximum-relative-jump"
MinimumRows(_) => "minimum-rows"
}
}
///|
pub fn validate_rule(
table : MeasurementTable,
rule : DataRule,
) -> RuleResult raise @domain.ExtractionError {
let rows = table.rows()
let mut violations = 0
let mut first = -1
let mut message = "rule passed"
match rule {
XRange(lower~, upper~) => {
@domain.require_finite("x rule lower", lower)
@domain.require_finite("x rule upper", upper)
for index, row in rows {
if row.x < lower || row.x > upper {
violations += 1
if first < 0 {
first = index
}
}
}
message = "x values must remain inside the declared range"
}
YRange(lower~, upper~) => {
@domain.require_finite("y rule lower", lower)
@domain.require_finite("y rule upper", upper)
for index, row in rows {
if row.y < lower || row.y > upper {
violations += 1
if first < 0 {
first = index
}
}
}
message = "y values must remain inside the declared range"
}
XStrictlyIncreasing => {
for index in 1.. {
for index, row in rows {
match row.quality {
Rejected(_) => {
violations += 1
if first < 0 {
first = index
}
}
_ => ()
}
}
message = "rejected measurements must be removed before fitting"
}
MaximumAbsoluteSlope(value~) => {
@domain.require_positive("maximum slope", value)
for index in 1.. value {
violations += 1
if first < 0 {
first = index
}
}
}
message = "successive slope magnitude exceeds the configured limit"
}
MaximumRelativeJump(value~) => {
@domain.require_positive("maximum relative jump", value)
for index in 1.. value {
violations += 1
if first < 0 {
first = index
}
}
}
message = "successive response change exceeds the configured limit"
}
MinimumRows(count~) => {
if rows.length() < count {
violations = count - rows.length()
first = rows.length()
}
message = "table does not contain enough measurements"
}
}
{
rule_name: rule_name(rule),
passed: violations == 0,
violations,
first_row: first,
message,
}
}
///|
pub fn validate_rules(
table : MeasurementTable,
rules : ArrayView[DataRule],
) -> Array[RuleResult] raise @domain.ExtractionError {
[
for rule in rules => validate_rule(table, rule)
]
}
///|
pub(all) struct QualityControlSummary {
results : Array[RuleResult]
passed : Bool
total_violations : Int
failed_rules : Int
} derive(Debug, Eq, ToJson)
///|
pub fn quality_control(
table : MeasurementTable,
rules : ArrayView[DataRule],
) -> QualityControlSummary raise @domain.ExtractionError {
let results = validate_rules(table, rules)
let mut violations = 0
let mut failed = 0
for result in results {
violations += result.violations
if !result.passed {
failed += 1
}
}
{
results,
passed: failed == 0,
total_violations: violations,
failed_rules: failed,
}
}
///|
pub fn QualityControlSummary::to_markdown(
self : QualityControlSummary,
) -> String {
let lines = [
"| Rule | Status | Violations | First row |", "|---|---|---:|---:|",
]
for result in self.results {
let status = if result.passed { "PASS" } else { "FAIL" }
lines.push(
"| \{result.rule_name} | \{status} | \{result.violations} | \{result.first_row} |",
)
}
lines.push("")
let overall = if self.passed { "PASS" } else { "FAIL" }
lines.push("Overall: \{overall}")
lines.join("\n")
}
///|
pub fn accepted_only(
table : MeasurementTable,
) -> MeasurementTable raise @domain.ExtractionError {
MeasurementTable::new(name=table.name(), rows=table.usable_rows())
}
///|
pub fn mark_quality_by_rules(
table : MeasurementTable,
rules : ArrayView[DataRule],
) -> MeasurementTable raise @domain.ExtractionError {
let rows = table.rows()
let marked = rows.copy()
for index, row in rows {
let mut is_suspect = false
for rule in rules {
let result = validate_rule(
MeasurementTable::new(name="single", rows=[row]),
rule,
)
if !result.passed {
is_suspect = true
}
}
if is_suspect {
marked[index] = row.mark(Suspect(reason="quality-control rule failed"))
}
}
MeasurementTable::new(name=table.name(), rows=marked)
}