updated version with forward, reverse, and empirical mode

This commit is contained in:
David Stein 2026-07-22 12:36:07 -04:00
parent 9c8dc6053e
commit 301a38da80
20 changed files with 1140 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"python-envs.defaultEnvManager": "ms-python.python:system"
}

View file

@ -0,0 +1,10 @@
solvent_properties:
name: WF2 + Dodecanedioic
water_solubility:
t_low_mass_fraction: 0.4299
t_high_mass_fraction: 0.0
ion_partition_coefficients:
t_low:
default: 0.63511
t_high:
default: 0.579491

10
empirical_wf_hcl_run.yaml Normal file
View file

@ -0,0 +1,10 @@
solvent_properties:
name: WF2 + HCl
water_solubility:
t_low_mass_fraction: 0.04
t_high_mass_fraction: 0.0
ion_partition_coefficients:
t_low:
default: 0.0
t_high:
default: 0.0

View file

@ -0,0 +1,10 @@
solvent_properties:
name: WF2 + Hexanoic
water_solubility:
t_low_mass_fraction: 0.5063
t_high_mass_fraction: 0.138
ion_partition_coefficients:
t_low:
default: 0.854573
t_high:
default: 0.682616

View file

@ -0,0 +1,10 @@
solvent_properties:
name: WF2 + Octanoic
water_solubility:
t_low_mass_fraction: 0.4556
t_high_mass_fraction: 0.264
ion_partition_coefficients:
t_low:
default: 0.640262
t_high:
default: 0.423737

View file

@ -0,0 +1,10 @@
solvent_properties:
name: WF2 + Suberic
water_solubility:
t_low_mass_fraction: 0.1884
t_high_mass_fraction: 0.0
ion_partition_coefficients:
t_low:
default: 0.158771
t_high:
default: -0.138553

View file

@ -0,0 +1,10 @@
solvent_properties:
name: WF2 + Succinic
water_solubility:
t_low_mass_fraction: 0.4446
t_high_mass_fraction: 0.1484
ion_partition_coefficients:
t_low:
default: 1.499923
t_high:
default: 0.77172

109
experimental_runs.yaml Normal file
View file

@ -0,0 +1,109 @@
# experimental_runs.yaml
# Feed TDS for all runs is approximately 238 mg/kg
experiments:
hcl_run:
acid_used: "HCl"
inputs:
wf_density_kg_L: 0.80
feed_water_g: 18.0030
feed_tds_mg_kg: 238.0
wf2_base_g: 8.1189
acid_g: 0.1675
measured_outputs:
hydrated_wf_g: 8.6320
reject_water_g: null # Not measured/reported
yield_water_g: 0.4645
dried_wf_g: 8.1675
reject_tds_mg_kg: null # Not measured/reported
yield_tds_mg_kg: null # Not measured/reported
hexanoic_run:
acid_used: "Hexanoic"
inputs:
feed_water_g: 18.0531
feed_tds_mg_kg: 238.0
wf2_base_g: 8.5090
acid_g: 0.2473
measured_outputs:
hydrated_wf_g: 17.7369
reject_water_g: 10.4738
yield_water_g: 7.5793
dried_wf_g: 10.1576
reject_tds_mg_kg: 164.6
yield_tds_mg_kg: 175.11
octanoic_run:
acid_used: "Octanoic"
inputs:
feed_water_g: 18.1049
feed_tds_mg_kg: 238.0
wf2_base_g: 8.5037
acid_g: 0.3008
measured_outputs:
hydrated_wf_g: 16.1714
reject_water_g: 13.8967
yield_water_g: 4.2082
dried_wf_g: 11.9632
reject_tds_mg_kg: 191.48
yield_tds_mg_kg: 220.62
palmitoyl_ascorbic_run:
acid_used: "6-O-Palmitoyl-L-ascorbic"
inputs:
feed_water_g: 18.0727
feed_tds_mg_kg: 238.0
wf2_base_g: 8.5056
acid_g: 0.8617
measured_outputs:
hydrated_wf_g: null # Did not form a phase barrier
reject_water_g: null
yield_water_g: null
dried_wf_g: null
reject_tds_mg_kg: null
yield_tds_mg_kg: null
succinic_run:
acid_used: "Succinic"
inputs:
feed_water_g: 18.0481
feed_tds_mg_kg: 238.0
wf2_base_g: 8.5479
acid_g: 0.2764
measured_outputs:
hydrated_wf_g: 15.8887
reject_water_g: 12.5214
yield_water_g: 5.5267
dried_wf_g: 10.3620
reject_tds_mg_kg: 112.66
yield_tds_mg_kg: 200.82
suberic_run:
acid_used: "Suberic"
inputs:
feed_water_g: 18.2303
feed_tds_mg_kg: 238.0
wf2_base_g: 8.5123
acid_g: 0.3638
measured_outputs:
hydrated_wf_g: 10.9361
reject_water_g: 14.8307
yield_water_g: 3.3996
dried_wf_g: 7.5365
reject_tds_mg_kg: 237.62
yield_tds_mg_kg: 242.44
dodecanedioic_run:
acid_used: "Dodecanedioic"
inputs:
feed_water_g: 18.0421
feed_tds_mg_kg: 238.0
wf2_base_g: 8.5123
acid_g: 0.4817
measured_outputs:
hydrated_wf_g: 15.7753
reject_water_g: 10.4636
yield_water_g: 7.5785
dried_wf_g: 8.1968
reject_tds_mg_kg: 189.08
yield_tds_mg_kg: 158.80

