///|
/// Solvent shortlist scoring for practical extraction design decisions.
pub(all) struct SolventOption {
name : String
distribution_coefficient : Double
selectivity : Double
density_difference : Double
viscosity : Double
volatility : Double
cost_index : Double
safety_index : Double
} derive(Debug, Eq, ToJson)
///|
pub fn SolventOption::new(
name~ : String,
distribution_coefficient~ : Double,
selectivity~ : Double,
density_difference~ : Double,
viscosity~ : Double,
volatility~ : Double,
cost_index~ : Double,
safety_index~ : Double,
) -> SolventOption {
{
name,
distribution_coefficient,
selectivity,
density_difference,
viscosity,
volatility,
cost_index,
safety_index,
}
}
///|
pub(all) struct SolventScore {
option : SolventOption
extraction_score : Double
handling_score : Double
sustainability_score : Double
total_score : Double
rank : Int
recommendation : String
} derive(Debug, Eq, ToJson)
///|
pub(all) struct ScreeningWeights {
extraction : Double
handling : Double
sustainability : Double
} derive(Debug, Eq, ToJson)
///|
pub fn SolventOption::validate(self : SolventOption) -> Bool {
self.distribution_coefficient >= 0.0 &&
self.selectivity >= 0.0 &&
self.density_difference >= 0.0 &&
self.viscosity > 0.0 &&
self.volatility >= 0.0 &&
self.cost_index >= 0.0 &&
self.safety_index >= 0.0 &&
self.safety_index <= 1.0
}
///|
pub fn normalized_score(value : Double, reference : Double) -> Double {
if reference <= 0.0 {
0.0
} else {
(value / reference).min(1.0).max(0.0)
}
}
///|
pub fn solvent_extraction_score(option : SolventOption) -> Double {
let distribution = normalized_score(option.distribution_coefficient, 10.0)
let selectivity = normalized_score(option.selectivity, 20.0)
let phase_split = normalized_score(option.density_difference, 500.0)
0.5 * distribution + 0.35 * selectivity + 0.15 * phase_split
}
///|
pub fn solvent_handling_score(option : SolventOption) -> Double {
let viscosity_score = 1.0 / (1.0 + option.viscosity / 10.0)
let volatility_score = 1.0 / (1.0 + option.volatility / 10.0)
let cost_score = 1.0 / (1.0 + option.cost_index)
0.4 * viscosity_score + 0.3 * volatility_score + 0.3 * cost_score
}
///|
pub fn solvent_sustainability_score(option : SolventOption) -> Double {
0.7 * option.safety_index + 0.3 / (1.0 + option.volatility)
}
///|
pub fn score_solvent(
option : SolventOption,
weights : ScreeningWeights,
) -> SolventScore raise @domain.ExtractionError {
if !option.validate() {
raise @domain.ExtractionError::InvalidArgument(
message="solvent option is invalid",
)
}
let total_weight = weights.extraction +
weights.handling +
weights.sustainability
if total_weight <= 0.0 {
raise @domain.ExtractionError::InvalidArgument(
message="screening weights sum to zero",
)
}
let extraction = solvent_extraction_score(option)
let handling = solvent_handling_score(option)
let sustainability = solvent_sustainability_score(option)
let total = (
weights.extraction * extraction +
weights.handling * handling +
weights.sustainability * sustainability
) /
total_weight
{
option,
extraction_score: extraction,
handling_score: handling,
sustainability_score: sustainability,
total_score: total,
rank: 0,
recommendation: if total >= 0.8 {
"preferred"
} else if total >= 0.6 {
"shortlist"
} else {
"review"
},
}
}
///|
pub fn score_solvents(
options : ArrayView[SolventOption],
weights : ScreeningWeights,
) -> Array[SolventScore] raise @domain.ExtractionError {
let scores = []
for option in options {
scores.push(score_solvent(option, weights))
}
for index in 0.. scores[index].total_score {
rank += 1
}
}
let old = scores[index]
scores[index] = {
option: old.option,
extraction_score: old.extraction_score,
handling_score: old.handling_score,
sustainability_score: old.sustainability_score,
total_score: old.total_score,
rank,
recommendation: old.recommendation,
}
}
scores
}
///|
pub fn top_solvent(scores : ArrayView[SolventScore]) -> SolventScore? {
if scores.length() == 0 {
None
} else {
let mut best = scores[0]
for score in scores {
if score.total_score > best.total_score {
best = score
}
}
Some(best)
}
}
///|
pub fn solvent_score_gap(scores : ArrayView[SolventScore]) -> Double {
if scores.length() < 2 {
return 0.0
}
let mut best = scores[0].total_score
let mut second = -1.0
for score in scores {
if score.total_score > best {
second = best
best = score.total_score
} else if score.total_score > second && score.total_score < best {
second = score.total_score
}
}
if second < 0.0 {
0.0
} else {
best - second
}
}
///|
pub fn sensitivity_to_weight(
options : ArrayView[SolventOption],
extraction_weight : Double,
handling_weight : Double,
sustainability_weight : Double,
) -> Array[Double] raise @domain.ExtractionError {
let scores = score_solvents(options, {
extraction: extraction_weight,
handling: handling_weight,
sustainability: sustainability_weight,
})
[
for score in scores => score.total_score
]
}
///|
pub fn solvent_screening_markdown(scores : ArrayView[SolventScore]) -> String {
let lines = [
"| Rank | Solvent | Extraction | Handling | Sustainability | Total | Recommendation |",
"|---:|---|---:|---:|---:|---:|---|",
]
for score in scores {
lines.push(
"| " +
score.rank.to_string() +
" | " +
score.option.name +
" | " +
score.extraction_score.to_string() +
" | " +
score.handling_score.to_string() +
" | " +
score.sustainability_score.to_string() +
" | " +
score.total_score.to_string() +
" | " +
score.recommendation +
" |",
)
}
lines.join("\n")
}
///|
pub fn solvent_screening_csv(scores : ArrayView[SolventScore]) -> String {
let lines = [
"rank,name,extraction_score,handling_score,sustainability_score,total_score,recommendation",
]
for score in scores {
lines.push(
score.rank.to_string() +
"," +
score.option.name +
"," +
score.extraction_score.to_string() +
"," +
score.handling_score.to_string() +
"," +
score.sustainability_score.to_string() +
"," +
score.total_score.to_string() +
"," +
score.recommendation,
)
}
lines.join("\n")
}
///|
pub fn safety_gate(option : SolventOption, minimum_safety : Double) -> Bool {
option.safety_index >= minimum_safety &&
option.volatility <= 10.0 &&
option.viscosity <= 100.0
}
///|
pub fn phase_split_gate(
option : SolventOption,
minimum_density_difference : Double,
) -> Bool {
option.density_difference >= minimum_density_difference &&
option.distribution_coefficient > 0.0
}
///|
pub fn solvent_is_usable(
option : SolventOption,
minimum_safety : Double,
minimum_density_difference : Double,
) -> Bool {
option.validate() &&
safety_gate(option, minimum_safety) &&
phase_split_gate(option, minimum_density_difference)
}
///|
pub fn usable_solvents(
options : ArrayView[SolventOption],
minimum_safety : Double,
minimum_density_difference : Double,
) -> Array[SolventOption] {
let result = []
for option in options {
if solvent_is_usable(option, minimum_safety, minimum_density_difference) {
result.push(option)
}
}
result
}
///|
pub fn screening_weights_equal() -> ScreeningWeights {
{ extraction: 1.0, handling: 1.0, sustainability: 1.0 }
}