///|
/// A stream state used by sequential flowsheet calculations.
pub(all) struct ProcessStream {
name : String
carrier_flow : Double
solvent_flow : Double
carrier_concentration : Double
solvent_concentration : Double
temperature : @units.Temperature
pressure : @units.Pressure
} derive(Debug, Eq, ToJson)
///|
pub fn ProcessStream::new(
name~ : String,
carrier_flow~ : Double,
solvent_flow~ : Double,
carrier_concentration~ : Double,
solvent_concentration? : Double = 0.0,
temperature~ : @units.Temperature,
pressure~ : @units.Pressure,
) -> ProcessStream raise @domain.ExtractionError {
if name.trim().length() == 0 {
raise @domain.ExtractionError::InvalidArgument(
message="process stream name cannot be blank",
)
}
@domain.require_positive("carrier flow", carrier_flow)
@domain.require_non_negative("solvent flow", solvent_flow)
@domain.require_non_negative("carrier concentration", carrier_concentration)
@domain.require_non_negative("solvent concentration", solvent_concentration)
{
name,
carrier_flow,
solvent_flow,
carrier_concentration,
solvent_concentration,
temperature,
pressure,
}
}
///|
pub fn ProcessStream::name(self : ProcessStream) -> String {
self.name
}
///|
pub fn ProcessStream::total_flow(self : ProcessStream) -> Double {
self.carrier_flow + self.solvent_flow
}
///|
pub fn ProcessStream::carrier_solute(self : ProcessStream) -> Double {
self.carrier_flow * self.carrier_concentration
}
///|
pub fn ProcessStream::solvent_solute(self : ProcessStream) -> Double {
self.solvent_flow * self.solvent_concentration
}
///|
pub fn ProcessStream::total_solute(self : ProcessStream) -> Double {
self.carrier_solute() + self.solvent_solute()
}
///|
pub fn ProcessStream::overall_concentration(self : ProcessStream) -> Double {
if self.total_flow() <= 0.0 {
0.0
} else {
self.total_solute() / self.total_flow()
}
}
///|
pub fn ProcessStream::with_name(
self : ProcessStream,
name : String,
) -> ProcessStream {
{
name,
carrier_flow: self.carrier_flow,
solvent_flow: self.solvent_flow,
carrier_concentration: self.carrier_concentration,
solvent_concentration: self.solvent_concentration,
temperature: self.temperature,
pressure: self.pressure,
}
}
///|
pub fn ProcessStream::with_temperature(
self : ProcessStream,
temperature : @units.Temperature,
) -> ProcessStream {
{
name: self.name,
carrier_flow: self.carrier_flow,
solvent_flow: self.solvent_flow,
carrier_concentration: self.carrier_concentration,
solvent_concentration: self.solvent_concentration,
temperature,
pressure: self.pressure,
}
}
///|
pub fn ProcessStream::with_pressure(
self : ProcessStream,
pressure : @units.Pressure,
) -> ProcessStream {
{
name: self.name,
carrier_flow: self.carrier_flow,
solvent_flow: self.solvent_flow,
carrier_concentration: self.carrier_concentration,
solvent_concentration: self.solvent_concentration,
temperature: self.temperature,
pressure,
}
}
///|
pub fn ProcessStream::scale(
self : ProcessStream,
factor : Double,
) -> ProcessStream raise @domain.ExtractionError {
@domain.require_non_negative("stream scale", factor)
{
name: self.name,
carrier_flow: self.carrier_flow * factor,
solvent_flow: self.solvent_flow * factor,
carrier_concentration: self.carrier_concentration,
solvent_concentration: self.solvent_concentration,
temperature: self.temperature,
pressure: self.pressure,
}
}
///|
/// Mix two streams on a common mass-flow basis.
pub fn mix_streams(
first : ProcessStream,
second : ProcessStream,
name~ : String,
) -> ProcessStream raise @domain.ExtractionError {
let carrier_flow = first.carrier_flow + second.carrier_flow
let solvent_flow = first.solvent_flow + second.solvent_flow
let carrier_concentration = if carrier_flow <= 0.0 {
0.0
} else {
(first.carrier_solute() + second.carrier_solute()) / carrier_flow
}
let solvent_concentration = if solvent_flow <= 0.0 {
0.0
} else {
(first.solvent_solute() + second.solvent_solute()) / solvent_flow
}
let total = first.total_flow() + second.total_flow()
let temperature = if total <= 0.0 {
first.temperature
} else {
@units.Temperature::new(
value=(
first.temperature.kelvin() * first.total_flow() +
second.temperature.kelvin() * second.total_flow()
) /
total,
unit=@units.TemperatureUnit::Kelvin,
)
}
let pressure = if first.pressure.pascal() <= second.pressure.pascal() {
first.pressure
} else {
second.pressure
}
ProcessStream::new(
name~,
carrier_flow~,
solvent_flow~,
carrier_concentration~,
solvent_concentration~,
temperature~,
pressure~,
)
}
///|
pub(all) enum OperationKind {
Contact(distribution~ : Double)
Heat(outlet_temperature_kelvin~ : Double)
Cool(outlet_temperature_kelvin~ : Double)
PressureDrop(outlet_pressure_pascal~ : Double)
Split(fraction~ : Double)
HoldUp(residence_seconds~ : Double)
} derive(Debug, Eq, ToJson)
///|
pub(all) struct ProcessStep {
name : String
kind : OperationKind
} derive(Debug, Eq, ToJson)
///|
pub fn ProcessStep::new(
name~ : String,
kind~ : OperationKind,
) -> ProcessStep raise @domain.ExtractionError {
if name.trim().length() == 0 {
raise @domain.ExtractionError::InvalidArgument(
message="process step name cannot be blank",
)
}
match kind {
Contact(distribution~) =>
@domain.require_non_negative("step distribution", distribution)
Heat(outlet_temperature_kelvin~) =>
@domain.require_positive("outlet temperature", outlet_temperature_kelvin)
Cool(outlet_temperature_kelvin~) =>
@domain.require_positive("outlet temperature", outlet_temperature_kelvin)
PressureDrop(outlet_pressure_pascal~) =>
@domain.require_positive("outlet pressure", outlet_pressure_pascal)
Split(fraction~) => {
@domain.require_fraction("split fraction", fraction)
if fraction <= 0.0 || fraction >= 1.0 {
raise @domain.ExtractionError::InvalidArgument(
message="split fraction must be strictly between zero and one",
)
}
}
HoldUp(residence_seconds~) =>
@domain.require_positive("residence time", residence_seconds)
}
{ name, kind }
}
///|
pub(all) struct StepResult {
step_name : String
inlet : ProcessStream
outlet : ProcessStream
residual : Double
duty : Double
warning : String
} derive(Debug, Eq, ToJson)
///|
fn contact_step(
inlet : ProcessStream,
name : String,
distribution : Double,
) -> ProcessStream raise @domain.ExtractionError {
let result = @liquid.single_stage_constant_k(
feed_flow=inlet.carrier_flow,
feed_concentration=inlet.carrier_concentration,
solvent_flow=inlet.solvent_flow.max(1.0e-12),
solvent_concentration=inlet.solvent_concentration,
distribution~,
)
ProcessStream::new(
name~,
carrier_flow=inlet.carrier_flow,
solvent_flow=inlet.solvent_flow.max(1.0e-12),
carrier_concentration=result.raffinate_concentration,
solvent_concentration=result.extract_concentration,
temperature=inlet.temperature,
pressure=inlet.pressure,
)
}
///|
pub fn apply_step(
inlet : ProcessStream,
step : ProcessStep,
) -> StepResult raise @domain.ExtractionError {
let outlet = match step.kind {
Contact(distribution~) => contact_step(inlet, step.name, distribution)
Heat(outlet_temperature_kelvin~) => {
let temperature = @units.Temperature::new(
value=outlet_temperature_kelvin,
unit=@units.TemperatureUnit::Kelvin,
)
inlet.with_name(step.name).with_temperature(temperature)
}
Cool(outlet_temperature_kelvin~) => {
let temperature = @units.Temperature::new(
value=outlet_temperature_kelvin,
unit=@units.TemperatureUnit::Kelvin,
)
inlet.with_name(step.name).with_temperature(temperature)
}
PressureDrop(outlet_pressure_pascal~) => {
let pressure = @units.Pressure::new(
value=outlet_pressure_pascal,
unit=@units.PressureUnit::Pascal,
)
inlet.with_name(step.name).with_pressure(pressure)
}
Split(fraction~) => inlet.scale(fraction).with_name(step.name)
HoldUp(_) => inlet.with_name(step.name)
}
let duty = match step.kind {
Heat(_) => outlet.temperature.kelvin() - inlet.temperature.kelvin()
Cool(_) => outlet.temperature.kelvin() - inlet.temperature.kelvin()
_ => 0.0
}
let residual = inlet.total_solute() - outlet.total_solute()
let warning = match step.kind {
Split(_) => "split outlet represents one branch"
HoldUp(_) => "residence model does not change composition"
_ => ""
}
{ step_name: step.name, inlet, outlet, residual, duty, warning }
}
///|
pub(all) struct Flowsheet {
name : String
steps : Array[ProcessStep]
} derive(Debug, Eq, ToJson)
///|
pub fn Flowsheet::new(
name~ : String,
) -> Flowsheet raise @domain.ExtractionError {
if name.trim().length() == 0 {
raise @domain.ExtractionError::InvalidArgument(
message="flowsheet name cannot be blank",
)
}
{ name, steps: [] }
}
///|
pub fn Flowsheet::add(self : Flowsheet, step : ProcessStep) -> Flowsheet {
let steps = self.steps.copy()
steps.push(step)
{ name: self.name, steps }
}
///|
pub fn Flowsheet::step_count(self : Flowsheet) -> Int {
self.steps.length()
}
///|
pub fn Flowsheet::steps(self : Flowsheet) -> Array[ProcessStep] {
self.steps.copy()
}
///|
pub(all) struct FlowsheetResult {
name : String
inlet : ProcessStream
outlet : ProcessStream
steps : Array[StepResult]
total_residual : Double
maximum_step_residual : Double
passed : Bool
} derive(Debug, Eq, ToJson)
///|
pub fn simulate(
flowsheet : Flowsheet,
inlet : ProcessStream,
tolerance? : Double = 1.0e-8,
) -> FlowsheetResult raise @domain.ExtractionError {
@domain.require_positive("flowsheet tolerance", tolerance)
let results : Array[StepResult] = []
let mut current = inlet
let mut maximum = 0.0
for step in flowsheet.steps {
let result = apply_step(current, step)
results.push(result)
current = result.outlet
maximum = maximum.max(result.residual.abs())
}
let total_residual = inlet.total_solute() - current.total_solute()
{
name: flowsheet.name,
inlet,
outlet: current,
steps: results,
total_residual,
maximum_step_residual: maximum,
passed: maximum <= tolerance,
}
}
///|
/// A recycle convergence result for a single recycle fraction.
pub(all) struct RecycleResult {
converged_stream : ProcessStream
iterations : Int
residual : Double
converged : Bool
} derive(Debug, Eq, ToJson)
///|
pub fn simulate_recycle(
flowsheet : Flowsheet,
inlet : ProcessStream,
recycle_fraction~ : Double,
tolerance? : Double = 1.0e-8,
maximum_iterations? : Int = 100,
) -> RecycleResult raise @domain.ExtractionError {
@domain.require_fraction("recycle fraction", recycle_fraction)
@domain.require_positive("recycle tolerance", tolerance)
if maximum_iterations < 1 {
raise @domain.ExtractionError::InvalidArgument(
message="recycle iteration limit must be positive",
)
}
let mut guess = inlet
let mut residual = 0.0
let mut iterations = 0
for index in 0.. Array[LedgerEntry] raise @domain.ExtractionError {
[
for step in result.steps => {
ledger_entry(
name=step.step_name,
inlet=step.inlet.total_solute(),
outlet=step.outlet.total_solute(),
)
}
]
}
///|
pub fn outlet_recovery(result : FlowsheetResult) -> Double {
let inlet = result.inlet.total_solute()
if inlet <= 0.0 {
0.0
} else {
result.outlet.total_solute() / inlet
}
}
///|
pub fn total_temperature_change(result : FlowsheetResult) -> Double {
result.outlet.temperature.kelvin() - result.inlet.temperature.kelvin()
}
///|
pub fn total_pressure_drop(result : FlowsheetResult) -> Double {
(result.inlet.pressure.pascal() - result.outlet.pressure.pascal()).max(0.0)
}
///|
/// Estimate a heat duty from a flowsheet temperature change.
pub fn estimate_heat_duty(
result : FlowsheetResult,
heat_capacity_kj_per_kg_k~ : Double,
) -> @units.Energy raise @domain.ExtractionError {
@domain.require_non_negative("heat capacity", heat_capacity_kj_per_kg_k)
let flow = result.inlet.total_flow()
let delta = total_temperature_change(result)
@units.Energy::new(
value=flow * heat_capacity_kj_per_kg_k * delta,
unit=@units.EnergyUnit::KiloJoule,
)
}