101
tradeoff_data.csv Normal file
View file

@ -0,0 +1,101 @@
S_water_low_mass_fraction,S_water_high_mass_fraction
0.5566,0.0050
0.5593,0.0190
0.5621,0.0327
0.5649,0.0463
0.5677,0.0596
0.5705,0.0727
0.5733,0.0856
0.5761,0.0983
0.5789,0.1109
0.5817,0.1232
0.5845,0.1354
0.5873,0.1474
0.5901,0.1592
0.5929,0.1709
0.5956,0.1824
0.5984,0.1937
0.6012,0.2049
0.6040,0.2159
0.6068,0.2268
0.6096,0.2375
0.6124,0.2481
0.6152,0.2586
0.6180,0.2689
0.6208,0.2790
0.6236,0.2890
0.6264,0.2989
0.6292,0.3087
0.6319,0.3183
0.6347,0.3279
0.6375,0.3373
0.6403,0.3465
0.6431,0.3557
0.6459,0.3647
0.6487,0.3737
0.6515,0.3825
0.6543,0.3912
0.6571,0.3998
0.6599,0.4083
0.6627,0.4167
0.6655,0.4250
0.6683,0.4332
0.6710,0.4413
0.6738,0.4493
0.6766,0.4572
0.6794,0.4651
0.6822,0.4728
0.6850,0.4804
0.6878,0.4880
0.6906,0.4954
0.6934,0.5028
0.6962,0.5101
0.6990,0.5173
0.7018,0.5245
0.7046,0.5315
0.7073,0.5385
0.7101,0.5454
0.7129,0.5523
0.7157,0.5590
0.7185,0.5657
0.7213,0.5723
0.7241,0.5789
0.7269,0.5853
0.7297,0.5917
0.7325,0.5981
0.7353,0.6043
0.7381,0.6105
0.7409,0.6167
0.7436,0.6228
0.7464,0.6288
0.7492,0.6347
0.7520,0.6406
0.7548,0.6465
0.7576,0.6522
0.7604,0.6580
0.7632,0.6636
0.7660,0.6692
0.7688,0.6748
0.7716,0.6803
0.7744,0.6857
0.7772,0.6911
0.7799,0.6964
0.7827,0.7017
0.7855,0.7070
0.7883,0.7122
0.7911,0.7173
0.7939,0.7224
0.7967,0.7274
0.7995,0.7324
0.8023,0.7374
0.8051,0.7423
0.8079,0.7471
0.8107,0.7520
0.8135,0.7567
0.8162,0.7615
0.8190,0.7661
0.8218,0.7708
0.8246,0.7754
0.8274,0.7799
0.8302,0.7845
0.8330,0.7889
1 S_water_low_mass_fraction S_water_high_mass_fraction
2 0.5566 0.0050
3 0.5593 0.0190
4 0.5621 0.0327
5 0.5649 0.0463
6 0.5677 0.0596
7 0.5705 0.0727
8 0.5733 0.0856
9 0.5761 0.0983
10 0.5789 0.1109
11 0.5817 0.1232
12 0.5845 0.1354
13 0.5873 0.1474
14 0.5901 0.1592
15 0.5929 0.1709
16 0.5956 0.1824
17 0.5984 0.1937
18 0.6012 0.2049
19 0.6040 0.2159
20 0.6068 0.2268
21 0.6096 0.2375
22 0.6124 0.2481
23 0.6152 0.2586
24 0.6180 0.2689
25 0.6208 0.2790
26 0.6236 0.2890
27 0.6264 0.2989
28 0.6292 0.3087
29 0.6319 0.3183
30 0.6347 0.3279
31 0.6375 0.3373
32 0.6403 0.3465
33 0.6431 0.3557
34 0.6459 0.3647
35 0.6487 0.3737
36 0.6515 0.3825
37 0.6543 0.3912
38 0.6571 0.3998
39 0.6599 0.4083
40 0.6627 0.4167
41 0.6655 0.4250
42 0.6683 0.4332
43 0.6710 0.4413
44 0.6738 0.4493
45 0.6766 0.4572
46 0.6794 0.4651
47 0.6822 0.4728
48 0.6850 0.4804
49 0.6878 0.4880
50 0.6906 0.4954
51 0.6934 0.5028
52 0.6962 0.5101
53 0.6990 0.5173
54 0.7018 0.5245
55 0.7046 0.5315
56 0.7073 0.5385
57 0.7101 0.5454
58 0.7129 0.5523
59 0.7157 0.5590
60 0.7185 0.5657
61 0.7213 0.5723
62 0.7241 0.5789
63 0.7269 0.5853
64 0.7297 0.5917
65 0.7325 0.5981
66 0.7353 0.6043
67 0.7381 0.6105
68 0.7409 0.6167
69 0.7436 0.6228
70 0.7464 0.6288
71 0.7492 0.6347
72 0.7520 0.6406
73 0.7548 0.6465
74 0.7576 0.6522
75 0.7604 0.6580
76 0.7632 0.6636
77 0.7660 0.6692
78 0.7688 0.6748
79 0.7716 0.6803
80 0.7744 0.6857
81 0.7772 0.6911
82 0.7799 0.6964
83 0.7827 0.7017
84 0.7855 0.7070
85 0.7883 0.7122
86 0.7911 0.7173
87 0.7939 0.7224
88 0.7967 0.7274
89 0.7995 0.7324
90 0.8023 0.7374
91 0.8051 0.7423
92 0.8079 0.7471
93 0.8107 0.7520
94 0.8135 0.7567
95 0.8162 0.7615
96 0.8190 0.7661
97 0.8218 0.7708
98 0.8246 0.7754
99 0.8274 0.7799
100 0.8302 0.7845
101 0.8330 0.7889

