Determination of Total Electrolytic Concentrations of Unknown Water Samples
Stephen Lukacs (2) iquanta.org/instruct/python
"""
reference: https://iquanta.org/instruct/python ::: Chemistry 12: Analytic Conductance ::: Stephen Lukacs, Ph.D. ©2023-01-23
"""
from py4web import URL, request
from ombott.request_pkg.helpers import FileUpload
from yatl.helpers import *
from iquanta.mcp import request_to_boolean, is_str_float, is_str_int, str_to_float, str_to_int, extra_x
from iquanta.chmpy import convert_vernier_raw_file__to__data
from numpy import array, mean, std, polyfit, diag, sqrt
from scipy import optimize
import plotly.graph_objects as go
BR, B = TAG['br/'], TAG['b']
sample_knowns = "0.03178, 3195\n0.01589, 1683\n0.007945, 885\n0.003972, 454\n0.001986, 234\n0.0009931, 119\n0.0004965, 58\n0.0002483, 28\n0.0001241, 13\n6.207e-05, 6\n3.103e-05, 2\n"
sample_unknowns = "1888\n663\n1287, (1/5)^2\nPool Water (Undiluted)=3777\nPool Water=1502, (1/5)**2\nTap Water=645\nOcean Water (Undiluted)=3802\nOcean Water=883, (1/5)^3\n"
<page_scripts>
<style>
input[type=text] { width: 70px; text-align: center; border-radius: 7px; }
input[type=file]::file-selector-button { width: 170px; border-radius: 7px; }
textarea { margin: 0px 2px; width: 310px; height: 230px; border-radius: 5px; }
p { margin: 2px 0px; padding: 8px; border-radius: 10px; border: 2px solid silver; }
table.mobilities { max-width: 400px; margin: auto; text-align: center; border-radius: 4px; border: 2px solid black; }
table.mobilities th, table.mobilities td { border-radius: 4px; border: 1px solid gray; }
table.mobilities th { font-weight: bold; border: 2px solid gray; }
div.eq { display: block; color: black; font-size: 16pt; background-color: white; }
span.eq { color: maroon; font-size: 20pt; font-weight: bold; }
div.error { display: block; color: red; font-weight: bold; font-size: 16pt; background-color: white; }
table.eq { max-width: 900px; margin: auto; border-radius: 3px; border: 2px solid black; }
table.eq td { border-radius: 3px; border: 1px solid gray; }
table.unknowns { color: black; font-size: 13pt; font-weight: normal; border-radius: 4px; border: 2px solid dimgray; }
table.unknowns tr:nth-child(even) { background-color: #ffffdd; }
table.unknowns th, table.unknowns td { font-size: 12pt; font-weight: normal; }
table.unknowns th { border-radius: 4px; border: 2px solid gray; }
</style>
<script>
/*back-quote in javascript is a literal string just as r-string is in python. and a literal string is what we want because we do not want anything system processing these following strings until this code is presented to the client-side javascript processor...*/
var demo1 = [`{{=sample_knowns}}`, `{{=sample_unknowns}}`]
var demo2 = [`Vernier Format 2\nL12conductivity2022_3.txt 12/16/2022 17:16:43\nRun 1\nC NaCl\tConductivity\t\nC\tC\t\nM\t\xb5S/cm\t\n\n0.03178\t3195\t\n0.01589\t1683\t\n0.007945\t885\t\n0.003972\t454\t\n0.001986\t234\t\n0.0009931\t119\t\n0.0004965\t58\t\n0.0002483\t28\t\n0.0001241\t13\t\n6.207e-05\t6\t\n3.103e-05\t2\t\nVernier Format 2\nL12conductivity2022_3.txt 12/16/2022 17:16:43\nRun 2\nC NaCl\tConductivity\t\nC\tC\t\nM\t\xb5S/cm\t\n\n0.03178\t3238\t\n0.01589\t1723\t\n0.007945\t900\t\n0.003972\t466\t\n0.001986\t242\t\n0.0009931\t122\t\n0.0004965\t61\t\n0.0002483\t31\t\n0.0001241\t15\t\n6.207e-05\t7\t\n3.103e-05\t3\t\nVernier Format 2\nL12conductivity2022_3.txt 12/16/2022 17:16:43\nRun 3\nC NaCl\tConductivity\t\nC\tC\t\nM\t\xb5S/cm\t\n\n0.03178\t3239\t\n0.01589\t1713\t\n0.007945\t891\t\n0.003972\t464\t\n0.001986\t241\t\n0.0009931\t122\t\n0.0004965\t60\t\n0.0002483\t30\t\n0.0001241\t15\t\n6.207e-05\t7\t\n3.103e-05\t3.2\t\n`, `Run 1\nLake=100\nOcean (Undiluted)=3779\nOcean=1943, (1/2)^5\nDistilled=30\nRain=341\nPool (Undiluted)=3748\nPool=2807, (1/2)**1\nTap=575\n\nRun 2\nLake=103\nOcean=1997, (1/2)^5\nBottled=487\nDistilled=36\nRain=352\nPool=2898, (1/2)**1\nTap=599\n\nRun 3\nLake=105\nOcean=1990, (1/2)^5\nDistilled=37\nRain=354\nPool=2886, (1/2)**1\nTap=597\n`];
jQuery(function() {
jQuery('select#demo').change(function(obj) {
var select = jQuery(this);
console.log('changed: "'+select.prop('id')+'", '+select.val());
if (select.val() == 'clear') {
jQuery('textarea[name="txtfile"]').val("");
jQuery('textarea[name="unknowns"]').val("");
} else if (select.val() == 'demo1') {
jQuery('textarea[name="txtfile"]').val(demo1[0]);
jQuery('textarea[name="unknowns"]').val(demo1[1]);
} else if (select.val() == 'demo2') {
jQuery('textarea[name="txtfile"]').val(demo2[0]);
jQuery('textarea[name="unknowns"]').val(demo2[1]);
}
});
});
jQuery(document).ready( function () {
var sx = "jQuery.version: "+jQuery.fn.jquery; //+" | jQuery.ui.version: "+jQuery.ui.version;
console.log("jQuery.document.ready from Chemistry12.js begin..."+sx);
//jQuery('textarea[name="txtfile"]').val(demo1[0]); jQuery('textarea[name="unknowns"]').val(demo1[1]);
console.log("reference: https://iquanta.org/instruct/python ::: Chemistry 12: Analytic Conductance ::: Stephen Lukacs, Ph.D. ©2023-01-23");
console.log("jQuery.document.ready from Chemistry12.js end");
});
</script>
</page_scripts>
collapse_runs = request_to_boolean(collapse_runs) if ('collapse_runs' in request.forms) else False
rtn = FORM(_action=None, _method="post", _enctype="multipart/form-data")
data, txttype, txt = convert_vernier_raw_file__to__data(request.POST.get('labQ2file') if request.POST.get('labQ2file') else request.forms.get('txtfile'), False)
if ('unknowns' in locals()):
du = []
if collapse_runs or ((unknowns.upper().find('RUN') == -1) and (unknowns.upper().find('TRIAL') == -1)):
du.append([])
for l in unknowns.replace('\r', '').split('\n'):
l = l.strip().replace('\t', ',').split('=')
if not collapse_runs and ((l[0].upper().find('RUN') > -1) or (l[0].upper().find('TRIAL') > -1)):
du.append([])
continue
if (len(l) == 1):
label, unk1 = "", l[0]
else:
label, unk1 = l[0].strip(), l[1]
unk2 = unk1.strip().split(',')
if (len(unk2[0].strip()) > 0) and is_str_float(unk2[0].strip()):
du[-1].append((label, str_to_float(unk2[0]), unk2[1].strip() if (len(unk2) > 1) else "1"))
rtn.append(DIV(
P("Imagine the most simple instrument. Two wires dipped into a solution and applying a voltage to input some power. Measuring the current through pure water reads zero amps. Add the smallest pinch of any kind of salt and a current is registered. One can fairly easily imagine the cations migrating towards the negative electrode (anode) and the anions towards the positive electrode (cathode), thus bridging the electron flow between the two electrodes through the resistive pure water solution. Add another exact pinch and the current doubles. Add a third amount, and again an increase by the same exact proportion. Again, conceptually, higher concentration of ions, more ions, a thicker bridge of electrons crossing the insulating pure water and thus a greater measured conductivity. What is going on there?", *[BR()]*2, r"With that essential concept, the specific conductivity L is that which we measure on the conductivity meter. It is given by: $$L = \left( \frac{A}{d} \right) \left( \frac{\Lambda C}{1000} \right) = cc\left( \frac{\Lambda C}{1000} \right)$$ where A is the cross-sectional area of the probe, d is the distance between the two probe plates, cc is the cell constant, cc=A/d, L is the equivalent conductance, C is the molar concentration of the the salts, and 1000 is meant to convert units so that we end up in the cm range.", *[BR()]*2, r"The main point of the latter equation is that all of these variables or parameters are on the macroscopic scale. Meaning for example, the probe area A and the distance d are both in cm, and cc, \(\Lambda\), and concentration C are large, like on the scale of human hands. To derive a physically relevant equation, we're going to have to drill down into the molecular or microscopic scale.", *[BR()]*2, XML(r"The equivalence conductance \(\Lambda\) is given by: $$\Lambda = \Lambda^\circ - B\sqrt{C}$$ where the second term, \(-B\sqrt{C}\), is a resistive term, B is a variable of resistivity, that accounts for the ions running into each other, interfering with each other, at higher concentrations. Continuing: $$\Lambda^\circ = \gamma_+\lambda^\circ_+ + \gamma_-\lambda^\circ_-$$ where the \(\lambda^\circ\)'s are based on the empirically derived mobilities of the particular ion and the \(\gamma\)'s allow for the concentrations of each ion from the molecular compound. Like, NaCl or KCl, \(\gamma_+=1\) and \(\gamma_-=1\), MgCl<sub>2</sub> or CaCl<sub>2</sub>, \(\gamma_+=1\) and \(\gamma_-=2\), and Fe<sub>2</sub>(SO<sub>4</sub>)<sub>3</sub>, \(\gamma_+=2\) and \(\gamma_-=3\). The mobilities of the most common ions are given below:")),
TABLE(TR(TH("Cation"), TH("\(\lambda^\circ_+\)"), TH("Anion"), TH("\(\lambda^\circ_-\)")), TR(TD("\(H_3O^+\)"), TD("349.82"), TD("\(OH^-\)"), TD("198.5")), TR(TD("\(Li^+\)"), TD("38.69"), TD("\(Cl^-\)"), TD("76.34")), TR(TD("\(Na^+\)"), TD("50.11"), TD("\(Br^-\)"), TD("78.4")), TR(TD("\(K^+\)"), TD("73.52"), TD("\(I^-\)"), TD("76.8")), TR(TD("\(NH_4^+\)"), TD("73.4"), TD("\(NO_3^-\)"), TD("71.4")), TR(TD("\(Ag^+\)"), TD("61.9"), TD("\(ClO_4^-\)"), TD("68.0")), TR(TD(r"\(\frac{1}{2}Mg^{+2}\)"), TD("53.1"), TD("\(CH_3COO^-\)"), TD("40.9")), TR(TD(r"\(\frac{1}{2}Ca^{+2}\)"), TD("59.5"), TD(r"\(\frac{1}{2}SO_4^{-2}\)"), TD("79.8")), TR(TD(r"\(\frac{1}{2}Ba^{+2}\)"), TD("63.64"), TD(r"\(\frac{1}{2}CO_3^{-2}\)"), TD("70")), TR(TD(r"\(\frac{1}{2}Pb^{+2}\)"), TD("73"), TD(r"\(\frac{1}{2}C_2O_4^{-2}\)"), TD("24")), TR(TD(r"\(\frac{1}{3}Fe^{+3}\)"), TD("68"), TD(r"\(\frac{1}{4}Fe(CN)_4^{-4}\)"), TD("110.5")), TR(TD(r"\(\frac{1}{3}La^{+3}\)"), TD("69.6"), TD(A("More...", _href="/instruct/static/pdf/Ionic_Conductivity_and_Diffusion_at_Infinite_Dilution.pdf", _target="ionic_table"), _colspan=2)), _class="mobilities"),
P(XML("Obviously through consideration of the above equations, the higher the mobility the higher the measured conductivity. And, notice the huge mobility of H<sup>+</sup> ion at 349.82. It has been theorized that its high mobility is due to the protons ability to move through hydrogen bonds of an aqueous solution."), *[BR()]*2, XML(r"After careful substitution of \(\Lambda^\circ\) into \(\Lambda\) and \(\Lambda\) into L, distributing, and collecting like terms, the final result would be: $$L = -\left( \frac{B \cdot cc}{1000} \right) C^{3/2} + \left( \frac{cc \cdot (\gamma_+ \lambda^\circ_+ + \gamma_- \lambda^\circ_-)}{1000} \right) C$$ where we can then extend this to the equation for fitting regression to: $$L = aC^{3/2} + bC + c$$ where a, b, and, c are fitting parameters and c should be very close to zero and is included to impose a better fit. Once the calibration curve is experimentally/empirically obtained and fit to the above equation, B, cc, \(\lambda^\circ_-\), and \(\lambda^\circ_+\) can be determined through the known values of a and b. Also, the first C<sup>3/2</sup> term, which contains the resistivity B, clearly allows for the interference of ions at higher concentrations which graphically adds a curve to the plotted data. The second term is a linear relationship where the slope depends entirely on the mobility and amount of ions in the solution. The fact that actual real conductivity measurements fit so precisely to this equation is evidence that this equation is fully physically relevant and appropriate for conductivity studies and understanding."), *[BR()]*2, XML("The above C<sup>3/2</sup> polynomial can now be used in the real macroscopic laboratory with the deeper understanding of the physically relevant interference (B), mobility (\(\Lambda\)), and amount (C) of ions on the microscopic scale in an aqueous solution."), *[BR()]*2, XML("The below computer program is designed to accept your known concentrations with responses and give you the a, b, and c fitting parameters. Also, since the C<sup>3/2</sup> polynomial can not be mathematically analytically determined, your unknown responses will be numerically determined...")),
))
rtn.append(CAT(DIV(DIV("Designed to upload LabQuest2 text (txt) outputs...", BR(), INPUT(_type="file", _name="labQ2file", _style="width:500px; background-color:yellow;"), XML(",& Copy in Demo: "), SELECT(OPTION(""), OPTION("Reset/Clear All Inputs...", _value="clear"), OPTION("Demo 1: Simple Molarities and Unknowns.", _value="demo1"), OPTION("Demo 2: Molarities and Unknowns Measured with 3 Different Probes.", _value="demo2"), _id="demo"), B(", OR,"), _style="background-color:none;"), DIV(XML("Manually enter a calibration curve below..."), BR(), "(C (M), L (uS/cm)) Data Entry.. .", BR(), TEXTAREA(txtfile if ('txtfile' in locals()) else sample_knowns, _name="txtfile"), BR(), "Enter for example \"Run 1\" at the top of each run or trial.", BR(), "Column for X Series: ", INPUT(_type="text", _class="integer", _name="xseries", _value=xseries if ('xseries' in locals()) else 1), XML("&&f2emsp;"), BR(), "Column for Y Series: ", INPUT(_type="text", _class="integer", _name="yseries", _value=yseries if ('yseries' in locals()) else 2), BR(), "Collapse All Runs into a Single: ", INPUT(_type="checkbox", _name="collapse_runs", _checked=collapse_runs), _style="float:left; max-width:340px; margin-right:12px; background-color:none;"), DIV("May also include conductivity responses below", BR(), "and the system will determine the unknown concentrations.", BR(), TEXTAREA(unknowns if 'unknowns' in locals() else sample_unknowns, _id="unknowns", _name="unknowns", _style=""), BR(), "On each line above: can simply (i) add the response value only, ", BR(), "or (ii) the response value separated by a comma and the dilution factor, ", BR(), "or finally, (iii) the label of the unknown, followed by an equal sign, the response value, and an optional dilution factor(s) separated by a comma, as shown above.", BR(), INPUT(_type="submit", _value="Upload Data"), ", or, just Upload to run the demonstration.", "" if (txt is None) else CAT(" ", SPAN(txttype, _style="font-weight:bold; color:maroon;")), _style="float:left; max-width:800px; background-color:none;"), DIV(_style="float:none; clear:both;"))))
def line2xXYtoMB(x1, y1=None, x2=None, y2=None):
if isinstance(x1, (list, tuple,)) and (y1 is None) and (len(x1) == 2) and isinstance(x1[0], (list, tuple,)) and isinstance(x1[1], (list, tuple,)) and ((len(x1[0]) + len(x1[1])) == 4):
d = x1
elif isinstance(x1, (list, tuple,)) and isinstance(y1, (list, tuple,)) and ((len(x1) + len(y1)) == 4):
d = ((x1[0], x1[1],), (y1[0], y1[1],),)
elif isinstance(x1, (float, int,)) and isinstance(y1, (float, int,)) and isinstance(x2, (float, int,)) and isinstance(y2, (float, int,)):
d = ((x1, y1,), (x2, y2,),)
else:
raise Exception("line2xXYtoMB: must be of the \"(x, y)\" format")
m = (d[1][1] - d[0][1])/(d[1][0] - d[0][0])
return m, d[0][1] - m*d[0][0] #m & b
def fit_polynomial(x, y, deg=1):
fit, ymean = polyfit(x, y, deg, full=True), mean(y)
#RSquared verified with Mathematica LinearModelFit["RSquared"]
SSres, SStot = fit[1][0], sum([(d - ymean)**2 for d in y])
return fit[0], (1 - SSres / SStot)
linear = lambda x, m, b: m*x + b
conductance = lambda x, a, b, c: a*(x**1.5) + b*x + c
conductance_params = lambda x, params: conductance(x, *params)
residual_conductance = lambda p, x, y: y - conductance(x, *p)
def fit_conductance(x, y, initial_values=[-2.0e5, 1.5e5, 50.]):
if isinstance(x, (list,tuple,)):
x = array(x)
if isinstance(y, (list,tuple,)):
y = array(y)
[a, b, c], conv = optimize.leastsq(residual_conductance, initial_values, args=(x, y))
return a, b, c
if ('data' in locals()) and ('du' in locals()):
rtn.append(str(data))
rtn.append(CAT(' ::: du = ', str(du)))
pcolors = ('darkred', 'darkorange', 'darkblue', 'darkgreen', 'darkviolet',) #plotly colors
plcolors = ('red', 'orange', 'blue', 'green', 'violet',) #plotly colors
ix, iy, ir = str_to_int(xseries)-1, str_to_int(yseries)-1, data[0]['axes']
#rtn.append(DIV(ix, ' ... ', iy, ' ... ', str(ir), _class="error"))
try:
fig, t = go.Figure(), TABLE(_class="eq")
for i, run in enumerate(data):
#bos...prepare data and numeric solutions...
run['data'].sort(key=lambda d: d[1])
zipped_data = list(zip(*run['data']))
x, y = zipped_data[ix], zipped_data[iy]
(mb, RSquared), abc, xss = fit_polynomial(x, y), fit_conductance(x, y), extra_x(x, 5)
#eos...prepare data and numeric solutions.
#bos...plot series...
fig.add_trace(go.Scatter(x=xss, y=[conductance(d, *abc) for d in xss], mode="lines", name=f"C^3/2 Fit #{i+1}", line=go.scatter.Line(width=3, color=plcolors[i])))
minypt, maxypt = run['data'][0], run['data'][-1] #minypt, maxypt = tuple(map(min, zip(*data))), tuple(map(max, zip(*data))) #get the datapoints of the minimum and maximum y, respectively.
#xss = [minypt[0], maxypt[0}}
fig.add_trace(go.Scatter(x=xss, y=[linear(d, *mb) for d in xss], mode='lines', name=f"Linear Fit #{i+1}", line=go.scatter.Line(width=1, color=plcolors[i], dash="dot")))
fig.add_trace(go.Scatter(x=x, y=y, mode='markers', name=f"Run #{i+1}", marker=go.scatter.Marker(size=9, color=pcolors[i], line={ 'width':0.75, 'color':"darkgreen" })))
#eos...plot series.
#bos...report the solutions to the equations...
td = TD(SPAN(XML(f"L = {abc[0]:#,.6g}*C<sup>3/2</sup> + {abc[1]:#,.6g}*C + {abc[2]:#,.5g}"), BR(), f"L = {mb[0]:#,.5g}*C + {mb[1]:#,.5g}", _style=f"color:{plcolors[i]}; font-size:20pt; font-weight:bold;"))
#bos...solve and report on the unknowns...
if (len(du) > i) and (len(du[i]) > 0):
if collapse_runs:
#can not have duplicates of the same x values under collapsed runs because they will return
#"division by zero" when computing the estimated slope. so, create a unique x list so that
#we don't get division by zero when estimating the initial concentration, initial_estimate.
xy, xset = [ ], set()
for d in run['data']:
if (d[0] not in xset):
xy.append(d)
xset.add(d[0])
del(xset)
minypt, maxypt = tuple(map(min, zip(*xy))), tuple(map(max, zip(*xy)))
else:
xy = run['data']
t2 = TABLE(TR(TH("Sample/Label"), TH("L (uS/cm)"), TH("Calibrated C (M)"), TH("Dilution Factor"), TH("Sample C (M)")), _class="unknowns")
for j, unk in enumerate(du[i]):
tr = TR(TD((j+1) if (len(unk[0]) == 0) else unk[0]))
dilution, cdilution, edilution = unk[2], 1, ""
try:
cdilution, edilution = eval(dilution.replace('^', '**')), ""
except:
cdilution, edilution = 1, " (error)"
L, cerror, initial_estimate = unk[1], f" (error0-\"{unk[1]}\"{isinstance(unk[1], float)})", 0
tr.append(TD(f"{L:.0f}"))
initial_estimate, cerror = 0., ""
try:
abcL = [ d for d in abc ]
abcL[2] = abcL[2] - L
#1st get m&b of L between calibration points.
if (L < minypt[1]):
mb, cerror = line2xXYtoMB((0, 0,), minypt), " (extrapolated-lower)"
elif (maxypt[1] < L):
mb, cerror = line2xXYtoMB(xy[-2:]), " (extrapolated-upper)"
else:
for k in range(len(xy) - 1):
if (xy[k][1] <= L <= xy[k+1][1]):
mb = line2xXYtoMB(xy[k:k+2])
break
except Exception as E:
cerror += f" (error1-{E})"
try:
#2nd calculate the estimated L using the line between those two points.
initial_estimate = (L - mb[1])/mb[0]
except Exception as E:
cerror += f" (error2-{E})"
try:
#3rd punch it through the C^3/2 polynomial numerical analysis.
C = optimize.fsolve(conductance_params, initial_estimate, args=abcL)[0]
except Exception as E:
C, cerror = L, cerror+f" (error3-{E})"
tr.append(TD(f'{C:.3e}{cerror}', _title=f'linear estimate = {initial_estimate:.3e} M'))
tr.append(TD(f'{dilution}{edilution}'))
tr.append(TD(f'{C/cdilution:.3e}'))
t2.append(tr)
td.append(t2)
#eos...solve and report on the unknowns.
t.append(TR(TD(f'#{i+1}', _style="padding:0px 8px; font-size:20pt; font-weight:bold;"), td))
#eos...report the solutions to the equations.
fig.update_layout(xaxis=go.XAxis(title="Concentration, C (M)", type="log"), yaxis=go.YAxis(title="Conductivity, L (uS/cm)", anchor='x', side='left', type="log"))
fig.update_layout(title_text="Conductivity Calibration Curve", height=1050, showlegend=True)
html = fig.to_html()
rtn.append(XML(html[html.find('<div>'):html.rfind('</div>')+6].replace('<div>', '<div id="plotly">')))
rtn.append(t)
except Exception as E:
rtn.append(DIV(f'Could not process or display the plot or solutions. Message: "{E}"', _class="error"))
rtn.append(CAT("lecture by Stephen Lukacs, Ph.D., ©2011 - 2023; updated: March 7, 2023. all data confirmed via ", A("lecture_data_analysis.nb", _href=URL('static', "pdf/lecture_data_analysis8.pdf"), _target="data_analysis"), "."))