pH/Redox Titration Endpoint Determination/Analysis
Stephen Lukacs (2) iquanta.org/instruct/python
"""
reference: https://iquanta.org/instruct/python ::: Chemistry 7, pH/Redox Titration ::: Stephen Lukacs, Ph.D. ©2021-11-10
"""
from py4web import URL, request
from ombott.request_pkg.helpers import FileUpload
from yatl.helpers import *
from iquanta.mcp import is_str_float, str_to_float, extra_x
from iquanta.chmpy import convert_vernier_raw_file__to__data
from copy import copy, deepcopy
from numpy import linspace, mean, std
from scipy.interpolate import UnivariateSpline, sproot
import plotly.graph_objects as go
from plotly.subplots import make_subplots
BR, B = TAG['br/'], TAG['b']
demo_data1 = "Run 1\n0, 1.33, 0.001277\n4.01, 1.41, 0.0019773\n6.03, 1.46, 0.0032596\n8.15, 1.53, 0.0049005\n10.09, 1.61, 0.0072616\n11.96, 1.72, 0.012798\n13.84, 1.85, 0.037788\n14.89, 1.96, 0.13688\n16.03, 2.11, 0.392\n16.9, 2.29, 0.69723\n18.09, 2.85, 0.6323\n19.3, 7.04, 0.031697\n20.25, 11.62, -0.74117\n21.31, 12.43, -0.56139\n22.87, 12.68, -0.18079\n26.25, 12.92, -0.05361\n29, 13.04, -0.014228\n31.95, 13.12, -0.0045124\n36.37, 13.22, -0.001988\n\nRun 2\n0, 1.19, 0.0087373\n1.62, 1.20, 0.0086346\n2.49, 1.26, 0.005253\n3.06, 1.27, -0.0032705\n3.54, 1.29, -0.0027853\n4.06, 1.30, 0.0017783\n4.62, 1.32, 0.0039351\n5.2, 1.35, 0.0019262\n5.9, 1.37, 0.0023051\n6.54, 1.39, 0.0043372\n7.06, 1.42, 0.0041897\n7.66, 1.44, 0.0040766\n8.19, 1.46, 0.0057009\n8.91, 1.49, 0.0064958\n9.45, 1.53, 0.0053455\n10.16, 1.56, 0.0067418\n10.69, 1.59, 0.0082882\n11.51, 1.65, 0.0072206\n12.52, 1.72, 0.0093566\n13.2, 1.77, 0.017772\n13.7, 1.82, 0.03393\n14.18, 1.86, 0.058698\n15.16, 1.97, 0.14502\n16.3, 2.15, 0.37454\n17.06, 2.33, 0.86157\n17.78, 2.65, 1.1022\n18.75, 4.50, 0.53385\n19.46, 9.19, -0.49137\n20.72, 12.27, -0.90111\n21.66, 12.58, -0.65597\n22.64, 12.65, -0.19159\n24.9, 12.85, -0.045756\n27.56, 13.00, -0.011472\n30.55, 13.11, -0.0039493\n34.86, 13.22, -0.0021761" # 2 trials of HCl
demo_data2 = "Vernier Format 2\t\t\r\nlab 4.txt 3/5/2020 21:0:11\t\t\r\nRun 1\t\t\r\nVol NaOH\tpH\t2ndD\r\nV\tp\t2\r\nmL\t\t\r\n\t\t\r\n1.79\t3.59\t-0.0084538\r\n3.79\t3.86\t-0.0086451\r\n5.79\t4.05\t-0.0081321\r\n7.78\t4.19\t-0.0063834\r\n9.76\t4.31\t-0.0043485\r\n11.8\t4.42\t-0.0028019\r\n13.69\t4.52\t-0.0017025\r\n15.74\t4.61\t-0.0010419\r\n17.79\t4.71\t-0.00072763\r\n19.8\t4.79\t-0.00024522\r\n21.72\t4.88\t0.00035994\r\n23.78\t4.97\t0.00099836\r\n25.69\t5.05\t0.0019122\r\n27.7\t5.15\t0.0032435\r\n29.69\t5.26\t0.0064096\r\n31.78\t5.39\t0.016146\r\n33.68\t5.53\t0.045965\r\n35.78\t5.73\t0.098108\r\n37.7\t6\t0.14708\r\n39.69\t6.68\t0.075553\r\n41.74\t11.44\t-0.091985\r\n43.69\t11.87\t-0.14984\r\n45.69\t12.06\t-0.098918\r\n47.64\t12.18\t-0.055788\r\n49.47\t12.26\t-0.024487\r\n\t\t\r\nVernier Format 2\t\t\r\nlab 4.txt 3/5/2020 21:0:11\t\t\r\nRun 2\t\t\r\nVol NaOH\tpH\t2ndD\r\nV\tp\t2\r\nmL\t\t\r\n\t\t\r\n0.98\t3.76\t-0.036576\r\n1.9\t4.01\t-0.033224\r\n2.99\t4.2\t-0.028781\r\n3.9\t4.34\t-0.021459\r\n4.99\t4.48\t-0.012909\r\n6.09\t4.61\t-0.0072285\r\n7.17\t4.73\t-0.0034903\r\n8.08\t4.83\t0.00098264\r\n8.88\t4.92\t0.0060228\r\n10.07\t5.06\t0.015953\r\n10.99\t5.17\t0.046258\r\n11.99\t5.31\t0.1411\r\n13\t5.49\t0.30901\r\n14.04\t5.72\t0.48102\r\n15\t6.16\t0.25947\r\n16.08\t10.2\t-0.17614\r\n17.8\t11.88\t-0.3415\r\n18.97\t12.07\t-0.32114\r\n19.88\t12.17\t-0.16393\r\n21.09\t12.27\t-0.063815\r\n22.27\t12.33\t-0.024086\r\n22.98\t12.37\t-0.012323\r\n23.96\t12.41\t-0.0073981\r\n24.8\t12.44\t-0.0040083\r\n25.99\t12.48\t-0.002383\r\n26.78\t12.51\t-0.0018131\r\n27.78\t12.53\t-0.0014749\r\n\t\t\r\nVernier Format 2\t\t\r\nlab 4.txt 3/5/2020 21:0:11\t\t\r\nRun 3\t\t\r\nVol NaOH\tpH\t2ndD\r\nV\tp\t2\r\nmL\t\t\r\n\t\t\r\n0.48\t4\t-0.056845\r\n1\t4.19\t-0.058819\r\n1.48\t4.33\t-0.05296\r\n1.98\t4.45\t-0.036078\r\n2.49\t4.58\t-0.024815\r\n2.99\t4.71\t-0.014033\r\n3.49\t4.81\t0.0057891\r\n3.98\t4.92\t0.029288\r\n4.57\t5.07\t0.071561\r\n5.07\t5.19\t0.18984\r\n5.49\t5.34\t0.54335\r\n6.07\t5.54\t1.2013\r\n6.53\t5.76\t1.9311\r\n7.07\t6.26\t1.2933\r\n7.57\t9.93\t-0.71134\r\n8.18\t11.46\t-1.9833\r\n8.58\t11.65\t-1.4252\r\n9.13\t11.81\t-0.49442\r\n10.5\t11.9\t0.19206\r\n10.13\t11.98\t-0.32246\r\n10.57\t12.04\t-0.49927\r\n11.16\t12.1\t-0.20405\r\n11.63\t12.15\t-0.084862\r\n12.08\t12.18\t-0.021627\r\n12.58\t12.23\t-0.018287\r\n13.08\t12.26\t-0.017928\r\n13.58\t12.29\t-0.016798"
demo_data3 = "0, 1.76\n1, 1.81\n2.04, 1.87\n2.97, 1.92\n4, 1.97\n5.1, 2.04\n6.08, 2.11\n7.1, 2.16\n8.1, 2.26\n9.16, 2.35\n10.08, 2.42\n11.17, 2.52\n12.1, 2.60\n13.2, 2.80\n14.16, 2.96\n15.1, 3.39\n15.6, 3.51\n16.1, 3.99\n16.5, 5.16\n17.07, 5.53\n17.5, 5.82\n18.1, 6.01\n19.2, 6.21\n20.5, 6.42\n21.5, 6.57\n22.06, 6.66\n22.51, 6.71\n23.18, 6.78\n23.66, 6.83\n24.27, 6.89\n25.37, 7.01\n26.4, 7.12\n27.47, 7.23\n28.02, 7.29\n29.04, 7.42\n29.7, 7.51\n30.3, 7.62\n30.81, 7.72\n31.37, 7.84\n31.6, 7.89\n31.91, 7.98\n32.33, 8.14\n32.66, 8.31\n33.1, 8.54\n33.31, 8.99\n33.58, 9.35\n33.81, 9.68\n34.22, 10.01\n34.98, 10.45\n35.5, 10.68\n36.11, 10.84\n36.62, 10.96\n37.68, 11.13\n38.81, 11.28\n40.02, 11.40\n41.2, 11.49\n41.58, 11.51\n42, 11.54\n42.37, 11.56\n43, 11.60\n43.34, 11.61\n44.08, 11.65\n45, 11.70\n45.91, 11.74\n47.03, 11.79\n" #phosphoric acid
<page_scripts>
<style>
input[type=text] { height: 30px; width: 70px; text-align: center; border-radius: 7px; }
input[type=file]::file-selector-button { width: 170px; border-radius: 7px; }
textarea { margin: 0px 2px; width: 410px; height: 230px; font-size: 12pt; border-radius: 5px; }
p { margin: 2px 0px; padding: 8px; border-radius: 10px; border: 2px solid silver; }
div.error { display: block; color: red; font-weight: bold; font-size: 16pt; background-color: white; }
/*textarea { margin: 0px 2px; width: 310px; height: 230px; border-radius: 5px; }*/
</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 = [`{{=demo_data1}}`]
var demo2 = [`{{=demo_data2}}`];
var demo3 = [`{{=demo_data3}}`];
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("");
} else if (select.val() == 'demo1') {
jQuery('textarea[name="txtfile"]').val(demo1[0]);
} else if (select.val() == 'demo2') {
jQuery('textarea[name="txtfile"]').val(demo2[0]);
} else if (select.val() == 'demo3') {
jQuery('textarea[name="txtfile"]').val(demo3[0]);
}
});
});
jQuery(document).ready( function () {
var sx = "jQuery.version: "+jQuery.fn.jquery; //+" | jQuery.ui.version: "+jQuery.ui.version;
console.log("jQuery.document.ready from Chemistry07.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 07: pH Titration ::: Stephen Lukacs, Ph.D. ©2023-03-07");
console.log("jQuery.document.ready from Chemistry07.js end");
});
</script>
</page_scripts>
data, txttype, txt = convert_vernier_raw_file__to__data(request.POST.get('labQ2file') if request.POST.get('labQ2file') else request.forms.get('txtfile'), False)
rtn = FORM(_action=None, _method="post", _enctype="multipart/form-data")
rtn.append(CAT(DIV(DIV("Designed to upload LabQuest2 text (txt) outputs...", BR(), INPUT(_type="file", _name="labQ2file", _style="width:500px; font-size: 12pt; background-color:yellow;"), XML(", Copy in Demo: "), SELECT(OPTION(""), OPTION("Reset/Clear All Inputs...", _value="clear"), OPTION("Demo 1: Two-Trial Manually-Entered Neutralization Demonstration.", _value="demo1"), OPTION("Demo 2: Three-Trials LabQuest2 Standardization of NaOH with KHP Titrations.", _value="demo2"), OPTION("Demo 3: One-Trial Two-Endpoint Phosphoric Acid Titration.", _value="demo3"), _id="demo"), _style="background-color:none;"), DIV(XML("<b>OR</b>, manually enter data below..."), BR(), "(x, y) Data Entry.. .", BR(), TEXTAREA(txt if txt else demo_data1, _name="txtfile"), BR(), "Enter for example \"Run 1\" at the top of each run or trial.", _style="float:left; max-width:440px; margin-right:12px; background-color:none;"), DIV("Column for X Series:", INPUT(_type="text", _class="integer", _name="xseries", _value=xseries if ('xseries' in locals()) else 1), XML(" "), "Column for Y Series:", INPUT(_type="text", _class="integer", _name="yseries", _value=yseries if ('yseries' in locals()) else 2), 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;"))))
extra_x = lambda dset, extra: linspace(dset[0], dset[-1], num=(extra*(len(dset)-1)+len(dset)))
def line_x(x1, y1, x2, y2):
slope = (y2-y1)/(x2-x1)
b = y1 - slope*x1
return "y = %.3fx + %.3f = 0" % (slope, b), "x = %.2fmL NaOH" % (-b/slope), (-b/slope)
#bos...titration fit functions...
fxn_logistical = lambda x, a, b, c, d: a / (1 + b**(x - c)) + d
residual_logistical = lambda p, x, y: y - fxn_logistical(x, *p)
def fit_logistical(x, y, initial_params=(5., 1., 10., 5.,)):
[a, b, c, d], conv = leastsq(residual_logistical, initial_params, args=(x,y,))
return a, b, c, d
D1st_logistical = lambda x, a, b, c, d: -((a*b**(x - c)*Ln(b))/(1 + b**(x - c))**2)
D2nd_logistical = lambda x, a, b, c, d: (2*a*b**(2*x - 2*c)*Ln(b)**2)/(1 + b**(x - c))**3 - (a*b**(x - c)*Ln(b)**2)/(1 + b**(x - c))**2
fxn_arcsinh = lambda x, a, b, c, d: a * arcsinh(b * (x - c)) + d
residual_arcsinh = lambda p, x, y: y - fxn_arcsinh(x, *p)
def fit_arcsinh(x, y, initial_params=(0.5, 1., 10., 10.,)):
[a, b, c, d], conv = leastsq(residual_arcsinh, initial_params, args=(x,y,))
return a, b, c, d
D1st_arcsinh = lambda x, a, b, c, d: (a * b)/((1 + (b**2 * (x - c)**2))**(1./2))
D2nd_arcsinh = lambda x, a, b, c, d: (a * b**3 * (x - c))/((1 + (b**2 * (x - c)**2))**(3./2))
D3rd_arcsinh = lambda x, a, b, c, d: a*(((3 * b**5 * (x-c)**2)/((1 + b**2 * (x-c)**2)**(5./2))) - (b**3/((1 + b**2 * (x-c)**2)**(3./2))))
#eos...titration fit functions.
if (data is not None):
rtn.append(CAT(len(data), " ... ", str(data)))
smoothing = 0.085
ep_determination_threshold = 0.2 #threshold, in percent unity, of relative 3rdD univariate equivalence finder
#fig = go.Figure()
fig = make_subplots(rows=len(data), cols=1, x_title='Volume Titrant (mL)', vertical_spacing=0.04, subplot_titles=[f"Trial {t}" for t in range(1, len(data)+1)], specs=[[{"secondary_y": True}] for t in range(len(data))])
for trial, data in enumerate(data, 1):
data = data['data']
p, x, y, points_rejected, lastd = P(B(f"Trial #{trial}. pH/Redox .. ."), BR()), [ ], [ ], [ ], (len(data) - 1)
lt = (data[0][1] < data[-1][1]) #True if acid analyte, False if base analyte
for i in range(lastd+1):
if (i in (0, lastd,)):
x.append(data[i][0])
y.append(data[i][1])
elif ((x[-1] < data[i][0]) and (data[i][0] < data[i+1][0])) and ((lt and (y[-1] < data[i][1]) and (data[i][1] < data[i+1][1])) or (not lt and (y[-1] > data[i][1]) and (data[i][1] > data[i+1][1]))):
x.append(data[i][0])
y.append(data[i][1])
else:
points_rejected.append((data[i][0], data[i][1],))
if (len(points_rejected) > 0):
p.append(CAT(f"Points Rejected: ", points_rejected, BR()))
f1 = UnivariateSpline(x, y, k=3, s=smoothing)
f1stD = f1.derivative()
f2ndD = f1stD.derivative()
f3rdD = f2ndD.derivative()
"""
f1_params = fit_arcsinh(x, y)
f1 = lambda x: fxn_arcsinh(x, *f1_params)
f1stD = lambda x: D1st_arcsinh(x, *f1_params)
f2ndD = lambda x: D2nd_arcsinh(x, *f1_params)
f3rdD = lambda x: D3rd_arcsinh(x, *f1_params)
"""
f3rdD_x = [ f3rdD(d) for d in x ]
f3rdD_mm = min(f3rdD_x) if lt else max(f3rdD_x)
ix_befores, idata_befores, data_x = [ ], [ ], [d[0] for d in data]
for i, xx in enumerate(f3rdD_x):
#gets right on or close to index of endpoint
if ((xx / f3rdD_mm) > ep_determination_threshold):
#when two neighboring endpoints are very close to the same 3rdD,
#then make sure the 2ndD changes sign and the line between the two points crosses the x-axis.
y_before, y_after = f2ndD(x[i]), f2ndD(x[i+1])
if (lt and (y_before > 0) and (y_after < 0)) or (not lt and (y_before < 0) and (y_after > 0)):
ix_befores.append(i)
idata_befores.append(data_x.index(x[i]))
p.append(CAT(f"{len(ix_befores)} Equivalence Point(s) Found. x{ix_befores}, data{idata_befores}. ", BR()))
plot_eqpt = [ ]
for i, (ix_before, idata_before) in enumerate(zip(ix_befores, idata_befores), 1):
ix_after, idata_after = ix_before + 1, idata_before + 1
p.append(CAT(B(f"Endpoint #{i}.. ."), BR()))
p.append(CAT(f"LabQuest2 Equivalence Points ::: P[{ix_before}]: {data[idata_before]}, P[{ix_after}]: {data[idata_after]}.", BR()))
equivalence_points = [ ]
if (len(data[idata_before]) > 2) and (len(data[idata_after]) > 2):
x1, x2, y1, y2 = data[idata_before][0], data[idata_after][0], data[idata_before][2], data[idata_after][2]
line = line_x(x1, y1, x2, y2)
equivalence_points.append(line[2])
p.append(CAT(f"LabQuest2 2ndD ::: P[{ix_before}]: ({x1:.2f}, {y1:.6f}), P[{ix_after}]: ({x2:.2f}, {y2:.6f}), {line[:2]}.", BR()))
"""
#zero experiment returns near same results as line through original data points and that makes sense if the root of the line between the original points is essentially the same as the root of the line between the zeroed in points.
zero_2ndD = [(d, float(f2ndD(d)),) for d in linspace(x[ix_before], x[ix_after], num=11)]
for i in range(len(zero_2ndD)):
y_before, y_after = zero_2ndD[i][1], zero_2ndD[i+1][1]
if (lt and (y_before > 0) and (y_after < 0)) or (not lt and (y_before < 0) and (y_after > 0)):
x1, x2, y1, y2 = zero_2ndD[i][0], zero_2ndD[i+1][0], y_before, y_after
break
p.append(CAT(zero_2ndD[i], '~~~', zero_2ndD[i+1], BR()))
"""
x1, x2, y1, y2 = data[idata_before][0], data[idata_after][0], f2ndD(data[idata_before][0]), f2ndD(data[idata_after][0])
line = line_x(x1, y1, x2, y2)
equivalence_points.append(line[2])
p.append(CAT(f"UnivariantSpline 2ndD ::: P[{ix_before}]: ({x1:.2f}, {y1:.6f}), P[{ix_after}]: ({x2:.2f}, {y2:.6f}), {line[:2]}.", BR()))
"""
fx = UnivariateSpline(x, [7.-i for i in y], k=3, s=smoothing)
zeros = fx.roots()
p.append(CAT(zeros, BR()))
"""
if (len(equivalence_points) > 1):
plot_eqpt.append(mean(equivalence_points))
p.append(CAT(SPAN(XML(f"Average±StdDev ::: {plot_eqpt[-1]:.2f}±{std(equivalence_points, ddof=1):.3f} mL Titrant (NaOH)"), _id=f"result", _style="font-weight:bold;"), BR()))
else:
plot_eqpt.append(line[2])
p.append(CAT(SPAN(XML(f"Equivalence Point ::: {plot_eqpt[-1]:.2f} mL Titrant (NaOH)"), _id=f"result", _style="font-weight:bold;"), BR()))
xaxis1 = extra_x(x, 9)
#univariant fit...
fig.add_trace(go.Scatter(x=xaxis1, y=[f1(d) for d in xaxis1], mode='lines', marker=go.scatter.Marker(color="#e89700")), row=trial, col=1, secondary_y=False)
#1stD...
fig.add_trace(go.Scatter(x=xaxis1, y=[f1stD(d) for d in xaxis1], mode='lines', marker=go.scatter.Marker(color="#bc5090")), row=trial, col=1, secondary_y=True)
#2ndD...
fig.add_trace(go.Scatter(x=xaxis1, y=[f2ndD(d) for d in xaxis1], mode='lines', marker=go.scatter.Marker(color="#003f5c")), row=trial, col=1, secondary_y=True)
#3rdD...
#fig.add_trace(go.Scatter(x=x, y=[f3rdD(d) for d in x], mode='lines', marker=go.scatter.Marker(color="Red")), row=trial, col=1, secondary_y=True)
#logistical...
"""
fit_logistical_params = fit_logistical(x, y, (5., 1., eqpt, 5.,))
p.append(CAT('fit_logistical_params: ', fit_logistical_params, BR()))
fig.add_trace(go.Scatter(x=xaxis1, y=[fxn_logistical(d, *fit_logistical_params) for d in xaxis1], mode='lines', marker=go.scatter.Marker(color="DarkCyan")), row=trial, col=1, secondary_y=False)
"""
#arcsinh...
"""
fit_arcsinh_params = fit_arcsinh(x, y, (0.5, 0.25, eqpt, 7.33,))
p.append(CAT('fit_arcsinh_params: ', fit_arcsinh_params, BR()))
fig.add_trace(go.Scatter(x=xaxis1, y=[fxn_arcsinh(d, *fit_arcsinh_params) for d in xaxis1], mode='lines', marker=go.scatter.Marker(color="DarkGreen")), row=trial, col=1, secondary_y=False)
"""
#lmfit arcsinh
"""
#fxn_lm = lambda x, a, b, c, d: a * arcsinh(b * (x - c)) + d
fxn_lm = lambda x, a, b, c, d: a / (1 + b**(x - c)) + d
params = Parameters()
params.add('a', 10.)
params.add('b', 20.)
params.add('c', 20.)
params.add('d', 7.)
def lm_residual(params, x, y):
a = params['a']
b = params['b']
c = params['c']
d = params['d']
return (y-fxn_lm(x, a, b, c, d))
fit_lm = minimize(lm_residual, params, 'least_squares', args=(x, y,))
fit_params = [v.value for k, v in fit_lm.params.items()]
p.append(CAT('fit_arcsinh_params: ', fit_params, BR()))
fig.add_trace(go.Scatter(x=xaxis1, y=[fxn_lm(d, *fit_arcsinh_params) for d in xaxis1], mode='lines', marker=go.scatter.Marker(color="DarkCyan")), row=trial, col=1, secondary_y=False)
"""
fig.add_trace(go.Scatter(x=plot_eqpt, y=[f1(d) for d in plot_eqpt], mode='markers', marker=go.scatter.Marker(size=11, color="Red")), row=trial, col=1, secondary_y=False)
fig.add_trace(go.Scatter(x=x, y=y, mode='markers', marker=go.scatter.Marker(color="Black")), row=trial, col=1, secondary_y=False)
fig.add_trace(go.Scatter(x=[d[0] for d in points_rejected], y=[d[1] for d in points_rejected], mode='markers', marker=go.scatter.Marker(color="Purple")), row=trial, col=1, secondary_y=False)
rtn.append(p)
fig.update_xaxes(linecolor="Black", mirror=True, linewidth=2, gridwidth=4)
fig.update_yaxes(title='pH/Redox', title_standoff=0, ticklabelposition="inside", gridcolor='#ffe1a9', gridwidth=2, linecolor="Black", mirror=True, linewidth=2, secondary_y=False)
fig.update_yaxes(title='1stD, 2ndD', title_standoff=0, ticklabelposition="inside", gridcolor='#fff', gridwidth=2, linecolor="Black", mirror=True, linewidth=2, secondary_y=True)
fig.update_layout(height=500*trial, margin=go.layout.Margin(l=25, r=25, b=60, t=60, pad=0), plot_bgcolor="#f5f5f5", paper_bgcolor="White", showlegend=False)
#fig.update_layout(xaxis=go.XAxis(title="mL Titrant"), yaxis=go.YAxis(title="pH/Redox", anchor='x', side='left'), yaxis2=go.YAxis(title='1stD, 2ndD', anchor='x', overlaying='y', side='right'))
#fig.update_layout(height=750, margin=go.layout.Margin(l=25, r=25, b=60, t=60, pad=0), plot_bgcolor="#f5f5f5", paper_bgcolor="White", showlegend=False)
html = fig.to_html()
rtn.append(XML(html[html.find('<div>'):html.rfind('</div>')+6].replace('<div>', '<div id="plotly">')))
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"), "."))