///|
/// Mass-balance closure and utility accounting for process streams.
pub(all) struct StreamBalance {
stream : String
carrier_in : Double
carrier_out : Double
solvent_in : Double
solvent_out : Double
solute_in : Double
solute_out : Double
carrier_error : Double
solvent_error : Double
solute_error : Double
closed : Bool
} derive(Debug, Eq, ToJson)
///|
pub(all) struct BalanceLedger {
entries : Array[StreamBalance]
total_carrier_error : Double
total_solvent_error : Double
total_solute_error : Double
maximum_relative_error : Double
closed : Bool
} derive(Debug, Eq, ToJson)
///|
pub(all) struct ComponentLedger {
component : String
feed : Double
product : Double
waste : Double
recovered : Double
recovery : Double
closure_error : Double
} derive(Debug, Eq, ToJson)
///|
pub fn stream_balance(
name : String,
inlet : ProcessStream,
outlet : ProcessStream,
tolerance : Double,
) -> StreamBalance {
let carrier_in = inlet.carrier_flow
let carrier_out = outlet.carrier_flow
let solvent_in = inlet.solvent_flow
let solvent_out = outlet.solvent_flow
let solute_in = inlet.total_solute()
let solute_out = outlet.total_solute()
let carrier_error = carrier_out - carrier_in
let solvent_error = solvent_out - solvent_in
let solute_error = solute_out - solute_in
{
stream: name,
carrier_in,
carrier_out,
solvent_in,
solvent_out,
solute_in,
solute_out,
carrier_error,
solvent_error,
solute_error,
closed: carrier_error.abs() <= tolerance &&
solvent_error.abs() <= tolerance &&
solute_error.abs() <= tolerance,
}
}
///|
pub fn balance_ledger(
entries : ArrayView[StreamBalance],
tolerance : Double,
) -> BalanceLedger {
let mut carrier = 0.0
let mut solvent = 0.0
let mut solute = 0.0
let mut maximum = 0.0
let mut closed = true
for entry in entries {
carrier += entry.carrier_error
solvent += entry.solvent_error
solute += entry.solute_error
let relative = entry.solute_error.abs() / entry.solute_in.max(1.0e-12)
maximum = maximum.max(relative)
if !entry.closed {
closed = false
}
}
{
entries: entries.to_owned(),
total_carrier_error: carrier,
total_solvent_error: solvent,
total_solute_error: solute,
maximum_relative_error: maximum,
closed: closed &&
carrier.abs() <= tolerance &&
solvent.abs() <= tolerance &&
solute.abs() <= tolerance,
}
}
///|
pub fn component_ledger(
component : String,
feed : Double,
product : Double,
waste : Double,
tolerance : Double,
) -> ComponentLedger raise @domain.ExtractionError {
if feed < 0.0 || product < 0.0 || waste < 0.0 {
raise @domain.ExtractionError::InvalidArgument(
message="component ledger values cannot be negative",
)
}
let recovered = product + waste
let closure = recovered - feed
{
component,
feed,
product,
waste,
recovered,
recovery: if feed == 0.0 {
0.0
} else {
product / feed
},
closure_error: closure.abs().max(tolerance),
}
}
///|
pub fn ledgers_reconciled(
ledgers : ArrayView[ComponentLedger],
tolerance : Double,
) -> Bool {
for ledger in ledgers {
if ledger.closure_error > tolerance {
return false
}
}
true
}
///|
pub fn total_feed(ledgers : ArrayView[ComponentLedger]) -> Double {
ledgers.fold(init=0.0, (sum, ledger) => sum + ledger.feed)
}
///|
pub fn total_product(ledgers : ArrayView[ComponentLedger]) -> Double {
ledgers.fold(init=0.0, (sum, ledger) => sum + ledger.product)
}
///|
pub fn overall_recovery(ledgers : ArrayView[ComponentLedger]) -> Double {
let feed = total_feed(ledgers)
if feed == 0.0 {
0.0
} else {
total_product(ledgers) / feed
}
}
///|
pub fn split_stream(
stream : ProcessStream,
fraction : Double,
first_name : String,
second_name : String,
) -> (ProcessStream, ProcessStream) raise @domain.ExtractionError {
if fraction < 0.0 || fraction > 1.0 {
raise @domain.ExtractionError::InvalidFraction(
name="split fraction",
value=fraction,
)
}
let first = ProcessStream::new(
name=first_name,
carrier_flow=stream.carrier_flow * fraction,
solvent_flow=stream.solvent_flow * fraction,
carrier_concentration=stream.carrier_concentration,
solvent_concentration=stream.solvent_concentration,
temperature=stream.temperature,
pressure=stream.pressure,
)
let second = ProcessStream::new(
name=second_name,
carrier_flow=stream.carrier_flow * (1.0 - fraction),
solvent_flow=stream.solvent_flow * (1.0 - fraction),
carrier_concentration=stream.carrier_concentration,
solvent_concentration=stream.solvent_concentration,
temperature=stream.temperature,
pressure=stream.pressure,
)
(first, second)
}
///|
pub fn merge_streams(
first : ProcessStream,
second : ProcessStream,
name : String,
) -> ProcessStream raise @domain.ExtractionError {
let carrier = first.carrier_flow + second.carrier_flow
let solvent = first.solvent_flow + second.solvent_flow
let carrier_solute = first.carrier_solute() + second.carrier_solute()
let solvent_solute = first.solvent_solute() + second.solvent_solute()
ProcessStream::new(
name~,
carrier_flow=carrier,
solvent_flow=solvent,
carrier_concentration=if carrier == 0.0 {
0.0
} else {
carrier_solute / carrier
},
solvent_concentration=if solvent == 0.0 {
0.0
} else {
solvent_solute / solvent
},
temperature=first.temperature,
pressure=first.pressure,
)
}
///|
pub fn concentration_after_recovery(
feed : Double,
recovery : Double,
) -> Double raise @domain.ExtractionError {
if feed < 0.0 || recovery < 0.0 || recovery > 1.0 {
raise @domain.ExtractionError::InvalidArgument(
message="recovery calculation inputs are invalid",
)
}
feed * recovery
}
///|
pub fn cascade_recovery(
stage_recoveries : ArrayView[Double],
) -> Double raise @domain.ExtractionError {
let mut remaining = 1.0
for recovery in stage_recoveries {
if recovery < 0.0 || recovery > 1.0 {
raise @domain.ExtractionError::InvalidFraction(
name="stage recovery",
value=recovery,
)
}
remaining *= 1.0 - recovery
}
1.0 - remaining
}
///|
pub fn required_recovery_per_stage(
target_recovery : Double,
stage_count : Int,
) -> Double raise @domain.ExtractionError {
if target_recovery < 0.0 || target_recovery > 1.0 || stage_count < 1 {
raise @domain.ExtractionError::InvalidArgument(
message="stage recovery target is invalid",
)
}
let mut remaining = 1.0 - target_recovery
for _ in 0.. String {
let lines = [
"| Stream | Carrier error | Solvent error | Solute error | Closed |", "|---|---:|---:|---:|---|",
]
for entry in ledger.entries {
lines.push(
"| " +
entry.stream +
" | " +
entry.carrier_error.to_string() +
" | " +
entry.solvent_error.to_string() +
" | " +
entry.solute_error.to_string() +
" | " +
entry.closed.to_string() +
" |",
)
}
lines.join("\n")
}
///|
pub fn component_ledger_csv(ledgers : ArrayView[ComponentLedger]) -> String {
let lines = ["component,feed,product,waste,recovered,recovery,closure_error"]
for ledger in ledgers {
lines.push(
ledger.component +
"," +
ledger.feed.to_string() +
"," +
ledger.product.to_string() +
"," +
ledger.waste.to_string() +
"," +
ledger.recovered.to_string() +
"," +
ledger.recovery.to_string() +
"," +
ledger.closure_error.to_string(),
)
}
lines.join("\n")
}
///|
pub fn balance_error_vector(ledger : BalanceLedger) -> Array[Double] {
[
ledger.total_carrier_error,
ledger.total_solvent_error,
ledger.total_solute_error,
ledger.maximum_relative_error,
]
}
///|
pub fn stream_concentration_ratio(stream : ProcessStream) -> Double {
if stream.carrier_concentration == 0.0 {
0.0
} else {
stream.solvent_concentration / stream.carrier_concentration
}
}
///|
pub fn stream_solute_fraction(stream : ProcessStream) -> Double {
let total = stream.total_solute()
if total == 0.0 {
0.0
} else {
stream.carrier_solute() / total
}
}
///|
pub fn stream_quality_label(stream : ProcessStream) -> String {
let ratio = stream_concentration_ratio(stream)
if ratio <= 0.01 {
"lean"
} else if ratio <= 0.2 {
"normal"
} else {
"rich"
}
}