///|
/// Circuit-level metrics for leaching and washing campaigns.
pub(all) struct CircuitStage {
stage : Int
feed_solute : Double
wash_solvent : Double
extracted_solute : Double
retained_solute : Double
local_recovery : Double
cumulative_recovery : Double
} derive(Debug, Eq, ToJson)
///|
pub(all) struct CircuitMetrics {
stages : Array[CircuitStage]
feed_solute : Double
recovered_solute : Double
residual_solute : Double
recovery : Double
solvent_to_solute : Double
diminishing_returns : Bool
} derive(Debug, Eq, ToJson)
///|
pub fn build_circuit_metrics(
feed_solute : Double,
wash_solvent : ArrayView[Double],
stage_recoveries : ArrayView[Double],
) -> CircuitMetrics raise @domain.ExtractionError {
if feed_solute < 0.0 ||
wash_solvent.length() != stage_recoveries.length() ||
wash_solvent.length() == 0 {
raise @domain.ExtractionError::InvalidArgument(
message="circuit arrays are invalid",
)
}
let stages : Array[CircuitStage] = []
let mut remaining = feed_solute
let mut recovered = 0.0
let mut solvent_total = 0.0
let mut previous_transfer = 1.0e30
let mut diminishing = true
for index in 0.. 1.0 || wash_solvent[index] < 0.0 {
raise @domain.ExtractionError::InvalidArgument(
message="stage recovery or wash solvent is invalid",
)
}
let extracted = remaining * recovery
remaining -= extracted
recovered += extracted
solvent_total += wash_solvent[index]
if extracted > previous_transfer {
diminishing = false
}
previous_transfer = extracted
stages.push({
stage: index + 1,
feed_solute: if index == 0 {
feed_solute
} else {
stages[index - 1].retained_solute
},
wash_solvent: wash_solvent[index],
extracted_solute: extracted,
retained_solute: remaining,
local_recovery: recovery,
cumulative_recovery: if feed_solute == 0.0 {
0.0
} else {
recovered / feed_solute
},
})
}
{
stages,
feed_solute,
recovered_solute: recovered,
residual_solute: remaining,
recovery: if feed_solute == 0.0 {
0.0
} else {
recovered / feed_solute
},
solvent_to_solute: if feed_solute == 0.0 {
0.0
} else {
solvent_total / feed_solute
},
diminishing_returns: diminishing,
}
}
///|
pub fn stage_count(metrics : CircuitMetrics) -> Int {
metrics.stages.length()
}
///|
pub fn circuit_stage(
metrics : CircuitMetrics,
stage : Int,
) -> CircuitStage raise @domain.ExtractionError {
if stage < 1 || stage > metrics.stages.length() {
raise @domain.ExtractionError::OutOfRange(
name="stage",
value=stage.to_double(),
lower=1.0,
upper=metrics.stages.length().to_double(),
)
}
metrics.stages[stage - 1]
}
///|
pub fn circuit_marginal_recoveries(metrics : CircuitMetrics) -> Array[Double] {
[
for stage in metrics.stages => stage.extracted_solute
]
}
///|
pub fn circuit_solvent_efficiency(metrics : CircuitMetrics) -> Double {
if metrics.solvent_to_solute == 0.0 {
metrics.recovery
} else {
metrics.recovery / metrics.solvent_to_solute
}
}
///|
pub fn circuit_residual_fraction(metrics : CircuitMetrics) -> Double {
if metrics.feed_solute == 0.0 {
0.0
} else {
metrics.residual_solute / metrics.feed_solute
}
}
///|
pub fn circuit_meets_target(metrics : CircuitMetrics, target : Double) -> Bool {
metrics.recovery >= target
}
///|
pub fn minimum_stages_for_target(
feed_solute : Double,
wash_solvent : Double,
per_stage_recovery : Double,
target : Double,
maximum_stages : Int,
) -> Int raise @domain.ExtractionError {
if feed_solute < 0.0 ||
wash_solvent < 0.0 ||
per_stage_recovery <= 0.0 ||
per_stage_recovery > 1.0 ||
target < 0.0 ||
target > 1.0 ||
maximum_stages < 1 {
raise @domain.ExtractionError::InvalidArgument(
message="stage target inputs are invalid",
)
}
let mut remaining = 1.0
let mut stages = 0
while stages < maximum_stages && 1.0 - remaining < target {
stages += 1
remaining *= 1.0 - per_stage_recovery
}
if 1.0 - remaining < target {
raise @domain.ExtractionError::ConvergenceFailure(
iterations=maximum_stages,
residual=target - (1.0 - remaining),
)
}
stages
}
///|
pub fn optimize_wash_allocation(
feed_solute : Double,
total_wash : Double,
stage_count : Int,
base_recovery : Double,
) -> Array[Double] raise @domain.ExtractionError {
if total_wash < 0.0 ||
stage_count < 1 ||
base_recovery <= 0.0 ||
base_recovery > 1.0 {
raise @domain.ExtractionError::InvalidArgument(
message="wash allocation inputs are invalid",
)
}
let allocations = Array::make(
stage_count,
total_wash / stage_count.to_double(),
)
let mut remaining = feed_solute
for index in 0.. Array[String] {
let alerts = []
for stage in metrics.stages {
if stage.local_recovery < minimum_stage_recovery {
alerts.push("stage " + stage.stage.to_string() + " below recovery target")
}
}
if metrics.residual_solute > metrics.feed_solute * 0.1 {
alerts.push("residual solute exceeds ten percent of feed")
}
if !metrics.diminishing_returns {
alerts.push("marginal recovery is not decreasing")
}
alerts
}
///|
pub fn circuit_markdown(metrics : CircuitMetrics) -> String {
let lines = [
"| Stage | Feed solute | Wash solvent | Extracted | Retained | Local recovery | Cumulative recovery |",
"|---:|---:|---:|---:|---:|---:|---:|",
]
for stage in metrics.stages {
lines.push(
"| " +
stage.stage.to_string() +
" | " +
stage.feed_solute.to_string() +
" | " +
stage.wash_solvent.to_string() +
" | " +
stage.extracted_solute.to_string() +
" | " +
stage.retained_solute.to_string() +
" | " +
stage.local_recovery.to_string() +
" | " +
stage.cumulative_recovery.to_string() +
" |",
)
}
lines.join("\n")
}
///|
pub fn circuit_csv(metrics : CircuitMetrics) -> String {
let lines = [
"stage,feed_solute,wash_solvent,extracted_solute,retained_solute,local_recovery,cumulative_recovery",
]
for stage in metrics.stages {
lines.push(
stage.stage.to_string() +
"," +
stage.feed_solute.to_string() +
"," +
stage.wash_solvent.to_string() +
"," +
stage.extracted_solute.to_string() +
"," +
stage.retained_solute.to_string() +
"," +
stage.local_recovery.to_string() +
"," +
stage.cumulative_recovery.to_string(),
)
}
lines.join("\n")
}