///|
/// Batch execution primitives for repeatable chemical-process campaigns.
pub struct BatchCharge {
material : String
mass_kg : Float
purity : Float
}
///|
pub fn batch_charge(
material : String,
mass_kg : Float,
purity : Float,
) -> BatchCharge {
{ material, mass_kg, purity }
}
///|
pub fn BatchCharge::active_mass(self : BatchCharge) -> Float {
self.mass_kg * self.purity
}
///|
pub fn BatchCharge::valid(self : BatchCharge) -> Bool {
self.mass_kg >= 0.0 && self.purity >= 0.0 && self.purity <= 1.0
}
///|
pub struct BatchRecipe {
name : String
target_kg : Float
charges : Array[BatchCharge]
hold_hours : Float
yield_fraction : Float
}
///|
pub fn batch_recipe(
name : String,
target_kg : Float,
charges : Array[BatchCharge],
hold_hours : Float,
yield_fraction : Float,
) -> BatchRecipe {
{ name, target_kg, charges, hold_hours, yield_fraction }
}
///|
pub fn BatchRecipe::input_mass(self : BatchRecipe) -> Float {
self.charges.fold(init=0.0, fn(total, charge) { total + charge.mass_kg })
}
///|
pub fn BatchRecipe::active_input(self : BatchRecipe) -> Float {
self.charges.fold(init=0.0, fn(total, charge) { total + charge.active_mass() })
}
///|
pub fn BatchRecipe::expected_output(self : BatchRecipe) -> Float {
self.active_input() * self.yield_fraction
}
///|
pub fn BatchRecipe::conversion(self : BatchRecipe) -> Float {
if self.active_input() == 0.0 {
0.0
} else {
self.expected_output() / self.active_input()
}
}
///|
pub fn BatchRecipe::valid(self : BatchRecipe) -> Bool {
self.target_kg >= 0.0 &&
self.hold_hours >= 0.0 &&
self.yield_fraction >= 0.0 &&
self.yield_fraction <= 1.0 &&
self.charges.all(fn(c) { c.valid() })
}
///|
pub struct BatchStep {
name : String
duration_hours : Float
temperature_c : Float
pressure_bar : Float
agitation_rpm : Float
}
///|
pub fn batch_step(
name : String,
duration_hours : Float,
temperature_c : Float,
pressure_bar : Float,
agitation_rpm : Float,
) -> BatchStep {
{ name, duration_hours, temperature_c, pressure_bar, agitation_rpm }
}
///|
pub fn BatchStep::safe(
self : BatchStep,
max_temperature : Float,
max_pressure : Float,
) -> Bool {
self.duration_hours >= 0.0 &&
self.temperature_c <= max_temperature &&
self.pressure_bar <= max_pressure &&
self.pressure_bar >= 0.0
}
///|
pub struct BatchPlan {
recipe : BatchRecipe
steps : Array[BatchStep]
cleaning_hours : Float
setup_hours : Float
}
///|
pub fn batch_plan(
recipe : BatchRecipe,
steps : Array[BatchStep],
cleaning_hours : Float,
setup_hours : Float,
) -> BatchPlan {
{ recipe, steps, cleaning_hours, setup_hours }
}
///|
pub fn BatchPlan::process_hours(self : BatchPlan) -> Float {
self.steps.fold(init=0.0, fn(total, step) { total + step.duration_hours })
}
///|
pub fn BatchPlan::cycle_hours(self : BatchPlan) -> Float {
self.process_hours() + self.cleaning_hours + self.setup_hours
}
///|
pub fn BatchPlan::throughput(self : BatchPlan) -> Float {
if self.cycle_hours() == 0.0 {
0.0
} else {
self.recipe.expected_output() / self.cycle_hours()
}
}
///|
pub fn BatchPlan::safe(
self : BatchPlan,
max_temperature : Float,
max_pressure : Float,
) -> Bool {
self.recipe.valid() &&
self.cleaning_hours >= 0.0 &&
self.setup_hours >= 0.0 &&
self.steps.all(fn(s) { s.safe(max_temperature, max_pressure) })
}
///|
pub struct BatchRecord {
id : String
plan_name : String
started_at : String
finished_at : String
output_kg : Float
scrap_kg : Float
accepted : Bool
}
///|
pub fn batch_record(
id : String,
plan_name : String,
started_at : String,
finished_at : String,
output_kg : Float,
scrap_kg : Float,
accepted : Bool,
) -> BatchRecord {
{ id, plan_name, started_at, finished_at, output_kg, scrap_kg, accepted }
}
///|
pub fn BatchRecord::total_mass(self : BatchRecord) -> Float {
self.output_kg + self.scrap_kg
}
///|
pub fn BatchRecord::yield_rate(self : BatchRecord, input_kg : Float) -> Float {
if input_kg == 0.0 {
0.0
} else {
self.output_kg / input_kg
}
}
///|
pub fn BatchRecord::scrap_rate(self : BatchRecord) -> Float {
if self.total_mass() == 0.0 {
0.0
} else {
self.scrap_kg / self.total_mass()
}
}
///|
pub fn batch_records_total(records : Array[BatchRecord]) -> Float {
records.fold(init=0.0, fn(total, record) { total + record.output_kg })
}
///|
pub fn batch_records_acceptance(records : Array[BatchRecord]) -> Float {
if records.length() == 0 {
0.0
} else {
Float::from_int(records.filter(fn(r) { r.accepted }).length()) /
Float::from_int(records.length())
}
}
///|
pub fn batch_records_table(records : Array[BatchRecord]) -> ReportTable {
let rows : Array[Array[String]] = []
for record in records {
rows.push([
record.id,
record.plan_name,
record.started_at,
record.finished_at,
"\{record.output_kg}",
"\{record.scrap_kg}",
"\{record.accepted}",
])
}
table(
["id", "plan", "started", "finished", "output kg", "scrap kg", "accepted"],
rows,
)
}
///|
pub fn batch_scale(plan : BatchPlan, factor : Float) -> BatchPlan {
let scaled = plan.steps.map(fn(step) {
batch_step(
step.name,
step.duration_hours,
step.temperature_c,
step.pressure_bar,
step.agitation_rpm,
)
})
let charges = plan.recipe.charges.map(fn(c) {
batch_charge(c.material, c.mass_kg * factor, c.purity)
})
{
recipe: batch_recipe(
plan.recipe.name,
plan.recipe.target_kg * factor,
charges,
plan.recipe.hold_hours,
plan.recipe.yield_fraction,
),
steps: scaled,
cleaning_hours: plan.cleaning_hours,
setup_hours: plan.setup_hours,
}
}
///|
pub fn batch_campaign_yield(
records : Array[BatchRecord],
input_kg : Float,
) -> Float {
if input_kg == 0.0 {
0.0
} else {
batch_records_total(records) / input_kg
}
}
///|
pub fn batch_campaign_scrap(records : Array[BatchRecord]) -> Float {
let total : Float = records.fold(init=0.0, fn(v, r) { v + r.total_mass() })
if total == 0.0 {
0.0
} else {
let scrap : Float = records.fold(init=0.0, fn(v, r) { v + r.scrap_kg })
scrap / total
}
}