///|
/// A named process study input for matrix evaluation.
pub(all) struct StudyCase {
name : String
feed_flow : Double
feed_concentration : Double
solvent_flow : Double
distribution : Double
target_recovery : Double
} derive(Debug, Eq, ToJson)
///|
pub fn StudyCase::new(
name~ : String,
feed_flow~ : Double,
feed_concentration~ : Double,
solvent_flow~ : Double,
distribution~ : Double,
target_recovery? : Double = 0.0,
) -> StudyCase raise @domain.ExtractionError {
if name.trim().length() == 0 {
raise @domain.ExtractionError::InvalidArgument(
message="study case name cannot be blank",
)
}
@domain.require_positive("study feed flow", feed_flow)
@domain.require_non_negative("study feed concentration", feed_concentration)
@domain.require_positive("study solvent flow", solvent_flow)
@domain.require_non_negative("study distribution", distribution)
@domain.require_fraction("study target recovery", target_recovery)
{
name,
feed_flow,
feed_concentration,
solvent_flow,
distribution,
target_recovery,
}
}
///|
pub fn StudyCase::run(
self : StudyCase,
) -> StudyCaseResult raise @domain.ExtractionError {
let result = @liquid.single_stage_constant_k(
feed_flow=self.feed_flow,
feed_concentration=self.feed_concentration,
solvent_flow=self.solvent_flow,
distribution=self.distribution,
)
{
case_name: self.name,
result,
passed: result.recovery >= self.target_recovery,
recovery_margin: result.recovery - self.target_recovery,
solvent_to_feed: self.solvent_flow / self.feed_flow,
}
}
///|
pub(all) struct StudyCaseResult {
case_name : String
result : @liquid.SingleStageResult
passed : Bool
recovery_margin : Double
solvent_to_feed : Double
} derive(Debug, Eq, ToJson)
///|
pub(all) struct StudyMatrix {
cases : Array[StudyCase]
results : Array[StudyCaseResult]
passed_count : Int
failed_count : Int
best_recovery : StudyCaseResult
worst_recovery : StudyCaseResult
} derive(Debug, Eq, ToJson)
///|
pub fn run_study_matrix(
cases : ArrayView[StudyCase],
) -> StudyMatrix raise @domain.ExtractionError {
if cases.length() == 0 {
raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
}
let results = [ for item in cases => item.run() ]
let mut best = results[0]
let mut worst = results[0]
let mut passed = 0
for result in results {
if result.passed {
passed += 1
}
if result.result.recovery > best.result.recovery {
best = result
}
if result.result.recovery < worst.result.recovery {
worst = result
}
}
{
cases: cases.to_owned(),
results,
passed_count: passed,
failed_count: results.length() - passed,
best_recovery: best,
worst_recovery: worst,
}
}
///|
pub fn StudyMatrix::result(
self : StudyMatrix,
index : Int,
) -> StudyCaseResult raise @domain.ExtractionError {
match self.results.get(index) {
Some(value) => value
None =>
raise @domain.ExtractionError::InvalidArgument(
message="study result index out of range",
)
}
}
///|
pub fn StudyMatrix::recoveries(self : StudyMatrix) -> Array[Double] {
[
for result in self.results => result.result.recovery
]
}
///|
pub fn StudyMatrix::mean_recovery(self : StudyMatrix) -> Double {
self.results.fold(init=0.0, (sum, item) => sum + item.result.recovery) /
self.results.length().to_double()
}
///|
pub fn StudyMatrix::mean_solvent_ratio(self : StudyMatrix) -> Double {
self.results.fold(init=0.0, (sum, item) => sum + item.solvent_to_feed) /
self.results.length().to_double()
}
///|
pub fn StudyMatrix::to_csv(self : StudyMatrix) -> String {
let lines = [
"case,recovery,raffinate,extract,balance_residual,solvent_to_feed,passed",
]
for item in self.results {
lines.push(
"\{item.case_name},\{item.result.recovery},\{item.result.raffinate_concentration},\{item.result.extract_concentration},\{item.result.balance_residual},\{item.solvent_to_feed},\{item.passed}",
)
}
lines.join("\n")
}
///|
pub fn StudyMatrix::to_markdown(self : StudyMatrix) -> String {
let lines = [
"| Case | Recovery | Raffinate | Balance residual | Status |", "|---|---:|---:|---:|---|",
]
for item in self.results {
let status = if item.passed { "PASS" } else { "FAIL" }
lines.push(
"| \{item.case_name} | \{item.result.recovery} | \{item.result.raffinate_concentration} | \{item.result.balance_residual} | \{status} |",
)
}
lines.join("\n")
}
///|
pub fn sensitivity_matrix(
base : StudyCase,
feed_multipliers : ArrayView[Double],
solvent_multipliers : ArrayView[Double],
) -> Array[StudyCaseResult] raise @domain.ExtractionError {
if feed_multipliers.length() == 0 || solvent_multipliers.length() == 0 {
raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
}
let cases : Array[StudyCase] = []
for feed_multiplier in feed_multipliers {
for solvent_multiplier in solvent_multipliers {
cases.push(
StudyCase::new(
name="feed={feed_multiplier},solvent={solvent_multiplier}",
feed_flow=base.feed_flow * feed_multiplier,
feed_concentration=base.feed_concentration,
solvent_flow=base.solvent_flow * solvent_multiplier,
distribution=base.distribution,
target_recovery=base.target_recovery,
),
)
}
}
[
for item in cases => item.run()
]
}
///|
pub fn recovery_elasticity(
base : StudyCase,
relative_step? : Double = 0.01,
) -> (Double, Double) raise @domain.ExtractionError {
@domain.require_positive("study relative step", relative_step)
let lower = StudyCase::{
name: base.name,
feed_flow: base.feed_flow * (1.0 - relative_step),
feed_concentration: base.feed_concentration,
solvent_flow: base.solvent_flow,
distribution: base.distribution,
target_recovery: base.target_recovery,
}.run()
let upper = StudyCase::{
name: base.name,
feed_flow: base.feed_flow * (1.0 + relative_step),
feed_concentration: base.feed_concentration,
solvent_flow: base.solvent_flow,
distribution: base.distribution,
target_recovery: base.target_recovery,
}.run()
let slope = (upper.result.recovery - lower.result.recovery) /
(2.0 * relative_step * base.feed_flow)
let nominal = base.run().result.recovery
let normalized = if nominal.abs() <= 1.0e-12 {
slope
} else {
slope * base.feed_flow / nominal
}
(slope, normalized)
}