|
| 1 | +# # Imports |
| 2 | +using LyoPronto |
| 3 | + |
| 4 | +# NonlinearSolve and TransformVariables are used for parameter fitting |
| 5 | +using NonlinearSolve |
| 6 | +using TransformVariables |
| 7 | + |
| 8 | +# CSV and TypedTables load and store experimental data |
| 9 | +using TypedTables, CSV |
| 10 | + |
| 11 | +# Plots is a frontend to several plotting packages, defaulting to GR |
| 12 | +using Plots |
| 13 | + |
| 14 | +## All we need from StatsPlots is the @df macro |
| 15 | +using StatsPlots: @df |
| 16 | + |
| 17 | +## Latexify helps with LaTeX formatting in plots |
| 18 | +using Latexify |
| 19 | +using LaTeXStrings |
| 20 | +## Set a default for Latexify labels |
| 21 | +set_default(labelformat=:square) # Latexify, not Plots |
| 22 | + |
| 23 | + |
| 24 | + |
| 25 | +# # Load process data |
| 26 | +# The process data here are from an actual microwave-assisted lyophilization cycle. |
| 27 | +# Since thermocouples cannot be used in a microwave field, the product temperature |
| 28 | +# measurements (from fiber optic probes) are in a separate file, and we must take care to |
| 29 | +# synchronize the time. |
| 30 | + |
| 31 | +# As in the other examples, the file locations here are wonky in order to execute this |
| 32 | +# documentation remotely; to follow along, adjust the file paths to match your local setup. |
| 33 | + |
| 34 | +filesdir = joinpath(@__DIR__, "..", "..", "example") |
| 35 | + |
| 36 | +dat1 = CSV.read(joinpath(filesdir, "2023-03-02_RF_Mannitol_temperature.csv"), Table) |
| 37 | +dat2 = CSV.read(joinpath(filesdir, "2023-03-02_RF_Mannitol_process.csv"), Table, |
| 38 | + comment="#", stripwhitespace=true, dateformat="mm/dd/yyyy H:M:S") |
| 39 | + |
| 40 | +cp(joinpath(filesdir, "2023-03-02_RF_Mannitol_temperature.csv"), "./2023-03-02_RF_Mannitol_temperature.csv"); #md #hide |
| 41 | +cp(joinpath(filesdir, "2023-03-02_RF_Mannitol_process.csv"), "./2023-03-02_RF_Mannitol_process.csv"); #md #hide |
| 42 | +# To follow along, you can use the same data files [here](2023-03-02_RF_Mannitol_process.csv) |
| 43 | +# and [here](2023-03-02_RF_Mannitol_temperature.csv). |
| 44 | + |
| 45 | +time1 = dat1.var"Elapsed [s]" |
| 46 | + |
| 47 | +lyo_full_pre = map(dat2) do row |
| 48 | + nt = (tstamp = row.Timestamp, |
| 49 | + pch_sp = row.var"SPLYO.VACUUM_SP.F_CV"*u"mTorr", |
| 50 | + pch_pir = row.var"SPLYO.CHAMBER_PIRANI.F_CV"*u"mTorr", |
| 51 | + pch_cm = row.var"SPLYO.CHAMBER_CM.F_CV"*u"mTorr", |
| 52 | + Tsh_sp = row.var"SPLYO.SHELF_SP.F_CV"*u"°C", |
| 53 | + Tsh_i = row.var"SPLYO.SHELF_INLET.F_CV"*u"°C", |
| 54 | + Tsh_o = row.var"SPLYO.SHELF_OUTLET.F_CV"*u"°C", |
| 55 | + ) |
| 56 | + return nt |
| 57 | +end |
| 58 | +time2 = uconvert.(u"hr", lyo_full_pre.tstamp .- lyo_full_pre.tstamp[1]) |
| 59 | +lyo_full = Table(lyo_full_pre; t=time2) |
| 60 | + |
| 61 | +# Here we are concerned with the primary drying step, so we need to identify that. |
| 62 | +istart_lyo_pd = findfirst(lyo_full.pch_sp .== 100u"mTorr") |
| 63 | +lyo_pd = Table(lyo_full[istart_lyo_pd:end]) |
| 64 | +## Set start of primary drying as zero time |
| 65 | +lyo_pd.t .-= lyo_pd.t[1] |
| 66 | +nothing #md #hide |
| 67 | + |
| 68 | +# It's a good idea at this point to plot temperatures and pressures, |
| 69 | +# to make sure everything looks right. |
| 70 | +plT = plot(lyo_pd.t, lyo_pd.Tsh_sp) |
| 71 | +plp = plot(lyo_pd.t, lyo_pd.pch_pir, ylim=(0, 300)) |
| 72 | +plot!(plp, lyo_pd.t, lyo_pd.pch_cm) |
| 73 | +plot(plT, plp, layout=(2,1), link=:x) |
| 74 | + |
| 75 | +# We will use this to set the shelf temperature and pressure that are used later in fitting. |
| 76 | +Tsh = RampedVariable(uconvert.(u"K", [-40.0u"°C", 10.0u"°C"]), 0.5u"K/minute") |
| 77 | +pch = RampedVariable(100u"mTorr") |
| 78 | + |
| 79 | +# We will also need to know when primary drying *actually* ends, |
| 80 | +# so we will use the second-derivative test in Pirani-CM convergence. |
| 81 | +t_end = identify_pd_end(lyo_pd.t, lyo_pd.pch_pir, Val(:der2)) |
| 82 | +@df lyo_pd plot(:t, :pch_pir) |
| 83 | +@df plot!(:t, pch_pir_sm) |
| 84 | +tendplot!(t_end) |
| 85 | + |
| 86 | + |
| 87 | +# Next, we need to read the temperature data from the fiber optic probes. |
| 88 | +thm_full = map(dat1) do row |
| 89 | + nt = (tstamp = row.var"Elapsed [s]"*u"s", |
| 90 | + T1 = row.var"T1 [C]"*u"°C", |
| 91 | + T2 = row.var"T2 [C]"*u"°C", |
| 92 | + T3 = row.var"T3 [C]"*u"°C", |
| 93 | + T4 = row.var"T4 [C]"*u"°C", |
| 94 | + ) |
| 95 | + return nt |
| 96 | +end |
| 97 | +@df thm_full exptfplot(:tstamp, :T1, :T2, :T3, :T4, nmarks=40, xunit=u"hr") |
| 98 | + |
| 99 | +# Clearly we can ignore T2, which is not reading anything physical. |
| 100 | +# Also, note that one of our three remaining thermal probes is on the outside wall of a vial, |
| 101 | +# while the other two are in frozen product. Based on the behavior we see in plots, it is |
| 102 | +# clear that the series denoted T3 is the odd one out. |
| 103 | + |
| 104 | +# Next, we need to identify the portion of the temperature data that corresponds to primary drying. |
| 105 | +# After some trial and error, I decided the following achieves that: |
| 106 | +istart_thm_pd = argmin(thm_full.T1) |
| 107 | +iend_thm_pd = argmax(thm_full.T4) + 600 |
| 108 | + |
| 109 | +thm_pd_sub = Table(thm_full[istart_thm_pd:iend_thm_pd]) |
| 110 | +thm_pd = Table(thm_pd_sub; t = uconvert.(u"hr", thm_pd_sub.tstamp .- thm_pd_sub.tstamp[1])) |
| 111 | + |
| 112 | +## Plot our temperatures to make sure everything looks right |
| 113 | +plot(u"hr", u"°C", xlabel="Time", ylabel="Temperature", size=(400,300), legend=false) |
| 114 | +@df thm_pd exptfplot!(:t, :T1, :T4, nmarks=40) |
| 115 | +@df thm_pd exptvwplot!(:t, :T3, nmarks=40) |
| 116 | +plot!(Tsh, c=:black, label="shelf", tmax=13.5u"hr") |
| 117 | + |
| 118 | +# Based on that plot, we identify the part that we want to fit: |
| 119 | +# everything up until the temperature minimum near 10 hours. |
| 120 | +iend_T4 = argmin(thm_pd.T4[1000:end]) + 1000 |
| 121 | +iend_T1 = argmin(thm_pd.T1[1000:end]) + 1000 |
| 122 | +## Factor of 6 is because the temperature data are every 10 seconds, |
| 123 | +## compared to every minute for process data |
| 124 | +fitdat = @df thm_pd[begin:6:end] PrimaryDryFit(:t, (:T4[begin:iend_T4÷6], |
| 125 | + :T1[begin:iend_T1÷6]), :T3, t_end); |
| 126 | +plot(fitdat) |
| 127 | + |
| 128 | +# # Set up other model parameters |
| 129 | +## Vial parameters |
| 130 | +vialsize = "6R" |
| 131 | +rad_i, rad_o = get_vial_radii(vialsize) |
| 132 | +A_p = π*rad_i^2 # cross-sectional area inside the vial |
| 133 | +A_v = π*rad_o^2 # vial bottom area |
| 134 | +m_v = get_vial_mass(vialsize) |
| 135 | +## Formulation and fill |
| 136 | +c_solid = 0.05u"g/mL" # g solute / mL solution |
| 137 | +ρ_solution = 1u"g/mL" # g/mL total solution density |
| 138 | +R0 = 1.4u"cm^2*hr*Torr/g" |
| 139 | +A1 = 16.0u"cm*hr*Torr/g" |
| 140 | +A2 = 0.0u"1/cm" |
| 141 | +Rp = RpFormFit(R0, A1, A2) |
| 142 | +Vfill = 5u"mL" |
| 143 | +## Heat transfer |
| 144 | +KC = 2.75e-4u"cal/s/K/cm^2" |
| 145 | +KP = 8.93e-4u"cal/s/K/cm^2/Torr" |
| 146 | +KD = 0.46u"1/Torr" |
| 147 | +K_shf_f = RpFormFit(KC, KP, KD) |
| 148 | +## Geometry |
| 149 | +h_f0 = Vfill/A_p |
| 150 | +m_f0 = Vfill * ρ_solution |
| 151 | +## RF fit parameters (dummy values, will be replaced in fitting) |
| 152 | +Bf = 2.0e7u"Ω/m^2" |
| 153 | +Bvw = 0.9e7u"Ω/m^2" |
| 154 | +Kvwf = 10.0u"W/K/m^2" |
| 155 | +## Controllable inputs |
| 156 | +f_RF = 8u"GHz" |
| 157 | +## Total of 10W nominal power, multiplied by 0.54 to account for system losses |
| 158 | +P_per_vial = RampedVariable(10u"W"/17 * 0.54) # actual power / vial |
| 159 | + |
| 160 | +# We combine these into a `ParamObjRF` which will bundle up all the parameters we need. |
| 161 | +# This object needs to be constructed with all these parameters in this order, |
| 162 | +# so it has a constructor taking this tuple-of-tuples form that helps logical grouping. |
| 163 | +params_base = ParamObjRF(( |
| 164 | + (Rp, h_f0, c_solid, ρ_solution), |
| 165 | + (K_shf_f, A_v, A_p), |
| 166 | + (pch, Tsh, P_per_vial), |
| 167 | + (m_f0, LyoPronto.cp_ice, m_v, LyoPronto.cp_gl), |
| 168 | + (f_RF, LyoPronto.eppf, LyoPronto.epp_gl), |
| 169 | + (Kvwf, Bf, Bvw), |
| 170 | +)) |
| 171 | +# Estimates of some physical properties we will often need are included in LyoPronto, |
| 172 | +# including the heat capacity of ice and glass. A literature correlation for the dielectric |
| 173 | +# loss of ice is provided as `eppf`, and for glass we provide a more-uncertain |
| 174 | +# single value of `epp_gl`. |
| 175 | + |
| 176 | +# # Parameter fitting |
| 177 | +# First, we set up the fit problem, and double check that our guess values are close-ish. |
| 178 | +## Transform variables to map from a 3-component vector to bounded physical values |
| 179 | +trans_KBB = KBB_transform_bounded(Kvwf, Bf, Bvw) |
| 180 | +## Create nonlinear least-squares function |
| 181 | +nls_M1 = NonlinearFunction{true}(nls_pd!, resid_prototype=zeros(num_errs(fitdat))) |
| 182 | +## Guess values for fit parameters, in log space |
| 183 | +## In practice, these often need tinkering with |
| 184 | +p0 = [3.0, 3.0, 0.3] |
| 185 | +## Check the physical solution for these guess values |
| 186 | +tsol = gen_sol_pd(p0, trans_KBB, params_base) |
| 187 | +modrftplot(tsol) |
| 188 | +plot!(fitdat, nmarks=40) |
| 189 | + |
| 190 | +# Now, we actually run the fit, which is a nonlinear least squares problem |
| 191 | + |
| 192 | +opt1 = solve(NonlinearLeastSquaresProblem(nls_M1, p0, (trans_KBB, params_base, fitdat)), LevenbergMarquardt()) |
| 193 | +## Get the fitted solution |
| 194 | +prof_RF = gen_sol_pd(opt1.u, trans_KBB, params_base) |
| 195 | +## Get the fitted parameters in parameter space |
| 196 | +transform(trans_KBB, opt1.u) |
| 197 | + |
| 198 | +# Now, we can plot the fit results against the experimental data. |
| 199 | + |
| 200 | +## Set up the plot |
| 201 | +plT = plot(u"hr", u"°C", xlabel="Time", ylabel="Temperature") |
| 202 | +## Experimental data |
| 203 | +@df thm_pd exptfplot!(:t, :T4, :T1, nmarks=40) |
| 204 | +@df thm_pd exptvwplot!(:t, :T3, nmarks=40, label=L"$T_\mathrm{vw1}$, exp.") |
| 205 | +## Model results |
| 206 | +modrftplot!(prof_RF, markeralpha=0, trimend=1) |
| 207 | +## Shelf temperature |
| 208 | +plot!(Tsh, c=:black, label=L"T_\mathrm{sh}") |
| 209 | +## End of primary drying |
| 210 | +tendplot!(fitdat.t_end, label="", ls=:dash) |
| 211 | +## Mark the end of drying with a nice label |
| 212 | +annotate!(9, -32, Plots.text("end of drying,\nRF off", 12, "Computer Modern")) |
| 213 | +plot!([fitdat.t_end-1.5u"hr", fitdat.t_end-0.2u"hr"], [-30, -30], arrow=:arrow, c=:black, linewidth=1, label="") |
| 214 | +## Set other plot attributes |
| 215 | +plot!(legend=:topleft, ylim=(-40, 50), xlim=(0,13)) |
| 216 | +plot!(size=(600,400), left_margin=15Plots.px) |
0 commit comments