+Delivering Hydrogen Fuel Gas +
+Thinking about hydrogen as a utility fuel gas by way of the relative compression costs.
+diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..8ac37b7 --- /dev/null +++ b/.nojekyll @@ -0,0 +1 @@ +27a7e7c6 \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 27b27bc..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# aefarrell.github.io -Jupyter notebooks rendered as a blog by quarto diff --git a/about.html b/about.html new file mode 100644 index 0000000..3243ab0 --- /dev/null +++ b/about.html @@ -0,0 +1,775 @@ + +
+ + + + + + + +From my previous analysis, I showed that the same piping operating at the same pressures delivers approximately the same energy, in terms of higher heating value, in systems in full hydrogen service as those in natural gas service. So, for an end user of natural gas (such as me, it’s how I heat my home) making some modifications to the fired equipment and getting a stream of hydrogen versus natural gas is a plausible pathway to low-carbon heating. That doesn’t entirely hold up for utility providing the gas, however, as there is an additional cost associated with compressing hydrogen over natural gas, which might make such systems impractically expensive to operate. At least that is the question I’m looking to answer here: is distributing hydrogen fuel gas to residential or industrial customers through a distribution network like natural gas feasible or not?
+The full economic analysis of hydrogen as a fuel gas versus some other low carbon source of energy would so strongly depend on local factors – the local cost of electricity versus hydrogen, whether that region is subject to a carbon tax and how that tax works, etc. – that I don’t think much can be generalized. The economics of hydrogen, where I live, where natural gas is abundant and widely used, export infrastructure is limited, and the carbon tax largely excludes all but the largest industrial emitters, is pretty different from a place where all natural gas is imported at large expense, or with a very different approach to carbon pricing.
+We already know that natural gas distribution systems are feasible, there is one delivering natural gas to my house right now and it is also delivering natural gas to the chemical plant I work at, the gas fired power plant that is powering my laptop right now, etc. To some extent we also already know that hydrogen distribution systems are feasible as they already exist, the longest hydrogen transmission pipeline in Europe is >1000km long and there are >700km of hydrogen pipelines in the United States.1 However those are primarily for supplying hydrogen as a feedstock to chemical and petrochemical facilities, not quite the same use case as hydrogen as a fuel gas.
+1 Sendehboudi and Gharbani, Hydrogen Production, Transportation, Storage, and Utilization.
A reasonable approach to answering this question is to compare a hypothetical hydrogen transmission system to a natural gas system. This is basically what I’ve already done for pipe-flow when looking at hydrogen blending: once the hydrogen is in the pipe and at pressure, everything works from that point down. What remains to be seen is whether it is feasible to get it into the pipe and at pressure. Specifically how much more work does it take to compress hydrogen to line pressure than natural gas?
+The standard equation for determining the work, , to compress a mass flowrate
of gas from a pressure of
to
is234
2 GPSA, Engineering Data Book.
3 Boyce et al., “Transport and Storage of Fluids.” 10–42.
4 Strictly speaking this is an approximation as it neglects the change in kinetic energy of the fluid, but for small compression ratios, less than ~5, it is appropriate
This is related to the isentropic work through the isentropic efficiency,
Where the integral of the specific volume is taken along an isentropic path. Real compressors are not isentropic, but compressor manufacturers provide tables or figures giving the isentropic efficiency, with values of 70% - 80% being fairly typical.
I am going to assume that whatever efficiency can be achieved for a standard natural gas compressor can also be achieved with a hydrogen compressor. They may be different compressors, but the isentropic efficiency is something of a design choice. The ratio of work for a hydrogen system to a natural gas system, , is then
The integrals, though, do not have to be tackled directly, recalling the differential for (specific) enthalpy
+Integrating from state 1 to state 2 along an isentropic path (i.e. ) gives:
Thus the ratio we’re looking for is given by:
+It is important to note that state 2 is not the same for hydrogen and natural gas. Since the integration is along an isentropic path, state 2 is at a pressure of and a temperature
defined by
and the entropy of hydrogen and natural gas are, in principle, different.
Compressors typically don’t raise pressures all the way from, say, atmospheric pressure to the 200-1500psi working pressures of natural gas transmission lines in a single stage. For one, as gases are compressed they heat up and that large temperature rise can damage a compressor. Usually compression is accomplished with a series of stages with interstage cooling. This work ratio is really only valid for a single stage.
+Suppose we are evaluating a system that uses a multi-stage compressor to take gas at ambient conditions, in this case suppose 1bar and 15C, to a relatively high transmission line pressure of 100bar using 4 stages, Figure 1. The overall compression ratio is 100, with 3 stages this gives a per stage ratio of
+
+Suppose, for simplicity, the interstage coolers bring the gas temperature down to 15C:
+With the inlet gas to each stage being at 15C and exiting at some temperature which is determined from the energy balance and isentropic efficiency.
+The total work required to compress the gas is then
+A useful first approach to most problems in life5 is to assume an ideal gas. It allows one to build some intuition about the problem and how fluid non-ideality may change the results. Starting with an ideal gas in stream 1 being isentropically compressed to stream 2,6 and equating the specific enthalpies
+5 for chemical engineers at least
6 Gmehling et al., Chemical Thermodynamics for Process Simulation, 596.
Assuming is a constant this simplifies to
For an ideal gas , giving
A well known result. The enthalpy of an ideal gas with constant is just
, so we have:
From the ideal gas law,
Which allows us to write the work ratio, for a single stage, compressing an ideal gas with constant heat capacity, as:
+where . From the ideal gas law the ratio of specific volumes is just the ratio of molar weights
and, since the inlet streams are all at the same temperature
+Furthermore, if we assume then
This is where I’ve encountered what I consider a serious error: assuming an equal mass flowrate of the two fuels. Making this assumption gives
+using Unitful, Clapeyronideal_hydrogen = ReidIdeal(["hydrogen"])
+ideal_natural_gas = ReidIdeal(["methane"])This gives a work ratio of 7.9, leading us to conclude that it will take 7.9× the power to run a hydrogen transmission system than a similar natural gas system.
+I think this is a mistake because the goal is not to deliver the same mass flowrate but the same thermal energy (combustion energy). Supposing we are seeking to deliver the same energy in terms of higher heating value
+and so
+where is the specific higher heating value (or gross heating value)
This gives a work ratio of 3.1, quite a bit smaller of an estimate.
+But the assumption that is perhaps not a good one, so we should explore how compression effects differ even as ideal gases.
k(gas) = isobaric_heat_capacity(gas, 1u"bar", 288.15u"K") /
+ isochoric_heat_capacity(gas, 1u"bar", 288.15u"K")This gives a work ratio of 3.25, which shows that our original approximation was reasonable: accounting for differences in isentropic expansion factor, , changes our estimate by only 5.0%.
To account for non-ideality we need to lose some generality. The ideal gas case ultimately doesn’t depend on what the initial and final conditions are (since all of that cancels out) but for real gases how non-ideal they are depends strongly on the actual pressures and temperatures of the system.
+I am going to use a volume translated Peng Robinson cubic equation of state for both hydrogen and methane.
+real_hydrogen = PR(["hydrogen"];
+ idealmodel=ReidIdeal,
+ alpha=TwuAlpha,
+ translation=PenelouxTranslation)real_natural_gas = PR(["methane"];
+ idealmodel=ReidIdeal,
+ alpha=TwuAlpha,
+ translation=PenelouxTranslation)Clapeyron.jl does not define functions for finding the enthalpy as a function of pressure and entropy, so we will need to first find the isentropic temperature, and then calculate the enthalpy.
using Roots: find_zero
+
+function isentropic_temperature(gas, p1, T1, p2)
+ s1 = entropy(gas, p1, T1)
+ k_ig = k(gas)
+ T2_guess = T1*(p2/p1)^(1-1/k_ig)
+ T2 = find_zero( T -> entropy(gas, p2, T) - s1, T2_guess)
+ return T2
+endFirst, the specific enthalpy difference for hydrogen
+Then the specific enthalpy difference for natural gas
+Finally, the work ratio of compressing hydrogen versus natural gas
+In this case the ideal gas law estimate and the estimate using a cubic equation of state differ by only 1.0%.
+The difference does become more pronounced at higher pressures, see Figure 2, but even at stage three the work ratio for the real gases differs from the ideal gas case by only 6.0%.
+In online discussions I have seen it claimed that the difference in work – why so much more energy is required to compress hydrogen over natural gas – is due to some obscure feature of hydrogen’s phase diagram. I would say that is false. The main reason why hydrogen requires more energy to compress is simply due to its low molecular weight. That hydrogen has a high energy density, on a mass basis, offsets this greatly when hydrogen and natural gas are compared on an equivalent energy basis, though.
+There are additional effects that make hydrogen even more difficult to compress than you would expect, from a pure ideal gas analysis, but they are pretty small unless the working pressures are either huge or the compression ratio is tremendous. Neither of which are particularly relevant for a gas transmission system using normal compressors and typical pipeline pressures.
+I wrote this post to address some misconceptions that I’ve encountered regarding hydrogen7 and in particular the rhetorical device of finding one single fact about hydrogen and taking that to mean some project or another has been “debunked”. Real engineering projects are just too complex for that to be a useful exercise. Reality always depends on a great many factors.
+7 is this all just an extended response to a thread on mastodon? I mean… sort of
Is the fact that a hydrogen fuel distribution system would require >3× the energy to operate mean that such a system is impractical? That really depends. It could be that a large, continent spanning, transmission system for hydrogen such as natural gas distribution employs in North America is rendered totally infeasible by the increased power demands. But then again, why should hydrogen be so geographically constrained? Natural gas is constrained by geology but presumably one could make green hydrogen wherever there is water and renewable power. Perhaps blue hydrogen is best built on top of the existing natural gas infrastructure – send natural gas across the continent and convert it to hydrogen closer to the end use. I am doubtful that one could come up with a sweeping conclusion from all of this that would say anything beyond one’s ignorance of the specific conditions of niche industries and use cases for hydrogen versus the panoply of alternative low carbon energy sources.
+I think the dreams of existing gas fired power plants simply retrofitting to hydrogen and continuing on as before are looking increasingly like a relic from a bygone era. The price of renewables and storage continues to plumet and the economics of these schemes seem increasingly out of touch with that reality. But for other industries, with other heating demands, perhaps there is a compelling case to be made.
+I say all of this as someone who is broadly skeptical of the hype around hydrogen. I think it is being pursued mostly as a saviour of fossil fuels and not as a technology that actually best solves the problems which face us as we transition to a low carbon future. But there are also a lot of really smart engineers working on projects centered around low-carbon hydrogen, and I imagine they know what they are doing.
+The standard references I looked at either provided an equation without giving any sense where it came from or, in one notable exception, gave an equation that (as far as I can tell) can’t possibly be right. So I thought this might be fertile ground for investigation.
+Gaussian plumes are a common first dispersion model for chemical release screening tools. They are easy to implement, especially in spreadsheets, and have convenient mathematical properties that makes calculating the parameters relevant to a hazard screening simple. The cases where either the plume is grounded1 or is free2 are particularly convenient as the plume extents can be calculated directly.
+1 emitted at ground level and perfectly reflecting off the ground plane
2 emitted high enough above the ground that the ground plane can be neglected entirely
For what follows I am going to examine a free plume – the results are very similar for a grounded plume – which is given by
+Where the origin has been chosen to coincide with the release point. The standard assumptions for a Gaussian plume are:
+For a free plume it is further assumed that there is no ground plane, the z-axis extends infinitely up and down.
+To actually use this model, we need a parametrization of and
for which I am going to use the simple power law
and
# System parameters
+w = 1 # kg/s
+u = 1 # m/s# Class D - Neutral atmospheric stability
+a = 0.128
+b = 0.905
+c = 0.20
+d = 0.76σy(x) = a*x^b
+σz(x) = c*x^dχ(x,y,z; w=w, u=u) = w*exp(-0.5*((y/σy(x))^2 + (z/σz(x))^2))/(2π*u*σy(x)*σz(x))Suppose I am interested in the region of the plume between two concentrations and
, these might be the upper flammability limit (UFL) and the lower flammability limit (LFL) (respectively). It doesn’t really matter. But further suppose that I have both the concentrations and the point along the
axis where the centerline concentration equals that concentration. This is the point where the isosurface crosses the
axis.
This is typically the step along a hazard analysis or consequence analysis where calculating the potential explosive energy takes place.
+x₁ = 10 # m
+x₂ = 100 # mχ₁ = χ(x₁,0,0)
+χ₂ = χ(x₂,0,0)At the level of screening tools, estimating the potential explosive energy in a vapour cloud typically involves estimating the mass of explosive material in the cloud3, then calculating the energy from the specific enthalpy of combustion.
+3 How one defines the flammable mass of a vapour cloud varies significantly from author to author, depending on whether one takes it to be the entire region with a concentration greater than the LFL, some fraction of the LFL (1/2 is common), or only the region between the LFL and UFL.
4 Bakkum and Duijm., “Vapour Cloud Dispersion,” 4.78.
5 “Calculation of the Amount of Gas in the Explosive Region of a Vapour Cloud Released in the Atmosphere”.
The CCPS tools CHEF and RAST, the TNO Yellow Book,4 and Van Buijtenen5 give the mass of a vapour cloud as
+Where is the mass of the region defined by the concentration
and
is a constant, which generally depends upon atmospheric stability. If the explosive mass is taken to be the region between two concentrations
and
with
then
| + | C | +
|---|---|
| CHEF & RAST | +1 | +
| TNO Yellow Book | +|
| Van Buijtenen | +
Woodward6 gives the following as the rigorous method for plumes, specifically for a free plume it is
+6 Estimating the Flammable Mass of a Vapour Cloud.
where is the complete elliptic integral of the second kind and
is a function of
and atmospheric stability.
The mass in the region of a Gaussian plume with a concentration greater than is given by the volume integral
where
+The mass in a free plume is given by
+By symmetry this is equal to
+Recalling that the concentration in a grounded plume is twice that of a free plume
+The total mass in the plume contained between the planes and
is simply the integral
Which follows directly from properties of Gaussian functions
+Which is the result used in CHEF v4.5 – the total mass in the plume. This also presents a useful upper bound: the mass in the flammable region of the plume must be less than the total mass of the plume.
+The isopleths for a free Gaussian plume are given by
+where
The whole isosurface is defined by
+These can be used directly, but a more general approach is to use marching squares to find the isopleth.
+For a general plume one can find the surface using marching tetrahedra. In the following the surface for is calculated by marching tetrahedra, as shown in Figure 3.
using Meshing: MarchingTetrahedra, isosurface
+using GeometryBasics: Mesh, Point, Vec, Triangle, TriangleFace, volumeχ_safe(x,y,z) = isnan(χ(x,y,z)) ? 0.0 : χ(x,y,z);xs = LinRange(0.0, x₂, 100)
+ys = LinRange(-7.5, 7.5, 25)
+zs = LinRange(-7.5, 7.5, 25)
+
+χfield = [ χ_safe(x,y,z) - χ₂ for x in xs, y in ys, z in zs ]
+pts,fcs = isosurface(χfield, MarchingTetrahedra(), xs, ys, zs);msh = Mesh(Point.(pts), TriangleFace.(fcs))Mesh{3, Float64, TriangleFace{Int64}}
+ faces: 43472
+ vertex position: 21740
+If the potential explosive energy was being determined using the volume of the cloud, well we would be done. The volume of a meshed surface can be calculated directly
+abs(volume(msh))8085.175640305937
+Presumably one could tetragonalize this mesh and calculate the volume integral of the mass through that. I will leave that as an exercise for the reader. For the particular case of a free plume that will be more work than is required.
+The most direct approach to calculating the explosive mass is to numerically integrate over a rectangular region containing the plume7
+7 Woodward, Estimating the Flammable Mass of a Vapour Cloud, 241.
This can be done directly using the trapezoidal rule in three dimensions.
+@inline χ_inbounds(x,y,z; χₗ,w,u) = χ(x,y,z; w=w,u=u)≥χₗ ? χ(x,y,z; w=w,u=u) : 0.0function mass🪤(xₗ; lower, upper, N=100, w=w, u=u)
+ y_a, z_a = lower
+ y_b, z_b = upper
+ x_a, x_b = 0.0, xₗ
+
+ Δx = (x_b - x_a)/N
+ Δy = (y_b - y_a)/N
+ Δz = (z_b - z_a)/N
+
+ χₗ, Σχ = χ(xₗ,0,0; w=w, u=u), 0.0
+ for i in 1:N, j in 1:N, k in 1:N
+ xᵢ₋₁, xᵢ = x_a + (i-1)*Δx, x_a + i*Δx
+ yⱼ₋₁, yⱼ = y_a + (j-1)*Δy, y_a + j*Δy
+ zₖ₋₁, zₖ = z_a + (k-1)*Δz, z_a + k*Δz
+
+ Σχ += ( χ_inbounds(xᵢ₋₁, yⱼ₋₁, zₖ₋₁; χₗ=χₗ, w=w, u=u)
+ + χ_inbounds(xᵢ₋₁, yⱼ₋₁, zₖ; χₗ=χₗ, w=w, u=u)
+ + χ_inbounds(xᵢ₋₁, yⱼ, zₖ₋₁; χₗ=χₗ, w=w, u=u)
+ + χ_inbounds(xᵢ₋₁, yⱼ, zₖ; χₗ=χₗ, w=w, u=u)
+ + χ_inbounds(xᵢ, yⱼ₋₁, zₖ₋₁; χₗ=χₗ, w=w, u=u)
+ + χ_inbounds(xᵢ₋₁, yⱼ₋₁, zₖ; χₗ=χₗ, w=w, u=u)
+ + χ_inbounds(xᵢ, yⱼ, zₖ₋₁; χₗ=χₗ, w=w, u=u)
+ + χ_inbounds(xᵢ₋₁, yⱼ, zₖ; χₗ=χₗ, w=w, u=u) )
+
+ end
+
+ return Σχ*Δx*Δy*Δz/8
+endm🪤 = mass🪤(x₂; lower=[-7.5,-7.5], upper=[7.5,7.5]) -
+ mass🪤(x₁; lower=[-1.5,-1.5], upper=[1.5,1.5]);The mass by trapezoidal rule is 55.77kg
+The obvious downside of this approach is that it integrates over regions that are outside the plume isosurface with the same resolution as regions within the plume. Getting a good result requires a very fine grid and calculating a great many points which are ultimately discarded.
+We can reduce the number of discards by taking advantage of what we know about the Gaussian plume: we know the vertical and crosswind isopleths. Introducing a change of variables ,
such that
and
and
where
This changes the domain of integration from a rectangular prism to one with a rectangular cross-section whose size is a function of . It is somewhat more efficient, and doesn’t require the user to pick a good bounding box.
function mass🪤2(xₗ; N=100, w=w, u=u)
+ χₗ = χ(xₗ,0,0; w=w, u=u)
+ function integrand(x,ξ,ζ)
+ K = 2*log(w/(2π*u*σy(x)*σz(x)*χₗ))
+ y_lim = σy(x)*√(K)
+ z_lim = σz(x)*√(K)
+ y, z = y_lim*ξ, z_lim*ζ
+ I = y_lim*z_lim*χ_inbounds(x,y,z; χₗ=χₗ, w=w, u=u)
+ return isnan(I) ? 0.0 : I
+ end
+
+ x_a, x_b = 0.0, xₗ
+ ξ_a, ξ_b = -1.0, 1.0
+ ζ_a, ζ_b = -1.0, 1.0
+ Δx = (x_b - x_a)/N
+ Δξ = (ξ_b - ξ_a)/N
+ Δζ = (ζ_b - ζ_a)/N
+
+ Σχ = 0.0
+ for i in 1:N, j in 1:N, k in 1:N
+ xᵢ₋₁, xᵢ = x_a + (i-1)*Δx, x_a + i*Δx
+ ξⱼ₋₁, ξⱼ = ξ_a + (j-1)*Δξ, ξ_a + j*Δξ
+ ζₖ₋₁, ζₖ = ζ_a + (k-1)*Δζ, ζ_a + k*Δζ
+
+ Σχ += ( integrand(xᵢ₋₁, ξⱼ₋₁, ζₖ₋₁)
+ + integrand(xᵢ₋₁, ξⱼ₋₁, ζₖ)
+ + integrand(xᵢ₋₁, ξⱼ, ζₖ₋₁)
+ + integrand(xᵢ₋₁, ξⱼ, ζₖ)
+ + integrand(xᵢ, ξⱼ₋₁, ζₖ₋₁)
+ + integrand(xᵢ₋₁, ξⱼ₋₁, ζₖ)
+ + integrand(xᵢ, ξⱼ, ζₖ₋₁)
+ + integrand(xᵢ₋₁, ξⱼ, ζₖ) )
+
+ end
+
+ return Σχ*Δx*Δξ*Δζ/8
+endm🪤2 = mass🪤2(x₂) - mass🪤2(x₁);The mass by trapezoidal rule, with a change of variables, is 55.73kg
+This is still a very wasteful integration since it suffers from the curse of dimensionality. To come up with a somewhat reasonable answer requires evaluating the integrand times. A large proportion of those evaluations are still being thrown out, as they are outside the region of interest.
This could be sped up by parallelizing the calculations, which would allow for larger values of N to get more accurate results, but a more efficient approach is to use Monte Carlo integration.
+using MCIntegrationfunction mass🎲(xₗ; w=w, u=u)
+ χₗ = χ(xₗ,0,0; w=w, u=u)
+ function integrand(x,ξ,ζ)
+ K = 2*log(w/(2π*u*σy(x)*σz(x)*χₗ))
+ y_lim = σy(x)*√(K)
+ z_lim = σz(x)*√(K)
+ y, z = y_lim*ξ, z_lim*ζ
+ I = y_lim*z_lim*χ_inbounds(x,y,z; χₗ=χₗ, w=w, u=u)
+ return isnan(I) ? 0.0 : I
+ end
+
+ xξζ = CompositeVar(Continuous(0.0,xₗ),
+ Continuous(-1.0,1.0),
+ Continuous(-1.0,1.0))
+ res = integrate(((x, ξ, ζ), c)-> integrand(x[1],ξ[1],ζ[1]); var = xξζ)
+ return res.mean[1]
+endmass🎲 (generic function with 1 method)
+m🎲 = mass🎲(x₂) - mass🎲(x₁);Total iterations * blocks 160: 100%|██████| Time: 0:00:02 (17.37 ms/it)
+
+The mass by Monte Carlo, with a change of variables, is 56.36kg
+Both of these approaches have a similar relative error (spoilers!) but the Monte Carlo integration is much more efficient – in time and memory.
+using BenchmarkTools🪤res = @benchmark mass🪤2(x₂)BenchmarkTools.Trial: 1 sample with 1 evaluation per sample. + Single result which took 9.444 s (11.24% GC) to evaluate, + with a memory estimate of 9.10 GiB, over 607442648 allocations.+
🎲res = @benchmark mass🎲(x₂)BenchmarkTools.Trial: 30 samples with 1 evaluation per sample. + Range (min … max): 161.499 ms … 184.335 ms ┊ GC (min … max): 8.80% … 6.33% + Time (median): 165.566 ms ┊ GC (median): 10.62% + Time (mean ± σ): 167.251 ms ± 5.510 ms ┊ GC (mean ± σ): 10.25% ± 2.34% + ▃ ▃▃█ ▃▃▃▃ ▃ + █▁▁▇▇███▁████▇▇▇▁▁▁▇▇▁▁▇▁▁▁▇▇▁▁▁▁▇▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█ ▁ + 161 ms Histogram: frequency by time 184 ms < + Memory estimate: 164.92 MiB, allocs estimate: 10748898.+
The only reason I included the trapezoidal rule is that I saw it suggested in an online resource that, on reflection, I think may have been AI slop and so I’m not linking to it (I was going to have a much longer diatribe here about it too, so consider yourself saved). The moral of the story is don’t use the trapezoidal rule for multidimensional integration unless you have some really compelling reason to do so.
+The advantage to the Monte Carlo approach given above is that it will work pretty much out of the box for any plume. The error will be smaller the more tightly the domain of integration can be bound around the region where , but it is in general pretty forgiving.
The other standard approach for multi-dimensional integration is adaptive cubature, for example h cubature. This approach really only works well when the bounds of integration are constants (e.g. the limits and
do not depend on
) and when the function being integrated does not have abrupt step changes. Taking the integrand from above and just running h cubature over it will be terribly inefficient.
A better approach is to re-write the integral such that the integration is only over the region with , and with an integrand that is smooth and continuous throughout. Firstly we re-write the integral.
Note that defines an ellipse, Figure 4, which suggests the change of variables
,
such that
and ,
This can be integrated directly with h cubature without involving any discontinuous functions.
+using HCubature: hcubaturefunction mass📦(xₗ; w=w, u=u)
+ lower = [0.0, 0.0, 0.0]
+ upper = [1.0, 2π, xₗ]
+ χₗ = χ(xₗ,0,0; w=w, u=u)
+
+ function integrand(r)
+ (ρ,θ,x) = r
+ K = 2*(log(w) - log(2π*χₗ*σy(x)*σz(x)*u))
+ y = σy(x)*√(K)*ρ*cos(θ)
+ z = σz(x)*√(K)*ρ*sin(θ)
+ return σy(x)*σz(x)*K*χ(x,y,z; w=w, u=u)*ρ
+ end
+
+ I, err = hcubature(integrand, lower, upper)
+ return I
+endm📦 = mass📦(x₂) - mass📦(x₁);The mass by H cubature is 56.23kg
+📦res = @benchmark mass📦(x₂)BenchmarkTools.Trial: 208 samples with 1 evaluation per sample. + Range (min … max): 19.541 ms … 35.658 ms ┊ GC (min … max): 0.00% … 37.93% + Time (median): 25.003 ms ┊ GC (median): 19.48% + Time (mean ± σ): 24.127 ms ± 2.908 ms ┊ GC (mean ± σ): 13.37% ± 10.14% + ▁ ▄▂█▂ + ▄▆▇█▇▄▄▃▃▁▃▁▁▂▁▁▁▂▃▂█████▆▆▃▂▃▃▂▁▂▁▂▁▂▁▁▁▂▁▁▂▁▁▂▁▁▁▁▁▁▁▁▂▁▂ ▃ + 19.5 ms Histogram: frequency by time 34.2 ms < + Memory estimate: 23.05 MiB, allocs estimate: 1459221.+
This is both significantly more accurate (spoilers!) and a dramatic improvement in both compute time and memory useage. Though at a cost that this is not as easily adapted to other plume types. For example, a Gaussian plume at some height above the ground with ground-reflection does not have a nice clean expression for the lower plume extent and the change of variables to polar coordinates doesn’t work as nicely.
+You might get the sense now that I am leading you somewhere very specific. By choosing polar coordinates for the integration, and noting that for the Gaussian free plume the isopleths form an ellipse, it should immediately suggest that we could just…integrate this analytically. Substituting ,
directly into the definition of
gives
The last integral is a simple one dimensional integral which can be done with QuadGK.
+using QuadGK: quadgkfunction mass🔴(xₗ; w=w, u=u)
+ I, err = quadgk( t -> σy(t)*σz(t), 0, xₗ)
+ return (w/u)*xₗ - 2π*χ(xₗ,0,0; w=w, u=u)*I
+endmass🔴 (generic function with 1 method)
+m🔴 = mass🔴(x₂) - mass🔴(x₁);For the special case where and
the integral can be done analytically to arrive at
Which is the result from Van Buijtenen8 given above. Similarly if we take and
then
8 “Calculation of the Amount of Gas in the Explosive Region of a Vapour Cloud Released in the Atmosphere”.
Which is the result from the TNO Yellow Book.9
+9 Bakkum and Duijm., “Vapour Cloud Dispersion”.
mₑ = (w/u)*((b+d)/(b+d+1))*(x₂ - x₁);The mass by QuadGK is 56.23kg, and the exact analytic solution is 56.23kg
+🔴res = @benchmark mass🔴(x₂)BenchmarkTools.Trial: 10000 samples with 1 evaluation per sample. + Range (min … max): 21.956 μs … 13.849 ms ┊ GC (min … max): 0.00% … 99.31% + Time (median): 29.064 μs ┊ GC (median): 0.00% + Time (mean ± σ): 29.891 μs ± 138.294 μs ┊ GC (mean ± σ): 4.60% ± 0.99% + ▁█▅▂ ▅█▇▅▂ + ▂▄▂▂▂▂▁████▆▄▃▂▂▃▃▇█████▇▅▄▃▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▃ + 22 μs Histogram: frequency by time 43.7 μs < + Memory estimate: 24.86 KiB, allocs estimate: 1519.+
It is definitely a little bit of cheating to point out that the simple one-dimensional integral is much more performant than any of the three integrations of the whole volume, see Table 1.
+| + | Mass (kg) | +Error (%) | +Median Time (ms) | +
|---|---|---|---|
| Trapezoidal Rule | +55.73 | +0.9% | +9443.63 | +
| Monte Carlo | +56.36 | +0.23% | +165.57 | +
| H Cubature | +56.23 | +1.0e-8% | +25.0 | +
| QuadGK | +56.23 | +5.0e-10% | +0.03 | +
With a slight change, the integration over the cross-sectional area of a free plume can be modified to give us the mass in a grounded plume
+where
The masses within the free iso-surface and grounded iso-surface which intersect the x-axis at are the same, as we expect, but the concentration which defines that iso-surface is not the same. An important distinction.
You may have noticed the absence of the rigorous method given by Woodward in the analysis above. The rigorous method looks quite different from the previous integrations, but is similarly easy to calculate using QuadGK.
As a reminder the “rigorous” method given by Woodward for a free plume is
+with and
the complete elliptic integral of the second kind.
using SpecialFunctions: ellipek²(x) = 1 - (σz(x)/σy(x))^2k² (generic function with 1 method)
+I, err = quadgk( t -> σy(t)^2 * ellipe(k²(t)), x₁, x₂)(3522.359412198113, 4.6837135414534714e-7)
+m_rigorous = 4*(χ₁ - χ₂)*I;m_rigorous1853.438596397458
+That is far too high, it is 33.0× the exact solution and 18.5× the entire mass in the plume at x₂.
+Clearly this doesn’t work. So what’s gone wrong? Referring to the original paper by Hesse10 the mass is given as
+10 “A Computational Procedure for Calculating the Mass of Flammable Vapor in a Neutrally Buoyant Cloud”.
where is the area between the ellipses defined by
and
.
Hesse proposes that11
11 Hesse equation 20.
where is the perimeter of the elliptical isopleth in the y-z plane defined by the concentration
. That is to say, Hesse is integrating the cross-sectional area by treating it like a series of concentric, elliptical, rings with perimeter
and width
. For the free plume the perimeter is
Where is the complete elliptic integral of the second kind with the elliptic modulus given by
, a constant with respect to
and
.
Substituting in and making the change of variables to
From here the remainder of the derivation follows rather obviously….Unfortunately, this doesn’t actually work as a method of integration. The problem is right at the very first step
+To demonstrate this, consider the integration simply over the cross-sectional area. Hesse proposes that this relation holds
+That is, we should be able to use Hesse’s technique to recover the area of an ellipse, since he is integrating over an elliptical cross-section. However, since is a constant, that’s not what we get:
But we know that the area of an ellipse is . The only case in which Hesse’s technique works is when
, since
(i.e. a circular cross-section).
There is another glaring flaw with how this integration is being done. Even were it the case that the integration over the cross-section was correct, the axial integration is being done over the region where the cross-section is no longer well defined. The ellipse that defines the inner boundary of our cross-sectional domain of integration is not defined for . This is, in fact, the definition of
12. The only region over which the integration even makes sense is from
, and yet the actual integration is being done over
.
12 is the point where
, i.e. where the inner ellipse vanishes. At any point
there is no point in the plume where
and so the isopleth does not exist
Even if the cross-sectional integration was adjusted such that the innner ellipse is ignored, and so the problem of being undefined in the region is solved, it still doesn’t work because it excludes the mass in the plume between
for which
. Clearly from Figure 1 and Figure 2, this is not a negligible region.
One might be tempted by the logic
+But that only works if , which is not the case in general.
It is possible this was fixed in errata that did not make it into the final publication. Spicer and Havens13 also reference Hesse but note the inclusion of “important author errata distributed at the meeting where the paper was presented”. Regardless, what is published in Hesse and Woodward is wrong.
+13 “Application of Dispersion Models to Flammable Cloud Analyses”.
For a screening level analysis I would use the relation
+to calculate the mass within an isosurface defined by . This gives some freedom in choice of dispersion parameters
and
. The free plume choice is a useful simplification even when considering release points at some elevation where ground reflection is important. The free plume model, while ignoring the ground plane entirely, does capture much of the mass that would accumulate along the ground (by integrating over the region that “passes through” the ground in the free model).
Something that may be worthwhile to explore is whether the mass within the isosurface that intersects the x-axis at for a plume at some height
with ground reflection is also the same as the mass in the grounded and free plumes. One would expect the concentration along the centerline to be somwhere between that of the grounded and free plumes, so it is certainly suggestive when the mass within the two plumes is identical. I don’t seen an obvious way of doing this analytically, but it would be nice to have an answer to the question of “how wrong would I be if I just used the same
equation for everything?”
The standard approach for assessing the consequences of a release from a pressure vessel is to:1
+1 Center for Chemical Process Safety, Guidelines for Consequence Analysis of Chemical Releases, 11.
The mass release rate from a vessel blowdown is taken as the max release rate (at the start of the blowdown) and generally assumed to be constant.2 While the standard references do acknowledge that the flow will decrease over time, this is typically not taken into account in the dispersion models. The one exception that I’m aware of is when modelling flaring due to vessel and pipeline blowdowns: sometimes an average flowrate is taken instead of the max, in which case the blowdown curve is used to derive that average. It is still a constant, though, for the purposes of dispersion modelling.
+2 Center for Chemical Process Safety, 29–35.
3 Palazzi et al., “Diffusion from a Steady Source of Short Duration.”
However, if we think back to the development of the Palazzi model3 for short duration releases, a rather obvious path presents itself for the special case of a release of an ideal gas from an isothermal blowdown: integrate the Gaussian puff model over time with an exponentially decaying mass release rate.
+Recalling the isothermal blowdown of an ideal gas, the mass release rate, , is given by
Where4
+4 This follows from the definition of :
For a blowdown through an isentropic nozzle the time constant is given by
With:
+For a release centred at the origin with an elevation h, the concentration profile for a single Gaussian puff is given by:5
+5 Center for Chemical Process Safety, Guidelines for Consequence Analysis of Chemical Releases, 90–91.
Where the gs are Gaussian functions in the x, y, and z directions
+gx(x,t,u,σx) = exp(-0.5*((x-u*t)/σx)^2)/(√(2π)*σx)gy(y,σy) = exp(-0.5*(y/σy)^2)/(√(2π)*σy)gz(z,h,σz) = ( exp(-0.5*((z-h)/σz)^2)
+ + exp(-0.5*((z+h)/σz)^2))/(√(2π)*σz)With:
+For puff releases, the dispersion parameters are typically given in reference to the centre of the cloud,6 here I have taken some puff dispersion parameters for a class D atmospheric stability.
+6 Center for Chemical Process Safety, 90.
# Puff dispersion parameters for Class D atmospheres
+σx(xc) = 0.06*xc^0.92
+σy(xc) = 0.06*xc^0.92
+σz(xc) = 0.15*xc^0.70I like to use Unitful to manage units. This can be a little tricky with correlations, so to make that easier I use a simple macro to add a method to each correlation function mapping the correct input units and output units.
+import Pkg
+Pkg.add(url="https://github.com/aefarrell/UnitfulCorrelations.jl")using Unitful
+using UnitfulCorrelations@ucorrel σx u"m" u"m"
+@ucorrel σy u"m" u"m"
+@ucorrel σz u"m" u"m"A good habit to get into, when developing code in julia, is to collect model parameters into structs. This is what I do here, collecting the parameters for a single Puff into a Puff struct.
struct Puff
+ m # mass
+ h # release height
+ u # velocity
+ t # release time
+endNow I create the concentration function which takes a single puff, and a location in space and time, and returns the concentration. I also check for the special case where the puff hasn’t actually been released yet, and so does not contribute to the concentration.
+Since I want this to be unit aware, both return values have to have the same units. I don’t want to hard-code this as I may also want to use this function with simple numeric types, like Float64. By using the unit function I can ensure the zero result has the same dimensions as the correct result, falling back to no units in the case where all inputs are simple numbers.
function c(p::Puff,x,y,z,t)
+ λ = t - p.t # time since release
+ xc = p.u*λ # location of cloud center
+ if λ > 0t
+ return p.m*gx(x,λ,p.u,σx(xc))*gy(y,σy(xc))*gz(z,p.h,σz(xc))
+ else # the puff hasn't been released yet
+ return 0*unit(p.m)/unit(xc)^3
+ end
+endThe single puff model assumes all of the mass is released in a single instant. This significantly over-estimates the concentration for longer duration releases, and so an alternative approach is to break up the release into several puffs and sum the result.
+Where is the duration of each puff and
is the time when puff i was released.
c(ps::Vector{Puff},x,y,z,t) = sum( c.(ps, x, y, z, t) );Taking the limit takes this from a discrete sum to the corresponding integral
For the Palazzi7 model 8 and, assuming the
s are independent of time, this can be integrated to give:
7 Palazzi et al., “Diffusion from a Steady Source of Short Duration.”
8 being the Heaviside function
using SpecialFunctions: erf, erfcstruct Palazzi
+ w # mass release rate
+ h # release height
+ u # velocity
+ t_f # end of release
+endfunction c(p::Palazzi,x,y,z,t)
+ Δt = min(t, p.t_f)
+ w, u = p.w, p.u
+ xa = u*(t-Δt)
+ xb = u*t
+ # n.b. erf(b,a) = erf(a) - erf(b)
+ return (w/(2u))*erf((x-xb)/(√2*σx(xb)), (x-xa)/(√2*σx(xa))) *
+ gy(y,σy(x))*gz(z,h,σz(x))
+endIt should be pretty obvious where I am going next: instead of assuming is a constant, let it be the exponential decay from an isothermal vessel blowdown. The integration is a little more tedious but it is not really any more difficult than the Palazzi case.
Splitting this into elements that depend on time and those that don’t
Letting everything within the integral equal
It makes the integration a little easier to introduce
By expanding everything within the , collecting terms and completing the square we arrive at:
If we evaluate the s at the end points then, given that
as
, this simplifies to:
Giving a final concentration of:
+struct IsothermalBlowdown
+ w_0 # mass release rate
+ τ # time constant
+ h # release height
+ u # velocity
+ t_f # end of release
+endfunction c(p::IsothermalBlowdown,x,y,z,t)
+ w₀, u, τ = p.w_0, p.u, p.τ
+ xb = u*t
+ xa = t < p.t_f ? 0*xb : u*(t-p.t_f)
+ return (w₀/(2u))*
+ exp( (σx(xb)^2 + 2u*τ*(x - xb))/(2*(u*τ)^2) ) *
+ erf( (σx(xb)^2 + u*τ*(x - xb))/(√(2)*σx(xb)*u*τ),
+ (σx(xa)^2 + u*τ*(x - xa))/(√(2)*σx(xa)*u*τ) )*
+ gy(y,σy(x))*gz(z,h,σz(x))
+endNote that I have implemented a slightly different version of the model. In the case where , with
being the time at which the blowdown ceases, this simplifies to the model given above, where I implicitly assumed
.
In the case where is some finite number and
, an extra term is added to, essentially, “turn off” the blowdown.
Just to have something to look at, suppose an isothermal blowdown from a vessel which starts at an initial release rate of 1kg/s and the vessel contains 1000kg of an ideal gas. The vent stack is 2m above the ground and ambient windspeed is 2m/s.
+# The example case
+u = 2.0u"m/s"
+h = 2.0u"m"
+w₀ = 1.0u"kg/s"
+m₀ = 1000.0u"kg"
+τ = m₀/w₀The mass release rate, per above, is simply the exponential decay.
+w(t) = w₀*exp(-t/τ)The total mass released by time t is simply the time-integral:
+m(t) = w₀*τ*(1 - exp(-t/τ))Suppose that after time has elapsed a block-valve shuts and the release abruptly ends. This release can be modelled as a series of discrete puffs by dividing the interval
into
sub-intervals and releasing a single puff at the start of each interval i with a mass
.
function discrete_puffs(;n=100, t_0=0τ, t_f=τ)
+ δt = (t_f - t_0)/(n-1)
+ pfs = Vector{Puff}()
+ for t_i ∈ range(t_0;stop=t_f,length=n)
+ m_i = w(t_i)*δt
+ pf = Puff(m_i,h,u,t_i)
+ push!(pfs,pf)
+ end
+ return pfs
+endpfs = discrete_puffs(n=25);For the purposes of illustration I chose a rather small number of puffs, as shown in Figure 1. However, if we calculate the total mass released we find that it isn’t too far off.
+m(pfs::Vector{Puff},t) = sum( pf.m for pf in pfs if pf.t < t );After time τ has elapsed, the total released mass is 632 kg, the total mass of the discrete puffs is 645 kg, an excess of only 2.1%.
+With the discrete puff case implemented, we can now compare with the approximate integral. Recall that I didn’t actually integrate the full expression, I approximated the integral as one where the s are constant (they aren’t) and integrated that. I then took that result and substituted back in the correlations for the
s. The hope is that this will be close enough to the full expression that we can use it.
For a less than rigorous approach, let us consider a point 1000m downwind of the vent stack, at the same release height as the stack. We will look at the concentration profile over time at that point.
+Another useful comparison is to the Palazzi model, we expect the concentration profile for the blowdown to be bounded between the Palazzi case with a constant mass rate and the case with a constant mass rate
. Furthermore, we expect the blowdown case should connect the two curves with something resembling an exponential decay.
bd = IsothermalBlowdown(w₀,τ,h,u,τ)The results are showin in Figure 2 above, which matches our expectations. The approximate integral model developed here is virtually identical to the discrete puffs model with 100 puffs. For comparison I also included the case where the Palazzi model is used but with a time-averaged constant release rate. This will have the correct total mass in the release, but clearly underestimates the peak concentration.
+The ground level concentration also conforms to our expectations, as shown in Figure 3. The region around the vent itself, besides having some artifacts of the discretization and marching squares, is likely quite unreliable. This is the region where the fundamental assumptions, that the release has zero momentum and no buoyancy, are most egregiously violated. I think this model is still reasonable for concentrations far enough from the vent that the windspeed dominates the advection, though an effective release point would need to be used.
+It is the nature of the universe that the instant I post this I will find where this model was published in the literature. I haven’t found it yet, but I can’t imagine I am first person to come up with this. Knowing me, it is probably in one of the references I look at all the time and, somehow, failed to notice.
+If this paragraph is still here when you see this, and you know of a published reference for this model, please leave a comment.
+1 Ooms, “A New Method for the Calculation of the Plume Path of Gases Emitted by a Stack”.
The Ooms plume model is a model of a continuous jet of fluid exiting into a crossflow. Unlike, for example, a simple Gaussian model which assumes the source has no momentum, or a free jet model which assumes there is no crossflow, the Ooms model accounts for the buoyancy and momentum of the jet as well as the crossflow without resorting empirical correlations (such as the Briggs’ model).
+However, unlike those simpler models, the Ooms model is not in the form of simple closed form expressions. It is an integral plume model which results in a system of differential algebraic equations which must be solved numerically for each particular plume. Unlike earlier integral plume models, which assumed a top hat velocity and density profile, the Ooms model assumes the plume parameters follow Gaussian profiles.
+
+Consider the sketch of a vertical vent shown in Figure 1. The plume starts at some point down stream of the actual vent, after the zone of flow establishment characterized by an elevation δ. The plume rises due to the buoyancy and momentum in the vent gases and bends over as it is carried along by the wind. The coordinate system is arranged such that the wind is in the positive x-direction and the center-line of the plume is within the x-z plane.
+Taking a slice through the plume, we assume it has a circular cross-section and use a local cylindrical coordinate system with s the direction along the plume axis, r the radial direction, and φ the radial angle. The overall plume radius at any point is , with b a characteristic length which is a function of distance along the center-line.
Zooming in on a differential element of the plume, Figure 2, we take it be approximately a cylinder where flow within the plume enters and exits through the circular ends and air is entrained through the outer surface with some entrainment velocity E.
+
+The Ooms model comes from the conservation relations for this differential element.
+The mass exiting the differential element is equal to the mass entering through the plume plus the entrained air.
+The mass of entrained air is simply the product of the mass flux (ρE) and the area:
+Giving a mass balance equation:
+The mass passing through a surface is simply the mass flux G = ρ u integrated over the surface area:
+
Finally giving
+
An errant has disappeared from the right hand side of the equation. It has been absorbed into the constants in E. The right hand side of the balance equations in Ooms2 appear at first blush like they were done for a top hat model of a plume with radius b, which would be a mistake. However, as the overall radius of a plume in a top hat model btop-hat =
, when the constants are scaled by a factor of
the two look the same.
2 “A New Method for the Calculation of the Plume Path of Gases Emitted by a Stack”.
The total mass of the vented substance is conserved as the plume expands. Assuming the vent is some species i with mass concentration c:
+
There are two equations for conservation of momentum: in the x-direction and z-direction. This is a consequence of the choice of coordinates – that the plume centerline is confined to the x-z plane and neither the jet nor the crossflow have velocity in the y-direction. In particular the coordinates were chosen such that the crossflow is entirely in the x-direction with velocity .
In the x-direction the total momentum into the differential element is the mass in times the velocity component in the x direction:
+And similarly for the total momentum leaving the element
+The change in momentum is equal to the momentum added to the plume from entrainment and drag from the wind. In this case the drag force acts in the positive direction, pushing the plume along.
+Ooms notes that the drag force on the plume is only due to the component of the wind velocity which is perpendicular to the plume direction, . Drag then follows the standard relationship, with the area being the outside surface area of the cylinder.
The drag force in the x-direction is acting on the area perpendicular to the x-direction
+Where the absolute value comes from the drag force being always positive.
+Giving
+In the z-direction the change in momentum is due to buoyant forces and drag in the z-direction. The buoyant force can be written as:
+Assuming the density within the differential element is approximately constant with s. Combining with the drag force in the z-direction gives the final momentum balance:
+Where ensures the drag force is acting in the right direction.
Starting from an energy balance, using the ambient temperature as the reference temperature, the enthalpy entering the differential element is:
+Similarly for the enthalpy out, giving an enthalpy change over the element of:
+To be very abusive of notation. Where T is the temperature of the plume and Ta,0 is the reference temperature – the ambient temperature at the vent exit. The enthalpy change is assumed to come only from entrainment. The enthalpy added to the differential element from entrainment of air is:
+Putting it all together we get the energy balance:
+Assuming the ideal gas law, we can make the substitution:
+Furthermore, if we assume and
then we can cancel all those constants giving:
These seem like radical assumptions if you are coming to the Ooms plume model as a dense gas dispersion model, but the original paper is concerned with the release of stack gases from combustion equipment. For stack gases this is not unreasonable and other models such as the Briggs’ model for plume rise make similar simplifications (any model that calculates buoyant flux from plume temperature alone is making that assumption implicitly).
+Up until this point all of the plume parameters have been calculated along the plume axis. This needs to be translated into the original coordinate system to be useful, in particular the curve the plume axis takes through space is given by:
+
One of the most important parts of the model is how it accounts for entrainment. Ooms considers entrainment to be the sum of three processes.
+In the immediate vicinity of the jet exit, when the jet velocity dominates, the entrainment is taken to be the same as a free jet, namely that it is proportional to the jet center line velocity. In this case we take the excess velocity:
+Where is the component of the wind velocity parallel to the jet. The parameter
is called the entrainment coefficient for a free jet and is independent of Reynolds’ number when
. Ooms gives this as
.
At distances further down the plume axis, when , the entrainment is taken to be the same as a cylindrical thermal in a stagnant atmosphere, given as:
Where is called the entrainment coefficient for a line thermal, it is similarly a constant at large Reynolds’ numbers. Ooms gives this as
To connect these two regimes, Ooms multiplies the line thermal term by . This doesn’t seem to have any theoretical justification, it just works to make the second term disappear when the vent is still mostly vertical. This is an important feature to note. The model is often presented such that the initial angle of the jet can be anything, but a key assumption of the entrainment model is that the jet is initially vertical.
Finally, Ooms adds a term to entrainment due to atmospheric turbulence. Presumably if you were only interested in jets entering a crossflow where that flow was nice and laminar you would leave this out. But Ooms is specifically developing his model for vent stacks releasing plumes into the atmosphere, and the actual structure of the atmosphere and its turbulence must be accounted for. He does this by including an entrainment velocity due to turbulence
Where is the entrainment coefficient due to turbulence, which is taken to be
. The entrainment velocity due to turbulence can be accounted for in one of two ways:
The total entrainment is then:
+or
+Where is defined in the next section.
Earlier I mentioned that the velocity, density, and concentration in the plume are assumed to have Gaussian profiles. Though it doesn’t really have a theoretical basis, Gaussian profiles are mathematically convenient and fit observed profiles quite well. This has been experimentally validated for both free jets and bent over plumes.3
+3 Keffer and Baines, “The Round Turbulent Jet in a Cross-Wind”.
The velocity is taken to be the component of the wind velocity parallel to the plume axis plus an excess velocity:
+The plume density, similarly, is the air density plus an excess density:
+Finally, the concentration simply follows a Gaussian profile:
+Where is the turbulent Schmidt number. This is entirely analogous to a free jet. I’m not sure entirely why Ooms gives the Schmidt number as what I would call the inverse of the Schmidt number, but that is just a quibble of notation.
Ooms uses a value of or
, which is consistent with observations of free jets.
The original paper does not provide the final differential algebraic equations, nor does it provide the worked out integrals, that is left as an exercise for the reader. I looked around and could not find a detailed description of the final model equations other than in the model documentation for DEGADIS.4 An earlier version of DEGADIS used the Ooms plume model for dense gas plumes with modifications to the model assumptions and, especially, the energy balance. This is a good start, but it is presented in its final matrix form with 17 model constants that are pre-calculated. It is not immediately clear where the model constants come from and how they are related to the constant λ.
+4 Havens and Spicer, “A Dispersion Model for Elevated Dense Gas Jet Chemical Releases,” 7–13.
+5 Havens and Spicer, 12.
The version in DEGADIS is intended for dense gas dispersion and makes additional assumptions such as that there is no vertical change in air density. This is a reasonable assumption for dense plumes that fall back to earth and roll along the ground, but is something that would have to be corrected for large buoyant plumes rising high into the air.
+I did my own working out here because I wanted two things:
+The integrals are not difficult to work out, though they can turn into a sort of alphabet soup of variables. The integrals involving Gaussians all involve something of the form which has a nice closed form solution.
I worked out five different constants that are integrals of the Gaussian profiles and the products of them:
+
These are basically in the order that I encountered them when working out the integrals and could probably be cleaned up for some consistency. Throughout I made the substitution such that every integral of a Gaussian in the model becomes
where the C corresponds to one of the above. Each of the 17 constants in the DEGADIS model correspond to one of these constants times a scaling factor. For all but
and
they are integer scaling factors, for the first two they
times
and
respectively. Below is a table showing the concordance.
| DEGADIS6 | +Me | +
|---|---|
6 Havens and Spicer, 12.
It is decidedly easier to put everything in dimensionless form first, using the following (where a bar over the variable indicates that it is dimensionless):
+
Where D is the initial jet diameter. This is the main point where what follows diverges from DEGADIS, where the model is given in dimensional form, which makes each of the expressions much larger and makes direct comparison between the two something of a chore.
+Thusly equipped, we can work out the integrals and subsequently all the derivatives. Starting with the conservation of mass:
+
So the balance equation is:
+
Where is the dimensionless entrainment velocity.
Expanding out the derivatives and dividing through by b, we get:
+
In the interests of not having this go on forever, I’m going to skip the details on the integral (they should be fairly obvious) and just give the balance equation and the final form with expanded out derivatives.
+The balance equation is:
+The final form is:
+
The balance equation in the x-direction is:
+The final form is:
+
The balance equation in the z-direction is:
+Where is the dimensionless gravity.
The final form is:
+
The balance equation is:
+Where is the dimensionless air density.
The final form is:
+
Implementing this in julia is very straightforward, starting with the model constants
+# constants from Ooms 1972
+const λ² = 1.35
+const α₁ = 0.057
+const α₂ = 0.5
+const α₃ = 1.0
+const ϵ = 0.0
+const Cd = 0.3# integration constants
+const C₁ = 1-exp(-2)
+const C₂ = λ²*(1-exp(-2/λ²))
+const C₃ = (λ²/(λ²+1))*(1-exp(-2*(λ²+1)/λ²))
+const C₄ = (1-exp(-4))/4
+const C₅ = (λ²/(4λ²+2))*(1-exp(-(4λ²+2)/λ²))# physical constants
+const g = 9.80665 # standard gravity, m/s²
+const MWₐ = 0.0289652 # molar weight dry air, kg/mol
+const cpₐ = 1.006 # specific heat dry air, kJ/kg/K
+const ρₐ₀ = 1.2250 # standard density dry air, kg/m³The standard way of writing a differential algebraic equation is in the form of a mass matrix, M:
+Where state is the state vector for this system. In this case M is not a constant, it is a function of the state of the system as well. Below is a function that calculates the mass matrix for a given state of the system, this is done in place to reduce the number of allocations required. The state variables are all in dimensionless form – the overbars are implied.
function ooms_matrix!(M,state,p,s)
+ # unpack variables for readability
+ c, b, u, θ, ρ, x, z = state
+
+ # calculate atmospheric conditons at centerline elevation
+ ρₐ_bar = p.rhoa_bar(z)
+
+ # species balance
+ M[1,1] = b*( C₃*u + C₂*cos(θ) )
+ M[1,2] = 2*c*( C₃*u + C₂*cos(θ) )
+ M[1,3] = C₃*c*b
+ M[1,4] = -C₂*c*b*sin(θ)
+ M[1,5] = 0
+ M[1,6] = 0
+ M[1,7] = 0
+
+ # overall mass balance
+ M[2,1] = 0
+ M[2,2] = 2cos(θ)*(2 + C₂*ρ) + 2u*(C₁ + C₃*ρ)
+ M[2,3] = b*(C₁ + C₃*ρ)
+ M[2,4] = -b*sin(θ)*(2 + C₂*ρ)
+ M[2,5] = b*(C₂*cos(θ) + C₃*u)
+ M[2,6] = 0
+ M[2,7] = 0
+
+ # x momentum balance
+ M[3,1] = 0
+ M[3,2] = 2cos(θ)*( 2u^2*(C₄ + C₅*ρ) + 2u*cos(θ)*(C₁ + C₃*ρ)
+ + cos(θ)^2*(2 + C₂*ρ))
+ M[3,3] = 2b*cos(θ)*( cos(θ)*(C₁ + C₃*ρ) + 2u*(C₄ + C₅*ρ) )
+ M[3,4] = -b*sin(θ)*( cos(θ)*( 4u*(C₁ + C₃*ρ) + 3cos(θ)*(2 + C₂*ρ) )
+ + 2u^2*(C₄ + C₅*ρ) )
+ M[3,5] = b*cos(θ)*( C₂*cos(θ)^2 + 2C₃*u*cos(θ) + 2C₅*u^2 )
+ M[3,6] = 0
+ M[3,7] = 0
+
+ # z momentum balance
+ M[4,1] = 0
+ M[4,2] = 2sin(θ)*( 2u*cos(θ)*(C₁ + C₃*ρ) + cos(θ)^2*(2 + C₂*ρ)
+ + 2u^2*(C₄ + C₅*ρ))
+ M[4,3] = 2b*sin(θ)*(cos(θ)*(C₁ + C₃*ρ) +2u*(C₄ + C₅*ρ))
+ M[4,4] = b*(2u*(cos(θ)^2 - sin(θ)^2)*(C₁ + C₃*ρ)
+ + (1-3sin(θ)^2)*cos(θ)*(2 + C₂*ρ)
+ + 2u^2*cos(θ)*(C₄ + C₅*ρ))
+ M[4,5] = b*sin(θ)*(C₂*cos(θ)^2 + 2C₃*u*cos(θ) + 2C₅*u^2)
+ M[4,6] = 0
+ M[4,7] = 0
+
+ # energy balance
+ M[5,1] = 0
+ M[5,2] = 2(2cos(θ) + C₁*u - ρₐ_bar*(u*(C₁ + C₃*ρ) + cos(θ)*(2 + C₂*ρ)) )
+ M[5,3] = b*( C₁ - ρₐ_bar*(C₁ + C₃*ρ) )
+ M[5,4] = -b*sin(θ)*( 2 - ρₐ_bar*(2 + C₂*ρ) )
+ M[5,5] = -b*ρₐ_bar*( C₂*cos(θ) + C₃*u )
+ M[5,6] = 0
+ M[5,7] = 0
+
+ # x coordinate
+ M[6,1:5] .= 0
+ M[6,6] = 1
+ M[6,7] = 0
+
+ # z coordinate
+ M[7,1:6] .= 0
+ M[7,7] = 1
+endIn dimensionless form, the only parameter of the system that is relevant to the mass matrix is which is a function of the (dimensionless) elevation.
The right-hand-side of the system of equations is below, and is also in place. In this case there are three parameters: ,
and
. These are all functions of elevation, the latter two because
is a function of elevation.
function ooms_rhs!(f,state,p,s)
+ # unpack variables for readability
+ c, b, u, θ, ρ, x, z = state
+
+ # calculate atmospheric conditons at centerline elevation
+ ρₐ_bar = p.rhoa_bar(z)
+ g_bar = p.g_bar(z)
+
+ # entrainment
+ u′ = p.uprime_bar(b, z)
+ E = α₁*abs(u) + α₂*abs(sin(θ))*cos(θ) + α₃*u′
+ sgn = θ<0 ? -1 : +1
+
+ f .= [ 0 # species balance
+ 2E # overall mass balance
+ 2E + Cd*abs(sin(θ)^3) # x momentum balance
+ -C₂*b*ρ*g_bar + sign(θ)*Cd*sin(θ)^2*cos(θ) # z momentum balance
+ 2*(1-ρₐ_bar)*E # energy balance
+ cos(θ) # x coordinate
+ sin(θ)] # z coordinate
+endThe most direct way of implementing the model is as an ODE where
+Though instead of taking the matrix inverse a linear solve is done. This is what you might call the conventional approach, or traditional approach perhaps. People who spend a lot of time with DAEs and numerical computation will tell you not to do this – it can be unstable and fail if M is singular or near-singular – but it is also throughout the literature, especially in older code. For example, this is how DEGADIS implements the right-hand-side of the ODE.
+using OrdinaryDiffEqfunction ode_rhs!(dstate,state,p,s)
+ ooms_matrix!(p.M,state,p,s)
+ ooms_rhs!(p.f,state,p,s)
+ dstate[:] = p.M\p.f
+endInstead of allocating (and garbage collecting) a matrix M and vector f every time the right-and-side is called, I pre-allocate them and store them with the model parameters as a kind of scratch space.
+For a working example, suppose the vent is releasing into a neutral atmosphere with no density gradient and a windspeed at the stack height of 2m/s
+const dρₐdz = 0.0
+const uₐ₀ = 2.0 # m/sThe vent itself is basically air but hotter and thus at a lower density. The vent stack is 2m from the ground and 20cm in diameter, the vent is being ejected at 10m/s vertically. I am also ignoring the zone of flow establishment and having the plume start exactly at the vent exit.
+const MWⱼ = MWₐ # kg/m³
+const cpⱼ = cpₐ # kJ/kg/K
+const ρⱼ = ρₐ₀/2 # kg/m³
+const D = 0.2 # m
+const u₀ = 10.0 # m/s
+const h = 2.0 # m
+const θ₀ = π/2
+const c₀ = ρⱼThe system parameters are simply the scratch space for M and f, and the three dimensionless groups which are each functions of elevation. In this case I am further assuming that windspeed is uniform.
+params = (M = zeros(7,7),
+ f = zeros(7),
+ rhoa_bar = (z) -> 1.0 + (dρₐdz/ρₐ₀)*D*z,
+ g_bar = (z) -> (g*D)/uₐ₀^2,
+ uprime_bar = (b, z) -> ∛(ϵ*b*D)/uₐ₀)The initial state, in dimensionless form, is then
+state0 = [ 1.0 ,# c
+ 1/(2√(2)) ,# b
+ u₀/uₐ₀ ,# u
+ θ₀ ,# θ
+ (ρⱼ - ρₐ₀)/ρₐ₀ ,# ρ
+ 0.0 ,# x
+ h/D ]# zThe initial value for might seem strange and arbitrary, but this comes from matching the initial dimensions of the plume to the exit of the vent stack. Recall the plume radius is
so, if the plume initially has a radius equal to the vent
and
Integrating out 100 stack diameters along the plume
+span = (0.0, 100)
+prob = ODEProblem(ode_rhs!, state0, span, params)sol = solve(prob, Tsit5())
+
+sol.retcodeReturnCode.Success = 1
+Nesting the linear solve step within the right-hand-side of the ODE can be dangerous if M ever becomes singular, or close to it. It is probably safer to use a DAE solver instead.
+DAE solvers expect to be solving a differential algebraic equation of the form:
+Using the matrix and rhs functions defined earlier this easy enough to do, in this case the function is in-place.
+function dae_lhs!(resid,dstate,state,p,s)
+ ooms_matrix!(p.M,state,p,s)
+ ooms_rhs!(p.f,state,p,s)
+ resid[:] = p.M*dstate - p.f
+endThe DAE solver also needs an initial state for all of the derivatives, which can be calculated by solving the linear system for the derivatives given the initial conditions.
+M0 = zeros(7,7)
+ooms_matrix!(M0,state0,params,0)
+
+f0 = zeros(7)
+ooms_rhs!(f0,state0,params,0)
+
+dstate0 = M0\f0diff_vars = fill(true, 7)
+daeprob = DAEProblem(dae_lhs!, dstate0, state0, span, params;
+ differential_vars = diff_vars)The DAEProblem also needs a hint as to which are differential equations, this is what is being passed by the differential_vars keyword argument. In this case they are all differential equations so I pass a vector of seven trues.
The DAE solver I am going to use is IDA from Sundials.
using Sundialsdaesol = solve(daeprob, IDA())
+
+daesol.retcodeReturnCode.Success = 1
+This works as well as the lazy method, slightly slower but it has not been implemented in a particularly optimal way.
+If you know anything about the universe of tools in julia for modelling differential algebraic equations you are probably yelling at your screen “use ModelingToolkit!”. In terms of getting a DAE from nothing to a working model it is by far the easiest way to do it. I deliberately put all of the working out in this blog post because it annoys me that it is so hard to find online and I want it to be somewhere. But if I didn’t care about that, ModelingToolkit is the obvious choice.
+using ModelingToolkit, Symbolics
+using ModelingToolkit: t_nounits as s, D_nounits as ∂
+
+# I would use D for derivative but I'm already using
+# that for jet diameter so I'm using ∂ insteadFirst I define the system variables, again these are in dimensionless form.
+vars = @variables c(s) b(s) u(s) θ(s) ρ(s) x(s) z(s)If this wasn’t in a notebook that includes other methods of solving the DAE, I would have declared the model constants using the @constants macro. It makes the formulas look nicer for one, e.g. instead of numbers like 0.86466 there would be the appropriate constant .
# conservation of mass
+∫ρurdr = b^2*( (C₁ + C₃*ρ)*u + (2 + C₂*ρ)*cos(θ) )
+
+E = α₁*abs(u) + α₂*abs(sin(θ))*cos(θ) + α₃*∛(ϵ*b*D)/uₐ₀
+
+eqn1 = expand_derivatives( ∂( ∫ρurdr ) ) ~ 2*b*E# conservation of species
+∫curdr = c*b^2*(C₂*cos(θ) + C₃*u)
+
+eqn2 = expand_derivatives( ∂( ∫curdr ) ) ~ 0# conservation of momentum
+# x-direction
+∫ρu²cosθrdr = b^2*cos(θ)*(2u*cos(θ)*(C₁ + C₃*ρ) + 2u^2*(C₄ + C₅*ρ)
+ + cos(θ)^2*(2 + C₂*ρ))
+
+eqn3 = expand_derivatives( ∂( ∫ρu²cosθrdr ) ) ~
+ b*( 2E + Cd*abs(sin(θ)^3) )# z-direction
+∫ρu²sinθrdr = b^2*sin(θ)*(2u*cos(θ)*(C₁ + C₃*ρ) + 2u^2*(C₄ + C₅*ρ)
+ + cos(θ)^2*(2 + C₂*ρ))
+
+eqn4 = expand_derivatives( ∂( ∫ρu²sinθrdr ) ) ~
+ -C₂*b^2*ρ*(g*D/uₐ₀^2) + sign(θ)*Cd*b*sin(θ)^2*cos(θ)# energy balance
+ρₐ_bar = 1 + dρₐdz*D*z/ρₐ₀
+
+∫ρucₚΔTrdr = b^2*(2cos(θ) + C₁*u - ρₐ_bar*( u*(C₁ + C₃*ρ)
+ + cos(θ)*(2 + C₂*ρ) ))
+
+eqn5 = expand_derivatives( ∂( ∫ρucₚΔTrdr ) ) ~ 2*b*(1 - ρₐ_bar)*E# The full system of equations
+
+eqns = [ eqn1
+ eqn2
+ eqn3
+ eqn4
+ eqn5
+ ∂(x) ~ cos(θ)
+ ∂(z) ~ sin(θ) ]Symbolics.jl has done all the derivatives and set up all the equations, what remains is to build ODESystem and solve.
@named sys = ODESystem(eqns, s)
+sys = structural_simplify(sys)In this case there are no model parameters as I inserted the equations for the dimensionless groups directly into the model.
+mtk_params = ()The initial values simply map over the initial state I worked out previously. Because ModelingToolkit generates its own internal structure and shuffles things around, a mapping needs to be provided for the initial conditions.
+initial_vals = [ c => state0[1],
+ b => state0[2],
+ u => state0[3],
+ θ => state0[4],
+ ρ => state0[5],
+ x => state0[6],
+ z => state0[7] ]mtk_prob = ODEProblem(sys, initial_vals, span)mtk_sol = solve(mtk_prob, Rodas5P())
+
+mtk_sol.retcodeReturnCode.Success = 1
+In terms of julia code that needed to be written, and calculus that needed to be done, this the simplest by far. Simply compare to the enormous mass matrix expression above to convince yourself of that. There are also code generation tools that can be used if you want to extract the model either as a julia script or even C code. Furthermore, if you want to go through term by term and look at the coefficients for each derivative, Symbolics.jl can do that too. I actually used Symbolics to check all of my work in the mass matrix.
+Another approach to the ODE problem is to use a matrix operator. This is a mass matrix problem with a state dependent mass matrix, which is one of the use cases for SciMLOperators.jl
+using SciMLOperatorsM = MatrixOperator(zeros(7,7); update_func! = ooms_matrix!)massprob = ODEProblem(ODEFunction(ooms_rhs!, mass_matrix=M), state0, span, params)mass_sol = solve(massprob,Rodas5P(); initializealg=BrownFullBasicInit())
+
+mass_sol.retcode┌ Warning: At t=0.0, dt was forced below floating point epsilon 5.0e-324, and step error estimate = NaN. Aborting. There is either an error in your model specification or the true solution is unstable (or the true solution can not be represented in the precision of Float64). + +└ @ SciMLBase ~/.julia/packages/SciMLBase/rvXrA/src/integrator_interface.jl:623 ++
ReturnCode.Unstable = 7
+I tried a bunch of different solvers and initialization algorithms, nothing could get past the first timestep. That there are two working versions of this system, in this post, using the same exact mass matrix function leads me to suspect it is not a model error, or that the solution is unstable. There is probably some aspect to how I’m supposed to be initializing this problem, or some other feature of using matrix operators, that I’m doing wrong, but I find the documentation on that to be mostly absent. I know these can work because I have used this exact method on simpler systems in the past.
+If I ever figure out what I need to do to make this work, or more definitively why it doesn’t, I’ll come back and update this. Consider this an invitation to tell me all the ways I’m doing this wrong in the comments.
+The plume solution is fundamentally in terms of the plume axis. It is not immediately obvious how to calculate the concentrations at particular points in space relative to the problem coordinate system. The way I see it, there are three related problems that involve calculating concentrations from the Ooms model.
+These all stem from the problem that for some arbitrary point not on the plume axis, it is not immediately clear which part of the plume axis is governing the concentration there. This is because the concentration profiles are not perpendicular to the x-axis, they are perpendicular to the s-axis and that curves through space.
+The easiest problem to solve is the isopleths in the plane . Suppose we want to calculate the isopleth for some concentration
. Recalling the concentration profile:
Where is the center line concentration at that point along the plume axis. We first solve for
, the distance from the plume axis:
Converting from cylindrical coordinates to Cartesian coordinates, , aligned such that
is aligned with the plume axis, the radius is
Since we are confined to the plane , we find
. Then we rotate the axis to align with the problem coordinate system and translate the origin to the problem origin.
Where and
is the location of the particular point on the plume axis we were looking at. The origin relative to the point on the plume axis, hence the subscript o. The positive r gives the upper isopleth and the negative r gives the lower isopleth.
Casal7 provides an alternative form of these isopleths:
+and
+These are actually equivalent, using the identity and the definition
, the first equation can be written as:
The second equation can be re-written to solve for x:
+
7 Casal, “Atmospheric Dispersion of Toxic or Flammable Clouds,” 306.
function upper_isopleth(solution, s, c)
+ cₒ, bₒ, uₒ, θₒ, ρₒ, xₒ, zₒ = solution(s)
+
+ if c ≈ cₒ
+ return Point(xₒ,zₒ)
+ elseif c > cₒ
+ return nothing
+ else
+ r = bₒ * √(λ²*log(cₒ/c))
+ x = xₒ - r*sin(θₒ)
+ z = zₒ + r*cos(θₒ)
+ return Point(x,z)
+ end
+endfunction lower_isopleth(solution, s, c)
+ cₒ, bₒ, uₒ, θₒ, ρₒ, xₒ, zₒ = solution(s)
+
+ if c ≈ cₒ
+ return Point(xₒ,zₒ)
+ elseif c > cₒ
+ return nothing
+ else
+ r = bₒ * √(λ²*log(cₒ/c))
+ x = xₒ + r*sin(θₒ)
+ z = zₒ - r*cos(θₒ)
+ return Point(x,z)
+ end
+endFor an example, suppose we want the isopleth for
cₗ = 0.02 # c/c₀ = 2%First, I find the point along the plume axis where the concentration drops below 2%, there is no point in looking for an isopleth past this point since it doesn’t exist.
+using Roots: find_zeroi_end = findfirst(sol[1,:] .< cₗ )20
+s_end = find_zero( (s) -> sol(s, idxs = 1) - cₗ, sol.t[i_end])46.23790952011145
+Then I can calculate a series of points for the upper isopleth and the lower isopleth from the origin out to where the plume concentration has dropped below 2%.
+upper_points = [ upper_isopleth(sol, s, cₗ) for s in LinRange(0.0, s_end, 100)
+ if !isnothing(upper_isopleth(sol, s, cₗ))];
+lower_points = [ lower_isopleth(sol, s, cₗ) for s in LinRange(0.0, s_end, 100)
+ if !isnothing(lower_isopleth(sol, s, cₗ))];A somewhat more difficult problem is finding the isopleths on the plane z=a. The logic is the same: for each point along the plume axis, work out the distance r to the given concentration, then solve for y given z=a.
+function cross_isopleth(solution, s, c, a)
+ cₒ, bₒ, uₒ, θₒ, ρₒ, xₒ, zₒ = solution(s)
+
+ if c ≈ cₒ
+ return Point(xₒ,0.0)
+ elseif c > cₒ
+ # isopleth doesn't exist here
+ return nothing
+ else
+ # find the x coordinate
+ xₒ′ = xₒ*cos(θₒ) + zₒ*sin(θₒ)
+ x = (xₒ′ - a*sin(θₒ))*sec(θₒ)
+
+ # find the y coordinate
+ r² = bₒ^2 * λ²*log(cₒ/c)
+ z′² = ((a - zₒ)*cos(θₒ) - (x - xₒ)*sin(θₒ))^2
+
+ if z′² > r²
+ # the isosurface doesn't intersect z=a
+ return nothing
+ else
+ y′ = √( r² - z′²)
+ y = y′
+ return Point(x,y)
+ end
+ end
+endPicking an arbitraty height of 20 stack diameters in elevation.
+a = 20 # 20 stack diametersWe need to find the start and end of the isopleth, which not immediately obvious like it was of the isopleths in the plane y=0. But we can re-use the vertical isopleths – the start of the isopleth is the point where the upper isopleth intersects z=a and the end is where the lower isopleth intersects it. I have used the word isopleth a lot, hopefully it makes sense and has not lost all meaning.
+# the start of the isopleth
+s_start = find_zero( (s) -> upper_isopleth(sol, s, cₗ)[2] - a, 14)14.87383455152286
+# the end of the isopleth
+s_end = find_zero( (s) -> lower_isopleth(sol, s, cₗ)[2] - a, 33)33.55677883331305
+cross_points = [ cross_isopleth(sol, s, cₗ, a) for s in LinRange(s_start+1e-3, s_end, 100)
+ if !isnothing(cross_isopleth(sol, s, cₗ, a))]flipped_points = [ Point( pt[1], -1*pt[2] ) for pt in cross_points ]Calculating the concentration at some arbitrary point involves first backing out where along the plume axis the concentration is coming from, then calculating the concentration using the Gaussian profile.
+To find the location on the axis that governs the concentration at the point, i.e. the location on the axis where a vector connecting it to the arbitrary point is perpendicular to the plume axis, I basically just rotate the problem coordinate system to align with the plume axis and check. Since the ODE solution includes a set of pre-calculated points, I use it to generate an initial guess of where to look and then use Newton’s method to find the exact location s.
+function find_centerline(solution,x,y,z)
+ function perp_test(s)
+ θₒ, _, xₒ, zₒ = solution(s, idxs=4:7)
+ x′ = x*cos(θₒ) + z*sin(θₒ)
+ xₒ′ = xₒ*cos(θₒ) + zₒ*sin(θₒ)
+ return x′ - xₒ′
+ end
+
+ # find initial guess
+ i0 = argmin( [ abs(perp_test(s)) for s in sol.t ] )
+ s0 = sol.t[i0]
+
+ # find the zero point
+ return find_zero(perp_test, s0)
+endThe concentration then builds on this by first finding the location along the plume axis that connects to the arbitrary point, calculating the distance r from the plume axis to the point, and finally returning the concentration.
+function concentration(solution,x,y,z)
+ # get the point on the centerline that governs this point
+ sₒ = find_centerline(solution,x,y,z)
+ cₒ, bₒ, uₒ, θₒ, ρₒ, xₒ, zₒ = sol(sₒ)
+
+ # rotate the coordinate system to the plume axis
+ y′ = y
+ z′ = (z - zₒ)*cos(θₒ) - (x - xₒ)*sin(θₒ)
+ r² = (y′)^2 + (z′)^2
+
+ # calculate concentration
+ c = cₒ*exp(-r²/(bₒ^2*λ²))
+
+ return c
+endHow do I know this is actually working? I don’t really have test data to compare against. But I do have some isopleths that I calculated independently (though using the same trig), I can check that the concentration at those points is indeed what it is supposed to be (2%).
+upper_concentrations = [ concentration(sol, pt[1], 0, pt[2]) for pt in upper_points ]
+lower_concentrations = [ concentration(sol, pt[1], 0, pt[2]) for pt in lower_points ]
+cross_concentrations = [ concentration(sol, pt[1], pt[2], a) for pt in cross_points ]Indeed it does recover the concentrations as expected. There is one massive caveat though, it is assuming that there is only one location on the plume axis where a line connecting the point to the plume is perpendicular to the plume axis. If the plume is strongly curving, such as when a dense plume is emitted and bends back down to earth, this is no longer true. I think the basic assumptions of the plume itself start to break down once the plume bends back and intersects itself. I don’t think there really is a “correct” answer for how to calculate the concentration there.
+The plume model only assumes that the vent gas has a similar molar weight and heat capacity to air. It is still possible to have a negatively buoyant plume, this would be equivalent to a vent of cryogenic gas. In this case the plume will crash to the ground and…continue going. There is nothing in the Ooms model that requires z to be positive. If we assume the initial condition is where h is the height of the vent stack, we can use a simple callback function to trigger once the integrator has crossed the ground plane and reflect the plume back.
ground_check(state, s, i) = state[7] # zfunction reflect_plume!(integrator)
+ # bounce off the ground
+ integrator.u[4] = abs(integrator.u[4]) # θ
+ integrator.u[7] = 0 # z
+endground_cb = ContinuousCallback(ground_check, reflect_plume!)This makes the plume bounce along the ground. An alternative, and what DEGADIS does, is to terminate the integration once the plume contacts the ground and transition to another model.
+dense_state0 = [ 1.0 ,# c
+ 1/(2√(2)) ,# b
+ u₀/uₐ₀ ,# u
+ θ₀ ,# θ
+ 10.0 ,# ρ
+ 0.0 ,# x
+ h/D ]# zdense_prob = ODEProblem(ode_rhs!, dense_state0, span.*2, params)dense_sol = solve(dense_prob, Tsit5(); callback=ground_cb)
+
+dense_sol.retcodeReturnCode.Success = 1
+ModelingToolkit implements callbacks a little bit differently, as symbolic equations.
+ground = [ z ~ 0 ]
+reflect = [ θ ~ -Pre(θ) ]Which are then added to the ODESystem
@named dense_sys = ODESystem(eqns, s; continuous_events= ground => reflect)
+dense_sys = structural_simplify(dense_sys)dense_vals = [ c => dense_state0[1],
+ b => dense_state0[2],
+ u => dense_state0[3],
+ θ => dense_state0[4],
+ ρ => dense_state0[5],
+ x => dense_state0[6],
+ z => dense_state0[7] ]dense_prob_mtk = ODEProblem(dense_sys, dense_vals, span.*2)dense_sol_mtk = solve(dense_prob_mtk, Rodas5P())
+
+dense_sol_mtk.retcodeReturnCode.Success = 1
+I make no claims that this is a reasonable thing for the plume to do. It is mostly just for fun. If the reflect callback was changed to terminate!, then the plume would halt when the center line impacted the ground. There is also a case to be made that once the plume boundary impacts the ground, , then the integration should terminate and another model used. This is basically what DEGADIS does, once the plume is at ground level it transitions to another model for grounded plumes.
It is all fine and good to say “well, those look like plausible curves,” I would like to have some validation that this is actually working as intended. For some confirmation I pulled data points from figure 3 in Ooms8 using a graph digitizer. I chose that figure since covers most of the range of the z-axis. The other two figures are squashed down, making it difficult to get good resolution on the data points.
+8 Ooms, “A New Method for the Calculation of the Plume Path of Gases Emitted by a Stack,” 907.
Unfortunately while Ooms provides most of the dimensionless groups needed to generate the plots, it is missing two important ones:
+The actual dimensions and starting location of the plume will depend on how the zone of flow establishment is calculated, and those details are missing from the paper. I assumed the initial plume dimension , which corresponds to the plume starting with an identical width to the jet. Further I just picked a flow establishment of ~6.5 diameters, which is reasonable for a free jet. This recreates the curve really well.
Putting aside basically guessing the length of the zone of flow establishment, which feels pretty sketchy, that the curve has the correct shape and reproduces figure 3 in the paper is decent validation. Adjusting the initial height simply translates the curve up and down, it doesn’t impact the result otherwise.
+I tracked down the original reference9 for the figure 3 data, and the zone of flow establishment is 6.2 diameters. Which, at the resolution of this plot, is indistinguishable from my guess of ~6.5. I think that safely puts this in the “validated” camp.
+9 Fan, “Turbulent Buoyant Jets into Stratified or Flowing Ambient Fluids”.
# x/D z/D
+lfn_data = [ 16.628 19.953;
+ 43.054 29.86;
+ 46.366 32.233;
+ 61.684 36.698;
+ 84.591 42.558;
+ 109.085 48.977]lfn_prms = (M = zeros(7,7),
+ f = zeros(7),
+ rhoa_bar = (z) -> 1.0,
+ g_bar = (z) -> 4.278,
+ uprime_bar = (b,z) -> 0.0)# initial values
+lfn_state0 = [ 1.0 ,# c
+ 1/(2√(2)) ,# b
+ 8.0 ,# u
+ π/2 ,# θ
+ -0.148 ,# ρ
+ 0.0 ,# x
+ 6.5 ]# zlfn_prob = ODEProblem(ode_rhs!, lfn_state0, (0.0,150.0), lfn_prms)lfn_sol = solve(lfn_prob, Tsit5())This actually relates to one of the main difficulties in finding test data to compare against, to validate that my code is working. Results from the first version of DEGADIS are not directly applicable as DEGADIS initializes a jet using a different algorithm and the inputs into the Ooms model are not the jet parameters passed to DEGADIS. That’s assuming that I could even find DEGADIS results where the plume had the same molar weight and heat capacity as air, at which point the DEGADIS model reduces down to the original Ooms model. It has a different energy balance and for all other situations would be expected to generate different results.
+That I can recreate the figures from the original paper and that the first 4 balance equations given here are equivalent to what is given in the DEGADIS documentation (once rendered dimensionless and with the corresponding constants substituted), and the 5th equation matches in the special case of the vent gas being air and the atmosphere having no density gradient (the right hand side of the equation is zero) leaves me pretty confident that my result is correct. I also have the advantage of being able to cross-check my integrals and all those derivatives using a CAS. But it would be more satisfying if I had some unambiguous test cases to reproduce.
+I only implemented the first version of the Ooms model. There are two subsequent papers that make modifications which may be worth implementing. The first significant modification is a more complex energy balance, which is the basis for the DEGADIS implementation of Ooms, in this case the molar weight and heat capacity of the plume are calculated from the concentration in the plume. This makes the integral vastly more complex and it might make sense to try this model out while numerically integrating the balance at each step. The second significant modification is a change to the plume shape. The Ooms model assumes the plume has a circular cross-section, which is known to be incorrect for plumes dispersing in the atmosphere. The plume can be modified to an elliptical cross section in such a way as to preserve the cross-sectional area while better matching the observed shapes of real plumes. I did not implement either of these mostly because I wanted the “minimal viable plume model” first. This can be a known-working starting point on top of which these modifications can be made.
+Another obvious modification is to add ground-reflection. Once the plume has been solved, and there is a way to calculate concentrations at arbitrary points, it is not a huge challenge to add in ground-reflection. That is, managing the situation once the plume disperses into the ground. For conventional Gaussian plumes the typical assumption is that the plume simply reflects off and the concentration in this zone is the sum of the normal plume concentration and the concentration of reflected plume. Something similar could be done for Ooms as well.
+Thus my project for the Victoria Day long weekend was to figure out how to collect data from my atmotube using python. This works on my laptop but could, presumably, be ported to something like a raspberry pi easily enough.
+The Atmotube documentation gives two main ways of getting data from the device: using GATT or just passively from the advertising data the Atmotube broadcasts when it isn’t connected to anything (the BLE advertising packet). The most straightforward, to retrieve something specific, is via GATT.
+I am going to be using Bleak to scan and connect to BLE devices. To start I need a BleakScanner to scan for devices and, once I have found the one I want, connect to it as a BleakClient. Then, to make the various requests, I need the corresponding UUIDs – these correspond to specific packets of data as described in the docs
import timefrom bleak import BleakScanner, BleakClient# some constants
+ATMOTUBE = "C2:2B:42:15:30:89" # the mac address of my Atmotube
+SGPC3_UUID = "DB450002-8E9A-4818-ADD7-6ED94A328AB4"
+BME280_UUID = "DB450003-8E9A-4818-ADD7-6ED94A328AB4"
+SPS30_UUID = "DB450005-8E9A-4818-ADD7-6ED94A328AB4"
+STATUS_UUID = "DB450004-8E9A-4818-ADD7-6ED94A328AB4"The function scan_and_connect scans for the device which matches the mac address of my Atmotube, then proceeds to request each of the four packets of data. This simply returns a tuple with the data and the timestamp.
async def scan_and_connect(address):
+ device = await BleakScanner.find_device_by_address(address)
+ if not device:
+ print("Device not found")
+ return None
+
+ async with BleakClient(device) as client:
+ stat = await client.read_gatt_char(STATUS_UUID)
+ bme = await client.read_gatt_char(BME280_UUID)
+ sgp = await client.read_gatt_char(SGPC3_UUID)
+ sps = await client.read_gatt_char(SPS30_UUID)
+ ts = time.time()
+ return (ts, stat, bme, sgp, sps)I can connect and get a single data point, but what I have is a timestamp and a collection of bytes. It is not cleaned up and readable in any way.
+res = await scan_and_connect(ATMOTUBE)The easiest way to unpack a sequence of bytes is to use the struct standard library. But there are two exceptions:
+import structfrom ctypes import LittleEndianStructure, c_uint8, c_int8
+
+class InfoBytes(LittleEndianStructure):
+ _fields_ = [
+ ("pm_sensor", c_uint8, 1),
+ ("error", c_uint8, 1),
+ ("bonding", c_uint8, 1),
+ ("charging", c_uint8, 1),
+ ("charge_timer", c_uint8, 1),
+ ("bit_6", c_uint8, 1),
+ ("pre_heating", c_uint8, 1),
+ ("bit_8", c_uint8, 1),
+ ("batt_level", c_uint8, 8),
+ ]int.from_bytes() directly, but I think this is a little neater and easier to read.class PMBytes(LittleEndianStructure):
+ _fields_ = [
+ ('_pm1', c_int8*3),
+ ('_pm2_5', c_int8*3),
+ ('_pm10', c_int8*3),
+ ('_pm4', c_int8*3),
+ ]
+ _pack_ = 1
+
+ @property
+ def pm1(self):
+ return int.from_bytes(self._pm1, 'little', signed=True)
+
+ @property
+ def pm2_5(self):
+ return int.from_bytes(self._pm2_5, 'little', signed=True)
+
+ @property
+ def pm10(self):
+ return int.from_bytes(self._pm10, 'little', signed=True)With those two pieces out of the way, I define the actual variables I want – these are the column names I want to have in the final dataframe – and process the bytes. The first step is to use the InfoByte struct I defined above to pull out the flags and battery status, I add this to the results more for my own interest. Then I use struct.unpack() to unpack the integers from each byte-string and store the results.
Finally I use the PMBytes class to process the PM data. If the sensor isn’t on the results are -1 and so I clean those out. The idea is to leave any blank readings as None, since that is easy to filter out with pandas later on.
HEADERS = ["Timestamp", "VOC", "RH", "T", "P", "PM1", "PM2.5", "PM10"]def process_gatt_data(data):
+ result = dict.fromkeys(HEADERS)
+ if res is not None:
+ ts, stat, bme, sgp, sps = data
+ result["Timestamp"] = ts
+
+ # Info and Battery data
+ inf_bits = InfoBytes.from_buffer_copy(stat)
+ for (fld, _, _) in inf_bits._fields_:
+ result[f"INFO.{fld}"] = getattr(inf_bits, fld)
+
+ # SGPC3 data format
+ tvoc, _ = struct.unpack('<hh', sgp)
+ result["VOC"] = tvoc/1000
+
+ # BME280 data format
+ rh, T, P, T_plus = struct.unpack('<bblh', bme)
+ result["RH"] = rh
+ result["T"] = T_plus/100
+ result["P"] = P/1000
+
+ # SPS30 data format
+ pms = PMBytes.from_buffer_copy(sps)
+ result["PM1"] = pms.pm1/100 if pms.pm1 > 0 else None
+ result["PM2.5"] = pms.pm2_5/100 if pms.pm2_5 > 0 else None
+ result["PM10"] = pms.pm10/100 if pms.pm10 > 0 else None
+
+ return resultNow I can process the result I collected earlier.
+process_gatt_data(res){'Timestamp': 1747673644.60206,
+ 'VOC': 0.223,
+ 'RH': 32,
+ 'T': 21.3,
+ 'P': 93.37,
+ 'PM1': 1.0,
+ 'PM2.5': 2.18,
+ 'PM10': 3.27,
+ 'INFO.pm_sensor': 1,
+ 'INFO.error': 0,
+ 'INFO.bonding': 0,
+ 'INFO.charging': 0,
+ 'INFO.charge_timer': 1,
+ 'INFO.bit_6': 0,
+ 'INFO.pre_heating': 1,
+ 'INFO.bit_8': 0,
+ 'INFO.batt_level': 63}
+The results are what I expect for my apartment. In addition to the air quality data, we can see that the PM sensor was on and that the Atmotube had been charging recently.1 The pre-heat flag indicates that the device has completed any pre-heating and is ready. So everything looks good.
+1 I unplugged it before charging was done so it wouldn’t interfere with any temperature readings when I tested this code, that’s why the battery was only at 63%
I could, at this point, just start a service or cron job to poll the device every so often and log the results. It will only return PM results when the atmotube is actively sampling, which could present some issues with timing. If the device is set to sample, for example, every 15 minutes and the script doesn’t make a request during that window, it will never return results. For everything that follows I set my atmotube to sample continuously.
+The other way of logging data from the atmotube is to pull it out of the advertising packet the atmotube broadcasts as a bluetooth device. In this case I don’t actually connect to the device, the scanner runs continuously and sends back any advertising data it finds using the adv_cb() callback function. This checks if the data came from my atmotube and, if it did, adds it to the results.
The scanner runs inside an event loop which starts the scanner, waits until the collection_time has elapsed, then shuts down and returns the results.
import asyncioasync def collect_data(device_mac, collection_time=600):
+ def adv_cb(device, advertising_data):
+ if device.address == device_mac:
+ results.append((time.time(), device, advertising_data))
+ else:
+ pass
+ return None
+
+ async def receiver(event):
+ async with BleakScanner(adv_cb, scanning_mode='active') as scanner:
+ await event.wait()
+
+ results = []
+ loop = asyncio.Event()
+ task = asyncio.create_task(receiver(loop))
+ await asyncio.sleep(collection_time)
+ loop.set()
+ _ = await asyncio.wait([task])
+ return resultsRunning this for 10 seconds lets me collect some example data to play with.
+broadcasts = await collect_data(ATMOTUBE, 10)Processing the advertising packet is similar to what was done with the GATT data, except that it comes in two flavours: the broadcast packet has the basic temperature, pressure, VOC, device status and the scan response packet contains the PM data and is shorter. Here the PM data is at a lower resolution – 16-bit integers – and so they can be unpacked using struct.unpack(). The GATT data returns the PM data to 2 decimal places (and the temperature to 1 decimal place), whereas the advertising packet data is rounded to the nearest whole number.
def process_adv_data(full_data, company_id=int(0xFFFF)):
+ result = dict.fromkeys(HEADERS)
+ if full_data is None:
+ return result
+ else:
+ timestamp, device, advertising_data = full_data
+ result["Timestamp"] = timestamp
+
+ # process advertising data
+ data = advertising_data.manufacturer_data.get(company_id)
+ if len(data) == 12:
+ tvoc, devid, rh, T, P, inf, batt = struct.unpack(">hhbblbb", data)
+ result["VOC"] = tvoc/1000
+ result["RH"] = rh
+ result["T"] = T
+ result["P"] = P/1000
+ elif len(data) == 9:
+ pm1, pm2_5, pm10, fw_maj, fw_min, fw_bld = struct.unpack(">hhhbbb", data)
+ result["PM1"] = pm1 if pm1 > 0 else None
+ result["PM2.5"] = pm2_5 if pm2_5 > 0 else None
+ result["PM10"] = pm10 if pm10 > 0 else None
+ else:
+ pass
+ return resultI can process this and look at examples of the two types of advertising packet
+process_adv_data(broadcasts[0]){'Timestamp': 1747673646.9507601,
+ 'VOC': None,
+ 'RH': None,
+ 'T': None,
+ 'P': None,
+ 'PM1': 1,
+ 'PM2.5': 2,
+ 'PM10': 3}
+process_adv_data(broadcasts[5]){'Timestamp': 1747673647.2869163,
+ 'VOC': 0.208,
+ 'RH': 36,
+ 'T': 21,
+ 'P': 93.357,
+ 'PM1': None,
+ 'PM2.5': None,
+ 'PM10': None}
+The way I have this set up is very wasteful of memory if the atmotube is set-up to only sample periodically. In those cases there will be a lot of packets with no PM data that are being dutifully logged in results. By processing the data as it is retrieved, I can collect only the packets that had measurements in them.
async def better_collect_data(device_mac, collection_time=600):
+ def adv_cb(device, advertising_data):
+ if device.address == device_mac:
+ row = process_adv_data((time.time(), device, advertising_data))
+ if len( [ val for key, val in row.items() if val is not None ]) >1:
+ # only collect results when we actually have a measurement
+ results.append(row)
+ else:
+ pass
+ return None
+
+ async def receiver(event):
+ async with BleakScanner(adv_cb) as scanner:
+ await event.wait()
+
+ results = []
+ loop = asyncio.Event()
+ task = asyncio.create_task(receiver(loop))
+ await asyncio.sleep(collection_time)
+ loop.set()
+ _ = await asyncio.wait([task])
+ return resultsWhich I let collect for 5 minutes
+new_broadcasts = await better_collect_data(ATMOTUBE, 300)At this point we want to actually look at the results and maybe do some stats. By logging the data as a list of dicts, transforming this into a dataframe is very straightforward.
+import pandas as pddf = pd.DataFrame(new_broadcasts)df.describe()| + | Timestamp | +VOC | +RH | +T | +P | +PM1 | +PM2.5 | +PM10 | +
|---|---|---|---|---|---|---|---|---|
| count | +4.100000e+02 | +57.000000 | +57.000000 | +57.0 | +57.000000 | +353.0 | +353.000000 | +353.000000 | +
| mean | +1.747674e+09 | +0.203228 | +35.105263 | +21.0 | +93.351193 | +1.0 | +2.005666 | +3.039660 | +
| std | +8.914660e+01 | +0.002797 | +0.450564 | +0.0 | +0.004576 | +0.0 | +0.184550 | +0.246825 | +
| min | +1.747674e+09 | +0.199000 | +34.000000 | +21.0 | +93.343000 | +1.0 | +1.000000 | +2.000000 | +
| 25% | +1.747674e+09 | +0.201000 | +35.000000 | +21.0 | +93.348000 | +1.0 | +2.000000 | +3.000000 | +
| 50% | +1.747674e+09 | +0.203000 | +35.000000 | +21.0 | +93.351000 | +1.0 | +2.000000 | +3.000000 | +
| 75% | +1.747674e+09 | +0.204000 | +35.000000 | +21.0 | +93.355000 | +1.0 | +2.000000 | +3.000000 | +
| max | +1.747674e+09 | +0.210000 | +36.000000 | +21.0 | +93.361000 | +1.0 | +3.000000 | +4.000000 | +
This shows a real asymmetry in quantity of data found and what was in it – of 410 packets received 353 were PM data and 57 contained the VOC, temperature, etc. data.
+df['Time'] = df['Timestamp'] - df.iloc[0]['Timestamp']
+Plotting the timeseries data shows the PM data is very noisy – largely because it is rounding to the nearest whole integer. I also suspect that I should be cleaning up the scan responses better. Probably a lot of those are duplicates – it is not actually a fresh reading just rebroadcast of what had been read last. I’m not really sure.
+If you are only collecting 5 minutes of data, reading directly into memory like this is reasonable. But probably you want to log the data over a longer stretch of time, and it makes more sense to log the data to a csv – saving it more permanently. The following creates a new csv with the given filename then, for every valid packet processed, appends the results to the csv.
+import csvasync def log_to_csv(device_mac, collection_time=600, file="atmotube.csv"):
+ def adv_cb(device, advertising_data):
+ if device.address == device_mac:
+ row = process_adv_data((time.time(), device, advertising_data))
+ if len( [ val for key, val in row.items() if val is not None ]) >1:
+ # only collect results when we actually have a measurement
+ with open(file, 'a', newline='') as csvfile:
+ writer = csv.DictWriter(csvfile, fieldnames=HEADERS)
+ writer.writerow(row)
+ else:
+ pass
+ return None
+
+ async def receiver(event):
+ async with BleakScanner(adv_cb) as scanner:
+ await event.wait()
+
+ # prepare csv file
+ with open(file, 'w', newline='') as csvfile:
+ writer = csv.DictWriter(csvfile, fieldnames=HEADERS)
+ writer.writeheader()
+
+ # start scanning
+ loop = asyncio.Event()
+ task = asyncio.create_task(receiver(loop))
+
+ # wait until the collection time is up
+ await asyncio.sleep(collection_time)
+ loop.set()
+ _ = await asyncio.wait([task])
+
+ return TrueThe callback function is doing a lot of work and blocking to write to a csv. This is, in general, not a good idea. When I put this together, I figured that the rate of new data from the Atmotube is significantly slower than the time required to process data and write it to a csv. Which is true, but it isn’t really robust. A better solution might be to have the callback put the data into a queue and have a seperate worker process results into the csv.
+To get this going, I just created a csv with the current timestep in the filename – so if I stop and start I don’t clobber previous data – and leave it to run for an hour. I just left this running in jupyter while I switched to a different desktop and went about my life, but a longer-term solution would be in a script that runs in the background.
+import mathnow = math.floor(time.time())
+timestamped_file = f"atmotube-{now}.csv"
+result = await log_to_csv(ATMOTUBE, 3600, timestamped_file)
+
+print("Success!") if result else print("Boo")Success!
+While it is running, you can check on the progress with tail -f %filename, and watch the results come in live on the terminal. Once it is done, the csv can be read into pandas and plotted like before
logged_data = pd.read_csv(timestamped_file)logged_data.describe()| + | Timestamp | +VOC | +RH | +T | +P | +PM1 | +PM2.5 | +PM10 | +
|---|---|---|---|---|---|---|---|---|
| count | +4.823000e+03 | +835.000000 | +835.000000 | +835.0 | +835.000000 | +3988.0 | +3988.000000 | +3988.000000 | +
| mean | +1.747676e+09 | +0.226522 | +34.810778 | +21.0 | +93.320590 | +1.0 | +1.919007 | +2.945587 | +
| std | +1.037307e+03 | +0.012884 | +0.711420 | +0.0 | +0.018019 | +0.0 | +0.328726 | +0.325025 | +
| min | +1.747674e+09 | +0.195000 | +34.000000 | +21.0 | +93.283000 | +1.0 | +1.000000 | +2.000000 | +
| 25% | +1.747675e+09 | +0.217000 | +34.000000 | +21.0 | +93.303000 | +1.0 | +2.000000 | +3.000000 | +
| 50% | +1.747676e+09 | +0.230000 | +35.000000 | +21.0 | +93.325000 | +1.0 | +2.000000 | +3.000000 | +
| 75% | +1.747677e+09 | +0.237000 | +35.000000 | +21.0 | +93.337000 | +1.0 | +2.000000 | +3.000000 | +
| max | +1.747678e+09 | +0.249000 | +37.000000 | +21.0 | +93.355000 | +1.0 | +3.000000 | +4.000000 | +
logged_data['Time'] = logged_data['Timestamp'] - logged_data.iloc[0]['Timestamp']
+The atmotube is also logging data to its internal memory, so I exported that and plotted it against what was broadcast.
+export_data = pd.read_csv('atmotube-export-data.csv')
+export_data.describe()| + | VOC, ppm | +AQS | +Air quality health index (AQHI) - Canada | +Temperature, °C | +Humidity, % | +Pressure, kPa | +PM1, ug/m3 | +PM2.5, ug/m3 | +PM2.5 (avg 3h), ug/m3 | +PM10, ug/m3 | +PM10 (avg 3h), ug/m3 | +Latitude | +Longitude | +
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| count | +66.000000 | +66.000000 | +66.0 | +66.0 | +66.000000 | +66.000000 | +66.0 | +66.000000 | +66.000000 | +66.000000 | +66.000000 | +0.0 | +0.0 | +
| mean | +0.239985 | +85.045455 | +1.0 | +21.0 | +34.484848 | +93.316364 | +1.0 | +1.530303 | +1.559175 | +2.545455 | +2.861027 | +NaN | +NaN | +
| std | +0.018563 | +1.156012 | +0.0 | +0.0 | +0.769464 | +0.019817 | +0.0 | +0.502905 | +0.036129 | +0.501745 | +0.041909 | +NaN | +NaN | +
| min | +0.212000 | +82.000000 | +1.0 | +21.0 | +33.000000 | +93.280000 | +1.0 | +1.000000 | +1.466667 | +2.000000 | +2.722222 | +NaN | +NaN | +
| 25% | +0.228250 | +85.000000 | +1.0 | +21.0 | +34.000000 | +93.300000 | +1.0 | +1.000000 | +1.550000 | +2.000000 | +2.866667 | +NaN | +NaN | +
| 50% | +0.238000 | +85.000000 | +1.0 | +21.0 | +34.500000 | +93.320000 | +1.0 | +2.000000 | +1.561111 | +3.000000 | +2.877778 | +NaN | +NaN | +
| 75% | +0.244750 | +86.000000 | +1.0 | +21.0 | +35.000000 | +93.337500 | +1.0 | +2.000000 | +1.583333 | +3.000000 | +2.888889 | +NaN | +NaN | +
| max | +0.295000 | +87.000000 | +1.0 | +21.0 | +36.000000 | +93.350000 | +1.0 | +2.000000 | +1.616667 | +3.000000 | +2.888889 | +NaN | +NaN | +
from datetime import datetimeexport_data['Timestamp'] = export_data[['Date']].apply(
+ lambda str: datetime.strptime(str.iloc[0], "%Y/%m/%d %H:%M:%S").timestamp(), axis=1)export_data['Time'] = export_data['Timestamp'] - logged_data.iloc[0]['Timestamp']
+The basic atmospheric data like temperature, pressure, and relative humidity appear to be the same. But there is something weird going on with the VOC measurements.
+
+I think the atmotube is actually exporting the rolling average of the VOC results over a fairly broad window, whereas the broadcast reading is more direct from the sensor. I would have to run this for much longer to see if that’s the case.
+
+The PM data shows the results are closer, but still have issues. The exported data is (I believe) a by-the-minute average, rounded to the nearest integer. There is a single data point for each minute in the dataset, giving 66 overall. Whereas the raw PM broadcast data has 3988 data points, and I think most of those are just rebroadcasts and are not “real”.
+One thing I was thinking of doing was to capture only the first scan response packet after an advertising packet then ignore all the rest until the next advertising packet. I have also been ignoring the info flags since, when I was just noodling around, they didn’t seem to change at all (with the device always sampling), they might actually be telling me things that I’ve been ignoring.
+Hopefully this helps you get set-up collecting data from your atmotube (I don’t know why else you would read this far). From here to building a simple dashboard or datalogger should be an easy weekend project. I think for applications where you want higher fidelity data over a long stretch of time, periodically requesting data using GATT makes the most sense. The PM data comes with more decimal places of precision, and you don’t need it more frequently than every minute or so.
+The BLE advertising data could be an easy way of building a passive dashboard, continuously listening and updating the air quality statistics. Though some effort would need to be put in cleaning up the data, or perhaps just presenting a rolling average of some kind to smooth out the noise.
+There is also a whole section of the documentation on connecting to an atmotube and downloading data from it, which I didn’t bother to investigate. It looked overly complicated for what I wanted to do. If you figure that out, please let me know!
+I have taken what I figured out in the following section and put it into a minimal python module with a few helper functions. See this example showing how to collect data from an AtmoTube and process the results.
+I was thinking about this more and there was one avenue I neglected to explore: subscribing to GATT notifications from the atmotube. Instead of requesting a single data point, like I did above, one can subscribe to the feed and the atmotube will just send packets whenever an update occurs. That is what I do below.
+To get started I decided to make cytpe structs for each of the bytestrings that can be returned. I don’t think this is necessary, but I like how it seperates the logic of decoding the response on an aesthetic level. It also makes it very clear how the bytestrings are structured.
+from ctypes import LittleEndianStructure, c_ubyte, c_byte, c_short, c_int
+
+class StatusData(LittleEndianStructure):
+ _fields_ = [
+ ("pm_sensor", c_ubyte, 1),
+ ("error", c_ubyte, 1),
+ ("bonding", c_ubyte, 1),
+ ("charging", c_ubyte, 1),
+ ("charging_timer", c_ubyte, 1),
+ ("_bit_6", c_ubyte, 1),
+ ("sgpc3_pre_heating", c_ubyte, 1),
+ ("_bit_8", c_ubyte, 1),
+ ("battery_level", c_ubyte, 8),
+ ]
+
+ def __new__(cls, ts, data):
+ return cls.from_buffer_copy(data)
+
+ def __init__(self, ts, data):
+ self.timestamp = tsclass SPS30Data(LittleEndianStructure):
+ _fields_ = [
+ ('_pm1', c_byte*3),
+ ('_pm2_5', c_byte*3),
+ ('_pm10', c_byte*3),
+ ('_pm4', c_byte*3),
+ ]
+ _pack_ = 1
+
+ def __new__(cls, ts, data):
+ return cls.from_buffer_copy(data)
+
+ def __init__(self, ts, data):
+ self.timestamp = ts
+
+ @property
+ def pm1(self):
+ res = int.from_bytes(self._pm1, 'little', signed=True)
+ return res/100 if res > 0 else None
+
+ @property
+ def pm2_5(self):
+ res = int.from_bytes(self._pm2_5, 'little', signed=True)
+ return res/100 if res > 0 else None
+
+ @property
+ def pm10(self):
+ res = int.from_bytes(self._pm10, 'little', signed=True)
+ return res/100 if res > 0 else Noneclass BME280Data(LittleEndianStructure):
+ _fields_ = [
+ ('_rh', c_byte),
+ ('_T', c_byte),
+ ('_P', c_int),
+ ('_T_dec', c_short),
+ ]
+ _pack_ = 1
+
+ def __new__(cls, ts, data):
+ return cls.from_buffer_copy(data)
+
+ def __init__(self, ts, data):
+ self.timestamp = ts
+
+ @property
+ def RH(self):
+ return self._rh
+
+ @property
+ def T(self):
+ return self._T_dec/100
+
+ @property
+ def P(self):
+ return self._P/1000class SGPC3Data(LittleEndianStructure):
+ _fields_ = [
+ ('_TVOC', c_short),
+ ]
+ _pack_ = 1
+
+ def __new__(cls, ts, data):
+ return cls.from_buffer_copy(data)
+
+ def __init__(self, ts, data):
+ self.timestamp = ts
+
+ @property
+ def TVOC(self):
+ return self._TVOC/1000With that out of the way, there are two other components I need for this to work: a collector which will collect all of the data sent back from the atmotube and a worker which will log it to a csv. Unlike above, where I logged each advertising packet as it came in, I am going to make these run asynchronously using asyncio. I think this is what really should be done, instead of blocking for file i/o every time a callback function is triggered.
To make this happen I largely copied what was done in this example which uses an async queue to pass data between the two workers. The basic idea is:
+collection_time and every time it gets a new set of data uses the callbacks status_cb and sps30_cb to process the bytestring and put the result on the queueasync def collect_data(mac, queue, collection_time):
+ async def status_cb(char, data):
+ ts = time.time()
+ res = StatusData(ts, data)
+ await queue.put(res)
+
+ async def sps30_cb(char, data):
+ ts = time.time()
+ res = SPS30Data(ts, data)
+ await queue.put(res)
+
+ device = await BleakScanner.find_device_by_address(mac)
+ if not device:
+ raise Exception("Device not found")
+
+ async with BleakClient(device) as client:
+ # start notifications
+ await client.start_notify(STATUS_UUID, status_cb)
+ await client.start_notify(SPS30_UUID, sps30_cb)
+
+ # wait for collection period to end
+ await asyncio.sleep(collection_time)
+
+ # signals end of queue
+ await queue.put(None)Concurrently with that, a logger needs to write things to a csv. The basic idea is this:
+filename, and writes the column headers.battery_level as a lazy check of which type of result it is.None, that is a signal that the collector has finished and the loop exits.task_done() to notify the queue of this and the loop begins again.import aiofiles, aiocsvHEADERS = ["Timestamp", "PM Sensor", "PM1", "PM2.5", "PM10"]async def write_row(filename,row):
+ async with aiofiles.open(filename, 'a', newline='') as csvfile:
+ writer = aiocsv.AsyncDictWriter(csvfile, fieldnames=HEADERS)
+ await writer.writerow(row)async def log_to_csv(filename, queue):
+ # prepare csv file
+ async with aiofiles.open(filename, 'w', newline='') as csvfile:
+ writer = aiocsv.AsyncDictWriter(csvfile, fieldnames=HEADERS)
+ await writer.writeheader()
+
+ # log data from queue
+ flag = True
+ while flag:
+ result = await queue.get()
+ if result is not None:
+ # we have some data to write
+ row = dict.fromkeys(HEADERS)
+ row["Timestamp"] = result.timestamp
+ if hasattr(result, "battery_level"):
+ # we have a status type
+ row["PM Sensor"] = result.pm_sensor
+ else:
+ # we have pm data
+ row["PM1"] = result.pm1
+ row["PM2.5"] = result.pm2_5
+ row["PM10"] = result.pm10
+
+ await write_row(filename,row)
+ else:
+ # the end of the queue
+ flag = False
+ queue.task_done()My first attempt at this I put the while loop inside the with block, so the whole thing ran inside the file context manager. This had the effect of nothing actually being written to the csv until the with block exited and the file closed. It took me a long time to realize that is what was happening, since it looked exactly the same as the two processes running sequentially: collect all the data and then write it all to csv.
In this version, every time a row is added to the csv the file is opened, a line is written, and then it is closed. There is probably a way of holding it open while logging, but that might make things more complicated since a whole bunch of new logic would be needed to catch any exceptions and ensure that the file is closed properly – something that happens behind the scenes with a simple with block.
Finally, I put it all together with a simple sequence of tasks:
+Queueasync def save_data(mac, csv, collection_time):
+ q = asyncio.Queue()
+
+ logger = asyncio.create_task(log_to_csv(csv, q))
+ collector = asyncio.ensure_future(collect_data(mac, q, collection_time))
+
+ await collectorI ran this for an hour in the background as a test and it seems to work fine.
+await save_data(ATMOTUBE, f"atmotube-{math.floor(time.time())}.csv", 3600)df = pd.read_csv("atmotube-1748482080.csv")df['Time'] = df['Timestamp'] - df.iloc[0]['Timestamp']df.head()| + | Timestamp | +PM Sensor | +PM1 | +PM2.5 | +PM10 | +Time | +
|---|---|---|---|---|---|---|
| 0 | +1.748482e+09 | +NaN | +10.92 | +13.43 | +14.97 | +0.000000 | +
| 1 | +1.748482e+09 | +NaN | +10.93 | +13.03 | +14.66 | +2.610108 | +
| 2 | +1.748482e+09 | +NaN | +11.05 | +13.42 | +15.19 | +5.220881 | +
| 3 | +1.748482e+09 | +NaN | +11.35 | +13.75 | +15.34 | +7.784888 | +
| 4 | +1.748482e+09 | +NaN | +11.59 | +14.13 | +15.50 | +10.395001 | +
+With no context it looks like something is horribly wrong, what are all those gaps in the data? My atmotube is set to only sample every 15 minutes, this is usually how I leave it to save on battery. This also explains some of the weirdness with the data, why does each sample start with a rapidly increasing concentration before levelling out? The atmotube is returning data right when the sampling fan has just turned on; this is not yet an accurate sample of the ambient air, it is the stagnant air inside the atmotube. This is a much more obvious problem with VOC data, it is clearly visible on the app as a funky saw-tooth wave where the VOC concentration plunges whenever the fan starts and, once it stops, slowly creeps up. It is an artifact of how the atmotube is sampling the air, not of how the data is being collected.
+If the atmotube is set to always on mode, these artifacts go away, but if you want to monitor it in other configurations it is worth considering how the data should be cleaned up. For example watching for the pm_sensor flag to turn on then throwing out the first ~30s of pm data before looking at the rest. The GATT notifications make it very clear when the atmotube is sampling and when it isn’t. There will be a notification that pm_sensor has turned from 0 to 1, then data will start arriving with pm data, then a notification that the pm_sensor has turned from 1 to 0, followed by an empty row of pm data. See a snippet of the csv below. Note that pm_sensor values and actual pm values are always on seperate rows.
df[36:42]| + | Timestamp | +PM Sensor | +PM1 | +PM2.5 | +PM10 | +Time | +
|---|---|---|---|---|---|---|
| 36 | +1.748482e+09 | +NaN | +12.42 | +14.34 | +15.23 | +127.802146 | +
| 37 | +1.748482e+09 | +0.0 | +NaN | +NaN | +NaN | +130.322095 | +
| 38 | +1.748482e+09 | +NaN | +NaN | +NaN | +NaN | +130.322191 | +
| 39 | +1.748483e+09 | +1.0 | +NaN | +NaN | +NaN | +1035.107224 | +
| 40 | +1.748483e+09 | +NaN | +7.74 | +9.55 | +10.21 | +1040.146906 | +
| 41 | +1.748483e+09 | +NaN | +7.81 | +9.82 | +11.40 | +1042.621936 | +
In addition to some data-wrangling, there are some other obvious upgrades to my code before it would be ready to deploy in an app. For one, there is minimal error handling. Any malformed bytestrings returned by the atmotube will throw an exception and kill everything. Additionally there are no checks to maintain a connection to the atmotube. It would simply timeout, having collected nothing. If you were planning on running this passively for a long period of time, unattended, that could be a big deal.
+ + +I live in an older core neighbourhood in Edmonton, which has a beautiful canopy of boulevard trees. Primarily elm, but also ash, maple, and oak. The legacy of people like Gladys Reeves and the Edmonton Tree Planting Committee who, starting in 1923, defended our city green spaces and built up our urban forest. A legacy we still fight for over a century later.
+In particular I’m going to be looking at American Elm, the most common boulevard tree in my neighbourhood, and I’ll restrict myself to just here (not the whole city).
+For a lot of problems, I find it easier to work with Unitful.jl. It enforces unit consistency and inconsistent units in an answer is a good indication that I have made a math mistake somewhere. However there are two questions that I need to answer before getting too deep into things:
I could calculate the pollen concentration on a mass basis, but that seems kind of weird to me. I think the logical unit is in terms of individual pollen grains. That is not a unit that Unitful.jl is aware of and so I need to define first what a pollen grain is. It is a unit of “number”, like a mole, and in fact there are 6.022×10²³ grains in a mole of pollen.
using Unitfulbegin
+
+@unit grains "grains" Grains (1/6.02214076e23)*u"mol" true;
+Unitful.register(@__MODULE__);
+
+endCorrelations can be annoying with Unitful.jl since the various constants in a correlation are not usually given with units and figuring them all out so that units remain consistent is kind of tedious. The easiest workaround is to use a macro to strip the units off the input to a correlation function and then stick the correct units back on the output.
macro ucorrel(f::Symbol, in_unit::Expr, out_unit::Expr)
+ quote
+ function $(esc(f))(x::Quantity)::Quantity
+ x = ustrip($in_unit, x)
+ res = $(esc(f))(x)
+ return res*$out_unit
+ end
+ end
+end;For an initial sketch I’m going to consider a single tree as an elevated point source producing pollen at a constant rate P and with the wind carrying the pollen away with a constant wind speed u. Individual pollen grains settle out of the plume as they are carried downwind with velocity . The coordinate system is centred at the base of the tree with an x-axis parallel to the wind.
+There are a few things I will need to know about each elm tree in the neighbourhood:
+Neither of these are typically measured and available in data sets for urban forests. I will need to use correlations – what foresters call allometric equations. These correlate physical parameters of trees to something easier to measure, such as the diameter at breast height or DBH.
+To build an example elm tree, suppose it has a DBH of 88 cm
+# Model Elm tree
+DBH = 88u"cm" |> u"m"The crown height for an American Elm is correlated to DBH by the following equation1 for urban trees in the North climate zone
+1 McPherson, Doorn, and Peper, “Urban Tree Database and Allometric Equations”.
# McPherson, van Doorn, and Peper, *Urban Tree Database*
+# Ulmus Americana, North climate zone
+crown_height(DBH) = 0.44998 + 0.55096*DBH - 0.00666*DBH^2 + 3e-5*DBH^3@ucorrel crown_height u"cm" u"m"I am going to assume this is the height at which pollen is released, that’s not particularly accurate but it is a start. A better value would be the “centre of mass” for pollen in the crown of an Elm tree, but that isn’t readily available.
+For the example tree, this predicts a height of 17.8 m which, just standing around and looking at the trees on my street seems plausible. The example elm tree should be taller than a 5 story building, and there is an elm tree at the end of my block that is about that in both diameter and height.
+The total amount of pollen in a given elm tree is given by the following equation2 where B is the tree basal area. The total pollen is based on counts of pollen per anther and an estimate of the total number of anthers per tree for urban elm trees in Ann Arbor, Michigan. Maybe not perfectly comparable to Edmonton, but it’s good enough for this exploratory work.
+2 Katz, Morris, and Batterman, “Pollen Production for 13 Urban North American Tree Species”.
# Ulmus Americana total pollen per tree
+# Katz, Morris, and Batterman, "Pollen Production," Table 2
+function total_pollen(DBH)
+ B = π/4*DBH^2
+ return exp(5.86*B + 23.11)
+end@ucorrel total_pollen u"m" u"grains"This gives a total pollen content of 384,082,918,235 grains for the model elm tree, which sounds like a lot.
+Elm trees release their pollen, in Edmonton, somewhere from the end of April to mid May and it usually lasts 1-2 weeks. As a very rough model I’m going to assume each tree releases its pollen at a constant rate over a 2 week period, and that the periods over which each of the trees are releasing overlap.
+Δt = 14u"d" |> u"s"pollen_rate(DBH) = total_pollen(DBH)/ΔtFor the example tree, this gives a pollen release rate of 317,529 grains s^-1.
+Elm pollen is relatively large and will settle out of the air. To account for this I am going to assume the pollen settles with a velocity equal to the terminal velocity given by Stokes’ Law, where each individual pollen grain is a solid sphere.
+
+begin
+
+# Ulmus Americana pollen
+# Brush and Brush, "Transport of Pollen," Tables 3 and 12.
+d = 31u"μm" |> u"m"
+SG = 1.1
+ρ = SG*1000u"kg/m^3"
+
+end;begin
+
+g = 9.80665u"m/s^2" # standard gravity
+ρₐ = 1.225u"kg/m^3" # density of dry air (15°C, 1atm)
+μₐ = 17.89e-6u"Pa*s" |> u"kg/m/s" # viscosity of dry air (15°C, 1atm)
+
+end;# Stokes Law
+vₜ = ((ρ - ρₐ)*g*d^2)/(18*μₐ)This gives a settling velocity for a grain of American Elm pollen in air of 3.22 cm s^-1
+We might naively consider the pollen being launched out of the tree like little cannon balls, with a velocity in the x-direction equal to the wind speed and the velocity in the z-direction equal to the terminal velocity of pollen. Assuming a wind speed of 2 m s^-1, then a pollen grain from our example tree would travel 1107 m before hitting the ground. That’s pretty far and also kind of unrealistic. It ignores all the turbulent mixing in the air column which will both loft it to much greater heights and, at times, push it towards the ground.
+The turbulent mixing in the air is captured using the dispersion parameters and
which are functions of the downwind distance. This gives an average view, averaged over all of the pollen grains. In this case I will be using the Briggs’ correlations for Urban terrain.3 I am also assuming class D atmospheric stability.
3 Briggs, “Diffusion Estimation for Small Emissions. Preliminary Report,” 38; Griffiths, “Errors in the Use of the Briggs Parameterization for Atmospheric Dispersion Coefficients”.
# wind speed, assumed
+u = 2u"m/s"σ_y(x) = 0.16x/√(1+0.0004x)@ucorrel σ_y u"m" u"m"σ_z(x) = 0.14x/√(1+0.0003x)@ucorrel σ_z u"m" u"m"I will be using the Ermak equation4 to model the dispersion of pollen, which results in a Gaussian-like dispersion but with the pollutant falling out and collecting on the ground. The Ermak equation is the solution to the advection diffusion equation with a constant settling velocity and deposition velocity
4 Ermak, “An Analytical Model for Air Pollutant Transport and Deposition from a Point Source”.
with boundary condition at the ground
+where K is the eddy diffusivity and r is defined as
+and the other boundary conditions are as for the conventional Gaussian dispersion (e.g. constant mass emissions, m, at a point h above the origin, etc.). This can be solved and put in terms of and
as, by definition,
where . In practice, relationships for
s are much easier to find than Ks and the following is used to recover
This follows from the definition of (and r). In this case I am going to generate the
using automatic differentiation with
ForwardDiff.jl.
using ForwardDiff: derivative∂ₓσ_z²(x) = 2*σ_z(x)*derivative(σ_z, x)@ucorrel ∂ₓσ_z² u"m" u"m"K_z(x; u) = (1/2)*u*∂ₓσ_z²(x)Models like this, with a point source emitting mass, have nonphysical results in the vicinity of the emission source. The concentration rises sharply and there is a singularity at the source itself. There are many ways of dealing with this, but the easiest is to define a maximum concentration, usually given from a mass balance, and cut off the dispersion model at that. I don’t have any specific upper bound, so I picked a large number simply to prevent the propagation of Inf or other errors.
This is only a problem very close to the source, and I am more interested in concentrations far from the tree, so this is not a concern. A better model would calculate a “virtual origin” for the tree such that the pollen concentration in the crown of the tree was more realistic.
+max_pollen = 1e6grains/1u"m^3"using SpecialFunctions: erfcfunction ermak(x, y, z; u=u, h=crown_height(DBH), P=pollen_rate(DBH),
+ W_set=vₜ, W_dep=vₜ, p_max=max_pollen)
+
+ if x<zero(x) || z<zero(z)
+ return zero(p_max)
+ end
+
+ s_y = σ_y(x)
+ s_z = σ_z(x)
+ K = K_z(x; u)
+
+ Wₒ = W_dep - 0.5*W_set
+
+ p = (P/(2π*u*s_y*s_z))*exp(-0.5*(y/s_y)^2)*
+ exp(-0.5*W_set*(z-h)/K - 0.125*(W_set*s_z/K)^2)*(
+ exp(-0.5*((z-h)/s_z)^2) + exp(-0.5*((z+h)/s_z)^2)
+ - (√(2π)*Wₒ*s_z/K)*exp(Wₒ*(z+h)/K + 0.5*(Wₒ*s_z/K)^2)*
+ erfc((Wₒ*s_z/K + (z+h)/s_z)/√(2)) )
+
+ return isnan(p) ? zero(p_max) : min(p, p_max)
+end;Using this model, the ground level pollen concentration 100 m downwind of the example tree is 105.82 grains m^-3. As shown in the figures below, the pollen is most concentrated in an area from about 75 m to 300 m downwind of the tree. Which is about 2.5 blocks going east-west (city blocks in Edmonton are longer in the north-south direction)
+
+
+I am left with some questions about how much pollen is actually needed, in the air, for pollination to have a chance. The pollen has to end up on a corresponding flower, so there must be a point where the concentration is just too low to make this likely. Trees do put some effort into improving the odds, they typically flower and disperse pollen before their leaves have meaningfully come back, helping to remove obstructions. The branching structures of trees are both useful for light gathering and provide a large effective area over which their flowers sieve the air for pollen.
+On the other side, pollen grains are somewhat fragile too, they can dry out or be damaged by excessive UV exposure. While a single pollen grain may have the potential to make it thousands of meters away from the tree, it may not be viable by the time it gets there.
+I would guess, from these calculations, that Elm trees are getting most of their action within 300 m or less. Anything beyond that and the pollen is so dispersed that the odds of it finding a pistil are too low.
+To move from modelling a single tree to an urban forest, I will need a data structure to contain the relevant parameters of a tree. In this case I need both the map location and the location of the tree relative to the origin of the local coordinate system, . Each tree also has a diameter, height, pollen release rate, and terminal velocity. In this case all the trees are Elm trees, and have the same pollen, but I’m leaving it general in case I want to model something else in the future.
begin
+
+struct Tree{G,L,P,V}
+ geopt::G
+ xₒ::L
+ yₒ::L
+ zₒ::L
+ DBH::L
+ h::L
+ P::P
+ vₜ::V
+end
+
+function Tree(geopt, xₒ, yₒ, DBH; v=vₜ)
+ h = crown_height(DBH)
+ P = pollen_rate(DBH)
+ xₒ, yₒ, zₒ, DBH, h = promote(xₒ, yₒ, zero(yₒ), DBH, h)
+ return Tree(geopt, xₒ, yₒ, zₒ, DBH, h, P, v)
+end
+
+end;elm = Tree(nothing, 0u"m",0u"m",DBH)Tree{Nothing, Quantity{Float64, 𝐋, Unitful.FreeUnits{(m,), 𝐋, nothing}}, Quantity{Float64, 𝐍 𝐓^-1, Unitful.FreeUnits{(grains, s^-1), 𝐍 𝐓^-1, nothing}}, Quantity{Float64, 𝐋 𝐓^-1, Unitful.FreeUnits{(m, s^-1), 𝐋 𝐓^-1, nothing}}}(
+ geopt = nothing
+ xₒ = 0.0 m
+ yₒ = 0.0 m
+ zₒ = 0.0 m
+ DBH = 0.88 m
+ h = 17.803579999999993 m
+ P = 317528.8675882605 grains s^-1
+ vₜ = 0.032156589905762846 m s^-1
+)
+I then added a method to ermak that takes a Tree object and returns the concentration of its pollen at a point x, y, and z on the local coordinate system.
function ermak(t::Tree, x, y, z; u=u)
+ x′ = x - t.xₒ
+ y′ = y - t.yₒ
+ z′ = z - t.zₒ
+ return ermak(x′, y′, z′; h=t.h, P=t.P, W_set=t.vₜ, W_dep=t.vₜ)
+end;The Ermak equation assumes the local area is a flat Euclidean plane. The earth is not that, and so a central task is going to be defining a local coordinate system that approximates my neighbourhood, Wîhkwêntôwin, as a flat plane. Then I will need to find all of the local trees and place them in this local coordinate system before adding in their individual contributions to the local Elm pollen situation.
+I arbitrarily picked a point more-or-less in the middle of the neighbourhood to act as the origin. My neighbourhood is pretty flat and so I’m going to assume everything is at the same altitude.
+using Geodesybegin
+
+latₒ, lonₒ, altₒ = 53.54100, -113.52141, 671
+Δlat, Δlon = 0.015, 0.035
+
+end;I oriented the grid such that the wind goes from west to east – which is usually the case. Another approach would be to look up the local windrose and orient the grid to the most frequent wind direction with the wind speed as the median wind speed.
+I am assuming that the area is locally flat relative to the curvature of the earth. Namely that the distance, in meters, per degree longitude is a constant across the whole neighbourhood – which I calculate from a straight line running through the origin going from the furthest west to the furthest east. Similarly for degrees latitude. This isn’t strictly true but the difference between the distance along the ellipsoid and the locally-flat distance is going to be trivially small, so I can safely ignore it.
+begin
+
+Δx = euclidean_distance(LLA(latₒ, lonₒ - Δlon/2, altₒ),
+ LLA(latₒ, lonₒ + Δlon/2, altₒ), wgs84)/Δlon
+Δy = euclidean_distance(LLA(latₒ + Δlat/2, lonₒ, altₒ),
+ LLA(latₒ - Δlat/2, lonₒ, altₒ), wgs84)/Δlat
+endfunction local_coords(lat,lon)
+ x = (lon - lonₒ)*Δx
+ y = (lat - latₒ)*Δy
+ return x, y
+endAt first glance it looks like I’m doing a lot of additional work for no reason. I ultimately want to overlay my maps on top of satellite imagery, which will require me to convert everything into Web Mercator. Why not use that as the local coordinate system? Points in Web Mercator are northing and easting in meters on a flat plane.
+Unlike UTM, where that kind of thing works out well enough for a lot of situations, there is a lot more distortion with Web Mercator. Especially closer to the poles. I’m not particularly close to the north pole, but more than close enough that the map distortion leads to significant errors when using Web Mercator naively like that.
+To demonstrate this I’m going to calculate the distance between my favourite coffee shop, stopgap, and a local park on the other side of the neighbourhood, Oliver park.
+begin
+
+stopgap = LLA(53.535618490862944, -113.5118491580413)
+oliver_park = LLA(53.54542679826651, -113.52603529325418)
+
+enddist = euclidean_distance(stopgap, oliver_park, wgs84)First I calculate the distance along the ellipsoid, which is 1441 m (the same as what Google maps tells me).
+Then I convert the coordinates to Web Mercator, which are northing and easting relative to the equator and the prime meridian.
+WM = WebMercatorfromLLA(wgs84)begin
+
+stopgap_wm = WM(stopgap)
+oliver_park_wm = WM(oliver_park)
+
+endwm_dist = √( (stopgap_wm[1] - oliver_park_wm[1])^2
+ + (stopgap_wm[2] - oliver_park_wm[2])^2 )The naive Euclidean distance using Web Mercator is 2423 m, about 68% greater than the true distance. If I set my local grid naively using the northing and easting of Web Mercator, everything would be distorted.
+Thankfully, I don’t need to wander the neighbourhood with a GPS unit and a tape measure to find all the local Elm trees and map them. The City of Edmonton has already done that. I filtered the data set to just my neighbourhood and just Ulmus Americana and downloaded it as a csv.
+using CSV, DataFramestrees_df = CSV.read("data/Ulmus_americana_wihkwentowin.csv",
+ DataFrame);describe(trees_df, :min, :max)19×3 DataFrame
+ Row │ variable min max
+ │ Symbol Any Any
+─────┼──────────────────────────────────────────────────────────────────────────────────────────────
+ 1 │ ID 155206 619701
+ 2 │ NEIGHBOURHOOD_NAME WÎHKWÊNTÔWIN WÎHKWÊNTÔWIN
+ 3 │ LOCATION_TYPE Alley Park
+ 4 │ SPECIES_BOTANICAL Ulmus americana Ulmus americana Patmore
+ 5 │ SPECIES_COMMON Elm, American Elm, American
+ 6 │ GENUS Ulmus Ulmus
+ 7 │ SPECIES americana americana
+ 8 │ CULTIVAR Brandon Patmore
+ 9 │ DIAMETER_BREAST_HEIGHT 5 110
+ 10 │ CONDITION_PERCENT 0 65
+ 11 │ PLANTED_DATE 1990/01/01 2024/09/25
+ 12 │ OWNER Parks Parks
+ 13 │ Bears Edible Fruit false false
+ 14 │ Type of Edible Fruit
+ 15 │ COUNT 1 1
+ 16 │ LATITUDE 53.5346 53.5496
+ 17 │ LONGITUDE -113.536 -113.51
+ 18 │ LOCATION (53.534594143551985, -113.510361… (53.549586375568154, -113.530880…
+ 19 │ Point Location POINT (-113.50950085882668 53.53… POINT (-113.53589639202552 53.54…
+What I would like is a vector of Trees. I could have added a column to the dataframe with Tree objects when it was created, but I’m not using the dataframe for anything else so I didn’t really see the point.
begin
+trees = Vector{Tree}()
+
+for row in eachrow(trees_df)
+ lat, lon = row.LATITUDE, row.LONGITUDE
+ DBH = row.DIAMETER_BREAST_HEIGHT*1u"cm" |> u"m"
+ pt = LLA(lat,lon,altₒ)
+ x, y = local_coords(lat, lon).*1u"m"
+ tree = Tree(pt,x,y,DBH)
+ push!(trees, tree)
+end
+
+endThere are 996 Elm trees in Wîhkwêntôwin alone. That’s impressive, we have a pretty great urban forest.
+trees[1]Tree{LLA{Float64}, Quantity{Float64, 𝐋, Unitful.FreeUnits{(m,), 𝐋, nothing}}, Quantity{Float64, 𝐍 𝐓^-1, Unitful.FreeUnits{(grains, s^-1), 𝐍 𝐓^-1, nothing}}, Quantity{Float64, 𝐋 𝐓^-1, Unitful.FreeUnits{(m, s^-1), 𝐋 𝐓^-1, nothing}}}(
+ geopt = LLA(lat=53.53695945675063°, lon=-113.51201340003675°, alt=671.0)
+ xₒ = 623.0131311454173 m
+ yₒ = -449.74535157065054 m
+ zₒ = 0.0 m
+ DBH = 0.2 m
+ h = 9.04518 m
+ P = 10810.758180096327 grains s^-1
+ vₜ = 0.032156589905762846 m s^-1
+)
+I am assuming that pollen is additive and doesn’t alter the properties of air at all. The concentration of pollen from multiple trees is just the concentration of pollen from each tree added together.
+ermak(trees::Vector{Tree}, x, y, z; u=u) =
+ sum( ermak.(trees, x, y, z; u=u) );Now that I have a set of trees and a bounding box, I need to generate some actual maps. I am going to use Tyler.jl to download the map tiles and make them plot-able in Makie. For which I need to give it a bounding box for the neighbourhood and identify a map provider. I am using the imagery from ESRI.
+using Tylerwihkwentowin = Rect2f(lonₒ - Δlon/2, latₒ - Δlat/2, Δlon, Δlat);provider = Tyler.TileProviders.Esri(:WorldImagery);I have defined a helper function to take a tree and return the appropriate Web Mercator coordinates to map on top of the ESRI imagery.
+function map_tree(tree::Tree)
+ x, y, _ = WM(tree.geopt)
+ return Point2f(x,y)
+endMapping all of the trees in the data set matches what I expected: they are mostly boulevard trees and the northwest corner of the neighbourhood is much more densely forested with Elm.
+
+Now I have all the tools in place to generate concentration contours for Elm pollen and plot them on top of the ESRI imagery for my neighbourhood. First, I create a helper function to convert grid points in Web Mercator to local grid coordinates, then return the concentration at that point with contributions from all 996 Elm trees.
+If I was doing this for the whole city I might want to first filter out all the Elm trees that are too distant from or downwind of the point of interest – since they won’t contribute anything.
+LLA_WM = LLAfromWebMercator(wgs84)function map_ermak(x, y)
+ lla = LLA_WM([x,y,altₒ])
+ local_x, local_y = local_coords(lla.lat, lla.lon).*1u"m"
+ return ustrip(ermak(trees, local_x, local_y, 0u"m"))
+endI then divide the neighbourhood into a grid of 10,000 points and calculate the concentration at each point.
+# defining the bounds of the grid
+
+begin
+
+xₗ, yₗ, _ = WM(LLA(latₒ - Δlat/2, lonₒ - Δlon/2, altₒ))
+xᵤ, yᵤ, _ = WM(LLA(latₒ + Δlat/2, lonₒ + Δlon/2, altₒ))
+
+end;begin
+
+xs = range(xₗ, xᵤ; length=100)
+ys = range(yₗ, yᵤ; length=100)
+
+zs = map_ermak.(xs, ys')
+
+end;Finally I overlay a contour plot on top of the ESRI imagery, showing everywhere with a pollen concentration >10 grains m^-3
+
+A major limitation to this style of dispersion modelling, especially in a neighbourhood like mine dominated by large apartment buildings, is that building downwash effects are not being accounted for. The Elm trees are at a similar height or shorter than the buildings around them. This model essentially ignores the buildings other than their contribution to surface roughness – reflected in the dispersion parameters and
. Short of doing a CFD model of the neighbourhood, I don’t think there is an easy way around that. Probably this would work better in neighbourhoods like Highlands or Ritchie which have mature Elm trees but where housing is mostly older homes, less than 2 stories, with yards spacing them out from each other.
A limitation to this specific example is that I haven’t included all the Elm trees in adjacent neighbourhoods – Westmount in particular. This under counts the Elm pollen on the west side of Wîhkwêntôwin. I can imagine one producing maps like this, for the whole city, based on which trees are producing pollen in any given week showing where the peak pollen action is. A where not to park your car map, if you want to avoid washing your windshield every morning, or where to avoid if you are allergic to tree pollen.
+The properties of the vessel form a natural data structure containing the valve properties, the vessel volume, and the initial conditions. It can also be divided into two distinct sub-problems: the gas expansion within the vessel and the gas expansion across the valve. The gas expansion within the vessel will be governed by the ODE or DAE for the particular expansion type – isothermal, adiabatic, &c. – whereas the expansion across the valve will always be isentropic. These sub-problems can then be solved in a way that is agnostic to the equation of state.
+An important decision must be made regarding which subset of the state variables, , will be used to define the system. The remaining variable will be defined by the equation of state. Equations of state are typically given in relation to the Helmholtz free energy,
, a function of molar volume and temperature, which makes those a natural choice. The pressure vessel can then be instantiated with the total volume, total mass of material contained, and the vessel temperature. The pressure then varies with the equation of state. Alternatively, the pressure and temperature of the vessel could be chosen as the state variables. But then the total mass in the vessel depends on the particular equation of state, which strikes me as weird.
begin
+
+struct PressureVessel{F <: Number}
+ c::F # valve discharge coefficient
+ A::F # valve flow area
+ V::F # vessel volume
+ T::F # vessel temperature
+ m::F # total mass of material
+end
+
+PressureVessel(c, A, V, T, m) =
+ PressureVessel(promote(c, A, V, T, m)...)
+
+endAbstracting the fluid properties – the P-v-T relationship, entropy, enthalpy, and the like – allows the vessel blowdown model to be re-used easily. Using Julia’s multiple dispatch no code even needs to change, just add new methods for a new fluid model and everything works. This leads naturally to a way of checking that the vessel model is working by comparing an ideal gas model to the known analytic solution. Verifying that it works with an ideal gas then gives confidence that the model is working with a real gas, for which the analytic solution is unknown.
+Collecting the ambient conditions into a data structure does not lead to any spectacular improvements or insights, it is just neat and tidy.
+begin
+
+struct Environment{F <: Number}
+ P::F
+ T::F
+end
+
+Environment(P, T) = Environment(promote(P,T)...)
+
+endFinally, a data structure for blowdown solutions is useful for dispatch.
+struct Blowdown{S}
+ pv::PressureVessel
+ env::Environment
+ sol::S
+endBase.length(::Blowdown) = 1Base.iterate(b::Blowdown, state=1) = state > length(b) ? nothing : (b,state+1)The first fluid model worth creating is the ideal gas model that corresponds to the known, analytic, solution. Specifically an ideal gas with constant heat capacities such that . Starting with the following data structure.
begin
+
+const R = 8.31446261815324 # m³⋅Pa/K/mol
+
+struct IdealGas{F <: Number}
+ cᵥ::F # J/kg/K
+ cₚ::F # J/kg/K
+ k::F
+ R::F # J/kg/K
+ MW::F # kg/mol
+end
+
+function IdealGas(cᵥ,MW; R=R)
+ cᵥ, MW = promote(cᵥ,MW)
+ cₚ = cᵥ + R
+ k = cₚ/cᵥ
+ return IdealGas(cᵥ,cₚ,k,R,MW)
+end
+
+function IdealGas(model::Clapeyron.EoSModel;
+ P=101325, T=288.15, z=[1.])
+ MW = Clapeyron.molecular_weight(model, z) # kg/mol
+ cᵥ = Clapeyron.isochoric_heat_capacity(model, P, T, z) # J/mol/K
+ return IdealGas(cᵥ, MW)
+end
+
+endA good practice, when solving ODEs, is to use NaNMath.jl for roots, logarithms, and the like. These versions return NaN when results are outside of the function domain – for example – instead of throwing a
DomainError. Returning NaNs makes it easier for the ODE solver to detect when it has left the domain of a valid solution.
begin
+
+using NaNMath
+
+√ = NaNMath.sqrt
+log = NaNMath.log
+
+endThe equation of state is implemented as a series of high-level functions, dispatching on the fluid model and returning the relevant fluid properties. Extending the blowdown model to use a different equation of state involves merely overloading these to dispatch on a different fluid type.
+pressure(model::IdealGas, v, T) = model.R*T/vvolume(model::IdealGas, P, T) = model.R*T/Pmolecular_weight(model::IdealGas) = model.MWmolar_enthalpy(model::IdealGas, v, T) = model.cₚ*Tmolar_entropy(model::IdealGas, v, T) =
+ model.cᵥ*log(T) + model.R*log(v)molar_internal_energy(model::IdealGas, v, T) = model.cᵥ*Tspeed_of_sound(model::IdealGas, v, T) =
+ √(model.k*model.R*T/model.MW)Clapeyron.jlThe high-level functions defined above are mapped to the corresponding Clapeyron.jl functions. And that’s it. Everything is ready to use for whichever equation of state your heart desires.
+import Clapeyronpressure(model::Clapeyron.EoSModel, v, T) =
+ Clapeyron.pressure(model, v, T)volume(model::Clapeyron.EoSModel, P, T; v0=nothing) =
+ Clapeyron.volume(model, P, T; phase=:vapor, vol0=v0)molecular_weight(model::Clapeyron.EoSModel) =
+ Clapeyron.molecular_weight(model)molar_enthalpy(model::Clapeyron.EoSModel, v, T) =
+ Clapeyron.VT_enthalpy(model, v, T)molar_entropy(model::Clapeyron.EoSModel, v, T) =
+ Clapeyron.VT_entropy(model, v, T)molar_internal_energy(model::Clapeyron.EoSModel, v, T) =
+ Clapeyron.VT_internal_energy(model, v, T)speed_of_sound(model::Clapeyron.EoSModel, v, T) =
+ Clapeyron.VT_speed_of_sound(model, v, T)The vessel blowdown relies on a good model of isentropic nozzle flow. This involves finding the pressure and temperature in the throat of the nozzle which maximizes the mass flux, G, while satisfying the constraints that the path from a stagnation point in the vessel through the nozzle is isentropic and that the enthalpy is conserved. The flow is further constrained to be either sonic or subsonic, i.e. the Mach number is less than or equal to one.
+For almost all of the blowdown the flow will be sonic and the pressure in the throat of the nozzle will be greater than atmospheric, this is called choked flow. The entropy and enthalpy balances can be solved for the throat conditions, , assuming the velocity is the local speed of sound. I do this here using NonlinearSolve.jl, where the objective function,
choked_nozzle_balance!, is in-place.
using NonlinearSolvefunction choked_nozzle_balance!(obj, y, prms)
+ # y = [v; T]
+ obj .= [ prms.entropy - molar_entropy(prms.model, y[1], y[2])
+ prms.enthalpy - molar_enthalpy(prms.model, y[1], y[2]) - 0.5*molecular_weight(prms.model)*speed_of_sound(prms.model, y[1], y[2])^2 ]
+ return nothing
+endchoked_nozzle_prob = NonlinearProblem(choked_nozzle_balance!, [0.0; 0.0],
+ (model=nothing, env=nothing,
+ entropy=0.0, enthalpy=0.0))In the case where the flow is subsonic, the pressure in the throat of the nozzle is atmospheric and the entropy and enthalpy balances are solved for gas velocity and temperature, .
function non_choked_nozzle_balance!(obj, y, prms)
+ # y = [u; T]
+ v = volume(prms.model, prms.env.P, y[2])
+ obj .= [ prms.entropy - molar_entropy(prms.model, v, y[2])
+ prms.enthalpy - molar_enthalpy(prms.model, v, y[2]) - 0.5*molecular_weight(prms.model)*y[1]^2 ]
+ return nothing
+endnon_choked_nozzle_prob = NonlinearProblem(non_choked_nozzle_balance!,
+ [0.0; 0.0],
+ (model=nothing, env=nothing,
+ entropy=0.0, enthalpy=0.0))The most obvious and direct way of solving the entropy and energy balances is to solve the optimization problem. However, I could not get that to work reliably. Using the same constraints on entropy and enthalpy as well as constraining the Mach number to be less than or equal to one, I could get it to work but only with very good guesses of the initial conditions. Using Optimization.jl, it would either get stuck in a local maximum or, depending on the solver, sometimes return results that simply did not satisfy the constraints (but came with return code “Success”). Given that this is going to be wrapped in an ODE and executed, potentially, hundreds of times, that is not good.
My completely stupid but it works approach is to solve the choked flow nonlinear system first and, if the nozzle pressure is below atmospheric, solve the non-choked flow system instead. This works perfectly though, presumably, is not nearly as efficient as solving the optimization problem directly would be if I could get it to work properly.
+function mass_flow(model, pv, env, v, T)
+ # calculate the molar entropy and molar enthalpy
+ # at vessel conditions
+ s₁ = molar_entropy(model, v, T)
+ h₁ = molar_enthalpy(model, v, T)
+
+ # solve the choked flow energy balance for
+ # an isentropic nozzle
+ params = (model=model, env=env, entropy=s₁, enthalpy=h₁)
+ y₀ = [v; T]
+ prob = remake(choked_nozzle_prob, u0=y₀, p=params)
+ sol = solve(prob, NewtonRaphson())
+ vₜ, Tₜ = sol.u
+ Pₜ = pressure(model, vₜ, Tₜ)
+ if Pₜ > env.P
+ # flow is choked, we're done
+ uₜ = speed_of_sound(model, vₜ, Tₜ)
+ else
+ # flow is not choked, solve the non-choked problem
+ v₀ = volume(model, env.P, T)
+ y₀ = [ speed_of_sound(model, v₀, T); T ]
+ prob = remake(non_choked_nozzle_prob, u0=y₀, p=params)
+ sol = solve(prob, NewtonRaphson())
+ uₜ, Tₜ = sol.u
+ vₜ = volume(model, env.P, Tₜ)
+ end
+
+ ρₜ = molecular_weight(model)/vₜ
+ return pv.c*pv.A*ρₜ*uₜ
+endThe general adiabatic blowdown solution proceeds in the same way as the ideal gas case (solved previously). Here the isentropic path is not directly available, so the problem is rewritten as a Differential Algebraic Equation (DAE), where the vessel state is constrained to be isentropic.
+The first step is to define a basic type, PressureODE; which will allow functions like blowdown_pressure to dispatch on solution type.
struct PressureODE{S}
+ ode_sol::S
+endThe governing equations are the ODE as defined before, plus the constraints that the P-v-T behaviour follows the equation of state and the entropy is constant.
+The equation of state does not need to be pulled into the DAE like this. It could be incorporated into the right hand side of the ODE. However, it is often convenient to have all of the state variables directly accessible in the solution.
+using OrdinaryDiffEq, DiffEqCallbacksfunction adiabatic_vessel!(dy, y, prms, t)
+ P, v, T = y
+
+ a² = speed_of_sound(prms.model, v, T)^2
+ w = mass_flow(prms.model, prms.pv, prms.env, v, T)
+
+ dy .= [-w*a²/prms.pv.V
+ v - volume(prms.model, P, T)
+ prms.init - molar_entropy(prms.model, v, T) ]
+ return nothing
+endabd_rhs = ODEFunction(adiabatic_vessel!, mass_matrix = [1 0 0
+ 0 0 0
+ 0 0 0])A callback function is used to terminate the integration once the vessel is within a given tolerance of atmospheric pressure. Without this the blowdown would continue forever, or until the limits of machine precision (whichever came first). Technically, this blowdown model predicts the pressure in the vessel will get arbitrarily close to atmospheric pressure but never actually achieve it.
+depressured_callback(y, t, I; reltol=0.001) =
+ y[1] - (1+reltol)*I.p.env.PThe entire model is packaged into a function which takes a fluid, pressure vessel, and environment and returns a Blowdown solution. By splitting the problem up like this, different fluid models, vessels or ambient conditions can be swapped around while reusing what has already been defined.
function adiabatic_blowdown(model, pv::PressureVessel,
+ env::Environment;
+ solver=Rodas5(),
+ tspan=(0.0, 600.0))
+
+ # vessel initial conditions
+ V, T₀, m = pv.V, pv.T, pv.m
+ n₀ = m/molecular_weight(model)
+ v₀ = V/n₀
+ P₀ = pressure(model, v₀, T₀)
+
+ # defining the parameters
+ s₀ = molar_entropy(model, v₀, T₀)
+ params = (model=model, pv=pv, env=env, init=s₀)
+
+ # callbacks
+ dpcb = ContinuousCallback(depressured_callback, terminate!)
+
+ # set up the ODEProblem and solve
+ y₀ = [P₀; v₀; T₀]
+ prob = ODEProblem(abd_rhs, y₀, tspan, params)
+ sol = solve(prob, solver; callback=dpcb)
+
+ return Blowdown(pv,env,PressureODE(sol))
+endFrom the ODE solution the blowdown time, pressure curve, and temperature can be recovered.
+blowdown_time(bd::Blowdown{<:PressureODE}) =
+ bd.sol.ode_sol.t[end]function blowdown_pressure(bd::Blowdown{<:PressureODE}, t)
+ bdt = blowdown_time(bd)
+ t = min(t, bdt)
+ return bd.sol.ode_sol(t; idxs=1)
+endfunction blowdown_temperature(bd::Blowdown{<:PressureODE}, t)
+ bdt = blowdown_time(bd)
+ t = min(t, bdt)
+ return bd.sol.ode_sol(t; idxs=3)
+endThe entire model, including all of the sub-models, is complicated and could easily have typos and hard to notice errors in it. An easy way to check this is to compare the results against the known analytic solution for the case where the gas is an ideal gas and the flow through the nozzle is always choked.
+struct IdealGasChoked{F <: Number}
+ P₀::F
+ k::F
+ τ::F
+endfunction adiabatic_choked_blowdown(model::IdealGas, pv::PressureVessel,
+ env::Environment)
+ # vessel parameters
+ c, A = pv.c, pv.A
+
+ # vessel initial conditions
+ V, T₀, m = pv.V, pv.T, pv.m
+ n₀ = m/molecular_weight(model)
+ v₀ = V/n₀
+ P₀ = pressure(model, v₀, T₀)
+
+ k, R, MW = model.k, model.R, model.MW
+ τ = 1/( (c*A/V)*√(k*R*T₀/MW)*(2/(k+1))^((k+1)/(2*(k-1))) )
+ return Blowdown(pv,env,IdealGasChoked(P₀,k,τ))
+endfunction blowdown_time(bd::Blowdown{<:IdealGasChoked})
+ P₀, Pₐ, k, τ = bd.sol.P₀, bd.env.P, bd.sol.k, bd.sol.τ
+ return (2τ/(1-k))*(1 - (Pₐ/P₀)^((1-k)/2k))
+endfunction blowdown_pressure(bd::Blowdown{<:IdealGasChoked}, t)
+ P₀, k, τ = bd.sol.P₀, bd.sol.k, bd.sol.τ
+ t = min(t, blowdown_time(bd))
+ return P₀*( 1 + 0.5*(k-1)*(t/τ))^((2*k)/(1-k))
+endfunction blowdown_temperature(bd::Blowdown{<:IdealGasChoked}, t)
+ T₀, P₀, k = bd.pv.T, bd.sol.P₀, bd.sol.k
+ t = min(t, blowdown_time(bd))
+ P = blowdown_pressure(bd, t)
+ return T₀*(P/P₀)^((k-1)/k)
+endThe same situation as the previous post on ideal gas blowdown is used here, a gas cylinder at 3000psia blowing down through a valve into the air. In this case the gas is nitrogen, instead of air, as having a single species is simpler than a mixture (though not by much).
+atm = Environment(101325,288.15)vessel = let
+ c = 0.85
+ D = 0.005 # m
+ A = 0.25*π*D^2 # m²
+ V = 0.01111 # m³
+ m = 2.743 # kg
+ T = 288.15 # K
+ PressureVessel(c, A, V, T, m)
+endThe real gas is modelled using a volume translated Peng-Robinson equation of state.
+using Clapeyron:PR, ReidIdeal, RackettTranslationnitrogen = PR(["nitrogen"]; idealmodel=ReidIdeal,
+ translation=RackettTranslation);ig_nitrogen = IdealGas(nitrogen);choked_model = adiabatic_choked_blowdown(ig_nitrogen, vessel, atm);ideal_gas = adiabatic_blowdown(ig_nitrogen, vessel, atm);real_gas = adiabatic_blowdown(nitrogen, vessel, atm);The blowdown using an ideal gas equation of state matches the known solution for the entire domain where flow is actually choked. This gives some assurance that the general model is working properly. The real gas model, VTPR, appears to work well and is not too far from the ideal case, as expected.
+When I first played around with this I assumed flow through the nozzle was always choked (as a test) and this led to numerical difficulties near the end of the integration. I had to manually stop the integration at around 20s. Each subsequent time step would end up venting a physically unrealistic amount of material and the thermodynamic models would start to suffer from domain errors. Pleasingly, once a better model for the valve was swapped in, these problems went away.
+Another problem that can happen, depending upon the equation of state, is that the vessel may be outside the range where the equation of state can return a physically real gas. The temperature in the vessel, and especially within the nozzle, drops dramatically and in this case it drops below the boiling point of nitrogen by the time the vessel is fully depressured. This is a real result. It is not a consequence of some numerical error. It is a consequence of assuming a perfectly adiabatic vessel.
+Another way of approaching this problem is to perform a mass and energy balance. This is a more general approach and is what underlies more complex blowdown simulators (such as BLOWDOWN). Starting with a mass balance:
+The energy balance is that the change in the internal energy within the vessel is equal to the rate of heat in, through the walls of the vessel, minus the rate of heat lost due to flow out of the vessel.
+Where is the specific enthalpy, note this is at vessel conditions. The boundary for the energy balance is around the vessel, not including the valve.
The total internal energy is the product of the mass remaining in the vessel and the specific internal energy, . Applying the chain rule:
Combining these two expressions:
+The specific internal energy, , is related to the molar internal energy,
, by the molar weight,
, similarly for the specific and molar enthalpy. Substituting and multiplying through by the molar weight gives:
The remaining mass, , can be written in terms of the molar volume,
:
The mass balance can also be written in terms of the molar volume:
+The full system of equations, in terms of is then:
The adiabatic case is the special case where .
It is not immediately clear that this is the same model as the adiabatic pressure equation. The adiabatic pressure equation assumes the expansion within the vessel is isentropic, but that condition is not explicitly applied in the energy equation. One hint this is the same model is that the ideal gas solution can be derived from the energy balance.
+Consider an ideal gas with constant heat capacities such that and
. For the adiabatic case the energy balance becomes:
Isentropic choked flow of an ideal gas occurs with:
+With nozzle density and temperature related to the vessel conditions by:
+Substituting all of this into the energy equation and dividing by gives:
Where . The time constant
is defined such that:
Which simplifies the ODE to:
+This is a separable equation and can be integrated to give:
+For an adiabatic expansion of an ideal gas:
+Which recovers the original solution:
+The governing equations for the vessel blowdown can be implemented as a DAE though, as the state variables, , no longer include pressure, determining when the vessel has fully depressured is slightly more complicated. The callback function must first calculate the pressure in the system. Previously, the callback function was a
ContinuousCallback, which adjusts the final time step to exactly depressurize the vessel. Here the callback is a DiscreteCallback which terminates once a time step has crossed the threshold.
function energy_eqn!(dy, y, prms, t)
+ u, v, T = y
+
+ h = molar_enthalpy(prms.model, v, T)
+ w = mass_flow(prms.model, prms.pv, prms.env, v, T)
+ M = molecular_weight(prms.model)
+ V = prms.pv.V
+
+ dy .= [ (M*prms.Qᵢ(T) + (u-h)*w)*v/(M*V)
+ (w*v^2)/(M*V)
+ u - molar_internal_energy(prms.model, v, T) ]
+ return nothing
+endueqn_rhs = ODEFunction(energy_eqn!, mass_matrix = [ 1 0 0
+ 0 1 0
+ 0 0 0 ])depressured_callback_2(y, t, I; reltol=0.001) =
+ pressure(I.p.model, y[2], y[3]) < (1+reltol)*I.p.env.PTo generate the blowdown curve the pressure must be calculated, as it is no longer an output of the ODE. This could be done on demand, retrieving the molar volume and temperature for a given time and calculating the pressure. Another approach is to calculate the pressure at each time step and interpolate. This is implemented here as a SavingCallback, which calculates and saves the pressure after each time step. A cubic interpolation of the pressure is created from the results and used to generate the blowdown curve. The solution type contains two pieces: the ode solution and the pressure-time interpolation.
using DataInterpolationsstruct EnergyODE{S,I}
+ ode_sol::S
+ p_interp::I
+endfunction energy_eqn_blowdown(model, pv::PressureVessel,
+ env::Environment;
+ Qi=(T)->0.0,
+ solver=Rodas5(),
+ tspan=(0.0, 600.0))
+
+ # vessel initial conditions
+ V, T₀, m = pv.V, pv.T, pv.m
+ Mw = molecular_weight(model)
+ v₀ = Mw*V/m
+ u₀ = molar_internal_energy(model, v₀, T₀)
+
+ # defining the parameters
+ params = (model=model, pv=pv, env=env, Qᵢ=Qi)
+
+ # callbacks
+ svs = SavedValues(Float64, Float64)
+ svcb = SavingCallback((y, t, I) -> pressure(I.p.model,y[2],y[3]), svs)
+ dpcb = DiscreteCallback(depressured_callback_2, terminate!)
+ cbs = CallbackSet(svcb,dpcb)
+
+ # set up the ODEProblem and solve
+ y₀ = [u₀; v₀; T₀]
+ prob = ODEProblem(ueqn_rhs, y₀, tspan, params)
+ sol = solve(prob, solver; callback=cbs)
+
+ # set up pressure interpolation
+ pi = AkimaInterpolation(svs.saveval, svs.t)
+
+ return Blowdown(pv,env,EnergyODE(sol,pi))
+endThe methods for blowdown time, pressure, and temperature are easily implemented.
+blowdown_time(bd::Blowdown{<:EnergyODE}) =
+ bd.sol.ode_sol.t[end]function blowdown_pressure(bd::Blowdown{<:EnergyODE}, t)
+ bdt = blowdown_time(bd)
+ t = min(t, bdt)
+ return bd.sol.p_interp(t)
+endfunction blowdown_temperature(bd::Blowdown{<:EnergyODE}, t)
+ bdt = blowdown_time(bd)
+ t = min(t, bdt)
+ return bd.sol.ode_sol(t; idxs=3)
+endThe results from the energy model can be compared to the pressure model, they are functionally identical.
+ideal_gas_energybd = energy_eqn_blowdown(ig_nitrogen, vessel, atm);real_gas_energybd = energy_eqn_blowdown(nitrogen, vessel, atm);I did not put a lot of effort into making exceptionally performant code. Firstly, the model for isentropic flow through the valve could be improved. Presumably this could also be incorporated into the governing equations of the ODEs, at a cost to model simplicity and reusability, which might unlock some performance opportunities.
+Given those limitations, the performance of the two models can be compared using BenchmarkTools.jl.
@benchmark adiabatic_blowdown(nitrogen, vessel, atm)BenchmarkTools.Trial: 42 samples with 1 evaluation.
+ Range (min … max): 111.588 ms … 149.424 ms ┊ GC (min … max): 0.00% … 20.45%
+ Time (median): 120.515 ms ┊ GC (median): 6.11%
+ Time (mean ± σ): 120.226 ms ± 5.882 ms ┊ GC (mean ± σ): 4.39% ± 4.00%
+
+ ▂ ██ ▅█ █
+ ▅██▁▅█▅▁▁▁▅▅███▅████▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▅ ▁
+ 112 ms Histogram: frequency by time 149 ms <
+
+ Memory estimate: 59.87 MiB, allocs estimate: 948090.
+The adiabatic blowdown using the energy model is about 35% faster than the pressure model. Partly this is due to the choice of terminating callbacks. Whether or not integration is terminated with a DiscreteCallback or a ContinuousCallback does not meaningfully change the performance for the pressure model. However, this choice dramatically changes the performance of the energy model. Changing to a ContinuousCallback erases the difference between the two models.
@benchmark energy_eqn_blowdown(nitrogen, vessel, atm)BenchmarkTools.Trial: 56 samples with 1 evaluation.
+ Range (min … max): 82.811 ms … 122.534 ms ┊ GC (min … max): 0.00% … 28.08%
+ Time (median): 89.420 ms ┊ GC (median): 0.00%
+ Time (mean ± σ): 89.381 ms ± 6.032 ms ┊ GC (mean ± σ): 4.05% ± 5.62%
+
+ ▂ ▂ ▂ ▂ █▂
+ █▅▅▅▁▅█▅█▅▅█▅▁▅▅██▅▁▁▁▁▁▁▁▁▁▅▁▁▁▁▅▁▁▁▁▅▅█▅██▁██▅█▁▁▅▅▁▅▁█▁▁▅ ▁
+ 82.8 ms Histogram: frequency by time 95.7 ms <
+
+ Memory estimate: 44.30 MiB, allocs estimate: 727470.
+The pressure model performance at the end of the blowdown is strongly dependent on whether molar volume is used as a state variable. When used as a state variable there is a major performance hit compared to moving the volume into the RHS, nearly double the compute time. Removing it as a state variable comes with a cost to the accuracy near the termination of the blowdown. It is not obvious to me why this is the case (maybe using volume as a system variable forces the solver to take smaller time steps?), but it hints that there are opportunities to improve the pressure model by tweaking how molar volume is incorporated.
+A big caveat to the kind of loose performance comparison I did here is that I did not define a metric for performance. If you wanted to more rigorously benchmark these two approaches defining what constitutes “good enough” in terms of the blowdown curve is necessary. You can always make a model faster by making it less precise.
+Extending the ideal gas blowdown to real gases using Clapeyron.jl is straightforward. Though the adiabatic case immediately calls into question the point in doing so. Even for a system as simple as a cylinder of nitrogen, the adiabatic assumption is too extreme to be plausible: it predicts the blowdown of a room temperature cylinder will result in a spray of liquid nitrogen. Really, though, the model breaks down once it results in the gas inside the vessel dropping below the boiling point while remaining a gas.
Rapid blowdowns often lead to cryogenic conditions where the assumption that the fluid in the vessel remains a gas becomes increasingly unlikely. The energy model given here can already accommodate variable heat transfer, for example , and it could be extended to include phase change by performing an isothermal flash calculation at each time step (and adjusting the enthalpy and internal energy calculations to account for the multiple phases). For a more realistic SCUBA tank model, this level of complexity isn’t needed, once a realistic heat transfer model is added the liquefaction problem would go away.
Slower blowdowns, relative to the volume of the vessel, make more sense to model as always a gas. In these cases however, modelling the vessel as having no internal flow may be a serious limitation. Modelling the blowdown of pipeline segments, for example, without accounting for the frictional losses from internal flows leads to a significant error. I didn’t include an example of isothermal blowdowns here, but it is even easier to implement than the adiabatic case (for the pressure equation).
+I think there is a limited space between the pure ideal gas blowdown model and a full real fluid model with heat transfer &c. Most real situations either don’t require meticulously accounting for fluid non-ideality, and the ideal gas model works well enough, or are complex enough that a realistic model that includes phase change and heat transfer is required. However, building up from the ideal gas case step by step offers multiple points where the intermediate steps can be checked against known solutions. This is a useful exercise when building complex models, which can otherwise be difficult to test and troubleshoot.
+ + +
+Consider the blowdown of a pressure vessel to a vent stack, where the vessel contains a gas. What we want is the time to fully depressure and the pressure curve (the blowdown curve). As a first approximation we can consider the ideal gas case and examine two limiting behaviours for the vessel: when the walls are perfect insulators (the adiabatic case) and when the walls are perfect conductors of heat (the isothermal case). Furthermore we assume the blowdown is through an isentropic nozzle.
+The adiabatic case is often a good approximation for small vessels and early in the blowdown, when the rate of energy lost from the vessel through the bulk transport of the gas is much higher than any heat gained from the environment.
+Starting with a mass balance on the vessel:
+where m is the mass inside the vessel and w is the mass flow through the valve. Since the volume of the vessel is a constant, V, we can write the mass balance as
+We can perform a change of variables from ρ to P
+The partial derivative is taken along an isentropic path as the adiabatic expansion within the vessel is isentropic (not because the valve is isentropic).
+We can write the mass flow through the nozzle in terms of the theoretical, friction less, mass velocity G, the discharge coefficient , and the flow area A.
giving1
+1 Botros, Jungowski, and Weiss, “Models and Methods of Simulating Gas Pipeline Blowdown”.
Assuming the flow through the valve is choked, the velocity in the throat is the sonic velocity which, for an ideal gas, is given by
An ideal gas undergoing an adiabatic expansion from vessel pressure to the pressure in the throat of the valve has the following relationship between density and pressure2
+2 Any physical chemistry textbook, such as Laidler, Meiser, and Sanctuary, Physical Chemistry, 79–81.
and, for choked flow, the pressure ratio is at maximum at3
+3 Tilton, “Fluid and Particle Dynamics,” 6-22-6-23.
putting this all together we can write G in terms of vessel conditions and
From thermodynamics we know
+and we can put this all together to get
+At this point, it is standard to introduce a time constant
or, more clearly,
+Where the subscript 0 indicates the initial conditions in the vessel. This simplifies the expression to
+Which is separable and can be integrated to give (after some rearrangement)
+and the depressure time is
+Another useful thing to determine is the mass flow rate over time, which can be recovered rather easily recalling
+and
+we get
+By recalling the definition of this simplifies to
This final model, for mass flow, is the model most often given in process safety references for blowdown rates. This makes some sense as early in a blowdown the observed pressure curve tend to approximate the adiabatic curve. However (foreshadowing) the isothermal curve leads to higher predicted vessel pressures, and generally higher mass flow rates, which might be more conservative depending on the context.
+The adiabatic model is the only simple model given in Lees,4 with the recommendation to use software such as BLOWDOWN to handle more complex, multi phase, mixtures and heat transfer problems. This is also what my older copy of Perry’s gives,5 albeit with a typo.
+4 Lees, Loss Prevention in the Process Industries, 15/44.
5 Crowl et al., “Process Safety.” 23–57.
Perry’s gives the following
+Note the sign change, it should be k-1 not k+1, given typical values of k~1.4 this actually a huge difference.
+Perry’s only gives the mass flow, so if you wanted the pressure (and the gas density and temperature) you would need to find some other reference. Or do it yourself, it does sketch out how the equation is derived, if you have the spare time to sit down and integrate.
+There are two obvious limitations to this model: it relies on the gas being well approximated by an ideal gas and that the flow out of the vessel is always choked. The first issue I am not going to deal with right now, the second one I think can be easily dealt with by slightly modifying the governing equations.
+We can solve this numerically given
+function isentropic_mass_flow(P, ρ; k=1.4, Pₐ=101325)
+ η = max( Pₐ/P, (2/(k+1))^(k/(k-1)))
+ G² = ρ*P*(2k/(k-1))*( η^(2/k) - η^((k+1)/k) )
+ G = G² > 0 ? √(G²) : 0
+ return G
+endfunction speed_of_sound(P, ρ; k=1.4)
+ a = √(k*P/ρ)
+ return a
+endfunction adiabatic_vessel(P, params, t)
+ c, A, V, k, ρ₀, P₀, Pₐ = params
+ ρ = ρ₀*(P/P₀)^(1/k)
+ a² = speed_of_sound(P, ρ; k=k)^2
+ G = isentropic_mass_flow(P, ρ; k=k, Pₐ=Pₐ)
+ return-c*A*a²*G/V
+endwith a callback function to terminate the integration once the vessel is fully depressured
+function depressured_callback(P, t, integrator; tol=0.001)
+ c, A, V, k, ρ₀, P₀, Pₐ = integrator.p
+ return P - (1+tol)*Pₐ
+endJust to have a real system to think about, I used to SCUBA dive when I was a teenager and had a few mishaps early on, when I was still figuring things out, accidentally opening the tank valve when the regulator yoke was not fully attached. Blasting air all over the place while I scrambled to shut it off. Typical tanks have capacities ranging from 80 cu. ft. to 100 cu. ft., with working pressures of >3000 psi. That’s a pretty high pressure for a relatively small tank. How fast could the tank blowdown if I opened the valve fully and just sat back and watched?
+# Ambient conditions
+begin
+ Pₐ = 101.325e3 # Pa
+ Tₐ = 288.15 # K
+ ρₐ = 1.21 # kg/m³
+end;I looked around online and a typical tank with a 80 cu. ft. capacity might have a “water volume” (actual internal volume) of 678 cu. in. (11.11L) and a working pressure of 3000 psi (20.68 MPa). I don’t actually know the flow area of a tank valve, I couldn’t find it easily, so I’m going to guess it’s basically a 1 mm diameter tube when fully open, with a discharge coefficient of 0.85 – all of this could be firmed up better with some real details of the valve. But this is a start.
+#Vessel conditions
+begin
+ c = 0.85
+ D = 0.001 # m
+ A = 0.25*π*D^2 # m²
+ V = 0.01111 # m³
+ P₀ = 20.68e6 # Pa
+ T₀ = Tₐ
+ ρ₀ = ρₐ*(P₀/Pₐ) # ideal gas law
+ k = 1.4
+end;I then set up the differential equation and integrate to get the blowdown curve.
+using OrdinaryDiffEq, Plotsbegin
+ params = (c, A, V, k, ρ₀, P₀, Pₐ)
+ t_span = (0.0, 12.0)
+ prob = ODEProblem(adiabatic_vessel, P₀, t_span, params)
+ sol = solve(prob, Tsit5(),
+ callback=ContinuousCallback(depressured_callback, terminate!))
+end;This model has the tank blowing down pretty fast, in less than 30s. Probably my guess for the valve area is too large. I did just make it up.
+Regarding the models themselves, the adiabatic choked model is a very good approximation to the full ODE until the last few fractions of a second, at which point the models diverge. This likely to be true for any high pressure blowdowns, where the vessel pressure starts well above ~2atm, as in that case the majority of the blowdown will be entirely in the choked flow regime.
+To play around with this more, I am first going to detour into creating some helper functions and I think this is a natural point to create a struct to contain the vessel parameters.
begin
+
+struct PressureVessel{F <: Number}
+ c::F
+ A::F
+ V::F
+ k::F
+ ρ₀::F
+ P₀::F
+ Pₐ::F
+ τ::F
+end
+
+PressureVessel(c, A, V, k, ρ₀, P₀, Pₐ, τ) =
+ PressureVessel(promote(c, A, V, k, ρ₀, P₀, Pₐ, τ)...)
+
+function PressureVessel(c, A, V, k, ρ₀, P₀, Pₐ)
+ τ = 1/( (c*A/V)*√(k*P₀/ρ₀)*(2/(k+1))^((k+1)/(2*(k-1))) )
+ return PressureVessel(c, A, V, k, ρ₀, P₀, Pₐ, τ)
+end
+
+end;Where I have added a helper function to ensure all numbers are of the same type, and calculate the value of τ when the PressureVessel type is constructed.
Recreating the results from above, I start with a definition of the vessel
+vessel = PressureVessel(c, A, V, k, ρ₀, P₀, Pₐ);I would like to create some generic functions for the blowdown properties I am interested in: pressure and mass flow rate as functions of time and total blowdown time. To accommodate this I define another type to contain the VesselBlowdown solution.
abstract type Blowdown endstruct AdiabaticBlowdown{S} <: Blowdown
+ pv::PressureVessel
+ sol::S
+endHere I add some functions to make a Blowdown object act like an iterator with only a single element. This is absolutely pointless except that I just happen to like being able to generate a vector of results by using the “dot” notation, like so
my_function.(blowdown, time_vector)where I want it to broadcast over the time_vector.
Base.length(::Blowdown) = 1Base.iterate(b::Blowdown, state=1) = state > length(b) ? nothing : (b,state+1)For the simple choked model this is fairly straight forward.
+adiabatic_blowdown_choked(vessel::PressureVessel) =
+ AdiabaticBlowdown(vessel,nothing)function blowdown_pressure(bd::AdiabaticBlowdown, t)
+ P₀, k, τ = bd.pv.P₀, bd.pv.k, bd.pv.τ
+ return P₀*( 1 + 0.5*(k-1)*(t/τ))^((2*k)/(1-k))
+endfunction blowdown_mass_rate(bd::AdiabaticBlowdown, t)
+ ρ₀, V, P₀, k, τ = bd.pv.ρ₀, bd.pv.V, bd.pv.P₀, bd.pv.k,
+ bd.pv.τ
+ m₀ = ρ₀*V
+ w₀ = m₀/τ
+ P = blowdown_pressure(bd, t)
+ return w₀*(P/P₀)^((k+1)/(2k))
+endfunction blowdown_time(bd::AdiabaticBlowdown)
+ P₀, Pₐ, k, τ = bd.pv.P₀, bd.pv.Pₐ, bd.pv.k, bd.pv.τ
+ return (2τ/(1-k))*(1 - (Pₐ/P₀)^((1-k)/2k))
+endFor the full model the initial step is to integrate the differential equation. As a first guess, I calculate the blowdown time for a fully choked blowdown and set the outer-bound for the integration to 10× this. The integrator will terminate when the pressure reaches ambient and thus the last time stored will be the actual blowdown time.
+function adiabatic_blowdown_full(vessel::PressureVessel; solver=Tsit5())
+ # unpack the parameters
+ c, A, V, k, ρ₀, P₀, Pₐ = vessel.c, vessel.A, vessel.V,
+ vessel.k, vessel.ρ₀, vessel.P₀,
+ vessel.Pₐ
+ params = (c, A, V, k, ρ₀, P₀, Pₐ)
+
+ # estimate the time span needed to fully blowdown
+ τ = vessel.τ
+ t_bd = (2τ/(1-k))*(1 - (Pₐ/P₀)^((1-k)/2k))
+ t_span = (0.0, 10t_bd)
+
+ # set up the ODEProblem and solve
+ prob = ODEProblem(adiabatic_vessel, P₀, t_span, params)
+ sol = solve(prob, solver,
+ callback=ContinuousCallback(depressured_callback, terminate!))
+
+ return AdiabaticBlowdown(vessel,sol)
+endfunction blowdown_pressure(bd::AdiabaticBlowdown{<:ODESolution}, t)
+ if t < blowdown_time(bd)
+ return bd.sol(t)
+ else
+ return bd.sol.u[end]
+ end
+endfunction blowdown_mass_rate(bd::AdiabaticBlowdown{<:ODESolution}, t)
+ if t < blowdown_time(bd)
+ # unpack the parameters
+ c, A, k, ρ₀, P₀, Pₐ = bd.pv.c, bd.pv.A, bd.pv.k,
+ bd.pv.ρ₀, bd.pv.P₀, bd.pv.Pₐ
+
+ # calculate w = c*A*G
+ P = blowdown_pressure(bd, t)
+ ρ = ρ₀*(P/P₀)^(1/k)
+ G = isentropic_mass_flow(P, ρ; k=k, Pₐ=Pₐ)
+
+ return c*A*G
+ else
+ return 0.0
+ end
+endblowdown_time(bd::AdiabaticBlowdown{<:ODESolution}) =
+ bd.sol.t[end]At this point I’ve built up enough machinery that playing around with all sorts of variations to the original case become quite simple. As an example, I look at the same air tank but pressured to 1.5 atm instead.
+test_vessel = PressureVessel(c, A, V, k, ρ₀, 1.5Pₐ, Pₐ);Now it is clear that the fully choked model model isn’t working well, it predicts a blowdown time of 11.68s whereas numerically solving the ODE gives an answer of 20.49s, a 75.0% greater predicted blowdown.
+That said…I’m being a little coy about something: the full ODE predicts that the vessel will never blowdown. The pressure will get closer and closer to ambient but never get there. This is because G, for non-choked flow, asymptotically approaches zero as the vessel pressure approaches ambient pressure. How you define blowdown time is really a function of how close to ambient is close enough. Even if I set the tolerance in the depressured_callback function, which terminates the integration once the integrator is within tolerance of the ambient pressure, to zero it would, in reality, simply terminate at the default numerical precision of DifferentialEquations.jl. In this case I’ve said “within 0.1% of ambient is close enough,” but that’s totally arbitrary.
The other limiting case worth exploring is the isothermal case, which is equivalent to the vessel having perfectly conductive walls and remaining always at thermal equilibrium with the environment. This is often a good approximation for large vessels where the blowdown rate is small relative to the thermal mass of the gas in the vessel.
+Recalling, for the adiabatic case, we had the following
+For the isothermal case the vessel is being depressured along an isothermal path (not an isentropic path) and so we substitute the appropriate partial derivative6
+6 Botros, Jungowski, and Weiss, “Models and Methods of Simulating Gas Pipeline Blowdown”.
As before, the blowdown is through an isentropic nozzle and we assume that flow is choked
+From thermodynamics we can write the partial derivative as
+Thus
+where the densities, , can be cancelled and, since the vessel is isothermal (i.e.
is a constant), the various constants can be collected to give
Where is as defined for the adiabatic case. This can easily be integrated to give
it also follows, from the ideal gas law, that
+and
+This can also be rearranged to give the blowdown time7
+7 N.B. the is the natural log, this matches the convention used in julia
This is the equation seen most often in references for estimating blowdown time for pipelines and compressor systems. It is also what is going on under the hood with many online calculators for vessel blowdown times. Though, in my experience, this is not always well documented and a modified form is often presented.
+The time constant, , can be broken up to look like this
Where we have made the substitution for
to account for non-ideal behaviour. If the gas has a value of k ~ 1.4, we can write
Where the constant is calculated entirely from the properties of air. Generally, I have found, few references describe where this constant comes from and in particular that it depends implicitly on a particular value for k. It also often has unit conversions absorbed into it, for example8
+8 Campbell, Gas Conditioning and Processing, 2:29; VANEC, “Pressure Volume-Blowdown Time Calculation”.
with the units
+I have also found a few sources that leave the value of the constant as a mystery for the user to puzzle out.9 I was honestly surprised at the quality of the results when I first googled this and looked it up in Knovel. The highest ranked results, at the time, were cryptic to the point of uselessness or included obvious mistakes (several referred to t as the “interstitial velocity” with units of cm/s, an obvious misprint being blindly recopied in several places, including some e-books on Knovel where one would hope the quality control would be better). There are a few places with useful derivations10 but I think a good starting point is the Tank Blowdown Math set of notes. It is pretty straight forward and does not require a lot of prior knowledge of the partial derivatives of various thermodynamic state variables.
+9 Temizel et al., Formulas and Calculations for Petroleum Engineering, 262; Engineers Edge, “Blowdown Time in Unsteady Gas Flow Calculator and Equation”.
10 Wheeler, “Tank Blowdown Math”; Botros, Jungowski, and Weiss, “Models and Methods of Simulating Gas Pipeline Blowdown”; Botros and Hardeveld, Pipeline Pumping and Compression Systems - a Practical Approach, 447; Saad, Compressible Fluid Flow, 98–103, to list but a few.
I personally would not bother with the models that pre-calculate the constant for you. We no longer live in the age of slide-rules. The blowdown time equation for fully choked flow is well within the capabilities of excel or any competent person with a scientific calculator. I think it is easier to justify and explain, will be a better model for gases where k is not 1.4, and allows one to incorporate small levels of non-ideality through the isentropic expansion factor n.
+The isothermal fully-choked model can be implemented building on the types already created, by first creating an IsothermalBlowdown type and associated blowdown functions
struct IsothermalBlowdown{S} <: Blowdown
+ pv::PressureVessel
+ sol::S
+endisothermal_blowdown_choked(vessel::PressureVessel) =
+ IsothermalBlowdown(vessel,nothing)function blowdown_pressure(bd::IsothermalBlowdown, t)
+ P₀, τ = bd.pv.P₀, bd.pv.τ
+ return P₀*exp(-t/τ)
+endfunction blowdown_mass_rate(bd::IsothermalBlowdown, t)
+ ρ₀, V, τ = bd.pv.ρ₀, bd.pv.V, bd.pv.τ
+ m₀ = ρ₀*V
+ w₀ = m₀/τ
+ return w₀*exp(-t/τ)
+endblowdown_time(bd::IsothermalBlowdown) =
+ bd.pv.τ*log(bd.pv.P₀/bd.pv.Pₐ)In a similar vein as the adiabatic case, the requirement for fully choked flow can be relaxed and the ODE integrated numerically instead, starting with the system
+We can solve this numerically given that, for an isothermal system, the density is given by
+and using the definition of G given in the adiabatic case.
+function isothermal_vessel(P, params, t)
+ c, A, V, k, ρ₀, P₀, Pₐ = params
+ ρ = ρ₀*(P/P₀)
+ a² = speed_of_sound(P, ρ; k=k)^2
+ G = isentropic_mass_flow(P, ρ; k=k, Pₐ=Pₐ)
+ return-c*A*a²*G/(k*V)
+endfunction isothermal_blowdown_full(vessel::PressureVessel; solver=Tsit5())
+ # unpack the parameters
+ c, A, V, k, ρ₀, P₀, Pₐ = vessel.c, vessel.A, vessel.V,
+ vessel.k, vessel.ρ₀, vessel.P₀,
+ vessel.Pₐ
+ params = (c, A, V, k, ρ₀, P₀, Pₐ)
+
+ # estimate the time span needed to fully blowdown
+ τ = vessel.τ
+ t_bd = τ*log(P₀/Pₐ)
+ t_span = (0.0, 10t_bd)
+
+ # set up the ODEProblem and solve
+ prob = ODEProblem(isothermal_vessel, P₀, t_span, params)
+ sol = solve(prob, solver,
+ callback=ContinuousCallback(depressured_callback, terminate!))
+
+ return IsothermalBlowdown(vessel,sol)
+endfunction blowdown_pressure(bd::IsothermalBlowdown{<:ODESolution}, t)
+ if t < blowdown_time(bd)
+ return bd.sol(t)
+ else
+ return bd.sol.u[end]
+ end
+endfunction blowdown_mass_rate(bd::IsothermalBlowdown{<:ODESolution}, t)
+ if t < blowdown_time(bd)
+ # unpack the parameters
+ c, A, k, ρ₀, P₀, Pₐ = bd.pv.c, bd.pv.A, bd.pv.k,
+ bd.pv.ρ₀, bd.pv.P₀, bd.pv.Pₐ
+
+ # calculate w = c*A*G
+ P = blowdown_pressure(bd, t)
+ ρ = ρ₀*(P/P₀)
+ G = isentropic_mass_flow(P, ρ; k=k, Pₐ=Pₐ)
+
+ return c*A*G
+ else
+ return 0.0
+ end
+endblowdown_time(bd::IsothermalBlowdown{<:ODESolution}) =
+ bd.sol.t[end]It is a similar story to the adiabatic case: for systems with a high initial pressure, the flow out of the valve is fully choked for almost the entire blowdown. It is only in the final fraction of a second that the full ODE system deviates from the model that assumes flow is choked all the time.
+In most practical situations, the difference would likely be swamped by two much greater problems with these models:
+Both of these assumptions will have a much greater impact on how well the model fits observed blowdowns than the slight deviation at the end of the blowdown due to non-choked flow.
+I think it might be simpler to visualize when the choked flow blowdown models will fall down by looking at the high pressure blowdown, the original example, versus the low pressure blowdown in dimensionless form. In this form, the choked flow blowdown curves (both adiabatic and isothermal) only depend on k. They are in fact the exact same curve. All that has changed is where along the curve the blowdown terminates.
+In the high pressure case the blowdown terminates much closer to and most of the curve is fully choked.
In the low pressure case the blowdown terminates at a much steeper part of the blowdown curve and the departure for non-choking flow is much more apparent.
+It is not immediately clear to me why the adiabatic case is all over the standard references for process safety, and the isothermal model is not. If what you care about is the pressure sustained within a vessel, the mass flow rate emitted through a blowdown stack or vent, and the duration of the blowdown, it is almost always more conservative to use the isothermal case. The isothermal (fully choked) model is also just easier to calculate, being just .
The adiabatic case will give a better sense of how temperature changes within the vessel. I’ve largely left it out, but adiabatic blowdown does lead to a significant temperature drop and this cryogenic cooling can be a process hazard on its own. The gas exiting, and the vessel walls themselves, will get quite cold. Anyone who has gone camping in more marginal weather and watched a one-pound propane cylinder develop frost on the outside while cooking has seen this effect in action.11 But actually calculating the vessel temperature is almost entirely ignored in blowdown calculations for ideal gases, in my experience.
+11 This is also why butane cylinders are often not a good idea for early spring camping (in Canada), the cooling effect is strong enough to cause the butane inside to liquefy and the stove won’t work very well.
The isothermal model, in my review of the literature, appeared to be more commonly used in operational contexts, such as estimating the time required to blowdown a system through a blowdown vent. In this case it is likely to be the conservative answer. The two curves do cross at high and so it is not always the case that the isothermal model is more conservative. Something worth noting.
I deliberately set up the ODEs such that there is a clear path to implementing a real gas model through an equation of state. All that really needs to be done is to create functions for these three steps:
+Plugging those into the relevant steps in the adiabatic_vessel and isothermal_vessel functions changes from the ideal gas case to the real gas case. The rest of the code remains the same and operates unchanged.
In this case I think solving the full ODE for the ideal gas case alone is probably not worth the effort for most cases. The error in assuming an ideal gas, or in assuming one of the limiting heat transfer cases, is probably far larger than the error in assuming fully choked flow for all but the few cases that are near atmospheric pressure. If you are going to be estimating the blowdown for a real gas, then that’s different. If you are going to the hassle of setting up and solving the ODE, might as well have as few unnecessary assumptions as you can get away with. It really isn’t any more person effort, at that point, just more computer effort, and when the calculations happen in less than a second, how much less than a second is of little practical importance.
+1 API, Standard 520, Sizing, Selection, and Installation of Pressure-Relieving Devices, Part i - Sizing and Selection, 10th Ed, 68.
Clapeyron.jl comes to the rescue here with a wide array of equations of state for real fluids. Combined with julia’s robust ecosystem of libraries for integration and optimization, solving real fluid problems becomes simple. This post walks through how to size a relief device, in gas service, starting from an ideal gas and working through various methods for real gases using equations of state.
+The general idea for sizing a relief valve is to determine the minimum area required such that the mass flow through the valve equals the mass flow required for the governing release case via the relation2
+2 API, Standard 520, Sizing, Selection, and Installation of Pressure-Relieving Devices, Part i - Sizing and Selection, 10th Ed equation B.5.
where is the required mass flow-rate,
is a capacity correction,
is the theoretical flow area, and
the frictionless) mass flux through the valve. Valves are sized based on the theoretical flow area.
The general process is as follows:
+Standards, such as API-2020, give equations that combine steps 3 and 4 and absorb unit-conversions into the constants, so that the equation is in a more convenient form, but this is what is happening under the hood.
+The complications creep in through calculating , it is path-dependent and is a function of the equation of state for the fluid. For gas releases the relief device is typically treated as an isentropic nozzle, the assumption being that the flow-rate through the valve is typically large enough that any heat transfer can be neglected.
+Consider the differential form of the mechanical energy balance, along a streamline from the stagnation point, in the vessel, through the valve and out into the atmosphere, assuming no elevation change and no friction
+Integrating from the stagnation point to the throat of the nozzle gives
+Where the velocity at the stagnation point, . Putting this in terms of the mass flux
This integral cannot be solved directly at this point as the conditions at the throat of the nozzle are not known. Solving this requires simultaneously solving for the nozzle conditions, .
If we specify that the streamline follows an isentropic path, then we can construct a constrained maximization problem: the nozzle conditions are the and
which maximizes
where the integration is taken along an isentropic path.
In the case where flow is choked, i.e. the flow in the nozzle reaches sonic velocity, the maximum occurs at the sonic velocity with a pressure
. This can allow for the direct calculation of the mass flux as
, where
is the sonic velocity at the throat. No integration required.
Consider the release of ethane from a vessel at 200 bar and 400 K, for the sake of simplicity assume the release is directly into the atmosphere at 1 bar and 288.15 K (15°C) (the flow is going to be choked, so this doesn’t actually matter).
+using Unitfulbegin
+# the vessel properties
+ P₁ = 200u"bar"
+ T₁ = 400u"K"
+
+# the ambient properties
+ P₂ = 1u"bar"
+ T₂ = 288.15u"K"
+endWe can use Clapeyron.jl to initialize a few example equations of state for ethane. In this case I’m going to use an ideal gas model (ReidIdeal is an ideal gas model that also includes correlations for the ideal gas heat capacity), a cubic equation of state (volume translated Peng Robinson), and an empirical Helmholtz model (GERG-2008).
using Clapeyronbegin
+# assorted equations of state for ethane
+ ig_ethane = ReidIdeal(["ethane"])
+ vtpr_ethane = VTPR(["ethane"]; idealmodel = ReidIdeal)
+ gerg_ethane = GERG2008(["ethane"])
+end# this is a hack, ideal models in Clapeyron do not return a
+# molar weight and so cannot return a mass density
+Clapeyron.mw(model::IdealModel) = Clapeyron.mw(vtpr_ethane)At system conditions ethane is a super critical fluid, with the temperature and pressure above the critical point, which can be modelled as a dense gas.
+Considering the choked flow case, we know that and, for an ideal gas, the sonic velocity is given by3
3 Tilton, “Fluid and Particle Dynamics” equation 6-113.
Combining these we have
+It can be shown that, along an isentropic path defined by , the critical pressure ratio is4
4 Tilton equation 6-119.
Which allows us to write
+and (using )
Substituting back into the equation for 5
5 API, Standard 520, Sizing, Selection, and Installation of Pressure-Relieving Devices, Part i - Sizing and Selection, 10th Ed equation B.21.
or, to put it in terms of density
+where , the isentropic expansion factor for an ideal gas, is the ratio of heat capacities
This is the basis of API 520 Part 1 equation 9 where the following substitutions is made:
+and the constant and some unit conversions are rolled up into the constant 0.03948 in the expression for
We can use Clapeyron.jl to calculate at any given temperature, using correlations for the ideal gas heat capacity.
function isentropic_expansion_factor(model::IdealModel, P, T; z=[1.0])
+ cₚ_ig = isobaric_heat_capacity(model, P, T; phase=:vapor)
+ cᵥ_ig = isochoric_heat_capacity(model, P, T; phase=:vapor)
+ return cₚ_ig/cᵥ_ig
+endFrom which we calculate k= 1.146.
+We can check our work by comparing with the tabulated values. At 15°C and 1 atm we calculate k= 1.193 which is the same as the tabulated value of 1.19 (given at 15°C and 1 atm).6
+6 API, 70.
function mass_flux_choked(model, P, T; z=[1.0])
+ k = isentropic_expansion_factor(model, P, T; z=z)
+ ρ = mass_density(model, P, T, z; phase=:vapor)
+ Gₜ² = k*P*ρ*(2/(k+1))^((k+1)/(k-1))
+ return √(Gₜ²)
+endThe theoretical mass flux for the ideal gas is then 38359 kg m^-2 s^-1
+The ideal gas model, when the flow is choked, calculates the mass flux directly without needing to calculate the actual conditions at the nozzle. These can be calculated easily as well.7
+7 Tilton, “Fluid and Particle Dynamics” equations 6-119 and 6-120.
nozzle_pressure_ideal(P, T, k) = P*(2/(k+1))^(k/(k-1))nozzle_temperature_ideal(P, T, k) = T*(2/(k+1))The pressure at the nozzle is 115 bar the temperature at the nozzle is 373 K, which is above the critical point. The fluid supercritical and choked when leaving the PSV.
+At the vessel conditions, the VTPR model of ethane gives a compressibility factor of 0.672 (GERG-2008 model gives a similar value of 0.69), well below 0.8 and therefore outside the range where the ideal gas model is expected to work well.
+An alternative method is to calculate what the effective isentropic expansion factor would be, for the real gas, assuming that the real fluid obeys
+where is a constant.
The derivation of follows from the definition of the speed of sound in a gas8
8 Tilton, 6–22; Gmehling et al., Chemical Thermodynamics for Process Simulation, 113.
The constant entropy partial derivative can be re-written to eliminate entropy9
+9 Gmehling et al., Chemical Thermodynamics for Process Simulation, 660.
Using the relations10
+10 Gmehling et al., Chemical Thermodynamics for Process Simulation equations C.21 and C.8 (respectively).
and
+we get
+and the sonic velocity is then
+equating this to the ideal gas case, , and solving for
gives11
11 API, Standard 520, Sizing, Selection, and Installation of Pressure-Relieving Devices, Part i - Sizing and Selection, 10th Ed equation B.13.
where has been used to distinguish it from
(the ideal gas case). This is the version of
presented in most references, such as API 520. The derivation, however, hints at a useful shortcut to calculating
that does not require digging into the internals of
Clapeyron.jl to retrieve partial derivatives:
The remainder of the calculations are identical as the ideal gas case, simply substituting wherever
appears. Unfortunately
is not actually constant and depends on the temperature and pressure, which are not actually known in the nozzle, so the temperature and pressure at the stagnation point are often used instead.
function isentropic_expansion_factor(model, P, T; z=[1.0])
+ ρ = mass_density(model, P, T, z)
+ c = speed_of_sound(model, P, T, z)
+ n = ρ*c^2/P
+ return n
+endUsing effective isentropic expansion factors from the VTPR equation of state, the theoretical mass flux is 57811 kg m^-2 s^-1 ( 59321 kg m^-2 s^-1 from GERG-2008 ). This is quite a bit larger than the ideal case, indicating that the ideal gas law leads to a significantly over-sized PRV, 51.0% larger.
+The isentropic expansion factor method works best when is approximately constant over the isentropic path. As the above figure shows, this breaks down in ethane for pressures greater than ~100 bar. It also shows that the different equations of state start to diverge greatly further into the supercritical regime.
Another approach, and one I have seen more often in older references, is to perform an energy balance over the isentropic path and, assuming the flow is choked, solve for sonic velocity in the nozzle.12 Consider an energy balance starting at the stagnation point, (1), and following an isentropic path to immediately after the throat of the nozzle (t).
+12 Crowl et al., “Process Safety.” 23–55; Gmehling et al., Chemical Thermodynamics for Process Simulation, 603.
Where is the speed of sound at the nozzle, a function of
and
. The procedure is then to solve the system of equations given by the energy balance and the entropy balance,
, for
and
, then the theoretical mass flux is given by
There are a few ways this could be done, a straight-forward way is to divide the problem into two: 1. Define the isentropic path, i.e. find the isentropic temperature for a given pressure P 2. Use the energy balance to solve for the pressure, following the isentropic path.
+A more direct way is to solve for and
simultaneously. This is what I do next, using NonlinearSolve.jl
# Clapeyron does not expose this by default
+molecular_weight(model,z) = Clapeyron.molecular_weight(model,z)function nozzle_balance(y, prms)
+ P, T = y
+
+ # stagnation point
+ s₁ = prms.entropy
+ h₁ = prms.enthalpy
+
+ # at throat conditions
+ s₂ = entropy(prms.model, P, T, prms.z)
+ h₂ = enthalpy(prms.model, P, T, prms.z)/prms.Mw
+ c² = speed_of_sound(prms.model, P, T, prms.z)^2
+
+ return [ s₁ - s₂
+ h₁ - h₂ - 0.5*c² ]
+endusing NonlinearSolvefunction mass_flux_choked_energy_balance(model, P, T; z=[1.0])
+ # calculate the entropy and specific enthalpy at
+ # initial conditions
+ Mw = molecular_weight(model, z)
+ s₁ = entropy(model, P, T)
+ h₁ = enthalpy(model, P, T)/Mw
+
+ # solve the choked flow energy balance for
+ # an isentropic nozzle
+ params = (model=model, entropy=s₁, enthalpy=h₁, z=z, Mw=Mw)
+ y₀ = [P; T]
+ prob = NonlinearProblem(nozzle_balance, y₀, params)
+ sol = solve(prob, NewtonRaphson())
+ Pₜ, Tₜ = sol.u
+
+ # velocity is the sonic velocity at nozzle conditions
+ ρₜ = mass_density(model, Pₜ, Tₜ, z)
+ cₜ = speed_of_sound(model, Pₜ, Tₜ, z)
+
+ return ρₜ*cₜ
+endfunction mass_flux_choked_energy_balance(model, P::Quantity, T::Quantity; z=[1.0])
+ P = ustrip(u"Pa", P)
+ T = ustrip(u"K", T)
+ return mass_flux_choked_energy_balance(model, P, T; z=z)*1u"kg*m^-2*s^-1"
+endSolving the choked flow energy balance, using VTPR equation of state, the theoretical mass flux is 54629 kg m^-2 s^-1 ( 54353 kg m^-2 s^-1 from GERG-2008 ). This is also quite a bit larger than the ideal case, 42.0% larger. Though the values for the two equations of state are closer, indicating that this method is less sensitive to model choice.
+In this case the ideal gas method and the isentropic expansion factor method bracket the more exact method of solving the energy balance directly.
+As it is written, this method would need to be modified to allow for non-choked flow. This is done by eliminating the assumption and instead finding the conditions which maximize
(subject to the constraints of the entropy balance and the enthalpy balance). This will arrive at the same solution, in the case of choked flow, but with a little more effort.
Direct integration is the method most commonly recommended today, as it is entirely general. It can be used to solve all flow conditions from liquids to gases as well as two-phase mixtures. As a reminder, this method constitutes finding the and
that maximize the mass flux given by
First introduce the change of variables such that the integration is from
to
.
This allows us to write the corresponding differential equation
+subject to the constraint
+Which can be implemented as a differential algebraic equation using DifferentialEquations.jl
+using DifferentialEquationsfunction rhs(u, params, ΔP)
+ ∫vdP, T = u
+ model, P₁, s₁, z, Mw = params
+ P = P₁ - ΔP
+ return [ volume(model, P, T, z)/Mw
+ s₁ - entropy(model, P, T) ]
+endBut we want to stop the integration when or, equivalently, when the velocity is sonic. We can show that these are the same by finding the stationary points of
by applying the chain rule and cancelling we get
recalling the definition of the speed of sound (above)
+we have
+which is simply restating .
function ∂G²_callback(u, ΔP, integrator)
+ ∫vdP, Tₜ = u
+ model, P₁, s₁, z, Mw = integrator.p
+ Pₜ = P₁ - ΔP
+ c = speed_of_sound(model, Pₜ, Tₜ, z)
+ return 2∫vdP - c^2
+endfunction mass_flux_direct_integration(model, P₁, T₁, P₂;
+ z=[1.0], solver=Rodas5P())
+ s₁ = entropy(model, P₁, T₁, z)
+ Mw = molecular_weight(model, z)
+
+ # defining the ODEFunction
+ M = [ 1 0
+ 0 0 ]
+ f = ODEFunction(rhs, mass_matrix = M)
+
+ # defining the ODEProblem
+ u0 = [0.0; T₁]
+ params = (model, P₁, s₁, z, Mw)
+ ΔP_span = (0.0, P₁ - P₂)
+ prob = ODEProblem(f, u0, ΔP_span, params)
+ cb = ContinuousCallback(∂G²_callback, terminate!)
+
+ # solving the DAE
+ sol = solve(prob, solver, callback=cb)
+
+ # unpacking the solution
+ ΔPₜ = sol.t[end]
+ ∫vdP, Tₜ = sol.u[end]
+ ρₜ = mass_density(model, P₁-ΔPₜ, Tₜ, z)
+ G = ρₜ*√(2*∫vdP)
+endfunction mass_flux_direct_integration(model, P₁::Quantity, T₁::Quantity,
+ P₂::Quantity; z=[1.0])
+ P_1 = ustrip(u"Pa", P₁)
+ P_2 = ustrip(u"Pa", P₂)
+ T_1 = ustrip(u"K", T₁)
+ return mass_flux_direct_integration(model, P_1, T_1, P_2; z=z)*1u"kg*m^-2*s^-1"
+endDirect integration of the VTPR equation of state gives a theoretical mass flux of 54629 kg m^-2 s^-1 ( 54353 kg m^-2 s^-1 from GERG-2008 ). Which is exactly the same as from solving the choked flow energy balance, as expected.
+Writing this as a differential algebraic equation was largely necessary because Clapeyron.jl does not expose any routines to calculate the volume as a function of pressure and entropy. Some libraries like CoolProps do, in which case the code could be simplified to be a one dimensional ode.
This method could be extended to include liquid and two-phase flows however, as it is currently implemented, it only handles gases. Unlike the energy balance method, though, the flow does not have to be choked. If the flow is not choked, the maximum will occur once the nozzle pressure reaches . This result will simply pop out without any extra effort.
For the sake of completeness, there are two other methods that should be looked at, which are really special cases: 1. the ideal gas case, but using the real compressibility, , at stagnation conditions, this is the API 520 standard approach for gases 2. using the isentropic expansion factor, n factor, method but calculating n at the average of the stagnation and nozzle conditions
These two approaches do better than the basic methods I presented, but I don’t think they add enough value on their own. Given a model of the gas which can generate the compressibility, using either the energy balance method or the direct integration method produces superior results than correcting the ideal gas case. Once a viable equation of state is in hand, the simplifications are not saving any actual engineer doing their job time, they are saving fractions of a second of compute time.
+I think the choice between the first law energy balance and the direct integration technique is more a matter of taste, at least in the case of choked flow. The direct integration method is in the relevant engineering codes/standards, and that is a strong justification for using it.
+In this case the choice of equation of state did not matter strongly, just for fun I have included a few other common cubic equations of state, they all perform reasonably. However this example is for a single compound that is not strongly associating, it is the type of example where cubic equations of state should work well. The choice of equation of state will be far more important with mixtures and strongly associating substances.
+I have long been an advocate for engineering to move out of using spreadsheets for everything and to use scripting languages and notebooks like Jupyter and Pluto far more. There are large classes of problems that are easy to solve with code and hard to solve with a spreadsheet. I think almost any calculation using equations of state fit into that category. We end up beholden to commercial software suppliers for calculations that, in my view, engineers should be doing themselves.
+Presumably you could do the calculations I laid out above in Excel, at enormous effort, and making liberal use of the solver. Julia, however, has a robust ecosystem for doing all the complicated math, it only needed to be connected up. What remains, for the engineer, is assessing the physical system and picking the appropriate methods and thermodynamic models.
+Hydrogen presents an interesting challenge for hazard analysis as it is lighter than air, rising and accumulating in places a more standard analysis would neglect. In my experience, the typical release modelling tools are for neutrally buoyant or negatively buoyant (heavier than air) gas releases, such as Gaussian plume models or dense gas models like SLAB, DEGADIS, and PHAST. They are not designed for, and may not accurately capture, the dispersion of hydrogen.
+Dense gas models typically assume cloud dynamics that are particular to denser than air clouds, with limiting behavior that brings the results in line with a neutrally buoyant release. Denser than air clouds pile up around the source, leading to dispersion upwind of the source, and have sharper cloud fronts than a more neutral cloud. These features are often written into the governing equations for plume dispersion from the outset.
+Neutral and positive buoyancy plume models typically account for buoyancy differences only through temperature as they are generally intended for hot stack gases and not low molecular weight gases. For example, the standard implementation of Brigg’s plume-rise in tools like ISC3 use only the temperature of the source to calculate the buoyant flux – implicitly assuming the molar weight of the gas is similar to that of air. The original Ooms model1 for positively buoyant plumes also only accounts for buoyancy differences due to temperature. These models would erroneously conclude that a stream of cold hydrogen gas would be heavier than air and would thus sink.
+1 Ooms, “A New Method for the Calculation of the Plume Path of Gases Emitted by a Stack”.
2 Ehrhart et al., “HyRAM+ (Hydrogen Plus Other Alternative Fuels Risk Assessment Models)”.
This leaves a lot of room for integral plume models that better handle the behaviour of low molecular weight gases. One such model is incorporated into HyRAM+, from Sandia National Laboratories in the United States.2 It includes an integral plume model for positively buoyant plumes that accounts for differences in buoyancy by molar weight in addition to temperature, and was designed with hydrogen dispersion in mind.
+HyRAM+ is implemented in python, with a Windows GUI, though I will be using it directly in a jupyter notebook. Partly because I use linux at home, but also I am interested in how one would use the plume dispersion and other tools independently. I’m interested in the use case where this is integrated into an existing process safety management system and what is needed are specific values from a consequence analysis such as the explosive mass.
+Just for something to play around with, consider the case of a leak from a hydrogen cylinder into the ambient air. Suppose a cylinder containing 50kg of hydrogen at 35MPa has fallen over and the valve has broken, creating a leak from a 1/4 in. hole at essentially ground level and is oriented at 45° upwards. The hydrogen is initially at ambient temperature and the ambient air is at standard conditions and is otherwise quiescent.
+
+Using the HyRAM+ API we can create the ambient air, air, and hydrogen, h2, fluid models at initial conditions.
import numpy as np
+import hyram.phys.api as api
+import hyram.phys as hpTa, Pa = 288.15, 101325
+air = api.create_fluid("Air", Ta, Pa)
+
+Th2, Ph2 = Ta, 35e6
+h2 = api.create_fluid("Hydrogen", Th2, Ph2)The broken valve is initialized as an Orifice object, which has a diameter and a discharge coefficient. In this case I assume the discharge coefficient is 0.6.
d_H = 25.4e-3/4 # mm
+c_d = 0.6 # assumed
+theta = np.pi/4
+orifice = hp.Orifice(d_H, c_d)The jet is modeled, by HyRAM+, as as a steady-state jet consisting of 3 distinct zones:3
+3 Ehrhart, Hecht, and Schroeder, Hydrogen Plus Other Alternative Fuels Risk Assessment Models (HyRAM+) Version 5.1 Technical Reference Manual.
4 Ooms, “A New Method for the Calculation of the Plume Path of Gases Emitted by a Stack”.
The jet is created by initializing a Jet object, which solves the zones and integrates the governing equations to determine the plume center-line.
HyRAM+ has several internal models for solving the notional nozzle, the default is the model by Yüceil and Ötügen and is selected with the keyword parameters nn_conserve_momentum=True and nn_T='solve_energy'
jet = hp.Jet(h2, orifice, air, theta0=theta,
+ nn_conserve_momentum=True,
+ nn_T='solve_energy',
+ verbose=True)solving for orifice flow... done
+solving for notional nozzle... done.
+integrating... done.
+With this done, we can retrieve the mass flow-rate (in kg/s)
+jet.mass_flow_rate0.4024272255826383
+We can compare this to a simple ideal gas model of an adiabatic orifice5
+5 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases.
A_h = (np.pi/4)*d_H**2
+k = 1.41
+
+ideal_gas_jet = c_d*A_h*np.sqrt( h2.rho*h2.P*k*pow(2/(k+1),(k+1)/(k-1)) )
+
+ideal_gas_jet0.37797876222402915
+jet.mass_flow_rate/ideal_gas_jet1.0646821086315916
+The HyRAM+ model is estimating a ~6% greater mass flow rate through the orifice than a simple ideal gas jet model. From an end user perspective, this adds a dimension of realism to the model without requiring really anything more from the user. There are probably several opportunities to use more realistic fluid models, elsewhere in the standard literature of hazard analysis, that haven’t been realized more for reasons of tradition and laziness than anything else.
+In the past, modelling an isentropic nozzle with a real gas from scratch was a pain as there is a lot of overhead in implementing a more realistic equation of state. Especially gathering all of the relevant model parameters. With libraries like CoolProp, it really drops the barrier for incorporating more realistic fluid models into ones calculations.
For hydrogen, the hazard we are most concerned with is fires and explosions. Conveniently, we can retrieve the lower flammability limit (LFL) for hydrogen without needing to look it up ourselves.
+lfl = hp.FuelProperties(h2.species).LFLand calculate the distance, along the plume center-line, to the LFL
+streamline_dists = jet.get_streamline_distances_to_mole_fractions([lfl])
+
+streamline_dists[0]19.655324152591245
+similarly we can retrieve the x-y coordinates of the plume extent, out to the LFL
+mole_frac_dists = jet.get_xy_distances_to_mole_fractions([lfl])
+
+mole_frac_dists{0.04: [(0, 13.734800620965242), (0, 14.201255169630102)]}
+
and, finally, calculate the flammable mass in the steady-state jet.
+jet.m_flammable()0.2789535809847125
+The next obvious thing we want to do is plot the actual plume dispersion, to do this we retrieve the x-y coordinates and corresponding mass fraction (X), mole fraction (Y), velocity (v) and temperature (T) fields.
+x, y, X, Y, v, T = jet._contourdatawe can use matplotlib to plot the concentrations and highlight the contour corresponding to the LFL
+
Above I walked through the steps using the physics models included in HyRAM+, but if what you want is just the final plot and some basic parameters for QRA there is a much easier way: use the analyze_plume_jet model in the HyRAM+ API.
plume = api.analyze_jet_plume(air, h2,
+ orif_diam=d_H,
+ rel_angle=theta,
+ dis_coeff=c_d,
+ nozzle_model='yuce',
+ contours=lfl,
+ xmin=0.0,
+ xmax=60,
+ ymin=0.0,
+ ymax=75,
+ vmin=0,
+ vmax=2*lfl,
+ output_dir='figures',
+ filename='h2_plume_fig.png')
By default this outputs a file, instead of plotting directly into the notebook, and does not allow for as much control of the final figure. But it returns the necessary arrays if you wanted to do your own thing.
+The mass flow-rate, distance along the streamline to the LFL, and contour of the LFL are also retrievable.
+plume['mass_flow_rate']0.4024272255826383
+plume['streamline_dists'][0]19.655324152591245
+plume['mole_frac_dists']{0.04: [(0, 13.734800620965242), (0, 14.201255169630102)]}
+This does not return a Jet object, it returns a Dict with just the contours of the plume and some distances, so I am not entirely sure how one would get the explosive mass. This isn’t obviously exposed through the API either.
An important limitation, from a usability standpoint, is that there is no obvious way to retrieve the concentration for a given point. Suppose you have some coordinates x, y, z and you really need to know what the hydrogen concentration will be at that location specifically. HyRAM+ is not really set up to answer that question, or at least that functionality is not obviously exposed to the user. You could take the arrays of x and y points used for generating the plots, do a 2D-interpolation, and work it out from there, but that is kind of clunky.
+Another limitation of this model, if it is being used for process equipment outdoors, is that there is no accounting for wind. Ambient conditions are always assumed to be quiescent. This leads to less dispersion than you would expect were wind included, and may over-estimate the degree to which the plume will rise and disperse vertically.
+Another, more major limitation, is that there is no accounting for the ground. For large releases, near ground level, it may not be obvious that mass is being lost through the ground, and not accumulating as you would actually expect. For example, taking the above scenario and setting the release angle to 45° downward, the jet simply disappears into the earth. In reality the hydrogen should accumulate along the ground, or reflect off with some momentum. Releases near ground-level, with shallow release angles relative to horizontal, may have hazardous build-ups in areas, and that is being neglected by this model.
+plume = api.analyze_jet_plume(air, h2,
+ orif_diam=d_H,
+ rel_angle=-theta,
+ dis_coeff=c_d,
+ nozzle_model='yuce',
+ contours=lfl,
+ xmin=0,
+ xmax=60,
+ ymin=-65,
+ ymax=10,
+ vmin=0,
+ vmax=2*lfl,
+ output_dir='figures',
+ filename='h2_downward_plume_fig.png')
For integral plume dispersion models, like this one, it is typical to restrict the plume center-line such that it cannot extend below the ground, i.e. any integration step that would have a center-line with y<0 is rejected and replaced with one with y≥0. It is also common to implement ground reflection where the plume dispersion “bounces off” the ground, perfectly elastically.
+Preventing the plume from passing through the ground is strictly necessary for denser than air models, for example DEGADIS, as the plume naturally falls to ground level and rolls along it. That perhaps explains why HyRAM+ doesn’t implement this, the plume will naturally rise away from the ground due to the relative density of hydrogen. However, since HyRAM+ assumes the plume is on the ground by default, this strikes me as a significant trap for users. Shallow release angles will have non-physical results in the immediate vicinity of the jet.
+An important feature of this tool is that it allows one to easily model the accumulation of a buoyant layer along the ceiling in an enclosed space. Suppose, to continue the example, this happened in my workroom, which for the sake of simplicity is just a 4m × 4m room with 2.7m (9ft) ceiling. While the explosive mass in the steady state plume is pretty small, the lfl extent of the unconfined plume extends much further than the walls of my room. The hydrogen will hit the far wall and accumulate quite significantly.
+
+The blowdown of the hydrogen cylinder will take some time and the exact blowdown curve is necessary for determining how rapidly the hydrogen will accumulate in the room. Assuming the jet it at the initial steady state mass-rate throughout will be very conservative and the entire contents of the cylinder will be gone within a few seconds.
+HyRAM+ uses the governing equations for adiabatic blow-down.
+where m is the mass remaining in the tank, u is the specific internal energy, h the specific enthalpy, and v the velocity through an isentropic nozzle. The thermodynamic state variables (P, T) are recovered from the fluid model and the internal energy, u, and the density ρ = m/V.
+First the cylinder is defined as a Source with an initial mass of hydrogen.
m_h2 = 50 #kg
+cylinder = hp.Source.fromMass(m_h2,h2)Then the cylinder can be blown down through the orifice previously defined. This numerically integrates the governing equations and returns the mass, pressure, temperature, and flowrate as functions of time.
A convenience function can also plot them for us.
+cylinder.empty(orifice)
+
+cylinder.plot_time_to_empty()
At this point the release is still unconfined. We need to define the room. This also includes defining the location of vents. Since no room is perfectly leak free, and for the sake of an example, I assume a similar leak area for a vent near the ceiling and one near the floor. I also define the cylinder as leaking from ground level essentially at one wall and aimed 45° upwards towards the opposite wall.
+ceiling_height = 2.7 #m
+floor_area = 16 #m^2
+release_height = 0 #m
+
+ceiling_vent = hp.Vent(A_h,2.6)
+floor_vent = hp.Vent(A_h,0.01)
+
+room = hp.Enclosure(ceiling_height, floor_area, release_height, ceiling_vent, floor_vent, Xwall=4)The release model then estimates the accumulation of a flammable layer starting at the roof and extending downwards.
+release = hp.IndoorRelease(source=cylinder,
+ orifice=orifice,
+ ambient=air,
+ enclosure=room,
+ theta0=theta,
+ nn_conserve_momentum=True,
+ nn_T='solve_energy',
+ tmax=30,
+ verbose=False)
+
+release.plot_mass()
At this point I thought, initially, that there was something wrong with the code. Why does the flammable mass suddenly disappear? Where does it go? Nowhere. HyRAM+ by default assumes the flammable mass is between the LFL and UFL. At around 15 seconds the room is essentially saturated with hydrogen and above the UFL, hence why it suddenly disappears. This can be seen by plotting the flammable layer at the ceiling.
+release.plot_layer()
By 15 seconds the layer reaches the floor and the entire room is above the UFL. Some recommend not cutting off at the UFL, this room is still quite hazardous, say if someone opened a door there would be an explosive mixture right in the door-frame that could explode and that explosion would mix with the rest of the gas and the whole mass of released hydrogen could explode.
+To consider the flammable mass to be the mixed area above the LFL and include areas that exceed the UFL, the X_lean and X_rich keyword arguments must be used.
full_release = hp.IndoorRelease(source=cylinder,
+ orifice=orifice,
+ ambient=air,
+ enclosure=room,
+ theta0=theta,
+ X_lean=lfl,
+ X_rich=1.0,
+ nn_conserve_momentum=True,
+ nn_T='solve_energy',
+ tmax=30,
+ verbose=False)
+
+full_release.plot_mass()
This whole analysis can also be accomplished using the API and the analyze_accumulation function. It outputs a series of plots into a given folder, including calculating the jet trajectories for a series of user selected times.
api.analyze_accumulation(amb_fluid=air,
+ rel_fluid=h2,
+ tank_volume=cylinder.V,
+ orif_diam=d_H,
+ rel_height=release_height,
+ enclos_height=ceiling_height,
+ floor_ceil_area=floor_area,
+ ceil_vent_xarea=A_h,
+ ceil_vent_height=2.6,
+ floor_vent_xarea=A_h,
+ floor_vent_height=0.01,
+ dist_rel_to_wall=4.0,
+ tmax=30,
+ times=[1,5,15,20,25,30],
+ orif_dis_coeff=c_d,
+ rel_angle=theta,
+ nozzle_key='yuce',
+ output_dir="figures/accumulation"){'status': 1,
+ 'pressures_per_time': array([ 388375.73527918, 3092893.44785494, 0. ,
+ 0. , 0. , 0. ]),
+ 'depths': array([1.34306015, 2.41226448, 2.45803334, 2.45809352, 2.4631503 ,
+ 2.46359848]),
+ 'concentrations': array([18.58049761, 43.85233797, 81.37521187, 89.24254417, 93.32644083,
+ 95.79735125]),
+ 'overpressure': 8136855.548411997,
+ 'time_of_overp': 11.970055170861283,
+ 'mass_flow_rates': array([0.3978773 , 0.38039983, 0.3417355 , 0.32307435, 0.30734882,
+ 0.29251744]),
+ 'pres_plot_filepath': 'figures/accumulation/pressure_plot_20240921-132132.png',
+ 'mass_plot_filepath': 'figures/accumulation/flam_mass_plot_20240921-132132.png',
+ 'layer_plot_filepath': 'figures/accumulation/layer_plot_20240921-132132.png',
+ 'trajectory_plot_filepath': 'figures/accumulation/trajectory_plot_20240921-132132.png',
+ 'mass_flow_plot_filepath': 'figures/accumulation/time-to-empty_20240921-132132.png'}
+




One limitation that stuck out to me, in the blowdown model, is that the blowdown is either at a constant heat flux or adiabatic (which is constant at zero). Blowdown models where the vessel is isothermal are fairly typical, especially for large (un-insulated) vessels blowing down through a small valve. For small vessels, assuming an adiabatic blowdown is reasonable, but this limits the model as the vessels get larger.
+If you are looking for a quick and easy-to-use tool for performing hazard analysis of hydrogen releases, HyRAM+ is worth checking out. I haven’t gone into it here, but the tool allows you to continue the analysis into blast over-pressure and a much more fully featured QRA. They python library allows you to split out each piece of the model, allowing you to really explore what it is doing, but also making it easier to pull out relevant pieces for comparison to other hazard analysis tools one might be using in a plant setting.
+The indoor accumulation model is worth exploring, for sites that are transitioning to hydrogen, as most “screening level” indoor accumulation models I have seen consider either a heavier-than-air layer along the ground or the entire indoor space (or zone, if it divides the area into zones) having one, fully-mixed, concentration. So it is possible that the standard plant tools for, e.g., LOPA may have blind spots for the unique hazards that hydrogen can present (e.g. accumulating as a flammable layer along the ceiling).
+For chemical plants that also operate a cogen, they may already have hydrogen venting into the air in an indoor space: large turbines (>60MW) typically use hydrogen coolant. It might be worthwhile running a HyRAM+ model for that venting just to confirm that the small quantities vented into the turbine hall are not an issue. While this may be a well known and well understood aspect of turbine operations, for people in the power business, process safety engineers tend to gasp and clutch their chests when told of routine venting of flammable and explosive gases into enclosed spaces.
+1 Celia, “Recycling Plastic Is a Dangerous Waste of Time”.
I won’t be commenting on the broader life-cycle of plastic as I’m hardly an impartial participant: My current employer is one of the world’s largest plastic manufacturers and my day job is working on a major expansion of one of its plastics manufacturing facilities. My employer has a whole suite of messaging about the importance of recycling and goals for advancing a circular economy which I don’t feel particularly compelled to try and advance, nor contradict, on my little side project blog about doing math in my spare time.
+Celia estimates that up to 2/3rds of the microplastics discharged directly into the environment2 come from the recycling industry. This is a huge number. One that should immediately raise eye-brows. So lets break that down, it comes from two numbers:
+2 These are so called “primary” microplastics, as opposed to “secondary” microplastics which are generated from plastic waste already in the environment
Celia takes the value of about 2Mt/y of microplastics emissions from the recycling industry from an interview given by an author of a recent study,3 and leaves it rather mysterious as to where exactly it comes from. However, this is a really easy number to calculate yourself: Approximately 9% of total plastic waste, globally, is recycled, that study estimated that up to 6 - 13% of recycled plastic could be lost to the environment as primary microplastics, which equates to about 2 - 4Mt/y.
+3 Brown et al., “The Potential for a Plastic Recycling Facility to Release Microplastic Pollution and Possible Filtration Remediation Effectiveness”.
4 OECD, “Global Plastics Outlook,” 20.
For example, the OECD estimated that, in 2019, global plastic waste generation was 353Mt of which 33Mt were recycled (~9%)4 6 - 13% of that is 1.98 - 4.29 Mt. So in some sense, taking the high end of that, makes the argument more dramatic.
+The main reference around which the entire essay revolves is that one study of a single plastics recycling plant in the UK. In that study, the authors looked at a relatively new plastics recycling facility that underwent an upgrade to its wastewater treatment process, adding additional filtration. The study looked at the microplastics emissions prior to and after the upgrade. Based on the concentrations measured in the wastewater they estimated that up to 13% of the mass of plastic brought into the facility may have been discharged in the wastewater as microplastics prior to the filtration upgrade and, after the upgrade, this dropped to 6%. These two numbers 6% and 13% form the basis for the estimate of how much primary microplastics are being discharged from the recycling industry as a whole.
+At this point we should pause consider the error bars on those numbers. The study gives a range for the total annual mass discharge in the wastewater, based on measured mass concentrations in the facility wastewater. The ratio of this mass out to the plastic taken in is the origin of the 6 - 13% range. However, I think it is deeply disingenuous to present these numbers without context as the study’s estimates span three orders of magnitude.
+| estimate | +low end (t/y) | +high end (t/y) | +
|---|---|---|
| before filter upgrade | +96 | +2933 | +
| after filter upgrade | +4 | +1366 | +
I think the take away from this is that far more data is needed to narrow these error bars. The low end estimates are still much larger than other studies for the whole life-cycle of plastic5 and the high end estimates are many orders of magnitude larger still. This study is only a single data point, but it is showing that the estimates used in other life cycle analysis may be far too small and that recycling is a much larger contributor to primary microplastics than has been accounted for.
+5 Ryberg, Laurent, and Hauschild, “Mapping of Global Plastics Value Chain and Plastics Losses to the Environment” page 56, estimates 0.005% loss;
+Boucher and Friot, “Primary Microplastics in the Oceans” page 37, estimates 0.00033 - 0.001% loss;
+The low end post-upgrade estimate from Brown et al., “The Potential for a Plastic Recycling Facility to Release Microplastic Pollution and Possible Filtration Remediation Effectiveness” is 0.018%
I think the ~3Mt/y is a relatively robust estimate, for the type of study Celia references, because it has been replicated6. However, this is the source of the most egregious and obvious mistake, and the one that prompted me to write this blog post in the first place. The studies referenced as the sources for the 3Mt/y number include recycling as a source in the estimate but do not estimate the losses from recycling to be anywhere near as high. Dividing these two numbers is simply a mathematically invalid operation.
+6 Ryberg, Laurent, and Hauschild, “Mapping of Global Plastics Value Chain and Plastics Losses to the Environment” estimates 3.1Mt/y;
+OECD, “Global Plastics Outlook” estimates 2.7Mt/y;
+Boucher and Friot, “Primary Microplastics in the Oceans” estimates 1.8 - 5Mt/y
Before I go any further, where do numbers like 3Mt/y come from? They are not from direct measurements of microplastics in the environment. They come from a life-cycle analysis that looks at the entire life of plastics and estimates rates of losses at the various steps along the path from the creation of virgin plastic to its ultimate fate. Adding all of these losses up gives the total estimated primary microplastics loss. This is why it is incorrect to simply ratio 2Mt/y over 3Mt/y: that would only work if the 2Mt/y was included in the total, and it isn’t.
+Supposing that we are going with 2Mt/y of primary microplastics from recycling, most studies (importantly the ones referenced by Celia) do not use a number anywhere near this high. In fact most assume it quite small and some take it to be negligible.7 The correct procedure would be to subtract the previous estimate for losses due to recycling from the total losses, and then add the new estimate of 2Mt/y, giving a corrected total. This would then be the denominator.
+7 Ryberg, Laurent, and Hauschild, “Mapping of Global Plastics Value Chain and Plastics Losses to the Environment,” 56.
8 Ryberg, Laurent, and Hauschild, 54.
Consider a UNEP study that estimates the total primary microplastics losses from the entire plastics value chain as 3.1Mt/y.8 Conveniently, this study assumed the losses due to recycling were negligible (i.e. zero). So based on this study’s estimates for all other sources of primary microplastics, and our estimate of 2Mt/y from the recycling industry alone, we would estimate a new total of 5.1Mt/y, of which 2/5.1 = 39% came from the recycling industry. So.. not 2/3rds.
+But considering how wide the error bars are for the estimate of primary microplastics emissions, from that one plastics recycling facility, all we can really say is that recycling is somewhere between a small, but important, source of primary microplastics and the single largest source of primary microplastics. Which is important in the sense that it identifies that we may be missing a major source of primary microplastics, but it really does not live up to the hype in Celia’s article.
+Celia makes reference to the landfilling of municipal solid waste being a source of methane emissions as part of the argument for why recycling should be abandoned and plastic incinerated instead. Independent of the merits of recycling or incinerating, this is at best irrelevant. Plastic has a negligible methane generating potential when landfilled, a fact that is related to the primary concern with plastic waste in the environment: its environmental persistence. The methane emissions coming from the landfilling of municipal waste is from decomposing organic matter, not the plastic. In fact a recent meta-analysis9 shows that, if anything, the presence of non-biodegradable plastic reduces the methane emissions from anaerobic digestion as non-biodegradable plastics may leach toxins that prevent bacteria from decomposing organic matter. I wouldn’t take that to mean we should be landfilling plastic waste, as some climate mitigation strategy, merely that the methane emissions from doing so are irrelevant to the argument around what to do with plastic waste.
+9 Gao et al., “Comprehensive Meta-Analysis Reveals the Impact of Non-Biodegradable Plastic Pollution on Methane Production in Anaerobic Digestion”.
There is certainly a growing chorus of concern over the fate of plastics in the environment, and the environmental and health consequences of microplastics given their ubiquity. That alone should warrant a lot more study into the sources of microplastics. That the estimate that recycling accounts for 2/3rds of primary microplastics doesn’t hold up, due to rudimentary math mistakes, doesn’t invalidate the broader concern that recycling simply has not lived up to the promise and may in fact be worsening the microplastics problem. We don’t know that is the case, given the data cited, but I think the onus is on the recycling industry to show that they are, in fact, part of the solution and not making the problem worse.
+I am not going to comment on the relative merits of incineration, recycling, or advanced recycling other than to say few of the technical problems in this field are truly insurmountable. The real question always comes down to cost and how much we are willing to pay to achieve the environmental performance we want.
+The same basic principles of extraction and diffusion apply to an espresso maker as apply to a French press, and we expect the same basic parameters to be relevant: the particle size, solid phase diffusivity, etc. The major difference is that the liquid phase, water, is moving through a fixed bed of coffee particles and this significantly changes the mass transfer problem.
+In the case of the French press I made the simplifying assumption that the system was well mixed, and so I could generally ignore the flow of the fluid. In the case of the espresso maker, it is more complicated than that.
+Making espresso involves packing coffee grounds into a cylindrical puck within a portafilter then passing hot water through the grounds under pressure, up to 10 bar.1 The specific unit operation that corresponds to making espresso is packed bed leaching, in which the coffee solubles are being leached from the coffee grounds. In a lot of standard undergraduate texts on unit operations and separations, I find, this is not well explored. Especially the non-equilibrium case, which is exactly the situation when pulling a shot of espresso: one doesn’t typically fully extract the beans and stops at some point in the transient regime. That said, the basic system is the same as desorption and so a good reference for what follows is often a text on chromatography and adsorption/desorption processes.
+1 Cameron et al., “Systematically Improving Espresso,” 631.
The espresso puck can be modelled as a perfectly cylindrical packed bed of ground coffee. Meaning both that the bed is a perfect cylinder and that the grounds are perfectly evenly distributed, i.e. has a constant porosity . A lot of technique goes into preparing the puck, to ensure the grounds are evenly distributed in the packed bed and the distribution of water through the bed is even, so this is a reasonable assumption.
In practice the pressure can vary over the course of the shot, but for simplicity I am only considering the case where the pressure is constant and, consequently, the flow rate of water is constant. We can simplify the flow condition more by assuming plug-flow, i.e. the velocity is a constant throughout. One final simplifying assumption is that the espresso is pulled at a constant temperature, this ensures the physical properties of water are also constant, e.g. constant density and viscosity.
+All of these assumptions, at their core, are to eliminate various partial derivatives and narrow down the governing equations to only core variables.
+Zooming in on a thin slice of the packed bed with depth, Δz, we can take a mass balance of the liquid phase.
+The mass flow into the liquid phase can be due to:
+Similarly the mass flow out of the liquid phase can be due to advection or axial diffusion out the bottom of the slice. Putting that together into a mass balance we have
+where as is the specific area of the coffee grounds, the surface area per unit volume. By writing the volume of the liquid phase and solid phase in terms of porosity and cross sectional area, A, we can cancel out some terms.
+In the limit Δz → 0 this becomes
+Assuming axial diffusion is Fickian
+For the solid phase, I am assuming a uniform bed of spherical particles, all with the same radius b. Actual coffee grounds, beyond being non-spherical, are also composed of multiple phases: the solid phase, the coffee oils, and the liquid water phase within the micro-porous structure of the bean. To simplify things greatly, I am assuming an effective solid phase diffusion, wherein diffusion within the particle of coffee follows Fick’s law with an effective diffusion coefficient that combines all of that complexity into a single parameter.
+In spherical coordinates, this becomes
+Where the solid phase concentration, q, is in units of mass per unit volume.
+Connecting the two phases, the liquid coffee and the solid coffee grounds, is a thin film. This is where the inter-phase mass transfer occurs, and I assume it follows a linear mass transfer relation.
+Where h is the mass transfer coefficient and cs is the liquid concentration immediately at the solid surface. I am making the additional assumption that this concentration is in equilibrium with the solid phase concentration at the surface, and that equilibrium is linear, i.e.
+The initial conditions for espresso are complicated. The bed is initially full of air and the first phase of making espresso, the pre-infusion, is to saturate the bed with hot water at a lower pressure than is used during the main extraction phase. This initial step has a very complicated multi-phase flow and mass transfer which dramatically complicates the model and most papers I’ve read avoid this by making one of two simplifying assumptions:
+Basically everyone ignores pre-infusion and focuses on the main extraction phase, after the bed has been filled with water. I am going to make the second assumption, that after pre-infusion the bed is full of water that is in equilibrium with the solids. This is in part because it is a convenient initial condition for solving the partial differential equations2 and in part because the actual volume of water in the bed is small and the pre-infusion step, which can take between 5-10s, is sufficiently long enough that the water will have extracted some coffee.
+2 Schwartzberg, “Leaching – Organic Materials,” 559.
For my model, the initial conditions are:
+Several recent papers that numerically integrate the pde use some variation on the first assumption3 and this will be the major difference between my approach and some of the published literature.
+3 Cameron et al., “Systematically Improving Espresso” page 635; Moroney et al., “Modelling of Coffee Extraction During Brewing Using Multiscale Methods” page 225; Vaca Guerra et al., “Modeling the Extraction of Espresso Components as Dispersed Flow Through a Packed Bed” page 5
I am assuming the water and portafilter are isothermal and the liquid phase has the same physical properties of pure water throughout. This is not entirely true in that the system is not perfectly isothermal but also the process of extracting coffee changes the density and viscosity of the coffee. I am assuming this effect is small and can be ignored.
+using Unitful
+
+# physical properties coffee
+# assumed to be water at 90C and 10bar
+ρ = 965.34u"kg/m^3"
+μ = 0.282*0.001u"Pa*s"
+ν = μ/ρ
+ν = upreferred(ν)The most significant factors for both modelling the flow and also determining the mass transfer parameters are the features of the espresso bed itself. Primarily the porosity and the particle size. For simplicity I am taking both of these from the literature for actual espresso shots,4 but in practice these are probably the most difficult to derive for someone making espresso at home, with the sorts of tools available in a kitchen.
+4 Cameron et al., “Systematically Improving Espresso” supplemental materials.
The porosity will vary from espresso shot to espresso shot, as it is a function of the particle size distribution, the distribution within the portafilter, and also the degree of tamping. Furthermore the porosity of the dry bed will not be the same as the porosity of the bed once fully saturated with water. The coffee grounds will swell somewhat and any liquid within a particle is already accounted for in the effective solid phase mass transfer, including it in an estimate of the porosity would be double counting. One could try measuring the mass and volume of the spent puck and calculate it directly, but I’m not sure how one would account for the volume of water absorbed into the grounds while discarding the water that is only in the void space between coffee particles.
+The particle size distribution for a given coffee, grinder, and grind setting can be measured in a variety of ways, including with an app on one’s phone5 where in this case we are interested in the Sauter mean radius as we are assuming a bed of uniform spherical particles. Camera based approaches have one main weakness in that particles of coffee have microscopic pores that increase the apparent surface area but are too small to be resolved by a typical camera. Laboratory methods tend to measure the adsorption of a neutral substance, like nitrogen, to measure this. Not something one is going to be doing in the kitchen.
+5 Gagné, The Physics of Filter Coffee, 199.
The flow rate through the bed is also an important factor, I am simply taking the total volume of the shot divided by the time taken to pull a shot as the flow rate, but this is only a rough estimate. The pre-infusion phase adds water at a lower flow rate and it is only the flow after the pre-infusion has ended that is relevant to the problem. This can be measured with some higher end coffee scales that can output the time series of mass measurements during a shot. I don’t have one of these, but it’s not out of the question that I could just write down the mass and time at several points. Or take a video of my coffee scale’s screen during the actual shot and extract the data very tediously that way.
+# Cameron et al, "Systematically Improving Espresso," supplemental materials.
+
+# porosity and particle size
+ε = 1 - 0.8272
+b = 12e-6u"m"
+
+# bed size
+R_pb = 29.2e-3u"m"
+L_pb = 18.7e-3u"m"
+A_pb = π*R_pb^2
+
+# shot size
+M_shot = 0.04u"kg" # the mass of the espresso shot
+t_shot = 20u"s"
+Q_shot = (M_shot/ρ)/t_shot
+
+v_s = Q_shot/A_pb # superficial velocity, m/s
+v = v_s/εFrom the dimensions of the packed bed, assumed porosity, and assumed flow rate, I can calculate the time for the water to traverse the bed.
+(ε*A_pb*L_pb)/Q_shot4.177834449522944 s
+In practice, for a lot of chemical engineering mass transfer problems, accurate mass transfer coefficients and diffusivities are hard to come by. This is equally true for the espresso system. I am going to be using a literature value for the effective solid phase diffusion, but then estimating the remainder from correlations.
+# effective solid phase diffusivity
+# Cameron et al, "Systematically Improving Espresso," 11.
+𝒟ₛ = 6.25e-10u"m^2/s"
+
+# (stagnant) liquid diffusivity
+# Schwartzberg, “Leaching – Organic Materials,” 557.
+D = 5*𝒟ₛThe thin film mass transfer coefficient is typically estimated from the Sherwood number which is a function of the Reynolds number and Schmidt number
+# Reynolds number
+Re = v_s*(2b)/ν# Schmidt number
+Sc = ν/DThe Sherwood number can be estimated using the Wilson-Geankopolis correlation for packed bed flow
+# Wilson-Geankopolis correlation
+# Hottel et al, "Heat and Mass Transfer," 5-77.
+Sh = (1.09/ε)*∛(Re*Sc)Giving the thin film mass transfer coefficient
+h = Sh*D/(2b)0.0014874874803418145 m s^-1
+The axial diffusion can be calculated using the Edwards-Richardson correlation
+# Edwards-Richardson correlation
+# LeVan and Carta, "Adsoprtion and Ion Exchange," 16-22.
+γ₁ = 0.45 + 0.55*ε
+γ₂ = 0.5*(1 + 13γ₁*ε/(Re*Sc))^-1
+Pe = ( γ₁*ε/(Re*Sc) + γ₂ )^-1𝒟ₗ = v*(2b)/Pe4.623616336378663e-8 m^2 s^-1
+I am using literature values for the saturated concentration of solubles both in the bean and in the coffee, and calculating an equilibrium constant from that.
+# saturation concentrations
+# Cameron et al, "Systematically Improving Espresso," 11.
+q_sat = 118.0u"kg/m^3"
+c_sat = 212.4u"kg/m^3"
+K = q_sat/c_sat0.5555555555555556
+Looking forward a little bit, I know that I will be using multiple approaches to the packed bed model and keeping track of all of the model parameters can be tricky. Especially in a notebook where everything is in the global name space. Which is why I think it is prudent to define a PackedBed data structure to contain all of the model parameters.
struct PackedBed
+ q₀
+ K
+ 𝒟ₛ
+ 𝒟ₗ
+ h
+ ε
+ b
+ c₀
+ v
+end# initial concentration
+c₀ = 0.0u"kg/m^3"pb = PackedBed(q_sat, K, 𝒟ₛ, 𝒟ₗ, h, ε, b, c₀, v);A good first approach to solving the pde is to try simplifying the mass transfer problem by eliminating some of the diffusion terms. Making the following simplifications:
+The governing equations can be reduced to
+with
+This is a dramatically simpler model, eliminating much of the real complexity of the mass transfer. However an effective mass transfer coefficient, h, can be fit from measured data that combines the solid diffusion and thin film mass transfer.6 Similarly an effective mass transfer coefficient can be calculated by addition of linear mass transfer resistances. Essentially this is shifting some of the complexity out of the governing equations and into the parameters. This is a fairly common model for packed beds and was first solved, for the equivalent heat transfer case, by Anzelius and independently by Schumann.7 What follows is a general sketch of a solution.
+6 See Moroney, “Analysing Extraction Uniformity from Porous Coffee Beds Using Mathematical Modelling and Computational Fluid Dynamics Approaches” for an example of this model being used for espresso extraction.
7 Anzelius, “Über Erwärmung Vermittels Durchströmender Medien”; Schumann, “Heat Transfer”.
8 Setting the pde in dimensionless form follows Bird, Stewart, and Lightfoot, Transport Phenomena pages 753-755
The first step is to transform this pde into dimensionless form8, first by introducing a dimensionless time . Which, when substituted into the equation for the solid phase, becomes
Further introducing a dimensionless space where
transforms the equation for the liquid phase into
By defining a dimensionless liquid phase concentration
+where q0 is the initial concentration of the solid phase and c0 is the concentration in the water at z=0. We can re-write the equation for the liquid phase as
+Letting
+the final system of equations is then
+Which, at this point, is just something that you can look up in Carslaw and Jaeger.9 The solution follows directly from taking the Laplace transform of , with respect to τ, which gives
9 Carslaw and Jaeger, Conduction of Heat in Solids, 393.
then taking the Laplace transform of , with respect to τ gives
which is a differential equation that can be easily solved using the initial condition u(0) = 1 or, in the Laplace domain, U(0) = 1/s
+Inverting this requires a little work, though not as much as it may seem. I am departing from Bird10 since I find their approach mystifying. It is clearly designed to reverse engineer a particular form of the answer as opposed to arriving at it naturally. The approach in Carslaw and Jaeger is more intuitive11 and is what I am following here.
+10 Bird, Stewart, and Lightfoot, Transport Phenomena, 762–63.
11 Carslaw and Jaeger, Conduction of Heat in Solids pages 393-394; and also Goldstein, “On the Mathematics of Exchange Processes in Fixed Columns i. Mathematical Solutions and Asymptotic Expansions” pages 153-158.
First, recognize that
+Then, looking at a table of Laplace transforms we find12
+12 With the caveat that you need a good table of Laplace transforms, most undergraduate textbooks have a very brief one. The tables in Carslaw and Jaeger are extensive and Perry’s is also a good reference.
where I0 is the modified Bessel function of the first kind. A basic property of Laplace transforms is that
+from which it follows that
+Another property of Laplace transforms is that
+which gives
+Which is laying the groundwork for the observation that since
+then
+and U can be rewritten as
+then by taking the inverse Laplace transform
+This is the solution that Schumann13 arrives at via a different means, though it is not in the form one generally sees in the standard references. To get there, we must take advantage of some properties of the Anzelius J Function (named because it is the solution to this differential equation).14
+13 This is equation 27 in Schumann, “Heat Transfer” page 409.
14 Goldstein, “On the Mathematics of Exchange Processes in Fixed Columns i. Mathematical Solutions and Asymptotic Expansions,” 160.
from which we can see that
+and finally
+Which is the form typically given in references. I think it is important to pause here and comment that this answer is not the answer, it is an answer. Both the solution above from taking the inverse Laplace transform and this solution are valid and, in fact, both are used when evaluating the Anzelius J function. It just happens to be the case that the latter result is what one tends to see in the literature.
+At this point we can calculate the dimensionless space and time for a point at the exit of the espresso bed, and at a similar point in (dimensionless) time.
+z = L_pb
+
+m = ε/(1-ε)
+aᵥ = 3/b
+ξ = (h*aᵥ*z)/(m*v)
+
+# τ = ξ
+t = (K/(h*aᵥ))*ξ + z/v
+τ = (h*aᵥ/K)*(t - z/v)
+
+@show ξ; @show τ;ξ = 7437.232219350375
+τ = 7437.232219350374
+It is convenient to create an AnzeliusSolution struct that takes a PackedBed and a particular point in space and generates a datatype that allows us to go back and forth between the problem in dimensionless form and the problem in actual units.
struct AnzeliusSolution{F,Q1,Q2}
+ ξ::F
+ τ₁::Q1
+ τ₂::Q2
+ pb::PackedBed
+end
+
+function AnzeliusSolution(z, pb::PackedBed)
+ m = ε/(1-ε)
+ aᵥ = 3/pb.b
+ ξ = (pb.h*aᵥ*z)/(m*pb.v)
+ τ₁ = (pb.h*aᵥ/pb.K)
+ τ₂ = τ₁*(z/pb.v)
+
+ return AnzeliusSolution(ξ, τ₁, τ₂, pb)
+endanzelius = AnzeliusSolution(z, pb);Generally the Anzelius solution is given in terms of an integral of a Bessel function that is wildly impractical to numerically integrate directly as written. For an example, Bird15 gives this as the the solution:
+15 Bird, Stewart, and Lightfoot, Transport Phenomena, 755.
where J0 in this case is the Bessel function of the first kind (not the Anzelius J function). This is correct, however, attempting to use it as written will run aground on numerical difficulties at even moderately large values of τ. The first issue with this form of the answer is that it requires one to cast everything into complex values only to cast back into floats (the answer is a real number), but the most important issue is that the integrand is the product of an exponential that decays rapidly to zero and a Bessel function that blows up rapidly to infinity. For even moderate values of τ this leads to NaN errors of the type 0*Inf.
When I was first playing around with this I attempted to integrate it as is. After that clearly didn’t work, I looked into whether or not there are scaled versions of the Bessel function. I think this is a good practice that maybe isn’t taught well in school: often functions like Bessel functions or the Gamma function explode to large numbers that would overflow, thus leading to NaN errors, consequently libraries of special functions tend to have scaled or log versions. Bessels.jl has an exponentially scaled version of I0 that works perfectly for what I need, with the exponentially scaled version being
By completing the square we can rewrite
+as
+Below is a figure showing a plot of the integrand for a value of τ much smaller than our particular example, just for illustration. The first curve is the original version of the solution (shifted up for visibility), which begins to fail due to NaN errors part-way up the curve (where the red X is). The second curve uses the exponentially scaled modified Bessel function and does not have this issue. The larger the τ the earlier problems arrive and by the time we get to the value of τ being used for this example the integrand doesn’t evaluate to any positive value prior to turning into NaNs.
using Bessels:besselj0, besseli0x
+
+f_orig(λ, τ) = exp(-(τ+λ))*real(besselj0(im*√(complex(4τ*λ))))
+
+fₐ(λ, τ) = exp(-(√(τ)-√(λ))^2)*besseli0x(√(4τ*λ))Now that we have a version of the integrand that we can actually calculate, the obvious approach is to integrate using a standard package for numerical integration, such as QuadGK.jl. To make things a little easier, I have made the change of variables , thus changing the bounds of integration to
using QuadGK: quadgk_count
+
+integrand(x) = exp(-(√(τ)-√(ξ*x))^2)*besseli0x(√(4τ*ξ*x))
+
+∫, e, N = quadgk_count(integrand,0,1)
+
+u = 1 - ξ*∫
+
+@show u; @show e; @show N;u = 0.5016355470840097
+e = 1.0518776639256381e-13
+N = 165
+Because of my particular choice of τ the integral stops somewhere on the curve where it is appreciably positive. There is a potential trap here when using an integration routine with automatic step-sizes like Gauss-Kronold: if the bounds of integration extend well past the peak of this curve, it is possible for the algorithm to step over it entirely and return a value of 0, when the actual integral should be ~1.
+One way of dealing with this issue is to take advantage of the symmetry of the J function and use the following rule:16
+16 Lassey, “On the Computation of Certain Integrals Containing the Modified Bessel Function ,” 631.
This ensures that the numerical integration is always being taken to the left of the peak of the integrand (where ξ = τ) and thus avoids the stepping over problem.
+using QuadGK: quadgk
+
+function J_quad(x,y)
+ if x ≈ 0
+ return 1.0, 0.0
+ elseif y ≈ 0
+ return exp(-x), 0.0
+ else
+ integrand(λ) = exp(-(√(y)-√(x*λ))^2)*besseli0x(√(4y*x*λ))
+ ∫, e = quadgk(integrand,0,1)
+ J = 1.0 - x*∫
+ return J, e
+ end
+end
+
+function c_quad(t, model::AnzeliusSolution)
+ # unpack some things, calculate model parameters
+ cₛ = model.pb.q₀/model.pb.K
+ c₀ = model.pb.c₀
+ τ = model.τ₁*t - model.τ₂
+ ξ = model.ξ
+
+ # use Gause-Kronold to integrate
+ # modified integral for τ < ξ from Lassey p. 631
+ if τ < 0 || ξ < 0
+ # outside the domain of the problem
+ J = 0.0
+ elseif ξ ≤ τ
+ J, e = J_quad(ξ,τ)
+ else # τ < ξ
+ J, e = J_quad(τ,ξ)
+ J = 1 + exp(-(√(τ)-√(ξ))^2)*besseli0x(√(4τ*ξ)) - J
+ end
+
+ # return back the concentration
+ c = cₛ + (c₀ - cₛ)*J
+ return c
+endA brief review of the literature around the Anzelius J function will reveal a multitude of series representations. For example, Goldstein17 gives the following
+17 Goldstein, “On the Mathematics of Exchange Processes in Fixed Columns i. Mathematical Solutions and Asymptotic Expansions,” 159.
Naïvely implementing this, without the use of any techniques like Richardson acceleration, ends up requiring a large number of iterations to approach the performance of direct integration by Gauss-Kronold. Since each iteration involves calculating exponentially scaled Bessel functions of higher and higher order, this doesn’t obviously lead to any improvement over direct numerical integration.
+# Naively, just adding them up
+using Bessels:besselix
+
+function J_series(x,y,N)
+ if x ≈ 0
+ return 1.0
+ elseif y ≈ 0
+ return exp(-x)
+ else
+ α = 2√(x*y)
+ η = √(x/y)
+ partial_sum = 0.0
+ for k in 1:N
+ partial_sum += besselix(k,α)*η^k
+ end
+ J = 1 - exp(-(√(x)-√(y))^2)*partial_sum
+ return J
+ end
+end
+
+N = 900
+u = J_series(ξ,τ,N)
+
+@show u; @show N;u = 0.5016355470840961
+N = 900
+An alternative approach from Bac̆lić et al.18 removes the need to calculate higher order Bessel functions and, though it also requires a large number of iterations, each iteration is a simpler calculation and thus the algorithm could be faster overall. If you were going to roll out the J function in production code it would be worthwhile bench marking this against numerical integration with QuadGK.jl.
+18 Bac̆lić, Gvozdenac, and Gragutinović, “Easy Way to Calculate the Anzelius-Schumann j Function,” 114.
function bac̆lić_sub_A(α₋₁,β₋₁,α₀,β₀,d₁,z,ε,max_iter)
+ αₙ₋₂, βₙ₋₂ = α₋₁, β₋₁
+ αₙ₋₁, βₙ₋₁ = α₀, β₀
+ αₙ, βₙ = 0.0, 0.0
+ dₙ = d₁
+ for n in 1:max_iter
+ αₙ = dₙ + (n/z)*αₙ₋₁ + αₙ₋₂
+ βₙ = 1 + (n/z)*βₙ₋₁ + βₙ₋₂
+
+ if βₙ > 1/ε
+ return αₙ, βₙ, n
+ else
+ dₙ = dₙ*d₁
+ αₙ₋₂, βₙ₋₂ = αₙ₋₁, βₙ₋₁
+ αₙ₋₁, βₙ₋₁ = αₙ, βₙ
+ end
+ end
+ return αₙ, βₙ, max_iter
+endfunction J_bac̆lić(x,y,ε=1e-9,max_iter=10^6)
+ if x ≈ 0
+ return 1.0
+ elseif y ≈ 0
+ return exp(-x)
+ else
+ z = √(x*y)
+ α₋₁ = 0.0
+ β₋₁ = 0.0
+ β₀ = 0.5
+ if y < x
+ α₀ = 1.0
+ d₁ = √(y/x)
+ αₙ, βₙ, N = bac̆lić_sub_A(α₋₁,β₋₁,α₀,β₀,d₁,z,ε,max_iter)
+ J = (αₙ/(2*βₙ))*exp(-(√(y)-√(x))^2)
+ else
+ α₀ = 0.0
+ d₁ = √(x/y)
+ αₙ, βₙ, N = bac̆lić_sub_A(α₋₁,β₋₁,α₀,β₀,d₁,z,ε,max_iter)
+ J = 1.0 - (αₙ/(2*βₙ))*exp(-(√(y)-√(x))^2)
+ end
+ return J, ε, N
+ end
+endu, e, N = J_bac̆lić(τ,ξ)
+
+@show u; @show e; @show N;u = 0.5016355471003766
+e = 1.0e-9
+N = 698
+function c_bac̆lić(t, model::AnzeliusSolution)
+ # unpack some things, calculate model parameters
+ cₛ = model.pb.q₀/model.pb.K
+ c₀ = model.pb.c₀
+ τ = model.τ₁*t - model.τ₂
+ ξ = model.ξ
+
+ if τ < 0 || ξ < 0
+ u = 0.0
+ else
+ u, err, N = J_bac̆lić(ξ,τ)
+ end
+
+ c = cₛ + (c₀ - cₛ)*u
+ return c
+endThomas19 provides an asymptotic expansion of J which forms the basis for several approximations to the J function
+19 Thomas, “CHROMATOGRAPHY” page 171. Note: Thomas gives this in terms of φ where
Taking the first terms of the asymptotic expansion and the limit as z → ∞20
using SpecialFunctions: erf, erfcfunction J_approx(x,y)
+ if x ≈ 0
+ return 1.0
+ elseif y ≈ 0
+ return exp(-x)
+ else
+ return 0.5*(erfc(√(x)-√(y)) + exp(-(√(x)-√(y))^2)/(√(π)*(√(y)+(x*y)^0.25)))
+ end
+end
+
+u = J_approx(ξ,τ)
+
+@show u;u = 0.5016355333390161
+function c_approx(t, model::AnzeliusSolution)
+ # unpack some things, calculate model parameters
+ cₛ = model.pb.q₀/model.pb.K
+ c₀ = model.pb.c₀
+ τ = model.τ₁*t - model.τ₂
+ ξ = model.ξ
+
+ # approximate integral
+ if τ < 0 || ξ < 0
+ u = 0.0
+ else
+ u = J_approx(ξ,τ)
+ end
+
+ # return back the concentration
+ c = cₛ + (c₀ - cₛ)*u
+ return c
+endRice21 goes even further and suggests that, for
21 Rice, “Letters to the Editor,” 334.
function J_rice(x,y)
+ if x ≈ 0
+ return 1.0
+ elseif y ≈ 0
+ return exp(-x)
+ else
+ return 0.5*erfc(√(x)-√(y))
+ end
+end
+
+u = J_rice(ξ,τ)
+
+@show u;u = 0.499999999999992
+function c_rice(t, model::AnzeliusSolution)
+ # unpack some things, calculate model parameters
+ cₛ = model.pb.q₀/model.pb.K
+ c₀ = model.pb.c₀
+ τ = model.τ₁*t - model.τ₂
+ ξ = model.ξ
+
+ # approximate integral
+ if τ < 0 || ξ < 0
+ u = 0.0
+ else
+ u = 0.5*erfc(√(ξ)-√(τ))
+ end
+
+ # return back the concentration
+ c = cₛ + (c₀ - cₛ)*u
+ return c
+endI don’t see any great reason to use anything other than direct numerical integration, so that is the default method I am going to set going forward.
+c(t,m::AnzeliusSolution) = c_quad(t,m)That said, for this particular case, the mass transfer across the thin film is so rapid that all of the approximations are close enough as to be indistinguishable. Indistinguishable to the naked eye when staring at a plot but, more importantly, experimentally indistinguishable.
+I wouldn’t take this to mean that the simple, single term, approximation will work for modelling an actual espresso shot, though it is certainly suggestive. This approach, neglecting the solid phase diffusion entirely and neglecting axial diffusion, has lead to a very sharp moving front that is physically unrealistic. What this model is telling us is that we should expect an espresso shot to start as fully and overly extracted coffee and, after some time, transition into essentially tap water in a fraction of a second. That is not my experience, qualitatively. What this shows is that we need to increase the complexity of the model.
Rosen22 solved the problem for the case where solid diffusion is included, but axial diffusion is still neglected, in a similar manner as above with Laplace transforms. In this case actually solving the pde is more tedious and what follows is just a loose sketch. Starting with the pde
+22 Rosen, “Kinetics of a Fixed Bed System for Solid Diffusion into Spherical Particles,” 387–94.
were
+Rosen introduced
where
+is the volumetric average concentration in a particle. This follows directly from a mass balance.
+We can put the liquid phase equation in dimensionless form by introducing a dimensionless time, , and a dimensionless z-coordinate,
, where
and, with the same dimensionless concentrations u and y as defined above for the Anzelius solution, we have
where and ys is the dimensionless solid phase concentration at the surface of a solid particle, i.e. at r = b.
If we further introduce a dimensionless particle radius we can rewrite the solid phase diffusion equation in dimensionless form
where 1/3 is 1/(asb). The solution to the solid phase diffusion problem is available in Carslaw and Jaeger23 and, with initial condition y=0 when τ=0, gives
+23 Carslaw and Jaeger, Conduction of Heat in Solids, 233.
This can be integrated over to get the volume average (dimensionless) concentration
since
+Taking the derivative with respect to τ gives (by integration by parts)
+At this point we can eliminate y and ys and have an expression entirely in terms of u. First we use the expression for the liquid phase concentration to obtain an expression for ys
+Thus
+and since
Rosen solves this by taking the Laplace transform, with respect to τ, with the following relations:
+arriving at
+Letting
+then
+and, solving this ode with initial condition u=1, U=1/s, gives
+The final solution follows from taking the inverse Laplace transform, by way of the contour integral
+A major component of the integration involves first defining YD in terms of trigonometric functions. The details are tedious, but the main result is
+with and
and
+Which allows Rosen to write the integral in terms of these harmonic functions24
+24 For details of the integration see Rosen, “Kinetics of a Fixed Bed System for Solid Diffusion into Spherical Particles” pages 390-391
At this point we can calculate the dimensionless space and time for a point at the exit of the espresso bed, and say at a similar point in (dimensionless) time, and proceed with calculating the integral to find the concentration.
+m = ε/(1-ε)
+aᵥ = 3/b
+
+ξ = (K*𝒟ₛ*aᵥ)/(b*m*v)*z
+
+τ = (𝒟ₛ*aᵥ/b)*(t-z/v)
+ν = (𝒟ₛ*K)/(b*h)
+
+@show ξ; @show τ; @show ν;ξ = 144.6719346388569
+τ = 144.6719346388569
+ν = 0.019452389057107278
+Unlike with the Anzelius case, I am not going to define a struct for the Rosen solution yet, first I am going to work through some details on how to perform the integral.
+The integral extends to infinity and so the performance of the harmonic functions at very large λ is important. The hyperbolic trig functions will blow up to infinity and, in the naïve implementation, lead to NaN errors as the numerator and denominator overflow. Rosen provides limiting behaviour, and a pre-calculated table of values, which can be used with the integrand switching from the default definition of the harmonic functions to the limiting behaviour after some λ threshold. An alternative, which I employ below, is to rewrite the hyperbolic trig functions in terms of exponentials, cancelling a exp(+4λ) from the numerator and denominator, to generate a form that handles large values of λ more gracefully.
function HD1(λ)
+ if λ ≤ eps(0.0)
+ return 0.0
+ else
+ # λ*((sinh(2λ) + sin(2λ))/(cosh(2λ) - cos(2λ))) - 1
+ return λ*( (1 - exp(-4λ) + 2*exp(-2λ)*sin(2λ)) / (1 + exp(-4λ) - 2*exp(-2λ)*cos(2λ))) -1
+ end
+end
+
+function HD2(λ)
+ if λ ≤ eps(0.0)
+ return 0.0
+ else
+ # λ*(sinh(2λ) - sin(2λ))/(cosh(2λ) - cos(2λ))
+ return λ*( (1 - exp(-4λ) - 2*exp(-2λ)*sin(2λ)) / (1 + exp(-4λ) - 2*exp(-2λ)*cos(2λ)))
+ end
+endThe integrand can be divided into a decay component, f, that is independent of τ, and an oscillatory component, K, that is a function of τ.
+This clean division also presents an opportunity to pre-calculate the integral to an extent. With a predefined set of points { λi } then f can be entirely pre-calculated. Using prosthaphaeresis you could go further and pre-calculate parts of , for some incremental improvements, though I leave that as an exercise for a more motivated individual.
It is worth looking at the case where τ gets large as this becomes a highly oscillating integral and can be tricky to evaluate – requiring a very large number of steps for conventional numerical integration techniques like Gauss-Kronold. In this example that starts to happen near the end of the extraction, but if the bed were, say, twice as deep then much of the extraction curve would be in this regime.
+function fᵣ(λ; ξ, ν)
+ hd1, hd2 = HD1(λ), HD2(λ)
+ H1 = (hd1 + ν*(hd1^2 + hd2^2))/((1 + ν*hd1)^2 + (ν*hd2)^2)
+ return exp(-ξ*H1)/λ
+end
+
+function 𝒦ᵣ(λ, τ; ξ, ν)
+ hd1, hd2 = HD1(λ), HD2(λ)
+ H2 = hd2/((1 + ν*hd1)^2 + (ν*hd2)^2)
+ return sin((2/3)*τ*λ^2 - ξ*H2)
+endBy introducing the change of variables25 β = λ2, the integrand is “compressed” along β and we can take advantage of the exponential decay to truncate the integration.
+25 Rosen, “General Numerical Solution for Solid Diffusion in Fixed Beds,” 1590–94.
function fᵣ₂(β; ξ, ν)
+ λ = √(β)
+ hd1, hd2 = HD1(λ), HD2(λ)
+ H1 = (hd1 + ν*(hd1^2 + hd2^2))/((1 + ν*hd1)^2 + (ν*hd2)^2)
+ return exp(-ξ*H1)/β
+end
+
+function 𝒦ᵣ₂(β, τ; ξ, ν)
+ λ = √(β)
+ hd1, hd2 = HD1(λ), HD2(λ)
+ H2 = hd2/((1 + ν*hd1)^2 + (ν*hd2)^2)
+ return sin((2/3)*τ*β - ξ*H2)
+endQuadGK.jl can compute the improper integral directly, by making the substitution λ = t/(1-t).
+using QuadGK: quadgk_count
+
+I₁, err₁, N₁ = quadgk_count(λ -> fᵣ(λ; ξ=ξ, ν=ν)*𝒦ᵣ(λ, τ; ξ=ξ, ν=ν), 0, Inf)
+
+@show I₁; @show err₁; @show N₁;I₁ = 0.011703238397164204
+err₁ = 1.0292584595556625e-10
+N₁ = 195
+By making the substitution β = λ2 and truncating the integral to the range [0,2] we can achieve similar precision, with almost half as many steps.
+I_2, err_2, N_2 = quadgk_count( β -> fᵣ₂(β; ξ=ξ, ν=ν)*𝒦ᵣ₂(β, τ; ξ=ξ, ν=ν), 0, 2)
+
+@show I_2/2; @show err_2; @show N_2;I_2 / 2 = 0.01170323839564812
+err_2 = 2.183245425172356e-10
+N_2 = 105
+I have mentioned a few times that highly oscillating integrals can be tricky to evaluate. Gaussian quadrature will, in general, work but it will require a large number of steps. An alternative is to use Levin colocation, an example implementation is given below using ApproxFun.jl.
+Given the integral
+if we suppose there is a function such that
Then we can eliminate the integral, using the fundamental theorem of calculus
+The problem is then one of finding the function .
If we choose such that
, then
Eliminating K(x) gives
+Solving this ode then gives the final solution.
+In this case I set K(x) to
+where h(x) = (2/3)τx2 - ξH2(λ), and f(x) is
+and the ode is solved for F in terms of Chebyshev polynomials.
+using ApproxFun:Interval, Fun, Derivative, Evaluation, \, I
+using LinearAlgebra: ⋅
+
+function levin(ξ, τ, ν, a, b)
+ d = Interval(a,b)
+ λ = Fun(d)
+ D = Derivative(d)
+ E = Evaluation(a)
+
+ HD1 = λ*(sinh(2λ) + sin(2λ))/(cosh(2λ) - cos(2λ)) - 1
+ HD2 = λ*(sinh(2λ) - sin(2λ))/(cosh(2λ) - cos(2λ))
+
+ H1 = (HD1 + ν*(HD1^2 + HD2^2))/((1 + ν*HD1)^2 + (ν*HD2)^2)
+ H2 = HD2/((1 + ν*HD1)^2 + (ν*HD2)^2)
+
+ h = (2/3)*τ*λ^2 - ξ*H2
+ h′ = D*h
+ w⃗ = [ sin(h); cos(h) ]
+
+ f = exp(-ξ*H1)/λ
+ f⃗ = [ 0; 0; f; 0 ]
+
+ L = [ E 0;
+ 0 E;
+ D -h′*I;
+ h′*I D ]
+
+ F = L\f⃗
+
+ return F(b)⋅w⃗(b) - F(a)⋅w⃗(a)
+endlevin(ξ, τ, ν, 0.0001, 2)0.011703248012960965
+This works well enough, though there is a complication in that there is a singularity at x=0, it is also rather slow.
+If the system of interest was consistently at large values of τ, where it is a highly oscillating integral throughout the main part of the problem domain, it would be worth looking at techniques to eliminate the singularity and speed it up. I include it here mostly for completeness. It is by delightful coincidence alone that I can get by solving this particular problem using more conventional numerical integration techniques.
+As I mentioned above, by splitting the integral into a function f that depends only on space and a function K that is a function of time and space, we can pre-calculate all of the space dependent components and write the integral as a weighted sum.
+The obvious way to do this is to use QuadGK.jl to take the weight function f and generate points that way. For example:
+pts, wts = gauss( x -> fᵣ₂(x; ξ=ξ, ν=ν), 20, 0, 2);I could not get this to work reliably, it would routinely run aground on DomainErrors close to the singularity at x=0. When I did get it to work it took a very long time to generate points, like leave my desk and go make coffee and maybe it will be done when I get back long time. I think if you really wanted to invest the time, and evaluating this integral was going to be in production code, it would be worth investigating a better quadrature rule since, when it does work, it allows you to use significantly fewer points in each integration.
The alternative, which works well enough for my purposes, is to use the gauss function to generate a set of points and weights in the truncated range and then pre-calculate the values of f over those points. The final integral is then the weighted sum. This involves calculating far more points for any given integral, but it is much faster than either Levin colocation or trying to have QuadGK generate the weights.
using QuadGK: gauss
+
+pts, wts = gauss(N₁, 0, 2);
+
+wts = wts .* fᵣ₂.(pts; ξ=ξ, ν=ν);
+
+I_gauss = sum( wts .* 𝒦ᵣ₂.(pts, τ; ξ=ξ, ν=ν) )/20.011703238395545075
+This can be packaged neatly into an IntegralTransform struct that, when constructed, generates the set of points and appropriate weights such that it only the kernel function actually needs to be evaluated for any given time.
struct IntegralTransform{T}
+ a::T
+ b::T
+ params::NamedTuple
+ numpts::Integer
+ pts::Vector{T}
+ wts::Vector{T}
+ kern::Function
+end
+
+function IntegralTransform(params, fun, kern; a=0.0, b=2.0, numpts=350)
+ pts, wts = gauss(numpts, a, b)
+ wts = wts .* fun.(pts; params...)
+ return IntegralTransform(a, b, params, numpts, pts, wts, kern)
+end
+
+function integrate(t, it::IntegralTransform)
+ return sum( it.wts .* it.kern.(it.pts, t; it.params...) )
+endOne could go further here: pre-calculating H1 and H2 as they only depend on ν and λ, splitting K into parts by prosthaphaeresis26 and pre-calculating the parts that only depend on ξ and λ. The current performance is more than good enough for me, but I think it worth highlighting that there are many opportunities for improvement.
+26 I love this word.
At this point I am finally ready to circle back and create my RosenSolution struct, one that includes the pre-calculated IntegralTransform for the particular location in the bed.
struct RosenSolution{Q1,Q2,T}
+ τ₁::Q1
+ τ₂::Q2
+ pb::PackedBed
+ it::IntegralTransform{T}
+end
+
+function RosenSolution(z, pb::PackedBed; fun=fᵣ₂, kern=𝒦ᵣ₂, a=0.0, b=2.0, numpts=200)
+ m = pb.ε/(1-pb.ε)
+ aᵥ = 3/pb.b
+ ξ = (pb.K*pb.𝒟ₛ*aᵥ*z)/(m*pb.v*pb.b)
+ ν = (pb.𝒟ₛ*pb.K)/(pb.b*pb.h)
+ τ₁ = pb.𝒟ₛ*aᵥ/pb.b
+ τ₂ = τ₁*(z/pb.v)
+
+ p = (ξ=ξ, ν=ν)
+ it = IntegralTransform(p, fun, kern; a=a, b=b, numpts=numpts)
+
+ return RosenSolution(τ₁, τ₂, pb, it)
+endThe concentration can then be obtained by calling the integrate function with the integral transform.
function c(t, model::RosenSolution)
+ # unpack some things
+ cₛ = model.pb.q₀/model.pb.K
+ c₀ = model.pb.c₀
+
+ # compute the integral
+ τ = model.τ₁*t - model.τ₂
+ I = integrate(τ, model.it)
+
+ # return back the concentration
+ u = 0.5 + I/π
+ c = cₛ + (c₀ - cₛ)*u
+ return c
+endrosen = RosenSolution(z, pb; a=0.0, b=2.0, numpts=200);Rosen27 provides an asymptotic approximation for cases where ξ is large
+27 Rosen, “General Numerical Solution for Solid Diffusion in Fixed Beds,” 1591.
Which is decidedly simpler to calculate.
+function c_approx(t, model::RosenSolution)
+ # unpack some things
+ cₛ = model.pb.q₀/model.pb.K
+ c₀ = model.pb.c₀
+
+ # compute the integral
+ τ = model.τ₁*t - model.τ₂
+ u = 0.5*(1 + erf( ( (τ/ξ) - 1 ) / ( 2*√((1+5ν)/(5ξ)) ) ) )
+
+ # return back the concentration
+ c = cₛ + (c₀ - cₛ)*u
+ return c
+endI briefly mentioned, above, an effective mass transfer coefficient can be derived for the Anzelius solution, one that accounts for the solid phase diffusion. This can be calculated rather simply from the linear resistance model28
+28 LeVan and Carta, “Adsorption and Ion Exchange,” 16–24.
Adapting the AnzeliusSolution to use a generic function to calculate the effective mass transfer coefficient allows us to reuse everything from the Anzelius case.
function AnzeliusSolution(z, h_fun, pb::PackedBed)
+ m = pb.ε/(1-pb.ε)
+ aᵥ = 3/pb.b
+ h = h_fun(pb)
+ ξ = (h*aᵥ*z)/(m*pb.v)
+ τ₁ = (h*aᵥ/pb.K)
+ τ₂ = τ₁*(z/pb.v)
+
+ return AnzeliusSolution(ξ, τ₁, τ₂, pb)
+end
+
+h_eff(pb) = 1/( 1/((1-pb.ε)*pb.h) + pb.b/(5*pb.K*pb.𝒟ₛ) )The Rosen solution is a significant departure from the pure Anzelius solution, i.e. neglecting solid diffusion, showing that for this problem the rate of solid phase diffusion is quite important. In this case ξ is large enough that the asymptotic approximation to Rosen’s integral is also a very good model and, with an appropriate effective mass transfer coefficient, the Anzelius solution also works well.
+Rasmuson and Neretnieks29 provide an exact solution for the case where axial diffusion is included. This is the original pde derived at the beginning. Their solution follows essentially the same steps as Rosen, with the main difference that the ode in the Laplace domain is second order, due to the inclusion of the term. The original paper has an detailed derivation in the appendix, if you are interested. In practice, this amounts to a relatively minor modification on what we have already put together for the Rosen solution.
29 Rasmuson and Neretnieks, “Exact Solution of a Model for Diffusion in Particles and Longitudinal Dispersion in Packed Beds,” 686–90.
First we define the decay function, f, and kernel, K, using the same harmonic functions as Rosen.
+function f_rasmuson(λ; ν, δ, R, Pe)
+ hd1, hd2 = HD1(λ), HD2(λ)
+ H1 = (hd1 + ν*(hd1^2 + hd2^2))/((1 + ν*hd1)^2 + (ν*hd2)^2)
+ H2 = hd2/((1 + ν*hd1)^2 + (ν*hd2)^2)
+ a = Pe*(0.25*Pe + δ*H1)
+ b = δ*Pe*((2/3)*λ^2/R + H2)
+ return exp(0.5*Pe - √(0.5*(√(a^2 + b^2) + a)))/λ
+end
+
+function 𝒦_rasmuson(λ, y; ν, δ, R, Pe)
+ hd1, hd2 = HD1(λ), HD2(λ)
+ H1 = (hd1 + ν*(hd1^2 + hd2^2))/((1 + ν*hd1)^2 + (ν*hd2)^2)
+ H2 = hd2/((1 + ν*hd1)^2 + (ν*hd2)^2)
+ a = Pe*(0.25*Pe + δ*H1)
+ b = δ*Pe*((2/3)*λ^2/R + H2)
+ return sin(y*λ^2 - √(0.5*(√(a^2 + b^2) - a)))
+endRasmuson and Neretnieks parameterize things slightly differently, and add some extra dimensionless groups due to the , but the result a similar sort of integral problem as Rosen, namely integrating a highly oscillating integral that decays rapidly.
γ = 3*𝒟ₛ*K/b^2
+δ = γ*z/(m*v)
+ν = γ*b/(3h)
+σ = 2*𝒟ₛ/b^2
+
+R = K/m
+Pe = (z*v)/𝒟ₗ
+y = σ*tThus it can be represented using the IntegralTransform type previously created.
p = (ν=ν, δ=δ, R=R, Pe=Pe)
+
+it = IntegralTransform(p, f_rasmuson, 𝒦_rasmuson; a=0.0, b=2.0, numpts=200);integrate(y, it)0.011984085774006418
+struct RasmusonSolution{Q,T}
+ σ::Q
+ pb::PackedBed
+ it::IntegralTransform{T}
+end
+
+function RasmusonSolution(z, pb::PackedBed; fun=f_rasmuson, kern=𝒦_rasmuson, a=0.0, b=2.0, numpts=350)
+ m = pb.ε/(1-pb.ε)
+ γ = 3*pb.𝒟ₛ*pb.K/pb.b^2
+ δ = γ*z/(m*pb.v)
+ ν = γ*pb.b/(3*pb.h)
+ σ = 2*pb.𝒟ₛ/pb.b^2
+
+ R = pb.K/m
+ Pe = (z*pb.v)/pb.𝒟ₗ
+
+ p = (ν=ν, δ=δ, R=R, Pe=Pe)
+ it = IntegralTransform(p, fun, kern; a=a, b=b, numpts=numpts)
+
+ return RasmusonSolution(σ, pb, it)
+endrasmuson = RasmusonSolution(z,pb);function c(t, model::RasmusonSolution)
+ # unpack some things
+ cₛ = model.pb.q₀/model.pb.K
+ c₀ = model.pb.c₀
+
+ # compute the integral
+ y = model.σ*t
+ I = integrate(y, model.it)
+
+ # return back the concentration
+ u = 0.5 + 2I/π
+ c = cₛ + (c₀ - cₛ)*u
+ return c
+endGoing from Rosen’s solution to Rasmuson’s solution is a less dramatic change than from Anzelius, but it is clear that axial dispersion is an important effect in this case. I haven’t shown it, since I think it should be obvious at this point, but one could generate asymptotic relations for Rasmuson, and also find effective mass transfer coefficients, h, that would bring both the Rosen and Anzelius solutions in line with the Rasmuson solution. I leave that as an exercise for the reader (hint: it is just linear mass transfer resistances).
+The more direct approach, when faced with a pde, is to integrate it by finite differences or Method of Lines. This allows one to use whatever kinetics and initial conditions one wants. The above cases are all limited by linear extraction and the initial conditions that the bed is at equilibrium. The major downside is that, for problems like this with a rather sharp moving front, the discretization needs to be very tight or another method, like moving finite element, needs to be used. Thus making the actual run time rather slow.
+The first step is to put the pde in dimensionless form, by introducing the following variables
+we can write the pde for the liquid phase concentration as
+and the pde for the solid phase concentration as
+with
+These equations can be discretized in both spatial dimensions ξ and , turning them into an ode in τ.
In general the bed can be divided into n cells with each cell transferring fluid to the cell below by advection and exchanging mass with the solid phase through the thin film approximation. The solid phase would then be divided into m cells per cell of the column making the overall ode an n×(m+1) vector of cells.
+As an example of the how tight the discretization needs to be, I have implemented the simple Anzelius case using an effective mass transfer coefficient. This is equivalent to the pde for the Rosen model, but with the solid phase mass transfer incorporated into the mass transfer coefficient, making the problem simpler to simulate: in this case m=1.
+I divide the bed into n cells with the first n elements in the vector u the liquid phase concentrations and the next n elements the average solid phase concentrations. The spatial derivatives are replaced with their discrete equivalents.
+using SparseArrays
+
+h_e = h_eff(pb)
+
+function parameters(n)
+ v_dm = v*t_shot/L_pb
+ h_dm = h_e*(3/b)*t_shot
+ dξ=1/(n-1)
+
+ # initial conditions
+ u0 = ones(Float64,2n)
+
+ M = spzeros(Float64,2n,2n)
+ # Liquid phase
+ # start of column, with the boundary condition that u[0]=0
+ # du[1] = -v/2dξ*(u[2] - u[0]) - h/m*(u[1] - u[n+1])
+ M[1,1] = -h_dm/m
+ M[1,2] = -v_dm/2dξ
+ M[1,1+n] = h_dm/m
+
+ # middle column
+ for i in 2:n-1
+ # du[i] = -v/2dξ*(u[i+1] - u[i-1]) - h/m*(u[i] - u[n+i])
+ M[i,i-1] = v_dm/2dξ
+ M[i,i] = -h_dm/m
+ M[i,i+1] = -v_dm/2dξ
+ M[i,i+n] = h_dm/m
+ end
+
+ # end of column
+ # du[n] = -v*(u[n]-u[n-1])/dξ - h/m*(u[n] - u[2n])
+ M[n,n-1] = v_dm/dξ
+ M[n,n] = -v_dm/dξ - h_dm/m
+ M[n,2n] = h_dm/m
+
+ # Solid phase
+ for i in n+1:2n
+ # du[i] = h/K*(u[i-n] - u[i]
+ M[i,i-n] = h_dm/K
+ M[i,i] = -h_dm/K
+ end
+
+ return u0, (0.0, 1.0), M
+endThe ode for this system is linear and is simply
+function rhs!(du,u,M,t)
+ du .= M*u
+endWhich could presumably be solved by eigendecomposition, but more generally this would be solved using a standard ode solver.
+using OrdinaryDiffEq
+import Static
+
+sol = solve(ODEProblem(rhs!,parameters(10)...), Tsit5(thread=Static.True()))
+
+sol.retcodeReturnCode.Success = 1
+The liquid concentration at the exit is then extracted from the vector solution.
+# Pull out the concentration at the exit
+function c(t,sol::ODESolution)
+ n = length(sol.u[1])÷2
+ τ = t/t_shot
+ u = sol(τ)
+ return u[n]*c_sat
+endBelow is a figure showing a series of runs for increasing n. At low values of n the solution looks reasonable, but with much more diffusion than is actually warranted given the mass transfer coefficient. This is a common feature of Method of Lines when applied to pdes of this type and can, if one is not careful, lead to under-estimates of the actual effective diffusion (since much of the diffusion seen in the results is coming from the numerical method). As n increases, a spurious oscillatory behaviour appears at the end of the extraction and damping this requires increasing n > 250.
+Since I am ultimately modeling the same pde as was solved, exactly, by Anzelius I can plot the exact solution, showing that n must be quite large, >500, to start to align with the correct answer. Were we to have used the full pde, with n=m=500, this would have required a 250,500 element state vector. This approach becomes severely computationally intensive rather quickly. That said, this is mostly an issue when the moving front is very sharp.
+This can be alleviated by using a different discretization technique, such as moving finite element but, personally, there is a point where solving the pde gets complicated enough that it’s easier to just use a multiphysics program like comsol than to try implementing it yourself.
+So far I have reviewed the “conventional” approach to packed bed mass transfer, examining the solutions that are recommended in the standard texts on unit operations and leaching.30 All of these approaches over-estimate the initial concentration in the espresso because they assume the bed starts in equilibrium, though if the extraction runs for long enough these models fit the observed results better and better. An alternative approach is to assume the bed is filled with water that is not at equilibrium and the extraction only begins at t=0. This is the approach taken by much of the recent literature on modeling espresso31. The downside to this approach is that it generally underestimates the initial concentration of the espresso.
+30 Schwartzberg, “Leaching – Organic Materials,” 558–63.
31 Cameron et al., “Systematically Improving Espresso” page 635; Moroney et al., “Modelling of Coffee Extraction During Brewing Using Multiscale Methods” page 225; Vaca Guerra et al., “Modeling the Extraction of Espresso Components as Dispersed Flow Through a Packed Bed” page 5
Since the initial conditions are different, none of the models above are directly comparable to the approaches taken in the literature. Though it should not be a huge undertaking to change the initial conditions after taking the Laplace transform and completing the result from there. Since the Laplace transform and its inverse are linear this should equate to adding an term to the solution somewhere.
I think it is also reasonable to be skeptical of the mass transfer coefficients that I estimated. These are based on correlations for packed beds with spherical packing and, while I am modeling the particles as spheres, something may have been lost in the accounting. Most of the mass transfer coefficients ultimately depend upon a good estimate for the solid phase diffusion, which in this case I obtained from literature and is comparable to what one would expect for plant matter like coffee beans.32 But an obvious next step is to compare with actual measured data.
+32 Schwartzberg, “Leaching – Organic Materials,” 557.
The model is also highly sensitive to particle size, which the figure below illustrates by successively doubling the effective diameter of the particles, while using the Rasmuson solution (all other parameters remaining equal). This diameter is also an effective parameter and not a directly measured one. It is the diameter of the sphere with an equivalent surface area to a coffee ground or, more accurately, the average of such diameters over the actual particle size distribution of the coffee grounds. This makes it somewhat difficult to determine exactly, especially if one is trying to incorporate the effects of microscopic pores on the effective surface area of coffee grounds. Rough estimates can be made using images taken of the grounds, using an app, but that will always be limited by the resolution of a camera.
+Another note of caution is in using the direct numerical integration of the Rosen and Rasmussen solutions. In this notebook I specified the (finite) bounds of integration and the number of points to sample within the interval, which were tuned more or less by eye. That does mean the code is brittle to major changes in some of the packed bed parameters, without going back and re-tuning the parameters of the numerical integration. A more robust approach would determine some of these algorithmically, especially the bounds of integration. I could just leave the upper bound of the integral as Inf, but in my experience there can be domain issues if one isn’t careful and the integrand isn’t capturing all edge cases properly.
While I focused mostly on calculating the various integrals numerically, the asymptotic and approximate forms are probably more useful if you just want to play around and explore how changing different coffee parameters changes overall extraction. They are certainly easier to calculate.
+Hydrogen is not, itself, a greenhouse gas, in the sense that hydrogen does not significantly absorb infrared radiation. However hydrogen does have a significant global warming potential. Hydrogen influences chemical processes in the atmosphere that impact other greenhouse gases. In particular hydrogen preferentially reacts with oxidants in the air, oxidants that would otherwise be available to oxidize methane, leading to methane having a longer lifetime in the atmosphere. It also increases tropospheric ozone, both an important actor in ground-level pollution and a greenhouse gas.1 There has been increased recognition of this in the literature,2 as there are growing plans to transition many sectors of the economy to hydrogen. But this concern has not, as of yet, lead to hydrogen being listed on the standard tables of greenhouse gases used for emissions reporting, national inventories, and, importantly, “carbon tax” programs.3 As a consequence I haven’t seen a lot of effort, from industry, to quantify the climate impact of switching to hydrogen due to those fugitive emissions. Typical modeling of a hydrogen transition project (i.e. transitioning from natural gas to hydrogen as a fuel source for combustion) focuses on the combustion products and, if there is any attention paid to fugitive emissions, it is to claim that fugitive emissions will “disappear” as hydrogen “is not a greenhouse gas”.
+1 Sand et al., “A Multi-Model Assessment of the Global Warming Potential of Hydrogen,” 2.
2 Dutta et al., “The Role of Fugitive Hydrogen Emissions in Selecting Hydrogen Carriers”; Bertagni et al., “Risk of the Hydrogen Economy for Atmospheric Methane”; Ocko and Hamburg, “Climate Consequences of Hydrogen Emissions”.
3 Hydrogen is not listed on the most recent IPCC table of greenhouse gases, Smith et al., “The Earth’s Energy Budget, Climate Feedbacks, and Climate Sensitivity Supplementary Material”, Table 7.SM.7.
+Hydrogen is also not listed in Schedule 1 of the Technology Innovation and Emissions Reduction Regulation, AR 133/2019, which is the industrial “carbon tax” in Alberta.
4 Hydrogen GWP100 from Sand et al., “A Multi-Model Assessment of the Global Warming Potential of Hydrogen” page 5. Methane GWP100 is that for fossil fuel derived methane from Forster et al., “The Earth’s Energy Budget, Climate Feedbacks, and Climate Sensitivity” page 1017.
Taking the broader view of hydrogen’s impact on atmospheric chemistry, it has a GWP100 of 11.6 as compared to the methane’s GWP100 of 29.84 and so, assuming similar leak rates, one would expect that a transition from natural gas (primarily methane) to hydrogen would lead to a reduction in overall climate impact. Though this is also another point towards hydrogen not actually being a zero emissions fuel.
+The first time this question landed on my desk it was related to a project to transition a large petrochemical facility from natural gas to hydrogen fuel gas. I did some back of the envelope calculations to estimate the climate impact, in CO2-e, of hydrogen fugitive emissions from this system with a few basic assumptions:
+The first assumption is not as close as you might think, at least in this part of Alberta, the utility natural gas to the site is ~90% (mol) methane (that the hydrogen is essentially pure was a much closer approximation in this case). The second assumption is probably closer, though it will depend on the actual line pressure, it is something of a joke among chemical engineers that all gases are ideal gases unless we’re absolutely forced to do it otherwise.
+The third assumption is at least superficially reasonable, here I am imagining leaks at flanges to be basically holes in the gaskets, gaps due to misaligned fittings, or possibly pinhole leaks in the metal itself (hopefully less likely, though that depends on how seriously you take mechanical integrity). The standard way of estimating flow from a hole or orifice uses a discharge coefficient cD which is a function of geometry and not the gas moving through it.
+The other main component of fugitive emissions from this system would be low level venting, typically seen when burners start and stop. During start-up some volume of fuel gas is purged before the burner actually lights and similarly a small volume leaks out after the burner is turned off. For some systems, where the burners are starting and stopping frequently, this can be a major component of fugitive emissions. I’m choosing to neglect those, or consider those part of stack emissions.
+The fourth assumption is pretty reasonable for the fuel gas distribution system at an industrial facility, where the line pressures are relatively high. This means that the leak rate for any given hole is independent of the system pressure and the flow will be turbulent.
+Pulling these together and assuming that for any given leak in the distribution network the mass flow is given by the equation for an ideal gas through an isentropic nozzle:
+The ratio of mass flow of hydrogen to that of methane is then:
+Assuming the system pressure, P1, after having switched to hydrogen, is the same as the system pressure when operating natural gas.
+For a system delivering fuel gas there is a good reason to assume this as the system will deliver approximately the same energy (in terms of HHV) when operated at the same pressure (pure methane versus pure hydrogen). Though this is worth keeping in mind as the hydrogen line can operate at slightly lower pressures while delivering the same heating value, which also reduces the leak rate. This effect is small at low and moderate pressures but could be important at high pressures.
+Because everything related to the particular hole and the conditions around it canceled out, we have gone from a relation for a single leak in a network to a relation that holds for the whole system. Since this was a back of the envelope calculation, I further assumed that as is within 10% of
then
and thus
+putting this in terms of emissions in CO2-e, with
and so we expect a ~87% reduction in fugitive emissions (in CO2-e) after having transitioned the system from natural gas to hydrogen.
+Since I’m now sitting in front of a computer, I can loosen off some of the aggressive approximations, using gas properties from Crane’s.5
+5 Crane, “TP410M”.
using Unitful
+
+# GWPs: Forster et al. "The Earth's Energy Budget," 1017.
+# Sand et al. "Multi Model Assessment," 5.
+#
+# Fluid properties: Crane's *Flow of Fluids*, A-6 and A-9
+
+# Methane
+GWP_CH4 = 29.8 # t-CO2e/t
+MW_CH4 = 16.043u"g/mol"
+μ_CH4 = 0.01103u"cP" # at 20°C
+k_CH4 = 1.31
+
+# Hydrogen
+GWP_H2 = 11.6 # t-CO2e/t
+MW_H2 = 2.016u"g/mol"
+μ_H2 = 0.008804u"cP" # at 20°C
+k_H2 = 1.41g(k) = k*(2/(k+1))^((k+1)/(k-1))
+
+E_H2 = GWP_H2*√(MW_H2*g(k_H2))
+E_CH4 = GWP_CH4*√(MW_CH4*g(k_CH4))
+
+E_H2/E_CH40.1415674991761294
+I assumed, above, that the fuel gas distribution system was at a high enough pressure for flow to be choked, but how high would that have to be? Choking flow for an isentropic nozzle is when
+where (1) is upstream of the nozzle and (2) is downstream of the jet, in this case atmospheric pressure since the leaks are all to atmosphere. From this we can back calculate the critical system pressure above which all jets are choked.
+# choking condition
+η_c(k) = (2/(k+1))^(-k/(k-1))
+
+P₂ = 101.325u"kPa" # atmospheric pressure
+
+P₁(k) = η_c(k)*P₂
+
+Pₘᵢₙ = min(P₁(k_H2),P₁(k_CH4))186.28417600555758 kPa
+or in terms of psi (absolute)
+uconvert(u"psi",Pₘᵢₙ)27.018235462782194 psi
+System pressures for the fuel gas distribution networks within chemical plants within them are often above 100psia, though by the time this has been stepped down to a burner it can be around 25psia. This is quite different from the operating pressures of the distribution network to residential customers, where typical pressures are in the range of 0.1-0.4psig.6
+6 For plant piping I have no references that are not confidential to the companies I have worked for, so I guess you’ll just have to trust me. For the residential distribution network see Mejia, Brouwer, and Kinnon, “Hydrogen Leaks at the Same Rate as Natural Gas in Typical Low-Pressure Gas Infrastructure” page 8815.
After getting a general sense of how I would expect fugitive emissions to change, I spent some time looking for more specific data, in particular measured performance of actual systems. In industrial settings, actual leak data from systems in hydrogen service is available. Hydrogen has been a common industrial gas for over a century. However the relevant question is not “what are the fugitive emissions from a system designed for hydrogen service?” it is the subtly different question “what are the fugitive emissions from a system designed for natural gas service, but operating in hydrogen service?”. Maybe switching from natural gas to hydrogen will lead to a system that leaks like a sieve with hydrogen leaking from fittings that would otherwise be gas-tight.
+The literature is pretty consistent that this is not the case. Hydrogen leaks from fuel gas systems switched over from natural gas at rates that are entirely consistent with what you would expect, given the differences in density and viscosity.7 What is different, from my analysis, is the model of fluid flow primarily used in the literature.
+7 Mejia, Brouwer, and Kinnon; Swain and Swain, “A Comparison of ,
and
Fuel Leakage in Residential Settings”; Schefer et al., “Characterization of Leaks from Compressed Hydrogen Dispensing Systems and Related Components”.
I assumed all leaks would be essentially turbulent flow, through a nozzle, using a modified Bernoulli equation. That model works well for large, macroscopic, jets of gases much like what one typically encounters when modeling leaks of process safety relevance. However most fugitive emissions are not big jets of gas like that, somebody would notice that and get it fixed. Fugitive emissions from flanges and fittings come through minuscule gaps in the pressure envelope that involve flow paths that are longer than they are wide, more analogous to pipe flow. Thus the model of fluid flow more commonly seen in the literature treats leaks like a series of tiny, tortuous, tubes.
+Starting from the Darcy-Weisbach equation, in terms of the Fanning friction factor, f, for incompressible flow
+The volumetric flow, Q, would be
+where ΔP is the pressure drop, D the hydraulic diameter, L the effective length and ρ the density. The relative leak rate is then the volumetric flow for hydrogen over that for methane
+This is the typical starting point in the literature. If we assume fully developed turbulent flow, f is a constant and independent of the Reynolds number, then (for ideal gases)
+If we assume laminar flow and
For pipe-flow , which after substitution gives
and, after squaring both sides and canceling
+These two equations are the ultimate source for most of the bounds given on the relative leak-rate of hydrogen fugitives versus natural gas fugitives.
+turbulent_leak_ratio = √(MW_CH4/MW_H2)2.8209638958319374
+laminar_leak_ratio = μ_CH4/μ_H21.2528396183552932
+I think it is important to show where these numbers come from, in particular the assumptions that go into them, as I have seen these values – 1.2× to 2.8× the leak rate of methane/natural gas – used directly in relation to GWP100s and other measures that are on a mass basis. This is incorrect. These are the ratios for volumetric flow. Hydrogen has a density ~1/8th that of methane, the mass flow rate is much less for both the turbulent and laminar regimes.
+For turbulent flow:
+and for laminar flow:
+turbulent_mass_ratio = √(MW_H2/MW_CH4)0.3544887623260728
+laminar_mass_ratio = (MW_H2/MW_CH4)*(μ_CH4/μ_H2)0.1574346861936216
+The mass emission ratio for the turbulent case is entirely what I came up with in my back of the envelope calculations, and I think you could extend this to include compressibility.8
+8 Schefer does this, replicating the same result as my model above, and goes further to provide a model for non-ideal gases that accounts for differences in compressibility factor, Schefer et al., “Characterization of Leaks from Compressed Hydrogen Dispensing Systems and Related Components” page 1251.
9 Frazer-Nash Consultancy, “Fugitive Hydrogen Emissions in a Future Hydrogen Economy,” 25; Mejia, Brouwer, and Kinnon, “Hydrogen Leaks at the Same Rate as Natural Gas in Typical Low-Pressure Gas Infrastructure,” 8813–14; Swain and Swain, “A Comparison of ,
and
Fuel Leakage in Residential Settings,” 808.
At this point we are drifting away from the original problem, the laminar regime is unlikely to occur at the high system pressures of typical transmission lines and plant fuel gas systems. We’ve basically just circled around to the answer I arrived at originally, but with more footnotes.9
+It is worth noting that for very low system pressures, like what is seen with residential distribution lines, an entirely different flow regime is encountered. In these mechanically assembled piping systems, e.g. NPS piping, leaks are primarily through the gaps in the threads or mechanical joints. These gaps, due to manufacturing defects or damage, form micro channels that are small enough for the continuum hypothesis to breakdown and flow is in a molecular flow regime.10 In this case the volumetric leak rate is identical for both hydrogen and natural gas.
+10 Mejia, Brouwer, and Kinnon, “Hydrogen Leaks at the Same Rate as Natural Gas in Typical Low-Pressure Gas Infrastructure,” 8814–15.
molecular_flow_mass_ratio = MW_H2/MW_CH40.12566228261547094
+Fugitive emissions are generally small compared to combustion emissions for fossil fuels. The large majority of the emissions, in CO2 equivalents, is what is coming out of the stack. In the case of hydrogen, very little is coming out of the stack other than water and nitrous oxide. So it is worth checking to see how important, relatively, fugitive emissions have become.
+As a first pass I am going to divide emissions into combustion and fugitive wherein the combustion emissions are the direct emissions of combustion products and the fugitive emissions are all the leaks in the entire system (burners included).
+My model for fugitive emissions will be quite simple: some fraction η of flow is lost from the system and the emissions, in CO2 equivalents is
+When hydrogen undergoes combustion it produces water
+Since there is no carbon in the fuel, no carbon dioxide is generated. Similarly, there is no possibility of generating methane through incomplete combustion. However nitrous oxide can be generated from any gaseous flame that uses air as a source of oxygen, though the chemistry of this process is complex.11 Thus the combustion emissions for hydrogen are
+11 Colorado, McDonell, and Samuelsen, “Direct Emissions of Nitrous Oxide from Combustion of Gaseous Fuels” lists 23 different reactions involved in the formation of N2O in gaseous flames..
Where EF is the emission factor for nitrous oxide and HHV is the higher heating value of hydrogen.
+The ratio of fugitive to combustion emissions is then12
+12 I am using the nitrous oxide emission factor for natural gas combustion, for lack of any more appropriate emission factor. This factor is highly dependent upon the actual burner design/operation, fuel gas, and host of other parameters relating to the actual stationary combustion device. I am implicitly assuming that whatever the nitrous oxide emission factor would be for hydrogen, it would be of the same order of magnitude as that for natural gas.
SG_H2 = 0.0696 # GPSA
+ρ_air = 1.225u"kg/m^3" # GPSA, at 15°C and 1atm
+ρ_H2 = SG_H2*ρ_air
+
+GWP_N2O = 273 # Forster et al., 1017.
+EF_N2O = 8.7e-7u"kg/MJ" # AEPA, 1-9 Industrial
+HHV_H2 = 12.102u"MJ/m^3" # GPSA, at 15°C and 1atm
+
+fugitives_to_combustion(η) = ((GWP_H2*ρ_H2)/(GWP_N2O*EF_N2O*HHV_H2))*(η/(1-η));Assuming that the leak rate is 1% we then have
+fugitives_to_combustion(0.01)3.4755942870304137
+At any appreciable leak percentage the amount of hydrogen lost to fugitive emissions rivals the stack emissions for climate impact.
+More relevant to a fuel switching program is to re-assess how much of a reduction switching to hydrogen achieves. Instead of comparing hydrogen to itself, we should compare hydrogen to the natural gas system that preceded it.
+For the natural gas system the fugitive emissions are similar, except that I am assuming the only climate relevant component of natural gas is methane
+and the combustion emissions now include carbon dioxide and methane along with nitrous oxide
+Total emissions are just .
What we are interested in is the ratio
+There are a few assumptions we need to make to proceed. The first is to assume that the system with natural gas and the system with hydrogen are operating under the same pressure. At the same pressure the hydrogen system will deliver about the same energy in HHV as the natural gas system, slightly more (depending on the exact natural gas, etc.). Which makes this a plausible assumption. The whole point of the fuel delivery system is to deliver sufficient energy to a combustion device, in the form of fuel heating value. This is not exact, so a more detailed analysis would work out the actual pressure of the hydrogen system and that would add a whole layer of complication.
+The second assumption is that the fraction of gas lost between the two systems is the same. At first blush this seems like a crazy assumption. I spent two sections talking about how significantly different the leak rates were, so what is going on here? Well the volumetric leak rate is higher with hydrogen but the line flow rate is also higher, and they are both higher by the same amount. It cancels out.
+Suppose the leaks are all in the turbulent regime, so
+For fully developed turbulent pipe flow we know the ratio of line flow rates is also
+By the definition of η
+To make the math a little less tedious to type out, I am going to define two emission factors, the fugitive emission factor13
+13 Note that the flowrates here are at standard state. The volumetric emission factors, heating values, and densities are also at standard state thus this is equivalent to the relation at actual conditions.
and the combustion emission factor
+Finally we can answer the question of “how much do the total emissions go down after switching to hydrogen?”
+# hydrogen
+EF_f_H2 = GWP_H2*ρ_H2
+EF_c_H2 = GWP_N2O*EF_N2O*HHV_H2
+
+# methane
+SG_CH4 = 0.5539 # GPSA
+ρ_CH4 = SG_CH4*ρ_air
+
+# natural gas
+x_CH4 = 0.90 # Alberta typical
+SG_NG = 0.61 # Alberta typical
+EF_CO2_NG = 1.962u"kg/m^3" # ECCC, 3.
+EF_CH4_NG = 3.7e-5u"kg/m^3" # ECCC, 3.
+EF_N2O_NG = 3.3e-5u"kg/m^3" # ECCC, 3.
+
+EF_f_NG = GWP_CH4*ρ_CH4*x_CH4
+EF_c_NG = EF_CO2_NG + GWP_CH4*EF_CH4_NG + GWP_N2O*EF_N2O_NG
+
+# Final answer
+emissions_ratio(η) = ((EF_c_H2*(1-η)+EF_f_H2*η)/(EF_c_NG*(1-η)+EF_f_NG*η))*√(SG_NG/SG_H2);emissions_ratio(0.01)0.017665064441514864
+So switching to hydrogen has reduced the overall emissions from this system by ~98.2%. Which is pretty significant, though it is not zero even though this analysis is assuming pure hydrogen.
+Even at relatively high leak rates, the total greenhouse gas emissions from a hydrogen system are a small fraction of that of a natural gas system. Transitioning to hydrogen does what you would expect: it radically reduces the climate impact of stationary combustion equipment. That said, it is not zero emissions. Which shifts the perspective on where hydrogen fits in the energy transition. If the goal is zero then hydrogen will not get us there by the simple fact that hydrogen has a significant global warming potential and fugitive emissions are unavoidable. If the goal is to radically decarbonize existing systems and run out the remaining life of a vast global fleet of process equipment, then transitioning to hydrogen may be a major player.
+Hydrogen may also be limited by the fact that it is not a zero impact fuel with regards to all of the other air emissions that are more locally important, such as nitrogen oxides (NOx), VOCs, and ground level ozone. Hydrogen combustion does directly produce nitrogen oxides and direct hydrogen emissions impact atmospheric chemistry increasing VOC and ground level ozone concentrations. If the choice is between hydrogen combustion and electrification, well electrification actually is zero emissions – both greenhouse gas emissions as well as other air pollutants – and while electrification projects are more complex than hydrogen as a “drop-in” solution, that can be a pretty strong advantage. For example in airsheds that are already stressed for NOx, switching to hydrogen fuel gas may also require the installation post-combustion NOx reduction technology such as SCR, as hydrogen combustion generally produces more NOx than natural gas. Replacing stationary combustion equipment with their electric equivalents has the advantage that it reduces all air emissions.
+Five pin bowling uses five pins but, unlike ten pin bowling, the pins are worth different amounts. Notably no pin is worth 1, and so a score of 1 is the first impossible score.
+
+Like ten pin bowling, if a strike or a spare is recorded in a given frame then the scores of subsequent ball(s) are counted in that frame, as well as the frame in which they were thrown. So, for example, if I throw a strike in the first frame I don’t actually know what to write on the score sheet for the first frame until the second, and possibly third, frames have been thrown. I know it is at least 15, but until I throw the next ball it could be anything up to 45. This was what initially gave me pause. It adds a layer of complexity since the possible scores for a given frame depend on what happens next.
+By symmetry, though, this way of scoring is equivalent to every strike and spare adding a multiplier to the next frame, and each frame is just scored counting whatever the pinfall is and applying the multiplier (no looking backwards). So, if I throw a strike in the first frame, then I record a 15 for the first frame and double count the next two balls. If I have thrown two strikes in a row then I triple count the first subsequent ball and double count the next one. This is a weird way of managing a score sheet, for bowlers, but makes it a lot easier to reason about the possible scores, since you don’t have to constantly be looking back two or three frames. This passing forward score sheet looks different to a regular one: The maximum score for the first frame is now 15, and for the second frame 30, and in the tenth frame it is possible to score 90 points. On a conventional score sheet the max score in any frame is 45.
+While hanging out at the lanes a few obvious impossible scores got thrown out: a 1, obviously, but also a 449 – there’s no way to throw a 14 with the last ball in the tenth frame. But the question still lingered: are there any other gaps? It was not immediately obvious, to my bowling team, how you would figure that out without checking.
+Maybe we can brute-force this and try every conceivable bowling game? However there are a lot of possible bowling games. As a first pass, there are thirty balls thrown in a game and each ball has up to fourteen possible pinfall scores (0 through 15 excluding 1 and 14). This would give 1430 possible bowling games. Even if it took a single nanosecond to evaluate each game that would take longer than the current age of the universe to work through.
+But that’s not a great upper bound, it doesn’t take into account the rules of bowling: you can only knock down up to five pins in any given frame, for example if the first ball scores a 13 then the second ball doesn’t get to choose from fourteen possibilities, it gets to chose from two: 0 and 2. Still, it is going to be a large number. The vast majority of those games are going to be completely redundant, since we are only looking for scores from 2 to 450.
+I was thinking about this some more and there is a different way of looking at this that gives a better estimate for the number of possible games.
+First let’s consider a single frame (within the first 9 frames). The order that pins are knocked down – the score per ball – matters because of the way strikes and spares are counted. So we are trying to answer the question “how many ways can 5 pins be divided into 4 categories (hit by ball 1, 2, or 3, or left standing)?” This is the sum of a multinomial and is well known, for n objects divided into m categories, the number of ways is :
The tenth frame has some extra rules, so lets go through it:
+| Frame | +Number of Possibilities | +description | +
|---|---|---|
| ? ? ? | +1024 | +Any combination of the first set of 5 pins | +
| X ? ? | +35 -1 = 242 | +Every follow up to a strike except X–, which has already been counted in ??? | +
| X X ? | +25 -1 = 31 | +Every follow up to 2 strikes except XX-, which has already been counted in X?? | +
| ? \ ? | +25 × (25-1) = 992 | +Every follow up to every spare except ?\- which were counted in ??? | +
Which gives 2289 possible ways of bowling the 10th frame.
+So this gives a total count of possible 5 pin bowling games of which is about
The easiest case to look at is when one never throws a strike or spare. In this case the possible scores for each frame are the same: just what you can get from knocking down any subset of the pins. This happens to be anything from 0 to 15 except 1 and 14. That’s easy enough to see just by inspection.
+This also leads to a (kinda loose) argument for why you should be able to get anything from 0 to 150 except 1 and 149:
+Suppose you are playing game with n frames and your goal is a score .
If and
then you can always get from a multiple of 15 to the final score in one frame.
If or
then you can’t get from a multiple of 15 to the final score in one frame, this is because you cannot score 1 or 14 in one frame. You can score 1 more than a multiple of 15 if you have two frames remaining: score a 13 in the first and a 2 in the next. Similarly you can score 1 less than a multiple of 15 if you have two frames remaining: score a 7 in the first frame and a 7 in the second.
Since for any x such that there are
frames remaining, all of those scores can thus be achieved.
What remains is the x such that and
, this single score is not achievable in a game with no strikes and spares.
Which is all to say there are only two impossible scores: 1 and 15n -1 or 149 in a standard ten frame game.
+I suspect that, if you wanted to put the work in, you could extend this argument to include spares and strikes, with all of the complications around how the 10th frame is scored. But an alternative is to just look through all possible scores and try and find a game that achieves it, using this general approach as a guide.
+This is pretty easy to do when only looking at the case where there are no strikes or spares: I generate a list of possible scores for a single frame (a possible move I can take towards my goal), sorted largest to smallest.
+basic_moves = [ n for n in range(16) if n not in [1,14] ]
+basic_moves.reverse()Then I define a function that recursively walks through the tree of possible games, always picking the largest viable move at each frame. If it finds an answer it returns it (in reverse order), if it exhausts the possible moves then it returns an empty list.1
+1 This code stops once it has found a single valid solution, it could be extended very easily to find every valid solution, however the space of possible games is huge.
def make_moves(cur_frame, cur_score, max_frame, target, moves=basic_moves):
+ if cur_frame == max_frame:
+ return [0] if cur_score == target else []
+ else:
+ next_frame = cur_frame + 1
+ mn, mx = min(moves), max(moves)
+ n = max_frame - next_frame
+ r = target - cur_score
+ for move in filter(lambda x: n*mn <= (r-x) <= n*mx, moves):
+ new_score = cur_score + move
+ advance = make_moves(next_frame, new_score, max_frame, target, moves)
+ if len(advance) > 0:
+ advance.append(move)
+ return advance
+ else:
+ return []Looping through all the scores: yields the impossible to bowl scores.
for score in range(151):
+ game = make_moves(0,0,10,score)
+ if len(game) == 0:
+ print("Score {0} is not possible".format(score))Score 1 is not possible
+Score 149 is not possible
+Which is what I expected, good news as I will be building off this general strategy for the cases where strikes and spares are included.
+Another question that comes to mind is: what if you were restricted to always hitting a pin, no gutter balls? Now you can’t get a score less than 7 (equivalent to hitting 2-2-3, the lowest pins). Does this change anything?2
+2 In five pin it is actually possible to bowl between the pins and hit nothing without it technically being a gutter ball, and you can bowl into the blank spots left by pins that were already knocked down to score zeros without putting it in the gutter. I am using the term gutter ball loosely.
Probably I could go back and look at the math again, but the nice thing about having written code is that I can just change the space of possible moves and run it again.
+no_gutters = [ n for n in range(7,16) if n != 14 ]
+no_gutters.reverse()for score in range(70,151):
+ game = make_moves(0,0,10,score,no_gutters)
+ if len(game)==0:
+ print("Score {0} is not possible, with no gutters".format(score))Score 149 is not possible, with no gutters
+So nothing really changes. I mean you can’t get a score <70, obviously, but this doesn’t open up any gaps in possible scores either.
+It does mean the strategy changes, now the code takes the biggest strides it can until the remainder is a multiple of 7 then runs out the game with a string of 7s.3
+3 You may have noticed an extra “frame” at the end with a score of 0. This is because, in five pin bowling, the last frame has special rules. You always get 3 balls in the last frame, even if your first two are a strike or spare. In this case, with no strikes or spares allowed by design, that extra scoring doesn’t enter into it.
[ frame for frame in reversed(make_moves(0,0,10,100,no_gutters)) ][15, 15, 15, 13, 7, 7, 7, 7, 7, 7, 0]
+Adding in spares means I can’t easily track the state of each frame with an integer, like I did for the case with deadwood every frame. Now I need to track three different properties for a given frame:
+Instead of diving into the full set of scoring rules for everything, I’m going to take one baby step forward and add in a data structure to track the state of the frame and select from two sets of possibilities for the subsequent frame: was there a spare or not?
+The data structure I’m using is just a struct, tracking the score, whether it is a “special” frame and whether or not it is the end of the game.
+class SingleFrame:
+ def __init__(self, score, special=None, end=False):
+ self.score = score
+ self.special = special
+ self.end = endInstead of a list of integers for possible moves, I now need a list of possible SingleFrame objects that represent a possible frame, now including the possibility of a spare. Note the pass forward approach to scoring: a spare in a regular frame is only worth 15.
single_frame_moves = [ SingleFrame(score) for score in basic_moves ]
+single_frame_moves.insert(0, SingleFrame(15,"spare"))Now I iterate through the possible first, second, and third balls for a frame following a spare. There are more possible scores here since the first ball will be double counted. First I exhaustively generate every combination then use set() to extract only the unique elements. For this purpose it doesn’t matter how many ways you can get a given score.
spares = [ 2*f+s for f in basic_moves
+ for s in filter(lambda x: f+x==15,basic_moves)]
+
+spares = list(set(spares))
+spares.sort(reverse=True)
+
+non_spares = [ 2*f+s+t for f in basic_moves
+ for s in filter(lambda x: f+x<15, basic_moves)
+ for t in filter(lambda x: f+s+x<=15, basic_moves) ]
+
+non_spares = list(set(non_spares))
+non_spares.sort(reverse=True)Now I generate a list of moves by combining the spares and non-spares. They are arranged such that the code tries the spares first before the non-spares, going from largest to smallest. There are now 42 possible ways of scoring a frame when spares are included (versus only 14 when they aren’t).
+spare_frame_moves = [ SingleFrame(score,"spare") for score in spares ]
+spare_frame_moves += [ SingleFrame(score) for score in non_spares ]
+
+len(spare_frame_moves)42
+There are only two scores that are not achievable in the frame following a spare: 1 and 29
+[ s for s in range(31) if s not in [x.score for x in spare_frame_moves] ][1, 29]
+Adding spares has also complicated determining if a move is valid or not. Since scoring now depends on the state of a given move – is it a spare or not – this impacts the bounds of possible scores that can follow any given move. Instead of putting this all into the same function, as I did before, I have broken it out into its own function that decides, given a move, a remaining number of frames, and a remaining number of points to pick up, is the move valid.4
+4 There is an extra move at the end because of the last frame rule: A spare at the start of the last frame leads to an extra ball, but one that can only count for up to 15.
def valid_spare_moves(move, n, r, mn=0, mx=15):
+ if move.special=="spare":
+ up = (2*n + 1)*mx
+ else:
+ up = (1 + 2*max(n-1,0) + 1)*mx
+
+ return n*mn <= (r - move.score) <= upThe bulk of the main function is the same. The one exception is that it now tracks whether the previous frame was a spare (with was_spare) and uses this to determine how to finish the last frame: if the last frame was a spare, then an extra ball is thrown but with no multiplier.
def make_spare_moves(cur_frame, cur_score, max_frame, target,
+ moves=single_frame_moves, was_spare=False):
+ if cur_frame == max_frame:
+ if cur_score == target:
+ return [SingleFrame(0,False,True)]
+ elif was_spare and (target - cur_score) in basic_moves:
+ # extra ball in the last frame
+ return [SingleFrame(target-cur_score,False,True)]
+ else:
+ return []
+ else:
+ next_frame = cur_frame + 1
+ n = max_frame - next_frame
+ r = target - cur_score
+ for move in filter(lambda x: valid_spare_moves(x,n,r), moves):
+ new_score = cur_score + move.score
+ if move.special == "spare":
+ next_moves = spare_frame_moves
+ else:
+ next_moves = single_frame_moves
+
+ advance = make_spare_moves(next_frame, new_score, max_frame, target, next_moves, move.special=="spare")
+ if len(advance) > 0:
+ advance.append(move)
+ return advance
+ else:
+ return []Looping through all the scores: yields the impossible to bowl scores, when spares are allowed.
for score in range(301):
+ game = make_spare_moves(0,0,10,score)
+ if len(game)==0:
+ print("Score {0} is not possible, with only spares allowed".format(score))Score 1 is not possible, with only spares allowed
+Score 299 is not possible, with only spares allowed
+Which is perhaps not surprising, we still can’t get 1 less than the largest multiple of 15 because we cannot bowl a 14 with the extra ball at the end of the 10th frame.
+This puts me in a good position to try a full game, I need to add the possibility of a strike. For single frame scores with nothing special before them, this just means adding a third way to score a 15. There are now 16 possible moves.
+# all the different scores for a frame following a regular frame
+
+single_frame_moves = [ SingleFrame(score) for score in basic_moves ]
+single_frame_moves.insert(0, SingleFrame(15,"spare"))
+single_frame_moves.insert(0, SingleFrame(15,"strike"))
+
+len(single_frame_moves)16
+Similarly the possible ways to follow a spare are the same as before, except that there is no way to “spare” with 30. Scoring a 30 after a spare requires that one throw a strike.
+# all the different scores for a frame following a spare
+
+spare_frame_moves = [ move for move in spare_frame_moves if move.score !=30 ]
+spare_frame_moves.insert(0, SingleFrame(30,"strike"))
+
+len(spare_frame_moves)42
+Following a single strike the rules are different: the first two balls count twice and the third counts once. Here I exhaustively generate all strikes, spares, and remaining that could follow a single strike and then trim only to the unique scores. This is a much smaller set as all spares that follow a strike must, by definition, have a score of 30.
+# all the different scores for a frame following a single strike
+
+non_spares = [ 2*f+2*s+t for f in filter(lambda x: x!=15,basic_moves)
+ for s in filter(lambda x: f+x<15, basic_moves)
+ for t in filter(lambda x: f+s+x<=15, basic_moves) ]
+
+non_spares = list(set(non_spares))
+non_spares.sort(reverse=True)
+
+single_strike_moves = [ SingleFrame(30,"strike") ]
+single_strike_moves += [ SingleFrame(30,"spare") ]
+single_strike_moves += [ SingleFrame(score) for score in non_spares ]
+
+len(single_strike_moves)30
+Following a double strike the rules are different again: the first ball is triple counted, the second double counted, and the third single counted.
+# all the different scores for a frame following 2 strikes
+
+spares = [ 3*f+2*s for f in filter(lambda x: x!=15,basic_moves)
+ for s in filter(lambda x: f+x==15,basic_moves)]
+
+spares = list(set(spares))
+spares.sort(reverse=True)
+
+non_spares = [ 3*f+2*s+t for f in filter(lambda x: x!=15,basic_moves)
+ for s in filter(lambda x: f+x<15, basic_moves)
+ for t in filter(lambda x: f+s+x<=15, basic_moves) ]
+
+non_spares = list(set(non_spares))
+non_spares.sort(reverse=True)
+
+double_strike_moves = [ SingleFrame(45,"strike") ]
+double_strike_moves += [ SingleFrame(score,"spare") for score in spares ]
+double_strike_moves += [ SingleFrame(score) for score in non_spares ]
+
+len(double_strike_moves)55
+This change in scoring, allowing for triple scoring, changes the bounds of possible scores following a given move. Now, after a strike, the next frame could count triple. But there are also potentially two more balls in the 10th frame that don’t count equally towards the upper bound on the score. The function to check for valid moves needs to be updated to reflect this.
+def valid_full_moves(move, n, r, mn=0, mx=15):
+ if move.special=="strike":
+ # max score is 3 times the max score for the remaining frames
+ # plus the multiplier for the remaining balls in the last frame
+ up = (3*n + 2 + 1)*mx
+ elif move.special=="spare":
+ # max score is 2 times the max score for the next frame
+ # 3 times the max score for the remaining frames
+ # plus the multiplier for the remaining balls in the last frame
+ up = (2 + 3*max(n-1,0) + 2 + 1)*mx
+ else:
+ up = (1 + 2*max(n-1,0) + 2 + 1)*mx
+ return n*mn <= r - move.score <= upAt this point all of the sets of moves for a regular frame contain every score except 1 and 1 less than the max score (i.e. a frame following a spare or single strike cannot score a 29 and a frame following a double-strike cannot score a 44). At this point you may expect that the only impossible scores will be 1 and 449 – this was true with the cases above. However there are two more sets of scoring possibilities just for the last frame.
+If the last frame starts with a spare, then it is the same as before: single ball, no multiplier.
+If the last frame starts with a strike, and is not preceded by one, then there are potentially two more balls left with no multipliers attached. And these do leave gaps.
+# all the different scores for the last 2 balls of the last frame
+# assuming the first ball in the last frame was a strike
+last_frame_moves = [ f+s for f in basic_moves
+ for s in filter(lambda x: f+x<=15,basic_moves) ]
+last_frame_moves += [ 15 + s for s in basic_moves ]
+last_frame_moves = set(last_frame_moves)
+
+[ s for s in range(31) if s not in last_frame_moves ][1, 16, 29]
+If the last frame starts with a strike and is preceded by one, then there are potentially two more balls but the first one is double counted. Again, this leaves gaps.
+# all the different scores for the last 2 balls of the last frame
+# assuming the second to last frame was a strike and the first ball
+# in the last frame was a strike
+last_frame_double_moves = [ 2*f+s for f in basic_moves
+ for s in filter(lambda x: f+x<=15,basic_moves) ]
+last_frame_double_moves += [ 30 + s for s in basic_moves ]
+last_frame_double_moves = set(last_frame_double_moves)
+
+[ s for s in range(46) if s not in last_frame_double_moves ][1, 29, 31, 44]
+It certainly looks now like there will be at least 4 scores that can’t be achieved because there no way to make the last step with the extra balls in the 10th frame, because the 10th frame is scored differently. This additional scoring complexity now makes it unwieldy to put all of that into the main function. I have broken it out into a separate function that just checks the last frame and either returns the last move with the remaining balls or returns an empty list if there is no possible move.
+def last_frame_rule(remaining, last_frame, max_move, mx=45):
+ if remaining == 0:
+ # hit the target, don't need any additional balls
+ return [SingleFrame(0,None,True)]
+ elif last_frame == "strike" and max_move == mx and remaining in last_frame_double_moves:
+ # two extra balls following a double strike
+ return [SingleFrame(remaining,None,True)]
+ elif last_frame == "strike" and remaining in last_frame_moves:
+ # two extra balls following a single strike
+ return [SingleFrame(remaining,None,True)]
+ elif last_frame == "spare" and remaining in basic_moves:
+ # only one extra ball
+ return [SingleFrame(remaining,None,True)]
+ else:
+ # not possible
+ return []The main function now has to track whether the last frame was a strike, to trigger the double strike rules, versus single strikes, spares, and regular frames. The logic is the same, though.
+def make_full_moves(cur_frame, cur_score, max_frame, target,
+ moves=single_frame_moves, last_frame=None):
+ if cur_frame == max_frame:
+ # max_move is used to check if the second-to-last frame was a strike
+ max_move = max( s.score for s in moves )
+ return last_frame_rule(target-cur_score, last_frame, max_move)
+ else:
+ next_frame = cur_frame + 1
+ n = max_frame - next_frame
+ r = target - cur_score
+ for move in filter(lambda x: valid_full_moves(x,n,r), moves):
+ new_score = cur_score + move.score
+ if last_frame == "strike" and move.special == "strike":
+ next_moves = double_strike_moves
+ next_last_frame = "strike"
+ elif move.special == "strike":
+ next_moves = single_strike_moves
+ next_last_frame = "strike"
+ elif move.special == "spare":
+ next_moves = spare_frame_moves
+ next_last_frame = "spare"
+ else:
+ next_moves = single_frame_moves
+ next_last_frame = None
+
+ advance = make_full_moves(next_frame, new_score, max_frame, target, next_moves, next_last_frame)
+ if len(advance) > 0:
+ advance.append(move)
+ return advance
+ else:
+ return []Looping through all the scores: yields the impossible to bowl scores, when spares and strikes are allowed.
for score in range(451):
+ game = make_full_moves(0,0,10,score)
+ if len(game)==0:
+ print("Score {} is not possible, full game".format(score))Score 1 is not possible, full game
+Score 434 is not possible, full game
+Score 436 is not possible, full game
+Score 449 is not possible, full game
+This conforms with our intuition, after looking at the possible last-frame moves. Of course this is entirely academic as, the way I bowl, I am in no danger of coming close to these impossible scores.
+ + +Typically, when evaluating various release scenarios, key pieces of the model are specified in advance and each scenario uses the same set of assumptions: comparing apples to apples. For a Gaussian plume dispersion model there are really three key correlations used for the model parameters: the wind-speed profile, crosswind dispersion, and vertical dispersion. Correlations for each of these are given in the standard references and there is not, to my mind, any deep reason to prefer one reference over the another. Besides maintaining consistency with other modeling or perhaps with industry practice in a particular area.
+This raises the obvious question: how much does it matter which reference you use? Usually one takes the results of a Gaussian plume model with a fair grain of salt, these are “order of magnitude” estimates really. That’s what I’m going to look at here.
+The windspeed correlations I am looking at here are the basic power law
+where uR is the known windspeed at a reference height zR and p is a parameter that depends upon the Pasquill stability class. There are more complex models that incorporate the surface roughness, Monin-Obukhov mixing length, and other measures of stability, they are beyond this analysis.
+There are three different standard references used in GasDispersion.jl for windspeed: the default which comes from Spicer and Havens,1 the correlations used by the EPA Industrial Source Complex (ISC3)2 dispersion models, and the correlations given in the various CCPS guidance documents3
1 EPA-450/4-89-019.
2 EPA-454/b-95-003b.
3 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases.
4 Handbook on Atmospheric Diffusion.
The ISC3 and CCPS correlations are divided into urban and rural terrain and are exactly the same correlations for the unstable classes. They appear to be the correlations given in Hanna, Briggs, and Hosker.4 They also bracket the default correlation. Clearly whether or not the terrain is urban is significant, it can lead to a 20-30% difference in estimated windspeed (depending upon elevation).
+For the stable atmospheres the ISC3 and CCPS rural correlations are the same. However they are very different for urban terrain and they no longer bracket the default correlation. The CCPS urban correlations are the same as Hanna, Briggs, and Hosker,5 the ISC3 correlations use the parameter p = 0.30 and no reference is given in the model specification so I don’t know why.
+5 Hanna, Briggs, and Hosker.
For an urban release scenario, whether or not one choses the default, the ISC3 urban, or the CCPS urban correlation can lead to a 300% difference in windspeed (for class F stability, depending on elevation). Which is a pretty large difference.
+The more diverse sets of correlations are for the plume dispersion parameters, the crosswind and vertical dispersion. To some extent this is because the early Turner6 presented the dispersion parameters graphically and many subsequent authors generated their own curves to fit these plots.
+6 Workbook of Atmospheric Dispersion Estimates.
Crosswind dispersion can be divided into the various attempts at fitting the curves presented graphically by Turner and those based on Briggs’ urban and rural correlations7
+7 Briggs, “Diffusion Estimation for Small Emissions. Preliminary Report” page 38; Note that the correlations are given with respect to half-width/half-depth
The default correlation is a simple set of correlations of the form
+which attempts to fit the Turner curves.
+The CCPS correlations are from Briggs8 and the ISC3 urban correlations are from Briggs as well, the ISC3 rural correlations are something else entirely but I suspect are intended to fit the Turner9 curves. The correlations from the TNO yellow book10 are also a different attempt at fitting the Turner curves. What GasDispersion,jl gives as “Turner” is the fit to the Turner curves given in Lees.11
8 Briggs.
9 Turner, Workbook of Atmospheric Dispersion Estimates.
10 Bakkum and Duijm., “Vapour Cloud Dispersion”.
11 Lees, Loss Prevention in the Process Industries.
Zooming in on the class F curves is illustrative of the lot: most of the lines overlap and hew pretty close to the curve-fit for Turner12 with the exception of the Briggs’ urban/rural correlations. The biggest impact on these model parameters is whether or not a rural/urban terrain is used or not. Note these are log-log plots.
+12 Turner, Workbook of Atmospheric Dispersion Estimates.
The vertical dispersion correlations are decidedly more varied. Varied enough that I’m just going to show them all at full scale13
+13 The correlations given in AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases for urban conditions has typos in the class A, B and D correlations, I have corrected them here to match the Briggs correlations on which they are supposed to be based.
For some of these there is an order of magnitude spread in vertical dispersion, depending on which model happens to be used. Even when looking only at the correlations that are “universal”, i.e. are not for either urban or rural terrains. From this alone one would expect that the concentration profiles would vary by a large amount, depending on which set of correlations one used to model a given scenario.
+Just to give an example of how this works out, lets look at the emissions from a large stack. I happened to have picked the stack for a large power plant in the Edmonton area: TransAlta’s Sundance station. This power plant is on the shores of Lake Wabamun and is pretty rural, it has several stacks but let’s consider only Stack 2 and examine the dispersion of SO2 emissions.
+From Alberta’s AEIR Air Emission Rates dataset we can pull the mass emission rates for SO2 as well as the relevant stack dimensions. Note this dataset is from 2018 and thus may not represent the current operations at Sundance.
+# TransAlta Sundance - Stack 2
+m = 3200/3600 # mass emission rate: 3200kg/h in kg/s
+h = 155.5 # stack height, m
+d = 7.3 # stack diameter, m
+v = 35.6 # stack exit velocity, m/s
+T = 439.7 # stack exit temperature, KFor the sake of modeling let’s assume a class D atmospheric stability with a windspeed at 10m of 2m/s. The atmosphere is otherwise at standard state.
+# assumed weather conditions
+uᵣ = 2 # windspeed, m/s
+zᵣ = 10 # windspeed elevation, m
+stability = ClassD
+
+# standard state
+Pₛ = 101325 # Pa
+Tₛ = 273.15 # KWe can construct the relevant scenario for GasDispersion.jl directly.
r = VerticalJet(m, Inf, d, v, h, Pₛ, T, 0.0)
+
+a = SimpleAtmosphere(pressure=Pₛ, temperature=Tₛ, windspeed=uᵣ, windspeed_height=zᵣ, stability=stability)
+
+# a dummy substance, since I know a gaussian plume doesn't require any material
+# properties I have just left them as NaNs
+SO2 = Substance(name=:SulfurDioxide,molar_weight=0.064066,liquid_density=1,boiling_temp=1,
+ latent_heat=1,gas_heat_capacity=1,liquid_heat_capacity=1)
+
+scn = Scenario(SO2,r,a)Substance: SulfurDioxide
+ MW: 0.064066 kg/mol
+ P_v: GasDispersion.Antoine{Float64}(0.007705368698167287, 0.007705368698167287, 0.0) Pa
+ ρ_g: 2.7095140841291006 kg/m^3
+ ρ_l: 1 kg/m^3
+ T_ref: 288.15 K
+ P_ref: 101325.0 Pa
+ k: 1.4
+ T_b: 1.0 K
+ Δh_v: 1 J/kg
+ Cp_g: 1 J/kg/K
+ Cp_l: 1 J/kg/K
+VerticalJet release:
+ ṁ: 0.8888888888888888 kg/s
+ Δt: Inf s
+ d: 7.3 m
+ u: 35.6 m/s
+ h: 155.5 m
+ P: 101325.0 Pa
+ T: 439.7 K
+ f_l: 0.0
+SimpleAtmosphere atmosphere:
+ P: 101325.0 Pa
+ T: 273.15 K
+ u: 2.0 m/s
+ h: 10.0 m
+ rh: 0.0 %
+ stability: ClassD
+The Gaussian plume model is then given by the following, neglecting the effect of plume rise.
+conc = plume(scn, GaussianPlume; plumerise=false);Plotted below are the results for every equation set, at near ground level (at basically “my head” level). Clearly the urban/rural choice is quite important, leading to a ~4× greater maximum concentration. The TNO correlations, which uses the default correlation for windspeed and the TNO correlations for the crosswind and vertical dispersion, leads to less dispersion and thus a greater maximum concentration relative to the rest.
+Plotted below is the 172ppbv isopleth, the 1-hr Ambient Air Quality Objective (AAQO) for SO2 in Alberta. As we would expect, the correlations that lead to a higher maximum concentration correspond to less overall dispersion and the isopleth is quite a bit smaller for the urban versus rural case and the TNO versus the remaining cases. The scale is in kilometers so this is quite a large difference in area.
+The above was assuming no plume rise, however the relative differences are much more pronounced when plume rise is included.
+conc = plume(scn, GaussianPlume; plumerise=true);Plotted below is the same downwind concentration plot as above, but incorporating the Briggs’ plume rise model. Since this leads to a greater overall dispersion, the concentration is much smaller (everything is well below the AAQO at ground level, which is good news). However this adds another dimension along which the models can vary: plume rise is a function of windspeed, and overall dispersion is a function of plume rise. These different sets of correlations lead to the plume rising to a different elevation, and also dispersing to a differing degree, magnifying the differences between them. In this case there is up to a ~30× difference between the max concentrations predicted between the urban and rural case.
+I think the above illustrates the necessity of picking a standard set of correlations for use when screening scenarios at a particular plant (e.g. using either the CCPS urban or rural correlations as appropriate for the area around the plant) and being careful to keep these consistent. It also shows how seriously one should take the exact values generated by the models: not very. The dispersion model results are highly sensitive to the choice of correlations, and they are also quite sensitive to the other assumptions that go into a release scenario (e.g. atmospheric stability, wind-speed, mass emission rate). The results are really order of magnitude at best.
+It is often the case that chemical plants are situated at the periphery of cities, in areas that blur the line between “urban” and “rural”. Also, cities grow and industrial areas fill in. A plant that was essentially rural may, overtime, fill in such that the urban correlations better represent the area. I think it is worth comparing the urban/rural models for a range of plausible results and considering whether assumptions made in the past about the area around the plant are still valid given changes in the area.
+There are other correlations, for wind-speed and for dispersion, that take into account the local surface roughness which could be used instead and the sensitivity to the models to assumptions about surface roughness could be evaluated. This would likely lead to a smaller range of values, and give a path for updating the screening model as the area around the plant changes (update the assumed surface roughness and re-run).
+Making coffee involves heat, mass, and momentum transfer across multiple phases – pretty standard stuff for undergraduate chemical engineering curricula. On the other hand, while industrial scale leaching operations are generally designed for maximum efficiency – removing the most amount of a substance with the least amount of solvent, energy, etc. – coffee makers are specifically designed to avoid that outcome. A saturated cup of coffee would be strong, harsh, and undrinkable. Coffee making, as a unit op, aims at a managed inefficiency, which makes for an interesting design case1
+1 I am not the first person to think of this: using coffee as a basis for exploring engineering concepts is the entire premise of this book. From the reviews it sounds like it is, essentially, a lab manual for exploring chemical engineering concepts using coffee.
I claimed that making coffee is, in a sense, a deliberately inefficient process. By this I mean the goal is not to maximize extraction – defined as the mass of solids dissolved in the final cup of coffee relative to the starting mass of coffee grounds – but instead to target some middle ground. This is largely because extraction is an imperfect measure of what we actually want. Coffee releases a whole slew of flavour compounds and a good cup of coffee is a balance of all of these. However we have both limited variables to control and limited knowledge of that final composition. Even simply measuring the total dissolved solids (TDS) with a refractometer puts one well into the stratospheric heights of coffee nerd-dom. Trying to monitor all of the relevant flavour compounds would require something like a quarter-million dollar GC-MS, well out of the reach of most coffee obsessives.
+So extraction is really the best we have, as far as quantitative measures go, with the giant caveat that coffee with the same extraction, from the same beans, can taste quite different depending on the brew method. Using an indirect measurement for the actual process variable of interest is not too different from how a lot of unit operations are controlled, distillation, for example, often uses temperature as a proxy for the composition.
+
+2 Batali, Ristenpart, and Guinard, “Brew Temperature, at Fixed Brew Strength and Extraction, Has Little Impact on the Sensory Profile of Drip Brew Coffee,” fig. 1.
The standard way of thinking about coffee extraction starts with Lockhart’s coffee control chart, this plots the concentration of solids (TDS) against total extraction. The diagonal lines represent a given dose of coffee (I typically brew 55g/L with my V60, which puts me pretty near the sweet spot). A given brew moves along the diagonal line for the given dose, moving from the bottom left to the upper right as the brew proceeds. The goal is to stop the brew once the extraction and strength (concentration) have reached the optimal level3:
+3 For a given dose of coffee, the concentration and extraction are directly proportional to one another.
+where the subscript cup means the mass/volume that ends up in the final cup of coffee. This is only approximately the case as some water is absorbed into the coffee grounds. The amount of water retained in the coffee grounds can be accounted for, giving a more accurate measure of final extraction.
For industrial scale distillation, absorption, extraction, leaching, etc. the process is usually modeled as a series of equilibrium stages, and the whole point is to maximize extraction and concentration. This leads to designs for counter-current solids extractors such as a Rotocel extractor or a Bollman extractor
+
+Extractors like this are, in fact, how one might decaffeinate coffee. In that case one does want to maximize the extraction of caffeine, and is free to adjust several parameters such as the solvent (with options such as supercritical CO2, dichloromethane, or ethyl acetate) that are otherwise pretty fixed for normal coffee making. At the end of the day a cup of coffee has to be made with water, a steaming cup of dichloromethane just won’t cut it.
+Coffee makers inhabit a space where the design parameters are highly restricted. Outside of espresso, the machine has to operate at atmospheric pressure and temperatures achievable with a normal kettle. The solvent must be water. The process is likely batch or semi-batch.4 The extraction happens fully within the mass-transfer dominated regime, specifically avoiding reaching equilibrium (the fundamental design assumption in most industrial extractors) as that leads to over-extracted coffee.
+4 I would love to see a fully continuous coffee maker, like the fully continuous industrial operations, and there is no reason why you couldn’t make one. Imagine going into your local coffee shop and seeing a glass fluidized bed continuously circulating grounds and hot water, that would be pretty groovy.
Perhaps the simplest method for making coffee is to put coffee grounds and water in a vessel, add heat, and let it steep for a while. This is, for example, how Turkish coffee is made as well as qahwa, bunna, and many others. A French press and other infusion brewers are a very similar idea except that the water is also the source of heat, and the pot is left to steep without any additional heat input. That’s not the only difference, of course, they differ quite substantially in grind size, whether or not the grounds are strained out at the end, and in the addition of spices or sugar during the brew. But for the purposes of building a simple model all of these methods are vessels in which coffee steeps in hot water. There are three main process variables that impact coffee extraction, and taste, for a given set of beans: brew temperature, grind size, and brew time.
+In some ways this makes these methods some of the easier ways to make good coffee. Dialing in grind size and temperature is reasonably straight forward and once set remain constant. The remaining variable, time, is relatively easy to adjust: simply wait longer.
+Modeling extraction is fairly straight forward, after some basic assumptions are made: that the brew is isothermal, that the ground coffee is uniform and with constant dimensions, and that the liquid phase is well mixed. All of these assumptions are wrong to some degree, and how wrong they are will ultimately govern how useful this model is.
+Brew temperature is an obvious variable to change, though it has wide ranging impacts and parsing out what exactly changing the temperature does is not obvious. Firstly, the solubility of the various compounds extracted from the beans is a function of temperature and in general solubility is difficult to predict, but broadly speaking solutes are more soluble at higher temperatures. Coffee is more extractable at higher temperatures. However the coffee matrix is complex and there are more than just two phases involved: flavour compounds in the coffee will partition between the solid matrix, coffee oils, and the water at different proportions depending upon the temperature. This is perhaps what is behind the notable difference in taste between cold brew versus a hot immersion brew. Even when made with the same beans, and to the same concentration, the flavour profile of cold brew is quite different.5 That said, over the range of temperatures used to brew a French press, this may not be very important.6
+5 Batali et al., “Sensory Analysis of Full Immersion Coffee”.
6 Batali, Ristenpart, and Guinard, “Brew Temperature, at Fixed Brew Strength and Extraction, Has Little Impact on the Sensory Profile of Drip Brew Coffee”.
7 Schwartzberg, “Leaching – Organic Materials,” 558; Poling, Prausnitz, and O’Connell, The Properties of Gases and Liquids, 11.21–33.
Secondly, brew temperature impacts the rate of extraction. Generally speaking, diffusion coefficients are proportional to (absolute) temperature, .7 At higher temperatures the various flavour compounds will diffuse more quickly through the grounds and also through the coffee, thus making the brew faster.
To make modeling extraction simpler, we assume the brew temperature is constant. This means that, whatever the relative solubilities or rate constants turn out to be, they are constant with respect to time. The only thing varying over time is the concentration of coffee solubles in water and remaining in the grounds. For something like Turkish coffee, the system is probably close to isothermal as it is continuously heated and will remain at or near the boiling point of water the entire time. For a French press this is less true, as the press will lose heat to the environment. How much heat is lost over the course of the brew is going to depend strongly upon the press and the environment it is in. My French press is a double walled stainless steel carafe like this one and likely loses much less heat than a more typical glass carafe. It is also important to consider whether or not the French press is pre-heated. If not, the brew temperature is not going to be the temperature of the kettle. The carafe has significant thermal mass, especially if it is glass, and it will absorb a lot of heat out of the water over the course of the brew (in addition to losing heat to the environment).
+Suppose my French press starts off at 95°C and cools to 75°C – a sizable loss of heat – how much impact would that have on extraction rate? Since , the percent change in the rate constant is equal to the percent change in (absolute) temperature
ΔT = 20
+T = 368.15
+ΔT / T = 0.054325682466385986
+Even over this significant loss of heat, that translates to only a 5.4% change in the rate constants. To the exacting standards of a coffee nerd that may seem like a lot, but to chemical engineer that is really not much, it justifies the isothermal assumption (at least as a first approximation).
+Grind size is important if only for being where most of your money can get sunk when building out your home coffee set-up. A good grinder is not cheap, and a bad grinder leads to truly bad coffee. In this case what you are chasing is the ability to tune the average grind size as well as the uniformity of the size of particles produced by the grinder. A good grinder can reliably produce a consistent and suitably narrow particle size distribution.
+Why does grind size matter at all? The grind size determines the available surface area of the coffee. Mass transfer from the coffee beans (grounds) to the water is proportional to the surface area of coffee exposed to water, and so changing the grind size directly impacts the rate of extraction. The direct impact of grind-size is typically quantified through the specific area, av, which is the surface area of the particle per unit volume. For a sphere this is
+where b is the radius of the particle. This leads immediately to the observation that, for the same dose of coffee, a finer grind leads to larger overall surface area and thus a faster rate of extraction. It also hints at why a uniform particle size distribution is important: a smaller particle has proportionately more surface area and will experience faster extraction than a larger particle, leading to the smallest particles (the fines) being over extracted while the largest particles (the boulders) are under extracted.
+Of course coffee grounds are not perfect spheres, they have a complex shape arising from the combination of cutting and brittle fracture that characterize the grinding process. The standard engineering approach is to assume that they are spheres anyways, since that is a simpler geometry to work with, and adjust for the non-sphericity with some sort of shape factor or other parameter. In the case of mass and heat transfer, typically that is the Sauter mean diameter (or Sauter mean radius), which is essentially the average diameter of the distribution of spheres that would have the same specific area as the actual particles. For an individual particle the Sauter radius is
+It is important to note, though, that the following model is developed for spheres and only works as well as the grounds can be approximated as spheres.
+Mass transfer problems, like this one, ultimately come down to finding good rate constants. They can be measured, estimated from a correlation, or simply tabulated in a reference, but regardless the model is only as good as the rate constants. The rate constants define, to some extent, the model itself and govern one of the key brew variables: brew time.
+In the case of coffee, and organic materials in general, there is a complex micro-scale geometry involving multiple phases: the solid ground itself, coffee oils, and water. The coffee will diffuse from the solid into the oils, into water in the interstitial spaces, and also out into the bulk liquid. All of these processes have potentially different rate constants. Additionally the solid phase is not structurally homogeneous, it is a complex arrangement of coffee bean cells, voids, pores and such. Building a model to incorporate all of this complexity is certainly possible8 but the standard approach is to treat this as a two-phase problem where all of the complexity of the solid phase, the marc, and any secondary phases (e.g. coffee oils) are all averaged together into one pseudo-homogeneous solid phase and the solvent (water) forms the liquid phase. This approximation leaves us with two mass transfer rates: the diffusion through the (pseudo-homogeneous) solid phase, within the coffee particles, and the diffusion through the solvent phase, the water outside of the coffee particles. At the interface, the solute leaves the solid phase and enters the liquid phase.
+8 Moroney et al., “Modelling of Coffee Extraction During Brewing Using Multiscale Methods”.
9 Schwartzberg, “Leaching – Organic Materials,” 557.
For organic material with hard cell walls the relative diffusivity of the solid phase to the liquid phase generally falls along the range ,9 this allows us to estimate the (effective) diffusivity within the solid based on measured diffusivities in liquid water. It also tells us that diffusion through the solid is 5-10× slower than in the liquid phase and so, depending upon the geometry of the problem, diffusion through the solid phase may be the governing rate.
Diffusion through the liquid phase is complicated by mixing. The diffusivity used above is the diffusivity in quiescent liquid water. In practice, in the brew vessel, the liquid will be moving and convective mass transfer will be very significant. Usually for mass transfer problems this is all rolled up into a mass transfer coefficient h which combines all of the flow complexity and geometry of the problem into a single coefficient. This is then typically estimated using correlations for the Sherwood number.
+The interface between the solid and liquid phase introduces a complication as there is some partitioning between the phases happening at the interface. If there wasn’t coffee couldn’t be made. A critical piece of the model is assuming a relationship between the concentration immediately on the solid side of the interface and the concentration immediately on the liquid side of the interface. For organic leaching it is typical to assume linear equilibrium with an equilibrium distribution coefficient
+Where q is the concentration of solute in the solid phase and c is the concentration of solute in the liquid phase. This is equivalent to assuming that there are two first order processes happening
+At equilibrium the rates of these two processes are equal
+Typically one assumes that at the interface, in the infintesimally thin slice of liquid on one side and the infintesimally thin slice of solid on the other, the solute is always at equilibrium (this is not the same as assuming the system is at equilibrium)
+At this point we can start defining what our specific brew is going to be: roast, grind size, dose, and water temperature. From this we can work to estimate the necessary parameters, such as the equilibrium constant, solid and liquid phase diffusivities. To an extent, these parameters then govern what specific model is used to model the brew.
+# properties of the coffee grounds
+# equilibrium parameters
+# Moroney et al. 2015
+q_sat = 118.95 # kg/m³
+c_sat = 212.4 # kg/m³
+K = q_sat/c_sat
+
+# effective diffusivity
+# Moroney et al. 2015; Schwartzberg 1987, 557
+𝒟ₗ = 2.2e-9 # m²/s
+𝒟ₛ = 0.1*𝒟ₗ
+
+# particle size
+# Moroney et al. 2015
+b = 569.45e-6 # m
+
+# density, medium roast
+# Rodrigues et al. 2002, 8
+ρₛ = 314.0 # kg/m³
+
+# dose
+# assumed, 22.5g in 500mL
+mₛ = 0.0225 # kg
+Vₛ = mₛ/ρₛ # m³
+Vₗ = 500e-6 # m³# properties of the water
+# density
+# Poling et al. 2007, 2-103
+MW = 18.015 #kg/kmol
+function ρₗ(T)
+ τ = 1 - T/647.096
+ mol_dens = 17.863 + 58.606*τ^0.35 - 95.396*τ^(2/3) + 213.89*τ - 141.26*τ^(4/3)
+ return mol_dens*MW
+end
+
+# viscosity
+# Poling et al. 2007, 2-432
+μₗ(T) = exp(-52.843 + 3703.6/T + 5.866*log(T) - 5.879e-29*T^10)
+νₗ(T) = μₗ(T)/ρₗ(T)
+
+# brew temperature
+# assumed, 95°C
+Tₗ = 95+273.15 #K
+
+# initial concentration
+c₀ = 0.0 # kg/m³
+
+# final (max) concentration
+c_max = min(q_sat*Vₛ/Vₗ + c₀, c_sat) # kg/m³These are a lot of parameters and I think it is good practice to think about how to organize them into a struct. In this case I define an InfusionBrew struct to store all of the parameters necessary for defining the brew recipe for an infusion brewer.
struct InfusionBrew{T}
+ K::T
+ q_max::T
+ c_max::T
+ Vₗ::T
+ Vₛ::T
+ mₛ::T
+ 𝒟ₛ::T
+ 𝒟ₗ::T
+ b::T
+end brew = InfusionBrew(K,q_sat,c_max,Vₗ,Vₛ,mₛ,𝒟ₛ,𝒟ₗ,b);Pulling together all of the information we have collected about coffee we can build a partial differential equation to describe the brewing process, making the following assumptions:
+We can visualize this set-up with three “phases”, the bulk liquid, a thin film around the coffee particle, and the pseudo-homogeneous solid coffee particle itself. Coffee is extracted from the particles into the thin film and from the thin film into the bulk liquid.
+There are two rates important processes governing the extraction of coffee:
+Starting with (1) the mass flux into the thin film is given by Fick’s first law (in spherical coordinates)
+Of course the concentration in the solid, q, is a function of time (as more is extracted, there less left behind), which is given by Fick’s second law (in spherical coordinates)
+Turning to (2) the mass flux from the thin film into the bulk liquid is given by
+Where c is the concentration in the bulk liquid and cs is the concentration at the surface.
+The change in concentration in the bulk liquid with respect to time can also be written in terms of a mass balance on the liquid phase:
+The solution to this partial differential equation depends upon which of these mass transfer processes, (1) or (2), is dominant.
+The standard approach to solving this problem is to look at the limiting cases, where the Biot number is either very large or very small10
+10 Seader, Henley, and Roper, Separation Process Principles, 663.
11 The intermediate solution is not given in Seader, Henley, and Roper, Separation Process Principles, only a reference: Schwartzberg, Henry G. and R. Y. Chao. 1982. “Solute Diffusivities in Leaching Processes.” Food Technology. 36, no. 2: 73-86, which has not been digitized and is not available from my local library, so I have no idea what it says ¯\_(ツ)_/¯
12 Conduction of Heat in Solids, 240–41.
13 Seader, Henley, and Roper, Separation Process Principles, 663.
I will argue in a very hand-wavy way that the Biot number for mass transfer is likely to be large, and so the model from Carslaw and Jaeger12 is the probably the best model. First let’s start with Biot number for mass transfer, which for this situation is13
+Where Sh is the Sherwood number, defined as
+Defining the Biot number in terms of the Sherwood number might, at first glance, not seem tremendously useful. However, if we suppose the Froessling equation14 for flow past a single sphere applies
+14 Hottel et al., “Heat and Mass Transfer,” 5–69.
with Re the Reynold’s number and Sc the Schmidt number, then we have a correlation for the Biot number as a function of the Reynold’s number.
+Where = 0.1 is assumed from Schwartzberg15 The Schmidt number, Sc, and equilibrium constant, K, can be calculated
15 “Leaching – Organic Materials,” 557.
Sc = νₗ(Tₗ) / 𝒟ₗ = 139.98370415887905
+K = q_sat / c_sat = 0.5600282485875706
+
+Bi = 35.7124842370744 + 51.178588534736114 √Re
+Under this model, any flow with Re > 10.3 corresponds to Bi > 200, which occurs when the velocity is
+Re = 10.3
+
+v = Re*νₗ(Tₗ)/b0.005570341094459917
+that is 5.6mm/s, a velocity so small that it may be achieved through the natural convection occurring within a French press (and especially so in the case of something heated from below like Turkish coffee), but is certainly the case when the French press is stirred.
+Regardless it is unlikely that and thus the simple exponential model is probably not a good fit, we turn instead to the model from Carslaw and Jaeger.16
16 Conduction of Heat in Solids.
In the above I casually disregarded boundary conditions, focusing instead on refining the model. Before we move forward we should take a moment to clarify what the boundary conditions are.
+First off the coffee starts with a set of initial concentrations q0 and c0, usually these would be the max concentration in the solid phase and zero respectively but they don’t have to be. By disregarding the transfer through the thin film we impose another boundary condition: that at r = b the solid-phase concentration is at equilibrium with the concentration in the bulk liquid qr=b = K c
+It might, at first glance, appear that I have lost the thread, Carslaw and Jaeger17 is a book on heat transfer, this is a mass transfer problem. This is an example of the unreasonable effectiveness of treating transport phenomena as a unified subject. By putting the PDE into dimensionless form we find that the PDE for the equivalent heat transfer problem (a solid sphere cooling in a liquid) has already been solved and we can just use that answer.
+17 Carslaw and Jaeger.
First step, to put the PDE in dimensionless form we make the substitutions:
+After which the PDE becomes
+With boundary conditions
+And the mass transfer into the liquid bulk becomes
+With and boundary condition
This is the equivalent PDE (in dimensionless form) to the heat transfer case for a hot solid sphere cooling in a well mixed fluid,18 with the solution
+18 Carslaw and Jaeger, 240–41; Bird, Stewart, and Lightfoot, Transport Phenomena, 379–81.
Where the xk s are the roots of the equation
+(the particular form shown here comes from Schwartzberg19)
+19 “Leaching – Organic Materials”.
The first problem, when actually using this solution, is generating the roots of the equation. The original equation has a repeated singularity and, in my experience, off-the-shelf root finding algorithms have trouble with that and will find spurious zeros in the vicinity of the singularities.
+A better approach is to re-write it in a different way
+This latter form is nice and continuous, with no singularities.
+using IntervalRootFinding
+using Roots
+
+α = Vₗ/(K*Vₛ)
+
+f(x) = tan(x) - 3x/(3+α*x^2)
+g(x) = (3 + α*x^2)*sin(x) - 3x*cos(x)
+
+# find the first 5 roots
+k=5
+xk = find_zeros(g, 0, (k+1/2)*π)6-element Vector{Float64}:
+ 0.0
+ 3.214656575481129
+ 6.321030394109289
+ 9.45018248676156
+ 12.585470558898335
+ 15.723260568418107
+Since α is fixed for a given problem we will end up using the same roots over and over again, so it would be nice to pre-calculate those roots. However, at this point, we don’t know how many we will need to get a reasonable answer. So my approach is to calculate as many as we need dynamically: if we need more roots than have already been calculated, calculate those ones and append them to the list of already calculated roots.
+function getroots(n)
+ if n ≤ length(xk)
+ return xk[2:n]
+ else
+ new_roots = find_zeros(x -> g(x), xk[end], (n+1/2)*π)
+ append!(xk, new_roots)
+ return xk[2:end]
+ end
+endThe standard approach to calculating an infinite series is to use Richardson extrapolation as this accelerates convergence and allows for an error estimate.
+using Richardson:extrapolate
+
+function u_f(τ)
+ val, err = extrapolate(1, x0=Inf) do N
+ xk = getroots(Int(N))
+ 6α*(α+1)*sum( exp.(-τ.*(xk.^2))./((9*(α+1)).+(α.*xk).^2) )
+ end
+ return val
+endNow we can put together a bulk concentration function
+function c(t)
+ τ = (𝒟ₛ*t)/b^2
+ c = (c₀ - c_max)*u_f(τ) + c_max
+ return c
+endExtraction is simply concentration over dose
+extraction(t) = c(t)*Vₗ/mₛAt this point we have enough to put together a struct to contain the parameters needed for the Carslaw and Jaeger model
struct CarslawSolution{T}
+ α::T
+ τ₁::T
+ xk::Vector{T}
+ ib::InfusionBrew{T}
+end
+
+function CarslawSolution(ib::InfusionBrew)
+ α = ib.Vₗ/(ib.K*ib.Vₛ)
+ τ₁ = ib.𝒟ₛ/ib.b^2
+ xk = find_zeros( x -> (3 + α*x^2)*sin(x) - 3x*cos(x) , 0, (10.5)*π)
+ return CarslawSolution(α, τ₁, xk, ib)
+endand update our code to add some methods for calculating the concentration and extraction based on a Carslaw and Jaeger model for the infusion brew.
+function getroots(n, model::CarslawSolution)
+ if n ≤ length(model.xk)
+ return model.xk[2:n]
+ else
+ new_roots = find_zeros(x -> (3 + model.α*x^2)*sin(x) - 3x*cos(x),
+ model.xk[end], (n+1/2)*π)
+ append!(model.xk, new_roots)
+ return model.xk[2:end]
+ end
+end
+
+function c(t, model::CarslawSolution)
+ τ = model.τ₁*t
+ α = model.α
+ u_f, err = extrapolate(1, x0=Inf) do N
+ xk = getroots(Int(N), model)
+ 6α*(α+1)*sum( exp.(-τ.*(xk.^2))./((9*(α+1)).+(α.*xk).^2) )
+ end
+ c = (c₀ - c_max)*u_f + c_max
+ return c
+end
+
+extraction(t, model::CarslawSolution) = c(t, model)*model.ib.Vₗ/model.ib.mₛsol = CarslawSolution(brew);The advantage of packaging code like this is that is now easy to explore the impact of changes to individual parameters, for example below is the impact that changing grind size has on the extraction curve. It follows our general intuition that smaller grind sizes extract faster. It also shows a major weakness of this model: there is only one particle size in the model, which is average over the range of actual particle sizes. This model works well if the grind is quite uniform, however if there is a wide range of particle sizes the actual coffee will be a mix of over extracted coffee (from the small particles) and under extracted coffee (from the large particles).
+I think this shows that making coffee can be an interesting exploration of how one would go about building a mass-transfer model for an extraction operation, and going through the stages of simplifying the model by, for example, assuming simpler geometries, limiting cases and such. I think you could also take this as an example of how very often chasing down appropriate model parameters is the limiting step when building an engineering model (at least in chemical engineering). Often the exact chemical process that you want to model has not been explored, experimentally, over the entire range of your process variables (if at all).
+The next obvious step with this model is to build some datasets and fit some of these models to actual observed extractions. This could be a jumping off point for exploring how changes in different parameters impact the overall extraction or required brew time.
+In anticipation I had ordered an Atmotube PRO as a relatively cheap and portable solution – something I can also hang from my backpack for when I travel. Just due to poor timing on my part, it did not arrive until part-way through the day on Saturday, May 20th, and I couldn’t use it to capture the impact of the smoke arriving as it was well and truly already here. That did not stop me from setting up two experiments, one to measure the rate of infiltration and another to demonstrate (to myself really) the effectiveness of a remedy.
+On Sunday, May 21st, I ran a very simple experiment to measure the ventilation rate in my bedroom (i.e. the rate of air infiltration). I live in an older apartment building with radiant heat, which makes my bedroom somewhat perfect: It has no vents or other connections to adjacent rooms, heat comes only from the radiators which are turned off (it’s summer). My bedroom has an older aluminum frame window, of the sort common with other apartments of the same vintage in my neighbourhood.
+The set-up was quite simple: I ran a portable HEPA filter in my bedroom, with the door closed, to maintain the particulate concentrations at a low level (more on that later) until 2:15pm. At that point I turned off the filter, left the room, and blocked off the gaps beneath the door with a wet towel. I left the atmotube sitting in the middle of the room, passively collecting at 15 minute intervals. A little over 10 hours later I returned and turned the HEPA filter back on, ending the experiment. I waited until Sunday to run the experiment as I had plans that day and knew I would be out of the apartment and thus not be tempted to go in the room for several hours.
+A house on the same block as my apartment building has a purple air outdoor air quality monitor mounted in their yard and the data is available at a 10-minute frequency through the purple air real-time air quality map. Using this and the data from the atmotube, I should be able to fit a simple building infiltration model.
+The purple air monitor can output the raw pm2.5 concentrations as a csv, which is easily imported into julia. As a first step I define when the experiment started such that I can also calculate how much time has elapsed – it is going to be easier to work with a time variable that is just a number starting at 0 when the experiment started than datetime objects. The default units of time, in julia, are milliseconds however the more convenient units for building ventilation are hours and so the time variable here is in hours.
+using CSV, DataFrames, Dates, Pipestart = DateTime(2023,5,21,14,15)2023-05-21T14:15:00
+The purple air monitor has dual particle count sensors, labeled in this dataset as “Purple Air A” and “Purple Air B”, for convenience I take the average of the two as the outdoor concentration.
+using Statistics: mean
+
+outdoor = @pipe "data/22_May_2023_raw-pm25-gm.csv" |>
+ CSV.read( _ , DataFrame, dateformat="yyyy-mm-dd HH:MM:SS") |>
+ transform( _ , AsTable(["Purple Air A", "Purple Air B"]) => ByRow(mean) => :pm25) |>
+ transform( _ , :DateTime => ByRow((x) -> Dates.value(x - start)/(3600*1000)) => :time);The atmotube outputs a whole bunch of stuff in one csv, including temperature, barometric pressure, VOCs, pm1, pm2.5 and pm10, I am only interested in the pm2.5s. That said, the csv has one serious issue: it implements a zero-order hold on data. There are pm2.5 values for every minute however the pm2.5 values are not sampled every minute, the atmotube holds the last value for all the minutes in between measurements. This is a problem as I am fitting a model to this data and I need the actual data at the times it was taken.
+raw_indoor = @pipe "data/C22B42153089_22_May_2023_00_43_32.csv" |>
+ CSV.read( _ , DataFrame; dateformat="yyyy-mm-dd HH:MM:SS") |>
+ sort!( _ , :Date);To retrieve only the actual measured data, and not the filled in rows, I create a new dataframe and walk through the raw data keeping a data point if it differs from the previous one or if more than 15 minutes have elapsed. Rows where the concentration value has not changed, and it has been less than 15 minutes from the last update, are assumed to be filled in rows and not “real”.
+last_good_data = raw_indoor[!, "PM2.5, ug/m3"][1]
+last_good_datetime = raw_indoor[!, :Date][1]
+
+indoor = DataFrame(datetime = DateTime[], meas = Float64[], time = Float64[])
+
+for r in eachrow(raw_indoor)
+ dt = r[:Date]
+ meas = r["PM2.5, ug/m3"]
+ time = Dates.value(dt - start)/(3600*1000)
+
+ if meas != last_good_data
+ last_good_data = meas
+ last_good_datetime = dt
+ push!(indoor, [dt, meas, time])
+ elseif Dates.value(dt - last_good_datetime) > 15*60*1000 # more than 15 minutes
+ last_good_data = meas
+ last_good_datetime = dt
+ push!(indoor, [dt, meas, time])
+ else
+ continue
+ end
+endPlotting the data looks encouraging (as far as fitting a model goes, not encouraging if one wanted to spend time in there breathing) as the particulates appear to be infiltrating with a rate proportional to the difference between the concentrations – the standard building infiltration model.
+The type of fit I am doing is quite simple: I am fitting a differential equation to the indoor concentration while taking the outdoor concentration as a parameter of the model. Thus I need the outdoor concentration as a continuous function of time and, for simplicity, I am using a linear interpolation of the measured outdoor concentration.
+using Interpolations: linear_interpolation, Flat
+
+cₒ = linear_interpolation(outdoor.time, outdoor.pm25, extrapolation_bc=Flat());To start, I define the differential equation that I am going to be fitting to the measured indoor concentration. This is the simple linear model for building infiltration
+Where c is the indoor concentration, co the outdoor concentration, and λ is the ventilation rate in units of h-1.
+using OrdinaryDiffEq
+
+# the model
+f(c, λ, t) = λ*(cₒ(t) - c)
+
+# initial condition
+c0 = indoor.meas[1]
+
+# timespan
+tspan = (0, indoor.time[end])
+
+# parameters
+p= [0.5] #initial guess of λ=0.5
+
+prb = ODEProblem(f, c0, tspan, p)Now I define the fit itself: with the cost function as the L2 loss between the measured indoor concentration and the predicted indoor concentration.
+using DiffEqParamEstim: build_loss_objective, L2Loss
+
+lossfn = L2Loss(indoor.time, indoor.meas)
+
+cost_function = build_loss_objective(prb,Tsit5(),lossfn,
+ maxiters=10000,verbose=false);Then using the Optim package to find the parameter λ which minimizes the cost function.
using Optim: optimize
+
+result = optimize(cost_function, 0.0, 1.0)Results of Optimization Algorithm
+ * Algorithm: Brent's Method
+ * Search Interval: [0.000000, 1.000000]
+ * Minimizer: 7.848374e-02
+ * Minimum: 1.517807e+03
+ * Iterations: 35
+ * Convergence: max(|x - x_upper|, |x - x_lower|) <= 2*(1.5e-08*|x|+2.2e-16): true
+ * Objective Function Calls: 36
+I can then retrieve the ventilation rate for my bedroom
+λfit = result.minimizer[1]0.07848373551388874
+prb = ODEProblem(f, c0, tspan, λfit)
+fit = solve(prb, Tsit5());I think this simple linear model works relatively well, all things considered. A more fulsome model would have treated the ventilation rate as a function of air pressure, windspeed, and the difference between indoor and outdoor temperatures.
+There are also a few weaknesses in the experimental design, beyond the quality of the sensors. For one I didn’t seal my door perfectly, and so there was some exchange with the rest of my apartment which had a much lower particulate concentration. I am also assuming that there is no deposition or adhesion of particulates when passing through the small leaks around my window. It’s possible that some particulates are being lost along the way, which would impact this. The placement of sensors could also be an issue, especially the outdoor ones: I live in a neighbourhood full of large apartment buildings and that creates complex wind patterns, I also live several stories up whereas the purple air monitor is at ground level. A better location would have been on my balcony, adjacent to the bedroom window.
+But I think as a first pass, and especially for screening potential shelter in place locations, something as simple as this could work and the time investment is very minimal. It’s major weakness is that the key variable, the outdoor concentration, is not controlled and this whole exercise is dependent upon the whims of wildfire smoke and on the individuals responsiveness to smoke forecasts.
+After all that time measuring how rapidly the particulates infiltrated a room, what is to be done? The air quality in Edmonton has been poor for several days on end. Without any sort of mitigation my bedroom would be well above the limits and would be unhealthly to be in and yet that’s where I sleep. The solution is either installing furnace air filters with a high MERV rating or, in places like mine that lack central air, using a portable fan with a HEPA filter. I picked up a portable air filter from IKEA and similar ones are available from many places, and can be made by hand. Unfortunately, dangerously high levels of pm2.5s are invisible and generally undetectable to one’s senses, so without some sort of monitoring one is left merely trusting that the system is doing what it is supposed to be doing.
+I tested my IKEA unit on Saturday night in a similar manner to the building infiltration test: I turned off the unit, closed my bedroom door, and left the space to accumulate particulates for several hours. Then, before I went to bed, I went in and turned it on. Throughout this the atmotube was located in the middle of the room collecting data. From the plot below it is clear that the air filter works: the indoor particulate concentration drops rapidly and stays at a low level throughout the night, even as the outdoor concentration rises to very high levels.
+indoor_hepa = @pipe "data/C22B42153089_21_May_2023_10_05_00.csv" |>
+ CSV.read( _ , DataFrame; dateformat="yyyy-mm-dd HH:MM:SS");
+
+outdoor_hepa = @pipe "data/21_May_2023_raw-pm25-gm.csv" |>
+ CSV.read( _ , DataFrame, dateformat="yyyy-mm-dd HH:MM:SS") |>
+ transform( _ , AsTable(["Purple Air A", "Purple Air B"]) => ByRow(mean) => :pm25);Using wildfire smoke to measure the ventilation rates of different buildings, or rooms in buildings, is certainly a niche activity. I don’t imagine there are many places where it is important to screen for safe shelter-in-place locations that also experience significant wildfire smoke events regularly. That does describe the petrochemical industry in Alberta, wildfire smoke is a regular occurrence now, and perhaps locations along the west coast, but it’s not universal.
+That said I wonder if this might be a more broadly useful activity when planning for how to manage indoor air quality beyond industry. The office building I work in struggles with indoor air quality during smoke events like this one whereas my home office does not because I have invested in HEPA filters and simple air monitoring. Perhaps schools, offices, and other places could use similar techniques to screen spaces for interventions. Rooms with high ventilation rates could benefit from interventions such as better sealing around windows. Perhaps the plastic sheeting used to seal drafty windows in the wintertime could find a second use during wildfire season. In this way the air filters themselves are being used more effectively: an air filter can manage a larger space if that space has a low ventilation rate.
+Currently a lot of the advice is merely to stay indoors, with little acknowledgment that indoors is often severely polluted as well.
+ + +As a re-cap the Britter-McQuaid model1 is a series of correlations for the dispersion of denser than air gases. These are given as a series of correlation curves and the typical procedure is to interpolate the downwind distance to the concentration of interest, for example to the Lower Flammability Limit (LFL). The model also gives some equations for estimating the plume horizontal and vertical dimensions, where conventional practice is to assume the plume has a rectangular cross-section and a uniform concentration.
+1 Britter and McQuaid, “Workbook on the Dispersion of Dense Gases”.
Just to have some numbers to look at, I am going to use a scenario adapted from the Burro series of trials of LNG dispersion.2 The release conditions are:
+2 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases., 122.
The goal is to find the distance to the lower flammability limit (LFL) which is 5%(v/v) and ultimately work out the extent of the plume and total explosive mass.
+using Unitful
+
+Tₐ = 288.15u"K" # ambient air temperature
+ρₐ = 1.225u"kg/m^3" # density of air at 15°C and 1atm
+u₁₀ = 10.9u"m/s" # windspeed at 10m
+
+ρₗ = 425.6u"kg/m^3" # liquid density of LNG, given
+ρᵥ = 1.76u"kg/m^3" # vapour density of LNG, given
+ṁ = ρₗ*0.23u"m^3/s" # mass release rate
+Tᵣ = (273.15 - 162)u"K" # boiling point of LNG, given
+LFL = 0.05 # lower flammability limit, volume fraction
+
+Qₒ = ṁ/ρᵥ # gas volumetric flowrate: mass flowrate divided by gas densityFirst calculate the critical length, D, and the dimensionless parameter α for the model
+D = √(Qₒ/u₁₀)
+
+g = 9.806u"m/s^2"
+gₒ = g * (ρᵥ - ρₐ )/ ρₐ
+α = 0.2*log10(gₒ^2 * Qₒ / u₁₀^5)Then, using digitized curves,3 work out the points for the linear interpolation in terms of
3 AIChE/CCPS, 118.
Cs = [ 0.1, 0.05, 0.02, 0.01, 0.005, 0.002]
+βs = [ 0.24*α+1.88, 0.36*α+2.16, 0.45*α+2.39, 0.49*α+2.59, 0.59*α+2.80, 0.39*α+2.87]These points only cover the middle region of the concentration curve, where the concentration ratio, , is between 0.1 and 0.002, there is a near-field correlation that needs to be connected for concentration ratios >0.1
function Cm_nf(x′)
+ if x′ > 0
+ return 306/(306 + x′^2)
+ else
+ return 1.0
+ end
+end
+
+xnf = 30
+βnf = log10(xnf)
+Cnf = Cm_nf(xnf)And a far field correlation for when the concentration ratio is <0.002 which is basically just continuing the curve from the last point but such that the concentration decays with 1/x2
+xff = 10^(maximum(βs))
+A = minimum(Cs)*xff^2
+
+function Cm_ff(x′; A=A)
+ return A/x′^2
+endFinally, putting together the pieces: near field correlation, a linear interpolation for the middle of the concentration curve, and a far field correlation, to form the complete concentration function, along with a correction for non-isothermal releases (of which this is an example)
+using Interpolations
+
+itp = interpolate( ([βnf; βs],), [Cnf; Cs], Gridded(Linear()) )function Cm(x::Quantity; xnf=xnf, xff=xff, D=D, T′=Tᵣ/Tₐ)
+ x′ = x/D
+ c′ = if x′ < xnf
+ Cm_nf(x′)
+ elseif xnf ≤ x′ < xff
+ itp(log10(x′))
+ else
+ Cm_ff(x′)
+ end
+
+ c = c′ / (c′ + (1 - c′)*T′)
+
+ return c
+endIf all one needs is the distance to the LFL there is an easier way of doing this: interpolate the concentrations to find the β corresponding to the LFL (after applying the non-isothermal correction). However, if one also requires the plume dimensions the concentration profile is required.
+From the concentration profile calculating the downwind distance to the LFL is very straight-forward.
+using Roots
+
+xn = find_zero((x) -> Cm(x) - LFL, (300,400).*1u"m", Roots.Brent())354.5630187009715 m
+At first glance the workbook seems to be giving the user everything they need to workout the size of the plume, giving the following diagram
+
+4 Britter and McQuaid, “Workbook on the Dispersion of Dense Gases,” fig. 10.
and the following relations for the labeled distances
+with the buoyancy scale lb defined as
lb = (gₒ*Qₒ)/u₁₀^30.18392758812310803 m
+Lᵤ = D/2 + 2lb1.4973003373658906 m
+Lₕₒ = D + 8lb3.7303110272242135 m
+Lₕ(x) = Lₕₒ + 2.5∛(lb*x^2)The curve given for LH for x > 0 is not the curve for x < 0, the upwind extent of the plume. This is the blue curve in the figure below. The orange curve is slightly adjusting LH such that for x < 0 the second term is subtracted (so the curve actually converges to zero instead of blowing up to +∞ as x → -∞). The black dots are points taken from the diagram given by Britter and McQuaid, using a graph digitizer and scaling to the actual LHo and LU. Clearly the given curve for LH is not at all what is shown in the diagram for the upwind region.
+A conservative approach to estimating the size of the upwind extent is to assume LH = LHo for LU < x < 0, i.e. making the upwind region a rectangle of width LHo and length LU.5 This is the green curve in the figure below.
+5 Bakkum and Duijm., “Vapour Cloud Dispersion”.
Alternatively one could “fit” a curve to hit the end points while also having the same power of x: where LU < x < 0, this at least retains the same general shape and is the red curve in the figure below. I think this should be taken with the giant caveat that I don’t know if insisting on the same power law is truly justified.
For most typical cases I would think the upwind region would be a small component of the overall plume and taking the conservative, rectangle, approach would be a small error.
+The vertical extent is not given on the diagram, but an equation is given in the text, with the note that this comes from continuity, however I think this is incorrect.
+Suppose a steady state plume with a system boundary such that the plume is sliced along the y-z plane at some downwind distance x. All of the mass entering the plume, from the source, exits the plume through this plane
+
Consider the steady state mass balance
+By the nature of a top-hat model the plume cross section is a rectangle with half-width LH and height LV and the concentration everywhere inside the rectangle is cm. Assuming a constant advection velocity, u, the integral can be simplified to
+The steady state mass balance is then
+and the vertical extent can be solved for with some simple re-arrangement
+Setting the advection velocity of the plume to the reference windspeed gives
+Lᵥ(x) = D^2/(2*Cm(x)*Lₕ(x))This is definitely similar to what is given by Britter and McQuaid but with two big differences:
+The last point could equally be a mistake in the diagram (I have no real way of checking) as while the diagram shows LH as the plume half-width, the text simply refers to it as the “lateral plume extent”, which is ambiguous – do they mean the entire lateral extent or from the center-line of the plume?
+The TNO Yellow Book gives a different equation6 for the vertical extent:
+6 Bakkum and Duijm. equation 4.104.
Which clearly follows from assuming LH is the half-width, and the corresponding figure is labeled as such (using the same equation for LH as Britter and McQuaid). But it doesn’t depend upon concentration.
+I think the vertical extent has to depend upon the concentration as otherwise mass will simply disappear from the plume as it extends downwind. There is also the obvious problem that since the plume lateral extent monotonically increases, and the vertical extent is inversely related to it, the vertical extent is monotonically decreasing. In fact it becomes vanishingly small quite quickly. This entirely the opposite of what is observed with actual dense plume dispersion.
+This can be seen most clearly in the following figure in which the vertical extent is shown as a function of downwind distance along with the mass flowrate in the plume (i.e. )
I think it is fairly obvious that both the Britter-McQuaid and TNO models give silly answers for the vertical extent. Though the corrected curve, the green curve, clearly has problems too: it has an odd bumpiness, as a result of the linear interpolation, and it is also too small due to both assuming the concentration everywhere is equal to the ground level concentration and due to an overly large advection velocity (the windspeed at 10m is quite a bit higher than the windspeed at ~1m).
+An alternative approach to using the reference windspeed as the advection velocity is to assume the advection velocity is some constant fraction of the reference velocity, e.g. , which is what Britter and McQuaid use for the instantaneous model.
Another alternative might be to use an average windspeed, ū over cross-section of the plume as the advection velocity, assuming windspeed is only a function of height.
+Assuming the windspeed follows a powerlaw distribution gives
plugging it into the simple mass balance
+re-arranging to solve for LV
+The red curve in the figure above is this model, using p = 0.15.7
+7 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases., 83.
This could also be done using the logarithmic windspeed curve where
is the friction velocity and z0 is the roughness length. Though I don’t imagine the expression would work out as nicely.
For the upwind region, assuming a simple rectangular prism with length LU, width 2LHo, height LVo and uniform concentration co is a conservative approach. Likely the plume downwind of the source will be much larger than the upwind area and so this will be a small overestimate.
+The simple mass balance approach to calculating the plume height is a reasonable approach if one simply wants to reference Britter and McQuaid and not have to justify additional assumptions. It is not what is given in the text, but it is what is described in the text. The other models for plume height may be more realistic, in the sense that they represent more realistic advection velocities, and will give larger explosive masses for the plume, however they have not been validated against any actual data. That validation may be a worthwhile exercise but is well beyond the scope of this blog post.
+The explosive mass in the cloud is the given by the volume integral
+where V is defined as the region where c ≥ LFL.8
+8 Some sources recommend 1/2 LFL.
Using the concentration profile and the plume extents, we could work out the function c(x,y,z) such that the concentration is returned if we are:
To determine the explosive mass in the downwind region this might be done by the following
+
+cₒ = ustrip(u"kg/m^3", ṁ/Qₒ)
+
+Lₕ(x::Number) = ustrip(u"m", Lₕ(x*1u"m"))
+Lᵥ(x::Number) = ustrip(u"m", D)^2/(2*Cm(x)*Lₕ(x))
+
+function c(x,y,z; lim=LFL)
+ c_ = Cm(x)
+
+ if c_ ≥ lim
+ if (abs(y) ≤ Lₕ(x)) && (z ≤ Lᵥ(x))
+ return cₒ*c_
+ else
+ return 0.0
+ end
+ else
+ return 0.0
+ end
+end
+using HCubature: hcubature
+
+x_min, x_max = 0, xn
+y_min, y_max = -Lₕ(xn), Lₕ(xn)
+z_min, z_max = 0, Lᵥ(xn)
+
+m_e, err = hcubature( c, [x_min, y_min, z_min], [x_max, y_max, z_max])This is a pretty tedious integration, is very inefficient, and doesn’t take into account any of the structure of the model and it turns out that a top-hat model has some pretty convenient structure.
+Returning to the integral for the explosive mass, the plume can be divided into an upwind region (x < 0) and a downwind region (x ≥ 0)
+with the explosive mass of the downwind region being
+For a top-hat model, since the concentration at a given downwind distance is constant everywhere within the plume cross-section , and, from a mass balance on the plume
which is a constant, thus
+For the explosive mass of the upwind region a simple box model gives . Putting everything together9
9 This is not specific to the Britter-McQuaid model, it works for any top hat model.
This can be simplified greatly by setting the advection velocity to uref
+cₒ = ṁ/Qₒ
+
+mₑ = cₒ*D^2*(Lᵤ+xn)3197.617661470163 kg
+This very simple expression is the obvious strength of a top-hat model: it makes calculating the explosive mass incredibly easy.10 It also retroactively justifies why the Britter McQuaid model is oriented around calculating xn: that’s all you actually need.11
+10 Woodward, Estimating the Flammable Mass of a Vapour Cloud.
11 Some sources recommend calculating the explosive mass as the region of the plume with the concentration LFL ≤ c ≤ UFL, in which case
If this seems too good to be true, the integration can be performed numerically by taking
+using QuadGK: quadgk
+
+function ∫∫cdA(x)
+ if Cm(x) ≥ LFL
+ return cₒ*Cm(x)*(2Lₕ(x))*Lᵥ(x)
+ else
+ return 0.0u"kg/m"
+ end
+end
+
+m_ed, err = quadgk(∫∫cdA, 0u"m", xn)
+
+m_eu = 2*cₒ*Lᵤ*Lₕₒ*Lᵥ(0u"m")
+
+m_eu + m_ed3197.617661470163 kg
+Which is exactly the same.
+Above I claimed the upwind region was “small” relative to the downwind region, this can be shown easily as the mass in each region is directly proportional to the length.
+Lᵤ/(Lᵤ+xn)0.004205187316042018
+Since the mass in the upwind region is <0.5% of the total mass in the cloud, I think the simple box model is justified.
+According to Britter and McQuaid the top-hat model generates an overly conservative plume extent and they recommend using given the lateral extent curve up to 2/3 xn and after which connecting to xn using straight lines, as shown in the plume diagram. This makes the integration for explosive mass a little more complicated.
+For simplicity the plume can be divided into three regions, the upwind region (x < 0), the downwind region up to the cutoff (0 ≤ x < 2/3 xn), and the downwind cutoff region (2/3 xn ≤ x < xn )
+The upwind region, me,u, and the first downwind region me,d1 are already known, they are the same as above up to 2/3 xn. What is left to determine is the explosive mass in the cutoff region.
+The integral can be re-written to take advantage of cmA being an invariant for a top-hat model,
+Assuming the vertical extent remains unchanged in this operation, the ratio of areas is the same as the ratio of horizontal extents
+From some simple geometry, the horizontal extent is
+Which then leads to
+There is probably a closed form for this integral but it is just as easy to integrate that numerically.
+mₑ_cutoff = cₒ*D^2*(Lᵤ + (2/3)*xn
+ + 3*Lₕ((2/3)*xn)*quadgk( (x) -> (xn - x)/(xn*Lₕ(x)), (2/3)*xn, xn)[1] )2620.489605856347 kg
+This works out to be about 20% less than the original explosive mass.
+mₑ_cutoff/mₑ0.8195131136008078
+I think the error in the vertical extent may have limited the apparent utility of the Britter-McQuaid model. Most references I have do use the Britter-McQuaid model, noting that it is “reasonably simple to apply, and produces results which appear to be as good as more sophisticated models”,12 however they either claim that it is only good for calculating xn or gloss over how it could be used for anything else. The CCPS references seem consistent in neglecting to mention at all that the model can also estimate the plume extent. So, while I can’t imagine I’m the first person to have noticed that the given equation for LV doesn’t work, I have yet to encounter anyone actually admitting it.
+12 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases., 122.
13 Lees, Loss Prevention in the Process Industries; Casal, Evaluation of the Effects of Consequences of Major Accidents in Industrial Plants.
14 Bakkum and Duijm., “Vapour Cloud Dispersion”.
That said, the correction also seems obvious to me: one simply follows what is described in the text which is exactly how Britter and McQuaid calculated the cloud height for the instantaneous model (which is correct) in the same workbook. That the incorrect equation for LV is repeated in other references,13 with only the TNO Yellow Book14 making a correction, while still repeating a critical mistake, strikes me as very odd.
+The Britter-McQuaid model would seem to be the perfect fit for screening models, which are often only order of magnitude estimates at best anyways. It gives reasonable concentrations, plausible plume extents, and the explosive mass is ridiculously easy to calculate (slightly more tedious if you are using the 2/3 cut-off region but nothing that couldn’t be worked out in advance if this was going to be incorporated into a routine calculation tool).
+To re-cap on what a Gaussian puff model even is: for a short duration release (strictly an instantaneous release) of a neutrally buoyant substance at ground-level, the concentration can be modeled as the product of three Gaussian distributions:
+where
+Where is the mass emission rate, Δt the duration of the release, and u the ambient windspeed. The coordinates are such that the release point is at the origin, the puff moves in the downwind, x, direction while spreading into the crosswind, y, and vertical, z, directions.
The dispersion parameters, σx, σy, σz are all functions of the downwind distance and the atmospheric stability.
+# class F puff dispersion
+# x is in meters
+σx(x) = 0.024*x^0.89
+σy(x) = σx(x)
+σz(x) = 0.05*x^0.61What this generates is an instantaneous release of all of the mass in an infinitesimal point that grows as it moves downwind. This isn’t terribly realistic for releases of any appreciable duration (all of the mass is released instantly in this model), so a common approach is to break up the release into a sequence of n smaller puffs that each capture the mass released over the sub-interval . Taking the limit as
equates to integrating the puff model from t - Δt to t giving a nice solution in terms of the error function erf and … this is where I made the critical mistake.
The dispersion parameters are functions of the downwind distance, but critically..to what? Taken as the downwind distance to the point being calculated, the dispersion parameters are constants (with respect to time) and the problem simplifies to integrating the Gaussian with respect to t, which is what I had assumed. However if the dispersion parameters are actually correlated to the downwind distance of the cloud center, which is
, they are in fact functions of time and this does not work.
This distinction is by no means made obvious in many of the references for chemical hazard analysis. Most are either vague about it or take the dispersion parameters at the downwind distance of the point being calculated. My main reference is the CCPS Guidelines for Consequence Analysis of Chemical Releases and it does this.1 As do several workbooks I have seen. However Lees2 notes that the dispersion parameters for the Pasquill-Gifford puff model (which this is) are given by3
+1 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases., 107–8.
2 Lees, Loss Prevention in the Process Industries.
3 Lees, 15/112.
where C and n are some constants from Sutton, and in general the dispersion correlations are functions of travel time with a lot of discussion in the literature of to what power. The standard correlations for the dispersion parameters come from Slade4 which gives some details on how the measurements were actually taken. It certainly seems to me that the downwind distance was to the cloud center, i.e. the experimenters measured the cloud dimensions at the downwind point to which it had traveled. Which makes the travel time and windspeed implicit.
+4 Slade, Meteorology and Atomic Energy, 117–89.
I think it is a reasonable confusion as the dispersion parameters for a continuous release, a Gaussian plume model, are indeed functions of the downwind distance to the point being calculated. It is also frequently the case that examples are given for the concentration at the cloud center, in which case the downwind distance at the point being calculated is the downwind distance to the cloud center.
+How critical of a mistake is this? For regions far enough from the origin the dispersion parameters do not vary much in the neighborhood of the plume center. This is shown in the plot below where the difference is taken over the interval . At distances further than a few hundred meters the difference is only a few percent. Suggesting that it might not be an unreasonable approximation to assume the dispersion parameters are constants for the purpose of the integral.
Another way of approaching this is simply to view it as an approximation instead of an error. On the one hand this is a pretty great rhetorical trick: my answer isn’t wrong, it’s just differently true. But it could be the case that this is a useful simplification, just by eye-balling isopleths and looking at limiting behavior in the previous notebook it certainly looked reasonable.
+To make life easier, going forward, I am going to define a unit-less time
and unit-less distances
+where I am abusing notation with the indicates the variable with units, and no
indicates it is unitless. A characteristic length,
, is introduced to make everything unitless and, due to the dispersion correlations
is the most convenient.
We can then explore the performance of different approximations to the integrated puff model by only examining the Gaussian distributions – with no dependence upon or u.
g(ξ,σ) = exp(-0.5*(ξ/σ)^2)/(√(2π)*σ)
+
+gx(x, t) = g((x-t),σx(t))
+gy(y, t) = g(y,σy(t))
+gz(z, t) = 2*g(z,σz(t))
+
+pf(x,y,z,t; Δt) = gx(x,t)*gy(y,t)*gz(z,t)*ΔtThe first type of approximation is to divide the release interval into n sub-intervals and n Gaussian puffs
+function Σpf(x,y,z,t; Δt, n)
+ Δt = min(t,Δt)
+ δt = Δt/(n-1)
+ _sum = 0
+ for i in 0:(n-1)
+ t′ = t-i*δt
+ pf_i = t′>0 ? gx(x,t′)*gy(y,t′)*gz(z,t′)*δt : 0
+ _sum += pf_i
+ end
+ return _sum
+endThe next type of approximation is the one I made in the previous post wherein is integrated with respect to time, treating the σs as constants.
There is a little sleight of hand as I include the downwind distance dependence of the σs after the integration (they aren’t actually constants)
+using SpecialFunctions: erf
+
+function ∫gx(x,t,Δt)
+ Δt = min(t,Δt)
+ a = (x-(t-Δt))/(√2*σx(t-Δt))
+ b = (x-t)/(√2*σx(t))
+ return erf(b,a)/2
+end
+
+∫pf_approx(x,y,z,t; Δt) = ∫gx(x,t,Δt)*gy(y,x)*gz(z,x)Finally, I take advantage of the QuadGK package to numerically integrate the Gaussian puff model, including the time dependence of the dispersion parameters.
using QuadGK: quadgk
+
+function ∫pf(x,y,z,t; Δt)
+ Δt = min(t,Δt)
+ integral, err = quadgk(τ -> gx(x,τ)*gy(y,τ)*gz(z,τ), t-Δt, t)
+ return integral
+endTo give a sense of how these successive approximations work, lets examine a series of slices through the cloud. The first is at a constant x on the center-line of the release, looking at how the concentration changes with time.
+Just by eye-ball the the approximate integral is very close to the numerical exact(ish) integral, as is a large enough number of puffs. Importantly, I think, the approximate integral error is of the same order of magnitude as a large number of puffs – so this is at least as good in a sense as the discrete sum of puffs method, given that we can vary the number of puffs to always make it a better/worse approximation
+In the crosswind and vertical directions the sum of discrete puffs approximation works decidedly less well, at least at this slice in the cloud, while the approximate integral still works relatively well. I would say it is still at least as good as a sum of discrete puffs for a suitably large number of puffs.
+This is, of course, very particular to that point downwind of the release. As we move closer to the origin the integral approximation gets worse, but then so does the sum of discrete puffs model. Especially for a low number of puffs: they become visibly discrete. I think this reinforces that, at least for class F stability, this approximation is in the same ball park as summing over a set discrete Gaussian puffs.
+Model error is not the only factor in deciding upon an approximation. Since QuadGK exists we have to ask ourselves, why would we not always use it? We can answer that by benchmarking the three approaches at a particular point of interest (I don’t think the choice of point impacts the calculations at all)
using BenchmarkTools: @benchmark
+
+# point of interest
+x₁ = 100
+y₁ = σy(x₁)
+z₁ = σz(x₁)
+t₁ = x₁Starting with the full numerical integration of the model, this is the time to beat. Any approximation that takes longer than ~30μs is literally pointless: it generates worse results and takes longer.
+@benchmark ∫pf(x₁,y₁,z₁,t₁; Δt=10)BenchmarkTools.Trial: 10000 samples with 1 evaluation. + Range (min … max): 28.056 μs … 57.603 μs ┊ GC (min … max): 0.00% … 0.00% + Time (median): 28.200 μs ┊ GC (median): 0.00% + Time (mean ± σ): 28.570 μs ± 1.565 μs ┊ GC (mean ± σ): 0.00% ± 0.00% + █▆▁ ▃▃ ▁▂▁▁ ▁ + ███▇▅██▇▅▄█████▆▆▆▇▆█▇▆▄▃▁▄▄▄▃▆▅▄▄▃▁▃▁▃▁▁▁▁▁▁▁▁▁▃▁▁▁▁▁▄▅▆▇▆ █ + 28.1 μs Histogram: log(frequency) by time 37.7 μs < + Memory estimate: 384 bytes, allocs estimate: 3.+
As we expect, the sequence of discrete puffs is much faster for fewer puffs, and adding an order of magnitude more puffs increases the time by an order of magnitude. At around n=100 we are no longer gaining anything over the full numerical integration. So, if the near-field matters a lot to you, then this is probably not a great approximation as the number of puffs required to approximate the full numerical integration well takes longer than just doing the integration.
+@benchmark Σpf(x₁,y₁,z₁,t₁; Δt=10, n=10)BenchmarkTools.Trial: 10000 samples with 8 evaluations. + Range (min … max): 3.746 μs … 11.498 μs ┊ GC (min … max): 0.00% … 0.00% + Time (median): 3.768 μs ┊ GC (median): 0.00% + Time (mean ± σ): 3.920 μs ± 450.991 ns ┊ GC (mean ± σ): 0.00% ± 0.00% + █ ▅ ▅▂▁▁▂ ▁ + ██▄█▇▅██████▇▆▄▄▄▃▅▄▄▂▄▄▅▃▄▅▅▆▇▇▆▆▇▇▆▆▅▅▅▅▆▅▅▅▅▄▃▅▅▄▅▅▄▄▄▂▃ █ + 3.75 μs Histogram: log(frequency) by time 5.97 μs < + Memory estimate: 16 bytes, allocs estimate: 1.+
@benchmark Σpf(x₁,y₁,z₁,t₁; Δt=10, n=100)BenchmarkTools.Trial: 10000 samples with 1 evaluation. + Range (min … max): 37.090 μs … 77.023 μs ┊ GC (min … max): 0.00% … 0.00% + Time (median): 37.199 μs ┊ GC (median): 0.00% + Time (mean ± σ): 37.760 μs ± 2.411 μs ┊ GC (mean ± σ): 0.00% ± 0.00% + █ ▂▁ ▃ ▁ + █▇▁▁▁██▃▃▁▃█▇▇▆▄▄▇█▅▄▄▄▄▄▄▃▄▄▃▁▅▁▄▃▁▁▃▃▃▃▄▇▇█▇▇▆▄▅▅▅▆▄▄▆▄▄▄ █ + 37.1 μs Histogram: log(frequency) by time 49.3 μs < + Memory estimate: 16 bytes, allocs estimate: 1.+
Finally we have the integral approximation. This takes ~1/50th the time as the full numerical integration and, by the results above, it potentially performs just as well as the discrete puff approximation. In the examples above it was doing as well as discrete puff approximations that are too large to be worthwhile.
+@benchmark ∫pf_approx(x₁,y₁,z₁,t₁; Δt=10)BenchmarkTools.Trial: 10000 samples with 189 evaluations. + Range (min … max): 534.974 ns … 988.852 ns ┊ GC (min … max): 0.00% … 0.00% + Time (median): 547.606 ns ┊ GC (median): 0.00% + Time (mean ± σ): 560.844 ns ± 40.302 ns ┊ GC (mean ± σ): 0.00% ± 0.00% + ▇▃▅█▅▅ ▅▃▄ ▃▄▁▁▁▁▁ ▂ + ██████▇█████████████▇█▇▇▆▆▆▆▅▇▇▆▅▆▇▆▆▅▅▅▅▄▄▆▅▅▅▆▆▄▅▅▂▅▄▅▄▃▅▅▅ █ + 535 ns Histogram: log(frequency) by time 770 ns < + Memory estimate: 16 bytes, allocs estimate: 1.+
I also have put no effort into optimizing any of this code, so take this with a grain of salt. Like the examination of the model error this is hardly rigorous, it is more suggestive than anything. It is possible that one could dramatically improve the discrete puff model, or re-write how the models are calculated to be more performant than I have. I prefer to write code that is easy for me to read, and re-uses things, but that does not necessarily translate into fast.
+I think it’s worth noting that calculations that take on the order of tens of microseconds, on my crappy old laptop, are fast. To make the various plots required calculating the concentration at hundreds of points and my laptop did it all in the blink of an eye. I would say the first choice, all things being equal, would be simply to use the QuadGK model and call it a day. In terms of lines of code it is certainly short, all the heavy lifting is being done by the library. It also best captures what you are trying to achieve.
If you are doing a huge number of calculations, and can tolerate some model error, then the integral approximation is a good choice. It is the fastest and can perform as well as the discrete puff model. That said, there is an elephant in the room: The two integral approaches strictly require that all of the puffs are moving along the same line, at the same speed. For a great many chemical release scenarios that is entirely the set of assumptions being made, so it works great. However, for more complex atmospheric conditions – with variable windspeed and direction – then they don’t work at all. Or, at least, it is not obvious to me how to adapt them to work. A slightly tweaked discrete puff model, tracking each puff’s individual center location and windspeed, would be quite easy to implement, giving a more flexible model overall. This is in fact the how several more complicated atmospheric dispersion modeling tools work.
+Very generally DMD is an approach to system identification problems that is well suited for high dimensional data and systems with coherent spatio-temporal structures. In particular DMD finds the “best fit” linear approximation to the dynamical system, i.e. it finds the matrix A such that
+Where x is the high dimensional state vector for the system. One key strength of DMD is that it allows one to calculate x(t) without explicitly calculating A. This may not seem like a particularly useful property on its face unless one notes that the matrix A is n×n and, for systems with a very large n (i.e. very high dimensionality) that can be huge. Context for huge is also important: a matrix that fits easily in memory on my laptop may be infeasibly huge for an embedded system. For control applications, such as MPC, DMD may be a good method for generating approximations that are both good and space efficient.
+As a motivating example, I am going to use the flow past a cylinder dataset from Data-Driven Science and Engineering, specifically the matlab dataset. This dataset is the simulated vorticity for fluid flow past a cylinder. The vector x in this case is the vorticity at every point in the discretized flow field at a particular time; a two dimensional array of 89,351 pixels reshaped into a column vector. The data is a sequence of equally spaced snapshots of the flow field, and ultimately we wish to generate a linear system that best approximates this.
+The MAT package allows us to import data from matlab data files directly into julia
using MAT
+
+file = matopen("data/CYLINDER_ALL.mat")
+
+# import the data set
+data = read(file, "VORTALL");
+
+# the orinal dimensions of each snapshot
+nx = Int(read(file, "nx"))
+ny = Int(read(file, "ny"))
+
+# the final dimensions of the data matrix
+n, m = size(data)(89351, 151)
+The data set, data, has already been processed into the form we need: each column represents a “frame” of the animation. We can walk through the matrix, taking each column and re-shaping it back into a 2D array, and recover the original flow as a movie.
+The data set has the property that the number of data points at each time step, n, is much greater than the number of time steps, m. In fact n is large enough that the n×n matrix A might be unwieldy to store: If we assume it is a dense matrix of 64-bit floats, 8 bytes each, we would need ~64GB of memory just to store it.
+size_A_naive = n*n*863868809608
+DMD provides us a method to both find a best fit approximation for A while also being more space (and computation) efficient. To get there we first need to define what a best fit means.
+Consider the general linear system Y = AX, where Y is a n × m matrix of outputs, X is a n × m matrix of inputs and A is an n × n linear transformation matrix. We say that the best fit matrix A is the matrix that minimizes
+where is the Frobenius norm.
The solution to which is
+where X† is the Moore-Penrose pseudoinverse of X.1
+1 I think this can be shown fairly easily by starting with the definition of the Frobenius norm and finding the matrix A that minimizes that using standard matrix calculus, and some properties of the pseudoinverse.
The conventional way of calculating the Moore-Penrose pseudoinverse is to use the Singular Value Decomposition: for a matrix X with SVD , the pseudoinverse is
. Returning to the best fit matrix A we find
We can calculate a projection of A onto the space of the upper singular vectors U
+Which then allows us to reconstruct the matrix A on demand while only needing to store the matrices à and U, by the following
This is useful when n > > m as U is n×m and à is m×m. For this example this has reduced the memory requirement to ~108MB, a >99.8% reduction
+size_A_exact = (n*m + m*m)*8108118416
+size_A_exact/size_A_naive0.0016928202774340957
+Returning to the original problem, we have a sequence of discrete snapshots arranged in a matrix such that each column, k, is the vector xk. Our aim, then, is to find the best fit matrix A for the linear system
+for all xk in our data set. Or in other words, to find the best fit matrix A for the system
+where X1 is the matrix of all of the vectors xk and X2 is the matrix of the corresponding xk+1’s.
+Though, using DMD, we will instead calculate à and U, leaving us with
+To start, we divide the data set into X1 and X2
+using LinearAlgebra# dividing into past and future states
+X₁ = data[:, 1:end-1];
+X₂ = data[:, 2:end];Then compute the SVD of X1.2
+2 The svd function in julia returns the singular values in a Vector, but for later on it will be more convenient have this as a Diagonal matrix.
# SVD
+U, Σ, V = svd(X₁)
+Σ = Diagonal(Σ);Then calculate the projection à (I am pre-computing YVΣ-1 as that will come in handy later)
+# projection
+YVΣ⁻¹ = X₂*V*Σ^-1
+Ã = U'*YVΣ⁻¹
+
+size(Ã)(150, 150)
+We can then calculate the predicted xk+1’s, without ever having to actually compute (or store) A
+X̂₂_exact = (U*(Ã*(U'*X₁)));As before, we can step through the matrix, extract each frame of the 2D flow field, and animate them, giving us a general sense of how well this worked
+
+Of course this only solves the problem in the discrete case (for control applications that may be all you need). Consider again the system , the solution to this differential equation is
where x0 is the initial conditions. If the matrix A has eigendecomposition ΦΛΦ-1 then this can be written as
+So it would be very convenient if we could get those eigenvalues and eigenvectors, preferably without having to actually compute A.
+Recall, by definition, the projection matrix à is unitarily similar to A, which means the eigenvalues are identical. The eigenvectors of A can also be recovered from properties of Ã: Suppose à has the eigendecomposition WΛW-1
+where
+This is what is given in the original DMD, however more recent work recommends using
+# calculate eigenvectors and eigenvalues
+# of projection Ã
+Λ, W = eigen(Ã)
+
+# reconstruct eigenvectors of A
+Φ = YVΣ⁻¹*W;Whether or not the ultimate goal is to generate the continuous system, the eigenvectors and eigenvalues are useful to examine as they represent the dynamic modes of the system.
+I’ve played somewhat fast and loose with variables: the A for the discrete system is not the same A as the continuous system. Specifically the eigenvalues of the continuous system, ω are related to the eigenvalues of the discrete system, λ by the following
+where Δt is the time step. The eigenvectors are the same, though. So we can generate a function x(t) pretty easily:
+# calculate the eigenvalues for
+# the continuous system
+Δt = 1
+Ω = Diagonal(log.(Λ)./Δt)
+
+# precomputing this
+Φ⁻¹x₀ = Φ\X₁[:,1]
+
+# continuous system
+x̂(t) = real( Φ*exp(Ω .* t)*Φ⁻¹x₀ )
+Through taking the SVD, the eigenvalue decomposition, and projections, DMD involves generating a whole bunch of matrices, which can be really unwieldy to manage without some structure. The low hanging fruit for refactoring is to introduce a struct to store those matrices.
struct DMD
+ r::Integer # Dimension
+ U::Matrix # Upper Singular Vectors
+ Ã::Matrix # Projection of A
+ Λ::Diagonal # Eigenvalues of A
+ Φ::Matrix # Eigenvectors of A
+endThen we can introduce a method that takes an input matrix X and output matrix Y and returns the corresponding DMD object. We can take advantage of multiple dispatch to to add further methods, such as for the case where we have a single data matrix X and wish to calculate the DMD on the “future” and “past” matrices.
function DMD(Y::Matrix, X::Matrix)
+ # dimension
+ r = rank(X)
+
+ # Full SVD
+ U, Σ, V = svd(X)
+ Σ = Diagonal(Σ)
+
+ # projection
+ YVΣ⁻¹ = Y*V*Σ^-1
+ Ã = U'*YVΣ⁻¹
+
+ # calculate eigenvectors and eigenvalues
+ # of projection Ã
+ Λ, W = eigen(Ã)
+ Λ = Diagonal(Λ)
+
+ # reconstruct eigenvectors of A
+ Φ = YVΣ⁻¹*W
+
+ return DMD(r,U,Ã,Λ,Φ)
+end
+
+function DMD(X::Matrix)
+ X₁ = X[:, 1:end-1]
+ X₂ = X[:, 2:end]
+ return DMD(X₂, X₁)
+endWe can check that this is doing what it is supposed to be doing by comparing with what we have already done
+d = DMD(data)
+
+# This produces the same result as before
+d.Φ == Φ && d.Λ == Diagonal(Λ)true
+If you were to build this into a larger project, it would be worthwhile to define some actual unit tests to validate that the DMD is working properly.
+Since we have a DMD type to work with, we can also refactor how discrete systems are generated. In this case I have defined a struct for the discrete system, and then added a method such that any discrete system acts as a callable xk+1=f(xk)
struct DiscreteSys
+ Ã::Matrix
+ U::Matrix
+end
+
+function DiscreteSys(d::DMD)
+ return DiscreteSys(d.Ã,d.U)
+end
+
+function (ds::DiscreteSys)(xₖ)
+ return (ds.U*(ds.Ã*(ds.U'*xₖ)))
+endds = DiscreteSys(d)
+
+# This produces the same result as before
+X̂₂_exact == ds(X₁)true
+Similarly we can refactor the generation of continuous systems, first by defining a struct for the continuous system, then by adding a method xt=f(t). This requires a little more information: we need to keep track of the initial state of the system x0 as well as the step size Δt
struct ContinuousSys
+ Φ⁻¹x₀::Vector
+ Ω::Diagonal
+ Φ::Matrix
+end
+
+function ContinuousSys(d::DMD, x₀, Δt=1)
+ Φ⁻¹x₀ = d.Φ\x₀
+ Ω = Diagonal(log.(d.Λ.diag)./Δt)
+ return ContinuousSys(Φ⁻¹x₀, Ω, d.Φ)
+end
+
+function (cs::ContinuousSys)(t)
+ return real( cs.Φ*exp(cs.Ω .* t)*cs.Φ⁻¹x₀ )
+endcs = ContinuousSys(d, X₁[:,1]);
+
+# This produces the same result as before
+x̂(150) == cs(150)true
+I have been using the default tools in julia, which work well for small matrices. If you are planning on doing DMD on enormous matrices then it is worth investigating packages such as IterativeSolvers.jl, Arpack.jl, KrylovKit.jl and others to find better ways than vanilla svd and eigen. It also may be worth thinking about refactoring the problem to be matrix-free, though that is way beyond the scope of these notes.
Whenever a problem involves computing the SVD of a matrix, dimensionality reduction lurks about in the shadows, winking suggestively. By the Eckart-Young theorem we know that the best rank r approximation to a matrix X=UΣVT is the truncated SVD Xr=UrΣrVrT, i.e. the SVD truncated to the r largest singular values (and corresponding singular vectors). So an obvious step for dimensionality reduction in DMD is substitute a truncated SVD for the full SVD.
+function DMD(Y::Matrix, X::Matrix, r::Integer)
+ # full SVD
+ U, Σ, V = svd(X)
+
+ # truncating to rank r
+ @assert r ≤ rank(X)
+ U = U[:, 1:r]
+ Σ = Diagonal(Σ[1:r])
+ V = V[:, 1:r]
+
+ # projection
+ YVΣ⁻¹ = Y*V*Σ^-1
+ Ã = U'*YVΣ⁻¹
+
+ # calculate eigenvectors and eigenvalues
+ # of projection Ã
+ Λ, W = eigen(Ã)
+ Λ = Diagonal(Λ)
+
+ # reconstruct eigenvectors of A
+ Φ = YVΣ⁻¹*W
+
+ return DMD(r,U,Ã,Λ,Φ)
+end
+
+function DMD(X::Matrix, r::Integer)
+ X₁ = X[:, 1:end-1]
+ X₂ = X[:, 2:end]
+ return DMD(X₂, X₁, r)
+endOne consequence of truncation, however, is that the resulting matrix Ur is only semi-unitary, in particular
+but
+This leads to a complication as the matrix U is required to be unitary, in particular when recovering A from the projection matrix Ã, and also when recovering the eigenvalues and eigenvectors of A from Ã.
+But, supposing that this at least approximately works, we are still left with the problem of picking an appropriate value for r. One could look at the singular values and pick one based on structure. For this problem it looks like an elbow happens at r=45.
+We can then generate a set of predictions for the reduced DMD, with r=45, and compare with the exact DMD
+ds_45 = DiscreteSys(DMD(data, 45))
+X̂₂_45 = ds_45(X₁)
+
+norm(X₂ - X̂₂_45) # Frobenius norm0.005459307491383062
+norm(X₂ - X̂₂_exact)0.0005597047465277092
+An alternative is to specify how much of the variance in the original data set needs to be captured. The singular values are a measure of the variance in the data, and so keeping the top p percent of the total variance equates to keeping the top p percent of the sum of all of the singular values.
+That is to say we calculate the r such that
+where σi is the ith singular value (in order of largest to smallest).
+function DMD(Y::Matrix, X::Matrix, p::AbstractFloat)
+ @assert p>0 && p≤1
+
+ # full SVD
+ U, Σ, V = svd(X)
+
+ # determine required rank
+ r = minimum( findall( >(p), cumsum(Σ)./sum(Σ)) )
+
+ # truncate
+ @assert r ≤ rank(X)
+ U = U[:, 1:r]
+ Σ = Diagonal(Σ[1:r])
+ V = V[:, 1:r]
+
+ # projection
+ YVΣ⁻¹ = Y*V*Σ^-1
+ Ã = U'*YVΣ⁻¹
+
+ # calculate eigenvectors and eigenvalues
+ # of projection Ã
+ Λ, W = eigen(Ã)
+ Λ = Diagonal(Λ)
+
+ # reconstruct eigenvectors of A
+ Φ = YVΣ⁻¹*W
+
+ return DMD(r,U,Ã,Λ,Φ)
+end
+
+function DMD(X::Matrix, p::AbstractFloat)
+ X₁ = X[:, 1:end-1]
+ X₂ = X[:, 2:end]
+ return DMD(X₂, X₁, p)
+endCapturing 99% of the variance, in this case, requires only keeping the first 14 singular values.
+
+There are also methods for finding the optimal rank for truncated SVD for a data set that involves gaussian noise which I am not going to go into here.
+So, supposing that p=0.99 works for us, how much further have we reduced the size of our matrices?
+# for p=0.99, r=14
+r = 14
+size_A_reduced = (n*r + r*r)*810008880
+To recover the (approximate) A matrix we only need to store 10MB, a ~91% reduction over the exact DMD
+size_A_reduced/size_A_exact0.09257331331972159
+and a >99.98% reduction of the naive case (recall the naive approach of storing the entire A matrix would take ~64GB)
+size_A_reduced/size_A_naive0.00015670998193688458
+In the above code I simply calculated the full SVD and then truncated it after the fact. If m (the rank of X) is particularly large, then this can be hilariously inefficient. In those cases it may be worth writing a method that uses TSVD.jl to efficiently calculate only the first r singular values – as opposed to calculating all m singular values and then chucking out most of them.
+Compressed DMD attempts to tackle the slowest step in the DMD algorithm: calculating the SVD. An SVD on full data is if we instead compress the data from n dimensions to k dimensions then the cost of the SVD is reduced to either
(when k>m) or
(when k < m), which for large n can be a dramatic speed-up.
Suppose we have some k×n unitary matrix C which compresses our input matrix X into the compressed input matrix Xc and our output matrix Y into the compressed output matrix Yc
+We suppose again that X has the SVD X=**UΣV***, then
+and, since C is unitary, the SVD of Xc is
+where Uc=CU is the upper singular values of the compressed input matrix.
+The projection matrix Ãc of the compressed input matrix is
+and so we should recover the same eigenvalues and eigenvectors as from the uncompressed data.
+using SparseArrays
+
+function cDMD(Y::Matrix, X::Matrix, C::AbstractSparseMatrix)
+ # determining dimensionality
+ r = rank(X)
+
+ # compress the X and Y
+ Xc = C*X
+ Yc = C*Y
+
+ # singular value decomposition
+ Uc, Σc, Vc = svd(Xc)
+ Σc = Diagonal(Σc)
+
+ # projection
+ Ã = Uc'*Yc*Vc*inv(Σc)
+ U = C'*Uc
+
+ # calculate eigenvectors and eigenvalues
+ # of projection Ã
+ Λ, W = eigen(Ã)
+ Λ = Diagonal(Λ)
+
+ # reconstruct eigenvectors of A
+ Φ = Y*Vc*inv(Σc)*W
+
+ return DMD(r,U,Ã,Λ,Φ)
+end
+
+function cDMD(X::Matrix, C::AbstractSparseMatrix)
+ X₁ = X[:, 1:end-1]
+ X₂ = X[:, 2:end]
+ return cDMD(X₂, X₁, C)
+endThe giant caveat is: how do we generate a unitary compression matrix? In fact we can relax this condition if we simply want to recover the eigenvalues and eigenvectors of A. It is enough that the data is sparse in some basis and that the compression matrix is incoherent with respect to that basis.
+We can think of C as a set of k (1×n)-row vectors that project an n dimensional vector x onto a k dimensional space. There are several ways of finding the basis for this projection – e.g. a uniform random projection or a gaussian projection – but by far the simplest is to pick a random subset of k single pixels and only take the measurements from those pixels.
+function cDMD(Y::Matrix, X::Matrix, k::Integer)
+ n, m = size(X)
+ @assert k≤n
+
+ # build (sparse) compression matrix
+ C = spzeros(k, n)
+ for i in 1:k
+ C[i,rand(1:n)] = 1
+ end
+
+ return cDMD(Y, X, C)
+end
+
+function cDMD(X::Matrix, k::Integer)
+ X₁ = X[:, 1:end-1]
+ X₂ = X[:, 2:end]
+ return cDMD(X₂, X₁, k)
+endSuppose we sample at 300 randomly chosen points in the flow field to form the compression matrix
+k = 300
+
+C = spzeros(k, n)
+for i in 1:k
+ C[i,rand(1:n)] = 1
+endThat is to say we are only sampling the vorticity at the green dots. This reduces the dimensionality of the data going in to the DMD algorithm from 89351 to 300.
+
Original flow field with randomly generated sample points for compressed DMD.
+We can generate a few different compressed DMDs to get a sense of how this impacts the overall performance (in terms of the Frobenius norm) and, much like we saw with reduced DMD, there are diminishing returns.
+Using the compression matrix from above, we can generate a compressed DMD3
+3 While we can reconstruct the eigenvalues and eigenvectors quite successfully, I don’t believe we adequately reconstruct U, and so this really only works for the continuous system. The reconstruction of U strongly depends on C being unitary and I don’t think that condition can be relaxed.
+The compressed DMD does not actually reduce the storage size of any of the matrices, it is more a technique to speed up the calculation of the SVD. Compressed DMD and reduced DMD can be combined: first by compressing the n×m matrix X to a k×m matrix Xc and then finding the best rank r approximation to the compressed matrix by truncating the SVD to the r largest singular values. The reduction step reduces the memory requirements and, if truncated SVD is used as well, this could significantly improve performance for enormous systems.
+There is a related approach called compressed sensing DMD, in which the full state vector is not available in the first place. A much smaller dimension set of measurements is sampled and the full state DMD generated using the same general idea as compressed DMD. It isn’t that much of a leap from what is above, just with a convex optimization step added to reconstruct the actual state matrix for a given set of measurements.
+The idea behind physics informed DMD is that the physics of the system imposes structure upon the solution, which we can build into the DMD algorithm. This way we generate results that are consistent with physical reality. Which is to say that we are not merely finding the best fit matrix A, we are finding the best fit matrix A subject to some constraints on its structure. The paper I am using as a reference gives a nice table of different types of flow problems and the sort of structure one might want to impose upon the solution,4
+4 Baddoo et al., “Physics-Informed Dynamic Mode Decomposition (piDMD)” fig 3.
+5 Baddoo et al., fig. 3.
Conveniently the flow past a cylinder example is on that table (that definitely wasn’t a motivating factor for choosing it as the example in the first place, nope, not at all) and what we want to impose on the solution is conservation of energy. Conservation of energy in this case equates to requiring that A be unitary, which is the standard procrustes problem
+We modify the best fit such that we are looking for the A matrix that minimizes
+For which the standard solution is to define a matrix M
+supposing M has SVD
+then the solution is
+Of course we can’t directly compute M in many cases for the same reason that we can’t directly compute A : it would be a n×n matrix and for large n that would be enormous. So instead we project X and Y onto the upper singular values of X and solve the procrustes problem in that smaller space:
+since SVD is invariant to left and right unitary transformations, the SVD of the projected is
where
+and the A matrix which solves the projected procrustes problem is
+which is exactly the projected A matrix we need to proceed with reconstructing the eigenvalues and eigenvectors as per the standard DMD algorithm.
+# this is piDMD *only* for the case where A must be unitary
+# see arXiv:2112.04307 for details on the alternative cases
+function piDMD(Y::Matrix, X::Matrix)
+ # dimension
+ r = rank(X)
+
+ # Full SVD
+ U, _, _ = svd(X)
+
+ # projection
+ Ỹ = U'*Y
+ X̃ = U'*X
+ M̃ = Ỹ*X̃'
+
+ # solve procrustes problem
+ Uₘ, _, Vₘ = svd(M̃)
+ Ã = Uₘ*Vₘ'
+
+ # calculate eigenvectors and eigenvalues
+ # of projection Ã
+ Λ, W = eigen(Ã)
+ Λ = Diagonal(Λ)
+
+ # reconstruct eigenvectors of A
+ Φ = U*W
+
+ return DMD(r,U,Ã,Λ,Φ)
+end
+
+function piDMD(X::Matrix)
+ X₁ = X[:, 1:end-1]
+ X₂ = X[:, 2:end]
+ return piDMD(X₂, X₁)
+end
+We can compare the Frobenius norm of the actual data versus the predicted, and it’s clear the physics informed DMD does not generate as good of a fit as exact DMD. Though it could equally be the case that the exact DMD is over-fitting.
+norm(X₂ - X̂₂_pi, 2)18.35684111920036
+norm(X₂ - X̂₂_exact, 2)0.0005597047465277092
+The main reason why you would pursue physics informed DMD, though, is not necessarily to generate a better fit as much as to generate better (or more physically realistic) dynamic modes.
+Similarly to compressed DMD, physics informed DMD can also be combined with reduced DMD. In this case there are two SVD steps but only the upper singular values of X, the U matrix, needs to be truncated. The second SVD proceeds without truncation.
+${missingFields[0]} field.`,
+ message: `The items being returned for this search do not include all the required fields. Please ensure that your index items include the ${missingFields[0]} field or use index-fields in your _quarto.yml file to specify the field names.`,
+ };
+ } else if (missingFields.length > 1) {
+ const missingFieldList = missingFields
+ .map((field) => {
+ return `${field}`;
+ })
+ .join(", ");
+
+ throw {
+ name: `Error: Search index is missing the following fields: ${missingFieldList}.`,
+ message: `The items being returned for this search do not include all the required fields. Please ensure that your index items includes the following fields: ${missingFieldList}, or use index-fields in your _quarto.yml file to specify the field names.`,
+ };
+ }
+ }
+}
+
+let lastQuery = null;
+function showCopyLink(query, options) {
+ const language = options.language;
+ lastQuery = query;
+ // Insert share icon
+ const inputSuffixEl = window.document.body.querySelector(
+ ".aa-Form .aa-InputWrapperSuffix"
+ );
+
+ if (inputSuffixEl) {
+ let copyButtonEl = window.document.body.querySelector(
+ ".aa-Form .aa-InputWrapperSuffix .aa-CopyButton"
+ );
+
+ if (copyButtonEl === null) {
+ copyButtonEl = window.document.createElement("button");
+ copyButtonEl.setAttribute("class", "aa-CopyButton");
+ copyButtonEl.setAttribute("type", "button");
+ copyButtonEl.setAttribute("title", language["search-copy-link-title"]);
+ copyButtonEl.onmousedown = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ const linkIcon = "bi-clipboard";
+ const checkIcon = "bi-check2";
+
+ const shareIconEl = window.document.createElement("i");
+ shareIconEl.setAttribute("class", `bi ${linkIcon}`);
+ copyButtonEl.appendChild(shareIconEl);
+ inputSuffixEl.prepend(copyButtonEl);
+
+ const clipboard = new window.ClipboardJS(".aa-CopyButton", {
+ text: function (_trigger) {
+ const copyUrl = new URL(window.location);
+ copyUrl.searchParams.set(kQueryArg, lastQuery);
+ copyUrl.searchParams.set(kResultsArg, "1");
+ return copyUrl.toString();
+ },
+ });
+ clipboard.on("success", function (e) {
+ // Focus the input
+
+ // button target
+ const button = e.trigger;
+ const icon = button.querySelector("i.bi");
+
+ // flash "checked"
+ icon.classList.add(checkIcon);
+ icon.classList.remove(linkIcon);
+ setTimeout(function () {
+ icon.classList.remove(checkIcon);
+ icon.classList.add(linkIcon);
+ }, 1000);
+ });
+ }
+
+ // If there is a query, show the link icon
+ if (copyButtonEl) {
+ if (lastQuery && options["copy-button"]) {
+ copyButtonEl.style.display = "flex";
+ } else {
+ copyButtonEl.style.display = "none";
+ }
+ }
+ }
+}
+
+/* Search Index Handling */
+// create the index
+var fuseIndex = undefined;
+var shownWarning = false;
+
+// fuse index options
+const kFuseIndexOptions = {
+ keys: [
+ { name: "title", weight: 20 },
+ { name: "section", weight: 20 },
+ { name: "text", weight: 10 },
+ ],
+ ignoreLocation: true,
+ threshold: 0.1,
+};
+
+async function readSearchData() {
+ // Initialize the search index on demand
+ if (fuseIndex === undefined) {
+ if (window.location.protocol === "file:" && !shownWarning) {
+ window.alert(
+ "Search requires JavaScript features disabled when running in file://... URLs. In order to use search, please run this document in a web server."
+ );
+ shownWarning = true;
+ return;
+ }
+ const fuse = new window.Fuse([], kFuseIndexOptions);
+
+ // fetch the main search.json
+ const response = await fetch(offsetURL("search.json"));
+ if (response.status == 200) {
+ return response.json().then(function (searchDocs) {
+ searchDocs.forEach(function (searchDoc) {
+ fuse.add(searchDoc);
+ });
+ fuseIndex = fuse;
+ return fuseIndex;
+ });
+ } else {
+ return Promise.reject(
+ new Error(
+ "Unexpected status from search index request: " + response.status
+ )
+ );
+ }
+ }
+
+ return fuseIndex;
+}
+
+function inputElement() {
+ return window.document.body.querySelector(".aa-Form .aa-Input");
+}
+
+function focusSearchInput() {
+ setTimeout(() => {
+ const inputEl = inputElement();
+ if (inputEl) {
+ inputEl.focus();
+ }
+ }, 50);
+}
+
+/* Panels */
+const kItemTypeDoc = "document";
+const kItemTypeMore = "document-more";
+const kItemTypeItem = "document-item";
+const kItemTypeError = "error";
+
+function renderItem(
+ item,
+ createElement,
+ state,
+ setActiveItemId,
+ setContext,
+ refresh,
+ quartoSearchOptions
+) {
+ switch (item.type) {
+ case kItemTypeDoc:
+ return createDocumentCard(
+ createElement,
+ "file-richtext",
+ item.title,
+ item.section,
+ item.text,
+ item.href,
+ item.crumbs,
+ quartoSearchOptions
+ );
+ case kItemTypeMore:
+ return createMoreCard(
+ createElement,
+ item,
+ state,
+ setActiveItemId,
+ setContext,
+ refresh
+ );
+ case kItemTypeItem:
+ return createSectionCard(
+ createElement,
+ item.section,
+ item.text,
+ item.href
+ );
+ case kItemTypeError:
+ return createErrorCard(createElement, item.title, item.text);
+ default:
+ return undefined;
+ }
+}
+
+function createDocumentCard(
+ createElement,
+ icon,
+ title,
+ section,
+ text,
+ href,
+ crumbs,
+ quartoSearchOptions
+) {
+ const iconEl = createElement("i", {
+ class: `bi bi-${icon} search-result-icon`,
+ });
+ const titleEl = createElement("p", { class: "search-result-title" }, title);
+ const titleContents = [iconEl, titleEl];
+ const showParent = quartoSearchOptions["show-item-context"];
+ if (crumbs && showParent) {
+ let crumbsOut = undefined;
+ const crumbClz = ["search-result-crumbs"];
+ if (showParent === "root") {
+ crumbsOut = crumbs.length > 1 ? crumbs[0] : undefined;
+ } else if (showParent === "parent") {
+ crumbsOut = crumbs.length > 1 ? crumbs[crumbs.length - 2] : undefined;
+ } else {
+ crumbsOut = crumbs.length > 1 ? crumbs.join(" > ") : undefined;
+ crumbClz.push("search-result-crumbs-wrap");
+ }
+
+ const crumbEl = createElement(
+ "p",
+ { class: crumbClz.join(" ") },
+ crumbsOut
+ );
+ titleContents.push(crumbEl);
+ }
+
+ const titleContainerEl = createElement(
+ "div",
+ { class: "search-result-title-container" },
+ titleContents
+ );
+
+ const textEls = [];
+ if (section) {
+ const sectionEl = createElement(
+ "p",
+ { class: "search-result-section" },
+ section
+ );
+ textEls.push(sectionEl);
+ }
+ const descEl = createElement("p", {
+ class: "search-result-text",
+ dangerouslySetInnerHTML: {
+ __html: text,
+ },
+ });
+ textEls.push(descEl);
+
+ const textContainerEl = createElement(
+ "div",
+ { class: "search-result-text-container" },
+ textEls
+ );
+
+ const containerEl = createElement(
+ "div",
+ {
+ class: "search-result-container",
+ },
+ [titleContainerEl, textContainerEl]
+ );
+
+ const linkEl = createElement(
+ "a",
+ {
+ href: offsetURL(href),
+ class: "search-result-link",
+ },
+ containerEl
+ );
+
+ const classes = ["search-result-doc", "search-item"];
+ if (!section) {
+ classes.push("document-selectable");
+ }
+
+ return createElement(
+ "div",
+ {
+ class: classes.join(" "),
+ },
+ linkEl
+ );
+}
+
+function createMoreCard(
+ createElement,
+ item,
+ state,
+ setActiveItemId,
+ setContext,
+ refresh
+) {
+ const moreCardEl = createElement(
+ "div",
+ {
+ class: "search-result-more search-item",
+ onClick: (e) => {
+ // Handle expanding the sections by adding the expanded
+ // section to the list of expanded sections
+ toggleExpanded(item, state, setContext, setActiveItemId, refresh);
+ e.stopPropagation();
+ },
+ },
+ item.title
+ );
+
+ return moreCardEl;
+}
+
+function toggleExpanded(item, state, setContext, setActiveItemId, refresh) {
+ const expanded = state.context.expanded || [];
+ if (expanded.includes(item.target)) {
+ setContext({
+ expanded: expanded.filter((target) => target !== item.target),
+ });
+ } else {
+ setContext({ expanded: [...expanded, item.target] });
+ }
+
+ refresh();
+ setActiveItemId(item.__autocomplete_id);
+}
+
+function createSectionCard(createElement, section, text, href) {
+ const sectionEl = createSection(createElement, section, text, href);
+ return createElement(
+ "div",
+ {
+ class: "search-result-doc-section search-item",
+ },
+ sectionEl
+ );
+}
+
+function createSection(createElement, title, text, href) {
+ const descEl = createElement("p", {
+ class: "search-result-text",
+ dangerouslySetInnerHTML: {
+ __html: text,
+ },
+ });
+
+ const titleEl = createElement("p", { class: "search-result-section" }, title);
+ const linkEl = createElement(
+ "a",
+ {
+ href: offsetURL(href),
+ class: "search-result-link",
+ },
+ [titleEl, descEl]
+ );
+ return linkEl;
+}
+
+function createErrorCard(createElement, title, text) {
+ const descEl = createElement("p", {
+ class: "search-error-text",
+ dangerouslySetInnerHTML: {
+ __html: text,
+ },
+ });
+
+ const titleEl = createElement("p", {
+ class: "search-error-title",
+ dangerouslySetInnerHTML: {
+ __html: ` ${title}`,
+ },
+ });
+ const errorEl = createElement("div", { class: "search-error" }, [
+ titleEl,
+ descEl,
+ ]);
+ return errorEl;
+}
+
+function positionPanel(pos) {
+ const panelEl = window.document.querySelector(
+ "#quarto-search-results .aa-Panel"
+ );
+ const inputEl = window.document.querySelector(
+ "#quarto-search .aa-Autocomplete"
+ );
+
+ if (panelEl && inputEl) {
+ panelEl.style.top = `${Math.round(panelEl.offsetTop)}px`;
+ if (pos === "start") {
+ panelEl.style.left = `${Math.round(inputEl.left)}px`;
+ } else {
+ panelEl.style.right = `${Math.round(inputEl.offsetRight)}px`;
+ }
+ }
+}
+
+/* Highlighting */
+// highlighting functions
+function highlightMatch(query, text) {
+ if (text) {
+ const start = text.toLowerCase().indexOf(query.toLowerCase());
+ if (start !== -1) {
+ const startMark = "";
+ const endMark = "";
+
+ const end = start + query.length;
+ text =
+ text.slice(0, start) +
+ startMark +
+ text.slice(start, end) +
+ endMark +
+ text.slice(end);
+ const startInfo = clipStart(text, start);
+ const endInfo = clipEnd(
+ text,
+ startInfo.position + startMark.length + endMark.length
+ );
+ text =
+ startInfo.prefix +
+ text.slice(startInfo.position, endInfo.position) +
+ endInfo.suffix;
+
+ return text;
+ } else {
+ return text;
+ }
+ } else {
+ return text;
+ }
+}
+
+function clipStart(text, pos) {
+ const clipStart = pos - 50;
+ if (clipStart < 0) {
+ // This will just return the start of the string
+ return {
+ position: 0,
+ prefix: "",
+ };
+ } else {
+ // We're clipping before the start of the string, walk backwards to the first space.
+ const spacePos = findSpace(text, pos, -1);
+ return {
+ position: spacePos.position,
+ prefix: "",
+ };
+ }
+}
+
+function clipEnd(text, pos) {
+ const clipEnd = pos + 200;
+ if (clipEnd > text.length) {
+ return {
+ position: text.length,
+ suffix: "",
+ };
+ } else {
+ const spacePos = findSpace(text, clipEnd, 1);
+ return {
+ position: spacePos.position,
+ suffix: spacePos.clipped ? "…" : "",
+ };
+ }
+}
+
+function findSpace(text, start, step) {
+ let stepPos = start;
+ while (stepPos > -1 && stepPos < text.length) {
+ const char = text[stepPos];
+ if (char === " " || char === "," || char === ":") {
+ return {
+ position: step === 1 ? stepPos : stepPos - step,
+ clipped: stepPos > 1 && stepPos < text.length,
+ };
+ }
+ stepPos = stepPos + step;
+ }
+
+ return {
+ position: stepPos - step,
+ clipped: false,
+ };
+}
+
+// removes highlighting as implemented by the mark tag
+function clearHighlight(searchterm, el) {
+ const childNodes = el.childNodes;
+ for (let i = childNodes.length - 1; i >= 0; i--) {
+ const node = childNodes[i];
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ if (
+ node.tagName === "MARK" &&
+ node.innerText.toLowerCase() === searchterm.toLowerCase()
+ ) {
+ el.replaceChild(document.createTextNode(node.innerText), node);
+ } else {
+ clearHighlight(searchterm, node);
+ }
+ }
+ }
+}
+
+function escapeRegExp(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
+}
+
+// highlight matches
+function highlight(term, el) {
+ const termRegex = new RegExp(term, "ig");
+ const childNodes = el.childNodes;
+
+ // walk back to front avoid mutating elements in front of us
+ for (let i = childNodes.length - 1; i >= 0; i--) {
+ const node = childNodes[i];
+
+ if (node.nodeType === Node.TEXT_NODE) {
+ // Search text nodes for text to highlight
+ const text = node.nodeValue;
+
+ let startIndex = 0;
+ let matchIndex = text.search(termRegex);
+ if (matchIndex > -1) {
+ const markFragment = document.createDocumentFragment();
+ while (matchIndex > -1) {
+ const prefix = text.slice(startIndex, matchIndex);
+ markFragment.appendChild(document.createTextNode(prefix));
+
+ const mark = document.createElement("mark");
+ mark.appendChild(
+ document.createTextNode(
+ text.slice(matchIndex, matchIndex + term.length)
+ )
+ );
+ markFragment.appendChild(mark);
+
+ startIndex = matchIndex + term.length;
+ matchIndex = text.slice(startIndex).search(new RegExp(term, "ig"));
+ if (matchIndex > -1) {
+ matchIndex = startIndex + matchIndex;
+ }
+ }
+ if (startIndex < text.length) {
+ markFragment.appendChild(
+ document.createTextNode(text.slice(startIndex, text.length))
+ );
+ }
+
+ el.replaceChild(markFragment, node);
+ }
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
+ // recurse through elements
+ highlight(term, node);
+ }
+ }
+}
+
+/* Link Handling */
+// get the offset from this page for a given site root relative url
+function offsetURL(url) {
+ var offset = getMeta("quarto:offset");
+ return offset ? offset + url : url;
+}
+
+// read a meta tag value
+function getMeta(metaName) {
+ var metas = window.document.getElementsByTagName("meta");
+ for (let i = 0; i < metas.length; i++) {
+ if (metas[i].getAttribute("name") === metaName) {
+ return metas[i].getAttribute("content");
+ }
+ }
+ return "";
+}
+
+function algoliaSearch(query, limit, algoliaOptions) {
+ const { getAlgoliaResults } = window["@algolia/autocomplete-preset-algolia"];
+
+ const applicationId = algoliaOptions["application-id"];
+ const searchOnlyApiKey = algoliaOptions["search-only-api-key"];
+ const indexName = algoliaOptions["index-name"];
+ const indexFields = algoliaOptions["index-fields"];
+ const searchClient = window.algoliasearch(applicationId, searchOnlyApiKey);
+ const searchParams = algoliaOptions["params"];
+ const searchAnalytics = !!algoliaOptions["analytics-events"];
+
+ return getAlgoliaResults({
+ searchClient,
+ queries: [
+ {
+ indexName: indexName,
+ query,
+ params: {
+ hitsPerPage: limit,
+ clickAnalytics: searchAnalytics,
+ ...searchParams,
+ },
+ },
+ ],
+ transformResponse: (response) => {
+ if (!indexFields) {
+ return response.hits.map((hit) => {
+ return hit.map((item) => {
+ return {
+ ...item,
+ text: highlightMatch(query, item.text),
+ };
+ });
+ });
+ } else {
+ const remappedHits = response.hits.map((hit) => {
+ return hit.map((item) => {
+ const newItem = { ...item };
+ ["href", "section", "title", "text", "crumbs"].forEach(
+ (keyName) => {
+ const mappedName = indexFields[keyName];
+ if (
+ mappedName &&
+ item[mappedName] !== undefined &&
+ mappedName !== keyName
+ ) {
+ newItem[keyName] = item[mappedName];
+ delete newItem[mappedName];
+ }
+ }
+ );
+ newItem.text = highlightMatch(query, newItem.text);
+ return newItem;
+ });
+ });
+ return remappedHits;
+ }
+ },
+ });
+}
+
+let subSearchTerm = undefined;
+let subSearchFuse = undefined;
+const kFuseMaxWait = 125;
+
+async function fuseSearch(query, fuse, fuseOptions) {
+ let index = fuse;
+ // Fuse.js using the Bitap algorithm for text matching which runs in
+ // O(nm) time (no matter the structure of the text). In our case this
+ // means that long search terms mixed with large index gets very slow
+ //
+ // This injects a subIndex that will be used once the terms get long enough
+ // Usually making this subindex is cheap since there will typically be
+ // a subset of results matching the existing query
+ if (subSearchFuse !== undefined && query.startsWith(subSearchTerm)) {
+ // Use the existing subSearchFuse
+ index = subSearchFuse;
+ } else if (subSearchFuse !== undefined) {
+ // The term changed, discard the existing fuse
+ subSearchFuse = undefined;
+ subSearchTerm = undefined;
+ }
+
+ // Search using the active fuse
+ const then = performance.now();
+ const resultsRaw = await index.search(query, fuseOptions);
+ const now = performance.now();
+
+ const results = resultsRaw.map((result) => {
+ const addParam = (url, name, value) => {
+ const anchorParts = url.split("#");
+ const baseUrl = anchorParts[0];
+ const sep = baseUrl.search("\\?") > 0 ? "&" : "?";
+ anchorParts[0] = baseUrl + sep + name + "=" + value;
+ return anchorParts.join("#");
+ };
+
+ return {
+ title: result.item.title,
+ section: result.item.section,
+ href: addParam(result.item.href, kQueryArg, query),
+ text: highlightMatch(query, result.item.text),
+ crumbs: result.item.crumbs,
+ };
+ });
+
+ // If we don't have a subfuse and the query is long enough, go ahead
+ // and create a subfuse to use for subsequent queries
+ if (
+ now - then > kFuseMaxWait &&
+ subSearchFuse === undefined &&
+ resultsRaw.length < fuseOptions.limit
+ ) {
+ subSearchTerm = query;
+ subSearchFuse = new window.Fuse([], kFuseIndexOptions);
+ resultsRaw.forEach((rr) => {
+ subSearchFuse.add(rr.item);
+ });
+ }
+ return results;
+}
diff --git a/sitemap.xml b/sitemap.xml
new file mode 100644
index 0000000..d99d465
--- /dev/null
+++ b/sitemap.xml
@@ -0,0 +1,175 @@
+
+