///|
/// A normalized stage profile suitable for plotting and equipment review.
pub(all) struct StageProfilePoint {
stage : Int
raffinate : Double
extract : Double
driving_force : Double
efficiency : Double
solute_transfer : Double
cumulative_transfer : Double
} derive(Debug, Eq, ToJson)
///|
pub(all) struct StageProfile {
points : Array[StageProfilePoint]
total_transfer : Double
average_driving_force : Double
minimum_driving_force : Double
limiting_stage : Int
minimum_efficiency : Double
maximum_efficiency : Double
} derive(Debug, Eq, ToJson)
///|
fn profile_from_efficiency_steps(
steps : ArrayView[EfficiencyStep],
) -> StageProfile raise @domain.ExtractionError {
if steps.length() == 0 {
raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
}
let points : Array[StageProfilePoint] = []
let mut cumulative = 0.0
let mut sum_force = 0.0
let mut minimum_force = 1.0e300
let mut limiting = 1
let mut minimum_efficiency = 1.0
let mut maximum_efficiency = 0.0
for index, step in steps {
let force = (step.equilibrium_extract - step.extract_in).max(0.0)
let transfer = (step.raffinate_in - step.raffinate_out).max(0.0)
cumulative += transfer
sum_force += force
if force < minimum_force {
minimum_force = force
limiting = index + 1
}
minimum_efficiency = minimum_efficiency.min(step.efficiency)
maximum_efficiency = maximum_efficiency.max(step.efficiency)
points.push({
stage: step.stage,
raffinate: step.raffinate_out,
extract: step.actual_extract_out,
driving_force: force,
efficiency: step.efficiency,
solute_transfer: transfer,
cumulative_transfer: cumulative,
})
}
{
points,
total_transfer: cumulative,
average_driving_force: sum_force / steps.length().to_double(),
minimum_driving_force: minimum_force,
limiting_stage: limiting,
minimum_efficiency,
maximum_efficiency,
}
}
///|
pub fn profile_efficiency_result(
result : EfficiencyStageResult,
) -> StageProfile raise @domain.ExtractionError {
profile_from_efficiency_steps(result.steps)
}
///|
pub fn profile_ideal_steps(
result : StageCountResult,
) -> StageProfile raise @domain.ExtractionError {
if result.steps.length() == 0 {
raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
}
let points : Array[StageProfilePoint] = []
let mut cumulative = 0.0
let mut force_sum = 0.0
let mut minimum_force = 1.0e300
let mut limiting = 1
for index, step in result.steps {
let force = (step.extract_out - step.extract_in).max(0.0)
let transfer = (step.raffinate_in - step.raffinate_out).max(0.0)
cumulative += transfer
force_sum += force
if force < minimum_force {
minimum_force = force
limiting = index + 1
}
points.push({
stage: step.stage,
raffinate: step.raffinate_out,
extract: step.extract_out,
driving_force: force,
efficiency: 1.0,
solute_transfer: transfer,
cumulative_transfer: cumulative,
})
}
{
points,
total_transfer: cumulative,
average_driving_force: force_sum / result.steps.length().to_double(),
minimum_driving_force: minimum_force,
limiting_stage: limiting,
minimum_efficiency: 1.0,
maximum_efficiency: 1.0,
}
}
///|
pub fn StageProfile::stage_count(self : StageProfile) -> Int {
self.points.length()
}
///|
pub fn StageProfile::point(
self : StageProfile,
index : Int,
) -> StageProfilePoint raise @domain.ExtractionError {
match self.points.get(index) {
Some(value) => value
None =>
raise @domain.ExtractionError::InvalidArgument(
message="stage profile index out of range",
)
}
}
///|
pub fn StageProfile::efficiency_range(self : StageProfile) -> Double {
self.maximum_efficiency - self.minimum_efficiency
}
///|
pub fn StageProfile::uniformity(self : StageProfile) -> Double {
if self.maximum_efficiency <= 0.0 {
0.0
} else {
self.minimum_efficiency / self.maximum_efficiency
}
}
///|
pub fn StageProfile::to_csv(self : StageProfile) -> String {
let lines = [
"stage,raffinate,extract,driving_force,efficiency,solute_transfer,cumulative_transfer",
]
for point in self.points {
lines.push(
"\{point.stage},\{point.raffinate},\{point.extract},\{point.driving_force},\{point.efficiency},\{point.solute_transfer},\{point.cumulative_transfer}",
)
}
lines.join("\n")
}
///|
pub fn StageProfile::to_markdown(self : StageProfile) -> String {
let lines = [
"| Stage | Raffinate | Extract | Driving force | Efficiency | Transfer |", "|---:|---:|---:|---:|---:|---:|",
]
for point in self.points {
lines.push(
"| \{point.stage} | \{point.raffinate} | \{point.extract} | \{point.driving_force} | \{point.efficiency} | \{point.solute_transfer} |",
)
}
lines.join("\n")
}
///|
pub(all) struct ProfileAlert {
stage : Int
code : String
severity : String
measured : Double
threshold : Double
message : String
} derive(Debug, Eq, ToJson)
///|
pub fn review_profile(
profile : StageProfile,
minimum_efficiency~ : Double,
minimum_driving_force~ : Double,
) -> Array[ProfileAlert] raise @domain.ExtractionError {
@domain.require_fraction("minimum efficiency", minimum_efficiency)
@domain.require_non_negative("minimum driving force", minimum_driving_force)
let alerts : Array[ProfileAlert] = []
for point in profile.points {
if point.efficiency < minimum_efficiency {
alerts.push({
stage: point.stage,
code: "EFF-LOW",
severity: "warning",
measured: point.efficiency,
threshold: minimum_efficiency,
message: "stage efficiency is below operating target",
})
}
if point.driving_force < minimum_driving_force {
alerts.push({
stage: point.stage,
code: "FORCE-LOW",
severity: "critical",
measured: point.driving_force,
threshold: minimum_driving_force,
message: "stage has a small equilibrium driving force",
})
}
}
alerts
}
///|
pub fn smooth_efficiency_profile(
efficiencies : ArrayView[Double],
window~ : Int,
) -> Array[Double] raise @domain.ExtractionError {
if efficiencies.length() == 0 {
raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
}
if window < 1 || window > efficiencies.length() {
raise @domain.ExtractionError::InvalidArgument(
message="efficiency smoothing window is invalid",
)
}
for efficiency in efficiencies {
@domain.require_fraction("efficiency", efficiency)
}
[
for index in 0.. {
let start = (index + 1 - window).max(0)
let mut sum = 0.0
for cursor in start..<=index {
sum += efficiencies[cursor]
}
sum / (index - start + 1).to_double()
}
]
}
///|
pub fn required_efficiency_for_target(
theoretical_stages~ : Double,
actual_stage_limit~ : Double,
) -> Double raise @domain.ExtractionError {
@domain.require_positive("theoretical stages", theoretical_stages)
@domain.require_positive("actual stage limit", actual_stage_limit)
(theoretical_stages / actual_stage_limit).min(1.0)
}
///|
pub fn allocate_efficiency_budget(
stage_count~ : Int,
average_efficiency~ : Double,
limiting_stage~ : Int,
limiting_efficiency~ : Double,
) -> Array[Double] raise @domain.ExtractionError {
if stage_count < 1 || limiting_stage < 1 || limiting_stage > stage_count {
raise @domain.ExtractionError::InvalidArgument(
message="efficiency budget stage index is invalid",
)
}
@domain.require_fraction("average efficiency", average_efficiency)
@domain.require_fraction("limiting efficiency", limiting_efficiency)
let remainder = average_efficiency * stage_count.to_double() -
limiting_efficiency
let other = if stage_count == 1 {
0.0
} else {
(remainder / (stage_count - 1).to_double()).clamp(min=0.0, max=1.0)
}
[
for stage in 1..<=stage_count => {
if stage == limiting_stage {
limiting_efficiency
} else {
other
}
}
]
}
///|
pub fn profile_transfer_fraction(
profile : StageProfile,
stage : Int,
) -> Double raise @domain.ExtractionError {
let point = profile.point(stage - 1)
if profile.total_transfer <= 0.0 {
0.0
} else {
point.solute_transfer / profile.total_transfer
}
}