BIN
tradeoff_plot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

View file

@ -0,0 +1,536 @@
import yaml
import os
import numpy as np
from scipy.optimize import root
import csv
import matplotlib.pyplot as plt
def load_yaml(filepath):
"""Loads a YAML file and returns the dictionary."""
if not os.path.exists(filepath):
raise FileNotFoundError(f"Missing {filepath}! Please check your paths.")
with open(filepath, 'r') as file:
return yaml.safe_load(file)
def calculate_brine_density(mass_ions_g, mass_total_g):
"""
Calculates brine density (kg/L) based on the mass fraction of salt.
"""
if mass_total_g == 0:
return 1.0
mass_fraction = mass_ions_g / mass_total_g
# Simple empirical approximation: water density + salt mass fraction
return 1.0 + mass_fraction
def initialize_feed_mass(ions_dict, feed_volume_L):
"""Converts feed concentrations (mg/L) into absolute masses (grams)."""
masses_g = {}
total_tds_mg_L = sum(details.get('value', 0.0) for details in ions_dict.values())
for ion, details in ions_dict.items():
conc_mg_L = details.get('value', 0.0)
masses_g[ion] = (conc_mg_L * feed_volume_L) / 1000.0
# We estimate initial mass using our density function
initial_mass_fraction = total_tds_mg_L / 1_000_000.0
density_kg_L = 1.0 + initial_mass_fraction
total_solution_mass_g = feed_volume_L * density_kg_L * 1000.0
total_ion_mass_g = sum(masses_g.values())
masses_g['H2O'] = total_solution_mass_g - total_ion_mass_g
return masses_g
def run_forward_mode(params, ions_dict, feed_volume, solvent_mass_g):
"""Runs the two-stage thermodynamic extraction simulation."""
# 1. Initialize Feed Mass
feed_masses_g = initialize_feed_mass(ions_dict, feed_volume)
# We need a fixed list of species to keep our arrays ordered
species_list = ['H2O'] + sorted(ions_dict.keys())
# Create an initial guess: assume 0 grams of ions transfer, and 100g of water transfers
initial_guesses = [100.0 if sp == 'H2O' else 0.0 for sp in species_list]
# ==========================================
# STAGE 1: EXTRACTION
# ==========================================
print("\n⚙️ Running Stage 1 Extraction (T_low)...")
solution_1 = root(
stage_1_residuals,
x0=initial_guesses,
args=(species_list, feed_masses_g, solvent_mass_g, params),
method='hybr'
)
if not solution_1.success:
print("❌ Stage 1 Solver failed to converge:", solution_1.message)
return
print("✅ Stage 1 Equilibrium Reached!")
# Unpack Stage 1 solution
loaded_org_masses = {species: solution_1.x[i] for i, species in enumerate(species_list)}
reject_aq_masses = {species: feed_masses_g[species] - solution_1.x[i] for i, species in enumerate(species_list)}
print(f" -> Reject Brine left behind: {sum(reject_aq_masses.values()):.2f} g")
print(f" -> Water/Salts pulled into solvent: {sum(loaded_org_masses.values()):.2f} g")
# ==========================================
# STAGE 2: RECOVERY
# ==========================================
print("\n⚙️ Running Stage 2 Recovery (T_high)...")
initial_guesses_2 = [10.0 if sp == 'H2O' else 0.0 for sp in species_list]
solution_2 = root(
stage_2_residuals,
x0=initial_guesses_2,
args=(species_list, loaded_org_masses, solvent_mass_g, params),
method='hybr'
)
if not solution_2.success:
print("❌ Stage 2 Solver failed to converge:", solution_2.message)
return
print("✅ Stage 2 Equilibrium Reached!")
# Unpack Stage 2 solution
final_org_masses = {species: solution_2.x[i] for i, species in enumerate(species_list)}
yield_aq_masses = {species: loaded_org_masses[species] - solution_2.x[i] for i, species in enumerate(species_list)}
# Convert masses back to volume and mg/L
reject_vol_L, reject_conc = convert_mass_to_concentration(reject_aq_masses)
yield_vol_L, yield_conc = convert_mass_to_concentration(yield_aq_masses)
yield_tds = sum(yield_conc.values())
# Output Results
print("\n==========================================")
print("🏆 FINAL PROCESS OUTPUT")
print("==========================================")
print("\n--- Reject Brine ---")
print(f" Volume: {reject_vol_L:.3f} L")
print(f" Na: {reject_conc.get('Na', 0):.1f} mg/L")
print(f" Cl: {reject_conc.get('Cl', 0):.1f} mg/L")
print("\n--- Yield Water (Agricultural Product) ---")
print(f" Volume: {yield_vol_L:.3f} L")
print(f" Total TDS: {yield_tds:.1f} mg/L")
for sp, conc in sorted(yield_conc.items()):
print(f" {sp}: {conc:.2f} mg/L")
print("\n--- Recycled Working Fluid (Dry) ---")
print(f" Solvent (Base): {solvent_mass_g:.2f} g")
print(f" Residual H2O: {final_org_masses['H2O']:.2f} g")
def run_reverse_mode(target_yield_g, solvent_mass_g, s_low_max=0.833, steps=100):
"""
Sweeps S_low up to the 400% volumetric expansion limit (0.833 mass fraction)
and calculates the required S_high to hit the target yield.
Exports a .csv and saves a .png plot.
"""
print(f"\n⚙️ Running Reverse Mode Parameter Sweep...")
print(f"Targeting {target_yield_g:.1f} g of water yield from {solvent_mass_g:.1f} g of solvent.")
target_ratio = target_yield_g / solvent_mass_g
# Calculate the absolute minimum S_low required to even hold the target yield
# (If S_high was 0, what must S_low be?)
s_low_min = target_ratio / (1.0 + target_ratio)
if s_low_max <= s_low_min:
print("❌ Error: The target yield requires more water capacity than your maximum limit allows.")
return
s_low_values = np.linspace(s_low_min + 0.001, s_low_max, steps)
valid_s_low = []
s_high_values = []
for s_low in s_low_values:
# Water held per gram of dry solvent at T_low
water_ratio_low = s_low / (1.0 - s_low)
# Water that MUST be held at T_high to expel the target amount
water_ratio_high = water_ratio_low - target_ratio
if water_ratio_high >= 0:
s_high = water_ratio_high / (1.0 + water_ratio_high)
valid_s_low.append(s_low)
s_high_values.append(s_high)
# 1. Export to CSV
csv_filename = "tradeoff_data.csv"
with open(csv_filename, mode='w', newline='') as file:
writer = csv.writer(file)
writer.writerow(["S_water_low_mass_fraction", "S_water_high_mass_fraction"])
for low, high in zip(valid_s_low, s_high_values):
writer.writerow([f"{low:.4f}", f"{high:.4f}"])
print(f"✅ Data exported to {csv_filename}")
# 2. Generate and Save Plot (Headless mode)
plt.figure(figsize=(8, 6))
plt.plot(valid_s_low, s_high_values, 'b-', linewidth=2, label="Required Thermodynamic Path")
# Shade the physically impossible region (below zero)
plt.axhline(0, color='black', linewidth=1)
plt.fill_between(valid_s_low, s_high_values, 0, alpha=0.1, color='blue')
plt.title(f"Working Fluid Design Space\n(Target: {target_yield_g:.0f}g Yield per {solvent_mass_g:.0f}g Solvent)")
plt.xlabel("Water Uptake at Extraction ($T_{low}$) [Mass Fraction]")
plt.ylabel("Water Uptake at Recovery ($T_{high}$) [Mass Fraction]")
plt.xlim([0.5, 0.85])
plt.ylim([0, max(s_high_values) * 1.1])
plt.grid(True, linestyle='--', alpha=0.7)
plt.legend()
png_filename = "tradeoff_plot.png"
plt.savefig(png_filename, dpi=300, bbox_inches='tight')
print(f"✅ Plot saved to {png_filename}")
plt.close() # Prevents the plot from popping up and halting the script
def run_empirical_mode(experiment_name, run_data):
"""
Parses lab data from a physical experiment, runs integrity checks,
and calculates empirical water solubilities and volumetric salt partition coefficients.
"""
print(f"\n--------------------------------------------------")
print(f"🔬 Analyzing: {experiment_name}")
print(f"--------------------------------------------------")
inputs = run_data['inputs']
outputs = run_data['measured_outputs']
if outputs['hydrated_wf_g'] is None:
print(f"❌ Incomplete data for extraction stage. Cannot calculate thermodynamics.")
return
# ==========================================
# 1. ABSOLUTE WATER UPTAKE
# ==========================================
dry_wf_in = inputs['wf2_base_g'] + inputs['acid_g']
water_extracted_g = outputs['hydrated_wf_g'] - dry_wf_in
s_low = water_extracted_g / outputs['hydrated_wf_g']
water_trapped_g = outputs['dried_wf_g'] - dry_wf_in
s_high = max(0.0, water_trapped_g / outputs['dried_wf_g'])
print("\n--- Empirical Water Solubility (Absolute) ---")
print(f" Dry WF Input: {dry_wf_in:.4f} g")
print(f" S_water at T_low: {s_low*100:.1f}% by mass ({water_extracted_g:.4f} g extracted)")
print(f" S_water at T_high: {s_high*100:.1f}% by mass ({max(0.0, water_trapped_g):.4f} g trapped)")
# ==========================================
# 2. VOLUMETRIC SALT PARTITIONING (mg/L)
# ==========================================
k_low, k_high = 0.0, 0.0
true_reject_mass_g = inputs['feed_water_g'] - water_extracted_g
if outputs['reject_tds_mg_kg'] is not None and outputs['yield_tds_mg_kg'] is not None:
# Calculate absolute salt (mg)
feed_salt_mg = inputs['feed_water_g'] * (inputs['feed_tds_mg_kg'] / 1000.0)
reject_salt_mg = true_reject_mass_g * (outputs['reject_tds_mg_kg'] / 1000.0)
yield_salt_mg = outputs['yield_water_g'] * (outputs['yield_tds_mg_kg'] / 1000.0)
# --- Density & Volume Conversions ---
wf_density_kg_L = inputs.get('wf_density_kg_L', 0.80)
# Aqueous Volumes (using empirical density approximation)
reject_density = 1.0 + (outputs['reject_tds_mg_kg'] / 1_000_000.0)
v_aq_reject_L = (true_reject_mass_g / 1000.0) / reject_density
yield_density = 1.0 + (outputs['yield_tds_mg_kg'] / 1_000_000.0)
v_aq_yield_L = (outputs['yield_water_g'] / 1000.0) / yield_density
# Organic Volumes (Additive assumption: V_total = V_dry_wf + V_water)
v_dry_wf_L = (dry_wf_in / 1000.0) / wf_density_kg_L
v_org_hydrated_L = v_dry_wf_L + (water_extracted_g / 1000.0) / 1.0 # water density ~1.0 kg/L
v_org_dried_L = v_dry_wf_L + (water_trapped_g / 1000.0) / 1.0
# --- K_low (Volumetric) ---
salt_transferred_mg = feed_salt_mg - reject_salt_mg
c_org_hydrated_vol = salt_transferred_mg / v_org_hydrated_L if v_org_hydrated_L > 0 else 0
c_aq_reject_vol = reject_salt_mg / v_aq_reject_L if v_aq_reject_L > 0 else 0
k_low = c_org_hydrated_vol / c_aq_reject_vol if c_aq_reject_vol > 0 else 0
# --- K_high (Volumetric) ---
salt_trapped_mg = salt_transferred_mg - yield_salt_mg
c_org_dried_vol = salt_trapped_mg / v_org_dried_L if v_org_dried_L > 0 else 0
c_aq_yield_vol = yield_salt_mg / v_aq_yield_L if v_aq_yield_L > 0 else 0
k_high = c_org_dried_vol / c_aq_yield_vol if c_aq_yield_vol > 0 else 0
print("\n--- Empirical Salt Partitioning (Volumetric mg/L) ---")
print(f" Calculated K_salt at T_low: {k_low:.5f}")
print(f" Calculated K_salt at T_high: {k_high:.5f}")
# ==========================================
# 3. MASS CLOSURE & DATA INTEGRITY REPORT
# ==========================================
print("\n--- Mass Closure & Data Integrity Report ---")
stage2_input = outputs['hydrated_wf_g']
stage2_output = outputs['yield_water_g'] + outputs['dried_wf_g']
stage2_diff = stage2_input - stage2_output
if abs(stage2_diff) < 0.001:
print(" ✅ Stage 2 Mass Balance: Perfect closure.")
else:
print(f" ❌ Stage 2 Mass Balance Error: {stage2_diff:+.4f} g missing during separation.")
if water_trapped_g < -0.01:
organic_loss = abs(water_trapped_g)
loss_pct = (organic_loss / dry_wf_in) * 100
print(f" ⚠️ Organic Phase Loss: {organic_loss:.4f} g ({loss_pct:.1f}%) of the working fluid was lost.")
else:
print(" ✅ Organic Phase Mass: No obvious solvent loss detected.")
reported_reject = outputs.get('reject_water_g')
if reported_reject is not None:
table_artifact = inputs['feed_water_g'] - outputs['yield_water_g']
if abs(reported_reject - table_artifact) < 0.001:
print(f" ⚠️ Reject Mass Artifact: The reported Reject water mass appears to be a spreadsheet calculation.")
print(f" -> Script automatically corrected Reject to: {true_reject_mass_g:.4f} g")
# ==========================================
# 4. EXPORT TO SPECIFIC YAML
# ==========================================
acid_name = run_data.get('acid_used', 'Unknown_Acid')
export_data = {
"solvent_properties": {
"name": f"WF2 + {acid_name}",
"water_solubility": {
"t_low_mass_fraction": float(round(s_low, 4)),
"t_high_mass_fraction": float(round(s_high, 4))
},
"ion_partition_coefficients": {
"t_low": {"default": float(round(k_low, 6))},
"t_high": {"default": float(round(k_high, 6))}
}
}
}
output_filename = f"empirical_wf_{experiment_name}.yaml"
with open(output_filename, 'w') as file:
yaml.safe_dump(export_data, file, sort_keys=False)
def stage_1_residuals(x, species_list, feed_masses_g, solvent_mass_g, params):
"""
The physics engine core. Evaluates the mass balances and equilibrium equations.
x = array of guesses for the mass of each species in the organic phase.
"""
k_dict = params['solvent_properties']['ion_partition_coefficients']['t_low']
default_k = k_dict['default']
water_solubility = params['solvent_properties']['water_solubility']['t_low_mass_fraction']
# 1. Distribute the mass based on Python's current guesses (x)
org_masses = {species: x[i] for i, species in enumerate(species_list)}
aq_masses = {species: feed_masses_g[species] - x[i] for i, species in enumerate(species_list)}
# 2. Calculate dynamic totals and volumes
total_org_mass = solvent_mass_g + sum(org_masses.values())
# Convert density from kg/L to g/L for the math
solvent_density_g_L = params['solvent_properties'].get('density_kg_L', 0.8) * 1000.0
org_vol_L = total_org_mass / solvent_density_g_L
total_aq_mass = sum(aq_masses.values())
aq_ion_mass = total_aq_mass - aq_masses['H2O']
aq_density = calculate_brine_density(aq_ion_mass, total_aq_mass)
aq_vol_L = total_aq_mass / (aq_density * 1000.0)
# 3. Calculate the residuals (how far off the physics are from perfect equilibrium)
residuals = []
for species in species_list:
m_org = org_masses[species]
m_aq = aq_masses[species]
if species == 'H2O':
# Is the solvent saturated with water?
actual_fraction = m_org / total_org_mass
residuals.append(water_solubility - actual_fraction)
else:
# Did the ions partition correctly?
c_org = m_org / org_vol_L
c_aq = m_aq / aq_vol_L if aq_vol_L > 0 else 0
k_val = k_dict.get(species, default_k)
residuals.append((k_val * c_aq) - c_org)
return residuals
def stage_2_residuals(x, species_list, loaded_org_masses_g, solvent_mass_g, params):
"""
Evaluates the recovery stage at T_high.
x = array of guesses for the mass of each species STAYING in the organic phase.
"""
k_dict = params['solvent_properties']['ion_partition_coefficients']['t_high']
default_k = k_dict['default']
water_solubility = params['solvent_properties']['water_solubility']['t_high_mass_fraction']
# 1. Distribute the mass based on guesses
# x is what stays in the organic phase. The rest is expelled as our aqueous yield.
org_masses = {species: x[i] for i, species in enumerate(species_list)}
aq_masses = {species: loaded_org_masses_g[species] - x[i] for i, species in enumerate(species_list)}
# 2. Calculate dynamic totals and volumes
total_org_mass = solvent_mass_g + sum(org_masses.values())
# Convert density from kg/L to g/L for the math
solvent_density_g_L = params['solvent_properties'].get('density_kg_L', 0.8) * 1000.0
org_vol_L = total_org_mass / solvent_density_g_L
total_aq_mass = sum(aq_masses.values())
# Prevent division by zero if the solver guesses 0 aqueous mass
if total_aq_mass > 0:
aq_ion_mass = total_aq_mass - aq_masses['H2O']
aq_density = calculate_brine_density(aq_ion_mass, total_aq_mass)
aq_vol_L = total_aq_mass / (aq_density * 1000.0)
else:
aq_vol_L = 0.0001 # tiny buffer
# 3. Calculate the residuals
residuals = []
for species in species_list:
m_org = org_masses[species]
m_aq = aq_masses[species]
if species == 'H2O':
actual_fraction = m_org / total_org_mass if total_org_mass > 0 else 0
residuals.append(water_solubility - actual_fraction)
else:
c_org = m_org / org_vol_L if org_vol_L > 0 else 0
c_aq = m_aq / aq_vol_L if aq_vol_L > 0 else 0
k_val = k_dict.get(species, default_k)
residuals.append((k_val * c_aq) - c_org)
return residuals
def convert_mass_to_concentration(masses_g):
"""
Converts an array of absolute masses (grams) back into
Total Volume (L) and Concentrations (mg/L).
"""
total_mass_g = sum(masses_g.values())
# Safety check: if the phase is virtually empty, return zeros
if total_mass_g < 0.001:
return 0.0, {}
ion_mass_g = total_mass_g - masses_g.get('H2O', 0.0)
# Calculate Density and Volume
density_kg_L = calculate_brine_density(ion_mass_g, total_mass_g)
volume_L = (total_mass_g / 1000.0) / density_kg_L
# Calculate mg/L for each ion
concentrations_mg_L = {}
for species, mass in masses_g.items():
if species != 'H2O' and mass > 0.0001:
# (grams * 1000) / Liters = mg/L
concentrations_mg_L[species] = (mass * 1000.0) / volume_L
return volume_L, concentrations_mg_L
def process_all_empirical_runs(experimental_yaml_path="experimental_runs.yaml"):
"""
Loops through all experiments in the YAML file and processes them.
Structured to easily drop in a hashing/caching mechanism later.
"""
print(f"\n📂 Loading batch experimental data from {experimental_yaml_path}...")
data = load_yaml(experimental_yaml_path)
experiments = data.get('experiments', {})
# ==========================================
# FUTURE CACHE SETUP
# ==========================================
# hash_file = ".empirical_cache.yaml"
# previous_hashes = load_yaml(hash_file) if os.path.exists(hash_file) else {}
# current_hashes = {}
processed_count = 0
for experiment_name, run_data in experiments.items():
# ==========================================
# FUTURE CACHE CHECK
# ==========================================
# run_hash = hashlib.sha256(str(run_data).encode('utf-8')).hexdigest()
# current_hashes[experiment_name] = run_hash
# if previous_hashes.get(experiment_name) == run_hash:
# print(f"⏩ Skipping {experiment_name} (No changes detected).")
# continue
# Pass the dictionary directly to the math engine
run_empirical_mode(experiment_name, run_data)
processed_count += 1
# ==========================================
# FUTURE CACHE SAVE
# ==========================================
# with open(hash_file, 'w') as f:
# yaml.safe_dump(current_hashes, f)
print(f"\n🎉 Finished processing {processed_count} experimental runs!")
def main():
print("🌊 Starting TS-LLE Solver...")
# 1. Load Files
water_file = "water_sources/permian_brine.yaml"
params_file = "tsse_parameters.yaml"
feed_water = load_yaml(water_file)
params = load_yaml(params_file)
# ==========================================
# NEW: EMPIRICAL OVERRIDE LOGIC
# ==========================================
empirical_file = params['process_settings'].get('empirical_solvent_file')
if empirical_file and os.path.exists(empirical_file):
print(f"🔄 Overwriting default solvent thermodynamics with: {empirical_file}")
emp_data = load_yaml(empirical_file)
emp_props = emp_data.get('solvent_properties', {})
# Safely overwrite only the keys generated by the empirical parser
if 'name' in emp_props:
params['solvent_properties']['name'] = emp_props['name']
if 'water_solubility' in emp_props:
params['solvent_properties']['water_solubility'] = emp_props['water_solubility']
if 'ion_partition_coefficients' in emp_props:
params['solvent_properties']['ion_partition_coefficients'] = emp_props['ion_partition_coefficients']
elif empirical_file:
print(f"⚠️ WARNING: Specified empirical file '{empirical_file}' not found. Using defaults.")
# 2. Extract Base Variables
mode = params['process_settings'].get('mode', 'forward')
feed_volume = params['process_settings']['feed_volume_L']
solvent_volume_L = params['process_settings']['solvent_volume_L']
ions_dict = feed_water.get('ions', {})
# Dynamically calculate solvent mass based on YAML density (which is safely preserved!)
density_kg_L = params['solvent_properties'].get('density_kg_L', 0.8)
solvent_mass_g = solvent_volume_L * (density_kg_L * 1000.0)
# 3. Route to the appropriate engine
if mode == "reverse":
target_reject_L = params['process_settings'].get('target_reject_volume_L', feed_volume / 2.0)
yield_volume_L = feed_volume - target_reject_L
target_yield_g = yield_volume_L * 1000.0
s_low_max = params['solvent_properties'].get('max_water_uptake_frac', 0.833)
run_reverse_mode(target_yield_g, solvent_mass_g, s_low_max)
elif mode == "forward":
run_forward_mode(params, ions_dict, feed_volume, solvent_mass_g)
elif mode == "empirical":
process_all_empirical_runs("experimental_runs.yaml")
else:
print(f"❌ Unknown mode selected in config: {mode}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,24 @@
# tsse_parameters.yaml
process_settings:
mode: "forward" # options are forward, reverse, and empirical
empirical_solvent_file: "empirical_wf_hexanoic_run.yaml" # NEW: Point to your empirical data!
feed_volume_L: 2.0
solvent_volume_L: 1.0
target_reject_volume_L: 1.0 # NEW: What volume do you want left behind?
t_low_C: 25.0
t_high_C: 70.0
solvent_properties:
name: "Generic DIPA-like Amine"
density_kg_L: 0.80 # NEW: Solvent density
max_water_uptake_frac: 0.833 # NEW: The 400% volumetric expansion limit
water_solubility:
t_low_mass_fraction: 0.35
t_high_mass_fraction: 0.05
ion_partition_coefficients:
t_low:
default: 0.001
t_high:
default: 0.0001

View file

@ -0,0 +1,50 @@
metadata:
name: "ICP Calibration Standard"
date_sampled: "Unknown"
source_document:
notes: "Created for Permian Brine at 200:1 or 150:1 dilution"
properties:
pH:
value:
conductivity:
value:
unit: "dS/m"
ions:
Cl:
value: 853.49
unit: "mg/L"
SO4:
value: 149.8
unit: "mg/L"
NO3:
value: 0
unit: "mg/L"
F:
value: 0
unit: "mg/L"
Ca:
value: 50
unit: "mg/L"
Mg:
value: 0
unit: "mg/L"
Na:
value: 500
unit: "mg/L"
K:
value: 50
unit: "mg/L"
B:
value: 10
unit: "mg/L"
Si:
value: 0
unit: "mg/L"
Li:
value: 10
unit: "mg/L"
Sr:
value: 10
unit: "mg/L"

View file

@ -0,0 +1,44 @@
metadata:
name: "Agua Pozo - 6"
date_sampled: "Unknown"
source_document: "agua pozo.pdf"
notes: "Units for some cations are suspiciously listed as g/100g. Requires conversion/validation."
properties:
pH:
value: 7.662
conductivity:
value: 8.829
unit: "dS/m"
ions:
Cl:
value: 1740.514
unit: "mg/L"
SO4:
value: 2002.490
unit: "mg/L"
NO3:
value: 59.032
unit: "mg/L"
F:
value: 2.101
unit: "mg/L"
Ca:
value: 355.913
unit: "mg/L"
Mg:
value: 310.033
unit: "mg/L"
Na:
value: 19.637
unit: "mg/L"
K:
value: 0.521
unit: "mg/L"
B:
value: 1.362
unit: "mg/Kg"
Si:
value: 9.491
unit: "mg/Kg"

View file

@ -0,0 +1,35 @@
metadata:
name: "EPA Moderately Hard Reconstituted Water"
date_sampled: "Standard Template"
source_document: "EPA-821-R-02-012"
notes: "Official EPA WET Testing baseline. Target Hardness: 80-100 mg/L. Target pH: 7.4-7.8."
properties:
pH:
value: 7.6
alkalinity:
value: 65.0
unit: "mg/L CaCO3"
ions:
Ca:
value: 14.0
unit: "mg/L"
Mg:
value: 12.1
unit: "mg/L"
Na:
value: 26.3
unit: "mg/L"
K:
value: 2.1
unit: "mg/L"
SO4:
value: 81.4
unit: "mg/L"
Cl:
value: 1.90
unit: "mg/L"
HCO3:
value: 69.7
unit: "mg/L"

View file

@ -0,0 +1,35 @@
metadata:
name: "EPA Very Hard Reconstituted Water"
date_sampled: "Standard Template"
source_document: "EPA-821-R-02-012"
notes: "Official EPA WET Testing baseline. Target Hardness: 280-320 mg/L. Target pH: 8.0-8.4."
properties:
pH:
value: 8.2
alkalinity:
value: 235.0
unit: "mg/L CaCO3"
ions:
Ca:
value: 56.0
unit: "mg/L"
Mg:
value: 48.4
unit: "mg/L"
Na:
value: 105.2
unit: "mg/L"
K:
value: 8.4
unit: "mg/L"
SO4:
value: 325.6
unit: "mg/L"
Cl:
value: 7.6
unit: "mg/L"
HCO3:
value: 278.8
unit: "mg/L"

View file

@ -0,0 +1,50 @@
metadata:
name: "MAFA 20 - Sample 3"
date_sampled: "2025-06-15"
source_document: "MAFA 20.pdf"
notes: "Grapes crop well. Sulfur reported as elemental S, not SO4."
properties:
pH:
value: 7.40
TDS:
value: 1569
unit: "mg/L"
conductivity:
value: 2.60
unit: "dS/m"
SAR:
value: 6
unit: "Calculated"
ions:
Ca:
value: 113
unit: "mg/L"
Mg:
value: 57
unit: "mg/L"
K:
value: 7.83
unit: "mg/L"
Na:
value: 331
unit: "mg/L"
HCO3:
value: 674
unit: "mg/L"
Cl:
value: 652
unit: "mg/L"
NO3:
value: "<10"
unit: "mg/L"
B:
value: 0.19
unit: "mg/L"
Mo:
value: 0.001
unit: "mg/L"
S:
value: 60
unit: "mg/L"

View file

@ -0,0 +1,65 @@
metadata:
name: "Permian Clean Brine"
date_sampled: "2023-05-16"
source_document: "Representative_Permian_Clean_Brine_Composition-1(1).26Feb26.pdf"
notes: "Extreme salinity. Lab noted a 10.5% charge imbalance."
properties:
pH:
value: 7.1
TDS:
value: 101941
unit: "mg/L"
alkalinity:
value: 482
unit: "mg/L CaCO3"
ions:
Na:
value: 35189
unit: "mg/L"
Cl:
value: 63300
unit: "mg/L"
Ca:
value: 1106
unit: "mg/L"
Mg:
value: 190
unit: "mg/L"
K:
value: 387
unit: "mg/L"
SO4:
value: 420
unit: "mg/L"
HCO3:
value: 588
unit: "mg/L"
Sr:
value: 389
unit: "mg/L"
Ba:
value: 1.3
unit: "mg/L"
Fe:
value: 27.6
unit: "mg/L"
Mn:
value: 0.4
unit: "mg/L"
Li:
value: 28.2
unit: "mg/L"
Zn:
value: 4.1
unit: "mg/L"
PO4:
value: 2.7
unit: "mg/L"
H3BO3:
value: 147
unit: "mg/L"
SiO2:
value: 31.9
unit: "mg/L"

View file

@ -0,0 +1,28 @@
metadata:
name: ""
date_sampled: ""
source_document: ""
notes: ""
properties:
pH:
value:
TDS:
value:
unit: "mg/L"
conductivity:
value:
unit: "uS/cm"
alkalinity:
value:
unit: "mg/L CaCO3"
# List ions exactly as reported. The Python script will translate units to mol/L.
ions:
Na:
value:
unit: "mg/L"
Cl:
value:
unit: "mg/L"
# Add other ions as needed...