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 @@ + + + + + + + + + +Allan Farrell – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ + + + +
+
+ +
+
+

Allan Farrell

+
+
+
+
+ +
+
+ +
+

What this is

+

This blog is a collection of (mostly) jupyter notebooks, in either python or julia, solving various engineering and math problems. These are my weekend projects and are often inspired by things happening in the world, interesting problems I may have encountered at work, or just passing interests of mine. There isn’t really a theme other than mostly chemical engineering, since that’s my profession, and mostly process safety and consequence modelling, as that’s something I’m personally interested in.

+

I think the best way to learn something new is to try it out yourself, play around with solving problems, see what works and what doesn’t. That’s what these notebooks are. I am also a big believer in putting one’s random projects and terrible code online for other people to look at. The source code for each post is available for you to download and modify to your hearts content. I also try to provide references for everything I’m doing, and those are a good resource for more context. This is a great opportunity for you to tell me all the ways my code is terrible and what I should be doing instead, or tell me all the interesting things you did with it, and the new directions you went in. The internet is a better place when we share.

+
+
+

What this is not

+

These blog posts do not contain my professional advice or opinion, nor do they represent the opinions of my employer. These are weekend projects, with no guarantees of correctness. You have to think for yourself.

+
+
+

Some technical caveats

+

The blog itself is rendered directly from the jupyter notebooks by quarto. However, a lot of the boiler plate and set-up is hidden in the final blog post for readability. If you want more details (especially how the plots are generated), please see the source noteboook.

+

Posts which use julia take advantage of the built in environment manager and there is an associated Project.toml for each notebook with compat entries frozen at the last known working versions. Many packages under active development will change significantly over time, so it is worth checking to see which version I was using as the package may have changed in the meantime.

+

Posts which use python use the poetry-kernel to track the associated virtual environments, which are maintained in the pyproject.toml for each notebook. This shows the state of the virtual environment at the last time I ran the notebook.

+ + +
+
+
+ + +
+ + + + + + \ No newline at end of file diff --git a/archive.html b/archive.html new file mode 100644 index 0000000..8464483 --- /dev/null +++ b/archive.html @@ -0,0 +1,1937 @@ + + + + + + + + + +archive – A Chemical Engineer's Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ + + + + + \ No newline at end of file diff --git a/feed.xml b/feed.xml new file mode 100644 index 0000000..22adf58 --- /dev/null +++ b/feed.xml @@ -0,0 +1,10 @@ + + + + + +https://aefarrell.github.io/index.xml + + + + diff --git a/googleaac10e261f67700b.html b/googleaac10e261f67700b.html new file mode 100644 index 0000000..41a8556 --- /dev/null +++ b/googleaac10e261f67700b.html @@ -0,0 +1 @@ +google-site-verification: googleaac10e261f67700b.html \ No newline at end of file diff --git a/images/favicon.png b/images/favicon.png new file mode 100644 index 0000000..17e6696 Binary files /dev/null and b/images/favicon.png differ diff --git a/images/pexels-daniil-prikhno-header.jpg b/images/pexels-daniil-prikhno-header.jpg new file mode 100644 index 0000000..244ae54 Binary files /dev/null and b/images/pexels-daniil-prikhno-header.jpg differ diff --git a/images/pipes-unsplash-header.jpg b/images/pipes-unsplash-header.jpg new file mode 100644 index 0000000..2acfc75 Binary files /dev/null and b/images/pipes-unsplash-header.jpg differ diff --git a/images/space-toast-2.png b/images/space-toast-2.png new file mode 100644 index 0000000..4bfd612 Binary files /dev/null and b/images/space-toast-2.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..2bfeb2a --- /dev/null +++ b/index.html @@ -0,0 +1,1833 @@ + + + + + + + + + +A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ + + + + + \ No newline at end of file diff --git a/index.xml b/index.xml new file mode 100644 index 0000000..5d8e74f --- /dev/null +++ b/index.xml @@ -0,0 +1,32831 @@ + + + +A Chemical Engineer's Notebook +https://aefarrell.github.io/ + +A collection of notebooks about chemical engineering and analysis +quarto-1.8.27 +Thu, 07 May 2026 06:00:00 GMT + + Delivering Hydrogen Fuel Gas + Allan Farrell + https://aefarrell.github.io/posts/hydrogen_compression/ + Previously I evaluated hydrogen as a fuel gas from the perspective of an end user – someone who purchases utility natural gas, at pressure, for use in combustion devices like boilers and heaters. From that perspective, hydrogen is not an unreasonable conversion, with material compatibility being the primary concern. In this post I’m going to look at it from the perspective of the gas utility.

+

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.

+
+

The Situation

+

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

+
+
+

+
+
+
+
+
+ +
+
+Figure 1: A three stage compressor with interstage cooling. +
+
+
+
+
+

Suppose, for simplicity, the interstage coolers bring the gas temperature down to 15C:

+
    +
  1. the first stage compresses the gas from 1 bar to 4.6 bar
  2. +
  3. the second stage compresses the gas from 4.6 bar to 21.5 bar
  4. +
  5. the last stage compresses the gas from 21.5 bar to 100.0 bar
  6. +
+

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

+

+
+
+

The Ideal Gas Case

+

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, Clapeyron
+
+
+
ideal_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%.

+
+
+
+
+

The Real Gas Case

+

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
+end
+
+

First, 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%.

+
+
+
+
+
+
+ +
+
+Figure 2: Relative work required to compress hydrogen versus methane for the 3 compressor stages. +
+
+
+
+
+
+

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.

+
+
+

Final Thoughts

+

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.

+
+
+

References

+
+
+Boyce, Meherwan P., Victor H. Edwards, Terry W. Cowley, Hugh D. Kaiser, Wayne B. Geyer, David Nadel, Larry Skoda, Shawn Testone, and Kenneth L. Walter. “Transport and Storage of Fluids.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+Gmehling, Jürgen, Bärbel Kolbe, Michael Kleiber, and Jürgen Rary. Chemical Thermodynamics for Process Simulation. Weinheim, DE: Wiley-VCH Verlag & Co., 2012. +
+
+GPSA. Engineering Data Book. 13th ed. Tulsa, OK: Gas Processors Suppliers Association, 2012. +
+
+Sendehboudi, Sohrab, and Bahram Gharbani. Hydrogen Production, Transportation, Storage, and Utilization. Amsterdam: Elsevier, 2025. +
+
+ + +
+ + + ]]>
+ julia + hydrogen + https://aefarrell.github.io/posts/hydrogen_compression/ + Thu, 07 May 2026 06:00:00 GMT + +
+ + The Masses of Clouds + Allan Farrell + https://aefarrell.github.io/posts/gaussian_explosive_mass/ + A common thing for me to do, when using a tool developed by someone else, is to read through all the tedious details and try and understand where it all comes from and what the unstated assumptions are. While doing this recently I was motivated to ask, how do people estimate the explosive energy in a vapour cloud? It is an important question to ask when performing a hazard analysis, especially in the petrochemical industry – often the worst case scenario is some chemical release leading to a vapour cloud explosion.

+

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.

+
+

The Gaussian dispersion model, a recap

+

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:

+
    +
  1. The release has a constant mass emission rate of
  2. +
  3. The release has no momentum or buoyancy
  4. +
  5. Advection is by a constant windspeed which is in the positive direction
  6. +
  7. Turbulence is captured by the parameters and which are functions of the downwind distance
  8. +
+

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)
+
+
+
+

A survey of estimates of the mass of vapour clouds

+

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 & RAST1
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 of a Gaussian plume

+

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

+

+
+

Total mass

+

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 isosurface of a Gaussian 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.

+
+
+
+
+ +
+
+Figure 1: The crosswind extent of the region of interest. +
+
+
+
+
+
+
+
+ +
+
+Figure 2: The vertical extent of the region of interest. +
+
+
+
+

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
+
+
+
+
+
+
+ +
+
+Figure 3: The χ₂ iso-surface, calculated by marching tetrahedra. +
+
+
+
+

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.

+
+
+

Direct numerical integration

+

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.0
+
+
+
function 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
+end
+
+
+
m🪤 = 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
+end
+
+
+
m🪤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 MCIntegration
+
+
+
function 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]
+end
+
+
mass🎲 (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 (minmax):  161.499 ms184.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.

+
+
+

Adaptive step-sizes with H Cubature

+

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.

+

+

+
+
+
+
+ +
+
+Figure 4: The cross-sectional area of the plume, an ellipse. +
+
+
+
+

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: hcubature
+
+
+
function 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
+end
+
+
+
m📦 = 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 (minmax):  19.541 ms35.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.

+
+
+

Integrating out the cross-sectional area

+

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: quadgk
+
+
+
function 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
+end
+
+
mass🔴 (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 (minmax):  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.

+
+
+
+
+Table 1: Performance of the integration methods. +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Mass (kg)Error (%)Median Time (ms)
Trapezoidal Rule55.730.9%9443.63
Monte Carlo56.360.23%165.57
H Cubature56.231.0e-8%25.0
QuadGK56.235.0e-10%0.03
+
+
+
+
+
+
+

The mass in a grounded plume

+

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.

+
+
+
+

The “rigorous” method

+

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: ellipe
+
+
+
(x) = 1 - (σz(x)/σy(x))^2
+
+
k² (generic function with 1 method)
+
+
+
+
I, err = quadgk( t -> σy(t)^2 * ellipe((t)), x₁, x₂)
+
+
(3522.359412198113, 4.6837135414534714e-7)
+
+
+
+
m_rigorous = 4*(χ₁ - χ₂)*I;
+
+
+
m_rigorous
+
+
1853.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 .

+
+
+
+
+ +
+
+Figure 5: The cross-sectional area of the plume, between the two ellipses. +
+
+
+
+

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”.

+
+

Conclusions and recommendations

+

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?”

+ + + +
+ + +

References

+
+Bakkum, E. A., and N. J. Duijm. “Vapour Cloud Dispersion.” In Methods for the Calculation of Physical Effects, CPR 14E, edited by C. J. H. van den Bosch and R. A. P. M. Weterings, 3rd ed. The Hague: TNO, 2005. +
+
+Hesse, D. J. “A Computational Procedure for Calculating the Mass of Flammable Vapor in a Neutrally Buoyant Cloud.” In International Conference and Workshop on Modeling and Mitigating the Consequences of Accidental Releases of Hazardous Materials, 511–28. New Orleans, 1991. +
+
+Spicer, Thomas O., and Jerry Havens. “Application of Dispersion Models to Flammable Cloud Analyses.” Journal of Hazardous Materials 49, no. 2 (1996): 115–24. https://doi.org/10.1016/0304-3894(96)01765-7. +
+
+Van Buijtenen, C. J. P. “Calculation of the Amount of Gas in the Explosive Region of a Vapour Cloud Released in the Atmosphere.” Journal of Hazardous Materials 3, no. 3 (1980): 201–20. https://doi.org/10.1016/0304-3894(80)85001-1. +
+
+Woodward, John L. Estimating the Flammable Mass of a Vapour Cloud. New York: American Institute of Chemical Engineers, 1998. +
+
]]>
+ julia + dispersion modelling + explosions + https://aefarrell.github.io/posts/gaussian_explosive_mass/ + Sun, 22 Feb 2026 07:00:00 GMT + +
+ + Vessel Blowdown and Dispersion + Allan Farrell + https://aefarrell.github.io/posts/vessel_blowdown_dispersion/ + I was thinking, recently, about how venting from vessel blowdown is modelled for screening purposes and how, more often than not, it does not take into account the blowdown curve. This is something that could easily be incorporated for simple Gaussian dispersion, which is what I examine here.

+
+

Background

+

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.

    +
  1. Identify the source model (gas, liquid, aerosol)
  2. +
  3. Calculate the mass release rate
  4. +
  5. Model the dispersion of the release
  6. +
+

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.

+
+

Isothermal Blowdown

+

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:

+
    +
  • – the discharge coefficient for the blowdown
  • +
  • – the flow area of the orifice through which the blowdown is happening (e.g. a PSV)
  • +
  • – the total volume of the vessel
  • +
  • – the isentropic expansion factor, which for an ideal gas is the ratio of specific heats
  • +
  • – the initial pressure in the vessel
  • +
  • – the initial density of the gas in the vessel
  • +
+
+
+

The Single Puff Model

+

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:

+
    +
  • – the constant mass release rate
  • +
  • – the duration of the release
  • +
  • – the uniform windspeed (acting only in the x direction)
  • +
  • s – the dispersion parameters.
  • +
+

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.70
+
+

I 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
+end
+
+

Now 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
+end
+
+
+
+

The Multi-Puff Model

+

The 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, erfc
+
+
+
struct Palazzi
+    w   # mass release rate
+    h   # release height
+    u   # velocity
+    t_f # end of release
+end
+
+
+
function 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))
+end
+
+
+
+
+

A Blowdown Dispersion Model

+

It 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
+end
+
+
+
function 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))
+end
+
+
+
+
+ +
+
+Note +
+
+
+

Note 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.

+
+
+
+

An Example Case

+

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/τ))
+
+
+
+

Discrete Puffs

+

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
+end
+
+
+
pfs = discrete_puffs(n=25);
+
+
+
+
+
+ +
+
+Figure 1: The release rate for an isothermal vessel blowdown, along with the sequence of discrete puffs generated to approximate it. +
+
+
+
+

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%.

+
+
+
+
+

Comparing Results

+

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,τ)
+
+
+
+
+
+ +
+
+Figure 2: The concentration profile at x=1000m, y=0m, z=2m. +
+
+
+
+

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.

+
+
+
+
+ +
+
+Figure 3: The ground level concentration for the isothermal blowdown. +
+
+
+
+

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.

+
+
+
+

A Note on Sources

+

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.

+
+
+

References

+
+
+Center for Chemical Process Safety. Guidelines for Consequence Analysis of Chemical Releases. New York: Center for Chemical Process Safety/AIChE, 1999. +
+
+Palazzi, E, M De Faveri, Giuseppe Fumarola, and G Ferraiolo. “Diffusion from a Steady Source of Short Duration.” Atmospheric Environment 16, no. 12 (1982): 2785–90. https://www.researchgate.net/publication/328744668_DIFFUSION_FROM_A_STEADY_SOURCE_OF_SHORT_DURATION. +
+
+ + +
+ + + ]]>
+ julia + blowdown + dispersion modelling + https://aefarrell.github.io/posts/vessel_blowdown_dispersion/ + Tue, 23 Dec 2025 07:00:00 GMT + +
+ + The Ooms Plume Model + Allan Farrell + https://aefarrell.github.io/posts/ooms_plume_model/ + I have been interested in the Ooms plume model1 for a long time, but I haven’t really set aside the time to really play around with it because the implementation details are surprisingly sparse. A recent weekend project of mine was to sit down and work out what the actual model equations are and get it running in julia. Something which might be useful to you if you are looking to run one of the O.G. integral plume models.

+

1 Ooms, “A New Method for the Calculation of the Plume Path of Gases Emitted by a Stack”.

+

The Ooms Plume Model

+

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.

+
+
+
+ +
+
+Figure 1: A sketch of the plume and the coordinate system. +
+
+
+

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.

+
+
+
+ +
+
+Figure 2: A differential element of the plume along the plume center-line. +
+
+
+

The Ooms model comes from the conservation relations for this differential element.

+
+

Conservation of…

+
+

Mass

+

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

+

+
+
+
+ +
+
+Note +
+
+
+

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”.

+
+

Species

+

The total mass of the vented substance is conserved as the plume expands. Assuming the vent is some species i with mass concentration c:

+

+
+
+

Momentum

+

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.

+
+
+

Energy

+

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).

+
+
+
+

Coordinate Transforms

+

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:

+

+
+
+

Entrainment

+

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:

+
    +
  1. Following Briggs, where is the eddy energy dissipation and is a function of atmospheric stability and elevation.
  2. +
  3. Empirically by the root-mean-square of the wind velocity fluctuation
  4. +
+

The total entrainment is then:

+

+

+

or

+

+

Where is defined in the next section.

+
+
+

Similarity Profiles

+

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.

+
+
+
+

Practical Necessities

+

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.

+
+
+ +
+
+Figure 3: The model constants from Havens,5 note the misprint in (should read 2.227186) +
+

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:

+
    +
  1. The relationship between the model constants (e.g λ) and the integration constants (the k’s in DEGADIS)
  2. +
  3. To re-create the model that allows for more structure to the atmosphere.
  4. +
+
+

A Series of Tedious Integrals

+

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.

+
+
+
+Table 1: Integration Constants +
+
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DEGADIS6Me
+

6 Havens and Spicer, 12.

+
+
+
+
+

Dimensionless Form

+

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.

+
+
+

The Full Equations

+
+

Conservation of Mass

+

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:

+

+
+
+

Conservation of Species

+

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:

+

+
+
+

Conservation of Momentum

+

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:

+

+
+
+

Conservation of Energy

+

The balance equation is:

+

+

Where is the dimensionless air density.

+

The final form is:

+

+
+
+
+
+

Implementing the Ooms Plume Model

+

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
+end
+
+

In 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
+end
+
+
+

… as an ODE

+

The 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 OrdinaryDiffEq
+
+
+
function 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
+end
+
+

Instead 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/s
+
+

The 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             ]# z
+
+
+
+
+ +
+
+Note +
+
+
+

The 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.retcode
+
+
ReturnCode.Success = 1
+
+
+
+
+
+
+ +
+
+Figure 4: The plume height as a function of downwind distance. +
+
+
+
+
+
+

… as a DAE

+

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
+end
+
+

The 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\f0
+
+
+
diff_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 Sundials
+
+
+
daesol = solve(daeprob, IDA())
+
+daesol.retcode
+
+
ReturnCode.Success = 1
+
+
+
+
+
+
+ +
+
+Figure 5: The plume height as a function of downwind distance, solutions using the DifferentialEquations.jl solver Tsit5 and the Sundials DAE solver IDA. +
+
+
+
+

This works as well as the lazy method, slightly slower but it has not been implemented in a particularly optimal way.

+
+
+

… using ModelingToolkit

+

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 ∂ instead
+
+

First 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.retcode
+
+
ReturnCode.Success = 1
+
+
+
+
+
+
+ +
+
+Figure 6: The plume height as a function of downwind distance, solutions using the lazy approach with the Tsit5 solver and ModelingToolkit using Rodas5P. +
+
+
+
+

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.

+
+
+

Dead Ends and Failures

+

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 SciMLOperators
+
+
+
M = 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 Problem of Concentration

+

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.

+
    +
  1. Calculating the isopleths in the x-z plane
  2. +
  3. Calculating the isopleths at an arbitrary elevation
  4. +
  5. Calculating the concentration at an arbitrary point
  6. +
+

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.

+
+

Isopleths in the x-z Plane

+

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.

+
+
+
+ +
+
+Note +
+
+
+

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
+end
+
+
+
function 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
+end
+
+

For 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_zero
+
+
+
i_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ₗ))];
+
+
+
+
+
+ +
+
+Figure 7: Plume vertical isopleths, 2%(vol) +
+
+
+
+
+
+

Isopleths at z=a

+

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
+= bₒ^2 * λ²*log(cₒ/c)
+        z′² = ((a - zₒ)*cos(θₒ) - (x - xₒ)*sin(θₒ))^2
+
+        if z′² >
+            # the isosurface doesn't intersect z=a
+            return nothing
+        else
+            y′ = ( r² - z′²)
+            y = y′
+            return Point(x,y)
+        end
+    end
+end
+
+

Picking an arbitraty height of 20 stack diameters in elevation.

+
+
a = 20 # 20 stack diameters
+
+

We 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 ]
+
+
+
+
+
+ +
+
+Figure 8: Plume crosswind isopleths at z/D = 20, 2%(vol) +
+
+
+
+
+
+

The Concentration at an Arbitrary Point

+

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)
+end
+
+

The 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(θₒ)
+= (y′)^2 + (z′)^2
+
+    # calculate concentration
+    c = cₒ*exp(-/(bₒ^2*λ²))
+
+    return c
+end
+
+

How 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 ]
+
+
+
+
+
+ +
+
+Figure 9: A scatterplot showing how well the concentration function recovered the concentration at the points on the isopleths +
+
+
+
+

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.

+
+
+
+

Capturing Dense Gas Behaviour

+

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.

+
+

Bouncing Plume with a Standard Callback

+
+
ground_check(state, s, i) = state[7] # z
+
+
+
function reflect_plume!(integrator)
+    # bounce off the ground
+    integrator.u[4] = abs(integrator.u[4]) # θ
+    integrator.u[7] = 0                    # z
+end
+
+
+
ground_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             ]# z
+
+
+
dense_prob =  ODEProblem(ode_rhs!, dense_state0, span.*2, params)
+
+
+
dense_sol = solve(dense_prob, Tsit5(); callback=ground_cb)
+
+dense_sol.retcode
+
+
ReturnCode.Success = 1
+
+
+
+
+

Bouncing Plume with ModelingToolkit

+

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.retcode
+
+
ReturnCode.Success = 1
+
+
+
+
+
+
+ +
+
+Figure 10: The plume height as a function of downwind distance, vent gas eleven times denser than ambient air. +
+
+
+
+

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.

+
+
+
+

Validating the Model

+

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:

+
    +
  1. The initial plume dimension
  2. +
  3. The length of the zone of flow establishment δ
  4. +
+

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.

+
+
+
+ +
+
+WarningUpdate +
+
+
+

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       ]# z
+
+
+
lfn_prob = ODEProblem(ode_rhs!, lfn_state0, (0.0,150.0), lfn_prms)
+
+
+
lfn_sol = solve(lfn_prob, Tsit5())
+
+
+
+
+
+ +
+
+Figure 11: A recreation of figure 3 from Ooms. +
+
+
+
+

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.

+
+
+

Future Opportunities

+

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.

+
+
+

References

+
+
+Casal, Joaquim. “Atmospheric Dispersion of Toxic or Flammable Clouds.” Amsterdam: Elsevier, 2018. https://app.knovel.com/hotlink/khtml/id:kt0125Q791/evaluation-effects-consequences/atmospheric-dispersion. +
+
+Fan, Loh-Nien. “Turbulent Buoyant Jets into Stratified or Flowing Ambient Fluids.” PhD thesis, California Institute of Technology, 1967. https://doi.org/10.7907/C69V-BE23. +
+
+Havens, Jerry, and Thomas Spicer. “A Dispersion Model for Elevated Dense Gas Jet Chemical Releases.” Office of Air Quality Planning and Standards, US EPA, 1988. https://nepis.epa.gov/Exe/ZyPURL.cgi?DocKey=2000NACQ.txt. +
+
+Keffer, J. F., and W. D. Baines. “The Round Turbulent Jet in a Cross-Wind.” Journal of Fluid Mechanics 15, no. 4 (1963): 481–96. https://doi.org/10.1017/S0022112063000409. +
+
+Ooms, Gijsbert. “A New Method for the Calculation of the Plume Path of Gases Emitted by a Stack.” Atmospheric Environment (1967) 6, no. 12 (1972): 899–909. https://doi.org/10.1016/0004-6981(72)90098-4. +
+
+ + +
+ + + ]]>
+ julia + dispersion modelling + integral plume models + https://aefarrell.github.io/posts/ooms_plume_model/ + Sun, 15 Jun 2025 06:00:00 GMT + +
+ + Logging data from an Atmotube PRO over Bluetooth + Allan Farrell + https://aefarrell.github.io/posts/atmotube_data_logging/ + I have had an Atmotube Pro for a few years, mostly using it during the summer to keep an eye on poor air quality during wildfire smoke events. I often export data from it, as a csv, to noodle around, but I haven’t really looked at how to log data directly from it with my laptop. Atmotube provides documentation on the bluetooth API and a guide for how to set up an MQTT router. But I couldn’t really find anything on just logging data from it directly, using python.

+

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.

+
+

Requesting data with GATT

+

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 time
+
+
+
from 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:

+
    +
  1. The info byte is 8-bits where each bit corresponds to a particular flag. I could pull out each bit one by one using bit-shifting or something, but using a ctype struct lets me map the whole two-byte status characteristic into the various info flags and the battery state in one clean step.
  2. +
+
+
import struct
+
+
+
from 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),
+    ]
+
+
    +
  1. The PM characteristic is a 12-byte sequence where each set of 3-bytes is a 24-bit integer. This is not an integer type that is natively supported by python. I thought I could do the same thing as the Status characteristic and map it onto a ctype struct, but that didn’t work. As a work-around I collect each 3-byte sequence as arrays and convert each to an int as a two-step process. I could also have used int.from_bytes() directly, but I think this is a little neater and easier to read.
  2. +
+
+
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 result
+
+

Now 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.

+
+
+

Collecting broadcast data

+

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 asyncio
+
+
+
async 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 results
+
+

Running 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 result
+
+

I 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 results
+
+

Which I let collect for 5 minutes

+
+
new_broadcasts = await better_collect_data(ATMOTUBE, 300)
+
+
+
+

Processing the broadcast data

+

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 pd
+
+
+
df = pd.DataFrame(new_broadcasts)
+
+
+
df.describe()
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampVOCRHTPPM1PM2.5PM10
count4.100000e+0257.00000057.00000057.057.000000353.0353.000000353.000000
mean1.747674e+090.20322835.10526321.093.3511931.02.0056663.039660
std8.914660e+010.0027970.4505640.00.0045760.00.1845500.246825
min1.747674e+090.19900034.00000021.093.3430001.01.0000002.000000
25%1.747674e+090.20100035.00000021.093.3480001.02.0000003.000000
50%1.747674e+090.20300035.00000021.093.3510001.02.0000003.000000
75%1.747674e+090.20400035.00000021.093.3550001.02.0000003.000000
max1.747674e+090.21000036.00000021.093.3610001.03.0000004.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']
+
+
+
+
+
+
+ +
+
+Figure 1: Time series data of indoor VOC and PM concentrations, a 5 minute sample of BLE advertising data +
+
+
+
+
+

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.

+
+
+

Logging to a CSV

+

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 csv
+
+
+
async 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 True
+
+
+
+
+ +
+
+Warning +
+
+
+

The 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 math
+
+
+
now = 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()
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampVOCRHTPPM1PM2.5PM10
count4.823000e+03835.000000835.000000835.0835.0000003988.03988.0000003988.000000
mean1.747676e+090.22652234.81077821.093.3205901.01.9190072.945587
std1.037307e+030.0128840.7114200.00.0180190.00.3287260.325025
min1.747674e+090.19500034.00000021.093.2830001.01.0000002.000000
25%1.747675e+090.21700034.00000021.093.3030001.02.0000003.000000
50%1.747676e+090.23000035.00000021.093.3250001.02.0000003.000000
75%1.747677e+090.23700035.00000021.093.3370001.02.0000003.000000
max1.747678e+090.24900037.00000021.093.3550001.03.0000004.000000
+ +
+
+
+
+
logged_data['Time'] = logged_data['Timestamp'] - logged_data.iloc[0]['Timestamp']
+
+
+
+
+
+
+ +
+
+Figure 2: Time series data of indoor VOC and PM concentrations, a 1-hr sample of BLE advertising data +
+
+
+
+
+

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, ppmAQSAir quality health index (AQHI) - CanadaTemperature, °CHumidity, %Pressure, kPaPM1, ug/m3PM2.5, ug/m3PM2.5 (avg 3h), ug/m3PM10, ug/m3PM10 (avg 3h), ug/m3LatitudeLongitude
count66.00000066.00000066.066.066.00000066.00000066.066.00000066.00000066.00000066.0000000.00.0
mean0.23998585.0454551.021.034.48484893.3163641.01.5303031.5591752.5454552.861027NaNNaN
std0.0185631.1560120.00.00.7694640.0198170.00.5029050.0361290.5017450.041909NaNNaN
min0.21200082.0000001.021.033.00000093.2800001.01.0000001.4666672.0000002.722222NaNNaN
25%0.22825085.0000001.021.034.00000093.3000001.01.0000001.5500002.0000002.866667NaNNaN
50%0.23800085.0000001.021.034.50000093.3200001.02.0000001.5611113.0000002.877778NaNNaN
75%0.24475086.0000001.021.035.00000093.3375001.02.0000001.5833333.0000002.888889NaNNaN
max0.29500087.0000001.021.036.00000093.3500001.02.0000001.6166673.0000002.888889NaNNaN
+ +
+
+
+
+
from datetime import datetime
+
+
+
export_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']
+
+
+
+
+
+
+ +
+
+Figure 3: Time series data of indoor temperature and pressure, a 1-hr sample of BLE advertising data and data exported from the Atmotube app +
+
+
+
+
+

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.

+
+
+
+
+
+ +
+
+Figure 4: Time series data of indoor VOC concentrations, a 1-hr sample of BLE advertising data and data exported from the Atmotube app +
+
+
+
+
+

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.

+
+
+
+
+
+ +
+
+Figure 5: Time series data of indoor PM2.5 concentrations, a 1-hr sample of BLE advertising data and data exported from the Atmotube app +
+
+
+
+
+

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.

+
+
+

Final Thoughts

+

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!

+
+
+

Update

+
+
+
+ +
+
+TipUpdate +
+
+
+

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 = ts
+
+
+
class 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 None
+
+
+
class 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/1000
+
+
+
class 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/1000
+
+

With 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:

+
    +
  1. The collector starts up and scans for the atmotube, by MAC address.
  2. +
  3. When it finds the device it requests notifications for one of the GATT characteristics, in this case I am requesting the status data and the SPS30 data, which contains the pm concentrations.
  4. +
  5. The collector then waits around for the 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 queue
  6. +
+
+
async 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:

+
    +
  1. When the logger starts it creates a new csv file with the given filename, and writes the column headers.
  2. +
  3. The worker waits for data to appear on the queue and, once it does, takes it out (first in first out).
  4. +
  5. The result from the queue is lined up to the right columns in the csv, I check for the attribute battery_level as a lazy check of which type of result it is.
  6. +
  7. Finally the worker writes the result as new row on the csv.
  8. +
  9. If the result is None, that is a signal that the collector has finished and the loop exits.
  10. +
  11. Regardless, once the logger has processed the data from the queue, it calls task_done() to notify the queue of this and the loop begins again.
  12. +
+
+
import aiofiles, aiocsv
+
+
+
HEADERS = ["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:

+
    +
  1. Create an empty asyncio Queue
  2. +
  3. Start the logger, the worker that logs results to the csv
  4. +
  5. Start the collector, the worker that collects packets from the atmotube
  6. +
  7. Wait for the collector to finish, then close.
  8. +
+
+
async 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 collector
+
+

I 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()
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampPM SensorPM1PM2.5PM10Time
01.748482e+09NaN10.9213.4314.970.000000
11.748482e+09NaN10.9313.0314.662.610108
21.748482e+09NaN11.0513.4215.195.220881
31.748482e+09NaN11.3513.7515.347.784888
41.748482e+09NaN11.5914.1315.5010.395001
+ +
+
+
+
+
+
+
+
+ +
+
+Figure 6: Time series data of indoor PM2.5 concentrations, a 1-hr sample using GATT notifications +
+
+
+
+
+

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]
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampPM SensorPM1PM2.5PM10Time
361.748482e+09NaN12.4214.3415.23127.802146
371.748482e+090.0NaNNaNNaN130.322095
381.748482e+09NaNNaNNaNNaN130.322191
391.748483e+091.0NaNNaNNaN1035.107224
401.748483e+09NaN7.749.5510.211040.146906
411.748483e+09NaN7.819.8211.401042.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.

+ + +
+ + + ]]>
+ python + air quality + atmotube + https://aefarrell.github.io/posts/atmotube_data_logging/ + Mon, 19 May 2025 06:00:00 GMT +
+ + Mapping Pollen Dispersion + Allan Farrell + https://aefarrell.github.io/posts/pollen_dispersion/ + It has been a beautiful spring in Edmonton and the trees are tentatively flowering and throwing pollen to the wind. Watching the trees come back from their barren winter state left me wondering about the wind dispersal of pollen. Pollen grains must be small enough to travel quite a distance to encounter any other trees to pollinate, but they do settle out eventually. So, how far do they actually go? I’ve been wanting to play around with the mapping tools in the julia ecosystem, and this question gave me an opportunity to make some maps exploring pollen dispersal in my neighbourhood.

+

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).

+
+
+
+ +
+
+NoteSome Notes on Unitful +
+
+
+

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:

+
    +
  1. What units am I using for pollen dispersal?
  2. +
  3. How will I be handling all the correlations?
  4. +
+

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 Unitful
+
begin
+
+@unit grains "grains" Grains (1/6.02214076e23)*u"mol" true;
+Unitful.register(@__MODULE__);
+
+end
+

Correlations 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;
+
+
+
+

Building a Model of Pollen Dispersion

+

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.

+
+
+
+ +
+
+Figure 1: A sketch of a single Elm tree as an elevated point source. +
+
+
+
+

A Model Elm Tree

+

There are a few things I will need to know about each elm tree in the neighbourhood:

+
    +
  1. The height at which pollen is released
  2. +
  3. The rate at which pollen is released
  4. +
+

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)/Δt
+

For the example tree, this gives a pollen release rate of 317,529 grains s^-1.

+
+
+

Pollen Settling

+

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.

+
+
+
+ +
+
+Figure 2: A single pollen grain as a solid sphere falling at terminal velocity. +
+
+
+
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

+
+
+

Atmospheric Dispersion

+

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"
+
+
+

The Ermak Equation

+

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: erfc
+
function 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)

+
+
+
+ +
+
+Figure 3: Plan view of ground level pollen concentration downwind of the model Elm tree. +
+
+
+
+
+
+ +
+
+Figure 4: Elevation view of pollen concentration downwind of the model Elm tree, through the centre of the plume (y=0). +
+
+
+

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.

+
+
+

A Tree Data Structure

+

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;
+
+
+
+

Mapping Elm Pollen in Wîhkwêntôwin

+

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.

+
+

Defining the Local Grid

+

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 Geodesy
+
begin
+
+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
+end
+
function local_coords(lat,lon)
+    x = (lon - lonₒ)*Δx
+    y = (lat - latₒ)*Δy
+    return x, y
+end
+
+
+
+ +
+
+NoteWhy not use Web Mercator? +
+
+
+

At 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)
+
+end
+
dist = 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)
+
+end
+
wm_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.

+
+
+
+
+

Finding the Neighbourhood Elm Trees

+

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, DataFrames
+
trees_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
+
+end
+

There 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) );
+
+
+

Mapping Wîhkwêntôwin

+

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 Tyler
+
wihkwentowin = 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)
+end
+

Mapping 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.

+
+
+
+ +
+
+Figure 5: Satellite view of Wîhkwêntôwin and surrounding area with neighbourhood Elm trees indicated with blue circles. +
+
+
+
+
+

Mapping the Pollen from all Elm Trees

+

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"))
+end
+

I 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

+
+
+
+ +
+
+Figure 6: Satellite view of Wîhkwêntôwin and surrounding area with pollen concentrations >10 grains m^-3 overlaid. +
+
+
+

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.

+
+
+
+

References

+
+
+Briggs, Gary A. “Diffusion Estimation for Small Emissions. Preliminary Report.” Oak Ridge, TN: Air Resources Atmospheric Turbulence; Diffusion Laboratory, National Oceanic; Atmospheric Administration, 1973. https://doi.org/10.2172/5118833. +
+
+Brush, Grace S., and Lucien M. Brush Jr. “Transport of Pollen in a Sendiment-Laden Channel: A Laboratory Study.” American Journal of Science 272, no. 4 (1972): 359–81. +
+
+Ermak, Donald L. “An Analytical Model for Air Pollutant Transport and Deposition from a Point Source.” Atmospheric Environment 11 (1977): 231–37. https://doi.org/10.1016/0004-6981(77)90140-8. +
+
+Griffiths, R. F. “Errors in the Use of the Briggs Parameterization for Atmospheric Dispersion Coefficients.” Atmospheric Environment 28, no. 17 (1994): 2861–65. https://doi.org/10.1016/1352-2310(94)90086-8. +
+
+Katz, Daniel S. W., Jonathan R. Morris, and Stuart A. Batterman. “Pollen Production for 13 Urban North American Tree Species: Allometric Equations for Tree Trunk Diameter and Crown Area.” Astrobiologia (Bologna) 36, no. 3 (2020). https://doi.org/10.1007/s10453-020-09638-8. +
+
+McPherson, E. Gregory, Natalie S. van Doorn, and Paula J. Peper. “Urban Tree Database and Allometric Equations.” Albany, CA: U. S. Department of Agriculture, Forest Service, Pacific Southwest Research Station, 2016. https://doi.org/10.2737/PSW-GTR-253. +
+
+ + +
+ + + ]]>
+ julia + dispersion modelling + pollen + https://aefarrell.github.io/posts/pollen_dispersion/ + Sat, 10 May 2025 06:00:00 GMT +
+ + Vessel Blowdown - Real Gases + Allan Farrell + https://aefarrell.github.io/posts/vessel_blowdown_real_gases/ + Continuing on from where I left off previously, examining vessel blowdown, it is time to implement real gases. I left the ideal gas case promising that implementing a real gas was easy, well now is the time to prove it. Instead of implementing real gas equations of state myself, I am going to use Clapeyron.jl but, as a first step, it is worthwhile to consider how the problem can be divided up into sub-problems and what data structures would be the most useful. I would like to write code that is general enough that any equation of state can be used with minimal changes. With that in mind, I am going to consider the problem as being composed of three distinct subsets: the vessel, the fluid model, and the ambient conditions.

+
+

Data Structures

+

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)...)
+
+end
+

Abstracting 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)...)
+
+end
+

Finally, a data structure for blowdown solutions is useful for dispatch.

+
struct Blowdown{S}
+    pv::PressureVessel
+    env::Environment
+    sol::S
+end
+
Base.length(::Blowdown) = 1
+
Base.iterate(b::Blowdown, state=1) = state > length(b) ? nothing : (b,state+1)
+
+
+

Equations of State

+
+

Ideal gases

+

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
+    
+end
+

A 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
+
+end
+

The 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/v
+
volume(model::IdealGas, P, T) = model.R*T/P
+
molecular_weight(model::IdealGas) = model.MW
+
molar_enthalpy(model::IdealGas, v, T) = model.cₚ*T
+
molar_entropy(model::IdealGas, v, T) =
+ model.cᵥ*log(T) + model.R*log(v)
+
molar_internal_energy(model::IdealGas, v, T) = model.cᵥ*T
+
speed_of_sound(model::IdealGas, v, T) =
+ (model.k*model.R*T/model.MW)
+
+
+

Real Gases with Clapeyron.jl

+

The 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 Clapeyron
+
pressure(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)
+
+
+
+

Isentropic Nozzle Flow

+

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 NonlinearSolve
+
function 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
+end
+
choked_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
+end
+
non_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ₜ
+end
+
+
+

Adiabatic Blowdown

+
+

The Pressure Equation

+

The 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
+end
+

The 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, DiffEqCallbacks
+
function adiabatic_vessel!(dy, y, prms, t)
+    P, v, T = y
+    
+= speed_of_sound(prms.model, v, T)^2
+    w = mass_flow(prms.model, prms.pv, prms.env, v, T)
+
+    dy .= [-w*/prms.pv.V
+            v - volume(prms.model, P, T)
+            prms.init - molar_entropy(prms.model, v, T) ]
+    return nothing
+end
+
abd_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.P
+

The 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))
+end
+

From 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)
+end
+
function blowdown_temperature(bd::Blowdown{<:PressureODE}, t)
+    bdt = blowdown_time(bd)
+    t = min(t, bdt)
+    return bd.sol.ode_sol(t; idxs=3)
+end
+
+

The Ideal Gas Choked Flow Model

+

The 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
+end
+
function 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,τ))
+end
+
function 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))
+end
+
function 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))
+end
+
function 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)
+end
+
+
+

Checking our work

+

The 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)
+end
+

The real gas is modelled using a volume translated Peng-Robinson equation of state.

+
using Clapeyron:PR, ReidIdeal, RackettTranslation
+
nitrogen = 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);
+
+
+
+ +
+
+Figure 1: The adiabatic blowdown curve for a tank of nitrogen, showing the fully choked ideal gas model model and the DAE solutions for both the ideal gas and real gas case. +
+
+
+

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.

+
+
+
+ +
+
+Figure 2: The vessel temperature for the nitrogen blowdown, showing the fully choked ideal gas model model and the DAE solutions for both the ideal gas and real gas case. +
+
+
+

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.

+
+
+
+

The Energy Equation

+

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 .

+
+

The Adiabatic Ideal Gas Case

+

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:

+

+
+
+

Implementing the DAE

+

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
+end
+
ueqn_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.P
+

To 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 DataInterpolations
+
struct EnergyODE{S,I}
+    ode_sol::S
+    p_interp::I
+end
+
function 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))
+end
+

The 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)
+end
+
function blowdown_temperature(bd::Blowdown{<:EnergyODE}, t)
+    bdt = blowdown_time(bd)
+    t = min(t, bdt)
+    return bd.sol.ode_sol(t; idxs=3)
+end
+

The 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);
+
+
+
+ +
+
+Figure 3: The blowdown curve for the nitrogen blowdown, showing the DAE solutions for both the pressure model and the energy balance model (ideal gas and real gas cases). The curves for the pressure model and energy balance model overlap. +
+
+
+
+
+
+ +
+
+Figure 4: The vessel temperature for the nitrogen blowdown, showing the DAE solutions for both the pressure model and the energy balance model (ideal gas and real gas cases). The curves for the pressure model and energy balance model overlap. +
+
+
+
+
+
+

Performance

+

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.

+
+
+
+ +
+
+Figure 5: The blowdown curve for the pressure model when molar volume is moved to the RHS of the ODE. The pressure model curves have a weird bump at the end. +
+
+
+

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.

+
+
+
+

Conclusions

+

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.

+ + +
+ + ]]>
+ julia + compressible flow + blowdown + equations of state + https://aefarrell.github.io/posts/vessel_blowdown_real_gases/ + Wed, 19 Mar 2025 06:00:00 GMT +
+ + Vessel Blowdown - Ideal Gases + Allan Farrell + https://aefarrell.github.io/posts/vessel_blowdown_ideal_gases/ + A recurring task of mine is to look at some old calculations, done by some previous engineer whose identity is lost to time and organizational flux, and update them to match current reality. Depending on the state of the spreadsheet, and its lack of documentation, this can also mean going down a rabbit hole of research to find where, exactly, a given equation came from and what all the constants in it represent. This post is the result of one of those journeys, trying to track down the source of a model for depressuring a vessel.

+
+
+
+ +
+
+Figure 1: A vessel blowdown scenario, discharging from vessel pressure (1), through an isentropic valve and into the atmosphere (2). +
+
+
+

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

+

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”.

+
+

Fully Choked Flow

+

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.

+
+
+

In the Literature

+

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.

+
+
+ +
+
+Note +
+
+
+

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.

+
+
+

The Complete ODE

+

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)))
+= ρ*P*(2k/(k-1))*( η^(2/k) - η^((k+1)/k) )
+    G => 0 ? (G²) : 0
+    return G
+end
+
function speed_of_sound(P, ρ; k=1.4)
+    a = (k*P/ρ)
+    return a
+end
+
function adiabatic_vessel(P, params, t)
+    c, A, V, k, ρ₀, P₀, Pₐ = params
+    ρ = ρ₀*(P/P₀)^(1/k)
+= speed_of_sound(P, ρ; k=k)^2
+    G = isentropic_mass_flow(P, ρ; k=k, Pₐ=Pₐ)
+    return-c*A**G/V
+end
+

with 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ₐ
+end
+
+
+

A Motivating Example

+

Just 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, Plots
+
begin
+    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;
+
+
+
+ +
+
+Figure 2: The adiabatic blowdown curve for a fully charged SCUBA tank, showing both the fully choked model and the ODE solution. +
+
+
+

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 end
+
struct AdiabaticBlowdown{S} <: Blowdown
+    pv::PressureVessel
+    sol::S
+end
+

Here 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) = 1
+
Base.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))
+end
+
function 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))
+end
+
function 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))
+end
+

For 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)
+end
+
function blowdown_pressure(bd::AdiabaticBlowdown{<:ODESolution}, t)
+    if t < blowdown_time(bd)
+        return bd.sol(t)
+    else
+        return bd.sol.u[end]
+    end
+end
+
function 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
+end
+
blowdown_time(bd::AdiabaticBlowdown{<:ODESolution}) = 
+    bd.sol.t[end]
+
+
+
+ +
+
+Figure 3: The adiabatic blowdown curves for a fully charged SCUBA tank, showing both the fully choked model and the ODE solution for pressure (top) and mass flow rate (bottom). +
+
+
+

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ₐ);
+
+
+
+ +
+
+Figure 4: The adiabatic blowdown curve for a partially charged SCUBA tank, showing both the fully choked model and the ODE solution. +
+
+
+

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 Isothermal Case

+

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”.

+
+

Fully Choked Flow

+

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

+
+
+

In the Literature

+

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

+
    +
  • Blowdown time, t, in seconds
  • +
  • Vessel volume, V, in cubic feet
  • +
  • Valve flow area, A, in square inches
  • +
  • Initial temperature, , in Rankine
  • +
  • Initial pressure, , in psia
  • +
  • Ambient pressure, , in psia
  • +
+

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
+end
+
isothermal_blowdown_choked(vessel::PressureVessel) = 
+    IsothermalBlowdown(vessel,nothing)
+
function blowdown_pressure(bd::IsothermalBlowdown, t)
+    P₀, τ = bd.pv.P₀, bd.pv.τ
+    return P₀*exp(-t/τ)
+end
+
function blowdown_mass_rate(bd::IsothermalBlowdown, t)
+    ρ₀, V, τ = bd.pv.ρ₀, bd.pv.V, bd.pv.τ
+    m₀ = ρ₀*V
+    w₀ = m₀/τ
+    return w₀*exp(-t/τ)
+end
+
blowdown_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₀)
+= speed_of_sound(P, ρ; k=k)^2
+    G = isentropic_mass_flow(P, ρ; k=k, Pₐ=Pₐ)
+    return-c*A**G/(k*V)
+end
+
function 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)
+end
+
function blowdown_pressure(bd::IsothermalBlowdown{<:ODESolution}, t)
+    if t < blowdown_time(bd)
+        return bd.sol(t)
+    else
+        return bd.sol.u[end]
+    end
+end
+
function 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
+end
+
blowdown_time(bd::IsothermalBlowdown{<:ODESolution}) = 
+    bd.sol.t[end]
+
+
+
+ +
+
+Figure 5: The isothermal blowdown curves for a fully charged SCUBA tank, showing both the fully choked model and the ODE solution for pressure (top) and mass flow rate (bottom). +
+
+
+

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:

+
    +
  1. the gases are assumed be ideal with constant k
  2. +
  3. the vessel is perfectly isothermal (or adiabatic)
  4. +
+

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.

+
+
+
+

Comparing Blowdown Models

+

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.

+
+
+
+ +
+
+Figure 6: The adiabatic and isothermal blowdown curves for a fully charged SCUBA tank, in dimensionless form. +
+
+
+

In the high pressure case the blowdown terminates much closer to and most of the curve is fully choked.

+
+
+
+ +
+
+Figure 7: The adiabatic and isothermal blowdown curves for a partially charged SCUBA tank, in dimensionless form. +
+
+
+

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.

+
+
+

Final Thoughts

+

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:

+
    +
  1. the speed of sound
  2. +
  3. the density as a function of pressure, either along an isentropic path (in the adiabatic case) or along an isothermal path
  4. +
  5. the isentropic mass velocity, G
  6. +
+

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.

+
+
+

References

+
+
+Botros, K. K., W. M. Jungowski, and M. H. Weiss. “Models and Methods of Simulating Gas Pipeline Blowdown.” The Canadian Journal of Chemical Engineering 67 (1989): 529–39. https://doi.org/10.1002/cjce.5450670402. +
+
+Botros, Kamal K., and Thomas Van Hardeveld. Pipeline Pumping and Compression Systems - a Practical Approach. 3rd ed. New York: ASME Press, 2018. +
+
+Campbell, John M. Gas Conditioning and Processing. Vol. 2. Tulsa, OK: John M. Campbell & Co, 1992. +
+
+Crowl, Daniel A., Lawrence G. Britton, Walter L. Frank, Stanley Grossel, Dennis Hendershot, W. G. High, Robert W. Johnson, et al. “Process Safety.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+Engineers Edge. “Blowdown Time in Unsteady Gas Flow Calculator and Equation,” 2025. https://www.engineersedge.com/calculators/blowdown_time_in_unsteady_gas_16011.htm. +
+
+Laidler, Keith J., John H. Meiser, and Bryan C. Sanctuary. Physical Chemistry. 4th ed. Boston, MA: Houghton Mifflin Co, 2003. +
+
+Lees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996. +
+
+Saad, Michel A. Compressible Fluid Flow. Englewood Cliffs, NJ: Prentiss-Hall, 1985. +
+
+Temizel, Cenk, Tayfun Tuna, Mehmet Melik Oskay, and Luigi Saputelli. Formulas and Calculations for Petroleum Engineering. Cambridge, MA: Gulf Professional Publishing, 2019. +
+
+Tilton, James N. “Fluid and Particle Dynamics.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+VANEC. “Pressure Volume-Blowdown Time Calculation,” 2025. https://www.vanec.com/pressurized-volume-blowdown-time-calculation.html. +
+
+Wheeler, Dean R. “Tank Blowdown Math,” 2019. http://www.et.byu.edu/~wheeler/Tank_Blowdown_Math.pdf. +
+
+ + +
+ + + ]]>
+ julia + compressible flow + blowdown + https://aefarrell.github.io/posts/vessel_blowdown_ideal_gases/ + Fri, 24 Jan 2025 07:00:00 GMT +
+ + Relief Valve Sizing with Real Gases + Allan Farrell + https://aefarrell.github.io/posts/relief_valve_sizing/ + Very often, in chemical engineering, the line between problems one can solve one’s self and problems that are solved with a piece of commercial software is when ideal fluid assumptions break down. Relief valve sizing is a typical example: if the fluid is (approximately) an ideal gas then sizing is simple and often done in a spreadsheet. When this isn’t the case, if the compressiblity is <0.8 or >1.1,1 then one typically has to turn to some commercial software. Models of real fluids are complicated and extracting the relevant thermodynamic properties from them can be quite tedious when doing it all from scratch.

+

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.

+
+

Sizing a Pressure Relief Valve

+

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:

+
    +
  1. Determine the governing release rate,
  2. +
  3. Determine the capacity correction,
  4. +
  5. Calculate the mass flux through the valve,
  6. +
  7. Calculate the theoretical flow area
  8. +
  9. Select the appropriate valve with a flow area .
  10. +
+

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.

+
+
+
+ +
+
+Figure 1: A hypothetical pressure relief device, connected to a pressure reservoir (1) and discharging into the atmosphere (2). +
+
+
+

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.

+
+

Choked Flow

+

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.

+
+
+
+

A Motivating Example

+

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 Unitful
+
begin
+# the vessel properties
+    P₁ = 200u"bar"
+    T₁ = 400u"K"
+
+# the ambient properties
+    P₂ = 1u"bar"
+    T₂ = 288.15u"K"
+end
+

We 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 Clapeyron
+
begin
+# 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.

+
+
+

The Ideal Gas Case

+

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

+

+
+
+
+ +
+
+Note +
+
+
+

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
+end
+

From 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ₜ²)
+end
+

The 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.

+
+
+

The Isentropic Expansion Factor

+

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
+end
+

Using 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.

+
+
+
+ +
+
+Figure 2: The isentropic expansion factor for ethane at 400K, calculated for a range of stagnation pressures. +
+
+
+

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.

+
+
+

Solving the Choked Flow Energy Balance

+

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
+= speed_of_sound(prms.model, P, T, prms.z)^2
+    
+    return [ s₁ - s₂
+             h₁ - h₂ - 0.5*c² ]
+end
+
using NonlinearSolve
+
function 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ₜ
+end
+
function 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"
+end
+

Solving 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.

+
+
+
+ +
+
+Figure 3: The isentropic paths for the ideal gas, effective isentropic factor, and true isentropic path methods. +
+
+
+

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

+

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 DifferentialEquations
+
function 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) ]
+end
+

But 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
+end
+
function 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)
+end
+
function 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"
+end
+

Direct 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.

+
+
+
+ +
+
+Figure 4: The mass flux as a function of nozzle pressure drop, showing the intermediate steps until a maximum was found. +
+
+
+

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.

+
+
+

Comparing the Results

+

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.

+
+
+
+ +
+
+Figure 5: A comparison of calculated theoretical mass flux for the six methods. The results from the first law energy balance and direct integration are identical. +
+
+
+

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.

+
+
+

Final Thoughts

+

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.

+
+
+

References

+
+
+API. Standard 520, Sizing, Selection, and Installation of Pressure-Relieving Devices, Part i - Sizing and Selection, 10th Ed. Washington, DC: American Petroleum Institute, 2020. +
+
+Chemical Process Safety, Center for. Guidelines for Pressure Relief and Effluent Handling Systems. Hoboken, NJ: John Wiley & Sons, 2017. +
+
+Crowl, Daniel A., Lawrence G. Britton, Walter L. Frank, Stanley Grossel, Dennis Hendershot, W. G. High, Robert W. Johnson, et al. “Process Safety.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green. New York: McGraw Hill, 2008. +
+
+Gmehling, Jürgen, Bärbel Kolbe, Michael Kleiber, and Jürgen Rary. Chemical Thermodynamics for Process Simulation. Weinheim, DE: Wiley-VCH Verlag & Co., 2012. +
+
+Green, Don W., ed. Perry’s Chemical Engineers’ Handbook. New York: McGraw Hill, 2008. +
+
+Tilton, James N. “Fluid and Particle Dynamics.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green. New York: McGraw Hill, 2008. +
+
+ + +
+ + + ]]>
+ julia + pressure relief + compressible flow + equations of state + https://aefarrell.github.io/posts/relief_valve_sizing/ + Mon, 28 Oct 2024 06:00:00 GMT +
+ + Modelling Hydrogen Releases Using HyRAM+ + Allan Farrell + https://aefarrell.github.io/posts/hydrogen_release_modeling/ + Continuing on a series of posts on hydrogen of sorts, this post is on modelling hydrogen releases for risk assessment. Industry in many places, and in Alberta in particular, is looking to hydrogen as a key component of the transition to a low carbon future. This means that, suddenly, there will by hydrogen pressure equipment in a lot of process areas where it wasn’t before, as fuel gas.

+

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.

+
+

The Scenario

+

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.

+
+
+
+ +
+
+Figure 1: A sketch of the scenario: a hydrogen gas release from a fallen cylinder. +
+
+
+

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 hp
+
+
+
Ta, 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)
+
+
+

Modeling the Jet

+

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.

    +
  1. Orifice flow in which the release occurs isentropically through an orifice. HyRAM+ uses the CoolProp library to perform this calculation for the real fluid (unlike many other models which assume an ideal gas for simplicity). For most situations, such as with this example, the flow will be choked and the jet will enter the atmosphere at sonic velocity (Ma = 1) and under-expanded (i.e. the pressure in the jet is above atmospheric)
  2. +
  3. Notional nozzle the under-expanded jet then expands to ambient pressure. HyRAM+ models this as occuring adiabatically and with no entrainment of ambient air. The expansion occurs across what is termed a notional nozzle as it is modeled as a nozzle stepping down the jet to ambient pressure, assuming isentropic expansion. The notional nozzle is assumed to be of negligible size, so this step is really about calculating the initial conditions for the actual dispersion.
  4. +
  5. Gaussian jet at the end of the notional nozzle, and assuming that no cryogenic effects need to be corrected for, the jet is assumed to follow a self-similar Gaussian profile in both velocity and concentration. It is the same Gaussian model I discussed previously for a turbulent jet, however in that case the jet center-line was simply a straight line. In this case the center-line follows a curve through space which needs to be solved for. This is done using an integral plume model not unlike the Ooms model4 which accounts for entrainment, conservation of momentum, and conservation of mass.
  6. +
+

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_rate
+
+
0.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_jet
+
+
0.37797876222402915
+
+
+
+
jet.mass_flow_rate/ideal_gas_jet
+
+
1.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.

+
+
+

Calculating Downstream Distances

+

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).LFL
+
+

and 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
+
+
+
+
+

Plotting the Plume Dispersion

+

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._contourdata
+
+

we can use matplotlib to plot the concentrations and highlight the contour corresponding to the LFL

+
+
+
+
+

+
+
+
+
+
+
+

Doing it the Easy Way

+

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.

+
+
+

Limitations

+

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.

+
+
+
+

Indoor Accumulation

+

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.

+
+
+
+ +
+
+Figure 2: A sketch of the room, the cylinder is supposed to have fallen by one wall. A layer of hydrogen gas accumulates at the ceiling, with the boundary moving downward as more hydrogen accumulates. +
+
+
+
+

Cylinder Blowdown

+

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()
+
+

+
+
+

The Indoor Release

+

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()
+
+

+
+
+

Using the HyRAM+ API

+

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'}
+
+
+

+

+

+

+

+
+
+

Limitations

+

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.

+
+
+
+

Final Thoughts

+

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.

+
+
+

References

+
+
+AIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999. +
+
+Ehrhart, Brian D., Ethan S. Hecht, and Benjamin B. Schroeder. Hydrogen Plus Other Alternative Fuels Risk Assessment Models (HyRAM+) Version 5.1 Technical Reference Manual, 2023. https://doi.org/10.2172/2369637. +
+
+Ehrhart, Brian D., Cianan Sims, Ethan S. Hecht, Benjamin B. Schroeder, Benjamin R. Liu, Katrina M. Groth, John T. Reynolds, and Gregory W. Walkup. “HyRAM+ (Hydrogen Plus Other Alternative Fuels Risk Assessment Models).” Sandia National Laboratories, February 8, 2024. https://hyram.sandia.gov. +
+
+Ooms, G. “A New Method for the Calculation of the Plume Path of Gases Emitted by a Stack.” Atmospheric Environment (1967) 6, no. 12 (1972): 899–909. https://doi.org/10.1016/0004-6981(72)90098-4. +
+
+ + +
+ + + ]]>
+ python + hydrogen + dispersion modelling + https://aefarrell.github.io/posts/hydrogen_release_modeling/ + Sun, 22 Sep 2024 06:00:00 GMT +
+ + Plastics Recycling and Microplastics + Allan Farrell + https://aefarrell.github.io/posts/plastics-recycling-microplastics/ + As perhaps just a hazard of my profession, any time an article comes out on the merits (or lack of) of recycling and plastic waste in general, people send it my way. Several times in the last month I was sent this article in Quillette.1 (and associated YouTube video) about how plastics recycling may be a massive source of the microplastics being discharged into the environment, adding to the long list of reasons why recycling has not lived up to the promises made by industry, and undermining our path towards a more circular economy. At first glance though, some of the numbers presented and the math struck me as rather sus, so I would like to take a moment to dive into it a bit more. tl;dr much the math in that essay doesn’t really work or comes with big caveats, but the broader point about the value of recycling and how we may not be fully appreciating the environmental impacts may hold up.

+

1 Celia, “Recycling Plastic Is a Dangerous Waste of Time”.

+
+
+ +
+
+Note +
+
+
+

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.

+
+
+
+

How Large of a Source of Microplastics is the Recycling Industry?

+

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

    +
  1. that the recycling industry discharges up to 2Mt/y of microplastics into the environment
  2. +
  3. that the total amount of primary microplastics discharged into the environment from all sources is 3Mt/y
  4. +
+
+

The Direct Discharge of Microplastics

+

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.

+ + + + + + + + + + + + + + + + + + + + +
estimatelow end (t/y)high end (t/y)
before filter upgrade962933
after filter upgrade41366
+

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%

+
+

The Total Amount of Primary Microplastics losses

+

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.

+
+
+
+

The Climate Impacts of Landfilling

+

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”.

+
+

Conclusions and Take Aways

+

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.

+
+
+

References

+
+
+Boucher, Julien, and Damien Friot. “Primary Microplastics in the Oceans.” Gland, CH: International Union for Conservation of Nature, 2017. https://doi.org/10.2305/IUCN.CH.2017.01.en. +
+
+Brown, Erina, Anna MacDonald, Steve Allen, and Deonie Allen. “The Potential for a Plastic Recycling Facility to Release Microplastic Pollution and Possible Filtration Remediation Effectiveness.” Journal of Hazardous Materials Advances 10 (2023): 100309. https://doi.org/10.1016/j.hazadv.2023.100309. +
+
+Celia, Frank. “Recycling Plastic Is a Dangerous Waste of Time.” Quillette, June 17, 2024. https://quillette.com/2024/06/17/recycling-plastic-is-a-dangerous-waste-of-time-microplastics-health/. +
+
+Gao, Zhenghui, Hang Qian, Tianyi Cui, Zongqiang Ren, and Xingjie Wang. “Comprehensive Meta-Analysis Reveals the Impact of Non-Biodegradable Plastic Pollution on Methane Production in Anaerobic Digestion.” Chemical Engineering Journal 484 (2024): 149703. https://doi.org/10.1016/j.cej.2024.149703. +
+
+OECD. “Global Plastics Outlook.” Paris: OECD Publishing, 2022. https://doi.org/10.1787/de747aef-en. +
+
+Ryberg, Morten W., Alexis Laurent, and Michael Hauschild. “Mapping of Global Plastics Value Chain and Plastics Losses to the Environment.” Nairobi: United Nations Environment Programme, 2018. https://www.unep.org/resources/report/mapping-global-plastics-value-chain-and-plastics-losses-environment-particular/. +
+
+ + +
+ + + ]]>
+ plastic + https://aefarrell.github.io/posts/plastics-recycling-microplastics/ + Sun, 14 Jul 2024 06:00:00 GMT + +
+ + Engineering a Cup of Coffee Part Two: Espresso + Allan Farrell + https://aefarrell.github.io/posts/engineering_a_cup_of_coffee_part-2/ + In a previous post I thought about how one might approach making coffee, in a French press, as a chemical engineering problem. The obvious next step is to look at percolation methods like espresso, which is what I am exploring here.

+

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.

+
+

Packed Bed Leaching

+

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.

+

Setting up the Governing Equations

+

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.

+
+
+
+ +
+
+Figure 1: The problem domain, a cylindrical puck of coffee. +
+
+
+

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.

+
+

Liquid Phase

+

Zooming in on a thin slice of the packed bed with depth, Δz, we can take a mass balance of the liquid phase.

+
+
+
+ +
+
+Figure 2: Mass transfer in the thin slice of the column. +
+
+
+

The mass flow into the liquid phase can be due to:

+
    +
  • advection, Qc, where c is the liquid phase concentration entering the slice and Q is the volumetric flow rate
  • +
  • axial diffusion, , where Jz is the mass flux at the top of the slice and is the porous area of the top of the slice, i.e. the area available to liquid flux.
  • +
  • mass transfer from the coffee grounds (the solid phase), , where Js is the mass flux of solubles from the grounds into the liquid phase and As is the surface area of the grounds
  • +
+

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

+

+
+
+

Solid Phase

+

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.

+
+
+

Thin Film

+

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.

+

+
+
+
+

Initial Conditions

+

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:

+
    +
  1. The bed is initially full of water, but that water has no coffee extracted into it.
  2. +
  3. The bed is initially full of water, and that water is in equilibrium with the coffee grounds (e.g. fully saturated).
  4. +
+

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:

+
    +
  • the solid concentration in the grounds is the saturation concentration
  • +
  • the liquid concentration in the bulk is, initially, in equilibrium and also saturated
  • +
  • the boundary condition is that the water entering the system has a concentration 0 mg/m3 coffee solubles
  • +
+

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

+
+
+

Determining the Parameters of the System

+

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(ν)
+
+
+

Parameters of the Packed Bed

+

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_shot
+
+
4.177834449522944 s
+
+
+
+
+

Mass Transfer Parameters

+

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 = ν/D
+
+

The 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)/Pe
+
+
4.623616336378663e-8 m^2 s^-1
+
+
+
+
+

Equilibrium Constant

+

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_sat
+
+
0.5555555555555556
+
+
+
+
+

A Packed Bed Data Structure

+

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);
+
+
+
+
+

Anzelius’ Integral Solution

+

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 rate of mass transfer across the thin film dominates, and thus the solid phase diffusion can be neglected
  • +
  • the mass flow into a given slice of the packed bed is dominated by advection, and the axial dispersion can be neglected
  • +
+

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.

+
+

Defining the Anzelius Solution

+

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)
+end
+
+
+
anzelius = AnzeliusSolution(z, pb);
+
+
+
+

Evaluating the Products of Exponentials and Bessel Functions

+

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τ*λ))
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 3: The Anzelius integrand, the upper curve represents the original form which encounters numerical difficulties and fails to return valid answers after the red X, the lower curve is the transformed form of the integrand that does not have these difficulties. +
+
+
+
+
+
+

Integration by Gauss-Kronold

+

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
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 4: Integrating the Anzelius integrand on the interval [0,1] +
+
+
+
+

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.

    +
  • for ξ≤τ, calculate by direct numerical integration
  • +
  • for ξ>τ, calculate by using the relation where is then numerically integrated
  • +
+

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
+end
+
+
+
+

Series Representations of the Anzelius J function

+

A 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
+end
+
+
+
function 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
+end
+
+
+
u, 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
+end
+
+
+
+

Approximations to the Anzelius J Function

+

Thomas19 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, erfc
+
+
+
function 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
+end
+
+

Rice21 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
+end
+
+
+
+

Reviewing Overall Performance

+

I 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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 5: The Anzelius solution and its approximations. For this problem the approximations are essentially exact. +
+
+
+
+

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.

+
+
+
+

Rosen’s Integral Solution

+

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

+
+

Defining the Rosen Solution

+

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 Harmonic Functions

+

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
+end
+
+
+
+

Integration by Gauss-Kronold

+

The 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)
+end
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 6: The integrand of the Rosen solution, for moderate values of τ this is a highly oscillating integral. +
+
+
+
+

By 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)
+end
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 7: The integrand of the Rosen solution, after the change of variables. The curve decays much more rapidly. +
+
+
+
+

QuadGK.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
+
+
+
+
+

Integration by Levin Colocation

+

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)
+end
+
+
+
levin(ξ, τ, ν, 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.

+
+
+

Pre-calculating the spatial component

+

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, τ; ξ=ξ, ν=ν) )/2
+
+
0.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...) )
+end
+
+

One 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.

+
+

Packaging a final approach

+

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)
+end
+
+

The 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
+end
+
+
+
rosen = RosenSolution(z, pb; a=0.0, b=2.0, numpts=200);
+
+
+
+

Approximations to the Rosen Integral

+

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
+end
+
+
+
+

Approximating the Rosen integral with the Anzelius J Function

+

I 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.𝒟ₛ) )
+
+
+
+

Reviewing Overall Performance

+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 8: The Rosen solution, and it’s approximations, compared with the Anzelius solution. Note the approximations are essentially exact for this problem. +
+
+
+
+
+
+
+

Rasmuson’s Integral Solution

+

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)))
+end
+
+

Rasmuson 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 = σ*t
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 9: The integrand of the Rasmuson-Neretnieks solution, for moderate values of y this becomes a highly oscillating integral. +
+
+
+
+

Thus 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)
+end
+
+
+
rasmuson = 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
+end
+
+

Going 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).

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 10: The Rasmuson-Neretnieks solution for packed bed extraction, compared with the Rosen and Anzelius cases. +
+
+
+
+
+
+

Integrating the PDE by finite difference

+

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 τ.

+
+
+
+ +
+
+Figure 11: A discretized mass transfer system, the column is divided into n thin slices and each slice is further subdivided into m+1 cells. +
+
+
+

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.

+
+

The Anzelius Example Case

+

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
+=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/2
+    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/2
+        M[i,i] = -h_dm/m
+        M[i,i+1] = -v_dm/2
+        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/
+    M[n,n] = -v_dm/- 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
+end
+
+

The ode for this system is linear and is simply

+

+
+
function rhs!(du,u,M,t)
+    du .= M*u
+end
+
+

Which 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.retcode
+
+
ReturnCode.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
+end
+
+

Below 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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 12: Method of Lines with increasing n, converging slowly to the exact (Anzelius) solution. +
+
+
+
+

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.

+
+
+
+

Conclusion

+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 13: The impact of particle size on espresso extraction. +
+
+
+
+

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.

+
+
+

References

+
+
+Anzelius, A. Über Erwärmung Vermittels Durchströmender Medien.” Zeitschrift für Angewandte Mathematik Und Mechanik. 6, no. 4 (1926): 291–94. https://doi.org/10.1002/zamm.19260060404. +
+
+Bac̆lić, Branislav, Dus̆an Gvozdenac, and Gordan Gragutinović. “Easy Way to Calculate the Anzelius-Schumann j Function.” Thermal Science 1, no. 1 (1997): 109–16. +
+
+Bird, R. Byron, Warren E. Stewart, and Edwin N. Lightfoot. Transport Phenomena. 2nd ed. Hoboken, NJ: John Wiley & Sons, 2007. +
+
+Cameron, Michael I., Dechen Morisco, Daniel Hofstetter, Erol Uman, Justin Wilkinson, Zachary C. Kennedy, Sean A. Fontenot, William T. Lee, Christopher H. Hendon, and Jamie M. Foster. “Systematically Improving Espresso: Insights from Mathematical Modeling and Experiment.” Matter 2, no. 3 (2020): 631–48. https://doi.org/10.1016/j.matt.2019.12.019. +
+
+Carslaw, Horatio S., and John C. Jaeger. Conduction of Heat in Solids. 2nd ed. London: Oxford University Press, 1959. +
+
+Gagné, Jonathan. The Physics of Filter Coffee. Scott Rao, 2020. +
+
+Goldstein, Sydney. “On the Mathematics of Exchange Processes in Fixed Columns i. Mathematical Solutions and Asymptotic Expansions.” Proceedings of the Royal Society of London. Series A. Mathematical and Physical Sciences 219, no. 1137 (1953): 151–71. https://doi.org/10.1098/rspa.1953.0137. +
+
+Green, Don W., ed. Perry’s Chemical Engineers’ Handbook. 8th ed. New York: McGraw Hill, 2008. +
+
+Hottel, Hoyt C., James J. Noble, Adel F. Sarofim, Geoffrey D. Silcox, Phillip C. Wankat, and Kent S. Knaebel. “Heat and Mass Transfer.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+Lassey, Keith R. “On the Computation of Certain Integrals Containing the Modified Bessel Function .” Mathematics of Computation 39, no. 160 (1982): 625–37. https://doi.org/10.1090/s0025-5718-1982-0669654-6. +
+
+LeVan, M. Douglas, and Giorgio Carta. “Adsorption and Ion Exchange.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+Moroney, K. M., W. T. Lee, S. B. G. O׳Brien, F. Suijver, and J. Marra. “Modelling of Coffee Extraction During Brewing Using Multiscale Methods: An Experimentally Validated Model.” Chemical Engineering Science 137 (2015): 216–34. https://doi.org/10.1016/j.ces.2015.06.003. +
+
+Moroney, Ken AND Meikle-Janney, Kevin M. AND O’Connell. “Analysing Extraction Uniformity from Porous Coffee Beds Using Mathematical Modelling and Computational Fluid Dynamics Approaches.” PLOS ONE 14, no. 7 (July 2019): 1–24. https://doi.org/10.1371/journal.pone.0219906. +
+
+Rasmuson, Anders, and Ivars Neretnieks. “Exact Solution of a Model for Diffusion in Particles and Longitudinal Dispersion in Packed Beds.” AIChE Journal 26, no. 4 (1980): 686–90. https://doi.org/10.1002/aic.690260425. +
+
+Rice, R. G. “Letters to the Editor.” AIChE Journal 26, no. 2 (1980): 334. https://doi.org/10.1002/aic.690260241. +
+
+Rosen, J. B. “General Numerical Solution for Solid Diffusion in Fixed Beds.” Industrial & Engineering Chemistry 46, no. 8 (1954): 1590–94. https://doi.org/10.1021/ie50536a026. +
+
+———. “Kinetics of a Fixed Bed System for Solid Diffusion into Spherical Particles.” The Journal of Chemical Physics 20, no. 3 (1952): 387–94. https://doi.org/10.1063/1.1700431. +
+
+Rousseau, Ronald W., ed. Handbook of Seperation Process Technology. Hoboken, NJ: John Wiley & Sons, 1987. +
+
+Schumann, T. E. W. “Heat Transfer: A Liquid Flowing Through a Porous Prism.” Journal of the Franklin Institute 208, no. 3 (1929): 405–16. https://doi.org/10.1016/S0016-0032(29)91186-8. +
+
+Schwartzberg, Henry G. “Leaching – Organic Materials.” In Handbook of Seperation Process Technology, edited by Ronald W. Rousseau. Hoboken, NJ: John Wiley & Sons, 1987. +
+
+Seader, J. D., Ernest J. Henley, and D. Keith Roper. Separation Process Principles. 3rd ed. Hoboken, NJ: John Wiley & Sons, 2011. +
+
+Thomas, Henry C. “CHROMATOGRAPHY: A PROBLEM IN KINETICS.” Annals of the New York Academy of Sciences 49, no. 2 (1948): 161–82. https://doi.org/10.1111/j.1749-6632.1948.tb35248.x. +
+
+Vaca Guerra, Mauricio, Yogesh M. Harshe, Lennart Fries, James Payan Lozada, Aitor Atxutegi, Stefan Palzer, and Stefan Heinrich. “Modeling the Extraction of Espresso Components as Dispersed Flow Through a Packed Bed.” Journal of Food Engineering 368 (2024): 111913. https://doi.org/10.1016/j.jfoodeng.2023.111913. +
+
+ + +
+ + + ]]>
+ julia + coffee + mass transfer + https://aefarrell.github.io/posts/engineering_a_cup_of_coffee_part-2/ + Sat, 23 Mar 2024 06:00:00 GMT +
+ + Estimating the impact of fugitive emissions + Allan Farrell + https://aefarrell.github.io/posts/fugitive-hydrogen/ + As Alberta continues down it’s path to the hydrogen economy, with more industrial facilities transitioning to hydrogen as a fuel, and more producers of hydrogen announcing new plants and expansions, questions around the impact of fugitive hydrogen emissions linger.

+
+

The climate impacts of fugitive hydrogen

+

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.

+
+
+

A first look at estimating leak rates

+

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:

+
    +
  1. Natural gas is entirely methane and the hydrogen fuel gas is pure hydrogen.
  2. +
  3. Methane and hydrogen are ideal gases
  4. +
  5. Fugitive emissions all come from leaks, which are just holes in the pressure envelope
  6. +
  7. The system pressure is high enough that flow is choked
  8. +
+

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.41
+
+
+
g(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_CH4
+
+
0.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.

+
+

Leaks as a series of tubes

+

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/μ_H2
+
+
1.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

+
+

Molecular flow

+

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_CH4
+
+
0.12566228261547094
+
+
+
+
+
+

Relative importance of fugitive emissions

+

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
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 1: The ratio of fugitive emissions to combustion emissions, as a function of leakage rate. +
+
+
+
+

At any appreciable leak percentage the amount of hydrogen lost to fugitive emissions rivals the stack emissions for climate impact.

+
+
+

Fugitive hydrogen and “net zero”

+

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

+

+
+

Some more simplifying assumptions

+

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 η

+

+

+

+
+
+

Relative emissions of switching to hydrogen

+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 2: The total emissions, in CO2-e, of hydrogen relative to natural gas. +
+
+
+
+
+
+
+

Final thoughts

+

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.

+
+
+

References

+
+
+Alberta Greenhouse Gas Quantification Methodologies (version 2.3). Edmonton, AB: Alberta Environment; Protected Areas, 2023. https://open.alberta.ca/publications/alberta-greenhouse-gas-quantification-methodologies. +
+
+Bertagni, Matteo B., Stephen W. Pacala, Fabien Paulot, and Amilcare Porporato. “Risk of the Hydrogen Economy for Atmospheric Methane.” Nature Communications 13 (2023). https://doi.org/10.1038/s41467-022-35419-7. +
+
+Colorado, Andrés, Vincent McDonell, and Scott Samuelsen. “Direct Emissions of Nitrous Oxide from Combustion of Gaseous Fuels.” International Journal of Hydrogen Energy 42, no. 1 (2017): 711–19. https://doi.org/10.1016/j.ijhydene.2016.09.202. +
+
+Crane. “TP410M: Flow of Fluids.” Stamford, CT: Crane, 2013. +
+
+Dutta, Indranil, Rajesh Kumar Parsapur, Sudipta Chatterjee, Amol M. Hengne, Davin Tan, Karthik Peramaiah, Theis I. Solling, Ole John Nielsen, and Kuo-Wei Huang. “The Role of Fugitive Hydrogen Emissions in Selecting Hydrogen Carriers.” ACS Energy Letters 8, no. 7 (2023): 3251–57. https://doi.org/10.1021/acsenergylett.3c01098. +
+
+Emission Factors and Reference Values (version 1.1). Gatineau, QC: Environment; Climate Change Canada, 2023. https://publications.gc.ca/collections/collection_2023/eccc/En84-294-2023-eng.pdf. +
+
+Forster, Piers, Trude Storelvmo, Kyle Armour, William Collins, Jean-Louis Dufresne, David Frame, Daniel J. Lunt, et al. “The Earth’s Energy Budget, Climate Feedbacks, and Climate Sensitivity.” In Climate Change 2021: The Physical Science Basis. Contribution of Working Group i to the Sixth Assessment Report of the Intergovernmental Panel on Climate Change, edited by Valérie Masson-Delmonte, Panmao Zhai, Anna Pirani, Sarah L. Connors, Clotilde Péan, Yang Chen, Leah Goldfarb, et al., 923–1054. Cambridge: Cambridge University Press, 2023. +
+
+Frazer-Nash Consultancy. “Fugitive Hydrogen Emissions in a Future Hydrogen Economy.” London, UK: UK Department for Business, Energy,; Industrial Strategy, 2022. https://www.gov.uk/government/publications/fugitive-hydrogen-emissions-in-a-future-hydrogen-economy/. +
+
+GPSA. Engineering Data Book. 13th ed. Tulsa, OK: Gas Processors Suppliers Association, 2012. +
+
+Masson-Delmonte, Valérie, Panmao Zhai, Anna Pirani, Sarah L. Connors, Clotilde Péan, Yang Chen, Leah Goldfarb, et al., eds. Climate Change 2021: The Physical Science Basis. Contribution of Working Group i to the Sixth Assessment Report of the Intergovernmental Panel on Climate Change. Cambridge: Cambridge University Press, 2023. +
+
+Mejia, Alejandra Hormaza, Jacob Brouwer, and Michael Mac Kinnon. “Hydrogen Leaks at the Same Rate as Natural Gas in Typical Low-Pressure Gas Infrastructure.” International Journal of Hydrogen Energy 45, no. 15 (2020): 8810–25. https://doi.org/10.1016/j.ijhydene.2019.12.159. +
+
+Ocko, Ilissa B., and Steven P. Hamburg. “Climate Consequences of Hydrogen Emissions.” Atmospheric Chemistry and Physics 22, no. 14 (2022): 9349–68. https://doi.org/10.5194/acp-22-9349-2022. +
+
+Sand, Maria, Ragnhild Bieltvedt Skeie, Marit Sandstad, Srinath Krishnan, Gunnar Myhre, Hannah Bryant, Richard Derwent, et al. “A Multi-Model Assessment of the Global Warming Potential of Hydrogen.” Communications Earth & Environment 4 (2023): 203. https://doi.org/10.1038/s43247-023-00857-8. +
+
+Schefer, R. W., W. G. Houf, C. San Marchi, W. P. Chernicoff, and L. Englom. “Characterization of Leaks from Compressed Hydrogen Dispensing Systems and Related Components.” International Journal of Hydrogen Energy 31, no. 9 (2006): 1247–60. https://doi.org/10.1016/j.ijhydene.2005.09.003. +
+
+Smith, Chris, Zebedee R. J. Nicholls, Kyle Armour, William Collins, Piers Forster, Malte Meinshausen, Matthew D. Palmer, et al. “The Earth’s Energy Budget, Climate Feedbacks, and Climate Sensitivity Supplementary Material.” In Climate Change 2021: The Physical Science Basis. Contribution of Working Group i to the Sixth Assessment Report of the Intergovernmental Panel on Climate Change, edited by Valérie Masson-Delmonte, Panmao Zhai, Anna Pirani, Sarah L. Connors, Clotilde Péan, Yang Chen, Leah Goldfarb, et al. Cambridge: Cambridge University Press, 2023. +
+
+Swain, M. R., and M. N. Swain. “A Comparison of , and Fuel Leakage in Residential Settings.” International Journal of Hydrogen Energy 17, no. 10 (1992). https://doi.org/10.1016/0360-3199(92)90025-R. +
+
+ + +
+ + + ]]>
+ julia + hydrogen + compressible flow + https://aefarrell.github.io/posts/fugitive-hydrogen/ + Wed, 03 Jan 2024 07:00:00 GMT + +
+ + Impossible bowling + Allan Farrell + https://aefarrell.github.io/posts/impossible_bowling/ + While bowling, this week, an interesting question came up: is it possible to get every score from 1 to 450 in a game of five pin bowling? Or, to flip it around, is there a score that you can never get no matter how fancy your bowling? The answer is not immediately obvious!

+
+

The rules of five pin bowling

+

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.

+
+
+
+ +
+
+Figure 1: The points value of each pin in five-pin bowling. +
+
+
+

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.

+
+
+

Trying everything

+

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.

+
+
+
+ +
+
+Note +
+
+
+

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:

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FrameNumber of Possibilitiesdescription
? ? ?1024Any combination of the first set of 5 pins
X ? ?35 -1 = 242Every follow up to a strike except X–, which has already been counted in ???
X X ?25 -1 = 31Every follow up to 2 strikes except XX-, which has already been counted in X??
? \ ?25 × (25-1) = 992Every 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

+
+
+
+
+

Nothing fancy

+

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.

+
+

Detour: what about with no gutters?

+

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]
+
+
+
+
+
+

Sparing no effort

+

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:

+
    +
  • what was scored in the frame
  • +
  • whether a strike or a spare was recorded
  • +
  • whether this is the end of the last frame (i.e am I done bowling yet?)
  • +
+

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 = end
+
+

Instead 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) <= up
+
+

The 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.

+
+
+

In striking distance of the final answer

+

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 <= up
+
+

At 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.

+ + +
+ + + ]]>
+ python + bowling + https://aefarrell.github.io/posts/impossible_bowling/ + Sun, 26 Nov 2023 07:00:00 GMT +
+ + Messing around with model parameters + Allan Farrell + https://aefarrell.github.io/posts/dispersion_parameter_sensitivity/ + Recently I added some alternative correlations to GasDispersion.jl, the julia package I put together for basic chemical release modeling, and I thought it would be worthwhile to circle back and look at some of those in more depth.

+

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.

+
+

Windspeed

+

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).

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 1: Windspeed correlations for class A, B, C, and D stability. +
+
+
+
+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 2: Windspeed correlations for class E and F stability. +
+
+
+
+
+
+

Plume Dispersion

+

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

+

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.

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 3: Crosswind dispersion correlations. +
+
+
+
+

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.

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 4: Crosswind dispersion correlations, class F stability. +
+
+
+
+
+
+

Vertical Dispersion

+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+Figure 5: Vertical dispersion correlations. +
+
+
+

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.

+
+
+
+

An Example

+

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, K
+
+

For 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 # K
+
+

We 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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 6: Downwind concentration of sulfur dioxide, at 2m elevation, as predicted by the default, CCPS, ISC3, TNO, and Turner correlations, neglecting plume rise. +
+
+
+
+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 7: Concentration isopleths for sulfur dioxide, at 2m elevation, as predicted by the default, CCPS, ISC3, TNO, and Turner correlations, neglecting plume rise. +
+
+
+
+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 8: Downwind concentration of sulfur dioxide, at 2m elevation, as predicted by the default, CCPS, ISC3, TNO, and Turner correlations, using Briggs’ plume rise correlations. +
+
+
+
+
+
+

Final Thoughts

+

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).

+
+
+

References

+
+
+AIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999. +
+
+Bakkum, E. A., and N. J. Duijm. “Vapour Cloud Dispersion.” In Methods for the Calculation of Physical Effects, CPR 14E, edited by C. J. H. van den Bosch and R. A. P. M. Weterings, 3rd ed. The Hague: TNO, 2005. +
+
+Briggs, Gary A. “Diffusion Estimation for Small Emissions. Preliminary Report.” Oak Ridge, TN: Air Resources Atmospheric Turbulence; Diffusion Laboratory, National Oceanic; Atmospheric Administration, 1973. https://doi.org/10.2172/5118833. +
+
+EPA-454/b-95-003b: User’s Guide for the ISC3 Dispersion Models. Vol. 2. Environmental Protection Agency, 1995. +
+
+Hanna, Steven R., Gary A. Briggs, and Rayford P. Hosker Jr. Handbook on Atmospheric Diffusion. Springfield, VA: National Technical Information Service, 1982. https://doi.org/10.2172/5591108. +
+
+Lees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996. +
+
+Spicer, Thomas O., and Jerry A. Havens. EPA-450/4-89-019: User’s Guide for the DEGADIS 2.1 Dense Gas Dispersion Model. Research Triangle Park, NC: Office of Air Quality Planning; Standards, United States Environmental Protection Agency, 1989. https://nepis.epa.gov/Exe/ZyNET.exe/2000J5GU.txt. +
+
+Turner, D. Bruce. Workbook of Atmospheric Dispersion Estimates. Research Triangle Park, NC: Office of Air Programs, United States Environmental Protection Agency, 1989. +
+
+ + +
+ + + ]]>
+ julia + dispersion modelling + https://aefarrell.github.io/posts/dispersion_parameter_sensitivity/ + Mon, 30 Oct 2023 06:00:00 GMT + +
+ + Engineering a Cup of Coffee + Allan Farrell + https://aefarrell.github.io/posts/engineering_a_cup_of_coffee/ + While making coffee one day, I started thinking about how the coffee making process is both a perfect representation of the sorts of systems chemical engineers work on every day and also a weird edge case unlike most of the unit operations in the standard repertoire of process engineering.

+

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.

+

Extraction and the Coffee Control Chart

+

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.

+
+
+
+ +
+
+Figure 1: The standard coffee control chart.2 +
+

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

+
+
+
+ +
+
+Figure 2: A Rotocel extractor, you are unlikely to see one of these at your local coffee shop. +
+
+
+

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.

+
+

The simplest coffee maker

+

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

+

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 and uniformity

+

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.

+
+
+

Mixing and rate constants

+

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)

+
+
+

An example brew

+

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);
+
+
+
+
+

A mass transfer model of coffee brewing

+

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:

+
    +
  • The system is isothermal with brew temperature Tl
  • +
  • Coffee grounds are spherical and have constant radius b
  • +
  • The coffee matrix is a pseudo-homogeneous solid, diffusion through the solid follows Fick’s second law with diffusivity and diffusion is only relevant in the radial direction r
  • +
  • The liquid phase is well mixed, i.e. the bulk concentration c is spatially homogeneous and is only a function of time
  • +
  • Mass transfer into the liquid phase occurs through a thin film with a mass transfer coefficient h
  • +
  • At the interface between the thin film and the solid coffee, the concentration of solubles is in equilibrium with equilibrium constant K
  • +
+

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.

+
+
+
+ +
+
+Figure 3: The mass transfer system for making coffee with a French press. +
+
+
+

There are two rates important processes governing the extraction of coffee:

+
    +
  1. Diffusion across the interface into the thin film, governed by Fick’s first law
  2. +
  3. Transfer from the thin film into the bulk liquid
  4. +
+

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 dominant rate

+

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.

    +
  • Bi < 0.001 : the mass transfer through the film dominates, a simple exponential model is appropriate
  • +
  • 0.001 < Bi < 200 : use an intermediate method11
  • +
  • Bi > 200 : the mass transfer through the coffee particles dominates, the more complicated solution from Carslaw and Jaeger is best
  • +
+

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ₗ)/b
+
+
0.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.

+
+

Boundary conditions

+

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

+
    +
  • t = 0 : q = q0 and c = c0
  • +
  • r = b : q = K c
  • +
  • r = 0 : q is finite
  • +
+
+
+

The Carslaw and Jaeger model

+

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

+
    +
  • τ = 0 : u = 0
  • +
  • ξ = 1 : u = uf
  • +
  • ξ = 0 : u is finite
  • +
+

And the mass transfer into the liquid bulk becomes

+

+

With and boundary condition

+
    +
  • τ = 0 : uf = 1
  • +
+

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
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 4: The roots of the equations f(x) and g(x), note the repeated singularities in f(x). +
+
+
+
+

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
+end
+
+

The 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
+end
+
+

Now we can put together a bulk concentration function

+
+
function c(t)
+    τ = (𝒟ₛ*t)/b^2
+    c = (c₀ - c_max)*u_f(τ) + c_max
+    return c
+end
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 5: The concentration of solubles in the extract over time. +
+
+
+
+

Extraction is simply concentration over dose

+
+
extraction(t) = c(t)*Vₗ/mₛ
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 6: The extraction of coffee solubles over time. +
+
+
+
+
+
+

Packaging the final result

+

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)
+end
+
+

and 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).

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 7: The evolution of coffee extraction over time for several grind sizes. Note that the smallest grind sizes extract faster, achieving equilibrium, whereas the largest grind sizes extract more slowly. +
+
+
+
+
+
+
+

Final thoughts

+

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.

+
+
+

References

+
+
+Batali, Mackenzie E., Lik Xian Lim, Jiexin Liang, Sara E. Yeager, Ashley N. Thompson, Juliet Han, William D. Ristenpart, and Jean-Xavier Guinard. “Sensory Analysis of Full Immersion Coffee: Cold Brew Is More Floral, and Less Bitter, Sour, and Rubbery Than Hot Brew.” Foods 11, no. 16 (2022): 2440. https://doi.org/10.3390/foods11162440. +
+
+Batali, Mackenzie E., William D. Ristenpart, and Jean‑Xavier Guinard. “Brew Temperature, at Fixed Brew Strength and Extraction, Has Little Impact on the Sensory Profile of Drip Brew Coffee.” Scientific Reports 10 (2020): 16450. https://doi.org/10.1038/s41598-020-73341-4. +
+
+Bird, R. Byron, Warren E. Stewart, and Edwin N. Lightfoot. Transport Phenomena. 2nd ed. Hoboken, NJ: John Wiley & Sons, 2007. +
+
+Carslaw, Horatio S., and John C. Jaeger. Conduction of Heat in Solids. 2nd ed. London: Oxford University Press, 1959. +
+
+Green, Don W., ed. Perry’s Chemical Engineers’ Handbook. 8th ed. New York: McGraw Hill, 2008. +
+
+Hottel, Hoyt C., James J. Noble, Adel F. Sarofim, Geoffrey D. Silcox, Phillip C. Wankat, and Kent S. Knaebel. “Heat and Mass Transfer.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+Moroney, Kevin M., William T. Lee, Stephen B. G. O’Brien, Freek Suijver, and Johan Marra. “Modelling of Coffee Extraction During Brewing Using Multiscale Methods: An Experimentally Validated Model.” Chemical Engineering Science 137 (2015): 216–34. https://doi.org/10.1016/j.ces.2015.06.003. +
+
+Poling, Bruce E., John M. Prausnitz, and John P. O’Connell. The Properties of Gases and Liquids. 5th ed. New York: McGraw Hill, 2001. +
+
+Poling, Bruce E., George H. Thomson, Daniel G. Friend, Richard L. Rowley, and W. Vincent Wilding. “Physical and Chemical Data.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+Rodrigues, Melissa A. A., Maria Lúcia A. Borges, Adriana S. Franca, Leandro S. Oliveira, and Paulo C. Corrêa. “Evaluation of Physical Properties of Coffee During Roasting.” Agricultural Engineering International: The CIGR Journal of Scientific Research and Development V (2003). https://www.researchgate.net/publication/267858074. +
+
+Rousseau, Ronald W., ed. Handbook of Seperation Process Technology. Hoboken, NJ: John Wiley & Sons, 1987. +
+
+Schwartzberg, Henry G. “Leaching – Organic Materials.” In Handbook of Seperation Process Technology, edited by Ronald W. Rousseau. Hoboken, NJ: John Wiley & Sons, 1987. +
+
+Seader, J. D., Ernest J. Henley, and D. Keith Roper. Separation Process Principles. 3rd ed. Hoboken, NJ: John Wiley & Sons, 2011. +
+
+ + +
+ + + ]]>
+ julia + coffee + mass transfer + https://aefarrell.github.io/posts/engineering_a_cup_of_coffee/ + Fri, 15 Sep 2023 06:00:00 GMT +
+ + Monitoring smoke infiltration + Allan Farrell + https://aefarrell.github.io/posts/indoor_air_quality/ + A few years ago I mused about using wildfire smoke events to measure the infiltration rate of buildings, in the context of modeling the infiltration of air pollution into buildings. Well it is wildfire season again and this past weekend saw a thick haze descend upon Edmonton, with airborne particulate concentrations, pm2.5 specifically, exceeding 440 μg/m3 in my neighbourhood.

+

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.

+
+

Measuring building infiltration

+

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.

+
+

Outdoor particulate concentration

+

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, Pipe
+
+
+
start = 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);
+
+
+
+

Indoor particulate concentration

+

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
+end
+
+

Plotting 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.

+
+
+
+
+
+ +
+
+Figure 1: Time series data for indoor and outdoor pm2.5 concentrations with the 1 hour AAQO indicated. +
+
+
+
+
+
+
+

Fitting the 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());
+
+
+
+
+
+
+ +
+
+Figure 2: Best fit curve for the simple linear building infiltration model. +
+
+
+
+
+

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.

+
+
+
+

Using a HEPA Filter

+

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);
+
+
+
+
+
+
+ +
+
+Figure 3: Response of measured indoor particulate concentrations to air filtration. Note that the outdoor fine particulate concentration remains high throughout the measurement period. +
+
+
+
+
+
+
+

Final thoughts

+

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.

+ + +
+ + ]]>
+ julia + air quality + atmotube + building infiltration + https://aefarrell.github.io/posts/indoor_air_quality/ + Mon, 22 May 2023 06:00:00 GMT +
+ + Taking a second look at the Britter-McQuaid model + Allan Farrell + https://aefarrell.github.io/posts/Britter-McQuaid/ + I recently spent some time looking in detail at the Britter-McQuaid workbook model for dense gas dispersion and I thought the plume model deserved some extra attention. Firstly because I believe there is an error in the plume dimensions, and secondly because I think an important feature of top-hat models is often neglected and the Britter-McQuaid workbook model should be used more.

+

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”.

+

A motivating example

+

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.

    +
  • release temperature: -162°C
  • +
  • release rate: 0.23 m³/s (liquid)
  • +
  • release duration: 174 s
  • +
  • windspeed at 10m: 10.9 m/s
  • +
  • LNG liquid density (at release conditions): 425.6 kg/m³
  • +
  • LNG gas density (at release conditions): 1.76 kg/m³
  • +
+

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 density
+
+

First 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
+end
+
+

Finally, 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
+end
+
+
+
+
+
+
+ +
+
+Figure 1: The concentration profile for the Britter-McQuaid dense gas model, with the LFL shown for reference. +
+
+
+
+
+

If 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
+
+
+
+
+

Looking again at plume dimensions

+

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

+
+
+
+ +
+
+Figure 2: Dense plume concentration contour.4 +
+

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₁₀^3
+
+
0.18392758812310803 m
+
+
+
+
Lᵤ  = D/2 + 2lb
+
+
1.4973003373658906 m
+
+
+
+
Lₕₒ = D + 8lb
+
+
3.7303110272242135 m
+
+
+
+
Lₕ(x) = Lₕₒ + 2.5(lb*x^2)
+
+
+

Upwind region

+

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.

+
+
+
+
+
+ +
+
+Figure 3: The various approaches to estimating the upwind plume extent, black dots are a digitization of the corresponding diagram from Britter and McQuaid shown for reference. +
+
+
+
+
+

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.

+
+
+

Vertical extent

+

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

+
+
+

+
image.png
+
+
+

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:

+
    +
  • it depends upon the concentration
  • +
  • it is divided by two
  • +
+

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.  )

+
+
+
+
+
+ +
+
+Figure 4: Approaches to plume height estimation (top) and the corresponding conservation of mass (bottom). +
+
+
+
+
+

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.

+
+
+

Recommendations

+

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.

+
+
+
+

Calculating the explosive mass

+

The explosive mass in the cloud is the given by the volume integral

+

+

where V is defined as the region where cLFL.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:

+
    +
  • within the plume, and
  • +
  • the concentration is ≥ LFL
  • +
+

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.

+
+

A nice property of top hat models

+

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 LFLcUFL, 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_ed
+
+
3197.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.

+
+
+

Added complications

+

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 xnx < 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
+
+
+
+
+
+

Final thoughts

+

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).

+
+
+

References

+
+
+AIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999. +
+
+Bakkum, E. A., and N. J. Duijm. “Vapour Cloud Dispersion.” In Methods for the Calculation of Physical Effects, CPR 14E, edited by C. J. H. van den Bosch and R. A. P. M. Weterings, 3rd ed. The Hague: TNO, 2005. +
+
+Britter, Rex E., and J. McQuaid. “Workbook on the Dispersion of Dense Gases. HSE Contract Research Report No. 17/1988,” 1988. +
+
+Casal, Joachim. Evaluation of the Effects of Consequences of Major Accidents in Industrial Plants. 2nd ed. Amsterdam: Elsevier, 2018. +
+
+Lees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996. +
+
+Woodward, John L. Estimating the Flammable Mass of a Vapour Cloud. New York: American Institute of Chemical Engineers, 1998. +
+
+ + +
+ + + ]]>
+ julia + dispersion modelling + https://aefarrell.github.io/posts/Britter-McQuaid/ + Sun, 12 Mar 2023 07:00:00 GMT + +
+ + Integrating a Gaussian puff - mistakes were made + Allan Farrell + https://aefarrell.github.io/posts/intpuff2_successive_approximations/ + The other day I was working on a project involving Gaussian puff models and I noticed that I had made a significant mistake, a mistake I have made several times without noticing, and one that invalidated a whole bunch of work I that I had done previously, so I thought this would be a good opportunity to examine my mistake and it’s consequences.

+
+

The Gaussian puff model

+

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.61
+
+
+
+

Integrating the puff

+

What 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.

+
+
+

Dispersion nearly-constants

+

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.

+
+
+
+
+
+ +
+
+Figure 1: The relative change in dispersion parameters, ±1σ, as a function of downwind distance. +
+
+
+
+
+
+
+

Different approaches to approximation

+

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)*Δt
+
+
+

Sum of discrete puffs

+

The 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
+end
+
+
+
+

Integrating assuming constant σs

+

The 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)
+
+
+
+

Numerically integrating the full model

+

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
+end
+
+
+
+
+

Comparing performance

+
+

Model error

+

To 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

+
+
+
+
+
+ +
+
+Figure 2: Top: concentration profile over time, at a fixed location, for discrete sums of puffs, the approximated integral, and the exact integral. Bottom: the relative error of a sum of discrete puffs and the approximate integral. +
+
+
+
+
+

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.

+
+
+
+
+
+ +
+
+Figure 3: Top: crosswind concentration profile, at a fixed location and time, for discrete sums of puffs, the approximated integral, and the exact integral. Bottom: the relative error of a sum of discrete puffs and the approximate integral. +
+
+
+
+
+
+
+
+
+
+ +
+
+Figure 4: Top: vertical concentration profile, at a fixed location and time, for discrete sums of puffs, the approximated integral, and the exact integral. Bottom: the relative error of a sum of discrete puffs and the approximate integral. +
+
+
+
+
+

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.

+
+
+
+
+
+ +
+
+Figure 5: Top: concentration profile over time near the origin, for discrete sums of puffs, the approximated integral, and the exact integral. Bottom: the relative error of a sum of discrete puffs and the approximate integral. +
+
+
+
+
+
+
+

Compute time

+

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 (minmax):  28.056 μs57.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 (minmax):  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 (minmax):  37.090 μs77.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 (minmax):  534.974 ns988.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.

+
+
+
+

Conclusions

+

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.

+
+
+

References

+
+
+AIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999. +
+
+Lees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996. +
+
+Slade, David H. Meteorology and Atomic Energy. Springfield, VA: National Technical Information Service, 1968. https://doi.org/10.2172/4492043. +
+
+ + +
+ + + ]]>
+ julia + dispersion modelling + https://aefarrell.github.io/posts/intpuff2_successive_approximations/ + Sun, 15 Jan 2023 07:00:00 GMT +
+ + Dynamic Mode Decomposition + Allan Farrell + https://aefarrell.github.io/posts/dynamic_mode_decomposition/ + Recently I’ve been playing around with Dynamic Mode Decomposition (DMD) and this notebook compiles my notes and julia code in one place for later reference.

+

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.

+
+

Example: Flow Past a Cylinder

+

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.

+
+
+
+ +
+
+Figure 1: Original data, vorticity of flow past a cylinder. +
+
+
+

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*8
+
+
63868809608
+
+
+
+
+

Exact DMD

+

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.

+
+

Best Fit Matrix

+

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.

+
+

Singular Value Decomposition

+

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)*8
+
+
108118416
+
+
+
+
size_A_exact/size_A_naive
+
+
0.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

+
+
+
+ +
+
+Figure 2: Original flow field (top) and reconstructed flow field (bottom). +
+
+
+
+
+

Dynamic Modes

+

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.

+
+
+
+
+
+ +
+
+Figure 3: The first and tenth dymanic mode 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
+(t) = real( Φ*exp.* t)*Φ⁻¹x₀ )
+
+
+
+
+ +
+
+Figure 4: Original flow field (top) and reconstructed flow field (bottom), using the continuous time vector function. +
+
+
+
+
+
+

Refactoring

+

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
+end
+
+

Then 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₁)
+end
+
+

We 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.

+
+

Discrete System

+

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ₖ)))
+end
+
+
+
ds = DiscreteSys(d)
+
+# This produces the same result as before
+X̂₂_exact == ds(X₁)
+
+
true
+
+
+
+
+

Continuous System

+

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₀ )
+end
+
+
+
cs = ContinuousSys(d, X₁[:,1]);
+
+# This produces the same result as before
+(150) == cs(150)
+
+
true
+
+
+
+
+

Large Systems

+

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.

+
+
+
+

Reduced DMD

+

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)
+end
+
+

One 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.

+
+
+
+
+

+
The singular values of the system showing a significant elbow 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 norm
+
+
0.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 && p1
+    
+    # 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)
+end
+
+

Capturing 99% of the variance, in this case, requires only keeping the first 14 singular values.

+
+
+
+
+
+ +
+
+Figure 5: The Frobenius norm of the difference between the original and reconstructed flow field as a function of reduced DMD rank, the point where 99% of the variance has been captured is indicated. +
+
+
+
+
+
+
+
+ +
+
+Figure 6: Original flow field (top) and reconstructed flow field (bottom), using reduced DMD capturing 99% of the variance. +
+
+
+

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)*8
+
+
10008880
+
+
+

To recover the (approximate) A matrix we only need to store 10MB, a ~91% reduction over the exact DMD

+
+
size_A_reduced/size_A_exact
+
+
0.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_naive
+
+
0.00015670998193688458
+
+
+
+

Truncated SVD and Large Systems

+

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

+

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)
+end
+
+

The 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 kn
+    
+    # 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)
+end
+
+

Suppose 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
+end
+
+

That 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.

+
+
+
+
+
+ +
+
+Figure 7: Compressed DMD performance, as measured by the Frobenius norm of the difference between the original flow field and the reconstructed field, over a range of sample sizes. +
+
+
+
+
+

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.

+
+
+ +
+
+Figure 8: Original flow field (top) and reconstructed flow field (bottom), using compressed DMD and sampling at 300 points. +
+
+
+

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.

+
+
+

Physics Informed DMD

+

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.

+
+
+ +
+
+Figure 9: A comparison of models trained with exact DMD and with piDMD, also showing the matrix structure of the corresponding piDMD method.5 +
+

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
+= U'*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
+
+
+
+
+ +
+
+Figure 10: Original flow field (top) and reconstructed flow field (bottom), using physics informed DMD. +
+
+
+

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.

+
+
+

References

+
+
+Baddoo, Peter J., Benjamin Herrmann, Beverley J. McKeon, J. Nathan Kutz, and Steven L. Brunton. “Physics-Informed Dynamic Mode Decomposition (piDMD),” December 8, 2021. https://doi.org/10.48550/arXiv.2112.04307. +
+
+Bai, Zhe, Eurika Kaiser, Joshua L. Proctor, J. Nathan Kutz, and Steven L. Brunton. “Dynamic Mode Decomposition for Compressive System Identification.” AIAA Journal 58 (2020): 561–74. https://doi.org/10.2514/1.J057870. +
+
+Brunton, Steven L., and J. Nathan Kutz. Data Driven Science and Engineering. Cambridge: Cambridge University Press, 2019. http://databookuw.com. +
+
+Brunton, Steven L., Joshua L. Proctor, and J. Nathan Kutz. “Compressive Sampling and Dynamic Mode Decomposition,” December 18, 2013. https://doi.org/10.48550/arXiv.1312.5186. +
+
+Brunton, Steven L., Joshua L. Proctor, Jonathan H. Tu, and J. Nathan Kutz. “Compressed Sensing and Dynamic Mode Decomposition.” Journal of Computational Dynamics 2 (2015): 165–91. https://doi.org/10.3934/jcd.2015002. +
+
+Schmid, Peter J. “Dynamic Mode Decomposition of Numerical and Experimental Data.” Journal of Fluid Mechanics 656 (2010): 5–28. https://doi.org/10.1017/S0022112010001217. +
+
+Tu, Jonathan H., Clarence W. Rowley, Dirk Martin Luchtenburg, Steven L. Brunton, and J. Nathan Kutz. “On Dynamic Mode Decomposition: Theory and Applications.” Journal of Computational Dynamics 1 (2014): 391–421. https://doi.org/10.3934/jcd.2014.1.391. +
+
+ + +
+ + + ]]>
+ julia + dynamical systems + https://aefarrell.github.io/posts/dynamic_mode_decomposition/ + Sun, 18 Dec 2022 07:00:00 GMT + +
+
+
diff --git a/listings.json b/listings.json new file mode 100644 index 0000000..3e429c8 --- /dev/null +++ b/listings.json @@ -0,0 +1,91 @@ +[ + { + "listing": "/index.html", + "items": [ + "/posts/hydrogen_compression/index.html", + "/posts/gaussian_explosive_mass/index.html", + "/posts/vessel_blowdown_dispersion/index.html", + "/posts/ooms_plume_model/index.html", + "/posts/atmotube_data_logging/index.html", + "/posts/pollen_dispersion/index.html", + "/posts/vessel_blowdown_real_gases/index.html", + "/posts/vessel_blowdown_ideal_gases/index.html", + "/posts/relief_valve_sizing/index.html", + "/posts/hydrogen_release_modeling/index.html", + "/posts/plastics-recycling-microplastics/index.html", + "/posts/engineering_a_cup_of_coffee_part-2/index.html", + "/posts/fugitive-hydrogen/index.html", + "/posts/impossible_bowling/index.html", + "/posts/dispersion_parameter_sensitivity/index.html", + "/posts/engineering_a_cup_of_coffee/index.html", + "/posts/indoor_air_quality/index.html", + "/posts/Britter-McQuaid/index.html", + "/posts/intpuff2_successive_approximations/index.html", + "/posts/dynamic_mode_decomposition/index.html", + "/posts/hydrogen_blending/index.html", + "/posts/adiabatic-compressible-flow/index.html", + "/posts/integrated_puff/index.html", + "/posts/turbulent_jet_notes_part_2/index.html", + "/posts/turbulent_jet_notes/index.html", + "/posts/federal_election/index.html", + "/posts/smoke_days/index.html", + "/posts/building_infiltration_2/index.html", + "/posts/building_infiltration_example/index.html", + "/posts/turbulent_jet_example/index.html", + "/posts/vapour_cloud_explosion_example/index.html", + "/posts/worst_case_weather/index.html", + "/posts/gaussian_dispersion_example/index.html", + "/posts/sizing_a_gooseneck_example/index.html", + "/posts/butane_leak_example/index.html" + ] + }, + { + "listing": "/archive.html", + "items": [ + "/posts/hydrogen_compression/index.html", + "/posts/gaussian_explosive_mass/index.html", + "/posts/vessel_blowdown_dispersion/index.html", + "/posts/ooms_plume_model/index.html", + "/posts/atmotube_data_logging/index.html", + "/posts/pollen_dispersion/index.html", + "/posts/vessel_blowdown_real_gases/index.html", + "/posts/vessel_blowdown_ideal_gases/index.html", + "/posts/relief_valve_sizing/index.html", + "/posts/hydrogen_release_modeling/index.html", + "/posts/plastics-recycling-microplastics/index.html", + "/posts/engineering_a_cup_of_coffee_part-2/index.html", + "/posts/fugitive-hydrogen/index.html", + "/posts/impossible_bowling/index.html", + "/posts/dispersion_parameter_sensitivity/index.html", + "/posts/engineering_a_cup_of_coffee/index.html", + "/posts/indoor_air_quality/index.html", + "/posts/Britter-McQuaid/index.html", + "/posts/intpuff2_successive_approximations/index.html", + "/posts/dynamic_mode_decomposition/index.html", + "/posts/hydrogen_blending/index.html", + "/posts/adiabatic-compressible-flow/index.html", + "/posts/integrated_puff/index.html", + "/posts/turbulent_jet_notes_part_2/index.html", + "/posts/turbulent_jet_notes/index.html", + "/posts/federal_election/index.html", + "/posts/smoke_days/index.html", + "/posts/building_infiltration_2/index.html", + "/posts/building_infiltration_example/index.html", + "/posts/turbulent_jet_example/index.html", + "/posts/vapour_cloud_explosion_example/index.html", + "/posts/worst_case_weather/index.html", + "/posts/gaussian_dispersion_example/index.html", + "/posts/sizing_a_gooseneck_example/index.html", + "/posts/butane_leak_example/index.html" + ] + }, + { + "listing": "/projects.html", + "items": [ + "/projects/gas_dispersion_jl/index.html", + "/projects/picocalc/index.html", + "/projects/pymotube/index.html", + "/projects/unitfulcorrelations_jl/index.html" + ] + } +] \ No newline at end of file diff --git a/posts/Britter-McQuaid/att1.png b/posts/Britter-McQuaid/att1.png new file mode 100644 index 0000000..cdc474e Binary files /dev/null and b/posts/Britter-McQuaid/att1.png differ diff --git a/posts/Britter-McQuaid/christianbuehner-unsplash-header.jpg b/posts/Britter-McQuaid/christianbuehner-unsplash-header.jpg new file mode 100644 index 0000000..fc0a338 Binary files /dev/null and b/posts/Britter-McQuaid/christianbuehner-unsplash-header.jpg differ diff --git a/posts/Britter-McQuaid/index.html b/posts/Britter-McQuaid/index.html new file mode 100644 index 0000000..c0786f5 --- /dev/null +++ b/posts/Britter-McQuaid/index.html @@ -0,0 +1,1331 @@ + + + + + + + + + + + + +Taking a second look at the Britter-McQuaid model – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Taking a second look at the Britter-McQuaid model

+
+
+ Re-evaluating plume extents and determining the explosive mass +
+
+
+
julia
+
dispersion modelling
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

March 12, 2023

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

I recently spent some time looking in detail at the Britter-McQuaid workbook model for dense gas dispersion and I thought the plume model deserved some extra attention. Firstly because I believe there is an error in the plume dimensions, and secondly because I think an important feature of top-hat models is often neglected and the Britter-McQuaid workbook model should be used more.

+

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.

+
+

A motivating example

+

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:

+
    +
  • release temperature: -162°C
  • +
  • release rate: 0.23 m³/s (liquid)
  • +
  • release duration: 174 s
  • +
  • windspeed at 10m: 10.9 m/s
  • +
  • LNG liquid density (at release conditions): 425.6 kg/m³
  • +
  • LNG gas density (at release conditions): 1.76 kg/m³
  • +
+

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 density
+
+

First 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 \(\beta = \log_{10}(x/D)\)

+
+
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, \({ c_m \over c_0 }\), 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
+end
+
+

Finally, 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
+end
+
+
+
+
+
+
+ +
+
+Figure 1: The concentration profile for the Britter-McQuaid dense gas model, with the LFL shown for reference. +
+
+
+
+
+

If 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
+
+
+
+
+

Looking again at plume dimensions

+

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

+
+
+
+ +
+
+Figure 2: Dense plume concentration contour.4 +
+
+
+

and the following relations for the labeled distances

+

\[ L_U = {D \over 2} + 2 l_b \]

+

\[ L_{Ho} = D + 8 l_b \]

+

\[ L_H = L_{Ho} + 2.5 \sqrt[3]{ l_b x^2 } \]

+

with the buoyancy scale lb defined as \[ l_b = { { g_o Q_o } \over u_{ref}^{3} } \]

+
+
lb = (gₒ*Qₒ)/u₁₀^3
+
+
0.18392758812310803 m
+
+
+
+
Lᵤ  = D/2 + 2lb
+
+
1.4973003373658906 m
+
+
+
+
Lₕₒ = D + 8lb
+
+
3.7303110272242135 m
+
+
+
+
Lₕ(x) = Lₕₒ + 2.5(lb*x^2)
+
+
+

Upwind region

+

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.

+

Alternatively one could “fit” a curve to hit the end points while also having the same power of x: \(L_H = L_{Ho} \left( {x + L_U} \over L_U \right)^{2/3}\) 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.

+
+
+
+
+
+ +
+
+Figure 3: The various approaches to estimating the upwind plume extent, black dots are a digitization of the corresponding diagram from Britter and McQuaid shown for reference. +
+
+
+
+
+

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.

+
+
+

Vertical extent

+

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.

+

\[ L_V = {Q_o \over {u_{ref} L_H} } = { D^2 \over L_H } \]

+

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

+
+
+

+
image.png
+
+
+

Consider the steady state mass balance

+

\[ \textrm{mass in} = \textrm{mass out} \]

+

\[ c_o Q_o = \iint_A c u \,dA = \int_{0}^{\infty} \int_{-\infty}^{\infty} c(x,y,z) u(x,y,z) \,dy \,dz \]

+

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

+

\[ \iint_A c u \,dA = c_m u \int_{0}^{L_V} \int_{-L_H}^{L_H} \,dy \,dz = 2 c_m u L_H L_V \]

+

The steady state mass balance is then

+

\[ c_o Q_o = 2 c_m u L_H L_V \]

+

and the vertical extent can be solved for with some simple re-arrangement

+

\[ L_V = { { c_o Q_o } \over { 2 c_m u L_H } } = {1 \over 2}{ c_o \over c_m } { Q_o \over {u L_H} } \]

+

Setting the advection velocity of the plume to the reference windspeed gives

+

\[ L_V = {1 \over 2}{ c_o \over c_m } { Q_o \over {u_{ref} L_H} } = {1 \over 2} { c_o \over c_m } { D^2 \over L_H }\]

+
+
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:

+
    +
  • it depends upon the concentration
  • +
  • it is divided by two
  • +
+

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.

\[ L_V = {1 \over 2} { Q_o \over {u_{ref} L_H} } = {1 \over 2} { D^2 \over L_H }\]

+

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. \(c_m u A\) )

+
+
+
+
+
+ +
+
+Figure 4: Approaches to plume height estimation (top) and the corresponding conservation of mass (bottom). +
+
+
+
+
+

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. \(u = 0.4 u_{ref}\), 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.

+

\[ \bar{u} = { { \iint_A u \,dA } \over A } = { { \int_{0}^{L_V} u(z) \,dz } \over L_V } \]

+

Assuming the windspeed follows a powerlaw distribution \(u = u_{ref} \left( z \over z_{ref} \right)^p\) gives

+

\[ \bar{u} = { { \int_{0}^{L_V} u(z) \,dz } \over L_V } \]

+

\[ = {1 \over L_V} \int_{0}^{L_V} u_{ref} \left( z \over z_{ref} \right)^p \,dz \]

+

\[ = { u_{ref} \over {p+1} } \left( L_V \over z_{ref} \right)^p \]

+

plugging it into the simple mass balance

+

\[ c_o Q_o = c_m \bar{u} A \]

+

\[ = c_m {u_{ref} \over {p+1} } \left( L_V \over z_{ref} \right)^p { 2 L_H L_V } \]

+

re-arranging to solve for LV

+

\[ L_V = \left( { {p+1} \over 2 } { c_o \over c_m } z_{ref}^p {Q_o \over {u_{ref} L_H} } \right)^{1 \over {p+1} } \]

+

\[ = \left( { {p+1} \over 2 } { c_o \over c_m } z_{ref}^p {D^2 \over L_H } \right)^{1 \over {p+1} }\]

+

The red curve in the figure above is this model, using p = 0.15.7

+

This could also be done using the logarithmic windspeed curve \(u = {u_{\star} \over \kappa} \log \left( z \over z_) \right)\) where \(u_{\star}\) is the friction velocity and z0 is the roughness length. Though I don’t imagine the expression would work out as nicely.

+
+
+

Recommendations

+

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.

+
+
+
+

Calculating the explosive mass

+

The explosive mass in the cloud is the given by the volume integral

+

\[ m_e = \iiint_V c dV \]

+

where V is defined as the region where cLFL.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:

+
    +
  • within the plume, and
  • +
  • the concentration is ≥ LFL
  • +
+

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.

+
+

A nice property of top hat models

+

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)

+

\[ m_e = \iiint_V c \,dV = m_{e,u} + m_{e,d} \]

+

with the explosive mass of the downwind region being

+

\[ m_{e,d} = \int_0^{x_n} \iint_A c \,dA \,dx \]

+

For a top-hat model, since the concentration at a given downwind distance is constant everywhere within the plume cross-section \(\iint_A c dA = c_m A\), and, from a mass balance on the plume

+

\[ c_m A u = c_o Q_o \]

+

\[ c_m A = { {c_o Q_o} \over u} \]

+

which is a constant, thus

+

\[ m_{e,d} = \int_0^{x_n} c_m A \,dx \]

+

\[ = \int_0^{x_n} { {c_o Q_o} \over u} \,dx \]

+

\[ = { {c_o Q_o} \over u} x_n \]

+

For the explosive mass of the upwind region a simple box model gives \(m_{e,u} = 2 c_o L_U L_{Ho} L_{Vo}\). Putting everything together9

+

9 This is not specific to the Britter-McQuaid model, it works for any top hat model.

\[ m_e = 2 c_o L_U L_{Ho} L_{Vo} + { {c_o Q_o} \over u} x_n \]

+

This can be simplified greatly by setting the advection velocity to uref

+

\[ m_e = 2 c_o L_U L_{Ho} L_{Vo} + { {c_o Q_o} \over u_{ref} } x_n \]

+

\[ = 2 c_o L_U L_{Ho} {1 \over 2}{D^2 \over L_{Ho} } + c_o D^2 x_n \]

+

\[ = c_o D^2 \left( L_U + x_n \right)\]

+
+
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

+

11 Some sources recommend calculating the explosive mass as the region of the plume with the concentration LFLcUFL, in which case \(m_e = c_o D^2 \left( x_{n,LFL} - x_{n,UFL} \right)\)

If this seems too good to be true, the integration can be performed numerically by taking

+

\[ \iint_A c \,dA = c_m \cdot 2L_H \cdot L_V \]

+
+
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_ed
+
+
3197.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.

+
+
+

Added complications

+

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 xnx < xn )

+

\[ m_{e, \textrm{cut off} } = m_{e,u} + m_{e,d1} + m_{e,d2} \]

+

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.

+

\[ m_{e,d2} = \int_{2/3 x_n}^{x_n} \iint_A c \,dA \,dx \]

+

The integral can be re-written to take advantage of cmA being an invariant for a top-hat model,

+

\[ m_{e,d2} = \int_{2/3 x_n}^{x_n} \iint_A c \,dA \,dx \]

+

\[ = c_m A_{\textrm{original} } \int_{2/3 x_n}^{x_n} { A_{\textrm{cut off} } \over A_{\textrm{original} } } \,dx \]

+

Assuming the vertical extent remains unchanged in this operation, the ratio of areas is the same as the ratio of horizontal extents

+

\[ { A_{\textrm{cut off} } \over A_{\textrm{original} } } = { L_{H, \textrm{cut off} } \over L_{H, \textrm{original} } } \]

+

From some simple geometry, the horizontal extent is

+

\[ L_{H, \textrm{cut off} } = 3 L_{H, 2/3 x_n} { {x_n - x} \over x_n }\]

+

Which then leads to

+

\[ m_{e,d2} = 3 c_o D^2 \int_{2/3 x_n}^{x_n} { L_{H, 2/3 x_n} \over L_H } { {x_n - x} \over x_n } \,dx \]

+

There is probably a closed form for this integral but it is just as easy to integrate that numerically.

+

\[ m_{e, \textrm{cut off} } = c_o D^2 \left( L_U + \frac{2}{3}x_n + 3 L_{H, 2/3 x_n} \int_{2/3 x_n}^{x_n} { 1 \over L_H(x) } { {x_n - x} \over x_n } \,dx \right)\]

+
+
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
+
+
+
+
+
+

Final thoughts

+

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.

+

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).

+
+
+

References

+
+
+AIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999. +
+
+Bakkum, E. A., and N. J. Duijm. “Vapour Cloud Dispersion.” In Methods for the Calculation of Physical Effects, CPR 14E, edited by C. J. H. van den Bosch and R. A. P. M. Weterings, 3rd ed. The Hague: TNO, 2005. +
+
+Britter, Rex E., and J. McQuaid. “Workbook on the Dispersion of Dense Gases. HSE Contract Research Report No. 17/1988,” 1988. +
+
+Casal, Joachim. Evaluation of the Effects of Consequences of Major Accidents in Industrial Plants. 2nd ed. Amsterdam: Elsevier, 2018. +
+
+Lees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996. +
+
+Woodward, John L. Estimating the Flammable Mass of a Vapour Cloud. New York: American Institute of Chemical Engineers, 1998. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/Britter-McQuaid/index_files/figure-html/47a693ec-3597-4c02-9462-40185d0ffb38-1-28b141a6-e12c-48f3-9264-64ac9eb91ec6.png b/posts/Britter-McQuaid/index_files/figure-html/47a693ec-3597-4c02-9462-40185d0ffb38-1-28b141a6-e12c-48f3-9264-64ac9eb91ec6.png new file mode 100644 index 0000000..cdc474e Binary files /dev/null and b/posts/Britter-McQuaid/index_files/figure-html/47a693ec-3597-4c02-9462-40185d0ffb38-1-28b141a6-e12c-48f3-9264-64ac9eb91ec6.png differ diff --git a/posts/Britter-McQuaid/index_files/figure-html/bf1df5b1-1e57-4604-9d4f-69045279efd3-1-5502f497-792d-4375-bd80-664107a07d5d.png b/posts/Britter-McQuaid/index_files/figure-html/bf1df5b1-1e57-4604-9d4f-69045279efd3-1-5502f497-792d-4375-bd80-664107a07d5d.png new file mode 100644 index 0000000..e637f04 Binary files /dev/null and b/posts/Britter-McQuaid/index_files/figure-html/bf1df5b1-1e57-4604-9d4f-69045279efd3-1-5502f497-792d-4375-bd80-664107a07d5d.png differ diff --git a/posts/Britter-McQuaid/index_files/figure-html/fig-conc-profile-output-1.svg b/posts/Britter-McQuaid/index_files/figure-html/fig-conc-profile-output-1.svg new file mode 100644 index 0000000..12ecbbf --- /dev/null +++ b/posts/Britter-McQuaid/index_files/figure-html/fig-conc-profile-output-1.svg @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/Britter-McQuaid/index_files/figure-html/fig-plume-height-output-1.svg b/posts/Britter-McQuaid/index_files/figure-html/fig-plume-height-output-1.svg new file mode 100644 index 0000000..23bb547 --- /dev/null +++ b/posts/Britter-McQuaid/index_files/figure-html/fig-plume-height-output-1.svg @@ -0,0 +1,397 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/Britter-McQuaid/index_files/figure-html/fig-upwind-output-1.svg b/posts/Britter-McQuaid/index_files/figure-html/fig-upwind-output-1.svg new file mode 100644 index 0000000..51ae934 --- /dev/null +++ b/posts/Britter-McQuaid/index_files/figure-html/fig-upwind-output-1.svg @@ -0,0 +1,263 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/adiabatic-compressible-flow/index.html b/posts/adiabatic-compressible-flow/index.html new file mode 100644 index 0000000..a23fa72 --- /dev/null +++ b/posts/adiabatic-compressible-flow/index.html @@ -0,0 +1,1502 @@ + + + + + + + + + + + + +Adiabatic Compressible Flow in a Pipe – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Adiabatic Compressible Flow in a Pipe

+
+
+ Evaluating different models of adiabatic pipe flow. +
+
+
+
julia
+
compressible flow
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

September 23, 2022

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

I was looking through some books and it struck me how strangely inconsistent many standard references are when it comes to adiabatic compressible pipe flow. There are standard methods for incompressible flow and isothermal compressible flow of an ideal gas, but when it comes to adiabatic pipe flow the guidance is very scattershot.

+
+
+
+ +
+
+Note +
+
+
+

For brevity, from this point on whenever I refer to a flow I am referring to the ideal gas case in a constant area duct, unless otherwise specified

+
+
+

As a brief review of some common references: Crane’s1 gives a graphical method for adiabatic flow, which is the easiest to use with a pencil and paper, but doesn’t give a lot of details on how that model was developed. Albright’s2 recommends assuming flow is locally isentropic and gives a model of isentropic flow – that is flow which is both adiabatic and reversible – but with frictional losses also included, which allows for direct calculation if one assumes the friction factor is constant (with respect to the Reynold’s number). Perry’s3 gives the adiabatic irreversible flow model (i.e. Fanno flow), though with only a sketch of how to perform the iterative solution. Hall4 gives the Fanno flow model and, helpfully, a procedure for how to actually do the calculations and example VBA code. Ludwig’s5 gives both the isentropic and Fanno flow models but in a very confused manner: the section labeled “Adiabatic Flow” gives a model of isentropic flow (albeit with a typo in the equation) and suggests that all adiabatic flow is isentropic (which is false) and much later in a section labeled “Other Simplified Compressible Flow Methods” gives the Fanno flow model, though it doesn’t explain what it is, misattributes the derivation, and gives no clues on how to use it. Probably the best reference to sort all of this out is Coulson and Richardson’s6 as it provides easy to follow derivations of both the reversible and irreversible adiabatic flow models (the isentropic and Fanno flow models) and highlights their differences.

+

Another part of this confusion is differences in how the problem is being approached – or what problem, exactly, one is trying to solve. Typically the isothermal and isentropic flow models are presented as ways to solve for the flowrate given the pressure drop between two points, whereas the Fanno flow model is often given in terms of the Mach number and one is solving for the pressure drop. If you have the Mach number, rather obviously, you already know the flow, and it is often left as an exercise for the reader to figure out how to use the Fanno flow model to solve for flow.

+

Given all of that, I thought it may be worthwhile to unpack these various approaches to adiabatic flow, and see how they perform relative to one another.

+
+

Motivating Example

+

To give us something to work towards, suppose we wish calculate the flowrate of air in a horizontal section of piping – a 20m length of 2in schedule 40 steel pipe. In this case the pipe starts a 100kPag vessel which is at ambient temperature and exits into the air at ambient pressure.

+
+
+
+ +
+
+Figure 1: A sketch of the example system, a long straight section of pipe through which air is flowing. +
+
+
+
+
# Pipe dimensions
+L = 20     # m
+D = 0.0525 # m
+ϵ = 0.0457*1e-3 # m
+
+A = 0.25*π*D^2
+
+

For “ambient” conditions I am assuming standard conditions: 1 atmosphere and 15°C

+
+
P₂ = 101325 #Pa
+P₁ = P₂ + 100e3
+T₁ = 288.15 #K
+
+
+

Key Assumptions

+
    +
  • Air is an ideal gas, Z=1
  • +
  • The ratio of heat capacities, γ is constant
  • +
  • Heat loss is negligible, Δq=0
  • +
  • Flow is steady state, \(\dot{m}_{in} = \dot{m}_{out}\)
  • +
  • Flow is turbulent, α=1
  • +
  • Flow is horizontal, Δz=0
  • +
  • Friction factor is constant along the length
  • +
+
+
# Universal gas constant 
+# to more digits than are at all necessary
+R = 8.31446261815324 # Pa⋅m³/mol/K
+
+# Some useful physical properties of air
+Mw = 0.02896 # kg/mol
+γ = 1.4      # Cp/Cv, ideal gas
+
+# density of air, ideal gas law
+ρ(P,T) = (P*Mw)/(R*T); # kg/m³
+
+# viscosity of air, from Perry's
+μ(T) = (1.425e-6*T^0.5039)/(1+108.3/T); # Pa⋅s
+
+

The mass velocity, G = ρu, in a pipe with constant cross-sectional area at steady state is constant7, and the Reynold’s number can be written in terms of G as:

+

7 This is a consequence of the steady state assumption, \[\dot{m}_{in} = G_{in} A = G_{out} A = \dot{m}_{out}\]

\[ \mathrm{Re} = { {G D} \over \mu } \]

+

Where only the viscosity is a function of temperature, and for most gases only weakly so.

+
+
# Reynold's number
+Re(G,T) = G*D/μ(T);
+
+

The Darcy friction factor, f, is a function of the Reynolds number and, for ease of calculation, I am assuming the Churchill correlation applies,8 and that it can be taken as a constant at the average temperature (the arithmetic average of T1 and T2)

+
+
function churchill(Re; κ=ϵ/D)
+    A = (2.457 * log(1/((7/Re)^0.9 + 0.27*κ)))^16
+    B = (37530/Re)^16
+    return 8*((8/Re)^12 + 1/(A+B)^(3/2))^(1/12)
+end;
+
+K_entrance = 0.5
+K_exit = 1.0
+
+Kf(Re) = K_entrance + churchill(Re)*L/D + K_exit;
+
+

For a large Reynolds number approximation I am using the Nikuradse rough pipe law.9

+
+
fₙ = (2*log10(3.7*D/ϵ))^-2
+
+Kf() = K_entrance + fₙ*L/D + K_exit;
+
+
+
+

Choking Flow

+

A pitfall of compressible flow calculations is that flow at the exit of the pipe cannot exceed Ma=1, once the exit velocity achieves sonic velocity then the exit pressure will rise and the overall flowrate will remain at a constant, no matter the upstream pressure.

+

The easiest way to check for this is to use the limiting factors in Crane’s.10 Using the estimated Kf > 8 and γ=1.4, we can check:

+

\[ {\Delta P \over P_1} \lt 0.762 \]

+
+
(P₁ - P₂)/P₁
+
+
0.496709300881659
+
+
+
+
(P₁ - P₂)/P₁ < 0.762
+
+
true
+
+
+

There is a fit to the critical pressure ratios, as a function of Kf

+

\[\log \left( {\Delta P} \over P_1 \right) = A \left( \log K_f \right)^3 + B \left( \log K_f \right)^2 + C \left( \log K_f \right) + D\]

+

With the constants for γ=1.4:

+ + + + + + + + + + + + + + + + + + + +
A0.0011
B-0.0302
C0.238
D-0.6455
+
+
function critical_pressure(Kf)
+    @assert Kf > 0
+    x = log(Kf)
+    y = 0.0011*x^3 - 0.0302*x^2 + 0.238*x - 0.6455
+    return exp(y)
+end
+
+critical_pressure(Kf())
+
+
0.7707810480736812
+
+
+
+
(P₁ - P₂)/P₁ < critical_pressure(Kf())
+
+
true
+
+
+

For this problem we are well within the sub-sonic region.

+
+
+
+

Mechanical Energy Balance

+

Consider the differential form of the mechanical energy balance:

+

\[ {u \over \alpha} du + g dz + v dP + \delta W_s + \delta F = 0 \]

+

From the assumptions listed above, and noting that in this system there is no shaft work Ws, this can be simplified to:

+

\[ u du + v dP + {u^2 \over 2} {f \over D} dl = 0 \]

+

where f is the Darcy friction factor.

+

For compressible flow the velocity, u, varies along the length of the pipe while the mass velocity does not, so it is convenient to make the substitution u=G/ρ=Gv

+

\[ G^2 v dv + v dP + G^2 {v^2 \over 2} {f \over D} dl = 0 \]

+

Dividing through by v2 and integrating gives:

+

\[ G^2 \log \left( {v_2 \over v_1} \right) + \int_{P_1}^{P_2} {dP \over v} + {K_f \over 2} G^2 = 0 \]

+

where Kf is the pipe friction fL/D

+

The integral \(\int {dP \over v}\) is where the reversible and irreversible models differ, but they both amount to the same thing: integrate over an adiabatic path, and solve the mechanical energy balance for G.

+
+
+

Reversible Adiabatic Flow (Isentropic Flow)

+

Typically the isentropic flow model comes as a consequence of examining non-isothermal flow more generally, where one assumes Pvk is constant with k being a function of heat transfer (for the isothermal case k=1). The adiabatic case is then taken to be when k=γ. I think this is the greatest source of vaguery and confusion in the various sources I’ve looked at. Coulson and Richardson’s11 emphasizes that this is only an approximation as this equates to assuming an isentropic path, but many other sources either don’t make the distinction or only hint at it.

+

\[ Pv^\gamma = P_1 v_1^\gamma \]

+

\[ \int_{P_1}^{P_2} {dP \over v} = \int_{P_1}^{P_2} {1 \over v_1} \left( P \over P_1 \right)^{1 \over \gamma} dP \\ += {\gamma \over {\gamma + 1} } {P_1 \over v_1} \left( \left( P_2 \over P_1 \right)^{ {\gamma+1}\over\gamma} - 1 \right)\]

+

Substituting this into the mechanical energy balance gives

+

\[ G^2 \log \left( {v_2 \over v_1} \right) + {\gamma \over {\gamma + 1} } {P_1 \over v_1} \left( \left( P_2 \over P_1 \right)^{ {\gamma+1}\over\gamma} - 1 \right) + {K_f \over 2} G^2 = 0 \]

+

Making the substitution \[ {v_2 \over v_1} = \left( P_1 \over P_2 \right)^{1\over\gamma} \]

+

\[ \left( {K_f \over 2} - {1 \over \gamma} \log \left( {P_2 \over P_1} \right) \right) G^2 + {\gamma \over {\gamma + 1} } P_1 \rho_1 \left( \left( P_2 \over P_1 \right)^{ {\gamma+1}\over\gamma} - 1 \right) = 0 \]

+

This form is a convenient objective function for numerical solution, however it can be re-arranged to solve for G, giving:

+

\[ G = \sqrt{ { {2 \gamma \over {\gamma + 1} } P_1 \rho_1 \left( 1 - \left( P_2 \over P_1 \right)^{ {\gamma+1}\over\gamma} \right) } \over { K_f - {2 \over \gamma} \log \left( {P_2 \over P_1} \right) } } \]

+

Which is the form typically given in texts. If one assumes Kf is a constant then the mass velocity can be calculated directly. In practice, however, Kf is a function of the Reynolds number and so this must be solved numerically.

+
+
function isentropic_flow(P₁, K::Number; T₁=T₁, P₂=P₂, γ=γ)
+    q = P₂/P₁
+    ρ₁ = ρ(P₁,T₁)
+    G = ( ((2γ/+1))*P₁*ρ₁*(1-q^((γ+1)/γ)))/(K - (2/γ)*log(q)) )
+    return G
+end;
+
+
+
using Roots: find_zero
+
+function isentropic_flow(P₁, K::Function; T₁=T₁, P₂=P₂, γ=γ) 
+    # Initialize Parameters
+    q = P₂/P₁
+    ρ₁ = ρ(P₁,T₁)
+    T₂ = T₁*(q^((γ-1)/γ))
+    Tₐᵥ = (T₁+T₂)/2
+    
+    # Initial guess
+    G_est = isentropic_flow(P₁, K(); T₁=T₁, P₂=P₂, γ=γ)
+    
+    # Numerically solve for G
+    obj(G) = (K(Re(G,Tₐᵥ)) - (2/γ)*log(q))*(G^2) - (2γ/+1))*P₁*ρ₁*(1-q^((γ+1)/γ))
+    G = find_zero(obj, G_est)
+    
+    return G
+end;
+
+
+
ṁ_i = isentropic_flow(P₁, Kf)*A
+
+
0.43138829795543004
+
+
+
+
+
+
+
+ +
+
+Figure 2: The mass flowrate through the example piping system as a function of pressure drop, using an isentropic flow model. +
+
+
+
+
+
+
+

Irreversible Adiabatic Flow (Fanno Flow)

+

The integration for Fanno flow is decidedly more tedious. As a sketch, start with the invariant (which comes from taking an energy balance for an ideal gas):

+

\[ {1 \over 2} \left( Gv \right)^2 + {\gamma \over {\gamma -1} } Pv = \textrm{a constant}\]

+

Solve for P, take the derivative to determine dP, substitute and integrate. The result is:

+

\[ \int_{P_1}^{P_2} {dP \over v} = { {\gamma -1} \over {\gamma} } G^2 \left( \left(v_1 \over v_2\right) -1 -2 \log \left(v_1 \over v_2\right) \right) - \frac{1}{2} {P_1 \over v_1} \left( 1 - \left(v_1 \over v_2\right)^2 \right)\]

+

Which, when substituted into the mechanical energy balance and simplified, becomes:

+

\[ \left( K - { {\gamma -1} \over {2 \gamma} } \left( 1 - \left( { \rho_2 \over \rho_1 } \right)^2 \right) - { {\gamma +1} \over \gamma} \log \left( { \rho_2 \over \rho_1 } \right) \right) G^2 - \left( 1 - \left( { \rho_2 \over \rho_1 } \right)^2 \right) P_1 \rho_1 = 0 \]

+

and can be further re-arranged to solve for G

+

\[ G = \sqrt{ { P_1 \rho_1 \left( 1 - \left( { \rho_2 \over \rho_1 } \right)^2 \right) } \over { K - { {\gamma -1} \over {2 \gamma} } \left( 1 - \left( { \rho_2 \over \rho_1 } \right)^2 \right) - { {\gamma +1} \over \gamma} \log \left( { \rho_2 \over \rho_1 } \right) } } \]

+

The obvious complication here is that ρ2 is unknown, so solving this requires simultaneously solving for either ρ2 or T2.

+

Most often the Fanno flow model is given in terms of the Mach number, however the equation above is equivalent. This can be shown most easily by starting with the definition of the Fanno parameter, Fa, and the relation K = Fa1 - Fa2,

+

\[ Fa = \left(\frac{1 - Ma^2}{\gamma Ma^2}\right) + \left(\frac{\gamma + 1}{2\gamma}\right)\log\left[\frac{Ma^2}{\left(\frac{2}{\gamma + 1}\right)\left(1 + \frac{\gamma - 1}{2}Ma^2\right)}\right] \]

+

\[ K = \left(\frac{1 - Ma_1^2}{\gamma Ma_1^2}\right) - \left(\frac{1 - Ma_2^2}{\gamma Ma_2^2}\right) + \left(\frac{\gamma + 1}{2\gamma}\right)\log\left[\frac{Ma_1^2}{Ma_2^2}\frac{\left(1 + \frac{\gamma - 1}{2}Ma_2^2\right)}{\left(1 + \frac{\gamma - 1}{2}Ma_1^2\right)}\right] \]

+

Then, making the substitution:

+

\[\left(v_1 \over v_2\right)^2 = { {Ma_1^2 \left( 1 + { {\gamma-1}\over 2} Ma_2^2 \right)} \over {Ma_2^2 \left( 1 + { {\gamma-1}\over 2} Ma_1^2 \right)} }\]

+

we get:

+

\[ K = \left( \frac{1}{\gamma Ma_1^2} + \frac{\gamma-1}{2\gamma} \right) \left( 1 - \left(v_1 \over v_2\right)^2 \right) + \frac{\gamma + 1}{\gamma} \log\left(v_1 \over v_2\right) \]

+

Then, using the definition of the Mach number, in terms of G, \(Ma=\frac{G}{\sqrt{\gamma P \rho} }\)

+

\[ K = \left( \frac{P_1 \rho_1}{G^2} + \frac{\gamma-1}{2\gamma} \right) \left( 1 - \left(v_1 \over v_2\right)^2 \right) + \frac{\gamma + 1}{\gamma} \log\left(v_1 \over v_2\right) \]

+

Solving for G, and making the substitution ρ = 1/v

+

\[ G = \sqrt{ { P_1 \rho_1 \left( 1 - \left( { \rho_2 \over \rho_1 } \right)^2 \right) } \over { K - { {\gamma -1} \over {2 \gamma} } \left( 1 - \left( { \rho_2 \over \rho_1 } \right)^2 \right) - { {\gamma +1} \over \gamma} \log \left( { \rho_2 \over \rho_1 } \right) } } \]

+

Which puts us back where we started.

+

When it comes to actually using the Fanno flow model, if the goal is to calculate the flowrate for a given pressure drop, working in terms of the specific volume or density is far easier than using the model given in terms of the Mach number.

+
+

Approximating Temperature Change

+

An obvious simplifying assumption is to estimate the exit temperature using the relationship for isentropic flow.

+

\[ {T_2 \over T_1} = \left( P_2 \over P_1 \right)^{ {\gamma-1} \over \gamma} \]

+

If we were assuming Kf is constant, then using this assumption to estimate the density at the exit allows for a direct calculation of the mass velocity, no numerical methods required. In the more general case, the flow still needs to be calculated iteratively as the friction factor is a function of the flow (Reynolds number).

+
+
function approx_fanno_flow(P₁, K::Number; T₁=T₁, P₂=P₂, γ=γ)
+    ρ₁ = ρ(P₁, T₁)
+    ρ₂ = ρ₁*(P₂/P₁)^(1/γ)
+    q  = ρ₂/ρ₁
+    G = ( (P₁*ρ₁*(1-q^2))/(K - ((γ-1)/(2γ))*(1-q^2) - ((γ+1)/γ)*log(q)) )
+    return G
+end;
+
+
+
using Roots: find_zero
+
+function approx_fanno_flow(P₁, K::Function; T₁=T₁, P₂=P₂, γ=γ)
+    # Initializing some parameters
+    T₂ = T₁*(P₂/P₁)^((γ-1)/γ)
+    ρ₁ = ρ(P₁, T₁)
+    ρ₂ = ρ(P₂, T₂)
+    q  = ρ₂/ρ₁
+    Tₐᵥ  = (T₁+T₂)/2
+    
+    # Initial guess
+    G_est = approx_fanno_flow(P₁, K(); T₁=T₁, P₂=P₂, γ=γ)
+    
+    # Numerically solve for G
+    obj(G) = (K(Re(G,Tₐᵥ)) - ((γ-1)/(2γ))*(1-q^2) - ((γ+1)/γ)*log(q))*(G^2) - (1-q^2)*P₁*ρ₁
+    G = find_zero(obj, G_est)
+    
+    return G
+end;
+
+
+
ṁ_fa = approx_fanno_flow(P₁, Kf)*A
+
+
0.38355173684967075
+
+
+
+
+

The Full Treatment

+

Actually calculating the exit conditions requires a little more work. I am going to simultaneously calculate the density at exit since it is somewhat simpler to work with than the temperature, though either could be done.

+

To start we note that:

+

\[ {1 \over 2} \left( Gv \right)^2 + {\gamma \over {\gamma -1} } Pv = C = \textrm{a constant}\]

+

Which is a quadratic in v, and solving for v:

+

\[ v = {1 \over G^2} \left( \sqrt{ \left( {\gamma \over {\gamma -1} } P \right)^2 + 2 G^2 C} - {\gamma \over {\gamma-1} } P \right) \]

+

Where C is calculated at the entrance conditions and ρ = 1/v.

+

From this the temperature at the exit can be backed out using the ideal gas law, and used to update the Reynolds number.

+

This makes the whole calculation somewhat more complicated, and I think that added complication makes the simplification that Kf is constant pointless – that assumption does not make the math easier in any case.

+
+
using Roots: find_zero
+
+function fanno_flow(P₁, K::Function; T₁=T₁, P₂=P₂, γ=γ)
+    # Initialize some parameters
+    ρ₁ = ρ(P₁,T₁)
+    
+    # Initial guesses
+    q = (P₂/P₁)^(1/γ) # isentropic
+    G_est = ( (P₁*ρ₁*(1-q^2))/(K() - ((γ-1)/(2γ))*(1-q^2) - ((γ+1)/γ)*log(q)) )
+    
+    function obj(G)
+        # Calculate the downstream density
+        C  = 0.5*(G/ρ₁)^2 +/-1))*(P₁/ρ₁)
+        G²v = (((γ/-1))*P₂)^2 + 2*(G^2)*C) -/-1))*P₂
+        ρ₂ = (G^2)/G²v
+        @assert ρ₂ > 0
+
+        # Update Temperature dependent parameters
+        T₂ = (P₂*Mw)/(R*ρ₂)
+        Tₐᵥ = (T₁+T₂)/2
+
+        # Calculate the objective value
+        q  = ρ₂/ρ₁
+        return (K(Re(G,Tₐᵥ)) - ((γ-1)/(2γ))*(1-q^2) - ((γ+1)/γ)*log(q))*G^2 - P₁*ρ₁*(1-q^2)
+    end
+    
+    G = find_zero(obj, G_est)
+    
+    return G
+end;
+
+
+
function fanno_flow(P₁, K::Number; T₁=T₁, P₂=P₂, γ=γ)
+    fn() = K
+    fn(Re) = K
+    return fanno_flow(P₁, fn; T₁=T₁, P₂=P₂, γ=γ)
+end;
+
+
+
ṁ_f = fanno_flow(P₁, Kf)*A
+
+
0.40934309494917254
+
+
+
+
+
+
+
+ +
+
+Figure 3: The mass flowrate through the example piping system as a function of pressure drop, using an adiabatic Fanno flow model. +
+
+
+
+
+

The approximation produces reasonable results in this case, especially at higher pressure drops, but one should always be cautious when mixing results from different models.12

+

12 This is something worth keeping mind more generally, as I have seen the assumption that Fanno flow is approximately isentropic (implicitly) taken for calculating different flow parameters, and it is often a bad assumption. For example, some references use the isentropic choking condition for a nozzle as an estimate for the choking condition in Fanno flow. Unless the pipe is incredibly short this is a terrible approximation – in the current example the pressure drop exceeds the choking flow condition for a nozzle and yet the pipe flow is far from choked.

+
+
+

Expansion Factors (Y Factors)

+

By far the simplest method is to use a modified Darcy equation with expansion factors (Y factors). This takes the well known Darcy equation for incompressible pipe flow and, in a classic engineering move, tacks on a Y factor to account for all the complexity in adiabatic flow.

+

\[ G = Y \sqrt{ { 2 \rho_1 \Delta P } \over K } \]

+

Where the expansion factor, Y, is read off of a chart. This is great if you are working things out by hand, but can present some challenges when calculating things on a computer. Ludwig’s13 provides a complicated series of equations to iteratively calculate the Y curves yourself, but I think if you are expending that level of effort then you really are not saving anything over using the Fanno model above. A much simpler approach is to either interpolate the critical expansion factor, Ycr, and critical pressure ratio, qcr, from the values given in Crane’s or use a correlation for them (that’s what I will use). Though this adds the wrinkle of only being able to use Y factors for gases with the same γ as what is either tabulated or available in a correlation.

+

The actual Y value then comes from a simple linear relationship (where \(q={ {\Delta P} \over P_1}\) )

+

\[ Y = \left( Y_{cr} -1 \right) \left( q \over q_{cr} \right) +1 \]

+

This has the added convenience of telling you when you have crossed into choked flow, it happens when q>qcr.

+

One downside is that this method does not directly produce the exit conditions, so the Reynolds number is typically taken at the entrance conditions only. Since the Reynolds number is only a function of Temperature through the viscosity, this works out fine over ranges where the viscosity is approximately constant.14

+

14 The temperature can be worked out by using the method given in the section for Fanno flow, calculating the invariant at entrance conditions (once G is known) and then solving for the exit density.

+
# Correlations for γ=1.4
+
+function Ycr(K)
+    x = log(K)
+    y = 0.0006*x^3 - 0.0185*x^2 + 0.1141*x - 0.5304
+    return exp(y)
+end;
+
+function qcr(K)
+    x = log(K)
+    y = 0.0011*x^3 - 0.0302*x^2 + 0.238*x - 0.6455
+    return exp(y)
+end;
+
+
+
+
+
+
+ +
+
+Figure 4: The correlation curves for critical expansion factor and critical pressure ratio, along with tabulated values from Crane’s.15 +
+
+
+
+
+

The correlation curves I am using fit the tabulated values from Crane’s reasonably well, but clearly the fit is not perfect.

+
+
function Y(K,q)
+    Yc, qc = Ycr(K), qcr(K)
+    if q < qc
+        return (Yc-1)*(q/qc)+1
+    else
+        return Yc
+    end
+end;
+
+
+
+
+
+
+ +
+
+Figure 5: The expansion factor vs pressure ratio, this calculated example falls between the reference curves from Crane’s as expected.16 +
+
+
+
+
+

The calculated curve is between the bracketing curves in Crane’s and looks plausible.

+

If you are assuming that Kf is constant then the mass velocity can be calculated directly, however if you wish to be more exact you can also iterate.

+
+
function modified_darcy(P₁, K::Number; T₁=T₁, P₂=P₂, γ=γ)
+    ρ₁ = ρ(P₁,T₁)
+    q = (P₁-P₂)/P₁
+    G = Y(K,q)*√(2*ρ₁*(P₁-P₂)/K)
+    return G
+end;
+
+
+
using Roots: find_zero
+
+function modified_darcy(P₁, K::Function; T₁=T₁, P₂=P₂, γ=γ)
+    # Intialize Parameters
+    ρ₁ = ρ(P₁,T₁)
+    q = (P₁-P₂)/P₁
+    
+    # Initial Guess
+    G_est = modified_darcy(P₁, K(); T₁=T₁, P₂=P₂, γ=γ)
+    
+    # Numerically solve for G
+    obj(G) = G - modified_darcy(P₁, K(Re(G,T₁)); T₁=T₁, P₂=P₂, γ=γ) 
+    G = find_zero(obj, G_est)
+    
+    return G
+end;
+
+
+
ṁ_y = modified_darcy(P₁, Kf)*A
+
+
0.4049511071122898
+
+
+
+
+
+
+
+ +
+
+Figure 6: The mass flowrate through the example piping system as a function of pressure drop, using the modified Darcy equation. +
+
+
+
+
+
+
+

Comparison

+

Below is a plot showing all of the methods examined so far, including assuming the isothermal case (this a common recommendation for a simplifying assumption). The expansion factor method approximates the Fanno flow method, from which it was derived, quite well, to the point where they are essentially indistinguishable. The isothermal model is practically just as good for this particular example, while the isentropic model works well only for low pressure drops, the version of the Fanno flow that approximates the temperature as isentropic is the opposite, being the worst model at low pressure drops and converging towards the Fanno model at higher pressure drops.

+
+
+
+
+
+ +
+
+Figure 7: The mass flowrate through the example piping system as a function of pressure drop, showing all of the discussed models. Note the significant overlap of the Fanno flow, modified Darcy equation, and isothermal flow curves. +
+
+
+
+
+

But this is just one example, perhaps we can look at a wider range of Kf and pressure drops. Conveniently, Crane’s has a table with Kf ranging from 1 to 100 and calculated pressure drops and expansion factors: the limiting factors. Using the models examined above, the effective expansion factors can be calculated quite easily for each K in Crane’s table (taking the pressure ratios as givens).

+
+
+
+
+
+ +
+
+Figure 8: Calculated expansion factors for flow at the critical K values tabulated in Crane’s, this represents flow at the critical pressure ratio17 +
+
+
+
+
+

Note that this represents the greatest pressure drop for a given Kf, which should correspond to the “worst case” for most models (except the approximated Fanno model). The Fanno model and the correlation I was using to generate Y factors line up quite nicely, though there is a fair amount of scatter with the tabulated Y factors which is interesting. The isentropic model is close, but not in great agreement, over the entire range. What I find more interesting is how rapidly the isothermal model comes into agreement. We would expect, then, at Kf=100 that the isothermal model would basically fall on top of the Fanno model over the entire range of pressure.

+
+
+
+
+
+ +
+
+Figure 9: The mass flowrate for isothermal and Fanno flow models vs pressure drop for high K piping systems. Note that both lines overlap almost entirely for the entire range. +
+
+
+
+
+

They are basically indistinguishable. However, this is not at all implying that the temperature in the Fanno flow model is remaining constant over these large pressure drops. As one would expect, the adiabatic flow moves further from isothermal as the pressure drop increases. The mass flows just happen to be the same.

+
+
+
+
+
+ +
+
+Figure 10: The exit temperature for isothermal, isentropic, and Fanno flow models vs pressure drop for high K piping systems. Note that while the isothermal and Fanno flow models may give identical mass flowrates, the exit conditions are quite different. +
+
+
+
+
+
+
+

Final Thoughts

+

I think the big take-away is that the isentropic flow model is not a very good approximation of Fanno flow and references that suggest that it is are in error. The other big take-away may be that, at least when calculating mass flow rates, the isothermal model is often better than one would expect: it does well at low pressure drops and also for long lines where Kf is large. In practice, when the flow conditions are within the range of available Y factors, the modified Darcy equation is the easiest to use and gives excellent agreement with the full Fanno model, however when the situation is outside of that range and Y factors have to be calculated it is not a time-saver.

+

The big elephant in the room is that, in practice, no actual gas flow is perfectly ideal or perfectly adiabatic, nor is the friction factor truly a constant. These assumptions play a big role in the overall model error, and being fussy about some of the details of different adiabatic ideal gas models may amount to nothing in practice.

+
+
+

References

+
+
+Albright, Lyle F. Albright’s Chemical Engineering Handbook. Boca Raton: CRC Press, 2009. +
+
+Chhabra, R. P., and V. Shankar. Coulson and Richardson’s Chemical Engineering: Fluid Flow: Fundamentals and Applications. 7th ed. Vol. 1A. Amsterdam: Elsevier, 2018. +
+
+Coker, A. Kayode. Ludwig’s Applied Process Design for Chemical and Petrochemical Plants. 4th ed. Amsterdam: Elsevier, 2007. +
+
+Crane. “TP410M: Flow of Fluids.” Stamford, CT: Crane, 2013. +
+
+Hall, Stephen M. Rules of Thumb for Chemical Engineers. 6th ed. Amsterdam: Elsevier, 2018. +
+
+Tilton, James N. “Fluid and Particle Dynamics.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/adiabatic-compressible-flow/index_files/figure-html/a0003116-672c-4d67-b435-041a95ddaf7d-1-65179bdc-753b-4290-83bc-9d63116a1a57.png b/posts/adiabatic-compressible-flow/index_files/figure-html/a0003116-672c-4d67-b435-041a95ddaf7d-1-65179bdc-753b-4290-83bc-9d63116a1a57.png new file mode 100644 index 0000000..2f74abf Binary files /dev/null and b/posts/adiabatic-compressible-flow/index_files/figure-html/a0003116-672c-4d67-b435-041a95ddaf7d-1-65179bdc-753b-4290-83bc-9d63116a1a57.png differ diff --git a/posts/adiabatic-compressible-flow/index_files/figure-html/fig-correl-output-1.svg b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-correl-output-1.svg new file mode 100644 index 0000000..ff4fb54 --- /dev/null +++ b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-correl-output-1.svg @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/adiabatic-compressible-flow/index_files/figure-html/fig-exp-and-models-output-1.svg b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-exp-and-models-output-1.svg new file mode 100644 index 0000000..04996c3 --- /dev/null +++ b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-exp-and-models-output-1.svg @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/adiabatic-compressible-flow/index_files/figure-html/fig-exp-factor-output-1.svg b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-exp-factor-output-1.svg new file mode 100644 index 0000000..80b4cb0 --- /dev/null +++ b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-exp-factor-output-1.svg @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/adiabatic-compressible-flow/index_files/figure-html/fig-fanno-output-1.svg b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-fanno-output-1.svg new file mode 100644 index 0000000..1cbab44 --- /dev/null +++ b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-fanno-output-1.svg @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/adiabatic-compressible-flow/index_files/figure-html/fig-isentropic-flow-output-1.svg b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-isentropic-flow-output-1.svg new file mode 100644 index 0000000..0de4f4a --- /dev/null +++ b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-isentropic-flow-output-1.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/adiabatic-compressible-flow/index_files/figure-html/fig-isotherm-fanno-output-1.svg b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-isotherm-fanno-output-1.svg new file mode 100644 index 0000000..302ebf0 --- /dev/null +++ b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-isotherm-fanno-output-1.svg @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/adiabatic-compressible-flow/index_files/figure-html/fig-mass-flow-output-1.svg b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-mass-flow-output-1.svg new file mode 100644 index 0000000..3f25706 --- /dev/null +++ b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-mass-flow-output-1.svg @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/adiabatic-compressible-flow/index_files/figure-html/fig-mod-darcy-output-1.svg b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-mod-darcy-output-1.svg new file mode 100644 index 0000000..1714ac1 --- /dev/null +++ b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-mod-darcy-output-1.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/adiabatic-compressible-flow/index_files/figure-html/fig-temp-drops-output-1.svg b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-temp-drops-output-1.svg new file mode 100644 index 0000000..47a09f4 --- /dev/null +++ b/posts/adiabatic-compressible-flow/index_files/figure-html/fig-temp-drops-output-1.svg @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/atmotube_data_logging/index.html b/posts/atmotube_data_logging/index.html new file mode 100644 index 0000000..7135ad7 --- /dev/null +++ b/posts/atmotube_data_logging/index.html @@ -0,0 +1,2101 @@ + + + + + + + + + + + + +Logging data from an Atmotube PRO over Bluetooth – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Logging data from an Atmotube PRO over Bluetooth

+
+
+ Having fun with data logging. +
+
+
+
python
+
air quality
+
atmotube
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

May 19, 2025

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

I have had an Atmotube Pro for a few years, mostly using it during the summer to keep an eye on poor air quality during wildfire smoke events. I often export data from it, as a csv, to noodle around, but I haven’t really looked at how to log data directly from it with my laptop. Atmotube provides documentation on the bluetooth API and a guide for how to set up an MQTT router. But I couldn’t really find anything on just logging data from it directly, using python.

+

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.

+
+

Requesting data with GATT

+

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 time
+
+
+
from 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:

+
    +
  1. The info byte is 8-bits where each bit corresponds to a particular flag. I could pull out each bit one by one using bit-shifting or something, but using a ctype struct lets me map the whole two-byte status characteristic into the various info flags and the battery state in one clean step.
  2. +
+
+
import struct
+
+
+
from 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),
+    ]
+
+
    +
  1. The PM characteristic is a 12-byte sequence where each set of 3-bytes is a 24-bit integer. This is not an integer type that is natively supported by python. I thought I could do the same thing as the Status characteristic and map it onto a ctype struct, but that didn’t work. As a work-around I collect each 3-byte sequence as arrays and convert each to an int as a two-step process. I could also have used int.from_bytes() directly, but I think this is a little neater and easier to read.
  2. +
+
+
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 result
+
+

Now 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.

+
+
+

Collecting broadcast data

+

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 asyncio
+
+
+
async 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 results
+
+

Running 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 result
+
+

I 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 results
+
+

Which I let collect for 5 minutes

+
+
new_broadcasts = await better_collect_data(ATMOTUBE, 300)
+
+
+
+

Processing the broadcast data

+

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 pd
+
+
+
df = pd.DataFrame(new_broadcasts)
+
+
+
df.describe()
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampVOCRHTPPM1PM2.5PM10
count4.100000e+0257.00000057.00000057.057.000000353.0353.000000353.000000
mean1.747674e+090.20322835.10526321.093.3511931.02.0056663.039660
std8.914660e+010.0027970.4505640.00.0045760.00.1845500.246825
min1.747674e+090.19900034.00000021.093.3430001.01.0000002.000000
25%1.747674e+090.20100035.00000021.093.3480001.02.0000003.000000
50%1.747674e+090.20300035.00000021.093.3510001.02.0000003.000000
75%1.747674e+090.20400035.00000021.093.3550001.02.0000003.000000
max1.747674e+090.21000036.00000021.093.3610001.03.0000004.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']
+
+
+
+
+
+
+ +
+
+Figure 1: Time series data of indoor VOC and PM concentrations, a 5 minute sample of BLE advertising data +
+
+
+
+
+

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.

+
+
+

Logging to a CSV

+

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 csv
+
+
+
async 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 True
+
+
+
+
+ +
+
+Warning +
+
+
+

The 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 math
+
+
+
now = 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()
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampVOCRHTPPM1PM2.5PM10
count4.823000e+03835.000000835.000000835.0835.0000003988.03988.0000003988.000000
mean1.747676e+090.22652234.81077821.093.3205901.01.9190072.945587
std1.037307e+030.0128840.7114200.00.0180190.00.3287260.325025
min1.747674e+090.19500034.00000021.093.2830001.01.0000002.000000
25%1.747675e+090.21700034.00000021.093.3030001.02.0000003.000000
50%1.747676e+090.23000035.00000021.093.3250001.02.0000003.000000
75%1.747677e+090.23700035.00000021.093.3370001.02.0000003.000000
max1.747678e+090.24900037.00000021.093.3550001.03.0000004.000000
+ +
+
+
+
+
logged_data['Time'] = logged_data['Timestamp'] - logged_data.iloc[0]['Timestamp']
+
+
+
+
+
+
+ +
+
+Figure 2: Time series data of indoor VOC and PM concentrations, a 1-hr sample of BLE advertising data +
+
+
+
+
+

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, ppmAQSAir quality health index (AQHI) - CanadaTemperature, °CHumidity, %Pressure, kPaPM1, ug/m3PM2.5, ug/m3PM2.5 (avg 3h), ug/m3PM10, ug/m3PM10 (avg 3h), ug/m3LatitudeLongitude
count66.00000066.00000066.066.066.00000066.00000066.066.00000066.00000066.00000066.0000000.00.0
mean0.23998585.0454551.021.034.48484893.3163641.01.5303031.5591752.5454552.861027NaNNaN
std0.0185631.1560120.00.00.7694640.0198170.00.5029050.0361290.5017450.041909NaNNaN
min0.21200082.0000001.021.033.00000093.2800001.01.0000001.4666672.0000002.722222NaNNaN
25%0.22825085.0000001.021.034.00000093.3000001.01.0000001.5500002.0000002.866667NaNNaN
50%0.23800085.0000001.021.034.50000093.3200001.02.0000001.5611113.0000002.877778NaNNaN
75%0.24475086.0000001.021.035.00000093.3375001.02.0000001.5833333.0000002.888889NaNNaN
max0.29500087.0000001.021.036.00000093.3500001.02.0000001.6166673.0000002.888889NaNNaN
+ +
+
+
+
+
from datetime import datetime
+
+
+
export_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']
+
+
+
+
+
+
+ +
+
+Figure 3: Time series data of indoor temperature and pressure, a 1-hr sample of BLE advertising data and data exported from the Atmotube app +
+
+
+
+
+

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.

+
+
+
+
+
+ +
+
+Figure 4: Time series data of indoor VOC concentrations, a 1-hr sample of BLE advertising data and data exported from the Atmotube app +
+
+
+
+
+

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.

+
+
+
+
+
+ +
+
+Figure 5: Time series data of indoor PM2.5 concentrations, a 1-hr sample of BLE advertising data and data exported from the Atmotube app +
+
+
+
+
+

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.

+
+
+

Final Thoughts

+

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!

+
+
+

Update

+
+
+
+ +
+
+TipUpdate +
+
+
+

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 = ts
+
+
+
class 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 None
+
+
+
class 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/1000
+
+
+
class 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/1000
+
+

With 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:

+
    +
  1. The collector starts up and scans for the atmotube, by MAC address.
  2. +
  3. When it finds the device it requests notifications for one of the GATT characteristics, in this case I am requesting the status data and the SPS30 data, which contains the pm concentrations.
  4. +
  5. The collector then waits around for the 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 queue
  6. +
+
+
async 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:

+
    +
  1. When the logger starts it creates a new csv file with the given filename, and writes the column headers.
  2. +
  3. The worker waits for data to appear on the queue and, once it does, takes it out (first in first out).
  4. +
  5. The result from the queue is lined up to the right columns in the csv, I check for the attribute battery_level as a lazy check of which type of result it is.
  6. +
  7. Finally the worker writes the result as new row on the csv.
  8. +
  9. If the result is None, that is a signal that the collector has finished and the loop exits.
  10. +
  11. Regardless, once the logger has processed the data from the queue, it calls task_done() to notify the queue of this and the loop begins again.
  12. +
+
+
import aiofiles, aiocsv
+
+
+
HEADERS = ["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:

+
    +
  1. Create an empty asyncio Queue
  2. +
  3. Start the logger, the worker that logs results to the csv
  4. +
  5. Start the collector, the worker that collects packets from the atmotube
  6. +
  7. Wait for the collector to finish, then close.
  8. +
+
+
async 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 collector
+
+

I 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()
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampPM SensorPM1PM2.5PM10Time
01.748482e+09NaN10.9213.4314.970.000000
11.748482e+09NaN10.9313.0314.662.610108
21.748482e+09NaN11.0513.4215.195.220881
31.748482e+09NaN11.3513.7515.347.784888
41.748482e+09NaN11.5914.1315.5010.395001
+ +
+
+
+
+
+
+
+
+ +
+
+Figure 6: Time series data of indoor PM2.5 concentrations, a 1-hr sample using GATT notifications +
+
+
+
+
+

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]
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampPM SensorPM1PM2.5PM10Time
361.748482e+09NaN12.4214.3415.23127.802146
371.748482e+090.0NaNNaNNaN130.322095
381.748482e+09NaNNaNNaNNaN130.322191
391.748483e+091.0NaNNaNNaN1035.107224
401.748483e+09NaN7.749.5510.211040.146906
411.748483e+09NaN7.819.8211.401042.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.

+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/atmotube_data_logging/index_files/figure-html/fig-1-output-1.png b/posts/atmotube_data_logging/index_files/figure-html/fig-1-output-1.png new file mode 100644 index 0000000..0170d1e Binary files /dev/null and b/posts/atmotube_data_logging/index_files/figure-html/fig-1-output-1.png differ diff --git a/posts/atmotube_data_logging/index_files/figure-html/fig-2-output-1.png b/posts/atmotube_data_logging/index_files/figure-html/fig-2-output-1.png new file mode 100644 index 0000000..0c0c497 Binary files /dev/null and b/posts/atmotube_data_logging/index_files/figure-html/fig-2-output-1.png differ diff --git a/posts/atmotube_data_logging/index_files/figure-html/fig-3-output-1.png b/posts/atmotube_data_logging/index_files/figure-html/fig-3-output-1.png new file mode 100644 index 0000000..f3291a9 Binary files /dev/null and b/posts/atmotube_data_logging/index_files/figure-html/fig-3-output-1.png differ diff --git a/posts/atmotube_data_logging/index_files/figure-html/fig-4-output-1.png b/posts/atmotube_data_logging/index_files/figure-html/fig-4-output-1.png new file mode 100644 index 0000000..aa33e18 Binary files /dev/null and b/posts/atmotube_data_logging/index_files/figure-html/fig-4-output-1.png differ diff --git a/posts/atmotube_data_logging/index_files/figure-html/fig-5-output-1.png b/posts/atmotube_data_logging/index_files/figure-html/fig-5-output-1.png new file mode 100644 index 0000000..56a21d4 Binary files /dev/null and b/posts/atmotube_data_logging/index_files/figure-html/fig-5-output-1.png differ diff --git a/posts/atmotube_data_logging/index_files/figure-html/fig-6-output-1.png b/posts/atmotube_data_logging/index_files/figure-html/fig-6-output-1.png new file mode 100644 index 0000000..e875bcc Binary files /dev/null and b/posts/atmotube_data_logging/index_files/figure-html/fig-6-output-1.png differ diff --git a/posts/atmotube_data_logging/pexels-fotios-photos-header.jpg b/posts/atmotube_data_logging/pexels-fotios-photos-header.jpg new file mode 100644 index 0000000..554a50b Binary files /dev/null and b/posts/atmotube_data_logging/pexels-fotios-photos-header.jpg differ diff --git a/posts/building_infiltration_2/index.html b/posts/building_infiltration_2/index.html new file mode 100644 index 0000000..85867eb --- /dev/null +++ b/posts/building_infiltration_2/index.html @@ -0,0 +1,1614 @@ + + + + + + + + + + + + +Building Infiltration Example – Chlorine Release – A Chemical Engineer's Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Building Infiltration Example – Chlorine Release

+
+
+ Single zone building infiltration model with an instantaneous release +
+
+
+
julia
+
dispersion modelling
+
building infiltration
+
hazard screening
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

June 19, 2021

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

In a previous notebook, I explored building infiltration due to forest fire smoke and noted that ambient conditions would impact the scenario, in this scenario I continue exploring building infiltration and examine the sensitivity of infiltration to changes in ambient conditions. In this case the scenario is a release of chlorine from a storage cylinder creating a cloud that moves downwind to a building. We would like to know what impact this has on the interior conditions of the building while also taking the opportunity to evaluate the impact of changes in ambient weather conditions.

+
+

The Scenario

+

The scenario is the catastrophic failure of a liquid chlorine cylinder, perhaps one being used as part of a water treatment facility. The release is outdoors and a small building is downwind of the release, which can be occupied and so the infiltration of chlorine is important.

+
+

Release Parameters

+

For simplicity suppose the entire contents of the cylinder are released essentially immediately and form a neutrally buoyant cloud. The mass of the release is the mass of chlorine in the cylinder which we can assume to be 68kg. CAMEO Chemicals lists the three emergency response planning guideline (ERPG) levels for chlorine as 1ppm, 3ppm, and 20ppm respectively.

+

Suppose the release is 1m off the ground and is otherwise the center of the coordinate system.

+
+
+

Building Parameters

+

The building is a small one story structure 100m downwind of the release point. For the scenario it will be taken as a given that the building volume is 255 m³ and the equivalent leak area is 690 cm². For simplicity any obstructions around the building are ignored.

+
+
+

Weather Parameters

+

For neutrally buoyant gaussian dispersion a class F Pasquill stability is the worst case scenario, this leads to the least dispersion and thus greatest concentrations downwind. The windspeed is initially assumed to be 1.5 m/s.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
m68 kg
erpg-11 ppm
erpg-23 ppm
erpg-320 ppm
h1 m
d100 m
\(A_l\)6.90×10⁻² m²
V255 m³
stabilityF
u1.5 m/s
+
+
using Contour, Plots, OrdinaryDiffEq, Statistics, SpecialFunctions, ForwardDiff, Roots
+
+
+
m     = 68.0   # mass of chlorine released, kg
+MW    = 70.9   # molar mass chlorine, kg/kmol
+MVC   = 24.465 # molar volume at 25C and 1atm, m3/kmol
+
+ppm_to_kg(ppm) = (ppm*1e-6)*(MW/MVC)
+erpg1 = ppm_to_kg(1) # erpgs for chlorine, kg/m3
+erpg2 = ppm_to_kg(3)
+erpg3 = ppm_to_kg(20)
+
+h   = 1.0      # height of release point, m
+
+d   = 100.0    # downwind distance to building, m
+Aₗ  = 6.90e-2  # equivalent leak area of building, m2
+V   = 255.0    # volume of building, m3
+
+u   = 1.5      # windspeed m/s
+
+
+
+
+

Air Dispersion

+
+

Gaussian Puff Model

+

The simplest model for a neutrally buoyant cloud is a gaussian disperison model, in this case because the cylinder is assumed to fail catastrophically the release can be treated as instantaneous and so we use a gaussian puff model.

+

It is often worth-while to estimate the initial dimensions of the cloud and then calculate a virtual emission point from which the release is assumed to take place. This is especially useful if the area immediately around the release point is of interest as the gaussian model assumes all of the mass is initially concentrated in a single point. However for a simple screening just using the default dispersion model is likely fine, and more conservative. The model gives the concentration as a gaussian distribution in the x, y, and z directions, while also adding in a term to account for ground reflection (mass cannot disperse below groundlevel)1

+

\[ c_{puff}(x,y,z,t) = { m \over { (2 \pi)^{3/2} \sigma_x \sigma_y \sigma_z } } +\exp \left( -\frac{1}{2} \left( {x - ut} \over \sigma_x \right)^2 \right) +\exp \left( -\frac{1}{2} \left( {y} \over \sigma_y \right)^2 \right) \]

+

\[ \times \left[ \exp \left( -\frac{1}{2} \left( {z - h} \over \sigma_z \right)^2 \right) + \exp \left( -\frac{1}{2} \left( {z + h} \over \sigma_z \right)^2 \right)\right]\]

+

The parameters σx, σy, and σz are generally found using empirically derived correlations that are functions of the downwind distance to the center of the puff xc and atmospheric stability.

+
+
function c_puff(x,y,z,t; # point in space
+                m, u, h, # parameters of the problem
+                σx::Function, σy::Function, σz::Function)
+    xc = u*t # center of the cloud
+    sx = σx(xc)
+    sy = σy(xc)
+    sz = σz(xc)
+    
+    C1 = m / ( (2*π)^(1.5) * sx * sy * sz )
+    C2 = exp(-0.5*((x-xc)/sx)^2)
+    C3 = exp(-0.5*(y/sy)^2)
+    C4 = ( exp(-0.5*((z-h)/sz)^2) + exp(-0.5*((z+h)/sz)^2) )
+    
+    c = C1*C2*C3*C4
+    
+    return isnan(c) ? 0.0 : c
+end
+
+
+
+

Dispersion Parameters

+

The dispersion parameters for puff models are not, in general, as well developed as for plume models, the following values were interpolated from a sparser set of correlations and it is worth keeping in mind. It is also worth noting that the dispersion parameters are where the impact of different windspeeds will be made most apparent as stability is a function of windspeed.

+
+

Dispersion parameters for a Gaussian puff model2

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Stability\(\sigma_x = \sigma_y\)\(\sigma_z\)Max Windspeed
A$ 0.18 x^{0.92} $$ 0.60 x^{0.75} $3 m/s
B$ 0.14 x^{0.92} $$ 0.53 x^{0.73} $5 m/s
C$ 0.10 x^{0.92} $$ 0.34 x^{0.71} $6 m/s
D$ 0.06 x^{0.92} $$ 0.15 x^{0.70} $
E$ 0.04 x^{0.92} $$ 0.10 x^{0.65} $5 m/s
F$ 0.02 x^{0.89} $$ 0.05 x^{0.61} $3 m/s
+
+
# Class F 
+
+σx(x) = 0.02*x^0.89
+σy(x) = 0.02*x^0.89
+σz(x) = 0.05*x^0.61
+
+
+
+
+

Outdoor Concentration

+

The outdoor concentration can be calculated at any point using the above equations and below is an animation showing the cloud moving from the release point down to the location of the center of the building. The horizontal slice is at 1m elevation, and the vertical slice is at 0m crosswind distance, the center planes of the cloud.

+

As is clear, for a very stable cloud the dispersion is small relative to the distance traveled (the cross wind distance is exaggerated for visibility) and the bulk of the mass of chlorine is contained in a 10m diameter ball by the time it reaches the building.

+
+
+
+
[ Info: Saved animation to /home/allan/Code/notes/notebooks/tmp.gif
+
+
+
+
+
+
+ +
+
+Figure 1: Dispersion of an instantaneous release of chlorine. +
+
+
+
+

The concentration as a function of time also shows how brief the exposure is at this point. A rapid pulse of chlorine passes over the area and is gone within a few seconds. However the concentration at that time is millions of times larger than the ERPG-1 limit (note the units, the scale is in kg/m³ whereas the ERPG limits are on the order of mg/m³)

+
+
+
+
+
+ +
+
+Figure 2: Outdoor concentration at the receptor as a function of time +
+
+
+
+
+

The concentration along the centerline of the cloud is also worth looking at, as this provides some context for the extent of the downwind impacts of the release.

+
+
+
+
+
+ +
+
+Figure 3: Centerline concentration of the puff as a function of travel distance. +
+
+
+
+
+
Downwind distance to ERPG-3 (outdoors) 13.5km
+Downwind distance to ERPG-2 (outdoors) 29.9km
+Downwind distance to ERPG-1 (outdoors) 47.3km
+
+
+

Clearly this release presents a serious hazard, one would have to travel downwind over 10km to be below the ERPG-3 line and nearly 50km to be below the ERPG-1 line. Though, keep in mind, this is for instantaneous exposure and not overall dose.

+
+
+
+

Building Infiltration

+
+

Single Zone Model

+

The single zone model assumes the air within the building is generally well mixed and well described by a single concentration. This is approximately true over long timescales, however in the situation of the brief pulse of chlorine passing over the building this assumption may breakdown and is a critical weakness of the discussion that follows.

+

Very likely in the ~10s it takes for the cloud to pass very little of it will have had time to diffuse into the interior space of the building and the interior mixing (or lack thereof) will be a significant slow step in the overall mass transfer.

+

The single zone model, however, will work as a first pass at least, and in this model the interior concentration is related to the outside concentration by the following ODE3

+

\[\frac{d}{dt} c_i(t) = f \left( c_i, \lambda, t \right) = \lambda \cdot \left( c_o(t) - c_i(t) \right) \]

+

Where cᵢ is the inside concentration, cₒ the outside concentration, and λ the natural ventilation rate of the building.

+

The natural ventilation rate itself is a function of windspeed, the temperature difference between inside and outside, and how leaky the building is.

+

The model is defined in a more generic way for now as this will be more useful later.

+
+
f(cᵢ, λ, t; cₒ=zero) = λ*(cₒ(t) - cᵢ)
+
+f(g) = (cᵢ, λ, t) -> f(cᵢ, λ, t; cₒ=g)
+
+
+
+

Simplified ASHRAE Model

+

The last parameter we need to estimate before solving the problem is the ventilation rate, λ, which can be estimated using the simplified ASHRAE model4

+

\[\lambda = \frac{Q}{V} \\ +Q = A_L \sqrt{ C_s \vert \Delta T \vert + C_w u^2 } \\ +\lambda = \frac{A_L}{V} \sqrt{ C_s \vert \Delta T \vert + C_w u^2 } \]

+

Where \(A_L\) and \(V\) were given earlier, \(C_s\) and \(C_w\) are tabulated constants, \(\Delta T\) is the difference between indoor and outdoor temperatures, in K, and u the windspeed, in m/s, and the ventilation rate is in s⁻¹.

+

In this case the indoor and outdoor temperature are assumed to be the same for simplicity5

+

5 Note that the constants have been adjusted such that the leak area is in m², in the ASHRAE handbook the leak area is in cm²

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Shelter Class1 Story2 Story3 Story
\(C_s\)all14.5×10⁻³29.0×10⁻³43.5×10⁻³
\(C_w\)131.9×10⁻³42.0×10⁻³49.4×10⁻³
224.6×10⁻³32.5×10⁻³38.2×10⁻³
317.4×10⁻³23.1×10⁻³27.1×10⁻³
410.4×10⁻³13.7×10⁻³16.1×10⁻³
53.20×10⁻³4.20×10⁻³4.90×10⁻³
+

With the shelter class defined as 1. No obstructions or local shielding 2. Isolated rural home 3. Another building across the street 4. Urban buildings on larger lots, with the nearest building >1 building height away 5. Immediately adjacent buildings (closer than 1 building height)

+

For this scenario we are assuming the (one-story) building is isolated and there are no obstructions or local shielding, so the class is 1.

+
+
# 1 Story building, shelter class 1
+
+λ(ΔT, u; Aₗ, V, Cs, Cw) = (Aₗ/V)*√(Cs*abs(ΔT)+Cw*u^2)
+
+λ₀ = λ(0.0, u, Aₗ=Aₗ, V=V, Cs=14.5e-3, Cw=31.9e-3)
+
+
7.249290622734888e-5
+
+
+
+
+
+

Indoor Concentration

+

The indoor concentration is calculated simply by solving the ODE with the initial condition that the indoor concentration is zero. Since the pulse of chlorine is so brief it is important to be careful with the integrator, typical variable step integrators can step right over brief pulses and miss them entirely. To counteract that the first phase of the response, up until twice the time it takes for the wind to travel the distance, is solved using a maximum timestep of 1s, the remainder is left free to the solver to adjust as necessary to meet the tolerances.

+
+
# puff model, note the units have changed to mg/m³
+
+cₒ(t) = c_puff(d, 0, h, t; m=m, u=u, h=h, σx=σx, σy=σy, σz=σz)*1e6
+
+c0 = 0.0       # initial condition
+sys = f(cₒ)
+
+tsp1 = (0.0, 2*d/u)
+prb1 = ODEProblem(sys, c0, tsp1, λ₀)
+sln1 = solve(prb1, Tsit5(), dtmax=1);
+
+
+
c0_2 = sln1[end]
+tsp2 = (tsp1[end], 2.5*(1/λ₀))
+prb2 = ODEProblem(sys, c0_2, tsp2, λ₀)
+sln2 = solve(prb2, Tsit5());
+
+

This is a very conservative model. It assumes all of the mass transfer occurs at a point, which happens to be where the maximum outdoor concentration will be, and that interior mixing is essentially instantaneous. It also has a significant weakness in that it assumes the building does not impede the movement of the cloud. If the building is very small relative to the cloud this might be reasonable, but in this case the cloud is quite concentrated and the deflection around the building is important.

+

The first assumption can be moderated by taking an average outdoor concentration over the building footprint. In this case I assume the building is a 10m×10m square just for demonstration. This tries to capture the reality that not all of the building is being exposed to an equally high concentration and that an effective outdoor concentration might be better estimated as a spatial average. These points are hanging in space at the centerline of the cloud and as such are exposed to the highest concentrations. Instead of a slice like this, a more fully featured box could be used, but at that point it would probably be more useful to look into the ways the building itself is deflecting and shaping the cloud. Recall that the cloud is essentially passing through the building in this model.

+
+
box_x = (d-5):1:(d+5)
+box_y = -5:1:5
+
+function box_average(t)
+    cs = [ c_puff(x, y, h, t; m=m, u=u, h=h, σx=σx, σy=σy, σz=σz)*1e6
+           for x in box_x, y in box_y ]
+    return mean(cs)
+end
+
+
+
sys_avg = f(box_average)
+
+tsp_box = (0.0, 2*d/u)
+prb_box = ODEProblem(sys_avg, c0, tsp_box, λ₀)
+sln_box = solve(prb_box, Tsit5(), dtmax=1);
+
+
+
c0_box2 = sln_box[end]
+tsp_box2 = (tsp_box[end], 2.5*(1/λ₀))
+prb_box2 = ODEProblem(sys_avg, c0_box2, tsp_box2, λ₀)
+sln_box2 = solve(prb_box2, Tsit5());
+
+
+
+
+
+
+ +
+
+Figure 4: Indoor concentrations assuming a linear ventillation model, showing both the building as a point source and as averaged box. +
+
+
+
+
+

In either the single point or averaged outdoor concentration models the indoor concentration rapidly rises above the ERPG-3 limit, which is very bad, and then slowly decays over time6. In this case almost immediately after the cloud has passed the indoor space is more concentrated in Chlorine than the outside air. At the very least this suggests that the building is not a good shelter in place location, or at least a much more detailed analysis of building infiltration would be needed to show that it was a good shelter in place location.

+

6 Note that the indoor concentrations are in mg/m³ whereas the outdoor concentration peaks in the kg/m³, so the building is doing something, it is reducing the indoor concentration by several orders of magnitude, it just isn’t enough

+
+

Sensitivity

+

For the scenario modeling I approached the problem in a very general way such that the methods for solving the indoor concentration didn’t depend explicitly upon the model of the outdoor concentration. Which is why it was solved numerically. This is a very useful way of approaching things, especially from a code re-use point of view.

+

However, in this particular case, if we take the outdoor concentration to be simply the gaussian puff model at a single point then this ODE can be solved analytically and that is useful for exploring the system’s sensitivity to windspeed, atmospheric stability, etc.

+

Starting with the original puff model for the outside concentration

+

\[ c_{o}(x,y,z,t) = { m \over { (2 \pi)^{3/2} \sigma_x \sigma_y \sigma_z } } +\exp \left( -\frac{1}{2} \left( {x - ut} \over \sigma_x \right)^2 \right) +\exp \left( -\frac{1}{2} \left( {y} \over \sigma_y \right)^2 \right) \]

+

\[ \times \left[ \exp \left( -\frac{1}{2} \left( {z - h} \over \sigma_z \right)^2 \right) + \exp \left( -\frac{1}{2} \left( {z + h} \over \sigma_z \right)^2 \right)\right] \]

+

We can split this into the product of three gaussians:

+

\[ c_{o}(x,y,z,t) = m \left[{ \exp \left( -\frac{1}{2} \left( {x - ut} \over \sigma_x \right)^2 \right) \over { \sqrt{2 \pi} \sigma_x } } \right] +\left[{ \exp \left( -\frac{1}{2} \left( {y} \over \sigma_y \right)^2 \right) \over { \sqrt{2 \pi} \sigma_y } } \right] \]

+

\[ \times { 1 \over { \sqrt{2 \pi} \sigma_z } } \left[ \exp \left( -\frac{1}{2} \left( {z - h} \over \sigma_z \right)^2 \right) + \exp \left( -\frac{1}{2} \left( {z + h} \over \sigma_z \right)^2 \right)\right]\]

+

\[ c_{o}(x,y,z,t) = m C_x(x, t) C_y(x, y) C_z(x, z)\]

+

and noting that only \(C_x\) depends on time we can collect the other stuff into a big constant called \(C_1\), giving usI am assuming the dispersion parameters are all constant, this is not strictly true as they all depend upon the downwind location of the center of the puff, which is a function of time

+

\[ c_{o}(t) = C_1 { 1 \over { \sqrt{2 \pi} \sigma_x } }\exp \left( -\frac{1}{2} \left( {x - ut} \over \sigma_x \right)^2 \right) \]

+

this is a gaussian in time, let \(\mu = \frac{x}{u}\) and \(\sigma_t = \frac{\sigma_x}{u}\)

+

\[ c_{o}(t) = { C_1 \over u } { 1 \over { \sqrt{2 \pi} \sigma_t } }\exp \left( -\frac{1}{2} \left( {t - \mu} \over \sigma_t \right)^2 \right) \]

+

suppose the Laplace transform of this is \(C_{o}(s)\), and taking the Laplace transform of the ODE we arrive at

+

\[ C_{i}(s) = { \lambda \over {s + \lambda} } C_{o}(s) \]

+

where \(C_{i}(s)\) is the Laplace transform of \(c_{i}(t)\), inverting the Laplace transform leads us to conclude

+

\[ c_{i}(t) = { C_1 \over u } \int_{0}^{\infty} \lambda \exp \left( -\lambda \left(t - \tau \right) \right) +{ 1 \over { \sqrt{2 \pi} \sigma_t } }\exp \left( -\frac{1}{2} \left( {\tau - \mu} \over \sigma_t \right)^2 \right) d\tau\]

+

that is, the solution is the convolution of the exponential and gaussian times some constants. Conveniently for us this is a well known integral and we can just look up the answer in a book

+

\[ c_{i}(t) = { C_1 \over u } \frac{\lambda}{2} \exp \left( \frac{\lambda}{2} \left( 2\mu + \lambda \sigma_t^2 - 2t \right) \right) \mathrm{erfc} \left( { \mu + \lambda \sigma_t^2 -t } \over { \sqrt{2} \sigma_t } \right) \]

+

where \(\mathrm{erfc(x)}\) is the complementary error function \(1 - \mathrm{erf}(x)\)

+

You could expand all this back out, but it is far more compact and readable in this form, especially when written out as code

+
+
function cᵢ(x,y,z,t;    # point in space
+            m, u, h, λ, # parameters of the problem
+            σx::Function, σy::Function, σz::Function)
+    
+    sx = σx(x)
+    sy = σy(x)
+    sz = σz(x)
+    
+    # time independent part
+    C1 = m / (2*π*sy*sz)
+    C1 *= exp(-0.5*(y/sy)^2)
+    C1 *= ( exp(-0.5*((z-h)/sz)^2) + exp(-0.5*((z+h)/sz)^2) )
+    
+    # the convolution
+    μ = x/u
+    σₜ = sx/u
+    
+    c =  (C1*λ)/(2*u)
+    c *= exp(0.5*λ*(2*μ+λ*σₜ^2-2*t))
+    c *= erfc((μ+λ*σₜ^2-t)/((2)*σₜ))
+    
+    return isnan(c) ? 0.0 : c
+end
+
+

From here we can use automatic differentiation to find the max concentration, for the original case

+
+
# at the point x=d, y=0, z=h, note the units in mg/m³
+
+c(t) = cᵢ(d, 0, h, t, m=m, u=u, h=h, λ=λ₀, σx=σx, σy=σy, σz=σz)*1e6
+
+∂c∂t(t) = ForwardDiff.derivative(t -> c(t), float(t))
+        
+tₘₐₓ = find_zero(∂c∂t, d/u)
+
+cₘₐₓ = c(tₘₐₓ)
+
+tₘₐₓ, cₘₐₓ
+
+
(70.04333860265841, 551.5422273514416)
+
+
+

An alternative is to make the approximation \(\mathrm{erfc}(-x) \approx 2 H(x)\), where \(H(x)\) is the Heaviside step function ( \(\mathrm{erfc}(-x)\) runs from 0 to 2 and \(H(x)\) runs from 0 to 1 hence the factor of 2)

+

\[ c_{i}(t) = { C_1 \over u } \frac{\lambda}{2} \exp \left( \frac{\lambda}{2} \left( 2\mu + \lambda \sigma_t^2 - 2t \right) \right) \mathrm{erfc} \left( { \mu + \lambda \sigma_t^2 -t } \over { \sqrt{2} \sigma_t } \right) \]

+

\[ = { C_1 \over u } \frac{\lambda}{2} \exp \left( { \left( \lambda \sigma_t \right)^2 \over 2 } \right) \exp \left( -\lambda \left( t - \mu \right) \right) \mathrm{erfc} \left( { \mu + \lambda \sigma_t^2 -t } \over { \sqrt{2} \sigma_t } \right) \]

+

\[ \approx { \lambda C_1 \over u } \exp \left( { \left( \lambda \sigma_t \right)^2 \over 2 } \right) \exp \left( -\lambda \left( t - \mu \right) \right) H( t - \mu - \lambda \sigma_t^2 ) \]

+

\[ \approx { \lambda C_1 \over u } \exp \left( { \left( \lambda \sigma_t \right)^2 \over 2 } \right) \exp \left( -\lambda \left( t - \mu \right) \right) H( t - \mu ) +\]

+

the maximum of this is clearly

+

\[ c_{max} = { \lambda C_1 \over u } \exp \left( { \left( \lambda \sigma_t \right)^2 \over 2 } \right) \]

+

and occurs when \(t = \mu\)

+
+
let x=d, y=0, z=h, λ=λ₀
+    sx = σx(x)
+    sy = σy(x)
+    sz = σz(x)
+    μ  = x/u
+    σₜ = sx/u
+    
+    C1 = m / (2*π*sy*sz)
+    C1 *= exp(-0.5*(y/sy)^2)
+    C1 *= ( exp(-0.5*((z-h)/sz)^2) + exp(-0.5*((z+h)/sz)^2) )
+    
+    c =*C1)/u
+    c *= exp(0.5**σₜ)^2)
+    
+    return (μ, c*1e6)
+end
+
+
(66.66666666666667, 551.6845234598728)
+
+
+

We can compare the solution from the ODE solver with the two approximations – the convolution and the step-function approximation – to convince ourselves that we are capturing the dynamics well.

+
+
+
+
+
+ +
+
+Figure 5: Approximations to the full integration of the linear ventillation model. +
+
+
+
+
+
+

Atmospheric stability

+

The sensitivity to atmospheric stability, at the max windspeed given in the table of dispersion parameters above, is shown below. Clearly the indoor concentration depends strongly on the atmospheric stability, and that makes sense as more unstable conditions lead to far greater mixing and thus lower outdoor concentrations. This effect is far greater than any increase in ventilation rate due to the greater windspeeds.

+
+
+
+
+
+ +
+
+Figure 6: Sensitivity of max indoor concentration to atmospheric stability +
+
+
+
+
+
+
+

Windspeed

+

Returning to the original scenario parameters we explore the impact of changing the windspeed, while assuming the atmospheric stability remains constant, in the figure below. In this case windspeed impacts both the parameters of the gaussian puff model and the natural ventilation rate in the single zone building infiltration model.

+
+
+
+
+
+ +
+
+Figure 7: Sensitivity of indoor concentration to outdoor windspeed. +
+
+
+
+
+

This was, to me, a surprising result. I expected the max indoor concentration to depend strongly on the windspeed whereas it appears to have far more to do with how quickly the indoor build up of Chlorine dissipates. This is due to the difference in timescales between the puff passing over the building and the single zone building infiltration model.

+

The time-scale of interest for the puff model is the width of the gaussian \(\sigma_t\), whereas the time-scale of interest for the building infiltration model is the time constant of the exponential decay \(\tau = \frac{1}{\lambda}\), for this situation with a class F atmospheric stability they are

+
+
σₜ = σx(d)/u
+
+
0.8034127814324771
+
+
+
+
τ = 1/λ₀
+
+
13794.453168477568
+
+
+
+
σₜ/τ
+
+
5.82417274262381e-5
+
+
+

The time it takes the cloud to pass is simply orders of magnitude faster than the response of the infiltration model. So, in effect, the puff is acting like a impulse – causing a step change – and changing the windspeed merely moves the time at which that step change takes place. The faster the windspeed the better the approximation $ (-x) H(x) $ gets and if we take another look at the approximation for max concentration, we see it is independent of windspeed.

+

Note that for the simplified ASHRAE model with \(\Delta T = 0\) the natural ventilation rate is directly proportional to windspeed, i.e. $ = k u $ where k is all the vaious constants of that model collected for convenience, plugging this into the approximation we find the windspeed cancels out entirely.

+

\[ { \lambda C_1 \over u } \exp \left( { \left( \lambda \sigma_t \right)^2 \over 2 } \right) \]

+

\[ = { { k u C_1 } \over u } \exp \left( { \left( k u \frac{\sigma_x}{u} \right)^2 \over 2 } \right) \]

+

\[ = k C_1 \exp \left( { \left( k \sigma_x \right)^2 \over 2 } \right) \]

+

Which is independent of windspeed.

+

We can perhaps see how small the effect is more clearly by directly varying the ratio \({ \sigma_t \over \tau}\) while keeping \(\mu\) constant and looking at the response. The maximum indoor concentration does change, but only slightly.

+
+
+
+
+
+ +
+
+Figure 8: Sensitivity of the indoor concentration to the ratio σt/τ +
+
+
+
+
+

Which is not to say the model is insensitive to the natural ventilation rate, merely that for a given building the impact of changing windspeed on the ventilation rate is canceled out by the change in the outdoor concentration profile.

+
+
+

Equivalent Leak Area

+

Below the equivalent leak area \(A_L\) is varied while keeping all other parameters constant. Unsurprisingly building tightness matters, and the impact is approximately linear. This is intuitive, of course, because the outside concentration does not depend in any way on the leakage area and so there is no canceling of effects like was seen with windspeed, and of course having more leaks leads to more of the outside air getting inside.

+
+
+
+
+
+ +
+
+Figure 9: Sensitivity of indoor concentration to the leakage area. +
+
+
+
+
+
+
+

Temperature

+

A similar effect as was seen with equivalent leak area is present with changes in the temperature difference between indoors and outdoors. Though in this case the change goes with the square-root of the temperature difference.

+
+
+
+
+
+ +
+
+Figure 10: Sensitivity of indoor concentration to the indoor-outdoor temperature difference +
+
+
+
+
+

On a summer day like today large temperature differences like this might seem extreme, but in the depths of winter when days around here routinely get to -30°C a standard heated building with a normal indoor temperature around 20°C would have a 50°C temperature difference with the outdoors.

+
+
+

Distance

+

The most obvious case of interest, however, is how far downwind would the building have to be such that it did not exceed the ERPG limits. With all other parameters equal to the scenario, the max indoor concentration as a function of building distance is shown in the figure below.

+
+
+
+
+
+ +
+
+Figure 11: Max indoor concentration as a function of downwind distance to the receptor. +
+
+
+
+
+
Downwind distance to ERPG-3 (indoors) 625.0m
+Downwind distance to ERPG-2 (indoors) 2383.0m
+Downwind distance to ERPG-1 (indoors) 5006.0m
+
+
+

As was seen above when examining the outdoor concentrations, this is a significant release and the downwind distance is large. This also shows the value of sheltering in place as an unprotected individual would have to travel downwind for many tens of kilometers to reach a safe distance, whereas indoors that is greatly reduced.

+

Again, this is all considering the instantaneous concentration and not considering dose.

+
+
+
+

Final Remarks

+

The results of the scenario speak to something the previous discussion noted, namely that after the cloud has passed the indoor concentration exceeds the outdoor concentration and so an important response, post release, is not just when to shelter in place but when to stop.

+

The worst case indoor concentration far exceeded the ERPG-3 limit, however it was still significantly lower exposure than had one been standing outside. But once the chlorine had leaked into the building it would take hours to fully clear purely by natural ventilation. At that point it would be far more prudent to leave the building and to turn the ventilation system back on. Identifying when this is the case is an interesting problem, because there are no guarantees that the emergency outside the building is fully resolved merely because the toxic release has passed. So one must balance the risks of sheltering in a place that is no longer safe versus evacuating into a potentially dangerous environment. In such a scenario it may be prudent to have indoor and outdoor air monitoring in the shelter in place location and a store of emergency escape respirators, though a better solution would be to move the shelter in place to a safer location.

+

These two examples cover two extremes of building infiltration, the forest fire smoke looked at enormous clouds that take hours to pass and this chlorine example covers very concentrated clouds which pass in under a minute. Most real scenarios at a chemical plant or other facility are likely to be between these extremes, but the same tools would apply.

+
+
+

References

+
+
+2017 ASHRAE Handbook - Fundamentals (SI Edition). Atlanta, GA: American Society of Heating, Refrigerating; Air-Conditioning Engineers, 2017. +
+
+AIChE/CCPS. Guidelines for Use of Vapour Cloud Dispersion Models. 2nd ed. New York: American Institute of Chemical Engineers, 1996. +
+
+Lees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/building_infiltration_2/index_files/figure-html/fig-approx-output-1.svg b/posts/building_infiltration_2/index_files/figure-html/fig-approx-output-1.svg new file mode 100644 index 0000000..2bcd024 --- /dev/null +++ b/posts/building_infiltration_2/index_files/figure-html/fig-approx-output-1.svg @@ -0,0 +1,303 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_2/index_files/figure-html/fig-bldg-model-output-1.svg b/posts/building_infiltration_2/index_files/figure-html/fig-bldg-model-output-1.svg new file mode 100644 index 0000000..2989933 --- /dev/null +++ b/posts/building_infiltration_2/index_files/figure-html/fig-bldg-model-output-1.svg @@ -0,0 +1,1028 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_2/index_files/figure-html/fig-centerline-output-1.svg b/posts/building_infiltration_2/index_files/figure-html/fig-centerline-output-1.svg new file mode 100644 index 0000000..bd0dd89 --- /dev/null +++ b/posts/building_infiltration_2/index_files/figure-html/fig-centerline-output-1.svg @@ -0,0 +1,1254 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_2/index_files/figure-html/fig-conc-v-t-output-1.svg b/posts/building_infiltration_2/index_files/figure-html/fig-conc-v-t-output-1.svg new file mode 100644 index 0000000..d1d370c --- /dev/null +++ b/posts/building_infiltration_2/index_files/figure-html/fig-conc-v-t-output-1.svg @@ -0,0 +1,240 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_2/index_files/figure-html/fig-sens-dist-output-1.svg b/posts/building_infiltration_2/index_files/figure-html/fig-sens-dist-output-1.svg new file mode 100644 index 0000000..d313952 --- /dev/null +++ b/posts/building_infiltration_2/index_files/figure-html/fig-sens-dist-output-1.svg @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_2/index_files/figure-html/fig-sens-leak-area-output-1.svg b/posts/building_infiltration_2/index_files/figure-html/fig-sens-leak-area-output-1.svg new file mode 100644 index 0000000..2eba4e4 --- /dev/null +++ b/posts/building_infiltration_2/index_files/figure-html/fig-sens-leak-area-output-1.svg @@ -0,0 +1,471 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_2/index_files/figure-html/fig-sens-sigma-tau-output-1.svg b/posts/building_infiltration_2/index_files/figure-html/fig-sens-sigma-tau-output-1.svg new file mode 100644 index 0000000..6ad0625 --- /dev/null +++ b/posts/building_infiltration_2/index_files/figure-html/fig-sens-sigma-tau-output-1.svg @@ -0,0 +1,483 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_2/index_files/figure-html/fig-sens-stab-output-1.svg b/posts/building_infiltration_2/index_files/figure-html/fig-sens-stab-output-1.svg new file mode 100644 index 0000000..d004f33 --- /dev/null +++ b/posts/building_infiltration_2/index_files/figure-html/fig-sens-stab-output-1.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_2/index_files/figure-html/fig-sens-temperature-output-1.svg b/posts/building_infiltration_2/index_files/figure-html/fig-sens-temperature-output-1.svg new file mode 100644 index 0000000..9121a4d --- /dev/null +++ b/posts/building_infiltration_2/index_files/figure-html/fig-sens-temperature-output-1.svg @@ -0,0 +1,543 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_2/index_files/figure-html/fig-sens-windspeed-output-1.svg b/posts/building_infiltration_2/index_files/figure-html/fig-sens-windspeed-output-1.svg new file mode 100644 index 0000000..80db713 --- /dev/null +++ b/posts/building_infiltration_2/index_files/figure-html/fig-sens-windspeed-output-1.svg @@ -0,0 +1,21380 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_2/output_53_0.svg b/posts/building_infiltration_2/output_53_0.svg new file mode 100644 index 0000000..88f931b --- /dev/null +++ b/posts/building_infiltration_2/output_53_0.svg @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_example/att2.png b/posts/building_infiltration_example/att2.png new file mode 100644 index 0000000..9329919 Binary files /dev/null and b/posts/building_infiltration_example/att2.png differ diff --git a/posts/building_infiltration_example/index.html b/posts/building_infiltration_example/index.html new file mode 100644 index 0000000..c000ef5 --- /dev/null +++ b/posts/building_infiltration_example/index.html @@ -0,0 +1,1440 @@ + + + + + + + + + + + + +Building Infiltration Example – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Building Infiltration Example

+
+
+ Single zone building infiltration of forest fire smoke. +
+
+
+
julia
+
air quality
+
building infiltration
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

May 22, 2021

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

A common part of emergency planning is the shelter in place. More often than not trying to outrun whatever calamity is happening at a chemical plant is more dangerous than finding a safe place to ride it out, which is usually the lunch room or somewhere like that. Sometimes facilities have different shelter in place locations depending on what the hazard is – for example a severe weather shelter location may not be where you go for a toxic gas release.

+

Naturally, when evaluating the consequences of a release, one wants to evaluate the effectiveness of a shelter in place location. This usually involves looking at some building infiltration model.

+

Another situation in which building infiltration has come up in recent years is forest fires, and I think this presents a unique opportunity to validate the models and the selection of shelter in place locations. I’m not talking about sheltering in place because the forest fire is at the plant boundary, I’m talking about smoke days where forest fire smoke blows in and blankets the whole area in higher than usual airborne particulates.

+
+

The Scenario

+

At least where I live, in Alberta, this is not an uncommon event. Every other year, it seems, there is a large forest fire either in the northern boreal forest or in the Rocky Mountains and the smoke from these enormous fires will blanket the entire province.

+

This is the view from my apartment of one of the last big smoke days, on May 30th of 2019

+
+
+
+ +
+
+Figure 1: The view from my apartment on May 30th, 2019, when the city experienced a significant air quality event due to wildfire smoke. +
+
+
+

This haze is unsurprisingly bad for outdoor air quality, but what does it do for indoor air quality? Typically people shut-down air handling systems to minimize the smoke infiltration and wait it out (which is why I think it is a good proxy for a release scenario), so a model of how quickly smoke, or airborne particulates, can work its way into the building is useful to have.

+
+
+

Ambient Air Data

+

The outdoor concentration of airborne particulates, PM2.5, is needed. There are several air monitoring stations throughout Alberta, with the closest one to me being the Edmonton Central station. Hourly air quality data can be downloaded as a csv from Alberta’s Air Data Warehouse and imported into julia as a dataframe.

+
+
using CSV, DataFrames, Dates, Pipe
+
+

I am using the Pipe.jl package to streamline the data manipulation process. It takes the output from what is on the left of |> and puts it in where the _ is on the right, which is very convenient when chaining together several single-input-single-output functions.

+
+
data_file = "data/Edmonton Central pm2.5.csv"
+
+# import the csv file, remove missing data
+# insert a column for the time since the start of the dataset in hours
+
+ambient_data = @pipe data_file |>
+    CSV.File( _ ; 
+             dateformat="mm/dd/yyyy HH:MM:SS", 
+             types=[DateTime, DateTime, Float64], 
+             header=16) |>
+    DataFrame(_) |>
+    rename(_, "MeasurementValue" => "conc") |>
+    rename(_, "IntervalStart" => "date") |>
+    select(_, Not([2])) |>
+    hcat(_, Dates.value.( _.date - _.date[1])/3.6e6) |>
+    rename(_, "x1" => "time") |>
+    dropmissing(_) 
+
+first(ambient_data, 6)
+
+

6 rows × 3 columns

dateconctime
DateTimeFloat64Float64
12019-05-24T00:00:0016.00.0
22019-05-24T01:00:007.01.0
32019-05-24T02:00:009.02.0
42019-05-24T03:00:0013.03.0
52019-05-24T04:00:009.04.0
62019-05-24T05:00:0010.05.0
+
+
+
+
+
+
+
+ +
+
+Figure 2: Ambient pm2.5 concentrations from the Central Edmonton air quality monitoring station, May 24th - June 5th 2019. +
+
+
+
+
+

In this case I restricted the dataset to just the PM2.5 concentration, since that is all I am interested in, and to a window of time around May 30th. The data clearly shows that May 30th had a big spike in airborne particulates, well in excess of the ambient air quality objectives. Though it was somewhat hazy in the days before and after too.

+
+
+

Infiltration Models

+

Building infiltration models can range from highly detailed CFD simulations of indoor airflow to simple “fully mixed” models that assume a single average indoor concentration. This second type is the easiest to use and a good start for screening scenarios. It is a simple differential equation that assumes the rate of infiltration is proportional to a ventilation rate, λ, and the concentration difference between the outside and inside air1

+

\[ \frac{d}{dt} c = f \left( c, \lambda, t \right) = \lambda \cdot \left( c_o(t) - c \right)\]

+

The outside concentration, \(c_o\), can be a constant, but it is more usefully thought of as a function of time. In practice the ventilation rate, λ, is usually taken to be a constant, but it is a function of ambient conditions and could be implicitly made a function of time as well.

+

This model is for for a single zone or single cell building, where the interior air is assumed to be well mixed and at a single uniform pressure and concentration. This works well for houses, non-segmented industrial buildings, and small open plan commercial buildings. For much larger buildings, with many zones, there are multiple zone models of various scales of complexity.

+
+
using Interpolations, OrdinaryDiffEq
+
+

The function below represents the right-hand-side of the differential equation in standard form, with the outside concentration as a generic function of time that is passed as a parameter. For convenience later on, I also created a function that takes the outside concentration and returns a callable with that pre-set.

+

The order of arguments is important here, OrdinaryDiffEq expects the arguments to be in the order unknowns, parameters, time, where both the unknowns and parameters can be vectors (if there’s more than one)

+
+
f(c, λ, t; cₒ=zero) = λ*(cₒ(t) - c)
+
+f(g) = (c, λ, t) -> f(c, λ, t; cₒ=g)
+
+
+

Natural Ventilation

+

Any structure, unless it is hermetically sealed, has some natural ventilation rate. This comes from leaks around doorframes, through ventilation systems (even when turned off), and other breaks in the building envelope. This natural ventilation rate, λ, is reported in air changes per hour (ACH) and is, in general, a function of ambient conditions inside and outside the structure.

+
+
+
+ +
+
+Figure 3: Natural ventillation mechanisms in a single zone building. +
+
+
+

The following plot is for a building infiltration model showing a building with windows open versus closed, showing the functional relationship between temperature differences and windspeed and the ventillation rate. Note the first plot is against the square root of the temperature difference.

+
+
+
+ +
+
+Figure 4: Natural ventillation rates as a function of temperature difference and windspeed. +
+
+
+

ASHRAE2 gives guidance on how to estimate the natural ventilation rate for single zone buildings, and a basic model of air leakage is

+

\[ Q = A_L \sqrt{ C_s \vert \Delta T \vert + C_w u^2 }\]

+

Where Q is the air leakage rate, in m³/s, \(A_L\) is the effective air leakage area, in m², \(\Delta T\) is the temperature difference between indoors and outdoors, u the windspeed, and \(C_s\) and \(C_w\) constants tabulated based on building height and extent of shelter from the wind (due to other buildings in the vicinity).

+

The ventilation rate is then simply the leakage rate divided by the building volume

+

\[ \lambda = {Q \over V } \]

+

This model effectively treats the leakage from the building like a leakage through a hole using Toricelli’s law, and the one big unknown that needs to be determined is the effective air leakage area. This can be determined by working through the different parts of the building and counting the elements such as windows and vents, or it can be determined experimentally for a given building, either by using a tracer (SF₆ is common) or by using the a blower system to pressurize the building and measuring how much flow is required to raise the internal pressure by a fixed amount (such as by 50Pa).

+

This is a lot of work for a simple building infiltration screening. However this may have already been done for the design of the building, as the air leakage is a critical component in a building’s overall thermal efficiency. If the data already exists for a given building, for other purposes, then why not use it, but if it isn’t readily available then it is more practical to use tabulated values for representative buildings.

+

This can be tricky, though, as most tabulated values are for houses and how “leaky” a house is depends very strongly on where that house was constructed (and when). Many references, such as Lees’ give values for British homes which are often much greater than equivalent values tabulated elsewhere for typical American and Canadian homes. Which could entirely be a function of local weather. The air tightness of a home where I live in Canada is probably a lot more important, especially on days when it is -30°C, than a similar home in the UK where such extremes are essentially unheard-of. Which is also putting aside the fact that commercial structures are quite different from houses and so these values may not be too representative either.

+

Typical values for Canadian homes in urban areas

+ + + + + + + + + + + + + + + + + + + + + +
LevelACH
Tight0.25
Average0.50
Leaky1.0
+

Typical values for houses in the US

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ConditionsTightTypicalLeaky
Mild0.070.10.4
Moderate0.20.31.0
Severe0.30.51.6
+

In this model of building infiltration the ASHRAE model could be used and, with a suitable dataset of outdoor temperature and windspeeds, the ventilation rate would be a function of time. But for simplicity I am going to assume that the change in windspeed and temperature from any given hour to the next is quite small and the ventillation rate can be assumed to be a constant.

+

This raises the obvious question of what impact windspeed has on the indoor concentration? At higher windspeeds the building ventilation rate is higher, and so more of what’s outside ends up inside, however at higher windspeeds there is more mixing and the outdoor concentration will generally be lower. I would expect the effect of mixing would dominate, but this might be worth investigating further.

+
+
+

Building Infiltration

+

Armed with some ideas of the building ventilation rate, the differential equation can be solved for different outdoor concentration scenarios. When defining the problem, above, I set the outdoor concentration as generic function of time that was passed as a parameter. This ODE is simple enough that it can be solved by hand for basic cases and easily numerically integrated for any well behaved set of initial conditions and outdoor concentrations.

+

For an example let’s consider a sudden pulse of a pollutant, say the outside air is 100%(vol) during the pulse and 0 otherwise, a square wave.

+
+
function c_square(t; cmax=1, t1=5, t2=20)
+    if (t >=t1) & (t <= t2)
+        return cmax
+    else
+        return 0
+    end
+end
+
+

Assuming the initial indoor concentration to be zero, and with a suitable ventillation rate, this can be solved numerically.

+

The problem is defined using the ODEProblem function, which creates a problem object which the solver then solves. In this case using the Tsit5 solver, the default for non-stiff problems. The solution returned is an object that contains both a vector of solutions, and times, but can also be called like a function to return an interpolated result. This way the solution acts like a continuous function of time.

+
+
λ₀ = 0.25 # ventilation rate, 0.25 h⁻¹
+c0 = 0.0  # initial condition
+tspan = (0.0, 25.0)
+sys = f(c_square)
+
+prb = ODEProblem(sys, c0, tspan, λ₀)
+sln = solve(prb, Tsit5())
+
+
+
+
+
+
+ +
+
+Figure 5: Building infiltration for a step-change in outdoor concentration. +
+
+
+
+
+

Solving for the indoor concentration of pm2.5s using measured outdoor concentrations is essentially the same process, except instead of a simple square wave we have a timeseries of measured values.

+

The first step is to turn that timeseries into a continuous function, in this case I am using a simple linear interpolation.

+
+
cₒᵤₜ = LinearInterpolation(ambient_data.time, ambient_data.conc, extrapolation_bc = Interpolations.Flat())
+
+

The remaining steps are the same, since the model for building infiltration is the same (with the parameter and inital conditions as defined earlier). The only difference is the timespan is the full span of the timeseries and the outdoor concentration is the linear interpolation defined above.

+
+
tspan = (0.0, ambient_data.time[end])
+sys = f(cₒᵤₜ)
+
+prb = ODEProblem(sys, c0, tspan, λ₀)
+sln = solve(prb, Tsit5())
+
+
+
+
+
+
+ +
+
+Figure 6: Building infiltration using measured outdoor concentrations. +
+
+
+
+
+

We can see, much like in the square wave model, that the indoor concentration lags behind the outoor concentration but still rises significantly. Once the pulse in high pm2.5 ends the indoor concentration decays, but again with a delay. So there is a period after the smoke has blown over in which the pm2.5 concentration indoors can be higher than outdoors (a good time to open some windows and air the place out)

+

For some context I have added Alberta’s Ambient Air Quality Objective for pm2.5s, clearly bad smoke days exceed that target but also indoor air quality can exceed it as well. Interestingly the indoor air quality may have exceeded workplace limits for airborne particulates through the whole period.

+
+
# max outdoor concentration
+
+maximum(ambient_data.conc)
+
+
867.0
+
+
+
+
# max indoor concentration
+
+maximum(sln.u)
+
+
501.877462902824
+
+
+
+
+

Ventilation and Infiltration Time

+

If we assume a constant outdoor concentration, a constant ventilation rate, and an initial indoor concentration of zero, the model can be solved analytically to give

+

\[ { c_i \over c_o } = 1 - e^{-\lambda t}\]

+

Which leads us to ask, how long does it take for the indoor concentration to reach some fraction x of the outdoor concentration?

+

\[ x = 1 - e^{-\lambda t} \]

+

\[ t = { -\ln{\left( 1 - x \right)} \over \lambda } \]

+

For simplicity’s sake let’s assume \(x = 0.5\).

+
+
t_x(λ; x=0.5) = -log(1-x)/λ
+
+
+
+
+
+
+ +
+
+Figure 7: The time to reach 1/2 the outdoor concentration as a function of ventillation rates. +
+
+
+
+
+

This gives us a sense of how building tightness - the natural ventilation rate - impacts how long a shelter in place would be effective for. If the emergency is lasting for several hours then a shelter in place location would have to be highly air tight to be effective.

+
+
+
+

Model Evaluation

+

A realistic shelter in place location is not going to be well mixed with the rest of the building. It will be an enclosed space that can be isolated from the building (e.g. by closing doors), and with an air handling system that can be isolated (unlike, say, a cafeteria where often the vents for clearing the air in the kitchen cannot be easily sealed). In this case the effective ventillation rate for that enclosed space, during a shelter in place, should be smaller than the ventillation rate for the building overall – when the doors are open and a single zone model is perhaps more representative.

+

In this case we can use the outdoor smoke event as a test. Somewhat like a tracer test but in reverse and we are not controlling the tracer. If we knew in advance that a smoke day was coming, which given publicly available modeling such as FireSmoke is reasonable, we could close off the shelter in place location with some indoor monitoring set up in the middle of the room and watch what happens.

+

If things go like they have in the past, at least where I work, the air handling system is shutdown and people try and minimize their time outdoors (and thus time spent opening and closing outside doors). By tracking the indoor concentration as well as outdoor concentrations we can compare the model to reality – does a single zone model work? do we need to incorporate weather conditions? – and estimate an effective ventilation rate by fitting the ODE to the measured data.

+

At least that’s the theory. I don’t have measured indoor air data for the time in question so I am going to simulate some by assuming the model works and adding some random noise. In this case I am adding ±10% random noise to the results calculated earlier for λ=0.25.

+
+
using DiffEqParamEstim, Optim
+
+

Here I make a copy of the ambient data dataframe, df, and create a new column called cin for indoor concentration. This is the solution found earlier times a random error ±10%, then for good measure any columns with the concentration below zero are chopped off at zero since that is unphysical.

+
+
# Dummy data
+
+df = deepcopy(ambient_data)
+
+df.cin = sln.(df.time) .* ( 1 .+ 0.10*randn(size(df.time)) )
+df.cin[ df.cin .< 0 ] .= 0
+
+
+
+
+
+
+ +
+
+Figure 8: Actual measured outdoor data and generated indoor data. +
+
+
+
+
+

The model can be fit to these two sets of data, essentially taking the outdoor concentration as a given and finding the best fit curve to the indoor concentration by solving the ODE repeatedly for different values of the parameter λ.

+

The major difference between this and simply solving the ODE is defining the loss function, in this case an L2 loss which is analogous to least squares, and then optimizing.

+
+
tspan = (0, df.time[end])
+c0 = df.cin[1] # initial condition
+
+sys = f(cₒᵤₜ)
+p = [0.5] #initial guess of λ=0.5
+
+prb = ODEProblem(sys, c0[1], tspan, p)
+lossfn = L2Loss(df.time, df.cin)
+
+cost_function = build_loss_objective(prb,Tsit5(),lossfn,
+                                     maxiters=10000,verbose=false)
+
+

The optimization is looking for the parameter that minimizes the cost function, which in this case is the least-squares difference between the model and the “measured” data for indoor concentration. The minimum is well defined and close to λ=0.25, which is what we would expect given that was how the data was generated.

+

Note: the plot below is nice to look at but not something one would normally generate, since calculating each point involves solving the ODE and could be fairly resource intensive for any problem more complex than this simple model. It sort of defeats the point of using an optimization algorithm to find the minimum. It’s just a nice visualization of what is happening in the background.

+
+
+
+
+
+ +
+
+Figure 9: The cost landscape showing the optimal parameter λ +
+
+
+
+
+
+
# optimize the cost function for parameters between 0 and 1
+
+result = optimize(cost_function, 0.0, 1.0)
+
+
Results of Optimization Algorithm
+ * Algorithm: Brent's Method
+ * Search Interval: [0.000000, 1.000000]
+ * Minimizer: 2.363617e-01
+ * Minimum: 9.520426e+03
+ * Iterations: 27
+ * Convergence: max(|x - x_upper|, |x - x_lower|) <= 2*(1.5e-08*|x|+2.2e-16): true
+ * Objective Function Calls: 28
+
+
+
+
λfit = result.minimizer[1]
+
+
0.2363617224674848
+
+
+
+
λfit/λ₀
+
+
0.9454468898699392
+
+
+

The best fit ventillation rate is quite close to the actual ventillation rate used to generate the data, which is what we would expect.

+

With an effective ventilation rate we can generate a best fit line

+
+
prb = ODEProblem(sys, c0[1], tspan, λfit)
+fit = solve(prb, Tsit5())
+
+
+
+
+
+
+ +
+
+Figure 10: The linear ventillation model fitted to the indoor concentration. +
+
+
+
+
+
+
+

A Control Systems Approach

+

For people with a background in control systems and process dynamics, an obvious alternative way of writing the problem is in term of Laplace transforms and transfer functions.

+

\[ \frac{d}{dt} c = \lambda \cdot \left( c_o(t) - c \right)\]

+

with the change of variables \(y = c(t) - c(0)\) and \(u = c_o\)

+

\[ y^\prime = \lambda u - \lambda y \]

+

Taking the Laplace transform of both sides \[ s Y = \lambda U - \lambda Y \]

+

\[ Y = { \lambda \over { s + \lambda} } U \]

+

This can then be solved analytically for various inputs, \(U\), or numerically for a given timeseries. In Julia this can be done with the ControlSystems.jl and ControlSystemIdentification.jl packages. This lets you define a system in terms of transfer functions and solve them that way.

+

I showed the more generic ODE approach at the start because this is more easily generalized to more complex models (e.g. by incorporating the functional dependence of λ on temperature and windspeed). Though the transfer function approach lends itself more simply to using different inputs to the same system, simply change \(u\) and you get a different result, whereas the generic ODE has the input as part of the system definition, which I think is sort of messy (though maybe there’s a better way of doing this that I don’t know about?).

+
+

Building Infiltration Model

+

The building infilration model is set up by simply defining the transfer function for the system and then simulating the response to the given input (the outdoor concentration in this case)

+
+
using ControlSystems, ControlSystemIdentification
+
+
+
u(x, t) = [cₒᵤₜ(t)]
+t = 0:1:ambient_data.time[end]
+
+sys = tf([λ₀], [1, λ₀])
+
+
TransferFunction{Continuous, ControlSystems.SisoRational{Float64}}
+   0.25
+-----------
+1.0s + 0.25
+
+Continuous-time transfer function model
+
+
+

Unlike the previous package, this returns the output, y, and time, t, as vectors with no interpolation or other information.

+
+
y, t, x = lsim(sys, u, t)
+
+
+
+
+
+
+ +
+
+Figure 11: The linear ventillation model, using ControlSystems.jl +
+
+
+
+
+
+
maximum(y)
+
+
508.2191947911374
+
+
+

The two approaches produce almost identical answers, which is not too surprising as the same ODE package and solver (Tsit5) is being used under the hood of ControlSystems.jl to solve this (I believe the only difference is one is using a variable time-step and the other a fixed time step, but I could be wrong).

+
+
+

Model Evaluation

+

For the purposes of fitting the model to the data, we note that the model is an ARX model, i.e. it is of the form

+

\[ A(s) Y = B(s) U + D \]

+

where \(A(s)\) and \(B(s)\) are polynomials in s, and use the ControlSystemIdentification.jl package to fit the model, generating a fitted transfer function.

+

In this simple approach the parameters of \(A(s)\) and \(B(s)\) are allowed to be different, and in general you could fit a variety of models and use this as a jumping off point to explore potentially better models of infiltration.

+

If you are more interested in an empirical model, fitted to timeseries data, this approach can be much simpler than the model driven approach taken above with fitting the ODE to the data. Especially when incorporating other elements like windspeed and temperature.

+
+
# generates the discrete timeseries data with a sample time of 1
+
+d = iddata(df.cin, df.conc, 1)
+
+
InputOutput data of length 311 with 1 outputs and 1 inputs
+
+
+
+
# finds the best fit transfer function with numerator order 1 and denominator order 1
+
+model_tf = arx(d, 1, 1)
+
+
TransferFunction{Discrete{Int64}, ControlSystems.SisoRational{Float64}}
+  0.22305776166128505
+------------------------
+1.0z - 0.738488961457766
+
+Sample Time: 1 (seconds)
+Discrete-time transfer function model
+
+
+
+
# generate the best fit line
+
+y_fit, t_fit, _ = lsim(model_tf, u, t)
+
+
+
+
+
+
+ +
+
+Figure 12: The linear ventillation model fitted to indoor concentrations using ControlSystemIdentification.jl +
+
+
+
+
+
+
+
+

References

+
+
+2017 ASHRAE Handbook - Fundamentals (SI Edition). Atlanta, GA: American Society of Heating, Refrigerating; Air-Conditioning Engineers, 2017. +
+
+Lees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/building_infiltration_example/index_files/figure-html/cell-1-1-b6cb177b-eefa-4464-a6e7-b6fff496c3b6.png b/posts/building_infiltration_example/index_files/figure-html/cell-1-1-b6cb177b-eefa-4464-a6e7-b6fff496c3b6.png new file mode 100644 index 0000000..10b48ce Binary files /dev/null and b/posts/building_infiltration_example/index_files/figure-html/cell-1-1-b6cb177b-eefa-4464-a6e7-b6fff496c3b6.png differ diff --git a/posts/building_infiltration_example/index_files/figure-html/cell-12-1-d10da518-c274-47ad-8668-f8938e45fc50.png b/posts/building_infiltration_example/index_files/figure-html/cell-12-1-d10da518-c274-47ad-8668-f8938e45fc50.png new file mode 100644 index 0000000..1f4f248 Binary files /dev/null and b/posts/building_infiltration_example/index_files/figure-html/cell-12-1-d10da518-c274-47ad-8668-f8938e45fc50.png differ diff --git a/posts/building_infiltration_example/index_files/figure-html/cell-12-2-ef432637-46d8-4f39-8ba0-79614ece7b76.png b/posts/building_infiltration_example/index_files/figure-html/cell-12-2-ef432637-46d8-4f39-8ba0-79614ece7b76.png new file mode 100644 index 0000000..74d4d3f Binary files /dev/null and b/posts/building_infiltration_example/index_files/figure-html/cell-12-2-ef432637-46d8-4f39-8ba0-79614ece7b76.png differ diff --git a/posts/building_infiltration_example/index_files/figure-html/fig-amb_pm-output-1.svg b/posts/building_infiltration_example/index_files/figure-html/fig-amb_pm-output-1.svg new file mode 100644 index 0000000..68e6cea --- /dev/null +++ b/posts/building_infiltration_example/index_files/figure-html/fig-amb_pm-output-1.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_example/index_files/figure-html/fig-arx-output-1.svg b/posts/building_infiltration_example/index_files/figure-html/fig-arx-output-1.svg new file mode 100644 index 0000000..d6dce73 --- /dev/null +++ b/posts/building_infiltration_example/index_files/figure-html/fig-arx-output-1.svg @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_example/index_files/figure-html/fig-control-sys-output-1.svg b/posts/building_infiltration_example/index_files/figure-html/fig-control-sys-output-1.svg new file mode 100644 index 0000000..4119d42 --- /dev/null +++ b/posts/building_infiltration_example/index_files/figure-html/fig-control-sys-output-1.svg @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_example/index_files/figure-html/fig-dummy-data-output-1.svg b/posts/building_infiltration_example/index_files/figure-html/fig-dummy-data-output-1.svg new file mode 100644 index 0000000..4adf7f3 --- /dev/null +++ b/posts/building_infiltration_example/index_files/figure-html/fig-dummy-data-output-1.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_example/index_files/figure-html/fig-fit-output-1.svg b/posts/building_infiltration_example/index_files/figure-html/fig-fit-output-1.svg new file mode 100644 index 0000000..f9cf201 --- /dev/null +++ b/posts/building_infiltration_example/index_files/figure-html/fig-fit-output-1.svg @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_example/index_files/figure-html/fig-optim-output-1.svg b/posts/building_infiltration_example/index_files/figure-html/fig-optim-output-1.svg new file mode 100644 index 0000000..4a4c48a --- /dev/null +++ b/posts/building_infiltration_example/index_files/figure-html/fig-optim-output-1.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_example/index_files/figure-html/fig-real-data-output-1.svg b/posts/building_infiltration_example/index_files/figure-html/fig-real-data-output-1.svg new file mode 100644 index 0000000..a55e3a3 --- /dev/null +++ b/posts/building_infiltration_example/index_files/figure-html/fig-real-data-output-1.svg @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_example/index_files/figure-html/fig-square-wave-output-1.svg b/posts/building_infiltration_example/index_files/figure-html/fig-square-wave-output-1.svg new file mode 100644 index 0000000..321ee26 --- /dev/null +++ b/posts/building_infiltration_example/index_files/figure-html/fig-square-wave-output-1.svg @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_example/index_files/figure-html/fig-time-output-1.svg b/posts/building_infiltration_example/index_files/figure-html/fig-time-output-1.svg new file mode 100644 index 0000000..d8a9126 --- /dev/null +++ b/posts/building_infiltration_example/index_files/figure-html/fig-time-output-1.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/building_infiltration_example/matt-palmer-unsplash.jpg b/posts/building_infiltration_example/matt-palmer-unsplash.jpg new file mode 100644 index 0000000..f7e43c3 Binary files /dev/null and b/posts/building_infiltration_example/matt-palmer-unsplash.jpg differ diff --git a/posts/butane_leak_example/index.html b/posts/butane_leak_example/index.html new file mode 100644 index 0000000..55d753f --- /dev/null +++ b/posts/butane_leak_example/index.html @@ -0,0 +1,1579 @@ + + + + + + + + + + + + +Chemical Release Screening Example - Butane leak – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Chemical Release Screening Example - Butane leak

+
+
+ Estimating the airborne quantity. +
+
+
+
julia
+
chemical releases
+
hazard screening
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

November 20, 2020

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

A routine practice of process safety is to model scenarios for different chemical hazards present at a plant. Often there are more plausible scenarios than there is the time or resources to model at the highest level of fidelity, the more complex models take time to set up and run and often there are only so many software licenses available. There needs to be some prioritization and screening. It’s fairly typical, especially for larger companies, to have screening tools that an engineer can use which incorporate simpler models and make conservative estimates to get a first guess at the impact of a given hazard, if this crosses a preset threshold then it is escalated to a more in depth level of modelling, drilling down to more and more detailed analysis as required.

+

More often than not I’ve seen these simple tools implemented as excel spreadsheets – which is fine, they do the job and everybody has excel on their computers – however overly involved spreadsheets can be rather opaque, it’s often not obvious what they are doing and what assumptions are being made in those calculations. So I am going to work through an example of how one could estimate the airborne quantity, and ultimately the consequences of, an example release of butane from a large storage sphere, while documenting the assumptions and models along the way.

+
+

The Scenario

+

As a simple scenario suppose a leak from a butane storage sphere. These are a fairly common sight around refineries and facilities that process large quantities of hydrocarbons. This sphere is 40ft in diameter and operates under 250psig of pressure, containing primarily n-butane, which I will assume is entirely n-butane for simplicity1. As for the leak itself I am supposing a leak area equivalent to a 2in rupture2. The sphere doesn’t sit directly on the ground, it is supported 10ft above a concrete pad which has a diked area of 500ft². The leak itself at the bottom somewhere, suppose exactly at the bottom for simplicity3. Furthermore I am assuming the release occurs on a day with an ambient temperature of 25°C and that the tank contents and surroundings are at thermal equilibrium.

+

1 If the vessel contained a mixture, for the purposes of screening, conservatively choosing the most volatile of the major components would be a reasonable assumption. These simplifications are suitable for screening purposes however if more in depth modeling is required then performing mixture flash calculations would have to be considered, which very quickly becomes a lot of work to set-up outside of a process simulator like Aspen

2 There are lots of ways of generating leak scenarios, from the very specific leaks from particular propagating events to simple rules of thumb. The Chemical Exposure Index gives the following rules for determining a leak scenario for a vessel:

+

A rupture based on the largest diameter process pipe attached to the vessel using the following:

+
    +
  • For anything less than 2in a full bore rupture (i.e. the full diameter of the pipe)
  • +
  • For between 2 and 4in assume a rupture area equal to that of a 2in diameter pipe
  • +
  • For >4in assume a rupture area equal to 20% of the pipe cross section area
  • +
+

3 Picking the bottom also ensures the leak occurs at the highest pressure, which gives a larger release and is most conservative. Releases at higher elevations also tend to mix more thoroughly with the air and present less of a hazard to personnel on the ground, and possibly less of an explosion hazard depending on where one supposes the ignition sources are.

Key Assumptions

+
    +
  • Storage sphere with 40ft diameter
  • +
  • Sphere located on a concrete pad with 500ft² diked area
  • +
  • Sphere contains ~100% n-butane
  • +
  • Leak area equivalent to a 2in rupture
  • +
  • Leak located at the bottom of the vessel for maximum release pressure
  • +
  • Vessel pressure is 250psig
  • +
  • Release temperature is 25°C
  • +
+
+
+
+ +
+
+Figure 1: A sketch of the release scenario, adapted from … somewhere +
+
+
+
+
using Unitful: ustrip, @u_str
+
+ft = ustrip(u"m", 1u"ft")     # unit conversion ft->m
+inch = ustrip(u"m", 1u"inch") # unit conversion inch->m
+psi = ustrip(u"Pa", 1u"psi")  # unit conversion psi->Pa
+
+Dᵥ = 40ft    # Diameter of the vessel, in m
+Ad = 500ft^2 # Dyked area, in m^2
+dₕ = 2inch   # Diameter of the hole, in m
+hₗ = 50ft    # height of liquid in the vessel
+hᵣ = 10ft    # height of release point
+
+pₐ= 14.7psi     # atmospheric pressure in Pa absolute
+p = 250psi + pₐ # pressure of the butane in Pa absolute
+Tᵣ= 25 + 273.15; # the release temperature in K
+
+

Some relevant thermodynamic properties of butane

+
+
# From Perry's, 8th edition
+
+R = 8.31446261815324 # universal gas constant, J/mol/K
+
+# Air
+MWₐᵢᵣ = 28.960
+ρa(T) = (pₐ*MWₐᵢᵣ)/(R*T)/1000
+μₐ(T) = (1.425e-6*T^0.5039)/(1 + 108.3/T)
+
+
+# Butane
+Mw = 58.122        # molar mass of butane, kg/kmol
+Tcr = 425.12       # critical temperature, K
+Tb = -0.6 + 273.15 # the normal boiling point of butane, K
+
+# vapour pressure in Pa, T in K
+(T) = exp(66.343 - (4363.2/T) - 7.046*log(T) + 9.4509e-6*T^2)
+
+# density in kg/m^3, T in K
+ρₗ(T) = Mw*( 1.0677/0.27188^(1+ (1-T/425.12)^0.28688) )
+
+# heat capacity in J/kmol/K, T in K
+cₚ(T) = 191030 - 1675*T + 12.5*T^2 - 0.03874*T^3 + 4.6121e-5*T^4
+
+# latent heat in J/kmol, T in K
+ΔHᵥ(T) = 3.6238e7*(1-(T/Tcr))^(0.8337 - 0.82274*(T/Tcr) + 0.39613*(T/Tcr)^2)
+
+# surface tension, N/m
+σ(T) = 0.05196*(1-(T/Tcr))^(1.2181);
+
+

The vapour pressure of butane at the release temperature is below the storage pressure, so the butane in the storage sphere will be a liquid.

+
+
(Tᵣ)<p
+
+
true
+
+
+
+
+

The Release Rate

+

Since the vapour pressure within the vessel is below the storage pressure, at ambient temperature, the butane within the storage sphere is a liquid. In general one would have to account for flashing and two-phase flow during the release, however for very short discharge distances (<10cm) there is typically not enough time for the liquid to flash during discharge,4 over the thickness of a hole this especially true. The butane discharged from the tank will be a stream of liquid initially and the simple Bernoulli equation for a liquid jet can be used.5

+

4 See AIChE/CCPS, Guidelines for Use of Vapour Cloud Dispersion Models, 2nd Ed., 1996, 37 for more of a disussion on two-phase discharge rates.

5 This is also known as Toricelli’s equation and can be derived from a mechanical energy balance and is found in a lot of references (e.g. Perry’s), the form of it I’m using here comes from AIChE/CCPS, 29 equation 4-10. This is really a function of time as the liquid height \(h_l\) will decrease as it leaks out. Using the discharge rate at the start of the leak throughout the analysis is a conservative assumption, again for the purposes of a simplified screening case. For more detailed modeling one could make this explicitly a function of time and integrate over the release.

\[ Q_l = c_d \rho_l A_h \sqrt{ 2 \left( p - p_a \over \rho_l \right) + 2gh_l } = c_d \rho_l { {\pi \over 4} d_h^2} \sqrt{ 2 \left( p - p_a \over \rho_l \right) + 2gh_l } \]

+

Where \(Q_l\) is the mass flow of liquid discharged through the hole (in kg/s), \(c_d\) is the discharge coefficient which can be assumed to be 0.61,6 \(g\) is the acceleration due to gravity \(9.81 m/s^2\) and the rest are as defined earlier. I am assuming, here, that the hole is circular for simplicity.

+

6 From AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases., 1999, 27, for sharp edged orifices and Reynolds numbers greater than 30,000 the discharge coefficient approaches 0.61, and the exit velocity is independent of the hole size. For a simple screening calculation one could also use a coefficient of 1.0, though that may be excessively conservative (large over-estimates end up wasting time modeling later).

Key Assumptions

+
    +
  • Liquid release
  • +
  • Sharp edged hole with discharge coefficient of 0.61
  • +
+
+
cd = 0.61
+g  = 9.81 # m/s^2
+Qₗ = cd*ρₗ(Tᵣ)*(π/4)*(dₕ^2)*√( 2*(p - pₐ)/ρₗ(Tᵣ) + 2*g*hₗ )
+
+
56.31092763613714
+
+
+
+
+

Flashing Fraction

+

Since the butane is significantly above it’s normal boiling point, as the liquid stream exits the storage sphere it will flash. However not all of it will flash into a vapour as the quantity that can vaporize is limited by the available energy. A simplified model of flashing is to assume the process is so rapid that it is effectively adiabatic and, from a simple steady-state energy balance, one arrives at the following7

+

7 This can be easily derived, but the form given here is from AIChE/CCPS, Guidelines for Use of Vapour Cloud Dispersion Models, 2nd Ed., 1996, 31, equation 4-14.

\[f_v = {Q_v \over Q_l} = { {c_p (T_r - T_b)} \over {\Delta H_v} }\]

+

where \(f_v\) is the mass fraction that flashes and \(Q_v\) is the mass flow of liquid that flashes (in kg/s) and recall that the heat capacity and latent heat are functions of temperature.

+

Key Assumptions

+
    +
  • flashing occurs rapidly and is effectively adiabatic
  • +
  • heat capacity and latent heat taken at the release temperature
  • +
+
+
fᵥ = cₚ(Tᵣ)*(Tᵣ-Tb)/ΔHᵥ(Tᵣ) 
+
+
0.17128269541302374
+
+
+
+
Qᵥ(t) = fᵥ*Qₗ
+
+
Qᵥ (generic function with 1 method)
+
+
+
+
+

Aerosol Fraction

+

As the butane flashes into a gas, some of the liquid stream will be entrained as an aerosol. The presence of aerosolized droplets are a major contributor to the overall mass of a vapour cloud and it is important to include them. There is a wide array of methods for estimating the aerosolized fraction, from as simple as assuming it is 1-2x the flashed fraction to more detailed models that take into account the different mechanisms behind aerosolization and rain-out.

+

The aerosol fraction, \(f_a\), the fraction of the liquid remaining in the cloud after flashing, in the form of aerosolized droplets.

+

\[ f_a = {Q_a \over { Q_l - Q_v } }\]

+

One method is to estimate the droplet size and from that determine the degree of rain out (i.e. the liquid that does not remain in the cloud) through a model of droplet settling. I am going to use the RELEASE model8 of droplet settling to determine the aerosol fraction.

+

Key Assumptions

+
    +
  • rain-out is the only significant mechanism by which liquid drops out to form a pool
  • +
  • all droplets larger than a critical size drop out, all droplets below that size remain in the cloud
  • +
  • evaporation is negligible
  • +
  • the RELEASE model is used to estimate the degree of rain-out
  • +
+
+

Mean droplet diameter

+

There are three important mechanisms of drop formation, capillary breakup which occurs when sub-cooled liquids are discharged through very small holes (<2mm), aerodynamic breakup which occurs with larger holes with sub-cooled liquids or slightly super-heated liquids, and flashing breakup which occurs as super-heated liquids are discharged and flash to vapour in the form of bubbles which breakup the surrounding liquid.

+

Aerodynamic breakup is correlated with the Weber number, which is the ratio of shear forces on the surface of the liquid to the surface tension.

+

\[ We = { { \rho_g u_d^2 d_p } \over \sigma } \]

+

Where \(\rho_g\) is the density of the gas, \(u_d\) the discharge velocity, \(d_p\) the mean droplet diameter, and \(\sigma\) the surface tension. Experimentally, droplet breakup occurs at a critical Weber number between 12 and 22, and so the mean droplet size can be estimated by rearranging9

+

\[ d_p = { { \sigma We_c } \over {\rho_g u_d^2 } } \]

+

and solving at the critical Weber number \(We_c = 12\), assuming \(\rho_g = \rho_a\) to be the density of ambient air, and with the discharge velocity \(u_d\) given as:

+

\[ u_d = { Q_l \over { c_d A_h \rho_l} } = { Q_l \over {c_d \frac{\pi}{4} d_h^2 \rho_l} }\]

+
+
# the cloud temperature, assumed to be the boiling point
+Tc = Tb
+
+# critical Weber number
+We = 12                                  
+
+# release velocity, m/s
+ud = Qₗ/( cd * (π/4)*dₕ^2 * ρₗ(Tᵣ)) 
+
+# droplet size, m, due to aerodynamic breakup
+da = ( σ(Tc) * We)/(ρa(Tc) * ud^2)  
+
+
2.188550597862162e-5
+
+
+

The diameter of droplets from flashing breakup can be calculated from the following empirical correlation10 and the mean droplet diameter is simply the smallest of either the aerodynamic or flashing diameter11 In almost all cases that are relevant for release modelling capillary breakup is not significant.

+

\[ d_p = { {0.03} \over {10 + 4.0 \cdot (T - T_b) } }\]

+
+
+
+ +
+
+Figure 2: A correlation for droplet size due to flashing breakup. +
+
+
+
+
# droplet size, m, due to flashing breakup
+df = (0.03)/(10 + 4*(Tᵣ-Tb))                   
+
+dₚ = min(da, df)
+
+
2.188550597862162e-5
+
+
+
+
+

The RELEASE model

+

The RELEASE model uses a distribution of droplet sizes to determine, based on a simple model of settling dynamics, the fraction of droplets that remain the cloud. The model assumes droplet diameter follows a log-normal distribution and that any droplet greater than a critical diameter, determined from a balance of drag and buoyancy, will rain out. The parameters of the model were fit to experimental data of rain out events.

+
+

Critical diameter

+

The critical diameter is a function of a critical velocity which is calculated from a model of the spray jet with a tuning parameter \(\beta\) which captures the expansion of the jet. The default value for \(\beta\) is given to be 4.46°12

+

\[ u_c = u_d \tan \beta \]

+
+
# the default value given by RELEASE is 4.46°
+β = deg2rad(4.46)
+uc = ud * tan(β) # critical velocity, m/s
+
+
6.197367132394693
+
+
+

The critical diameter is found by solving the balance of buoyant and drag forces on a droplet

+

\[ F_{buoyant} = F_{drag} \]

+

\[ \left( \rho_l - \rho_g \right) g V_{droplet} = \frac{1}{2} C_D \rho_g u_c^2 A_{droplet} \]

+

\[ \left( \rho_l - \rho_g \right) g \cdot \frac{\pi}{6} d_c^3 = \frac{1}{2} C_D \rho_g u_c^2 \cdot \frac{\pi}{4} d_c^2 \]

+

\[ \left( \rho_l - \rho_g \right) g \cdot d_c - \frac{3}{4} C_D \rho_g u_c^2 = 0\]

+

Where \(C_D\) is the drag coefficient, which for a solid sphere in viscous flow is given by this correlation13

+

13 White, Viscous Fluid Flow. This could be an opportunity for improvement to the RELEASE model as liquid droplets and bubbles do not experience drag in the same way as solids, due to internal flows that can dissipate energy.

\[ C_D = 0.4 + {24 \over Re} + {6 \over {1 - \sqrt{Re} } } \]

+

with the Reynolds number \(Re\) as

+

\[ Re = { {\rho_g u_c d_c} \over \mu_a} \]

+

for simplicity the gas density \(\rho_g\) can be calculated assuming an ideal gas, and \(\mu_a\) is the viscosity of air.

+

This relationship will have to be solved numerically to get the critical diameter, since the Reynolds number and thus drag coefficient is a function of the critical diameter. Which is fairly straight forward and in this case I use the bounds \(0.1 \cdot d_p \le d_c \le 10 \cdot d_p\) as a very broad starting point.

+
+
using Roots: find_zero
+
+ρg(T) = (pₐ * Mw)/(R * T)/1000  # ideal gas law, kg/m^3
+
+# the Reynold's number at the release temperature
+Re(d) = ρg(Tc) * uc * d / μₐ(Tc)
+
+# the drag coefficient
+CD(d) = 0.4 + (24/Re(d)) + 6/(1-√(Re(d))) 
+
+# critical diameter
+dc = find_zero( d ->   (ρₗ(Tc) - ρg(Tc))*g*d - 0.75*CD(d)*ρg(Tc) * uc^2, (0.1*dₚ, 10*dₚ))
+
+
0.00014250630981793824
+
+
+
+
+

Aerosol Fraction

+

The aerosol fraction, in the RELEASE model, is the mass fraction of droplets with a diameter less than the critical diameter:

+

\[ f_a = { {F_m \left( d_c \right)} \over {F_m \left( \infty \right)} } \]

+

Where \(F_m(d)\) is the cumulative mass distribution function for droplets. This is based on a log-normal distribution and is given as

+

\[ F_m \left( d \right) = \left( \frac{\pi}{6} \rho_l d_p^3 \right) \int_0^t { t^2 \over {\sqrt{2 \pi} \log{\sigma_G} } } {\exp \left( -\frac{1}{2} \left( \log{t} \over \log{\sigma_G} \right)^2 \right)} dt\]

+

Where \(t = d/d_p\) and \(\sigma_G\) is another tuning parameter (the default value given is 1.8).

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 3: The distribution of droplet sizes and rain-out region. +
+
+
+
+

With a change of variables \(z = \log{t}\) and \(s = \log{\sigma_g}\) the cumulative mass distribution function can be integrated:

+

\[ F_m \left( d \right) = \left( \frac{\pi}{6} \rho_l d_p^3 \right) \int_{-\infty}^z { 1 \over {\sqrt{2 \pi} s} }{\exp \left( 3z \right) \exp \left(-\frac{1}{2} \left( z \over s \right)^2 \right)} dz \]

+

\[ = \left( \frac{\pi}{6} \rho_l d_p^3 \right) \frac{-1}{2} \exp \left( 9s^2 \over 2 \right) \left[ \mathrm{erf} \left( {3s^2 - z} \over {\sqrt{2} s} \right) \right]_{-\infty}^z\]

+

\[= \left( \frac{\pi}{6} \rho_l d_p^3 \right) \exp \left( 9 \left( \log{\sigma_G} \right)^2 \over 2 \right) \times \frac{1}{2} \left[ 1 - \mathrm{erf} \left( {3 \left( \log{\sigma_G} \right)^2 - \log{d} + \log{d_p} } \over {\sqrt{2} \log{\sigma_G} } \right) \right] \]

+

where \(\mathrm{erf}\left( x \right)\) is the error function. Finally the aerosol fraction is:14

+

14 Johnson and Woodward, RELEASE - a Model with Data to Predict Aerosol Rainout in Accidental Releases, 58–60. The integration is not shown in the text, but the fortran code is included on the CD if one wants to verify the final result.

\[ f_a = { {F_m \left( d_c \right)} \over {F_m \left( \infty \right)} } = \frac{1}{2} \left[ 1 - \mathrm{erf} \left( {3 \left( \log{\sigma_G} \right)^2 - \log{d_c} + \log{d_p} } \over {\sqrt{2} \log{\sigma_G} } \right) \right]\]

+

The RELEASE code uses this formula and also does a check for extreme cases, defaulting to either 1 or 0.

+
+
using SpecialFunctions: erf
+
+function RELEASE_fa(dc, dp; σG=1.8)
+    if (dp/dc) >= exp(σG)
+        # checks for where erf(x) ~ 1
+        return 0.0
+    elseif (dc/dp) >= 15*exp(σG)
+        # checks for where erf(x) ~ -1
+        return 1.0
+    else
+        return 0.5*( 1 - erf((3*log(σG)^2 -log(dc) + log(dp)) / ((2) * log(σG)) ))
+    end
+end
+
+
RELEASE_fa (generic function with 1 method)
+
+
+
+
fₐ = RELEASE_fa(dc, dₚ)  # calculates the aerosol fraction using the RELEASE method
+
+
0.9227949810754577
+
+
+
+
Qₐ(t) = fₐ*(Qₗ - Qᵥ(t));
+
+
+
+
+
+

Pool Evaporation

+

The droplets that rain out of the cloud will form a pool and, depending on how long the release occurs, evaporation from the pool can be a significant contributor to the overall airborne quantity. In this case the liquid is assumed to be at the boiling point of butane, it cooled through evaporative cooling, and is cryogenic with respect to the ground. There are two major factors that impact the evaporation rate: the area of the pool and the heat transfer into the pool from the environment. Both of these, in general, can be quite complicated time-dependent phenomena with lots of different models capturing a wide array of scenarios.

+

Since this is a simple screening calculation, I will be avoiding all of that and use some simple models for pool spread and evaporative flux.

+

Key Assumptions

+
    +
  • Simple model of pool spread
  • +
  • Evaporation of pool is driven by heat transferred from the ground by conduction
  • +
+

A simple model of pool spread as a function of time is15

+

\[ A_{pu} = \frac{\pi}{4} \sqrt{\frac{2048}{81} {Q_{p} \over \rho_l} t^3} \]

+

Where \(A_{pu}\) is the unconstrained pool area in m², \(t\) is the time since the start of the release in seconds, and \(Q_{p}\) is the mass flow of liquid to the pool in kg/s (the total release rate less what was lost to flashing and entrained in the cloud as an aerosol)

+

\[ Q_{p} = Q_l - Q_v - Q_a \]

+

In practice the area of the pool will be limited to be at most the diked area. For large spills having a diked area is significant, both in the obvious containing of the spill, but also since it can significantly reduce the amount of pool evaporation.

+
+
# the liquid temperature, taken to be the boiling point
+Tₗ = Tb
+
+# mass flow to the pool, kg/s
+Qₚ(t) = Qₗ - Qᵥ(t) - Qₐ(t)
+
+# unconstrained pool area, m^2
+Aₚᵤ(t) = (π/4) * ((2048/81) * (Qₚ(t)/ρₗ(Tₗ)) * t^3 )
+
+# Pool area, restricted to at most dyked area, m^2
+Aₚ(t) = min( Aₚᵤ(t) , Ad);
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 4: Pool spreading as a function of time for constrained and unconstrained releases. +
+
+
+
+

In general the evaporation rate is derived from a heat balance accounting for the heat transfer from the ground, from the ambient air, and from solar flux, however in this case a simplifying assumption is that the majority of the heat transferred to the liquid is from the ground.

+

For a cryogenic liquid spilled on land a simple model of the evaporative flux \(G_e\) in kg/s/m² is16

+

\[ G_e = { {Mw} \over {\Delta H_v} } { k \left( T_s - T_l \right) \over \sqrt{\pi \alpha t} } \]

+

Where \(k\) is the thermal conductivity of the surface (ground) in W/m/K, \(T_s\) is the temperature of the surface in K, \(T_l\) the temperature of the liquid in K, and \(\alpha\) the thermal diffusivity of the surface in m²/s

+

The overall evaporation rate is the product of the pool area and the evaporative flux

+

\[ Q_e \left( t \right) = G_e \left( t \right) \cdot A_p \left( t \right) \]

+

It’s worth taking a moment to note that the evaporative flux will decrease with time. This is because the ground under the spill cools down over time. The overall evaporation rate will grow as the pool grows – in this model the pool grows \(\propto t^{3/2}\) while the flux decreases \(\propto t^{-1/2}\), so the evaporation rate should grow \(\propto t\) – but once it hits the limit of the diked area the overall evaporation rate will decrease over time.

+

One thing worth noting is that the pool area equation does not take into account a mass balance. As time goes on the unconstrained pool only grows, even if the evaporation rate were to exceed the rate of new liquid being added to the pool. This limitation probably doesn’t matter for short duration leaks in which it is expected that the pool evaporation rate is strictly lower than the rate at which new liquid is added to the pool, however for long duration spills or instantaneous spills this not appropriate and a more complex model of pool growth and evaporation should be considered.

+
+
# Thermal properties of concrete 
+# A. Bejan, Kraus, A. D., Heat Transfer Handbook, John Wiley & Sons, 2003
+k = 1.28     # W/m/K
+α = 6.6e-7   # m^2/s
+
+# surface temperature, taken to be the ambient temperature
+Tₛ = Tᵣ      
+
+# evaporative flux, kg/s/m^2
+Gₑ(t) = (Mw/ΔHᵥ(Tₗ)) * k * (Tₛ - Tₗ) / (π*α*t)
+
+# evaporation rate, kg/s
+Qₑ(t) = min( Gₑ(t)*Aₚ(t), Qₚ(t));
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 5: The pool evaporation rate as a function of time for a constrained release. +
+
+
+
+
+
+

Airborne Quantity

+

The total airborne quantity is the sum of the flashed vapour, the aerosolized droplets, and the vapour from pool evaporation. So far the calculation has been in terms of rates, but the total airborne quantity depends upon the release duration, \(t_d\). There are lots of different ways of deciding on a release duration, in general the release duration of interest is the time it would take for a vapour cloud to find an ignition source – for vapour cloud explosion scenarios – or, more optimistically, the time it would take for the plant to respond and take some action to mitigate the hazard. A common default release duration is 10 minutes17

+

One common simplification is to take the vapour and aerosol rates to be a constant, and the pool evaporation rate as a constant at the final time \(t_d\), then multiply by the total duration. An alternative is to integrate over time from 0 to \(t_d\).

+

\[ Q_{aq} \left( t \right) = Q_v \left( t \right) + Q_a \left( t \right) + Q_e \left( t \right)\]

+

\[ m_{aq} = \int_0^{t_d} Q_{aq} \left( t \right) dt = \int_0^{t_d} Q_v \left( t \right) + Q_a \left( t \right) + Q_e \left( t \right) dt \]

+

One could try to integrate this analytically, but for re-useability of code it’s a better idea to integrate numerically – then different models for each of the rates can be swapped in and out with ease.

+
+
using QuadGK: quadgk
+
+# release duration of 10 minutes, seconds
+td = 10*60 
+
+# total airborne release rate is the sum of the individual 
+# mechanism release rates, in kg/s
+Qaq(t) = Qᵥ(t) + Qₐ(t) + Qₑ(t) 
+
+# total airborne quantity is the integral over time
+maq, err = quadgk(Qaq, 0, td)  
+
+
(31737.218210630548, 0.00014433872246399915)
+
+
+

A quick sanity check is to make sure that the total airborne quantity is less than the total quantity released, i.e. \(Q_l \cdot t_d\).

+
+
maq <= Qₗ*td
+
+
true
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 6: The total airborne quantity as a function of time. +
+
+
+
+

It is often insightful to compare the airborne quantity to the case where there was no secondary containment, i.e. the pool could expand without bound.

+
+
Qₑᵤ(t) = min( Gₑ(t)*Aₚᵤ(t), Qₚ(t))
+Qaqu(t) = Qᵥ(t) + Qₐ(t) + Qₑᵤ(t)
+
+maqu, err = quadgk(Qaqu, 0, td)
+
+
(33426.49125139247, 0.0003172098610093599)
+
+
+
+
+
With secondary containment    31.737 t 
+Without secondary containment 33.426 t 
+
+
+

In this case the secondary containment reduced the overall airborne quantity by ~5%, and we wouldn’t expect it to be hugely important for this example as most of the mass of the vapour cloud came from the flashing of the liquid immediately upon release and from entrained droplets.

+
+
+

Closing Remarks

+

There are always trade offs to be made with model accuracy, model complexity, and the shear amount of data required to run the models (often overlooked unless you’re the flunky tasked with finding all of these constants). This notebook aimed at creating a simple screening model and made several simplifications along the way. One thing I tried to avoid, though, is the use of gross “rules of thumb” and any pre-calculating of constants. I see this fairly often in older works because, likely, the calculations were being done by hand and this greatly speeds that up. I don’t think the justification for it is still valid, though, for a few reasons. For one many very rough rules of thumb were developed to avoid iterative solutions, but with modern computers there’s really no reason to, the numerical solutions in this notebook took fractions of a second to calculate on my laptop. For another much of the work collecting constants and pre-calculating things simply makes it harder to validate formulas. With a notebook like this not only can I properly typeset the formula more-or-less as presented in the reference (while keeping a consistent nomenclature) but I can also fairly transparently type that into Julia, making it very clear what the code is doing, step by step, and where those formulae came from. This should make it very easy to verify that I haven’t made a typo, for example. In my experience with some older excel tools that used a lot of pre-calculated rearranged equations, it was often entirely not obvious how the reference (if there is one given at all) lead to the final equations in the spreadsheet and verifying that the spreadsheet worked as intended without some written down derivation could take hours.

+

One feature that I didn’t use, but could be a nice addition, is the unit-aware library Unitful, I included it at the beginning for some unit conversions. However it can be used to track the units for each number throughout, ensuring that results are in the appropriate units and that there are no unit mismatches. I did not use that part of things because there are quite a few correlations and figuring out the appropriate units for the various constants in those correlations such that all the units match properly can be a pain in the butt. In general, though, using Unitful is a very powerful tool when working with physical modelling.

+

That said, this notebook is far from perfect. Probably the biggest simplification that could be changed is the assumption that the liquid release rate is a constant and is at the highest rate. This could be made a function of time – as the vessel empties there is less hydro-static pressure – and the rates of flashing and aerosol entrainment made explicitly time dependent as well. Like all things this would take some time to implement – though not much – and would improve model performance for long duration releases. The assumption made is on the conservative side and for short duration, and large vessels, is appropriate for screening purposes. Though, like all things with code, you only have to put the effort in once…

+

For re-useability the lowest hanging fruit for changes would be to link this to a database of substance properties. Probably the most tedious part of using this notebook is finding and filling in all of the correlations at the beginning for the various temperature dependent material properties. There’s no reason why a small database couldn’t be set up, containing everything in a given plant’s inventory, and some code added so the notebook can look up the properties for you.

+

There are also lots of opportunities for embedding some of the decision logic into the notebook, I set up the notebook to do a liquid discharge because I knew what the scenario was. Furthermore I knew that the boiling point of butane is less than ambient and so the pool evaporation would be for a cryogenic liquid spill. There’s no reason why the logic behind those decisions, and others, couldn’t be generalized and the notebook setup to choose which model was appropriate in a clear and transparent way.

+
+
+

References

+
+
+AIChE/CCPS. Dow’s Chemical Exposure Index Guide. New York: American Institute of Chemical Engineers, 1998. +
+
+———. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999. +
+
+———. Guidelines for Use of Vapour Cloud Dispersion Models, 2nd Ed. New York: American Institute of Chemical Engineers, 1996. +
+
+Johnson, David W., and John L. Woodward. RELEASE - a Model with Data to Predict Aerosol Rainout in Accidental Releases. New York: American Institute of Chemical Engineers, 1999. +
+
+White, F. M. Viscous Fluid Flow. New York: McGraw-Hill, 1974. +
+
+Woodward, John L. Estimating the Flammable Mass of a Vapour Cloud. New York: American Institute of Chemical Engineers, 1998. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/butane_leak_example/index_files/figure-html/cell-16-1-e0048660-aa9b-44b3-b810-c646b282edb6.png b/posts/butane_leak_example/index_files/figure-html/cell-16-1-e0048660-aa9b-44b3-b810-c646b282edb6.png new file mode 100644 index 0000000..a048895 Binary files /dev/null and b/posts/butane_leak_example/index_files/figure-html/cell-16-1-e0048660-aa9b-44b3-b810-c646b282edb6.png differ diff --git a/posts/butane_leak_example/index_files/figure-html/cell-2-1-48383a9b-bfb2-4557-ab7d-ae594a1482cc.png b/posts/butane_leak_example/index_files/figure-html/cell-2-1-48383a9b-bfb2-4557-ab7d-ae594a1482cc.png new file mode 100644 index 0000000..e2f75fb Binary files /dev/null and b/posts/butane_leak_example/index_files/figure-html/cell-2-1-48383a9b-bfb2-4557-ab7d-ae594a1482cc.png differ diff --git a/posts/dispersion_parameter_sensitivity/index.html b/posts/dispersion_parameter_sensitivity/index.html new file mode 100644 index 0000000..3551d73 --- /dev/null +++ b/posts/dispersion_parameter_sensitivity/index.html @@ -0,0 +1,2111 @@ + + + + + + + + + + + + +Messing around with model parameters – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Messing around with model parameters

+
+
+ The importance of choosing the right references. +
+
+
+
julia
+
dispersion modelling
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

October 30, 2023

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

Recently I added some alternative correlations to GasDispersion.jl, the julia package I put together for basic chemical release modeling, and I thought it would be worthwhile to circle back and look at some of those in more depth.

+

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.

+
+

Windspeed

+

The windspeed correlations I am looking at here are the basic power law

+

\[ u = u_R \left( z \over z_R \right)^p \]

+

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

+

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).

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 1: Windspeed correlations for class A, B, C, and D stability. +
+
+
+
+

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.

+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 2: Windspeed correlations for class E and F stability. +
+
+
+
+
+
+

Plume Dispersion

+

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.

+
+

Crosswind Dispersion

+

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

+

\[ \sigma_y = a x^b \]

+

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

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 3: Crosswind dispersion correlations. +
+
+
+
+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 4: Crosswind dispersion correlations, class F stability. +
+
+
+
+
+
+

Vertical Dispersion

+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+Figure 5: Vertical dispersion correlations. +
+
+
+

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.

+
+
+
+

An Example

+

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, K
+
+

For 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 # K
+
+

We 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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 6: Downwind concentration of sulfur dioxide, at 2m elevation, as predicted by the default, CCPS, ISC3, TNO, and Turner correlations, neglecting plume rise. +
+
+
+
+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 7: Concentration isopleths for sulfur dioxide, at 2m elevation, as predicted by the default, CCPS, ISC3, TNO, and Turner correlations, neglecting plume rise. +
+
+
+
+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 8: Downwind concentration of sulfur dioxide, at 2m elevation, as predicted by the default, CCPS, ISC3, TNO, and Turner correlations, using Briggs’ plume rise correlations. +
+
+
+
+
+
+

Final Thoughts

+

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).

+
+
+

References

+
+
+AIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999. +
+
+Bakkum, E. A., and N. J. Duijm. “Vapour Cloud Dispersion.” In Methods for the Calculation of Physical Effects, CPR 14E, edited by C. J. H. van den Bosch and R. A. P. M. Weterings, 3rd ed. The Hague: TNO, 2005. +
+
+Briggs, Gary A. “Diffusion Estimation for Small Emissions. Preliminary Report.” Oak Ridge, TN: Air Resources Atmospheric Turbulence; Diffusion Laboratory, National Oceanic; Atmospheric Administration, 1973. https://doi.org/10.2172/5118833. +
+
+EPA-454/b-95-003b: User’s Guide for the ISC3 Dispersion Models. Vol. 2. Environmental Protection Agency, 1995. +
+
+Hanna, Steven R., Gary A. Briggs, and Rayford P. Hosker Jr. Handbook on Atmospheric Diffusion. Springfield, VA: National Technical Information Service, 1982. https://doi.org/10.2172/5591108. +
+
+Lees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996. +
+
+Spicer, Thomas O., and Jerry A. Havens. EPA-450/4-89-019: User’s Guide for the DEGADIS 2.1 Dense Gas Dispersion Model. Research Triangle Park, NC: Office of Air Quality Planning; Standards, United States Environmental Protection Agency, 1989. https://nepis.epa.gov/Exe/ZyNET.exe/2000J5GU.txt. +
+
+Turner, D. Bruce. Workbook of Atmospheric Dispersion Estimates. Research Triangle Park, NC: Office of Air Programs, United States Environmental Protection Agency, 1989. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/dispersion_parameter_sensitivity/output_35_0.svg b/posts/dispersion_parameter_sensitivity/output_35_0.svg new file mode 100644 index 0000000..b9fdb83 --- /dev/null +++ b/posts/dispersion_parameter_sensitivity/output_35_0.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/dynamic_mode_decomposition/index.html b/posts/dynamic_mode_decomposition/index.html new file mode 100644 index 0000000..7224b11 --- /dev/null +++ b/posts/dynamic_mode_decomposition/index.html @@ -0,0 +1,1642 @@ + + + + + + + + + + + + +Dynamic Mode Decomposition – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Dynamic Mode Decomposition

+
+
+ Dynamic mode decomposition of fluid flow problems. +
+
+
+
julia
+
dynamical systems
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

December 18, 2022

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

Recently I’ve been playing around with Dynamic Mode Decomposition (DMD) and this notebook compiles my notes and julia code in one place for later reference.

+

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

+

\[ \mathbf{\dot{x} } = \mathbf{A x} \]

+

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.

+
+

Example: Flow Past a Cylinder

+

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.

+
+
+
+ +
+
+Figure 1: Original data, vorticity of flow past a cylinder. +
+
+
+

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*8
+
+
63868809608
+
+
+
+
+

Exact DMD

+

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.

+
+

Best Fit Matrix

+

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

+

\[ \| \mathbf{ A X } - \mathbf{Y} \|_{F} \]

+

where \(\| \cdots \|_{F}\) is the Frobenius norm.

+

The solution to which is

+

\[ \mathbf{A} = \mathbf{YX}^{\dagger} \]

+

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 \(\| \mathbf{ A X } - \mathbf{Y} \|_{F}^{2} = \mathrm{Tr}\left( \left(\mathbf{ A X } - \mathbf{Y}\right)\left(\mathbf{ A X } - \mathbf{Y} \right)^{T} \right)\) and finding the matrix A that minimizes that using standard matrix calculus, and some properties of the pseudoinverse.

+
+

Singular Value Decomposition

+

The conventional way of calculating the Moore-Penrose pseudoinverse is to use the Singular Value Decomposition: for a matrix X with SVD \(\mathbf{X}=\mathbf{U}\mathbf{\Sigma}\mathbf{V}^{*}\), the pseudoinverse is \(\mathbf{X}^{\dagger} = \mathbf{V} \mathbf{\Sigma}^{-1} \mathbf{U}^{*}\). Returning to the best fit matrix A we find

+

\[ \mathbf{A} = \mathbf{Y} \mathbf{V} \mathbf{\Sigma}^{-1} \mathbf{U}^{*} \]

+

We can calculate a projection of A onto the space of the upper singular vectors U

+

\[ \tilde{ \mathbf{A} } = \mathbf{U}^{*} \mathbf{A} \mathbf{U} = \mathbf{U}^{*} \mathbf{Y} \mathbf{V} \mathbf{\Sigma}^{-1} \mathbf{U}^{*} \mathbf{U} = \mathbf{U}^{*} \mathbf{Y} \mathbf{V} \mathbf{\Sigma}^{-1} \]

+

Which then allows us to reconstruct the matrix A on demand while only needing to store the matrices à and U, by the following \[ \mathbf{A} = \mathbf{U} \tilde{ \mathbf{A} } \mathbf{U}^{*} \]

+

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)*8
+
+
108118416
+
+
+
+
size_A_exact/size_A_naive
+
+
0.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

+

\[ \mathbf{x}_{k+1} = \mathbf{A} \mathbf{x}_k \]

+

for all xk in our data set. Or in other words, to find the best fit matrix A for the system

+

\[ \mathbf{X}_{2} = \mathbf{A} \mathbf{X}_{1} \]

+

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

+

\[ \mathbf{x}_{k+1} = \mathbf{U} \mathbf{ \tilde{A} } \mathbf{U}^{*} \mathbf{x}_k \]

+

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

+
+
+
+ +
+
+Figure 2: Original flow field (top) and reconstructed flow field (bottom). +
+
+
+
+
+

Dynamic Modes

+

Of course this only solves the problem in the discrete case (for control applications that may be all you need). Consider again the system \(\mathbf{\dot{x} } = \mathbf{A x}\), the solution to this differential equation is

+

\[ \mathbf{x}\left( t \right) = e^{\mathbf{A}t} \mathbf{x}_{0} \]

+

where x0 is the initial conditions. If the matrix A has eigendecomposition ΦΛΦ-1 then this can be written as

+

\[ \mathbf{x}\left( t \right) = \mathbf{\Phi} e^{\mathbf{\Lambda}t} \mathbf{\Phi}^{-1} \mathbf{x}_{0} \]

+

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

+

\[ \mathbf{ \tilde{A} } \mathbf{W} = \mathbf{W} \mathbf{\Lambda} \]

+

\[ \mathbf{U}^{*} \mathbf{A} \mathbf{U} \mathbf{W} = \mathbf{W} \mathbf{\Lambda} \]

+

\[ \mathbf{U} \mathbf{U}^{*} \mathbf{A} \mathbf{U} \mathbf{W} = \mathbf{U} \mathbf{W} \mathbf{\Lambda} \]

+

\[ \mathbf{A} \mathbf{\Phi} = \mathbf{\Phi} \mathbf{\Lambda} \]

+

where

+

\[ \mathbf{\Phi} = \mathbf{U} \mathbf{W} \]

+

This is what is given in the original DMD, however more recent work recommends using

+

\[ \mathbf{\Phi} = \mathbf{Y} \mathbf{V} \mathbf{\Sigma}^{-1} \mathbf{W} \]

+
+
# 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.

+
+
+
+
+
+ +
+
+Figure 3: The first and tenth dymanic mode 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

+

\[ \omega_{i} = {\log{ \lambda_{i} } \over \Delta t} \]

+

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
+(t) = real( Φ*exp.* t)*Φ⁻¹x₀ )
+
+
+
+
+ +
+
+Figure 4: Original flow field (top) and reconstructed flow field (bottom), using the continuous time vector function. +
+
+
+
+
+
+

Refactoring

+

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
+end
+
+

Then 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₁)
+end
+
+

We 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.

+
+

Discrete System

+

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ₖ)))
+end
+
+
+
ds = DiscreteSys(d)
+
+# This produces the same result as before
+X̂₂_exact == ds(X₁)
+
+
true
+
+
+
+
+

Continuous System

+

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₀ )
+end
+
+
+
cs = ContinuousSys(d, X₁[:,1]);
+
+# This produces the same result as before
+(150) == cs(150)
+
+
true
+
+
+
+
+

Large Systems

+

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.

+
+
+
+

Reduced DMD

+

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)
+end
+
+

One consequence of truncation, however, is that the resulting matrix Ur is only semi-unitary, in particular

+

\[ \mathbf{U}_{r}^{*} \mathbf{U}_{r} = \mathbf{I}_{r \times r} \]

+

but

+

\[ \mathbf{U}_{r} \mathbf{U}_{r}^{*} \ne \mathbf{I}_{n \times n} \]

+

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.

+
+
+
+
+

+
The singular values of the system showing a significant elbow 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 norm
+
+
0.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

+

\[ { {\sum_{i}^{r} \sigma_i} \over {\sum_{i}^{m} \sigma_i} } \le p \]

+

where σi is the ith singular value (in order of largest to smallest).

+
+
function DMD(Y::Matrix, X::Matrix, p::AbstractFloat)
+    @assert p>0 && p1
+    
+    # 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)
+end
+
+

Capturing 99% of the variance, in this case, requires only keeping the first 14 singular values.

+
+
+
+
+
+ +
+
+Figure 5: The Frobenius norm of the difference between the original and reconstructed flow field as a function of reduced DMD rank, the point where 99% of the variance has been captured is indicated. +
+
+
+
+
+
+
+
+ +
+
+Figure 6: Original flow field (top) and reconstructed flow field (bottom), using reduced DMD capturing 99% of the variance. +
+
+
+

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)*8
+
+
10008880
+
+
+

To recover the (approximate) A matrix we only need to store 10MB, a ~91% reduction over the exact DMD

+
+
size_A_reduced/size_A_exact
+
+
0.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_naive
+
+
0.00015670998193688458
+
+
+
+

Truncated SVD and Large Systems

+

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

+

Compressed DMD attempts to tackle the slowest step in the DMD algorithm: calculating the SVD. An SVD on full data is \(\mathcal{O}\left( n m^2 \right)\) if we instead compress the data from n dimensions to k dimensions then the cost of the SVD is reduced to either \(\mathcal{O}\left( k m^2 \right)\) (when k>m) or \(\mathcal{O}\left( m k^2 \right)\) (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

+

\[ \mathbf{X}_c = \mathbf{C} \mathbf{X} \\ \mathbf{Y}_c = \mathbf{C} \mathbf{Y} \]

+

We suppose again that X has the SVD X=**UΣV***, then

+

\[ \mathbf{X}_c = \mathbf{C} \mathbf{X} = \mathbf{C} \mathbf{U} \mathbf{\Sigma} \mathbf{V}^{*} \]

+

and, since C is unitary, the SVD of Xc is

+

\[ \mathbf{X}_c = \mathbf{U}_c \mathbf{\Sigma} \mathbf{V}^{*} \]

+

where Uc=CU is the upper singular values of the compressed input matrix.

+

The projection matrix Ãc of the compressed input matrix is

+

\[ \mathbf{ \tilde{A} }_c = \mathbf{U}^{*}_c \mathbf{Y}_c \mathbf{V} \mathbf{\Sigma}^{-1} \]

+

\[ = \left( \mathbf{CU} \right)^{*} \mathbf{C} \mathbf{Y} \mathbf{V} \mathbf{\Sigma}^{-1} \]

+

\[ = \mathbf{U}^{*} \mathbf{C}^{*} \mathbf{C} \mathbf{Y} \mathbf{V} \mathbf{\Sigma}^{-1} \]

+

\[ = \mathbf{U}^{*} \mathbf{C}^{*} \mathbf{C} \mathbf{Y} \mathbf{V} \mathbf{\Sigma}^{-1} \]

+

\[ = \mathbf{U}^{*} \mathbf{Y} \mathbf{V} \mathbf{\Sigma}^{-1} = \mathbf{ \tilde{A} } \]

+

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)
+end
+
+

The 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 kn
+    
+    # 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)
+end
+
+

Suppose 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
+end
+
+

That 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.

+
+
+
+
+
+ +
+
+Figure 7: Compressed DMD performance, as measured by the Frobenius norm of the difference between the original flow field and the reconstructed field, over a range of sample sizes. +
+
+
+
+
+

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.

+
+
+ +
+
+Figure 8: Original flow field (top) and reconstructed flow field (bottom), using compressed DMD and sampling at 300 points. +
+
+
+

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.

+
+
+

Physics Informed DMD

+

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

+
+
+
+ +
+
+Figure 9: A comparison of models trained with exact DMD and with piDMD, also showing the matrix structure of the corresponding piDMD method.5 +
+
+
+

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

+

\[ \| \mathbf{ A X } - \mathbf{Y} \|_{F} \]

+

\[ \textrm{ subject to } \mathbf{A}^{*} \mathbf{A} = \mathbf{I} \]

+

For which the standard solution is to define a matrix M

+

\[ \mathbf{M} = \mathbf{Y} \mathbf{X}^{*} \]

+

supposing M has SVD

+

\[ \mathbf{M} = \mathbf{U}_{M} \mathbf{\Sigma}_{M} \mathbf{V}_{M}^{*} \]

+

then the solution is

+

\[ \mathbf{A} = \mathbf{U}_{M} \mathbf{V}_{M}^{*} \]

+

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:

+

\[ \mathbf{X} = \mathbf{U} \mathbf{\Sigma} \mathbf{V}^{*} \]

+

\[ \mathbf{ \tilde{X} } = \mathbf{U}^{*} \mathbf{X} \]

+

\[ \mathbf{ \tilde{Y} } = \mathbf{U}^{*} \mathbf{Y} \]

+

\[ \mathbf{ \tilde{M} } = \mathbf{ \tilde{Y} } \mathbf{ \tilde{X} }^{*} = \mathbf{U}^{*} \mathbf{Y} \mathbf{X}^{*} \mathbf{U} = \mathbf{U}^{*} \mathbf{M} \mathbf{U}\]

+

since SVD is invariant to left and right unitary transformations, the SVD of the projected \(\mathbf{ \tilde{M} }\) is

+

\[ \mathbf{ \tilde{M} } = \mathbf{U}_{ \tilde{M} } \mathbf{\Sigma}_{M} \mathbf{V}_{ \tilde{M} }^{*} \]

+

where

+

\[ \mathbf{U}_{ \tilde{M} } = \mathbf{U}^{*} \mathbf{U}_{ M } \textrm{ and } \mathbf{V}_{ \tilde{M} } = \mathbf{U}^{*} \mathbf{V}_{M} \]

+

and the A matrix which solves the projected procrustes problem is

+

\[ \mathbf{ \tilde{A} } = \mathbf{U}_{ \tilde{M} } \mathbf{V}_{ \tilde{M} }^{*} = \mathbf{U}^{*} \mathbf{U}_{ M } \mathbf{V}_{M}^{*} \mathbf{U} = \mathbf{U}^{*} \mathbf{A} \mathbf{U} \]

+

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
+= U'*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
+
+
+
+
+ +
+
+Figure 10: Original flow field (top) and reconstructed flow field (bottom), using physics informed DMD. +
+
+
+

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.

+
+
+

References

+
+
+Baddoo, Peter J., Benjamin Herrmann, Beverley J. McKeon, J. Nathan Kutz, and Steven L. Brunton. “Physics-Informed Dynamic Mode Decomposition (piDMD),” December 8, 2021. https://doi.org/10.48550/arXiv.2112.04307. +
+
+Bai, Zhe, Eurika Kaiser, Joshua L. Proctor, J. Nathan Kutz, and Steven L. Brunton. “Dynamic Mode Decomposition for Compressive System Identification.” AIAA Journal 58 (2020): 561–74. https://doi.org/10.2514/1.J057870. +
+
+Brunton, Steven L., and J. Nathan Kutz. Data Driven Science and Engineering. Cambridge: Cambridge University Press, 2019. http://databookuw.com. +
+
+Brunton, Steven L., Joshua L. Proctor, and J. Nathan Kutz. “Compressive Sampling and Dynamic Mode Decomposition,” December 18, 2013. https://doi.org/10.48550/arXiv.1312.5186. +
+
+Brunton, Steven L., Joshua L. Proctor, Jonathan H. Tu, and J. Nathan Kutz. “Compressed Sensing and Dynamic Mode Decomposition.” Journal of Computational Dynamics 2 (2015): 165–91. https://doi.org/10.3934/jcd.2015002. +
+
+Schmid, Peter J. “Dynamic Mode Decomposition of Numerical and Experimental Data.” Journal of Fluid Mechanics 656 (2010): 5–28. https://doi.org/10.1017/S0022112010001217. +
+
+Tu, Jonathan H., Clarence W. Rowley, Dirk Martin Luchtenburg, Steven L. Brunton, and J. Nathan Kutz. “On Dynamic Mode Decomposition: Theory and Applications.” Journal of Computational Dynamics 1 (2014): 391–421. https://doi.org/10.3934/jcd.2014.1.391. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/dynamic_mode_decomposition/index_files/figure-html/bb2bc89f-0c6b-42b5-a8ee-2299f0f24c71-1-2614ae01-2755-4f97-b6aa-8f45c00d2087.png b/posts/dynamic_mode_decomposition/index_files/figure-html/bb2bc89f-0c6b-42b5-a8ee-2299f0f24c71-1-2614ae01-2755-4f97-b6aa-8f45c00d2087.png new file mode 100644 index 0000000..8ef25b3 Binary files /dev/null and b/posts/dynamic_mode_decomposition/index_files/figure-html/bb2bc89f-0c6b-42b5-a8ee-2299f0f24c71-1-2614ae01-2755-4f97-b6aa-8f45c00d2087.png differ diff --git a/posts/dynamic_mode_decomposition/index_files/figure-html/fig-compress-levels-output-1.svg b/posts/dynamic_mode_decomposition/index_files/figure-html/fig-compress-levels-output-1.svg new file mode 100644 index 0000000..c22d6a5 --- /dev/null +++ b/posts/dynamic_mode_decomposition/index_files/figure-html/fig-compress-levels-output-1.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/dynamic_mode_decomposition/index_files/figure-html/fig-dmd-modes-output-1.svg b/posts/dynamic_mode_decomposition/index_files/figure-html/fig-dmd-modes-output-1.svg new file mode 100644 index 0000000..dda9123 --- /dev/null +++ b/posts/dynamic_mode_decomposition/index_files/figure-html/fig-dmd-modes-output-1.svg @@ -0,0 +1,8080 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/dynamic_mode_decomposition/index_files/figure-html/fig-frob-norm-output-1.svg b/posts/dynamic_mode_decomposition/index_files/figure-html/fig-frob-norm-output-1.svg new file mode 100644 index 0000000..143d3b1 --- /dev/null +++ b/posts/dynamic_mode_decomposition/index_files/figure-html/fig-frob-norm-output-1.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/dynamic_mode_decomposition/index_files/figure-html/plot-elbow-output-1.svg b/posts/dynamic_mode_decomposition/index_files/figure-html/plot-elbow-output-1.svg new file mode 100644 index 0000000..dfd9503 --- /dev/null +++ b/posts/dynamic_mode_decomposition/index_files/figure-html/plot-elbow-output-1.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/dynamic_mode_decomposition/output_11_0.gif b/posts/dynamic_mode_decomposition/output_11_0.gif new file mode 100644 index 0000000..2e3dfe0 Binary files /dev/null and b/posts/dynamic_mode_decomposition/output_11_0.gif differ diff --git a/posts/dynamic_mode_decomposition/output_15_0.gif b/posts/dynamic_mode_decomposition/output_15_0.gif new file mode 100644 index 0000000..f694701 Binary files /dev/null and b/posts/dynamic_mode_decomposition/output_15_0.gif differ diff --git a/posts/dynamic_mode_decomposition/output_29_0.gif b/posts/dynamic_mode_decomposition/output_29_0.gif new file mode 100644 index 0000000..d18e1a7 Binary files /dev/null and b/posts/dynamic_mode_decomposition/output_29_0.gif differ diff --git a/posts/dynamic_mode_decomposition/output_2_0.gif b/posts/dynamic_mode_decomposition/output_2_0.gif new file mode 100644 index 0000000..af22d3d Binary files /dev/null and b/posts/dynamic_mode_decomposition/output_2_0.gif differ diff --git a/posts/dynamic_mode_decomposition/output_36_0.gif b/posts/dynamic_mode_decomposition/output_36_0.gif new file mode 100644 index 0000000..6d35111 Binary files /dev/null and b/posts/dynamic_mode_decomposition/output_36_0.gif differ diff --git a/posts/dynamic_mode_decomposition/output_38_0.gif b/posts/dynamic_mode_decomposition/output_38_0.gif new file mode 100644 index 0000000..8f099c0 Binary files /dev/null and b/posts/dynamic_mode_decomposition/output_38_0.gif differ diff --git a/posts/dynamic_mode_decomposition/output_40_0.gif b/posts/dynamic_mode_decomposition/output_40_0.gif new file mode 100644 index 0000000..1fb3796 Binary files /dev/null and b/posts/dynamic_mode_decomposition/output_40_0.gif differ diff --git a/posts/dynamic_mode_decomposition/thumbnail.svg b/posts/dynamic_mode_decomposition/thumbnail.svg new file mode 100644 index 0000000..dda9123 --- /dev/null +++ b/posts/dynamic_mode_decomposition/thumbnail.svg @@ -0,0 +1,8080 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/engineering_a_cup_of_coffee/index.html b/posts/engineering_a_cup_of_coffee/index.html new file mode 100644 index 0000000..fde0d2a --- /dev/null +++ b/posts/engineering_a_cup_of_coffee/index.html @@ -0,0 +1,1592 @@ + + + + + + + + + + + + +Engineering a Cup of Coffee – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Engineering a Cup of Coffee

+
+
+ Better coffee through chemical engineering. +
+
+
+
julia
+
coffee
+
mass transfer
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

September 15, 2023

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

While making coffee one day, I started thinking about how the coffee making process is both a perfect representation of the sorts of systems chemical engineers work on every day and also a weird edge case unlike most of the unit operations in the standard repertoire of process engineering.

+

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.

+

Extraction and the Coffee Control Chart

+

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.

+ +

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.

+

\[ \mathrm{Dose} = D = { m_{beans} \over V_{water} } \approx { m_{beans} \over V_{cup} } \]

+

\[ \mathrm{Extraction} = E = { m_{cup} \over m_{beans} } = { { c_{cup} V_{cup} } \over m_{beans} } \]

+

\[ c_{cup} = D \cdot E \]

+

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

+
+
+
+ +
+
+Figure 2: A Rotocel extractor, you are unlikely to see one of these at your local coffee shop. +
+
+
+

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.

+
+

The simplest coffee maker

+

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

+

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

+

Secondly, brew temperature impacts the rate of extraction. Generally speaking, diffusion coefficients are proportional to (absolute) temperature, \(\mathscr{D} \propto T\).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 \(\mathscr{D} \propto T\), the percent change in the rate constant is equal to the percent change in (absolute) temperature

+

\[ { { \Delta \mathscr{D} } \over \mathscr{D} } = { { \Delta T } \over T } \]

+
+
+
Δ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 and uniformity

+

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

+

\[ a_v = {S \over V} = { {4 \pi b^2} \over { \frac{4}{3} \pi b^3} } = {3 \over b} \]

+

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

+

\[ b_{s} = {3 \over a_v } \]

+

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.

+
+
+

Mixing and rate constants

+

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.

+

For organic material with hard cell walls the relative diffusivity of the solid phase to the liquid phase generally falls along the range \(\frac{\mathscr{D}_s}{\mathscr{D}_l} = 0.1-0.2\),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

+

\[ K = \frac{ q^{*} }{ c^{*} } \]

+

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

+

\[ \mathrm{coffee}_{s} \xrightarrow{k_1} \mathrm{coffee}_{l} \]

+

\[ \mathrm{coffee}_{l} \xrightarrow{k_2} \mathrm{coffee}_{s} \]

+

At equilibrium the rates of these two processes are equal

+

\[ k_1 q^{*} = k_2 c^{*} \Leftrightarrow \frac{k_2}{k_1} = \frac{ q^{*} }{ c^{*} } = {K} \]

+

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)

+
+
+

An example brew

+

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);
+
+
+
+
+

A mass transfer model of coffee brewing

+

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:

+
    +
  • The system is isothermal with brew temperature Tl
  • +
  • Coffee grounds are spherical and have constant radius b
  • +
  • The coffee matrix is a pseudo-homogeneous solid, diffusion through the solid follows Fick’s second law with diffusivity \(\mathscr{D}_s\) and diffusion is only relevant in the radial direction r
  • +
  • The liquid phase is well mixed, i.e. the bulk concentration c is spatially homogeneous and is only a function of time
  • +
  • Mass transfer into the liquid phase occurs through a thin film with a mass transfer coefficient h
  • +
  • At the interface between the thin film and the solid coffee, the concentration of solubles is in equilibrium with equilibrium constant K
  • +
+

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.

+
+
+
+ +
+
+Figure 3: The mass transfer system for making coffee with a French press. +
+
+
+

There are two rates important processes governing the extraction of coffee:

+
    +
  1. Diffusion across the interface into the thin film, governed by Fick’s first law
  2. +
  3. Transfer from the thin film into the bulk liquid
  4. +
+

Starting with (1) the mass flux into the thin film is given by Fick’s first law (in spherical coordinates)

+

\[ J_1 = - \mathscr{D}_s \left( { \partial q } \over { \partial r} \right)_{r=b} \]

+

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)

+

\[ { {\partial q} \over {\partial t} } = \frac{1}{r^2} { \partial \over {\partial r} } \left( r^2 \mathscr{D}_s { {\partial q} \over {\partial r} } \right) \]

+

Turning to (2) the mass flux from the thin film into the bulk liquid is given by

+

\[ J_2 = - h \left( c - c_s \right) \]

+

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:

+

\[ V_l { { \partial c} \over {\partial t} } = a_v V_s J_2 = \frac{3}{b} V_s J_2 \]

+

\[ { { \partial c} \over {\partial t} } = \frac{3}{b} \frac{V_s}{V_l} J_2 \]

+

The solution to this partial differential equation depends upon which of these mass transfer processes, (1) or (2), is dominant.

+
+

The dominant rate

+

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

+
    +
  • Bi < 0.001 : the mass transfer through the film dominates, a simple exponential model is appropriate
  • +
  • 0.001 < Bi < 200 : use an intermediate method11
  • +
  • Bi > 200 : the mass transfer through the coffee particles dominates, the more complicated solution from Carslaw and Jaeger is best
  • +
+

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 ¯\_(ツ)_/¯

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

+

\[ \mathrm{Bi} = { {h b} \over {K \mathscr{D}_s} } = {\mathscr{D}_l \over \mathscr{D}_s} { \mathrm{Sh} \over K }\]

+

Where Sh is the Sherwood number, defined as

+

\[ \mathrm{Sh} = { {h b} \over \mathscr{D}_l } \]

+

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

+

\[ \mathrm{Sh} = 2 + 0.552 \mathrm{Re}^{1/2} \mathrm{Sc}^{1/3} \]

+

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.

+

\[ \mathrm{Bi} = {\mathscr{D}_l \over \mathscr{D}_s} { 1 \over K } \left( 2 + 0.552 \mathrm{Re}^{1/2} \mathrm{Sc}^{1/3} \right) \]

+

\[ \mathrm{Bi} = { 20 \over K } + { 5.52 \over K } \mathrm{Re}^{1/2} \mathrm{Sc}^{1/3} \]

+

Where \({\mathscr{D}_s \over \mathscr{D}_l}\) = 0.1 is assumed from Schwartzberg15 The Schmidt number, Sc, and equilibrium constant, K, can be calculated

+
+
+
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ₗ)/b
+
+
0.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 \(\mathrm{Bi}<0.001\) and thus the simple exponential model is probably not a good fit, we turn instead to the model from Carslaw and Jaeger.16

+
+
+

Boundary conditions

+

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

+
    +
  • t = 0 : q = q0 and c = c0
  • +
  • r = b : q = K c
  • +
  • r = 0 : q is finite
  • +
+
+
+

The Carslaw and Jaeger model

+

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.

+

First step, to put the PDE in dimensionless form we make the substitutions:

+

\[ \xi = {r \over b} \]

+

\[ \tau = { {\mathscr{D}_s t} \over b^2} \]

+

\[ u = { { q - q^{*} } \over { q_0 - q^{*} } }\]

+

\[ u_f = { {c - c^{*} } \over { c_0 - c^{*} } }\]

+

After which the PDE becomes

+

\[ { {\partial u} \over {\partial \tau} } = \frac{1}{\xi^2} { \partial \over {\partial \xi} } \left( \xi^2 {\partial \over {\partial \xi} } \right) \]

+

With boundary conditions

+
    +
  • τ = 0 : u = 0
  • +
  • ξ = 1 : u = uf
  • +
  • ξ = 0 : u is finite
  • +
+

And the mass transfer into the liquid bulk becomes

+

\[ { {\partial u_f} \over {\partial \tau} } = -\frac{3}{\alpha} \left. { {\partial u} \over {\partial \xi} } \right|_{\xi=1} \]

+

With \(\alpha = { V_l \over {K V_s} }\) and boundary condition

+
    +
  • τ = 0 : uf = 1
  • +
+

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

+

\[ u_f = 6α (α+1) \sum_{k=1}^{\infty} { \exp(-τ x_k^2 )\over { 9(α+1) + (α x_k)^2 } } \]

+

Where the xk s are the roots of the equation

+

\[ \tan(x) = { {3 x} \over { 3 + \alpha x^2 } } \]

+

(the particular form shown here comes from Schwartzberg19)

+

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

+

\[ \tan(x) = { \sin(x) \over \cos(x) } \]

+

\[ \tan(x) - { {3 x} \over { 3 + \alpha x^2 } } = 0 \Leftrightarrow \left( 3 + \alpha x^2 \right) \sin(x) - 3 x \cos(x) = 0 \]

+

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
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 4: The roots of the equations f(x) and g(x), note the repeated singularities in f(x). +
+
+
+
+

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
+end
+
+

The 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
+end
+
+

Now we can put together a bulk concentration function

+
+
function c(t)
+    τ = (𝒟ₛ*t)/b^2
+    c = (c₀ - c_max)*u_f(τ) + c_max
+    return c
+end
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 5: The concentration of solubles in the extract over time. +
+
+
+
+

Extraction is simply concentration over dose

+
+
extraction(t) = c(t)*Vₗ/mₛ
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 6: The extraction of coffee solubles over time. +
+
+
+
+
+
+

Packaging the final result

+

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)
+end
+
+

and 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).

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 7: The evolution of coffee extraction over time for several grind sizes. Note that the smallest grind sizes extract faster, achieving equilibrium, whereas the largest grind sizes extract more slowly. +
+
+
+
+
+
+
+

Final thoughts

+

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.

+
+
+

References

+
+
+Batali, Mackenzie E., Lik Xian Lim, Jiexin Liang, Sara E. Yeager, Ashley N. Thompson, Juliet Han, William D. Ristenpart, and Jean-Xavier Guinard. “Sensory Analysis of Full Immersion Coffee: Cold Brew Is More Floral, and Less Bitter, Sour, and Rubbery Than Hot Brew.” Foods 11, no. 16 (2022): 2440. https://doi.org/10.3390/foods11162440. +
+
+Batali, Mackenzie E., William D. Ristenpart, and Jean‑Xavier Guinard. “Brew Temperature, at Fixed Brew Strength and Extraction, Has Little Impact on the Sensory Profile of Drip Brew Coffee.” Scientific Reports 10 (2020): 16450. https://doi.org/10.1038/s41598-020-73341-4. +
+
+Bird, R. Byron, Warren E. Stewart, and Edwin N. Lightfoot. Transport Phenomena. 2nd ed. Hoboken, NJ: John Wiley & Sons, 2007. +
+
+Carslaw, Horatio S., and John C. Jaeger. Conduction of Heat in Solids. 2nd ed. London: Oxford University Press, 1959. +
+
+Green, Don W., ed. Perry’s Chemical Engineers’ Handbook. 8th ed. New York: McGraw Hill, 2008. +
+
+Hottel, Hoyt C., James J. Noble, Adel F. Sarofim, Geoffrey D. Silcox, Phillip C. Wankat, and Kent S. Knaebel. “Heat and Mass Transfer.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+Moroney, Kevin M., William T. Lee, Stephen B. G. O’Brien, Freek Suijver, and Johan Marra. “Modelling of Coffee Extraction During Brewing Using Multiscale Methods: An Experimentally Validated Model.” Chemical Engineering Science 137 (2015): 216–34. https://doi.org/10.1016/j.ces.2015.06.003. +
+
+Poling, Bruce E., John M. Prausnitz, and John P. O’Connell. The Properties of Gases and Liquids. 5th ed. New York: McGraw Hill, 2001. +
+
+Poling, Bruce E., George H. Thomson, Daniel G. Friend, Richard L. Rowley, and W. Vincent Wilding. “Physical and Chemical Data.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+Rodrigues, Melissa A. A., Maria Lúcia A. Borges, Adriana S. Franca, Leandro S. Oliveira, and Paulo C. Corrêa. “Evaluation of Physical Properties of Coffee During Roasting.” Agricultural Engineering International: The CIGR Journal of Scientific Research and Development V (2003). https://www.researchgate.net/publication/267858074. +
+
+Rousseau, Ronald W., ed. Handbook of Seperation Process Technology. Hoboken, NJ: John Wiley & Sons, 1987. +
+
+Schwartzberg, Henry G. “Leaching – Organic Materials.” In Handbook of Seperation Process Technology, edited by Ronald W. Rousseau. Hoboken, NJ: John Wiley & Sons, 1987. +
+
+Seader, J. D., Ernest J. Henley, and D. Keith Roper. Separation Process Principles. 3rd ed. Hoboken, NJ: John Wiley & Sons, 2011. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/engineering_a_cup_of_coffee/index_files/figure-html/a91c6ec4-34c9-42a2-bac9-75a14350b5ff-1-867304b6-2f42-4977-a241-709cf06a75d5.png b/posts/engineering_a_cup_of_coffee/index_files/figure-html/a91c6ec4-34c9-42a2-bac9-75a14350b5ff-1-867304b6-2f42-4977-a241-709cf06a75d5.png new file mode 100644 index 0000000..a191324 Binary files /dev/null and b/posts/engineering_a_cup_of_coffee/index_files/figure-html/a91c6ec4-34c9-42a2-bac9-75a14350b5ff-1-867304b6-2f42-4977-a241-709cf06a75d5.png differ diff --git a/posts/engineering_a_cup_of_coffee/index_files/figure-html/a91c6ec4-34c9-42a2-bac9-75a14350b5ff-2-9327befe-96ed-447f-af7d-8123218ce151.png b/posts/engineering_a_cup_of_coffee/index_files/figure-html/a91c6ec4-34c9-42a2-bac9-75a14350b5ff-2-9327befe-96ed-447f-af7d-8123218ce151.png new file mode 100644 index 0000000..8300492 Binary files /dev/null and b/posts/engineering_a_cup_of_coffee/index_files/figure-html/a91c6ec4-34c9-42a2-bac9-75a14350b5ff-2-9327befe-96ed-447f-af7d-8123218ce151.png differ diff --git a/posts/engineering_a_cup_of_coffee/index_files/figure-html/fac54ddd-3ea3-497a-9691-b5340ea6ec67-1-1aa2f9b6-0524-4caa-a5e8-383f17018211.svg b/posts/engineering_a_cup_of_coffee/index_files/figure-html/fac54ddd-3ea3-497a-9691-b5340ea6ec67-1-1aa2f9b6-0524-4caa-a5e8-383f17018211.svg new file mode 100644 index 0000000..86e4f9d --- /dev/null +++ b/posts/engineering_a_cup_of_coffee/index_files/figure-html/fac54ddd-3ea3-497a-9691-b5340ea6ec67-1-1aa2f9b6-0524-4caa-a5e8-383f17018211.svg @@ -0,0 +1,3 @@ + + +
Liquid
Liquid
Thin Film
Thin F...
Coffee Particle
Coffee Partic...
1
1
2
2
Text is not SVG - cannot display
\ No newline at end of file diff --git a/posts/engineering_a_cup_of_coffee/rene-porter-unsplash.jpg b/posts/engineering_a_cup_of_coffee/rene-porter-unsplash.jpg new file mode 100644 index 0000000..342ad40 Binary files /dev/null and b/posts/engineering_a_cup_of_coffee/rene-porter-unsplash.jpg differ diff --git a/posts/engineering_a_cup_of_coffee_part-2/blake-verdoorn-unsplash.jpg b/posts/engineering_a_cup_of_coffee_part-2/blake-verdoorn-unsplash.jpg new file mode 100644 index 0000000..eacf10b Binary files /dev/null and b/posts/engineering_a_cup_of_coffee_part-2/blake-verdoorn-unsplash.jpg differ diff --git a/posts/engineering_a_cup_of_coffee_part-2/index.html b/posts/engineering_a_cup_of_coffee_part-2/index.html new file mode 100644 index 0000000..658c42c --- /dev/null +++ b/posts/engineering_a_cup_of_coffee_part-2/index.html @@ -0,0 +1,2885 @@ + + + + + + + + + + + + +Engineering a Cup of Coffee Part Two: Espresso – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Engineering a Cup of Coffee Part Two: Espresso

+
+
+ Modelling espresso bed extraction. +
+
+
+
julia
+
coffee
+
mass transfer
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

March 23, 2024

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

In a previous post I thought about how one might approach making coffee, in a French press, as a chemical engineering problem. The obvious next step is to look at percolation methods like espresso, which is what I am exploring here.

+

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.

+
+

Packed Bed Leaching

+

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.

+
+

Setting up the Governing Equations

+

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 \(\varepsilon\). 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.

+
+
+
+ +
+
+Figure 1: The problem domain, a cylindrical puck of coffee. +
+
+
+

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.

+
+

Liquid Phase

+

Zooming in on a thin slice of the packed bed with depth, Δz, we can take a mass balance of the liquid phase.

+
+
+
+ +
+
+Figure 2: Mass transfer in the thin slice of the column. +
+
+
+

The mass flow into the liquid phase can be due to:

+
    +
  • advection, Qc, where c is the liquid phase concentration entering the slice and Q is the volumetric flow rate
  • +
  • axial diffusion, \(\varepsilon A J_z\), where Jz is the mass flux at the top of the slice and \(\varepsilon A\) is the porous area of the top of the slice, i.e. the area available to liquid flux.
  • +
  • mass transfer from the coffee grounds (the solid phase), \(A_s J_s\), where Js is the mass flux of solubles from the grounds into the liquid phase and As is the surface area of the grounds
  • +
+

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

+

\[ { {d m_l} \over {d t} } = Q c_z - Q c_{z + \Delta z} + \varepsilon A J_z - \varepsilon A J_{z+\Delta z} + A_s J_s \]

+

\[ V_l { {d c} \over {d t} } = - Q \Delta c - \varepsilon A \Delta J_z + a_s V_s J_s \]

+

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.

+

\[ \varepsilon A \Delta z { {d c} \over {d t} } = - \varepsilon A v \Delta c - \varepsilon A \Delta J_z + \left( 1 - \varepsilon \right) A {\Delta z} a_s J_s\]

+

\[ { {d c} \over {d t} } = - v { {\Delta c} \over {\Delta z} } - { {\Delta J_z} \over {\Delta z} } + \left( {1 - \varepsilon} \over \varepsilon \right) a_s J_s \]

+

In the limit Δz → 0 this becomes

+

\[ { {\partial c} \over {\partial t} } = - v { {\partial c} \over {\partial z} } - { {\partial J_z} \over {\partial z} } + \left({ 1 - \varepsilon } \over \varepsilon \right) a_s J_s \]

+

Assuming axial diffusion is Fickian

+

\[ { {\partial c} \over {\partial t} } = \mathscr{D}_l { {\partial^2 c} \over {\partial z^2} } - v { {\partial c} \over {\partial z} } + \left({ 1 - \varepsilon } \over \varepsilon \right) a_s J_s \]

+
+
+

Solid Phase

+

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.

+

\[ { {\partial q} \over {\partial t} } = \mathscr{D}_s \nabla^2 q\]

+

In spherical coordinates, this becomes

+

\[ { {\partial q} \over {\partial t} } = \mathscr{D}_s \frac{1}{r^2} {\partial \over {\partial r} } \left( r^2 { {\partial q} \over {\partial r} } \right)\]

+

\[ { {\partial q} \over {\partial t} } = \mathscr{D}_s \left( { {\partial^2 q} \over {\partial r^2} } + {2 \over r} { {\partial q} \over {\partial r} } \right) \]

+

Where the solid phase concentration, q, is in units of mass per unit volume.

+
+
+

Thin Film

+

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.

+

\[ J_s = h \left( c_{s} - c \right) \]

+

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.

+

\[ K = c_{s}/q_{s} = \mathrm{constant} \]

+
+
+
+

Initial Conditions

+

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:

+
    +
  1. The bed is initially full of water, but that water has no coffee extracted into it.
  2. +
  3. The bed is initially full of water, and that water is in equilibrium with the coffee grounds (e.g. fully saturated).
  4. +
+

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.

+

For my model, the initial conditions are:

+
    +
  • the solid concentration in the grounds is the saturation concentration
  • +
  • the liquid concentration in the bulk is, initially, in equilibrium and also saturated
  • +
  • the boundary condition is that the water entering the system has a concentration 0 mg/m3 coffee solubles
  • +
+

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.

+
+
+
+

Determining the Parameters of the System

+

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(ν)
+
+
+

Parameters of the Packed Bed

+

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.

+

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.

+

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_shot
+
+
4.177834449522944 s
+
+
+
+
+

Mass Transfer Parameters

+

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 = ν/D
+
+

The 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)/Pe
+
+
4.623616336378663e-8 m^2 s^-1
+
+
+
+
+

Equilibrium Constant

+

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_sat
+
+
0.5555555555555556
+
+
+
+
+

A Packed Bed Data Structure

+

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);
+
+
+
+
+

Anzelius’ Integral Solution

+

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 rate of mass transfer across the thin film dominates, and thus the solid phase diffusion can be neglected
  • +
  • the mass flow into a given slice of the packed bed is dominated by advection, and the axial dispersion can be neglected
  • +
+

The governing equations can be reduced to

+

\[ { {\partial c} \over {\partial t} } = - v { {\partial c} \over {\partial z} } + \left({ 1 - \varepsilon } \over \varepsilon \right) a_s J_s \]

+

\[ { {\partial q} \over {\partial t} } = - a_s J_s \]

+

with

+

\[ J_s = h \left( c_{s} - c \right) \]

+

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.

+

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 \(\tau = \frac{h a_s}{K} \left( t - \frac{z}{v} \right)\). Which, when substituted into the equation for the solid phase, becomes

+

\[ {{\partial q} \over {\partial \tau}} = K \left( c - c_{s} \right) \]

+

Further introducing a dimensionless space \(\xi = \frac{h a_s}{m v} z\) where \(m = \left({ 1 - \varepsilon } \over \varepsilon \right)\) transforms the equation for the liquid phase into

+

\[ {{\partial c} \over {\partial \xi}} = c_s - c \]

+

By defining a dimensionless liquid phase concentration

+

\[ u = {{c - \frac{q_0}{K}} \over {c_0 - \frac{q_0}{K}}} \]

+

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

+

\[ {{\partial u} \over {\partial \xi}} = {{q - q_0} \over {K c_0 - q_0}} - u \]

+

Letting

+

\[ y = { {q - q_0} \over {K c_0 - q_0} } \]

+

the final system of equations is then

+

\[ { {\partial u} \over {\partial \xi} } = y - u \]

+

\[ { {\partial y} \over {\partial \tau} } = u - y \]

+

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 \({ {\partial y} \over {\partial \tau} }\), with respect to τ, which gives

+

\[ sY = U - Y\]

+

\[ Y = \frac{1}{s+1}U \]

+

then taking the Laplace transform of \({ {\partial u} \over {\partial \xi} }\), with respect to τ gives

+

\[ { {d U} \over {d \xi} } = Y - U = \frac{-s}{s+1}U \]

+

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

+

\[ U = \frac{1}{s} \exp\left( \frac{-s}{s+1} \xi \right) \]

+

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.

+

First, recognize that

+

\[ \frac{1}{s} \exp\left( \frac{-s}{s+1} \xi \right) = \exp\left( -\xi \right) \frac{1}{s} \exp\left( \frac{1}{s+1} \xi \right) \]

+

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.

\[ \mathscr{L}^{-1} \left\{ \frac{1}{s} \exp\left( \frac{1}{s} x \right) \right\} = I_0 \left( 2 \sqrt{xt} \right)\]

+

where I0 is the modified Bessel function of the first kind. A basic property of Laplace transforms is that

+

\[ \mathscr{L}^{-1} \left\{ F(s+a) \right\} = \exp(-at)f(t) \]

+

from which it follows that

+

\[ \mathscr{L}^{-1} \left\{ \frac{1}{s+1} \exp\left( \frac{1}{s+1} x \right) \right\} = \exp\left( -t \right) I_0 \left( 2 \sqrt{xt} \right)\]

+

Another property of Laplace transforms is that

+

\[ \mathscr{L}^{-1} \left\{ \frac{1}{s} F(s) \right\} = \int_0^t f\left( \lambda \right) d\lambda \]

+

which gives

+

\[ \mathscr{L}^{-1} \left\{ \frac{1}{s} \frac{1}{s+1} \exp\left( \frac{1}{s+1} x \right) \right\} = \int_0^t \exp\left( -\lambda \right) I_0 \left( 2 \sqrt{x \lambda} \right) d\lambda\]

+

Which is laying the groundwork for the observation that since

+

\[ \frac{1}{s} - \frac{1}{s+1} = \frac{1}{s}\frac{1}{s+1} \]

+

then

+

\[ \frac{1}{s} = \frac{1}{s+1} + \frac{1}{s}\frac{1}{s+1} \]

+

and U can be rewritten as

+

\[ U = \exp\left( -\xi \right) \left[ \frac{1}{s+1} \exp\left( \frac{1}{s+1} \xi \right) +\frac{1}{s} \frac{1}{s+1} \exp\left( \frac{1}{s+1} \xi \right) \right] \]

+

then by taking the inverse Laplace transform

+

\[ u = \exp\left( -\xi \right) \left[ \exp\left( - \tau \right) I_0 \left( 2 \sqrt{\tau \xi} \right) + \int_0^\tau \exp\left( -\lambda \right) I_0 \left( 2 \sqrt{\lambda \xi} \right) d\lambda \right] \]

+

\[ u = \exp\left( -(\tau + \xi) \right) I_0 \left( 2 \sqrt{\tau \xi} \right) + \int_0^\tau \exp\left( -(\lambda + \xi) \right) I_0 \left( 2 \sqrt{\lambda \xi} \right) d\lambda \]

+

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

+

\[ J\left( x, y \right) = 1 - \int_0^x \exp\left( -(\lambda + y) \right) I_0 \left( 2 \sqrt{\lambda y} \right) d\lambda \]

+

\[ J\left( x, y \right) + J\left( y, x \right) = 1 + \exp\left( -(x+y) \right) I_0 \left( 2 \sqrt{ x y} \right) \]

+

from which we can see that

+

\[ u = \exp\left( -(\xi + \tau) \right) I_0 \left( 2 \sqrt{\xi \tau} \right) + 1 - J\left( \tau, \xi \right) = J\left( \xi, \tau \right) \]

+

and finally

+

\[ u = 1 - \int_0^\xi \exp\left( - (\tau + \lambda) \right) I_0 \left( 2 \sqrt{\tau \lambda} \right) d\lambda \]

+

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.

+
+

Defining the Anzelius Solution

+

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)
+end
+
+
+
anzelius = AnzeliusSolution(z, pb);
+
+
+
+

Evaluating the Products of Exponentials and Bessel Functions

+

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:

+

\[ u = 1 - \int_0^\xi \exp\left( - (\tau + \lambda) \right) J_0 \left( i \sqrt{4\tau \lambda} \right) d\lambda \]

+

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

+

\[ I_{x,0}(z) = \exp(-z) I_0(z) \]

+

By completing the square we can rewrite

+

\[ u = 1 - \int_0^\xi \exp\left( - (\tau + \lambda) \right) I_0 \left( 2 \sqrt{\tau \lambda} \right) d\lambda \]

+

as

+

\[ u = 1 - \int_0^\xi \exp\left( - \left( \sqrt{\tau} - \sqrt{\lambda} \right)^2 \right) \exp\left(- 2 \sqrt{\tau \lambda} \right) I_0 \left( 2 \sqrt{\tau \lambda} \right) d\lambda \]

+

\[ u = 1 - \int_0^\xi \exp\left( - \left( \sqrt{\tau} - \sqrt{\lambda} \right)^2 \right) I_{x,0} \left( 2 \sqrt{\tau \lambda} \right) d\lambda \]

+

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τ*λ))
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 3: The Anzelius integrand, the upper curve represents the original form which encounters numerical difficulties and fails to return valid answers after the red X, the lower curve is the transformed form of the integrand that does not have these difficulties. +
+
+
+
+
+
+

Integration by Gauss-Kronold

+

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 \(x = {\lambda \over \xi}\), thus changing the bounds of integration to \(x \in [0, 1]\)

+
+
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
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 4: Integrating the Anzelius integrand on the interval [0,1] +
+
+
+
+

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

+
    +
  • for ξ≤τ, calculate \(J \left( \xi, \tau \right)\) by direct numerical integration
  • +
  • for ξ>τ, calculate \(J \left( \xi, \tau \right)\) by using the relation \[ J\left( \xi, \tau \right) = 1 + \exp\left( - \left( \sqrt{\tau} - \sqrt{\xi} \right)^2 \right) I_{x,0} \left( 2 \sqrt{\tau \xi} \right) - J\left( \tau, \xi \right) \] where \(J\left( \tau, \xi \right)\) is then numerically integrated
  • +
+

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
+end
+
+
+
+

Series Representations of the Anzelius J function

+

A brief review of the literature around the Anzelius J function will reveal a multitude of series representations. For example, Goldstein17 gives the following

+

\[ J(x,y) = 1 - \exp\left( - \left( \sqrt{x} - \sqrt{y} \right)^2 \right) \sum_{n=1}^{\infty} \left( x \over y \right)^{n \over 2} \exp \left( -2 \sqrt{xy} \right) \mathrm{I}_{n} \left( 2 \sqrt{xy} \right) \]

+

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.

+
+
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
+end
+
+
+
function 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
+end
+
+
+
u, 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
+end
+
+
+
+

Approximations to the Anzelius J Function

+

Thomas19 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 \(J(x,y) = 1 - \exp \left( -(x+y) \right)\phi(x,y)\)

\[ J(x,y) \approx 1 - \frac{1}{2}\mathrm{erfc} \left( \sqrt{y} - \sqrt{x} \right) + \exp \left( -(x+y) \right) { \sqrt[4]{x} \over { \sqrt[4]{y} + \sqrt[4]{x} } } I_0 \left( 2\sqrt{xy} \right) + \ldots\]

+

Taking the first terms of the asymptotic expansion and the limit \(I_{x,0}(z) \to \frac{1}{\sqrt{2\pi z} }\) as z → ∞20

+

\[ J(x,y) \approx \frac{1}{2}\mathrm{erfc} \left( \sqrt{x} - \sqrt{y} \right) + { \exp \left( - \left( \sqrt{x} - \sqrt{y} \right)^2 \right) \over { 2\sqrt{\pi} \left( \sqrt{y} + \sqrt[4]{xy} \right) } }\]

+
+
using SpecialFunctions: erf, erfc
+
+
+
function 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
+end
+
+

Rice21 goes even further and suggests that, for \(\sqrt{xy} \gt 60\)

+

\[ J(x,y) \approx \frac{1}{2}\mathrm{erfc} \left( \sqrt{x} - \sqrt{y} \right) \]

+
+
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
+end
+
+
+
+

Reviewing Overall Performance

+

I 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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 5: The Anzelius solution and its approximations. For this problem the approximations are essentially exact. +
+
+
+
+

I wouldn’t take this to mean that the simple, single term, \(\mathrm{erfc}(\ldots)\) 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.

+
+
+
+

Rosen’s Integral Solution

+

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

+

\[ { {\partial c} \over {\partial t} } + v { {\partial c} \over {\partial z} } = \left({ 1 - \varepsilon } \over \varepsilon \right) a_s J_s \]

+

\[ { {\partial q} \over {\partial t} } = \mathscr{D}_s \left( { {\partial^2 q} \over {\partial r^2} } + {2 \over r} { {\partial q} \over {\partial r} } \right) \]

+

were

+

\[ J_s = h \left( c_{s} - c \right) \]

+

Rosen introduced \[ { {\partial \bar{q} } \over {\partial t} } = - a_s J_s \]

+

where

+

\[ \bar{q} \left( z,t \right) = \frac{3}{b^3} \int_0^b q \left( r, \xi, \tau \right) r^2 dr \]

+

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, \(\tau = { { \mathscr{D} a_s} \over b} \left( t - \frac{z}{v} \right)\), and a dimensionless z-coordinate, \(\xi = { { K D a_s} \over {b m v} } z\), where \(m = { \varepsilon \over \left( 1 - \varepsilon \right) }\) and, with the same dimensionless concentrations u and y as defined above for the Anzelius solution, we have

+

\[ { { \partial u} \over {\partial \xi} } = - { { \partial \bar{y} } \over {\partial \tau} } = \frac{1}{\nu} \left( y_s - u \right)\]

+

where \(\nu = \frac{\mathscr{D} K}{b h}\) 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 \(\vartheta = \frac{r}{b}\) we can rewrite the solid phase diffusion equation in dimensionless form

+

\[ { {\partial y} \over {\partial \tau} } = \frac{1}{3} \left( { {\partial^2 y} \over {\partial \vartheta^2} } + {2 \over \vartheta} { {\partial y} \over {\partial \vartheta} } \right) \]

+

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

+

\[ y \left( \vartheta, \xi, \tau \right) = \frac{2}{3} \sum_{n=1}^{\infty} \left(-1 \right)^{n+1} n\pi { {\sin \left( n \pi \vartheta \right) } \over \vartheta} \int_0^{\tau} y_s \left( \xi, \lambda \right) \exp \left( -\frac{n^2 \pi^2}{3} \left( \tau - \lambda \right) \right) d\lambda\]

+

This can be integrated over \(\vartheta\) to get the volume average (dimensionless) concentration

+

\[ \bar{y} = 3 \int_0^1 y \vartheta^2 d \vartheta \]

+

\[ \bar{y} = 2 \sum_{n=1}^{\infty} \int_0^{\tau} y_s \exp \left( -\frac{n^2 \pi^2}{3} \left( \tau - \lambda \right) \right) d\lambda \]

+

since

+

\[ \int_0^1 n\pi \sin \left( n \pi \vartheta \right) \vartheta d\vartheta = \left(-1 \right)^{n+1} \]

+

Taking the derivative with respect to τ gives (by integration by parts)

+

\[ { {\partial \bar{y} } \over {\partial \tau} } = 2 \sum_{n=1}^{\infty} \int_0^{\tau} { { \partial y_s } \over {\partial \lambda} } \exp \left( -\frac{n^2 \pi^2}{3} \left( \tau - \lambda \right) \right) d\lambda \]

+

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

+

\[ { { \partial u} \over {\partial \xi} } = \frac{1}{\nu} \left( y_s - u \right)\]

+

\[ y_s = u + \nu { { \partial u} \over {\partial \xi} } \]

+

Thus

+

\[ { {\partial \bar{y} } \over {\partial \tau} } = 2 \sum_{n=1}^{\infty} \int_0^{\tau} \left[ { {\partial u} \over {\partial \lambda} } + \nu { { \partial^2 u} \over {\partial \lambda \partial \xi} } \right] \exp \left( -\frac{n^2 \pi^2}{3} \left( \tau - \lambda \right) \right) d\lambda \]

+

and since \({ { \partial u} \over {\partial \xi} } = - { { \partial \bar{y} } \over {\partial \tau} }\)

+

\[ { {\partial u } \over {\partial \xi} } = -2 \sum_{n=1}^{\infty} \int_0^{\tau} \left[ { {\partial u} \over {\partial \lambda} } + \nu { { \partial^2 u} \over {\partial \lambda \partial \xi} } \right] \exp \left( -\frac{n^2 \pi^2}{3} \left( \tau - \lambda \right) \right) d\lambda \]

+

Rosen solves this by taking the Laplace transform, with respect to τ, with the following relations:

+

\[ \mathscr{L} \left\{ \int_0^\tau f(\lambda) g(\tau-\lambda) d\lambda \right\} = F(s) G(s) \]

+

\[ \mathscr{L} \left\{ { {\partial u} \over {\partial \tau} } + \nu { { \partial^2 u} \over {\partial \tau \partial \xi} } \right\} = s U + \nu s { {d U} \over {d\xi} }\]

+

\[ \mathscr{L} \left\{ \exp \left( -\frac{n^2 \pi^2}{3} \tau \right) \right\} = { 1 \over {s + \frac{n^2 \pi^2}{3} } }\]

+

arriving at

+

\[ { {d U} \over {d\xi} } = -2 \left( s U + \nu s { {d U} \over {d\xi} } \right) \sum_{n=1}^{\infty} { s \over {s + \frac{n^2 \pi^2}{3} } } \]

+

Letting

+

\[ Y_D(s) = 2 \sum_{n=1}^{\infty} { s \over {s + \frac{n^2 \pi^2}{3} } } \]

+

then

+

\[ { {d U} \over {d\xi} } = - { Y_D \over { 1 + \nu Y_D } } U \]

+

and, solving this ode with initial condition u=1, U=1/s, gives

+

\[ U = \frac{1}{s} \exp \left( - { Y_D \over { 1 + \nu Y_D } } \xi \right) \]

+

The final solution follows from taking the inverse Laplace transform, by way of the contour integral

+

\[ u \left( \xi, \tau \right) = \frac{1}{2\pi i} \int_{\alpha - i\infty}^{\alpha + i\infty} \frac{1}{s} \exp \left( s \tau - { Y_D \over { 1 + \nu Y_D } } \xi \right) ds \]

+

A major component of the integration involves first defining YD in terms of trigonometric functions. The details are tedious, but the main result is

+

\[ Y_T \left( i \beta \right) = { Y_D \over { 1 + \nu Y_D } } = H_1 \left( \lambda, \nu \right) + i H_2 \left( \lambda, \nu \right) \]

+

with \(\lambda = \sqrt{ \frac{3}{2} \beta}\) and

+

\[ H_1 \left( \lambda, \nu \right) = { { H_{D1} + \nu \left( H_{D1}^2 + H_{D2}^2 \right) } \over { \left( 1 + \nu H_{D1} \right)^2 + \left( \nu H_{D2} \right)^2 } }\]

+

\[ H_2 \left( \lambda, \nu \right) = { H_{D2} \over { \left( 1 + \nu H_{D1} \right)^2 + \left( \nu H_{D2} \right)^2 } }\]

+

and

+

\[ H_{D1} = \lambda { {\sinh 2\lambda + \sin 2\lambda} \over { \cosh 2\lambda - \cos 2\lambda } } - 1\]

+

\[ H_{D2} = \lambda { {\sinh 2\lambda - \sin 2\lambda} \over { \cosh 2\lambda - \cos 2\lambda } } \]

+

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

\[ u \left( \xi, \tau \right) = \frac{1}{2} + \frac{2}{\pi} \int_0^\infty { { \exp \left( -\xi H_1 \left( \lambda, \nu \right) \right) \sin \left( \frac{2}{3} \tau \lambda^2 - \xi H_2 \left( \lambda, \nu \right) \right) } \over \lambda } d\lambda \]

+
+

Defining the Rosen Solution

+

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 Harmonic Functions

+

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.

+

\[ { {\sinh 2\lambda + \sin 2\lambda } \over {\cosh 2\lambda - \cos 2\lambda} } = { { 1 - \exp(-2\lambda) \left( \exp(-2\lambda) + 2 \sin 2\lambda \right) } \over {1 + \exp(-2\lambda) \left( \exp(-2\lambda) - 2 \cos 2\lambda \right) } }\]

+
+
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
+end
+
+
+
+

Integration by Gauss-Kronold

+

The integrand can be divided into a decay component, f, that is independent of τ, and an oscillatory component, K, that is a function of τ.

+

\[ f \left( \lambda; \xi, \nu \right) = { { \exp \left( -\xi H_1 \left( \lambda, \nu \right) \right) } \over \lambda }\]

+

\[ \mathscr{K} \left( \lambda, \tau; \xi, \nu \right) = \sin \left( \frac{2}{3} \tau \lambda^2 - \xi H_2 \left( \lambda, \nu \right) \right) \]

+

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 \(\mathscr{K}\), 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)
+end
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 6: The integrand of the Rosen solution, for moderate values of τ this is a highly oscillating integral. +
+
+
+
+

By introducing the change of variables25 β = λ2, the integrand is “compressed” along β and we can take advantage of the exponential decay to truncate the integration.

+
+
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)
+end
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 7: The integrand of the Rosen solution, after the change of variables. The curve decays much more rapidly. +
+
+
+
+

QuadGK.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
+
+
+
+
+

Integration by Levin Colocation

+

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

+

\[ \int_a^b \mathbf{f}(x) \cdot \mathbf{K}(x) dx \]

+

if we suppose there is a function \(\mathbf{F}\) such that

+

\[ \frac{d}{dx} \mathbf{F}(x) \cdot \mathbf{K}(x) = \mathbf{f}(x) \cdot \mathbf{K}(x) \]

+

Then we can eliminate the integral, using the fundamental theorem of calculus

+

\[ \int_a^b \mathbf{f}(x) \cdot \mathbf{K}(x) dx = \int_a^b \frac{d}{dx} \mathbf{F}(x) \cdot \mathbf{K}(x) dx = \mathbf{F}(b) \cdot \mathbf{K}(b) - \mathbf{F}(a) \cdot \mathbf{K}(a)\]

+

The problem is then one of finding the function \(\mathbf{F}\).

+

If we choose \(\mathbf{K}(x)\) such that \(\frac{d}{dx} \mathbf{K}(x) = \mathbf{A} \mathbf{K}(x)\), then

+

\[ \frac{d}{dx} \left( \mathbf{F}(x) \cdot \mathbf{K}(x) \right) = \mathbf{F}^{\prime}(x) \cdot \mathbf{K}(x) + \mathbf{F}(x) \cdot \mathbf{A} \mathbf{K}(x) \]

+

\[ \mathbf{F}^{\prime}(x) \cdot \mathbf{K}(x) + \mathbf{F}(x) \cdot \mathbf{A} \mathbf{K}(x) = \mathbf{f}(x) \cdot \mathbf{K}(x) \]

+

Eliminating K(x) gives

+

\[ \mathbf{F}^{\prime}(x) + \mathbf{A}^{T} \mathbf{F}(x) = \mathbf{f}(x) \]

+

Solving this ode then gives the final solution.

+

In this case I set K(x) to

+

\[ \mathbf{K}(x) = \begin{pmatrix} \sin(h(x) \\ \cos(h(x)) \end{pmatrix} \]

+

where h(x) = (2/3)τx2 - ξH2(λ), and f(x) is

+

\[ \mathbf{f}(x) = \begin{pmatrix} f(x) \\ 0 \end{pmatrix} \]

+

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)
+end
+
+
+
levin(ξ, τ, ν, 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.

+
+
+

Pre-calculating the spatial component

+

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.

+

\[ \int_a^b f \left(x; p\right) \mathcal{K} \left(x, t; p\right) dx \approx \sum_i^{N} w_i \mathcal{K}\left(x_i, t\right)\]

+

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 \(\beta \in [0,2]\) 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, τ; ξ=ξ, ν=ν) )/2
+
+
0.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...) )
+end
+
+

One 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.

+
+

Packaging a final approach

+

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)
+end
+
+

The 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
+end
+
+
+
rosen = RosenSolution(z, pb; a=0.0, b=2.0, numpts=200);
+
+
+
+

Approximations to the Rosen Integral

+

Rosen27 provides an asymptotic approximation for cases where ξ is large

+

\[ u = \frac{1}{2} \left[ 1 + \mathrm{erf} \left( { { \frac{\tau}{\xi} - 1} \over { 2 \sqrt{ {1 + 5\nu} \over {5 \xi} } } } \right) \right] \]

+

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
+end
+
+
+
+

Approximating the Rosen integral with the Anzelius J Function

+

I 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

+

\[ \frac{1}{h_{eff} } = \frac{1}{(1-\varepsilon) h} + \frac{b}{5 K \mathscr{D}_s} \]

+

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.𝒟ₛ) )
+
+
+
+

Reviewing Overall Performance

+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 8: The Rosen solution, and it’s approximations, compared with the Anzelius solution. Note the approximations are essentially exact for this problem. +
+
+
+
+
+
+
+

Rasmuson’s Integral Solution

+

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 \(\frac{\partial^2 c}{\partial z^2}\) 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.

+

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)))
+end
+
+

Rasmuson and Neretnieks parameterize things slightly differently, and add some extra dimensionless groups due to the \(\mathscr{D}_L\), 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 = σ*t
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 9: The integrand of the Rasmuson-Neretnieks solution, for moderate values of y this becomes a highly oscillating integral. +
+
+
+
+

Thus 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)
+end
+
+
+
rasmuson = 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
+end
+
+

Going 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).

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 10: The Rasmuson-Neretnieks solution for packed bed extraction, compared with the Rosen and Anzelius cases. +
+
+
+
+
+
+

Integrating the PDE by finite difference

+

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

+

\[ \xi = \frac{z}{L} \]

+

\[ \vartheta = \frac{r}{b} \]

+

\[ \tau = \frac{t}{t_{shot} } \]

+

\[ u = \frac{c}{c_{sat} }\]

+

\[ y = \frac{q}{q_{0} } \]

+

we can write the pde for the liquid phase concentration as

+

\[ { {\partial u} \over {\partial \tau} } = { { t_{shot} \mathscr{D}_L } \over L^2 } { {\partial^2 u} \over {\partial \xi^2} } - { {t_{shot} v} \over L }{ {\partial u} \over {\partial \xi} } - { {1 - \varepsilon} \over \varepsilon } h a_s t_{shot} \left( u - y \right)\]

+

and the pde for the solid phase concentration as

+

\[ { {\partial y} \over {\partial \tau} } = { {t_{shot} \mathscr{D}_s} \over b^2 } \left( { {\partial^2 y} \over {\partial \vartheta^2} } + \frac{2}{\vartheta} { {\partial y} \over {\partial \vartheta} } \right) \]

+

with

+

\[ \left. { {\partial y} \over {\partial \tau} } \right\vert_{\vartheta=1} = { {h a_s t_{shot} } \over K} \left( u - y \right) \]

+

These equations can be discretized in both spatial dimensions ξ and \(\vartheta\), turning them into an ode in τ.

+
+
+
+ +
+
+Figure 11: A discretized mass transfer system, the column is divided into n thin slices and each slice is further subdivided into m+1 cells. +
+
+
+

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.

+
+

The Anzelius Example Case

+

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
+=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/2
+    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/2
+        M[i,i] = -h_dm/m
+        M[i,i+1] = -v_dm/2
+        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/
+    M[n,n] = -v_dm/- 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
+end
+
+

The ode for this system is linear and is simply

+

\[ { {d \mathbf{u} } \over {d \tau} } = \mathbf{M} \mathbf{u} \]

+
+
function rhs!(du,u,M,t)
+    du .= M*u
+end
+
+

Which 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.retcode
+
+
ReturnCode.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
+end
+
+

Below 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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 12: Method of Lines with increasing n, converging slowly to the exact (Anzelius) solution. +
+
+
+
+

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.

+
+
+
+

Conclusion

+

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.

+

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 \(\exp\left(...\right)\) 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.

+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 13: The impact of particle size on espresso extraction. +
+
+
+
+

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.

+
+
+

References

+
+
+Anzelius, A. Über Erwärmung Vermittels Durchströmender Medien.” Zeitschrift für Angewandte Mathematik Und Mechanik. 6, no. 4 (1926): 291–94. https://doi.org/10.1002/zamm.19260060404. +
+
+Bac̆lić, Branislav, Dus̆an Gvozdenac, and Gordan Gragutinović. “Easy Way to Calculate the Anzelius-Schumann j Function.” Thermal Science 1, no. 1 (1997): 109–16. +
+
+Bird, R. Byron, Warren E. Stewart, and Edwin N. Lightfoot. Transport Phenomena. 2nd ed. Hoboken, NJ: John Wiley & Sons, 2007. +
+
+Cameron, Michael I., Dechen Morisco, Daniel Hofstetter, Erol Uman, Justin Wilkinson, Zachary C. Kennedy, Sean A. Fontenot, William T. Lee, Christopher H. Hendon, and Jamie M. Foster. “Systematically Improving Espresso: Insights from Mathematical Modeling and Experiment.” Matter 2, no. 3 (2020): 631–48. https://doi.org/10.1016/j.matt.2019.12.019. +
+
+Carslaw, Horatio S., and John C. Jaeger. Conduction of Heat in Solids. 2nd ed. London: Oxford University Press, 1959. +
+
+Gagné, Jonathan. The Physics of Filter Coffee. Scott Rao, 2020. +
+
+Goldstein, Sydney. “On the Mathematics of Exchange Processes in Fixed Columns i. Mathematical Solutions and Asymptotic Expansions.” Proceedings of the Royal Society of London. Series A. Mathematical and Physical Sciences 219, no. 1137 (1953): 151–71. https://doi.org/10.1098/rspa.1953.0137. +
+
+Green, Don W., ed. Perry’s Chemical Engineers’ Handbook. 8th ed. New York: McGraw Hill, 2008. +
+
+Hottel, Hoyt C., James J. Noble, Adel F. Sarofim, Geoffrey D. Silcox, Phillip C. Wankat, and Kent S. Knaebel. “Heat and Mass Transfer.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+Lassey, Keith R. “On the Computation of Certain Integrals Containing the Modified Bessel Function \(I_0(\xi)\).” Mathematics of Computation 39, no. 160 (1982): 625–37. https://doi.org/10.1090/s0025-5718-1982-0669654-6. +
+
+LeVan, M. Douglas, and Giorgio Carta. “Adsorption and Ion Exchange.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+Moroney, K. M., W. T. Lee, S. B. G. O׳Brien, F. Suijver, and J. Marra. “Modelling of Coffee Extraction During Brewing Using Multiscale Methods: An Experimentally Validated Model.” Chemical Engineering Science 137 (2015): 216–34. https://doi.org/10.1016/j.ces.2015.06.003. +
+
+Moroney, Ken AND Meikle-Janney, Kevin M. AND O’Connell. “Analysing Extraction Uniformity from Porous Coffee Beds Using Mathematical Modelling and Computational Fluid Dynamics Approaches.” PLOS ONE 14, no. 7 (July 2019): 1–24. https://doi.org/10.1371/journal.pone.0219906. +
+
+Rasmuson, Anders, and Ivars Neretnieks. “Exact Solution of a Model for Diffusion in Particles and Longitudinal Dispersion in Packed Beds.” AIChE Journal 26, no. 4 (1980): 686–90. https://doi.org/10.1002/aic.690260425. +
+
+Rice, R. G. “Letters to the Editor.” AIChE Journal 26, no. 2 (1980): 334. https://doi.org/10.1002/aic.690260241. +
+
+Rosen, J. B. “General Numerical Solution for Solid Diffusion in Fixed Beds.” Industrial & Engineering Chemistry 46, no. 8 (1954): 1590–94. https://doi.org/10.1021/ie50536a026. +
+
+———. “Kinetics of a Fixed Bed System for Solid Diffusion into Spherical Particles.” The Journal of Chemical Physics 20, no. 3 (1952): 387–94. https://doi.org/10.1063/1.1700431. +
+
+Rousseau, Ronald W., ed. Handbook of Seperation Process Technology. Hoboken, NJ: John Wiley & Sons, 1987. +
+
+Schumann, T. E. W. “Heat Transfer: A Liquid Flowing Through a Porous Prism.” Journal of the Franklin Institute 208, no. 3 (1929): 405–16. https://doi.org/10.1016/S0016-0032(29)91186-8. +
+
+Schwartzberg, Henry G. “Leaching – Organic Materials.” In Handbook of Seperation Process Technology, edited by Ronald W. Rousseau. Hoboken, NJ: John Wiley & Sons, 1987. +
+
+Seader, J. D., Ernest J. Henley, and D. Keith Roper. Separation Process Principles. 3rd ed. Hoboken, NJ: John Wiley & Sons, 2011. +
+
+Thomas, Henry C. “CHROMATOGRAPHY: A PROBLEM IN KINETICS.” Annals of the New York Academy of Sciences 49, no. 2 (1948): 161–82. https://doi.org/10.1111/j.1749-6632.1948.tb35248.x. +
+
+Vaca Guerra, Mauricio, Yogesh M. Harshe, Lennart Fries, James Payan Lozada, Aitor Atxutegi, Stefan Palzer, and Stefan Heinrich. “Modeling the Extraction of Espresso Components as Dispersed Flow Through a Packed Bed.” Journal of Food Engineering 368 (2024): 111913. https://doi.org/10.1016/j.jfoodeng.2023.111913. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/engineering_a_cup_of_coffee_part-2/index_files/figure-html/baae4980-968b-4df0-95cd-88465c0458a3-1-3c1c4afd-11ac-46a2-a599-1b1d484a4ed3.svg b/posts/engineering_a_cup_of_coffee_part-2/index_files/figure-html/baae4980-968b-4df0-95cd-88465c0458a3-1-3c1c4afd-11ac-46a2-a599-1b1d484a4ed3.svg new file mode 100644 index 0000000..99e7ec0 --- /dev/null +++ b/posts/engineering_a_cup_of_coffee_part-2/index_files/figure-html/baae4980-968b-4df0-95cd-88465c0458a3-1-3c1c4afd-11ac-46a2-a599-1b1d484a4ed3.svg @@ -0,0 +1,3 @@ + + +
z
z
r
r
L
L
espresso bed
espresso b...
 R 
 R 
\ No newline at end of file diff --git a/posts/engineering_a_cup_of_coffee_part-2/index_files/figure-html/db04e7de-7a09-4d48-bb02-9b929b74f154-1-764dcc2c-6f44-471e-9dc8-f6555994f969.svg b/posts/engineering_a_cup_of_coffee_part-2/index_files/figure-html/db04e7de-7a09-4d48-bb02-9b929b74f154-1-764dcc2c-6f44-471e-9dc8-f6555994f969.svg new file mode 100644 index 0000000..aa5ca01 --- /dev/null +++ b/posts/engineering_a_cup_of_coffee_part-2/index_files/figure-html/db04e7de-7a09-4d48-bb02-9b929b74f154-1-764dcc2c-6f44-471e-9dc8-f6555994f969.svg @@ -0,0 +1,3 @@ + + +
Liquid Phase
Liquid Phase
Solid Phase
Solid Phase
Js
Js
Q
Q
Q
Q
Δz
Δz
Jz
Jz
Jz+Δz
Jz+Δz
\ No newline at end of file diff --git a/posts/engineering_a_cup_of_coffee_part-2/index_files/figure-html/f0d83c06-fe44-4c56-a5b8-4ab5d37a037f-1-2d14b379-213c-4904-a551-1674ac2de1b3.svg b/posts/engineering_a_cup_of_coffee_part-2/index_files/figure-html/f0d83c06-fe44-4c56-a5b8-4ab5d37a037f-1-2d14b379-213c-4904-a551-1674ac2de1b3.svg new file mode 100644 index 0000000..dd7b123 --- /dev/null +++ b/posts/engineering_a_cup_of_coffee_part-2/index_files/figure-html/f0d83c06-fe44-4c56-a5b8-4ab5d37a037f-1-2d14b379-213c-4904-a551-1674ac2de1b3.svg @@ -0,0 +1,3 @@ + + +
Liquid Phase
cell i
Liquid Phase...
Solid Phase
cell i,n
Solid Phas...
 Ji 
 Ji 
Qi+1
Qi+1
Qi
Qi
Solid Phase
cell i,1
Solid Phas...
 ...
 ...
\ No newline at end of file diff --git a/posts/federal_election/chris-desort-unsplash.jpg b/posts/federal_election/chris-desort-unsplash.jpg new file mode 100644 index 0000000..0f997a1 Binary files /dev/null and b/posts/federal_election/chris-desort-unsplash.jpg differ diff --git a/posts/federal_election/index.html b/posts/federal_election/index.html new file mode 100644 index 0000000..ff548fd --- /dev/null +++ b/posts/federal_election/index.html @@ -0,0 +1,1002 @@ + + + + + + + + + + + + +The 2021 Canadian Federal Election – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

The 2021 Canadian Federal Election

+
+
+ An analysis of how exceptionally little changed. +
+
+
+
julia
+
elections
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

September 22, 2021

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

On Monday, September 20 2021, Canadians went to the polls and ended up electing a parliament that looked very much like the one we had in August, prior to the election. Very notably so. I’m not much of a political watcher, but I did wonder was this really so notably similar? Or do we just have short memories?

+

This is easy enough to answer.

+
+

Methodology

+

What I would like to do is take a table with the number of seats each party got in each election and calculate the change in seats from one election to the next, then add that up. I can’t simply add it up though, as the total number of seats (usually) remains constant and any party’s gain is another party’s loss: the total would always be zero. Instead I am going to add up the absolute value of the change, which effectively double counts each seat (it is counted when one party loses it and again when another party gains it). Also the total number of seats in the house of commons has not always been 338, to adjust for this I will take the absolute value of the change in percentage of seats. So, for example, if party A enters an election with 20% of the seats and leaves with 20% of the seats then this counts as no change, though if they entered with 20 seats and left with 20 seats but the overall number of seats had increased, then that counts as a change.

+

I can calculate this for each election and see how much of an outlier 2021 was.

+
+
using CSV, DataFrames, Statistics, Pipe, Plots
+
+
+
# takes a dataframe of the form
+# | YEAR | party1 | party 2 | ... | party n |
+# |------|--------|---------|-----|---------|
+# | 1    |  100   |  50     | ... |   0     |
+# |  :   |    :   |    :    |  :  |   :     |
+# |  m   |   30   |   160   | ... |   1     |
+# and returns a length m vector with the relative change for each year
+function seat_change(df)
+    
+    # the first election seat change is undefined
+    changes = [NaN]
+    
+    for i in 2:nrow(df)
+        # starting with the second election
+        prev = df[i-1, Not(:YEAR)]
+        prev_total = sum(prev)
+        
+        curr = df[i, Not(:YEAR)]
+        curr_total = sum(curr)
+        
+        Δseats = 0
+        
+        # for each party, calculate the absolute difference
+        for j in 1:length(curr)
+            
+            prev_pct = prev[j]/prev_total
+            curr_pct = curr[j]/curr_total
+            Δseats += abs(curr_pct - prev_pct)
+        end
+        
+        # add the change to the list
+        push!(changes, Δseats)
+    end
+    
+    return changes
+end
+
+
+
+

Dataset

+

I pulled the seat count for each federal election since 1867 from wikipedia as a CSV, with a little bit of finessing in the data entry. We have had a lot of political parties in our short time as a country and many of them either never ended up with any seats or only one or two before disappearing from history – I have elected to lump these in with the independents as “Other”. We have also had several parties that merged or changed, for example the CCF ultimately became the NDP and the Reform party became part of the Canadian Alliance, I have chosen to treat those as the same party.

+

Running this through the function I defined earlier gives the relative absolute seat change per election.

+
+
data_file = "data/federal-electon-results.csv"
+
+results = @pipe data_file |>
+    CSV.File( _ ;  header=1 ) |>
+    DataFrame(_) |>
+    hcat(_, seat_change(_)) |>
+    rename(_, "x1" => "Change")
+
+show(first(results, 6), allcols=true)
+
+
+
6×14 DataFrame
+
+ Row  YEAR   Other  Liberal  Conservatives  CCF/NDP  BQ     Progressive  Anti-Confederate  Social Credit  United Farmers  Reform/Canadian Alliance  Liberal Progressive  Unionist Coalition  Change      
+
+      Int64  Int64  Int64    Int64          Int64    Int64  Int64        Int64             Int64          Int64           Int64                     Int64                Int64               Float64     
+
+─────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+   1 │  1867      0       62            100        0      0            0                18              0               0                         0                    0                   0  NaN
+
+   2 │  1872      5       95            100        0      0            0                 0              0               0                         0                    0                   0    0.311111
+
+   3 │  1874     12      129             65        0      0            0                 0              0               0                         0                    0                   0    0.368932
+
+   4 │  1878      9       63            134        0      0            0                 0              0               0                         0                    0                   0    0.669903
+
+   5 │  1882      4       73            134        0      0            0                 0              0               0                         0                    0                   0    0.0802926
+
+   6 │  1887     11       80            124        0      0            0                 0              0               0                         0                    0                   0    0.116654
+
+
+
+
+
+

Results

+

Plotting the results gives us some interesting years to think about, such as 1917 when the government was composed of the Unionist Coalition, a coalition of mostly Conservatives and some Liberals and others, that basically only existed for the war in what was, apparently, one of the most bitter campaigns in Canadian history. For the next election the coalition dissolved back into it’s original parties, hence an enormous change going in and going out of that parliament. There are other large changes, like the 1993 election in which the Conservatives went into the election with 156 seats and left with 2, nearly being wiped out of parliament entirely – the largest change in history according to this metric.

+

There have been periods of low change, the red-line on the plot indicates a change of less than 10%, but none as low as 2021. I do find it interesting that in the late 1800s and the early 1900s we had successive governments with very little change in overall composition but after 1908 things are a lot more variable.

+
+
+
+
+
+ +
+
+Figure 1: Change in seats per Canadian Federal Election, 1867-2021 +
+
+
+
+
+

We can filter out the low-change elections and get a sense of not just the 2021 election, but the neighbourhood of low-change elections.

+
+
lowest = filter(row -> row[:Change] < 0.10, results)
+
+sort!(lowest, [:Change]);
+
+
+
+
+
7×9 DataFrame
+
+ Row  YEAR   Liberal  Conservatives  BQ     CCF/NDP  Social Credit  Liberal Progressive  Other  Change    
+
+      Int64  Int64    Int64          Int64  Int64    Int64          Int64                Int64  Float64   
+
+─────┼─────────────────────────────────────────────────────────────────────────────────────────────────────
+
+   1 │  2021      158            119     34       25              0                    0      2  0.0236686
+
+   2 │  1940      179             39      0        8             10                    3      6  0.0653061
+
+   3 │  1965      131             97      0       21             14                    0      2  0.0754717
+
+   4 │  1908      133             85      0        0              0                    0      3  0.0767539
+
+   5 │  1904      137             75      0        0              0                    0      2  0.0784959
+
+   6 │  1882       73            134      0        0              0                    0      4  0.0802926
+
+   7 │  1891       90            118      0        0              0                    0      7  0.0930233
+
+
+
+

This is an exceptionally low change, the next lowest year (1940) had >2× as many seats change hands. Also, the last time the overall seat change was even close to this low was decades ago, the next previous year with a relative change <10% was 1965 and in that case >3× as many seats changed hands.

+

This result may change, as of right now several ridings are still too-close to call without mail in ballots, but for some of those if they flip it will actually lower the overall change in seats, not increase it. For example Edmonton Center is currently undecided with the Liberal candidate ahead, but if it flips to the incumbent Conservative the overall relative change for this election would go down.

+ + +
+ +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/federal_election/index_files/figure-html/fig-results-output-1.svg b/posts/federal_election/index_files/figure-html/fig-results-output-1.svg new file mode 100644 index 0000000..ae39683 --- /dev/null +++ b/posts/federal_election/index_files/figure-html/fig-results-output-1.svg @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/fugitive-hydrogen/index.html b/posts/fugitive-hydrogen/index.html new file mode 100644 index 0000000..3c37d29 --- /dev/null +++ b/posts/fugitive-hydrogen/index.html @@ -0,0 +1,1348 @@ + + + + + + + + + + + + +Estimating the impact of fugitive emissions – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Estimating the impact of fugitive emissions

+
+
+ Evaluating the zero emissions fuel. +
+
+
+
julia
+
hydrogen
+
compressible flow
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

January 3, 2024

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

As Alberta continues down it’s path to the hydrogen economy, with more industrial facilities transitioning to hydrogen as a fuel, and more producers of hydrogen announcing new plants and expansions, questions around the impact of fugitive hydrogen emissions linger.

+
+

The climate impacts of fugitive hydrogen

+

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”.

+

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.

+
+
+

A first look at estimating leak rates

+

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:

+
    +
  1. Natural gas is entirely methane and the hydrogen fuel gas is pure hydrogen.
  2. +
  3. Methane and hydrogen are ideal gases
  4. +
  5. Fugitive emissions all come from leaks, which are just holes in the pressure envelope
  6. +
  7. The system pressure is high enough that flow is choked
  8. +
+

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:

+

\[ \dot{m} = c_d A_h \sqrt{ \rho_1 P_1 k \left( 2 \over k+1 \right)^{k+1 \over k-1} } \]

+

The ratio of mass flow of hydrogen to that of methane is then:

+

\[ {\dot{m}_{H2} \over \dot{m}_{CH4} } = \sqrt{ {\rho_{H2} \over \rho_{CH4}} {P_{1,H2} \over P_{1,CH4}} { {k_{H2} \left( 2 \over k_{H2}+1 \right)^{k_{H2}+1 \over k_{H2}-1} } \over {k_{CH4} \left( 2 \over k_{CH4}+1 \right)^{k_{CH4}+1 \over k_{CH4}-1} } } }\]

+

Assuming the system pressure, P1, after having switched to hydrogen, is the same as the system pressure when operating natural gas.

+

\[ {\dot{m}_{H2} \over \dot{m}_{CH4} } = \sqrt{ {\rho_{H2} \over \rho_{CH4}} { {k_{H2} \left( 2 \over k_{H2}+1 \right)^{k_{H2}+1 \over k_{H2}-1} } \over {k_{CH4} \left( 2 \over k_{CH4}+1 \right)^{k_{CH4}+1 \over k_{CH4}-1} } } }\]

+

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 \(k_{H2}\) is within 10% of \(k_{CH4}\) then

+

\[ { {k_{H2} \left( 2 \over k_{H2}+1 \right)^{k_{H2}+1 \over k_{H2}-1} } \over {k_{CH4} \left( 2 \over k_{CH4}+1 \right)^{k_{CH4}+1 \over k_{CH4}-1} } } \approx 1 \]

+

and thus

+

\[ {\dot{m}_{H2} \over \dot{m}_{CH4} } = \sqrt{ {\rho_{H2} \over \rho_{CH4}} } = \sqrt{ {MW_{H2} \over MW_{CH4}} } \]

+

putting this in terms of emissions in CO2-e, with \(E_i = GWP_i \cdot \dot{m}_i\)

+

\[ { E_{H2} \over E_{CH4} } = { {GWP}_{H2} \over {GWP}_{CH4} } \sqrt{ MW_{H2} \over MW_{CH4} } \approx \frac{12}{30} \sqrt{ \frac{2}{16} } \approx 0.13 \]

+

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

+
+
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.41
+
+
+
g(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_CH4
+
+
0.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

+

\[ {P_1 \over P_2} \lt \left( 2 \over {k+1} \right)^{ -k \over {k-1} } \]

+

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.

+
+

Leaks as a series of tubes

+

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.

+

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

+

\[ \Delta P = 2 f \frac{L}{D} \rho u^2 \]

+

\[ u = \sqrt{ {\Delta P D} \over {2 \rho f L} } \]

+

The volumetric flow, Q, would be

+

\[ Q = \frac{\pi}{4} u D^2 \]

+

\[ Q = \frac{ \sqrt{2} }{8} \pi \sqrt{ {\Delta P D^5} \over {2 \rho f L} } \]

+

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

+

\[ { Q_{H2} \over Q_{CH4} } = \sqrt{ { \rho_{CH4} \over \rho_{H2} } { f_{CH4} \over f_{H2} } }\]

+

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)

+

\[ { Q_{H2} \over Q_{CH4} } = \sqrt{ { \rho_{CH4} \over \rho_{H2} } } = \sqrt{ MW_{CH4} \over MW_{H2} }\]

+

If we assume laminar flow \(f = \frac{16}{ \mathrm{Re} }\) and

+

\[ { Q_{H2} \over Q_{CH4} } = \sqrt{ { \rho_{CH4} \over \rho_{H2} } { \mathrm{Re}_{H2} \over \mathrm{Re}_{CH4} } } \]

+

For pipe-flow \(\mathrm{Re} = \frac{4}{\pi} { { \rho Q } \over { \mu D } }\), which after substitution gives

+

\[ { Q_{H2} \over Q_{CH4} } = \sqrt{ { Q_{H2} \over Q_{CH4} } { \mu_{CH4} \over \mu_{H2} } } \]

+

and, after squaring both sides and canceling

+

\[ { Q_{H2} \over Q_{CH4} } = { \mu_{CH4} \over \mu_{H2} } \]

+

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/μ_H2
+
+
1.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:

+

\[ { \dot{m}_{H2} \over \dot{m}_{CH4} } = \sqrt{ { \rho_{H2} \over \rho_{CH4} } } = \sqrt{ MW_{H2} \over MW_{CH4} }\]

+

and for laminar flow:

+

\[ { \dot{m}_{H2} \over \dot{m}_{CH4} } = { \rho_{H2} \over \rho_{CH4} } { \mu_{CH4} \over \mu_{H2} } = { MW_{H2} \over MW_{CH4} } { \mu_{CH4} \over \mu_{H2} } \]

+
+
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

+

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

+
+

Molecular flow

+

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.

+
+
molecular_flow_mass_ratio = MW_H2/MW_CH4
+
+
0.12566228261547094
+
+
+
+
+
+

Relative importance of fugitive emissions

+

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

+

\[ E_f = GWP_{H2} \cdot \rho_{H2} \cdot \eta \cdot Q_{H2} \]

+

When hydrogen undergoes combustion it produces water

+

\[ H_2 + \frac{1}{2}O_2 \rightarrow H_2O \]

+

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..

\[ E_c = GWP_{N2O} \cdot EF_{N2O} \cdot HHV_{H2} \cdot (1-\eta) \cdot Q_{H2} \]

+

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.

\[ { E_f \over E_c } = { {GWP_{H2} \cdot \rho_{H2}} \over { GWP_{N2O} \cdot EF_{N2O} \cdot HHV_{H2} } } \cdot {\eta \over {1-\eta}} \]

+
+
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
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 1: The ratio of fugitive emissions to combustion emissions, as a function of leakage rate. +
+
+
+
+

At any appreciable leak percentage the amount of hydrogen lost to fugitive emissions rivals the stack emissions for climate impact.

+
+
+

Fugitive hydrogen and “net zero”

+

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

+

\[ E_f = GWP_{CH4} \cdot \rho_{CH4} \cdot x_{CH4} \cdot \eta \cdot Q_{NG}\]

+

and the combustion emissions now include carbon dioxide and methane along with nitrous oxide

+

\[ E_c = \left( GWP_{CO2} \cdot EF_{CO2} + GWP_{CH4} \cdot EF_{CH4} + GWP_{N2O} \cdot EF_{N2O} \right) \left( 1 - \eta \right) Q_{NG} \]

+

Total emissions are just \(E_T = E_f + E_c\).

+

What we are interested in is the ratio

+

\[ { E_{T,H2} \over E_{T,NG} } \]

+
+

Some more simplifying assumptions

+

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

+

\[ {Q_{leak,H2} \over Q_{leak,NG}} = \sqrt{\rho_{NG} \over \rho_{H2}} \]

+

For fully developed turbulent pipe flow we know the ratio of line flow rates is also

+

\[ { Q_{H2} \over Q_{NG} } = \sqrt{\rho_{NG} \over \rho_{H2}} \]

+

By the definition of η

+

\[ \eta_{H2} = { Q_{leak,H2} \over Q_{H2} } = { Q_{leak,H2} \over Q_{leak,CH4} } { Q_{CH4} \over Q_{H2} } { Q_{leak,CH4} \over Q_{CH4} } \]

+

\[ \eta_{H2} = \sqrt{\rho_{NG} \over \rho_{H2}} \sqrt{\rho_{H2} \over \rho_{NG}} \eta_{CH4} \]

+

\[ \eta_{H2} = \eta_{CH4} \]

+
+
+

Relative emissions of switching to hydrogen

+

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.

\[ EF_f = {E_f \over Q_T} \]

+

and the combustion emission factor

+

\[ EF_c = {E_c \over Q_T} \]

+

Finally we can answer the question of “how much do the total emissions go down after switching to hydrogen?”

+

\[ { E_{T,H2} \over E_{T,NG} } = { \left[ EF_{c} ( 1 - \eta ) + EF_{f} \eta \right]_{H2} \over \left[ EF_{c} ( 1 - \eta ) + EF_{f} \eta \right]_{NG} } { Q_{H2} \over Q_{NG} }\]

+

\[ { E_{T,H2} \over E_{T,NG} } = { \left[ EF_{c} ( 1 - \eta ) + EF_{f} \eta \right]_{H2} \over \left[ EF_{c} ( 1 - \eta ) + EF_{f} \eta \right]_{NG} } \sqrt{ \rho_{NG} \over \rho_{H2} }\]

+

\[ { E_{T,H2} \over E_{T,NG} } = { \left[ EF_{c} ( 1 - \eta ) + EF_{f} \eta \right]_{H2} \over \left[ EF_{c} ( 1 - \eta ) + EF_{f} \eta \right]_{NG} } \sqrt{ {SG}_{NG} \over {SG}_{H2} }\]

+
+
# 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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+Figure 2: The total emissions, in CO2-e, of hydrogen relative to natural gas. +
+
+
+
+
+
+
+

Final thoughts

+

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.

+
+
+

References

+
+
+Alberta Greenhouse Gas Quantification Methodologies (version 2.3). Edmonton, AB: Alberta Environment; Protected Areas, 2023. https://open.alberta.ca/publications/alberta-greenhouse-gas-quantification-methodologies. +
+
+Bertagni, Matteo B., Stephen W. Pacala, Fabien Paulot, and Amilcare Porporato. “Risk of the Hydrogen Economy for Atmospheric Methane.” Nature Communications 13 (2023). https://doi.org/10.1038/s41467-022-35419-7. +
+
+Colorado, Andrés, Vincent McDonell, and Scott Samuelsen. “Direct Emissions of Nitrous Oxide from Combustion of Gaseous Fuels.” International Journal of Hydrogen Energy 42, no. 1 (2017): 711–19. https://doi.org/10.1016/j.ijhydene.2016.09.202. +
+
+Crane. “TP410M: Flow of Fluids.” Stamford, CT: Crane, 2013. +
+
+Dutta, Indranil, Rajesh Kumar Parsapur, Sudipta Chatterjee, Amol M. Hengne, Davin Tan, Karthik Peramaiah, Theis I. Solling, Ole John Nielsen, and Kuo-Wei Huang. “The Role of Fugitive Hydrogen Emissions in Selecting Hydrogen Carriers.” ACS Energy Letters 8, no. 7 (2023): 3251–57. https://doi.org/10.1021/acsenergylett.3c01098. +
+
+Emission Factors and Reference Values (version 1.1). Gatineau, QC: Environment; Climate Change Canada, 2023. https://publications.gc.ca/collections/collection_2023/eccc/En84-294-2023-eng.pdf. +
+
+Forster, Piers, Trude Storelvmo, Kyle Armour, William Collins, Jean-Louis Dufresne, David Frame, Daniel J. Lunt, et al. “The Earth’s Energy Budget, Climate Feedbacks, and Climate Sensitivity.” In Climate Change 2021: The Physical Science Basis. Contribution of Working Group i to the Sixth Assessment Report of the Intergovernmental Panel on Climate Change, edited by Valérie Masson-Delmonte, Panmao Zhai, Anna Pirani, Sarah L. Connors, Clotilde Péan, Yang Chen, Leah Goldfarb, et al., 923–1054. Cambridge: Cambridge University Press, 2023. +
+
+Frazer-Nash Consultancy. “Fugitive Hydrogen Emissions in a Future Hydrogen Economy.” London, UK: UK Department for Business, Energy,; Industrial Strategy, 2022. https://www.gov.uk/government/publications/fugitive-hydrogen-emissions-in-a-future-hydrogen-economy/. +
+
+GPSA. Engineering Data Book. 13th ed. Tulsa, OK: Gas Processors Suppliers Association, 2012. +
+
+Masson-Delmonte, Valérie, Panmao Zhai, Anna Pirani, Sarah L. Connors, Clotilde Péan, Yang Chen, Leah Goldfarb, et al., eds. Climate Change 2021: The Physical Science Basis. Contribution of Working Group i to the Sixth Assessment Report of the Intergovernmental Panel on Climate Change. Cambridge: Cambridge University Press, 2023. +
+
+Mejia, Alejandra Hormaza, Jacob Brouwer, and Michael Mac Kinnon. “Hydrogen Leaks at the Same Rate as Natural Gas in Typical Low-Pressure Gas Infrastructure.” International Journal of Hydrogen Energy 45, no. 15 (2020): 8810–25. https://doi.org/10.1016/j.ijhydene.2019.12.159. +
+
+Ocko, Ilissa B., and Steven P. Hamburg. “Climate Consequences of Hydrogen Emissions.” Atmospheric Chemistry and Physics 22, no. 14 (2022): 9349–68. https://doi.org/10.5194/acp-22-9349-2022. +
+
+Sand, Maria, Ragnhild Bieltvedt Skeie, Marit Sandstad, Srinath Krishnan, Gunnar Myhre, Hannah Bryant, Richard Derwent, et al. “A Multi-Model Assessment of the Global Warming Potential of Hydrogen.” Communications Earth & Environment 4 (2023): 203. https://doi.org/10.1038/s43247-023-00857-8. +
+
+Schefer, R. W., W. G. Houf, C. San Marchi, W. P. Chernicoff, and L. Englom. “Characterization of Leaks from Compressed Hydrogen Dispensing Systems and Related Components.” International Journal of Hydrogen Energy 31, no. 9 (2006): 1247–60. https://doi.org/10.1016/j.ijhydene.2005.09.003. +
+
+Smith, Chris, Zebedee R. J. Nicholls, Kyle Armour, William Collins, Piers Forster, Malte Meinshausen, Matthew D. Palmer, et al. “The Earth’s Energy Budget, Climate Feedbacks, and Climate Sensitivity Supplementary Material.” In Climate Change 2021: The Physical Science Basis. Contribution of Working Group i to the Sixth Assessment Report of the Intergovernmental Panel on Climate Change, edited by Valérie Masson-Delmonte, Panmao Zhai, Anna Pirani, Sarah L. Connors, Clotilde Péan, Yang Chen, Leah Goldfarb, et al. Cambridge: Cambridge University Press, 2023. +
+
+Swain, M. R., and M. N. Swain. “A Comparison of \(H_2\), \(CH_4\) and \(C_3 H_8\) Fuel Leakage in Residential Settings.” International Journal of Hydrogen Energy 17, no. 10 (1992). https://doi.org/10.1016/0360-3199(92)90025-R. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/fugitive-hydrogen/output_29_0.svg b/posts/fugitive-hydrogen/output_29_0.svg new file mode 100644 index 0000000..d7947ca --- /dev/null +++ b/posts/fugitive-hydrogen/output_29_0.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/gaussian_dispersion_example/Gaussian_Plume.png b/posts/gaussian_dispersion_example/Gaussian_Plume.png new file mode 100644 index 0000000..0d7be35 Binary files /dev/null and b/posts/gaussian_dispersion_example/Gaussian_Plume.png differ diff --git a/posts/gaussian_dispersion_example/Gaussian_Plume.svg b/posts/gaussian_dispersion_example/Gaussian_Plume.svg new file mode 100644 index 0000000..ad576dd --- /dev/null +++ b/posts/gaussian_dispersion_example/Gaussian_Plume.svg @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + Pollutantconcentrationprofiles + + + + + + + + + + + Plumecenterline + + + + + + + + + + + + + + H + e + at x + 3 + + + + + + + + + + + + + + H + e + at x + 2 + + + + + + + + + + + + + + H + e + at x + 1 + + + + + + + + + + + + ++y +-y + + Actual stack height + Effective stack height + pollutant release height + H + s + + Δh + plume rise + ===== + H + s + H + e + Δh + +z +Wind + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +x + + + + + + + + + + + + + + + + + + + + + + + + + + +H +s + + + + + + + + + \ No newline at end of file diff --git a/posts/gaussian_dispersion_example/index.html b/posts/gaussian_dispersion_example/index.html new file mode 100644 index 0000000..c9e23ab --- /dev/null +++ b/posts/gaussian_dispersion_example/index.html @@ -0,0 +1,1447 @@ + + + + + + + + + + + + +Air Dispersion Example - Gaussian Dispersion Model of Stack Emissions – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Air Dispersion Example - Gaussian Dispersion Model of Stack Emissions

+
+
+ Estimating the airborne quantity. +
+
+
+
julia
+
hazard screening
+
dispersion modelling
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

December 5, 2020

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

This is an interesting example that came up in conversation with another engineer related to a construction project happening at an existing facility. Imagine construction involving scaffolding and workers at an elevation that potentially puts them within the plume of an existing stack – say from an adjacent boiler. If the facility is still operating while this construction work happens then it is possible that workers will be exposed to combustion products in excess of the occupational exposure limits. The operating boiler does not have to be all that close by for the plume – which is very visible this time of year in the cold weather – to envelope a similarly tall set of scaffolding.

+

So, how would one determine whether or not the operating stack presents a hazard to the workers? In practice by hiring a consultant to do detailed modelling, because safety issues like this are not the time to pencil-whip some number. But we may want to come up with a rough estimate regardless, and for that a Gaussian dispersion model of the stack can be a useful first start.

+
+
+
+ +
+
+Figure 1: The problem domain, a stack with a Gaussian plume (Mbeychok, CC BY-SA 3.0, via Wikimedia Commons). +
+
+
+
+

The Scenario

+

Suppose a natural gas boiler with a rated capacity, \(W\), of 300GJ/h, stack height, \(h_s\), of 10m and diameter, \(D_s\) of 2m, and an exit temperature of 450K.

+

In this case we are interested in the carbon monoxide concentrations at a work platform at the same height as the stack and 100m away, we also would like to know the concentration for a worker at ground level. I am approximately 2m tall let’s suppose the relevant height is 2m (for anyone shorter than that the concentration should be lower and thus this is conservative).

+

Additionally I am assuming ambient conditions of 25°C and 1atm

+
+
using Unitful
+
+W = uconvert(u"GJ/s", 300u"GJ/hr")
+hₛ = 10u"m"   # stack height
+Dₛ = 2u"m"    # stack diameter
+Tₛ = 450u"K"  # stack exit temperature
+
+h₁ = hₛ      # height of platform, m
+x₁ = 100u"m" # distance to platform, m
+
+pₐ = 101.325u"kPa" # ambient pressure, 1atm
+Tₐ = 298.15u"K"    # ambient temperature, 25°C
+
+

Prior to any dispersion modelling, the following parameters need to be collected:

+
    +
  • the mass emission rate of the species, carbon monoxide, in kg/s
  • +
  • the concentration of interest, in this case the occupational exposure limit of carbon monoxide in kg/m3
  • +
  • the wind speed and atmospheric stability
  • +
  • the effective stack height, in m
  • +
+
+

Mass Emission Rate

+

The EPA has tabulated emission factors for most combustion products in EPA AP-42 and for a natural gas boiler it is 84 lb/10^6 SCF1 with a reference higher heating value of 1020 MMBTU/10^6 SCF.

+

1 EPA, “AP 42” Table 1.4-1. The emission factor is relative to the volume of natural gas consumed not the volume of stack gas emitted.

If we suppose the boiler is operating at max rates then the mass emission rate is

+

\[ Q = { W \cdot EF \over HV} \]

+

Where EF is the emission factor and HV the higher heating value.

+
+
HV = uconvert(u"GJ/m^3", 1020u"btu/ft^3")
+EF = uconvert(u"kg/m^3", 84*1e-6u"lb/ft^3")
+
+Q = EF * W / HV # mass emission rate in kg/s
+
+
0.002950437713234783 kg s^-1
+
+
+

This gives a mass flow rate of carbon monoxide in the plume, but we will also need some sense of how large the plume is in general, i.e. what is the volumetric flow rate of stack gas exiting the stack?

+
+
+

Volumetric Flow Rate of Flue Gas

+

There are several ways the volumetric flow rate of flue gas could be estimated. One simple method is to use EPA Method 192 with the equation

+

\[ V_s^o = F_w { 20.9 \over 20.9 \left( 1 - B_{wa} \right) - \%O_{2w} } \cdot W\]

+

Where \(V_s^o\) is the volumetric flow of flue gas at standard conditions, \(B_{wa}\) the moisture fraction of ambient air, \(\%O_{2w}\) the percentage of oxygen on a wet basis, and the parameter \(F_w\) captures the differences in combustion stoichiometry for different fuels and is tabulated. Alternatively one could work out the volume of stack gas from the stoichiometry of combustion, this is just a shortcut.

+
    +
  • the default value for \(B_{wa} = 0.027\)
  • +
  • \(\%O_{2w}\) usually ranges from 2-6% and for this case I am assuming \(\%O_{2w} = 4\)
  • +
  • from Method 19 for natural gas, \(F_w = 2.85 \times 10^{-7} \mathrm{sm^3 \over J}\)
  • +
+
+
Fw = 2.85e-7u"m^3/J"
+pct_O2 = 4
+Bwa = 0.027
+
+Vₛᵒ = Fw * (20.9 / (20.9*(1-Bwa) - pct_O2)) * W
+
+Vₛᵒ = upreferred(Vₛᵒ)
+
+
30.385903267077627 m^3 s^-1
+
+
+

The actual volumetric flow rate can be calculated assuming the ideal gas law

+

\[ { p^o V_s^o \over T^o } = { p_a V_s \over T_s } \]

+

\[ V_s = { T_s \over T^o } { p^o \over p_a } V_s^o \]

+

Where the standard conditions of Method 19 are \(T^o = 20 \mathrm{C}\) and \(p^o = 760 \mathrm{mm Hg}\)

+
+
# Unitful doesn't know what "mm Hg" is
+@unit mmHg "mm Hg" MillimetersMercury 133.322387415u"Pa" false 
+
+Tᵒ = uconvert(u"K", 20u"°C")
+pᵒ = uconvert(u"kPa", 760mmHg)
+
+Vₛ = (Tₛ / Tᵒ) * (pᵒ / pₐ) * Vₛᵒ
+
+
46.6438970432218 m^3 s^-1
+
+
+
+
+

The Concentration of Interest

+

This analysis is fundamentally about identifying whether a worker on the work platform would experience flue gases in excess of some concentration of interest. In this case I am supposing the Occupational Exposure Limit (OEL) for carbon monoxide alone because it is simple. In practice, since flue gas is a mixture of many substances that each have an associated OEL, one would have to look at the cumulative impact of all of these substances instead of treating them all individually3

+

3 For example CCOHS recommends calculating the sum \[ \sum_i {C_i \over T_i } \] for each substance i where C is the observed concentration and T is the threshold, and this sum should be less than one.

4 From the NIOSH Handbook, using the conversion 1.15 mg/m^3 per ppm

For carbon monoxide there are three concentrations of interest worth considering4

+
    +
  • the Time Weighted Average (TWA) concentration which represents the limit for workers in that environment for a standard shift and 40 hours per week
  • +
  • the Ceiling concentration which is the level that the concentration cannot exceed
  • +
  • the Immediately Dangerous to Life and Health (IDLH) limit which is a concentration that could either kill a worker outright or render them incapable of saving themselves
  • +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Limitppmmg/m^3
TWA3540
Ceiling200229
IDLH12001380
+
+
TWA = uconvert(u"kg/m^3", 40u"mg/m^3")
+Ceil = uconvert(u"kg/m^3", 229u"mg/m^3")
+IDLH = uconvert(u"kg/m^3", 1380u"mg/m^3");
+
+

We can check if the stack gas concentration at the exit exceeds the TWA. If it does not exceed the TWA then there is no reason to proceed with the calculations as a worker could work in the stack and not exceed the limits and they certainly would not exceed the limits after the plume mixed with ambient air.

+
+
uconvert(u"mg/m^3", Q/Vₛᵒ)
+
+
97.0988977125952 mg m^-3
+
+
+
+
Q/Vₛᵒ > TWA
+
+
true
+
+
+

The concentration in the flue gas is above the limit for long term work exposure but below the ceiling. At this point we are justified in continuing on to estimate the concentration at the work platform.

+
+
+
+

Meteorological Conditions

+

The ambient conditions impact the release in some obvious ways and in some non-obvious ways. Obviously the wind speed impacts how far the plume is moved, through advection. Somewhat non-obviously the ambient conditions also govern how high the plume will rise due to buoyancy as well as the extent of mixing as the plume moves through the air.

+

Suppose a wind speed of 1.5m/s at the stack height, just arbitrarily.

+
+
uₛ = 1.5u"m/s"
+
+
1.5 m s^-1
+
+
+
+

Atmospheric Stability

+

The atmospheric stability relates to the vertical mixing of the air due to a temperature gradient, during the day air temperature decreases with elevation and this temperature gradient induces a vertical flow that leads to vertical mixing.

+
+
+
+ +
+
+Figure 2: The effect of atmospheric stability on plume dispersion. +
+
+
+

This is captured by the atmospheric stability parameter \(s\) which is given by5

+

\[ s = \frac{g}{T_a} { \partial \theta \over \partial z } \]

+

Where \(\partial \theta \over \partial z\) is the lapse rate in K/m

+

The “worst case” is the case with the least mixing and corresponds to a class F Pasquill stability, i.e. very stable, which has a corresponding default lapse rate of \({ \partial \theta \over \partial z } = 0.035 K/m\).6

+
+
+
+ +
+
+WarningAddendum +
+
+
+

This isn’t entirely true. For neutrally buoyant plumes released at ground level, or in this case level with the elevated work platform, class F is likely the worst case. For buoyant plumes released at elevation the minimal vertical dispersion with stable atmospheres means the bulk of the plume will rise and be dispersed far above the ground and another class and wind speed should be considered. See Guidelines for Use of Vapour Cloud Dispersion Models, 2nd ed. section 5.8 for more details

+
+
+
+
# acceleration due to gravity
+g = 9.80616u"m/s^2"
+
+# default lapse rate for class F
+Γ = 0.035u"K/m"
+
+# stability parameter
+s = (g/Tₐ) * Γ
+
+
0.0011511507630387393 s^-2
+
+
+
+
+

Effective Stack Height

+

The plume rising out of the stack will rise higher than the stack height due to buoyancy – in this case because the stack gas is at a higher temperature than the ambient air – and because the stack gas is ejected with some kinetic energy. What follows is essentially a simplified version of the Brigg’s model for plume rise for stable plumes.

+

As a first check, verify that stack down wash will not be relevant. For low momentum releases the effective stack height of the plume is reduced by vortices shed downwind of the stack that pull the plume downwards. This is only really relevant when \(v_s \lt 1.5 u\)

+

Where \(v_s\) is the stack exit velocity and is calculated from the volumetric flow as

+

\[ v_s = { V_s \over A_s} = { V_s \over \frac{\pi}{4} D^2 } \]

+
+
vₛ = Vₛ / ((π/4)*Dₛ^2)
+
+vₛ > 1.5uₛ
+
+
true
+
+
+

The following assumes a stable plume rise, recall that Pasquill stability class F corresponds to very stable conditions.

+

The first question that must be answered is whether or not the plume rise is dominated by buoyancy or by momentum. For buoyant plume rise to dominate the actual temperature difference – the difference between the stack exit temperature and the ambient temperature – must be greater than a critical temperature difference7

+

\[ T_s - T_a = \Delta T \gt \left( \Delta T \right)_c = 0.019582 T_s v_s \sqrt{s} \]

+
+
ΔTc = 0.019582u"m^-1*s^2" * Tₛ * vₛ * (s)
+
+(Tₛ - Tₐ) > ΔTc
+
+
true
+
+
+

In this case buoyant plume rise is dominant, and the stable plume rise equation is8

+

\[ \Delta h = 2.6 \left( F_b \over u_s s \right)^{1/3} \]

+

where \(\Delta h\) is the increase in effective stack height due to plume rise, and \(F_b\) is the buoyancy flux parameter9

+

\[ F_b = g v_s D_s^2 { \left( T_s - T_a \right) \over 4 T_s } \]

+

Plume rise is not instantaneous and the distance to the final rise, \(x_f\) is given by10

+

\[ x_f = 2.0715 {u_s \over \sqrt{s} } \]

+

with any distance closer to the source than \(x_f\) experiencing a lesser plume rise, given by11

+

\[ \Delta h = 1.60 \left( F_b x^2 \over u_s^3 \right)^{1/3} \]

+

this can be put together into a function that calculates \(\Delta h\) as a function of distance x

+
+
Fb = g * vₛ * Dₛ^2 * (Tₛ - Tₐ) / (4Tₛ)
+
+
49.1299376393856 m^4 s^-3
+
+
+
+
xf = 2.0715*uₛ/(s)
+
+
91.58199372993636 m
+
+
+
+
function Δh(x)
+    xf = 2.0715*uₛ/(s)
+    
+    if x < xf
+        return 1.60*(Fb*x^2/uₛ^3)^(1/3)
+    else
+        return 2.6*(Fb/(uₛ*s))^(1/3)
+    end
+    
+end;
+
+
+
+
+
+ +
+
+Figure 3: Plume rise as a function of downwind distance. +
+
+
+
+

Plume rise is impacted by the wind speed at the stack height, as the following plot shows, but with several large caveats. For one the model for plume rise given is not defined at no wind speed and for very low wind speeds the value should be treated with suspicion. Similarly for very large wind speeds the assumption of stable rise is likely quite invalid.

+
+
+
+
+ +
+
+Figure 4: Plume rise as a function of windspeed. +
+
+
+
+
+
+
+

Gaussian Dispersion Model

+

As the plume is carried downwind it will mix with the ambient air and the pollutant, carbon monoxide, will be dispersed. A simple model of this is a Gaussian dispersion model, the derivation for which is sketched out as follows.

+
+

A Differential Mass Balance

+

Starting with a coordinate system centred at the top of the stack, emitting a mass flow of Q kg/s, which is assumed to be released from a point, the advection-diffusion equation for mass can be written as

+

\[ {\partial C \over \partial t} = - \nabla \cdot \mathbf{D} \cdot \nabla C + \nabla \cdot \mathbf{u} C \]

+

Where \(\mathbf{D}\) is the diffusivity, \(C\) the concentration of the species, and \(\mathbf{u}\) the wind speed. The diffusivity in this case is a vector and depends upon the direction, i.e. \(D_x \ne D_y \ne D_z\) and represents an eddy diffusion as opposed to a simple Fickian diffusion.

+

Some simplifying assumptions can be made

+
    +
  • the wind speed u is a constant everywhere
  • +
  • the air is moving entirely in the x direction, i.e. \(u_{y} = u_{z} = 0\) and \(u_x = u\) and thus \(\nabla \cdot \mathbf{u} C = u {\partial C \over \partial x}\)
  • +
  • the diffusivities \(D_x\), \(D_y\), and \(D_z\) are constant everywhere
  • +
  • advection is much more significant than diffusion in the x direction i.e. \(\left \vert {\partial \over \partial x} C u \right \vert \gg \left \vert {\partial^{2} \over \partial x^{2} } D_{x} C \right \vert\), leading to \(\nabla \cdot \mathbf{D} \cdot \nabla C = D_y {\partial^2 C \over \partial y^2} + D_z {\partial^2 C \over \partial z^2}\)
  • +
  • the system is at steady state, \({\partial C \over \partial t} = 0\)
  • +
+

Reducing the PDE to

+

\[ u {\partial C \over \partial x} = D_{y} {\partial^{2} C \over \partial y^{2} } + D_{z} {\partial^{2} C \over \partial z^{2} } \]

+

Which has solutions for particular boundary conditions

+

\[ C = {k \over x} \exp \left[ - \left( {y^{2} \over D_{y} } + {z^{2} \over D_{z} } \right) { u \over 4x } \right] \]

+

Where k is a constant set by the boundary conditions.

+
+
+

Boundary Conditions

+

To solve for k note that Q is assumed to be constant and that mass must be conserved as it is carried downwind which has the effect that for any given x the flux through the y-z plane is Q.

+

\[ Q = \int \int C u dy dz \]

+

\[ Q = \int_{0}^{\infty} \int_{-\infty}^{\infty} {k u \over x} \exp \left[ - \left( {y^{2} \over D_{y} } + {z^{2} \over D_{z} } \right) { u \over 4x } \right] dy dz \]

+

where the release point is assumed to be at ground level (z=0).

+

Making the change of variables \(y' = {y \over \sqrt{D_{y} } }\) and \(z' = {z \over \sqrt{D_{z} } }\) gives

+

\[ Q = {k u \over x} \sqrt{D_{y} D_{z} } \int_{-\infty}^{\infty} \exp \left[ - {u \over 4 x} y'^{2} \right] dy' \int_{0}^{\infty} \exp \left[ - {u \over 4 x} z'^{2} \right] dz' \]

+

which are gaussian integrals that can be integrated

+

\[ Q = {k u \over x} \sqrt{D_{y} D_{z} } \left( \sqrt{\pi} \over \sqrt{u \over 4x} \right) \left( \sqrt{\pi} \over 2 \sqrt{u \over 4x} \right) \]

+

simplifying

+

\[ Q = 2 \pi k \sqrt{D_{y} D_{z} } \]

+

and solving for k

+

\[ k = {Q \over 2 \pi \sqrt{D_{y} D_{z} } }\]

+
+
+

Gaussian Model

+

Substituting k back into the model gives the gaussian dispersion model.

+

\[ C = {Q \over 2 \pi x \sqrt{D_{y} D_{z} } } \exp \left[ - \left( {y^{2} \over D_{y} } + {z^{2} \over D_{z} } \right) { u \over 4x } \right] \]

+

However this is more commonly expressed in terms of dispersion by letting

+

\[ \sigma_{y}^{2} = {2 D_{y} x \over u}\]

+

\[ \sigma_{z}^{2} = {2 D_{z} x \over u}\]

+

which gives a more explicitly gaussian distribution of concentration at a given point x

+

\[ C = {Q \over \pi u \sigma_{y} \sigma_{z} } \exp \left[ -\frac{1}{2} \left( {y^{2} \over \sigma_{y}^{2} } + {z^{2} \over \sigma_{z}^{2} } \right) \right] \]

+

Note the parameters \(\sigma_y\) and \(\sigma_z\) have units of length.

+
+
+

Ground Reflection

+

When solving for k, I assumed the release point was at ground level, this simplified the integration by making one of the bounds of the integral zero.

+

However what we want is a generalized equation with the emissions released at some elevation h. The plume can disperse downwards but only to a distance h below the release point, at which point the mass can neither disperse further downwards (pass through the ground) nor does it just disappear. This is ground reflection.

+
+
+
+ +
+
+Figure 5: A sketch of ground reflection by method of images. +
+
+
+

One way to capture this is to integrate z from \(-\infty\) to \(\infty\) (recall that the release point is at the origin) and introduce a mirror image of the stack shifted 2h below. The ground being the x-y plane at z = -h. By symmetry the portion of the mirror plume extending up above this plane is the same as the portion of the plume that, in this simple model, has extended below the ground. By adding the stack and the mirror stack together and shifting the z-coordinate so z = 0 is the ground, ground reflection is captured and the expression for a release point at elevation h is given by

+

\[ C = {Q \over 2 \pi u \sigma_{y} \sigma_{z} } \exp \left[ -\frac{1}{2} \left( y \over \sigma_{y} \right)^2 \right] \]

+

\[ \times \left\{ \exp \left[ -\frac{1}{2} \left( { z -h } \over \sigma_{z} \right)^2 \right] + \exp \left[ -\frac{1}{2} \left( { z + h } \over \sigma_{z} \right)^2 \right] \right\} \]

+
+
+
+

Pasquill-Gifford Model

+

The \(\sigma_{y}\) and \(\sigma_{z}\) are functions of the downwind distance x. In the derivation of the model they were assumed to be linear in x however in practice they are typically of the form:

+

\[ \sigma_{y} = a x^{b} \]

+

\[ \sigma_{z} = c x^{d} \]

+

With the constants tabulated based on the Pasquill stability class criteria.

+

These particular correlations come from Lees12 and are for a Pasquill stability class F

+

12 Lees, Loss Prevention in the Process Industries, 15/113. There is a typo in the 4th edition of Lees’ for the \(\sigma_{z}\) corresponding to class F stability. For \(x>500\) it is given as \[ \sigma_{z} = 10^{(1.91 - 1.37 \log(x) - 0.119 \log(x)^2)} \] when it should be (note the signs) \[ \sigma_{z} = 10^{(-1.91 + 1.37 \log(x) - 0.119 \log(x)^2)} \]. I happen to have the paper version of the 2nd edition at home, which does not have the typo, whereas the standard version I use at work is the 4th edition on Knovel.

+
σy(x) = 0.067*x^0.90
+
+function σz(x)
+    if x < 500.0
+        return 0.057*x^0.80
+    else
+        # Note: Lee's gives the commented out form but it is wrong
+        # 10^(1.91 - 1.37*log10(x) - 0.119*log10(x)^2)
+        return 10^(-1.91 + 1.37*log10(x) - 0.119*log10(x)^2)
+    end
+end;
+
+

These correlations are currently not unit-aware, so we can add that using a macro

+
+
# this macro adds a method to handle units
+macro correl(f::Symbol, in_unit::Expr, out_unit::Expr)
+    quote
+        function $(esc(f))(x::Quantity)::Quantity
+            x = ustrip($in_unit, x)
+            res = $f(x)
+            return res*$out_unit
+        end
+    end
+end
+
+@correl σy u"m" u"m"
+@correl σz u"m" u"m"
+
+
σz (generic function with 2 methods)
+
+
+
+
+
+
+ +
+
+Figure 6: Pasquill-Gifford dispersion parameters as a function of downwind distance, for class F atmospheric stability. +
+
+
+
+
+

Effect of Plume Rise

+

The effect of plume rise on this model is to shift from the actual stack height to an effective stack height \(h_e = h_s + \Delta h\) with \(\Delta h\) given by the plume rise model already discussed. Additionally the dispersion is adjusted by the following13

+

\[ \sigma_{ze}^2 = \left( \Delta h \over 3.5 \right)^2 + \sigma_z^2 \]

+

\[ \sigma_{ye}^2 = \left( \Delta h \over 3.5 \right)^2 + \sigma_y^2 \]

+

and the final model of concentration is given in respect to the effective stack height

+

\[ C = {Q \over 2 \pi u \sigma_{ye} \sigma_{ze} } \exp \left[ -\frac{1}{2} \left( y \over \sigma_{ye} \right)^2 \right] \]

+

\[ \times \left\{ \exp \left[ -\frac{1}{2} \left( { z -h_e } \over \sigma_{ze} \right)^2 \right] + \exp \left[ -\frac{1}{2} \left( { z + h_e } \over \sigma_{ze} \right)^2 \right] \right\} \]

+
+
function C(x, y, z)
+    hₑ  = hₛ + Δh(x)
+    σyₑ = ( (Δh(x)/3.5)^2 + σy(x)^2 )
+    σzₑ = ( (Δh(x)/3.5)^2 + σz(x)^2 )
+    
+    C = (Q/(2*π*uₛ*σyₑ*σzₑ)) *
+         exp(-0.5*(y/σyₑ)^2) *
+         ( exp(-0.5*((z-hₑ)/σzₑ)^2) + exp(-0.5*((z+hₑ)/σzₑ)^2) )
+    
+end
+
+
+
+
+

Modelling Dispersion

+

There are two cases worth considering

+
    +
  1. without accounting for plume rise
  2. +
  3. with plume rise
  4. +
+

The first case would be very conservative and the stack plume would immediately point directly downwind, at the stack height, this is far more likely to impact the work platform and any workers on the ground, though it is also quite unrealistic.

+

Note the following contour plots max out at the time weighted average concentration, shown in mg/m^3

+
+
+
+
+ +
+
+Figure 7: Contour plots of concentration with no plume rise. +
+
+
+
+

This clearly represents something of an extreme case, and I believe illustrates something of interest. While the work platform is ultimately below the TWA, to get even close to that concentration at the work platform the model is assuming extremely little mixing and no plume rise.

+

A more realistic model would take into account the buoyant rise of hot stack gases.

+
+
+
+
+ +
+
+Figure 8: Contour plots of concentration with using Briggs’ plume rise equations. +
+
+
+
+

In this model the plume clearly rises significantly and, as it goes, mixes into the air column to such an extent that there is hardly any carbon monoxide at the elevations of interest downwind of the stack.

+
+
uconvert(u"mg/m^3",C(x₁, 0u"m", h₁))
+
+
0.0014282911474771348 mg m^-3
+
+
+
+
C(x₁, 0u"m", h₁) > TWA
+
+
false
+
+
+
+
+

Concluding Remarks

+

This model assumed a continuous, steady-state, flow of stack gases. Boilers don’t always operate that way and the model did not, for example, consider startup or upset conditions that could lead to higher in-stack concentrations of carbon monoxide.

+

The model also assumed mixing was captured by a simple Gaussian dispersion model. This model does not, for example, account for variability of wind speed either with time or spatially – wind speed typically increases with height – in this case I believe the model underestimates the degree of mixing. Nor does it account for interactions with buildings and potential down wash, which can be very significant.

+

This also assumes no other sources of carbon monoxide, both at the facility surrounding the worksite but also potentially from some portable equipment.

+

I think that, while modelling like this might be informative about the potential hazards, it is always good practise to develop a monitoring plan for the work area that includes the flue gases and any other potential substances to ensure workers on the scaffolding are not being exposed.

+
+
+

References

+
+
+AIChE/CCPS. Guidelines for Use of Vapour Cloud Dispersion Models, 2nd Ed. New York: American Institute of Chemical Engineers, 1996. +
+
+EPA. “AP 42: Compilation of Air Emissions Factors.” 5th ed. Research Triangle Park, NC: Environmental Protection Agency, 1995. https://www.epa.gov/air-emissions-factors-and-quantification/ap-42-compilation-air-emissions-factors. +
+
+———. “EPA-454/b-95-003b: User’s Guide for the ISC3 Dispersion Models.” Vol. 2. Environmental Protection Agency, 1995. +
+
+———. “Method 19: Determination of Sulfur Dioxide Removal Efficiency and Particulate Matter, Sulfur Dioxide, and Nitrogen Oxide Emission Rates.” Environmental Protection Agency, 2017. https://www.epa.gov/sites/default/files/2017-08/documents/method_19.pdf. +
+
+Lees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996. +
+
+Vallero, Daniel. Fundamentals of Air Pollution. 5th ed. Amsterdam: Elsevier, 2014. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/gaussian_dispersion_example/index_files/figure-html/cell-17-1-484527d5-7bee-471b-a866-9a9333e7e9d6.png b/posts/gaussian_dispersion_example/index_files/figure-html/cell-17-1-484527d5-7bee-471b-a866-9a9333e7e9d6.png new file mode 100644 index 0000000..e6f0642 Binary files /dev/null and b/posts/gaussian_dispersion_example/index_files/figure-html/cell-17-1-484527d5-7bee-471b-a866-9a9333e7e9d6.png differ diff --git a/posts/gaussian_dispersion_example/index_files/figure-html/cell-31-1-1845ad68-e996-4e56-981e-8178cd5d9b27.jpg b/posts/gaussian_dispersion_example/index_files/figure-html/cell-31-1-1845ad68-e996-4e56-981e-8178cd5d9b27.jpg new file mode 100644 index 0000000..ff00831 Binary files /dev/null and b/posts/gaussian_dispersion_example/index_files/figure-html/cell-31-1-1845ad68-e996-4e56-981e-8178cd5d9b27.jpg differ diff --git a/posts/gaussian_dispersion_example/veeterzy-unsplash-header.jpg b/posts/gaussian_dispersion_example/veeterzy-unsplash-header.jpg new file mode 100644 index 0000000..20613da Binary files /dev/null and b/posts/gaussian_dispersion_example/veeterzy-unsplash-header.jpg differ diff --git a/posts/gaussian_explosive_mass/index.html b/posts/gaussian_explosive_mass/index.html new file mode 100644 index 0000000..a2118f0 --- /dev/null +++ b/posts/gaussian_explosive_mass/index.html @@ -0,0 +1,1580 @@ + + + + + + + + + + + + +The Masses of Clouds – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

The Masses of Clouds

+
+
+ Calculating the mass of a Gaussian plume. +
+
+
+
julia
+
dispersion modelling
+
explosions
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

February 22, 2026

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

A common thing for me to do, when using a tool developed by someone else, is to read through all the tedious details and try and understand where it all comes from and what the unstated assumptions are. While doing this recently I was motivated to ask, how do people estimate the explosive energy in a vapour cloud? It is an important question to ask when performing a hazard analysis, especially in the petrochemical industry – often the worst case scenario is some chemical release leading to a vapour cloud explosion.

+

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.

+
+

The Gaussian dispersion model, a recap

+

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

+

\[ \chi = { w \over {2\pi u \sigma_y \sigma_z} } \exp \left( - \frac{1}{2} \left( \left(\frac{y}{\sigma_y}\right)^2 + \left(\frac{z}{\sigma_z}\right)^2 \right) \right) \]

+

Where the origin has been chosen to coincide with the release point. The standard assumptions for a Gaussian plume are:

+
    +
  1. The release has a constant mass emission rate of \(w\)
  2. +
  3. The release has no momentum or buoyancy
  4. +
  5. Advection is by a constant windspeed \(u\) which is in the positive \(x\) direction
  6. +
  7. Turbulence is captured by the parameters \(\sigma_y\) and \(\sigma_z\) which are functions of the downwind distance \(x\)
  8. +
+

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 \(\sigma_y\) and \(\sigma_z\) for which I am going to use the simple power law \(\sigma_y = a x^b\) and \(\sigma_z = c x^d\)

+
+
# 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 \(\chi_1\) and \(\chi_2\), 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 \(x\) axis where the centerline concentration equals that concentration. This is the point where the isosurface crosses the \(x\) 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)
+
+
+
+

A survey of estimates of the mass of vapour clouds

+

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.

The CCPS tools CHEF and RAST, the TNO Yellow Book,4 and Van Buijtenen5 give the mass of a vapour cloud as

+

\[ m_e = C \frac{w}{u} x_l \]

+

Where \(m_l\) is the mass of the region defined by the concentration \(\chi_l\) and \(C\) is a constant, which generally depends upon atmospheric stability. If the explosive mass is taken to be the region between two concentrations \(\chi_1\) and \(\chi_2\) with \(\chi_1 > \chi_2\) then \(m_e = m_2 - m_1\)

+ + + + + + + + + + + + + + + + + + + + + +
C
CHEF & RAST1
TNO Yellow Book\({ {f_{z2}(L) + 1} \over {f_{z2}(L) + 2} }\)
Van Buijtenen\({ {b + d} \over {b + d + 1 } }\)
+

Woodward6 gives the following as the rigorous method for plumes, specifically for a free plume it is

+

\[ m_e = 4 \left( \chi_1 - \chi_2 \right) \int_{x_1}^{x_2} \sigma_y^2 E\left( k^2 \right) dx \]

+

where \(E \left( k^2 \right)\) is the complete elliptic integral of the second kind and \(k\) is a function of \(x\) and atmospheric stability.

+
+
+

The mass of a Gaussian plume

+

The mass in the region of a Gaussian plume with a concentration greater than \(\chi_l\) is given by the volume integral

+

\[ m = \iiint_V \chi dV \]

+

where

+

\[ V = \left\{ x,y,z \vert \chi \left(x,y,z\right) \ge \chi_l \right\} \]

+

The mass in a free plume is given by

+

\[ m_{free} = \iiint_V \chi dV = \int_0^{x_l} \int_{-y_l}^{y_l} \int_{-z_l}^{z_l} \chi_{free} dz dy dx \]

+

By symmetry this is equal to

+

\[ 2 \int_0^{x_l} \int_{-y_l}^{y_l} \int_{0}^{z_l} \chi_{free} dz dy dx \]

+

Recalling that the concentration in a grounded plume is twice that of a free plume

+

\[ m_{free} = 2 \int_0^{x_l} \int_{-y_l}^{y_l} \int_{0}^{z_l} \chi_{free} dz dy dx = \int_0^{x_l} \int_{-y_l}^{y_l} \int_{0}^{z_l} \chi_{grounded} dz dy dx = m_{grounded} \]

+
+

Total mass

+

The total mass in the plume contained between the planes \(x=0\) and \(x=x_l\) is simply the integral

+

\[ m_T = \iiint_{V_T} \chi dV \]

+

\[ V_T = \left\{ x,y,z \vert 0 \le x \le x_l, \chi\left(x,y,z\right) \gt 0 \right\} \]

+

Which follows directly from properties of Gaussian functions

+

\[ m_T = \int_0^{x_l} \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} \chi dz dy dx \]

+

\[ m_T = \frac{w}{u} x_l \]

+

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 isosurface of a Gaussian plume

+

The isopleths for a free Gaussian plume are given by

+

\[ y_l = \pm \sigma_y \sqrt{K} \]

+

\[ z_l = \pm \sigma_z \sqrt{K} \]

+

where \(K = 2 \log \left( w \over {2\pi u \sigma_y \sigma_z \chi_l } \right)\)

+

The whole isosurface is defined by

+

\[ S = \left\{ x, y, z \bigg\vert \left(\frac{y}{\sigma_y}\right)^2 + \left(\frac{z}{\sigma_z}\right)^2 = K \right\} \]

+

These can be used directly, but a more general approach is to use marching squares to find the isopleth.

+
+
+
+
+ +
+
+Figure 1: The crosswind extent of the region of interest. +
+
+
+
+
+
+
+
+ +
+
+Figure 2: The vertical extent of the region of interest. +
+
+
+
+

For a general plume one can find the surface using marching tetrahedra. In the following the surface for \(\chi_2\) 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
+
+
+
+
+
+
+ +
+
+Figure 3: The χ₂ iso-surface, calculated by marching tetrahedra. +
+
+
+
+

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.

+
+
+

Direct numerical integration

+

The most direct approach to calculating the explosive mass is to numerically integrate over a rectangular region containing the plume7

+

\[ +m_l = \iiint_{V} \chi dV = \int_{0}^{x_l} \int_{-y_l}^{y_l} \int_{-z_l}^{z_l} \begin{cases} + \chi & \chi \ge \chi_l \\ + 0 & \chi \lt \chi_l + \end{cases} dz dy dx +\]

+

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.0
+
+
+
function 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
+end
+
+
+
m🪤 = 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 \(\xi\), \(\zeta\) such that \(\xi \in [-1,1]\) and \(\zeta \in [-1,1]\) and

+

\[ y = \sigma_y \sqrt{K} \xi \]

+

\[ z = \sigma_z \sqrt{K} \zeta \]

+

\[ +m_l = \iiint_{V} \chi dV = \int_{0}^{x_l} \int_{-1}^{1} \int_{-1}^{1} \begin{cases} + \sigma_y \sigma_z K \chi & \chi \ge \chi_l \\ + 0 & \chi \lt \chi_l + \end{cases} d\zeta d\xi dx +\]

+

where \(K = 2 \log \left( w \over {2\pi u \sigma_y \sigma_z \chi_l } \right)\)

+

This changes the domain of integration from a rectangular prism to one with a rectangular cross-section whose size is a function of \(x\). 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
+end
+
+
+
m🪤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 \(8 \times N^3\) 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 MCIntegration
+
+
+
function 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]
+end
+
+
mass🎲 (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 (minmax):  161.499 ms184.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 \(\chi \ge \chi_l\), but it is in general pretty forgiving.

+
+
+

Adaptive step-sizes with H Cubature

+

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 \(y_l\) and \(z_l\) do not depend on \(x\)) 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 \(\chi \ge \chi_l\), and with an integrand that is smooth and continuous throughout. Firstly we re-write the integral.

+

\[m_l = \iint_V \chi dV = \int_0^{x_l} \iint_{\mathcal{E}} \chi dA dx \]

+

\[ \mathcal{E} = \left\{ y, z \bigg\vert \left( y \over \sigma_y \right)^2 + \left( z \over \sigma_z \right)^2 \le K\left( x \right) \right\} \]

+
+
+
+
+ +
+
+Figure 4: The cross-sectional area of the plume, an ellipse. +
+
+
+
+

Note that \(\mathcal{E}\) defines an ellipse, Figure 4, which suggests the change of variables \(\rho\), \(\theta\) such that

+

\[ y = \sigma_y \sqrt{K} \rho \cos \theta \]

+

\[ z = \sigma_z \sqrt{K} \rho \sin \theta \]

+

and \(\rho \in [0,1]\), \(\theta \in [0, 2\pi]\)

+

\[ m_l = \int_0^{x_l} \int_0^{2\pi} \int_0^1 \sigma_y \sigma_z K \chi \rho {d\rho} {d\theta} {dx} \]

+

This can be integrated directly with h cubature without involving any discontinuous functions.

+
+
using HCubature: hcubature
+
+
+
function 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
+end
+
+
+
m📦 = 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 (minmax):  19.541 ms35.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.

+
+
+

Integrating out the cross-sectional area

+

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 \(\rho\), \(\theta\) directly into the definition of \(\chi\) gives

+

\[ m_l = \int_0^{x_l} \int_0^{2\pi} \int_0^1 \sigma_y \sigma_z K \chi \rho d\rho d\theta dx \]

+

\[ = \int_0^{x_l} \int_0^{2\pi} \int_0^1 \sigma_y \sigma_z K \left( \frac{w}{2\pi u \sigma_y \sigma_z} \exp \left( -\frac{K}{2} \rho^2 \right) \right) \rho d\rho d\theta dx \]

+

\[ = \int_0^{x_l} \int_0^{2\pi} \sigma_y \sigma_z \left[ \frac{w}{2\pi u \sigma_y \sigma_z} \left( 1 - \exp \left( -\frac{K}{2} \right) \right) \right] d\theta dx \]

+

\[ = \int_0^{x_l} \frac{w}{u} \left( 1 - \exp \left( -\frac{K}{2} \right) \right) dx \]

+

\[ m_l = \frac{w}{u} x_l - 2\pi \chi_l \int_0^{x_l} \sigma_y \sigma_z dx \]

+

The last integral is a simple one dimensional integral which can be done with QuadGK.

+
+
using QuadGK: quadgk
+
+
+
function 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
+end
+
+
mass🔴 (generic function with 1 method)
+
+
+
+
m🔴 = mass🔴(x₂) - mass🔴(x₁);
+
+

For the special case where \(\sigma_y = a x^b\) and \(\sigma_z = c x^d\) the integral can be done analytically to arrive at

+

\[ m_l = { {b+d} \over {b+d+1} } \frac{w}{u} x_l \]

+

Which is the result from Van Buijtenen8 given above. Similarly if we take \(\sigma_y \propto x\) and \(\sigma_z \propto x^{f_{z2}(L)}\) then

+

\[ m_l = { {f_{z2}(L) +1} \over {f_{z2}(L)+2} } \frac{w}{u} x_l \]

+

Which is the result from the TNO Yellow Book.9

+
+
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 (minmax):  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.

+
+
+
+
+Table 1: Performance of the integration methods. +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Mass (kg)Error (%)Median Time (ms)
Trapezoidal Rule55.730.9%9443.63
Monte Carlo56.360.23%165.57
H Cubature56.231.0e-8%25.0
QuadGK56.235.0e-10%0.03
+
+
+
+
+
+
+

The mass in a grounded plume

+

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

+

\[ m_g = \int_0^{x_l} \int_0^{\pi} \int_0^1 \sigma_y \sigma_z K_g \chi_g \rho d\rho d\theta dx \]

+

where \(K_g = 2 \log \left( w \over {\pi u \sigma_y \sigma_z \chi_{g,l} } \right)\)

+

\[ = \int_0^{x_l} \int_0^{\pi} \int_0^1 \sigma_y \sigma_z K_g \left( \frac{w}{\pi u \sigma_y \sigma_z} \exp \left( -\frac{K_g}{2} \rho^2 \right) \right) \rho d\rho d\theta dx \]

+

\[ = \int_0^{x_l} \int_0^{\pi} \sigma_y \sigma_z \left[ \frac{w}{\pi u \sigma_y \sigma_z} \left( 1 - \exp \left( -\frac{K_g}{2} \right) \right) \right] d\theta dx \]

+

\[ = \int_0^{x_l} \frac{w}{u} \left( 1 - \exp \left( -\frac{K_g}{2} \right) \right) dx \]

+

\[ m_g = \frac{w}{u} x_l - \pi \chi_{g,l} \int_0^{x_l} \sigma_y \sigma_z dx = \frac{w}{u} x_l - 2 \pi \chi_{f,l} \int_0^{x_l} \sigma_y \sigma_z dx = m_f\]

+

The masses within the free iso-surface and grounded iso-surface which intersect the x-axis at \(x_l\) are the same, as we expect, but the concentration which defines that iso-surface is not the same. An important distinction.

+
+
+
+

The “rigorous” method

+

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

+

\[ m_e = 4 \left( \chi_1 - \chi_2 \right) \int_{x_1}^{x_2} \sigma_y^2 E\left( k^2 \right) dx \]

+

with \(k^2 = 1 - \left(\frac{\sigma_z}{\sigma_y}\right)^2\) and \(E\) the complete elliptic integral of the second kind.

+
+
using SpecialFunctions: ellipe
+
+
+
(x) = 1 - (σz(x)/σy(x))^2
+
+
k² (generic function with 1 method)
+
+
+
+
I, err = quadgk( t -> σy(t)^2 * ellipe((t)), x₁, x₂)
+
+
(3522.359412198113, 4.6837135414534714e-7)
+
+
+
+
m_rigorous = 4*(χ₁ - χ₂)*I;
+
+
+
m_rigorous
+
+
1853.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

+

\[ m_e = \iiint_V \chi dV = \int_{x_1}^{x_2} \iint_{\mathcal{E}} \chi dA dx \]

+

\[ \mathcal{E} = \left\{ y, z \bigg\vert K_1 \le \left(\frac{y}{\sigma_y}\right)^2 + \left(\frac{z}{\sigma_z}\right)^2 \le K_2 \right\} \]

+

where \(\mathcal{E}\) is the area between the ellipses defined by \(\chi_1\) and \(\chi_2\).

+
+
+
+
+ +
+
+Figure 5: The cross-sectional area of the plume, between the two ellipses. +
+
+
+
+

Hesse proposes that11 \[ \iint_{\mathcal{E}} \chi dA = \int_{\sigma_y \sqrt{K_1}}^{\sigma_y \sqrt{K_2}} \chi p\left( x,y \right) dy \]

+

11 Hesse equation 20.

where \(p(x,y)\) is the perimeter of the elliptical isopleth in the y-z plane defined by the concentration \(\chi \left( x, y, 0 \right)\). That is to say, Hesse is integrating the cross-sectional area by treating it like a series of concentric, elliptical, rings with perimeter \(p\) and width \(dy\). For the free plume the perimeter is

+

\[ p(x,y) = 4 y E \left( k^2 \right) \]

+

Where \(E \left( k^2 \right)\) is the complete elliptic integral of the second kind with the elliptic modulus given by \(k^2 = 1 - \left(\frac{\sigma_z}{\sigma_y}\right)^2\), a constant with respect to \(y\) and \(z\).

+

Substituting in and making the change of variables to \(\chi = \frac{w}{2\pi u \sigma_y \sigma_z} \exp \left( -\frac{1}{2} \left( \frac{y}{\sigma_y} \right)^2 \right)\)

+

\[\iint_{\mathcal{E}} \chi dA = \int_{\sigma_y \sqrt{K_1}}^{\sigma_y \sqrt{K_2}} \chi p\left( x,y \right) dy \]

+

\[ = 4 \int_{\sigma_y \sqrt{K_1}}^{\sigma_y \sqrt{K_2}} \chi y E \left( k^2 \right) dy \]

+

\[ = 4 \int_{\chi_2}^{\chi_1} \chi y E \left( k^2 \right) { \sigma_y^2 \over {\chi y} } d\chi \]

+

\[ = 4 \int_{\chi_2}^{\chi_1} \sigma_y^2 E \left( k^2 \right) d\chi \]

+

\[ \iint_{\mathcal{E}} \chi dA = 4 \left(\chi_1 - \chi_2 \right) \sigma_y^2 E \left( k^2 \right) \]

+

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

+

\[\iint_{\mathcal{E}} \chi dA \ne \int_{\sigma_y \sqrt{K_1}}^{\sigma_y \sqrt{K_2}} \chi p\left( x,y \right) dy \]

+

To demonstrate this, consider the integration simply over the cross-sectional area. Hesse proposes that this relation holds

+

\[ \iint_{\mathcal{E}} dA = \int_0^{a} 4 y E \left( k^2 \right) dy \]

+

\[ \mathcal{E} = \left\{ y, z \bigg\vert \left(\frac{y}{a}\right)^2 + \left(\frac{z}{b}\right)^2 \le 1 \right\} \]

+

\[ k^2 = 1 - \left(\frac{b}{a}\right)^2 \]

+

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 \(E \left( k^2 \right)\) is a constant, that’s not what we get:

+

\[ A_{ellipse} = \int_0^{a} 4 y E \left( k^2 \right) dy \]

+

\[ = 2 a^2 E \left( k^2 \right) \]

+

But we know that the area of an ellipse is \(\pi a b\). The only case in which Hesse’s technique works is when \(a = b\), since \(E(0) = \frac{\pi}{2}\) (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 \(x \gt x_1\). This is, in fact, the definition of \(x_1\) 12. The only region over which the integration even makes sense is from \(0 \le x \le x_1\), and yet the actual integration is being done over \(x_1 \lt x \lt x_2\).

+

12 \(x_1\) is the point where \(K_1 = 0\), i.e. where the inner ellipse vanishes. At any point \(x \gt x_1\) there is no point in the plume where \(\chi = \chi_1\) 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 \(x_1 \lt x \lt x_2\) is solved, it still doesn’t work because it excludes the mass in the plume between \(0 \le x \le x_1\) for which \(\chi_2 \le \chi \lt \chi_1\). Clearly from Figure 1 and Figure 2, this is not a negligible region.

+

One might be tempted by the logic

+

\[ m_e = m_2 - m_1 \]

+

\[ = \int_{0}^{x_2} \iint_{\mathcal{E_2}} \chi dA dx - \int_{0}^{x_1} \iint_{\mathcal{E_1}} \chi dA dx \]

+

\[ = \int_{x_1}^{x_2} \iint_{\mathcal{E}} \chi dA dx \]

+

But that only works if \(\mathcal{E_1} = \mathcal{E_2}\), 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.

+
+
+

Conclusions and recommendations

+

For a screening level analysis I would use the relation

+

\[ m_l = \frac{w}{u} x_l - 2\pi \chi_l \int_0^{x_l} \sigma_y \sigma_z dx \]

+

to calculate the mass within an isosurface defined by \(x_l\). This gives some freedom in choice of dispersion parameters \(\sigma_y\) and \(\sigma_z\). 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 \(x_l\) for a plume at some height \(h\) 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 \(m_l\) equation for everything?”

+ + + +
+ + +

References

+
+Bakkum, E. A., and N. J. Duijm. “Vapour Cloud Dispersion.” In Methods for the Calculation of Physical Effects, CPR 14E, edited by C. J. H. van den Bosch and R. A. P. M. Weterings, 3rd ed. The Hague: TNO, 2005. +
+
+Hesse, D. J. “A Computational Procedure for Calculating the Mass of Flammable Vapor in a Neutrally Buoyant Cloud.” In International Conference and Workshop on Modeling and Mitigating the Consequences of Accidental Releases of Hazardous Materials, 511–28. New Orleans, 1991. +
+
+Spicer, Thomas O., and Jerry Havens. “Application of Dispersion Models to Flammable Cloud Analyses.” Journal of Hazardous Materials 49, no. 2 (1996): 115–24. https://doi.org/10.1016/0304-3894(96)01765-7. +
+
+Van Buijtenen, C. J. P. “Calculation of the Amount of Gas in the Explosive Region of a Vapour Cloud Released in the Atmosphere.” Journal of Hazardous Materials 3, no. 3 (1980): 201–20. https://doi.org/10.1016/0304-3894(80)85001-1. +
+
+Woodward, John L. Estimating the Flammable Mass of a Vapour Cloud. New York: American Institute of Chemical Engineers, 1998. +
+
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/gaussian_explosive_mass/plume.png b/posts/gaussian_explosive_mass/plume.png new file mode 100644 index 0000000..05f9182 Binary files /dev/null and b/posts/gaussian_explosive_mass/plume.png differ diff --git a/posts/hydrogen_blending/flame-unsplash-header.jpg b/posts/hydrogen_blending/flame-unsplash-header.jpg new file mode 100644 index 0000000..b3ca183 Binary files /dev/null and b/posts/hydrogen_blending/flame-unsplash-header.jpg differ diff --git a/posts/hydrogen_blending/index.html b/posts/hydrogen_blending/index.html new file mode 100644 index 0000000..01906ad --- /dev/null +++ b/posts/hydrogen_blending/index.html @@ -0,0 +1,1304 @@ + + + + + + + + + + + + +Hydrogen Blending – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Hydrogen Blending

+
+
+ Blending hydrogen into natural gas. +
+
+
+
julia
+
hydrogen
+
compressible flow
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

November 10, 2022

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

November this year came in with a bang where I live: the temperature outside is currently -20°C, there is a pile of snow, and suddenly staying in and staying warm is a very important activity. At the same time, with COP27 taking place in Egypt, climate change is top of mind for everyone (Edmonton is evaluating its net zero strategy) and I thought it would be worthwhile to look at one of the largest sources of household energy use in Canada: space heating. Space heating accounts for 61.6% of household energy use, and in the province of Alberta that predominantly comes from burning natural gas. If we are going to meet our net zero goals as a municipality, we will need to address the carbon emissions that come from simply living here.

+

A commonly bandied about tool for reducing household carbon emissions is hydrogen blending, such as this project in the Edmonton area. The idea is to gradually increase the hydrogen content of the natural gas and, since hydrogen burns without producing any carbon dioxide, the carbon emissions will decline. This has the obvious advantage of using the existing gas distribution infrastructure and existing appliances (e.g. people’s furnaces), thus avoiding costly retrofits.

+

But is this actually a useful thing to do? A common criticism of hydrogen is its low energy density: for a given flowrate you would expect to get ~1/3 the energy from pure hydrogen than from natural gas (assuming you are combusting it). But hydrogen is also incredibly light and, since flowrate is \(\propto \frac{1}{\sqrt{\rho} }\), for the same pressure drop across a pipe you would expect a greater flowrate. So which is it? Does the energy content decrease, increase, or stay the same? Instead of just waving my hands and guessing, I thought it might be worthwhile to work through some simple calculations to get a sense of the scale of things.

+

That said, it is rarely the case in engineering that decisions are clear cut. Blending hydrogen will have advantages and disadvantages, and whether the one out-weighs the other depends greatly on the particular location, its needs, infrastructure, and a host of other factors.

+
+

Energy density

+

The obvious place to start, and where I have found people often end, is with the heating value. For convenience I am going to use higher heating values, given here per standard cubic meter. Where standard in this case means at 15°C and 1atm.

+
+
using Unitful
+
+# Standard State
+R  = 8.31446261815324u"m^3*Pa/K/mol"
+Tᵣ = 288.15u"K"
+Pᵣ = 101325u"Pa"
+
+# Hydrogen, from the GPSA handbook, 13th ed.
+HHV_H2 = 12.109u"MJ/m^3"
+
+# Natural Gas, typical
+HHV_NG = 35.396u"MJ/m^3"
+
+

We can use a simple mixing rule to determine what the heating value would be with x% hydrogen by volume.

+
+
HHV(x) = x*HHV_H2 + (1-x)*HHV_NG
+
+
+
+
+
+
+ +
+
+Figure 1: Higher heating value of blended natural gas/hydrogen fuel gas as a function of hydrogen content, assuming an ideal gas and simple mixing rule. +
+
+
+
+
+

Which clearly shows that increasing the hydrogen content decreases the overall heating value of the fuel. At 100% hydrogen the fuel gas has lost ~66% of it’s heat content.

+

Suppose you are a customer whose natural gas was transitioned entirely to hydrogen, now you are receiving about a third of the energy per unit volume than you were before. Well the obvious thing to do would be to increase the volume that you use (by a factor of three) to make up the difference. This does raise the obvious question of can you actually do that? Superficially, it looks like you are asking to triple the demand on the current infrastructure.

+
+
+

Energy per unit of pressure drop

+

The glaring omission with the previous analysis is that we know that flowrate, generally, is \(\propto \frac{1}{\sqrt{\rho} }\), and natural gas is something like 9× denser than hydrogen. So, you would expect, for the same pressure drop in the same pipes, that you would get around 3× the flow of hydrogen.

+

Suppose we are only looking at the “last mile” of the distribution network, perhaps the pipe connecting your house to the main, reducing the problem to that of simple pipe flow. Assuming the flow is nearly isothermal, and an ideal gas, the mass velocity, G, of the fuel gas arriving at your house is given by:

+

\[ G = \sqrt{ \rho_1 P_1 } \sqrt{ \left(1 - \left( P_2 \over P_1 \right)^2 \right) \over { K - 2\log \left( P_2 \over P_1 \right)} } \]

+

Where 1 is the point just after the tee and 2 is the point just before your meter (e.g. a straight length of pipe). The volumetric flow rate, Q, at the upstream point 1, is then given by

+

\[ Q_1 = {\pi \over 4} D^2 {G \over \rho_1} \]

+

which, when corrected to the reference (standard) state is

+

\[ Q_s = Q_1 \cdot {v_{r} \over v_1} \]

+

Where v is the ideal gas molar volume (RT/P), a constant independent of the gas.

+

The heat rate, q, is simply the higher heating value times the volumetric flowrate (at standard state)

+

\[ q = HHV \cdot Q_s = HHV \cdot {\pi \over 4} D^2 \sqrt{P_1 \over \rho_1} \sqrt{ \left(1 - \left( P_2 \over P_1 \right)^2 \right) \over { K - 2\log \left( P_2 \over P_1 \right)} } \cdot {v_{r} \over v_1} \]

+

This looks like a lot however most of that is a constant, i.e. it is a function of the system and not the gas moving through it.

+

\[ q = { HHV \over \sqrt{\rho_1} } \times \textrm{a constant} \]

+

So, assuming a constant pressure drop, along an identical pipe, with fully developed turbulent flow (i.e. K is constant) the ratio of heat delivered by hydrogen to that of natural gas is given by:

+

\[ { q_{H_2} \over q_{NG} } = { HHV_{H_2} \over HHV_{NG} } \sqrt{ \rho_{NG} \over \rho_{H_2} } = { HHV_{H_2} \over HHV_{NG} } \sqrt{ MW_{NG} \over MW_{H_2} }\]

+
+
# Hydrogen
+MW_H2 = 2.016e-3u"kg/mol"
+
+# Natural Gas
+MW_NG = 19.5e-3u"kg/mol"
+
+heat_ratio = (HHV_H2/HHV_NG)*√(MW_NG/MW_H2)
+
+
1.0639620426132184
+
+
+

So in total opposition to what we expected from merely looking at energy density we now expect, for the same system operating at the same pressures, to receive slightly more energy when transitioned over to pure hydrogen.

+

But what about the in-between, when hydrogen is blended into natural gas? Is it just a straight line connecting these two?

+

We can explore this more closely by first looking at how density, and thus volumetric flowrate, changes with the hydrogen content. I am assuming an ideal gas case and so the mixing rule is quite simple

+

\[ \rho = x_{H_2} \rho_{H_2} + x_{NG} \rho_{NG} \]

+

where, for an ideal gas

+

\[ \rho = MW {P \over {R T} } \]

+

giving

+

\[ \rho = {P \over {R T} } \left( x_{H_2} MW_{H_2} + x_{NG} MW_{NG} \right)\]

+
+
# ideal gas density
+ρ(x, T, P) = (P/(R*T))*( x*MW_H2 + (1-x)*MW_NG );
+
+
+
+
+
+
+ +
+
+Figure 2: Density and relative flowrate for blended natural gas/hydrogen fuel gas, assuming an ideal gas. +
+
+
+
+
+

So we have two competing effects: as the mole fraction increases the heating value of the gas decreases but at the same time the flowrate increases. We can explore this further by plotting the ratio of the heat rate with blended fuel gas to the heat rate with straight natural gas.

+
+
# heat rate for blended fuel gas relative to natural gas
+q_ratio(x) = (HHV(x)/HHV_NG)*√(ρ(0,Tᵣ,Pᵣ)/ρ(x,Tᵣ,Pᵣ));
+
+
+
+
+
+
+ +
+
+Figure 3: The amount of energy delivered, in higher heating value, for a blended natural gas/hydrogen fuel gas system at constant operating conditions, relative to natural gas +
+
+
+
+
+

Initially the loss of heating value “wins out” and increasing the hydrogen content merely decreases the energy supplied at a given pressure. But once the stream is predominantly hydrogen, the lower density takes over and the heat rate increases.

+

The minimum ratio can be found by setting the derivative to zero

+
+
using ForwardDiff: derivative
+using Roots: find_zero
+
+∂q_ratio(x) = derivative(q_ratio,x)
+xₘᵢₙ = find_zero(∂q_ratio,(0,1))
+
+xₘᵢₙ, q_ratio(xₘᵢₙ)
+
+
(0.7106211503798253, 0.8839840357969662)
+
+
+

Initially, blending hydrogen decreases the overall energy delivered, bottoming out at ~12% less, when hydrogen makes up 71% of the fuel gas. While this is not the 66% decline predicted by a naive look at energy density, neither is it nothing.

+

Another important point is where the ratio becomes one: the concentration where the blended hydrogen fuel gas reattains the energy content of the original natural gas stream

+
+
xₑᵥₑₙ = find_zero( (x)-> q_ratio(x)-1, (xₘᵢₙ,1))
+
+
0.9684672945947692
+
+
+

The system doesn’t recover the original energy supply until the hydrogen content is >96.8%, at which point a whole host of other concerns may become more relevant – burning pure and nearly pure hydrogen comes with its own issues.

+
+
+

Greenhouse gas emissions

+

The whole point of doing this is to decrease the carbon emissions associated with space heating (plus the other uses of household natural gas, but mostly space heating). So it is worth circling back to answer the question: does this actually do that? and by how much?

+

The dominant greenhouse gas associated with combustion is carbon dioxide, and the carbon dioxide emissions from combustion are fairly easy to calculate from stoichiometry, for a generic hydrocarbon the combustion equation is

+

\[ C_n H_m + \left( n + {m \over 4} \right) O_2 \rightarrow n CO_2 + {m \over 2} H_2 O \]

+

If we presume the natural gas is mostly methane and n≈1, then there is one mole of carbon dioxide produced per mole of natural gas delivered (assuming perfectly complete combustion). When combusting hydrogen there is no carbon dioxide produced, and so the moles of carbon dioxide produced from the combustion of a blended hydrogen fuel gas is

+

\[ \dot{n}_{CO_2} = \left( 1 - x_{H_2} \right) \dot{n}_{FG} \]

+

Where \(\dot{n}\) is the molar flowrate. We don’t actually know the molar flowrate of fuel gas, but we can calculate it from the ideal gas law and the volumetric flowrate at standard state Qs

+

\[ \dot{n}_{FG} = {P_r \over {R T_r} } Q_s \]

+

\[ \dot{n}_{CO_2} = \left( 1 - x_{H_2} \right) {P_r \over {R T_r} } Q_s \]

+

What we want is the mass flowrate of carbon dioxide, so simply multiply both sides by the molar weight

+

\[ \dot{m}_{CO_2} = \left( 1 - x_{H_2} \right) MW_{CO_2} {P_r \over {R T_r} } Q_s \\ = \left( 1 - x_{H_2} \right) \rho_{CO_2,r} Q_s \]

+

If we assume that the users of fuel gas are using a fixed amount of energy, regardless of the actual flowrate, then what we want is the carbon intensity of the fuel: how much carbon dioxide is emitted per Megajoule of heat generated?

+

\[ E = \frac{\dot{m}_{CO_2} }{q} = { {\left( 1 - x_{H_2} \right) \rho_{CO_2,r} Q_s} \over {HHV(x) Q_s} } = { { \left( 1 - x_{H_2} \right) \rho_{CO_2,r} } \over {HHV(x)} }\]

+
+
# Carbon Dioxide
+MW_CO2 = 44.009e-3u"kg/mol"
+ρ_CO2 = MW_CO2*Pᵣ/(R*Tᵣ)
+
+E(x) = (1-x)*ρ_CO2/HHV(x)
+
+
+
+
+
+
+ +
+
+Figure 4: The carbon dioxide emissions intensity for a blended natural gas/hydrogen fuel gas, over a range of hydrogen content. +
+
+
+
+
+

So there are emissions reductions but at a cost, beyond whatever method is used to generate the hydrogen in the first place. The system must be operated at greater pressures to supply the same amount of energy, which itself takes some energy, at least until the hydrogen exceeds 96.8%. At that high level the system seems like an easy win: it takes less pressure to supply the same amount of energy and the emissions intensity is a ~8.7% that of natural gas (a ~91% reduction)

+
+
E(xₑᵥₑₙ)/E(0)
+
+
0.08690379085511468
+
+
+

There are a few caveats with this: for one carbon dioxide is not the only significant greenhouse gas that comes from combustion, nitrous oxide is also produced and has a global warming potential ~300× that of carbon dioxide. Unlike carbon dioxide, nitrous oxide is producded when hydrogen is combusted with air because, like many other nitrogen oxides, it is generated from the high temperature reaction of the nitrogen and oxygen from the air. So burning pure hydrogen is only net zero for very particular definitions of zero, it is not net zero greenhouse gas emissions though it is net zero carbon emissions.

+
+
+

Material concerns

+

So far the analysis has completely ignored the material issues that hydrogen brings. At high temperatures (such as, say, inside a furnace that is burning hydrogen) high temperature hydrogen attack is a real concern and using hydrogen as a fuel gas would eventually destroy most burners that were designed for use with natural gas. Similarly hydrogen embrittlement would be a concern for the entire system, wherever steel is used. Neither of these are insurmountable but they would require extensive retrofitting with different materials and special alloys. This dampens a lot of the advantages of hydrogen blending, namely being able to use the existing infrastructure.

+

To skip over the details (I’m not a materials engineer), I think it is fair to say that the mechanical integrity of the system is strongly dependent on the hydrogen content and it likely will be a limiting factor in any hydrogen blending project.

+
+
+

Conclusions

+

At the level of “back of the envelope” calculations like I have done above, it is fairly clear that blending hydrogen into the utility natural gas system is not a panacea, but then neither is it completely infeasible. I think there are several other factors that need to be considered when evaluating the possible role of hydrogen blending in the future energy mix:

+
    +
  • Availability of hydrogen - in areas like Edmonton, there are already industrial suppliers of hydrogen (and large industrial consumers), with a roadmap to both expand that capacity and bring it to net zero emissions. Tying into that existing network significantly lowers the barrier for a blending project and can realize real emissions reductions now.
  • +
  • Feasibility of alternatives - it seems to be accepted wisdom that, at least for now, air source heat pumps are not very effective below -20°C. It is entirely possible that, while retrofitting to add heat pumps to homes would be hugely effective for most of the year, households in Edmonton would still require some additional source of space heating for those extremely cold days. Not only are -20°C days fairly normal in the winter, it is not at all uncommon to exceed -30°C and periodically it gets to -40°C. Hydrogen blending could be part of that energy future, as people with heat pumps keep their furnaces around.
  • +
  • Existing housing stock/pace of retrofits - it may be the case that, after performing a full life cycle analysis, heat pumps + resistive heating is the better technology. But that may fail to acknowledge the greater metropolitan area of 1.4M people that is Edmonton who, almost universally, live in buildings that do not have heat pumps and resistive heating, and instead rely on natural gas fired heaters (e.g. furnaces, boilers). With the majority of household energy use being space heating, hydrogen blending may have a role in realizing significant emissions reductions while the existing housing stock is transitioned over.
  • +
+

Personally I think hydrogen’s role in the future is over-hyped. A lot of people working in the fossil fuel space have pinned their industry’s future on hydrogen, which comes with a certain amount of motivated reasoning. Also hydrogen is appealing as it looks like the easy solution: swap the burning of one fuel for the burning of another, and we don’t have to make sweeping and systemic change, except that hydrogen brings its own host of issues (low energy density, material incompatibility). I think the answer will turn out not to be one silver bullet, like hydrogen, but an entire ecosystem of different technologies, often hyper specific to different locations, and what will connect them all will be the electrification of everything. That said, we have a vast, globe spanning, infrastructure and centuries of know-how in burning things and that gives hydrogen a big leg-up as a transitional solution.

+
+
+

An example system

+

I thought I would end with a basic pipe flow example, if you wanted to look at specific numbers this is how you might start that. This is also an example of the life changing magic of solving problems with code: once you have solved them once you never have to solve them again. Since I have frequently worked out pipe flow problems with julia, I can throw together a more detailed than is at all necessary model through the magic of copying and pasting.

+
+

Mixture viscosity

+

We’ve already worked out the mixture density and heating value, and the next most important material property is viscosity. I don’t have a curve for natural gas, so I am just going to use methane as a proxy. I am already treating natural gas like a homogeneous substance, so this is simply an extension of that.

+
+
using UnitfulCorrelations
+
+# Hydrogen - from Perry's, 8th ed.
+μ_H2(T) = (1.797e-7*T^0.685)/(1-0.59/T+140/(T^2));
+@ucorrel μ_H2 u"K" u"Pa*s"
+
+# Methane (Natural Gas) - from Perry's, 8th ed. 
+μ_NG(T) = (5.2546e-7*T^0.59006)/(1+105.67/T);
+@ucorrel μ_NG u"K" u"Pa*s"
+
+

Where I have used a macro that I wrote previously to turn correlations into correlations with units.

+

At standard conditions the viscosity of hydrogen and that of natural gas (methane) are not too different, so we can get away with using a simple method for estimating the viscosity of the overall mixture.

+
+
μ_H2(Tᵣ)/μ_NG(Tᵣ)
+
+
0.8004989026814741
+
+
+

I happen to already have Wilke’s method for a binary mixture worked out, I just need to swap in what the two components are. A more fulsome analysis would have a complete composition of natural gas (broken down into methane, ethane, propane, etc.) in which case the generalized Wilke’s method could be used as well.

+
+
# mixture viscosity using Wilke method
+# from *The Properties of Gases and Liquids* 5th ed.
+function μ(x,T)
+    μ₁ = μ_H2(T)
+    M₁ = MW_H2
+    y₁ = x
+    
+    μ₂ = μ_NG(T)
+    M₂ = MW_NG
+    y₂ = 1-x
+    
+    ϕ₁₂ = ((1+√((μ₁/μ₂)*√(M₂/M₁)))^2)/(8*(1+(M₁/M₂)))
+    ϕ₂₁ = ϕ₁₂*(μ₂/μ₁)*(M₁/M₂)
+    
+    μ = (y₁*μ₁/(y₁+y₂*ϕ₁₂)) + (y₂*μ₂/(y₂+y₁*ϕ₂₁))
+    return μ
+end;
+
+
+
+
+
+
+ +
+
+Figure 5: The viscosity of blended natural gas/hydrogen for a range of hydrogen content. For a wide range the viscosity is nearly constant. +
+
+
+
+
+
+
+

Pipe dimensions and friction

+

For the sake of having something to calculate I am just assuming a 20m length of 2in steel pipe. But you could put in really anything here.

+
+
# Pipe dimensions
+L = 20u"m"      # length
+D = 52.5u"mm"   # diameter
+ϵ = 0.0457u"mm" # roughness
+
+A = 0.25*π*D^2  # cross-sectional area
+l = L/D         # relative length
+κ = ϵ/D         # relative roughness
+
+

The Reynold’s number is simply a function of the mass velocity, G, the pipe diameter, D, and the mixture viscosity μ

+
+
# Reynold's number
+Re(x,T,G) = G*D/μ(x,T);
+
+

I am using my favourite correlation for the Darcy friction factor, f,

+
+
# Churchill correlation, from Perry's
+function churchill(Re)
+    A = (2.457 * log(1/((7/Re)^0.9 + 0.27*κ)))^16
+    B = (37530/Re)^16
+    return 8*((8/Re)^12 + 1/(A+B)^(3/2))^(1/12)
+end;
+
+

Since this is just a straight length of pipe, the K factor is simply fL/D, defaulting back to the Nikuradse rough pipe law for fully developed turbulent flow (i.e. very high Reynold’s numbers)

+
+
Kf() = l/(2*log10(3.7/κ))^2 # Nikuradse
+Kf(Re) = l*churchill(Re)    # Churchill
+
+
+
+

Volumetric flowrate

+

The volumetric flowrate for an isothermal ideal gas is simply the mass velocity, G, multiplied by the cross sectional area and divided by the density GA/ρ. It is very easy to modify some code I had previously written to solve for the volumetric flowrate.

+
+
# Isothermal ideal gas pipeflow
+function Q₁(x, T₁, P₁, P₂, K::Number)
+    ρ₁ = ρ(x, T₁, P₁)
+    v̄₁ = 1/ρ₁
+    q  = P₂/P₁
+    Q₁ = A*√((v̄₁*P₁*(1-q^2))/(K-2*log(q)))
+    return upreferred(Q₁)
+end
+
+function Q₁(x, T₁, P₁, P₂, K::Function)
+    # Initialize Parameters
+    ρ₁ = ρ(x, T₁, P₁)
+    q = P₂/P₁
+    
+    # Initial Guesses
+    Q₀ = Q₁(x, T₁, P₁, P₂, K())
+    G₀ = Q₀*ρ₁/A
+
+    # Numerically solve for G
+    obj(G) = (K(Re(x,T₁,G))- 2*log(q))*(G^2) - ρ₁*P₁*(1-q^2)
+    G = find_zero(obj, G₀)
+    
+    return upreferred(G*A/ρ₁)
+end
+
+

This uses julia’s multiple dispatch to handle two cases: for large Reynold’s numbers where K is a constant, and for cases where K is a function of the Reynold’s number (and thus the volumetric flowrate).

+

The volumetric flowrate at standard state is then the flowrate from above, corrected to the reference pressure and temperature1

+

1 I have been using upreferred to force Unitful to cancel out and simplify units.

+
Qₛ(x, T₁, P₁, P₂) = upreferred((P₁/Pᵣ)*(Tᵣ/T₁)*Q₁(x, T₁, P₁, P₂, Kf))
+
+
+
+

Heat rate

+

The heat rate is then the heating value, already worked out, times the volumetric flowrate at standard state

+
+
q(x, T₁, P₁, P₂) = HHV(x)*Qₛ(x, T₁, P₁, P₂)
+
+
+
+
+
+
+ +
+
+Figure 6: The energy supplied by the example fuel gas delivery system for a range of pressure drops. Pure natural gas and pure hydrogen deliver nearly the same energy for the same pressure drop. +
+
+
+
+
+

We see the same ordering that we expect, given the previous analysis, namely that the 0% and 100% cases are pretty close to each other, followed by the in-between hydrogen contents.

+

Another way of looking at this is to pick a required heat rate and look at the pressure drop as a function of hydrogen content.

+
+
+
+
+
+ +
+
+Figure 7: The pressure drop required to deliver a fixed heat rate for blended natural gas/hydrogen fuel gas in the example system. +
+
+
+
+
+

All of this has been done assuming the ideal gas case. The next logical step is to start incorporating non-ideal gas models, say a cubic equation of state, and so on.

+
+
+
+

References

+
+
+GPSA. Engineering Data Book. 13th ed. Tulsa, OK: Gas Processors Suppliers Association, 2012. +
+
+Green, Don W., ed. Perry’s Chemical Engineers’ Handbook. 8th ed. New York: McGraw Hill, 2008. +
+
+Poling, Bruce E., John M. Prausnitz, and John P. O’Connell. The Properties of Gases and Liquids. 5th ed. New York: McGraw Hill, 2001. +
+
+Poling, Bruce E., George H. Thomson, Daniel G. Friend, Richard L. Rowley, and W. Vincent Wilding. “Physical and Chemical Data.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/hydrogen_blending/index_files/figure-html/fig-co2-output-1.svg b/posts/hydrogen_blending/index_files/figure-html/fig-co2-output-1.svg new file mode 100644 index 0000000..c7b1a34 --- /dev/null +++ b/posts/hydrogen_blending/index_files/figure-html/fig-co2-output-1.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/hydrogen_blending/index_files/figure-html/fig-density-output-1.svg b/posts/hydrogen_blending/index_files/figure-html/fig-density-output-1.svg new file mode 100644 index 0000000..a7166e6 --- /dev/null +++ b/posts/hydrogen_blending/index_files/figure-html/fig-density-output-1.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/hydrogen_blending/index_files/figure-html/fig-energy-output-1.svg b/posts/hydrogen_blending/index_files/figure-html/fig-energy-output-1.svg new file mode 100644 index 0000000..0e1fa72 --- /dev/null +++ b/posts/hydrogen_blending/index_files/figure-html/fig-energy-output-1.svg @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/hydrogen_blending/index_files/figure-html/fig-energy-pv-output-1.svg b/posts/hydrogen_blending/index_files/figure-html/fig-energy-pv-output-1.svg new file mode 100644 index 0000000..9b1f045 --- /dev/null +++ b/posts/hydrogen_blending/index_files/figure-html/fig-energy-pv-output-1.svg @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/hydrogen_blending/index_files/figure-html/fig-hhv-output-1.svg b/posts/hydrogen_blending/index_files/figure-html/fig-hhv-output-1.svg new file mode 100644 index 0000000..4aca3ae --- /dev/null +++ b/posts/hydrogen_blending/index_files/figure-html/fig-hhv-output-1.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/hydrogen_blending/index_files/figure-html/fig-pressure-drop-output-1.svg b/posts/hydrogen_blending/index_files/figure-html/fig-pressure-drop-output-1.svg new file mode 100644 index 0000000..da3d02c --- /dev/null +++ b/posts/hydrogen_blending/index_files/figure-html/fig-pressure-drop-output-1.svg @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/hydrogen_blending/index_files/figure-html/fig-viscosity-output-1.svg b/posts/hydrogen_blending/index_files/figure-html/fig-viscosity-output-1.svg new file mode 100644 index 0000000..c73ed3e --- /dev/null +++ b/posts/hydrogen_blending/index_files/figure-html/fig-viscosity-output-1.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/hydrogen_compression/compressor.png b/posts/hydrogen_compression/compressor.png new file mode 100644 index 0000000..42ce31f Binary files /dev/null and b/posts/hydrogen_compression/compressor.png differ diff --git a/posts/hydrogen_compression/index.html b/posts/hydrogen_compression/index.html new file mode 100644 index 0000000..66d8f27 --- /dev/null +++ b/posts/hydrogen_compression/index.html @@ -0,0 +1,1278 @@ + + + + + + + + + + + + +Delivering Hydrogen Fuel Gas – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Delivering Hydrogen Fuel Gas

+
+
+ Thinking about hydrogen as a utility fuel gas by way of the relative compression costs. +
+
+
+
julia
+
hydrogen
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

May 7, 2026

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

Previously I evaluated hydrogen as a fuel gas from the perspective of an end user – someone who purchases utility natural gas, at pressure, for use in combustion devices like boilers and heaters. From that perspective, hydrogen is not an unreasonable conversion, with material compatibility being the primary concern. In this post I’m going to look at it from the perspective of the gas utility.

+

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.

+
+

The Situation

+

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.

+

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, \(\dot{w}_{g}\), to compress a mass flowrate \(\dot{m}\) of gas from a pressure of \(p_1\) to \(p_2\) is234

+

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

\[ +\dot{w}_{g} = \dot{m} \int_{p_1}^{p_2} v dp +\]

+

This is related to the isentropic work through the isentropic efficiency, \(\varepsilon_{i}\)

+

\[ +\varepsilon_{i} \dot{w}_{g} = \dot{m} \int_{p_1}^{p_2} v dp \vert_{isentropic} +\]

+

Where the integral of the specific volume \(v\) 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, \(r\), is then

+

\[ +r = { {\dot{w}_{g}}_{H2} \over {\dot{w}_{g}}_{NG} } = { \left( \varepsilon_{i} \dot{w}_{g} \right)_{H2} \over \left( \varepsilon_{i} \dot{w}_{g} \right)_{NG} } = { \left( \dot{m} \int_{p_1}^{p_2} v dp \right)_{H2} \over \left( \dot{m} \int_{p_1}^{p_2} v dp \right)_{NG} } +\]

+

The integrals, though, do not have to be tackled directly, recalling the differential for (specific) enthalpy

+

\[ +dh = v dp + T ds +\]

+

Integrating from state 1 to state 2 along an isentropic path (i.e. \(ds = 0\)) gives:

+

\[ +\int_{h_1}^{h_2} dh = h_2 - h_1 = \int_{p_1}^{p_2} v dp +\]

+

Thus the ratio we’re looking for is given by:

+

\[ +r = { \dot{m}_{H2} \over \dot{m}_{NG} } { {\Delta h}_{H2} \over {\Delta h}_{NG} } +\]

+

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 \(p_2\) and a temperature \(T_2\) defined by \(s_1 = s_2\) 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

+
+
+

\[\begin{align} +\eta_t &= 100\;\text{ }(\text{total compression ratio}) +\\[10pt] +n &= 3\;\text{ }(\text{number of stages}) +\\[10pt] +\eta &= \eta_t^{\frac{1}{n}} +\\[10pt] +&= 100^{\frac{1}{3}} +\\[10pt] +&= 4.64 +\end{align}\]

+
+
+
+
+
+ +
+
+Figure 1: A three stage compressor with interstage cooling. +
+
+
+
+
+

Suppose, for simplicity, the interstage coolers bring the gas temperature down to 15C:

+
    +
  1. the first stage compresses the gas from 1 bar to 4.6 bar
  2. +
  3. the second stage compresses the gas from 4.6 bar to 21.5 bar
  4. +
  5. the last stage compresses the gas from 21.5 bar to 100.0 bar
  6. +
+

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

+

\[ +r_{T} = { \dot{m}_{H2} \over \dot{m}_{NG} } { \left( {\Delta h}_{1 \to 2} + {\Delta h}_{3 \to 4} + {\Delta h}_{5 \to 6} \right)_{H2} \over \left( {\Delta h}_{1 \to 2} + {\Delta h}_{3 \to 4} + {\Delta h}_{5 \to 6} \right)_{NG} } +\]

+
+
+

The Ideal Gas Case

+

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

+

\[ +s_0 + \int_{T_0}^{T_1} {c_p \over T} dT - R \log { p_1 \over p_0 } = s_0 + \int_{T_0}^{T_2} {c_p \over T} dT - R \log { p_2 \over p_0 } +\]

+

Assuming \(c_p\) is a constant this simplifies to

+

\[ +c_p \log{ T_2 \over T_1 } = R \log{p_2 \over p_1} +\]

+

For an ideal gas \(c_p - c_v = R\), giving

+

\[ +\log{T_2 \over T_1} = { {c_p - c_v} \over c_p} \log{ p_2 \over p_1} +\]

+

\[ +{T_2 \over T_1} = \left( p_2 \over p_1 \right)^{1 - \frac{1}{k}} +\]

+

A well known result. The enthalpy of an ideal gas with constant \(c_p\) is just \(c_p T\), so we have:

+

\[ +\Delta h = c_p \Delta T = c_p \left( T_2 - T_1 \right) +\]

+

\[ += c_p T_1 \left( {T_2 \over T_1} -1 \right) +\]

+

\[ += c_p T_1 \left( \left( p_2 \over p_1 \right)^{1 - \frac{1}{k}} - 1 \right) +\]

+

From the ideal gas law, \(T = \frac{pv}{R}\)

+

\[ +\Delta h = \frac{c_p}{R} p_1 v_1 \left( \left( p_2 \over p_1 \right)^{1 - \frac{1}{k}} - 1 \right) +\]

+

\[ += {k \over {k-1}} p_1 v_1 \left( \left( p_2 \over p_1 \right)^{\frac{k-1}{k}} - 1 \right) +\]

+

Which allows us to write the work ratio, for a single stage, compressing an ideal gas with constant heat capacity, as:

+

\[ +r = { \dot{m}_{H2} \over \dot{m}_{NG} } { {v_1}_{H2} \over {v_1}_{NG} } {C_{NG} \over C_{H2}} { { \eta^{C_{H2}} -1 } \over { \eta^{C_{NG}} - 1} } +\]

+

where \(C = \frac{k-1}{k}\). From the ideal gas law the ratio of specific volumes is just the ratio of molar weights \[ +{ {v_1}_{H2} \over {v_1}_{NG} } = { MW_{NG} \over MW_{H2} } +\]

+

\[ +r = { \dot{m}_{H2} \over \dot{m}_{NG} } { MW_{H2} \over MW_{NG} } {C_{NG} \over C_{H2}} { { \eta^{C_{H2}} -1 } \over { \eta^{C_{NG}} - 1} } +\]

+

and, since the inlet streams are all at the same temperature

+

\[ +r_T = r +\]

+

Furthermore, if we assume \(k_{NG} \approx k_{H2}\) then

+

\[ +r = { \dot{m}_{H2} \over \dot{m}_{NG} } { MW_{NG} \over MW_{H2} } +\]

+

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

+

\[ +r = { MW_{NG} \over MW_{H2} } +\]

+
+
using Unitful, Clapeyron
+
+
+
ideal_hydrogen = ReidIdeal(["hydrogen"])
+ideal_natural_gas = ReidIdeal(["methane"])
+
+
+
+

\[\begin{align} +MW_{NG} &= 16.04\,\mathrm{kg}\,\mathrm{kmol}^{-1} +\\[10pt] +MW_{H2} &= 2.02\,\mathrm{kg}\,\mathrm{kmol}^{-1} +\\[10pt] +r &= \frac{MW_{NG}}{MW_{H2}} +\\[10pt] +&= \frac{16.04\,\mathrm{kg}\,\mathrm{kmol}^{-1}}{2.02\,\mathrm{kg}\,\mathrm{kmol}^{-1}} +\\[10pt] +&= 7.94 +\end{align}\]

+
+
+
+
+

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

+

\[ +hv_{H2} \dot{m}_{H2} = hv_{NG} \dot{m}_{NG} +\]

+

\[ +{ \dot{m}_{H2} \over \dot{m}_{NG} } = { hv_{NG} \over hv_{H2} } +\]

+

and so

+

\[ +r = { hv_{NG} \over hv_{H2} } { MW_{NG} \over MW_{H2} } +\]

+

where \(hv\) is the specific higher heating value (or gross heating value)

+
+
+

\[\begin{align} +hv_{NG} &= 55.58\,\mathrm{MJ}\,\mathrm{kg}^{-1}\;\text{ }(\text{GPSA Handbook}) +\\[10pt] +hv_{H2} &= 141.95\,\mathrm{MJ}\,\mathrm{kg}^{-1}\;\text{ }(\text{GPSA Handbook}) +\\[10pt] +r &= \frac{hv_{NG}}{hv_{H2}} \cdot \frac{MW_{NG}}{MW_{H2}} +\\[10pt] +&= \frac{55.58\,\mathrm{MJ}\,\mathrm{kg}^{-1}}{141.95\,\mathrm{MJ}\,\mathrm{kg}^{-1}} \cdot \frac{16.04\,\mathrm{kg}\,\mathrm{kmol}^{-1}}{2.02\,\mathrm{kg}\,\mathrm{kmol}^{-1}} +\\[10pt] +&= 3.11 +\end{align}\]

+
+
+
+
+

This gives a work ratio of 3.1, quite a bit smaller of an estimate.

+
+
+

But the assumption that \(k_{H2} \approx k_{NG}\) 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")
+
+
+
+

\[\begin{align} +k_{H2} &= 1.41\;\text{ }(\text{Clapeyron.jl, at 1bar and 15C}) +\\[10pt] +C_{H2} &= 1 - \frac{1}{k_{H2}} +\\[10pt] +&= 1 - \frac{1}{1.41} +\\[10pt] +&= 0.29 +\\[10pt] +k_{NG} &= 1.31\;\text{ }(\text{Clapeyron.jl, at 1bar and 15C}) +\\[10pt] +C_{NG} &= 1 - \frac{1}{k_{NG}} +\\[10pt] +&= 1 - \frac{1}{1.31} +\\[10pt] +&= 0.23 +\\[10pt] +r_{ig} &= \frac{hv_{NG}}{hv_{H2}} \cdot \frac{MW_{NG}}{MW_{H2}} \cdot \frac{C_{NG} \cdot \left( \eta^{C_{H2}} - 1 \right)}{C_{H2} \cdot \left( \eta^{C_{NG}} - 1 \right)} +\\[10pt] +&= \frac{55.58\,\mathrm{MJ}\,\mathrm{kg}^{-1}}{141.95\,\mathrm{MJ}\,\mathrm{kg}^{-1}} \cdot \frac{16.04\,\mathrm{kg}\,\mathrm{kmol}^{-1}}{2.02\,\mathrm{kg}\,\mathrm{kmol}^{-1}} \cdot \frac{0.23 \cdot \left( 4.64^{0.29} - 1 \right)}{0.29 \cdot \left( 4.64^{0.23} - 1 \right)} +\\[10pt] +&= 3.25 +\end{align}\]

+
+
+
+
+

This gives a work ratio of 3.25, which shows that our original approximation was reasonable: accounting for differences in isentropic expansion factor, \(k\), changes our estimate by only 5.0%.

+
+
+
+
+

The Real Gas Case

+

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
+end
+
+

First, the specific enthalpy difference for hydrogen

+
+
+

\[\begin{align} +p_{1} &= 1\,\mathrm{bar} +\\[10pt] +T_{1} &= 288.15\,\mathrm{K} +\\[10pt] +p_{2} &= 4.64\,\mathrm{bar} +\\[10pt] +T_{2H2} &= 447.3\,\mathrm{K}\;\text{ }(\text{Clapeyron.jl}) +\\[10pt] +H_{1H2} &= -283.16\,\mathrm{J}\,\mathrm{mol}^{-1}\;\text{ }(\text{Clapeyron.jl}) +\\[10pt] +H_{2H2} &= 4344.03\,\mathrm{J}\,\mathrm{mol}^{-1}\;\text{ }(\text{Clapeyron.jl}) +\\[10pt] +{\Delta}h_{H2} &= \frac{H_{2H2} - H_{1H2}}{MW_{H2}} +\\[10pt] +&= \frac{4344.03\,\mathrm{J}\,\mathrm{mol}^{-1} + 283.16\,\mathrm{J}\,\mathrm{mol}^{-1}}{2.02\,\mathrm{kg}\,\mathrm{kmol}^{-1}} +\\[10pt] +&= 2290.68\,\mathrm{kJ}\,\mathrm{kg}^{-1} +\end{align}\]

+
+
+

Then the specific enthalpy difference for natural gas

+
+
+

\[\begin{align} +T_{2NG} &= 404.45\,\mathrm{K}\;\text{ }(\text{Clapeyron.jl}) +\\[10pt] +H_{1NG} &= -369.79\,\mathrm{J}\,\mathrm{mol}^{-1}\;\text{ }(\text{Clapeyron.jl}) +\\[10pt] +H_{2NG} &= 4015.67\,\mathrm{J}\,\mathrm{mol}^{-1}\;\text{ }(\text{Clapeyron.jl}) +\\[10pt] +{\Delta}h_{NG} &= \frac{H_{2NG} - H_{1NG}}{MW_{NG}} +\\[10pt] +&= \frac{4015.67\,\mathrm{J}\,\mathrm{mol}^{-1} + 369.79\,\mathrm{J}\,\mathrm{mol}^{-1}}{16.04\,\mathrm{kg}\,\mathrm{kmol}^{-1}} +\\[10pt] +&= 273.41\,\mathrm{kJ}\,\mathrm{kg}^{-1} +\end{align}\]

+
+
+

Finally, the work ratio of compressing hydrogen versus natural gas

+
+
+

\[\begin{align} +r_{rg} &= \frac{hv_{NG}}{hv_{H2}} \cdot \frac{{\Delta}h_{H2}}{{\Delta}h_{NG}} +\\[10pt] +&= \frac{55.58\,\mathrm{MJ}\,\mathrm{kg}^{-1}}{141.95\,\mathrm{MJ}\,\mathrm{kg}^{-1}} \cdot \frac{2290.68\,\mathrm{kJ}\,\mathrm{kg}^{-1}}{273.41\,\mathrm{kJ}\,\mathrm{kg}^{-1}} +\\[10pt] +&= 3.28 +\end{align}\]

+
+
+
+
+

In this case the ideal gas law estimate and the estimate using a cubic equation of state differ by only 1.0%.

+
+
+
+
+
+
+ +
+
+Figure 2: Relative work required to compress hydrogen versus methane for the 3 compressor stages. +
+
+
+
+
+
+

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.

+
+
+

Final Thoughts

+

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.

+
+
+

References

+
+
+Boyce, Meherwan P., Victor H. Edwards, Terry W. Cowley, Hugh D. Kaiser, Wayne B. Geyer, David Nadel, Larry Skoda, Shawn Testone, and Kenneth L. Walter. “Transport and Storage of Fluids.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+Gmehling, Jürgen, Bärbel Kolbe, Michael Kleiber, and Jürgen Rary. Chemical Thermodynamics for Process Simulation. Weinheim, DE: Wiley-VCH Verlag & Co., 2012. +
+
+GPSA. Engineering Data Book. 13th ed. Tulsa, OK: Gas Processors Suppliers Association, 2012. +
+
+Sendehboudi, Sohrab, and Bahram Gharbani. Hydrogen Production, Transportation, Storage, and Utilization. Amsterdam: Elsevier, 2025. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/hydrogen_compression/thumbnail.png b/posts/hydrogen_compression/thumbnail.png new file mode 100644 index 0000000..171934f Binary files /dev/null and b/posts/hydrogen_compression/thumbnail.png differ diff --git a/posts/hydrogen_release_modeling/index.html b/posts/hydrogen_release_modeling/index.html new file mode 100644 index 0000000..5f82f22 --- /dev/null +++ b/posts/hydrogen_release_modeling/index.html @@ -0,0 +1,1302 @@ + + + + + + + + + + + + +Modelling Hydrogen Releases Using HyRAM+ – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Modelling Hydrogen Releases Using HyRAM+

+
+
+ Hydrogen plume modelling and indoor accumulation. +
+
+
+
python
+
hydrogen
+
dispersion modelling
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

September 22, 2024

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

Continuing on a series of posts on hydrogen of sorts, this post is on modelling hydrogen releases for risk assessment. Industry in many places, and in Alberta in particular, is looking to hydrogen as a key component of the transition to a low carbon future. This means that, suddenly, there will by hydrogen pressure equipment in a lot of process areas where it wasn’t before, as fuel gas.

+

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.

+

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.

+
+

The Scenario

+

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.

+
+
+
+ +
+
+Figure 1: A sketch of the scenario: a hydrogen gas release from a fallen cylinder. +
+
+
+

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 hp
+
+
+
Ta, 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)
+
+
+

Modeling the Jet

+

The jet is modeled, by HyRAM+, as as a steady-state jet consisting of 3 distinct zones:3

+
    +
  1. Orifice flow in which the release occurs isentropically through an orifice. HyRAM+ uses the CoolProp library to perform this calculation for the real fluid (unlike many other models which assume an ideal gas for simplicity). For most situations, such as with this example, the flow will be choked and the jet will enter the atmosphere at sonic velocity (Ma = 1) and under-expanded (i.e. the pressure in the jet is above atmospheric)
  2. +
  3. Notional nozzle the under-expanded jet then expands to ambient pressure. HyRAM+ models this as occuring adiabatically and with no entrainment of ambient air. The expansion occurs across what is termed a notional nozzle as it is modeled as a nozzle stepping down the jet to ambient pressure, assuming isentropic expansion. The notional nozzle is assumed to be of negligible size, so this step is really about calculating the initial conditions for the actual dispersion.
  4. +
  5. Gaussian jet at the end of the notional nozzle, and assuming that no cryogenic effects need to be corrected for, the jet is assumed to follow a self-similar Gaussian profile in both velocity and concentration. It is the same Gaussian model I discussed previously for a turbulent jet, however in that case the jet center-line was simply a straight line. In this case the center-line follows a curve through space which needs to be solved for. This is done using an integral plume model not unlike the Ooms model4 which accounts for entrainment, conservation of momentum, and conservation of mass.
  6. +
+

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_rate
+
+
0.4024272255826383
+
+
+

We can compare this to a simple ideal gas model of an adiabatic orifice5

+

\[ \dot{m} = c_d A_h \sqrt{ \rho_1 p_1 k \left( 2 \over k+1 \right)^{k+1 \over k-1} } \]

+
+
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_jet
+
+
0.37797876222402915
+
+
+
+
jet.mass_flow_rate/ideal_gas_jet
+
+
1.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.

+
+
+

Calculating Downstream Distances

+

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).LFL
+
+

and 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
+
+
+
+
+

Plotting the Plume Dispersion

+

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._contourdata
+
+

we can use matplotlib to plot the concentrations and highlight the contour corresponding to the LFL

+
+
+
+
+

+
+
+
+
+
+
+

Doing it the Easy Way

+

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.

+
+
+

Limitations

+

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.

+
+
+
+

Indoor Accumulation

+

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.

+
+
+
+ +
+
+Figure 2: A sketch of the room, the cylinder is supposed to have fallen by one wall. A layer of hydrogen gas accumulates at the ceiling, with the boundary moving downward as more hydrogen accumulates. +
+
+
+
+

Cylinder Blowdown

+

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.

+

\[ \frac{dm}{dt} = -c_d A \rho v \]

+

\[ \frac{du}{dt} = \frac{1}{m} \frac{dm}{dt} \left( h - u \right) \]

+

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()
+
+

+
+
+

The Indoor Release

+

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()
+
+

+
+
+

Using the HyRAM+ API

+

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'}
+
+
+

+

+

+

+

+
+
+

Limitations

+

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.

+
+
+
+

Final Thoughts

+

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.

+
+
+

References

+
+
+AIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999. +
+
+Ehrhart, Brian D., Ethan S. Hecht, and Benjamin B. Schroeder. Hydrogen Plus Other Alternative Fuels Risk Assessment Models (HyRAM+) Version 5.1 Technical Reference Manual, 2023. https://doi.org/10.2172/2369637. +
+
+Ehrhart, Brian D., Cianan Sims, Ethan S. Hecht, Benjamin B. Schroeder, Benjamin R. Liu, Katrina M. Groth, John T. Reynolds, and Gregory W. Walkup. “HyRAM+ (Hydrogen Plus Other Alternative Fuels Risk Assessment Models).” Sandia National Laboratories, February 8, 2024. https://hyram.sandia.gov. +
+
+Ooms, G. “A New Method for the Calculation of the Plume Path of Gases Emitted by a Stack.” Atmospheric Environment (1967) 6, no. 12 (1972): 899–909. https://doi.org/10.1016/0004-6981(72)90098-4. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-1-3a9e9b1f-08d6-4597-8282-d0568c3ecf51.png b/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-1-3a9e9b1f-08d6-4597-8282-d0568c3ecf51.png new file mode 100644 index 0000000..ea74933 Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-1-3a9e9b1f-08d6-4597-8282-d0568c3ecf51.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-2-72d94995-43ae-446b-9b94-d8687b1798de.png b/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-2-72d94995-43ae-446b-9b94-d8687b1798de.png new file mode 100644 index 0000000..8ff7b1d Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-2-72d94995-43ae-446b-9b94-d8687b1798de.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-3-afea3acc-bbf3-441e-8039-eb35b50d9f3b.png b/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-3-afea3acc-bbf3-441e-8039-eb35b50d9f3b.png new file mode 100644 index 0000000..dae84c1 Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-3-afea3acc-bbf3-441e-8039-eb35b50d9f3b.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-4-bd38e109-3f1c-45e4-ae91-f53944d14238.png b/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-4-bd38e109-3f1c-45e4-ae91-f53944d14238.png new file mode 100644 index 0000000..d9f9594 Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-4-bd38e109-3f1c-45e4-ae91-f53944d14238.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-5-f293a0a3-4d98-4b13-b221-8a8b6a56c3ef.png b/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-5-f293a0a3-4d98-4b13-b221-8a8b6a56c3ef.png new file mode 100644 index 0000000..6539046 Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/0d0d55db-5c7f-4dbb-b894-fb20aeaa5893-5-f293a0a3-4d98-4b13-b221-8a8b6a56c3ef.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/1432e82b-86b5-4efd-ac8a-a85c98cc47ea-1-510fa069-d010-4160-bbc1-6fb345669aca.png b/posts/hydrogen_release_modeling/index_files/figure-html/1432e82b-86b5-4efd-ac8a-a85c98cc47ea-1-510fa069-d010-4160-bbc1-6fb345669aca.png new file mode 100644 index 0000000..0acfc64 Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/1432e82b-86b5-4efd-ac8a-a85c98cc47ea-1-510fa069-d010-4160-bbc1-6fb345669aca.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/354a8335-47cc-4ec1-9bac-9a5dfac1fd3d-1-8d17f0e0-4f8b-40a8-b7ba-d3a87a6254be.png b/posts/hydrogen_release_modeling/index_files/figure-html/354a8335-47cc-4ec1-9bac-9a5dfac1fd3d-1-8d17f0e0-4f8b-40a8-b7ba-d3a87a6254be.png new file mode 100644 index 0000000..ea74933 Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/354a8335-47cc-4ec1-9bac-9a5dfac1fd3d-1-8d17f0e0-4f8b-40a8-b7ba-d3a87a6254be.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/655a46be-7861-4c44-b9b3-dd7c869ac67a-1-c098687a-837e-48f0-ae23-9cd5fbaf9500.png b/posts/hydrogen_release_modeling/index_files/figure-html/655a46be-7861-4c44-b9b3-dd7c869ac67a-1-c098687a-837e-48f0-ae23-9cd5fbaf9500.png new file mode 100644 index 0000000..8c2fa1e Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/655a46be-7861-4c44-b9b3-dd7c869ac67a-1-c098687a-837e-48f0-ae23-9cd5fbaf9500.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/736ae8c3-5c97-4879-a0ef-8704ba348679-1-d475e27c-27c4-465d-99fa-196684c1f7da.png b/posts/hydrogen_release_modeling/index_files/figure-html/736ae8c3-5c97-4879-a0ef-8704ba348679-1-d475e27c-27c4-465d-99fa-196684c1f7da.png new file mode 100644 index 0000000..d9f9594 Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/736ae8c3-5c97-4879-a0ef-8704ba348679-1-d475e27c-27c4-465d-99fa-196684c1f7da.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/7cf751fd-8561-4fa2-b93f-909fb90a3fa7-1-8572b75f-1e38-4394-bd2a-c2c2fd005e31.png b/posts/hydrogen_release_modeling/index_files/figure-html/7cf751fd-8561-4fa2-b93f-909fb90a3fa7-1-8572b75f-1e38-4394-bd2a-c2c2fd005e31.png new file mode 100644 index 0000000..b4daeec Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/7cf751fd-8561-4fa2-b93f-909fb90a3fa7-1-8572b75f-1e38-4394-bd2a-c2c2fd005e31.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/cell-11-output-2.png b/posts/hydrogen_release_modeling/index_files/figure-html/cell-11-output-2.png new file mode 100644 index 0000000..bc0cade Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/cell-11-output-2.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/cell-15-output-1.png b/posts/hydrogen_release_modeling/index_files/figure-html/cell-15-output-1.png new file mode 100644 index 0000000..f369c2a Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/cell-15-output-1.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/ebac05ea-0df1-4764-b815-386f44a1c0eb-1-c367c681-01ca-4ac5-8c70-69cffef33ac3.png b/posts/hydrogen_release_modeling/index_files/figure-html/ebac05ea-0df1-4764-b815-386f44a1c0eb-1-c367c681-01ca-4ac5-8c70-69cffef33ac3.png new file mode 100644 index 0000000..0681eff Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/ebac05ea-0df1-4764-b815-386f44a1c0eb-1-c367c681-01ca-4ac5-8c70-69cffef33ac3.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/f1247962-7b07-4929-bc8d-be7dffbbee64-1-9785d155-d308-4c3c-96eb-ee3412136f7f.png b/posts/hydrogen_release_modeling/index_files/figure-html/f1247962-7b07-4929-bc8d-be7dffbbee64-1-9785d155-d308-4c3c-96eb-ee3412136f7f.png new file mode 100644 index 0000000..7661311 Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/f1247962-7b07-4929-bc8d-be7dffbbee64-1-9785d155-d308-4c3c-96eb-ee3412136f7f.png differ diff --git a/posts/hydrogen_release_modeling/index_files/figure-html/fc183c69-0a00-4c32-9388-2fda4fb7397e-1-95027319-589e-4d19-88ff-d62295e0357a.png b/posts/hydrogen_release_modeling/index_files/figure-html/fc183c69-0a00-4c32-9388-2fda4fb7397e-1-95027319-589e-4d19-88ff-d62295e0357a.png new file mode 100644 index 0000000..8ff7b1d Binary files /dev/null and b/posts/hydrogen_release_modeling/index_files/figure-html/fc183c69-0a00-4c32-9388-2fda4fb7397e-1-95027319-589e-4d19-88ff-d62295e0357a.png differ diff --git a/posts/impossible_bowling/index.html b/posts/impossible_bowling/index.html new file mode 100644 index 0000000..d5a1dbd --- /dev/null +++ b/posts/impossible_bowling/index.html @@ -0,0 +1,1343 @@ + + + + + + + + + + + + +Impossible bowling – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Impossible bowling

+
+
+ Looking for impossible bowling games. +
+
+
+
python
+
bowling
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

November 26, 2023

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

While bowling, this week, an interesting question came up: is it possible to get every score from 1 to 450 in a game of five pin bowling? Or, to flip it around, is there a score that you can never get no matter how fancy your bowling? The answer is not immediately obvious!

+
+

The rules of five pin bowling

+

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.

+
+
+
+ +
+
+Figure 1: The points value of each pin in five-pin bowling. +
+
+
+

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.

+
+
+

Trying everything

+

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.

+
+
+
+ +
+
+Note +
+
+
+

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 \(m^n\): \(4^5 = 1024\)

+

The tenth frame has some extra rules, so lets go through it:

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FrameNumber of Possibilitiesdescription
? ? ?1024Any combination of the first set of 5 pins
X ? ?35 -1 = 242Every follow up to a strike except X–, which has already been counted in ???
X X ?25 -1 = 31Every follow up to 2 strikes except XX-, which has already been counted in X??
? \ ?25 × (25-1) = 992Every 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 \(2289 \times 1024^9\) which is about \(2.8 \times 10^{30}\)

+
+
+
+
+

Nothing fancy

+

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 \(x \le 15 n\).

+

If \(x \not \equiv 1 (\textrm{mod} 15)\) and \(x \not \equiv 14 (\textrm{mod} 15)\) then you can always get from a multiple of 15 to the final score in one frame.

+

If \(x \equiv 1 (\textrm{mod} 15)\) or \(x \equiv 14 (\textrm{mod} 15)\) 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 \(1 \lt x \le 15 (n-1)\) there are \(\ge 2\) frames remaining, all of those scores can thus be achieved.

+

What remains is the x such that \(15(n-1) \lt x \lt 15 n\) and \(x \equiv 14 (\textrm{mod} 15)\), 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: \(0 \le score \le 150\) 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.

+
+

Detour: what about with no gutters?

+

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]
+
+
+
+
+
+

Sparing no effort

+

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:

+
    +
  • what was scored in the frame
  • +
  • whether a strike or a spare was recorded
  • +
  • whether this is the end of the last frame (i.e am I done bowling yet?)
  • +
+

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 = end
+
+

Instead 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) <= up
+
+

The 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: \(0 \le score \le 300\) 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.

+
+
+

In striking distance of the final answer

+

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 <= up
+
+

At 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: \(0 \le score \le 450\) 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.

+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/impossible_bowling/index_files/figure-html/5fe67f81-f378-4258-a005-1914e099b559-1-31ff655b-8ba9-40cd-a9af-7ff1538b1345.png b/posts/impossible_bowling/index_files/figure-html/5fe67f81-f378-4258-a005-1914e099b559-1-31ff655b-8ba9-40cd-a9af-7ff1538b1345.png new file mode 100644 index 0000000..a9c739f Binary files /dev/null and b/posts/impossible_bowling/index_files/figure-html/5fe67f81-f378-4258-a005-1914e099b559-1-31ff655b-8ba9-40cd-a9af-7ff1538b1345.png differ diff --git a/posts/impossible_bowling/mandell-smock-unsplash.jpg b/posts/impossible_bowling/mandell-smock-unsplash.jpg new file mode 100644 index 0000000..782405e Binary files /dev/null and b/posts/impossible_bowling/mandell-smock-unsplash.jpg differ diff --git a/posts/indoor_air_quality/index.html b/posts/indoor_air_quality/index.html new file mode 100644 index 0000000..5cf4c05 --- /dev/null +++ b/posts/indoor_air_quality/index.html @@ -0,0 +1,1091 @@ + + + + + + + + + + + + +Monitoring smoke infiltration – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Monitoring smoke infiltration

+
+
+ Better indoor air quality through data. +
+
+
+
julia
+
air quality
+
atmotube
+
building infiltration
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

May 22, 2023

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

A few years ago I mused about using wildfire smoke events to measure the infiltration rate of buildings, in the context of modeling the infiltration of air pollution into buildings. Well it is wildfire season again and this past weekend saw a thick haze descend upon Edmonton, with airborne particulate concentrations, pm2.5 specifically, exceeding 440 μg/m3 in my neighbourhood.

+

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.

+
+

Measuring building infiltration

+

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.

+
+

Outdoor particulate concentration

+

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, Pipe
+
+
+
start = 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);
+
+
+
+

Indoor particulate concentration

+

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
+end
+
+

Plotting 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.

+
+
+
+
+
+ +
+
+Figure 1: Time series data for indoor and outdoor pm2.5 concentrations with the 1 hour AAQO indicated. +
+
+
+
+
+
+
+

Fitting the 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

+

\[ {d \over dt} c = \lambda \left( c\_o - c \right) \]

+

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());
+
+
+
+
+
+
+ +
+
+Figure 2: Best fit curve for the simple linear building infiltration model. +
+
+
+
+
+

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.

+
+
+
+

Using a HEPA Filter

+

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);
+
+
+
+
+
+
+ +
+
+Figure 3: Response of measured indoor particulate concentrations to air filtration. Note that the outdoor fine particulate concentration remains high throughout the measurement period. +
+
+
+
+
+
+
+

Final thoughts

+

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.

+ + +
+ +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/indoor_air_quality/index_files/figure-html/fig-curve-fit-output-1.svg b/posts/indoor_air_quality/index_files/figure-html/fig-curve-fit-output-1.svg new file mode 100644 index 0000000..e38a345 --- /dev/null +++ b/posts/indoor_air_quality/index_files/figure-html/fig-curve-fit-output-1.svg @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/indoor_air_quality/index_files/figure-html/fig-hepa-output-1.svg b/posts/indoor_air_quality/index_files/figure-html/fig-hepa-output-1.svg new file mode 100644 index 0000000..dc17e4d --- /dev/null +++ b/posts/indoor_air_quality/index_files/figure-html/fig-hepa-output-1.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/indoor_air_quality/index_files/figure-html/fig-time-series-output-1.svg b/posts/indoor_air_quality/index_files/figure-html/fig-time-series-output-1.svg new file mode 100644 index 0000000..1ca9a6a --- /dev/null +++ b/posts/indoor_air_quality/index_files/figure-html/fig-time-series-output-1.svg @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/integrated_puff/index.html b/posts/integrated_puff/index.html new file mode 100644 index 0000000..a809644 --- /dev/null +++ b/posts/integrated_puff/index.html @@ -0,0 +1,1151 @@ + + + + + + + + + + + + +Between a puff and a plume – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Between a puff and a plume

+
+
+ An integrated Gaussian puff model +
+
+
+
julia
+
dispersion modelling
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

June 10, 2022

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

In previous examples I used both Gaussian plume and puff models for continuous and instantaneous releases, respectively, but what about the in-between cases? It is more commonly the case for a leak from a process vessel to be a prolonged, but finitely long, release.

+

The guidance is to typically pick one or the other, depending upon the length of the release, or use a more complex model, e.g. the guidance in Lees1 is

+ ++++ + + + + + + + + + + + + + + +
\[ u \Delta t \lt 2 \sigma_x \]Use puff model
\[ 2 \sigma_x \lt u \Delta t \lt 5 \sigma_x\]Neither model entirely appropriate
\[ u \Delta t \gt 5 \sigma_x \]Use plume model
+

Where u is the wind speed, Δt the duration of the release, and \(\sigma_x\) is the downwind dispersion evaluated at \(x = \frac{u \Delta t}{2}\).2

+

2 These criteria drive one to using a plume model in most cases that are not “instantaneous”. Suppose \(\sigma_x = \alpha x^\beta\) then the criteria for a puff is \(u \Delta t \lt 2 \alpha^{1 \over 1-\beta}\) which, for a class D release with windspeed of 2m/s works out to \(\Delta t \lt 5 \times 10^{-16} \mathrm{s}\). The criteria for a plume is \(u \Delta t \gt 2 \left( 2.5 \alpha \right)^{1 \over 1-\beta}\) which works out to \(\Delta t \gt 5 \times 10^{-11} \mathrm{s}\) for the same situation. So for any release of appreciable duration it will be a plume.

An alternative approach is to evaluate the downwind dispersion at a particular point of interest x1 and use the same criteria. This is much less strict than what Lees gives and is what I will do, it motivates investigating anything other than pure plume models.

+

One approach in this in-between zone is to try both and pick the most conservative. But that can lead to extremely conservative results. An alternative might be to take a page from more complex models, such as INTPUFF and SCIPUFF, and treat an intermediate release as a series of smaller puff releases.

+
+

Motivating example

+

Suppose a release from a process vessel, say a jet of gas issuing from a hole, we suppose the release is a constant rate of 1kg/s for 5s just for some nice round numbers. The release is at ground level and the ambient conditions are class D with a 2m/s windspeed. We have a point of interest 100m down-wind of the release, this could be an inhabited building or the fence-line.3

+

3 We are also implicitly assuming the release is neutrally buoyant, and so a Gaussian dispersion model would be appropriate.

+
m  = 1 #kg/s
+Δt = 5 #s
+h  = 0 #m
+u  = 2 #m/s
+
+x₁ = 100  #m
+t₁ = x₁/u #s
+
+

The class D dispersion parameters4 are:

+
+
# class D puff dispersion
+
+σx(x) = 0.06*x^0.92
+σy(x) = σx(x)
+σz(x) = 0.15*x^0.70
+
+

The release, at the point of interest, meets neither the criteria for a puff model nor a plume model.

+
+
u*Δt < 2*σx(x₁)
+
+
false
+
+
+
+
u*Δt > 5*σx(x₁)
+
+
false
+
+
+

So some other kind of model must be used.

+
+
+

Single Gaussian puff

+

Recall that a single Gaussian puff is the product of 3 Gaussian distributions

+

\[ c \left(x,y,z,t \right) = m \Delta t \cdot g_x(x, t) \cdot g_y(y) \cdot g_z(z) \]

+

with

+

\[ g_x(x,t) = {1 \over \sqrt{2\pi} \sigma_x } \exp \left( -\frac{1}{2} \left( x-u t \over \sigma_x \right)^2 \right) \]

+

\[ g_y(y) = {1 \over \sqrt{2\pi} \sigma_y } \exp \left( -\frac{1}{2} \left( y \over \sigma_y \right)^2 \right) \]

+

\[ g_z(z) = {1 \over \sqrt{2\pi} \sigma_z } \left[ \exp \left( -\frac{1}{2} \left( z-h \over \sigma_z \right)^2 \right) + \exp \left( -\frac{1}{2} \left( z+h \over \sigma_z \right)^2 \right) \right]\]

+

where, for the sake of clarity, I’ve neglected the fact that the dispersion parameters σ are themselves all functions of t by being functions of the location of the center of the puff.

+
+
gx(x, t) = exp((-1/2)*((x-u*t)/σx(u*t))^2)/((2π)*σx(u*t))
+gy(y, t) = exp((-1/2)*(y/σy(u*t))^2)/((2π)*σy(u*t))
+gz(z, t) = (exp((-1/2)*((z-h)/σz(u*t))^2)+exp((-1/2)*((z+h)/σz(u*t))^2))/((2π)*σz(u*t))
+
+
+
c_pf(x,y,z,t; m, Δt) = m*Δt*gx(x,t)*gy(y,t)*gz(z,t)
+
+

For some context we can plot the puff as a single, instantaneous, release

+
+
+
+
+ +
+
+Figure 1: The dispersion of a single, instantaneous, puff. +
+
+
+
+
+
+

Multiple puffs

+

Our first approximation to a release of appreciable duration is to break the release up into n intervals and release a single puff per interval.

+

\[ c(x,y,z,t) = \sum_{i=0}^{n} m \frac{\Delta t}{n} \cdot g_x(x, t-\frac{i}{n}\Delta t) \cdot g_y(y) \cdot g_z(z) \]

+

where we have simply taken the sum of n single puffs, each representing a fraction of the overall release, and emitting them one after the other.

+
+
function sum_of_puffs(x,y,z,t; m, Δt, n)
+    c = 0
+    δt = Δt/n
+    for i in 0:1:n
+        t′ = t-i*δt
+        c′  = t′>0 ? c_pf(x,y,z,t′; m=m, Δt=δt) : 0
+        c += isnan(c′) ? 0 : c′ 
+    end
+    return c
+end
+
+

We can plot this for n=5 and we see that, while initially the individual puffs are distinct, they quickly merge into a larger more spread-out cloud.5

+

5 To an extent this is a function of using a class D atmosphere. For a much more stable atmosphere, e.g. class F, the puffs remain quite distinct until a size-able number of them have been released.

+
+
+
+ +
+
+Figure 2: The dispersion of a sequence of multiple smaller puffs. +
+
+
+
+

If we plot the max-concentration experienced at our point of interest, x=100m, against an increasingly finely-divided release (more puffs, but each puff represents a smaller slice of time) it is clear that they are converging towards a number, and relatively quickly.

+
+
+
+
+
+ +
+
+Figure 3: The effect of increasing the number of puffs +
+
+
+
+
+

This suggests a next step, taking the limit as \(n \to \infty\)

+
+
+

Integrated puffs

+

Returning to our model of multiple puffs

+

\[ c(x,y,z,t) = \sum_{i=0}^{n} m \frac{\Delta t}{n} \cdot g_x(x, t-\frac{i}{n}\Delta t) \cdot g_y(y) \cdot g_z(z) \]

+

We can re-arrange this and take the limit as \(n \to \infty\)

+

\[ c(x,y,z,t) = m\cdot g_y(y) \cdot g_z(z) \cdot \left( \lim_{n \to \infty} \sum_{i=0}^{n} g_x(x, t-\frac{i}{n}\Delta t) \frac{\Delta t}{n} \right) \]

+

\[ = m\cdot g_y(y) \cdot g_z(z) \cdot \int_{t-\Delta t}^{t} g_x(x, t^{\prime}) dt^{\prime}\]

+

Where we have replaced the limit with the integral.6

+

6 I am assuming the dispersion parameters are constants, though they are not in practice as they are correlated to the downwind distance to the center of any given puff. I am assuming for a small enough release this is approximately constant at least.

This is an integral of a Gaussian, and so we expect the results to be in terms of the error function

+

\[ \mathrm{erf}(x) = \frac{2}{\sqrt{\pi}} \int_0^x \exp \left( -t^2 \right) dt \]

+

For the integral of the x component of the Gaussian puff we have

+

\[ \int_{t-\Delta t}^{t} g_x(x, t^{\prime}) dt^{\prime} = \int_{t-\Delta t}^{t} {1 \over \sqrt{2\pi} \sigma_x } \exp \left( -\frac{1}{2} \left( x-u t^{\prime} \over \sigma_x \right)^2 \right) dt^{\prime}\]

+

making the substitution \[\xi = { {x - u t^{\prime} } \over \sqrt{2} \sigma_x} \]

+

we get7

+

7 This model and the sum of puffs model both naively include contributions from releases that haven’t happened yet, e.g. at t=1 only the contribution of material released at times t≤1 should be included, but without any correction the other parts of the release would be included causing slight errors in the vicinity of the release point at t<Δt. The solution is simply to take the duration of the release to be the minimum of either the elapsed time (i.e. when the release is still “happening”) or the total release duration.

\[ \int_{t-\Delta t}^{t} g_x(x, t^{\prime}) dt^{\prime} = {-1 \over \sqrt{\pi} u} \int_{a}^{b} \exp \left( -\xi^2 \right) d\xi \]

+

\[ = {-1 \over \sqrt{\pi} u} \left[ \frac{\sqrt{\pi}}{2} \mathrm{erf}(b) - \frac{\sqrt{\pi}}{2} \mathrm{erf}(a) \right] \]

+

\[ = \frac{1}{2u} \left( \mathrm{erf}(a) - \mathrm{erf}(b) \right)\]

+

where

+

\[a = { {x - u (t-\Delta t)} \over \sqrt{2} \sigma_x }\]

+

\[b = { {x - u t} \over \sqrt{2} \sigma_x } \]

+
+
using SpecialFunctions: erf
+
+function ∫gx(x,t,Δt)
+    Δt = min(t,Δt)
+    a  = (x-u*(t-Δt))/(√2*σx(u*(t-Δt)))
+    b  = (x-u*t)/(√2*σx(u*t))
+    return erf(b,a)/(2u)
+end
+
+intpuff(x,y,z,t; m, Δt) = m*gy(y,x/u)*gz(z,x/u)*∫gx(x,t,Δt)
+
+
+
+
+
+ +
+
+Figure 4: The dispersion of the integrated puff model, assuming constant dispersion/ +
+
+
+
+

This release model has some convenient properties: clearly as \(\Delta t \to 0\) it becomes a Gaussian puff again, but also as \(\Delta t \to \infty\) also limits to the Gaussian plume.8

+

8 This is somewhat hand-wavy but a release of infinite duration is an event that began an infinite amount of time in the past and continues an infinite amount into the future, so the term \(\frac{1}{2u} \left( \mathrm{erf}(a) - \mathrm{erf}(b) \right)\) goes in the limit to \(\frac{1}{2u} \left( \mathrm{erf}(\infty) - \mathrm{erf}(-\infty) \right) = \frac{1}{u}\) resulting in a concentration profile of \(c \left(x,y,z,t \right) = \frac{m}{u} \cdot g_y(y) \cdot g_z(z)\), which is exactly a plume model.

+
+
+
+
+ +
+
+Figure 5: The limiting behaviour of the integrated puff model, it smoothly connects the single puff model and the plume model when using the same dispersion constants. +
+
+
+
+
+
+
+

Complications

+

I’ve been casually treating the dispersion parameters, \(\sigma_x, \sigma_y, \sigma_z\), as being constants that are independent of the model and any transformations on the model. Within the context of taking sums and doing integrals it is reasonable: within a reasonable radius of the center of a given puff they are nearly constant. However, in practice, they depend upon time through their dependence on the location of the puff center and are also functions of the model itself.

+

The dispersion parameters for a plume model are not the same as for a puff, and so the nice smooth curve connecting the two doesn’t really work. Not if you are strictly taking dispersion parameters as provided in standard references. It is not at all clear how to transition from the one set to the other either, in a smooth manner, to ensure that there is a smooth transition from puff to plume.

+

That said, multiple puff models use the dispersion parameters for puffs and so using the puff parameters in the integrated puff model at least puts one in good company.

+
+
+
+ +
+
+WarningUpdate +
+
+
+

There is a follow-up post that discusses the quality of these approximations in more detail.

+

When I first wrote this post I could not find my final result in the literature – it wasn’t in the standard references I use, and I think I just didn’t know the right search terms. Though it seemed equally obvious to me that it must be in the literature somewhere. Since posting this, I found it: Palazzi et al.9 This is also the model used by ALOHA and the older ARCHIE models.

+
+
+
+
+

References

+
+
+AIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999. +
+
+Lees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996. +
+
+Palazzi, E, M De Faveri, Giuseppe Fumarola, and G Ferraiolo. “Diffusion from a Steady Source of Short Duration.” Atmospheric Environment 16, no. 12 (1982): 2785–90. https://www.researchgate.net/publication/328744668_DIFFUSION_FROM_A_STEADY_SOURCE_OF_SHORT_DURATION. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/integrated_puff/index_files/figure-html/fig-int-limiting-output-1.svg b/posts/integrated_puff/index_files/figure-html/fig-int-limiting-output-1.svg new file mode 100644 index 0000000..ad98e10 --- /dev/null +++ b/posts/integrated_puff/index_files/figure-html/fig-int-limiting-output-1.svg @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/integrated_puff/index_files/figure-html/fig-n-puffs-output-1.svg b/posts/integrated_puff/index_files/figure-html/fig-n-puffs-output-1.svg new file mode 100644 index 0000000..532088c --- /dev/null +++ b/posts/integrated_puff/index_files/figure-html/fig-n-puffs-output-1.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/integrated_puff/int_puff.png b/posts/integrated_puff/int_puff.png new file mode 100644 index 0000000..2e57709 Binary files /dev/null and b/posts/integrated_puff/int_puff.png differ diff --git a/posts/intpuff2_successive_approximations/index.html b/posts/intpuff2_successive_approximations/index.html new file mode 100644 index 0000000..bc9920b --- /dev/null +++ b/posts/intpuff2_successive_approximations/index.html @@ -0,0 +1,1197 @@ + + + + + + + + + + + + +Integrating a Gaussian puff - mistakes were made – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Integrating a Gaussian puff - mistakes were made

+
+
+ Successive approximations to … an integrated gaussian puff model. +
+
+
+
julia
+
dispersion modelling
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

January 15, 2023

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

The other day I was working on a project involving Gaussian puff models and I noticed that I had made a significant mistake, a mistake I have made several times without noticing, and one that invalidated a whole bunch of work I that I had done previously, so I thought this would be a good opportunity to examine my mistake and it’s consequences.

+
+

The Gaussian puff model

+

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:

+

\[ c \left(x,y,z,t \right) = \dot{m} \Delta t \cdot g_x(x, t) \cdot g_y(y) \cdot g_z(z) \]

+

where

+

\[ g_x(x,t) = {1 \over \sqrt{2\pi} \sigma_x } \exp \left( -\frac{1}{2} \left( x-u t \over \sigma_x \right)^2 \right) \]

+

\[ g_y(y) = {1 \over \sqrt{2\pi} \sigma_y } \exp \left( -\frac{1}{2} \left( y \over \sigma_y \right)^2 \right) \]

+

\[ g_z(z) = {2 \over \sqrt{2\pi} \sigma_z } \exp \left( -\frac{1}{2} \left( z \over \sigma_z \right)^2 \right) \]

+

Where \(\dot{m}\) 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.61
+
+
+
+

Integrating the puff

+

What 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 \({ \Delta t \over n }\). Taking the limit as \(n \to \infty\) 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 \(g_{x}(x,t)\) 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 \(x_c = u t\), 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

+

\[ \sigma = { C^2 \over 2 } \left( u t \right)^{2-n} \]

+

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.

+

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.

+
+
+

Dispersion nearly-constants

+

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 \([ x - \sigma_x, x + \sigma_x ]\). 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.

+
+
+
+
+
+ +
+
+Figure 1: The relative change in dispersion parameters, ±1σ, as a function of downwind distance. +
+
+
+
+
+
+
+

Different approaches to approximation

+

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 \[ t = { u t^{\prime} \over L } \]

+

and unit-less distances

+

\[ x = {x^{\prime} \over L } \\ y = {y^{\prime} \over L } \\ z = {z^{\prime} \over L } \]

+

where I am abusing notation with the \(\prime\) indicates the variable with units, and no \(\prime\) indicates it is unitless. A characteristic length, \(L\), is introduced to make everything unitless and, due to the dispersion correlations \(L = 1 \mathrm{m}\) 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 \(\dot{m}\) 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)*Δt
+
+
+

Sum of discrete puffs

+

The 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
+end
+
+
+
+

Integrating assuming constant σs

+

The next type of approximation is the one I made in the previous post wherein \(g_x(x,t)\) 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)
+
+
+
+

Numerically integrating the full model

+

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
+end
+
+
+
+
+

Comparing performance

+
+

Model error

+

To 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

+
+
+
+
+
+ +
+
+Figure 2: Top: concentration profile over time, at a fixed location, for discrete sums of puffs, the approximated integral, and the exact integral. Bottom: the relative error of a sum of discrete puffs and the approximate integral. +
+
+
+
+
+

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.

+
+
+
+
+
+ +
+
+Figure 3: Top: crosswind concentration profile, at a fixed location and time, for discrete sums of puffs, the approximated integral, and the exact integral. Bottom: the relative error of a sum of discrete puffs and the approximate integral. +
+
+
+
+
+
+
+
+
+
+ +
+
+Figure 4: Top: vertical concentration profile, at a fixed location and time, for discrete sums of puffs, the approximated integral, and the exact integral. Bottom: the relative error of a sum of discrete puffs and the approximate integral. +
+
+
+
+
+

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.

+
+
+
+
+
+ +
+
+Figure 5: Top: concentration profile over time near the origin, for discrete sums of puffs, the approximated integral, and the exact integral. Bottom: the relative error of a sum of discrete puffs and the approximate integral. +
+
+
+
+
+
+
+

Compute time

+

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 (minmax):  28.056 μs57.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 (minmax):  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 (minmax):  37.090 μs77.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 (minmax):  534.974 ns988.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.

+
+
+
+

Conclusions

+

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.

+
+
+

References

+
+
+AIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999. +
+
+Lees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996. +
+
+Slade, David H. Meteorology and Atomic Energy. Springfield, VA: National Technical Information Service, 1968. https://doi.org/10.2172/4492043. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/intpuff2_successive_approximations/index_files/figure-html/fig-crosswind-error-output-1.svg b/posts/intpuff2_successive_approximations/index_files/figure-html/fig-crosswind-error-output-1.svg new file mode 100644 index 0000000..3199d9a --- /dev/null +++ b/posts/intpuff2_successive_approximations/index_files/figure-html/fig-crosswind-error-output-1.svg @@ -0,0 +1,300 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/intpuff2_successive_approximations/index_files/figure-html/fig-disp-consts-output-1.svg b/posts/intpuff2_successive_approximations/index_files/figure-html/fig-disp-consts-output-1.svg new file mode 100644 index 0000000..3b61b3c --- /dev/null +++ b/posts/intpuff2_successive_approximations/index_files/figure-html/fig-disp-consts-output-1.svg @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/intpuff2_successive_approximations/index_files/figure-html/fig-near-origin-output-1.svg b/posts/intpuff2_successive_approximations/index_files/figure-html/fig-near-origin-output-1.svg new file mode 100644 index 0000000..a8ec995 --- /dev/null +++ b/posts/intpuff2_successive_approximations/index_files/figure-html/fig-near-origin-output-1.svg @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/intpuff2_successive_approximations/index_files/figure-html/fig-time-error-output-1.svg b/posts/intpuff2_successive_approximations/index_files/figure-html/fig-time-error-output-1.svg new file mode 100644 index 0000000..a0b47de --- /dev/null +++ b/posts/intpuff2_successive_approximations/index_files/figure-html/fig-time-error-output-1.svg @@ -0,0 +1,318 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/intpuff2_successive_approximations/index_files/figure-html/fig-vertical-error-output-1.svg b/posts/intpuff2_successive_approximations/index_files/figure-html/fig-vertical-error-output-1.svg new file mode 100644 index 0000000..cf8b3d5 --- /dev/null +++ b/posts/intpuff2_successive_approximations/index_files/figure-html/fig-vertical-error-output-1.svg @@ -0,0 +1,306 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/menagerie_of_integrated_plumes/index.html b/posts/menagerie_of_integrated_plumes/index.html new file mode 100644 index 0000000..575a820 --- /dev/null +++ b/posts/menagerie_of_integrated_plumes/index.html @@ -0,0 +1,891 @@ + + + + + + + + + + + + +Vessel Blowdown and Dispersion – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Vessel Blowdown and Dispersion

+
+
+ Considering the Gaussian dispersion of an isothermal blowdown case. +
+
+
+
julia
+
compressible flow
+
blowdown
+
dispersion modelling
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

November 25, 2025

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

Continuing on from where I left off previously, examining vessel blowdown,

+

\[ +\frac{w}{w_0} = \exp \left( \frac{-t}{\tau} \right) +\]

+

\[ +\frac{1}{\tau} = \frac{c_D A}{V} \sqrt{ {k P_0} \over \rho_0 } \left( 2 \over {k+1} \right)^{\frac{k+1}{2 \left( k - 1 \right)} } +\]

+
+
using SpecialFunctions
+
+ + + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/ooms_plume_model/havens_constants.png b/posts/ooms_plume_model/havens_constants.png new file mode 100644 index 0000000..1f09d08 Binary files /dev/null and b/posts/ooms_plume_model/havens_constants.png differ diff --git a/posts/ooms_plume_model/index.html b/posts/ooms_plume_model/index.html new file mode 100644 index 0000000..9885be8 --- /dev/null +++ b/posts/ooms_plume_model/index.html @@ -0,0 +1,2154 @@ + + + + + + + + + + + + +The Ooms Plume Model – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

The Ooms Plume Model

+
+
+ An integral plume model for buoyant plumes. +
+
+
+
julia
+
dispersion modelling
+
integral plume models
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

June 15, 2025

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

I have been interested in the Ooms plume model1 for a long time, but I haven’t really set aside the time to really play around with it because the implementation details are surprisingly sparse. A recent weekend project of mine was to sit down and work out what the actual model equations are and get it running in julia. Something which might be useful to you if you are looking to run one of the O.G. integral plume models.

+
+

The Ooms Plume Model

+

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.

+
+
+
+ +
+
+Figure 1: A sketch of the plume and the coordinate system. +
+
+
+

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 \(\sqrt{2}b\), 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.

+
+
+
+ +
+
+Figure 2: A differential element of the plume along the plume center-line. +
+
+
+

The Ooms model comes from the conservation relations for this differential element.

+
+

Conservation of…

+
+

Mass

+

The mass exiting the differential element is equal to the mass entering through the plume plus the entrained air.

+

\[ m_{out} = m_{in} + m_{ent} \]

+

The mass of entrained air is simply the product of the mass flux (ρE) and the area:

+

\[ m_{ent} = \rho_a E \cdot 2\pi \left( \sqrt{2} b \right) ds \]

+

Giving a mass balance equation:

+

\[ \frac{d}{ds} m = 2\pi \rho_a b \left( \sqrt{2} E \right) \]

+

The mass passing through a surface is simply the mass flux G = ρ u integrated over the surface area:

+

\[ m = \int_{A_{in}} \rho u dA = \int_0^{2\pi} \int_0^{\sqrt{2}b} \rho u r dr d\phi \] \[ m = 2\pi \int_{0}^{\sqrt{2}b} \rho u r dr \]

+

Finally giving

+

\[ 2\pi \frac{d}{ds} \int_{0}^{\sqrt{2}b} \rho u r dr = 2\pi \rho_a b \left( \sqrt{2} E \right) \] \[ \frac{d}{ds} \int_{0}^{\sqrt{2}b} \rho u r dr = \rho_a b E \]

+
+
+
+ +
+
+Note +
+
+
+

An errant \(\sqrt{2}\) 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 = \(\sqrt{2}b_{gauss}\), when the constants are scaled by a factor of \(\sqrt{2}\) the two look the same.

+
+
+
+
+

Species

+

The total mass of the vented substance is conserved as the plume expands. Assuming the vent is some species i with mass concentration c:

+

\[ \frac{d}{ds} m_{i} = 0 \] \[ m_i = \int_0^{2\pi} \int_0^{\sqrt{2}b} c u r dr d\phi = 2\pi \int_0^{\sqrt{2}b} c u r dr \] \[ \frac{d}{ds} \int_0^{\sqrt{2}b} c u r dr = 0 \]

+
+
+

Momentum

+

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 \(u_a\).

+

In the x-direction the total momentum into the differential element is the mass in times the velocity component in the x direction:

+

\[ p_{x,in} = \int_{A_{in}} \rho_{in} u_{in} u_{x,in} dA = \int_{A_{in}} \rho_{in} u_{in}^2 \cos\theta_{in} dA \]

+

And similarly for the total momentum leaving the element

+

\[ p_{x,out} = \int_{A_{out}} \rho_{out} u_{out} u_{x,out} dA = \int_{A_{out}} \rho_{out} u_{out}^2 \cos\theta_{out} dA \]

+

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.

+

\[ p_{x,out} - p_{x,in} = m_{ent} u_a + F_{d,x} \]

+

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, \(u_a \sin \theta\). Drag then follows the standard relationship, with the area being the outside surface area of the cylinder.

+

\[ F_{d} = \frac{1}{2} C_d A_{\perp} \rho_a v^2 = \frac{1}{2} C_d A_{\perp} \rho_a u_a^2 \sin^2 \theta \]

+

The drag force in the x-direction is acting on the area perpendicular to the x-direction

+

\[ F_{d,x} = \frac{1}{2} C_d \rho_a u_a^2 \sin^2 \theta 2\pi \left(\sqrt{2}b\right) | \sin \theta | ds = \pi b C_d \rho_a u_a^2 | \sin^3 \theta | ds\]

+

Where the absolute value comes from the drag force being always positive.

+

Giving

+

\[ 2 \frac{d}{ds} \int_{0}^{\sqrt{2}b} \rho u^2 \cos \theta r dr = 2 b \rho_a u_a E + \pi b C_d \rho_a u_a^2 | \sin^3 \theta | \]

+

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:

+

\[ F_b = \int_V g \left(\rho_a - \rho \right) dV = 2\pi ds \int_0^{\sqrt{2}b} g \left(\rho_a - \rho \right) rdr \cdot \]

+

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:

+

\[ 2 \frac{d}{ds} \int_{0}^{\sqrt{2}b} \rho u^2 \sin \theta r dr = 2 \int_0^{\sqrt{2}b} g \left(\rho_a - \rho \right) r dr + \mathrm{sgn}\theta \cdot \pi b C_d \rho_a u_a^2 \sin^2 \theta \cos \theta \]

+

Where \(\mathrm{sgn} \theta\) ensures the drag force is acting in the right direction.

+
+
+

Energy

+

Starting from an energy balance, using the ambient temperature as the reference temperature, the enthalpy entering the differential element is:

+

\[ H_{in} = \int_{A_{in}} \rho u_{in} c_p \left( T - T_{a,0} \right) dA \]

+

Similarly for the enthalpy out, giving an enthalpy change over the element of:

+

\[ d \left( \int_{A} \rho u_{in} c_p \left( T - T_{a,0} \right) dA \right) = d \left( 2\pi \int_0^{\sqrt{2}b} \rho u c_p \left( T - T_{a,0} \right) r dr \right) \]

+

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:

+

\[ \rho_a E c_{p,a} \left( T_a - T_{a,0} \right) \cdot 2\pi b ds \]

+

Putting it all together we get the energy balance:

+

\[ \frac{d}{ds} \int_0^{\sqrt{2}b} \rho u c_p \left( T - T_{a,0} \right) r dr = b \rho_a E c_{p,a} \left( T_a - T_{a,0} \right) \]

+

Assuming the ideal gas law, we can make the substitution:

+

\[ T = {{ P {MW} } \over {R \rho}} \]

+

Furthermore, if we assume \(MW = MW_a\) and \(c_p = c_{p,a}\) then we can cancel all those constants giving:

+

\[ \frac{d}{ds} \int_0^{\sqrt{2}b} \rho u \left( \frac{1}{\rho} - \frac{1}{\rho_{a,0}} \right) r dr = b \rho_a E \left( \frac{1}{\rho_a} - \frac{1}{\rho_{a,0}} \right) \]

+

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).

+
+
+
+

Coordinate Transforms

+

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:

+

\[ \frac{dx}{ds} = \cos \theta \] \[ \frac{dz}{ds} = \sin \theta \]

+
+
+

Entrainment

+

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:

+

\[ E_1 = \alpha_1 | u - u_a \cos \theta | \]

+

Where \(u_a \cos \theta\) is the component of the wind velocity parallel to the jet. The parameter \(\alpha_1\) is called the entrainment coefficient for a free jet and is independent of Reynolds’ number when \(\mathrm{Re} > 10^4\). Ooms gives this as \(\alpha_1 = 0.057\).

+

At distances further down the plume axis, when \(u \approx u_a\), the entrainment is taken to be the same as a cylindrical thermal in a stagnant atmosphere, given as:

+

\[ E_2 = \alpha_2 u_a | \sin \theta | \]

+

Where \(\alpha_2\) is called the entrainment coefficient for a line thermal, it is similarly a constant at large Reynolds’ numbers. Ooms gives this as \(\alpha_2 = 0.5\)

+

To connect these two regimes, Ooms multiplies the line thermal term by \(\cos \theta\). 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 \(u^{\prime}\)

+

\[ E_3 = \alpha_3 u^{\prime} \]

+

Where \(\alpha_3\) is the entrainment coefficient due to turbulence, which is taken to be \(\alpha_1 = 1.0\). The entrainment velocity due to turbulence can be accounted for in one of two ways:

+
    +
  1. Following Briggs, \(u^{\prime} = \sqrt[3]{\epsilon b}\) where \(\epsilon\) is the eddy energy dissipation and is a function of atmospheric stability and elevation.
  2. +
  3. Empirically by the root-mean-square of the wind velocity fluctuation \(u^{\prime} = \sqrt{u_a^2}\)
  4. +
+

The total entrainment is then:

+

\[ E = E_1 + E_2 \cos \theta + E_3 \]

+

\[ E = \alpha_1 | u - u_a \cos \theta | + \alpha_2 u_a | \sin \theta | \cos \theta + \alpha_3 u^{\prime} \]

+

or

+

\[ E = \alpha_1 | u^{*} | + \alpha_2 u_a | \sin \theta | \cos \theta + \alpha_3 u^{\prime} \]

+

Where \(u^{*}\) is defined in the next section.

+
+
+

Similarity Profiles

+

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

+

The velocity is taken to be the component of the wind velocity parallel to the plume axis plus an excess velocity:

+

\[ u = u_a \cos \theta + u^{*} \exp \left( - \left(r \over b\right)^2 \right) \]

+

The plume density, similarly, is the air density plus an excess density:

+

\[ \rho = \rho_a + \rho^{*} \exp \left( - \left(r \over {\lambda b} \right)^2 \right) \]

+

Finally, the concentration simply follows a Gaussian profile:

+

\[ c = c^{*} \exp \left( - \left(r \over {\lambda b} \right)^2 \right) \]

+

Where \(\frac{1}{\lambda^2}\) 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 \(\lambda^2 = 1.35\) or \(\mathrm{Sc}_t = 0.741\), which is consistent with observations of free jets.

+
+
+
+

Practical Necessities

+

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 λ.

+
+
+
+ +
+
+Figure 3: The model constants from Havens,5 note the misprint in \(k_{14}\) (should read 2.227186) +
+
+
+

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:

+
    +
  1. The relationship between the model constants (e.g λ) and the integration constants (the k’s in DEGADIS)
  2. +
  3. To re-create the model that allows for more structure to the atmosphere.
  4. +
+
+

A Series of Tedious Integrals

+

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 \(\int \exp(-ar^2) r dr\) 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:

+

\[ C_1 = 2 \int_0^{\sqrt{2}} \exp \left( - \xi^2 \right) \xi d\xi = 1 - \exp \left( -2 \right)\] \[ C_2 = 2 \int_0^{\sqrt{2}} \exp \left( - \left( \frac{\xi}{\lambda} \right)^2 \right) \xi d\xi = \lambda^2 \left( 1 - \exp \left( -\frac{2}{\lambda^2} \right) \right)\] \[ C_3 = 2 \int_0^{\sqrt{2}} \exp \left( - \xi^2 - \left( \frac{\xi}{\lambda} \right)^2 \right) \xi d\xi = \frac{\lambda^2}{\lambda^2 + 1} \left( 1 - \exp \left( -\frac{2 \left(\lambda^2 + 1\right)}{\lambda^2} \right) \right)\] \[ C_4 = \int_0^{\sqrt{2}} \exp \left( - 2\xi^2 \right) \xi d\xi = \frac{1}{4} \left( 1 - \exp \left( -4 \right) \right)\] \[ C_5 = \int_0^{\sqrt{2}} \exp \left( - 2\xi^2 - \left( \frac{\xi}{\lambda} \right)^2 \right) \xi d\xi = \frac{\lambda^2}{4\lambda^2 + 2} \left( 1 - \exp \left( -\frac{4\lambda^2 + 2}{\lambda^2} \right) \right)\]

+

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 \(\xi = \frac{r}{b}\) such that every integral of a Gaussian in the model becomes \(b^2 C\) 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 \(k_1\) and \(k_2\) they are integer scaling factors, for the first two they \(\frac{1}{\lambda^2}\) times \(C_2\) and \(C_3\) respectively. Below is a table showing the concordance.

+
+
+
+Table 1: Integration Constants +
+
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DEGADIS6Me
\(k_{1 }\)\(\frac{C_2}{\lambda^2}\)
\(k_{2 }\)\(\frac{C_3}{\lambda^2}\)
\(k_{3 }\)\(C_1\)
\(k_{4 }\)\(C_2\)
\(k_{5 }\)\(C_3\)
\(k_{6 }\)\(2C_1\)
\(k_{7 }\)\(2C_4\)
\(k_{8 }\)\(2C_3\)
\(k_{9 }\)\(2C_5\)
\(k_{10}\)\(4C_4\)
\(k_{11}\)\(4C_5\)
\(k_{12}\)\(4C_1\)
\(k_{13}\)\(3C_2\)
\(k_{14}\)\(4C_3\)
\(k_{15}\)\(\frac{C_2}{2}\)
\(k_{16}\)\(\frac{C_1}{2}\)
\(k_{17}\)\(\frac{C_3}{2}\)
+
+
+
+
+
+

Dimensionless Form

+

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):

+

\[ \bar{s} = \frac{s}{D} \] \[ \bar{c} = \frac{c^{*}}{c_0} \] \[ \bar{b} = \frac{b}{D} \] \[ \bar{u} = \frac{u^{*}}{u_a} \] \[ \bar{\rho} = \frac{\rho^{*}}{\rho_a} \] \[ \bar{x} = \frac{x}{D} \] \[ \bar{z} = \frac{z}{D} \]

+

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.

+
+
+

The Full Equations

+
+

Conservation of Mass

+

Thusly equipped, we can work out the integrals and subsequently all the derivatives. Starting with the conservation of mass:

+

\[ \int_0^{\sqrt{2}b} \rho u r dr = \int_0^{\sqrt{2}b} \rho_a u_a \left( 1 + \bar{\rho} \exp \left( - \left(r \over {\lambda b} \right)^2 \right) \right) \left( \cos \theta + \bar{u} \exp \left( - \left(r \over {b} \right)^2 \right) \right) r dr\]

+

\[ = \rho_a u_a b^2 \left( \cos \theta + \bar{\rho} \cos \theta \int_0^{\sqrt{2}} \exp \left( - \left(\xi \over {\lambda } \right)^2 \right) \xi d\xi + \bar{u} \int_0^{\sqrt{2}} \exp \left( - \xi^2 \right) \xi d\xi + \bar{\rho} \bar{u} \int_0^{\sqrt{2}} \exp \left( - \xi \left(\xi \over {\lambda } \right)^2 \right) \xi d\xi \right)\] \[ = \frac{1}{2} \rho_a u_a D^2 \bar{b}^2 \left( \left( C_1 + C_3 \bar{\rho} \right) \bar{u} + \left(2 + C_2 \bar{\rho} \right) \cos \theta \right) \]

+

So the balance equation is:

+

\[ \frac{1}{2} \rho_a u_a \frac{d}{d\bar{s}} \bar{b}^2 \left( \left( C_1 + C_3 \bar{\rho} \right) \bar{u} + \left(2 + C_2 \bar{\rho} \right) \cos \theta \right) = b \rho_a u_a \bar{E} \] \[ \frac{d}{d\bar{s}} \bar{b}^2 \left( \left( C_1 + C_3 \bar{\rho} \right) \bar{u} + \left(2 + C_2 \bar{\rho} \right) \cos \theta \right) = b \bar{E} \]

+

Where \(\bar{E} = \frac{E}{u_a}\) is the dimensionless entrainment velocity.

+

Expanding out the derivatives and dividing through by b, we get:

+

\[ \left( 2\cos \theta \left( 2 + C_2 \bar{\rho} \right) + 2 \bar{u} \left( C_1 + C_3\bar{\rho} \right) \right) \frac{d\bar{b}}{d\bar{s}} \] \[ + \bar{b} \left( C_1 + C_3\bar{\rho} \right) \frac{d\bar{u}}{d\bar{s}} \] \[ - \bar{b} \sin \theta \left( 2 + C_2\bar{\rho} \right) \frac{d\theta}{d\bar{s}} \] \[ + \bar{b} \left( C_2\cos \theta + C_3\bar{u} \right) \frac{d\bar{\rho}}{d\bar{s}} = 2 \bar{E}\]

+
+
+

Conservation of Species

+

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:

+

\[ \frac{d}{d\bar{s}} \left( \bar{c}\bar{b}^2 \left( C_2 \cos \theta + C_3 \bar{u} \right) \right) = 0 \]

+

The final form is:

+

\[ \bar{b} \left(C_3\bar{u} + C_2 \cos \theta \right) \frac{d\bar{c}}{d\bar{s}} \] \[ + 2 \bar{c} \left(C_3\bar{u} + C_2 \cos \theta \right) \frac{d\bar{b}}{d\bar{s}} \] \[ + C_3 \bar{c} \bar{b} \frac{d\bar{u}}{d\bar{s}} \] \[ - C_2 \bar{c} \bar{b} \sin \theta \frac{d\theta}{d\bar{s}} = 0\]

+
+
+

Conservation of Momentum

+

The balance equation in the x-direction is:

+

\[ \frac{d}{d\bar{s}} \left[ \bar{b}^2 \cos \theta \left( 2\bar{u}^2 \left( C_4 + C_5\bar{\rho} \right) + 2\bar{u} \cos\theta \left(C_1 + C_3 \bar{\rho} \right) + \cos^2 \theta \left( 2 + C_2 \bar{\rho} \right) \right) \right] = \bar{b} \left( 2\bar{E} + C_d | \sin^3 \theta | \right) \]

+

The final form is:

+

\[ 2\cos\theta \left[ 2\bar{u}^2 \left( C_4 + C_5 \bar{\rho} \right) + 2\bar{u} \cos\theta \left(C_1 + C_3 \bar{\rho} \right) + \cos^2 \theta \left( 2 + C_2 \bar{\rho} \right) \right] \frac{d\bar{b}}{d\bar{s}} \] \[ + 2\bar{b} \cos\theta \left[ 2\bar{u} \left( C_4 + C_5\bar{\rho} \right) + \cos\theta \left(C_1 + C_3 \bar{\rho} \right) \right] \frac{d\bar{u}}{d\bar{s}} \] \[ - \bar{b} \sin\theta \left[ 2\bar{u}^2 \left( C_4 + C_5 \bar{\rho} \right) + \cos \theta \left( 4\bar{u} \left(C_1 + C_3 \bar{\rho} \right) + 3\cos \theta \left( 2 + C_2 \bar{\rho} \right) \right) \right] \frac{d\theta}{d\bar{s}} \] \[ + \bar{b} \cos\theta \left[ C_2 \cos^2 \theta + 2C_3 \bar{u} \cos \theta + 2 C_5 \bar{u}^2 \right] \frac{d\bar{\rho}}{d\bar{s}} = 2 \bar{E} + C_d | \sin^3 \theta |\]

+

The balance equation in the z-direction is:

+

\[ \frac{d}{d\bar{s}} \left[ \bar{b}^2 \sin \theta \left( 2\bar{u}^2 \left( C_4 + C_5\bar{\rho} \right) + 2\bar{u} \cos\theta \left(C_1 + C_3 \bar{\rho} \right) + \cos^2 \theta \left( 2 + C_2 \bar{\rho} \right) \right) \right] = -C_2 \bar{b}^2 \bar{\rho} \bar{g} + \mathrm{sgn}\theta \cdot C_d \bar{b} \sin^2 \theta \cos \theta\]

+

Where \(\bar{g} = \frac{Dg}{u_a^2}\) is the dimensionless gravity.

+

The final form is:

+

\[ 2\sin\theta \left[ 2\bar{u}^2 \left( C_4 + C_5 \bar{\rho} \right) + 2\bar{u} \cos\theta \left(C_1 + C_3 \bar{\rho} \right) + \cos^2 \theta \left( 2 + C_2 \bar{\rho} \right) \right] \frac{d\bar{b}}{d\bar{s}} \] \[ + 2\bar{b} \sin\theta \left[ 2\bar{u} \left( C_4 + C_5\bar{\rho} \right) + \cos\theta \left(C_1 + C_3 \bar{\rho} \right) \right] \frac{d\bar{u}}{d\bar{s}} \] \[ + \bar{b} \left[ 2\bar{u}^2 \cos\theta \left(C_4 + C_5 \bar{\rho} \right) + 2\bar{u} \left(\cos^2 \theta - \sin^2 \theta \right) \left( C_1 - C_3 \bar{\rho} \right) + \left(1 - 3\sin^2 \theta \right)\cos\theta \left(2 + C_2 \bar{\rho} \right) \right] \frac{d\theta}{d\bar{s}} \] \[ + \bar{b} \sin\theta \left[ C_2 \cos^2 \theta + 2C_3 \bar{u} \cos \theta + 2 C_5 \bar{u}^2 \right] \frac{d\bar{\rho}}{d\bar{s}} = -C_2 \bar{b} \bar{\rho} \bar{g} + \mathrm{sgn}\theta \cdot C_d \sin^2 \theta \cos \theta\]

+
+
+

Conservation of Energy

+

The balance equation is:

+

\[ \frac{d}{d\bar{s}} \left[ \bar{b}^2 \left( 2\cos\theta + C_1 \bar{u} - \bar{\rho_a} \left( \bar{u} \left(C_1 + C_3 \bar{\rho} \right) + \cos \theta \left( 2 + C_2\bar{\rho} \right) \right) \right)\right] = 2\bar{b} \left( 1 - \bar{\rho_a} \right) \bar{E}\]

+

Where \(\bar{\rho_a} = \frac{\rho_a}{\rho_{a,0}}\) is the dimensionless air density.

+

The final form is:

+

\[ 2\left( 2\cos\theta + C_1 \bar{u} - \bar{\rho_a} \left( \bar{u} \left(C_1 + C_3 \bar{\rho} \right) + \cos \theta \left( 2 + C_2\bar{\rho} \right) \right)\right) \frac{d\bar{b}}{d\bar{s}} \] \[ + b\left( C_1 - \bar{\rho_a} \left(C_1 + C_3 \bar{\rho} \right) \right) \frac{d\bar{u}}{d\bar{s}} \] \[ -b\sin\theta \left( 2 - \bar{\rho_a} \left( 2 + C_2\bar{\rho} \right) \right) \frac{d\theta}{d\bar{s}} \] \[ - \bar{b} \bar{\rho_a} \left( C_3 \bar{u} + C_2 \cos \theta \right) \frac{d\bar{\rho}}{d\bar{s}} = 2 \left( 1 - \bar{\rho_a} \right) \bar{E}\]

+
+
+
+
+

Implementing the Ooms Plume Model

+

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:

+

\[ M \frac{d}{d\bar{s}} \mathrm{state} = f\left(\mathrm{state}, s\right) \]

+

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
+end
+
+

In dimensionless form, the only parameter of the system that is relevant to the mass matrix is \(\bar{\rho_a}\) 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: \(\bar{\rho_a}\), \(\bar{g}\) and \(\bar{u^{\prime}} = \frac{u^{\prime}}{u_a}\). These are all functions of elevation, the latter two because \(u_a\) 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
+end
+
+
+

… as an ODE

+

The most direct way of implementing the model is as an ODE where

+

\[ \frac{d}{d\bar{s}} \mathrm{state} = M^{-1} f \]

+

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 OrdinaryDiffEq
+
+
+
function 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
+end
+
+

Instead 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/s
+
+

The 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             ]# z
+
+
+
+
+ +
+
+Note +
+
+
+

The initial value for \(\bar{b}_{0}\) 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 \(\sqrt{2}{b}\) so, if the plume initially has a radius equal to the vent \(\sqrt{2}b_0 = \frac{D}{2}\) and \(\bar{b}_0 = \frac{b}{D} = \frac{1}{2\sqrt{2}}\)

+
+
+

Integrating out 100 stack diameters along the plume

+
+
span = (0.0, 100)
+prob = ODEProblem(ode_rhs!, state0, span, params)
+
+
+
sol = solve(prob, Tsit5())
+
+sol.retcode
+
+
ReturnCode.Success = 1
+
+
+
+
+
+
+ +
+
+Figure 4: The plume height as a function of downwind distance. +
+
+
+
+
+
+

… as a DAE

+

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:

+

\[ f\left( \mathrm{state}^{\prime}, \mathrm{state}, s \right) = 0\]

+

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
+end
+
+

The 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\f0
+
+
+
diff_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 Sundials
+
+
+
daesol = solve(daeprob, IDA())
+
+daesol.retcode
+
+
ReturnCode.Success = 1
+
+
+
+
+
+
+ +
+
+Figure 5: The plume height as a function of downwind distance, solutions using the DifferentialEquations.jl solver Tsit5 and the Sundials DAE solver IDA. +
+
+
+
+

This works as well as the lazy method, slightly slower but it has not been implemented in a particularly optimal way.

+
+
+

… using ModelingToolkit

+

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 ∂ instead
+
+

First 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)
+
+

\[ \begin{equation} +\left[ +\begin{array}{c} +c\left( t \right) \\ +b\left( t \right) \\ +u\left( t \right) \\ +\theta\left( t \right) \\ +\rho\left( t \right) \\ +x\left( t \right) \\ +z\left( t \right) \\ +\end{array} +\right] +\end{equation} +\]

+
+
+

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 \(C_1\).

+
+
# 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
+
+

\[ \begin{equation} +2 \left( u\left( t \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) + \left( 2 + 1.0431 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) \right) \frac{\mathrm{d} b\left( t \right)}{\mathrm{d}t} b\left( t \right) + \left( b\left( t \right) \right)^{2} \left( 0.5568 u\left( t \right) \frac{\mathrm{d} \rho\left( t \right)}{\mathrm{d}t} + \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \frac{\mathrm{d} u\left( t \right)}{\mathrm{d}t} + 1.0431 \frac{\mathrm{d} \rho\left( t \right)}{\mathrm{d}t} \cos\left( \theta\left( t \right) \right) - \sin\left( \theta\left( t \right) \right) \left( 2 + 1.0431 \rho\left( t \right) \right) \frac{\mathrm{d} \theta\left( t \right)}{\mathrm{d}t} \right) = 2 \left( 0.057 \left|u\left( t \right)\right| + 0.5 \left|\sin\left( \theta\left( t \right) \right)\right| \cos\left( \theta\left( t \right) \right) \right) b\left( t \right) +\end{equation} +\]

+
+
+
+
# conservation of species
+∫curdr = c*b^2*(C₂*cos(θ) + C₃*u)
+
+eqn2 = expand_derivatives( ( ∫curdr ) ) ~ 0
+
+

\[ \begin{equation} +2 \left( 0.5568 u\left( t \right) + 1.0431 \cos\left( \theta\left( t \right) \right) \right) c\left( t \right) \frac{\mathrm{d} b\left( t \right)}{\mathrm{d}t} b\left( t \right) + \left( b\left( t \right) \right)^{2} \left( 0.5568 u\left( t \right) + 1.0431 \cos\left( \theta\left( t \right) \right) \right) \frac{\mathrm{d} c\left( t \right)}{\mathrm{d}t} + \left( b\left( t \right) \right)^{2} \left( 0.5568 \frac{\mathrm{d} u\left( t \right)}{\mathrm{d}t} - 1.0431 \sin\left( \theta\left( t \right) \right) \frac{\mathrm{d} \theta\left( t \right)}{\mathrm{d}t} \right) c\left( t \right) = 0 +\end{equation} +\]

+
+
+
+
# 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) )
+
+

\[ \begin{equation} +2 \left( 2 \left( u\left( t \right) \right)^{2} \left( 0.24542 + 0.18167 \rho\left( t \right) \right) + 2 u\left( t \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) + \cos^{2}\left( \theta\left( t \right) \right) \left( 2 + 1.0431 \rho\left( t \right) \right) \right) \frac{\mathrm{d} b\left( t \right)}{\mathrm{d}t} \cos\left( \theta\left( t \right) \right) b\left( t \right) - \left( b\left( t \right) \right)^{2} \left( 2 \left( u\left( t \right) \right)^{2} \left( 0.24542 + 0.18167 \rho\left( t \right) \right) + 2 u\left( t \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) + \cos^{2}\left( \theta\left( t \right) \right) \left( 2 + 1.0431 \rho\left( t \right) \right) \right) \sin\left( \theta\left( t \right) \right) \frac{\mathrm{d} \theta\left( t \right)}{\mathrm{d}t} + \left( b\left( t \right) \right)^{2} \left( 0.36335 \left( u\left( t \right) \right)^{2} \frac{\mathrm{d} \rho\left( t \right)}{\mathrm{d}t} + 4 u\left( t \right) \left( 0.24542 + 0.18167 \rho\left( t \right) \right) \frac{\mathrm{d} u\left( t \right)}{\mathrm{d}t} + 1.1136 u\left( t \right) \frac{\mathrm{d} \rho\left( t \right)}{\mathrm{d}t} \cos\left( \theta\left( t \right) \right) + 2 \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \frac{\mathrm{d} u\left( t \right)}{\mathrm{d}t} \cos\left( \theta\left( t \right) \right) + 1.0431 \cos^{2}\left( \theta\left( t \right) \right) \frac{\mathrm{d} \rho\left( t \right)}{\mathrm{d}t} - 2 u\left( t \right) \sin\left( \theta\left( t \right) \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \frac{\mathrm{d} \theta\left( t \right)}{\mathrm{d}t} - 2 \sin\left( \theta\left( t \right) \right) \left( 2 + 1.0431 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) \frac{\mathrm{d} \theta\left( t \right)}{\mathrm{d}t} \right) \cos\left( \theta\left( t \right) \right) = \left( 0.3 \left|\sin^{3}\left( \theta\left( t \right) \right)\right| + 2 \left( 0.057 \left|u\left( t \right)\right| + 0.5 \left|\sin\left( \theta\left( t \right) \right)\right| \cos\left( \theta\left( t \right) \right) \right) \right) b\left( t \right) +\end{equation} +\]

+
+
+
+
# 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(θ)
+
+

\[ \begin{equation} +2 \left( 2 \left( u\left( t \right) \right)^{2} \left( 0.24542 + 0.18167 \rho\left( t \right) \right) + 2 u\left( t \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) + \cos^{2}\left( \theta\left( t \right) \right) \left( 2 + 1.0431 \rho\left( t \right) \right) \right) \sin\left( \theta\left( t \right) \right) \frac{\mathrm{d} b\left( t \right)}{\mathrm{d}t} b\left( t \right) + \left( b\left( t \right) \right)^{2} \left( 2 \left( u\left( t \right) \right)^{2} \left( 0.24542 + 0.18167 \rho\left( t \right) \right) + 2 u\left( t \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) + \cos^{2}\left( \theta\left( t \right) \right) \left( 2 + 1.0431 \rho\left( t \right) \right) \right) \cos\left( \theta\left( t \right) \right) \frac{\mathrm{d} \theta\left( t \right)}{\mathrm{d}t} + \left( b\left( t \right) \right)^{2} \left( 0.36335 \left( u\left( t \right) \right)^{2} \frac{\mathrm{d} \rho\left( t \right)}{\mathrm{d}t} + 4 u\left( t \right) \left( 0.24542 + 0.18167 \rho\left( t \right) \right) \frac{\mathrm{d} u\left( t \right)}{\mathrm{d}t} + 1.1136 u\left( t \right) \frac{\mathrm{d} \rho\left( t \right)}{\mathrm{d}t} \cos\left( \theta\left( t \right) \right) + 2 \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \frac{\mathrm{d} u\left( t \right)}{\mathrm{d}t} \cos\left( \theta\left( t \right) \right) + 1.0431 \cos^{2}\left( \theta\left( t \right) \right) \frac{\mathrm{d} \rho\left( t \right)}{\mathrm{d}t} - 2 u\left( t \right) \sin\left( \theta\left( t \right) \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \frac{\mathrm{d} \theta\left( t \right)}{\mathrm{d}t} - 2 \sin\left( \theta\left( t \right) \right) \left( 2 + 1.0431 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) \frac{\mathrm{d} \theta\left( t \right)}{\mathrm{d}t} \right) \sin\left( \theta\left( t \right) \right) = - 0.51149 \left( b\left( t \right) \right)^{2} \rho\left( t \right) + 0.3 \sin^{2}\left( \theta\left( t \right) \right) \cos\left( \theta\left( t \right) \right) b\left( t \right) sign\left( \theta\left( t \right) \right) +\end{equation} +\]

+
+
+
+
# 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
+
+

\[ \begin{equation} +2 \left( 0.86466 u\left( t \right) + 2 \cos\left( \theta\left( t \right) \right) - u\left( t \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) - \left( 2 + 1.0431 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) \right) \frac{\mathrm{d} b\left( t \right)}{\mathrm{d}t} b\left( t \right) + \left( b\left( t \right) \right)^{2} \left( 0.86466 \frac{\mathrm{d} u\left( t \right)}{\mathrm{d}t} - 0.5568 u\left( t \right) \frac{\mathrm{d} \rho\left( t \right)}{\mathrm{d}t} - 2 \sin\left( \theta\left( t \right) \right) \frac{\mathrm{d} \theta\left( t \right)}{\mathrm{d}t} + \left( -0.86466 - 0.5568 \rho\left( t \right) \right) \frac{\mathrm{d} u\left( t \right)}{\mathrm{d}t} - 1.0431 \frac{\mathrm{d} \rho\left( t \right)}{\mathrm{d}t} \cos\left( \theta\left( t \right) \right) - \sin\left( \theta\left( t \right) \right) \left( -2 - 1.0431 \rho\left( t \right) \right) \frac{\mathrm{d} \theta\left( t \right)}{\mathrm{d}t} \right) = 0 +\end{equation} +\]

+
+
+
+
# 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)
+
+

\[ \begin{align} +\frac{\mathrm{d} b\left( t \right)}{\mathrm{d}t} &= \mathtt{bˍt}\left( t \right) \\ +\frac{\mathrm{d} \rho\left( t \right)}{\mathrm{d}t} &= \mathtt{{\rho}ˍt}\left( t \right) \\ +\frac{\mathrm{d} \theta\left( t \right)}{\mathrm{d}t} &= \mathtt{{\theta}ˍt}\left( t \right) \\ +\frac{\mathrm{d} u\left( t \right)}{\mathrm{d}t} &= \mathtt{uˍt}\left( t \right) \\ +0 &= 2 \left( 0.057 \left|u\left( t \right)\right| + 0.5 \left|\sin\left( \theta\left( t \right) \right)\right| \cos\left( \theta\left( t \right) \right) \right) b\left( t \right) - 2 \left( u\left( t \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) + \left( 2 + 1.0431 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) \right) b\left( t \right) \mathtt{bˍt}\left( t \right) + \left( b\left( t \right) \right)^{2} \left( - 0.5568 u\left( t \right) \mathtt{{\rho}ˍt}\left( t \right) - \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \mathtt{uˍt}\left( t \right) - 1.0431 \mathtt{{\rho}ˍt}\left( t \right) \cos\left( \theta\left( t \right) \right) + \sin\left( \theta\left( t \right) \right) \left( 2 + 1.0431 \rho\left( t \right) \right) \mathtt{{\theta}ˍt}\left( t \right) \right) \\ +0 &= - 2 \left( 0.86466 u\left( t \right) + 2 \cos\left( \theta\left( t \right) \right) - u\left( t \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) + \left( -2 - 1.0431 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) \right) b\left( t \right) \mathtt{bˍt}\left( t \right) + \left( b\left( t \right) \right)^{2} \left( - 0.86466 \mathtt{uˍt}\left( t \right) + 0.5568 u\left( t \right) \mathtt{{\rho}ˍt}\left( t \right) + 2 \sin\left( \theta\left( t \right) \right) \mathtt{{\theta}ˍt}\left( t \right) - \left( -0.86466 - 0.5568 \rho\left( t \right) \right) \mathtt{uˍt}\left( t \right) + 1.0431 \mathtt{{\rho}ˍt}\left( t \right) \cos\left( \theta\left( t \right) \right) + \sin\left( \theta\left( t \right) \right) \left( -2 - 1.0431 \rho\left( t \right) \right) \mathtt{{\theta}ˍt}\left( t \right) \right) \\ +0 &= \left( 0.3 \left|\sin^{3}\left( \theta\left( t \right) \right)\right| + 2 \left( 0.057 \left|u\left( t \right)\right| + 0.5 \left|\sin\left( \theta\left( t \right) \right)\right| \cos\left( \theta\left( t \right) \right) \right) \right) b\left( t \right) - 2 \left( 2 \left( u\left( t \right) \right)^{2} \left( 0.24542 + 0.18167 \rho\left( t \right) \right) + 2 u\left( t \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) + \cos^{2}\left( \theta\left( t \right) \right) \left( 2 + 1.0431 \rho\left( t \right) \right) \right) \cos\left( \theta\left( t \right) \right) b\left( t \right) \mathtt{bˍt}\left( t \right) + \left( b\left( t \right) \right)^{2} \left( 2 \left( u\left( t \right) \right)^{2} \left( 0.24542 + 0.18167 \rho\left( t \right) \right) + 2 u\left( t \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) + \cos^{2}\left( \theta\left( t \right) \right) \left( 2 + 1.0431 \rho\left( t \right) \right) \right) \sin\left( \theta\left( t \right) \right) \mathtt{{\theta}ˍt}\left( t \right) + \left( b\left( t \right) \right)^{2} \left( - 0.36335 \left( u\left( t \right) \right)^{2} \mathtt{{\rho}ˍt}\left( t \right) - 4 u\left( t \right) \left( 0.24542 + 0.18167 \rho\left( t \right) \right) \mathtt{uˍt}\left( t \right) - 1.1136 u\left( t \right) \mathtt{{\rho}ˍt}\left( t \right) \cos\left( \theta\left( t \right) \right) - 2 \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) \mathtt{uˍt}\left( t \right) - 1.0431 \cos^{2}\left( \theta\left( t \right) \right) \mathtt{{\rho}ˍt}\left( t \right) + 2 u\left( t \right) \sin\left( \theta\left( t \right) \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \mathtt{{\theta}ˍt}\left( t \right) + 2 \sin\left( \theta\left( t \right) \right) \left( 2 + 1.0431 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) \mathtt{{\theta}ˍt}\left( t \right) \right) \cos\left( \theta\left( t \right) \right) \\ +0 &= - 0.51149 \left( b\left( t \right) \right)^{2} \rho\left( t \right) + 0.3 \sin^{2}\left( \theta\left( t \right) \right) \cos\left( \theta\left( t \right) \right) b\left( t \right) sign\left( \theta\left( t \right) \right) - 2 \left( 2 \left( u\left( t \right) \right)^{2} \left( 0.24542 + 0.18167 \rho\left( t \right) \right) + 2 u\left( t \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) + \cos^{2}\left( \theta\left( t \right) \right) \left( 2 + 1.0431 \rho\left( t \right) \right) \right) \sin\left( \theta\left( t \right) \right) b\left( t \right) \mathtt{bˍt}\left( t \right) + \left( b\left( t \right) \right)^{2} \left( - 2 \left( u\left( t \right) \right)^{2} \left( 0.24542 + 0.18167 \rho\left( t \right) \right) - 2 u\left( t \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) + \cos^{2}\left( \theta\left( t \right) \right) \left( -2 - 1.0431 \rho\left( t \right) \right) \right) \cos\left( \theta\left( t \right) \right) \mathtt{{\theta}ˍt}\left( t \right) - \left( b\left( t \right) \right)^{2} \left( 0.36335 \left( u\left( t \right) \right)^{2} \mathtt{{\rho}ˍt}\left( t \right) + 4 u\left( t \right) \left( 0.24542 + 0.18167 \rho\left( t \right) \right) \mathtt{uˍt}\left( t \right) + 1.1136 u\left( t \right) \mathtt{{\rho}ˍt}\left( t \right) \cos\left( \theta\left( t \right) \right) + 2 \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) \mathtt{uˍt}\left( t \right) + 1.0431 \cos^{2}\left( \theta\left( t \right) \right) \mathtt{{\rho}ˍt}\left( t \right) - 2 u\left( t \right) \sin\left( \theta\left( t \right) \right) \left( 0.86466 + 0.5568 \rho\left( t \right) \right) \mathtt{{\theta}ˍt}\left( t \right) - 2 \sin\left( \theta\left( t \right) \right) \left( 2 + 1.0431 \rho\left( t \right) \right) \cos\left( \theta\left( t \right) \right) \mathtt{{\theta}ˍt}\left( t \right) \right) \sin\left( \theta\left( t \right) \right) \\ +\frac{\mathrm{d} c\left( t \right)}{\mathrm{d}t} &= \mathtt{cˍt}\left( t \right) \\ +0 &= - 2 \left( 0.5568 u\left( t \right) + 1.0431 \cos\left( \theta\left( t \right) \right) \right) c\left( t \right) b\left( t \right) \mathtt{bˍt}\left( t \right) - \left( b\left( t \right) \right)^{2} \left( 0.5568 u\left( t \right) + 1.0431 \cos\left( \theta\left( t \right) \right) \right) \mathtt{cˍt}\left( t \right) - \left( b\left( t \right) \right)^{2} \left( 0.5568 \mathtt{uˍt}\left( t \right) - 1.0431 \sin\left( \theta\left( t \right) \right) \mathtt{{\theta}ˍt}\left( t \right) \right) c\left( t \right) \\ +\frac{\mathrm{d} z\left( t \right)}{\mathrm{d}t} &= \sin\left( \theta\left( t \right) \right) \\ +\frac{\mathrm{d} x\left( t \right)}{\mathrm{d}t} &= \cos\left( \theta\left( t \right) \right) +\end{align} +\]

+
+
+

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.retcode
+
+
ReturnCode.Success = 1
+
+
+
+
+
+
+ +
+
+Figure 6: The plume height as a function of downwind distance, solutions using the lazy approach with the Tsit5 solver and ModelingToolkit using Rodas5P. +
+
+
+
+

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.

+
+
+

Dead Ends and Failures

+

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 SciMLOperators
+
+
+
M = 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 Problem of Concentration

+

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.

+
    +
  1. Calculating the isopleths in the x-z plane
  2. +
  3. Calculating the isopleths at an arbitrary elevation \(z=a\)
  4. +
  5. Calculating the concentration at an arbitrary point \(x,y,z\)
  6. +
+

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.

+
+

Isopleths in the x-z Plane

+

The easiest problem to solve is the isopleths in the plane \(y=0\). Suppose we want to calculate the isopleth for some concentration \(c = c_l\). Recalling the concentration profile:

+

\[ \bar{c}_l = \bar{c}_o \exp \left( - \left( \frac{r}{\lambda b} \right)^2 \right) \]

+

Where \(\bar{c}_o\) is the center line concentration at that point along the plume axis. We first solve for \(r\), the distance from the plume axis:

+

\[ r = b \lambda \sqrt{ \log \left( \bar{c}_o \over \bar{c}_l \right) } \]

+

Converting from cylindrical coordinates to Cartesian coordinates, \(x^{\prime}, y^{\prime}, z^{\prime}\), aligned such that \(x^{\prime}\) is aligned with the plume axis, the radius is

+

\[ r^2 = \left(y^{\prime}\right)^2 + \left(z^{\prime}\right)^2 \]

+

Since we are confined to the plane \(y^{\prime} = 0\), we find \(z^{\prime} = \pm r\). Then we rotate the axis to align with the problem coordinate system and translate the origin to the problem origin.

+

\[ x = x_o \mp r \sin \theta \] \[ z = z_o \pm r \cos \theta \]

+

Where \(x_o\) and \(z_o\) 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.

+
+
+
+ +
+
+Note +
+
+
+

Casal7 provides an alternative form of these isopleths:

+

\[ z = z_o \pm \sqrt{ { {\lambda^2 b^2} \over { 1 + \tan^2 \theta }} \log \left(\frac{c_o}{c_l} \right) } \]

+

and

+

\[ {{z - z_o} \over {x - x_o}} = -\cot \theta \]

+

These are actually equivalent, using the identity \(\sec^2 = 1 + \tan^2 \theta\) and the definition \(\sec \theta = \frac{1}{\cos \theta}\), the first equation can be written as:

+

\[ z = z_o \pm \sqrt{ { {\lambda^2 b^2} \over { \sec^2 \theta }} \log \left(\frac{c_o}{c_l} \right) } = z_o \pm \sqrt{ \lambda^2 b^2 \cos^2 \theta \log \left(\frac{c_o}{c_l} \right) } \] \[ = z_o \pm \lambda b \sqrt{ \log \left(\frac{c_o}{c_l} \right) } \cos \theta = z_o \pm r \cos \theta\]

+

The second equation can be re-written to solve for x:

+

\[ {{z - z_o} \over {x - x_o}} = -\cot \theta \] \[ {z - z_o} = -\left(x - x_o\right)\cot \theta \] \[ \pm r \cos \theta = -\left(x - x_o\right)\cot \theta \] \[ \pm r \cos \theta = - \left(x - x_o\right) \frac{\cos \theta}{\sin \theta}\] \[ x = x_o \mp r \sin \theta \]

+
+
+
+
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
+end
+
+
+
function 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
+end
+
+

For an example, suppose we want the isopleth for \(c/c_0 = 2\%\)

+
+
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_zero
+
+
+
i_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ₗ))];
+
+
+
+
+
+ +
+
+Figure 7: Plume vertical isopleths, 2%(vol) +
+
+
+
+
+
+

Isopleths at z=a

+

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
+= bₒ^2 * λ²*log(cₒ/c)
+        z′² = ((a - zₒ)*cos(θₒ) - (x - xₒ)*sin(θₒ))^2
+
+        if z′² >
+            # the isosurface doesn't intersect z=a
+            return nothing
+        else
+            y′ = ( r² - z′²)
+            y = y′
+            return Point(x,y)
+        end
+    end
+end
+
+

Picking an arbitraty height of 20 stack diameters in elevation.

+
+
a = 20 # 20 stack diameters
+
+

We 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 ]
+
+
+
+
+
+ +
+
+Figure 8: Plume crosswind isopleths at z/D = 20, 2%(vol) +
+
+
+
+
+
+

The Concentration at an Arbitrary Point

+

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)
+end
+
+

The 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(θₒ)
+= (y′)^2 + (z′)^2
+
+    # calculate concentration
+    c = cₒ*exp(-/(bₒ^2*λ²))
+
+    return c
+end
+
+

How 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 ]
+
+
+
+
+
+ +
+
+Figure 9: A scatterplot showing how well the concentration function recovered the concentration at the points on the isopleths +
+
+
+
+

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.

+
+
+
+

Capturing Dense Gas Behaviour

+

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 \(\bar{z}_0 = \frac{h+\delta}{D}\) 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.

+
+

Bouncing Plume with a Standard Callback

+
+
ground_check(state, s, i) = state[7] # z
+
+
+
function reflect_plume!(integrator)
+    # bounce off the ground
+    integrator.u[4] = abs(integrator.u[4]) # θ
+    integrator.u[7] = 0                    # z
+end
+
+
+
ground_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             ]# z
+
+
+
dense_prob =  ODEProblem(ode_rhs!, dense_state0, span.*2, params)
+
+
+
dense_sol = solve(dense_prob, Tsit5(); callback=ground_cb)
+
+dense_sol.retcode
+
+
ReturnCode.Success = 1
+
+
+
+
+

Bouncing Plume with ModelingToolkit

+

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.retcode
+
+
ReturnCode.Success = 1
+
+
+
+
+
+
+ +
+
+Figure 10: The plume height as a function of downwind distance, vent gas eleven times denser than ambient air. +
+
+
+
+

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, \(z - \sqrt{2}b \sin \theta = 0\), 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.

+
+
+
+

Validating the Model

+

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.

+

Unfortunately while Ooms provides most of the dimensionless groups needed to generate the plots, it is missing two important ones:

+
    +
  1. The initial plume dimension \(\bar{b}_{0}\)
  2. +
  3. The length of the zone of flow establishment δ
  4. +
+

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 \(\bar{b}_{0} = \frac{1}{2\sqrt{2}}\), 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.

+
+
+
+ +
+
+WarningUpdate +
+
+
+

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.

+
+
+
+
#              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       ]# z
+
+
+
lfn_prob = ODEProblem(ode_rhs!, lfn_state0, (0.0,150.0), lfn_prms)
+
+
+
lfn_sol = solve(lfn_prob, Tsit5())
+
+
+
+
+
+ +
+
+Figure 11: A recreation of figure 3 from Ooms. +
+
+
+
+

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.

+
+
+

Future Opportunities

+

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.

+
+
+

References

+
+
+Casal, Joaquim. “Atmospheric Dispersion of Toxic or Flammable Clouds.” Amsterdam: Elsevier, 2018. https://app.knovel.com/hotlink/khtml/id:kt0125Q791/evaluation-effects-consequences/atmospheric-dispersion. +
+
+Fan, Loh-Nien. “Turbulent Buoyant Jets into Stratified or Flowing Ambient Fluids.” PhD thesis, California Institute of Technology, 1967. https://doi.org/10.7907/C69V-BE23. +
+
+Havens, Jerry, and Thomas Spicer. “A Dispersion Model for Elevated Dense Gas Jet Chemical Releases.” Office of Air Quality Planning and Standards, US EPA, 1988. https://nepis.epa.gov/Exe/ZyPURL.cgi?DocKey=2000NACQ.txt. +
+
+Keffer, J. F., and W. D. Baines. “The Round Turbulent Jet in a Cross-Wind.” Journal of Fluid Mechanics 15, no. 4 (1963): 481–96. https://doi.org/10.1017/S0022112063000409. +
+
+Ooms, Gijsbert. “A New Method for the Calculation of the Plume Path of Gases Emitted by a Stack.” Atmospheric Environment (1967) 6, no. 12 (1972): 899–909. https://doi.org/10.1016/0004-6981(72)90098-4. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/ooms_plume_model/plume_diff_elem.png b/posts/ooms_plume_model/plume_diff_elem.png new file mode 100644 index 0000000..d83298b Binary files /dev/null and b/posts/ooms_plume_model/plume_diff_elem.png differ diff --git a/posts/ooms_plume_model/plume_sketch.png b/posts/ooms_plume_model/plume_sketch.png new file mode 100644 index 0000000..c1275b6 Binary files /dev/null and b/posts/ooms_plume_model/plume_sketch.png differ diff --git a/posts/plastics-recycling-microplastics/index.html b/posts/plastics-recycling-microplastics/index.html new file mode 100644 index 0000000..e9f3923 --- /dev/null +++ b/posts/plastics-recycling-microplastics/index.html @@ -0,0 +1,946 @@ + + + + + + + + + + + + +Plastics Recycling and Microplastics – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Plastics Recycling and Microplastics

+
+
+ Is plastic recycling a huge source of microplastics? +
+
+
+
plastic
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

July 14, 2024

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

As perhaps just a hazard of my profession, any time an article comes out on the merits (or lack of) of recycling and plastic waste in general, people send it my way. Several times in the last month I was sent this article in Quillette.1 (and associated YouTube video) about how plastics recycling may be a massive source of the microplastics being discharged into the environment, adding to the long list of reasons why recycling has not lived up to the promises made by industry, and undermining our path towards a more circular economy. At first glance though, some of the numbers presented and the math struck me as rather sus, so I would like to take a moment to dive into it a bit more. tl;dr much the math in that essay doesn’t really work or comes with big caveats, but the broader point about the value of recycling and how we may not be fully appreciating the environmental impacts may hold up.

+
+
+
+ +
+
+Note +
+
+
+

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.

+
+
+
+

How Large of a Source of Microplastics is the Recycling Industry?

+

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

    +
  1. that the recycling industry discharges up to 2Mt/y of microplastics into the environment
  2. +
  3. that the total amount of primary microplastics discharged into the environment from all sources is 3Mt/y
  4. +
+
+

The Direct Discharge of Microplastics

+

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.

+

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.

+ + + + + + + + + + + + + + + + + + + + +
estimatelow end (t/y)high end (t/y)
before filter upgrade962933
after filter upgrade41366
+

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%

+
+

The Total Amount of Primary Microplastics losses

+

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.

+

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.

+
+
+
+

The Climate Impacts of Landfilling

+

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.

+
+
+

Conclusions and Take Aways

+

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.

+
+
+

References

+
+
+Boucher, Julien, and Damien Friot. “Primary Microplastics in the Oceans.” Gland, CH: International Union for Conservation of Nature, 2017. https://doi.org/10.2305/IUCN.CH.2017.01.en. +
+
+Brown, Erina, Anna MacDonald, Steve Allen, and Deonie Allen. “The Potential for a Plastic Recycling Facility to Release Microplastic Pollution and Possible Filtration Remediation Effectiveness.” Journal of Hazardous Materials Advances 10 (2023): 100309. https://doi.org/10.1016/j.hazadv.2023.100309. +
+
+Celia, Frank. “Recycling Plastic Is a Dangerous Waste of Time.” Quillette, June 17, 2024. https://quillette.com/2024/06/17/recycling-plastic-is-a-dangerous-waste-of-time-microplastics-health/. +
+
+Gao, Zhenghui, Hang Qian, Tianyi Cui, Zongqiang Ren, and Xingjie Wang. “Comprehensive Meta-Analysis Reveals the Impact of Non-Biodegradable Plastic Pollution on Methane Production in Anaerobic Digestion.” Chemical Engineering Journal 484 (2024): 149703. https://doi.org/10.1016/j.cej.2024.149703. +
+
+OECD. “Global Plastics Outlook.” Paris: OECD Publishing, 2022. https://doi.org/10.1787/de747aef-en. +
+
+Ryberg, Morten W., Alexis Laurent, and Michael Hauschild. “Mapping of Global Plastics Value Chain and Plastics Losses to the Environment.” Nairobi: United Nations Environment Programme, 2018. https://www.unep.org/resources/report/mapping-global-plastics-value-chain-and-plastics-losses-environment-particular/. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/plastics-recycling-microplastics/soren-funk-unsplash.jpg b/posts/plastics-recycling-microplastics/soren-funk-unsplash.jpg new file mode 100644 index 0000000..a0a4212 Binary files /dev/null and b/posts/plastics-recycling-microplastics/soren-funk-unsplash.jpg differ diff --git a/posts/plastics-recycling-microplastics/thumbnail.jpg b/posts/plastics-recycling-microplastics/thumbnail.jpg new file mode 100644 index 0000000..9e9c7b4 Binary files /dev/null and b/posts/plastics-recycling-microplastics/thumbnail.jpg differ diff --git a/posts/pollen_dispersion/banner.jpg b/posts/pollen_dispersion/banner.jpg new file mode 100644 index 0000000..f8e50df Binary files /dev/null and b/posts/pollen_dispersion/banner.jpg differ diff --git a/posts/pollen_dispersion/figure-1.png b/posts/pollen_dispersion/figure-1.png new file mode 100644 index 0000000..8221de0 Binary files /dev/null and b/posts/pollen_dispersion/figure-1.png differ diff --git a/posts/pollen_dispersion/figure-2.png b/posts/pollen_dispersion/figure-2.png new file mode 100644 index 0000000..80ab5be Binary files /dev/null and b/posts/pollen_dispersion/figure-2.png differ diff --git a/posts/pollen_dispersion/figure-3.png b/posts/pollen_dispersion/figure-3.png new file mode 100644 index 0000000..c992f3c Binary files /dev/null and b/posts/pollen_dispersion/figure-3.png differ diff --git a/posts/pollen_dispersion/figure-4.png b/posts/pollen_dispersion/figure-4.png new file mode 100644 index 0000000..9bd309e Binary files /dev/null and b/posts/pollen_dispersion/figure-4.png differ diff --git a/posts/pollen_dispersion/figure-5.png b/posts/pollen_dispersion/figure-5.png new file mode 100644 index 0000000..919f1f5 Binary files /dev/null and b/posts/pollen_dispersion/figure-5.png differ diff --git a/posts/pollen_dispersion/figure-6.png b/posts/pollen_dispersion/figure-6.png new file mode 100644 index 0000000..cb315c5 Binary files /dev/null and b/posts/pollen_dispersion/figure-6.png differ diff --git a/posts/pollen_dispersion/index.html b/posts/pollen_dispersion/index.html new file mode 100644 index 0000000..87b4f71 --- /dev/null +++ b/posts/pollen_dispersion/index.html @@ -0,0 +1,1392 @@ + + + + + + + + + + + + +Mapping Pollen Dispersion – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Mapping Pollen Dispersion

+
+
+ Calculating how far the wind blows. +
+
+
+
julia
+
dispersion modelling
+
pollen
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

May 10, 2025

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

It has been a beautiful spring in Edmonton and the trees are tentatively flowering and throwing pollen to the wind. Watching the trees come back from their barren winter state left me wondering about the wind dispersal of pollen. Pollen grains must be small enough to travel quite a distance to encounter any other trees to pollinate, but they do settle out eventually. So, how far do they actually go? I’ve been wanting to play around with the mapping tools in the julia ecosystem, and this question gave me an opportunity to make some maps exploring pollen dispersal in my neighbourhood.

+

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).

+
+
+
+ +
+
+NoteSome Notes on Unitful +
+
+
+

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:

+
    +
  1. What units am I using for pollen dispersal?
  2. +
  3. How will I be handling all the correlations?
  4. +
+

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 Unitful
+
begin
+
+@unit grains "grains" Grains (1/6.02214076e23)*u"mol" true;
+Unitful.register(@__MODULE__);
+
+end
+

Correlations 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;
+
+
+
+

Building a Model of Pollen Dispersion

+

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 \(W_{set}\). The coordinate system is centred at the base of the tree with an x-axis parallel to the wind.

+
+
+
+ +
+
+Figure 1: A sketch of a single Elm tree as an elevated point source. +
+
+
+
+

A Model Elm Tree

+

There are a few things I will need to know about each elm tree in the neighbourhood:

+
    +
  1. The height at which pollen is released
  2. +
  3. The rate at which pollen is released
  4. +
+

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

+
# 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.

+
# 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)/Δt
+

For the example tree, this gives a pollen release rate of 317,529 grains s^-1.

+
+
+

Pollen Settling

+

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.

+
+
+
+ +
+
+Figure 2: A single pollen grain as a solid sphere falling at terminal velocity. +
+
+
+
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

+
+
+

Atmospheric Dispersion

+

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 \(\sigma_y\) and \(\sigma_z\) 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.

+
# 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"
+
+
+

The Ermak Equation

+

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 \(W_{set}\) and deposition velocity \(W_{dep}\)

+

\[ +\frac{\partial c}{\partial r} - \frac{W_{set}}{K} \frac{\partial c}{\partial z} = \frac{\partial^2 c}{\partial y^2} + \frac{\partial^2 c}{\partial z^2} +\]

+

with boundary condition at the ground

+

\[ +\left( K \frac{\partial c}{\partial z} + W_{set} c \right)_{z=0} = W_{dep} c|_{z=0} +\]

+

where K is the eddy diffusivity and r is defined as

+

\[ +r = \frac{1}{u} \int_0^x K dx^{\prime} +\]

+

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 \(\sigma_y\) and \(\sigma_z\) as, by definition, \(\sigma^2 = 2 r\)

+

\[ +c = \frac{m}{2\pi u \sigma_y \sigma_z} \exp\left( - \frac{y^2}{2 \sigma_y^2} \right) \exp\left( { - {W_{set} (z-h)} \over {2K_z} } - { {W_{set}^2 \sigma_z^2} \over {8K_z^2} } \right) +\]

+

\[ +\times \left( \exp \left( - \left(z-h\right)^2 \over {2\sigma_z^2} \right) + \exp \left( - \left(z+h\right)^2 \over {2\sigma_z^2} \right) \right. +\]

+

\[ +\left. - { {\sqrt{2\pi} W_o \sigma_z} \over K_z} \exp\left( { {W_o (z+h)} \over {K_z} } + { {W_o^2 \sigma_z^2} \over {2K_z^2} } \right) \mathrm{erfc} \left( { {W_o \sigma_z} \over {\sqrt{2}K_z} } + { {z + h} \over {\sqrt{2}\sigma_z} } \right) \right) +\]

+

where \(W_o = W_{dep} - \frac{1}{2}W_{set}\). In practice, relationships for \(\sigma\)s are much easier to find than Ks and the following is used to recover \(K_z\)

+

\[ +K_z = \frac{1}{2} u \frac{d \sigma_z^2}{dx} +\]

+

This follows from the definition of \(\sigma_z\) (and r). In this case I am going to generate the \(K_z\) 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: erfc
+
function 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)

+
+
+
+ +
+
+Figure 3: Plan view of ground level pollen concentration downwind of the model Elm tree. +
+
+
+
+
+
+ +
+
+Figure 4: Elevation view of pollen concentration downwind of the model Elm tree, through the centre of the plume (y=0). +
+
+
+

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.

+
+
+

A Tree Data Structure

+

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, \(x_o, y_o, z_o\). 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;
+
+
+
+

Mapping Elm Pollen in Wîhkwêntôwin

+

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.

+
+

Defining the Local Grid

+

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 Geodesy
+
begin
+
+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
+end
+
function local_coords(lat,lon)
+    x = (lon - lonₒ)*Δx
+    y = (lat - latₒ)*Δy
+    return x, y
+end
+
+
+
+ +
+
+NoteWhy not use Web Mercator? +
+
+
+

At 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)
+
+end
+
dist = 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)
+
+end
+
wm_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.

+
+
+
+
+

Finding the Neighbourhood Elm Trees

+

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, DataFrames
+
trees_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
+
+end
+

There 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) );
+
+
+

Mapping Wîhkwêntôwin

+

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 Tyler
+
wihkwentowin = 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)
+end
+

Mapping 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.

+
+
+
+ +
+
+Figure 5: Satellite view of Wîhkwêntôwin and surrounding area with neighbourhood Elm trees indicated with blue circles. +
+
+
+
+
+

Mapping the Pollen from all Elm Trees

+

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"))
+end
+

I 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

+
+
+
+ +
+
+Figure 6: Satellite view of Wîhkwêntôwin and surrounding area with pollen concentrations >10 grains m^-3 overlaid. +
+
+
+

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 \(\sigma_y\) and \(\sigma_z\). 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.

+
+
+
+

References

+
+
+Briggs, Gary A. “Diffusion Estimation for Small Emissions. Preliminary Report.” Oak Ridge, TN: Air Resources Atmospheric Turbulence; Diffusion Laboratory, National Oceanic; Atmospheric Administration, 1973. https://doi.org/10.2172/5118833. +
+
+Brush, Grace S., and Lucien M. Brush Jr. “Transport of Pollen in a Sendiment-Laden Channel: A Laboratory Study.” American Journal of Science 272, no. 4 (1972): 359–81. +
+
+Ermak, Donald L. “An Analytical Model for Air Pollutant Transport and Deposition from a Point Source.” Atmospheric Environment 11 (1977): 231–37. https://doi.org/10.1016/0004-6981(77)90140-8. +
+
+Griffiths, R. F. “Errors in the Use of the Briggs Parameterization for Atmospheric Dispersion Coefficients.” Atmospheric Environment 28, no. 17 (1994): 2861–65. https://doi.org/10.1016/1352-2310(94)90086-8. +
+
+Katz, Daniel S. W., Jonathan R. Morris, and Stuart A. Batterman. “Pollen Production for 13 Urban North American Tree Species: Allometric Equations for Tree Trunk Diameter and Crown Area.” Astrobiologia (Bologna) 36, no. 3 (2020). https://doi.org/10.1007/s10453-020-09638-8. +
+
+McPherson, E. Gregory, Natalie S. van Doorn, and Paula J. Peper. “Urban Tree Database and Allometric Equations.” Albany, CA: U. S. Department of Agriculture, Forest Service, Pacific Southwest Research Station, 2016. https://doi.org/10.2737/PSW-GTR-253. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/relief_valve_sizing/figure1.svg b/posts/relief_valve_sizing/figure1.svg new file mode 100644 index 0000000..1bf475d --- /dev/null +++ b/posts/relief_valve_sizing/figure1.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/relief_valve_sizing/figure2.svg b/posts/relief_valve_sizing/figure2.svg new file mode 100644 index 0000000..5eab53e --- /dev/null +++ b/posts/relief_valve_sizing/figure2.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/relief_valve_sizing/figure3.svg b/posts/relief_valve_sizing/figure3.svg new file mode 100644 index 0000000..5fc774d --- /dev/null +++ b/posts/relief_valve_sizing/figure3.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/relief_valve_sizing/figure4.svg b/posts/relief_valve_sizing/figure4.svg new file mode 100644 index 0000000..d160e6e --- /dev/null +++ b/posts/relief_valve_sizing/figure4.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/relief_valve_sizing/index.html b/posts/relief_valve_sizing/index.html new file mode 100644 index 0000000..ecc787a --- /dev/null +++ b/posts/relief_valve_sizing/index.html @@ -0,0 +1,1283 @@ + + + + + + + + + + + + +Relief Valve Sizing with Real Gases – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Relief Valve Sizing with Real Gases

+
+
+ Compressible orifice flow calculations using equations of state. +
+
+
+
julia
+
pressure relief
+
compressible flow
+
equations of state
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

October 28, 2024

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

Very often, in chemical engineering, the line between problems one can solve one’s self and problems that are solved with a piece of commercial software is when ideal fluid assumptions break down. Relief valve sizing is a typical example: if the fluid is (approximately) an ideal gas then sizing is simple and often done in a spreadsheet. When this isn’t the case, if the compressiblity is <0.8 or >1.1,1 then one typically has to turn to some commercial software. Models of real fluids are complicated and extracting the relevant thermodynamic properties from them can be quite tedious when doing it all from scratch.

+

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.

+
+

Sizing a Pressure Relief Valve

+

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

+

\[ W = K A G \]

+

where \(W\) is the required mass flow-rate, \(K\) is a capacity correction, \(A\) is the theoretical flow area, and \(G\) the frictionless) mass flux through the valve. Valves are sized based on the theoretical flow area.

+

The general process is as follows:

+
    +
  1. Determine the governing release rate, \(W\)
  2. +
  3. Determine the capacity correction, \(K\)
  4. +
  5. Calculate the mass flux through the valve, \(G\)
  6. +
  7. Calculate the theoretical flow area \(A = \frac{W}{K G}\)
  8. +
  9. Select the appropriate valve with a flow area \(>A\).
  10. +
+

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 \(G\), 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.

+
+
+
+ +
+
+Figure 1: A hypothetical pressure relief device, connected to a pressure reservoir (1) and discharging into the atmosphere (2). +
+
+
+

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

+

\[ dP + \rho u du = 0 \]

+

\[ u du = -\frac{dP}{\rho} = - vdP \]

+

Integrating from the stagnation point to the throat of the nozzle gives

+

\[ \frac{1}{2} u_t^2 = - \int_{P_1}^{P_t} v dP \]

+

Where the velocity at the stagnation point, \(u_1=0\). Putting this in terms of the mass flux \(u = v G\)

+

\[ \frac{1}{2} v_t^2 G_t^2 = - \int_{P_1}^{P_t} v dP \]

+

\[ G_t = \frac{1}{v_t} \sqrt{-2 \int_{P_1}^{P_t} v dP} = \rho_t \sqrt{2 \int_{P_t}^{P_1} v dP} \]

+

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, \(P_t, T_t\).

+

If we specify that the streamline follows an isentropic path, then we can construct a constrained maximization problem: the nozzle conditions are the \(P_t\) and \(T_t\) which maximizes \(G_t\) where the integration is taken along an isentropic path.

+
+

Choked Flow

+

In the case where flow is choked, i.e. the flow in the nozzle reaches sonic velocity, the maximum \(G_t\) occurs at the sonic velocity with a pressure \(P_t > P_2\). This can allow for the direct calculation of the mass flux as \(G_t = \rho_t c_t\), where \(c_t\) is the sonic velocity at the throat. No integration required.

+
+
+
+

A Motivating Example

+

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 Unitful
+
begin
+# the vessel properties
+    P₁ = 200u"bar"
+    T₁ = 400u"K"
+
+# the ambient properties
+    P₂ = 1u"bar"
+    T₂ = 288.15u"K"
+end
+

We 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 Clapeyron
+
begin
+# 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.

+
+
+

The Ideal Gas Case

+

Considering the choked flow case, we know that \(G_t = \frac{c_t}{v_t}\) and, for an ideal gas, the sonic velocity is given by3

+

\[ c = \sqrt{ {k R T} \over M} = \sqrt{k P v} \]

+

Combining these we have

+

\[ G_t = \frac{c_t}{v_t} = { \sqrt{ k P_t v_t } \over v_t } = \sqrt{ k P_t \over v_t } \]

+

It can be shown that, along an isentropic path defined by \(P v^k = \mathrm{const}\), the critical pressure ratio is4

+

4 Tilton equation 6-119.

\[ {P_t \over P_1} = { P_{chk} \over P_1 } = \left(2 \over {k+1} \right)^{k \over {k-1} } \]

+

Which allows us to write

+

\[ P_t = P_1 {P_t \over P_1} = P_1 \left(2 \over {k+1} \right)^{k \over {k-1} } \]

+

and (using \(P_1 v_1^k = P_t v_t^k\))

+

\[ v_t = v_1 \left(P_1 \over P_t \right)^{1 \over k} = v_1 \left(2 \over {k+1} \right)^{-1 \over {k-1} } \]

+

Substituting back into the equation for \(G_t\)5

+

\[ G_t = \sqrt{ k \frac{P_1}{v_1} \left(2 \over {k+1} \right)^{k+1 \over {k-1} } } \]

+

or, to put it in terms of density

+

\[ G_t = \sqrt{ k P_1 \rho_1 \left(2 \over {k+1} \right)^{k+1 \over {k-1} } } \]

+

where \(k\), the isentropic expansion factor for an ideal gas, is the ratio of heat capacities

+

\[ k = { c_{p,ig} \over c_{v,ig} } \]

+
+
+
+ +
+
+Note +
+
+
+

This is the basis of API 520 Part 1 equation 9 where the following substitutions is made:

+

\[ \rho = { {P M} \over {Z R T} } \]

+

and the constant \(R\) and some unit conversions are rolled up into the constant 0.03948 in the expression for \(C\)

+

\[ R = 8.314 { {\mathrm{m^3} \cdot \mathrm{Pa} } \over {\mathrm{mol} \cdot \mathrm{K} } } = 8,314 { {\mathrm{m^3} \cdot \mathrm{Pa} } \over {\mathrm{kmol} \cdot \mathrm{K} } } = 8,314 { {\mathrm{kg} \cdot \mathrm{m^2} } \over { \mathrm{kmol} \cdot \mathrm{s^2} \cdot \mathrm{K} } } \]

+

\[ {1 \over \sqrt{8,314} } \left[ { \sqrt{ \mathrm{kmol} \cdot \mathrm{K} } \cdot \mathrm{s} } \over { \sqrt{\mathrm{kg} } \cdot \mathrm{m} } \right] \times 3600 \left[ \mathrm{s} \over \mathrm{h} \right] \times 10^{-6} \left[ \mathrm{m^2} \over \mathrm{mm^2} \right] \times 10^3 \left[ \mathrm{Pa} \over \mathrm{kPa} \right] \]

+

\[ = 0.03948 \left[ \sqrt{\mathrm{kmol} \cdot \mathrm{kg} \cdot \mathrm{K} } \over { \mathrm{h} \cdot \mathrm{mm^2} \cdot \mathrm{kPa} } \right] \]

+
+
+

We can use Clapeyron.jl to calculate \(k\) 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
+end
+

From 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

+
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ₜ²)
+end
+

The 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.

+
+
+

The Isentropic Expansion Factor

+

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

+

\[ P_1 v_1^n = P_t v_t^n \]

+

where \(n\) is a constant.

+

The derivation of \(n\) follows from the definition of the speed of sound in a gas8

+

\[ c = \sqrt{ \left( {\partial P} \over {\partial \rho} \right)_S} =\sqrt{ -v^2 \left( {\partial P} \over {\partial v} \right)_S} \]

+

The constant entropy partial derivative can be re-written to eliminate entropy9

+

\[ \left( {\partial P} \over {\partial v} \right)_S = { { \left( {\partial S} \over {\partial T} \right)_P \left( {\partial P} \over {\partial T} \right)_v } \over { \left( {\partial S} \over {\partial T} \right)_v \left( {\partial v} \over {\partial T} \right)_P } } \]

+

Using the relations10

+

10 Gmehling et al., Chemical Thermodynamics for Process Simulation equations C.21 and C.8 (respectively).

\[ c_p = T \left( {\partial S} \over {\partial T} \right)_P \]

+

and

+

\[ c_v = T \left( {\partial S} \over {\partial T} \right)_V \]

+

we get

+

\[ \left( {\partial P} \over {\partial v} \right)_S = {c_p \over c_v} \left( {\partial P} \over {\partial v} \right)_T \]

+

and the sonic velocity is then

+

\[ c = \sqrt{ -v^2 {c_p \over c_v} \left( {\partial P} \over {\partial v} \right)_T } \]

+

equating this to the ideal gas case, \(c = \sqrt{n P v}\), and solving for \(n\) gives11

+

\[ n = -\frac{v}{P} {c_p \over c_v} \left( {\partial P} \over {\partial v} \right)_T \]

+

where \(n\) has been used to distinguish it from \(k\) (the ideal gas case). This is the version of \(n\) presented in most references, such as API 520. The derivation, however, hints at a useful shortcut to calculating \(n\) that does not require digging into the internals of Clapeyron.jl to retrieve partial derivatives:

+

\[ n = { c^2 \over {P v} } = { {\rho c^2} \over P } \]

+

The remainder of the calculations are identical as the ideal gas case, simply substituting \(n\) wherever \(k\) appears. Unfortunately \(n\) 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
+end
+

Using 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.

+
+
+
+ +
+
+Figure 2: The isentropic expansion factor for ethane at 400K, calculated for a range of stagnation pressures. +
+
+
+

The isentropic expansion factor method works best when \(n\) 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.

+
+
+

Solving the Choked Flow Energy Balance

+

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).

+

\[ h_1 = h_t + \frac{1}{2} c_t^2 \]

+

Where \(c_t\) is the speed of sound at the nozzle, a function of \(P_t\) and \(T_t\). The procedure is then to solve the system of equations given by the energy balance and the entropy balance, \(s_1 = s_t\), for \(P_t\) and \(T_t\), then the theoretical mass flux is given by

+

\[ G_t = \rho_t c_t \]

+

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 \(P_t\) and \(T_t\) 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
+= speed_of_sound(prms.model, P, T, prms.z)^2
+    
+    return [ s₁ - s₂
+             h₁ - h₂ - 0.5*c² ]
+end
+
using NonlinearSolve
+
function 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ₜ
+end
+
function 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"
+end
+

Solving 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.

+
+
+
+ +
+
+Figure 3: The isentropic paths for the ideal gas, effective isentropic factor, and true isentropic path methods. +
+
+
+

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 \(u_t = c_t\) and instead finding the conditions which maximize \(G_t\) (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

+

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 \(P_t\) and \(T_t\) that maximize the mass flux given by

+

\[ G_t = \rho_t \sqrt{2 \int_{P_t}^{P_1} v dP} \]

+

First introduce the change of variables \(\Delta P = P_1 - P\) such that the integration is from \(0\) to \(P_1 - P_2\).

+

\[ \int_{P_2}^{P_1} v dP = - \int_{0}^{\Delta P} v\left( P_1 - \Delta P \right)_{s = s_1} d\left(\Delta P \right) \]

+

This allows us to write the corresponding differential equation

+

\[ { {d} \over {d \left(\Delta P \right)} } I = v\left(P₁ - ΔP\right) \]

+

subject to the constraint

+

\[ s(P_1 - \Delta P, T) = s(P_1, T_1) \]

+

Which can be implemented as a differential algebraic equation using DifferentialEquations.jl

+
using DifferentialEquations
+
function 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) ]
+end
+

But we want to stop the integration when \({ {\partial G} \over {\partial \left( \Delta P \right) } } = 0\) or, equivalently, when the velocity is sonic. We can show that these are the same by finding the stationary points of \(G^2\)

+

\[ { {\partial G^2} \over {\partial \left( \Delta P \right) } } = { {\partial } \over {\partial \left( \Delta P \right) } } \left( 2 \rho_t^2 \int_0^{\Delta P_t} v d \left( \Delta P \right) \right) = 0 \]

+

by applying the chain rule and cancelling \(\rho\) we get

+

\[ 2 \left( {\partial \rho} \over { \partial P } \right)_S \int_0^{\Delta P_t} v d \left( \Delta P \right) - 1 = 0 \]

+

recalling the definition of the speed of sound (above)

+

\[ \left( {\partial \rho} \over { \partial P } \right)_S = \frac{1}{c^2} \]

+

we have

+

\[ 2 \int_0^{\Delta P_t} v d \left( \Delta P \right) - c^2 = 0 \]

+

which is simply restating \(u_t = c_t\).

+
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
+end
+
function 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)
+end
+
function 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"
+end
+

Direct 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.

+
+
+
+ +
+
+Figure 4: The mass flux as a function of nozzle pressure drop, showing the intermediate steps until a maximum was found. +
+
+
+

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 \(P_2\). This result will simply pop out without any extra effort.

+
+
+

Comparing the Results

+

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, \(Z\), 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.

+
+
+
+ +
+
+Figure 5: A comparison of calculated theoretical mass flux for the six methods. The results from the first law energy balance and direct integration are identical. +
+
+
+

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.

+
+
+

Final Thoughts

+

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.

+
+
+

References

+
+
+API. Standard 520, Sizing, Selection, and Installation of Pressure-Relieving Devices, Part i - Sizing and Selection, 10th Ed. Washington, DC: American Petroleum Institute, 2020. +
+
+Chemical Process Safety, Center for. Guidelines for Pressure Relief and Effluent Handling Systems. Hoboken, NJ: John Wiley & Sons, 2017. +
+
+Crowl, Daniel A., Lawrence G. Britton, Walter L. Frank, Stanley Grossel, Dennis Hendershot, W. G. High, Robert W. Johnson, et al. “Process Safety.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green. New York: McGraw Hill, 2008. +
+
+Gmehling, Jürgen, Bärbel Kolbe, Michael Kleiber, and Jürgen Rary. Chemical Thermodynamics for Process Simulation. Weinheim, DE: Wiley-VCH Verlag & Co., 2012. +
+
+Green, Don W., ed. Perry’s Chemical Engineers’ Handbook. New York: McGraw Hill, 2008. +
+
+Tilton, James N. “Fluid and Particle Dynamics.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green. New York: McGraw Hill, 2008. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/relief_valve_sizing/pressure_relief.png b/posts/relief_valve_sizing/pressure_relief.png new file mode 100644 index 0000000..6944a46 Binary files /dev/null and b/posts/relief_valve_sizing/pressure_relief.png differ diff --git a/posts/sizing_a_gooseneck_example/index.html b/posts/sizing_a_gooseneck_example/index.html new file mode 100644 index 0000000..d862dd7 --- /dev/null +++ b/posts/sizing_a_gooseneck_example/index.html @@ -0,0 +1,1312 @@ + + + + + + + + + + + + +Compressible Flow Example - Sizing a Goose Neck Vent – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Compressible Flow Example - Sizing a Goose Neck Vent

+
+
+ Calculating the minimum diameter in incompressible, isothermal, and adiabatic flow situations. +
+
+
+
julia
+
compressible flow
+
pressure relief
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

November 28, 2020

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

When determining the required venting for above ground storage tanks it is typical to calculate normal and emergency vent rates using standards such as API 2000, which gives the venting as an equivalent flow rate of air at standard state. Most off-the-shelf vents are sized in terms of pressure drop and SCFH through it, so after calculating the required venting one can simply buy a vent with the needed characteristics.

+
+
+
+


+

+


+

+
+
+Figure 1: An example emergency vent from Morrison brothers +
+
+
+

It’s not uncommon, though, for tanks to have goose-neck vents constructed from piping. This is fairly normal for tanks holding water, or other nonvolatile substances, where the tank is open to atmosphere and there are no dangerous vapours that need to be managed. The goose-neck itself is merely to keep rain, and wildlife, out of the tank.

+

Sizing the goose-neck to match the required venting involves performing some simple compressible flow calculations, which is fairly straightforward to set up in a generalized way such that, beyond this motivating example, it can be extended to lots of other problems. Though it isn’t at all uncommon, in this particular case, for the flow calculations to be done assuming an incompressible fluid as, over the length of the goose-neck, the pressure drop is typically slight and the compressibility of the gas (air usually) is not important.

+

With that in mind, I am going to work through the problem in stages of escalating complexity, very likely the most complicated (fanno flow) is overkill for this specific example but it’s worth putting it all down as the same tools can be used for compressible flow calculations through many piping situations.

+
+

The Scenario

+

Suppose an atmospheric storage tank with a normal venting requirement, as calculated from API 2000 or the like, of 200×10³ SCFH and a max pressure of 1 psig. We wish to design a goose-neck vent that can handle that level of venting. Suppose that the goose-neck design we have in mind is a vertical length of pipe extending up from the tank roof, two 90° bends, and an exit covered with a mesh screen (to keep birds from nesting in it, yes this is a thing). The goose-neck is a constant diameter of pipe throughout.

+

For notation, the flow begins at the pipe entrance (1) and ends at the exit (2).

+
+
+
+ +
+
+Figure 2: A sketch of the goose neck vent. +
+
+
+

For the pipe bends the bend radius to diameter ratio needs to be specified, I’m going to suppose \(r/D = 1.5\). Another important parameter is the pipe roughness, \(\epsilon\), which for commercial steel is \(\epsilon = 0.0457 \mathrm{mm}\).1 At this point I could specify a length for the straight section of pipe, a fixed height above the tank roof that is independent of the final chosen diameter of the piping, or I could fix a design and scale the whole vent up and down as required. For simplicity I am going to assume 3ft of piping.

+

At this point I am going to set up the equations with no knowledge of what the final pipe diameter \(D\) will be, then numerically solve for the minimum diameter that meets the requirements. The actual diameter will be the next largest NPS size pipe.

+
+
using Unitful: @u_str, uconvert, ustrip, upreferred
+
+# Setting up some convenient unit conversions
+SCFH = uconvert(u"m^3/s", 1u"ft^3/hr")
+psi = uconvert(u"Pa", 1u"psi")
+ft = uconvert(u"m", 1u"ft")
+inch = uconvert(u"m", 1u"inch")
+mm = uconvert(u"m", 1u"mm")
+
+# Given in the scenario, now converted to SI
+Q = 200e3SCFH
+pₐ = 14.696psi
+pₘₐₓ = 1psi + pₐ
+L = 3ft
+ϵ = 0.0457mm;
+
+

I am assuming, for simplicity, that ambient conditions are standard conditions.

+
+
# Universal gas constant to more digits than are at all necessary
+R = 8.31446261815324u"Pa*m^3/mol/K"
+
+# Standard conditions, 15°C 
+Tₐ = 288.15u"K"
+
+# Some useful physical properties of air
+Mw = 0.02896u"kg/mol"  # Molar weight of air, from Perry's
+k = 1.4                # Ratio of heat capacities, Cp/Cv, ideal gas
+
+# density of air, ideal gas law, kg/m^3
+ρ(p, T) = (p * Mw)/(R * T)
+
+# viscosity of air, from Perry's
+μ(T) = 1u"Pa*s"*(1.425e-6*ustrip(u"K",T)^0.5039)/(1 + 108.3/ustrip(u"K",T));
+
+
+

Frictional head loss

+

Regardless of the method of performing the compressible flow calculations, the frictional head loss in the piping needs to be accounted for. I am using the K factor method as it is convenient and K factors are tabulated for most everything in references such as Crane’s TP-410. One thing to be very careful with is the difference between the Darcy and Fanning friction factors. I am using Crane’s where everything is in terms of the Darcy friction factor, which is 4× the Fanning friction factor, but Perry’s defaults to the Fanning friction factor.

+

From Crane’s I have the following K factors for each piece of the goose-neck2

+
    +
  • Entrance - \(K_1 = 0.5\)
  • +
  • Vertical Pipe - \(K_2 = f \frac{L}{D}\)
  • +
  • First 90° bend - \(K_3 = 14 f_T\)
  • +
  • Second 90° bend - \(K_4 = 14 f_T\)
  • +
  • Mesh screen - \(K_5 = f_T\)
  • +
  • Exit to atmosphere - \(K_6 = 1.0\)
  • +
+

Where \(f\) is the Darcy friction factor, \(f_T\) is the turbulent friction factor. I am assuming the entrance to the vent is sharp edged, and the K factors for the bends are for bends with \(r/D = 1.5\).

+

For some notational convenience I am going to define the relative roughness \(\kappa = { \epsilon \over D }\) and the reduced length \(l = {L \over D}\) so that, along with the Reynolds number \(Re\), the K factors are in terms of dimensionless numbers only.

+

The Darcy friction factor generally depends on which regime the flow is in, laminar, transitional, or turbulent. Since I don’t want to be referring to a Moody diagram I want to use a single equation that operates over a wide range of Reynolds numbers and potentially in laminar and transitional flow regimes since I don’t know a priori what the flow in the vent will be. There are equations like the Serghide correlation or Churchill correlation that attempt to fit a Moody diagram but in a more convenient to use manner.

+
+
+
+
+
+ +
+
+Figure 3: A Moody diagram with the Serghide correlation and Churchill correlation overlaid. +
+
+
+
+
+

The black line conservatively takes the max of either the laminar or turbulent friction factor in the transitional region \(2100 \le Re \le 4000\). The Churchill correlation fits the general curve well for both the turbulent and laminar region, and provides reasonable values in the transitional region.

+

The Churchill correlation is3

+

3 Tilton, “Fluid and Particle Dynamics,” 6–11. The equation here is given in terms of the darcy friction factor.

\[ f = 8 \left( \left( \frac{8}{Re} \right)^{12} + { 1 \over {\left( A+B \right)^{3/2} } } \right)^{1/12} \]

+

\[ A = \left( 2.457 \ln\left( {1 \over {\left( \frac{7}{Re} \right)^{0.9} + 0.27\kappa} } \right) \right)^{16} \]

+

\[ B = \left( \frac{37530}{Re} \right)^{16} \]

+

The turbulent friction factor is the friction factor at fully turbulent flow, when f is no longer dependent upon the Reynolds number.

+

\[ f_T = { 0.25 \over { \left( \log \left( \kappa \over 3.7 \right) \right)^2 } } \]

+

With these defined I can write a function that gives \(\sum_j K_j\) for any \(\kappa\), l, and Re

+
+
function f(κ, Re)
+    A = (2.457 * log(1/((7/Re)^0.9 + 0.27*κ)))^16
+    B = (37530/Re)^16
+    return 8*((8/Re)^12 + 1/(A+B)^(3/2))^(1/12)
+end
+
+fT(κ) = 0.25/log10/3.7)^2
+
+ΣK(κ,l,Re) = 0.5 + f(κ,Re)*l + 14*fT(κ) + 14*fT(κ) + fT(κ) + 1.0;
+
+
+
+

The Reynolds number

+

Since the vent has a constant cross sectional area, the mass velocity, \(G\), is constant throughout

+

\[ G = \frac{4 \dot{m} }{\pi D^2} \]

+

Where \(\dot{m}\) is the mass flow rate in kg/s flowing through the vent, which is \[\dot{m} = Q_{std} \cdot \rho \left(p_{std}, T_{std} \right) \] with \(Q\) the flow at standard conditions and \(\rho\) the density at standard conditions.

+

Note The required vent flow is given in SCFH, this is not temperature or pressure dependent and \(\dot{m}\) is a constant. If the flow given was a true volumetric flow rate, then \(\dot{m}\) would be a function of temperature and pressure in general. This is one of those things that routinely snags inexperienced engineers as a flow in terms of a standard volume looks like a volumetric flow rate, it’s in units of volume, but really isn’t one since the temperature and pressure are set to standard state by definition.

+

The Reynolds number in terms of the mass velocity is

+

\[ Re = \frac{G D}{\mu} \]

+

The only parameter in the Reynolds number which is not a constant is the viscosity, \(\mu\), which is mostly dependent upon temperature and not pressure. So, to a very good first approximation, the Reynolds number is only a function of temperature. Which is very convenient.

+
+
# mass flow, recall Q is at standard state so is not a function of 
+# temperature or pressure
+m = Q*ρ(pₐ, Tₐ)
+
+# mass velocity
+G(D) = (4*m)/(π*D^2)
+
+# Reynold's number
+# upreferred promotes derived units to combos of base units
+# e.g. converts Pa -> kg/m/s^2
+Re(D, T) = upreferred(G(D)*D/μ(T));
+
+
+
+
+

Incompressible Flow

+

For problems, like this vent, where the pressure drop is expected to be small it is not unreasonable to assume the flow is approximately incompressible. This is very often what is done since, typically, it does not require any iterative methods and one can solve for incompressible flow directly. It is also useful to do even if one plans on performing a more complete compressible flow calculation, since this provides something of a sanity check and can be a good place to start compressible flow iterative calculations, i.e. the initial guess is from the incompressible case

+

To check whether or not the incompressible assumption is reasonable, consider the ratio of density inside the tank (at the max allowable pressure) to the density outside the tank, assuming ambient temperature. For an ideal gas this is

+

\[ { \rho_1 \over \rho_2 } = { {p_1 Mw} \over {R T_a} } { {R T_a} \over {p_2 Mw} } = \frac{p_1}{p_2} = \frac{p_{max} }{p_a}\]

+
+
pₘₐₓ/pₐ
+
+
1.0680457267283614
+
+
+

Typically flows are considered incompressible if the density varies by less than ~5-10%, so this example (where the density varies by ~7%) is right in that range. You could justify it either way and it’s more a function of how accurate the calculations need to be. Since, ultimately, we are solving for the pipe diameter and choosing the next largest pipe size it’s probably fine to use an incompressible flow assumption. If anything the incompressible flow assumption will overestimate the pressure drop and thus lead to an oversized pipe (erring on the side of caution)

+

The mechanical energy balance for an incompressible fluid is4

+

\[ p_1 - p_2 = \alpha_2 \frac{\rho v_2^2}{2} - \alpha_1 \frac{\rho v_1^2}{2} + \rho g \left( h_2 - h_1 \right) + \sum_j K_j \frac{\rho v^2}{2} \]

+

With the following simplifications + given the assumption of incompressible flow and a vent with a constant cross-sectional area \(v_1 = v_2 = v\), + the flow is uniform throughout \(\alpha_1 = \alpha_2 = 1.0\) + the contribution due to hydro-static pressure is negligible as the gas density is very small, \(\rho g \left( h_2 - h_1 \right) \approx 0\)

+

This becomes

+

\[ p_1 - p_2 = \sum_j K_j \frac{\rho v^2}{2} \]

+

Where the velocity, \(v\) is

+

\[ v = \frac{Q}{A} = { Q \over { \frac{\pi}{4} D^2 } } \]

+

Which can be solved algebraically for \(D\), where \(\rho\) is taken at the average pressure. That said it is easier to solve it numerically.

+
+
using Roots: find_zero, Brent
+
+v(D) = Q / ((π/4)*D^2)
+
+Dᵢₙ(Dₗ, Dᵤ) = find_zero( 
+    (D) -> pₘₐₓ - pₐ - 0.5*ΣK/D,L/D,Re(D,Tₐ))((pₘₐₓ + pₐ)/2, Tₐ)*v(D)^2,
+    (Dₗ, Dᵤ), # Lower and upper bracket of the root
+    Brent() ) # Solve using Brent's method
+
+D0 = Dᵢₙ(4inch, 12inch)
+
+uconvert(u"inch", D0)
+
+
6.497118827423374 inch
+
+
+

At this point one would typically stop for this example, compressible flow calculations are probably unnecessary.

+
+
+

Compressible Flow

+

In general compressible flow situations can be very difficult to solve, since the density of the working fluid is a function of the pressure and temperature and the pressure and temperature are varying throughout, which means heat transfer must also be accounted for in some way. There are several key simplifying assumptions that take this from the sort of problem solved with CFD to something actually quite simple. The first is to assume an ideal gas, the second is to examine two extreme cases of heat transfer: isothermal flow and adiabatic flow.

+

At these two extreme cases, the first where heat transfer is instantaneous and the second where it doesn’t occur at all, provide bounds on the problem.

+
+

Isothermal Compressible Flow

+

Isothermal compressible flow of an ideal gas is fairly straight forward. As already mentioned the Reynolds number depends only on temperature, which is constant by definition, so the Reynolds number is a constant. This means the frictional head loss is also constant throughout, and it is a simple matter to calculate the pressure drop.

+

The assumption that the flow is isothermal is very reasonable in this case. We are assuming normal venting from a tank at thermal equilibrium with it’s surroundings, that is that the air flowing through the vent starts and ends in reservoirs of equal temperature. As gases expand the temperature decreases but the pressure drop across the vent is small so this effect should be negligible.

+

A quick check is to estimate the ratio of temperatures at the start and end of the vent assuming a friction-less adiabatic expansion

+

\[ p_1^{1-k} T_1^k = p_2^{1-k} T_2^k = \mathrm{const}\]

+

\[ \frac{T_2}{T_1} = \left( p_1 \over p_2 \right)^{ {1-k} \over k}\]

+
+
(pₘₐₓ/pₐ)^((1-k)/k)
+
+
0.9813670503935878
+
+
+

So we expect even in the most extreme case the temperature change is ~2%, justifying the assumption that the venting is isothermal.

+

The isothermal flow of an ideal gas going through a length of piping is5

+

5 Tilton, 6–23. This equation also neglects changes in elevation.

\[ p_{1}^{2} = G^{2} \frac{RT}{Mw} \left[ \sum \limits_{j} K_{j} + 2\ln \frac{p_{1} }{p_{2} } \right] + p_{2}^{2} \]

+

If we assume the system is at thermal equilibrium with the outside air, then \(T = T_a\) and \(p_2 = p_a\)

+

The only unknown is p1, which can be solved for numerically.

+
+
pᵢₜ(G,κ,l,Re) = find_zero(
+    p -> p^2 - G^2 * (R*Tₐ/Mw) * (ΣK(κ,l,Re) + 2*log(p/pₐ)) - pₐ^2, 
+    pₘₐₓ); # initial guess
+
+

At this point we can write a simple function to solve for the minimum diameter that meets our requirement that \(p_1 \le p_{max}\).

+
+
Dᵢₜ(Dₗ, Dᵤ) = find_zero( 
+    D -> pₘₐₓ - pᵢₜ( G(D), ϵ/D, L/D, Re(D, Tₐ)), 
+    (Dₗ, Dᵤ), # Lower and upper bracket of the root
+    Brent() ) # Solve using Brent's method
+
+D1 = Dᵢₜ(4inch, 12inch)
+
+uconvert(u"inch", D1)
+
+
6.491472166277518 inch
+
+
+
+
+

Adiabatic (Fanno) Flow

+

Adiabatic flow of an ideal gas through a pipe, also called Fanno flow, is somewhat more difficult than isothermal flow – there are more steps in the iterative solution as the temperature along the length of the vent changes and thus the Reynolds number changes. The general process starts by assuming a constant friction factor, calculating the pressure and temperature changes due to the adiabatic expansion of an ideal gas, adjusting the friction factor for the temperature change, and iterating until everything converges.

+

There are a few ways of setting up the calculations. We could assume the gas exits at ambient conditions – both ambient temperature and pressure – or assume the tank starts at thermal equilibrium with the environment but at a higher pressure and the gas exits at ambient pressure and some other temperature – less than ambient due to adiabatic expansion. The first set of assumptions is in some ways easier to calculate, but the second set of assumptions is more physically realistic, and consistent with the assumptions made when solving the isothermal case.

+

One thing we should check before proceeding is whether or not the flow will be choked, essentially will the flow velocity reach \(Ma = 1\), the following discussion assumes flow remains subsonic, and this is easy to check. The critical pressure, at which flow becomes sonic, is given by6

+

\[ { p^o \over p_1 } = \left(2 \over k+1 \right)^{k \over {k-1} } \]

+

with the criteria that flow is subsonic if

+

\[ { p_2 \over p_1 } > { p^o \over p_1 } \]

+
+
(pₐ / pₘₐₓ) > (2 / (k+1))^(k/(k-1))
+
+
true
+
+
+

The basic relation of Fanno flow that drives the equations is the relationship between the Fanno parameter and the Mach number7

+

\[ Fa = \left( \frac{fL^{*} }{D} \right) = \frac{1 - Ma^{2} }{kMa^{2} } + \frac{k+1}{2k} \ln \left( \frac{ \left( k+1 \right) Ma^{2} }{ 2+\left( k+1 \right) Ma^{2} } \right) \]

+

Where I am defining \(Fa\) to be the Fanno parameter. The Fanno parameter is calculated from some point in the flow path through to the critical point, where flow goes sonic. The critical point can be a hypothetical point, assuming the pipe is infinite, or it can be real. In this case I am assuming the flow within the vent will remain subsonic.

+

It is worth noting that elbows near the exit of a pipe may choke the flow even though the \(Ma < 1\) due to the nonuniform velocity profile in the elbow. By the design of this goose-neck we know this is the case and should keep that in mind when evaluating the results.

+

For two points along a pipe, 1 and 2, the difference between their Fanno parameters is the frictional loss between those two points8

+

\[ Fa_1 - Fa_2 = \sum_{j} K_{j} \]

+

Where the \(K_j\) are usually evaluated at the average temperature \({ {T_1 + T_2} \over 2}\).

+

The Mach number at some point i along the pipe, for an ideal gas, is given by9

+

9 Derived for an ideal gas:

+

\[ G = \rho v = { {p Mw} \over {R T} } v\]

+

\[ c = \sqrt{ {k R T} \over Mw } \]

+

\[ Ma = { v \over c } = G { {R T} \over {p Mw} } \sqrt{ Mw \over {k R T} } = \frac{G}{p} \sqrt{ \frac{RT}{kMw} }\]

\[ Ma_{i} = \frac{v}{c} = \frac{G}{p_{i} } \sqrt{ \frac{RT_{i} }{kMw} } \]

+

and the pressure can be calculated given a Mach number by rearranging

+

\[ p_{i} = \frac{G}{Ma_{i} } \sqrt{ \frac{RT_{i} }{kMw} } \]

+

and for any two points along the pipe the temperatures are related by10

+

10 Tilton, “Fluid and Particle Dynamics” equation 6-116. Taking two points and cancelling out the stagnation temperature.

\[ T_{1} = T_{2} \frac{2 + \left( k-1 \right) Ma_{2}^{2} }{2 + \left( k-1 \right) Ma_{1}^{2} } \]

+

Putting all of this together, the procedure for adiabatic ideal gas flow through piping with a given diameter \(D\) is:

+
    +
  1. Given \(G\) calculate \(Ma_2\) at ambient conditions, this is the initial guess for the exit conditions
  2. +
  3. Calculate \(\sum_j K_j\) at ambient conditions, this is the initial guess for the frictional loss
  4. +
  5. Calculate \(Fa_2\) with \(Ma_2\)
  6. +
  7. Calculate \(Fa_1\) from \(Fa_2\) and \(\sum_j K_j\)
  8. +
  9. Solve for \(Ma_1\) given \(Fa_1\), this is done numerically
  10. +
  11. Solve for \(T_2\) given \(Ma_1\) and letting \(Ma_2\) vary with temperature, this is done numerically
  12. +
  13. Recalculate \(Ma_2\) given \(T_2\) and \(\sum_j K_j\) at the average temperature \({ {T_1 + T_2} \over 2}\) and repeat from step 3
  14. +
  15. Continue to iterate until \(p_1\) stops changing
  16. +
+

While that looks complicated, each step is fairly easy. In my experience, with subsonic flow, this converges very quickly.

+
+
# Fanno parameter
+Fa(Ma) = ((1-Ma^2)/(k*Ma^2)) + ((k+1)/(2k))*log( ((k+1)*Ma^2) / (2 + (k+1)*Ma^2))
+
+# Mach number
+# upreferred(...) ensures units cancel appropriately and the Ma is unitless
+Ma(G, p, T) = upreferred((G/p)*√((R*T)/(k*Mw)))  
+
+
+T2(T₁, Ma₁, G, p) = find_zero(
+    T -> (2 + (k-1)*Ma₁^2)*T₁ - (2 + (k-1)*Ma(G, p, T)^2)*T,
+    T₁) # Use the isothermal case as an initial guess
+
+
+function pfa(D)
+    # pre-calculating diameter dependent variables
+    Gᵢ = G(D)
+    κᵢ = ϵ/D
+    lᵢ = L/D
+
+    # initial values
+    T₂ = Tₐ
+    Ma₁ = Ma(Gᵢ, pₐ, Tₐ)
+    p₁ⁿᵉʷ = uconvert(u"Pa",(Gᵢ/Ma₁)*√((R*Tₐ)/(k*Mw)))
+    
+    # loop until the error is below the given tolerance, but don't loop forever!
+    err, i = 1.0, 0
+    rtol, max_count = 1e-9, 1e5
+    while (err > rtol) && (i < max_count)
+        # Starting up
+        Tₐᵥ = 0.5*(Tₐ + T₂)
+        Reᵢ = Re(D,Tₐᵥ)
+        Ma₂ = Ma(Gᵢ, pₐ, T₂)
+             
+        # Steps 3 - 6
+        Fa₂ = Fa(Ma₂)
+        Fa₁ = Fa₂ + ΣK(κᵢ,lᵢ,Reᵢ)
+        Ma₁ = find_zero(x -> Fa₁ - Fa(x), (Ma₂ + Ma₁)/2)
+        T₂ = T2(Tₐ, Ma₁, Gᵢ, pₐ)
+        
+        # Check if pressure has converged
+        p₁ᵒˡᵈ = p₁ⁿᵉʷ
+        p₁ⁿᵉʷ = uconvert(u"Pa",(Gᵢ/Ma₁)*√((R*Tₐ)/(k*Mw)))
+        err = abs(p₁ⁿᵉʷ - p₁ᵒˡᵈ)/p₁ᵒˡᵈ
+        i += 1
+    end
+       
+    # if the loop failed to converge, let me know
+    if i >= max_count
+        error_msg = "iterations exceeded max count, remaining error is $err"
+        error(error_msg)
+    end
+    
+    return p₁ⁿᵉʷ
+end;
+
+
+
Dfa(Dₗ, Dᵤ) = find_zero( 
+    D -> pₘₐₓ - pfa(D), 
+    (Dₗ, Dᵤ), # Lower and upper bracket of the root
+    Brent() ) # Solve using Brent's method
+
+D2 = Dfa(4inch, 12inch)
+
+uconvert(u"inch", D2)
+
+
6.485474802835819 inch
+
+
+
+
+
+ +
+
+NoteUpdate +
+
+
+

The method given here is from Perry’s and, while it works, is an awkward way of calculating the flow from a given pressure drop. A better method, adapted from Coulson and Richardson’s is presented here.

+
+
+
+
+
+

Minimum Diameter

+

At this point we have solved for the minimum vent diameter in three different ways and, more or less, got the same answer three times. The minimum diameter is ~6.4in ID for all cases and the next largest standard pipe size is 8in so regardless of the method, in this particular example, one arrives at the same final answer.

+

In general the incompressible model will always overestimate the pressure drop across the vent, leading to a larger vent size, and the adiabatic flow will provide an underestimate, the true minimum would be somewhere between the two. This is seen much more clearly at vent diameters less than ~5in where the pressure drop is more significant, more analogous to relief piping for a pressure vessel than venting for an atmospheric storage tank. Of course all of this is assuming flow remains subsonic, if the pressure drop leads to sonic flow then things are quite different.

+
+
+
+
+
+ +
+
+Figure 4: Pressure drop versus vent diameter for the three models explored. +
+
+
+
+
+
+
+

Concluding Remarks

+

Often things like compressible flow can be intimidating since these problems, even in the simplified ideal gas case, require iterative solutions and often iterative solutions within iterative solutions. However, once the basic pieces are set up, compressible flow can be fairly simple to deal with. There are some pitfalls here that, if one wanted to create a nice generalized set of code, would have to be dealt with.

+

The big one being all the find_zero() calls that rely on the initial guess being a good one, or the bracketed values actually bracketing the answer. It’s more than possible to supply a terrible initial guess, especially for pipe diameter, and have the root solver fail outright. Adding some code to check that guesses are within the domains of functions would be a start, e.g. catching attempts to take log(0) and returning -Inf or something to ensure that the root-finding algorithms respect function domains. This also presents an opportunity to generate better default values, programmatically, prior to solving. As opposed to me just picking reasonable numbers off the top of my head and having everything work out because I’m lucky.

+

Relatedly there is a lot of room to fiddle around with which root finding algorithm is employed.

+
+
+

References

+
+
+Crane. TP410M Flow of Fluids. Stamford, CT: Crane, 2013. +
+
+Green, Don W., ed. Perry’s Chemical Engineers’ Handbook. New York: McGraw Hill, 2007. +
+
+Tilton, James N. “Fluid and Particle Dynamics.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green. New York: McGraw Hill, 2007. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/sizing_a_gooseneck_example/index_files/figure-html/cell-1-1-91c76a16-77ed-42de-bff2-ff11771256db.png b/posts/sizing_a_gooseneck_example/index_files/figure-html/cell-1-1-91c76a16-77ed-42de-bff2-ff11771256db.png new file mode 100644 index 0000000..855391e Binary files /dev/null and b/posts/sizing_a_gooseneck_example/index_files/figure-html/cell-1-1-91c76a16-77ed-42de-bff2-ff11771256db.png differ diff --git a/posts/sizing_a_gooseneck_example/index_files/figure-html/cell-1-2-e61c4633-aa66-4b40-8ff6-3df7b0e3448f.png b/posts/sizing_a_gooseneck_example/index_files/figure-html/cell-1-2-e61c4633-aa66-4b40-8ff6-3df7b0e3448f.png new file mode 100644 index 0000000..387053b Binary files /dev/null and b/posts/sizing_a_gooseneck_example/index_files/figure-html/cell-1-2-e61c4633-aa66-4b40-8ff6-3df7b0e3448f.png differ diff --git a/posts/sizing_a_gooseneck_example/index_files/figure-html/cell-2-1-0639f70d-4bb1-4e12-a83f-5beebd5fb78e.png b/posts/sizing_a_gooseneck_example/index_files/figure-html/cell-2-1-0639f70d-4bb1-4e12-a83f-5beebd5fb78e.png new file mode 100644 index 0000000..54a5691 Binary files /dev/null and b/posts/sizing_a_gooseneck_example/index_files/figure-html/cell-2-1-0639f70d-4bb1-4e12-a83f-5beebd5fb78e.png differ diff --git a/posts/sizing_a_gooseneck_example/index_files/figure-html/fig-3-output-1.svg b/posts/sizing_a_gooseneck_example/index_files/figure-html/fig-3-output-1.svg new file mode 100644 index 0000000..bb44c8e --- /dev/null +++ b/posts/sizing_a_gooseneck_example/index_files/figure-html/fig-3-output-1.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/sizing_a_gooseneck_example/index_files/figure-html/fig-4-output-1.svg b/posts/sizing_a_gooseneck_example/index_files/figure-html/fig-4-output-1.svg new file mode 100644 index 0000000..b5bec7b --- /dev/null +++ b/posts/sizing_a_gooseneck_example/index_files/figure-html/fig-4-output-1.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/smoke_days/index.html b/posts/smoke_days/index.html new file mode 100644 index 0000000..965a098 --- /dev/null +++ b/posts/smoke_days/index.html @@ -0,0 +1,1106 @@ + + + + + + + + + + + + +Smoke Days – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Smoke Days

+
+
+ Frequency of forest fire smoke events. +
+
+
+
julia
+
air quality
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

July 18, 2021

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

Recently wildfire smoke has returned and blanketed the city in haze, causing the air quality health index (AQHI) to sky rocket, and as a result I’ve been spending a lot more time inside. This feels like it is a lot more common event than it used to be, but I’m not sure if that’s true or if it merely feels true because I’m looking out at a hazy skyline.

+

I would like to look into this more using air quality data and see if this truly is a recent change, or maybe Edmonton has always been like this and I’ve simply forgotten.

+
+

Particulates as a proxy

+

Alberta has a series of air quality monitoring stations set up around the province and I can pull a data-set from the Edmonton Central station (the closest one to me) and look at airborne particulates (pm2.5) as a proxy for wildfire smoke. Though the smoke itself is more than just particulates <2.5μm in diameter, it is those particulates that cause the AQHI to rise significantly.

+

However there are more sources of pm2.5 than just wildfires, vehicles are a major source for one, and in the winter atmospheric inversions can lead to really poor air quality during which time the pm2.5 concentration rises. Additionally farmers around the city often burn stubble and other stuff in the fall, leading to smoke days that have nothing to do with wildfires.

+

So this is a proxy for wildfire smoke, but not a great one.

+
+

Ambient Air Data

+

I downloaded just the pm2.5 measurements for Edmonton Central from October 2000, the earliest reported values, through to the end of December 2020, the latest values in the database at this time, from Alberta’s Ambient Air Data Warehouse. This is a csv with 177,072 rows of data and several columns each corresponding to, I’m guessing, a different instrument. Over time the station has swapped out instruments for measuring pm2.5s and those are recorded as a different measurement type.

+
+
using CSV, DataFrames, Dates, Pipe, Plots
+
+
+
data_file = "data/Long Term pm2.5 Edmonton Central.csv"
+
+ambient_data = @pipe data_file |>
+    CSV.File( _ ; 
+             dateformat="mm/dd/yyyy HH:MM:SS", 
+             types=[DateTime, DateTime, Float64, Float64, Float64, Float64], 
+             header=16, silencewarnings=true) |>
+    DataFrame(_);
+
+
+
+
+
177072×6 DataFrame
+
+6×6 DataFrame
+
+ Row  IntervalStart        IntervalEnd          MeasurementValue  MeasurementValue_1  MeasurementValue_2  MeasurementValue_3 
+
+      DateTime             DateTime             Float64?          Float64?            Float64?            Float64?           
+
+─────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+   1 │ 2000-10-20T00:00:00  2000-10-20T00:59:00           missing             missing             missing                 2.3
+
+   2 │ 2000-10-20T01:00:00  2000-10-20T01:59:00           missing             missing             missing                 1.8
+
+   3 │ 2000-10-20T02:00:00  2000-10-20T02:59:00           missing             missing             missing                 1.8
+
+   4 │ 2000-10-20T03:00:00  2000-10-20T03:59:00           missing             missing             missing                 1.0
+
+   5 │ 2000-10-20T04:00:00  2000-10-20T04:59:00           missing             missing             missing                 1.8
+
+   6 │ 2000-10-20T05:00:00  2000-10-20T05:59:00           missing             missing             missing                 2.0
+
+
+
+
+
+

Parsing the Data

+

What I want to know is when smoke events happened and how long they were. To estimate that I am going to assume a smoke event is a period in which the hourly pm2.5 exceed the ambient air quality guideline for pm2.5s. The event starts at the first hour greater than that limit and ends on the first hour less than that limit. This has the obvious weakness that sometimes the clouds of wildfire smoke has breaks in it, so what feels like a week long smoke event would end up as a series of smaller events as the pm2.5 count might dip overnight or something. But this is a start.

+

One complication is that the dataset has four columns of pm2.5 data that are full of mostly missing values since they each only correspond to the period in which the given instrument is running. So I first need to collect those measurement values, drop the missing values, and take the mean of what remains. I assume there is no overlap and so it’s the mean of one number, but I haven’t checked to see if that’s true and the mean seems like the most sensible thing to do if there is overlap.

+

If there is a missing hour entirely, i.e. no instrument has a reading, then I skip it. That neither counts as the start nor the end of a smoke event and I move to the next row.

+
+
using Statistics
+
+lim_1h = 80.0  #μg/m³ 1-hr limit
+lim_24h = 29.0 #μg/m³ 24-hr limit
+
+function exceedences(df; limit=lim_1h)
+
+    results = DataFrame(start_date = DateTime[], end_date = DateTime[], month = Int64[], year = Int64[], duration = Float64[], max_conc = Float64[])
+
+    flag = false
+    start_date = nothing
+    end_date = nothing
+    max_conc = 0.0
+
+    for r in eachrow(ambient_data)
+        measurements = [r[:MeasurementValue], r[:MeasurementValue_1], r[:MeasurementValue_2], r[:MeasurementValue_3]]
+        measurements = collect( skipmissing(measurements) )
+        conc = if (sizeof(measurements)>0) mean(measurements) else missing end
+
+        if typeof(conc) == Missing
+            # ignore missing data
+        elseif conc > limit
+            if flag == true            # we are already in a sequence
+                end_date = r[:IntervalEnd]
+                max_conc = max(conc, max_conc)
+            else                       # we are starting a sequence
+                flag = true
+                start_date = r[:IntervalStart]
+                end_date = r[:IntervalEnd]
+                max_conc = max(conc, max_conc)
+            end
+        else
+            if flag == true           # we are ending a sequence
+                flag = false
+                duration = Dates.value.(end_date - start_date)/3.6e6
+                push!(results, [start_date, end_date, month(start_date), year(start_date), duration, max_conc])
+                max_conc = 0.0
+            end
+        end
+    end
+    
+    return results
+end
+
+
+
result_1hr = exceedences(ambient_data, limit=lim_1h)
+
+
+
+
+
Results: 53×6 DataFrame
+
+
+
+Summary: 
+
+6×7 DataFrame
+
+ Row  variable    mean     min                  median   max                  ⋯
+
+      Symbol      Union…   Any                  Union…   Any                  ⋯
+
+─────┼──────────────────────────────────────────────────────────────────────────
+
+   1 │ start_date           2001-05-24T11:00:00           2019-05-31T15:00:00  ⋯
+
+   2 │ end_date             2001-05-24T12:59:00           2019-05-31T15:59:00
+
+   3 │ month       5.84906  1                    7.0      12
+
+   4 │ year        2011.15  2001                 2010.0   2019
+
+   5 │ duration    3.85126  0.983333             1.98333  26.9833              ⋯
+
+   6 │ max_conc    135.9    80.3                 93.9     867.0
+
+                                                               2 columns omitted
+
+
+
+

Over the past 20yrs there were 53 periods with the pm2.5 concentration above the limit, these range from 1hr to 27hrs long and a max concentration observed of 867μg/m³

+
+
+
+

Results

+

A plot of the results, showing each period in excess of the hourly limit and the duration of that period, is very suggestive that these are becoming more frequent events. If we also plot the maximum hourly concentration observed it appears that the extreme smoke days are a more recent phenomenon. Though with the big caveat that the data only goes back 20 years, it could be that the period between 2000 and 2010 was an abnormally smoke-less period.

+
+
+
+
+
+ +
+
+Figure 1: Duration of AAQO exceedances in Edmonton from 2001 through 2019 +
+
+
+
+
+
+
+
+
+
+ +
+
+Figure 2: Observed concentrations for AAQO exceedances in Edmonton, 2001 through 2019 +
+
+
+
+
+

The plots below aggregate the events by year, and it certainly seems like smoke events are becoming more frequent and lasting longer, with more time spent in haze than in the early 2000s. But there are notable years such as 2010 and 2018 which could simply be outliers. Interestingly the most notable, in the news, years for wildfires are not obvious ones here – the Slave Lake fire of 2011 and Ft. McMurray fire of 2016. I think it is often the case that the wildfire smoke in Alberta has less to do with fires in Alberta itself and more to do with smoke being carried in from neighbouring states and provinces. That is certainly true now when the major wildfires are in BC, northern Saskatchewan, and northern Manitoba.

+
+
+
+
+
+ +
+
+Figure 3: Frequency of smoke days in Edmonton, 2001 through 2019 +
+
+
+
+
+

One thing these plots may mask is that while the median duration perhaps hasn’t changed much, it’s clear from the scatter plots that the outlier periods are more common in the last decade than the one preceding it.

+
+
+
+
+
+ +
+
+Figure 4: Frequency of smoke days exceeding 5 hours in duration, Edmonton 2001-2019. +
+
+
+
+
+

When grouped by month, we can see winter months have notable representation, which is likely those atmospheric inversions trapping pollutants near ground level, but the summer months appears to be when the hazy periods are longest and that likely corresponds to wildfire smoke.

+
+
+
+
+
+ +
+
+Figure 5: Frequency of smoke events by month, Edmonton 2001-2019 +
+
+
+
+
+

Filtering out only the extended periods, with a duration >5 hrs, we see that prolonged periods of excess pm2.5 appears to be a summer phenomena, especially August. Which is certainly consistent with my experience of noticeable smokey days, corresponding with wildfire season.

+
+
+
+
+
+ +
+
+Figure 6: Frequency of smoke events exceeding 5 hours in duration by month, Edmonton 2001-2019 +
+
+
+
+
+
+
+

Limitations and Opportunities

+

An opportunity for further analysis would be to look for correlations between pm2.5 and other pollutants, say NOx, to allow one to exclude pm2.5s from vehicle emissions. That would still leave road dust, construction dust, and just farmers burning stubble, but I imagine that would go pretty far in terms of removing unrelated bad air quality days from the dataset. If one was only concerned with the most extreme cases, when the sky turns orange and visibility drops to only a few blocks, well that’s visible from space and could presumably be pulled out a dataset of satellite images, taking care to distinguish smoke from cloud cover.

+

I would like to see a longer dataset. The dataset I was looking at was relatively short, only twenty years, which doesn’t allow me to answer the question of whether or not extended periods of wildfire smoke is truly a recent phenomenon versus a “return to normal”, i.e. it could be that 2000-2010 were the abnormal years and that is equally consistent with this dataset. Just looking around the air data warehouse it doesn’t look like this kind of air analysis was routine before the 2000s, but I could simply be ignorant of some other studies or datasets.

+

Finally I picked pm2.5s since that is the variable responsible for the high risk AQHI levels, but there could be much better proxies for wildfire smoke that have better datasets. I can’t think of anything off the top of my head, but I’m a chemical engineer not an air quality expert.

+ + +
+ +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/smoke_days/index_files/figure-html/fig-concentration-output-1.svg b/posts/smoke_days/index_files/figure-html/fig-concentration-output-1.svg new file mode 100644 index 0000000..2f4f105 --- /dev/null +++ b/posts/smoke_days/index_files/figure-html/fig-concentration-output-1.svg @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/smoke_days/index_files/figure-html/fig-duration-5hrs-output-1.svg b/posts/smoke_days/index_files/figure-html/fig-duration-5hrs-output-1.svg new file mode 100644 index 0000000..01a0b2b --- /dev/null +++ b/posts/smoke_days/index_files/figure-html/fig-duration-5hrs-output-1.svg @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/smoke_days/index_files/figure-html/fig-duration-output-1.svg b/posts/smoke_days/index_files/figure-html/fig-duration-output-1.svg new file mode 100644 index 0000000..01316fa --- /dev/null +++ b/posts/smoke_days/index_files/figure-html/fig-duration-output-1.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/smoke_days/index_files/figure-html/fig-monthly-5hr-output-1.svg b/posts/smoke_days/index_files/figure-html/fig-monthly-5hr-output-1.svg new file mode 100644 index 0000000..68619ac --- /dev/null +++ b/posts/smoke_days/index_files/figure-html/fig-monthly-5hr-output-1.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/smoke_days/index_files/figure-html/fig-monthly-output-1.svg b/posts/smoke_days/index_files/figure-html/fig-monthly-output-1.svg new file mode 100644 index 0000000..f64dec1 --- /dev/null +++ b/posts/smoke_days/index_files/figure-html/fig-monthly-output-1.svg @@ -0,0 +1,386 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/smoke_days/index_files/figure-html/fig-smoke-days-output-1.svg b/posts/smoke_days/index_files/figure-html/fig-smoke-days-output-1.svg new file mode 100644 index 0000000..4c7341c --- /dev/null +++ b/posts/smoke_days/index_files/figure-html/fig-smoke-days-output-1.svg @@ -0,0 +1,508 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/turbulent_jet_example/index.html b/posts/turbulent_jet_example/index.html new file mode 100644 index 0000000..2868483 --- /dev/null +++ b/posts/turbulent_jet_example/index.html @@ -0,0 +1,1179 @@ + + + + + + + + + + + + +Turbulent Jet Example - Acetylene Leak – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Turbulent Jet Example - Acetylene Leak

+
+
+ Estimating the explosive mass. +
+
+
+
julia
+
chemical releases
+
hazard screening
+
dispersion modelling
+
turbulent jets
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

April 10, 2021

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

In previous examples I discussed release scenarios involving vapour clouds spreading over a large area, carried by the wind. In those examples the momentum of the jet of fluid was not very important relative to the ambient wind conditions and could be ignored. In this example I am looking at the opposite extreme, a release from a pressure vessel inside a building where the momentum of the jet dominates.

+
+

The Scenario

+

Consider, for an example, a leak from an acetylene cylinder inside a large building, such as in a warehouse or shop. We imagine, for convenience, that the air within the building is quiescent. For the sake of an example suppose the leak is a 1/4 in. hole, similar in diameter to a typical acetylene hose, and that the operating pressure at that point is 15psig1 We are interested in exploring the concentration distribution as the acetylene jets into the air and mixes, with our reference concentration of interest being half the LEL of 2.5%(vol).

+

1 From CGA G-1 2009 the safe operating pressure of an acetylene system

+
using Unitful: @u_str, ustrip
+
+inch = ustrip(u"m", 1u"inch") # unit conversion inch->m
+psi = ustrip(u"Pa", 1u"psi")  # unit conversion psi->Pa
+
+p₂ = 14.7psi   # atmospheric pressure, Pa absolute
+T₂ = 25+273.15 # ambient temperature, K
+
+d  = 0.25inch  # diameter of the hole, m
+p₁ = 15psi+p₂ # pressure of the acetylene, Pa absolute
+T₁ = T₂        # the release temperature, K
+
+
298.15
+
+
+

We can look up some properties of acetylene in Perry’s2

+
+
# universal gas constant, J/mol/K
+R = 8.31446261815324 
+
+# ideal gas density, kg/m³
+ρ(p,T;MW) = (p*MW)/(R*T)/1000
+
+# gas viscosity correlation, Pa*s
+μ(T;C) = (C[1]*T^(C[2]))/(1+(C[3]/T)+(C[4]/T^2)) 
+
+# Properties of Acetylene
+MWⱼ = 26.037 # molar mass, kg/kmol
+LEL = 0.025  # Lower explosive limit, vol/vol
+k   = 1.26   # ratio cp/cv at 15C
+μⱼ  = μ(T₁;C=[1.2025e-6,0.4952,291.4,0])
+ρ₁  = ρ(p₁,T₁;MW=MWⱼ)
+
+# Properties of Air
+MWₐ = 28.960  # molar mass, kg/kmol
+ρ₂  = ρ(p₂,T₂;MW=MWₐ)
+
+
1.1840386427594014
+
+
+
+
+

The Release Rate

+

We can model the release as a gas jet3 where the gas is ideal and the expansion through the jet is an isentropic process4

+

\[ G = \rho u = c_d \sqrt{ \rho_1 p_1 \left( 2 k \over k-1 \right) \left[ \left(p_2 \over p_1\right)^{2 \over k} - \left(p_2 \over p_1\right)^{k+1 \over k} \right]} \]

+

for non-choked flow and

+

\[ G = c_d \sqrt{ \rho_1 p_1 k \left( 2 \over k+1 \right)^{k+1 \over k-1} } \]

+

for choked flow, which occurs when

+

\[ \left(p_2 \over p_1 \right) \lt \left( 2 \over k+1 \right)^{k \over k-1} \]

+

Where G is the mass velocity of acetylene discharged through the hole (in kg/m²/s), cd is the discharge coefficient which can be assumed to be 0.61,5 and the rest are as defined earlier. I am assuming, here, that the hole is circular for simplicity.

+
+
(p₂/p₁) < (2/(k+1))^(k/(k-1)) 
+
+
true
+
+
+

Therefore the flow is choked and

+
+
c_d = 0.61
+
+G = c_d * (ρ₁*p₁*k*(2/(k+1))^((k+1)/(k-1)) )
+
+
267.1556913840265
+
+
+

The density at the orifice is reduced, through the expansion and, for an isentropic process, is related to the pressure by

+

\[ {\rho_o \over \rho_1} = \left( p_o \over p_1 \right)^{1 \over k} \]

+

Where subscript o indicates at the orifice. At this point, after the expansion \(p_o = p_2\) and

+

\[ \rho_o = \rho_1 \left( p_o \over p_1 \right)^{1 \over k} \]

+
+
ρₒ = ρ₁*(p₂/p₁)^(1/k)
+
+
1.2307940295609565
+
+
+

The velocity at the orifice, i.e. after the gas has expanded, is then

+

\[ u_o = {G \over \rho_o} \]

+
+
uₒ = G/ρₒ
+
+
217.05962571115586
+
+
+
+
+

Jet Behavior

+

To model the concentration profile I am going to assume a turbulent jet, from a circular hole, mixing with air. In this case the density of air and acetylene are similar and so a simple turbulent jet model is appropriate. If there was a significant difference in densities then a density correction would be needed, however for many applications “close” means a ratio of ambient to jet densities between6

+

\[ \frac{1}{4} \le { \rho_{a} \over \rho_{j} } \le 4 \]

+

Where subscript a indicates the ambient fluid and j the jet.

+

Circular turbulent jets expand by entraining ambient fluid, tracing out a cone defined by a jet angle \(\alpha \approx 15-25^\circ\). The mixing layer penetrates into the jet forming the potential cone, inside is pure jet material and outside is mixed. After approximately 6 hole diameters the region is fully developed.7

+
+
+
+ +
+
+Figure 1: A turbulent jet expanding into a quiescent atmosphere. +
+
+
+

Empirical approximations of the velocity, and concentration, profiles are often given with respect to this jet angle or, equivalently, the slope of line (i.e. \(\tan \frac{\alpha}{2}\))

+

Another important factor is the Reynolds number, the jet is fully turbulent when \(Re \gt 2000\), where the Reynolds number is calculated with respect to the initial jet velocity and jet diameter (i.e. the hole diameter)

+

\[ Re = { \rho u d \over \mu } = { G d \over \mu }\]

+
+
0.25 < (ρ₂/ρₒ) < 4
+
+
true
+
+
+
+
Re = G*d/μⱼ
+
+Re > 2000
+
+
true
+
+
+

The densities are within the appropriate range and the flow is fully turbulent, so the turbulent jet model requirements are satisfied.

+
+

Velocity and Concentration distributions

+

There are many different empirical velocity distributions as well as velocity distributions derived from theories of turbulent mixing available in various references. Mostly of the same general type (gaussian), but parametrized slightly differently. However, in my experience, there are far fewer concentration distributions available, this is not too critical due to an interesting result in turbulent mass transfer for jets8

+

\[ { C \over C_{max} } = \left( v_z \over v_{z,max} \right)^{Sc_t} \]

+

That is, at a given distance z away from the hole, the concentration profile is the velocity profile raised to the power \(Sc_t\) – the turbulent Schmidt number. Experimentally this is approximately 0.7. Note also that \(C_{max}\) and \(v_{z,max}\) are taken at the centerline. Physically this means that the concentration profile, at a given downstream distance, is wider than the velocity distribution; concentration expands more.

+

A similar way of capturing the same phenomenon that is often seen with empirical velocity distributions is to define a width parameter \(b\) and note that the equivalent width for the concentration profile is \(1.17b\)9 and substitute in accordingly.

+

In this example I am using the empirical concentration given in Lees10 for simplicity

+

\[ {C \over C_0 } = k_2 \left( d_h \over z \right) \left( \rho_z \over \rho_o \right)^{0.5} \exp \left( - \left( k_3 r \over z \right)^2 \right) \]

+

Note also the ratio of densities, the density \(\rho_z\) is the density of the jet at some distance z and it is common to conservatively take this as \(\rho_a\).

+

The parameters \(k_2\) and \(k_3\) are empirically derived for the particular jet and \(k_2\) is a function of Reynolds number below \(Re \lt 20000\).11 The conservative values suggested are 6 and 5 respectively.

+
+
function C(r, z; C₀=1.0, k₂=6, k₃=5, d=d, ρz=ρ₂, ρₒ=ρₒ)
+    C = C₀ * k₂ * (d/z) * (ρz/ρₒ) * exp(-(k₃*r/z)^2)
+end
+
+
C (generic function with 1 method)
+
+
+
+
+
+
+
+ +
+
+Figure 2: The centerline concentration of acetylene as a concentration of downstream distance. +
+
+
+
+
+

At this point it is worth pointing out that the model of the jet is independent of the discharge rate. The concentration profile is only a function of the hole diameter and the fluid density. The velocity in the jet, and the amount of air entrained in the jet, do depend strongly on the initial discharge rate but in such a way that the concentration does not. As the jet velocity increases proportionally more air is entrained and the concentration profile remains constant.

+
+
+
+
+
+ +
+
+Figure 3: Concentration contours at the release elevation. +
+
+
+
+
+
+
+
+

Explosive Mass

+

Now that we have a model of the jet, showing the concentration of acetylene, the most relevant parameter we would want to know is the explosive mass such that some blast modeling could be done.

+

The most obvious way to do this is to integrate over the jet, using cylindrical coordinates for convenience

+

\[ m_e = \int \rho C(r,z) dV = 2\pi \rho_o \int_{0}^{\infty} \int_{0}^{\infty} C(r,z) r dr dz \]

+

Except that we define the explosive mass to be the volume where \(C > \frac{1}{2} LEL\). A lazy way to do this is to define a function that equals \(C\) if it is \(\gt \frac{1}{2} LEL\) and zero otherwise.

+

The potential core region is poorly described by this model, and the closer to the origin of the jet the more un-physical the results: giving concentrations greater than 100% and being undefined completely at the origin. One way of hand waving this away is to chop off any concentrations above 100%.

+
+
function igrd(v; lim=0.5*LEL)
+    r, z = v
+    
+    if z>0
+        c = C(r,z)
+        c = c<lim ? 0 : min(1,c)
+    else
+        c = 0
+    end
+
+    return r*c
+end
+
+
igrd (generic function with 1 method)
+
+
+

Integrating over some plausible bounds, taken by looking at the plots above, gives the volume of acetylene.

+
+
using HCubature: hcubature
+
+I, err = hcubature(igrd, [0, 0], [0.25, 2.0], atol = 1e-8)
+
+
(0.0008207940258726464, 9.999922827914883e-9)
+
+
+

Which can be plugged into the equation to calculate the final explosive mass.

+
+
mₑ = 2*π*ρₒ*I
+
+
0.006347452155224944
+
+
+

To give a sense of how much this is, the explosive mass is equivalent to ~1s of discharge at the steady state discharge rate.

+
+
m = G*(π/4)*d^2
+
+mₑ/m
+
+
0.7502356087241902
+
+
+
+
+

Conclusions

+

Turbulent jet mixing is a much simpler model for estimating releases, especially when using empirical models, compared to models for plumes influences by buoyancy and wind. There are much fewer parameters that need to be estimated.

+

One big weakness to the model as presented here is that it does not take into account the enclosed space. If the assumption is that the warehouse is large and ignition sources are numerous then that likely doesn’t matter, the acetylene leak will ignite before it has a chance to accumulate. However it will grossly underestimate the potential explosive mass that could develop as the acetylene disperses through the air of warehouse, since the model presumes the ambient air has no acetylene in it and is effectively infinite in extent.

+

This limitation would, for me, motivate exploring more detailed models of gas build up in enclosed spaces

+
+
+

References

+
+
+AIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999. +
+
+Bird, R. Byron, Warren E. Stewart, and Edwin N. Lightfoot. Transport Phenomena. 2nd ed. Hoboken, NJ: John Wiley & Sons, 2007. +
+
+Kaye, Nigel B., Abdul A. Khan, and Firat Y. Testik. “Environmental Fluid Mechanics.” In Handbook of Environmental Engineering, edited by Myer Kutz. New York: John Wiley & Sons, 2018. +
+
+Lees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996. +
+
+Long, V. D. “Estimation of the Extent of Hazard Areas Around a Vent.” Second Symposium On Chemical Process Hazards, 1963, 6–14. +
+
+Poleshaw, Yury V., and V. V. Golub. “Jets.” In Thermopedia, 2013. https://doi.org/10.1615/AtoZ.j.jets. +
+
+Poling, Bruce E., George H. Thomson, Daniel G. Friend, Richard L. Rowley, and W. Vincent Wilding. “Physical and Chemical Data.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+Revill, B. K. “Jet Mixing.” In Mixing in the Process Industries, edited by N. Harnby, M. F. Edwards, and A. W. Nienow, 2nd ed. Oxford: Butterworth-Heinemann, 1992. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/turbulent_jet_example/index_files/figure-html/cell-14-1-0dfb9ef0-987e-4c09-88c3-1c19b4216aaa.png b/posts/turbulent_jet_example/index_files/figure-html/cell-14-1-0dfb9ef0-987e-4c09-88c3-1c19b4216aaa.png new file mode 100644 index 0000000..da66047 Binary files /dev/null and b/posts/turbulent_jet_example/index_files/figure-html/cell-14-1-0dfb9ef0-987e-4c09-88c3-1c19b4216aaa.png differ diff --git a/posts/turbulent_jet_example/index_files/figure-html/fig-conc-output-1.svg b/posts/turbulent_jet_example/index_files/figure-html/fig-conc-output-1.svg new file mode 100644 index 0000000..e034374 --- /dev/null +++ b/posts/turbulent_jet_example/index_files/figure-html/fig-conc-output-1.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/turbulent_jet_example/index_files/figure-html/fig-cont-output-1.svg b/posts/turbulent_jet_example/index_files/figure-html/fig-cont-output-1.svg new file mode 100644 index 0000000..b1ed764 --- /dev/null +++ b/posts/turbulent_jet_example/index_files/figure-html/fig-cont-output-1.svg @@ -0,0 +1,477 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/turbulent_jet_notes/index.html b/posts/turbulent_jet_notes/index.html new file mode 100644 index 0000000..f243ecd --- /dev/null +++ b/posts/turbulent_jet_notes/index.html @@ -0,0 +1,1480 @@ + + + + + + + + + + + + +Turbulent Jets – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Turbulent Jets

+
+
+ Notes on turbulent jets and velocity profiles. +
+
+
+
julia
+
dispersion modelling
+
turbulent jets
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

April 8, 2022

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

In a previous post I worked through a chemical release modeled as a turbulent jet and while I mentioned there were several ways modeling the jet, I didn’t go into any of them. I’m taking the opportunity here to collect my notes on turbulent jets, some different ways of modeling the jets, and the relative performance of each approach.

+
+

Observations on Turbulent Jets

+

We are considering a submerged circular jet, issuing from a surface, with the coordinate system centered on the jet. Since it is circular, the natural coordinate system is cylindrical with a downstream distance z, radial distance r, and angular coordinate θ. The jet is fully turbulent when the Reynolds number, \(Re \gt 2000\), where the Reynolds number is calculated with respect to the initial jet velocity and jet diameter

+

\[ Re = { \rho_j v_0 d_0 \over \mu_j } \]

+

We are also considering the case where the densities of the two fluids are similar, where we take “similar” to mean \[ \frac{1}{4} \le { \rho_{a} \over \rho_{j} } \le 4 \]

+

Where subscript a indicates the ambient fluid and j the jet. For much the experimental data the jet and ambient fluid are the same fluid, e.g. a jet of air into air or water into water.

+

Turbulent jets expand by entraining ambient fluid, tracing out a cone defined by a jet angle \(\alpha \approx 15-25^\circ\). The mixing layer penetrates into the jet forming the potential core, inside is pure jet material and outside is mixed. After approximately 6 diameters the region is fully developed.

+
+
+
+ +
+
+Figure 1: A turbulent jet emitted from a circular orifice. +
+
+
+

Empirical approximations of the velocity profile are often given with respect to this jet angle or, equivalently, the slope of the line (i.e. \(\tan \frac{\alpha}{2}\)). A related way of parameterizing the jet is in terms of a width parameter b. Typically this is the width of the velocity profile at half-height \(b_{1/2}\) (though not always). With a constant jet angle and a self-similar velocity profile the width is directly proportional to the downstream distance \(b_{1/2} = \tan \left( \frac{\alpha_{1/2} }{2} \right) z = c z\).

+

Where the value of c is can be found in the literature

+ + + + + + + + + + + + + + + + + + + + + +
cReference
0.082 - 0.097Garde1
0.0848Bird, Stewart, and Lightfoot2
0.10Rajaratnam3
+

At this point it is common to introduce a variable \(\xi = {r \over b_{1/2} }\) or \(\xi = {r \over z }\) where we are taking advantage of the fact that \(b_{1/2} \propto z\). This is a scaled radial distance, using the width at half-height as a characteristic length. It is important to keep track of which definition of ξ is being used as they differ by a scaling factor. The reason for this change of variables is the observation that the shape of the velocity profile is the same at any downstream point, it is merely scaled down in height and wider as one travels downstream. That is \({ \bar{v}_z \over \bar{v}_{max} } = f \left( \xi \right)\) is the same for all downstream distances (in the region where the jet is fully developed).

+

Another important observation is that the center-line velocity, the max velocity in the jet, decays with the inverse of the downstream distance, i.e.

+

\[ \bar{v}_{max} \propto z^{-1} \]

+

Putting those two observations together we expect the velocity profile to have the form

+

\[ \bar{v}_z = { \mathrm{const} \over z } f \left( \xi \right)\]

+
+
+

Modeling Turbulent Jets

+

To set up our system we consider the case of a jet coming out of a point on an infinite surface into a quiescent medium, and that the jet and medium have the same density. This is a major simplification, but it makes the math easier to deal with. The coordinate system is centered at this point and all momentum in the jet ultimately comes from the origin.

+

The boundary conditions for the problem are:

+
    +
  1. at the center-line, r=0, the velocity is entirely in the z-direction
  2. +
  3. at the center-line, r=0, the velocity in the z-direction is at a maximum
  4. +
  5. as the radius increases, r → ∞ , the velocity in the z-direction goes to zero
  6. +
+
+

Time Averaged Values

+

Since we are concerned with turbulent flow, we can employ Reynolds decomposition to transform the velocities like so

+

\[ v_z = \bar{v}_z + v^{\prime}_{z} \]

+

\[ v_r = \bar{v}_r + v^{\prime}_{r} \]

+

where \(\bar{v}\) is the time-smoothed velocity and \(v^{\prime}\) is an instantaneous deviation such that \(\bar{v^{\prime} } = 0\) and the time-averaging operator follows the Reynolds criteria.

+
+
+

Equations of Motion

+

The equations of motion in terms of time-smoothed velocities are \[ \rho {D \mathbf{\bar{v} } \over D t } = - \nabla \bar{p} - \nabla \cdot \mathbf{ \bar{\tau} } + \rho \mathbf{g} \]

+

Where \(\mathbf{ \bar{\tau} }\) is the turbulent stress and includes the Reynolds stresses.

+

With the z component, in cylindrical coordinates4

+

\[ \rho \left( {\partial \over \partial t} \bar{v}_z + \bar{v}_r {\partial \bar{v}_z \over \partial r} + {\bar{v}_\theta \over r} {\partial \bar{v}_z \over \partial \theta} + \bar{v}_z {\partial \bar{v}_z \over \partial z} \right) \]

+

\[ = - {\partial \bar{p} \over \partial z} - {1 \over r} {\partial \left( r \bar{\tau}_{rz} \right) \over \partial r } - {1 \over r} {\partial \bar{\tau}_{\theta z} \over \partial \theta } - {\partial \bar{\tau}_{z z} \over \partial z } + \rho g_z\]

+

Making the assumptions:

+
    +
  1. Zero pressure gradient ( \({\partial p \over \partial z} = 0\) )
  2. +
  3. Steady state ( \({\partial \over \partial t} \left( \cdots \right) = 0\) )
  4. +
  5. Axisymmetric ( \({\partial \over \partial \theta} \left( \cdots \right) = 0\) )
  6. +
  7. Effect of gravity can be neglected ( \(\rho g_z \approx 0\) )
  8. +
  9. Within the jet \(\mid v_z \mid \gg \mid v_r \mid\) and, by boundary layer approximation, \(\bar{\tau}_{z z}\) can be neglected5
  10. +
+

5 The boundary layer approximation is that

+

\[ { \partial^2 \bar{v}_z \over \partial z^2 } \ll { \partial^2 \bar{v}_z \over \partial r^2 } \]

+

and if we suppose that

+

\[ \bar{\tau}_{z z} \propto { \partial \bar{v}_z \over \partial z } \]

+

and

+

\[ \bar{\tau}_{r z} \propto {\partial \bar{v}_z \over \partial r} \]

+

we find

+

\[ { \partial \bar{\tau}_{z z} \over \partial z } \propto {\partial^2 \bar{v}_z \over \partial z^2} \ll { \partial r \bar{\tau}_{r z} \over \partial r} \propto {\partial^2 \bar{v}_z \over \partial r^2} \]

+

and thus we can assume the free turbulence is dominated by \(\bar{\tau}_{r z}\) and

+

\[ { \partial \bar{\tau}_{z z} \over \partial z} \approx 0 \]

The equations of motion, in the z direction, simplifies to

+

\[ \bar{v}_r {\partial \bar{v}_z \over \partial r} + \bar{v}_z {\partial \bar{v}_z \over \partial z} = - {1 \over \rho r} {\partial \left( r \bar{\tau}_{rz} \right) \over \partial r } \]

+
+
+

Equation of Continuity

+

The continuity equation in terms of time-smoothed velocities is

+

\[ {\partial \rho \over \partial t} + \nabla \cdot \rho \mathbf{ \bar{v} } = 0 \]

+

In cylindrical coordinates6

+

\[ {\partial \rho \over \partial t} + {1 \over r} {\partial \rho r \bar{v}_r \over \partial r} + {1 \over r} { \partial \rho \bar{v}_\theta \over \partial \theta} + {\partial \rho \bar{v}_z \over \partial z} = 0 \]

+

Making the assumptions:

+
    +
  1. Steady state ( \({\partial \over \partial t} \left( \cdots \right) = 0\) )
  2. +
  3. Axisymmetric ( \({\partial \over \partial \theta} \left( \cdots \right) = 0\) )
  4. +
  5. Incompressible ( \({\partial \rho \over \partial z} = {\partial \rho \over \partial r} = {\partial \rho \over \partial \theta} = 0\) )
  6. +
+

The equation of continuity simplifies to

+

\[ {1 \over r} {\partial r \bar{v}_r \over \partial r} + {\partial \bar{v}_z \over \partial z} = 0 \]

+
+
+

Stokes Stream Function

+

To simplify things down to working with one dependent variable we introduce a Stokes stream function \(\psi\) defined such that

+

\[ \bar{v}_z = -{1 \over r} {\partial \psi \over \partial r} \]

+

and

+

\[ \bar{v}_r = {1 \over r} {\partial \psi \over \partial z} \]

+

This definition ensures that the equation of continuity is satisfied. Suppose that \(\psi = k z F\left(\xi\right)\), where F is a unitless function of \(\xi = \frac{r}{z}\) and \(k\) is a constant with units \([[ \mathrm{length} ]]^2 \times [[ \mathrm{time} ]]^{-1}\), then

+

\[ \bar{v}_z = -{1 \over r} {\partial \xi \over \partial r} {\partial \psi \over \partial \xi} += -{1 \over r} {1 \over z} {k z F^{\prime} } \]

+

\[= -{k \over z} {F^{\prime} \over \xi} = { \mathrm{const} \over z } f \left( \xi \right)\]

+

Which matches what we expect from the empirical observations (which is why we supposed that form of the stream function in the first place). We can use this definition to work out some other useful terms

+

\[ {\partial \bar{v}_z \over \partial z} = {k \over z^2} F^{\prime \prime} \]

+

\[ {\partial \bar{v}_z \over \partial r} = -{k \over z^2} \left( { F^{\prime \prime} \over \xi} - { F^{\prime} \over \xi^2} \right) \]

+

\[ \bar{v}_r = { k \over z } \left( { F \over \xi } - F^{\prime} \right) \]

+

Substituting these back into the equation of motion, in the z direction, leads to

+

\[ \left( k \over z \right)^2 \left[ { F F^{\prime \prime} \over \xi } - {F F^{\prime} \over \xi^2} + { \left( F^{\prime} \right)^2 \over \xi } \right] = {1 \over \rho} {\partial \over \partial r} \left( r \bar{\tau}_{rz} \right) \]

+

\[ \left( k \over z \right)^2 { d \over d \xi } \left( F F^{\prime} \over \xi \right) = {1 \over \rho} {\partial \over \partial r} \left( r \bar{\tau}_{rz} \right) \]

+

Which is suggestive of the overall approach to follow: find an expression for the right hand side of this differential equation, integrate both sides with respect to ξ, and solve for F(ξ)

+
+
+

Boundary Conditions

+

The initial boundary conditions of the problem were that:

+
    +
  1. \(\bar{v}_r = 0\) at r=0
  2. +
  3. \({\partial \bar{v}_z \over \partial r} = 0\) at r=0 (i.e. the velocity is at a maximum)
  4. +
  5. \(\bar{v}_z \to 0\) as r → ∞ (i.e. the velocity decays to zero)
  6. +
+

In terms of F and ξ these become:

+
    +
  1. \({F \over \xi} - F^{\prime} = 0\) at ξ=0, which implies F=0 at ξ=0
  2. +
  3. \(F^{\prime \prime} - {F^{\prime} \over \xi} = 0\) at ξ=0
  4. +
  5. \({F^{\prime} \over \xi} \to 0\) as ξ → ∞
  6. +
+
+
+

Momentum Balance

+

To determine the constant k we use a momentum balance: the momentum flux, J, in the z direction is constant. Initially the momentum flux is

+

\[ J = \rho v_0^2 A_0 = \rho v_0^2 {\pi \over 4} d_0^2\]

+

and at some point z downstream of the origin we have

+

\[ J = \int_{0}^{2\pi} \int_{0}^{\infty} \rho \bar{v}_z^2 r dr d\theta \]

+

\[ = 2 \pi \rho \int_{0}^{\infty} \bar{v}_{z,max}^2 \left( \bar{v}_z \over \bar{v}_{z,max} \right)^2 r dr \]

+

\[ = 2 \pi \rho \bar{v}_{z,max}^2 \int_{0}^{\infty} \left( \bar{v}_z \over \bar{v}_{z,max} \right)^2 r dr \]

+

\[ = 2 \pi \rho k^2 \int_{0}^{\infty} \left( f\left( \xi \right) \right)^2 \xi d \xi \]

+

Taking the integral to be I, and equating the initial momentum flux with the momentum flux at point z7

+

7 I’ve played a little fast and loose with the definition of \(\bar{v}_z\) in that I am implicitly assuming \(f(\xi) = {-F^{\prime}(\xi) \over \xi}\) which isn’t strictly true, there can be scaling factor. In practice all of these are collected together into one constant so it doesn’t matter, but that is something to be aware of as the definition of k here is really \(k\times \mathrm{const}\) where \(\mathrm{const} = {-F^{\prime}(\xi) \over \xi} \div f(\xi)\)

\[ J = \rho v_0^2 {\pi \over 4} d_0^2 = 2 \pi \rho k^2 I \]

+

\[ k = \sqrt{1 \over 8 I } v_0 d_0 \]

+
+
+
+

Prandtl Mixing Length

+

The Prandtl mixing length model makes the assumption that momentum transfer occurs over some “mixing length” l such that

+

\[ \bar{\tau}_{rz} = -\rho l^2 \left| {\partial \bar{v}_z \over \partial r} \right| \left( {\partial \bar{v}_z \over \partial r} \right)\]

+

We suppose that the mixing length is proportional to the width of the velocity profile \(b_{1/2}\), the characteristic length for the velocity profile, which we know is proportional to the downstream distance z

+

\[ l \propto b_{1/2} \propto z\]

+

\[ l = c z \]

+

Where \(c\) is some unitless constant. Making the observation that \({\partial \bar{v}_z \over \partial r} < 0\) we can make the simplification

+

\[ \bar{\tau}_{rz} = \rho c^2 z^2 \left( {\partial \bar{v}_z \over \partial r} \right)^2\]

+
+

Setting up the ODE

+

Recall that the equation of motion in the z direction is (in terms of the unitless function F) is

+

\[ \left( k \over z \right)^2 { d \over d \xi } \left( F F^{\prime} \over \xi \right) = {1 \over \rho} {\partial \over \partial r} \left( r \bar{\tau}_{rz} \right) \]

+

Substituting the expression for \(\bar{\tau}_{rz}\) we have

+

\[ \left( k \over z \right)^2 { d \over d \xi } \left( F F^{\prime} \over \xi \right) = c^2 z^2 {\partial \over \partial r} \left( r \left( \partial \bar{v}_{z} \over \partial r \right)^2 \right) \]

+

\[ \left( k \over z \right)^2 { d \over d \xi } \left( F F^{\prime} \over \xi \right) = c^2 z^2 \left( \left( \partial \bar{v}_{z} \over \partial r \right)^2 + 2r \left( \partial \bar{v}_{z} \over \partial r \right) \left( \partial^2 \bar{v}_{z} \over \partial r^2 \right) \right) \]

+

Substituting in the expressions for \({\partial \bar{v}_z \over \partial r}\) and \({\partial^2 \bar{v}_z \over \partial r^2}\) we arrive at

+

\[ \left( k \over z \right)^2 { d \over d \xi } \left( F F^{\prime} \over \xi \right) = c^2 \left(k \over z \right)^2 \left( 1 \over \xi \right)\left( F^{\prime \prime} - { F^{\prime} \over \xi } \right) \left( 2 F^{\prime \prime \prime} - 3 { F^{\prime \prime} \over \xi } + { F^{\prime} \over \xi^2 }\right) \]

+

\[ { d \over d \xi } \left( F F^{\prime} \over \xi \right) = c^2 { d \over d \xi } \left( 1 \over \xi \right)\left( F^{\prime \prime} - { F^{\prime} \over \xi } \right)^2 \]

+

Integrating both sides

+

\[ \left( F F^{\prime} \over \xi \right) = c^2 \left( 1 \over \xi \right)\left( F^{\prime \prime} - { F^{\prime} \over \xi } \right)^2 + \mathrm{const}\]

+

By applying the boundary conditions we find the constant of integration is zero, thus

+

\[ F F^{\prime} = c^2 \left( F^{\prime \prime} - { F^{\prime} \over \xi } \right)^2 \]

+

Making the substitution \(\phi = a^{-1} \xi\) where \(a = c^{2/3}\)

+

\[ F F^{\prime} = \left( F^{\prime \prime} - { F^{\prime} \over \phi } \right)^2 \]

+

\[ F^{\prime \prime} = { F^{\prime} \over \phi } + \sqrt{ F F^{\prime} } \]

+

Which is in a form that can be solved numerically.

+
+
+

Solving the ODE

+

We can solve the ODE and perform the integral needed for the momentum balance at the same time. First we define a vector u such that:

+

\[ \mathbf{u} = \begin{bmatrix} u_{1} \\ u_{2} \end{bmatrix} = \begin{bmatrix} F \\ F^{\prime} \end{bmatrix}\]

+

The ODE then becomes:

+

\[ {d \mathbf{u} \over dt } = \begin{bmatrix} F^{\prime} \\ F^{\prime \prime} \end{bmatrix} = \begin{bmatrix} u_{2} \\ \frac{ u_{2} }{t} + \sqrt{ u_{1} u_{2} } \end{bmatrix} \]

+

Which has a singularity at t=0, but one that can be easily dealt with by setting the initial value of the derivatives to8

+

8 From the boundary conditions we know F’(0) = 0 but what about F’’? Taking the ratio

+

\[ { \bar{v}_z \over \bar{v}_{z,max} }_{r=0} = - {F^{\prime} \over \phi }_{\phi=0} = 1 \]

+

we find \({F^{\prime} \over \phi } = -1\) at φ = 0 and, from the boundary conditions,

+

\[ F^{\prime \prime} = {F^{\prime} \over \phi } \]

+

at φ = 0, therefore F’’(0) = -1

\[ {d \mathbf{u} \over dt }_{t=0} = \begin{bmatrix} 0 \\ -1 \end{bmatrix}\]

+

Putting that together, the ODE can be integrated easily9

+

9 Because of how \(\bar{v}_z\) and \(\bar{v}_r\) were defined \(-{ F^{\prime} \over \phi } \ge 0\), i.e. \({ F^{\prime} \over \phi } \le 0\). For the signs to work out, \(F \le 0\) and \(F^{\prime} \le 0\) (since \(F F^{\prime} \ge 0\))

+
using StaticArrays
+using DifferentialEquations: ODEProblem, Tsit5, solve, TerminateSteadyState
+
+function sys(u,p,t)
+    u₁, u₂ = u[1], u[2]
+    if t > 0.0
+        du₁ = u₂
+        du₂ = u₂/t + (max((u₁*u₂),0))
+    else
+        du₁ = 0.0
+        du₂ = -1.0
+    end
+    
+    return SA[du₁; du₂]
+end
+
+u0    = SA[0.0; 0.0]
+tspan = (0.0, 6.0)
+prob  = ODEProblem(sys, u0, tspan)
+sol   = solve(prob, Tsit5(), dtmax=0.1, callback=TerminateSteadyState())
+
+print(sol.retcode)
+
+
Success
+
+
+
+
+
+
+
+ +
+
+Figure 2: The numerical integration of F(φ), Prandtl mixing length theory. +
+
+
+
+
+

Using the solution in terms of φ we can write a function f(ξ)

+
+
function f_pml(ξ; a=0.066)
+    ϕ = abs(ξ)/a
+    
+    if ϕ >0
+        F, F′ = sol(ϕ)
+        f = -F′/ϕ
+        f  = max(f, 0)
+    else
+        f = 1
+    end
+    
+    return f
+end
+
+
+
+

Comparison with Tollmien

+

The classic treatment of the Prandtl mixing length model is from Tollmien10 in which, instead of solving numerically in the way shown above, the ODE is further transformed and a series expansion is used to generate a table of results. More often than not it is these tabulated values, or similar ones,11 that are presented as the solution to the model.

+

11 Rajaratnam, Turbulent Jets, 39. The table has an error at φ=1: the value of \({F^{\prime} \over \phi }\) should be 0.606 but is given as 0.505 (presumably a typo).

We can easily compare the result here with the tabulated values and verify for ourselves that we have indeed solved the right differential equation. Though by solving numerically in this way we can control the level of precision and easily generate smooth interpolations. In my opinion, this makes using the ODE solution far more convenient than the tabulated values.

+
+
+
+
+
+ +
+
+Figure 3: This solution versus the tabulated results of Tollmien, demonstrating that this is the correct solution but by a different means. +
+
+
+
+
+
+
+

Width at Half Height

+

The width at half height, \(b_{1/2}\), is an important parameter and often velocity profiles are scaled relative to this. To compare different models on a fair basis, it is a good idea to determine what the model parameters are relative to \(b_{1/2}\). Then each model can be scaled to the same \(b_{1/2}\) and compared, apples-to-apples.

+

In this case we don’t have a closed form for the velocity profile so we need to solve for φ such that f(φ)=0 numerically.

+
+
using Roots: find_zero
+
+ϕ_half = find_zero( ϕ -> f_pml(ϕ; a=1)-0.5, (1, 1.25))
+
+
1.2277665940765845
+
+
+

and we then write the model parameter a in terms of \(b_{1/2}\)

+
+
+

\[ a = \frac{1}{\phi_{1/2}} \frac{b_{1/2}}{z} = 0.814 \frac{b_{1/2}}{z} \]

+
+
+

Using a default value for \({ b_{1/2} \over z } = 0.0848\) we arrive at

+
+
b_half = 0.0848
+
+a = b_half/ϕ_half
+
+
0.06906850244103516
+
+
+

Several sources have tabulated values for a

+ + + + + + + + + + + + + + + + + +
aReference
0.063Tollmien12
0.066Rajaratnam13
+

and the result of this notebook compares with those

+
+
+

Velocity Profile

+

Now that we have completed the integration we can calculate the parameter k, using the equation derived from the momentum balance

+

\[ k = \sqrt{1 \over 8 I } v_0 d_0 \]

+

with the value of the integral coming directly from the ode solver

+
+
using NumericalIntegration: integrate
+
+ϕ, F′ = sol.t, sol[2,:]
+
+# trim any unphysical values
+F′[F′.>0] .= 0.0
+
+function integrand(ϕ, F′)
+    if ϕ>0
+        return F′^2/ϕ
+    else
+        return 0
+    end
+end
+
+I = integrate(ϕ, integrand.(ϕ, F′))
+I = a^2 * I
+
+
0.002573069044757039
+
+
+

Allowing us to write the velocity profile as

+
+
+

\[ \bar{v}_z = 6.97 { v_0 d_0 \over z} f(\xi) \]

+
+
+
+
+
+

Eddy Viscosity

+

The eddy viscosity model makes the assumption that the turbulent shear stress depends on the rate of strain in a manner that is analogous to laminar flow, with the constant of proportionality being the eddy viscosity ε:

+

\[ \bar{\tau}_{rz} = - \rho \varepsilon {\partial \bar{v}_z \over \partial r}\]

+
+

Setting up the ODE

+

Recall that the equation of motion in the z direction is (in terms of the unitless function F)

+

\[ \left( k \over z \right)^2 { d \over d \xi } \left( F F^{\prime} \over \xi \right) = {1 \over \rho} {\partial \over \partial r} \left( r \bar{\tau}_{rz} \right) \]

+

Substituting the expression for \(\bar{\tau}_{rz}\) we have

+

\[ \left( k \over z \right)^2 { d \over d \xi } \left( F F^{\prime} \over \xi \right) = - \varepsilon {\partial \over \partial r} \left( r \left( \partial \bar{v}_{z} \over \partial r \right) \right) \]

+

\[ \left( k \over z \right)^2 { d \over d \xi } \left( F F^{\prime} \over \xi \right) = - \varepsilon \left( \left( \partial \bar{v}_{rz} \over \partial r \right) + r \left( \partial^2 \bar{v}_{rz} \over \partial r^2 \right) \right) \]

+

Substituting in the expressions for \({\partial \bar{v}_z \over \partial r}\) and \({\partial^2 \bar{v}_z \over \partial r^2}\) we arrive at

+

\[ \left( k \over z \right)^2 { d \over d \xi } \left( F F^{\prime} \over \xi \right) = { k \varepsilon \over z^2} \left( F^{\prime \prime \prime} - { F^{\prime \prime} \over \xi } + { F^{\prime} \over \xi^2 } \right) \]

+

\[ \left( k \over z \right)^2 { d \over d \xi } \left( F F^{\prime} \over \xi \right) = { k \varepsilon \over z^2} { d \over d \xi } \left( F^{\prime \prime} - { F^{\prime} \over \xi } \right) \]

+

at this point we note that k and ε have the same units of \([[ \mathrm{length} ]]^2 \times [[ \mathrm{time} ]]^{-1}\) and are independent of z and ξ, so we propose that \(\varepsilon = c k\) where c is some unknown constant of proportionality.

+

\[ \left( k \over z \right)^2 { d \over d \xi } \left( F F^{\prime} \over \xi \right) = c \left( k \over z \right)^2 { d \over d \xi } \left( F^{\prime \prime} - { F^{\prime} \over \xi } \right) \]

+

\[ { d \over d \xi } \left( F F^{\prime} \over \xi \right) = c { d \over d \xi } \left( F^{\prime \prime} - { F^{\prime} \over \xi } \right) \]

+

Integrating both sides

+

\[ { F F^{\prime} \over \xi } = c \left( F^{\prime \prime} - { F^{\prime} \over \xi } \right) + \mathrm{const}\]

+

By applying the boundary conditions we find the constant of integration is zero, thus

+

\[ F F^{\prime} = c \left( \xi F^{\prime \prime} - F^{\prime} \right) \]

+

\[{ d \over d \xi } \left( \frac{1}{2} F^2 \right) = c { d \over d \xi } \left( \xi F^{\prime} - 2 F \right) \]

+

Integrating both sides

+

\[ \frac{1}{2} F^2 = c \left( \xi F^{\prime} - 2 F \right) + \mathrm{const}\]

+

By applying the boundary conditions we find the constant of integration is zero, thus

+

\[ c \xi F^{\prime} = \frac{1}{2} F^2 + 2c F \]

+

Which is separable

+

\[ \int { d \xi \over \xi} = \int { c \over {\frac{1}{2} F^2 + 2c F} } dF \]

+

Integrating one last time

+

\[ \log \left( C_1 \xi \right) = \frac{1}{2} \log \left( F \over F + 4 c \right) \]

+

Where C1 is an undetermined constant of integration. Re-arranging and solving for F we arrive at

+

\[ F\left( \xi \right) = { 4 c C_1 \xi^2 \over {1 - C_1 \xi^2 } } \]

+

A common substitution is \(C_1 = - \left( C_2 \over 2 \right)^2\) then

+

\[ F\left( \xi \right) = { - c \left( C_2 \xi \right)^2 \over {1 + \frac{1}{4} \left( C_2 \xi \right)^2 } } \]

+

What we need, for the velocity profile, is the first derivative of F, which is

+

\[ F^{\prime}\left( \xi \right) = { - 2 c C_2^2 \xi \over \left( 1 + \left( C_2 \xi \over 2 \right)^2 \right)^2 } \]

+

and finally

+

\[ \bar{v}_z = -{k \over z} {F^{\prime} \over \xi} \]

+

\[ = -{k \over z} { 1 \over \xi }{ - 2 c C_2^2 \xi \over \left( 1 + \left( C_2 \xi \over 2 \right)^2 \right)^2 } \]

+

\[ = {2 \varepsilon C_2^2 \over z} \left( 1 + \left( C_2 \xi \over 2 \right)^2 \right)^{-2} \]

+

\[ f \left( \xi \right) = { \bar{v}_z \over \bar{v}_{z,max} } = \left( 1 + \left( C_2 \xi \over 2 \right)^2 \right)^{-2} \]

+
+
f_ev(ξ; C₂=15.1) = ( 1 + (C₂*ξ/2)^2 )^-2
+
+
+
+

Width at Half Height

+

Since we have a convenient closed form for the velocity profile, we can calculate what the parameter \(C_2\) is in terms of the width at half height rather easily.

+

\[ f(\xi) = \left( 1 + \left( C_2 \xi \over 2 \right)^2 \right)^{-2} \]

+

\[ \frac{1}{2} = \left( 1 + \left( {C_2 \over 2} { b_{1/2} \over z }\right)^2 \right)^{-2} \]

+

\[ C_2 = 2 \sqrt{\sqrt{2}-1} \frac{z}{ b_{1/2} } \]

+

using the same parameterization as above we get

+
+
C₂ = 2*√((2)-1)/b_half
+
+
15.179109738339214
+
+
+
+
+

Velocity Profile

+

Returning to the momentum balance, we need to solve the integral:

+

\[ I = \int_{0}^{\infty} f\left( \xi \right)^2 \xi d \xi\\ += \int_{0}^{\infty} \xi \left( 1 + \left( C_2 \xi \over 2 \right)^2 \right)^{-4} d\xi \]

+

Which can be integrated to give \[ I = {2 \over 3} C_2^{-2} \]

+

and finally

+

\[ k = \sqrt{ 3 \over 16 } C_2 v_0 d_0 \]

+

with the velocity profile as

+
+
+

\[ \bar{v}_z = 6.57 { v_0 d_0 \over z} \left( 1 + 57.6 \xi^2 \right)^{-2} \]

+
+
+
+
+
+

Empirical Velocity Profiles

+

Perhaps the most widely used turbulent jet model is simply an empirical gaussian fit to the data. These are easy to use – no solving of ODEs required – and fitting them to data is relatively straight forward. There is no real theoretical basis that I am aware of, merely based on the observation that a gaussian function fits the velocity profile well.

+

\[ f \left( \xi \right) = \exp \left( -c \xi^2 \right) \]

+

Where c is a parameter determined by fitting to a dataset.

+
+
f_emp(ξ; c=72) = exp(-c*ξ^2)
+
+
+

Width at Half Height

+

Since we have a convenient closed form for the velocity profile, we can calculate what the parameter \(c\) is in terms of the width at half height rather easily

+

\[ f(\xi) = \exp \left( -c \xi^2 \right) \]

+

\[ \frac{1}{2} = \exp \left( - c \left( \frac{ b_{1/2} }{z} \right)^2 \right) \]

+

\[ c = \ln \left( 2 \right) \left( \frac{z}{ b_{1/2} } \right)^2 \]

+

using the same parameterization as above we get

+
+
c = log(2)/b_half^2
+
+
96.39039423504045
+
+
+
+
+

Velocity Profile

+

Returning to the momentum balance, we need to solve the integral:

+

\[ I = \int_{0}^{\infty} f\left( \xi \right)^2 \xi d \xi\\ += \int_{0}^{\infty} \xi \exp \left( -2 c \xi^2 \right) d\xi \]

+

Which can be integrated to give

+

\[ I = {1 \over 4 c} \]

+

and finally

+

\[ k = \sqrt{ c \over 2 } v_0 d_0 \]

+

with the velocity profile as

+
+
+

\[ \bar{v}_z = 6.94 { v_0 d_0 \over z} \exp\left( -193.0 \xi^2 \right) \]

+
+
+
+
+
+

Comparing the Models

+

At this point two models of velocity were derived using different models of the free turbulent stress and one purely empirical model was introduced. Each of these models uses a different set of parameters, and have different strengths and weaknesses in terms of usability. To compare them like-for-like we can scale each to the same width at half height, which is shown below along with some measured data14

+

14 Pope, Turbulent Flows, points captured from a figure using WebPlotDigitizer.

We can also calculate a Mean Square Error (MSE) and evaluate which model is a better fit to the observed velocity profile.

+
+
+
+
+
+ +
+
+Figure 4: Comparing all three turbulent jet models to the observed velocity profile. +
+
+
+
+
+
Prandtl Mixing Length Model MSE 0.00051
+Eddy Viscosity Model        MSE 0.00092
+Gaussian (empirical) Model  MSE 0.00054
+
+
+

Interestingly the Prandtl mixing length model works the best, though the gaussian fit is close enough as to be essentially the same given this data set. Which is convenient as a gaussian fit is easier to work with. The eddy viscosity model is the easiest to derive, however it clearly does not work as well for the outer parts of the jet.

+

The above approach, the one you will most likely see in the literature, compares each model scaled to the same height and width. Which is sensible if one is planning on fitting data, and allowing that the height and width to be free parameters. However we know, from the analysis above, that the height of each model is dependent upon the width, so might be instructive to look at how that plays out in practice.

+

Suppose we are looking at a velocity profile far enough downstream to be in the fully developed flow, say \(z = 7 d_0\)

+
+
+
+
+
+ +
+
+Figure 5: A comparison of the three turbulent jet models with an identical half-width, with the height calculated from the momentum balance. +
+
+
+
+
+

Note that in the region near the center-line the three models are no longer particularly close to one another and the eddy viscosity and prandtl mixing length models have changed places. Relative to the predicted \(v_{max}\) the the eddy viscosity model stays high when compared to the prandtl mixing length model, however the eddy viscosity model predicts a lower \(v_{max}\) such that the effect is entirely reversed.

+

It’s also worth noting that the gaussian fit and the prandtl mixing length model track one another reasonably well. I have seen a gaussian fit of the Tollmien tabulated results used in some papers when a smooth interpolation of the intermediate values is required and this suggests that may not be a bad idea. Though, to me, just solving the ode is easier. On a modern machine it takes milliseconds or less and a good ode package like DifferentialEquations.jl provides a higher-order interpolation for free.

+

This comparison has been done with each of the model parameters set based on a shared width. However there are as many different ways of arriving at the model parameters as there are datasets to fit against. There is a wide spread in tabulated values in the literature and so the predictions of two independently arrived at models can be quite different due all of these factors coming together.

+
+
+

Where to go from here

+

All of this work was to determine the velocity field, which is not necessarily what anyone cares about. In a release scenario, for example, it is concentration that is most relevant. For a heat transfer application, perhaps, you may care about the temperature field instead. However, with the velocity field the concentrations, temperatures, total entrained flow, etc. can be easily derived.

+
+
+

References

+
+
+Bird, R. Byron, Warren E. Stewart, and Edwin N. Lightfoot. Transport Phenomena. 2nd ed. Hoboken, NJ: John Wiley & Sons, 2007. +
+
+Garde, R. J. Turbulent Flows. 3rd ed. London: New Academic Science, 2010. +
+
+Pope, Stephen B. Turbulent Flows. Cambridge: Cambridge University Press, 2000. +
+
+Rajaratnam, N. Turbulent Jets. Amsterdam: Elsevier, 1974. +
+
+Tollmien, Walter. “Berechnung Turbulenter Ausbreitungsvorgänge.” Zeitschrift Für Angewandte Mathematik Und Mechanik 6 (1926): 468–78. https://doi.org/10.1002/zamm.19260060604
reprinted and translated in NACA-TM-1085.
+
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/turbulent_jet_notes/index_files/figure-html/d167de8a-1b9e-4c1e-ae38-2302b5c77bd1-1-0dfb9ef0-987e-4c09-88c3-1c19b4216aaa.png b/posts/turbulent_jet_notes/index_files/figure-html/d167de8a-1b9e-4c1e-ae38-2302b5c77bd1-1-0dfb9ef0-987e-4c09-88c3-1c19b4216aaa.png new file mode 100644 index 0000000..da66047 Binary files /dev/null and b/posts/turbulent_jet_notes/index_files/figure-html/d167de8a-1b9e-4c1e-ae38-2302b5c77bd1-1-0dfb9ef0-987e-4c09-88c3-1c19b4216aaa.png differ diff --git a/posts/turbulent_jet_notes/index_files/figure-html/fig-compare-models-output-1.svg b/posts/turbulent_jet_notes/index_files/figure-html/fig-compare-models-output-1.svg new file mode 100644 index 0000000..ce5c501 --- /dev/null +++ b/posts/turbulent_jet_notes/index_files/figure-html/fig-compare-models-output-1.svg @@ -0,0 +1,271 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/turbulent_jet_notes/index_files/figure-html/fig-equal-width-output-1.svg b/posts/turbulent_jet_notes/index_files/figure-html/fig-equal-width-output-1.svg new file mode 100644 index 0000000..b316ad4 --- /dev/null +++ b/posts/turbulent_jet_notes/index_files/figure-html/fig-equal-width-output-1.svg @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/turbulent_jet_notes/index_files/figure-html/fig-pmt-tollmien-output-1.svg b/posts/turbulent_jet_notes/index_files/figure-html/fig-pmt-tollmien-output-1.svg new file mode 100644 index 0000000..3c95bca --- /dev/null +++ b/posts/turbulent_jet_notes/index_files/figure-html/fig-pmt-tollmien-output-1.svg @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/turbulent_jet_notes/index_files/figure-html/fig-prandtl-stream-functions-output-1.svg b/posts/turbulent_jet_notes/index_files/figure-html/fig-prandtl-stream-functions-output-1.svg new file mode 100644 index 0000000..a4a58ab --- /dev/null +++ b/posts/turbulent_jet_notes/index_files/figure-html/fig-prandtl-stream-functions-output-1.svg @@ -0,0 +1,320 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/turbulent_jet_notes_part_2/index.html b/posts/turbulent_jet_notes_part_2/index.html new file mode 100644 index 0000000..b59f836 --- /dev/null +++ b/posts/turbulent_jet_notes_part_2/index.html @@ -0,0 +1,1429 @@ + + + + + + + + + + + + +More on Turbulent Jets – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

More on Turbulent Jets

+
+
+ Calculating concentrations, temperatures, and flow rates. +
+
+
+
julia
+
dispersion modelling
+
turbulent jets
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

May 8, 2022

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

Previously I worked through the velocity profiles for turbulent jets and left off claiming that everything else of interest followed simply from those profiles. This time I am going to follow through by sketching out how to derive the concentration profile and volumetric flow rate.

+
+

Concentration

+

For hazard identification, among other purposes, what one often wants is not the velocity distribution of the jet, but the concentration profile. For example, suppose a vessel develops a small hole and a jet of process fluid is exiting out into the air, to determine how bad that is and what sort of hazard is presented (explosive, toxic, etc.) we first need to determine the concentration profile.

+

Suppose the concentration of a species A is cA, for the sake of simplicity let this be a time-averaged concentration done in a way that is consistent with Reynolds averaging.1

+

1 Note the concentration is given in units of \([[ quantity ]] \times [[length]]^{-3}\), e.g. kmol/m³

The continuity equation for species A is given by2

+

\[ {\mathrm{D} \over \mathrm{D}t} c_A = - \nabla \cdot \mathbf{J}_A + r_A \]

+

where J is the molar flux and r is the rate of reaction3

+

3 The molar flux J is the time averaged molar flux and is the sum of both viscous and turbulent terms. \(\mathbf{J}_A = \mathbf{J}_A^{(v)} + \mathbf{J}_A^{(t)}\)

In cylindrical coordinates this is:

+

\[ {\partial c_A \over \partial t} + \bar{v}_r {\partial c_A \over \partial r} + {\bar{v}_{\theta} \over r } {\partial c_A \over \partial \theta} + \bar{v}_z {\partial c_A \over \partial x} \]

+

\[ = -\left[ {1 \over r} {\partial \over \partial r} \left(r J_{A,r}\right) + {1 \over r} {\partial \over \partial \theta} J_{A,\theta} + {\partial \over \partial z} J_{A,z} \right] + r_A\]

+

Making the following assumptions:

+
    +
  1. Steady state ( \({\partial \over \partial t} \left( \dots \right) = 0\) )
  2. +
  3. Axisymmetric ( \({\partial \over \partial \theta} \left( \dots \right) = 0\) )
  4. +
  5. Boundary layer approximation ( \(\vert {\partial c_A \over \partial z} \vert \ll \vert {\partial c_A \over \partial r} \vert\) )
  6. +
  7. Non-reacting ( \(r_A = 0\) )
  8. +
+

This simplifies down to

+

\[ \bar{v}_r {\partial c_A \over \partial r} + \bar{v}_z {\partial c_A \over \partial z} = {-1 \over r} {\partial \over \partial r} \left( r J_{A,r} \right) \]

+

We suppose that, much like the velocity profile, the concentration profile is self-similar. That is, at any given downstream distance z the profile has the same shape, just scaled and stretched.

+

\[ c_A = {k_c \over z} g\left(\xi\right) \]

+

where ξ is r/z and kc is some constant to be determined. From this we can work out some useful partial derivatives

+

\[ {\partial c_A \over \partial r} = {k_c \over z^2} g^{\prime}\left(\xi\right) \]

+

\[ {\partial c_A \over \partial z} = {-k_c \over z^2} \left[ g\left(\xi\right) + \xi g^{\prime}\left(\xi\right) \right]\]

+

recalling the velocity profiles in terms of F(ξ) and substituting into the equation of continuity we arrive at

+

\[{ k k_c \over z^2 } {d \over d\xi} \left(F g\right) = -{ \partial \over \partial r} \left( r J_{A,r} \right)\]

+

Which gives us our path forward: find a model for \(J_{A,r}\) and substitute into the right hand side of the equation, integrate both sides and solve for g in terms of F and ξ.

+
+

Prandtl mixing length models

+

We are going to assume that the overall molar flux is proportional to the concentration gradient and some mixing length4 l, that is

+

\[ J_{A,r} = -l_c^2 \left\vert \partial \bar{v}_z \over \partial r \right\vert \left(\partial c_A \over \partial r \right)\]

+

we assume the mixing length is proportional to the downstream distance for the same reasons as when we derived the velocity profile and, anticipating the form of the constants from how it worked out for the velocity distribution, \(l_c = a_c^{3/2} z\)

+

Putting this into the equation of continuity for A and doing some rearranging gives

+

\[{ k k_c \over z^2 } {d \over d\xi} \left(F g\right) = { k k_c \over z^2 } a_c^3 {d \over d\xi}\left(g^{\prime}F^{\prime\prime} - {g^{\prime}F^{\prime} \over \xi} \right)\]

+

Cancelling some terms and integrating both sides gives us

+

\[ F g = a_c^3 \left(g^{\prime}F^{\prime\prime} - {g^{\prime}F^{\prime} \over \xi} \right) + \mathrm{const} \]

+

Where we can see from the boundary conditions that the constant of integration is zero. We can separate F and g

+

\[ {g^{\prime} \over g} = { a_c^{-3} F \over F^{\prime \prime} - {F^{\prime} \over \xi} }\]

+

and integrating both sides again, we arrive at

+

\[ \log{c_A \over c_{A,max} } = \log{g\left(\xi\right) \over g\left(0\right)} = a_c^{-3} \int_0^{\xi} {F \over F^{\prime \prime} - {F^{\prime} \over \xi} } d\xi\]

+

Note that, when setting up the original ode, we had

+

\[ F F^{\prime} = a^3 \left( F^{\prime\prime} - {F^{\prime} \over \xi} \right)^2 \]

+

or

+

\[ F^{\prime\prime} - {F^{\prime} \over \xi} = \sqrt{ F F^{\prime} \over a^3 } \]

+

Making the substitution gives us an integral entirely in terms of F, F′ and ξ

+

\[ \log{g\left(\xi\right) \over g\left(0\right)} = a_c^{-3} a^{3/2} \int_0^{\xi} {F \over \sqrt{ F F^{\prime} } } d\xi\]

+

Taking the exponential of both sides gives us:

+

\[ {g\left(\xi\right) \over g\left(0\right)} = \left( \exp \left(\int_0^{\xi} {F \over \sqrt{ F F^{\prime} } } d\xi \right)\right)^{ a_c^{-3} a^{3/2} } \]

+

Which is something we can compute using the ode solution from last time, by importing the code for the ode from the previous notebook and running it to get the velocity distribution.

+
+
# importing just the ODE solution
+
+using NBInclude
+
+@nbinclude("../turbulent_jet_notes/index.ipynb"; counters=[1 3])
+
+

We can then perform the integration numerically, in this case the cumulative integral

+
+
using NumericalIntegration: cumul_integrate
+
+ϕ, F, F′ = sol.t, sol[1,:], sol[2,:]
+
+# trim any unphysical values
+F[ F.>0 ] .= 0.0
+F′[F′.>0] .= 0.0
+
+
+function intgrnd(F, F′) 
+    if F == 0.0
+        return 0.0
+    elseif F′ == 0.0
+        return -Inf
+    else
+        return F ./ .(F.*F′)
+    end
+end
+    
+log_g = cumul_integrate(ϕ, intgrnd.(F, F′));
+
+

For some context we can plot the concentration along with the velocity, with the constants \(a_c^{-3} a^{3/2} = 1\)

+
+
+
+
+ +
+
+Figure 1: Comparison of the concentration profile to the velocity profile, Prandtl mixing length theory. +
+
+
+
+

Well, that’s interesting, it looks like we have arrived at

+

\[ {c_A \over c_{A,max} } = \left( \bar{v}_z \over \bar{v}_{z,max} \right)^{\mathrm{const} } \]

+

Which is, in fact, the case and is generally the case – the Prandtl mixing length, eddy diffusion, and Gaussian models work out to the same conclusion.

+

Consider the following derivative

+

\[ {d \over d\xi} \log{f\left(\xi\right)} = {d \over d\xi} \log \left( -F^{\prime} \over \xi \right) = {1 \over F^{\prime} } \left( F^{\prime\prime} - {F^{\prime} \over \xi} \right)\]

+

Recalling back to our original ode for the Prandtl mixing length velocity distribution, we had the relationship

+

\[ F F^{\prime} = a^3 \left( F^{\prime\prime} - {F^{\prime} \over \xi} \right)^2 \]

+

or

+

\[ {F \over F^{\prime\prime} - {F^{\prime} \over \xi} } = a^3 { {F^{\prime\prime} - {F^{\prime} \over \xi} } \over F^{\prime} } \]

+

and so

+

\[ {d \over d\xi} \log{f\left(\xi\right)} = a^{-3} {F \over F^{\prime\prime} - {F^{\prime} \over \xi} } \]

+

and recalling that the integral we originally wished to solve was

+

\[ \log{c_A \over c_{A,max} } = a_c^{-3} \int_0^{\xi} {F \over F^{\prime \prime} - {F^{\prime} \over \xi} } d\xi\]

+

we get

+

\[ \log{c_A \over c_{A,max} } = \left(a \over a_c\right)^{3} \left[ \log \left( f\left(\xi\right) \over f\left(0\right) \right) \right] = \log \left( \bar{v}_z \over \bar{v}_{z,max} \right)^{\left(a \over a_c\right)^{3} } \]

+

and finally

+

\[ { c_A \over c_{A,max} } = \left( \bar{v}_z \over \bar{v}_{z,max} \right)^{\left(a \over a_c\right)^{3} }\]

+

Where, rather pleasingly, the constant \({\left(a \over a_c\right)^{3} }\) works out to be the ratio of the mixing lengths, all squared5

+

5 Suppose an “equivalent” eddy viscosity for the Prandtl mixing length model of

+

\[\varepsilon = -l^2 \left\vert \partial \bar{v}_z \over \partial r \right\vert\]

+

and eddy diffusivity of

+

\[\mathscr{D}_{AB} = -l_c^2 \left\vert \partial \bar{v}_z \over \partial r \right\vert\]

+

the turbulent Schmidt number is then

+

\[\mathrm{Sc} = {\varepsilon \over \mathscr{D}_{AB} } = \left( l \over l_c \right)^2\]

+

making the final result

+

\[{c_A \over c_{A,max} } = \left( \bar{v}_z \over \bar{v}_{z,max} \right)^{Sc}\]

\[ {\left(a \over a_c\right)^{3} } = \left( l \over l_c \right)^2 \]

+

We can now plot the concentration profile along with the velocity profile and see that the two profiles have a similar shape, with the concentration profile stretched to be wider. Concentration spreads out more than velocity.

+
+
+
+
+ +
+
+Figure 2: The dimensionless concentration profile and velocity profile, Prandtl mixing length theory. +
+
+
+
+
+
+

Eddy diffusivity models

+

In the eddy diffusivity model we assume the molar flux is proportional to the concentration gradient with the constant of proportionality being an effective diffusivity, the eddy diffusivity. In some treatments the viscous and turbulent diffusivities are treated separately, in this model we lump it all together into one constant

+

\[ J_{A,r} = -\mathscr{D}_{AB} {\partial c_A \over \partial r} \]

+

where \(\mathscr{D}_{AB}\) is the eddy diffusivity for species A. Using the definition of cA we can work out the right hand side of the equation of continuity for A

+

\[ -{ \partial \over \partial r} \left( r J_{A,r} \right) = {k_c \over z^2} \mathscr{D}_{AB} {d \over d\xi} \left( \xi g^{\prime} \right) \]

+

putting that into the equation of continuity for A, we get

+

\[{ k k_c \over z^2 } {d \over d\xi} \left(F g\right) = {k_c \over z^2} \mathscr{D}_{AB} {d \over d\xi} \left( \xi g^{\prime} \right) \]

+

cancelling some terms and integrating once gives us

+

\[k F g = \mathscr{D}_{AB} \xi g^{\prime} + \mathrm{const}\]

+

where, by use of boundary conditions, the constant of integration is zero. This can be rearranged to isolate F and g

+

\[ {g^{\prime} \over g} = {k \over \mathscr{D}_{AB} } {F \over \xi} \]

+

integrating once more

+

\[ \log \left( g\left(\xi\right) \over g\left(0\right) \right) = {k \over \mathscr{D}_{AB} } \int_0^{\xi} {F \over \xi} d\xi \]

+

recalling that, for the eddy diffusivity model

+

\[ F\left( \xi \right) = { - c \left( C_2 \xi \right)^2 \over {1 + \frac{1}{4} \left( C_2 \xi \right)^2 } } \]

+

\[ \int_0^{\xi} {F \over \xi} d\xi = {c} \int_0^{\xi} { -C_2^2 \xi \over {1 + \frac{1}{4} \left( C_2 \xi \right)^2 } } d\xi = {c} \log \left(1 + \frac{1}{4} \left( C_2 \xi \right)^2 \right)^{-2} \]

+

and so we have

+

\[ \log \left( g\left(\xi\right) \over g\left(0\right) \right) = {k c \over \mathscr{D}_{AB} } \log \left(1 + \frac{1}{4} \left( C_2 \xi \right)^2 \right)^{-2} \]

+

recalling that the constant k is related to the eddy diffusivity \(\varepsilon\) by \(\varepsilon=ck\), we have

+

\[ {c_A \over c_{A,max} } = \left( g\left(\xi\right) \over g\left(0\right) \right) = \left(1 + \frac{1}{4} \left( C_2 \xi \right)^2 \right)^{-2{\varepsilon \over \mathscr{D}_{AB} } } \]

+

and, looking back on the definition of f(ξ) from the eddy viscosity model, we get6

+

6 The turbulent Schmidt number is defined as the ratio of the eddy viscosity to the eddy diffusivity

+

\[\mathrm{Sc} = {\varepsilon \over \mathscr{D}_{AB} }\]

+

making the final result

+

\[{c_A \over c_{A,max} } = \left( \bar{v}_z \over \bar{v}_{z,max} \right)^{Sc}\]

\[ {c_A \over c_{A,max} } = \left( \bar{v}_z \over \bar{v}_{z,max} \right)^{\varepsilon \over \mathscr{D}_{AB} } \]

+

We can plot the concentration and velocity profiles for the eddy diffusivity model as well, and it is a similar story. The shapes of the profiles are the same but the concentration profile is stretched, such that concentration “spreads out” more than velocity does.

+
+
+
+
+ +
+
+Figure 3: The dimensionless concentration profile and velocity profile, Eddy diffusivity model. +
+
+
+
+
+
+

Gaussian models

+

The standard Gaussian model was defined as

+

\[ f\left(\xi\right) = \exp \left( -c \xi^2 \right) \]

+

where the constant c (note: not the concentration) was found to be

+

\[ c = \log{2} \left(z \over b_{1/2}\right)^2 = \log{2} \left(1 \over \beta\right)^2 \]

+

where I am introducing the constant β to represent the spreading constant (i.e. the ratio of b to z) mostly to cut down on all the constants I call c given that I am also using c to represent concentrations.

+

We can then write the velocity distribution as

+

\[ f\left(\xi\right) = \exp \left( -c \xi^2 \right) = \exp \left( -\log{2} \left(\xi \over \beta\right)^2 \right) = \exp \left(- \log 2 \left(\xi \over \beta\right)^2 \right)\]

+

The concentration distribution is similarly defined empirically in terms of a half-width, \(b_{1/2,c}\) and entirely analogously we end up with a distribution

+

\[ g\left(\xi\right) = \exp \left( -\log 2 \left(\xi \over \beta_c\right)^2 \right)\]

+

where βc is the spreading constant for the concentration profile. But it’s fairly easy to see that this is equivalent to

+

\[ g\left(\xi\right) = \exp \left( -\log 2 \left(\xi \over \beta\right)^2 \left(\beta \over \beta_c\right)^2 \right) = f\left(\xi\right)^{\left(\beta \over \beta_c\right)^2} \]

+

or7

+

7 We can argue, in a manner analogous to the Prandtl mixing length theory, that the eddy viscosity is proportional to the characteristic length squared, and similarly for the eddy diffusivity and thus the turbulent Schmidt number is then

+

\[\mathrm{Sc} = {\varepsilon \over \mathscr{D}_{AB} } = \left( b_{1/2} \over b_{1/2,c} \right)^2 = \left( \beta \over \beta_c \right)^2\]

+

thus making the final result

+

\[{c_A \over c_{A,max} } = \left( \bar{v}_z \over \bar{v}_{z,max} \right)^{Sc}\]

\[ {c_A \over c_{A,max} } = \left({\bar{v}_z \over \bar{v}_{z,max} }\right)^{\left(\beta \over \beta_c\right)^2} \]

+
+
+ +

The dimensionless concentration profile and velocity profile, Gaussian empirical model.

+
+
+
+
+

Schmidt number

+

All three of the turbulent jet models looked at so far ended up with a concentration profile of

+

\[{c_A \over c_{A,max} } = \left( \bar{v} \over \bar{v}_{z,max} \right)^{Sc} \]

+

where I declared the constant Sc to be the turbulent Schmidt number. There are, of course, several ways of arriving at this value but literature generally gives \(Sc \approx 0.7\). This also tends to be the same value given for the turbulent Prandtl number, which is convenient.

+ + + + + + + + + + + + + + + + + +
ScReference
0.7Bird, Stewart, and Lightfoot8
0.73Kaye, Khan, and Testik9
+
+
+

Mass balance

+

Throughout all of this there has been a constant kc floating around, unaddressed. In practice this is usually a free parameter determined by fitting with experimental data. However it can also be determined by a mass balance.

+

The total molar flux through any plane z=m is the same for all m (in the region of fully developed flow), which is to say

+

\[ n_A = \int_0^{2\pi} \int_0^{\infty} c_A \bar{v}_z r dr d\theta = \mathrm{const} \]

+

We also know what total molar flux is from the initial conditions of the jet

+

\[ n_A = c_0 v_0 {\pi \over 4} d_0^2 \]

+

and we can write the integral for any downstream distance z

+

\[ n_A = 2\pi \int_0^{\infty} c_A \bar{v}_z r dr = 2 \pi k k_c \int_0^{\infty} \left(\bar{v}_z \over \bar{v}_{z,max} \right)^{Sc+1} \xi d\xi \]

+

and so we can write kc in terms of the other parameters

+

\[ k_c = { c_0 v_0 d_0^2 \over 8 k I_c } \]

+

where

+

\[I_c = \int_0^{\infty} \left(\bar{v}_z \over \bar{v}_{z,max} \right)^{Sc+1} \xi d\xi\]

+

recalling that \(k = {v_0 d_0 \over \sqrt{8I} }\) where \[I = \int_0^{\infty} \left(\bar{v}_z \over \bar{v}_{z,max} \right)^{2} \xi d\xi\]

+

we can simplify this to

+

\[ k_c = \sqrt{I \over 8 I_c^2} c_0 d_0 \]

+

or

+

\[ {c_A \over c_0} = \sqrt{I \over 8 I_c^2} {d_0 \over z} \left(\bar{v}_z \over \bar{v}_{z,max} \right)^{Sc} \]

+

These integrals can get difficult to solve analytically – except for the Gaussian case which is fairly simple – but are very easy to estimate numerically.10

+

10 It is worth keeping in mind, when using the empirical constants provided in the literature, that they are often fitted parameters and as such mass and momentum conservation is not necessarily guaranteed.

As an example, suppose we wish to calculate the constant for the Prandtl mixing length model, we have already imported the solution to the ode for the velocity profile and it is a simple matter to numerically integrate (using the trapezoidal rule) the two integrals in question.

+

We still need two parameters to complete the integration, the parameter a and ac or, equivalently, β and the Schmidt number Sc.

+
+
using NumericalIntegration: integrate
+
+# solutions of the ode
+ϕ, F′ = sol.t, sol[2,:]
+
+# trim any unphysical values
+F′[F′.>0] .= 0
+
+# parameters of the model
+β = 0.0848
+a = β/1.2277667062444657
+Sc = 0.7
+f  = -F′./ϕ
+ξ  = a.*ϕ
+
+# momentum balance integrand
+int = (f.^2).*ξ
+int[ξ.≤0] .= 0
+
+# mass balance integrand
+int_c = (f.^(Sc+1)).*ξ
+int_c[ξ.≤0] .= 0
+
+I   = integrate(ξ, int)
+I_c = integrate(ξ, int_c)
+
+k_c = (I/(8*I_c^2))
+
+
5.793131625321363
+
+
+

The other two models could also be integrated numerically, for example the eddy diffusivity model

+
+
using QuadGK: quadgk
+
+C₂ = 2*√((2)-1)/β
+f_ev(ξ) = ( 1 + (C₂*ξ/2)^2 )^-2
+
+I, err   = quadgk((ξ) -> ξ*f_ev(ξ)^2, 0, Inf)
+I_c, err = quadgk((ξ) -> ξ*f_ev(ξ)^(Sc+1), 0, Inf)
+
+k_c = (I/(8*I_c^2))
+
+
5.258197856093409
+
+
+

and the Gaussian model

+
+
c = log(2)/β^2
+f_emp(ξ) = exp(-c*ξ^2)
+
+I, err   = quadgk((ξ) -> ξ*f_emp(ξ)^2, 0, Inf)
+I_c, err = quadgk((ξ) -> ξ*f_emp(ξ)^(Sc+1), 0, Inf)
+
+k_c = (I/(8*I_c^2))
+
+
5.900934664729694
+
+
+

In these cases I followed my convention from the previous notebook of setting each of the velocity distributions to have the same width at half height, and using the same Schmidt number for the concentration profiles. This makes it more of an “apples to apples” comparison.

+

Looking at the results we see that the constant for the concentration is smaller than the velocity profiles, indicating that the centerline concentration drops off faster than the velocity. Which makes some sense as we saw above that the concentration also spreads out radially more than the velocity.

+

We can combine the velocity and concentration profiles and get a sense of how the jet expands. Note that the plot is looking at the fully-developed jet, in this case ~4 diameters downstream.

+
+
+
+
+ +
+
+Figure 4: The flowfield and concentration contours for an example turbulent jet, Prandtl mixing length model +
+
+
+
+
+
+

Practical considerations

+

In most practical cases I’ve encountered, by far the easiest approach is to use a standard Gaussian model with parameters from literature. That said, there is a lot of variability of recommended parameters and some thought needs to go into what the model is being used for. Consider the following table giving model parameters for various gaseous jets entering into air.11

+

\[ {c_A \over c_0} = k_2 {d_0 \over z} \sqrt{ \rho_a \over \rho_j} \exp \left( - \left(k_3 {r \over z} \right)^2 \right) \]

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
JetRe\(k_2\)\(k_3\)
CO₂50 0005.49.2
N₂27 0005.47.9
He3 4004.15.3
air + 1.1% towngas67 0005.38.8
hot air67 0005.38.8
air + N₂O tracer27-57 0004.57.1
hot air18-25 0004.57.1
hot air30-60 0005.37.8
hot air10-20 0005.06.4
hot air4.87.1
hot air5.97.7
+

Below is a plot showing the range of values this generates for the Gaussian jet model, along with the recommended parameters for use when estimating the extent of a hazardous area around a vent.12 The range of values is quite wide.

+

12 Clearly the recommendation is a conservative approach, which is what you would want for a hazard analysis.

+
+
+
+ +
+
+Figure 5: Gaussian concentration profile, range of values and recommended constants from Long13 +
+
+
+
+

It is also worth noting that the concentration model breaks down when \(z<k_2\), it will register concentrations greater than is possible. The normal way of dealing with this is a so-called top-hat model: chop off any concentrations \(c_A > c_0\), though if the region of interest is primarily very close to the hole a different model should be used.

+
+
+
+

Temperature

+

The temperature profiles follow directly from the velocity profiles in an entirely analogous way to concentration. The temperature profile is given by:

+

\[ {T - T_a \over T_0 - T_a} = k_T {d_0 \over z} \left( \bar{v}_z \over \bar{v}_{z,max} \right)^{Pr} \]

+

Where Ta is the ambient temperature, T0 is the jet temperature, and Pr is a turbulent Prandtl number. The constant kT is then determined by an energy balance, again entirely analogously to the case for concentration.

+
+
+

Volumetric Flow

+

The volumetric flow-rate of the jet grows with the downstream distance, as the jet entrains the surrounding fluid. This can be calculated from an integral of the velocity distribution

+

\[ Q = \int_0^{2\pi} \int_0^{\infty} \bar{v}_z r dr d\theta \]

+

recalling the definition of \(\bar{v}_z\) in terms of the function F and making the change of variables to ξ

+

\[ Q = \int_0^{2\pi} \int_0^{\infty} {-k \over z} {F^{\prime} \over \xi} (z \xi) z d\xi d\theta \]

+

\[ = 2 \pi k z \int_0^{\infty} -F^{\prime} d\xi \]

+

\[ = 2 \pi k z \left[ -F\left(\xi\right) \right]_{0}^{\infty} \]

+

let’s define the limit

+

\[ \lim_{\xi \to \infty} F\left(\xi\right) = F_{\infty}\]

+

and recall that from boundary conditions F(0)=0, so

+

\[ \left[ -F\left(\xi\right) \right]_{0}^{\infty} = -F_{\infty} \]

+

recalling the definition of k

+

\[ k = {v_0 d_0 \over \sqrt{8 I} }\]

+

and putting it all together we get

+

\[ Q = -{ 2 \pi \over \sqrt{8 I} } F_{\infty} v_0 d_0 z \]

+

It is often more convenient to put things in in dimensionless terms, so let

+

\[ Q_0 = {\pi \over 4} v_0 d_0^2 \]

+

which gives us

+

\[ {Q \over Q_0} = - \sqrt{8 \over I} F_{\infty} {z \over d_0} \]

+

we can define a new constant kQ

+

\[ k_Q = - \sqrt{8 \over I} F_{\infty} \]

+

and finally

+

\[ {Q \over Q_0} = k_Q {z \over d_0} \]

+

Where \(k_Q\) is some constant defined by the particular model and the corresponding model parameter.

+

For the Prandtl mixing length model we can compute this constant numerically

+
+
a = β/1.2277667062444657
+
+I   = integrate(ξ, int)
+F∞  = a^2*sol[1,end]
+
+k_Q = -√(8/I)*F∞
+
+
0.3026722591952044
+
+
+

For the eddy viscosity model this can be worked out analytically to be \[k_Q = {4\sqrt{3} \over C_2}\]

+
+
C₂ = 2*√((2)-1)/β
+
+k_Q = 4*3/C₂
+
+
0.4564301431180997
+
+
+

The Gaussian model can also be worked out analytically, giving \[k_Q = {4 \over \sqrt{2 c}}\]

+
+
c = log(2)/β^2
+
+k_Q = 4/(2*c)
+
+
0.28808995465769605
+
+
+

The literature14 gives \(k_Q = 0.32\) which compares well with the calculations above. Though it’s worth noting that the eddy viscosity model over predicts the volumetric flow rate quite noticeably, this is not surprising considering that it has fatter tails than either the Prandtl mixing length model or the Gaussian model. In the tails is where the eddy viscosity model no longer matches well with the observed data, so this is just a weakness of the model itself.

+
+
+

Conclusions

+

This was just a brief tour of the different parameters that can be calculated from the velocity distribution or stream function, once it is known. In practice, very often the constants encountered along the way are treated like fitting parameters and so it is always worth keeping in mind what the model is being used for and what conditions must be strictly followed or not. For example, if one wants a very accurate fit to concentration, that may result in mass no longer being conserved because a model may not have enough degrees of freedom to fit the one and guarantee the other.

+

If you are not fitting data, there is a wide range of parameters given in the literature and I think it is more important to find a set of parameters that work for the situation of interest – for whichever model they were fit, but generally it is Gaussian – than it is to fiddle around in the margins of whether or not one should use a Prandtl mixing length model versus an eddy viscosity model. Very often the answer is going to be Gaussian because that’s what the best model in the literature was fit to.

+
+
+

References

+
+
+Bird, R. Byron, Warren E. Stewart, and Edwin N. Lightfoot. Transport Phenomena. 2nd ed. Hoboken, NJ: John Wiley & Sons, 2007. +
+
+Kaye, Nigel B., Abdul A. Khan, and Firat Y. Testik. “Environmental Fluid Mechanics.” In Handbook of Environmental Engineering, edited by Myer Kutz. New York: John Wiley & Sons, 2018. +
+
+Long, V. D. “Estimation of the Extent of Hazard Areas Around a Vent.” Second Symposium On Chemical Process Hazards, 1963, 6–14. +
+
+Rajaratnam, N. Turbulent Jets. Amsterdam: Elsevier, 1974. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-eddy-profiles-output-1.svg b/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-eddy-profiles-output-1.svg new file mode 100644 index 0000000..6530c42 --- /dev/null +++ b/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-eddy-profiles-output-1.svg @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-flow-field-output-1.svg b/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-flow-field-output-1.svg new file mode 100644 index 0000000..d61c8bd --- /dev/null +++ b/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-flow-field-output-1.svg @@ -0,0 +1,3052 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-integration-output-1.svg b/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-integration-output-1.svg new file mode 100644 index 0000000..75435af --- /dev/null +++ b/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-integration-output-1.svg @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-long-profile-output-1.svg b/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-long-profile-output-1.svg new file mode 100644 index 0000000..9909f73 --- /dev/null +++ b/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-long-profile-output-1.svg @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-pml-conc-output-1.svg b/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-pml-conc-output-1.svg new file mode 100644 index 0000000..1c85967 --- /dev/null +++ b/posts/turbulent_jet_notes_part_2/index_files/figure-html/fig-pml-conc-output-1.svg @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/turbulent_jet_notes_part_2/index_files/figure-html/gaussian-profiles-output-1.svg b/posts/turbulent_jet_notes_part_2/index_files/figure-html/gaussian-profiles-output-1.svg new file mode 100644 index 0000000..f7068d9 --- /dev/null +++ b/posts/turbulent_jet_notes_part_2/index_files/figure-html/gaussian-profiles-output-1.svg @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/turbulent_jet_notes_part_2/jet.png b/posts/turbulent_jet_notes_part_2/jet.png new file mode 100644 index 0000000..11b9548 Binary files /dev/null and b/posts/turbulent_jet_notes_part_2/jet.png differ diff --git a/posts/vapour_cloud_explosion_example/index.html b/posts/vapour_cloud_explosion_example/index.html new file mode 100644 index 0000000..336131f --- /dev/null +++ b/posts/vapour_cloud_explosion_example/index.html @@ -0,0 +1,1680 @@ + + + + + + + + + + + + +VCE Example - Butane Vapour Cloud – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

VCE Example - Butane Vapour Cloud

+
+
+ Using the Baker-Strehlow-Tang model for a vapour cloud explosion. +
+
+
+
julia
+
chemical releases
+
hazard screening
+
dispersion modelling
+
explosions
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

January 9, 2021

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

In a previous post I worked through estimating the airborne quantity of butane due to a leak from a storage sphere. That example stopped at estimating the total quantity released, here I would like to go further into the potential for a vapour cloud explosion.

+

As a recap the scenario is a leak from a butane storage sphere, the leak is 10ft above grade and results in cloud of mostly aerosolized butane that is initially below ambient temperature. The scenario parameters and results are summarized below.

+

As a quick note, the whole purpose of this exercise is a sort of high-level hazard screening. Detailed enough to decide whether or not the consequences of a hazard warrant more detailed modeling.

+
+
using Plots
+using Unitful
+using Interpolations
+using CSV
+using DataFrames
+
+gr()
+
+ft = ustrip(u"m", 1u"ft")     # unit conversion ft->m
+inch = ustrip(u"m", 1u"inch") # unit conversion inch->m
+psi = ustrip(u"Pa", 1u"psi")  # unit conversion psi->Pa
+
+
+
# Scenario parameters
+hᵣ = 10ft       # height of release point, m
+pₐ= 14.7psi     # atmospheric pressure, Pa
+Tₐ= 25 + 273.15 # the ambient temperature, K
+td = 10*60      # release duration, 10 minutes, s
+
+# Constants
+R = 8.31446261815324 # universal gas constant, J/mol/K
+g = 9.806            # acceleration due to gravity, m/s2
+
+# Properties of Butane, from Perry's or DIPPR
+Mw = 58.122    # molar weight kg/kmol
+ρₗ(T) = Mw*( 1.0677/0.27188^(1+ (1-T/425.12)^0.28688) ) # density liquid, kg/m3
+ρg(T) = (pₐ*Mw)/(R*T)/1000 # density gas, ideal gas law, kg/m3
+ΔHc = 2657.320 # heat of combustion, kJ/mol
+LFL = 1.86e-2  # lower flammability limit, vol/vol
+
+# Properties of Air
+MWₐᵢᵣ = 28.960                  # molar weight air, kg/kmol
+ρa(T) = (pₐ*MWₐᵢᵣ)/(R*T)/1000   # density of air, ideal gas law, kg/m^3
+
+# Calculated previously
+Tc = -0.6 + 273.15       # cloud temperature, K
+fᵥ = 0.17128269541302374 # flashed fraction
+fₐ = 0.9227949810754577  # aerosol fraction
+
+Qaq = 52.82002170865257 # airborne quantity, kg/s
+
+

This example focuses on a next step in a standard hazard screening, namely estimating the scale of a potential vapour cloud explosion. Typically, for flammable gases, the potential for a vapour cloud explosion is the worst case outcome of a release.

+
+

Vapour Cloud Dispersion

+

The first step in determining the consequences of a vapour cloud explosion is to estimate the size of the vapour cloud that could take part in the explosion. This is generally done through some sort of dispersion modeling. There are many ways of defining the portion of the vapour cloud that can explode, in this case I am going to assume the flammable portion of the cloud is that with a concentration \(\ge \frac{1}{2} LFL\) .

+

There is a fair bit of discussion in the literature as to whether to use the LFL or 1/2 LFL, using half the LFL is, at the very least, more conservative and given the simplified methods I am using to estimate the size of the cloud it is probably best to err on the side of an overly large cloud.

+
+

Atmospheric Stability and Wind Profile

+

Prior to determining the particular dispersion model some meteorological parameters must be decided upon. Atmospheric stability and the wind profile define the extent of vertical mixing, which governs how large the cloud can grow and how dispersed the butane, in this case, will get during the release. They are also important in the decision criteria for which type of dispersion model to use.

+

In general, the worst case atmospheric stability is the most stable, Pasquill stability class F, for neutral to negatively buoyant clouds at ground level. This limits the degree of mixing and leads to a larger explosive mass, since we define the explosive mass as the portion of the cloud greater than half the lower flammability limit. If the cloud mixes thoroughly with the air it will be dispersed to levels well below the LFL and thus cannot explode

+

For this scenario I am supposing class F stability and a moderate windspeed of 3m/s at the release point.

+

There are two other important wind-speeds that will be needed, the friction velocity, \(u_{\star}\), and the wind-speed at the standard elevation of 10m, \(u_{10}\) which can be obtained from the wind profile. The wind profile can be estimated using a power law distribution parameterized based on the Pasquill stability class.

+

\[ {u \over u_r} = \left( h \over h_r \right)^p \]

+

Where the parameter p is tabulated1 here

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Stabilityurbanrural
A0.150.07
B0.150.07
C0.200.10
D0.250.15
E0.400.35
F0.600.55
+

There are several ways to estimate the friction velocity, but a simple rule of thumb used in the EPA TSCREEN model is to assume

+

\[ u_{\star} = 0.06 u_{10} \]

+

This is a very simplified approach and there are many alternatives to calculating the friction velocity, and parameterizing the atmospheric stability. For the purposes of this simple example this is fine but it is an opportunity for future refinement of the hazard screening.

+
+
# wind profile
+uᵣ = 3.0     # the wind-speed at release height, m/s
+p = 0.55     # parameter, pasquill stability class F
+
+u(h) = uᵣ*(h/hᵣ)^p
+
+u₁₀ = u(10)
+
+u₊ = 0.06 * u₁₀
+
+
0.3459905806850393
+
+
+
+
+

Release Type

+

The first important decision is whether to model the release as a continuous plume or an instantaneous puff. In reality the answer is neither but these limiting cases are easier to model and are used as first approximations.

+

A simple rule of thumb is that if the distance traveled by a parcel of air over the release duration is greater than 2.5 times the distance to the point of interest, then the release can be modeled as continuous, otherwise it would be treated as instantaneous.

+

\[ 2.5 \le { u_r t_d \over x^{\star} } \]

+

Roughly speaking if the plume is still attached to the release point when the leading edge hits the downwind point of interest then it looks like a continuous plume to an observer at this point.

+

This can be used to define a critical distance, such that any distance less than that is best modeled by a continuous release and any distance greater is best modeled by an instantaneous release. This is useful as the downwind distance is, as of yet, unknown. The distance to half the LFL concentration, which defines the extent of the cloud involved in the explosion, depends upon which model is used and is in fact one of the key parameters we need to solve for.

+
+
x⁺ = uᵣ*td/2.5
+
+
720.0
+
+
+

From prior experience, the distance to 1/2 LFL will likely be <~200m and so a continuous release can be assumed. If after performing the calculation the downwind distance turns out to be greater than 720m then this can be re-assessed.

+
+
+

Dense Gas Dispersion

+

The second critical factor for determining which model to use is whether or not the cloud is significantly denser than air. Dense clouds slump and hug the ground to a far greater extent than neutrally buoyant clouds and models for neutral clouds can lead to significant overestimations of the size of the vapour cloud when used on a dense cloud.

+

The relevant parameter for determining if a dense gas dispersion model should be used is the Richardson number, the ratio of the potential energy from the excess density to the kinetic energy from ambient turbulence. The Richardson number for continuous releases is defined as2

+

\[ \mathrm{Ri} = { { g_o V_r } \over { D_c u_{\star} } } \]

+

where

+

\[ g_o = g { {\rho_c - \rho_a } \over \rho_a } \]

+

with \(\rho_c\) the density of the cloud and \(\rho_a\) the density of the ambient air. \(V_r\) is the volumetric release rate, and \(D_c\) a critical distance, in this case we take \(D_c\) to be the release height.

+

The density of the cloud is significantly larger than the vapour density of butane as the cloud has a large fraction of aerosolized droplets, overall the cloud density can be estimated by

+

\[ \frac{1}{\rho_c} = { f_v \over \rho_g } + { \left(1 - f_v \right) f_a \over \rho_l }\]

+

The critical Richardson number is about 50 such that if the Richardson number is greater than 50 then a dense gas model must be used.

+
+
ρc(T) = ((fᵥ/ρg(T)) + ((1-fᵥ)*fₐ/ρₗ(T)))^-1
+
+gₒ = g * ((ρc(Tc) - ρa(Tₐ))/ ρa(Tₐ))
+
+Vr = Qaq/ρc(Tc)
+
+Ri = (gₒ * Vr)/(hᵣ * u₊)
+
+
381.8214520915426
+
+
+
+
Ri > 50
+
+
true
+
+
+

This suggests that a dense gas model should be used, which conforms to our expectations as the vapour cloud is significantly denser than the ambient air.

+

An additional check is to use the criteria from Britter and McQuaid3

+

\[ \left( g_o V_r \over { u_{10}^3 D} \right)^{1/3} \ge 0.15 \]

+

Where \(D\) is a critical distance defined as

+

\[ D = \sqrt{ V_r \over u_{10} } \]

+
+
D = (Vr/u₁₀)
+
+( (gₒ * Vr) / (u₁₀^3 * D) )^(1/3)  0.15
+
+
true
+
+
+
+
+

Britter-McQuaid model

+

The Britter-McQuaid model4 is a dense cloud dispersion model based on dimensional analysis and fitting to experimental data. It is given as a series of correlation curves for six different concentrations and the distance to the concentration of interest is interpolated from these. The concentrations represent a mean concentration over the whole cloud at that distance.

+

This is one of the simpler models to use directly, and is appropriate for a screening case. Dense gas dispersion modeling is a large field with many different models that could be used and, like many things, as the models grow in detail they also grow in the number of parameters that must be provided. For the purposes of screening the limiting factor is often not computing power or model complexity per se as much as the information required to even run the models and the time needed to gather that information.

+

Note the concentrations in this model are given in volume fraction and it is assumed that the in-cloud concentration of butane is 1.0 (i.e 100%) at the release point.

+

The Britter-McQuaid curves can be approximated with a series of piece-wise linear functions5

+

\[ \beta = m \alpha + b \]

+
+
function piecewise(α; αs, ms, bs)
+    i = findnext(x -> x > α, αs, 1)
+    return ms[i]*α + bs[i]
+end
+
+function piecewise(; αs, ms, bs)
+    return α -> piecewise(α, αs=αs, ms=ms, bs=bs)
+end
+
+
+Britter_McQuaid_correlations = Dict{Float64, Function}(
+    0.001 => piecewise(αs=[-0.69, -0.25, -0.13, 1.0],
+                       ms=[0.00, 0.39, 0.00, -0.50],
+                       bs=[2.60, 2.87, 2.77, 2.71]),
+    0.005 => piecewise(αs=[-0.67, -0.28, -0.15, 1.0],
+                       ms=[0.00, 0.59, 0.00, -0.49],
+                       bs=[2.40, 2.80, 2.63, 2.56]),
+    0.010 => piecewise(αs=[-0.70, -0.29, -0.20, 1.0],
+                       ms=[0.00, 0.49, 0.00, -0.52],
+                       bs=[2.25, 2.59, 2.45, 2.35]),
+    0.020 => piecewise(αs=[-0.69, -0.31, -0.16, 1.0],
+                       ms=[0.00, 0.45, 0.00, -0.54],
+                       bs=[2.08, 2.39, 2.25, 2.16]),
+    0.050 => piecewise(αs=[-0.68, -0.29, -0.18, 1.0],
+                       ms=[0.00, 0.36, 0.00, -0.56],
+                       bs=[1.92, 2.16, 2.06, 1.96]),
+    0.100 => piecewise(αs=[-0.55, -0.14, 1.0],
+                       ms=[0.00, 0.24, -0.50],
+                       bs=[1.75, 1.88, 1.78]),
+)
+
+
Dict{Float64,Function} with 6 entries:
+  0.01  => #7
+  0.005 => #7
+  0.02  => #7
+  0.001 => #7
+  0.1   => #7
+  0.05  => #7
+
+
+
+
+
+
+
+ +
+
+Figure 1: The digitized Britter-McQuaid correlation curves. +
+
+
+
+
+

The correlations are given in terms of the parameters α and β, which are

+

\[ \alpha = 0.2 \cdot \log \left( g_o^2 V_r u_{10}^{-5} \right) \]

+

and

+

\[ \beta = \log \left( x \over D \right) \]

+

At first glance it is not obvious how to use these plots, or the associated piecewise functions, since, at least to me, the obvious form of a model is to compute the concentration at a given point whereas the Britter-McQuaid model does the opposite. One supplies a concentration of interest and solves for the downwind distance where the leading edge of the plume hits this concentration.

+

The general procedure is:

+
    +
  1. Compute the parameter α for the given scenario
  2. +
  3. Find the concentration curves that bracket the concentration of interest
  4. +
  5. Calculate β at these two concentrations and interpolate to find β at the concentration of interest
  6. +
  7. Calculate the downwind concentration, x, from β
  8. +
+

The function below is a convenience function that calculates the β for each concentration curve at a fixed α and returns a function that linearly interpolates to find the downwind distance for a given concentration.

+
+
"""
+    britter_mcquaid_model(α, D; table=Britter_McQuaid_correlations)
+
+Generate the interpolation function x(c), using the Britter-McQuaid correlations.
+The system is parameterized by α and D, which are defined in the Britter-McQuaid
+model documentation.
+
+"""
+function britter_mcquaid_model(α, D; table=Britter_McQuaid_correlations)
+    interp_data = [ [conc, bfun(α)] for (conc, bfun) in table ]
+    interp_data = hcat(interp_data...)'
+    interp_data = sortslices(interp_data, dims=1)
+    linterp = LinearInterpolation(interp_data[:,1], interp_data[:,2], extrapolation_bc=Line())
+      
+    return c -> 10^(linterp(c))*D
+end
+
+
britter_mcquaid_model
+
+
+
+
α = 0.2*log10( gₒ^2 * Vr * u₁₀^-5 )
+
+
0.17108241842192004
+
+
+
+
x = britter_mcquaid_model(α, D)
+
+
#11 (generic function with 1 method)
+
+
+

The Britter-McQuaid model assumes an isothermal case and the following correction is suggested for non-isothermal cases

+

\[ C^\prime = { C \over { C + (1-C) \frac{T_a}{T_c} } }\]

+

In this case the concentration of interest is half the lower flammability limit and the cloud is assumed to be well below ambient conditions.

+
+
c = 0.5*LFL
+
+Cᵢ = c / (c + (1 - c)*(Tₐ/Tc) )
+
+
0.008508269826866945
+
+
+

The down-wind distance to half the LFL can then be estimated, and checked to ensure it is within the region for which a continuous release is a reasonable approximation.

+
+
xᵢ = x(Cᵢ)
+
+
165.85001073807788
+
+
+
+
xᵢ  x⁺
+
+
true
+
+
+

The Britter-McQuaid model also has a correlation for short distances, \(x \lt 30D\)

+

\[ C = { { 306 \left( \frac{x}{D} \right)^{-2} } \over { 1 + 306 \left( \frac{x}{D} \right)^{-2} } } \]

+
+
+
+
+
+ +
+
+Figure 2: The Britter-McQuaid concentration curve for the example release. +
+
+
+
+
+

The Britter-McQuaid model does provide some further correlations to calculate the dimensions of the plume, though with many caveats as the model can over-estimate the width of the plume. Also I’ve noticed several sources give an obviously incorrect equation for plume height – it returns heights on the order of a few centimeters for plumes extending hundreds of meters in horizontal directions.

+
+
+
+ +
+
+Figure 3: The Britter-McQuaid plume shape and dimensions. +
+
+
+

Instead of that I am going to use a simple rule-of-thumb for small plumes

+

\[ V_{PES} = 0.03 x_{\frac{1}{2}LFL}^3 \]

+

where \(V_{PES}\) is the volume of the potential explosion site. In general this more than just the volume of a plume dispersing in open space, since buildings and equipment can confine the cloud and create multiple potential explosion sites of various sizes. This is a simple rule-of-thumb for screening purposes only

+
+
Vₚₑₛ = 0.03 * xᵢ^3
+
+
136857.23663150807
+
+
+
+
+
+

Vapour Cloud Explosion

+

There are several parameters that can be estimated to characterize explosions with the positive overpressure perhaps being the most useful for simple screening cases. Tables exist relating different levels of positive overpressure to possible damage of nearby structures.

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
psikPaDamage
0.020.14Annoying noise
0.040.28Loud noise, sonic boom, glass failure
0.151.03Typical pressure for glass breakage
0.42.76Limited minor structural damage
16.9Partial demolition of houses, made uninhabitable
2 - 313.8 - 20.7Concrete or cinder block walls, not reinforced, shattered
320.7Steel framed buildings distorted and pulled away from foundations
427.6Cladding of light industrial buildings ruptured
534.5Wooden utility poles snapped
748.2Loaded train wagon overturned
1068.9Probable total destruction of buildings
+

It’s worth taking a moment to talk briefly about deflagrations and detonations. Deflagrations are characterized by subsonic flame propagation where the reaction zone moves through the flammable vapour by diffusion of heat and mass. Deflagrations typically result in relatively modest overpressures. A detonation, on the other hand, is characterized by a supersonic flame propagation and the reaction zone propagates by a pressure wave compressing the flammable vapour adiabatically to a temperature above the autoignition temperature. Detonations typically have significantly higher overpressures than deflagrations. Typically vapour cloud explosions in an open space with little congestion are deflagrations, however confinement and obstacles in the flame path can can accelerate a subsonic deflagration into a supersonic detonation. This is one reason why confinement and obstacles around a potential explosion site are important in the calculations.

+

The simplest way of calculating the overpressure is the TNT model, where the volume previously defined is used to estimate a potential explosion energy in TNT equivalents and this is compared to blast curves for TNT. For most major vapour cloud explosion incidents the TNT equivalences have been estimated to be from 1-10% of the full energy content of the cloud.

+

This is conceptually simple but can lead to very conservative estimates as vapour clouds, basically, don’t explode like TNT. These methods typically overestimate pressure close to the explosion source and underestimate it far afield. Even though the TNT method is generally not recommended, at least not in any of the references I have, it is still used in some places and it does crop up.

+

A better approach is to use blast curves specifically for VCEs, in this case I am using the Baker-Strehlow-Tang curves but there are others.

+
+

Explosive Energy

+

There are several ways of estimating explosive energy and all depend, in some way, on the size of the vapour cloud. Supposing the vapour cloud explosion is a deflagration and the energy in the explosion, fundamentally, comes from the combustion of the butane in the cloud then the energy can be found by estimating how much butane will combust and multiplying that by the heat of combustion of butane.

+

In some references the entire volume of the cloud will be assumed butane, but that can be excessively conservative – we are assuming the edge of the cloud to be 1/2 LFL or ~0.93% (v/v) butane so assuming it to be 100% butane in that region is a serious over-estimate.

+

An alternative method6 is to assume the cloud overall is at stoichiometric conditions. That is find the value of the stoichiometric concentration \(\eta\)

+

\[ \eta = { \textrm{moles fuel} \over \textrm{moles fuel + air} } = { n_f \over { n_f + \frac{n_o}{f_o} } } \]

+

where \(n_f\) is the moles of fuel, \(n_o\) the moles of oxygen, and \(f_o\) the mole fraction of oxygen in air, 20.946%. For the combustion of n-butane

+

\[ C_{4} H_{10} + \frac{13}{2} O_{2} \longrightarrow 4 C O_{2} + 5 H_{2} O\]

+

and so for \(n_f = 1\) we have \(n_o = 6.5\)

+
+
η = 1 / (1 + 6.5/0.20946)
+
+
0.031218607756809045
+
+
+

Using the ideal gas law the total moles of gas in the cloud can be estimated

+

\[ n_c = { { p_a V_{PES} } \over { R T_c } }\]

+

and the explosive energy is then

+

\[ E_{PES} = \eta n_c \Delta H_c \]

+
+
nc = ( pₐ * Vₚₑₛ )/(R * Tc)
+
+Eₚₑₛ = η * nc * ΔHc
+
+
5.0778644110258764e8
+
+
+
+
+

The BST model

+

The Baker-Strehlow-Tang model provides a series of correlation curves for different flame speeds and relates the positive overpressure to an energy scaled distance. The curves are based on spherical explosions and so it is important to include ground reflection when calculating the scaled distance.

+

The positive overpressure used in the BST model is a dimensionless pressure

+

\[ P = { { p - p_a } \over p_a } \]

+

where \(p\) is the positive overpressure and \(p_a\) the atmospheric pressure. This is correlated to the scaled distance

+

\[ R = r \cdot \left( p_a \over E \right)^{1/3} \]

+

where \(E\) is the explosive energy and \(r\) the distance from the explosion epicentre. Which in this case we can take as the centre of the cloud and estimate to be half-way to \(x_i\)

+

A spreadsheet with the BST curves is provided along with the AIChE/CCPS Guidelines for Chemical Process Quantitative Risk Analysis7 from which I’ve extracted just the positive overpressure curves as a csv.

+
+
bst_curves = CSV.read("data/BST-curves.csv", DataFrame)
+
+# just show the first 5 rows
+first(bst_curves, 5)
+
+

5 rows × 3 columns

MfScaled DistanceOverpressure
Float64Float64Float64
10.0370.0101790.0100961
20.0370.01048670.0100271
30.0370.01080270.0100993
40.0370.01112870.0101009
50.0370.01146460.0101025
+
+
+
+
+
+
+
+ +
+
+Figure 4: The Baker-Strehlow-Tank overpressure curves +
+
+
+
+
+

The following table8 cross-references flame speed – the key parameter of the BST curves – with qualitative descriptors of fuel reactivity, density of surrounding process equipment, and degree of confinement

+

+

The dimensionality given is referencing the number of dimensions along which the pressure wave can travel. For example an explosion confined to a tunnel is considered one dimensional since the pressure wave can only travel along the tunnel, whereas an explosion in an open field is three dimensional since it can expand in all directions. The entries labeled DDT are where a deflagration to detonation transition may occur, due to the flame speed and potential congestion. In these cases it is recommended to use the \(Mf = 5.2\) curve, sometimes referred to as the detonation blast curve.

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
DimensionDescription
3-DUnconfined volume, almost completely free expansion
2.5-DBlockage partially prevents flame in one direction, such as piperacks with tightly packed pipes, lightweight roofs, or frangible panels
2-DPlatforms carrying process equipment, space beneath cars, open sided multistory buildings
1-DTunnels, corridors, or sewage systems
+

For the storage sphere I am assuming it is standing freely on its own without any platforms or structures confining it, so the dimension is 3-D

+

The density of surrounding equipment is defined qualitatively in terms of how much the surrounding area obstructs the expansion of the pressure wave. This can be defined in terms of the percentage of area in a plane occupied by obstacles.

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
TypeBlockage RatioPitch for Obstacle Layers
Low< 10%One or two layers of obstacles
Medium10 - 40%Two to three layers of obstacles
High> 40%Three of more fairly closely spaced obstacle layers
+

For the storage sphere suppose that there is some process equipment nearby but it is not highly confined, defaulting to medium.

+

The fuel reactivity categories are defined in terms of the laminar burning velocity

+ + + + + + + + + + + + + + + + + + + + + +
ReactivityLaminar Burning Velocity
Low< 45 cm/s
Medium45 - 75 cm/s
High> 75 cm/s
+

The best resource for finding these tabulated is Appendix D of NFPA 68.9 For butane the laminar burning velocity is 45cm/s10 and thus is medium reactivity

+

10 NFPA 68 Table D.1(a).

Returning to the table we find that the flame speed for a medium reactivity fuel, medium obstacle density, 3D case is 0.44 (in terms of Mach number)

+
+
Mf = 0.44
+
+
0.44
+
+
+

Note the flame speeds given in the table do not correspond to the flame speeds given in the BST curves. In general one will have to double-interpolate to get the results. Find the curves that bracket the desired flame speed, interpolate to find the corresponding pair of overpressures at the given scaled distance then interpolate to find the overpressure at the desired flame speed.

+

The following code sets this up in an easy way, though probably a very sub-optimal one, by doing the following:

+
    +
  1. An interpolation function is created for each flame speed, these are stored in bst_interps
  2. +
  3. The function Δp₊ calculates the positive overpressure by stepping through the array bst_interps and calculating the overpressures at each tabulated flame speed for a given distance, then interpolates for the desired flame speed
  4. +
+
+
bst_interps = []
+flame_speeds = unique(bst_curves[!, "Mf"])
+
+for speed in flame_speeds
+    data = bst_curves[ bst_curves."Mf" .== speed, :]
+    interp = LinearInterpolation(data[!, "Scaled Distance"], 
+                                 data[!, "Overpressure"], 
+                                 extrapolation_bc=Line())
+    push!(bst_interps, (speed, interp))
+end
+
+
+
"""
+    Δp₊(r ; Mf=Mf, E=2*Eₚₑₛ, p₀=pₐ, curves=bst_interps)
+
+Calculate the positive overpressure at distance r from the explosion epicentre.
+The model parameters are the apparent flame speed, Mf, in terms of Mach number,
+the explosion energy, E, and the atmospheric pressure p₀. The units of E and p₀
+must agree, e.g. kJ and kPa.
+
+The Baker-Strehlow-Tang curves are supplied through the curves keyword.
+"""
+function Δp₊(r ; Mf=Mf, E=2*Eₚₑₛ, p₀=pₐ, curves=bst_interps)
+    E = 1000*E # Energy must be in J
+    R = r*(p₀/E)^(1/3)
+    
+    Mfs = []
+    Ps = []
+    
+    for (speed, interp) in bst_interps
+        push!(Mfs, speed)
+        push!(Ps, interp(R))
+    end
+    
+    P = LinearInterpolation(Mfs, Ps)
+    
+    return P(Mf)*p₀
+    
+end
+
+
Δp₊
+
+
+

This generates a new blast curve interpolated between the curves supplied with the BST model, as shown in the plot below. Though care should be taken when the curve is used outside the range of the original curves.

+

Note that the explosion energy is being multiplied by 2. This is to account for ground reflection as the BST curves are based on spherical explosions. In general the explosion energy is multiplied by a factor, which ranges from 1 to 2, to account for ground reflection where a factor of 2 is for explosions exactly at ground level and a factor of 1 is for explosions at significant elevation. The simpler and more conservative approach is to use a factor of 2.

+
+
+
+
+
+ +
+
+Figure 5: The Baker-Strehlow-Tank overpressure curves with the current scenario indicated. +
+
+
+
+
+

The explosion epicentre is assumed to be the centre of the vapour cloud, here approximated to be half way between the release point and the downwind distance to 1/2 LFL, and in this simple model the explosion is a hemispherical pressure wave expanding in all directions unobstructed. More advanced modeling will take into account buildings and equipment and their impact on shaping the pressure wave.

+

The plot below shows the maximum positive overpressure experienced at that distance. Which is what is typically tabulated for different types of consequences. In general when I refer to overpressure this is what I am referring to.

+
+
+
+
+
+ +
+
+Figure 6: The overpressure contours for the given example, showing the maximum overpressure experienced at the location. +
+
+
+
+
+
+
+

Sensitivity

+

A useful question to ask at this point is how sensitive is the predicted overpressure to the parameters of the model.

+

The figure below shows the impact of varying reactivity, while holding all other parameters constant. Clearly whether or not the explosion is treated as a detonation or deflagration matters hugely, the high reactivity curve corresponds to a detonation. The difference between a medium and low reactivity material is a approximately a factor of 4 in terms of max overpressure. So finding an appropriate value of reactivity while not being overly conservative is important.

+
+
+
+
+
+ +
+
+Figure 7: The sensitivity of the overpressure curve to reactivity. +
+
+
+
+
+

The following figure shows the impact of varying the levels of congestion. There is a fair amount of sensitivity going from low to medium but less from medium to high, which is perhaps what you would expect. I think the somewhat strange shape of the peaks is an artifact of linear interpolation.

+
+
+
+
+
+ +
+
+Figure 8: The sensitivity of the overpressure curve to levels of congestion. +
+
+
+
+
+

As is clear from the figure below the results are much less sensitive to changes in the level of confinement. At least while neglecting the one dimensional case, which should always be treated as a special case regardless.

+
+
+
+
+
+ +
+
+Figure 9: The sensitivity of the overpressure curve to levels of confinement. +
+
+
+
+
+

The last parameter worth investigating is the explosive energy, a significant portion of this exercise was in estimating the size of the vapour cloud and the consequent explosive energy. As is clear from the following figure the model is much less sensitive to changes in the explosive energy than the other parameters.

+
+
+
+
+
+ +
+
+Figure 10: The sensitivity of the overpressure curve to +/- 50% change in explosive energy +
+
+
+
+
+
+
+
+

References

+
+
+AIChE/CCPS. Guidelines for Chemical Process Quantitative Risk Analysis. 2nd ed. New York: American Institute of Chemical Engineers, 2000. +
+
+———. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999. +
+
+———. Guidelines for Use of Vapour Cloud Dispersion Models, 2nd Ed. New York: American Institute of Chemical Engineers, 1996. +
+
+———. Guidelines for Vapour Cloud Explosion, Pressure Vessel Burst, BLEVE and Flash Fire Hazards. 2nd ed. New York: American Institute of Chemical Engineers, 2000. +
+
+Britter, Rex E., and J. McQuaid. “Workbook on the Dispersion of Dense Gases. HSE Contract Research Report No. 17/1988,” 1988. +
+
+NFPA 68: Standard on Explosion Protection by Deflagration Venting. Boston, MA: National Fire Protection Association, 2018. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/vapour_cloud_explosion_example/index_files/figure-html/cell-29-1-1b77966f-9017-41be-9ec9-db0110d00730.png b/posts/vapour_cloud_explosion_example/index_files/figure-html/cell-29-1-1b77966f-9017-41be-9ec9-db0110d00730.png new file mode 100644 index 0000000..b5ab3a8 Binary files /dev/null and b/posts/vapour_cloud_explosion_example/index_files/figure-html/cell-29-1-1b77966f-9017-41be-9ec9-db0110d00730.png differ diff --git a/posts/vapour_cloud_explosion_example/index_files/figure-html/cell-39-1-621f59ba-b505-4b67-b5d0-2ab3182b34bc.png b/posts/vapour_cloud_explosion_example/index_files/figure-html/cell-39-1-621f59ba-b505-4b67-b5d0-2ab3182b34bc.png new file mode 100644 index 0000000..35def91 Binary files /dev/null and b/posts/vapour_cloud_explosion_example/index_files/figure-html/cell-39-1-621f59ba-b505-4b67-b5d0-2ab3182b34bc.png differ diff --git a/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-blast-contours-output-1.svg b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-blast-contours-output-1.svg new file mode 100644 index 0000000..9ef2ab9 --- /dev/null +++ b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-blast-contours-output-1.svg @@ -0,0 +1,524 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-bst-scn-output-1.svg b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-bst-scn-output-1.svg new file mode 100644 index 0000000..207a069 --- /dev/null +++ b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-bst-scn-output-1.svg @@ -0,0 +1,337 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-concentrations-output-1.svg b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-concentrations-output-1.svg new file mode 100644 index 0000000..b510c91 --- /dev/null +++ b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-concentrations-output-1.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-curves-output-1.svg b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-curves-output-1.svg new file mode 100644 index 0000000..dbd2283 --- /dev/null +++ b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-curves-output-1.svg @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-overpress-output-1.svg b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-overpress-output-1.svg new file mode 100644 index 0000000..ef0934e --- /dev/null +++ b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-overpress-output-1.svg @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-sens-conf-output-1.svg b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-sens-conf-output-1.svg new file mode 100644 index 0000000..ed13e2d --- /dev/null +++ b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-sens-conf-output-1.svg @@ -0,0 +1,282 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-sens-cong-output-1.svg b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-sens-cong-output-1.svg new file mode 100644 index 0000000..5dd6e4c --- /dev/null +++ b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-sens-cong-output-1.svg @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-sens-expl-output-1.svg b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-sens-expl-output-1.svg new file mode 100644 index 0000000..de617a8 --- /dev/null +++ b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-sens-expl-output-1.svg @@ -0,0 +1,264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-sens-react-output-1.svg b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-sens-react-output-1.svg new file mode 100644 index 0000000..13bf8cf --- /dev/null +++ b/posts/vapour_cloud_explosion_example/index_files/figure-html/fig-sens-react-output-1.svg @@ -0,0 +1,521 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_dispersion/dispersion.png b/posts/vessel_blowdown_dispersion/dispersion.png new file mode 100644 index 0000000..171934f Binary files /dev/null and b/posts/vessel_blowdown_dispersion/dispersion.png differ diff --git a/posts/vessel_blowdown_dispersion/index.html b/posts/vessel_blowdown_dispersion/index.html new file mode 100644 index 0000000..98b2510 --- /dev/null +++ b/posts/vessel_blowdown_dispersion/index.html @@ -0,0 +1,1268 @@ + + + + + + + + + + + + +Vessel Blowdown and Dispersion – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Vessel Blowdown and Dispersion

+
+
+ Considering the Gaussian dispersion of an isothermal blowdown case. +
+
+
+
julia
+
blowdown
+
dispersion modelling
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

December 23, 2025

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

I was thinking, recently, about how venting from vessel blowdown is modelled for screening purposes and how, more often than not, it does not take into account the blowdown curve. This is something that could easily be incorporated for simple Gaussian dispersion, which is what I examine here.

+
+

Background

+

The standard approach for assessing the consequences of a release from a pressure vessel is to:1

+
    +
  1. Identify the source model (gas, liquid, aerosol)
  2. +
  3. Calculate the mass release rate
  4. +
  5. Model the dispersion of the release
  6. +
+

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.

+

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.

+
+

Isothermal Blowdown

+

Recalling the isothermal blowdown of an ideal gas, the mass release rate, \(w\), is given by

+

\[ +w\left(t\right) = w_0 \exp \left( - \frac{t}{\tau} \right) +\]

+

Where4

+

4 This follows from the definition of \(\tau\): \[ \tau = {m_0 \over w_0} \] \[ w_0 = {m_0 \over \tau} \] \[ w_0 = { {\rho_0 V} \over \tau }\]

\[ +w_0 = { {\rho_0 V} \over \tau } +\]

+

For a blowdown through an isentropic nozzle the time constant \(\tau\) is given by

+

\[ +\frac{1}{\tau} = \frac{c_D A}{V} \sqrt{ {k P_0} \over \rho_0 } \left( 2 \over {k+1} \right)^{\frac{k+1}{2 \left( k - 1 \right)} } +\]

+

With:

+
    +
  • \(c_D\) – the discharge coefficient for the blowdown
  • +
  • \(A\) – the flow area of the orifice through which the blowdown is happening (e.g. a PSV)
  • +
  • \(V\) – the total volume of the vessel
  • +
  • \(k\) – the isentropic expansion factor, which for an ideal gas is the ratio of specific heats \(\frac{c_p}{c_v}\)
  • +
  • \(P_0\) – the initial pressure in the vessel
  • +
  • \(\rho_0\) – the initial density of the gas in the vessel
  • +
+
+
+

The Single Puff Model

+

For a release centred at the origin with an elevation h, the concentration profile for a single Gaussian puff is given by:5

+

\[ +c \left(x,y,z,t \right) = w \Delta t \cdot g_x(x, t) \cdot g_y(y) \cdot g_z(z) +\]

+

Where the gs are Gaussian functions in the x, y, and z directions

+

\[ +g_x(x,t) = {1 \over \sqrt{2\pi} \sigma_x } \exp \left( -\frac{1}{2} \left( x-u t \over \sigma_x \right)^2 \right) +\]

+
+
gx(x,t,u,σx) = exp(-0.5*((x-u*t)/σx)^2)/((2π)*σx)
+
+

\[ +g_y(y) = {1 \over \sqrt{2\pi} \sigma_y } \exp \left( -\frac{1}{2} \left( y \over \sigma_y \right)^2 \right) +\]

+
+
gy(y,σy) = exp(-0.5*(y/σy)^2)/((2π)*σy)
+
+

\[ +g_z(z) = {1 \over \sqrt{2\pi} \sigma_z } \left[ \exp \left( -\frac{1}{2} \left( z-h \over \sigma_z \right)^2 \right) + \exp \left( -\frac{1}{2} \left( z+h \over \sigma_z \right)^2 \right) \right] +\]

+
+
gz(z,h,σz) = (  exp(-0.5*((z-h)/σz)^2)
+              + exp(-0.5*((z+h)/σz)^2))/((2π)*σz)
+
+

With:

+
    +
  • \(w\) – the constant mass release rate
  • +
  • \(\Delta t\) – the duration of the release
  • +
  • \(u\) – the uniform windspeed (acting only in the x direction)
  • +
  • \(\sigma\)s – the dispersion parameters.
  • +
+

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.

+
+
# 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.70
+
+

I 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
+end
+
+

Now 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
+end
+
+
+
+

The Multi-Puff Model

+

The 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.

+

\[ +c(x,y,z,t) = \sum_{i=0}^{n} w\left( t_i \right) \delta t \cdot g_x(x, t - t_i ) \cdot g_y(y) \cdot g_z(z) +\]

+

Where \(\delta t\) is the duration of each puff and \(t_i\) 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 \(\delta t \to 0\) takes this from a discrete sum to the corresponding integral

+

\[ +c(x,y,z,t) = \int_{0}^{t} w\left( t^{\prime} \right) \cdot g_x(x, t - t^{\prime}) \cdot g_y(y) \cdot g_z(z) dt^{\prime} +\]

+

For the Palazzi7 model \(w(t) = w_0 \left( H\left(t - \Delta t \right) - H\left( t \right) \right)\)8 and, assuming the \(\sigma\)s are independent of time, this can be integrated to give:

+

\[ +c(x,y,z,t) = \frac{w_0}{2u} \left( \mathrm{erf}\left({ {x - u (t-\Delta t)} \over \sqrt{2} \sigma_x }\right) - \mathrm{erf}\left( { {x - u t} \over \sqrt{2} \sigma_x } \right) \right) \cdot g_y(y) \cdot g_z(z) +\]

+
+
using SpecialFunctions: erf, erfc
+
+
+
struct Palazzi
+    w   # mass release rate
+    h   # release height
+    u   # velocity
+    t_f # end of release
+end
+
+
+
function 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))
+end
+
+
+
+
+

A Blowdown Dispersion Model

+

It should be pretty obvious where I am going next: instead of assuming \(w(t)\) 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.

+

\[ +c(x,y,z,t) = \int_{0}^{t} w\left( t^{\prime} \right) \cdot g_x(x, t - t^{\prime}) \cdot g_y(y) \cdot g_z(z) dt^{\prime} +\]

+

\[ +c(x,y,z,t) = \int_{0}^{t} \left[ w_0 \exp\left( -{t^{\prime} \over \tau} \right) \right] \cdot \left[ {1 \over \sqrt{2\pi} \sigma_x } \exp \left( -\frac{1}{2} \left( x-u (t - t^{\prime}) \over \sigma_x \right)^2 \right) \right] \cdot g_y(y) \cdot g_z(z) dt^{\prime} +\]

+

Splitting this into elements that depend on time and those that don’t \[ +c(x,y,z,t) = w_0 \left[ \int_{0}^{t} {1 \over \sqrt{2\pi} \sigma_x } \exp\left( -{t^{\prime} \over \tau} -\frac{1}{2} \left( x-u (t - t^{\prime}) \over \sigma_x \right)^2 \right) dt^{\prime} \right] \cdot g_y(y) \cdot g_z(z) +\]

+

Letting everything within the integral equal \(I(x,t)\)

+

\[ +c(x,y,z,t) = w_0 \cdot I(x,t) \cdot g_y(y) \cdot g_z(z) +\]

+

It makes the integration a little easier to introduce \(\lambda = t-t^{\prime}\)

+

\[ +I(x,t) = \int_{0}^{t} {1 \over \sqrt{2\pi} \sigma_x } \exp\left( -{{t - \lambda} \over \tau} -\frac{1}{2} \left( x-u \lambda \over \sigma_x \right)^2 \right) d\lambda +\]

+

By expanding everything within the \(\exp(\dots)\), collecting terms and completing the square we arrive at:

+

\[ +I(x,t) = {1 \over \sqrt{2\pi} \sigma_x } \exp \left( {\sigma_x^2 + 2 u \tau (x - u t)} \over {2 u^2 \tau^2} \right) \int_{0}^{t} \exp\left( -\left( {\sigma_x^2 + u \tau ( x - u \lambda ) } \over {\sqrt{2} \sigma_x u \tau} \right)^2 \right) d\lambda +\]

+

\[ +I(x,t) = \frac{1}{2u} \exp \left( {\sigma_x^2 + 2 u \tau (x - u t)} \over {2 u^2 \tau^2} \right) \left[ \mathrm{erf}\left( {\sigma_x^2 + u \tau x} \over {\sqrt{2} \sigma_x u \tau} \right) - \mathrm{erf}\left( {\sigma_x^2 + u \tau (x - u t)} \over {\sqrt{2} \sigma_x u \tau} \right)\right] +\]

+

If we evaluate the \(\sigma_x\)s at the end points then, given that \(\sigma_x \to 0\) as \(x_c \to 0\), this simplifies to:

+

\[ +I(x,t) = \frac{1}{2u} \exp \left( {\sigma_x^2 + 2 u \tau (x - u t)} \over {2 u^2 \tau^2} \right) \mathrm{erfc}\left( {\sigma_x^2 + u \tau (x - u t)} \over {\sqrt{2} \sigma_x u \tau} \right) +\]

+

Giving a final concentration of:

+

\[ +c(x,y,z,t) = \frac{w_0}{2u} \exp \left( {\sigma_x^2 + 2 u \tau (x - u t)} \over {2 u^2 \tau^2} \right) \mathrm{erfc}\left( {\sigma_x^2 + u \tau (x - u t)} \over {\sqrt{2} \sigma_x u \tau} \right) \cdot g_y(y) \cdot g_z(z) +\]

+
+
struct IsothermalBlowdown
+    w_0   # mass release rate
+    τ     # time constant
+    h     # release height
+    u     # velocity
+    t_f   # end of release
+end
+
+
+
function 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))
+end
+
+
+
+
+ +
+
+Note +
+
+
+

Note that I have implemented a slightly different version of the model. In the case where \(t < t_f\), with \(t_f\) being the time at which the blowdown ceases, this simplifies to the model given above, where I implicitly assumed \(t_f \to \infty\).

+

In the case where \(t_f\) is some finite number and \(t \ge t_f\), an extra term is added to, essentially, “turn off” the blowdown.

+
+
+
+

An Example Case

+

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) = \int_0^t w_0 \exp \left( -\frac{t^{\prime}}{\tau} \right) d t^{\prime} +\]

+

\[ +m(t) = w_0 \tau \left( 1 - \exp \left( -\frac{t^{\prime}}{\tau} \right) \right) +\]

+
+
m(t) = w₀*τ*(1 - exp(-t/τ))
+
+
+
+

Discrete Puffs

+

Suppose that after \(\tau\) 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 \([ 0, \tau )\) into \(n\) sub-intervals and releasing a single puff at the start of each interval i with a mass \(m_i = w(t_i) \delta t\).

+
+
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
+end
+
+
+
pfs = discrete_puffs(n=25);
+
+
+
+
+
+ +
+
+Figure 1: The release rate for an isothermal vessel blowdown, along with the sequence of discrete puffs generated to approximate it. +
+
+
+
+

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%.

+
+
+
+
+

Comparing Results

+

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 \(\sigma\)s are constant (they aren’t) and integrated that. I then took that result and substituted back in the correlations for the \(\sigma\)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 \(w = w_0\) and the case with a constant mass rate \(w = w(\tau)\). Furthermore, we expect the blowdown case should connect the two curves with something resembling an exponential decay.

+
+
bd = IsothermalBlowdown(w₀,τ,h,u,τ)
+
+
+
+
+
+ +
+
+Figure 2: The concentration profile at x=1000m, y=0m, z=2m. +
+
+
+
+

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.

+
+
+
+
+ +
+
+Figure 3: The ground level concentration for the isothermal blowdown. +
+
+
+
+

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.

+
+
+
+

A Note on Sources

+

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.

+
+
+

References

+
+
+Center for Chemical Process Safety. Guidelines for Consequence Analysis of Chemical Releases. New York: Center for Chemical Process Safety/AIChE, 1999. +
+
+Palazzi, E, M De Faveri, Giuseppe Fumarola, and G Ferraiolo. “Diffusion from a Steady Source of Short Duration.” Atmospheric Environment 16, no. 12 (1982): 2785–90. https://www.researchgate.net/publication/328744668_DIFFUSION_FROM_A_STEADY_SOURCE_OF_SHORT_DURATION. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/vessel_blowdown_dispersion/index_files/figure-html/fig-puffs-1-output-1.svg b/posts/vessel_blowdown_dispersion/index_files/figure-html/fig-puffs-1-output-1.svg new file mode 100644 index 0000000..8f5b53b --- /dev/null +++ b/posts/vessel_blowdown_dispersion/index_files/figure-html/fig-puffs-1-output-1.svg @@ -0,0 +1,658 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_dispersion/index_files/figure-html/fig-puffs-2-output-1.svg b/posts/vessel_blowdown_dispersion/index_files/figure-html/fig-puffs-2-output-1.svg new file mode 100644 index 0000000..7ef8b9a --- /dev/null +++ b/posts/vessel_blowdown_dispersion/index_files/figure-html/fig-puffs-2-output-1.svg @@ -0,0 +1,515 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_dispersion/post.html b/posts/vessel_blowdown_dispersion/post.html new file mode 100644 index 0000000..e6ed61b --- /dev/null +++ b/posts/vessel_blowdown_dispersion/post.html @@ -0,0 +1,1195 @@ + + + + + + + + + + + + +Vessel Blowdown and Dispersion – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Vessel Blowdown and Dispersion

+
+
+ Considering the Gaussian dispersion of an isothermal blowdown case. +
+
+
+
julia
+
blowdown
+
dispersion modelling
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

December 6, 2025

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

I was thinking, recently, about how venting from vessel blowdown is modelled for screening purposes and how, more often than not, it does not take into account the blowdown curve. This is something that could easily be incorporated for simple Gaussian dispersion, which is what I examine here.

+
+

Background

+

The standard approach for assessing the consequences of a release from a pressure vessel is to:1

+
    +
  1. Identify the source model (gas, liquid, aerosol)
  2. +
  3. Calculate the mass release rate
  4. +
  5. Model the dispersion of the release
  6. +
+

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 The standard approach in most modelling tools (e.g. SLAB, DEGADIS, ALOHA) is to assume the release rate is constant. Though references do acknowledge that the flow will decrease over time, this is typically not taken into account. 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 for the purposes of dispersion modelling.

+

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.

+
+

Isothermal Blowdown

+

Recalling the isothermal blowdown of an ideal gas, the mass release rate, \(w\), is given by

+

\[ +w\left(t\right) = w_0 \exp \left( - \frac{t}{\tau} \right) +\]

+

Where4

+

4 This follows from the definition of \(\tau\): \[ \tau = {m_0 \over w_0} \] \[ w_0 = {m_0 \over \tau} \] \[ w_0 = { {\rho_0 V} \over \tau }\]

\[ +w_0 = { {\rho_0 V} \over \tau } +\]

+

with a time constant \(\tau\) given by

+

\[ +\frac{1}{\tau} = \frac{c_D A}{V} \sqrt{ {k P_0} \over \rho_0 } \left( 2 \over {k+1} \right)^{\frac{k+1}{2 \left( k - 1 \right)} } +\]

+

With:

+
    +
  • \(c_D\) – the discharge coefficient for the blowdown
  • +
  • \(A\) – the flow area of the orifice through which the blowdown is happening (e.g. a PSV)
  • +
  • \(V\) – the total volume of the vessel
  • +
  • \(k\) – the isentropic expansion factor, which for an ideal gas is the ratio of specific heats \(\frac{c_p}{c_v}\)
  • +
  • \(P_0\) – the initial pressure in the vessel
  • +
  • \(\rho_0\) – the initial density of the gas in the vessel
  • +
+
+
+

The Single Puff Model

+

For a release centred at the origin with an elevation h, the concentration profile for a single Gaussian puff is given by:5

+

\[ +c \left(x,y,z,t \right) = w \Delta t \cdot g_x(x, t) \cdot g_y(y) \cdot g_z(z) +\]

+

Where the gs are Gaussian functions in the x, y, and z directions

+

\[ +g_x(x,t) = {1 \over \sqrt{2\pi} \sigma_x } \exp \left( -\frac{1}{2} \left( x-u t \over \sigma_x \right)^2 \right) +\]

+
+
gx(x,t,u,σx) = exp(-((x-u*t)/σx)^2/2)/((2)*π*σx)
+
+

\[ +g_y(y) = {1 \over \sqrt{2\pi} \sigma_y } \exp \left( -\frac{1}{2} \left( y \over \sigma_y \right)^2 \right) +\]

+
+
gy(y,σy) = exp(-(y/σy)^2/2)/((2)*π*σy)
+
+

\[ +g_z(z) = {1 \over \sqrt{2\pi} \sigma_z } \left[ \exp \left( -\frac{1}{2} \left( z-h \over \sigma_z \right)^2 \right) + \exp \left( -\frac{1}{2} \left( z+h \over \sigma_z \right)^2 \right) \right] +\]

+
+
gz(z,h,σz) = (  exp(-((z-h)/σz)^2/2)
+              + exp(-((z+h)/σz)^2/2))/((2)*π*σz)
+
+

With:

+
    +
  • \(w\) – the constant mass release rate
  • +
  • \(\Delta t\) – the duration of the release
  • +
  • \(u\) – the uniform windspeed (acting only in the x direction)
  • +
  • \(\sigma\)s – the dispersion parameters.
  • +
+
+
# 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.70
+
+
+
struct Puff{F}
+    m::F # mass
+    h::F # release height
+    u::F # velocity
+    t::F # release time
+    c_max::F # max concentration
+end
+
+Puff(m,h,u,t,c_max) = Puff(promote(m,h,u,t,c_max)...)
+
+
+
function c(p::Puff,x,y,z,t)
+    λ  = t - p.t # time since release
+    xc = p.u*λ   # location of cloud center
+    if λ < 0 # the puff hasn't been released yet
+        return 0.0
+    else
+        c = p.m*gx(x,λ,p.u,σx(xc))*gy(y,σy(xc))*gz(z,h,σz(xc))
+        return isnan(c) ? 0.0 : min(c,p.c_max)
+    end
+end
+
+
+
+

The Multi-Puff Model

+

The 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.

+

\[ +c(x,y,z,t) = \sum_{i=0}^{n} w\left( t_i \right) \delta t \cdot g_x(x, t - t_i ) \cdot g_y(y) \cdot g_z(z) +\]

+

Where \(\delta t\) is the duration of each puff and \(t_i\) 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 \(\delta t \to 0\) takes this from a discrete sum to the corresponding integral

+

\[ +c(x,y,z,t) = \int_{0}^{t} w\left( t^{\prime} \right) \cdot g_x(x, t - t^{\prime}) \cdot g_y(y) \cdot g_z(z) dt^{\prime} +\]

+

For the Palazzi6 model \(w(t) = w_0 \left( H\left(t - \Delta t \right) - H\left( t \right) \right)\)7 and, assuming the \(\sigma\)s are independent of time, this can be integrated to give:

+

\[ +c(x,y,z,t) = \frac{w_0}{2u} \left( \mathrm{erf}\left({ {x - u (t-\Delta t)} \over \sqrt{2} \sigma_x }\right) - \mathrm{erf}\left( { {x - u t} \over \sqrt{2} \sigma_x } \right) \right) \cdot g_y(y) \cdot g_z(z) +\]

+
+
+
+

A Blowdown Dispersion Model

+

It should be pretty obvious where I am going next: instead of assuming \(w(t)\) 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.

+

\[ +c(x,y,z,t) = \int_{0}^{t} w\left( t^{\prime} \right) \cdot g_x(x, t - t^{\prime}) \cdot g_y(y) \cdot g_z(z) dt^{\prime} +\]

+

\[ +c(x,y,z,t) = \int_{0}^{t} \left[ w_0 \exp\left( -{t^{\prime} \over \tau} \right) \right] \cdot \left[ {1 \over \sqrt{2\pi} \sigma_x } \exp \left( -\frac{1}{2} \left( x-u (t - t^{\prime}) \over \sigma_x \right)^2 \right) \right] \cdot g_y(y) \cdot g_z(z) dt^{\prime} +\]

+

Splitting this into elements that depend on time and those that don’t \[ +c(x,y,z,t) = w_0 \left[ \int_{0}^{t} {1 \over \sqrt{2\pi} \sigma_x } \exp\left( -{t^{\prime} \over \tau} -\frac{1}{2} \left( x-u (t - t^{\prime}) \over \sigma_x \right)^2 \right) dt^{\prime} \right] \cdot g_y(y) \cdot g_z(z) +\]

+

Letting everything within the integral equal \(I(x,t)\)

+

\[ +c(x,y,z,t) = w_0 \cdot I(x,t) \cdot g_y(y) \cdot g_z(z) +\]

+

It makes the integration a little easier to introduce \(\lambda = t-t^{\prime}\)

+

\[ +I(x,t) = \int_{0}^{t} {1 \over \sqrt{2\pi} \sigma_x } \exp\left( -{{t - \lambda} \over \tau} -\frac{1}{2} \left( x-u \lambda \over \sigma_x \right)^2 \right) d\lambda +\]

+

By expanding everything within the \(\exp(\dots)\), collecting terms and completing the square we arrive at:

+

\[ +I(x,t) = {1 \over \sqrt{2\pi} \sigma_x } \exp \left( {\sigma_x^2 + 2 u \tau (x - u t)} \over {2 u^2 \tau^2} \right) \int_{0}^{t} \exp\left( -\left( {\sigma_x^2 + u \tau ( x - u \lambda ) } \over {\sqrt{2} \sigma_x u \tau} \right)^2 \right) d\lambda +\]

+

\[ +I(x,t) = \frac{1}{2u} \exp \left( {\sigma_x^2 + 2 u \tau (x - u t)} \over {2 u^2 \tau^2} \right) \left[ \mathrm{erf}\left( {\sigma_x^2 + u \tau x} \over {\sqrt{2} \sigma_x u \tau} \right) - \mathrm{erf}\left( {\sigma_x^2 + u \tau (x - u t)} \over {\sqrt{2} \sigma_x u \tau} \right)\right] +\]

+

This has the same limitations as the Palazzi model, namely that it assumes the dispersion parameters \(\sigma_x\), \(\sigma_y\), and \(\sigma_z\) are constant. This is perhaps more thoroughly violated here than in a short duration release with a constant emission rate because the blowdown never really ceases. The plume could extend out arbitrarily far from the emission source, where clearly the assumption of constant dispersion parameters would be strongly violated. However, the contribution from the later parts of the release become exponentially small. So perhaps it “cancels out” for releases with a large enough \(\tau\).

+
+

The Reference Case - Discrete Puffs

+
+
# The example case
+u  = 2.0    # m/s
+h  = 10.0   # m
+c₀ = 1.0    # kg/m^3
+w₀ = 1.0    # kg/s
+m₀ = 1000.0 # kg
+τ  = m₀/w₀
+
+
+
w(t) = w₀*exp(-t/τ)
+
+

\[ +m(t) = \int_0^t w_0 \exp \left( -\frac{t^{\prime}}{\tau} \right) d t^{\prime} +\]

+

\[ +m(t) = w_0 \tau \left( 1 - \exp \left( -\frac{t^{\prime}}{\tau} \right) \right) +\]

+
+
m(t) = w₀*τ*(1 - exp(-t/τ))
+
+
m (generic function with 1 method)
+
+
+

::: {#98b90fca-3aa4-40b0-99df-4af5f4f9bbbb .cell 0=‘o’ 1=‘u’ 2=‘t’ 3=‘p’ 4=‘u’ 5=‘t’ 6=‘:’ 7=‘f’ 8=‘a’ 9=‘l’ 10=‘s’ 11=‘e’ execution_count=11}

+
function discrete_puffs(;n=100, t_0=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,c₀)
+        push!(pfs,pf)
+    end
+    return pfs
+end
+
+
discrete_puffs (generic function with 1 method)
+
+

:::

+
+
m(pfs::Vector{Puff},t) = sum( pf.m for pf in pfs if pf.t < t )
+
+
m (generic function with 2 methods)
+
+
+
+
pfs = discrete_puffs(n=100);
+
+
+
m(pfs,τ)
+
+
635.3184615206982
+
+
+
+
m(τ)
+
+
632.1205588285577
+
+
+
+
+
100-element Vector{Float64}:
+ 3.5338047520570564e-222
+ 5.9945904391486026e-217
+ 8.780076906348763e-212
+ 1.1103476054378067e-206
+ 1.2123874654194069e-201
+ 1.1429987409866083e-196
+ 9.304047167478635e-192
+ 6.539128802406203e-187
+ 3.9681649546291795e-182
+ 2.0791287302864883e-177
+ 9.405781359455912e-173
+ 3.6739244027566487e-168
+ 1.2390458837143029e-163
+ ⋮
+ 4.0079999580163855e-6
+ 1.6594582580263465e-6
+ 5.932350384630131e-7
+ 1.831087476751391e-7
+ 4.879925904118141e-8
+ 1.1228951350106937e-8
+ 2.23093547634655e-9
+ 3.826984085142713e-10
+ 5.6682388145366026e-11
+ 7.248722267338629e-12
+ 8.003807184963484e-13
+ 7.630510816771475e-14
+
+
+
+
+
+
+ +
+
+Figure 1: Some intelligent caption goes here. +
+
+
+
+
+
pfs[1]
+
+
Puff{Float64}(1.0101010101010102, 10.0, 2.0, 0.0, 1.0)
+
+
+
+
using SpecialFunctions: erf, erfc
+
+
+
+
+

References

+
+
+Center for Chemical Process Safety. Guidelines for Consequence Analysis of Chemical Releases. New York: Center for Chemical Process Safety/AIChE, 1999. +
+
+Palazzi, E, M De Faveri, Giuseppe Fumarola, and G Ferraiolo. “Diffusion from a Steady Source of Short Duration.” Atmospheric Environment 16, no. 12 (1982): 2785–90. https://www.researchgate.net/publication/328744668_DIFFUSION_FROM_A_STEADY_SOURCE_OF_SHORT_DURATION. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/vessel_blowdown_ideal_gases/fig-1.svg b/posts/vessel_blowdown_ideal_gases/fig-1.svg new file mode 100644 index 0000000..759ef9d --- /dev/null +++ b/posts/vessel_blowdown_ideal_gases/fig-1.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_ideal_gases/fig-2.svg b/posts/vessel_blowdown_ideal_gases/fig-2.svg new file mode 100644 index 0000000..7ad7d31 --- /dev/null +++ b/posts/vessel_blowdown_ideal_gases/fig-2.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_ideal_gases/fig-4.svg b/posts/vessel_blowdown_ideal_gases/fig-4.svg new file mode 100644 index 0000000..eabc918 --- /dev/null +++ b/posts/vessel_blowdown_ideal_gases/fig-4.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_ideal_gases/fig-5.svg b/posts/vessel_blowdown_ideal_gases/fig-5.svg new file mode 100644 index 0000000..cfd820c --- /dev/null +++ b/posts/vessel_blowdown_ideal_gases/fig-5.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_ideal_gases/fig-7.svg b/posts/vessel_blowdown_ideal_gases/fig-7.svg new file mode 100644 index 0000000..56bc87e --- /dev/null +++ b/posts/vessel_blowdown_ideal_gases/fig-7.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_ideal_gases/fig-8.svg b/posts/vessel_blowdown_ideal_gases/fig-8.svg new file mode 100644 index 0000000..46f06dc --- /dev/null +++ b/posts/vessel_blowdown_ideal_gases/fig-8.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_ideal_gases/index.html b/posts/vessel_blowdown_ideal_gases/index.html new file mode 100644 index 0000000..1b35a69 --- /dev/null +++ b/posts/vessel_blowdown_ideal_gases/index.html @@ -0,0 +1,1507 @@ + + + + + + + + + + + + +Vessel Blowdown - Ideal Gases – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Vessel Blowdown - Ideal Gases

+
+
+ Evaluating approaches to ideal gas blowdowns. +
+
+
+
julia
+
compressible flow
+
blowdown
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

January 24, 2025

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

A recurring task of mine is to look at some old calculations, done by some previous engineer whose identity is lost to time and organizational flux, and update them to match current reality. Depending on the state of the spreadsheet, and its lack of documentation, this can also mean going down a rabbit hole of research to find where, exactly, a given equation came from and what all the constants in it represent. This post is the result of one of those journeys, trying to track down the source of a model for depressuring a vessel.

+
+
+
+ +
+
+Figure 1: A vessel blowdown scenario, discharging from vessel pressure (1), through an isentropic valve and into the atmosphere (2). +
+
+
+

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

+

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:

+

\[ +\frac{dm}{dt} = - w +\]

+

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

+

\[ +V \frac{d \rho}{dt} = - w +\]

+

We can perform a change of variables from ρ to P

+

\[ +V \left( \frac{\partial \rho}{\partial P} \right)_S \frac{dP}{dt} = - w +\]

+

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 \(c_D\), and the flow area A.

+

\[ +w = c_D A G +\]

+

giving1

+

\[ +\frac{dP}{dt} = - \frac{c_D A}{V} \left( \frac{\partial P}{\partial \rho} \right)_S G +\]

+
+

Fully Choked Flow

+

Assuming the flow through the valve is choked, the velocity in the throat is the sonic velocity \(a_t\) which, for an ideal gas, is given by

+

\[ +a = \sqrt{ {k R T} \over M} = \sqrt{ {k P} \over \rho} +\]

+

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.

\[ +\frac{\rho_t}{\rho} = \left( \frac{P_t}{P} \right)^{\frac{1}{k} } +\]

+

and, for choked flow, the pressure ratio is at maximum at3

+

\[ +\frac{P_t}{P} = \left( { 2 \over {k+1} } \right)^{\frac{k}{k-1} } +\]

+

putting this all together we can write G in terms of vessel conditions \(\rho\) and \(P\)

+

\[ +G = \rho_t u_t = \rho_t a_t = \rho_t \sqrt{ {k P_t} \over \rho_t} = \sqrt{k \rho_t P_t} +\]

+

\[ +G = \sqrt{k \rho P} \left( 2 \over {k+1} \right)^{\frac{k+1}{2 \left( k - 1 \right)} } +\]

+

From thermodynamics we know

+

\[ +\left( \frac{\partial P}{\partial \rho} \right)_S = a^2 = \frac{k P}{\rho} +\]

+

and we can put this all together to get

+

\[ +\frac{dP}{dt} = - \frac{c_D A}{V} \left( {k P} \over \rho \right) \sqrt{k \rho P} \left( 2 \over {k+1} \right)^{\frac{k+1}{2 \left( k - 1 \right)} } +\]

+

At this point, it is standard to introduce a time constant \(\tau\)

+

\[ +\tau = \frac{m_0}{w_0} = \frac{\rho_0 V}{c_D A \sqrt{k \rho_0 P_0} } \left( 2 \over {k+1} \right)^{-\frac{k+1}{2 \left( k - 1 \right)} } +\]

+

or, more clearly,

+

\[ +\frac{1}{\tau} = \frac{c_D A}{V} \sqrt{ {k P_0} \over \rho_0 } \left( 2 \over {k+1} \right)^{\frac{k+1}{2 \left( k - 1 \right)} } +\]

+

Where the subscript 0 indicates the initial conditions in the vessel. This simplifies the expression to

+

\[ +\frac{dP}{dt} = -\frac{k}{\tau} P \left( \frac{P}{P_0} \right)^{\frac{k-1}{2k} } +\]

+

Which is separable and can be integrated to give (after some rearrangement)

+

\[ +\frac{P}{P_0} = \left( 1 + \left( {k-1} \over 2 \right) \frac{t}{\tau} \right)^{\frac{2k}{1-k} } +\]

+

and the depressure time is

+

\[ +t = \frac{2\tau}{1-k} \left( 1 - \left( \frac{P_a}{P_0} \right)^{\frac{1-k}{2k} } \right) +\]

+

Another useful thing to determine is the mass flow rate over time, which can be recovered rather easily recalling

+

\[ +w = -\frac{V}{a^2} \frac{dP}{dt} = -\frac{\rho V}{k P} \frac{dP}{dt} +\]

+

and

+

\[ +\frac{dP}{dt} = -\frac{k}{\tau} P \left( \frac{P}{P_0} \right)^{\frac{k-1}{2k} } +\]

+

we get

+

\[ +w = \frac{\rho V}{\tau} \left( \frac{P}{P_0} \right)^{\frac{k-1}{2k} } = \frac{\rho_0 V}{\tau} \left( \frac{\rho}{\rho_0} \right) \left( \frac{P}{P_0} \right)^{\frac{k-1}{2k} } +\]

+

By recalling the definition of \(\tau\) this simplifies to

+

\[ +\frac{w}{w_0} = \left( \frac{P}{P_0} \right)^{ {k+1} \over {2k} } = \left( 1 + \left({k-1} \over 2 \right) \frac{t}{\tau} \right)^{\frac{1+k}{1-k} } +\]

+

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.

+
+
+

In the Literature

+

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.

+
+
+
+ +
+
+Note +
+
+
+

Perry’s gives the following

+

\[ +\frac{w}{w_0} = \left( 1 + \left(\mathbf{k + 1} \over 2 \right) \frac{t}{\tau} \right)^{\frac{1+k}{1-k} } +\]

+

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.

+
+
+

The Complete ODE

+

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.

+

\[ +\frac{dP}{dt} = -\frac{c_D A}{V} a^2 G +\]

+

We can solve this numerically given

+

\[ +\rho = \rho_0 \left(\frac{P}{P_0}\right)^{\frac{1}{k} } +\]

+

\[ +G = \sqrt{ \rho P \left( {2k} \over {k - 1} \right) \left( \left( \frac{P_t}{P}\right)^{ \frac{2}{k} } - \left( \frac{P_t}{P} \right)^{ \frac{k+1}{k} } \right) } +\]

+
function isentropic_mass_flow(P, ρ; k=1.4, Pₐ=101325)
+    η = max( Pₐ/P, (2/(k+1))^(k/(k-1)))
+= ρ*P*(2k/(k-1))*( η^(2/k) - η^((k+1)/k) )
+    G => 0 ? (G²) : 0
+    return G
+end
+
function speed_of_sound(P, ρ; k=1.4)
+    a = (k*P/ρ)
+    return a
+end
+
function adiabatic_vessel(P, params, t)
+    c, A, V, k, ρ₀, P₀, Pₐ = params
+    ρ = ρ₀*(P/P₀)^(1/k)
+= speed_of_sound(P, ρ; k=k)^2
+    G = isentropic_mass_flow(P, ρ; k=k, Pₐ=Pₐ)
+    return-c*A**G/V
+end
+

with 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ₐ
+end
+
+
+

A Motivating Example

+

Just 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, Plots
+
begin
+    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;
+
+
+
+ +
+
+Figure 2: The adiabatic blowdown curve for a fully charged SCUBA tank, showing both the fully choked model and the ODE solution. +
+
+
+

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 end
+
struct AdiabaticBlowdown{S} <: Blowdown
+    pv::PressureVessel
+    sol::S
+end
+

Here 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) = 1
+
Base.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))
+end
+
function 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))
+end
+
function 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))
+end
+

For 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)
+end
+
function blowdown_pressure(bd::AdiabaticBlowdown{<:ODESolution}, t)
+    if t < blowdown_time(bd)
+        return bd.sol(t)
+    else
+        return bd.sol.u[end]
+    end
+end
+
function 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
+end
+
blowdown_time(bd::AdiabaticBlowdown{<:ODESolution}) = 
+    bd.sol.t[end]
+
+
+
+ +
+
+Figure 3: The adiabatic blowdown curves for a fully charged SCUBA tank, showing both the fully choked model and the ODE solution for pressure (top) and mass flow rate (bottom). +
+
+
+

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ₐ);
+
+
+
+ +
+
+Figure 4: The adiabatic blowdown curve for a partially charged SCUBA tank, showing both the fully choked model and the ODE solution. +
+
+
+

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 Isothermal Case

+

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

+

\[ +\frac{dP}{dt} = - \frac{c_D A}{V} \left( \frac{\partial P}{\partial \rho} \right)_S G +\]

+

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

+

\[ +\frac{dP}{dt} = - \frac{c_D A}{V} \left( \frac{\partial P}{\partial \rho} \right)_T G +\]

+
+

Fully Choked Flow

+

As before, the blowdown is through an isentropic nozzle and we assume that flow is choked

+

\[ +G = \sqrt{k \rho P} \left( \frac{2}{k+1} \right)^{\frac{k+1}{2 \left( k-1 \right)} } = \rho \sqrt{ {k P} \over \rho} \left( \frac{2}{k+1} \right)^{\frac{k+1}{2 \left( k-1 \right)} } +\]

+

From thermodynamics we can write the partial derivative as

+

\[ +\left( \frac{\partial P}{\partial \rho} \right)_T = \frac{a^2}{k} = \frac{P}{\rho} +\]

+

Thus

+

\[ +\frac{dP}{dt} = - \frac{c_D A}{V} \frac{P}{\rho} \rho \sqrt{ {k P} \over \rho} \left( \frac{2}{k+1} \right)^{\frac{k+1}{2 \left( k-1 \right)} } +\]

+

where the densities, \(\rho\), can be cancelled and, since the vessel is isothermal (i.e. \(\frac{P}{\rho}\) is a constant), the various constants can be collected to give

+

\[ +\frac{dP}{dt} = -\frac{P}{\tau} +\]

+

Where \(\tau\) is as defined for the adiabatic case. This can easily be integrated to give

+

\[ +\frac{P}{P_0} = \exp \left( \frac{-t}{\tau} \right) +\]

+

it also follows, from the ideal gas law, that

+

\[ +\frac{\rho}{\rho_0} = \exp \left( \frac{-t}{\tau} \right) +\]

+

and

+

\[ +\frac{w}{w_0} = \exp \left( \frac{-t}{\tau} \right) +\]

+

This can also be rearranged to give the blowdown time7

+

7 N.B. the \(\log \left( \dots \right)\) is the natural log, this matches the convention used in julia

\[ +t = \tau \log \left( \frac{P_0}{P_a} \right) +\]

+
+
+

In the Literature

+

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, \(\tau\), can be broken up to look like this

+

\[ +\tau = \frac{V}{c_D A} \sqrt{\frac{M}{M_{air} Z_0 T_0} } \sqrt{\frac{M_{air} }{kR} } \left( 2 \over {k+1} \right)^{\frac{-1}{2} \frac{k+1}{k-1} } +\]

+

Where we have made the substitution \(Z_0 R T_0\) for \(R T_0\) to account for non-ideal behaviour. If the gas has a value of k ~ 1.4, we can write

+

\[ +\tau = \mathrm{const} \frac{V}{c_D A} \sqrt{ \frac{SG}{Z_0 T_0} } +\]

+

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

+

\[ +t = 5.5 \frac{V}{c_D A} \sqrt{ \frac{SG}{Z_0 T_0} } \log \left( \frac{P_0}{P_a} \right) +\]

+

with the units

+
    +
  • Blowdown time, t, in seconds
  • +
  • Vessel volume, V, in cubic feet
  • +
  • Valve flow area, A, in square inches
  • +
  • Initial temperature, \(T_0\), in Rankine
  • +
  • Initial pressure, \(P_0\), in psia
  • +
  • Ambient pressure, \(P_a\), in psia
  • +
+

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.

+

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
+end
+
isothermal_blowdown_choked(vessel::PressureVessel) = 
+    IsothermalBlowdown(vessel,nothing)
+
function blowdown_pressure(bd::IsothermalBlowdown, t)
+    P₀, τ = bd.pv.P₀, bd.pv.τ
+    return P₀*exp(-t/τ)
+end
+
function blowdown_mass_rate(bd::IsothermalBlowdown, t)
+    ρ₀, V, τ = bd.pv.ρ₀, bd.pv.V, bd.pv.τ
+    m₀ = ρ₀*V
+    w₀ = m₀/τ
+    return w₀*exp(-t/τ)
+end
+
blowdown_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

+

\[ +\frac{dP}{dt} = -\frac{c_D A}{V} \frac{a^2}{k} G +\]

+

We can solve this numerically given that, for an isothermal system, the density is given by

+

\[ +\rho = \rho_0 \left(\frac{P}{P_0}\right) +\]

+

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₀)
+= speed_of_sound(P, ρ; k=k)^2
+    G = isentropic_mass_flow(P, ρ; k=k, Pₐ=Pₐ)
+    return-c*A**G/(k*V)
+end
+
function 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)
+end
+
function blowdown_pressure(bd::IsothermalBlowdown{<:ODESolution}, t)
+    if t < blowdown_time(bd)
+        return bd.sol(t)
+    else
+        return bd.sol.u[end]
+    end
+end
+
function 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
+end
+
blowdown_time(bd::IsothermalBlowdown{<:ODESolution}) = 
+    bd.sol.t[end]
+
+
+
+ +
+
+Figure 5: The isothermal blowdown curves for a fully charged SCUBA tank, showing both the fully choked model and the ODE solution for pressure (top) and mass flow rate (bottom). +
+
+
+

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:

+
    +
  1. the gases are assumed be ideal with constant k
  2. +
  3. the vessel is perfectly isothermal (or adiabatic)
  4. +
+

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.

+
+
+
+

Comparing Blowdown Models

+

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.

+
+
+
+ +
+
+Figure 6: The adiabatic and isothermal blowdown curves for a fully charged SCUBA tank, in dimensionless form. +
+
+
+

In the high pressure case the blowdown terminates much closer to \(\frac{P}{P_0}=0\) and most of the curve is fully choked.

+
+
+
+ +
+
+Figure 7: The adiabatic and isothermal blowdown curves for a partially charged SCUBA tank, in dimensionless form. +
+
+
+

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 \(\exp \left( \frac{-t}{\tau} \right)\).

+

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 \(\frac{t}{\tau}\) and so it is not always the case that the isothermal model is more conservative. Something worth noting.

+
+
+

Final Thoughts

+

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:

+
    +
  1. the speed of sound
  2. +
  3. the density as a function of pressure, either along an isentropic path (in the adiabatic case) or along an isothermal path
  4. +
  5. the isentropic mass velocity, G
  6. +
+

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.

+
+
+

References

+
+
+Botros, K. K., W. M. Jungowski, and M. H. Weiss. “Models and Methods of Simulating Gas Pipeline Blowdown.” The Canadian Journal of Chemical Engineering 67 (1989): 529–39. https://doi.org/10.1002/cjce.5450670402. +
+
+Botros, Kamal K., and Thomas Van Hardeveld. Pipeline Pumping and Compression Systems - a Practical Approach. 3rd ed. New York: ASME Press, 2018. +
+
+Campbell, John M. Gas Conditioning and Processing. Vol. 2. Tulsa, OK: John M. Campbell & Co, 1992. +
+
+Crowl, Daniel A., Lawrence G. Britton, Walter L. Frank, Stanley Grossel, Dennis Hendershot, W. G. High, Robert W. Johnson, et al. “Process Safety.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+Engineers Edge. “Blowdown Time in Unsteady Gas Flow Calculator and Equation,” 2025. https://www.engineersedge.com/calculators/blowdown_time_in_unsteady_gas_16011.htm. +
+
+Laidler, Keith J., John H. Meiser, and Bryan C. Sanctuary. Physical Chemistry. 4th ed. Boston, MA: Houghton Mifflin Co, 2003. +
+
+Lees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996. +
+
+Saad, Michel A. Compressible Fluid Flow. Englewood Cliffs, NJ: Prentiss-Hall, 1985. +
+
+Temizel, Cenk, Tayfun Tuna, Mehmet Melik Oskay, and Luigi Saputelli. Formulas and Calculations for Petroleum Engineering. Cambridge, MA: Gulf Professional Publishing, 2019. +
+
+Tilton, James N. “Fluid and Particle Dynamics.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008. +
+
+VANEC. “Pressure Volume-Blowdown Time Calculation,” 2025. https://www.vanec.com/pressurized-volume-blowdown-time-calculation.html. +
+
+Wheeler, Dean R. “Tank Blowdown Math,” 2019. http://www.et.byu.edu/~wheeler/Tank_Blowdown_Math.pdf. +
+
+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/vessel_blowdown_ideal_gases/pressure_relief.png b/posts/vessel_blowdown_ideal_gases/pressure_relief.png new file mode 100644 index 0000000..6944a46 Binary files /dev/null and b/posts/vessel_blowdown_ideal_gases/pressure_relief.png differ diff --git a/posts/vessel_blowdown_real_gases/bad_blowdown.svg b/posts/vessel_blowdown_real_gases/bad_blowdown.svg new file mode 100644 index 0000000..77320e1 --- /dev/null +++ b/posts/vessel_blowdown_real_gases/bad_blowdown.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_real_gases/figure-1.svg b/posts/vessel_blowdown_real_gases/figure-1.svg new file mode 100644 index 0000000..81bb52a --- /dev/null +++ b/posts/vessel_blowdown_real_gases/figure-1.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_real_gases/figure-2.svg b/posts/vessel_blowdown_real_gases/figure-2.svg new file mode 100644 index 0000000..f2c6c95 --- /dev/null +++ b/posts/vessel_blowdown_real_gases/figure-2.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_real_gases/figure-3.svg b/posts/vessel_blowdown_real_gases/figure-3.svg new file mode 100644 index 0000000..81e201c --- /dev/null +++ b/posts/vessel_blowdown_real_gases/figure-3.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_real_gases/figure-4.svg b/posts/vessel_blowdown_real_gases/figure-4.svg new file mode 100644 index 0000000..c10de85 --- /dev/null +++ b/posts/vessel_blowdown_real_gases/figure-4.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/vessel_blowdown_real_gases/index.html b/posts/vessel_blowdown_real_gases/index.html new file mode 100644 index 0000000..6c8f5af --- /dev/null +++ b/posts/vessel_blowdown_real_gases/index.html @@ -0,0 +1,1474 @@ + + + + + + + + + + + + +Vessel Blowdown - Real Gases – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Vessel Blowdown - Real Gases

+
+
+ Modelling vessel blowdowns using equations of state. +
+
+
+
julia
+
compressible flow
+
blowdown
+
equations of state
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

March 19, 2025

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

Continuing on from where I left off previously, examining vessel blowdown, it is time to implement real gases. I left the ideal gas case promising that implementing a real gas was easy, well now is the time to prove it. Instead of implementing real gas equations of state myself, I am going to use Clapeyron.jl but, as a first step, it is worthwhile to consider how the problem can be divided up into sub-problems and what data structures would be the most useful. I would like to write code that is general enough that any equation of state can be used with minimal changes. With that in mind, I am going to consider the problem as being composed of three distinct subsets: the vessel, the fluid model, and the ambient conditions.

+
+

Data Structures

+

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, \(P, v, T\), 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\), 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)...)
+
+end
+

Abstracting 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)...)
+
+end
+

Finally, a data structure for blowdown solutions is useful for dispatch.

+
struct Blowdown{S}
+    pv::PressureVessel
+    env::Environment
+    sol::S
+end
+
Base.length(::Blowdown) = 1
+
Base.iterate(b::Blowdown, state=1) = state > length(b) ? nothing : (b,state+1)
+
+
+

Equations of State

+
+

Ideal gases

+

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 \(c_p - c_v = R\). 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
+    
+end
+

A 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 \(\sqrt{-1}\) – 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
+
+end
+

The 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/v
+
volume(model::IdealGas, P, T) = model.R*T/P
+
molecular_weight(model::IdealGas) = model.MW
+
molar_enthalpy(model::IdealGas, v, T) = model.cₚ*T
+
molar_entropy(model::IdealGas, v, T) =
+ model.cᵥ*log(T) + model.R*log(v)
+
molar_internal_energy(model::IdealGas, v, T) = model.cᵥ*T
+
speed_of_sound(model::IdealGas, v, T) =
+ (model.k*model.R*T/model.MW)
+
+
+

Real Gases with Clapeyron.jl

+

The 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 Clapeyron
+
pressure(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)
+
+
+
+

Isentropic Nozzle Flow

+

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.

+

\[ +s_1 = s_t +\]

+

\[ +h_1 = h(v_t, T_t) + \frac{1}{2} M u_t^2 +\]

+

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, \(v_t, T_t\), 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 NonlinearSolve
+
function 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
+end
+
choked_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, \(u_t, T_t\).

+
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
+end
+
non_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ₜ
+end
+
+
+

Adiabatic Blowdown

+
+

The Pressure Equation

+

The 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
+end
+

The 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.

+

\[ +\frac{dP}{dt} = -\frac{c_D A}{V} a^2 G +\]

+

\[ +0 = v - volume(P, T) +\]

+

\[ +0 = s_0 - entropy(v, T) +\]

+

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, DiffEqCallbacks
+
function adiabatic_vessel!(dy, y, prms, t)
+    P, v, T = y
+    
+= speed_of_sound(prms.model, v, T)^2
+    w = mass_flow(prms.model, prms.pv, prms.env, v, T)
+
+    dy .= [-w*/prms.pv.V
+            v - volume(prms.model, P, T)
+            prms.init - molar_entropy(prms.model, v, T) ]
+    return nothing
+end
+
abd_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.P
+

The 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))
+end
+

From 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)
+end
+
function blowdown_temperature(bd::Blowdown{<:PressureODE}, t)
+    bdt = blowdown_time(bd)
+    t = min(t, bdt)
+    return bd.sol.ode_sol(t; idxs=3)
+end
+
+

The Ideal Gas Choked Flow Model

+

The 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
+end
+
function 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,τ))
+end
+
function 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))
+end
+
function 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))
+end
+
function 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)
+end
+
+
+

Checking our work

+

The 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)
+end
+

The real gas is modelled using a volume translated Peng-Robinson equation of state.

+
using Clapeyron:PR, ReidIdeal, RackettTranslation
+
nitrogen = 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);
+
+
+
+ +
+
+Figure 1: The adiabatic blowdown curve for a tank of nitrogen, showing the fully choked ideal gas model model and the DAE solutions for both the ideal gas and real gas case. +
+
+
+

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.

+
+
+
+ +
+
+Figure 2: The vessel temperature for the nitrogen blowdown, showing the fully choked ideal gas model model and the DAE solutions for both the ideal gas and real gas case. +
+
+
+

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.

+
+
+
+

The Energy Equation

+

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:

+

\[ +\frac{dm}{dt} = -w +\]

+

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.

+

\[ +\frac{dU}{dt} = Q_i - Q_o +\]

+

\[ +\frac{dU}{dt} = Q_i - w \bar{h} +\]

+

Where \(\bar{h}\) 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, \(U=m \bar{u}\). Applying the chain rule:

+

\[ +\frac{dU}{dt} = m \frac{d \bar{u}}{dt} + \bar{u} \frac{dm}{dt} = m \frac{d \bar{u}}{dt} - \bar{u} w +\]

+

Combining these two expressions:

+

\[ +\frac{d \bar{u}}{dt} = \frac{1}{m} \left( Q_i + \left(\bar{u} - \bar{h} \right) w \right) +\]

+

The specific internal energy, \(\bar{u}\), is related to the molar internal energy, \(u\), by the molar weight, \(M \bar{u} = u\), similarly for the specific and molar enthalpy. Substituting and multiplying through by the molar weight gives:

+

\[ +\frac{d u}{dt} = \frac{1}{m} \left( M Q_i + \left(u - h \right) w \right) +\]

+

The remaining mass, \(m\), can be written in terms of the molar volume, \(v\):

+

\[ +\frac{d u}{dt} = \frac{v}{M V} \left( M Q_i + \left(u - h \right) w \right) +\]

+

The mass balance can also be written in terms of the molar volume:

+

\[ +\frac{dv}{dt} = \frac{w v^2}{M V} +\]

+

The full system of equations, in terms of \(u, v, T\) is then:

+

\[ +\frac{d u}{dt} = \left( M Q_i + \left(u - h \right) w \right) \frac{v}{M V} +\]

+

\[ +\frac{dv}{dt} = \frac{w v^2}{M V} +\]

+

\[ +0 = u - internal\_energy(v, T) +\]

+

The adiabatic case is the special case where \(Q_i = 0\).

+
+

The Adiabatic Ideal Gas Case

+

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 \(u = c_v T\) and \(h = c_p T\). For the adiabatic case the energy balance becomes:

+

\[ +c_v \frac{d T}{dt} = \frac{1}{m} \left( c_v T - c_p T \right) w +\]

+

Isentropic choked flow of an ideal gas occurs with:

+

\[ +w = c_d A \rho_t \sqrt{ \frac{k R T_t}{M} } +\]

+

With nozzle density and temperature related to the vessel conditions by:

+

\[ +\rho_t = \rho \left( 2 \over {k+1} \right)^{\frac{1}{k-1}} +\]

+

\[ +T_t = T \left( 2 \over {k+1} \right) +\]

+

Substituting all of this into the energy equation and dividing by \(c_v\) gives:

+

\[ +\frac{dT}{dt} = \frac{c_d A}{V} \left( 1 - k \right) \left(2 \over {k+1} \right)^{\frac{k+1}{2(k-1)}} \sqrt{ \frac{k R T}{M} } T +\]

+

Where \(k = \frac{c_p}{c_v}\). The time constant \(\tau\) is defined such that:

+

\[ +\frac{1}{\tau} = \frac{c_d A}{V} \left(2 \over {k+1} \right)^{\frac{k+1}{2(k-1)}} \sqrt{ \frac{k R T_0}{M} } +\]

+

Which simplifies the ODE to:

+

\[ +\frac{dT}{dt} = \frac{1-k}{\tau} T \sqrt{\frac{T}{T_0}} +\]

+

This is a separable equation and can be integrated to give:

+

\[ +\frac{T}{T_0} = \left( 1 + \frac{k-1}{2} \frac{t}{\tau} \right)^{-2} +\]

+

For an adiabatic expansion of an ideal gas:

+

\[ +\frac{P}{P_0} = \left( \frac{T}{T_0} \right)^{\frac{k}{k-1}} +\]

+

Which recovers the original solution:

+

\[ +\frac{P}{P_0} = \left( 1 + \frac{k+1}{2} \frac{t}{\tau} \right)^{\frac{2k}{1-k}} +\]

+
+
+

Implementing the DAE

+

The governing equations for the vessel blowdown can be implemented as a DAE though, as the state variables, \(u, v, T\), 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
+end
+
ueqn_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.P
+

To 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 DataInterpolations
+
struct EnergyODE{S,I}
+    ode_sol::S
+    p_interp::I
+end
+
function 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))
+end
+

The 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)
+end
+
function blowdown_temperature(bd::Blowdown{<:EnergyODE}, t)
+    bdt = blowdown_time(bd)
+    t = min(t, bdt)
+    return bd.sol.ode_sol(t; idxs=3)
+end
+

The 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);
+
+
+
+ +
+
+Figure 3: The blowdown curve for the nitrogen blowdown, showing the DAE solutions for both the pressure model and the energy balance model (ideal gas and real gas cases). The curves for the pressure model and energy balance model overlap. +
+
+
+
+
+
+ +
+
+Figure 4: The vessel temperature for the nitrogen blowdown, showing the DAE solutions for both the pressure model and the energy balance model (ideal gas and real gas cases). The curves for the pressure model and energy balance model overlap. +
+
+
+
+
+
+

Performance

+

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.

+
+
+
+ +
+
+Figure 5: The blowdown curve for the pressure model when molar volume is moved to the RHS of the ODE. The pressure model curves have a weird bump at the end. +
+
+
+

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.

+
+
+
+

Conclusions

+

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 \(Q = k \left( T - T_a \right)\), 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.

+ + +
+ +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/worst_case_weather/index.html b/posts/worst_case_weather/index.html new file mode 100644 index 0000000..c982959 --- /dev/null +++ b/posts/worst_case_weather/index.html @@ -0,0 +1,1170 @@ + + + + + + + + + + + + +Worst Case Meterological Conditions – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Worst Case Meterological Conditions

+
+
+ The worst case weather conditions for air dispersion modeling. +
+
+
+
julia
+
dispersion modelling
+
+
+
+ + +
+ +
+
Author
+
+

Allan Farrell

+
+
+ +
+
Published
+
+

December 12, 2020

+
+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + +

In a previous post I modeled an example of a plume from an elevated stack. In that example I assumed very stable conditions and a low windspeed – pasquill stability class F and a windspeed of 1.5m/s – as the worst case. This was an error!

+

For neutrally buoyant releases at or near ground-level that is a common “worst case”, for example when considering the potential impact due to a vapour cloud explosion. But for elevated stacks, releasing a buoyant plume, a class D stability with a moderate windspeed is often recommended. I thought it would be interesting to explore how the maximum concentration at the point of interest – an elevated work area downwind of the stack – varies with stability class and windspeed.

+

I am not going to repeat all of the assumptions and working out of the previous notebook, the important results are in the source code for this post. I have also re-defined some of the functions to be a little more re-useable and to represent other stability cases not covered in the original notebook.

+
+

Pasquill Stability

+

As a refresher, Pasquill stability classes are a qualitative way of describing the atmospheric stability – the tendency of the atmosphere to resist or enhance vertical motion. Stability is itself related to the temperature gradient with height, wind speed, and various other things. For a simple model such as this the key model parameters are tabulated with respect to the Pasquill stability, which is why it is relevant to this discussion.

+
+

Pasquill Stability Classes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Stability ClassDescription
AExtremely unstable
BUnstable
CSlightly unstable
DNeutral
ESlightly stable
FStable to extremely stable
+

In general the more stable the class the less dispersion, and thus the higher the concentration within the plume. Which is why class F is typically used for a ground level, neutrally buoyant, cloud. However plume rise is also a function of stability and, in general, more stable plumes rise without as much dispersion and thus the ground level concentration is lower than if the plume dispersed more. Furthermore the plume rise is a function of windspeed, the greater the windspeed the less the plume rises before leveling off and, again, the greater the ground level concentration.

+

We can visualize the relationship between windspeed, stability class, and the concentration at the point of interest with the following plot which includes plume rise relationships for unstable, neutral, and stable atmospheres.

+
+
+
+
+

+
+
+
+
+

We note that, as we expect, lower stability (e.g. A or B) corresponds to a higher groundlevel concentration at low windspeed, but at high windspeed higher stability leads to a greater groundlevel concentration.

+

At first blush it would appear that class F is still the worst case, however this plot naively assumes atmospheric stability is unrelated to windspeed. This is not true and roughly speaking the stability transitions towards classes C and D as the windspeed increases.

+
+
+

Pasquill Stability and Windspeed

+
+

Pasquill Stability vs Incoming Solar Radiation

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Windspeed (m/s)StrongModerateSlight
< 2AA - BB
2 - 3A - BBC
3 - 5BB - CC
5 - 6CC - DD
> 6CDD
+
+
+

Pasquill Stability vs Nighttime Cloud Cover

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Windspeed (m/s)> 4/8 cloud< 3/8 cloud
< 2
2 - 3EF
3 - 5DE
5 - 6DD
> 6DD
+

To represent this crudely, the plots can be chopped off at the windspeed limits from the tables above. So, for example, the class F plot would end at 3m/s.

+

The plots also present an obvious way of finding the worst case for a particular scenario: find the extremal point for the worst stability class. This is found rather simply by setting the derivative to zero. Something that would be complicated to do analytically but is quite straight forward when using the ForwardDiff library for automatic differentiation.

+
+
# ForwardDiff doesn't play nicely with unitful
+# so values have to be stripped of units first
+
+Fb′ = ustrip(Fb)
+x₁′ = ustrip(x₁)
+h₁′ = ustrip(h₁)
+Q′  = ustrip(Q)
+hₛ′ = ustrip(hₛ)
+
+∂Cᵤ(u) = ForwardDiff.derivative(u -> C(u, x=x₁′, 
+                                          y=0.0, 
+                                          z=h₁′, 
+                                          Q=Q′, 
+                                          h=hₛ′, 
+                                          Δh=(x,u) -> Δhᵣ(x, u, Fb=Fb′, stable=false), 
+                                          σy=σy("D"), 
+                                          σz=σz("D")), float(u))
+
+
∂Cᵤ (generic function with 1 method)
+
+
+
+
# Find the point where ∂C/∂u = 0
+# Initial guess of 25 just by eye-ball
+
+u_worst = find_zero(∂Cᵤ, 25)
+
+C_worst = C(x₁, 0.0u"m", h₁, 
+            u=u_worst*1u"m/s", 
+            Q=Q, 
+            h=hₛ, 
+            Δh=(x,u) -> Δhᵣ(x, u, Fb=Fb, stable=false), 
+            σy=σy("D"), 
+            σz=σz("D"))
+
+uconvert(u"mg/m^3", C_worst)
+
+
0.2753490886768969 mg m^-3
+
+
+
+
+
+
+

+
+
+
+
+

We find that the worst case is indeed class D but with quite a high windspeed, ~26.4m/s or 95kph, which would be considered a 10 on the Beaufort scale with trees being uprooted and considerable structural damage. It’s unlikely that workers would still be on the platform and it may not even be the case that the scaffolding would still be standing!

+

Regardless we can look at the contour plots at the work platform elevation and vertically, along the centerline.

+

Note the colours are scaled to 4mg/m^3, one tenth the occupational limit of 40mg/m^3.

+
+
+
+
+

+
+
+
+
+

Another interesting impact of elevated releases like this is that the worst concentration for an observer on the ground is often a significant distance downwind of the stack. Because the plume must disperse downwards.

+

Below is a contour plot showing the downwind concentration at a 2m elevation – the height of a reasonably tall person. Note the scale is set to even lower concentrations. The maximum of the colour bar is 1000x lower than the occupational limit.

+
+
+
+
+

+
+
+
+
+
+
+
+
+

Multiple Concentrations

+

Previously, in the discussion of the occupational exposure limit I noted that, in general, one would have to account for the impact of multiple substances in the flue gas, though in that particular example I was only modeling carbon monoxide and I just moved on. I think I left the impression that one would have to model each substance separately and, at least with this simple gaussian dispersion model, that is very much not the case.

+

Consider for some substance i being released with in-stack concentration \(C_{s,i}\), we can define a dimensionless “dilution” \(\chi\) as

+

\[ \chi \left( x, y, z \right) = { C_i \left( x, y, z \right) \over C_{s,i} } \]

+

Assuming the in-stack concentration to be simply the mass emission rate of i divided by the volumetric flow-rate1

+

1 At standard state, because the concentrations given for the occupational exposure limits are given in terms of a volume at standard state. This is also a potential error in the original model as it does not correct the concentrations back to standard state, nor does it really track temperature to make that even possible, especially near the stack.

\[ C_{s,i} = { Q_i \over V_s^o } \]

+

and recalling the concentration function from the gaussian dispersion model

+

\[ C_i \left( x, y, z \right) = {Q_i \over 2 \pi u \sigma_{ye} \sigma_{ze} } f \left( x, y, z \right) \]

+

where $ f ( x, y, z ) $ is the products of the exponentials, and is a function of x, y, z only. Putting all that together we get an expression for the dilution that does not depend upon the substance being released

+

\[ \chi \left( x, y, z \right) = {V_s^o \over 2 \pi u \sigma_{ye} \sigma_{ze} } f \left( x, y, z \right) \]

+

If you have already done the modeling for a particular substance then calculate \(\chi\) for the points of interest by dividing the concentrations by the in stack concentration, otherwise substitute the formula for \(\chi\) given above for the concentration and model that instead.

+

Then, when evaluating multiple substances, the test2

+

2 From CCOHS

\[ \sum_i {C_i \left( x, y, z \right) \over T_i } \lt 1 \]

+

becomes

+

\[ \chi \left( x, y, z \right) \cdot \sum_i {C_{s,i} \over T_i } \lt 1 \]

+

where \(T_i\) is the relevant occupational exposure limit.

+

To recap, instead of calculating the concentrations at the points of interest using a gaussian dispersion model multiple times, calculate a dimensionless dilution at the points of interest and apply that to the in stack concentrations of all of the substances of interest. Then combine those as per the relevant rules for occupational hygiene.

+

Below are a series of contour plots showing the dilution \(\chi\), where colours are are from 0-5% – i.e. the concentration within the yellow region is ≥ 5% the in-stack concentration.

+

Note: This is backwards to the usual way of defining dilution, where a \(\chi\) of 5% would be a 95% dilution.

+
+
+
+
+

+
+
+
+
+
+
+

Thoughts on Code and Reusability

+

A simple way of taking the work from a previous notebook and adding to it is just to import the notebook. This loads the results into the current notebook including any function definitions and such.

+

For example

+

+using NBInclude
+@nbinclude("2020-12-05-gaussian_dispersion_example.ipynb")
+

I didn’t do that here for two reasons:

+
    +
  1. I want these notebooks to be independent and stand on their own
  2. +
  3. I didn’t write the previous notebook in a very extendable or reusable way
  4. +
+

The second point is worth going into if one wants to build a library of worked out, generic, models as notebooks. This way an engineer can import previously defined models as needed for a particular analysis while also keeping the documentation for the models in the model. In the previous notebook I left most things in the global namespace and defined functions that used those global variables. Which is fine for that particular notebook but it means that the current workspace gets very cluttered when importing things and also those functions are not very re-usable as they were defined for a very particular example.

+

It’s better, I think, to write functions that use keyword arguments for any important parameters that can then be passed as needed, instead of defining those parameters in the global namespace. Unless they are truly constants, like g the acceleration due to gravity or R the universal gas constant.

+

Another point is on the use of the library Unitful. It is convenient, and a good check, to have units propagate through calculations, however Unitful does not play nicely with all libraries. This is especially the case with Plots but it can also be real hassle to use with correlations that have lots of parameters. I think this is a good opportunity to take advantage of julia’s multiple dispatch.

+

For example, suppose a correlation of the form \(f \left( x \right) = a \cdot x^b\), this can be written in julia very simply (supposing a and b are parameters)

+

+f(x; a, b) = a*x^b
+

But if we pass x with some units and don’t pass the matching units with the parameter a this will throw an error. We could tediously work out the units for each set of parameters a and b to make the units cancel out properly, or we could use multiple dispatch to manage this for us

+

+function f(x::Quantity; a, b)::Quantity
+    x′ = ustrip(u"expected input unit", x)
+    return f(x′, a=a, b=b)*1u"correlation output unit"
+end
+

Where we convert the input to the expected units, whatever they may be, evaluate the function in a unitless way, then tack on the expected output units at the end. Now when we use f(x) in contexts without units, for example when plotting f(x), it works as expected and if we pass a value of x with units attached we get the unit conversion/checking that we want from Unitful.

+ + +
+ + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/posts/worst_case_weather/index_files/figure-html/cell-10-output-1.svg b/posts/worst_case_weather/index_files/figure-html/cell-10-output-1.svg new file mode 100644 index 0000000..999641f --- /dev/null +++ b/posts/worst_case_weather/index_files/figure-html/cell-10-output-1.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/worst_case_weather/index_files/figure-html/cell-11-output-1.svg b/posts/worst_case_weather/index_files/figure-html/cell-11-output-1.svg new file mode 100644 index 0000000..5039ca6 --- /dev/null +++ b/posts/worst_case_weather/index_files/figure-html/cell-11-output-1.svg @@ -0,0 +1,1718 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/worst_case_weather/index_files/figure-html/cell-12-output-1.svg b/posts/worst_case_weather/index_files/figure-html/cell-12-output-1.svg new file mode 100644 index 0000000..4ba3648 --- /dev/null +++ b/posts/worst_case_weather/index_files/figure-html/cell-12-output-1.svg @@ -0,0 +1,2388 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/worst_case_weather/index_files/figure-html/cell-13-output-1.svg b/posts/worst_case_weather/index_files/figure-html/cell-13-output-1.svg new file mode 100644 index 0000000..7d0a111 --- /dev/null +++ b/posts/worst_case_weather/index_files/figure-html/cell-13-output-1.svg @@ -0,0 +1,1626 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/posts/worst_case_weather/index_files/figure-html/cell-7-output-1.svg b/posts/worst_case_weather/index_files/figure-html/cell-7-output-1.svg new file mode 100644 index 0000000..107df1c --- /dev/null +++ b/posts/worst_case_weather/index_files/figure-html/cell-7-output-1.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects.html b/projects.html new file mode 100644 index 0000000..f921842 --- /dev/null +++ b/projects.html @@ -0,0 +1,911 @@ + + + + + + + + + +Projects – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

Projects

+
+
+ + +
+ + + + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/projects/gas_dispersion_jl/gasdispersion.jpg b/projects/gas_dispersion_jl/gasdispersion.jpg new file mode 100644 index 0000000..0ba3091 Binary files /dev/null and b/projects/gas_dispersion_jl/gasdispersion.jpg differ diff --git a/projects/gas_dispersion_jl/gasdispersion_brittermcquaid.jpg b/projects/gas_dispersion_jl/gasdispersion_brittermcquaid.jpg new file mode 100644 index 0000000..8186e3a Binary files /dev/null and b/projects/gas_dispersion_jl/gasdispersion_brittermcquaid.jpg differ diff --git a/projects/gas_dispersion_jl/gasdispersion_gaussian.svg b/projects/gas_dispersion_jl/gasdispersion_gaussian.svg new file mode 100644 index 0000000..d602d3e --- /dev/null +++ b/projects/gas_dispersion_jl/gasdispersion_gaussian.svg @@ -0,0 +1,366 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/gas_dispersion_jl/gasdispersion_puff.gif b/projects/gas_dispersion_jl/gasdispersion_puff.gif new file mode 100644 index 0000000..b483757 Binary files /dev/null and b/projects/gas_dispersion_jl/gasdispersion_puff.gif differ diff --git a/projects/gas_dispersion_jl/gasdispersion_simplejet.svg b/projects/gas_dispersion_jl/gasdispersion_simplejet.svg new file mode 100644 index 0000000..3734783 --- /dev/null +++ b/projects/gas_dispersion_jl/gasdispersion_simplejet.svg @@ -0,0 +1,828 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/gas_dispersion_jl/index.html b/projects/gas_dispersion_jl/index.html new file mode 100644 index 0000000..de3795e --- /dev/null +++ b/projects/gas_dispersion_jl/index.html @@ -0,0 +1,826 @@ + + + + + + + + + + +GasDispersion.jl – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ + + + +
+ + +
+
+

GasDispersion.jl

+
+
julia
+
+
+ +
+
+ A julia package for dispersion modeling of chemical releases +
+
+ + +
+ + + + +
+ + + +
+ + +

LICENSE Stable Documentation Dev Documentation Build Status Coverage

+

GasDispersion.jl aims to bring together several models for dispersion modelling of chemical releases with a consistent interface. Currently it implements several Gaussian dispersion models, the Britter-McQuaid dense gas dispersion model, a subset of the SLAB dense gas dispersion model, and others.

+
+
+
+

+
+
+

+
+
+
+
+

+
+
+

+
+
+
+ + + +
+ +
+ + + + + + + \ No newline at end of file diff --git a/projects/picocalc/cardiod.bmp b/projects/picocalc/cardiod.bmp new file mode 100644 index 0000000..bbea247 Binary files /dev/null and b/projects/picocalc/cardiod.bmp differ diff --git a/projects/picocalc/index.html b/projects/picocalc/index.html new file mode 100644 index 0000000..69da9b1 --- /dev/null +++ b/projects/picocalc/index.html @@ -0,0 +1,833 @@ + + + + + + + + + + +PicoMite Library – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ + + + +
+ + +
+
+

PicoMite Library

+
+
basic
+
+
+ +
+
+ A collection of PicoMite BASIC programs written for the PicoCalc +
+
+ + +
+ + + + +
+ + + +
+ + +

LICENSE 

+

A collection of programs written in PicoMite BASIC for the PicoCalc.

+

The programs are organized into the following categories:

+ +
+
+
+

+
+
+

+
+
+
+
+

+
+
+

+
+
+
+ + + +
+ +
+ + + + + + + \ No newline at end of file diff --git a/projects/picocalc/out.bmp b/projects/picocalc/out.bmp new file mode 100644 index 0000000..94c2650 Binary files /dev/null and b/projects/picocalc/out.bmp differ diff --git a/projects/picocalc/snake.bmp b/projects/picocalc/snake.bmp new file mode 100644 index 0000000..9877b8e Binary files /dev/null and b/projects/picocalc/snake.bmp differ diff --git a/projects/picocalc/tone.bmp b/projects/picocalc/tone.bmp new file mode 100644 index 0000000..436b0d9 Binary files /dev/null and b/projects/picocalc/tone.bmp differ diff --git a/projects/pymotube/index.html b/projects/pymotube/index.html new file mode 100644 index 0000000..1361235 --- /dev/null +++ b/projects/pymotube/index.html @@ -0,0 +1,950 @@ + + + + + + + + + + +PymoTube – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ + + + +
+ + +
+
+

PymoTube

+
+
python
+
+
+ +
+
+ A python module for logging data from an AtmoTube over bluetooth. +
+
+ + +
+ + + + +
+ + + +
+ + +

LICENSE Tests codecov

+

A python module for logging data from an AtmoTube via bluetooth. Very much in development.

+

PymoTube is currently just a set of helper functions and classes for taking bytearrays returned by the AtmoTube and turning it into a basic struct. The actual connection to the AtmoTube is managed by Bleak. A minimal example of connecting to an AtmoTube and logging results into an asynchronous queue is shown here

+
+

A Logging Example

+

As an example of how to use this, consider the case where you want to connect to your AtmoTube from a PC, log data from it over a pre-defined period, then exit. This will be done asynchronously and the retrieved data put into an asynchronous queue for processing.

+

The first step is to create a function which connects to the AtmoTube, collects data, and then puts that data into a queue.

+
from bleak import BleakClient, BleakScanner
+from atmotube import start_gatt_notifications, get_available_services
+import asyncio
+
+
+async def collect_data(mac, queue, collection_time):
+1    async def callback_queue(packet):
+        await queue.put(packet)
+
+2    device = await BleakScanner.find_device_by_address(mac)
+    if not device:
+        raise Exception("Device not found")
+
+3    async with BleakClient(device) as client:
+        if not client.is_connected:
+            raise Exception("Failed to connect to device")
+4        packet_list = get_available_services(client)
+5        await start_gatt_notifications(client, callback_queue,
+                                       packet_list=packet_list)
+6        await asyncio.sleep(collection_time)
+7        await queue.put(None)
+
+
1
+
+Define a callback function which takes a packet of data and does something with it. In this case it puts it in an asynchronous queue. +
+
2
+
+Find the device using Bleak, in this case by mac address +
+
3
+
+Connect to the device using Bleak +
+
4
+
+Call the get_available_services function with the connected device, this generates a list of GATT services that both the atmotube library knows about and the AtmoTube device supports. +
+
5
+
+Start the GATT notifications for the list of available services. If no list is provided, it will attempt to start GATT notifications for all services supported by the atmotube library. +
+
6
+
+Wait while data is collected, for the pre-defined collection time (in seconds) +
+
7
+
+Put a None on the queue to indicate that the collection has ended. +
+
+

What ends up on the queue is a series of AtmoTubePacket objects representing the different types of data packets the AtmoTube returns. Each packet has an associated datetime object representing the time when the packet was received. As a somewhat silly example, this takes those packets and logs them to the logger – a more realistic thing to do might be to put the data in a database or append the data to a CSV.

+
from atmotube import SPS30Packet, StatusPacket, BME280Packet, SGPC3Packet
+
+import logging
+
+
+def log_packet(packet):
+1    match packet:
+        case AtmotubeProStatus():
+2            logging.info(f"{str(packet.date_time)} - Status Packet - "
+                         f"Battery: {packet.battery_level}%, "
+                         f"PM Sensor: {packet.pm_sensor_status}, "
+                         f"Pre-heating: {packet.pre_heating}, "
+                         f"Error: {packet.error_flag}")
+        case AtmotubeProSPS30():
+            logging.info(f"{str(packet.date_time)} - SPS30 Packet - "
+                         f"PM1: {packet.pm1} µg/m³, "
+                         f"PM2.5: {packet.pm2_5} µg/m³, "
+                         f"PM4: {packet.pm4} µg/m³, "
+                         f"PM10: {packet.pm10} µg/m³")
+        case AtmotubeProBME280():
+            logging.info(f"{str(packet.date_time)} - BME280 Packet - "
+                         f"Humidity: {packet.humidity}%, "
+                         f"Temperature: {packet.temperature}°C, "
+                         f"Pressure: {packet.pressure} mbar")
+        case AtmotubeProSGPC3():
+            logging.info(f"{str(packet.date_time)} - SGPC3 Packet - "
+                         f"TVOC: {packet.tvoc} ppb")
+        case _:
+            logging.info("Unknown packet type")
+
+
1
+
+Use structural pattern matching to identify which data has been returned. +
+
2
+
+Send some information about it to the logger +
+
+

Finally, an asynchronous event loop is created which runs the collector and then logs the data.

+
ATMOTUBE = "00:00:00:00:00:00" # the mac address of the ATMOTUBE
+
+def main():
+    mac = ATMOTUBE
+    collection_time = 60  # seconds
+1    queue = asyncio.Queue()
+
+2    async def runner():
+3        collector = asyncio.create_task(
+            collect_data(mac, queue, collection_time)
+            )
+4        while True:
+            item = await queue.get()
+            if item is None:
+                break
+            log_packet(item)
+        await collector
+
+    asyncio.run(runner())
+
+
+if __name__ == "__main__":
+    logging.basicConfig(level=logging.INFO)
+    main()
+
+
1
+
+Initialize an asynchronous queue, this will be used to pass the data between the two workers +
+
2
+
+Create a runner function to handle the main sequence of events +
+
3
+
+Start the collector +
+
4
+
+Wait for data to appear on the queue, and then pass the data to log_packet +
+
+ + +
+ +
+ +
+ + + + + + \ No newline at end of file diff --git a/projects/pymotube/pymotube.png b/projects/pymotube/pymotube.png new file mode 100644 index 0000000..e875bcc Binary files /dev/null and b/projects/pymotube/pymotube.png differ diff --git a/projects/unitfulcorrelations_jl/index.html b/projects/unitfulcorrelations_jl/index.html new file mode 100644 index 0000000..a215c22 --- /dev/null +++ b/projects/unitfulcorrelations_jl/index.html @@ -0,0 +1,864 @@ + + + + + + + + + + +UnitfulCorrelations.jl – A Chemical Engineer’s Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ + + + +
+ + +
+
+

UnitfulCorrelations.jl

+
+
julia
+
+
+ +
+
+ A julia module for using correlations with Unitful +
+
+ + +
+ + + + +
+ + + +
+ + +

LICENSE 

+

A simple macro for working with empirical correlations and Unitful.

+
+

Installation

+

UnitfulCorrelations.jl can be installed using Julia’s built-in package manager. In a Julia session, enter the package manager mode by hitting ], then run the command

+
pkg> add https://github.com/aefarrell/UnitfulCorrelations.jl
+
+
+

Examples

+

Suppose you have an empirical correlation \(f(x) = 0.92 x^{0.2}\), where it is given that \(x\) is in meters and \(f\) is in seconds. You could figure out the units that the constants must have to make everything work out, or write a function that uses ustrip() to manage units, but that can get tedious if there are a lot of these.

+

The macro @ucorrel does this for you, adding another method for the case where the function is called with units. The arguments are: function (or function block), input units, output units.

+

+f(x) = 0.92*x^0.2
+@ucorrel f u"m" u"s"
+
+# f(2) == 1.0568024865972723
+# f(2 u"m") == 1.0568024865972723 u"s"
+

this can also be done with a function block

+

+@ucorrel function f(x)
+    return 0.92*x^0.2
+end u"m" u"s"
+
+# f(2) == 1.0568024865972723
+# f(2 u"m") == 1.0568024865972723 u"s"
+

So far it only supports one dimensional correlations, because I have basically just copied over a macro that I use frequently and have not added anything to its functionality.

+ + +
+ +
+ +
+ + + + + + \ No newline at end of file diff --git a/projects/unitfulcorrelations_jl/unitfulcorrelations.png b/projects/unitfulcorrelations_jl/unitfulcorrelations.png new file mode 100644 index 0000000..0fa78eb Binary files /dev/null and b/projects/unitfulcorrelations_jl/unitfulcorrelations.png differ diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..ae0567d --- /dev/null +++ b/robots.txt @@ -0,0 +1 @@ +Sitemap: https://aefarrell.github.io/sitemap.xml diff --git a/search.json b/search.json new file mode 100644 index 0000000..4ba9056 --- /dev/null +++ b/search.json @@ -0,0 +1,1745 @@ +[ + { + "objectID": "about.html", + "href": "about.html", + "title": "Allan Farrell", + "section": "", + "text": "This blog is a collection of (mostly) jupyter notebooks, in either python or julia, solving various engineering and math problems. These are my weekend projects and are often inspired by things happening in the world, interesting problems I may have encountered at work, or just passing interests of mine. There isn’t really a theme other than mostly chemical engineering, since that’s my profession, and mostly process safety and consequence modelling, as that’s something I’m personally interested in.\nI think the best way to learn something new is to try it out yourself, play around with solving problems, see what works and what doesn’t. That’s what these notebooks are. I am also a big believer in putting one’s random projects and terrible code online for other people to look at. The source code for each post is available for you to download and modify to your hearts content. I also try to provide references for everything I’m doing, and those are a good resource for more context. This is a great opportunity for you to tell me all the ways my code is terrible and what I should be doing instead, or tell me all the interesting things you did with it, and the new directions you went in. The internet is a better place when we share." + }, + { + "objectID": "about.html#what-this-is", + "href": "about.html#what-this-is", + "title": "Allan Farrell", + "section": "", + "text": "This blog is a collection of (mostly) jupyter notebooks, in either python or julia, solving various engineering and math problems. These are my weekend projects and are often inspired by things happening in the world, interesting problems I may have encountered at work, or just passing interests of mine. There isn’t really a theme other than mostly chemical engineering, since that’s my profession, and mostly process safety and consequence modelling, as that’s something I’m personally interested in.\nI think the best way to learn something new is to try it out yourself, play around with solving problems, see what works and what doesn’t. That’s what these notebooks are. I am also a big believer in putting one’s random projects and terrible code online for other people to look at. The source code for each post is available for you to download and modify to your hearts content. I also try to provide references for everything I’m doing, and those are a good resource for more context. This is a great opportunity for you to tell me all the ways my code is terrible and what I should be doing instead, or tell me all the interesting things you did with it, and the new directions you went in. The internet is a better place when we share." + }, + { + "objectID": "about.html#what-this-is-not", + "href": "about.html#what-this-is-not", + "title": "Allan Farrell", + "section": "What this is not", + "text": "What this is not\nThese blog posts do not contain my professional advice or opinion, nor do they represent the opinions of my employer. These are weekend projects, with no guarantees of correctness. You have to think for yourself." + }, + { + "objectID": "about.html#some-technical-caveats", + "href": "about.html#some-technical-caveats", + "title": "Allan Farrell", + "section": "Some technical caveats", + "text": "Some technical caveats\nThe blog itself is rendered directly from the jupyter notebooks by quarto. However, a lot of the boiler plate and set-up is hidden in the final blog post for readability. If you want more details (especially how the plots are generated), please see the source noteboook.\nPosts which use julia take advantage of the built in environment manager and there is an associated Project.toml for each notebook with compat entries frozen at the last known working versions. Many packages under active development will change significantly over time, so it is worth checking to see which version I was using as the package may have changed in the meantime.\nPosts which use python use the poetry-kernel to track the associated virtual environments, which are maintained in the pyproject.toml for each notebook. This shows the state of the virtual environment at the last time I ran the notebook." + }, + { + "objectID": "index.html", + "href": "index.html", + "title": "A Chemical Engineer's Notebook", + "section": "", + "text": "Delivering Hydrogen Fuel Gas\n\n\n\n\n\nThinking about hydrogen as a utility fuel gas by way of the relative compression costs.\n\n\n\n\n\nMay 7, 2026\n\n\nAllan Farrell\n\n11 min\n\n\n\n\n\n\n\n\n\n\n\nThe Masses of Clouds\n\n\n\n\n\nCalculating the mass of a Gaussian plume.\n\n\n\n\n\nFeb 22, 2026\n\n\nAllan Farrell\n\n18 min\n\n\n\n\n\n\n\n\n\n\n\nVessel Blowdown and Dispersion\n\n\n\n\n\nConsidering the Gaussian dispersion of an isothermal blowdown case.\n\n\n\n\n\nDec 23, 2025\n\n\nAllan Farrell\n\n10 min\n\n\n\n\n\n\n\n\n\n\n\nThe Ooms Plume Model\n\n\n\n\n\nAn integral plume model for buoyant plumes.\n\n\n\n\n\nJun 15, 2025\n\n\nAllan Farrell\n\n38 min\n\n\n\n\n\n\n\n\n\n\n\nLogging data from an Atmotube PRO over Bluetooth\n\n\n\n\n\nHaving fun with data logging.\n\n\n\n\n\nMay 19, 2025\n\n\nAllan Farrell\n\n15 min\n\n\n\n\n\n\n\n\n\n\n\nMapping Pollen Dispersion\n\n\n\n\n\nCalculating how far the wind blows.\n\n\n\n\n\nMay 10, 2025\n\n\nAllan Farrell\n\n26 min\n\n\n\n\n\n\n\n\n\n\n\nVessel Blowdown - Real Gases\n\n\n\n\n\nModelling vessel blowdowns using equations of state.\n\n\n\n\n\nMar 19, 2025\n\n\nAllan Farrell\n\n29 min\n\n\n\n\n\n\n\n\n\n\n\nVessel Blowdown - Ideal Gases\n\n\n\n\n\nEvaluating approaches to ideal gas blowdowns.\n\n\n\n\n\nJan 24, 2025\n\n\nAllan Farrell\n\n25 min\n\n\n\n\n\n\n\n\n\n\n\nRelief Valve Sizing with Real Gases\n\n\n\n\n\nCompressible orifice flow calculations using equations of state.\n\n\n\n\n\nOct 28, 2024\n\n\nAllan Farrell\n\n20 min\n\n\n\n\n\n\n\n\n\n\n\nModelling Hydrogen Releases Using HyRAM+\n\n\n\n\n\nHydrogen plume modelling and indoor accumulation.\n\n\n\n\n\nSep 22, 2024\n\n\nAllan Farrell\n\n14 min\n\n\n\n\n\n\n\n\n\n\n\nPlastics Recycling and Microplastics\n\n\n\n\n\nIs plastic recycling a huge source of microplastics?\n\n\n\n\n\nJul 14, 2024\n\n\nAllan Farrell\n\n9 min\n\n\n\n\n\n\n\n\n\n\n\nEngineering a Cup of Coffee Part Two: Espresso\n\n\n\n\n\nModelling espresso bed extraction.\n\n\n\n\n\nMar 23, 2024\n\n\nAllan Farrell\n\n44 min\n\n\n\n\n\n\n\n\n\n\n\nEstimating the impact of fugitive emissions\n\n\n\n\n\nEvaluating the zero emissions fuel.\n\n\n\n\n\nJan 3, 2024\n\n\nAllan Farrell\n\n19 min\n\n\n\n\n\n\n\n\n\n\n\nImpossible bowling\n\n\n\n\n\nLooking for impossible bowling games.\n\n\n\n\n\nNov 26, 2023\n\n\nAllan Farrell\n\n15 min\n\n\n\n\n\n\n\n\n\n\n\nMessing around with model parameters\n\n\n\n\n\nThe importance of choosing the right references.\n\n\n\n\n\nOct 30, 2023\n\n\nAllan Farrell\n\n8 min\n\n\n\n\n\n\n\n\n\n\n\nEngineering a Cup of Coffee\n\n\n\n\n\nBetter coffee through chemical engineering.\n\n\n\n\n\nSep 15, 2023\n\n\nAllan Farrell\n\n24 min\n\n\n\n\n\n\n\n\n\n\n\nMonitoring smoke infiltration\n\n\n\n\n\nBetter indoor air quality through data.\n\n\n\n\n\nMay 22, 2023\n\n\nAllan Farrell\n\n9 min\n\n\n\n\n\n\n\n\n\n\n\nTaking a second look at the Britter-McQuaid model\n\n\n\n\n\nRe-evaluating plume extents and determining the explosive mass\n\n\n\n\n\nMar 12, 2023\n\n\nAllan Farrell\n\n16 min\n\n\n\n\n\n\n\n\n\n\n\nIntegrating a Gaussian puff - mistakes were made\n\n\n\n\n\nSuccessive approximations to … an integrated gaussian puff model.\n\n\n\n\n\nJan 15, 2023\n\n\nAllan Farrell\n\n10 min\n\n\n\n\n\n\n\n\n\n\n\nDynamic Mode Decomposition\n\n\n\n\n\nDynamic mode decomposition of fluid flow problems.\n\n\n\n\n\nDec 18, 2022\n\n\nAllan Farrell\n\n17 min\n\n\n\n\n\n\n\n\n\n\n\nHydrogen Blending\n\n\n\n\n\nBlending hydrogen into natural gas.\n\n\n\n\n\nNov 10, 2022\n\n\nAllan Farrell\n\n15 min\n\n\n\n\n\n\n\n\n\n\n\nAdiabatic Compressible Flow in a Pipe\n\n\n\n\n\nEvaluating different models of adiabatic pipe flow.\n\n\n\n\n\nSep 23, 2022\n\n\nAllan Farrell\n\n17 min\n\n\n\n\n\n\n\n\n\n\n\nBetween a puff and a plume\n\n\n\n\n\nAn integrated Gaussian puff model\n\n\n\n\n\nJun 10, 2022\n\n\nAllan Farrell\n\n9 min\n\n\n\n\n\n\n\n\n\n\n\nMore on Turbulent Jets\n\n\n\n\n\nCalculating concentrations, temperatures, and flow rates.\n\n\n\n\n\nMay 8, 2022\n\n\nAllan Farrell\n\n19 min\n\n\n\n\n\n\n\n\n\n\n\nTurbulent Jets\n\n\n\n\n\nNotes on turbulent jets and velocity profiles.\n\n\n\n\n\nApr 8, 2022\n\n\nAllan Farrell\n\n24 min\n\n\n\n\n\n\n\n\n\n\n\nThe 2021 Canadian Federal Election\n\n\n\n\n\nAn analysis of how exceptionally little changed.\n\n\n\n\n\nSep 22, 2021\n\n\nAllan Farrell\n\n4 min\n\n\n\n\n\n\n\n\n\n\n\nSmoke Days\n\n\n\n\n\nFrequency of forest fire smoke events.\n\n\n\n\n\nJul 18, 2021\n\n\nAllan Farrell\n\n6 min\n\n\n\n\n\n\n\n\n\n\n\nBuilding Infiltration Example – Chlorine Release\n\n\n\n\n\nSingle zone building infiltration model with an instantaneous release\n\n\n\n\n\nJun 19, 2021\n\n\nAllan Farrell\n\n21 min\n\n\n\n\n\n\n\n\n\n\n\nBuilding Infiltration Example\n\n\n\n\n\nSingle zone building infiltration of forest fire smoke.\n\n\n\n\n\nMay 22, 2021\n\n\nAllan Farrell\n\n16 min\n\n\n\n\n\n\n\n\n\n\n\nTurbulent Jet Example - Acetylene Leak\n\n\n\n\n\nEstimating the explosive mass.\n\n\n\n\n\nApr 10, 2021\n\n\nAllan Farrell\n\n7 min\n\n\n\n\n\n\n\n\n\n\n\nVCE Example - Butane Vapour Cloud\n\n\n\n\n\nUsing the Baker-Strehlow-Tang model for a vapour cloud explosion.\n\n\n\n\n\nJan 9, 2021\n\n\nAllan Farrell\n\n22 min\n\n\n\n\n\n\n\n\n\n\n\nWorst Case Meterological Conditions\n\n\n\n\n\nThe worst case weather conditions for air dispersion modeling.\n\n\n\n\n\nDec 12, 2020\n\n\nAllan Farrell\n\n12 min\n\n\n\n\n\n\n\n\n\n\n\nAir Dispersion Example - Gaussian Dispersion Model of Stack Emissions\n\n\n\n\n\nEstimating the airborne quantity.\n\n\n\n\n\nDec 5, 2020\n\n\nAllan Farrell\n\n18 min\n\n\n\n\n\n\n\n\n\n\n\nCompressible Flow Example - Sizing a Goose Neck Vent\n\n\n\n\n\nCalculating the minimum diameter in incompressible, isothermal, and adiabatic flow situations.\n\n\n\n\n\nNov 28, 2020\n\n\nAllan Farrell\n\n16 min\n\n\n\n\n\n\n\n\n\n\n\nChemical Release Screening Example - Butane leak\n\n\n\n\n\nEstimating the airborne quantity.\n\n\n\n\n\nNov 20, 2020\n\n\nAllan Farrell\n\n20 min\n\n\n\n\nNo matching items" + }, + { + "objectID": "posts/adiabatic-compressible-flow/index.html", + "href": "posts/adiabatic-compressible-flow/index.html", + "title": "Adiabatic Compressible Flow in a Pipe", + "section": "", + "text": "I was looking through some books and it struck me how strangely inconsistent many standard references are when it comes to adiabatic compressible pipe flow. There are standard methods for incompressible flow and isothermal compressible flow of an ideal gas, but when it comes to adiabatic pipe flow the guidance is very scattershot.\nAs a brief review of some common references: Crane’s1 gives a graphical method for adiabatic flow, which is the easiest to use with a pencil and paper, but doesn’t give a lot of details on how that model was developed. Albright’s2 recommends assuming flow is locally isentropic and gives a model of isentropic flow – that is flow which is both adiabatic and reversible – but with frictional losses also included, which allows for direct calculation if one assumes the friction factor is constant (with respect to the Reynold’s number). Perry’s3 gives the adiabatic irreversible flow model (i.e. Fanno flow), though with only a sketch of how to perform the iterative solution. Hall4 gives the Fanno flow model and, helpfully, a procedure for how to actually do the calculations and example VBA code. Ludwig’s5 gives both the isentropic and Fanno flow models but in a very confused manner: the section labeled “Adiabatic Flow” gives a model of isentropic flow (albeit with a typo in the equation) and suggests that all adiabatic flow is isentropic (which is false) and much later in a section labeled “Other Simplified Compressible Flow Methods” gives the Fanno flow model, though it doesn’t explain what it is, misattributes the derivation, and gives no clues on how to use it. Probably the best reference to sort all of this out is Coulson and Richardson’s6 as it provides easy to follow derivations of both the reversible and irreversible adiabatic flow models (the isentropic and Fanno flow models) and highlights their differences.\nAnother part of this confusion is differences in how the problem is being approached – or what problem, exactly, one is trying to solve. Typically the isothermal and isentropic flow models are presented as ways to solve for the flowrate given the pressure drop between two points, whereas the Fanno flow model is often given in terms of the Mach number and one is solving for the pressure drop. If you have the Mach number, rather obviously, you already know the flow, and it is often left as an exercise for the reader to figure out how to use the Fanno flow model to solve for flow.\nGiven all of that, I thought it may be worthwhile to unpack these various approaches to adiabatic flow, and see how they perform relative to one another." + }, + { + "objectID": "posts/adiabatic-compressible-flow/index.html#motivating-example", + "href": "posts/adiabatic-compressible-flow/index.html#motivating-example", + "title": "Adiabatic Compressible Flow in a Pipe", + "section": "Motivating Example", + "text": "Motivating Example\nTo give us something to work towards, suppose we wish calculate the flowrate of air in a horizontal section of piping – a 20m length of 2in schedule 40 steel pipe. In this case the pipe starts a 100kPag vessel which is at ambient temperature and exits into the air at ambient pressure.\n\n\n\n\n\n\nFigure 1: A sketch of the example system, a long straight section of pipe through which air is flowing.\n\n\n\n\n# Pipe dimensions\nL = 20 # m\nD = 0.0525 # m\nϵ = 0.0457*1e-3 # m\n\nA = 0.25*π*D^2\n\nFor “ambient” conditions I am assuming standard conditions: 1 atmosphere and 15°C\n\nP₂ = 101325 #Pa\nP₁ = P₂ + 100e3\nT₁ = 288.15 #K\n\n\nKey Assumptions\n\nAir is an ideal gas, Z=1\nThe ratio of heat capacities, γ is constant\nHeat loss is negligible, Δq=0\nFlow is steady state, \\(\\dot{m}_{in} = \\dot{m}_{out}\\)\nFlow is turbulent, α=1\nFlow is horizontal, Δz=0\nFriction factor is constant along the length\n\n\n# Universal gas constant \n# to more digits than are at all necessary\nR = 8.31446261815324 # Pa⋅m³/mol/K\n\n# Some useful physical properties of air\nMw = 0.02896 # kg/mol\nγ = 1.4 # Cp/Cv, ideal gas\n\n# density of air, ideal gas law\nρ(P,T) = (P*Mw)/(R*T); # kg/m³\n\n# viscosity of air, from Perry's\nμ(T) = (1.425e-6*T^0.5039)/(1+108.3/T); # Pa⋅s\n\nThe mass velocity, G = ρu, in a pipe with constant cross-sectional area at steady state is constant7, and the Reynold’s number can be written in terms of G as:\n7 This is a consequence of the steady state assumption, \\[\\dot{m}_{in} = G_{in} A = G_{out} A = \\dot{m}_{out}\\]\\[ \\mathrm{Re} = { {G D} \\over \\mu } \\]\nWhere only the viscosity is a function of temperature, and for most gases only weakly so.\n\n# Reynold's number\nRe(G,T) = G*D/μ(T);\n\nThe Darcy friction factor, f, is a function of the Reynolds number and, for ease of calculation, I am assuming the Churchill correlation applies,8 and that it can be taken as a constant at the average temperature (the arithmetic average of T1 and T2)\n8 Tilton, “Fluid and Particle Dynamics,” 6–11.\nfunction churchill(Re; κ=ϵ/D)\n A = (2.457 * log(1/((7/Re)^0.9 + 0.27*κ)))^16\n B = (37530/Re)^16\n return 8*((8/Re)^12 + 1/(A+B)^(3/2))^(1/12)\nend;\n\nK_entrance = 0.5\nK_exit = 1.0\n\nKf(Re) = K_entrance + churchill(Re)*L/D + K_exit;\n\nFor a large Reynolds number approximation I am using the Nikuradse rough pipe law.9\n9 Crane, “TP410M,” 1–10.\nfₙ = (2*log10(3.7*D/ϵ))^-2\n\nKf() = K_entrance + fₙ*L/D + K_exit;\n\n\n\nChoking Flow\nA pitfall of compressible flow calculations is that flow at the exit of the pipe cannot exceed Ma=1, once the exit velocity achieves sonic velocity then the exit pressure will rise and the overall flowrate will remain at a constant, no matter the upstream pressure.\nThe easiest way to check for this is to use the limiting factors in Crane’s.10 Using the estimated Kf > 8 and γ=1.4, we can check:\n10 Crane, A–23.\\[ {\\Delta P \\over P_1} \\lt 0.762 \\]\n\n(P₁ - P₂)/P₁\n\n0.496709300881659\n\n\n\n(P₁ - P₂)/P₁ < 0.762\n\ntrue\n\n\nThere is a fit to the critical pressure ratios, as a function of Kf\n\\[\\log \\left( {\\Delta P} \\over P_1 \\right) = A \\left( \\log K_f \\right)^3 + B \\left( \\log K_f \\right)^2 + C \\left( \\log K_f \\right) + D\\]\nWith the constants for γ=1.4:\n\n\n\nA\n0.0011\n\n\nB\n-0.0302\n\n\nC\n0.238\n\n\nD\n-0.6455\n\n\n\n\nfunction critical_pressure(Kf)\n @assert Kf > 0\n x = log(Kf)\n y = 0.0011*x^3 - 0.0302*x^2 + 0.238*x - 0.6455\n return exp(y)\nend\n\ncritical_pressure(Kf())\n\n0.7707810480736812\n\n\n\n(P₁ - P₂)/P₁ < critical_pressure(Kf())\n\ntrue\n\n\nFor this problem we are well within the sub-sonic region." + }, + { + "objectID": "posts/adiabatic-compressible-flow/index.html#mechanical-energy-balance", + "href": "posts/adiabatic-compressible-flow/index.html#mechanical-energy-balance", + "title": "Adiabatic Compressible Flow in a Pipe", + "section": "Mechanical Energy Balance", + "text": "Mechanical Energy Balance\nConsider the differential form of the mechanical energy balance:\n\\[ {u \\over \\alpha} du + g dz + v dP + \\delta W_s + \\delta F = 0 \\]\nFrom the assumptions listed above, and noting that in this system there is no shaft work Ws, this can be simplified to:\n\\[ u du + v dP + {u^2 \\over 2} {f \\over D} dl = 0 \\]\nwhere f is the Darcy friction factor.\nFor compressible flow the velocity, u, varies along the length of the pipe while the mass velocity does not, so it is convenient to make the substitution u=G/ρ=Gv\n\\[ G^2 v dv + v dP + G^2 {v^2 \\over 2} {f \\over D} dl = 0 \\]\nDividing through by v2 and integrating gives:\n\\[ G^2 \\log \\left( {v_2 \\over v_1} \\right) + \\int_{P_1}^{P_2} {dP \\over v} + {K_f \\over 2} G^2 = 0 \\]\nwhere Kf is the pipe friction fL/D\nThe integral \\(\\int {dP \\over v}\\) is where the reversible and irreversible models differ, but they both amount to the same thing: integrate over an adiabatic path, and solve the mechanical energy balance for G." + }, + { + "objectID": "posts/adiabatic-compressible-flow/index.html#reversible-adiabatic-flow-isentropic-flow", + "href": "posts/adiabatic-compressible-flow/index.html#reversible-adiabatic-flow-isentropic-flow", + "title": "Adiabatic Compressible Flow in a Pipe", + "section": "Reversible Adiabatic Flow (Isentropic Flow)", + "text": "Reversible Adiabatic Flow (Isentropic Flow)\nTypically the isentropic flow model comes as a consequence of examining non-isothermal flow more generally, where one assumes Pvk is constant with k being a function of heat transfer (for the isothermal case k=1). The adiabatic case is then taken to be when k=γ. I think this is the greatest source of vaguery and confusion in the various sources I’ve looked at. Coulson and Richardson’s11 emphasizes that this is only an approximation as this equates to assuming an isentropic path, but many other sources either don’t make the distinction or only hint at it.\n11 Chhabra and Shankar, Coulson and Richardson’s Chemical Engineering.\\[ Pv^\\gamma = P_1 v_1^\\gamma \\]\n\\[ \\int_{P_1}^{P_2} {dP \\over v} = \\int_{P_1}^{P_2} {1 \\over v_1} \\left( P \\over P_1 \\right)^{1 \\over \\gamma} dP \\\\\n= {\\gamma \\over {\\gamma + 1} } {P_1 \\over v_1} \\left( \\left( P_2 \\over P_1 \\right)^{ {\\gamma+1}\\over\\gamma} - 1 \\right)\\]\nSubstituting this into the mechanical energy balance gives\n\\[ G^2 \\log \\left( {v_2 \\over v_1} \\right) + {\\gamma \\over {\\gamma + 1} } {P_1 \\over v_1} \\left( \\left( P_2 \\over P_1 \\right)^{ {\\gamma+1}\\over\\gamma} - 1 \\right) + {K_f \\over 2} G^2 = 0 \\]\nMaking the substitution \\[ {v_2 \\over v_1} = \\left( P_1 \\over P_2 \\right)^{1\\over\\gamma} \\]\n\\[ \\left( {K_f \\over 2} - {1 \\over \\gamma} \\log \\left( {P_2 \\over P_1} \\right) \\right) G^2 + {\\gamma \\over {\\gamma + 1} } P_1 \\rho_1 \\left( \\left( P_2 \\over P_1 \\right)^{ {\\gamma+1}\\over\\gamma} - 1 \\right) = 0 \\]\nThis form is a convenient objective function for numerical solution, however it can be re-arranged to solve for G, giving:\n\\[ G = \\sqrt{ { {2 \\gamma \\over {\\gamma + 1} } P_1 \\rho_1 \\left( 1 - \\left( P_2 \\over P_1 \\right)^{ {\\gamma+1}\\over\\gamma} \\right) } \\over { K_f - {2 \\over \\gamma} \\log \\left( {P_2 \\over P_1} \\right) } } \\]\nWhich is the form typically given in texts. If one assumes Kf is a constant then the mass velocity can be calculated directly. In practice, however, Kf is a function of the Reynolds number and so this must be solved numerically.\n\nfunction isentropic_flow(P₁, K::Number; T₁=T₁, P₂=P₂, γ=γ)\n q = P₂/P₁\n ρ₁ = ρ(P₁,T₁)\n G = √( ((2γ/(γ+1))*P₁*ρ₁*(1-q^((γ+1)/γ)))/(K - (2/γ)*log(q)) )\n return G\nend;\n\n\nusing Roots: find_zero\n\nfunction isentropic_flow(P₁, K::Function; T₁=T₁, P₂=P₂, γ=γ) \n # Initialize Parameters\n q = P₂/P₁\n ρ₁ = ρ(P₁,T₁)\n T₂ = T₁*(q^((γ-1)/γ))\n Tₐᵥ = (T₁+T₂)/2\n \n # Initial guess\n G_est = isentropic_flow(P₁, K(); T₁=T₁, P₂=P₂, γ=γ)\n \n # Numerically solve for G\n obj(G) = (K(Re(G,Tₐᵥ)) - (2/γ)*log(q))*(G^2) - (2γ/(γ+1))*P₁*ρ₁*(1-q^((γ+1)/γ))\n G = find_zero(obj, G_est)\n \n return G\nend;\n\n\nṁ_i = isentropic_flow(P₁, Kf)*A\n\n0.43138829795543004\n\n\n\n\n\n\n\n\n\n\nFigure 2: The mass flowrate through the example piping system as a function of pressure drop, using an isentropic flow model." + }, + { + "objectID": "posts/adiabatic-compressible-flow/index.html#irreversible-adiabatic-flow-fanno-flow", + "href": "posts/adiabatic-compressible-flow/index.html#irreversible-adiabatic-flow-fanno-flow", + "title": "Adiabatic Compressible Flow in a Pipe", + "section": "Irreversible Adiabatic Flow (Fanno Flow)", + "text": "Irreversible Adiabatic Flow (Fanno Flow)\nThe integration for Fanno flow is decidedly more tedious. As a sketch, start with the invariant (which comes from taking an energy balance for an ideal gas):\n\\[ {1 \\over 2} \\left( Gv \\right)^2 + {\\gamma \\over {\\gamma -1} } Pv = \\textrm{a constant}\\]\nSolve for P, take the derivative to determine dP, substitute and integrate. The result is:\n\\[ \\int_{P_1}^{P_2} {dP \\over v} = { {\\gamma -1} \\over {\\gamma} } G^2 \\left( \\left(v_1 \\over v_2\\right) -1 -2 \\log \\left(v_1 \\over v_2\\right) \\right) - \\frac{1}{2} {P_1 \\over v_1} \\left( 1 - \\left(v_1 \\over v_2\\right)^2 \\right)\\]\nWhich, when substituted into the mechanical energy balance and simplified, becomes:\n\\[ \\left( K - { {\\gamma -1} \\over {2 \\gamma} } \\left( 1 - \\left( { \\rho_2 \\over \\rho_1 } \\right)^2 \\right) - { {\\gamma +1} \\over \\gamma} \\log \\left( { \\rho_2 \\over \\rho_1 } \\right) \\right) G^2 - \\left( 1 - \\left( { \\rho_2 \\over \\rho_1 } \\right)^2 \\right) P_1 \\rho_1 = 0 \\]\nand can be further re-arranged to solve for G\n\\[ G = \\sqrt{ { P_1 \\rho_1 \\left( 1 - \\left( { \\rho_2 \\over \\rho_1 } \\right)^2 \\right) } \\over { K - { {\\gamma -1} \\over {2 \\gamma} } \\left( 1 - \\left( { \\rho_2 \\over \\rho_1 } \\right)^2 \\right) - { {\\gamma +1} \\over \\gamma} \\log \\left( { \\rho_2 \\over \\rho_1 } \\right) } } \\]\nThe obvious complication here is that ρ2 is unknown, so solving this requires simultaneously solving for either ρ2 or T2.\nMost often the Fanno flow model is given in terms of the Mach number, however the equation above is equivalent. This can be shown most easily by starting with the definition of the Fanno parameter, Fa, and the relation K = Fa1 - Fa2,\n\\[ Fa = \\left(\\frac{1 - Ma^2}{\\gamma Ma^2}\\right) + \\left(\\frac{\\gamma + 1}{2\\gamma}\\right)\\log\\left[\\frac{Ma^2}{\\left(\\frac{2}{\\gamma + 1}\\right)\\left(1 + \\frac{\\gamma - 1}{2}Ma^2\\right)}\\right] \\]\n\\[ K = \\left(\\frac{1 - Ma_1^2}{\\gamma Ma_1^2}\\right) - \\left(\\frac{1 - Ma_2^2}{\\gamma Ma_2^2}\\right) + \\left(\\frac{\\gamma + 1}{2\\gamma}\\right)\\log\\left[\\frac{Ma_1^2}{Ma_2^2}\\frac{\\left(1 + \\frac{\\gamma - 1}{2}Ma_2^2\\right)}{\\left(1 + \\frac{\\gamma - 1}{2}Ma_1^2\\right)}\\right] \\]\nThen, making the substitution:\n\\[\\left(v_1 \\over v_2\\right)^2 = { {Ma_1^2 \\left( 1 + { {\\gamma-1}\\over 2} Ma_2^2 \\right)} \\over {Ma_2^2 \\left( 1 + { {\\gamma-1}\\over 2} Ma_1^2 \\right)} }\\]\nwe get:\n\\[ K = \\left( \\frac{1}{\\gamma Ma_1^2} + \\frac{\\gamma-1}{2\\gamma} \\right) \\left( 1 - \\left(v_1 \\over v_2\\right)^2 \\right) + \\frac{\\gamma + 1}{\\gamma} \\log\\left(v_1 \\over v_2\\right) \\]\nThen, using the definition of the Mach number, in terms of G, \\(Ma=\\frac{G}{\\sqrt{\\gamma P \\rho} }\\)\n\\[ K = \\left( \\frac{P_1 \\rho_1}{G^2} + \\frac{\\gamma-1}{2\\gamma} \\right) \\left( 1 - \\left(v_1 \\over v_2\\right)^2 \\right) + \\frac{\\gamma + 1}{\\gamma} \\log\\left(v_1 \\over v_2\\right) \\]\nSolving for G, and making the substitution ρ = 1/v\n\\[ G = \\sqrt{ { P_1 \\rho_1 \\left( 1 - \\left( { \\rho_2 \\over \\rho_1 } \\right)^2 \\right) } \\over { K - { {\\gamma -1} \\over {2 \\gamma} } \\left( 1 - \\left( { \\rho_2 \\over \\rho_1 } \\right)^2 \\right) - { {\\gamma +1} \\over \\gamma} \\log \\left( { \\rho_2 \\over \\rho_1 } \\right) } } \\]\nWhich puts us back where we started.\nWhen it comes to actually using the Fanno flow model, if the goal is to calculate the flowrate for a given pressure drop, working in terms of the specific volume or density is far easier than using the model given in terms of the Mach number.\n\nApproximating Temperature Change\nAn obvious simplifying assumption is to estimate the exit temperature using the relationship for isentropic flow.\n\\[ {T_2 \\over T_1} = \\left( P_2 \\over P_1 \\right)^{ {\\gamma-1} \\over \\gamma} \\]\nIf we were assuming Kf is constant, then using this assumption to estimate the density at the exit allows for a direct calculation of the mass velocity, no numerical methods required. In the more general case, the flow still needs to be calculated iteratively as the friction factor is a function of the flow (Reynolds number).\n\nfunction approx_fanno_flow(P₁, K::Number; T₁=T₁, P₂=P₂, γ=γ)\n ρ₁ = ρ(P₁, T₁)\n ρ₂ = ρ₁*(P₂/P₁)^(1/γ)\n q = ρ₂/ρ₁\n G = √( (P₁*ρ₁*(1-q^2))/(K - ((γ-1)/(2γ))*(1-q^2) - ((γ+1)/γ)*log(q)) )\n return G\nend;\n\n\nusing Roots: find_zero\n\nfunction approx_fanno_flow(P₁, K::Function; T₁=T₁, P₂=P₂, γ=γ)\n # Initializing some parameters\n T₂ = T₁*(P₂/P₁)^((γ-1)/γ)\n ρ₁ = ρ(P₁, T₁)\n ρ₂ = ρ(P₂, T₂)\n q = ρ₂/ρ₁\n Tₐᵥ = (T₁+T₂)/2\n \n # Initial guess\n G_est = approx_fanno_flow(P₁, K(); T₁=T₁, P₂=P₂, γ=γ)\n \n # Numerically solve for G\n obj(G) = (K(Re(G,Tₐᵥ)) - ((γ-1)/(2γ))*(1-q^2) - ((γ+1)/γ)*log(q))*(G^2) - (1-q^2)*P₁*ρ₁\n G = find_zero(obj, G_est)\n \n return G\nend;\n\n\nṁ_fa = approx_fanno_flow(P₁, Kf)*A\n\n0.38355173684967075\n\n\n\n\nThe Full Treatment\nActually calculating the exit conditions requires a little more work. I am going to simultaneously calculate the density at exit since it is somewhat simpler to work with than the temperature, though either could be done.\nTo start we note that:\n\\[ {1 \\over 2} \\left( Gv \\right)^2 + {\\gamma \\over {\\gamma -1} } Pv = C = \\textrm{a constant}\\]\nWhich is a quadratic in v, and solving for v:\n\\[ v = {1 \\over G^2} \\left( \\sqrt{ \\left( {\\gamma \\over {\\gamma -1} } P \\right)^2 + 2 G^2 C} - {\\gamma \\over {\\gamma-1} } P \\right) \\]\nWhere C is calculated at the entrance conditions and ρ = 1/v.\nFrom this the temperature at the exit can be backed out using the ideal gas law, and used to update the Reynolds number.\nThis makes the whole calculation somewhat more complicated, and I think that added complication makes the simplification that Kf is constant pointless – that assumption does not make the math easier in any case.\n\nusing Roots: find_zero\n\nfunction fanno_flow(P₁, K::Function; T₁=T₁, P₂=P₂, γ=γ)\n # Initialize some parameters\n ρ₁ = ρ(P₁,T₁)\n \n # Initial guesses\n q = (P₂/P₁)^(1/γ) # isentropic\n G_est = √( (P₁*ρ₁*(1-q^2))/(K() - ((γ-1)/(2γ))*(1-q^2) - ((γ+1)/γ)*log(q)) )\n \n function obj(G)\n # Calculate the downstream density\n C = 0.5*(G/ρ₁)^2 + (γ/(γ-1))*(P₁/ρ₁)\n G²v = √(((γ/(γ-1))*P₂)^2 + 2*(G^2)*C) - (γ/(γ-1))*P₂\n ρ₂ = (G^2)/G²v\n @assert ρ₂ > 0\n\n # Update Temperature dependent parameters\n T₂ = (P₂*Mw)/(R*ρ₂)\n Tₐᵥ = (T₁+T₂)/2\n\n # Calculate the objective value\n q = ρ₂/ρ₁\n return (K(Re(G,Tₐᵥ)) - ((γ-1)/(2γ))*(1-q^2) - ((γ+1)/γ)*log(q))*G^2 - P₁*ρ₁*(1-q^2)\n end\n \n G = find_zero(obj, G_est)\n \n return G\nend;\n\n\nfunction fanno_flow(P₁, K::Number; T₁=T₁, P₂=P₂, γ=γ)\n fn() = K\n fn(Re) = K\n return fanno_flow(P₁, fn; T₁=T₁, P₂=P₂, γ=γ)\nend;\n\n\nṁ_f = fanno_flow(P₁, Kf)*A\n\n0.40934309494917254\n\n\n\n\n\n\n\n\n\n\nFigure 3: The mass flowrate through the example piping system as a function of pressure drop, using an adiabatic Fanno flow model.\n\n\n\n\n\nThe approximation produces reasonable results in this case, especially at higher pressure drops, but one should always be cautious when mixing results from different models.12\n12 This is something worth keeping mind more generally, as I have seen the assumption that Fanno flow is approximately isentropic (implicitly) taken for calculating different flow parameters, and it is often a bad assumption. For example, some references use the isentropic choking condition for a nozzle as an estimate for the choking condition in Fanno flow. Unless the pipe is incredibly short this is a terrible approximation – in the current example the pressure drop exceeds the choking flow condition for a nozzle and yet the pipe flow is far from choked." + }, + { + "objectID": "posts/adiabatic-compressible-flow/index.html#expansion-factors-y-factors", + "href": "posts/adiabatic-compressible-flow/index.html#expansion-factors-y-factors", + "title": "Adiabatic Compressible Flow in a Pipe", + "section": "Expansion Factors (Y Factors)", + "text": "Expansion Factors (Y Factors)\nBy far the simplest method is to use a modified Darcy equation with expansion factors (Y factors). This takes the well known Darcy equation for incompressible pipe flow and, in a classic engineering move, tacks on a Y factor to account for all the complexity in adiabatic flow.\n\\[ G = Y \\sqrt{ { 2 \\rho_1 \\Delta P } \\over K } \\]\nWhere the expansion factor, Y, is read off of a chart. This is great if you are working things out by hand, but can present some challenges when calculating things on a computer. Ludwig’s13 provides a complicated series of equations to iteratively calculate the Y curves yourself, but I think if you are expending that level of effort then you really are not saving anything over using the Fanno model above. A much simpler approach is to either interpolate the critical expansion factor, Ycr, and critical pressure ratio, qcr, from the values given in Crane’s or use a correlation for them (that’s what I will use). Though this adds the wrinkle of only being able to use Y factors for gases with the same γ as what is either tabulated or available in a correlation.\n13 Coker, Ludwig’s Applied Process Design for Chemical and Petrochemical Plants.The actual Y value then comes from a simple linear relationship (where \\(q={ {\\Delta P} \\over P_1}\\) )\n\\[ Y = \\left( Y_{cr} -1 \\right) \\left( q \\over q_{cr} \\right) +1 \\]\nThis has the added convenience of telling you when you have crossed into choked flow, it happens when q>qcr.\nOne downside is that this method does not directly produce the exit conditions, so the Reynolds number is typically taken at the entrance conditions only. Since the Reynolds number is only a function of Temperature through the viscosity, this works out fine over ranges where the viscosity is approximately constant.14\n14 The temperature can be worked out by using the method given in the section for Fanno flow, calculating the invariant at entrance conditions (once G is known) and then solving for the exit density.\n# Correlations for γ=1.4\n\nfunction Ycr(K)\n x = log(K)\n y = 0.0006*x^3 - 0.0185*x^2 + 0.1141*x - 0.5304\n return exp(y)\nend;\n\nfunction qcr(K)\n x = log(K)\n y = 0.0011*x^3 - 0.0302*x^2 + 0.238*x - 0.6455\n return exp(y)\nend;\n\n\n\n\n\n\n\n\n\nFigure 4: The correlation curves for critical expansion factor and critical pressure ratio, along with tabulated values from Crane’s.15\n\n15 Crane, “TP410M,” A–23.\n\n\n\nThe correlation curves I am using fit the tabulated values from Crane’s reasonably well, but clearly the fit is not perfect.\n\nfunction Y(K,q)\n Yc, qc = Ycr(K), qcr(K)\n if q < qc\n return (Yc-1)*(q/qc)+1\n else\n return Yc\n end\nend;\n\n\n\n\n\n\n\n\n\nFigure 5: The expansion factor vs pressure ratio, this calculated example falls between the reference curves from Crane’s as expected.16\n\n16 Crane, A–23.\n\n\n\nThe calculated curve is between the bracketing curves in Crane’s and looks plausible.\nIf you are assuming that Kf is constant then the mass velocity can be calculated directly, however if you wish to be more exact you can also iterate.\n\nfunction modified_darcy(P₁, K::Number; T₁=T₁, P₂=P₂, γ=γ)\n ρ₁ = ρ(P₁,T₁)\n q = (P₁-P₂)/P₁\n G = Y(K,q)*√(2*ρ₁*(P₁-P₂)/K)\n return G\nend;\n\n\nusing Roots: find_zero\n\nfunction modified_darcy(P₁, K::Function; T₁=T₁, P₂=P₂, γ=γ)\n # Intialize Parameters\n ρ₁ = ρ(P₁,T₁)\n q = (P₁-P₂)/P₁\n \n # Initial Guess\n G_est = modified_darcy(P₁, K(); T₁=T₁, P₂=P₂, γ=γ)\n \n # Numerically solve for G\n obj(G) = G - modified_darcy(P₁, K(Re(G,T₁)); T₁=T₁, P₂=P₂, γ=γ) \n G = find_zero(obj, G_est)\n \n return G\nend;\n\n\nṁ_y = modified_darcy(P₁, Kf)*A\n\n0.4049511071122898\n\n\n\n\n\n\n\n\n\n\nFigure 6: The mass flowrate through the example piping system as a function of pressure drop, using the modified Darcy equation." + }, + { + "objectID": "posts/adiabatic-compressible-flow/index.html#comparison", + "href": "posts/adiabatic-compressible-flow/index.html#comparison", + "title": "Adiabatic Compressible Flow in a Pipe", + "section": "Comparison", + "text": "Comparison\nBelow is a plot showing all of the methods examined so far, including assuming the isothermal case (this a common recommendation for a simplifying assumption). The expansion factor method approximates the Fanno flow method, from which it was derived, quite well, to the point where they are essentially indistinguishable. The isothermal model is practically just as good for this particular example, while the isentropic model works well only for low pressure drops, the version of the Fanno flow that approximates the temperature as isentropic is the opposite, being the worst model at low pressure drops and converging towards the Fanno model at higher pressure drops.\n\n\n\n\n\n\n\n\nFigure 7: The mass flowrate through the example piping system as a function of pressure drop, showing all of the discussed models. Note the significant overlap of the Fanno flow, modified Darcy equation, and isothermal flow curves.\n\n\n\n\n\nBut this is just one example, perhaps we can look at a wider range of Kf and pressure drops. Conveniently, Crane’s has a table with Kf ranging from 1 to 100 and calculated pressure drops and expansion factors: the limiting factors. Using the models examined above, the effective expansion factors can be calculated quite easily for each K in Crane’s table (taking the pressure ratios as givens).\n\n\n\n\n\n\n\n\nFigure 8: Calculated expansion factors for flow at the critical K values tabulated in Crane’s, this represents flow at the critical pressure ratio17\n\n17 Crane, A–23.\n\n\n\nNote that this represents the greatest pressure drop for a given Kf, which should correspond to the “worst case” for most models (except the approximated Fanno model). The Fanno model and the correlation I was using to generate Y factors line up quite nicely, though there is a fair amount of scatter with the tabulated Y factors which is interesting. The isentropic model is close, but not in great agreement, over the entire range. What I find more interesting is how rapidly the isothermal model comes into agreement. We would expect, then, at Kf=100 that the isothermal model would basically fall on top of the Fanno model over the entire range of pressure.\n\n\n\n\n\n\n\n\nFigure 9: The mass flowrate for isothermal and Fanno flow models vs pressure drop for high K piping systems. Note that both lines overlap almost entirely for the entire range.\n\n\n\n\n\nThey are basically indistinguishable. However, this is not at all implying that the temperature in the Fanno flow model is remaining constant over these large pressure drops. As one would expect, the adiabatic flow moves further from isothermal as the pressure drop increases. The mass flows just happen to be the same.\n\n\n\n\n\n\n\n\nFigure 10: The exit temperature for isothermal, isentropic, and Fanno flow models vs pressure drop for high K piping systems. Note that while the isothermal and Fanno flow models may give identical mass flowrates, the exit conditions are quite different." + }, + { + "objectID": "posts/adiabatic-compressible-flow/index.html#final-thoughts", + "href": "posts/adiabatic-compressible-flow/index.html#final-thoughts", + "title": "Adiabatic Compressible Flow in a Pipe", + "section": "Final Thoughts", + "text": "Final Thoughts\nI think the big take-away is that the isentropic flow model is not a very good approximation of Fanno flow and references that suggest that it is are in error. The other big take-away may be that, at least when calculating mass flow rates, the isothermal model is often better than one would expect: it does well at low pressure drops and also for long lines where Kf is large. In practice, when the flow conditions are within the range of available Y factors, the modified Darcy equation is the easiest to use and gives excellent agreement with the full Fanno model, however when the situation is outside of that range and Y factors have to be calculated it is not a time-saver.\nThe big elephant in the room is that, in practice, no actual gas flow is perfectly ideal or perfectly adiabatic, nor is the friction factor truly a constant. These assumptions play a big role in the overall model error, and being fussy about some of the details of different adiabatic ideal gas models may amount to nothing in practice." + }, + { + "objectID": "posts/adiabatic-compressible-flow/index.html#references", + "href": "posts/adiabatic-compressible-flow/index.html#references", + "title": "Adiabatic Compressible Flow in a Pipe", + "section": "References", + "text": "References\n\n\nAlbright, Lyle F. Albright’s Chemical Engineering Handbook. Boca Raton: CRC Press, 2009.\n\n\nChhabra, R. P., and V. Shankar. Coulson and Richardson’s Chemical Engineering: Fluid Flow: Fundamentals and Applications. 7th ed. Vol. 1A. Amsterdam: Elsevier, 2018.\n\n\nCoker, A. Kayode. Ludwig’s Applied Process Design for Chemical and Petrochemical Plants. 4th ed. Amsterdam: Elsevier, 2007.\n\n\nCrane. “TP410M: Flow of Fluids.” Stamford, CT: Crane, 2013.\n\n\nHall, Stephen M. Rules of Thumb for Chemical Engineers. 6th ed. Amsterdam: Elsevier, 2018.\n\n\nTilton, James N. “Fluid and Particle Dynamics.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008." + }, + { + "objectID": "posts/intpuff2_successive_approximations/index.html", + "href": "posts/intpuff2_successive_approximations/index.html", + "title": "Integrating a Gaussian puff - mistakes were made", + "section": "", + "text": "The other day I was working on a project involving Gaussian puff models and I noticed that I had made a significant mistake, a mistake I have made several times without noticing, and one that invalidated a whole bunch of work I that I had done previously, so I thought this would be a good opportunity to examine my mistake and it’s consequences." + }, + { + "objectID": "posts/intpuff2_successive_approximations/index.html#the-gaussian-puff-model", + "href": "posts/intpuff2_successive_approximations/index.html#the-gaussian-puff-model", + "title": "Integrating a Gaussian puff - mistakes were made", + "section": "The Gaussian puff model", + "text": "The Gaussian puff model\nTo 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:\n\\[ c \\left(x,y,z,t \\right) = \\dot{m} \\Delta t \\cdot g_x(x, t) \\cdot g_y(y) \\cdot g_z(z) \\]\nwhere\n\\[ g_x(x,t) = {1 \\over \\sqrt{2\\pi} \\sigma_x } \\exp \\left( -\\frac{1}{2} \\left( x-u t \\over \\sigma_x \\right)^2 \\right) \\]\n\\[ g_y(y) = {1 \\over \\sqrt{2\\pi} \\sigma_y } \\exp \\left( -\\frac{1}{2} \\left( y \\over \\sigma_y \\right)^2 \\right) \\]\n\\[ g_z(z) = {2 \\over \\sqrt{2\\pi} \\sigma_z } \\exp \\left( -\\frac{1}{2} \\left( z \\over \\sigma_z \\right)^2 \\right) \\]\nWhere \\(\\dot{m}\\) 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.\nThe dispersion parameters, σx, σy, σz are all functions of the downwind distance and the atmospheric stability.\n\n# class F puff dispersion\n# x is in meters\nσx(x) = 0.024*x^0.89\nσy(x) = σx(x)\nσz(x) = 0.05*x^0.61" + }, + { + "objectID": "posts/intpuff2_successive_approximations/index.html#integrating-the-puff", + "href": "posts/intpuff2_successive_approximations/index.html#integrating-the-puff", + "title": "Integrating a Gaussian puff - mistakes were made", + "section": "Integrating the puff", + "text": "Integrating the puff\nWhat 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 \\({ \\Delta t \\over n }\\). Taking the limit as \\(n \\to \\infty\\) 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.\nThe 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 \\(g_{x}(x,t)\\) 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 \\(x_c = u t\\), they are in fact functions of time and this does not work.\nThis 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\n1 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases., 107–8.2 Lees, Loss Prevention in the Process Industries.3 Lees, 15/112.\\[ \\sigma = { C^2 \\over 2 } \\left( u t \\right)^{2-n} \\]\nwhere 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.\n4 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." + }, + { + "objectID": "posts/intpuff2_successive_approximations/index.html#dispersion-nearly-constants", + "href": "posts/intpuff2_successive_approximations/index.html#dispersion-nearly-constants", + "title": "Integrating a Gaussian puff - mistakes were made", + "section": "Dispersion nearly-constants", + "text": "Dispersion nearly-constants\nHow 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 \\([ x - \\sigma_x, x + \\sigma_x ]\\). 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.\n\n\n\n\n\n\n\n\nFigure 1: The relative change in dispersion parameters, ±1σ, as a function of downwind distance." + }, + { + "objectID": "posts/intpuff2_successive_approximations/index.html#different-approaches-to-approximation", + "href": "posts/intpuff2_successive_approximations/index.html#different-approaches-to-approximation", + "title": "Integrating a Gaussian puff - mistakes were made", + "section": "Different approaches to approximation", + "text": "Different approaches to approximation\nAnother 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.\nTo make life easier, going forward, I am going to define a unit-less time \\[ t = { u t^{\\prime} \\over L } \\]\nand unit-less distances\n\\[ x = {x^{\\prime} \\over L } \\\\ y = {y^{\\prime} \\over L } \\\\ z = {z^{\\prime} \\over L } \\]\nwhere I am abusing notation with the \\(\\prime\\) indicates the variable with units, and no \\(\\prime\\) indicates it is unitless. A characteristic length, \\(L\\), is introduced to make everything unitless and, due to the dispersion correlations \\(L = 1 \\mathrm{m}\\) is the most convenient.\nWe can then explore the performance of different approximations to the integrated puff model by only examining the Gaussian distributions – with no dependence upon \\(\\dot{m}\\) or u.\n\ng(ξ,σ) = exp(-0.5*(ξ/σ)^2)/(√(2π)*σ)\n\ngx(x, t) = g((x-t),σx(t))\ngy(y, t) = g(y,σy(t))\ngz(z, t) = 2*g(z,σz(t))\n\npf(x,y,z,t; Δt) = gx(x,t)*gy(y,t)*gz(z,t)*Δt\n\n\nSum of discrete puffs\nThe first type of approximation is to divide the release interval into n sub-intervals and n Gaussian puffs\n\nfunction Σpf(x,y,z,t; Δt, n)\n Δt = min(t,Δt)\n δt = Δt/(n-1)\n _sum = 0\n for i in 0:(n-1)\n t′ = t-i*δt\n pf_i = t′>0 ? gx(x,t′)*gy(y,t′)*gz(z,t′)*δt : 0\n _sum += pf_i\n end\n return _sum\nend\n\n\n\nIntegrating assuming constant σs\nThe next type of approximation is the one I made in the previous post wherein \\(g_x(x,t)\\) is integrated with respect to time, treating the σs as constants.\nThere is a little sleight of hand as I include the downwind distance dependence of the σs after the integration (they aren’t actually constants)\n\nusing SpecialFunctions: erf\n\nfunction ∫gx(x,t,Δt)\n Δt = min(t,Δt)\n a = (x-(t-Δt))/(√2*σx(t-Δt))\n b = (x-t)/(√2*σx(t))\n return erf(b,a)/2\nend\n\n∫pf_approx(x,y,z,t; Δt) = ∫gx(x,t,Δt)*gy(y,x)*gz(z,x)\n\n\n\nNumerically integrating the full model\nFinally, I take advantage of the QuadGK package to numerically integrate the Gaussian puff model, including the time dependence of the dispersion parameters.\n\nusing QuadGK: quadgk\n\nfunction ∫pf(x,y,z,t; Δt)\n Δt = min(t,Δt)\n integral, err = quadgk(τ -> gx(x,τ)*gy(y,τ)*gz(z,τ), t-Δt, t)\n return integral\nend" + }, + { + "objectID": "posts/intpuff2_successive_approximations/index.html#comparing-performance", + "href": "posts/intpuff2_successive_approximations/index.html#comparing-performance", + "title": "Integrating a Gaussian puff - mistakes were made", + "section": "Comparing performance", + "text": "Comparing performance\n\nModel error\nTo 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.\nJust 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\n\n\n\n\n\n\n\n\nFigure 2: Top: concentration profile over time, at a fixed location, for discrete sums of puffs, the approximated integral, and the exact integral. Bottom: the relative error of a sum of discrete puffs and the approximate integral.\n\n\n\n\n\nIn 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.\n\n\n\n\n\n\n\n\nFigure 3: Top: crosswind concentration profile, at a fixed location and time, for discrete sums of puffs, the approximated integral, and the exact integral. Bottom: the relative error of a sum of discrete puffs and the approximate integral.\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 4: Top: vertical concentration profile, at a fixed location and time, for discrete sums of puffs, the approximated integral, and the exact integral. Bottom: the relative error of a sum of discrete puffs and the approximate integral.\n\n\n\n\n\nThis 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.\n\n\n\n\n\n\n\n\nFigure 5: Top: concentration profile over time near the origin, for discrete sums of puffs, the approximated integral, and the exact integral. Bottom: the relative error of a sum of discrete puffs and the approximate integral.\n\n\n\n\n\n\n\nCompute time\nModel 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)\n\nusing BenchmarkTools: @benchmark\n\n# point of interest\nx₁ = 100\ny₁ = σy(x₁)\nz₁ = σz(x₁)\nt₁ = x₁\n\nStarting 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.\n\n@benchmark ∫pf(x₁,y₁,z₁,t₁; Δt=10)\n\n\nBenchmarkTools.Trial: 10000 samples with 1 evaluation.\n Range (min … max): 28.056 μs … 57.603 μs ┊ GC (min … max): 0.00% … 0.00%\n Time (median): 28.200 μs ┊ GC (median): 0.00%\n Time (mean ± σ): 28.570 μs ± 1.565 μs ┊ GC (mean ± σ): 0.00% ± 0.00%\n █▆▁ ▃▃ ▁▂▁▁ ▁\n ███▇▅██▇▅▄█████▆▆▆▇▆█▇▆▄▃▁▄▄▄▃▆▅▄▄▃▁▃▁▃▁▁▁▁▁▁▁▁▁▃▁▁▁▁▁▄▅▆▇▆ █\n 28.1 μs Histogram: log(frequency) by time 37.7 μs <\n Memory estimate: 384 bytes, allocs estimate: 3.\n\n\n\nAs 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.\n\n@benchmark Σpf(x₁,y₁,z₁,t₁; Δt=10, n=10)\n\n\nBenchmarkTools.Trial: 10000 samples with 8 evaluations.\n Range (min … max): 3.746 μs … 11.498 μs ┊ GC (min … max): 0.00% … 0.00%\n Time (median): 3.768 μs ┊ GC (median): 0.00%\n Time (mean ± σ): 3.920 μs ± 450.991 ns ┊ GC (mean ± σ): 0.00% ± 0.00%\n █ ▅ ▅▂▁▁▂ ▁\n ██▄█▇▅██████▇▆▄▄▄▃▅▄▄▂▄▄▅▃▄▅▅▆▇▇▆▆▇▇▆▆▅▅▅▅▆▅▅▅▅▄▃▅▅▄▅▅▄▄▄▂▃ █\n 3.75 μs Histogram: log(frequency) by time 5.97 μs <\n Memory estimate: 16 bytes, allocs estimate: 1.\n\n\n\n\n@benchmark Σpf(x₁,y₁,z₁,t₁; Δt=10, n=100)\n\n\nBenchmarkTools.Trial: 10000 samples with 1 evaluation.\n Range (min … max): 37.090 μs … 77.023 μs ┊ GC (min … max): 0.00% … 0.00%\n Time (median): 37.199 μs ┊ GC (median): 0.00%\n Time (mean ± σ): 37.760 μs ± 2.411 μs ┊ GC (mean ± σ): 0.00% ± 0.00%\n █ ▂▁ ▃ ▁\n █▇▁▁▁██▃▃▁▃█▇▇▆▄▄▇█▅▄▄▄▄▄▄▃▄▄▃▁▅▁▄▃▁▁▃▃▃▃▄▇▇█▇▇▆▄▅▅▅▆▄▄▆▄▄▄ █\n 37.1 μs Histogram: log(frequency) by time 49.3 μs <\n Memory estimate: 16 bytes, allocs estimate: 1.\n\n\n\nFinally 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.\n\n@benchmark ∫pf_approx(x₁,y₁,z₁,t₁; Δt=10)\n\n\nBenchmarkTools.Trial: 10000 samples with 189 evaluations.\n Range (min … max): 534.974 ns … 988.852 ns ┊ GC (min … max): 0.00% … 0.00%\n Time (median): 547.606 ns ┊ GC (median): 0.00%\n Time (mean ± σ): 560.844 ns ± 40.302 ns ┊ GC (mean ± σ): 0.00% ± 0.00%\n ▇▃▅█▅▅ ▅▃▄ ▃▄▁▁▁▁▁ ▂\n ██████▇█████████████▇█▇▇▆▆▆▆▅▇▇▆▅▆▇▆▆▅▅▅▅▄▄▆▅▅▅▆▆▄▅▅▂▅▄▅▄▃▅▅▅ █\n 535 ns Histogram: log(frequency) by time 770 ns <\n Memory estimate: 16 bytes, allocs estimate: 1.\n\n\n\nI 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." + }, + { + "objectID": "posts/intpuff2_successive_approximations/index.html#conclusions", + "href": "posts/intpuff2_successive_approximations/index.html#conclusions", + "title": "Integrating a Gaussian puff - mistakes were made", + "section": "Conclusions", + "text": "Conclusions\nI 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.\nIf 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." + }, + { + "objectID": "posts/intpuff2_successive_approximations/index.html#references", + "href": "posts/intpuff2_successive_approximations/index.html#references", + "title": "Integrating a Gaussian puff - mistakes were made", + "section": "References", + "text": "References\n\n\nAIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999.\n\n\nLees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996.\n\n\nSlade, David H. Meteorology and Atomic Energy. Springfield, VA: National Technical Information Service, 1968. https://doi.org/10.2172/4492043." + }, + { + "objectID": "posts/hydrogen_release_modeling/index.html", + "href": "posts/hydrogen_release_modeling/index.html", + "title": "Modelling Hydrogen Releases Using HyRAM+", + "section": "", + "text": "Continuing on a series of posts on hydrogen of sorts, this post is on modelling hydrogen releases for risk assessment. Industry in many places, and in Alberta in particular, is looking to hydrogen as a key component of the transition to a low carbon future. This means that, suddenly, there will by hydrogen pressure equipment in a lot of process areas where it wasn’t before, as fuel gas.\nHydrogen 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.\nDense 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.\nNeutral 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.\nThis 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.\nHyRAM+ 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." + }, + { + "objectID": "posts/hydrogen_release_modeling/index.html#the-scenario", + "href": "posts/hydrogen_release_modeling/index.html#the-scenario", + "title": "Modelling Hydrogen Releases Using HyRAM+", + "section": "The Scenario", + "text": "The Scenario\nJust 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.\n\n\n\n\n\n\nFigure 1: A sketch of the scenario: a hydrogen gas release from a fallen cylinder.\n\n\n\nUsing the HyRAM+ API we can create the ambient air, air, and hydrogen, h2, fluid models at initial conditions.\n\nimport numpy as np\nimport hyram.phys.api as api\nimport hyram.phys as hp\n\n\nTa, Pa = 288.15, 101325\nair = api.create_fluid(\"Air\", Ta, Pa)\n\nTh2, Ph2 = Ta, 35e6\nh2 = api.create_fluid(\"Hydrogen\", Th2, Ph2)\n\nThe 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.\n\nd_H = 25.4e-3/4 # mm\nc_d = 0.6 # assumed\ntheta = np.pi/4\norifice = hp.Orifice(d_H, c_d)\n\n\nModeling the Jet\nThe jet is modeled, by HyRAM+, as as a steady-state jet consisting of 3 distinct zones:3\n3 Ehrhart, Hecht, and Schroeder, Hydrogen Plus Other Alternative Fuels Risk Assessment Models (HyRAM+) Version 5.1 Technical Reference Manual.\nOrifice flow in which the release occurs isentropically through an orifice. HyRAM+ uses the CoolProp library to perform this calculation for the real fluid (unlike many other models which assume an ideal gas for simplicity). For most situations, such as with this example, the flow will be choked and the jet will enter the atmosphere at sonic velocity (Ma = 1) and under-expanded (i.e. the pressure in the jet is above atmospheric)\nNotional nozzle the under-expanded jet then expands to ambient pressure. HyRAM+ models this as occuring adiabatically and with no entrainment of ambient air. The expansion occurs across what is termed a notional nozzle as it is modeled as a nozzle stepping down the jet to ambient pressure, assuming isentropic expansion. The notional nozzle is assumed to be of negligible size, so this step is really about calculating the initial conditions for the actual dispersion.\nGaussian jet at the end of the notional nozzle, and assuming that no cryogenic effects need to be corrected for, the jet is assumed to follow a self-similar Gaussian profile in both velocity and concentration. It is the same Gaussian model I discussed previously for a turbulent jet, however in that case the jet center-line was simply a straight line. In this case the center-line follows a curve through space which needs to be solved for. This is done using an integral plume model not unlike the Ooms model4 which accounts for entrainment, conservation of momentum, and conservation of mass.\n\n4 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.\nHyRAM+ 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'\n\njet = hp.Jet(h2, orifice, air, theta0=theta,\n nn_conserve_momentum=True,\n nn_T='solve_energy',\n verbose=True)\n\nsolving for orifice flow... done\nsolving for notional nozzle... done.\nintegrating... done.\n\n\nWith this done, we can retrieve the mass flow-rate (in kg/s)\n\njet.mass_flow_rate\n\n0.4024272255826383\n\n\nWe can compare this to a simple ideal gas model of an adiabatic orifice5\n5 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases.\\[ \\dot{m} = c_d A_h \\sqrt{ \\rho_1 p_1 k \\left( 2 \\over k+1 \\right)^{k+1 \\over k-1} } \\]\n\nA_h = (np.pi/4)*d_H**2\nk = 1.41\n\nideal_gas_jet = c_d*A_h*np.sqrt( h2.rho*h2.P*k*pow(2/(k+1),(k+1)/(k-1)) )\n\nideal_gas_jet\n\n0.37797876222402915\n\n\n\njet.mass_flow_rate/ideal_gas_jet\n\n1.0646821086315916\n\n\nThe 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.\nIn 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.\n\n\nCalculating Downstream Distances\nFor 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.\n\nlfl = hp.FuelProperties(h2.species).LFL\n\nand calculate the distance, along the plume center-line, to the LFL\n\nstreamline_dists = jet.get_streamline_distances_to_mole_fractions([lfl])\n\nstreamline_dists[0]\n\n19.655324152591245\n\n\nsimilarly we can retrieve the x-y coordinates of the plume extent, out to the LFL\n\nmole_frac_dists = jet.get_xy_distances_to_mole_fractions([lfl])\n\nmole_frac_dists\n\n{0.04: [(0, 13.734800620965242), (0, 14.201255169630102)]}\n\n\n\n\n\n\n\n\n\nand, finally, calculate the flammable mass in the steady-state jet.\n\njet.m_flammable()\n\n0.2789535809847125\n\n\n\n\nPlotting the Plume Dispersion\nThe 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.\n\nx, y, X, Y, v, T = jet._contourdata\n\nwe can use matplotlib to plot the concentrations and highlight the contour corresponding to the LFL\n\n\n\n\n\n\n\n\n\n\n\nDoing it the Easy Way\nAbove 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.\n\nplume = api.analyze_jet_plume(air, h2, \n orif_diam=d_H,\n rel_angle=theta,\n dis_coeff=c_d,\n nozzle_model='yuce',\n contours=lfl,\n xmin=0.0,\n xmax=60,\n ymin=0.0,\n ymax=75,\n vmin=0,\n vmax=2*lfl,\n output_dir='figures',\n filename='h2_plume_fig.png')\n\n\nBy 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.\nThe mass flow-rate, distance along the streamline to the LFL, and contour of the LFL are also retrievable.\n\nplume['mass_flow_rate']\n\n0.4024272255826383\n\n\n\nplume['streamline_dists'][0]\n\n19.655324152591245\n\n\n\nplume['mole_frac_dists']\n\n{0.04: [(0, 13.734800620965242), (0, 14.201255169630102)]}\n\n\nThis 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.\n\n\nLimitations\nAn 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.\nAnother 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.\nAnother, 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.\n\nplume = api.analyze_jet_plume(air, h2, \n orif_diam=d_H,\n rel_angle=-theta,\n dis_coeff=c_d,\n nozzle_model='yuce',\n contours=lfl,\n xmin=0,\n xmax=60,\n ymin=-65,\n ymax=10,\n vmin=0,\n vmax=2*lfl,\n output_dir='figures',\n filename='h2_downward_plume_fig.png')\n\n\nFor 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.\nPreventing 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." + }, + { + "objectID": "posts/hydrogen_release_modeling/index.html#indoor-accumulation", + "href": "posts/hydrogen_release_modeling/index.html#indoor-accumulation", + "title": "Modelling Hydrogen Releases Using HyRAM+", + "section": "Indoor Accumulation", + "text": "Indoor Accumulation\nAn 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.\n\n\n\n\n\n\nFigure 2: A sketch of the room, the cylinder is supposed to have fallen by one wall. A layer of hydrogen gas accumulates at the ceiling, with the boundary moving downward as more hydrogen accumulates.\n\n\n\n\nCylinder Blowdown\nThe 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.\nHyRAM+ uses the governing equations for adiabatic blow-down.\n\\[ \\frac{dm}{dt} = -c_d A \\rho v \\]\n\\[ \\frac{du}{dt} = \\frac{1}{m} \\frac{dm}{dt} \\left( h - u \\right) \\]\nwhere 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.\nFirst the cylinder is defined as a Source with an initial mass of hydrogen.\n\nm_h2 = 50 #kg\ncylinder = hp.Source.fromMass(m_h2,h2)\n\nThen 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.\nA convenience function can also plot them for us.\n\ncylinder.empty(orifice)\n\ncylinder.plot_time_to_empty()\n\n\n\n\nThe Indoor Release\nAt 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.\n\nceiling_height = 2.7 #m\nfloor_area = 16 #m^2\nrelease_height = 0 #m\n\nceiling_vent = hp.Vent(A_h,2.6)\nfloor_vent = hp.Vent(A_h,0.01)\n\nroom = hp.Enclosure(ceiling_height, floor_area, release_height, ceiling_vent, floor_vent, Xwall=4)\n\nThe release model then estimates the accumulation of a flammable layer starting at the roof and extending downwards.\n\nrelease = hp.IndoorRelease(source=cylinder,\n orifice=orifice,\n ambient=air,\n enclosure=room,\n theta0=theta,\n nn_conserve_momentum=True,\n nn_T='solve_energy',\n tmax=30,\n verbose=False)\n\nrelease.plot_mass()\n\n\nAt 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.\n\nrelease.plot_layer()\n\n\nBy 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.\nTo 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.\n\nfull_release = hp.IndoorRelease(source=cylinder,\n orifice=orifice,\n ambient=air,\n enclosure=room,\n theta0=theta, \n X_lean=lfl,\n X_rich=1.0,\n nn_conserve_momentum=True,\n nn_T='solve_energy',\n tmax=30,\n verbose=False)\n\nfull_release.plot_mass()\n\n\n\n\nUsing the HyRAM+ API\nThis 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.\n\napi.analyze_accumulation(amb_fluid=air,\n rel_fluid=h2,\n tank_volume=cylinder.V, \n orif_diam=d_H, \n rel_height=release_height,\n enclos_height=ceiling_height, \n floor_ceil_area=floor_area,\n ceil_vent_xarea=A_h, \n ceil_vent_height=2.6,\n floor_vent_xarea=A_h, \n floor_vent_height=0.01,\n dist_rel_to_wall=4.0,\n tmax=30,\n times=[1,5,15,20,25,30],\n orif_dis_coeff=c_d,\n rel_angle=theta,\n nozzle_key='yuce',\n output_dir=\"figures/accumulation\")\n\n{'status': 1,\n 'pressures_per_time': array([ 388375.73527918, 3092893.44785494, 0. ,\n 0. , 0. , 0. ]),\n 'depths': array([1.34306015, 2.41226448, 2.45803334, 2.45809352, 2.4631503 ,\n 2.46359848]),\n 'concentrations': array([18.58049761, 43.85233797, 81.37521187, 89.24254417, 93.32644083,\n 95.79735125]),\n 'overpressure': 8136855.548411997,\n 'time_of_overp': 11.970055170861283,\n 'mass_flow_rates': array([0.3978773 , 0.38039983, 0.3417355 , 0.32307435, 0.30734882,\n 0.29251744]),\n 'pres_plot_filepath': 'figures/accumulation/pressure_plot_20240921-132132.png',\n 'mass_plot_filepath': 'figures/accumulation/flam_mass_plot_20240921-132132.png',\n 'layer_plot_filepath': 'figures/accumulation/layer_plot_20240921-132132.png',\n 'trajectory_plot_filepath': 'figures/accumulation/trajectory_plot_20240921-132132.png',\n 'mass_flow_plot_filepath': 'figures/accumulation/time-to-empty_20240921-132132.png'}\n\n\n\n\n\n\n\n\n\nLimitations\nOne 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." + }, + { + "objectID": "posts/hydrogen_release_modeling/index.html#final-thoughts", + "href": "posts/hydrogen_release_modeling/index.html#final-thoughts", + "title": "Modelling Hydrogen Releases Using HyRAM+", + "section": "Final Thoughts", + "text": "Final Thoughts\nIf 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.\nThe 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).\nFor 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." + }, + { + "objectID": "posts/hydrogen_release_modeling/index.html#references", + "href": "posts/hydrogen_release_modeling/index.html#references", + "title": "Modelling Hydrogen Releases Using HyRAM+", + "section": "References", + "text": "References\n\n\nAIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999.\n\n\nEhrhart, Brian D., Ethan S. Hecht, and Benjamin B. Schroeder. Hydrogen Plus Other Alternative Fuels Risk Assessment Models (HyRAM+) Version 5.1 Technical Reference Manual, 2023. https://doi.org/10.2172/2369637.\n\n\nEhrhart, Brian D., Cianan Sims, Ethan S. Hecht, Benjamin B. Schroeder, Benjamin R. Liu, Katrina M. Groth, John T. Reynolds, and Gregory W. Walkup. “HyRAM+ (Hydrogen Plus Other Alternative Fuels Risk Assessment Models).” Sandia National Laboratories, February 8, 2024. https://hyram.sandia.gov.\n\n\nOoms, G. “A New Method for the Calculation of the Plume Path of Gases Emitted by a Stack.” Atmospheric Environment (1967) 6, no. 12 (1972): 899–909. https://doi.org/10.1016/0004-6981(72)90098-4." + }, + { + "objectID": "posts/gaussian_explosive_mass/index.html", + "href": "posts/gaussian_explosive_mass/index.html", + "title": "The Masses of Clouds", + "section": "", + "text": "A common thing for me to do, when using a tool developed by someone else, is to read through all the tedious details and try and understand where it all comes from and what the unstated assumptions are. While doing this recently I was motivated to ask, how do people estimate the explosive energy in a vapour cloud? It is an important question to ask when performing a hazard analysis, especially in the petrochemical industry – often the worst case scenario is some chemical release leading to a vapour cloud explosion.\nThe 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." + }, + { + "objectID": "posts/gaussian_explosive_mass/index.html#the-gaussian-dispersion-model-a-recap", + "href": "posts/gaussian_explosive_mass/index.html#the-gaussian-dispersion-model-a-recap", + "title": "The Masses of Clouds", + "section": "The Gaussian dispersion model, a recap", + "text": "The Gaussian dispersion model, a recap\nGaussian 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.\n1 emitted at ground level and perfectly reflecting off the ground plane2 emitted high enough above the ground that the ground plane can be neglected entirelyFor what follows I am going to examine a free plume – the results are very similar for a grounded plume – which is given by\n\\[ \\chi = { w \\over {2\\pi u \\sigma_y \\sigma_z} } \\exp \\left( - \\frac{1}{2} \\left( \\left(\\frac{y}{\\sigma_y}\\right)^2 + \\left(\\frac{z}{\\sigma_z}\\right)^2 \\right) \\right) \\]\nWhere the origin has been chosen to coincide with the release point. The standard assumptions for a Gaussian plume are:\n\nThe release has a constant mass emission rate of \\(w\\)\nThe release has no momentum or buoyancy\nAdvection is by a constant windspeed \\(u\\) which is in the positive \\(x\\) direction\nTurbulence is captured by the parameters \\(\\sigma_y\\) and \\(\\sigma_z\\) which are functions of the downwind distance \\(x\\)\n\nFor a free plume it is further assumed that there is no ground plane, the z-axis extends infinitely up and down.\nTo actually use this model, we need a parametrization of \\(\\sigma_y\\) and \\(\\sigma_z\\) for which I am going to use the simple power law \\(\\sigma_y = a x^b\\) and \\(\\sigma_z = c x^d\\)\n\n# System parameters\nw = 1 # kg/s\nu = 1 # m/s\n\n\n# Class D - Neutral atmospheric stability\na = 0.128\nb = 0.905\nc = 0.20\nd = 0.76\n\n\nσy(x) = a*x^b\nσz(x) = c*x^d\n\n\nχ(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))\n\nSuppose I am interested in the region of the plume between two concentrations \\(\\chi_1\\) and \\(\\chi_2\\), 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 \\(x\\) axis where the centerline concentration equals that concentration. This is the point where the isosurface crosses the \\(x\\) axis.\nThis is typically the step along a hazard analysis or consequence analysis where calculating the potential explosive energy takes place.\n\nx₁ = 10 # m\nx₂ = 100 # m\n\n\nχ₁ = χ(x₁,0,0)\nχ₂ = χ(x₂,0,0)" + }, + { + "objectID": "posts/gaussian_explosive_mass/index.html#a-survey-of-estimates-of-the-mass-of-vapour-clouds", + "href": "posts/gaussian_explosive_mass/index.html#a-survey-of-estimates-of-the-mass-of-vapour-clouds", + "title": "The Masses of Clouds", + "section": "A survey of estimates of the mass of vapour clouds", + "text": "A survey of estimates of the mass of vapour clouds\nAt 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.\n3 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\n\\[ m_e = C \\frac{w}{u} x_l \\]\nWhere \\(m_l\\) is the mass of the region defined by the concentration \\(\\chi_l\\) and \\(C\\) is a constant, which generally depends upon atmospheric stability. If the explosive mass is taken to be the region between two concentrations \\(\\chi_1\\) and \\(\\chi_2\\) with \\(\\chi_1 > \\chi_2\\) then \\(m_e = m_2 - m_1\\)\n\n\n\n\nC\n\n\n\n\nCHEF & RAST\n1\n\n\nTNO Yellow Book\n\\({ {f_{z2}(L) + 1} \\over {f_{z2}(L) + 2} }\\)\n\n\nVan Buijtenen\n\\({ {b + d} \\over {b + d + 1 } }\\)\n\n\n\nWoodward6 gives the following as the rigorous method for plumes, specifically for a free plume it is\n6 Estimating the Flammable Mass of a Vapour Cloud.\\[ m_e = 4 \\left( \\chi_1 - \\chi_2 \\right) \\int_{x_1}^{x_2} \\sigma_y^2 E\\left( k^2 \\right) dx \\]\nwhere \\(E \\left( k^2 \\right)\\) is the complete elliptic integral of the second kind and \\(k\\) is a function of \\(x\\) and atmospheric stability." + }, + { + "objectID": "posts/gaussian_explosive_mass/index.html#the-mass-of-a-gaussian-plume", + "href": "posts/gaussian_explosive_mass/index.html#the-mass-of-a-gaussian-plume", + "title": "The Masses of Clouds", + "section": "The mass of a Gaussian plume", + "text": "The mass of a Gaussian plume\nThe mass in the region of a Gaussian plume with a concentration greater than \\(\\chi_l\\) is given by the volume integral\n\\[ m = \\iiint_V \\chi dV \\]\nwhere\n\\[ V = \\left\\{ x,y,z \\vert \\chi \\left(x,y,z\\right) \\ge \\chi_l \\right\\} \\]\nThe mass in a free plume is given by\n\\[ m_{free} = \\iiint_V \\chi dV = \\int_0^{x_l} \\int_{-y_l}^{y_l} \\int_{-z_l}^{z_l} \\chi_{free} dz dy dx \\]\nBy symmetry this is equal to\n\\[ 2 \\int_0^{x_l} \\int_{-y_l}^{y_l} \\int_{0}^{z_l} \\chi_{free} dz dy dx \\]\nRecalling that the concentration in a grounded plume is twice that of a free plume\n\\[ m_{free} = 2 \\int_0^{x_l} \\int_{-y_l}^{y_l} \\int_{0}^{z_l} \\chi_{free} dz dy dx = \\int_0^{x_l} \\int_{-y_l}^{y_l} \\int_{0}^{z_l} \\chi_{grounded} dz dy dx = m_{grounded} \\]\n\nTotal mass\nThe total mass in the plume contained between the planes \\(x=0\\) and \\(x=x_l\\) is simply the integral\n\\[ m_T = \\iiint_{V_T} \\chi dV \\]\n\\[ V_T = \\left\\{ x,y,z \\vert 0 \\le x \\le x_l, \\chi\\left(x,y,z\\right) \\gt 0 \\right\\} \\]\nWhich follows directly from properties of Gaussian functions\n\\[ m_T = \\int_0^{x_l} \\int_{-\\infty}^{\\infty} \\int_{-\\infty}^{\\infty} \\chi dz dy dx \\]\n\\[ m_T = \\frac{w}{u} x_l \\]\nWhich 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." + }, + { + "objectID": "posts/gaussian_explosive_mass/index.html#the-isosurface-of-a-gaussian-plume", + "href": "posts/gaussian_explosive_mass/index.html#the-isosurface-of-a-gaussian-plume", + "title": "The Masses of Clouds", + "section": "The isosurface of a Gaussian plume", + "text": "The isosurface of a Gaussian plume\nThe isopleths for a free Gaussian plume are given by\n\\[ y_l = \\pm \\sigma_y \\sqrt{K} \\]\n\\[ z_l = \\pm \\sigma_z \\sqrt{K} \\]\nwhere \\(K = 2 \\log \\left( w \\over {2\\pi u \\sigma_y \\sigma_z \\chi_l } \\right)\\)\nThe whole isosurface is defined by\n\\[ S = \\left\\{ x, y, z \\bigg\\vert \\left(\\frac{y}{\\sigma_y}\\right)^2 + \\left(\\frac{z}{\\sigma_z}\\right)^2 = K \\right\\} \\]\nThese can be used directly, but a more general approach is to use marching squares to find the isopleth.\n\n\n\n\n\n\n\nFigure 1: The crosswind extent of the region of interest.\n\n\n\n\n\n\n\n\n\n\n\nFigure 2: The vertical extent of the region of interest.\n\n\n\n\nFor a general plume one can find the surface using marching tetrahedra. In the following the surface for \\(\\chi_2\\) is calculated by marching tetrahedra, as shown in Figure 3.\n\nusing Meshing: MarchingTetrahedra, isosurface\nusing GeometryBasics: Mesh, Point, Vec, Triangle, TriangleFace, volume\n\n\nχ_safe(x,y,z) = isnan(χ(x,y,z)) ? 0.0 : χ(x,y,z);\n\n\nxs = LinRange(0.0, x₂, 100)\nys = LinRange(-7.5, 7.5, 25)\nzs = LinRange(-7.5, 7.5, 25)\n\nχfield = [ χ_safe(x,y,z) - χ₂ for x in xs, y in ys, z in zs ]\npts,fcs = isosurface(χfield, MarchingTetrahedra(), xs, ys, zs);\n\n\nmsh = Mesh(Point.(pts), TriangleFace.(fcs))\n\nMesh{3, Float64, TriangleFace{Int64}}\n faces: 43472\n vertex position: 21740\n\n\n\n\n\n\n\n\n\nFigure 3: The χ₂ iso-surface, calculated by marching tetrahedra.\n\n\n\n\nIf 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\n\nabs(volume(msh))\n\n8085.175640305937\n\n\nPresumably 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." + }, + { + "objectID": "posts/gaussian_explosive_mass/index.html#direct-numerical-integration", + "href": "posts/gaussian_explosive_mass/index.html#direct-numerical-integration", + "title": "The Masses of Clouds", + "section": "Direct numerical integration", + "text": "Direct numerical integration\nThe most direct approach to calculating the explosive mass is to numerically integrate over a rectangular region containing the plume7\n7 Woodward, Estimating the Flammable Mass of a Vapour Cloud, 241.\\[\nm_l = \\iiint_{V} \\chi dV = \\int_{0}^{x_l} \\int_{-y_l}^{y_l} \\int_{-z_l}^{z_l} \\begin{cases}\n \\chi & \\chi \\ge \\chi_l \\\\\n 0 & \\chi \\lt \\chi_l\n \\end{cases} dz dy dx\n\\]\nThis can be done directly using the trapezoidal rule in three dimensions.\n\n@inline χ_inbounds(x,y,z; χₗ,w,u) = χ(x,y,z; w=w,u=u)≥χₗ ? χ(x,y,z; w=w,u=u) : 0.0\n\n\nfunction mass🪤(xₗ; lower, upper, N=100, w=w, u=u)\n y_a, z_a = lower\n y_b, z_b = upper\n x_a, x_b = 0.0, xₗ\n\n Δx = (x_b - x_a)/N\n Δy = (y_b - y_a)/N\n Δz = (z_b - z_a)/N\n\n χₗ, Σχ = χ(xₗ,0,0; w=w, u=u), 0.0\n for i in 1:N, j in 1:N, k in 1:N\n xᵢ₋₁, xᵢ = x_a + (i-1)*Δx, x_a + i*Δx\n yⱼ₋₁, yⱼ = y_a + (j-1)*Δy, y_a + j*Δy\n zₖ₋₁, zₖ = z_a + (k-1)*Δz, z_a + k*Δz\n\n Σχ += ( χ_inbounds(xᵢ₋₁, yⱼ₋₁, zₖ₋₁; χₗ=χₗ, w=w, u=u) \n + χ_inbounds(xᵢ₋₁, yⱼ₋₁, zₖ; χₗ=χₗ, w=w, u=u) \n + χ_inbounds(xᵢ₋₁, yⱼ, zₖ₋₁; χₗ=χₗ, w=w, u=u) \n + χ_inbounds(xᵢ₋₁, yⱼ, zₖ; χₗ=χₗ, w=w, u=u)\n + χ_inbounds(xᵢ, yⱼ₋₁, zₖ₋₁; χₗ=χₗ, w=w, u=u)\n + χ_inbounds(xᵢ₋₁, yⱼ₋₁, zₖ; χₗ=χₗ, w=w, u=u)\n + χ_inbounds(xᵢ, yⱼ, zₖ₋₁; χₗ=χₗ, w=w, u=u)\n + χ_inbounds(xᵢ₋₁, yⱼ, zₖ; χₗ=χₗ, w=w, u=u) )\n\n end\n\n return Σχ*Δx*Δy*Δz/8\nend\n\n\nm🪤 = mass🪤(x₂; lower=[-7.5,-7.5], upper=[7.5,7.5]) - \n mass🪤(x₁; lower=[-1.5,-1.5], upper=[1.5,1.5]);\n\n\n\nThe mass by trapezoidal rule is 55.77kg\n\n\nThe 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.\nWe 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 \\(\\xi\\), \\(\\zeta\\) such that \\(\\xi \\in [-1,1]\\) and \\(\\zeta \\in [-1,1]\\) and\n\\[ y = \\sigma_y \\sqrt{K} \\xi \\]\n\\[ z = \\sigma_z \\sqrt{K} \\zeta \\]\n\\[\nm_l = \\iiint_{V} \\chi dV = \\int_{0}^{x_l} \\int_{-1}^{1} \\int_{-1}^{1} \\begin{cases}\n \\sigma_y \\sigma_z K \\chi & \\chi \\ge \\chi_l \\\\\n 0 & \\chi \\lt \\chi_l\n \\end{cases} d\\zeta d\\xi dx\n\\]\nwhere \\(K = 2 \\log \\left( w \\over {2\\pi u \\sigma_y \\sigma_z \\chi_l } \\right)\\)\nThis changes the domain of integration from a rectangular prism to one with a rectangular cross-section whose size is a function of \\(x\\). It is somewhat more efficient, and doesn’t require the user to pick a good bounding box.\n\nfunction mass🪤2(xₗ; N=100, w=w, u=u) \n χₗ = χ(xₗ,0,0; w=w, u=u)\n function integrand(x,ξ,ζ)\n K = 2*log(w/(2π*u*σy(x)*σz(x)*χₗ))\n y_lim = σy(x)*√(K)\n z_lim = σz(x)*√(K)\n y, z = y_lim*ξ, z_lim*ζ\n I = y_lim*z_lim*χ_inbounds(x,y,z; χₗ=χₗ, w=w, u=u)\n return isnan(I) ? 0.0 : I\n end\n\n x_a, x_b = 0.0, xₗ\n ξ_a, ξ_b = -1.0, 1.0\n ζ_a, ζ_b = -1.0, 1.0\n Δx = (x_b - x_a)/N\n Δξ = (ξ_b - ξ_a)/N\n Δζ = (ζ_b - ζ_a)/N\n\n Σχ = 0.0\n for i in 1:N, j in 1:N, k in 1:N\n xᵢ₋₁, xᵢ = x_a + (i-1)*Δx, x_a + i*Δx\n ξⱼ₋₁, ξⱼ = ξ_a + (j-1)*Δξ, ξ_a + j*Δξ\n ζₖ₋₁, ζₖ = ζ_a + (k-1)*Δζ, ζ_a + k*Δζ\n\n Σχ += ( integrand(xᵢ₋₁, ξⱼ₋₁, ζₖ₋₁) \n + integrand(xᵢ₋₁, ξⱼ₋₁, ζₖ) \n + integrand(xᵢ₋₁, ξⱼ, ζₖ₋₁) \n + integrand(xᵢ₋₁, ξⱼ, ζₖ)\n + integrand(xᵢ, ξⱼ₋₁, ζₖ₋₁)\n + integrand(xᵢ₋₁, ξⱼ₋₁, ζₖ)\n + integrand(xᵢ, ξⱼ, ζₖ₋₁)\n + integrand(xᵢ₋₁, ξⱼ, ζₖ) )\n\n end\n\n return Σχ*Δx*Δξ*Δζ/8\nend\n\n\nm🪤2 = mass🪤2(x₂) - mass🪤2(x₁);\n\n\n\nThe mass by trapezoidal rule, with a change of variables, is 55.73kg\n\n\nThis 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 \\(8 \\times N^3\\) times. A large proportion of those evaluations are still being thrown out, as they are outside the region of interest.\nThis 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.\n\nusing MCIntegration\n\n\nfunction mass🎲(xₗ; w=w, u=u)\n χₗ = χ(xₗ,0,0; w=w, u=u)\n function integrand(x,ξ,ζ)\n K = 2*log(w/(2π*u*σy(x)*σz(x)*χₗ))\n y_lim = σy(x)*√(K)\n z_lim = σz(x)*√(K)\n y, z = y_lim*ξ, z_lim*ζ\n I = y_lim*z_lim*χ_inbounds(x,y,z; χₗ=χₗ, w=w, u=u)\n return isnan(I) ? 0.0 : I\n end\n \n xξζ = CompositeVar(Continuous(0.0,xₗ),\n Continuous(-1.0,1.0),\n Continuous(-1.0,1.0))\n res = integrate(((x, ξ, ζ), c)-> integrand(x[1],ξ[1],ζ[1]); var = xξζ)\n return res.mean[1]\nend\n\nmass🎲 (generic function with 1 method)\n\n\n\nm🎲 = mass🎲(x₂) - mass🎲(x₁);\n\n\nTotal iterations * blocks 160: 100%|██████| Time: 0:00:02 (17.37 ms/it)\n\n\n\n\n\n\nThe mass by Monte Carlo, with a change of variables, is 56.36kg\n\n\nBoth of these approaches have a similar relative error (spoilers!) but the Monte Carlo integration is much more efficient – in time and memory.\n\nusing BenchmarkTools\n\n\n🪤res = @benchmark mass🪤2(x₂)\n\n\nBenchmarkTools.Trial: 1 sample with 1 evaluation per sample.\n Single result which took 9.444 s (11.24% GC) to evaluate,\n with a memory estimate of 9.10 GiB, over 607442648 allocations.\n\n\n\n\n🎲res = @benchmark mass🎲(x₂)\n\n\nBenchmarkTools.Trial: 30 samples with 1 evaluation per sample.\n Range (min … max): 161.499 ms … 184.335 ms ┊ GC (min … max): 8.80% … 6.33%\n Time (median): 165.566 ms ┊ GC (median): 10.62%\n Time (mean ± σ): 167.251 ms ± 5.510 ms ┊ GC (mean ± σ): 10.25% ± 2.34%\n ▃ ▃▃█ ▃▃▃▃ ▃ \n █▁▁▇▇███▁████▇▇▇▁▁▁▇▇▁▁▇▁▁▁▇▇▁▁▁▁▇▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█ ▁\n 161 ms Histogram: frequency by time 184 ms <\n Memory estimate: 164.92 MiB, allocs estimate: 10748898.\n\n\n\nThe 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.\nThe 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 \\(\\chi \\ge \\chi_l\\), but it is in general pretty forgiving." + }, + { + "objectID": "posts/gaussian_explosive_mass/index.html#adaptive-step-sizes-with-h-cubature", + "href": "posts/gaussian_explosive_mass/index.html#adaptive-step-sizes-with-h-cubature", + "title": "The Masses of Clouds", + "section": "Adaptive step-sizes with H Cubature", + "text": "Adaptive step-sizes with H Cubature\nThe 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 \\(y_l\\) and \\(z_l\\) do not depend on \\(x\\)) 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.\nA better approach is to re-write the integral such that the integration is only over the region with \\(\\chi \\ge \\chi_l\\), and with an integrand that is smooth and continuous throughout. Firstly we re-write the integral.\n\\[m_l = \\iint_V \\chi dV = \\int_0^{x_l} \\iint_{\\mathcal{E}} \\chi dA dx \\]\n\\[ \\mathcal{E} = \\left\\{ y, z \\bigg\\vert \\left( y \\over \\sigma_y \\right)^2 + \\left( z \\over \\sigma_z \\right)^2 \\le K\\left( x \\right) \\right\\} \\]\n\n\n\n\n\n\n\nFigure 4: The cross-sectional area of the plume, an ellipse.\n\n\n\n\nNote that \\(\\mathcal{E}\\) defines an ellipse, Figure 4, which suggests the change of variables \\(\\rho\\), \\(\\theta\\) such that\n\\[ y = \\sigma_y \\sqrt{K} \\rho \\cos \\theta \\]\n\\[ z = \\sigma_z \\sqrt{K} \\rho \\sin \\theta \\]\nand \\(\\rho \\in [0,1]\\), \\(\\theta \\in [0, 2\\pi]\\)\n\\[ m_l = \\int_0^{x_l} \\int_0^{2\\pi} \\int_0^1 \\sigma_y \\sigma_z K \\chi \\rho {d\\rho} {d\\theta} {dx} \\]\nThis can be integrated directly with h cubature without involving any discontinuous functions.\n\nusing HCubature: hcubature\n\n\nfunction mass📦(xₗ; w=w, u=u)\n lower = [0.0, 0.0, 0.0]\n upper = [1.0, 2π, xₗ]\n χₗ = χ(xₗ,0,0; w=w, u=u)\n \n function integrand(r)\n (ρ,θ,x) = r\n K = 2*(log(w) - log(2π*χₗ*σy(x)*σz(x)*u))\n y = σy(x)*√(K)*ρ*cos(θ)\n z = σz(x)*√(K)*ρ*sin(θ)\n return σy(x)*σz(x)*K*χ(x,y,z; w=w, u=u)*ρ\n end\n\n I, err = hcubature(integrand, lower, upper)\n return I\nend\n\n\nm📦 = mass📦(x₂) - mass📦(x₁);\n\n\n\nThe mass by H cubature is 56.23kg\n\n\n\n📦res = @benchmark mass📦(x₂)\n\n\nBenchmarkTools.Trial: 208 samples with 1 evaluation per sample.\n Range (min … max): 19.541 ms … 35.658 ms ┊ GC (min … max): 0.00% … 37.93%\n Time (median): 25.003 ms ┊ GC (median): 19.48%\n Time (mean ± σ): 24.127 ms ± 2.908 ms ┊ GC (mean ± σ): 13.37% ± 10.14%\n ▁ ▄▂█▂ \n ▄▆▇█▇▄▄▃▃▁▃▁▁▂▁▁▁▂▃▂█████▆▆▃▂▃▃▂▁▂▁▂▁▂▁▁▁▂▁▁▂▁▁▂▁▁▁▁▁▁▁▁▂▁▂ ▃\n 19.5 ms Histogram: frequency by time 34.2 ms <\n Memory estimate: 23.05 MiB, allocs estimate: 1459221.\n\n\n\nThis 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." + }, + { + "objectID": "posts/gaussian_explosive_mass/index.html#integrating-out-the-cross-sectional-area", + "href": "posts/gaussian_explosive_mass/index.html#integrating-out-the-cross-sectional-area", + "title": "The Masses of Clouds", + "section": "Integrating out the cross-sectional area", + "text": "Integrating out the cross-sectional area\nYou 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 \\(\\rho\\), \\(\\theta\\) directly into the definition of \\(\\chi\\) gives\n\\[ m_l = \\int_0^{x_l} \\int_0^{2\\pi} \\int_0^1 \\sigma_y \\sigma_z K \\chi \\rho d\\rho d\\theta dx \\]\n\\[ = \\int_0^{x_l} \\int_0^{2\\pi} \\int_0^1 \\sigma_y \\sigma_z K \\left( \\frac{w}{2\\pi u \\sigma_y \\sigma_z} \\exp \\left( -\\frac{K}{2} \\rho^2 \\right) \\right) \\rho d\\rho d\\theta dx \\]\n\\[ = \\int_0^{x_l} \\int_0^{2\\pi} \\sigma_y \\sigma_z \\left[ \\frac{w}{2\\pi u \\sigma_y \\sigma_z} \\left( 1 - \\exp \\left( -\\frac{K}{2} \\right) \\right) \\right] d\\theta dx \\]\n\\[ = \\int_0^{x_l} \\frac{w}{u} \\left( 1 - \\exp \\left( -\\frac{K}{2} \\right) \\right) dx \\]\n\\[ m_l = \\frac{w}{u} x_l - 2\\pi \\chi_l \\int_0^{x_l} \\sigma_y \\sigma_z dx \\]\nThe last integral is a simple one dimensional integral which can be done with QuadGK.\n\nusing QuadGK: quadgk\n\n\nfunction mass🔴(xₗ; w=w, u=u)\n I, err = quadgk( t -> σy(t)*σz(t), 0, xₗ)\n return (w/u)*xₗ - 2π*χ(xₗ,0,0; w=w, u=u)*I\nend\n\nmass🔴 (generic function with 1 method)\n\n\n\nm🔴 = mass🔴(x₂) - mass🔴(x₁);\n\nFor the special case where \\(\\sigma_y = a x^b\\) and \\(\\sigma_z = c x^d\\) the integral can be done analytically to arrive at\n\\[ m_l = { {b+d} \\over {b+d+1} } \\frac{w}{u} x_l \\]\nWhich is the result from Van Buijtenen8 given above. Similarly if we take \\(\\sigma_y \\propto x\\) and \\(\\sigma_z \\propto x^{f_{z2}(L)}\\) then\n8 “Calculation of the Amount of Gas in the Explosive Region of a Vapour Cloud Released in the Atmosphere”.\\[ m_l = { {f_{z2}(L) +1} \\over {f_{z2}(L)+2} } \\frac{w}{u} x_l \\]\nWhich is the result from the TNO Yellow Book.9\n9 Bakkum and Duijm., “Vapour Cloud Dispersion”.\nmₑ = (w/u)*((b+d)/(b+d+1))*(x₂ - x₁);\n\n\n\nThe mass by QuadGK is 56.23kg, and the exact analytic solution is 56.23kg\n\n\n\n🔴res = @benchmark mass🔴(x₂)\n\n\nBenchmarkTools.Trial: 10000 samples with 1 evaluation per sample.\n Range (min … max): 21.956 μs … 13.849 ms ┊ GC (min … max): 0.00% … 99.31%\n Time (median): 29.064 μs ┊ GC (median): 0.00%\n Time (mean ± σ): 29.891 μs ± 138.294 μs ┊ GC (mean ± σ): 4.60% ± 0.99%\n ▁█▅▂ ▅█▇▅▂ \n ▂▄▂▂▂▂▁████▆▄▃▂▂▃▃▇█████▇▅▄▃▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▃\n 22 μs Histogram: frequency by time 43.7 μs <\n Memory estimate: 24.86 KiB, allocs estimate: 1519.\n\n\n\nIt 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.\n\n\n\n\nTable 1: Performance of the integration methods.\n\n\n\n\n\n\n\nMass (kg)\nError (%)\nMedian Time (ms)\n\n\n\n\nTrapezoidal Rule\n55.73\n0.9%\n9443.63\n\n\nMonte Carlo\n56.36\n0.23%\n165.57\n\n\nH Cubature\n56.23\n1.0e-8%\n25.0\n\n\nQuadGK\n56.23\n5.0e-10%\n0.03\n\n\n\n\n\n\n\n\n\nThe mass in a grounded plume\nWith 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\n\\[ m_g = \\int_0^{x_l} \\int_0^{\\pi} \\int_0^1 \\sigma_y \\sigma_z K_g \\chi_g \\rho d\\rho d\\theta dx \\]\nwhere \\(K_g = 2 \\log \\left( w \\over {\\pi u \\sigma_y \\sigma_z \\chi_{g,l} } \\right)\\)\n\\[ = \\int_0^{x_l} \\int_0^{\\pi} \\int_0^1 \\sigma_y \\sigma_z K_g \\left( \\frac{w}{\\pi u \\sigma_y \\sigma_z} \\exp \\left( -\\frac{K_g}{2} \\rho^2 \\right) \\right) \\rho d\\rho d\\theta dx \\]\n\\[ = \\int_0^{x_l} \\int_0^{\\pi} \\sigma_y \\sigma_z \\left[ \\frac{w}{\\pi u \\sigma_y \\sigma_z} \\left( 1 - \\exp \\left( -\\frac{K_g}{2} \\right) \\right) \\right] d\\theta dx \\]\n\\[ = \\int_0^{x_l} \\frac{w}{u} \\left( 1 - \\exp \\left( -\\frac{K_g}{2} \\right) \\right) dx \\]\n\\[ m_g = \\frac{w}{u} x_l - \\pi \\chi_{g,l} \\int_0^{x_l} \\sigma_y \\sigma_z dx = \\frac{w}{u} x_l - 2 \\pi \\chi_{f,l} \\int_0^{x_l} \\sigma_y \\sigma_z dx = m_f\\]\nThe masses within the free iso-surface and grounded iso-surface which intersect the x-axis at \\(x_l\\) are the same, as we expect, but the concentration which defines that iso-surface is not the same. An important distinction." + }, + { + "objectID": "posts/gaussian_explosive_mass/index.html#the-rigorous-method", + "href": "posts/gaussian_explosive_mass/index.html#the-rigorous-method", + "title": "The Masses of Clouds", + "section": "The “rigorous” method", + "text": "The “rigorous” method\nYou 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.\nAs a reminder the “rigorous” method given by Woodward for a free plume is\n\\[ m_e = 4 \\left( \\chi_1 - \\chi_2 \\right) \\int_{x_1}^{x_2} \\sigma_y^2 E\\left( k^2 \\right) dx \\]\nwith \\(k^2 = 1 - \\left(\\frac{\\sigma_z}{\\sigma_y}\\right)^2\\) and \\(E\\) the complete elliptic integral of the second kind.\n\nusing SpecialFunctions: ellipe\n\n\nk²(x) = 1 - (σz(x)/σy(x))^2\n\nk² (generic function with 1 method)\n\n\n\nI, err = quadgk( t -> σy(t)^2 * ellipe(k²(t)), x₁, x₂)\n\n(3522.359412198113, 4.6837135414534714e-7)\n\n\n\nm_rigorous = 4*(χ₁ - χ₂)*I;\n\n\nm_rigorous\n\n1853.438596397458\n\n\n\n\nThat is far too high, it is 33.0× the exact solution and 18.5× the entire mass in the plume at x₂.\n\n\nClearly this doesn’t work. So what’s gone wrong? Referring to the original paper by Hesse10 the mass is given as\n10 “A Computational Procedure for Calculating the Mass of Flammable Vapor in a Neutrally Buoyant Cloud”.\\[ m_e = \\iiint_V \\chi dV = \\int_{x_1}^{x_2} \\iint_{\\mathcal{E}} \\chi dA dx \\]\n\\[ \\mathcal{E} = \\left\\{ y, z \\bigg\\vert K_1 \\le \\left(\\frac{y}{\\sigma_y}\\right)^2 + \\left(\\frac{z}{\\sigma_z}\\right)^2 \\le K_2 \\right\\} \\]\nwhere \\(\\mathcal{E}\\) is the area between the ellipses defined by \\(\\chi_1\\) and \\(\\chi_2\\).\n\n\n\n\n\n\n\nFigure 5: The cross-sectional area of the plume, between the two ellipses.\n\n\n\n\nHesse proposes that11 \\[ \\iint_{\\mathcal{E}} \\chi dA = \\int_{\\sigma_y \\sqrt{K_1}}^{\\sigma_y \\sqrt{K_2}} \\chi p\\left( x,y \\right) dy \\]\n11 Hesse equation 20.where \\(p(x,y)\\) is the perimeter of the elliptical isopleth in the y-z plane defined by the concentration \\(\\chi \\left( x, y, 0 \\right)\\). That is to say, Hesse is integrating the cross-sectional area by treating it like a series of concentric, elliptical, rings with perimeter \\(p\\) and width \\(dy\\). For the free plume the perimeter is\n\\[ p(x,y) = 4 y E \\left( k^2 \\right) \\]\nWhere \\(E \\left( k^2 \\right)\\) is the complete elliptic integral of the second kind with the elliptic modulus given by \\(k^2 = 1 - \\left(\\frac{\\sigma_z}{\\sigma_y}\\right)^2\\), a constant with respect to \\(y\\) and \\(z\\).\nSubstituting in and making the change of variables to \\(\\chi = \\frac{w}{2\\pi u \\sigma_y \\sigma_z} \\exp \\left( -\\frac{1}{2} \\left( \\frac{y}{\\sigma_y} \\right)^2 \\right)\\)\n\\[\\iint_{\\mathcal{E}} \\chi dA = \\int_{\\sigma_y \\sqrt{K_1}}^{\\sigma_y \\sqrt{K_2}} \\chi p\\left( x,y \\right) dy \\]\n\\[ = 4 \\int_{\\sigma_y \\sqrt{K_1}}^{\\sigma_y \\sqrt{K_2}} \\chi y E \\left( k^2 \\right) dy \\]\n\\[ = 4 \\int_{\\chi_2}^{\\chi_1} \\chi y E \\left( k^2 \\right) { \\sigma_y^2 \\over {\\chi y} } d\\chi \\]\n\\[ = 4 \\int_{\\chi_2}^{\\chi_1} \\sigma_y^2 E \\left( k^2 \\right) d\\chi \\]\n\\[ \\iint_{\\mathcal{E}} \\chi dA = 4 \\left(\\chi_1 - \\chi_2 \\right) \\sigma_y^2 E \\left( k^2 \\right) \\]\nFrom 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\n\\[\\iint_{\\mathcal{E}} \\chi dA \\ne \\int_{\\sigma_y \\sqrt{K_1}}^{\\sigma_y \\sqrt{K_2}} \\chi p\\left( x,y \\right) dy \\]\nTo demonstrate this, consider the integration simply over the cross-sectional area. Hesse proposes that this relation holds\n\\[ \\iint_{\\mathcal{E}} dA = \\int_0^{a} 4 y E \\left( k^2 \\right) dy \\]\n\\[ \\mathcal{E} = \\left\\{ y, z \\bigg\\vert \\left(\\frac{y}{a}\\right)^2 + \\left(\\frac{z}{b}\\right)^2 \\le 1 \\right\\} \\]\n\\[ k^2 = 1 - \\left(\\frac{b}{a}\\right)^2 \\]\nThat 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 \\(E \\left( k^2 \\right)\\) is a constant, that’s not what we get:\n\\[ A_{ellipse} = \\int_0^{a} 4 y E \\left( k^2 \\right) dy \\]\n\\[ = 2 a^2 E \\left( k^2 \\right) \\]\nBut we know that the area of an ellipse is \\(\\pi a b\\). The only case in which Hesse’s technique works is when \\(a = b\\), since \\(E(0) = \\frac{\\pi}{2}\\) (i.e. a circular cross-section).\nThere 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 \\(x \\gt x_1\\). This is, in fact, the definition of \\(x_1\\) 12. The only region over which the integration even makes sense is from \\(0 \\le x \\le x_1\\), and yet the actual integration is being done over \\(x_1 \\lt x \\lt x_2\\).\n12 \\(x_1\\) is the point where \\(K_1 = 0\\), i.e. where the inner ellipse vanishes. At any point \\(x \\gt x_1\\) there is no point in the plume where \\(\\chi = \\chi_1\\) and so the isopleth does not existEven if the cross-sectional integration was adjusted such that the innner ellipse is ignored, and so the problem of being undefined in the region \\(x_1 \\lt x \\lt x_2\\) is solved, it still doesn’t work because it excludes the mass in the plume between \\(0 \\le x \\le x_1\\) for which \\(\\chi_2 \\le \\chi \\lt \\chi_1\\). Clearly from Figure 1 and Figure 2, this is not a negligible region.\nOne might be tempted by the logic\n\\[ m_e = m_2 - m_1 \\]\n\\[ = \\int_{0}^{x_2} \\iint_{\\mathcal{E_2}} \\chi dA dx - \\int_{0}^{x_1} \\iint_{\\mathcal{E_1}} \\chi dA dx \\]\n\\[ = \\int_{x_1}^{x_2} \\iint_{\\mathcal{E}} \\chi dA dx \\]\nBut that only works if \\(\\mathcal{E_1} = \\mathcal{E_2}\\), which is not the case in general.\nIt 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.\n13 “Application of Dispersion Models to Flammable Cloud Analyses”." + }, + { + "objectID": "posts/gaussian_explosive_mass/index.html#conclusions-and-recommendations", + "href": "posts/gaussian_explosive_mass/index.html#conclusions-and-recommendations", + "title": "The Masses of Clouds", + "section": "Conclusions and recommendations", + "text": "Conclusions and recommendations\nFor a screening level analysis I would use the relation\n\\[ m_l = \\frac{w}{u} x_l - 2\\pi \\chi_l \\int_0^{x_l} \\sigma_y \\sigma_z dx \\]\nto calculate the mass within an isosurface defined by \\(x_l\\). This gives some freedom in choice of dispersion parameters \\(\\sigma_y\\) and \\(\\sigma_z\\). 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).\nSomething that may be worthwhile to explore is whether the mass within the isosurface that intersects the x-axis at \\(x_l\\) for a plume at some height \\(h\\) 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 \\(m_l\\) equation for everything?”" + }, + { + "objectID": "posts/vapour_cloud_explosion_example/index.html", + "href": "posts/vapour_cloud_explosion_example/index.html", + "title": "VCE Example - Butane Vapour Cloud", + "section": "", + "text": "In a previous post I worked through estimating the airborne quantity of butane due to a leak from a storage sphere. That example stopped at estimating the total quantity released, here I would like to go further into the potential for a vapour cloud explosion.\nAs a recap the scenario is a leak from a butane storage sphere, the leak is 10ft above grade and results in cloud of mostly aerosolized butane that is initially below ambient temperature. The scenario parameters and results are summarized below.\nAs a quick note, the whole purpose of this exercise is a sort of high-level hazard screening. Detailed enough to decide whether or not the consequences of a hazard warrant more detailed modeling.\nusing Plots\nusing Unitful\nusing Interpolations\nusing CSV\nusing DataFrames\n\ngr()\n\nft = ustrip(u\"m\", 1u\"ft\") # unit conversion ft->m\ninch = ustrip(u\"m\", 1u\"inch\") # unit conversion inch->m\npsi = ustrip(u\"Pa\", 1u\"psi\") # unit conversion psi->Pa\n# Scenario parameters\nhᵣ = 10ft # height of release point, m\npₐ= 14.7psi # atmospheric pressure, Pa\nTₐ= 25 + 273.15 # the ambient temperature, K\ntd = 10*60 # release duration, 10 minutes, s\n\n# Constants\nR = 8.31446261815324 # universal gas constant, J/mol/K\ng = 9.806 # acceleration due to gravity, m/s2\n\n# Properties of Butane, from Perry's or DIPPR\nMw = 58.122 # molar weight kg/kmol\nρₗ(T) = Mw*( 1.0677/0.27188^(1+ (1-T/425.12)^0.28688) ) # density liquid, kg/m3\nρg(T) = (pₐ*Mw)/(R*T)/1000 # density gas, ideal gas law, kg/m3\nΔHc = 2657.320 # heat of combustion, kJ/mol\nLFL = 1.86e-2 # lower flammability limit, vol/vol\n\n# Properties of Air\nMWₐᵢᵣ = 28.960 # molar weight air, kg/kmol\nρa(T) = (pₐ*MWₐᵢᵣ)/(R*T)/1000 # density of air, ideal gas law, kg/m^3\n\n# Calculated previously\nTc = -0.6 + 273.15 # cloud temperature, K\nfᵥ = 0.17128269541302374 # flashed fraction\nfₐ = 0.9227949810754577 # aerosol fraction\n\nQaq = 52.82002170865257 # airborne quantity, kg/s\nThis example focuses on a next step in a standard hazard screening, namely estimating the scale of a potential vapour cloud explosion. Typically, for flammable gases, the potential for a vapour cloud explosion is the worst case outcome of a release." + }, + { + "objectID": "posts/vapour_cloud_explosion_example/index.html#vapour-cloud-dispersion", + "href": "posts/vapour_cloud_explosion_example/index.html#vapour-cloud-dispersion", + "title": "VCE Example - Butane Vapour Cloud", + "section": "Vapour Cloud Dispersion", + "text": "Vapour Cloud Dispersion\nThe first step in determining the consequences of a vapour cloud explosion is to estimate the size of the vapour cloud that could take part in the explosion. This is generally done through some sort of dispersion modeling. There are many ways of defining the portion of the vapour cloud that can explode, in this case I am going to assume the flammable portion of the cloud is that with a concentration \\(\\ge \\frac{1}{2} LFL\\) .\nThere is a fair bit of discussion in the literature as to whether to use the LFL or 1/2 LFL, using half the LFL is, at the very least, more conservative and given the simplified methods I am using to estimate the size of the cloud it is probably best to err on the side of an overly large cloud.\n\nAtmospheric Stability and Wind Profile\nPrior to determining the particular dispersion model some meteorological parameters must be decided upon. Atmospheric stability and the wind profile define the extent of vertical mixing, which governs how large the cloud can grow and how dispersed the butane, in this case, will get during the release. They are also important in the decision criteria for which type of dispersion model to use.\nIn general, the worst case atmospheric stability is the most stable, Pasquill stability class F, for neutral to negatively buoyant clouds at ground level. This limits the degree of mixing and leads to a larger explosive mass, since we define the explosive mass as the portion of the cloud greater than half the lower flammability limit. If the cloud mixes thoroughly with the air it will be dispersed to levels well below the LFL and thus cannot explode\nFor this scenario I am supposing class F stability and a moderate windspeed of 3m/s at the release point.\nThere are two other important wind-speeds that will be needed, the friction velocity, \\(u_{\\star}\\), and the wind-speed at the standard elevation of 10m, \\(u_{10}\\) which can be obtained from the wind profile. The wind profile can be estimated using a power law distribution parameterized based on the Pasquill stability class.\n\\[ {u \\over u_r} = \\left( h \\over h_r \\right)^p \\]\nWhere the parameter p is tabulated1 here\n1 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases., 83.\n\n\nStability\nurban\nrural\n\n\n\n\nA\n0.15\n0.07\n\n\nB\n0.15\n0.07\n\n\nC\n0.20\n0.10\n\n\nD\n0.25\n0.15\n\n\nE\n0.40\n0.35\n\n\nF\n0.60\n0.55\n\n\n\nThere are several ways to estimate the friction velocity, but a simple rule of thumb used in the EPA TSCREEN model is to assume\n\\[ u_{\\star} = 0.06 u_{10} \\]\nThis is a very simplified approach and there are many alternatives to calculating the friction velocity, and parameterizing the atmospheric stability. For the purposes of this simple example this is fine but it is an opportunity for future refinement of the hazard screening.\n\n# wind profile\nuᵣ = 3.0 # the wind-speed at release height, m/s\np = 0.55 # parameter, pasquill stability class F\n\nu(h) = uᵣ*(h/hᵣ)^p\n\nu₁₀ = u(10)\n\nu₊ = 0.06 * u₁₀\n\n0.3459905806850393\n\n\n\n\nRelease Type\nThe first important decision is whether to model the release as a continuous plume or an instantaneous puff. In reality the answer is neither but these limiting cases are easier to model and are used as first approximations.\nA simple rule of thumb is that if the distance traveled by a parcel of air over the release duration is greater than 2.5 times the distance to the point of interest, then the release can be modeled as continuous, otherwise it would be treated as instantaneous.\n\\[ 2.5 \\le { u_r t_d \\over x^{\\star} } \\]\nRoughly speaking if the plume is still attached to the release point when the leading edge hits the downwind point of interest then it looks like a continuous plume to an observer at this point.\nThis can be used to define a critical distance, such that any distance less than that is best modeled by a continuous release and any distance greater is best modeled by an instantaneous release. This is useful as the downwind distance is, as of yet, unknown. The distance to half the LFL concentration, which defines the extent of the cloud involved in the explosion, depends upon which model is used and is in fact one of the key parameters we need to solve for.\n\nx⁺ = uᵣ*td/2.5\n\n720.0\n\n\nFrom prior experience, the distance to 1/2 LFL will likely be <~200m and so a continuous release can be assumed. If after performing the calculation the downwind distance turns out to be greater than 720m then this can be re-assessed.\n\n\nDense Gas Dispersion\nThe second critical factor for determining which model to use is whether or not the cloud is significantly denser than air. Dense clouds slump and hug the ground to a far greater extent than neutrally buoyant clouds and models for neutral clouds can lead to significant overestimations of the size of the vapour cloud when used on a dense cloud.\nThe relevant parameter for determining if a dense gas dispersion model should be used is the Richardson number, the ratio of the potential energy from the excess density to the kinetic energy from ambient turbulence. The Richardson number for continuous releases is defined as2\n2 AIChE/CCPS, Guidelines for Use of Vapour Cloud Dispersion Models, 2nd Ed., 50.\\[ \\mathrm{Ri} = { { g_o V_r } \\over { D_c u_{\\star} } } \\]\nwhere\n\\[ g_o = g { {\\rho_c - \\rho_a } \\over \\rho_a } \\]\nwith \\(\\rho_c\\) the density of the cloud and \\(\\rho_a\\) the density of the ambient air. \\(V_r\\) is the volumetric release rate, and \\(D_c\\) a critical distance, in this case we take \\(D_c\\) to be the release height.\nThe density of the cloud is significantly larger than the vapour density of butane as the cloud has a large fraction of aerosolized droplets, overall the cloud density can be estimated by\n\\[ \\frac{1}{\\rho_c} = { f_v \\over \\rho_g } + { \\left(1 - f_v \\right) f_a \\over \\rho_l }\\]\nThe critical Richardson number is about 50 such that if the Richardson number is greater than 50 then a dense gas model must be used.\n\nρc(T) = ((fᵥ/ρg(T)) + ((1-fᵥ)*fₐ/ρₗ(T)))^-1\n\ngₒ = g * ((ρc(Tc) - ρa(Tₐ))/ ρa(Tₐ))\n\nVr = Qaq/ρc(Tc)\n\nRi = (gₒ * Vr)/(hᵣ * u₊)\n\n381.8214520915426\n\n\n\nRi > 50\n\ntrue\n\n\nThis suggests that a dense gas model should be used, which conforms to our expectations as the vapour cloud is significantly denser than the ambient air.\nAn additional check is to use the criteria from Britter and McQuaid3\n3 “Workbook on the Dispersion of Dense Gases”.\\[ \\left( g_o V_r \\over { u_{10}^3 D} \\right)^{1/3} \\ge 0.15 \\]\nWhere \\(D\\) is a critical distance defined as\n\\[ D = \\sqrt{ V_r \\over u_{10} } \\]\n\nD = √(Vr/u₁₀)\n\n( (gₒ * Vr) / (u₁₀^3 * D) )^(1/3) ≥ 0.15\n\ntrue\n\n\n\n\nBritter-McQuaid model\nThe Britter-McQuaid model4 is a dense cloud dispersion model based on dimensional analysis and fitting to experimental data. It is given as a series of correlation curves for six different concentrations and the distance to the concentration of interest is interpolated from these. The concentrations represent a mean concentration over the whole cloud at that distance.\n4 Britter and McQuaid.This is one of the simpler models to use directly, and is appropriate for a screening case. Dense gas dispersion modeling is a large field with many different models that could be used and, like many things, as the models grow in detail they also grow in the number of parameters that must be provided. For the purposes of screening the limiting factor is often not computing power or model complexity per se as much as the information required to even run the models and the time needed to gather that information.\nNote the concentrations in this model are given in volume fraction and it is assumed that the in-cloud concentration of butane is 1.0 (i.e 100%) at the release point.\nThe Britter-McQuaid curves can be approximated with a series of piece-wise linear functions5\n5 AIChE/CCPS, Guidelines for Chemical Process Quantitative Risk Analysis.\\[ \\beta = m \\alpha + b \\]\n\nfunction piecewise(α; αs, ms, bs)\n i = findnext(x -> x > α, αs, 1)\n return ms[i]*α + bs[i]\nend\n\nfunction piecewise(; αs, ms, bs)\n return α -> piecewise(α, αs=αs, ms=ms, bs=bs)\nend\n\n\nBritter_McQuaid_correlations = Dict{Float64, Function}(\n 0.001 => piecewise(αs=[-0.69, -0.25, -0.13, 1.0],\n ms=[0.00, 0.39, 0.00, -0.50],\n bs=[2.60, 2.87, 2.77, 2.71]),\n 0.005 => piecewise(αs=[-0.67, -0.28, -0.15, 1.0],\n ms=[0.00, 0.59, 0.00, -0.49],\n bs=[2.40, 2.80, 2.63, 2.56]),\n 0.010 => piecewise(αs=[-0.70, -0.29, -0.20, 1.0],\n ms=[0.00, 0.49, 0.00, -0.52],\n bs=[2.25, 2.59, 2.45, 2.35]),\n 0.020 => piecewise(αs=[-0.69, -0.31, -0.16, 1.0],\n ms=[0.00, 0.45, 0.00, -0.54],\n bs=[2.08, 2.39, 2.25, 2.16]),\n 0.050 => piecewise(αs=[-0.68, -0.29, -0.18, 1.0],\n ms=[0.00, 0.36, 0.00, -0.56],\n bs=[1.92, 2.16, 2.06, 1.96]),\n 0.100 => piecewise(αs=[-0.55, -0.14, 1.0],\n ms=[0.00, 0.24, -0.50],\n bs=[1.75, 1.88, 1.78]),\n)\n\nDict{Float64,Function} with 6 entries:\n 0.01 => #7\n 0.005 => #7\n 0.02 => #7\n 0.001 => #7\n 0.1 => #7\n 0.05 => #7\n\n\n\n\n\n\n\n\n\n\nFigure 1: The digitized Britter-McQuaid correlation curves.\n\n\n\n\n\nThe correlations are given in terms of the parameters α and β, which are\n\\[ \\alpha = 0.2 \\cdot \\log \\left( g_o^2 V_r u_{10}^{-5} \\right) \\]\nand\n\\[ \\beta = \\log \\left( x \\over D \\right) \\]\nAt first glance it is not obvious how to use these plots, or the associated piecewise functions, since, at least to me, the obvious form of a model is to compute the concentration at a given point whereas the Britter-McQuaid model does the opposite. One supplies a concentration of interest and solves for the downwind distance where the leading edge of the plume hits this concentration.\nThe general procedure is:\n\nCompute the parameter α for the given scenario\nFind the concentration curves that bracket the concentration of interest\nCalculate β at these two concentrations and interpolate to find β at the concentration of interest\nCalculate the downwind concentration, x, from β\n\nThe function below is a convenience function that calculates the β for each concentration curve at a fixed α and returns a function that linearly interpolates to find the downwind distance for a given concentration.\n\n\"\"\"\n britter_mcquaid_model(α, D; table=Britter_McQuaid_correlations)\n\nGenerate the interpolation function x(c), using the Britter-McQuaid correlations.\nThe system is parameterized by α and D, which are defined in the Britter-McQuaid\nmodel documentation.\n\n\"\"\"\nfunction britter_mcquaid_model(α, D; table=Britter_McQuaid_correlations)\n interp_data = [ [conc, bfun(α)] for (conc, bfun) in table ]\n interp_data = hcat(interp_data...)'\n interp_data = sortslices(interp_data, dims=1)\n linterp = LinearInterpolation(interp_data[:,1], interp_data[:,2], extrapolation_bc=Line())\n \n return c -> 10^(linterp(c))*D\nend\n\nbritter_mcquaid_model\n\n\n\nα = 0.2*log10( gₒ^2 * Vr * u₁₀^-5 )\n\n0.17108241842192004\n\n\n\nx = britter_mcquaid_model(α, D)\n\n#11 (generic function with 1 method)\n\n\nThe Britter-McQuaid model assumes an isothermal case and the following correction is suggested for non-isothermal cases\n\\[ C^\\prime = { C \\over { C + (1-C) \\frac{T_a}{T_c} } }\\]\nIn this case the concentration of interest is half the lower flammability limit and the cloud is assumed to be well below ambient conditions.\n\nc = 0.5*LFL\n\nCᵢ = c / (c + (1 - c)*(Tₐ/Tc) )\n\n0.008508269826866945\n\n\nThe down-wind distance to half the LFL can then be estimated, and checked to ensure it is within the region for which a continuous release is a reasonable approximation.\n\nxᵢ = x(Cᵢ)\n\n165.85001073807788\n\n\n\nxᵢ ≤ x⁺\n\ntrue\n\n\nThe Britter-McQuaid model also has a correlation for short distances, \\(x \\lt 30D\\)\n\\[ C = { { 306 \\left( \\frac{x}{D} \\right)^{-2} } \\over { 1 + 306 \\left( \\frac{x}{D} \\right)^{-2} } } \\]\n\n\n\n\n\n\n\n\nFigure 2: The Britter-McQuaid concentration curve for the example release.\n\n\n\n\n\nThe Britter-McQuaid model does provide some further correlations to calculate the dimensions of the plume, though with many caveats as the model can over-estimate the width of the plume. Also I’ve noticed several sources give an obviously incorrect equation for plume height – it returns heights on the order of a few centimeters for plumes extending hundreds of meters in horizontal directions.\n\n\n\n\n\n\nFigure 3: The Britter-McQuaid plume shape and dimensions.\n\n\n\nInstead of that I am going to use a simple rule-of-thumb for small plumes\n\\[ V_{PES} = 0.03 x_{\\frac{1}{2}LFL}^3 \\]\nwhere \\(V_{PES}\\) is the volume of the potential explosion site. In general this more than just the volume of a plume dispersing in open space, since buildings and equipment can confine the cloud and create multiple potential explosion sites of various sizes. This is a simple rule-of-thumb for screening purposes only\n\nVₚₑₛ = 0.03 * xᵢ^3\n\n136857.23663150807" + }, + { + "objectID": "posts/vapour_cloud_explosion_example/index.html#vapour-cloud-explosion", + "href": "posts/vapour_cloud_explosion_example/index.html#vapour-cloud-explosion", + "title": "VCE Example - Butane Vapour Cloud", + "section": "Vapour Cloud Explosion", + "text": "Vapour Cloud Explosion\nThere are several parameters that can be estimated to characterize explosions with the positive overpressure perhaps being the most useful for simple screening cases. Tables exist relating different levels of positive overpressure to possible damage of nearby structures.\n\n\n\n\n\n\n\n\npsi\nkPa\nDamage\n\n\n\n\n0.02\n0.14\nAnnoying noise\n\n\n0.04\n0.28\nLoud noise, sonic boom, glass failure\n\n\n0.15\n1.03\nTypical pressure for glass breakage\n\n\n0.4\n2.76\nLimited minor structural damage\n\n\n1\n6.9\nPartial demolition of houses, made uninhabitable\n\n\n2 - 3\n13.8 - 20.7\nConcrete or cinder block walls, not reinforced, shattered\n\n\n3\n20.7\nSteel framed buildings distorted and pulled away from foundations\n\n\n4\n27.6\nCladding of light industrial buildings ruptured\n\n\n5\n34.5\nWooden utility poles snapped\n\n\n7\n48.2\nLoaded train wagon overturned\n\n\n10\n68.9\nProbable total destruction of buildings\n\n\n\nIt’s worth taking a moment to talk briefly about deflagrations and detonations. Deflagrations are characterized by subsonic flame propagation where the reaction zone moves through the flammable vapour by diffusion of heat and mass. Deflagrations typically result in relatively modest overpressures. A detonation, on the other hand, is characterized by a supersonic flame propagation and the reaction zone propagates by a pressure wave compressing the flammable vapour adiabatically to a temperature above the autoignition temperature. Detonations typically have significantly higher overpressures than deflagrations. Typically vapour cloud explosions in an open space with little congestion are deflagrations, however confinement and obstacles in the flame path can can accelerate a subsonic deflagration into a supersonic detonation. This is one reason why confinement and obstacles around a potential explosion site are important in the calculations.\nThe simplest way of calculating the overpressure is the TNT model, where the volume previously defined is used to estimate a potential explosion energy in TNT equivalents and this is compared to blast curves for TNT. For most major vapour cloud explosion incidents the TNT equivalences have been estimated to be from 1-10% of the full energy content of the cloud.\nThis is conceptually simple but can lead to very conservative estimates as vapour clouds, basically, don’t explode like TNT. These methods typically overestimate pressure close to the explosion source and underestimate it far afield. Even though the TNT method is generally not recommended, at least not in any of the references I have, it is still used in some places and it does crop up.\nA better approach is to use blast curves specifically for VCEs, in this case I am using the Baker-Strehlow-Tang curves but there are others.\n\nExplosive Energy\nThere are several ways of estimating explosive energy and all depend, in some way, on the size of the vapour cloud. Supposing the vapour cloud explosion is a deflagration and the energy in the explosion, fundamentally, comes from the combustion of the butane in the cloud then the energy can be found by estimating how much butane will combust and multiplying that by the heat of combustion of butane.\nIn some references the entire volume of the cloud will be assumed butane, but that can be excessively conservative – we are assuming the edge of the cloud to be 1/2 LFL or ~0.93% (v/v) butane so assuming it to be 100% butane in that region is a serious over-estimate.\nAn alternative method6 is to assume the cloud overall is at stoichiometric conditions. That is find the value of the stoichiometric concentration \\(\\eta\\)\n6 AIChE/CCPS, Guidelines for Vapour Cloud Explosion, Pressure Vessel Burst, BLEVE and Flash Fire Hazards.\\[ \\eta = { \\textrm{moles fuel} \\over \\textrm{moles fuel + air} } = { n_f \\over { n_f + \\frac{n_o}{f_o} } } \\]\nwhere \\(n_f\\) is the moles of fuel, \\(n_o\\) the moles of oxygen, and \\(f_o\\) the mole fraction of oxygen in air, 20.946%. For the combustion of n-butane\n\\[ C_{4} H_{10} + \\frac{13}{2} O_{2} \\longrightarrow 4 C O_{2} + 5 H_{2} O\\]\nand so for \\(n_f = 1\\) we have \\(n_o = 6.5\\)\n\nη = 1 / (1 + 6.5/0.20946)\n\n0.031218607756809045\n\n\nUsing the ideal gas law the total moles of gas in the cloud can be estimated\n\\[ n_c = { { p_a V_{PES} } \\over { R T_c } }\\]\nand the explosive energy is then\n\\[ E_{PES} = \\eta n_c \\Delta H_c \\]\n\nnc = ( pₐ * Vₚₑₛ )/(R * Tc)\n\nEₚₑₛ = η * nc * ΔHc\n\n5.0778644110258764e8\n\n\n\n\nThe BST model\nThe Baker-Strehlow-Tang model provides a series of correlation curves for different flame speeds and relates the positive overpressure to an energy scaled distance. The curves are based on spherical explosions and so it is important to include ground reflection when calculating the scaled distance.\nThe positive overpressure used in the BST model is a dimensionless pressure\n\\[ P = { { p - p_a } \\over p_a } \\]\nwhere \\(p\\) is the positive overpressure and \\(p_a\\) the atmospheric pressure. This is correlated to the scaled distance\n\\[ R = r \\cdot \\left( p_a \\over E \\right)^{1/3} \\]\nwhere \\(E\\) is the explosive energy and \\(r\\) the distance from the explosion epicentre. Which in this case we can take as the centre of the cloud and estimate to be half-way to \\(x_i\\)\nA spreadsheet with the BST curves is provided along with the AIChE/CCPS Guidelines for Chemical Process Quantitative Risk Analysis7 from which I’ve extracted just the positive overpressure curves as a csv.\n7 AIChE/CCPS, Guidelines for Chemical Process Quantitative Risk Analysis.\nbst_curves = CSV.read(\"data/BST-curves.csv\", DataFrame)\n\n# just show the first 5 rows\nfirst(bst_curves, 5)\n\n5 rows × 3 columnsMfScaled DistanceOverpressureFloat64Float64Float6410.0370.0101790.010096120.0370.01048670.010027130.0370.01080270.010099340.0370.01112870.010100950.0370.01146460.0101025\n\n\n\n\n\n\n\n\n\n\nFigure 4: The Baker-Strehlow-Tank overpressure curves\n\n\n\n\n\nThe following table8 cross-references flame speed – the key parameter of the BST curves – with qualitative descriptors of fuel reactivity, density of surrounding process equipment, and degree of confinement\n8 AIChE/CCPS, Guidelines for Vapour Cloud Explosion, Pressure Vessel Burst, BLEVE and Flash Fire Hazards.\nThe dimensionality given is referencing the number of dimensions along which the pressure wave can travel. For example an explosion confined to a tunnel is considered one dimensional since the pressure wave can only travel along the tunnel, whereas an explosion in an open field is three dimensional since it can expand in all directions. The entries labeled DDT are where a deflagration to detonation transition may occur, due to the flame speed and potential congestion. In these cases it is recommended to use the \\(Mf = 5.2\\) curve, sometimes referred to as the detonation blast curve.\n\n\n\n\n\n\n\nDimension\nDescription\n\n\n\n\n3-D\nUnconfined volume, almost completely free expansion\n\n\n2.5-D\nBlockage partially prevents flame in one direction, such as piperacks with tightly packed pipes, lightweight roofs, or frangible panels\n\n\n2-D\nPlatforms carrying process equipment, space beneath cars, open sided multistory buildings\n\n\n1-D\nTunnels, corridors, or sewage systems\n\n\n\nFor the storage sphere I am assuming it is standing freely on its own without any platforms or structures confining it, so the dimension is 3-D\nThe density of surrounding equipment is defined qualitatively in terms of how much the surrounding area obstructs the expansion of the pressure wave. This can be defined in terms of the percentage of area in a plane occupied by obstacles.\n\n\n\n\n\n\n\n\nType\nBlockage Ratio\nPitch for Obstacle Layers\n\n\n\n\nLow\n< 10%\nOne or two layers of obstacles\n\n\nMedium\n10 - 40%\nTwo to three layers of obstacles\n\n\nHigh\n> 40%\nThree of more fairly closely spaced obstacle layers\n\n\n\nFor the storage sphere suppose that there is some process equipment nearby but it is not highly confined, defaulting to medium.\nThe fuel reactivity categories are defined in terms of the laminar burning velocity\n\n\n\nReactivity\nLaminar Burning Velocity\n\n\n\n\nLow\n< 45 cm/s\n\n\nMedium\n45 - 75 cm/s\n\n\nHigh\n> 75 cm/s\n\n\n\nThe best resource for finding these tabulated is Appendix D of NFPA 68.9 For butane the laminar burning velocity is 45cm/s10 and thus is medium reactivity\n9 NFPA 68.10 NFPA 68 Table D.1(a).Returning to the table we find that the flame speed for a medium reactivity fuel, medium obstacle density, 3D case is 0.44 (in terms of Mach number)\n\nMf = 0.44\n\n0.44\n\n\nNote the flame speeds given in the table do not correspond to the flame speeds given in the BST curves. In general one will have to double-interpolate to get the results. Find the curves that bracket the desired flame speed, interpolate to find the corresponding pair of overpressures at the given scaled distance then interpolate to find the overpressure at the desired flame speed.\nThe following code sets this up in an easy way, though probably a very sub-optimal one, by doing the following:\n\nAn interpolation function is created for each flame speed, these are stored in bst_interps\nThe function Δp₊ calculates the positive overpressure by stepping through the array bst_interps and calculating the overpressures at each tabulated flame speed for a given distance, then interpolates for the desired flame speed\n\n\nbst_interps = []\nflame_speeds = unique(bst_curves[!, \"Mf\"])\n\nfor speed in flame_speeds\n data = bst_curves[ bst_curves.\"Mf\" .== speed, :]\n interp = LinearInterpolation(data[!, \"Scaled Distance\"], \n data[!, \"Overpressure\"], \n extrapolation_bc=Line())\n push!(bst_interps, (speed, interp))\nend\n\n\n\"\"\"\n Δp₊(r ; Mf=Mf, E=2*Eₚₑₛ, p₀=pₐ, curves=bst_interps)\n\nCalculate the positive overpressure at distance r from the explosion epicentre.\nThe model parameters are the apparent flame speed, Mf, in terms of Mach number,\nthe explosion energy, E, and the atmospheric pressure p₀. The units of E and p₀\nmust agree, e.g. kJ and kPa.\n\nThe Baker-Strehlow-Tang curves are supplied through the curves keyword.\n\"\"\"\nfunction Δp₊(r ; Mf=Mf, E=2*Eₚₑₛ, p₀=pₐ, curves=bst_interps)\n E = 1000*E # Energy must be in J\n R = r*(p₀/E)^(1/3)\n \n Mfs = []\n Ps = []\n \n for (speed, interp) in bst_interps\n push!(Mfs, speed)\n push!(Ps, interp(R))\n end\n \n P = LinearInterpolation(Mfs, Ps)\n \n return P(Mf)*p₀\n \nend\n\nΔp₊\n\n\nThis generates a new blast curve interpolated between the curves supplied with the BST model, as shown in the plot below. Though care should be taken when the curve is used outside the range of the original curves.\nNote that the explosion energy is being multiplied by 2. This is to account for ground reflection as the BST curves are based on spherical explosions. In general the explosion energy is multiplied by a factor, which ranges from 1 to 2, to account for ground reflection where a factor of 2 is for explosions exactly at ground level and a factor of 1 is for explosions at significant elevation. The simpler and more conservative approach is to use a factor of 2.\n\n\n\n\n\n\n\n\nFigure 5: The Baker-Strehlow-Tank overpressure curves with the current scenario indicated.\n\n\n\n\n\nThe explosion epicentre is assumed to be the centre of the vapour cloud, here approximated to be half way between the release point and the downwind distance to 1/2 LFL, and in this simple model the explosion is a hemispherical pressure wave expanding in all directions unobstructed. More advanced modeling will take into account buildings and equipment and their impact on shaping the pressure wave.\nThe plot below shows the maximum positive overpressure experienced at that distance. Which is what is typically tabulated for different types of consequences. In general when I refer to overpressure this is what I am referring to.\n\n\n\n\n\n\n\n\nFigure 6: The overpressure contours for the given example, showing the maximum overpressure experienced at the location.\n\n\n\n\n\n\n\nSensitivity\nA useful question to ask at this point is how sensitive is the predicted overpressure to the parameters of the model.\nThe figure below shows the impact of varying reactivity, while holding all other parameters constant. Clearly whether or not the explosion is treated as a detonation or deflagration matters hugely, the high reactivity curve corresponds to a detonation. The difference between a medium and low reactivity material is a approximately a factor of 4 in terms of max overpressure. So finding an appropriate value of reactivity while not being overly conservative is important.\n\n\n\n\n\n\n\n\nFigure 7: The sensitivity of the overpressure curve to reactivity.\n\n\n\n\n\nThe following figure shows the impact of varying the levels of congestion. There is a fair amount of sensitivity going from low to medium but less from medium to high, which is perhaps what you would expect. I think the somewhat strange shape of the peaks is an artifact of linear interpolation.\n\n\n\n\n\n\n\n\nFigure 8: The sensitivity of the overpressure curve to levels of congestion.\n\n\n\n\n\nAs is clear from the figure below the results are much less sensitive to changes in the level of confinement. At least while neglecting the one dimensional case, which should always be treated as a special case regardless.\n\n\n\n\n\n\n\n\nFigure 9: The sensitivity of the overpressure curve to levels of confinement.\n\n\n\n\n\nThe last parameter worth investigating is the explosive energy, a significant portion of this exercise was in estimating the size of the vapour cloud and the consequent explosive energy. As is clear from the following figure the model is much less sensitive to changes in the explosive energy than the other parameters.\n\n\n\n\n\n\n\n\nFigure 10: The sensitivity of the overpressure curve to +/- 50% change in explosive energy" + }, + { + "objectID": "posts/vapour_cloud_explosion_example/index.html#references", + "href": "posts/vapour_cloud_explosion_example/index.html#references", + "title": "VCE Example - Butane Vapour Cloud", + "section": "References", + "text": "References\n\n\nAIChE/CCPS. Guidelines for Chemical Process Quantitative Risk Analysis. 2nd ed. New York: American Institute of Chemical Engineers, 2000.\n\n\n———. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999.\n\n\n———. Guidelines for Use of Vapour Cloud Dispersion Models, 2nd Ed. New York: American Institute of Chemical Engineers, 1996.\n\n\n———. Guidelines for Vapour Cloud Explosion, Pressure Vessel Burst, BLEVE and Flash Fire Hazards. 2nd ed. New York: American Institute of Chemical Engineers, 2000.\n\n\nBritter, Rex E., and J. McQuaid. “Workbook on the Dispersion of Dense Gases. HSE Contract Research Report No. 17/1988,” 1988.\n\n\nNFPA 68: Standard on Explosion Protection by Deflagration Venting. Boston, MA: National Fire Protection Association, 2018." + }, + { + "objectID": "posts/integrated_puff/index.html", + "href": "posts/integrated_puff/index.html", + "title": "Between a puff and a plume", + "section": "", + "text": "In previous examples I used both Gaussian plume and puff models for continuous and instantaneous releases, respectively, but what about the in-between cases? It is more commonly the case for a leak from a process vessel to be a prolonged, but finitely long, release.\nThe guidance is to typically pick one or the other, depending upon the length of the release, or use a more complex model, e.g. the guidance in Lees1 is\nWhere u is the wind speed, Δt the duration of the release, and \\(\\sigma_x\\) is the downwind dispersion evaluated at \\(x = \\frac{u \\Delta t}{2}\\).2\nAn alternative approach is to evaluate the downwind dispersion at a particular point of interest x1 and use the same criteria. This is much less strict than what Lees gives and is what I will do, it motivates investigating anything other than pure plume models.\nOne approach in this in-between zone is to try both and pick the most conservative. But that can lead to extremely conservative results. An alternative might be to take a page from more complex models, such as INTPUFF and SCIPUFF, and treat an intermediate release as a series of smaller puff releases." + }, + { + "objectID": "posts/integrated_puff/index.html#motivating-example", + "href": "posts/integrated_puff/index.html#motivating-example", + "title": "Between a puff and a plume", + "section": "Motivating example", + "text": "Motivating example\nSuppose a release from a process vessel, say a jet of gas issuing from a hole, we suppose the release is a constant rate of 1kg/s for 5s just for some nice round numbers. The release is at ground level and the ambient conditions are class D with a 2m/s windspeed. We have a point of interest 100m down-wind of the release, this could be an inhabited building or the fence-line.3\n3 We are also implicitly assuming the release is neutrally buoyant, and so a Gaussian dispersion model would be appropriate.\nm = 1 #kg/s\nΔt = 5 #s\nh = 0 #m\nu = 2 #m/s\n\nx₁ = 100 #m\nt₁ = x₁/u #s\n\nThe class D dispersion parameters4 are:\n4 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases, 90.\n# class D puff dispersion\n\nσx(x) = 0.06*x^0.92\nσy(x) = σx(x)\nσz(x) = 0.15*x^0.70\n\nThe release, at the point of interest, meets neither the criteria for a puff model nor a plume model.\n\nu*Δt < 2*σx(x₁)\n\nfalse\n\n\n\nu*Δt > 5*σx(x₁)\n\nfalse\n\n\nSo some other kind of model must be used." + }, + { + "objectID": "posts/integrated_puff/index.html#single-gaussian-puff", + "href": "posts/integrated_puff/index.html#single-gaussian-puff", + "title": "Between a puff and a plume", + "section": "Single Gaussian puff", + "text": "Single Gaussian puff\nRecall that a single Gaussian puff is the product of 3 Gaussian distributions\n\\[ c \\left(x,y,z,t \\right) = m \\Delta t \\cdot g_x(x, t) \\cdot g_y(y) \\cdot g_z(z) \\]\nwith\n\\[ g_x(x,t) = {1 \\over \\sqrt{2\\pi} \\sigma_x } \\exp \\left( -\\frac{1}{2} \\left( x-u t \\over \\sigma_x \\right)^2 \\right) \\]\n\\[ g_y(y) = {1 \\over \\sqrt{2\\pi} \\sigma_y } \\exp \\left( -\\frac{1}{2} \\left( y \\over \\sigma_y \\right)^2 \\right) \\]\n\\[ g_z(z) = {1 \\over \\sqrt{2\\pi} \\sigma_z } \\left[ \\exp \\left( -\\frac{1}{2} \\left( z-h \\over \\sigma_z \\right)^2 \\right) + \\exp \\left( -\\frac{1}{2} \\left( z+h \\over \\sigma_z \\right)^2 \\right) \\right]\\]\nwhere, for the sake of clarity, I’ve neglected the fact that the dispersion parameters σ are themselves all functions of t by being functions of the location of the center of the puff.\n\ngx(x, t) = exp((-1/2)*((x-u*t)/σx(u*t))^2)/(√(2π)*σx(u*t))\ngy(y, t) = exp((-1/2)*(y/σy(u*t))^2)/(√(2π)*σy(u*t))\ngz(z, t) = (exp((-1/2)*((z-h)/σz(u*t))^2)+exp((-1/2)*((z+h)/σz(u*t))^2))/(√(2π)*σz(u*t))\n\n\nc_pf(x,y,z,t; m, Δt) = m*Δt*gx(x,t)*gy(y,t)*gz(z,t)\n\nFor some context we can plot the puff as a single, instantaneous, release\n\n\n\n\n\n\n\nFigure 1: The dispersion of a single, instantaneous, puff." + }, + { + "objectID": "posts/integrated_puff/index.html#multiple-puffs", + "href": "posts/integrated_puff/index.html#multiple-puffs", + "title": "Between a puff and a plume", + "section": "Multiple puffs", + "text": "Multiple puffs\nOur first approximation to a release of appreciable duration is to break the release up into n intervals and release a single puff per interval.\n\\[ c(x,y,z,t) = \\sum_{i=0}^{n} m \\frac{\\Delta t}{n} \\cdot g_x(x, t-\\frac{i}{n}\\Delta t) \\cdot g_y(y) \\cdot g_z(z) \\]\nwhere we have simply taken the sum of n single puffs, each representing a fraction of the overall release, and emitting them one after the other.\n\nfunction sum_of_puffs(x,y,z,t; m, Δt, n)\n c = 0\n δt = Δt/n\n for i in 0:1:n\n t′ = t-i*δt\n c′ = t′>0 ? c_pf(x,y,z,t′; m=m, Δt=δt) : 0\n c += isnan(c′) ? 0 : c′ \n end\n return c\nend\n\nWe can plot this for n=5 and we see that, while initially the individual puffs are distinct, they quickly merge into a larger more spread-out cloud.5\n5 To an extent this is a function of using a class D atmosphere. For a much more stable atmosphere, e.g. class F, the puffs remain quite distinct until a size-able number of them have been released.\n\n\n\n\n\n\nFigure 2: The dispersion of a sequence of multiple smaller puffs.\n\n\n\n\nIf we plot the max-concentration experienced at our point of interest, x=100m, against an increasingly finely-divided release (more puffs, but each puff represents a smaller slice of time) it is clear that they are converging towards a number, and relatively quickly.\n\n\n\n\n\n\n\n\nFigure 3: The effect of increasing the number of puffs\n\n\n\n\n\nThis suggests a next step, taking the limit as \\(n \\to \\infty\\)" + }, + { + "objectID": "posts/integrated_puff/index.html#integrated-puffs", + "href": "posts/integrated_puff/index.html#integrated-puffs", + "title": "Between a puff and a plume", + "section": "Integrated puffs", + "text": "Integrated puffs\nReturning to our model of multiple puffs\n\\[ c(x,y,z,t) = \\sum_{i=0}^{n} m \\frac{\\Delta t}{n} \\cdot g_x(x, t-\\frac{i}{n}\\Delta t) \\cdot g_y(y) \\cdot g_z(z) \\]\nWe can re-arrange this and take the limit as \\(n \\to \\infty\\)\n\\[ c(x,y,z,t) = m\\cdot g_y(y) \\cdot g_z(z) \\cdot \\left( \\lim_{n \\to \\infty} \\sum_{i=0}^{n} g_x(x, t-\\frac{i}{n}\\Delta t) \\frac{\\Delta t}{n} \\right) \\]\n\\[ = m\\cdot g_y(y) \\cdot g_z(z) \\cdot \\int_{t-\\Delta t}^{t} g_x(x, t^{\\prime}) dt^{\\prime}\\]\nWhere we have replaced the limit with the integral.6\n6 I am assuming the dispersion parameters are constants, though they are not in practice as they are correlated to the downwind distance to the center of any given puff. I am assuming for a small enough release this is approximately constant at least.This is an integral of a Gaussian, and so we expect the results to be in terms of the error function\n\\[ \\mathrm{erf}(x) = \\frac{2}{\\sqrt{\\pi}} \\int_0^x \\exp \\left( -t^2 \\right) dt \\]\nFor the integral of the x component of the Gaussian puff we have\n\\[ \\int_{t-\\Delta t}^{t} g_x(x, t^{\\prime}) dt^{\\prime} = \\int_{t-\\Delta t}^{t} {1 \\over \\sqrt{2\\pi} \\sigma_x } \\exp \\left( -\\frac{1}{2} \\left( x-u t^{\\prime} \\over \\sigma_x \\right)^2 \\right) dt^{\\prime}\\]\nmaking the substitution \\[\\xi = { {x - u t^{\\prime} } \\over \\sqrt{2} \\sigma_x} \\]\nwe get7\n7 This model and the sum of puffs model both naively include contributions from releases that haven’t happened yet, e.g. at t=1 only the contribution of material released at times t≤1 should be included, but without any correction the other parts of the release would be included causing slight errors in the vicinity of the release point at t<Δt. The solution is simply to take the duration of the release to be the minimum of either the elapsed time (i.e. when the release is still “happening”) or the total release duration.\\[ \\int_{t-\\Delta t}^{t} g_x(x, t^{\\prime}) dt^{\\prime} = {-1 \\over \\sqrt{\\pi} u} \\int_{a}^{b} \\exp \\left( -\\xi^2 \\right) d\\xi \\]\n\\[ = {-1 \\over \\sqrt{\\pi} u} \\left[ \\frac{\\sqrt{\\pi}}{2} \\mathrm{erf}(b) - \\frac{\\sqrt{\\pi}}{2} \\mathrm{erf}(a) \\right] \\]\n\\[ = \\frac{1}{2u} \\left( \\mathrm{erf}(a) - \\mathrm{erf}(b) \\right)\\]\nwhere\n\\[a = { {x - u (t-\\Delta t)} \\over \\sqrt{2} \\sigma_x }\\]\n\\[b = { {x - u t} \\over \\sqrt{2} \\sigma_x } \\]\n\nusing SpecialFunctions: erf\n\nfunction ∫gx(x,t,Δt)\n Δt = min(t,Δt)\n a = (x-u*(t-Δt))/(√2*σx(u*(t-Δt)))\n b = (x-u*t)/(√2*σx(u*t))\n return erf(b,a)/(2u)\nend\n\nintpuff(x,y,z,t; m, Δt) = m*gy(y,x/u)*gz(z,x/u)*∫gx(x,t,Δt)\n\n\n\n\n\n\n\n\nFigure 4: The dispersion of the integrated puff model, assuming constant dispersion/\n\n\n\n\nThis release model has some convenient properties: clearly as \\(\\Delta t \\to 0\\) it becomes a Gaussian puff again, but also as \\(\\Delta t \\to \\infty\\) also limits to the Gaussian plume.8\n8 This is somewhat hand-wavy but a release of infinite duration is an event that began an infinite amount of time in the past and continues an infinite amount into the future, so the term \\(\\frac{1}{2u} \\left( \\mathrm{erf}(a) - \\mathrm{erf}(b) \\right)\\) goes in the limit to \\(\\frac{1}{2u} \\left( \\mathrm{erf}(\\infty) - \\mathrm{erf}(-\\infty) \\right) = \\frac{1}{u}\\) resulting in a concentration profile of \\(c \\left(x,y,z,t \\right) = \\frac{m}{u} \\cdot g_y(y) \\cdot g_z(z)\\), which is exactly a plume model.\n\n\n\n\n\n\n\nFigure 5: The limiting behaviour of the integrated puff model, it smoothly connects the single puff model and the plume model when using the same dispersion constants." + }, + { + "objectID": "posts/integrated_puff/index.html#complications", + "href": "posts/integrated_puff/index.html#complications", + "title": "Between a puff and a plume", + "section": "Complications", + "text": "Complications\nI’ve been casually treating the dispersion parameters, \\(\\sigma_x, \\sigma_y, \\sigma_z\\), as being constants that are independent of the model and any transformations on the model. Within the context of taking sums and doing integrals it is reasonable: within a reasonable radius of the center of a given puff they are nearly constant. However, in practice, they depend upon time through their dependence on the location of the puff center and are also functions of the model itself.\nThe dispersion parameters for a plume model are not the same as for a puff, and so the nice smooth curve connecting the two doesn’t really work. Not if you are strictly taking dispersion parameters as provided in standard references. It is not at all clear how to transition from the one set to the other either, in a smooth manner, to ensure that there is a smooth transition from puff to plume.\nThat said, multiple puff models use the dispersion parameters for puffs and so using the puff parameters in the integrated puff model at least puts one in good company.\n\n\n\n\n\n\nWarningUpdate\n\n\n\nThere is a follow-up post that discusses the quality of these approximations in more detail.\nWhen I first wrote this post I could not find my final result in the literature – it wasn’t in the standard references I use, and I think I just didn’t know the right search terms. Though it seemed equally obvious to me that it must be in the literature somewhere. Since posting this, I found it: Palazzi et al.9 This is also the model used by ALOHA and the older ARCHIE models.\n\n\n9 “Diffusion from a Steady Source of Short Duration.”" + }, + { + "objectID": "posts/integrated_puff/index.html#references", + "href": "posts/integrated_puff/index.html#references", + "title": "Between a puff and a plume", + "section": "References", + "text": "References\n\n\nAIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999.\n\n\nLees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996.\n\n\nPalazzi, E, M De Faveri, Giuseppe Fumarola, and G Ferraiolo. “Diffusion from a Steady Source of Short Duration.” Atmospheric Environment 16, no. 12 (1982): 2785–90. https://www.researchgate.net/publication/328744668_DIFFUSION_FROM_A_STEADY_SOURCE_OF_SHORT_DURATION." + }, + { + "objectID": "posts/pollen_dispersion/index.html", + "href": "posts/pollen_dispersion/index.html", + "title": "Mapping Pollen Dispersion", + "section": "", + "text": "It has been a beautiful spring in Edmonton and the trees are tentatively flowering and throwing pollen to the wind. Watching the trees come back from their barren winter state left me wondering about the wind dispersal of pollen. Pollen grains must be small enough to travel quite a distance to encounter any other trees to pollinate, but they do settle out eventually. So, how far do they actually go? I’ve been wanting to play around with the mapping tools in the julia ecosystem, and this question gave me an opportunity to make some maps exploring pollen dispersal in my neighbourhood.\nI 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.\nIn 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)." + }, + { + "objectID": "posts/pollen_dispersion/index.html#building-a-model-of-pollen-dispersion", + "href": "posts/pollen_dispersion/index.html#building-a-model-of-pollen-dispersion", + "title": "Mapping Pollen Dispersion", + "section": "Building a Model of Pollen Dispersion", + "text": "Building a Model of Pollen Dispersion\nFor 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 \\(W_{set}\\). The coordinate system is centred at the base of the tree with an x-axis parallel to the wind.\n\n\n\n\n\n\nFigure 1: A sketch of a single Elm tree as an elevated point source.\n\n\n\n\nA Model Elm Tree\nThere are a few things I will need to know about each elm tree in the neighbourhood:\n\nThe height at which pollen is released\nThe rate at which pollen is released\n\nNeither 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.\nTo build an example elm tree, suppose it has a DBH of 88 cm\n# Model Elm tree\nDBH = 88u\"cm\" |> u\"m\"\nThe crown height for an American Elm is correlated to DBH by the following equation1 for urban trees in the North climate zone\n1 McPherson, Doorn, and Peper, “Urban Tree Database and Allometric Equations”.# McPherson, van Doorn, and Peper, *Urban Tree Database*\n# Ulmus Americana, North climate zone\ncrown_height(DBH) = 0.44998 + 0.55096*DBH - 0.00666*DBH^2 + 3e-5*DBH^3\n@ucorrel crown_height u\"cm\" u\"m\"\nI 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.\nFor 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.\nThe 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.\n2 Katz, Morris, and Batterman, “Pollen Production for 13 Urban North American Tree Species”.# Ulmus Americana total pollen per tree\n# Katz, Morris, and Batterman, \"Pollen Production,\" Table 2\nfunction total_pollen(DBH)\n B = π/4*DBH^2\n return exp(5.86*B + 23.11)\nend\n@ucorrel total_pollen u\"m\" u\"grains\"\nThis gives a total pollen content of 384,082,918,235 grains for the model elm tree, which sounds like a lot.\nElm 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.\nΔt = 14u\"d\" |> u\"s\"\npollen_rate(DBH) = total_pollen(DBH)/Δt\nFor the example tree, this gives a pollen release rate of 317,529 grains s^-1.\n\n\nPollen Settling\nElm 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.\n\n\n\n\n\n\nFigure 2: A single pollen grain as a solid sphere falling at terminal velocity.\n\n\n\nbegin\n\n# Ulmus Americana pollen\n# Brush and Brush, \"Transport of Pollen,\" Tables 3 and 12.\nd = 31u\"μm\" |> u\"m\"\nSG = 1.1\nρ = SG*1000u\"kg/m^3\"\n\nend;\nbegin\n\ng = 9.80665u\"m/s^2\" # standard gravity\nρₐ = 1.225u\"kg/m^3\" # density of dry air (15°C, 1atm)\nμₐ = 17.89e-6u\"Pa*s\" |> u\"kg/m/s\" # viscosity of dry air (15°C, 1atm)\n\nend;\n# Stokes Law\nvₜ = ((ρ - ρₐ)*g*d^2)/(18*μₐ)\nThis gives a settling velocity for a grain of American Elm pollen in air of 3.22 cm s^-1\n\n\nAtmospheric Dispersion\nWe 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.\nThe turbulent mixing in the air is captured using the dispersion parameters \\(\\sigma_y\\) and \\(\\sigma_z\\) 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.\n3 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\nu = 2u\"m/s\"\nσ_y(x) = 0.16x/√(1+0.0004x)\n@ucorrel σ_y u\"m\" u\"m\"\nσ_z(x) = 0.14x/√(1+0.0003x)\n@ucorrel σ_z u\"m\" u\"m\"\n\n\nThe Ermak Equation\nI 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 \\(W_{set}\\) and deposition velocity \\(W_{dep}\\)\n4 Ermak, “An Analytical Model for Air Pollutant Transport and Deposition from a Point Source”.\\[\n\\frac{\\partial c}{\\partial r} - \\frac{W_{set}}{K} \\frac{\\partial c}{\\partial z} = \\frac{\\partial^2 c}{\\partial y^2} + \\frac{\\partial^2 c}{\\partial z^2}\n\\]\nwith boundary condition at the ground\n\\[\n\\left( K \\frac{\\partial c}{\\partial z} + W_{set} c \\right)_{z=0} = W_{dep} c|_{z=0}\n\\]\nwhere K is the eddy diffusivity and r is defined as\n\\[\nr = \\frac{1}{u} \\int_0^x K dx^{\\prime}\n\\]\nand 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 \\(\\sigma_y\\) and \\(\\sigma_z\\) as, by definition, \\(\\sigma^2 = 2 r\\)\n\\[\nc = \\frac{m}{2\\pi u \\sigma_y \\sigma_z} \\exp\\left( - \\frac{y^2}{2 \\sigma_y^2} \\right) \\exp\\left( { - {W_{set} (z-h)} \\over {2K_z} } - { {W_{set}^2 \\sigma_z^2} \\over {8K_z^2} } \\right)\n\\]\n\\[\n\\times \\left( \\exp \\left( - \\left(z-h\\right)^2 \\over {2\\sigma_z^2} \\right) + \\exp \\left( - \\left(z+h\\right)^2 \\over {2\\sigma_z^2} \\right) \\right.\n\\]\n\\[\n\\left. - { {\\sqrt{2\\pi} W_o \\sigma_z} \\over K_z} \\exp\\left( { {W_o (z+h)} \\over {K_z} } + { {W_o^2 \\sigma_z^2} \\over {2K_z^2} } \\right) \\mathrm{erfc} \\left( { {W_o \\sigma_z} \\over {\\sqrt{2}K_z} } + { {z + h} \\over {\\sqrt{2}\\sigma_z} } \\right) \\right)\n\\]\nwhere \\(W_o = W_{dep} - \\frac{1}{2}W_{set}\\). In practice, relationships for \\(\\sigma\\)s are much easier to find than Ks and the following is used to recover \\(K_z\\)\n\\[\nK_z = \\frac{1}{2} u \\frac{d \\sigma_z^2}{dx}\n\\]\nThis follows from the definition of \\(\\sigma_z\\) (and r). In this case I am going to generate the \\(K_z\\) using automatic differentiation with ForwardDiff.jl.\nusing ForwardDiff: derivative\n∂ₓσ_z²(x) = 2*σ_z(x)*derivative(σ_z, x)\n@ucorrel ∂ₓσ_z² u\"m\" u\"m\"\nK_z(x; u) = (1/2)*u*∂ₓσ_z²(x)\nModels 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.\nThis 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.\nmax_pollen = 1e6grains/1u\"m^3\"\nusing SpecialFunctions: erfc\nfunction ermak(x, y, z; u=u, h=crown_height(DBH), P=pollen_rate(DBH), \n W_set=vₜ, W_dep=vₜ, p_max=max_pollen)\n\n if x<zero(x) || z<zero(z)\n return zero(p_max)\n end\n \n s_y = σ_y(x)\n s_z = σ_z(x)\n K = K_z(x; u)\n\n Wₒ = W_dep - 0.5*W_set\n\n p = (P/(2π*u*s_y*s_z))*exp(-0.5*(y/s_y)^2)*\n exp(-0.5*W_set*(z-h)/K - 0.125*(W_set*s_z/K)^2)*(\n exp(-0.5*((z-h)/s_z)^2) + exp(-0.5*((z+h)/s_z)^2)\n - (√(2π)*Wₒ*s_z/K)*exp(Wₒ*(z+h)/K + 0.5*(Wₒ*s_z/K)^2)*\n erfc((Wₒ*s_z/K + (z+h)/s_z)/√(2)) )\n\n return isnan(p) ? zero(p_max) : min(p, p_max)\nend;\nUsing 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)\n\n\n\n\n\n\nFigure 3: Plan view of ground level pollen concentration downwind of the model Elm tree.\n\n\n\n\n\n\n\n\n\nFigure 4: Elevation view of pollen concentration downwind of the model Elm tree, through the centre of the plume (y=0).\n\n\n\nI 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.\nOn 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.\nI 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.\n\n\nA Tree Data Structure\nTo 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, \\(x_o, y_o, z_o\\). 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.\nbegin\n\nstruct Tree{G,L,P,V}\n geopt::G\n xₒ::L\n yₒ::L\n zₒ::L\n DBH::L\n h::L\n P::P\n vₜ::V\nend\n\nfunction Tree(geopt, xₒ, yₒ, DBH; v=vₜ)\n h = crown_height(DBH)\n P = pollen_rate(DBH) \n xₒ, yₒ, zₒ, DBH, h = promote(xₒ, yₒ, zero(yₒ), DBH, h)\n return Tree(geopt, xₒ, yₒ, zₒ, DBH, h, P, v)\nend\n\nend;\nelm = Tree(nothing, 0u\"m\",0u\"m\",DBH)\nTree{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}}}(\n geopt = nothing\n xₒ = 0.0 m\n yₒ = 0.0 m\n zₒ = 0.0 m\n DBH = 0.88 m\n h = 17.803579999999993 m\n P = 317528.8675882605 grains s^-1\n vₜ = 0.032156589905762846 m s^-1\n)\nI 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.\nfunction ermak(t::Tree, x, y, z; u=u)\n x′ = x - t.xₒ\n y′ = y - t.yₒ\n z′ = z - t.zₒ\n return ermak(x′, y′, z′; h=t.h, P=t.P, W_set=t.vₜ, W_dep=t.vₜ)\nend;" + }, + { + "objectID": "posts/pollen_dispersion/index.html#mapping-elm-pollen-in-wîhkwêntôwin", + "href": "posts/pollen_dispersion/index.html#mapping-elm-pollen-in-wîhkwêntôwin", + "title": "Mapping Pollen Dispersion", + "section": "Mapping Elm Pollen in Wîhkwêntôwin", + "text": "Mapping Elm Pollen in Wîhkwêntôwin\nThe 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.\n\nDefining the Local Grid\nI 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.\nusing Geodesy\nbegin\n\nlatₒ, lonₒ, altₒ = 53.54100, -113.52141, 671\nΔlat, Δlon = 0.015, 0.035\n\nend;\nI 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.\nI 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.\nbegin\n\nΔx = euclidean_distance(LLA(latₒ, lonₒ - Δlon/2, altₒ), \n LLA(latₒ, lonₒ + Δlon/2, altₒ), wgs84)/Δlon\nΔy = euclidean_distance(LLA(latₒ + Δlat/2, lonₒ, altₒ), \n LLA(latₒ - Δlat/2, lonₒ, altₒ), wgs84)/Δlat\nend\nfunction local_coords(lat,lon)\n x = (lon - lonₒ)*Δx\n y = (lat - latₒ)*Δy\n return x, y\nend\n\n\n\n\n\n\nNoteWhy not use Web Mercator?\n\n\n\nAt 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.\nUnlike 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.\nTo 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.\nbegin\n \nstopgap = LLA(53.535618490862944, -113.5118491580413)\noliver_park = LLA(53.54542679826651, -113.52603529325418)\n\nend\ndist = euclidean_distance(stopgap, oliver_park, wgs84)\nFirst I calculate the distance along the ellipsoid, which is 1441 m (the same as what Google maps tells me).\nThen I convert the coordinates to Web Mercator, which are northing and easting relative to the equator and the prime meridian.\nWM = WebMercatorfromLLA(wgs84)\nbegin\n\nstopgap_wm = WM(stopgap)\noliver_park_wm = WM(oliver_park)\n\nend\nwm_dist = √( (stopgap_wm[1] - oliver_park_wm[1])^2 \n + (stopgap_wm[2] - oliver_park_wm[2])^2 )\nThe 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.\n\n\n\n\nFinding the Neighbourhood Elm Trees\nThankfully, 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.\nusing CSV, DataFrames\ntrees_df = CSV.read(\"data/Ulmus_americana_wihkwentowin.csv\", \n DataFrame);\ndescribe(trees_df, :min, :max)\n19×3 DataFrame\n Row │ variable min max \n │ Symbol Any Any \n─────┼──────────────────────────────────────────────────────────────────────────────────────────────\n 1 │ ID 155206 619701\n 2 │ NEIGHBOURHOOD_NAME WÎHKWÊNTÔWIN WÎHKWÊNTÔWIN\n 3 │ LOCATION_TYPE Alley Park\n 4 │ SPECIES_BOTANICAL Ulmus americana Ulmus americana Patmore\n 5 │ SPECIES_COMMON Elm, American Elm, American\n 6 │ GENUS Ulmus Ulmus\n 7 │ SPECIES americana americana\n 8 │ CULTIVAR Brandon Patmore\n 9 │ DIAMETER_BREAST_HEIGHT 5 110\n 10 │ CONDITION_PERCENT 0 65\n 11 │ PLANTED_DATE 1990/01/01 2024/09/25\n 12 │ OWNER Parks Parks\n 13 │ Bears Edible Fruit false false\n 14 │ Type of Edible Fruit \n 15 │ COUNT 1 1\n 16 │ LATITUDE 53.5346 53.5496\n 17 │ LONGITUDE -113.536 -113.51\n 18 │ LOCATION (53.534594143551985, -113.510361… (53.549586375568154, -113.530880…\n 19 │ Point Location POINT (-113.50950085882668 53.53… POINT (-113.53589639202552 53.54…\nWhat 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.\nbegin\ntrees = Vector{Tree}()\n\nfor row in eachrow(trees_df)\n lat, lon = row.LATITUDE, row.LONGITUDE\n DBH = row.DIAMETER_BREAST_HEIGHT*1u\"cm\" |> u\"m\"\n pt = LLA(lat,lon,altₒ)\n x, y = local_coords(lat, lon).*1u\"m\"\n tree = Tree(pt,x,y,DBH)\n push!(trees, tree)\nend\n\nend\nThere are 996 Elm trees in Wîhkwêntôwin alone. That’s impressive, we have a pretty great urban forest.\ntrees[1]\nTree{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}}}(\n geopt = LLA(lat=53.53695945675063°, lon=-113.51201340003675°, alt=671.0)\n xₒ = 623.0131311454173 m\n yₒ = -449.74535157065054 m\n zₒ = 0.0 m\n DBH = 0.2 m\n h = 9.04518 m\n P = 10810.758180096327 grains s^-1\n vₜ = 0.032156589905762846 m s^-1\n)\nI 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.\nermak(trees::Vector{Tree}, x, y, z; u=u) = \n sum( ermak.(trees, x, y, z; u=u) );\n\n\nMapping Wîhkwêntôwin\nNow 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.\nusing Tyler\nwihkwentowin = Rect2f(lonₒ - Δlon/2, latₒ - Δlat/2, Δlon, Δlat);\nprovider = Tyler.TileProviders.Esri(:WorldImagery);\nI have defined a helper function to take a tree and return the appropriate Web Mercator coordinates to map on top of the ESRI imagery.\nfunction map_tree(tree::Tree)\n x, y, _ = WM(tree.geopt)\n return Point2f(x,y)\nend\nMapping 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.\n\n\n\n\n\n\nFigure 5: Satellite view of Wîhkwêntôwin and surrounding area with neighbourhood Elm trees indicated with blue circles.\n\n\n\n\n\nMapping the Pollen from all Elm Trees\nNow 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.\nIf 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.\nLLA_WM = LLAfromWebMercator(wgs84)\nfunction map_ermak(x, y)\n lla = LLA_WM([x,y,altₒ])\n local_x, local_y = local_coords(lla.lat, lla.lon).*1u\"m\"\n return ustrip(ermak(trees, local_x, local_y, 0u\"m\"))\nend\nI then divide the neighbourhood into a grid of 10,000 points and calculate the concentration at each point.\n# defining the bounds of the grid\n\nbegin\n\nxₗ, yₗ, _ = WM(LLA(latₒ - Δlat/2, lonₒ - Δlon/2, altₒ))\nxᵤ, yᵤ, _ = WM(LLA(latₒ + Δlat/2, lonₒ + Δlon/2, altₒ))\n\nend;\nbegin\n\nxs = range(xₗ, xᵤ; length=100)\nys = range(yₗ, yᵤ; length=100)\n\nzs = map_ermak.(xs, ys')\n \nend;\nFinally I overlay a contour plot on top of the ESRI imagery, showing everywhere with a pollen concentration >10 grains m^-3\n\n\n\n\n\n\nFigure 6: Satellite view of Wîhkwêntôwin and surrounding area with pollen concentrations >10 grains m^-3 overlaid.\n\n\n\nA 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 \\(\\sigma_y\\) and \\(\\sigma_z\\). 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.\nA 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." + }, + { + "objectID": "posts/pollen_dispersion/index.html#references", + "href": "posts/pollen_dispersion/index.html#references", + "title": "Mapping Pollen Dispersion", + "section": "References", + "text": "References\n\n\nBriggs, Gary A. “Diffusion Estimation for Small Emissions. Preliminary Report.” Oak Ridge, TN: Air Resources Atmospheric Turbulence; Diffusion Laboratory, National Oceanic; Atmospheric Administration, 1973. https://doi.org/10.2172/5118833.\n\n\nBrush, Grace S., and Lucien M. Brush Jr. “Transport of Pollen in a Sendiment-Laden Channel: A Laboratory Study.” American Journal of Science 272, no. 4 (1972): 359–81.\n\n\nErmak, Donald L. “An Analytical Model for Air Pollutant Transport and Deposition from a Point Source.” Atmospheric Environment 11 (1977): 231–37. https://doi.org/10.1016/0004-6981(77)90140-8.\n\n\nGriffiths, R. F. “Errors in the Use of the Briggs Parameterization for Atmospheric Dispersion Coefficients.” Atmospheric Environment 28, no. 17 (1994): 2861–65. https://doi.org/10.1016/1352-2310(94)90086-8.\n\n\nKatz, Daniel S. W., Jonathan R. Morris, and Stuart A. Batterman. “Pollen Production for 13 Urban North American Tree Species: Allometric Equations for Tree Trunk Diameter and Crown Area.” Astrobiologia (Bologna) 36, no. 3 (2020). https://doi.org/10.1007/s10453-020-09638-8.\n\n\nMcPherson, E. Gregory, Natalie S. van Doorn, and Paula J. Peper. “Urban Tree Database and Allometric Equations.” Albany, CA: U. S. Department of Agriculture, Forest Service, Pacific Southwest Research Station, 2016. https://doi.org/10.2737/PSW-GTR-253." + }, + { + "objectID": "posts/relief_valve_sizing/index.html", + "href": "posts/relief_valve_sizing/index.html", + "title": "Relief Valve Sizing with Real Gases", + "section": "", + "text": "Very often, in chemical engineering, the line between problems one can solve one’s self and problems that are solved with a piece of commercial software is when ideal fluid assumptions break down. Relief valve sizing is a typical example: if the fluid is (approximately) an ideal gas then sizing is simple and often done in a spreadsheet. When this isn’t the case, if the compressiblity is <0.8 or >1.1,1 then one typically has to turn to some commercial software. Models of real fluids are complicated and extracting the relevant thermodynamic properties from them can be quite tedious when doing it all from scratch.\nClapeyron.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." + }, + { + "objectID": "posts/relief_valve_sizing/index.html#sizing-a-pressure-relief-valve", + "href": "posts/relief_valve_sizing/index.html#sizing-a-pressure-relief-valve", + "title": "Relief Valve Sizing with Real Gases", + "section": "Sizing a Pressure Relief Valve", + "text": "Sizing a Pressure Relief Valve\nThe 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\n2 API, Standard 520, Sizing, Selection, and Installation of Pressure-Relieving Devices, Part i - Sizing and Selection, 10th Ed equation B.5.\\[ W = K A G \\]\nwhere \\(W\\) is the required mass flow-rate, \\(K\\) is a capacity correction, \\(A\\) is the theoretical flow area, and \\(G\\) the frictionless) mass flux through the valve. Valves are sized based on the theoretical flow area.\nThe general process is as follows:\n\nDetermine the governing release rate, \\(W\\)\nDetermine the capacity correction, \\(K\\)\nCalculate the mass flux through the valve, \\(G\\)\nCalculate the theoretical flow area \\(A = \\frac{W}{K G}\\)\nSelect the appropriate valve with a flow area \\(>A\\).\n\nStandards, 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.\nThe complications creep in through calculating \\(G\\), 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.\n\n\n\n\n\n\nFigure 1: A hypothetical pressure relief device, connected to a pressure reservoir (1) and discharging into the atmosphere (2).\n\n\n\nConsider 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\n\\[ dP + \\rho u du = 0 \\]\n\\[ u du = -\\frac{dP}{\\rho} = - vdP \\]\nIntegrating from the stagnation point to the throat of the nozzle gives\n\\[ \\frac{1}{2} u_t^2 = - \\int_{P_1}^{P_t} v dP \\]\nWhere the velocity at the stagnation point, \\(u_1=0\\). Putting this in terms of the mass flux \\(u = v G\\)\n\\[ \\frac{1}{2} v_t^2 G_t^2 = - \\int_{P_1}^{P_t} v dP \\]\n\\[ G_t = \\frac{1}{v_t} \\sqrt{-2 \\int_{P_1}^{P_t} v dP} = \\rho_t \\sqrt{2 \\int_{P_t}^{P_1} v dP} \\]\nThis 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, \\(P_t, T_t\\).\nIf we specify that the streamline follows an isentropic path, then we can construct a constrained maximization problem: the nozzle conditions are the \\(P_t\\) and \\(T_t\\) which maximizes \\(G_t\\) where the integration is taken along an isentropic path.\n\nChoked Flow\nIn the case where flow is choked, i.e. the flow in the nozzle reaches sonic velocity, the maximum \\(G_t\\) occurs at the sonic velocity with a pressure \\(P_t > P_2\\). This can allow for the direct calculation of the mass flux as \\(G_t = \\rho_t c_t\\), where \\(c_t\\) is the sonic velocity at the throat. No integration required." + }, + { + "objectID": "posts/relief_valve_sizing/index.html#a-motivating-example", + "href": "posts/relief_valve_sizing/index.html#a-motivating-example", + "title": "Relief Valve Sizing with Real Gases", + "section": "A Motivating Example", + "text": "A Motivating Example\nConsider 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).\nusing Unitful\nbegin\n# the vessel properties\n P₁ = 200u\"bar\"\n T₁ = 400u\"K\"\n\n# the ambient properties\n P₂ = 1u\"bar\"\n T₂ = 288.15u\"K\"\nend\nWe 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).\nusing Clapeyron\nbegin\n# assorted equations of state for ethane\n ig_ethane = ReidIdeal([\"ethane\"])\n vtpr_ethane = VTPR([\"ethane\"]; idealmodel = ReidIdeal)\n gerg_ethane = GERG2008([\"ethane\"])\nend\n# this is a hack, ideal models in Clapeyron do not return a \n# molar weight and so cannot return a mass density\nClapeyron.mw(model::IdealModel) = Clapeyron.mw(vtpr_ethane)\nAt 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." + }, + { + "objectID": "posts/relief_valve_sizing/index.html#the-ideal-gas-case", + "href": "posts/relief_valve_sizing/index.html#the-ideal-gas-case", + "title": "Relief Valve Sizing with Real Gases", + "section": "The Ideal Gas Case", + "text": "The Ideal Gas Case\nConsidering the choked flow case, we know that \\(G_t = \\frac{c_t}{v_t}\\) and, for an ideal gas, the sonic velocity is given by3\n3 Tilton, “Fluid and Particle Dynamics” equation 6-113.\\[ c = \\sqrt{ {k R T} \\over M} = \\sqrt{k P v} \\]\nCombining these we have\n\\[ G_t = \\frac{c_t}{v_t} = { \\sqrt{ k P_t v_t } \\over v_t } = \\sqrt{ k P_t \\over v_t } \\]\nIt can be shown that, along an isentropic path defined by \\(P v^k = \\mathrm{const}\\), the critical pressure ratio is4\n4 Tilton equation 6-119.\\[ {P_t \\over P_1} = { P_{chk} \\over P_1 } = \\left(2 \\over {k+1} \\right)^{k \\over {k-1} } \\]\nWhich allows us to write\n\\[ P_t = P_1 {P_t \\over P_1} = P_1 \\left(2 \\over {k+1} \\right)^{k \\over {k-1} } \\]\nand (using \\(P_1 v_1^k = P_t v_t^k\\))\n\\[ v_t = v_1 \\left(P_1 \\over P_t \\right)^{1 \\over k} = v_1 \\left(2 \\over {k+1} \\right)^{-1 \\over {k-1} } \\]\nSubstituting back into the equation for \\(G_t\\)5\n5 API, Standard 520, Sizing, Selection, and Installation of Pressure-Relieving Devices, Part i - Sizing and Selection, 10th Ed equation B.21.\\[ G_t = \\sqrt{ k \\frac{P_1}{v_1} \\left(2 \\over {k+1} \\right)^{k+1 \\over {k-1} } } \\]\nor, to put it in terms of density\n\\[ G_t = \\sqrt{ k P_1 \\rho_1 \\left(2 \\over {k+1} \\right)^{k+1 \\over {k-1} } } \\]\nwhere \\(k\\), the isentropic expansion factor for an ideal gas, is the ratio of heat capacities\n\\[ k = { c_{p,ig} \\over c_{v,ig} } \\]\n\n\n\n\n\n\nNote\n\n\n\nThis is the basis of API 520 Part 1 equation 9 where the following substitutions is made:\n\\[ \\rho = { {P M} \\over {Z R T} } \\]\nand the constant \\(R\\) and some unit conversions are rolled up into the constant 0.03948 in the expression for \\(C\\)\n\\[ R = 8.314 { {\\mathrm{m^3} \\cdot \\mathrm{Pa} } \\over {\\mathrm{mol} \\cdot \\mathrm{K} } } = 8,314 { {\\mathrm{m^3} \\cdot \\mathrm{Pa} } \\over {\\mathrm{kmol} \\cdot \\mathrm{K} } } = 8,314 { {\\mathrm{kg} \\cdot \\mathrm{m^2} } \\over { \\mathrm{kmol} \\cdot \\mathrm{s^2} \\cdot \\mathrm{K} } } \\]\n\\[ {1 \\over \\sqrt{8,314} } \\left[ { \\sqrt{ \\mathrm{kmol} \\cdot \\mathrm{K} } \\cdot \\mathrm{s} } \\over { \\sqrt{\\mathrm{kg} } \\cdot \\mathrm{m} } \\right] \\times 3600 \\left[ \\mathrm{s} \\over \\mathrm{h} \\right] \\times 10^{-6} \\left[ \\mathrm{m^2} \\over \\mathrm{mm^2} \\right] \\times 10^3 \\left[ \\mathrm{Pa} \\over \\mathrm{kPa} \\right] \\]\n\\[ = 0.03948 \\left[ \\sqrt{\\mathrm{kmol} \\cdot \\mathrm{kg} \\cdot \\mathrm{K} } \\over { \\mathrm{h} \\cdot \\mathrm{mm^2} \\cdot \\mathrm{kPa} } \\right] \\]\n\n\nWe can use Clapeyron.jl to calculate \\(k\\) at any given temperature, using correlations for the ideal gas heat capacity.\nfunction isentropic_expansion_factor(model::IdealModel, P, T; z=[1.0])\n cₚ_ig = isobaric_heat_capacity(model, P, T; phase=:vapor)\n cᵥ_ig = isochoric_heat_capacity(model, P, T; phase=:vapor)\n return cₚ_ig/cᵥ_ig\nend\nFrom which we calculate k= 1.146.\nWe 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\n6 API, 70.function mass_flux_choked(model, P, T; z=[1.0])\n k = isentropic_expansion_factor(model, P, T; z=z)\n ρ = mass_density(model, P, T, z; phase=:vapor)\n Gₜ² = k*P*ρ*(2/(k+1))^((k+1)/(k-1))\n return √(Gₜ²)\nend\nThe theoretical mass flux for the ideal gas is then 38359 kg m^-2 s^-1\nThe 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\n7 Tilton, “Fluid and Particle Dynamics” equations 6-119 and 6-120.nozzle_pressure_ideal(P, T, k) = P*(2/(k+1))^(k/(k-1))\nnozzle_temperature_ideal(P, T, k) = T*(2/(k+1))\nThe 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." + }, + { + "objectID": "posts/relief_valve_sizing/index.html#the-isentropic-expansion-factor", + "href": "posts/relief_valve_sizing/index.html#the-isentropic-expansion-factor", + "title": "Relief Valve Sizing with Real Gases", + "section": "The Isentropic Expansion Factor", + "text": "The Isentropic Expansion Factor\nAt 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.\nAn alternative method is to calculate what the effective isentropic expansion factor would be, for the real gas, assuming that the real fluid obeys\n\\[ P_1 v_1^n = P_t v_t^n \\]\nwhere \\(n\\) is a constant.\nThe derivation of \\(n\\) follows from the definition of the speed of sound in a gas8\n8 Tilton, 6–22; Gmehling et al., Chemical Thermodynamics for Process Simulation, 113.\\[ c = \\sqrt{ \\left( {\\partial P} \\over {\\partial \\rho} \\right)_S} =\\sqrt{ -v^2 \\left( {\\partial P} \\over {\\partial v} \\right)_S} \\]\nThe constant entropy partial derivative can be re-written to eliminate entropy9\n9 Gmehling et al., Chemical Thermodynamics for Process Simulation, 660.\\[ \\left( {\\partial P} \\over {\\partial v} \\right)_S = { { \\left( {\\partial S} \\over {\\partial T} \\right)_P \\left( {\\partial P} \\over {\\partial T} \\right)_v } \\over { \\left( {\\partial S} \\over {\\partial T} \\right)_v \\left( {\\partial v} \\over {\\partial T} \\right)_P } } \\]\nUsing the relations10\n10 Gmehling et al., Chemical Thermodynamics for Process Simulation equations C.21 and C.8 (respectively).\\[ c_p = T \\left( {\\partial S} \\over {\\partial T} \\right)_P \\]\nand\n\\[ c_v = T \\left( {\\partial S} \\over {\\partial T} \\right)_V \\]\nwe get\n\\[ \\left( {\\partial P} \\over {\\partial v} \\right)_S = {c_p \\over c_v} \\left( {\\partial P} \\over {\\partial v} \\right)_T \\]\nand the sonic velocity is then\n\\[ c = \\sqrt{ -v^2 {c_p \\over c_v} \\left( {\\partial P} \\over {\\partial v} \\right)_T } \\]\nequating this to the ideal gas case, \\(c = \\sqrt{n P v}\\), and solving for \\(n\\) gives11\n11 API, Standard 520, Sizing, Selection, and Installation of Pressure-Relieving Devices, Part i - Sizing and Selection, 10th Ed equation B.13.\\[ n = -\\frac{v}{P} {c_p \\over c_v} \\left( {\\partial P} \\over {\\partial v} \\right)_T \\]\nwhere \\(n\\) has been used to distinguish it from \\(k\\) (the ideal gas case). This is the version of \\(n\\) presented in most references, such as API 520. The derivation, however, hints at a useful shortcut to calculating \\(n\\) that does not require digging into the internals of Clapeyron.jl to retrieve partial derivatives:\n\\[ n = { c^2 \\over {P v} } = { {\\rho c^2} \\over P } \\]\nThe remainder of the calculations are identical as the ideal gas case, simply substituting \\(n\\) wherever \\(k\\) appears. Unfortunately \\(n\\) 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.\nfunction isentropic_expansion_factor(model, P, T; z=[1.0])\n ρ = mass_density(model, P, T, z)\n c = speed_of_sound(model, P, T, z)\n n = ρ*c^2/P\n return n\nend\nUsing 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.\n\n\n\n\n\n\nFigure 2: The isentropic expansion factor for ethane at 400K, calculated for a range of stagnation pressures.\n\n\n\nThe isentropic expansion factor method works best when \\(n\\) 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." + }, + { + "objectID": "posts/relief_valve_sizing/index.html#solving-the-choked-flow-energy-balance", + "href": "posts/relief_valve_sizing/index.html#solving-the-choked-flow-energy-balance", + "title": "Relief Valve Sizing with Real Gases", + "section": "Solving the Choked Flow Energy Balance", + "text": "Solving the Choked Flow Energy Balance\nAnother 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).\n12 Crowl et al., “Process Safety.” 23–55; Gmehling et al., Chemical Thermodynamics for Process Simulation, 603.\\[ h_1 = h_t + \\frac{1}{2} c_t^2 \\]\nWhere \\(c_t\\) is the speed of sound at the nozzle, a function of \\(P_t\\) and \\(T_t\\). The procedure is then to solve the system of equations given by the energy balance and the entropy balance, \\(s_1 = s_t\\), for \\(P_t\\) and \\(T_t\\), then the theoretical mass flux is given by\n\\[ G_t = \\rho_t c_t \\]\nThere 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.\nA more direct way is to solve for \\(P_t\\) and \\(T_t\\) simultaneously. This is what I do next, using NonlinearSolve.jl\n# Clapeyron does not expose this by default\nmolecular_weight(model,z) = Clapeyron.molecular_weight(model,z)\nfunction nozzle_balance(y, prms)\n P, T = y\n\n # stagnation point\n s₁ = prms.entropy\n h₁ = prms.enthalpy\n\n # at throat conditions\n s₂ = entropy(prms.model, P, T, prms.z)\n h₂ = enthalpy(prms.model, P, T, prms.z)/prms.Mw\n c² = speed_of_sound(prms.model, P, T, prms.z)^2\n \n return [ s₁ - s₂\n h₁ - h₂ - 0.5*c² ]\nend\nusing NonlinearSolve\nfunction mass_flux_choked_energy_balance(model, P, T; z=[1.0])\n # calculate the entropy and specific enthalpy at \n # initial conditions\n Mw = molecular_weight(model, z)\n s₁ = entropy(model, P, T)\n h₁ = enthalpy(model, P, T)/Mw\n\n # solve the choked flow energy balance for\n # an isentropic nozzle\n params = (model=model, entropy=s₁, enthalpy=h₁, z=z, Mw=Mw)\n y₀ = [P; T]\n prob = NonlinearProblem(nozzle_balance, y₀, params)\n sol = solve(prob, NewtonRaphson())\n Pₜ, Tₜ = sol.u\n\n # velocity is the sonic velocity at nozzle conditions\n ρₜ = mass_density(model, Pₜ, Tₜ, z)\n cₜ = speed_of_sound(model, Pₜ, Tₜ, z)\n \n return ρₜ*cₜ\nend\nfunction mass_flux_choked_energy_balance(model, P::Quantity, T::Quantity; z=[1.0])\n P = ustrip(u\"Pa\", P)\n T = ustrip(u\"K\", T)\n return mass_flux_choked_energy_balance(model, P, T; z=z)*1u\"kg*m^-2*s^-1\"\nend\nSolving 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.\n\n\n\n\n\n\nFigure 3: The isentropic paths for the ideal gas, effective isentropic factor, and true isentropic path methods.\n\n\n\nIn this case the ideal gas method and the isentropic expansion factor method bracket the more exact method of solving the energy balance directly.\nAs it is written, this method would need to be modified to allow for non-choked flow. This is done by eliminating the assumption \\(u_t = c_t\\) and instead finding the conditions which maximize \\(G_t\\) (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." + }, + { + "objectID": "posts/relief_valve_sizing/index.html#direct-integration", + "href": "posts/relief_valve_sizing/index.html#direct-integration", + "title": "Relief Valve Sizing with Real Gases", + "section": "Direct Integration", + "text": "Direct Integration\nDirect 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 \\(P_t\\) and \\(T_t\\) that maximize the mass flux given by\n\\[ G_t = \\rho_t \\sqrt{2 \\int_{P_t}^{P_1} v dP} \\]\nFirst introduce the change of variables \\(\\Delta P = P_1 - P\\) such that the integration is from \\(0\\) to \\(P_1 - P_2\\).\n\\[ \\int_{P_2}^{P_1} v dP = - \\int_{0}^{\\Delta P} v\\left( P_1 - \\Delta P \\right)_{s = s_1} d\\left(\\Delta P \\right) \\]\nThis allows us to write the corresponding differential equation\n\\[ { {d} \\over {d \\left(\\Delta P \\right)} } I = v\\left(P₁ - ΔP\\right) \\]\nsubject to the constraint\n\\[ s(P_1 - \\Delta P, T) = s(P_1, T_1) \\]\nWhich can be implemented as a differential algebraic equation using DifferentialEquations.jl\nusing DifferentialEquations\nfunction rhs(u, params, ΔP)\n ∫vdP, T = u\n model, P₁, s₁, z, Mw = params\n P = P₁ - ΔP\n return [ volume(model, P, T, z)/Mw\n s₁ - entropy(model, P, T) ]\nend\nBut we want to stop the integration when \\({ {\\partial G} \\over {\\partial \\left( \\Delta P \\right) } } = 0\\) or, equivalently, when the velocity is sonic. We can show that these are the same by finding the stationary points of \\(G^2\\)\n\\[ { {\\partial G^2} \\over {\\partial \\left( \\Delta P \\right) } } = { {\\partial } \\over {\\partial \\left( \\Delta P \\right) } } \\left( 2 \\rho_t^2 \\int_0^{\\Delta P_t} v d \\left( \\Delta P \\right) \\right) = 0 \\]\nby applying the chain rule and cancelling \\(\\rho\\) we get\n\\[ 2 \\left( {\\partial \\rho} \\over { \\partial P } \\right)_S \\int_0^{\\Delta P_t} v d \\left( \\Delta P \\right) - 1 = 0 \\]\nrecalling the definition of the speed of sound (above)\n\\[ \\left( {\\partial \\rho} \\over { \\partial P } \\right)_S = \\frac{1}{c^2} \\]\nwe have\n\\[ 2 \\int_0^{\\Delta P_t} v d \\left( \\Delta P \\right) - c^2 = 0 \\]\nwhich is simply restating \\(u_t = c_t\\).\nfunction ∂G²_callback(u, ΔP, integrator)\n ∫vdP, Tₜ = u\n model, P₁, s₁, z, Mw = integrator.p\n Pₜ = P₁ - ΔP\n c = speed_of_sound(model, Pₜ, Tₜ, z)\n return 2∫vdP - c^2\nend\nfunction mass_flux_direct_integration(model, P₁, T₁, P₂; \n z=[1.0], solver=Rodas5P())\n s₁ = entropy(model, P₁, T₁, z)\n Mw = molecular_weight(model, z)\n\n # defining the ODEFunction\n M = [ 1 0\n 0 0 ]\n f = ODEFunction(rhs, mass_matrix = M)\n\n # defining the ODEProblem\n u0 = [0.0; T₁]\n params = (model, P₁, s₁, z, Mw)\n ΔP_span = (0.0, P₁ - P₂)\n prob = ODEProblem(f, u0, ΔP_span, params)\n cb = ContinuousCallback(∂G²_callback, terminate!)\n\n # solving the DAE\n sol = solve(prob, solver, callback=cb)\n\n # unpacking the solution\n ΔPₜ = sol.t[end]\n ∫vdP, Tₜ = sol.u[end]\n ρₜ = mass_density(model, P₁-ΔPₜ, Tₜ, z)\n G = ρₜ*√(2*∫vdP)\nend\nfunction mass_flux_direct_integration(model, P₁::Quantity, T₁::Quantity,\n P₂::Quantity; z=[1.0])\n P_1 = ustrip(u\"Pa\", P₁)\n P_2 = ustrip(u\"Pa\", P₂)\n T_1 = ustrip(u\"K\", T₁)\n return mass_flux_direct_integration(model, P_1, T_1, P_2; z=z)*1u\"kg*m^-2*s^-1\"\nend\nDirect 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.\n\n\n\n\n\n\nFigure 4: The mass flux as a function of nozzle pressure drop, showing the intermediate steps until a maximum was found.\n\n\n\nWriting 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.\nThis 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 \\(P_2\\). This result will simply pop out without any extra effort." + }, + { + "objectID": "posts/relief_valve_sizing/index.html#comparing-the-results", + "href": "posts/relief_valve_sizing/index.html#comparing-the-results", + "title": "Relief Valve Sizing with Real Gases", + "section": "Comparing the Results", + "text": "Comparing the Results\nFor 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, \\(Z\\), 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\nThese 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.\nI 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.\n\n\n\n\n\n\nFigure 5: A comparison of calculated theoretical mass flux for the six methods. The results from the first law energy balance and direct integration are identical.\n\n\n\nIn 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." + }, + { + "objectID": "posts/relief_valve_sizing/index.html#final-thoughts", + "href": "posts/relief_valve_sizing/index.html#final-thoughts", + "title": "Relief Valve Sizing with Real Gases", + "section": "Final Thoughts", + "text": "Final Thoughts\nI 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.\nPresumably 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." + }, + { + "objectID": "posts/relief_valve_sizing/index.html#references", + "href": "posts/relief_valve_sizing/index.html#references", + "title": "Relief Valve Sizing with Real Gases", + "section": "References", + "text": "References\n\n\nAPI. Standard 520, Sizing, Selection, and Installation of Pressure-Relieving Devices, Part i - Sizing and Selection, 10th Ed. Washington, DC: American Petroleum Institute, 2020.\n\n\nChemical Process Safety, Center for. Guidelines for Pressure Relief and Effluent Handling Systems. Hoboken, NJ: John Wiley & Sons, 2017.\n\n\nCrowl, Daniel A., Lawrence G. Britton, Walter L. Frank, Stanley Grossel, Dennis Hendershot, W. G. High, Robert W. Johnson, et al. “Process Safety.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green. New York: McGraw Hill, 2008.\n\n\nGmehling, Jürgen, Bärbel Kolbe, Michael Kleiber, and Jürgen Rary. Chemical Thermodynamics for Process Simulation. Weinheim, DE: Wiley-VCH Verlag & Co., 2012.\n\n\nGreen, Don W., ed. Perry’s Chemical Engineers’ Handbook. New York: McGraw Hill, 2008.\n\n\nTilton, James N. “Fluid and Particle Dynamics.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green. New York: McGraw Hill, 2008." + }, + { + "objectID": "posts/worst_case_weather/index.html", + "href": "posts/worst_case_weather/index.html", + "title": "Worst Case Meterological Conditions", + "section": "", + "text": "In a previous post I modeled an example of a plume from an elevated stack. In that example I assumed very stable conditions and a low windspeed – pasquill stability class F and a windspeed of 1.5m/s – as the worst case. This was an error!\nFor neutrally buoyant releases at or near ground-level that is a common “worst case”, for example when considering the potential impact due to a vapour cloud explosion. But for elevated stacks, releasing a buoyant plume, a class D stability with a moderate windspeed is often recommended. I thought it would be interesting to explore how the maximum concentration at the point of interest – an elevated work area downwind of the stack – varies with stability class and windspeed.\nI am not going to repeat all of the assumptions and working out of the previous notebook, the important results are in the source code for this post. I have also re-defined some of the functions to be a little more re-useable and to represent other stability cases not covered in the original notebook." + }, + { + "objectID": "posts/worst_case_weather/index.html#pasquill-stability", + "href": "posts/worst_case_weather/index.html#pasquill-stability", + "title": "Worst Case Meterological Conditions", + "section": "Pasquill Stability", + "text": "Pasquill Stability\nAs a refresher, Pasquill stability classes are a qualitative way of describing the atmospheric stability – the tendency of the atmosphere to resist or enhance vertical motion. Stability is itself related to the temperature gradient with height, wind speed, and various other things. For a simple model such as this the key model parameters are tabulated with respect to the Pasquill stability, which is why it is relevant to this discussion.\n\nPasquill Stability Classes\n\n\n\nStability Class\nDescription\n\n\n\n\nA\nExtremely unstable\n\n\nB\nUnstable\n\n\nC\nSlightly unstable\n\n\nD\nNeutral\n\n\nE\nSlightly stable\n\n\nF\nStable to extremely stable\n\n\n\nIn general the more stable the class the less dispersion, and thus the higher the concentration within the plume. Which is why class F is typically used for a ground level, neutrally buoyant, cloud. However plume rise is also a function of stability and, in general, more stable plumes rise without as much dispersion and thus the ground level concentration is lower than if the plume dispersed more. Furthermore the plume rise is a function of windspeed, the greater the windspeed the less the plume rises before leveling off and, again, the greater the ground level concentration.\nWe can visualize the relationship between windspeed, stability class, and the concentration at the point of interest with the following plot which includes plume rise relationships for unstable, neutral, and stable atmospheres.\n\n\n\n\n\n\n\n\n\nWe note that, as we expect, lower stability (e.g. A or B) corresponds to a higher groundlevel concentration at low windspeed, but at high windspeed higher stability leads to a greater groundlevel concentration.\nAt first blush it would appear that class F is still the worst case, however this plot naively assumes atmospheric stability is unrelated to windspeed. This is not true and roughly speaking the stability transitions towards classes C and D as the windspeed increases.\n\n\nPasquill Stability and Windspeed\n\nPasquill Stability vs Incoming Solar Radiation\n\n\n\nWindspeed (m/s)\nStrong\nModerate\nSlight\n\n\n\n\n< 2\nA\nA - B\nB\n\n\n2 - 3\nA - B\nB\nC\n\n\n3 - 5\nB\nB - C\nC\n\n\n5 - 6\nC\nC - D\nD\n\n\n> 6\nC\nD\nD\n\n\n\n\n\nPasquill Stability vs Nighttime Cloud Cover\n\n\n\nWindspeed (m/s)\n> 4/8 cloud\n< 3/8 cloud\n\n\n\n\n< 2\n\n\n\n\n2 - 3\nE\nF\n\n\n3 - 5\nD\nE\n\n\n5 - 6\nD\nD\n\n\n> 6\nD\nD\n\n\n\nTo represent this crudely, the plots can be chopped off at the windspeed limits from the tables above. So, for example, the class F plot would end at 3m/s.\nThe plots also present an obvious way of finding the worst case for a particular scenario: find the extremal point for the worst stability class. This is found rather simply by setting the derivative to zero. Something that would be complicated to do analytically but is quite straight forward when using the ForwardDiff library for automatic differentiation.\n\n# ForwardDiff doesn't play nicely with unitful\n# so values have to be stripped of units first\n\nFb′ = ustrip(Fb)\nx₁′ = ustrip(x₁)\nh₁′ = ustrip(h₁)\nQ′ = ustrip(Q)\nhₛ′ = ustrip(hₛ)\n\n∂Cᵤ(u) = ForwardDiff.derivative(u -> C(u, x=x₁′, \n y=0.0, \n z=h₁′, \n Q=Q′, \n h=hₛ′, \n Δh=(x,u) -> Δhᵣ(x, u, Fb=Fb′, stable=false), \n σy=σy(\"D\"), \n σz=σz(\"D\")), float(u))\n\n∂Cᵤ (generic function with 1 method)\n\n\n\n# Find the point where ∂C/∂u = 0\n# Initial guess of 25 just by eye-ball\n\nu_worst = find_zero(∂Cᵤ, 25)\n\nC_worst = C(x₁, 0.0u\"m\", h₁, \n u=u_worst*1u\"m/s\", \n Q=Q, \n h=hₛ, \n Δh=(x,u) -> Δhᵣ(x, u, Fb=Fb, stable=false), \n σy=σy(\"D\"), \n σz=σz(\"D\"))\n\nuconvert(u\"mg/m^3\", C_worst)\n\n0.2753490886768969 mg m^-3\n\n\n\n\n\n\n\n\n\n\n\nWe find that the worst case is indeed class D but with quite a high windspeed, ~26.4m/s or 95kph, which would be considered a 10 on the Beaufort scale with trees being uprooted and considerable structural damage. It’s unlikely that workers would still be on the platform and it may not even be the case that the scaffolding would still be standing!\nRegardless we can look at the contour plots at the work platform elevation and vertically, along the centerline.\nNote the colours are scaled to 4mg/m^3, one tenth the occupational limit of 40mg/m^3.\n\n\n\n\n\n\n\n\n\nAnother interesting impact of elevated releases like this is that the worst concentration for an observer on the ground is often a significant distance downwind of the stack. Because the plume must disperse downwards.\nBelow is a contour plot showing the downwind concentration at a 2m elevation – the height of a reasonably tall person. Note the scale is set to even lower concentrations. The maximum of the colour bar is 1000x lower than the occupational limit." + }, + { + "objectID": "posts/worst_case_weather/index.html#multiple-concentrations", + "href": "posts/worst_case_weather/index.html#multiple-concentrations", + "title": "Worst Case Meterological Conditions", + "section": "Multiple Concentrations", + "text": "Multiple Concentrations\nPreviously, in the discussion of the occupational exposure limit I noted that, in general, one would have to account for the impact of multiple substances in the flue gas, though in that particular example I was only modeling carbon monoxide and I just moved on. I think I left the impression that one would have to model each substance separately and, at least with this simple gaussian dispersion model, that is very much not the case.\nConsider for some substance i being released with in-stack concentration \\(C_{s,i}\\), we can define a dimensionless “dilution” \\(\\chi\\) as\n\\[ \\chi \\left( x, y, z \\right) = { C_i \\left( x, y, z \\right) \\over C_{s,i} } \\]\nAssuming the in-stack concentration to be simply the mass emission rate of i divided by the volumetric flow-rate1\n1 At standard state, because the concentrations given for the occupational exposure limits are given in terms of a volume at standard state. This is also a potential error in the original model as it does not correct the concentrations back to standard state, nor does it really track temperature to make that even possible, especially near the stack.\\[ C_{s,i} = { Q_i \\over V_s^o } \\]\nand recalling the concentration function from the gaussian dispersion model\n\\[ C_i \\left( x, y, z \\right) = {Q_i \\over 2 \\pi u \\sigma_{ye} \\sigma_{ze} } f \\left( x, y, z \\right) \\]\nwhere $ f ( x, y, z ) $ is the products of the exponentials, and is a function of x, y, z only. Putting all that together we get an expression for the dilution that does not depend upon the substance being released\n\\[ \\chi \\left( x, y, z \\right) = {V_s^o \\over 2 \\pi u \\sigma_{ye} \\sigma_{ze} } f \\left( x, y, z \\right) \\]\nIf you have already done the modeling for a particular substance then calculate \\(\\chi\\) for the points of interest by dividing the concentrations by the in stack concentration, otherwise substitute the formula for \\(\\chi\\) given above for the concentration and model that instead.\nThen, when evaluating multiple substances, the test2\n2 From CCOHS\\[ \\sum_i {C_i \\left( x, y, z \\right) \\over T_i } \\lt 1 \\]\nbecomes\n\\[ \\chi \\left( x, y, z \\right) \\cdot \\sum_i {C_{s,i} \\over T_i } \\lt 1 \\]\nwhere \\(T_i\\) is the relevant occupational exposure limit.\nTo recap, instead of calculating the concentrations at the points of interest using a gaussian dispersion model multiple times, calculate a dimensionless dilution at the points of interest and apply that to the in stack concentrations of all of the substances of interest. Then combine those as per the relevant rules for occupational hygiene.\nBelow are a series of contour plots showing the dilution \\(\\chi\\), where colours are are from 0-5% – i.e. the concentration within the yellow region is ≥ 5% the in-stack concentration.\nNote: This is backwards to the usual way of defining dilution, where a \\(\\chi\\) of 5% would be a 95% dilution." + }, + { + "objectID": "posts/worst_case_weather/index.html#thoughts-on-code-and-reusability", + "href": "posts/worst_case_weather/index.html#thoughts-on-code-and-reusability", + "title": "Worst Case Meterological Conditions", + "section": "Thoughts on Code and Reusability", + "text": "Thoughts on Code and Reusability\nA simple way of taking the work from a previous notebook and adding to it is just to import the notebook. This loads the results into the current notebook including any function definitions and such.\nFor example\n\nusing NBInclude\n@nbinclude(\"2020-12-05-gaussian_dispersion_example.ipynb\")\nI didn’t do that here for two reasons:\n\nI want these notebooks to be independent and stand on their own\nI didn’t write the previous notebook in a very extendable or reusable way\n\nThe second point is worth going into if one wants to build a library of worked out, generic, models as notebooks. This way an engineer can import previously defined models as needed for a particular analysis while also keeping the documentation for the models in the model. In the previous notebook I left most things in the global namespace and defined functions that used those global variables. Which is fine for that particular notebook but it means that the current workspace gets very cluttered when importing things and also those functions are not very re-usable as they were defined for a very particular example.\nIt’s better, I think, to write functions that use keyword arguments for any important parameters that can then be passed as needed, instead of defining those parameters in the global namespace. Unless they are truly constants, like g the acceleration due to gravity or R the universal gas constant.\nAnother point is on the use of the library Unitful. It is convenient, and a good check, to have units propagate through calculations, however Unitful does not play nicely with all libraries. This is especially the case with Plots but it can also be real hassle to use with correlations that have lots of parameters. I think this is a good opportunity to take advantage of julia’s multiple dispatch.\nFor example, suppose a correlation of the form \\(f \\left( x \\right) = a \\cdot x^b\\), this can be written in julia very simply (supposing a and b are parameters)\n\nf(x; a, b) = a*x^b\nBut if we pass x with some units and don’t pass the matching units with the parameter a this will throw an error. We could tediously work out the units for each set of parameters a and b to make the units cancel out properly, or we could use multiple dispatch to manage this for us\n\nfunction f(x::Quantity; a, b)::Quantity\n x′ = ustrip(u\"expected input unit\", x)\n return f(x′, a=a, b=b)*1u\"correlation output unit\"\nend\nWhere we convert the input to the expected units, whatever they may be, evaluate the function in a unitless way, then tack on the expected output units at the end. Now when we use f(x) in contexts without units, for example when plotting f(x), it works as expected and if we pass a value of x with units attached we get the unit conversion/checking that we want from Unitful." + }, + { + "objectID": "posts/engineering_a_cup_of_coffee_part-2/index.html", + "href": "posts/engineering_a_cup_of_coffee_part-2/index.html", + "title": "Engineering a Cup of Coffee Part Two: Espresso", + "section": "", + "text": "In a previous post I thought about how one might approach making coffee, in a French press, as a chemical engineering problem. The obvious next step is to look at percolation methods like espresso, which is what I am exploring here.\nThe 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.\nIn 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." + }, + { + "objectID": "posts/engineering_a_cup_of_coffee_part-2/index.html#packed-bed-leaching", + "href": "posts/engineering_a_cup_of_coffee_part-2/index.html#packed-bed-leaching", + "title": "Engineering a Cup of Coffee Part Two: Espresso", + "section": "Packed Bed Leaching", + "text": "Packed Bed Leaching\nMaking 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.\n1 Cameron et al., “Systematically Improving Espresso,” 631.\nSetting up the Governing Equations\nThe 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 \\(\\varepsilon\\). 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.\n\n\n\n\n\n\nFigure 1: The problem domain, a cylindrical puck of coffee.\n\n\n\nIn 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.\nAll of these assumptions, at their core, are to eliminate various partial derivatives and narrow down the governing equations to only core variables.\n\nLiquid Phase\nZooming in on a thin slice of the packed bed with depth, Δz, we can take a mass balance of the liquid phase.\n\n\n\n\n\n\nFigure 2: Mass transfer in the thin slice of the column.\n\n\n\nThe mass flow into the liquid phase can be due to:\n\nadvection, Qc, where c is the liquid phase concentration entering the slice and Q is the volumetric flow rate\naxial diffusion, \\(\\varepsilon A J_z\\), where Jz is the mass flux at the top of the slice and \\(\\varepsilon A\\) is the porous area of the top of the slice, i.e. the area available to liquid flux.\nmass transfer from the coffee grounds (the solid phase), \\(A_s J_s\\), where Js is the mass flux of solubles from the grounds into the liquid phase and As is the surface area of the grounds\n\nSimilarly 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\n\\[ { {d m_l} \\over {d t} } = Q c_z - Q c_{z + \\Delta z} + \\varepsilon A J_z - \\varepsilon A J_{z+\\Delta z} + A_s J_s \\]\n\\[ V_l { {d c} \\over {d t} } = - Q \\Delta c - \\varepsilon A \\Delta J_z + a_s V_s J_s \\]\nwhere 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.\n\\[ \\varepsilon A \\Delta z { {d c} \\over {d t} } = - \\varepsilon A v \\Delta c - \\varepsilon A \\Delta J_z + \\left( 1 - \\varepsilon \\right) A {\\Delta z} a_s J_s\\]\n\\[ { {d c} \\over {d t} } = - v { {\\Delta c} \\over {\\Delta z} } - { {\\Delta J_z} \\over {\\Delta z} } + \\left( {1 - \\varepsilon} \\over \\varepsilon \\right) a_s J_s \\]\nIn the limit Δz → 0 this becomes\n\\[ { {\\partial c} \\over {\\partial t} } = - v { {\\partial c} \\over {\\partial z} } - { {\\partial J_z} \\over {\\partial z} } + \\left({ 1 - \\varepsilon } \\over \\varepsilon \\right) a_s J_s \\]\nAssuming axial diffusion is Fickian\n\\[ { {\\partial c} \\over {\\partial t} } = \\mathscr{D}_l { {\\partial^2 c} \\over {\\partial z^2} } - v { {\\partial c} \\over {\\partial z} } + \\left({ 1 - \\varepsilon } \\over \\varepsilon \\right) a_s J_s \\]\n\n\nSolid Phase\nFor 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.\n\\[ { {\\partial q} \\over {\\partial t} } = \\mathscr{D}_s \\nabla^2 q\\]\nIn spherical coordinates, this becomes\n\\[ { {\\partial q} \\over {\\partial t} } = \\mathscr{D}_s \\frac{1}{r^2} {\\partial \\over {\\partial r} } \\left( r^2 { {\\partial q} \\over {\\partial r} } \\right)\\]\n\\[ { {\\partial q} \\over {\\partial t} } = \\mathscr{D}_s \\left( { {\\partial^2 q} \\over {\\partial r^2} } + {2 \\over r} { {\\partial q} \\over {\\partial r} } \\right) \\]\nWhere the solid phase concentration, q, is in units of mass per unit volume.\n\n\nThin Film\nConnecting 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.\n\\[ J_s = h \\left( c_{s} - c \\right) \\]\nWhere 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.\n\\[ K = c_{s}/q_{s} = \\mathrm{constant} \\]\n\n\n\nInitial Conditions\nThe 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:\n\nThe bed is initially full of water, but that water has no coffee extracted into it.\nThe bed is initially full of water, and that water is in equilibrium with the coffee grounds (e.g. fully saturated).\n\nBasically 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.\n2 Schwartzberg, “Leaching – Organic Materials,” 559.For my model, the initial conditions are:\n\nthe solid concentration in the grounds is the saturation concentration\nthe liquid concentration in the bulk is, initially, in equilibrium and also saturated\nthe boundary condition is that the water entering the system has a concentration 0 mg/m3 coffee solubles\n\nSeveral 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.\n3 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" + }, + { + "objectID": "posts/engineering_a_cup_of_coffee_part-2/index.html#determining-the-parameters-of-the-system", + "href": "posts/engineering_a_cup_of_coffee_part-2/index.html#determining-the-parameters-of-the-system", + "title": "Engineering a Cup of Coffee Part Two: Espresso", + "section": "Determining the Parameters of the System", + "text": "Determining the Parameters of the System\nI 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.\n\nusing Unitful\n\n# physical properties coffee\n# assumed to be water at 90C and 10bar\nρ = 965.34u\"kg/m^3\"\nμ = 0.282*0.001u\"Pa*s\"\nν = μ/ρ\nν = upreferred(ν)\n\n\nParameters of the Packed Bed\nThe 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.\n4 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.\nThe 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.\n5 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.\n\n# Cameron et al, \"Systematically Improving Espresso,\" supplemental materials.\n\n# porosity and particle size\nε = 1 - 0.8272\nb = 12e-6u\"m\"\n\n# bed size\nR_pb = 29.2e-3u\"m\"\nL_pb = 18.7e-3u\"m\"\nA_pb = π*R_pb^2\n\n# shot size\nM_shot = 0.04u\"kg\" # the mass of the espresso shot\nt_shot = 20u\"s\"\nQ_shot = (M_shot/ρ)/t_shot\n\nv_s = Q_shot/A_pb # superficial velocity, m/s\nv = v_s/ε\n\nFrom the dimensions of the packed bed, assumed porosity, and assumed flow rate, I can calculate the time for the water to traverse the bed.\n\n(ε*A_pb*L_pb)/Q_shot\n\n4.177834449522944 s\n\n\n\n\nMass Transfer Parameters\nIn 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.\n\n# effective solid phase diffusivity\n# Cameron et al, \"Systematically Improving Espresso,\" 11.\n𝒟ₛ = 6.25e-10u\"m^2/s\"\n\n# (stagnant) liquid diffusivity\n# Schwartzberg, “Leaching – Organic Materials,” 557.\nD = 5*𝒟ₛ\n\nThe thin film mass transfer coefficient is typically estimated from the Sherwood number which is a function of the Reynolds number and Schmidt number\n\n# Reynolds number\nRe = v_s*(2b)/ν\n\n\n# Schmidt number\nSc = ν/D\n\nThe Sherwood number can be estimated using the Wilson-Geankopolis correlation for packed bed flow\n\n# Wilson-Geankopolis correlation\n# Hottel et al, \"Heat and Mass Transfer,\" 5-77.\nSh = (1.09/ε)*∛(Re*Sc)\n\nGiving the thin film mass transfer coefficient\n\nh = Sh*D/(2b)\n\n0.0014874874803418145 m s^-1\n\n\nThe axial diffusion can be calculated using the Edwards-Richardson correlation\n\n# Edwards-Richardson correlation\n# LeVan and Carta, \"Adsoprtion and Ion Exchange,\" 16-22.\nγ₁ = 0.45 + 0.55*ε\nγ₂ = 0.5*(1 + 13γ₁*ε/(Re*Sc))^-1\nPe = ( γ₁*ε/(Re*Sc) + γ₂ )^-1\n\n\n𝒟ₗ = v*(2b)/Pe\n\n4.623616336378663e-8 m^2 s^-1\n\n\n\n\nEquilibrium Constant\nI 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.\n\n# saturation concentrations\n# Cameron et al, \"Systematically Improving Espresso,\" 11.\nq_sat = 118.0u\"kg/m^3\"\nc_sat = 212.4u\"kg/m^3\"\nK = q_sat/c_sat\n\n0.5555555555555556\n\n\n\n\nA Packed Bed Data Structure\nLooking 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.\n\nstruct PackedBed\n q₀\n K\n 𝒟ₛ\n 𝒟ₗ\n h\n ε\n b\n c₀\n v\nend\n\n\n# initial concentration\nc₀ = 0.0u\"kg/m^3\"\n\n\npb = PackedBed(q_sat, K, 𝒟ₛ, 𝒟ₗ, h, ε, b, c₀, v);" + }, + { + "objectID": "posts/engineering_a_cup_of_coffee_part-2/index.html#anzelius-integral-solution", + "href": "posts/engineering_a_cup_of_coffee_part-2/index.html#anzelius-integral-solution", + "title": "Engineering a Cup of Coffee Part Two: Espresso", + "section": "Anzelius’ Integral Solution", + "text": "Anzelius’ Integral Solution\nA 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:\n\nthe rate of mass transfer across the thin film dominates, and thus the solid phase diffusion can be neglected\nthe mass flow into a given slice of the packed bed is dominated by advection, and the axial dispersion can be neglected\n\nThe governing equations can be reduced to\n\\[ { {\\partial c} \\over {\\partial t} } = - v { {\\partial c} \\over {\\partial z} } + \\left({ 1 - \\varepsilon } \\over \\varepsilon \\right) a_s J_s \\]\n\\[ { {\\partial q} \\over {\\partial t} } = - a_s J_s \\]\nwith\n\\[ J_s = h \\left( c_{s} - c \\right) \\]\nThis 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.\n6 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-755The first step is to transform this pde into dimensionless form8, first by introducing a dimensionless time \\(\\tau = \\frac{h a_s}{K} \\left( t - \\frac{z}{v} \\right)\\). Which, when substituted into the equation for the solid phase, becomes\n\\[ {{\\partial q} \\over {\\partial \\tau}} = K \\left( c - c_{s} \\right) \\]\nFurther introducing a dimensionless space \\(\\xi = \\frac{h a_s}{m v} z\\) where \\(m = \\left({ 1 - \\varepsilon } \\over \\varepsilon \\right)\\) transforms the equation for the liquid phase into\n\\[ {{\\partial c} \\over {\\partial \\xi}} = c_s - c \\]\nBy defining a dimensionless liquid phase concentration\n\\[ u = {{c - \\frac{q_0}{K}} \\over {c_0 - \\frac{q_0}{K}}} \\]\nwhere 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\n\\[ {{\\partial u} \\over {\\partial \\xi}} = {{q - q_0} \\over {K c_0 - q_0}} - u \\]\nLetting\n\\[ y = { {q - q_0} \\over {K c_0 - q_0} } \\]\nthe final system of equations is then\n\\[ { {\\partial u} \\over {\\partial \\xi} } = y - u \\]\n\\[ { {\\partial y} \\over {\\partial \\tau} } = u - y \\]\nWhich, 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 \\({ {\\partial y} \\over {\\partial \\tau} }\\), with respect to τ, which gives\n9 Carslaw and Jaeger, Conduction of Heat in Solids, 393.\\[ sY = U - Y\\]\n\\[ Y = \\frac{1}{s+1}U \\]\nthen taking the Laplace transform of \\({ {\\partial u} \\over {\\partial \\xi} }\\), with respect to τ gives\n\\[ { {d U} \\over {d \\xi} } = Y - U = \\frac{-s}{s+1}U \\]\nwhich 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\n\\[ U = \\frac{1}{s} \\exp\\left( \\frac{-s}{s+1} \\xi \\right) \\]\nInverting 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.\n10 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\n\\[ \\frac{1}{s} \\exp\\left( \\frac{-s}{s+1} \\xi \\right) = \\exp\\left( -\\xi \\right) \\frac{1}{s} \\exp\\left( \\frac{1}{s+1} \\xi \\right) \\]\nThen, looking at a table of Laplace transforms we find12\n12 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.\\[ \\mathscr{L}^{-1} \\left\\{ \\frac{1}{s} \\exp\\left( \\frac{1}{s} x \\right) \\right\\} = I_0 \\left( 2 \\sqrt{xt} \\right)\\]\nwhere I0 is the modified Bessel function of the first kind. A basic property of Laplace transforms is that\n\\[ \\mathscr{L}^{-1} \\left\\{ F(s+a) \\right\\} = \\exp(-at)f(t) \\]\nfrom which it follows that\n\\[ \\mathscr{L}^{-1} \\left\\{ \\frac{1}{s+1} \\exp\\left( \\frac{1}{s+1} x \\right) \\right\\} = \\exp\\left( -t \\right) I_0 \\left( 2 \\sqrt{xt} \\right)\\]\nAnother property of Laplace transforms is that\n\\[ \\mathscr{L}^{-1} \\left\\{ \\frac{1}{s} F(s) \\right\\} = \\int_0^t f\\left( \\lambda \\right) d\\lambda \\]\nwhich gives\n\\[ \\mathscr{L}^{-1} \\left\\{ \\frac{1}{s} \\frac{1}{s+1} \\exp\\left( \\frac{1}{s+1} x \\right) \\right\\} = \\int_0^t \\exp\\left( -\\lambda \\right) I_0 \\left( 2 \\sqrt{x \\lambda} \\right) d\\lambda\\]\nWhich is laying the groundwork for the observation that since\n\\[ \\frac{1}{s} - \\frac{1}{s+1} = \\frac{1}{s}\\frac{1}{s+1} \\]\nthen\n\\[ \\frac{1}{s} = \\frac{1}{s+1} + \\frac{1}{s}\\frac{1}{s+1} \\]\nand U can be rewritten as\n\\[ U = \\exp\\left( -\\xi \\right) \\left[ \\frac{1}{s+1} \\exp\\left( \\frac{1}{s+1} \\xi \\right) +\\frac{1}{s} \\frac{1}{s+1} \\exp\\left( \\frac{1}{s+1} \\xi \\right) \\right] \\]\nthen by taking the inverse Laplace transform\n\\[ u = \\exp\\left( -\\xi \\right) \\left[ \\exp\\left( - \\tau \\right) I_0 \\left( 2 \\sqrt{\\tau \\xi} \\right) + \\int_0^\\tau \\exp\\left( -\\lambda \\right) I_0 \\left( 2 \\sqrt{\\lambda \\xi} \\right) d\\lambda \\right] \\]\n\\[ u = \\exp\\left( -(\\tau + \\xi) \\right) I_0 \\left( 2 \\sqrt{\\tau \\xi} \\right) + \\int_0^\\tau \\exp\\left( -(\\lambda + \\xi) \\right) I_0 \\left( 2 \\sqrt{\\lambda \\xi} \\right) d\\lambda \\]\nThis 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\n13 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.\\[ J\\left( x, y \\right) = 1 - \\int_0^x \\exp\\left( -(\\lambda + y) \\right) I_0 \\left( 2 \\sqrt{\\lambda y} \\right) d\\lambda \\]\n\\[ J\\left( x, y \\right) + J\\left( y, x \\right) = 1 + \\exp\\left( -(x+y) \\right) I_0 \\left( 2 \\sqrt{ x y} \\right) \\]\nfrom which we can see that\n\\[ u = \\exp\\left( -(\\xi + \\tau) \\right) I_0 \\left( 2 \\sqrt{\\xi \\tau} \\right) + 1 - J\\left( \\tau, \\xi \\right) = J\\left( \\xi, \\tau \\right) \\]\nand finally\n\\[ u = 1 - \\int_0^\\xi \\exp\\left( - (\\tau + \\lambda) \\right) I_0 \\left( 2 \\sqrt{\\tau \\lambda} \\right) d\\lambda \\]\nWhich 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.\n\nDefining the Anzelius Solution\nAt 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.\n\nz = L_pb\n\nm = ε/(1-ε)\naᵥ = 3/b\nξ = (h*aᵥ*z)/(m*v)\n\n# τ = ξ\nt = (K/(h*aᵥ))*ξ + z/v\nτ = (h*aᵥ/K)*(t - z/v)\n\n@show ξ; @show τ;\n\nξ = 7437.232219350375\nτ = 7437.232219350374\n\n\nIt 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.\n\nstruct AnzeliusSolution{F,Q1,Q2}\n ξ::F\n τ₁::Q1\n τ₂::Q2\n pb::PackedBed\nend\n\nfunction AnzeliusSolution(z, pb::PackedBed)\n m = ε/(1-ε) \n aᵥ = 3/pb.b\n ξ = (pb.h*aᵥ*z)/(m*pb.v)\n τ₁ = (pb.h*aᵥ/pb.K)\n τ₂ = τ₁*(z/pb.v)\n\n return AnzeliusSolution(ξ, τ₁, τ₂, pb)\nend\n\n\nanzelius = AnzeliusSolution(z, pb);\n\n\n\nEvaluating the Products of Exponentials and Bessel Functions\nGenerally 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:\n15 Bird, Stewart, and Lightfoot, Transport Phenomena, 755.\\[ u = 1 - \\int_0^\\xi \\exp\\left( - (\\tau + \\lambda) \\right) J_0 \\left( i \\sqrt{4\\tau \\lambda} \\right) d\\lambda \\]\nwhere 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.\nWhen 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\n\\[ I_{x,0}(z) = \\exp(-z) I_0(z) \\]\nBy completing the square we can rewrite\n\\[ u = 1 - \\int_0^\\xi \\exp\\left( - (\\tau + \\lambda) \\right) I_0 \\left( 2 \\sqrt{\\tau \\lambda} \\right) d\\lambda \\]\nas\n\\[ u = 1 - \\int_0^\\xi \\exp\\left( - \\left( \\sqrt{\\tau} - \\sqrt{\\lambda} \\right)^2 \\right) \\exp\\left(- 2 \\sqrt{\\tau \\lambda} \\right) I_0 \\left( 2 \\sqrt{\\tau \\lambda} \\right) d\\lambda \\]\n\\[ u = 1 - \\int_0^\\xi \\exp\\left( - \\left( \\sqrt{\\tau} - \\sqrt{\\lambda} \\right)^2 \\right) I_{x,0} \\left( 2 \\sqrt{\\tau \\lambda} \\right) d\\lambda \\]\nBelow 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.\n\nusing Bessels:besselj0, besseli0x\n\nf_orig(λ, τ) = exp(-(τ+λ))*real(besselj0(im*√(complex(4τ*λ))))\n\nfₐ(λ, τ) = exp(-(√(τ)-√(λ))^2)*besseli0x(√(4τ*λ))\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 3: The Anzelius integrand, the upper curve represents the original form which encounters numerical difficulties and fails to return valid answers after the red X, the lower curve is the transformed form of the integrand that does not have these difficulties.\n\n\n\n\n\n\nIntegration by Gauss-Kronold\nNow 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 \\(x = {\\lambda \\over \\xi}\\), thus changing the bounds of integration to \\(x \\in [0, 1]\\)\n\nusing QuadGK: quadgk_count\n\nintegrand(x) = exp(-(√(τ)-√(ξ*x))^2)*besseli0x(√(4τ*ξ*x))\n\n∫, e, N = quadgk_count(integrand,0,1)\n\nu = 1 - ξ*∫\n\n@show u; @show e; @show N;\n\nu = 0.5016355470840097\ne = 1.0518776639256381e-13\nN = 165\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 4: Integrating the Anzelius integrand on the interval [0,1]\n\n\n\n\nBecause 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.\nOne way of dealing with this issue is to take advantage of the symmetry of the J function and use the following rule:16\n16 Lassey, “On the Computation of Certain Integrals Containing the Modified Bessel Function \\(I_0(\\xi)\\),” 631.\nfor ξ≤τ, calculate \\(J \\left( \\xi, \\tau \\right)\\) by direct numerical integration\nfor ξ>τ, calculate \\(J \\left( \\xi, \\tau \\right)\\) by using the relation \\[ J\\left( \\xi, \\tau \\right) = 1 + \\exp\\left( - \\left( \\sqrt{\\tau} - \\sqrt{\\xi} \\right)^2 \\right) I_{x,0} \\left( 2 \\sqrt{\\tau \\xi} \\right) - J\\left( \\tau, \\xi \\right) \\] where \\(J\\left( \\tau, \\xi \\right)\\) is then numerically integrated\n\nThis 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.\n\nusing QuadGK: quadgk\n\nfunction J_quad(x,y)\n if x ≈ 0\n return 1.0, 0.0\n elseif y ≈ 0\n return exp(-x), 0.0\n else\n integrand(λ) = exp(-(√(y)-√(x*λ))^2)*besseli0x(√(4y*x*λ))\n ∫, e = quadgk(integrand,0,1)\n J = 1.0 - x*∫\n return J, e\n end\nend\n\nfunction c_quad(t, model::AnzeliusSolution)\n # unpack some things, calculate model parameters\n cₛ = model.pb.q₀/model.pb.K\n c₀ = model.pb.c₀\n τ = model.τ₁*t - model.τ₂\n ξ = model.ξ\n\n # use Gause-Kronold to integrate\n # modified integral for τ < ξ from Lassey p. 631\n if τ < 0 || ξ < 0\n # outside the domain of the problem\n J = 0.0\n elseif ξ ≤ τ\n J, e = J_quad(ξ,τ)\n else # τ < ξ\n J, e = J_quad(τ,ξ)\n J = 1 + exp(-(√(τ)-√(ξ))^2)*besseli0x(√(4τ*ξ)) - J\n end\n \n # return back the concentration\n c = cₛ + (c₀ - cₛ)*J\n return c\nend\n\n\n\nSeries Representations of the Anzelius J function\nA brief review of the literature around the Anzelius J function will reveal a multitude of series representations. For example, Goldstein17 gives the following\n17 Goldstein, “On the Mathematics of Exchange Processes in Fixed Columns i. Mathematical Solutions and Asymptotic Expansions,” 159.\\[ J(x,y) = 1 - \\exp\\left( - \\left( \\sqrt{x} - \\sqrt{y} \\right)^2 \\right) \\sum_{n=1}^{\\infty} \\left( x \\over y \\right)^{n \\over 2} \\exp \\left( -2 \\sqrt{xy} \\right) \\mathrm{I}_{n} \\left( 2 \\sqrt{xy} \\right) \\]\nNaï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.\n\n# Naively, just adding them up\nusing Bessels:besselix\n\nfunction J_series(x,y,N)\n if x ≈ 0\n return 1.0\n elseif y ≈ 0\n return exp(-x)\n else\n α = 2√(x*y)\n η = √(x/y)\n partial_sum = 0.0\n for k in 1:N\n partial_sum += besselix(k,α)*η^k\n end\n J = 1 - exp(-(√(x)-√(y))^2)*partial_sum\n return J\n end\nend\n\nN = 900\nu = J_series(ξ,τ,N)\n\n@show u; @show N;\n\nu = 0.5016355470840961\nN = 900\n\n\nAn 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.\n18 Bac̆lić, Gvozdenac, and Gragutinović, “Easy Way to Calculate the Anzelius-Schumann j Function,” 114.\nfunction bac̆lić_sub_A(α₋₁,β₋₁,α₀,β₀,d₁,z,ε,max_iter)\n αₙ₋₂, βₙ₋₂ = α₋₁, β₋₁\n αₙ₋₁, βₙ₋₁ = α₀, β₀\n αₙ, βₙ = 0.0, 0.0\n dₙ = d₁\n for n in 1:max_iter\n αₙ = dₙ + (n/z)*αₙ₋₁ + αₙ₋₂\n βₙ = 1 + (n/z)*βₙ₋₁ + βₙ₋₂\n\n if βₙ > 1/ε\n return αₙ, βₙ, n\n else\n dₙ = dₙ*d₁\n αₙ₋₂, βₙ₋₂ = αₙ₋₁, βₙ₋₁\n αₙ₋₁, βₙ₋₁ = αₙ, βₙ\n end\n end\n return αₙ, βₙ, max_iter\nend\n\n\nfunction J_bac̆lić(x,y,ε=1e-9,max_iter=10^6)\n if x ≈ 0\n return 1.0\n elseif y ≈ 0\n return exp(-x)\n else\n z = √(x*y)\n α₋₁ = 0.0\n β₋₁ = 0.0\n β₀ = 0.5\n if y < x\n α₀ = 1.0\n d₁ = √(y/x)\n αₙ, βₙ, N = bac̆lić_sub_A(α₋₁,β₋₁,α₀,β₀,d₁,z,ε,max_iter)\n J = (αₙ/(2*βₙ))*exp(-(√(y)-√(x))^2)\n else\n α₀ = 0.0\n d₁ = √(x/y)\n αₙ, βₙ, N = bac̆lić_sub_A(α₋₁,β₋₁,α₀,β₀,d₁,z,ε,max_iter)\n J = 1.0 - (αₙ/(2*βₙ))*exp(-(√(y)-√(x))^2)\n end\n return J, ε, N\n end\nend\n\n\nu, e, N = J_bac̆lić(τ,ξ)\n\n@show u; @show e; @show N;\n\nu = 0.5016355471003766\ne = 1.0e-9\nN = 698\n\n\n\nfunction c_bac̆lić(t, model::AnzeliusSolution)\n # unpack some things, calculate model parameters\n cₛ = model.pb.q₀/model.pb.K\n c₀ = model.pb.c₀\n τ = model.τ₁*t - model.τ₂\n ξ = model.ξ\n \n if τ < 0 || ξ < 0\n u = 0.0\n else\n u, err, N = J_bac̆lić(ξ,τ)\n end\n \n c = cₛ + (c₀ - cₛ)*u\n return c\nend\n\n\n\nApproximations to the Anzelius J Function\nThomas19 provides an asymptotic expansion of J which forms the basis for several approximations to the J function\n19 Thomas, “CHROMATOGRAPHY” page 171. Note: Thomas gives this in terms of φ where \\(J(x,y) = 1 - \\exp \\left( -(x+y) \\right)\\phi(x,y)\\)\\[ J(x,y) \\approx 1 - \\frac{1}{2}\\mathrm{erfc} \\left( \\sqrt{y} - \\sqrt{x} \\right) + \\exp \\left( -(x+y) \\right) { \\sqrt[4]{x} \\over { \\sqrt[4]{y} + \\sqrt[4]{x} } } I_0 \\left( 2\\sqrt{xy} \\right) + \\ldots\\]\nTaking the first terms of the asymptotic expansion and the limit \\(I_{x,0}(z) \\to \\frac{1}{\\sqrt{2\\pi z} }\\) as z → ∞20\n20 NIST DLMF 10.30.4\\[ J(x,y) \\approx \\frac{1}{2}\\mathrm{erfc} \\left( \\sqrt{x} - \\sqrt{y} \\right) + { \\exp \\left( - \\left( \\sqrt{x} - \\sqrt{y} \\right)^2 \\right) \\over { 2\\sqrt{\\pi} \\left( \\sqrt{y} + \\sqrt[4]{xy} \\right) } }\\]\n\nusing SpecialFunctions: erf, erfc\n\n\nfunction J_approx(x,y)\n if x ≈ 0\n return 1.0\n elseif y ≈ 0\n return exp(-x)\n else\n return 0.5*(erfc(√(x)-√(y)) + exp(-(√(x)-√(y))^2)/(√(π)*(√(y)+(x*y)^0.25)))\n end\nend\n\nu = J_approx(ξ,τ)\n\n@show u;\n\nu = 0.5016355333390161\n\n\n\nfunction c_approx(t, model::AnzeliusSolution)\n # unpack some things, calculate model parameters\n cₛ = model.pb.q₀/model.pb.K\n c₀ = model.pb.c₀\n τ = model.τ₁*t - model.τ₂\n ξ = model.ξ\n\n # approximate integral\n if τ < 0 || ξ < 0\n u = 0.0\n else\n u = J_approx(ξ,τ)\n end\n \n # return back the concentration\n c = cₛ + (c₀ - cₛ)*u\n return c\nend\n\nRice21 goes even further and suggests that, for \\(\\sqrt{xy} \\gt 60\\)\n21 Rice, “Letters to the Editor,” 334.\\[ J(x,y) \\approx \\frac{1}{2}\\mathrm{erfc} \\left( \\sqrt{x} - \\sqrt{y} \\right) \\]\n\nfunction J_rice(x,y)\n if x ≈ 0\n return 1.0\n elseif y ≈ 0\n return exp(-x)\n else\n return 0.5*erfc(√(x)-√(y))\n end\nend\n\nu = J_rice(ξ,τ)\n\n@show u;\n\nu = 0.499999999999992\n\n\n\nfunction c_rice(t, model::AnzeliusSolution)\n # unpack some things, calculate model parameters\n cₛ = model.pb.q₀/model.pb.K\n c₀ = model.pb.c₀\n τ = model.τ₁*t - model.τ₂\n ξ = model.ξ\n\n # approximate integral\n if τ < 0 || ξ < 0\n u = 0.0\n else\n u = 0.5*erfc(√(ξ)-√(τ))\n end\n \n # return back the concentration\n c = cₛ + (c₀ - cₛ)*u\n return c\nend\n\n\n\nReviewing Overall Performance\nI 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.\n\nc(t,m::AnzeliusSolution) = c_quad(t,m)\n\nThat 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.\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 5: The Anzelius solution and its approximations. For this problem the approximations are essentially exact.\n\n\n\n\nI wouldn’t take this to mean that the simple, single term, \\(\\mathrm{erfc}(\\ldots)\\) 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." + }, + { + "objectID": "posts/engineering_a_cup_of_coffee_part-2/index.html#rosens-integral-solution", + "href": "posts/engineering_a_cup_of_coffee_part-2/index.html#rosens-integral-solution", + "title": "Engineering a Cup of Coffee Part Two: Espresso", + "section": "Rosen’s Integral Solution", + "text": "Rosen’s Integral Solution\nRosen22 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\n22 Rosen, “Kinetics of a Fixed Bed System for Solid Diffusion into Spherical Particles,” 387–94.\\[ { {\\partial c} \\over {\\partial t} } + v { {\\partial c} \\over {\\partial z} } = \\left({ 1 - \\varepsilon } \\over \\varepsilon \\right) a_s J_s \\]\n\\[ { {\\partial q} \\over {\\partial t} } = \\mathscr{D}_s \\left( { {\\partial^2 q} \\over {\\partial r^2} } + {2 \\over r} { {\\partial q} \\over {\\partial r} } \\right) \\]\nwere\n\\[ J_s = h \\left( c_{s} - c \\right) \\]\nRosen introduced \\[ { {\\partial \\bar{q} } \\over {\\partial t} } = - a_s J_s \\]\nwhere\n\\[ \\bar{q} \\left( z,t \\right) = \\frac{3}{b^3} \\int_0^b q \\left( r, \\xi, \\tau \\right) r^2 dr \\]\nis the volumetric average concentration in a particle. This follows directly from a mass balance.\nWe can put the liquid phase equation in dimensionless form by introducing a dimensionless time, \\(\\tau = { { \\mathscr{D} a_s} \\over b} \\left( t - \\frac{z}{v} \\right)\\), and a dimensionless z-coordinate, \\(\\xi = { { K D a_s} \\over {b m v} } z\\), where \\(m = { \\varepsilon \\over \\left( 1 - \\varepsilon \\right) }\\) and, with the same dimensionless concentrations u and y as defined above for the Anzelius solution, we have\n\\[ { { \\partial u} \\over {\\partial \\xi} } = - { { \\partial \\bar{y} } \\over {\\partial \\tau} } = \\frac{1}{\\nu} \\left( y_s - u \\right)\\]\nwhere \\(\\nu = \\frac{\\mathscr{D} K}{b h}\\) and ys is the dimensionless solid phase concentration at the surface of a solid particle, i.e. at r = b.\nIf we further introduce a dimensionless particle radius \\(\\vartheta = \\frac{r}{b}\\) we can rewrite the solid phase diffusion equation in dimensionless form\n\\[ { {\\partial y} \\over {\\partial \\tau} } = \\frac{1}{3} \\left( { {\\partial^2 y} \\over {\\partial \\vartheta^2} } + {2 \\over \\vartheta} { {\\partial y} \\over {\\partial \\vartheta} } \\right) \\]\nwhere 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\n23 Carslaw and Jaeger, Conduction of Heat in Solids, 233.\\[ y \\left( \\vartheta, \\xi, \\tau \\right) = \\frac{2}{3} \\sum_{n=1}^{\\infty} \\left(-1 \\right)^{n+1} n\\pi { {\\sin \\left( n \\pi \\vartheta \\right) } \\over \\vartheta} \\int_0^{\\tau} y_s \\left( \\xi, \\lambda \\right) \\exp \\left( -\\frac{n^2 \\pi^2}{3} \\left( \\tau - \\lambda \\right) \\right) d\\lambda\\]\nThis can be integrated over \\(\\vartheta\\) to get the volume average (dimensionless) concentration\n\\[ \\bar{y} = 3 \\int_0^1 y \\vartheta^2 d \\vartheta \\]\n\\[ \\bar{y} = 2 \\sum_{n=1}^{\\infty} \\int_0^{\\tau} y_s \\exp \\left( -\\frac{n^2 \\pi^2}{3} \\left( \\tau - \\lambda \\right) \\right) d\\lambda \\]\nsince\n\\[ \\int_0^1 n\\pi \\sin \\left( n \\pi \\vartheta \\right) \\vartheta d\\vartheta = \\left(-1 \\right)^{n+1} \\]\nTaking the derivative with respect to τ gives (by integration by parts)\n\\[ { {\\partial \\bar{y} } \\over {\\partial \\tau} } = 2 \\sum_{n=1}^{\\infty} \\int_0^{\\tau} { { \\partial y_s } \\over {\\partial \\lambda} } \\exp \\left( -\\frac{n^2 \\pi^2}{3} \\left( \\tau - \\lambda \\right) \\right) d\\lambda \\]\nAt 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\n\\[ { { \\partial u} \\over {\\partial \\xi} } = \\frac{1}{\\nu} \\left( y_s - u \\right)\\]\n\\[ y_s = u + \\nu { { \\partial u} \\over {\\partial \\xi} } \\]\nThus\n\\[ { {\\partial \\bar{y} } \\over {\\partial \\tau} } = 2 \\sum_{n=1}^{\\infty} \\int_0^{\\tau} \\left[ { {\\partial u} \\over {\\partial \\lambda} } + \\nu { { \\partial^2 u} \\over {\\partial \\lambda \\partial \\xi} } \\right] \\exp \\left( -\\frac{n^2 \\pi^2}{3} \\left( \\tau - \\lambda \\right) \\right) d\\lambda \\]\nand since \\({ { \\partial u} \\over {\\partial \\xi} } = - { { \\partial \\bar{y} } \\over {\\partial \\tau} }\\)\n\\[ { {\\partial u } \\over {\\partial \\xi} } = -2 \\sum_{n=1}^{\\infty} \\int_0^{\\tau} \\left[ { {\\partial u} \\over {\\partial \\lambda} } + \\nu { { \\partial^2 u} \\over {\\partial \\lambda \\partial \\xi} } \\right] \\exp \\left( -\\frac{n^2 \\pi^2}{3} \\left( \\tau - \\lambda \\right) \\right) d\\lambda \\]\nRosen solves this by taking the Laplace transform, with respect to τ, with the following relations:\n\\[ \\mathscr{L} \\left\\{ \\int_0^\\tau f(\\lambda) g(\\tau-\\lambda) d\\lambda \\right\\} = F(s) G(s) \\]\n\\[ \\mathscr{L} \\left\\{ { {\\partial u} \\over {\\partial \\tau} } + \\nu { { \\partial^2 u} \\over {\\partial \\tau \\partial \\xi} } \\right\\} = s U + \\nu s { {d U} \\over {d\\xi} }\\]\n\\[ \\mathscr{L} \\left\\{ \\exp \\left( -\\frac{n^2 \\pi^2}{3} \\tau \\right) \\right\\} = { 1 \\over {s + \\frac{n^2 \\pi^2}{3} } }\\]\narriving at\n\\[ { {d U} \\over {d\\xi} } = -2 \\left( s U + \\nu s { {d U} \\over {d\\xi} } \\right) \\sum_{n=1}^{\\infty} { s \\over {s + \\frac{n^2 \\pi^2}{3} } } \\]\nLetting\n\\[ Y_D(s) = 2 \\sum_{n=1}^{\\infty} { s \\over {s + \\frac{n^2 \\pi^2}{3} } } \\]\nthen\n\\[ { {d U} \\over {d\\xi} } = - { Y_D \\over { 1 + \\nu Y_D } } U \\]\nand, solving this ode with initial condition u=1, U=1/s, gives\n\\[ U = \\frac{1}{s} \\exp \\left( - { Y_D \\over { 1 + \\nu Y_D } } \\xi \\right) \\]\nThe final solution follows from taking the inverse Laplace transform, by way of the contour integral\n\\[ u \\left( \\xi, \\tau \\right) = \\frac{1}{2\\pi i} \\int_{\\alpha - i\\infty}^{\\alpha + i\\infty} \\frac{1}{s} \\exp \\left( s \\tau - { Y_D \\over { 1 + \\nu Y_D } } \\xi \\right) ds \\]\nA major component of the integration involves first defining YD in terms of trigonometric functions. The details are tedious, but the main result is\n\\[ Y_T \\left( i \\beta \\right) = { Y_D \\over { 1 + \\nu Y_D } } = H_1 \\left( \\lambda, \\nu \\right) + i H_2 \\left( \\lambda, \\nu \\right) \\]\nwith \\(\\lambda = \\sqrt{ \\frac{3}{2} \\beta}\\) and\n\\[ H_1 \\left( \\lambda, \\nu \\right) = { { H_{D1} + \\nu \\left( H_{D1}^2 + H_{D2}^2 \\right) } \\over { \\left( 1 + \\nu H_{D1} \\right)^2 + \\left( \\nu H_{D2} \\right)^2 } }\\]\n\\[ H_2 \\left( \\lambda, \\nu \\right) = { H_{D2} \\over { \\left( 1 + \\nu H_{D1} \\right)^2 + \\left( \\nu H_{D2} \\right)^2 } }\\]\nand\n\\[ H_{D1} = \\lambda { {\\sinh 2\\lambda + \\sin 2\\lambda} \\over { \\cosh 2\\lambda - \\cos 2\\lambda } } - 1\\]\n\\[ H_{D2} = \\lambda { {\\sinh 2\\lambda - \\sin 2\\lambda} \\over { \\cosh 2\\lambda - \\cos 2\\lambda } } \\]\nWhich allows Rosen to write the integral in terms of these harmonic functions24\n24 For details of the integration see Rosen, “Kinetics of a Fixed Bed System for Solid Diffusion into Spherical Particles” pages 390-391\\[ u \\left( \\xi, \\tau \\right) = \\frac{1}{2} + \\frac{2}{\\pi} \\int_0^\\infty { { \\exp \\left( -\\xi H_1 \\left( \\lambda, \\nu \\right) \\right) \\sin \\left( \\frac{2}{3} \\tau \\lambda^2 - \\xi H_2 \\left( \\lambda, \\nu \\right) \\right) } \\over \\lambda } d\\lambda \\]\n\nDefining the Rosen Solution\nAt 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.\n\nm = ε/(1-ε)\naᵥ = 3/b\n\nξ = (K*𝒟ₛ*aᵥ)/(b*m*v)*z\n\nτ = (𝒟ₛ*aᵥ/b)*(t-z/v)\nν = (𝒟ₛ*K)/(b*h)\n\n@show ξ; @show τ; @show ν;\n\nξ = 144.6719346388569\nτ = 144.6719346388569\nν = 0.019452389057107278\n\n\nUnlike 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.\n\n\nThe Harmonic Functions\nThe 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.\n\\[ { {\\sinh 2\\lambda + \\sin 2\\lambda } \\over {\\cosh 2\\lambda - \\cos 2\\lambda} } = { { 1 - \\exp(-2\\lambda) \\left( \\exp(-2\\lambda) + 2 \\sin 2\\lambda \\right) } \\over {1 + \\exp(-2\\lambda) \\left( \\exp(-2\\lambda) - 2 \\cos 2\\lambda \\right) } }\\]\n\nfunction HD1(λ)\n if λ ≤ eps(0.0)\n return 0.0\n else\n # λ*((sinh(2λ) + sin(2λ))/(cosh(2λ) - cos(2λ))) - 1\n return λ*( (1 - exp(-4λ) + 2*exp(-2λ)*sin(2λ)) / (1 + exp(-4λ) - 2*exp(-2λ)*cos(2λ))) -1 \n end\nend\n\nfunction HD2(λ)\n if λ ≤ eps(0.0)\n return 0.0\n else\n # λ*(sinh(2λ) - sin(2λ))/(cosh(2λ) - cos(2λ))\n return λ*( (1 - exp(-4λ) - 2*exp(-2λ)*sin(2λ)) / (1 + exp(-4λ) - 2*exp(-2λ)*cos(2λ))) \n end\nend\n\n\n\nIntegration by Gauss-Kronold\nThe integrand can be divided into a decay component, f, that is independent of τ, and an oscillatory component, K, that is a function of τ.\n\\[ f \\left( \\lambda; \\xi, \\nu \\right) = { { \\exp \\left( -\\xi H_1 \\left( \\lambda, \\nu \\right) \\right) } \\over \\lambda }\\]\n\\[ \\mathscr{K} \\left( \\lambda, \\tau; \\xi, \\nu \\right) = \\sin \\left( \\frac{2}{3} \\tau \\lambda^2 - \\xi H_2 \\left( \\lambda, \\nu \\right) \\right) \\]\nThis 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 \\(\\mathscr{K}\\), for some incremental improvements, though I leave that as an exercise for a more motivated individual.\nIt 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.\n\nfunction fᵣ(λ; ξ, ν)\n hd1, hd2 = HD1(λ), HD2(λ)\n H1 = (hd1 + ν*(hd1^2 + hd2^2))/((1 + ν*hd1)^2 + (ν*hd2)^2)\n return exp(-ξ*H1)/λ\nend\n\nfunction 𝒦ᵣ(λ, τ; ξ, ν)\n hd1, hd2 = HD1(λ), HD2(λ)\n H2 = hd2/((1 + ν*hd1)^2 + (ν*hd2)^2)\n return sin((2/3)*τ*λ^2 - ξ*H2)\nend\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 6: The integrand of the Rosen solution, for moderate values of τ this is a highly oscillating integral.\n\n\n\n\nBy introducing the change of variables25 β = λ2, the integrand is “compressed” along β and we can take advantage of the exponential decay to truncate the integration.\n25 Rosen, “General Numerical Solution for Solid Diffusion in Fixed Beds,” 1590–94.\nfunction fᵣ₂(β; ξ, ν)\n λ = √(β)\n hd1, hd2 = HD1(λ), HD2(λ)\n H1 = (hd1 + ν*(hd1^2 + hd2^2))/((1 + ν*hd1)^2 + (ν*hd2)^2)\n return exp(-ξ*H1)/β\nend\n\nfunction 𝒦ᵣ₂(β, τ; ξ, ν)\n λ = √(β)\n hd1, hd2 = HD1(λ), HD2(λ)\n H2 = hd2/((1 + ν*hd1)^2 + (ν*hd2)^2)\n return sin((2/3)*τ*β - ξ*H2)\nend\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 7: The integrand of the Rosen solution, after the change of variables. The curve decays much more rapidly.\n\n\n\n\nQuadGK.jl can compute the improper integral directly, by making the substitution λ = t/(1-t).\n\nusing QuadGK: quadgk_count\n\nI₁, err₁, N₁ = quadgk_count(λ -> fᵣ(λ; ξ=ξ, ν=ν)*𝒦ᵣ(λ, τ; ξ=ξ, ν=ν), 0, Inf)\n\n@show I₁; @show err₁; @show N₁;\n\nI₁ = 0.011703238397164204\nerr₁ = 1.0292584595556625e-10\nN₁ = 195\n\n\nBy making the substitution β = λ2 and truncating the integral to the range [0,2] we can achieve similar precision, with almost half as many steps.\n\nI_2, err_2, N_2 = quadgk_count( β -> fᵣ₂(β; ξ=ξ, ν=ν)*𝒦ᵣ₂(β, τ; ξ=ξ, ν=ν), 0, 2)\n\n@show I_2/2; @show err_2; @show N_2;\n\nI_2 / 2 = 0.01170323839564812\nerr_2 = 2.183245425172356e-10\nN_2 = 105\n\n\n\n\nIntegration by Levin Colocation\nI 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.\nGiven the integral\n\\[ \\int_a^b \\mathbf{f}(x) \\cdot \\mathbf{K}(x) dx \\]\nif we suppose there is a function \\(\\mathbf{F}\\) such that\n\\[ \\frac{d}{dx} \\mathbf{F}(x) \\cdot \\mathbf{K}(x) = \\mathbf{f}(x) \\cdot \\mathbf{K}(x) \\]\nThen we can eliminate the integral, using the fundamental theorem of calculus\n\\[ \\int_a^b \\mathbf{f}(x) \\cdot \\mathbf{K}(x) dx = \\int_a^b \\frac{d}{dx} \\mathbf{F}(x) \\cdot \\mathbf{K}(x) dx = \\mathbf{F}(b) \\cdot \\mathbf{K}(b) - \\mathbf{F}(a) \\cdot \\mathbf{K}(a)\\]\nThe problem is then one of finding the function \\(\\mathbf{F}\\).\nIf we choose \\(\\mathbf{K}(x)\\) such that \\(\\frac{d}{dx} \\mathbf{K}(x) = \\mathbf{A} \\mathbf{K}(x)\\), then\n\\[ \\frac{d}{dx} \\left( \\mathbf{F}(x) \\cdot \\mathbf{K}(x) \\right) = \\mathbf{F}^{\\prime}(x) \\cdot \\mathbf{K}(x) + \\mathbf{F}(x) \\cdot \\mathbf{A} \\mathbf{K}(x) \\]\n\\[ \\mathbf{F}^{\\prime}(x) \\cdot \\mathbf{K}(x) + \\mathbf{F}(x) \\cdot \\mathbf{A} \\mathbf{K}(x) = \\mathbf{f}(x) \\cdot \\mathbf{K}(x) \\]\nEliminating K(x) gives\n\\[ \\mathbf{F}^{\\prime}(x) + \\mathbf{A}^{T} \\mathbf{F}(x) = \\mathbf{f}(x) \\]\nSolving this ode then gives the final solution.\nIn this case I set K(x) to\n\\[ \\mathbf{K}(x) = \\begin{pmatrix} \\sin(h(x) \\\\ \\cos(h(x)) \\end{pmatrix} \\]\nwhere h(x) = (2/3)τx2 - ξH2(λ), and f(x) is\n\\[ \\mathbf{f}(x) = \\begin{pmatrix} f(x) \\\\ 0 \\end{pmatrix} \\]\nand the ode is solved for F in terms of Chebyshev polynomials.\n\nusing ApproxFun:Interval, Fun, Derivative, Evaluation, \\, I\nusing LinearAlgebra: ⋅\n\nfunction levin(ξ, τ, ν, a, b)\n d = Interval(a,b)\n λ = Fun(d)\n D = Derivative(d)\n E = Evaluation(a)\n\n HD1 = λ*(sinh(2λ) + sin(2λ))/(cosh(2λ) - cos(2λ)) - 1\n HD2 = λ*(sinh(2λ) - sin(2λ))/(cosh(2λ) - cos(2λ))\n \n H1 = (HD1 + ν*(HD1^2 + HD2^2))/((1 + ν*HD1)^2 + (ν*HD2)^2)\n H2 = HD2/((1 + ν*HD1)^2 + (ν*HD2)^2)\n\n h = (2/3)*τ*λ^2 - ξ*H2\n h′ = D*h\n w⃗ = [ sin(h); cos(h) ]\n \n f = exp(-ξ*H1)/λ\n f⃗ = [ 0; 0; f; 0 ]\n \n L = [ E 0;\n 0 E;\n D -h′*I;\n h′*I D ]\n \n F = L\\f⃗\n \n return F(b)⋅w⃗(b) - F(a)⋅w⃗(a)\nend\n\n\nlevin(ξ, τ, ν, 0.0001, 2)\n\n0.011703248012960965\n\n\nThis works well enough, though there is a complication in that there is a singularity at x=0, it is also rather slow.\nIf 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.\n\n\nPre-calculating the spatial component\nAs 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.\n\\[ \\int_a^b f \\left(x; p\\right) \\mathcal{K} \\left(x, t; p\\right) dx \\approx \\sum_i^{N} w_i \\mathcal{K}\\left(x_i, t\\right)\\]\nThe obvious way to do this is to use QuadGK.jl to take the weight function f and generate points that way. For example:\npts, wts = gauss( x -> fᵣ₂(x; ξ=ξ, ν=ν), 20, 0, 2);\nI 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.\nThe 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 \\(\\beta \\in [0,2]\\) 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.\n\nusing QuadGK: gauss\n\npts, wts = gauss(N₁, 0, 2);\n\nwts = wts .* fᵣ₂.(pts; ξ=ξ, ν=ν);\n\nI_gauss = sum( wts .* 𝒦ᵣ₂.(pts, τ; ξ=ξ, ν=ν) )/2\n\n0.011703238395545075\n\n\nThis 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.\n\nstruct IntegralTransform{T}\n a::T\n b::T\n params::NamedTuple\n numpts::Integer\n pts::Vector{T}\n wts::Vector{T}\n kern::Function\nend \n\nfunction IntegralTransform(params, fun, kern; a=0.0, b=2.0, numpts=350)\n pts, wts = gauss(numpts, a, b)\n wts = wts .* fun.(pts; params...)\n return IntegralTransform(a, b, params, numpts, pts, wts, kern)\nend\n\nfunction integrate(t, it::IntegralTransform)\n return sum( it.wts .* it.kern.(it.pts, t; it.params...) )\nend\n\nOne 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.\n26 I love this word.\n\nPackaging a final approach\nAt 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.\n\nstruct RosenSolution{Q1,Q2,T}\n τ₁::Q1\n τ₂::Q2\n pb::PackedBed\n it::IntegralTransform{T}\nend\n\nfunction RosenSolution(z, pb::PackedBed; fun=fᵣ₂, kern=𝒦ᵣ₂, a=0.0, b=2.0, numpts=200)\n m = pb.ε/(1-pb.ε)\n aᵥ = 3/pb.b\n ξ = (pb.K*pb.𝒟ₛ*aᵥ*z)/(m*pb.v*pb.b)\n ν = (pb.𝒟ₛ*pb.K)/(pb.b*pb.h)\n τ₁ = pb.𝒟ₛ*aᵥ/pb.b\n τ₂ = τ₁*(z/pb.v)\n\n p = (ξ=ξ, ν=ν)\n it = IntegralTransform(p, fun, kern; a=a, b=b, numpts=numpts)\n \n return RosenSolution(τ₁, τ₂, pb, it)\nend\n\nThe concentration can then be obtained by calling the integrate function with the integral transform.\n\nfunction c(t, model::RosenSolution)\n # unpack some things\n cₛ = model.pb.q₀/model.pb.K\n c₀ = model.pb.c₀\n\n # compute the integral\n τ = model.τ₁*t - model.τ₂\n I = integrate(τ, model.it)\n\n # return back the concentration\n u = 0.5 + I/π\n c = cₛ + (c₀ - cₛ)*u\n return c\nend\n\n\nrosen = RosenSolution(z, pb; a=0.0, b=2.0, numpts=200);\n\n\n\nApproximations to the Rosen Integral\nRosen27 provides an asymptotic approximation for cases where ξ is large\n27 Rosen, “General Numerical Solution for Solid Diffusion in Fixed Beds,” 1591.\\[ u = \\frac{1}{2} \\left[ 1 + \\mathrm{erf} \\left( { { \\frac{\\tau}{\\xi} - 1} \\over { 2 \\sqrt{ {1 + 5\\nu} \\over {5 \\xi} } } } \\right) \\right] \\]\nWhich is decidedly simpler to calculate.\n\nfunction c_approx(t, model::RosenSolution)\n # unpack some things\n cₛ = model.pb.q₀/model.pb.K\n c₀ = model.pb.c₀\n\n # compute the integral\n τ = model.τ₁*t - model.τ₂\n u = 0.5*(1 + erf( ( (τ/ξ) - 1 ) / ( 2*√((1+5ν)/(5ξ)) ) ) )\n\n # return back the concentration\n c = cₛ + (c₀ - cₛ)*u\n return c\nend\n\n\n\nApproximating the Rosen integral with the Anzelius J Function\nI 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\n28 LeVan and Carta, “Adsorption and Ion Exchange,” 16–24.\\[ \\frac{1}{h_{eff} } = \\frac{1}{(1-\\varepsilon) h} + \\frac{b}{5 K \\mathscr{D}_s} \\]\nAdapting the AnzeliusSolution to use a generic function to calculate the effective mass transfer coefficient allows us to reuse everything from the Anzelius case.\n\nfunction AnzeliusSolution(z, h_fun, pb::PackedBed)\n m = pb.ε/(1-pb.ε) \n aᵥ = 3/pb.b\n h = h_fun(pb)\n ξ = (h*aᵥ*z)/(m*pb.v)\n τ₁ = (h*aᵥ/pb.K)\n τ₂ = τ₁*(z/pb.v)\n\n return AnzeliusSolution(ξ, τ₁, τ₂, pb)\nend\n\nh_eff(pb) = 1/( 1/((1-pb.ε)*pb.h) + pb.b/(5*pb.K*pb.𝒟ₛ) )\n\n\n\nReviewing Overall Performance\nThe 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.\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 8: The Rosen solution, and it’s approximations, compared with the Anzelius solution. Note the approximations are essentially exact for this problem." + }, + { + "objectID": "posts/engineering_a_cup_of_coffee_part-2/index.html#rasmusons-integral-solution", + "href": "posts/engineering_a_cup_of_coffee_part-2/index.html#rasmusons-integral-solution", + "title": "Engineering a Cup of Coffee Part Two: Espresso", + "section": "Rasmuson’s Integral Solution", + "text": "Rasmuson’s Integral Solution\nRasmuson 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 \\(\\frac{\\partial^2 c}{\\partial z^2}\\) 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.\n29 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.\n\nfunction f_rasmuson(λ; ν, δ, R, Pe)\n hd1, hd2 = HD1(λ), HD2(λ)\n H1 = (hd1 + ν*(hd1^2 + hd2^2))/((1 + ν*hd1)^2 + (ν*hd2)^2)\n H2 = hd2/((1 + ν*hd1)^2 + (ν*hd2)^2)\n a = Pe*(0.25*Pe + δ*H1)\n b = δ*Pe*((2/3)*λ^2/R + H2)\n return exp(0.5*Pe - √(0.5*(√(a^2 + b^2) + a)))/λ\nend\n\nfunction 𝒦_rasmuson(λ, y; ν, δ, R, Pe)\n hd1, hd2 = HD1(λ), HD2(λ)\n H1 = (hd1 + ν*(hd1^2 + hd2^2))/((1 + ν*hd1)^2 + (ν*hd2)^2)\n H2 = hd2/((1 + ν*hd1)^2 + (ν*hd2)^2)\n a = Pe*(0.25*Pe + δ*H1)\n b = δ*Pe*((2/3)*λ^2/R + H2)\n return sin(y*λ^2 - √(0.5*(√(a^2 + b^2) - a)))\nend\n\nRasmuson and Neretnieks parameterize things slightly differently, and add some extra dimensionless groups due to the \\(\\mathscr{D}_L\\), but the result a similar sort of integral problem as Rosen, namely integrating a highly oscillating integral that decays rapidly.\n\nγ = 3*𝒟ₛ*K/b^2\nδ = γ*z/(m*v)\nν = γ*b/(3h)\nσ = 2*𝒟ₛ/b^2\n\nR = K/m\nPe = (z*v)/𝒟ₗ\ny = σ*t\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 9: The integrand of the Rasmuson-Neretnieks solution, for moderate values of y this becomes a highly oscillating integral.\n\n\n\n\nThus it can be represented using the IntegralTransform type previously created.\n\np = (ν=ν, δ=δ, R=R, Pe=Pe)\n\nit = IntegralTransform(p, f_rasmuson, 𝒦_rasmuson; a=0.0, b=2.0, numpts=200);\n\n\nintegrate(y, it)\n\n0.011984085774006418\n\n\n\nstruct RasmusonSolution{Q,T}\n σ::Q\n pb::PackedBed\n it::IntegralTransform{T}\nend\n\nfunction RasmusonSolution(z, pb::PackedBed; fun=f_rasmuson, kern=𝒦_rasmuson, a=0.0, b=2.0, numpts=350)\n m = pb.ε/(1-pb.ε)\n γ = 3*pb.𝒟ₛ*pb.K/pb.b^2\n δ = γ*z/(m*pb.v)\n ν = γ*pb.b/(3*pb.h)\n σ = 2*pb.𝒟ₛ/pb.b^2\n \n R = pb.K/m\n Pe = (z*pb.v)/pb.𝒟ₗ\n\n p = (ν=ν, δ=δ, R=R, Pe=Pe)\n it = IntegralTransform(p, fun, kern; a=a, b=b, numpts=numpts)\n \n return RasmusonSolution(σ, pb, it)\nend\n\n\nrasmuson = RasmusonSolution(z,pb);\n\n\nfunction c(t, model::RasmusonSolution)\n # unpack some things\n cₛ = model.pb.q₀/model.pb.K\n c₀ = model.pb.c₀\n\n # compute the integral\n y = model.σ*t\n I = integrate(y, model.it)\n\n # return back the concentration\n u = 0.5 + 2I/π\n c = cₛ + (c₀ - cₛ)*u\n return c\nend\n\nGoing 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).\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 10: The Rasmuson-Neretnieks solution for packed bed extraction, compared with the Rosen and Anzelius cases." + }, + { + "objectID": "posts/engineering_a_cup_of_coffee_part-2/index.html#integrating-the-pde-by-finite-difference", + "href": "posts/engineering_a_cup_of_coffee_part-2/index.html#integrating-the-pde-by-finite-difference", + "title": "Engineering a Cup of Coffee Part Two: Espresso", + "section": "Integrating the PDE by finite difference", + "text": "Integrating the PDE by finite difference\nThe 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.\nThe first step is to put the pde in dimensionless form, by introducing the following variables\n\\[ \\xi = \\frac{z}{L} \\]\n\\[ \\vartheta = \\frac{r}{b} \\]\n\\[ \\tau = \\frac{t}{t_{shot} } \\]\n\\[ u = \\frac{c}{c_{sat} }\\]\n\\[ y = \\frac{q}{q_{0} } \\]\nwe can write the pde for the liquid phase concentration as\n\\[ { {\\partial u} \\over {\\partial \\tau} } = { { t_{shot} \\mathscr{D}_L } \\over L^2 } { {\\partial^2 u} \\over {\\partial \\xi^2} } - { {t_{shot} v} \\over L }{ {\\partial u} \\over {\\partial \\xi} } - { {1 - \\varepsilon} \\over \\varepsilon } h a_s t_{shot} \\left( u - y \\right)\\]\nand the pde for the solid phase concentration as\n\\[ { {\\partial y} \\over {\\partial \\tau} } = { {t_{shot} \\mathscr{D}_s} \\over b^2 } \\left( { {\\partial^2 y} \\over {\\partial \\vartheta^2} } + \\frac{2}{\\vartheta} { {\\partial y} \\over {\\partial \\vartheta} } \\right) \\]\nwith\n\\[ \\left. { {\\partial y} \\over {\\partial \\tau} } \\right\\vert_{\\vartheta=1} = { {h a_s t_{shot} } \\over K} \\left( u - y \\right) \\]\nThese equations can be discretized in both spatial dimensions ξ and \\(\\vartheta\\), turning them into an ode in τ.\n\n\n\n\n\n\nFigure 11: A discretized mass transfer system, the column is divided into n thin slices and each slice is further subdivided into m+1 cells.\n\n\n\nIn 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.\n\nThe Anzelius Example Case\nAs 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.\nI 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.\n\nusing SparseArrays\n\nh_e = h_eff(pb)\n\nfunction parameters(n)\n v_dm = v*t_shot/L_pb\n h_dm = h_e*(3/b)*t_shot\n dξ=1/(n-1)\n\n # initial conditions\n u0 = ones(Float64,2n)\n\n M = spzeros(Float64,2n,2n)\n # Liquid phase\n # start of column, with the boundary condition that u[0]=0\n # du[1] = -v/2dξ*(u[2] - u[0]) - h/m*(u[1] - u[n+1])\n M[1,1] = -h_dm/m\n M[1,2] = -v_dm/2dξ\n M[1,1+n] = h_dm/m\n \n # middle column\n for i in 2:n-1\n # du[i] = -v/2dξ*(u[i+1] - u[i-1]) - h/m*(u[i] - u[n+i])\n M[i,i-1] = v_dm/2dξ\n M[i,i] = -h_dm/m\n M[i,i+1] = -v_dm/2dξ\n M[i,i+n] = h_dm/m\n end\n \n # end of column\n # du[n] = -v*(u[n]-u[n-1])/dξ - h/m*(u[n] - u[2n])\n M[n,n-1] = v_dm/dξ\n M[n,n] = -v_dm/dξ - h_dm/m\n M[n,2n] = h_dm/m\n \n # Solid phase\n for i in n+1:2n\n # du[i] = h/K*(u[i-n] - u[i]\n M[i,i-n] = h_dm/K\n M[i,i] = -h_dm/K\n end\n \n return u0, (0.0, 1.0), M\nend\n\nThe ode for this system is linear and is simply\n\\[ { {d \\mathbf{u} } \\over {d \\tau} } = \\mathbf{M} \\mathbf{u} \\]\n\nfunction rhs!(du,u,M,t)\n du .= M*u\nend\n\nWhich could presumably be solved by eigendecomposition, but more generally this would be solved using a standard ode solver.\n\nusing OrdinaryDiffEq\nimport Static\n\nsol = solve(ODEProblem(rhs!,parameters(10)...), Tsit5(thread=Static.True()))\n\nsol.retcode\n\nReturnCode.Success = 1\n\n\nThe liquid concentration at the exit is then extracted from the vector solution.\n\n# Pull out the concentration at the exit\nfunction c(t,sol::ODESolution)\n n = length(sol.u[1])÷2\n τ = t/t_shot\n u = sol(τ)\n return u[n]*c_sat\nend\n\nBelow 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.\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 12: Method of Lines with increasing n, converging slowly to the exact (Anzelius) solution.\n\n\n\n\nSince 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.\nThis 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." + }, + { + "objectID": "posts/engineering_a_cup_of_coffee_part-2/index.html#conclusion", + "href": "posts/engineering_a_cup_of_coffee_part-2/index.html#conclusion", + "title": "Engineering a Cup of Coffee Part Two: Espresso", + "section": "Conclusion", + "text": "Conclusion\nSo 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.\n30 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 5Since 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 \\(\\exp\\left(...\\right)\\) term to the solution somewhere.\nI 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.\n32 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.\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 13: The impact of particle size on espresso extraction.\n\n\n\n\nAnother 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.\nWhile 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." + }, + { + "objectID": "posts/engineering_a_cup_of_coffee_part-2/index.html#references", + "href": "posts/engineering_a_cup_of_coffee_part-2/index.html#references", + "title": "Engineering a Cup of Coffee Part Two: Espresso", + "section": "References", + "text": "References\n\n\nAnzelius, A. “Über Erwärmung Vermittels Durchströmender Medien.” Zeitschrift für Angewandte Mathematik Und Mechanik. 6, no. 4 (1926): 291–94. https://doi.org/10.1002/zamm.19260060404.\n\n\nBac̆lić, Branislav, Dus̆an Gvozdenac, and Gordan Gragutinović. “Easy Way to Calculate the Anzelius-Schumann j Function.” Thermal Science 1, no. 1 (1997): 109–16.\n\n\nBird, R. Byron, Warren E. Stewart, and Edwin N. Lightfoot. Transport Phenomena. 2nd ed. Hoboken, NJ: John Wiley & Sons, 2007.\n\n\nCameron, Michael I., Dechen Morisco, Daniel Hofstetter, Erol Uman, Justin Wilkinson, Zachary C. Kennedy, Sean A. Fontenot, William T. Lee, Christopher H. Hendon, and Jamie M. Foster. “Systematically Improving Espresso: Insights from Mathematical Modeling and Experiment.” Matter 2, no. 3 (2020): 631–48. https://doi.org/10.1016/j.matt.2019.12.019.\n\n\nCarslaw, Horatio S., and John C. Jaeger. Conduction of Heat in Solids. 2nd ed. London: Oxford University Press, 1959.\n\n\nGagné, Jonathan. The Physics of Filter Coffee. Scott Rao, 2020.\n\n\nGoldstein, Sydney. “On the Mathematics of Exchange Processes in Fixed Columns i. Mathematical Solutions and Asymptotic Expansions.” Proceedings of the Royal Society of London. Series A. Mathematical and Physical Sciences 219, no. 1137 (1953): 151–71. https://doi.org/10.1098/rspa.1953.0137.\n\n\nGreen, Don W., ed. Perry’s Chemical Engineers’ Handbook. 8th ed. New York: McGraw Hill, 2008.\n\n\nHottel, Hoyt C., James J. Noble, Adel F. Sarofim, Geoffrey D. Silcox, Phillip C. Wankat, and Kent S. Knaebel. “Heat and Mass Transfer.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008.\n\n\nLassey, Keith R. “On the Computation of Certain Integrals Containing the Modified Bessel Function \\(I_0(\\xi)\\).” Mathematics of Computation 39, no. 160 (1982): 625–37. https://doi.org/10.1090/s0025-5718-1982-0669654-6.\n\n\nLeVan, M. Douglas, and Giorgio Carta. “Adsorption and Ion Exchange.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008.\n\n\nMoroney, K. M., W. T. Lee, S. B. G. O׳Brien, F. Suijver, and J. Marra. “Modelling of Coffee Extraction During Brewing Using Multiscale Methods: An Experimentally Validated Model.” Chemical Engineering Science 137 (2015): 216–34. https://doi.org/10.1016/j.ces.2015.06.003.\n\n\nMoroney, Ken AND Meikle-Janney, Kevin M. AND O’Connell. “Analysing Extraction Uniformity from Porous Coffee Beds Using Mathematical Modelling and Computational Fluid Dynamics Approaches.” PLOS ONE 14, no. 7 (July 2019): 1–24. https://doi.org/10.1371/journal.pone.0219906.\n\n\nRasmuson, Anders, and Ivars Neretnieks. “Exact Solution of a Model for Diffusion in Particles and Longitudinal Dispersion in Packed Beds.” AIChE Journal 26, no. 4 (1980): 686–90. https://doi.org/10.1002/aic.690260425.\n\n\nRice, R. G. “Letters to the Editor.” AIChE Journal 26, no. 2 (1980): 334. https://doi.org/10.1002/aic.690260241.\n\n\nRosen, J. B. “General Numerical Solution for Solid Diffusion in Fixed Beds.” Industrial & Engineering Chemistry 46, no. 8 (1954): 1590–94. https://doi.org/10.1021/ie50536a026.\n\n\n———. “Kinetics of a Fixed Bed System for Solid Diffusion into Spherical Particles.” The Journal of Chemical Physics 20, no. 3 (1952): 387–94. https://doi.org/10.1063/1.1700431.\n\n\nRousseau, Ronald W., ed. Handbook of Seperation Process Technology. Hoboken, NJ: John Wiley & Sons, 1987.\n\n\nSchumann, T. E. W. “Heat Transfer: A Liquid Flowing Through a Porous Prism.” Journal of the Franklin Institute 208, no. 3 (1929): 405–16. https://doi.org/10.1016/S0016-0032(29)91186-8.\n\n\nSchwartzberg, Henry G. “Leaching – Organic Materials.” In Handbook of Seperation Process Technology, edited by Ronald W. Rousseau. Hoboken, NJ: John Wiley & Sons, 1987.\n\n\nSeader, J. D., Ernest J. Henley, and D. Keith Roper. Separation Process Principles. 3rd ed. Hoboken, NJ: John Wiley & Sons, 2011.\n\n\nThomas, Henry C. “CHROMATOGRAPHY: A PROBLEM IN KINETICS.” Annals of the New York Academy of Sciences 49, no. 2 (1948): 161–82. https://doi.org/10.1111/j.1749-6632.1948.tb35248.x.\n\n\nVaca Guerra, Mauricio, Yogesh M. Harshe, Lennart Fries, James Payan Lozada, Aitor Atxutegi, Stefan Palzer, and Stefan Heinrich. “Modeling the Extraction of Espresso Components as Dispersed Flow Through a Packed Bed.” Journal of Food Engineering 368 (2024): 111913. https://doi.org/10.1016/j.jfoodeng.2023.111913." + }, + { + "objectID": "posts/sizing_a_gooseneck_example/index.html", + "href": "posts/sizing_a_gooseneck_example/index.html", + "title": "Compressible Flow Example - Sizing a Goose Neck Vent", + "section": "", + "text": "When determining the required venting for above ground storage tanks it is typical to calculate normal and emergency vent rates using standards such as API 2000, which gives the venting as an equivalent flow rate of air at standard state. Most off-the-shelf vents are sized in terms of pressure drop and SCFH through it, so after calculating the required venting one can simply buy a vent with the needed characteristics.\nIt’s not uncommon, though, for tanks to have goose-neck vents constructed from piping. This is fairly normal for tanks holding water, or other nonvolatile substances, where the tank is open to atmosphere and there are no dangerous vapours that need to be managed. The goose-neck itself is merely to keep rain, and wildlife, out of the tank.\nSizing the goose-neck to match the required venting involves performing some simple compressible flow calculations, which is fairly straightforward to set up in a generalized way such that, beyond this motivating example, it can be extended to lots of other problems. Though it isn’t at all uncommon, in this particular case, for the flow calculations to be done assuming an incompressible fluid as, over the length of the goose-neck, the pressure drop is typically slight and the compressibility of the gas (air usually) is not important.\nWith that in mind, I am going to work through the problem in stages of escalating complexity, very likely the most complicated (fanno flow) is overkill for this specific example but it’s worth putting it all down as the same tools can be used for compressible flow calculations through many piping situations." + }, + { + "objectID": "posts/sizing_a_gooseneck_example/index.html#the-scenario", + "href": "posts/sizing_a_gooseneck_example/index.html#the-scenario", + "title": "Compressible Flow Example - Sizing a Goose Neck Vent", + "section": "The Scenario", + "text": "The Scenario\nSuppose an atmospheric storage tank with a normal venting requirement, as calculated from API 2000 or the like, of 200×10³ SCFH and a max pressure of 1 psig. We wish to design a goose-neck vent that can handle that level of venting. Suppose that the goose-neck design we have in mind is a vertical length of pipe extending up from the tank roof, two 90° bends, and an exit covered with a mesh screen (to keep birds from nesting in it, yes this is a thing). The goose-neck is a constant diameter of pipe throughout.\nFor notation, the flow begins at the pipe entrance (1) and ends at the exit (2).\n\n\n\n\n\n\nFigure 2: A sketch of the goose neck vent.\n\n\n\nFor the pipe bends the bend radius to diameter ratio needs to be specified, I’m going to suppose \\(r/D = 1.5\\). Another important parameter is the pipe roughness, \\(\\epsilon\\), which for commercial steel is \\(\\epsilon = 0.0457 \\mathrm{mm}\\).1 At this point I could specify a length for the straight section of pipe, a fixed height above the tank roof that is independent of the final chosen diameter of the piping, or I could fix a design and scale the whole vent up and down as required. For simplicity I am going to assume 3ft of piping.\n1 Tilton, “Fluid and Particle Dynamics,” 6–10.At this point I am going to set up the equations with no knowledge of what the final pipe diameter \\(D\\) will be, then numerically solve for the minimum diameter that meets the requirements. The actual diameter will be the next largest NPS size pipe.\n\nusing Unitful: @u_str, uconvert, ustrip, upreferred\n\n# Setting up some convenient unit conversions\nSCFH = uconvert(u\"m^3/s\", 1u\"ft^3/hr\")\npsi = uconvert(u\"Pa\", 1u\"psi\")\nft = uconvert(u\"m\", 1u\"ft\")\ninch = uconvert(u\"m\", 1u\"inch\")\nmm = uconvert(u\"m\", 1u\"mm\")\n\n# Given in the scenario, now converted to SI\nQ = 200e3SCFH\npₐ = 14.696psi\npₘₐₓ = 1psi + pₐ\nL = 3ft\nϵ = 0.0457mm;\n\nI am assuming, for simplicity, that ambient conditions are standard conditions.\n\n# Universal gas constant to more digits than are at all necessary\nR = 8.31446261815324u\"Pa*m^3/mol/K\"\n\n# Standard conditions, 15°C \nTₐ = 288.15u\"K\"\n\n# Some useful physical properties of air\nMw = 0.02896u\"kg/mol\" # Molar weight of air, from Perry's\nk = 1.4 # Ratio of heat capacities, Cp/Cv, ideal gas\n\n# density of air, ideal gas law, kg/m^3\nρ(p, T) = (p * Mw)/(R * T)\n\n# viscosity of air, from Perry's\nμ(T) = 1u\"Pa*s\"*(1.425e-6*ustrip(u\"K\",T)^0.5039)/(1 + 108.3/ustrip(u\"K\",T));\n\n\nFrictional head loss\nRegardless of the method of performing the compressible flow calculations, the frictional head loss in the piping needs to be accounted for. I am using the K factor method as it is convenient and K factors are tabulated for most everything in references such as Crane’s TP-410. One thing to be very careful with is the difference between the Darcy and Fanning friction factors. I am using Crane’s where everything is in terms of the Darcy friction factor, which is 4× the Fanning friction factor, but Perry’s defaults to the Fanning friction factor.\nFrom Crane’s I have the following K factors for each piece of the goose-neck2\n2 Crane, TP410M Flow of Fluids, A–30.\nEntrance - \\(K_1 = 0.5\\)\nVertical Pipe - \\(K_2 = f \\frac{L}{D}\\)\nFirst 90° bend - \\(K_3 = 14 f_T\\)\nSecond 90° bend - \\(K_4 = 14 f_T\\)\nMesh screen - \\(K_5 = f_T\\)\nExit to atmosphere - \\(K_6 = 1.0\\)\n\nWhere \\(f\\) is the Darcy friction factor, \\(f_T\\) is the turbulent friction factor. I am assuming the entrance to the vent is sharp edged, and the K factors for the bends are for bends with \\(r/D = 1.5\\).\nFor some notational convenience I am going to define the relative roughness \\(\\kappa = { \\epsilon \\over D }\\) and the reduced length \\(l = {L \\over D}\\) so that, along with the Reynolds number \\(Re\\), the K factors are in terms of dimensionless numbers only.\nThe Darcy friction factor generally depends on which regime the flow is in, laminar, transitional, or turbulent. Since I don’t want to be referring to a Moody diagram I want to use a single equation that operates over a wide range of Reynolds numbers and potentially in laminar and transitional flow regimes since I don’t know a priori what the flow in the vent will be. There are equations like the Serghide correlation or Churchill correlation that attempt to fit a Moody diagram but in a more convenient to use manner.\n\n\n\n\n\n\n\n\nFigure 3: A Moody diagram with the Serghide correlation and Churchill correlation overlaid.\n\n\n\n\n\nThe black line conservatively takes the max of either the laminar or turbulent friction factor in the transitional region \\(2100 \\le Re \\le 4000\\). The Churchill correlation fits the general curve well for both the turbulent and laminar region, and provides reasonable values in the transitional region.\nThe Churchill correlation is3\n3 Tilton, “Fluid and Particle Dynamics,” 6–11. The equation here is given in terms of the darcy friction factor.\\[ f = 8 \\left( \\left( \\frac{8}{Re} \\right)^{12} + { 1 \\over {\\left( A+B \\right)^{3/2} } } \\right)^{1/12} \\]\n\\[ A = \\left( 2.457 \\ln\\left( {1 \\over {\\left( \\frac{7}{Re} \\right)^{0.9} + 0.27\\kappa} } \\right) \\right)^{16} \\]\n\\[ B = \\left( \\frac{37530}{Re} \\right)^{16} \\]\nThe turbulent friction factor is the friction factor at fully turbulent flow, when f is no longer dependent upon the Reynolds number.\n\\[ f_T = { 0.25 \\over { \\left( \\log \\left( \\kappa \\over 3.7 \\right) \\right)^2 } } \\]\nWith these defined I can write a function that gives \\(\\sum_j K_j\\) for any \\(\\kappa\\), l, and Re\n\nfunction f(κ, Re)\n A = (2.457 * log(1/((7/Re)^0.9 + 0.27*κ)))^16\n B = (37530/Re)^16\n return 8*((8/Re)^12 + 1/(A+B)^(3/2))^(1/12)\nend\n\nfT(κ) = 0.25/log10(κ/3.7)^2\n\nΣK(κ,l,Re) = 0.5 + f(κ,Re)*l + 14*fT(κ) + 14*fT(κ) + fT(κ) + 1.0;\n\n\n\nThe Reynolds number\nSince the vent has a constant cross sectional area, the mass velocity, \\(G\\), is constant throughout\n\\[ G = \\frac{4 \\dot{m} }{\\pi D^2} \\]\nWhere \\(\\dot{m}\\) is the mass flow rate in kg/s flowing through the vent, which is \\[\\dot{m} = Q_{std} \\cdot \\rho \\left(p_{std}, T_{std} \\right) \\] with \\(Q\\) the flow at standard conditions and \\(\\rho\\) the density at standard conditions.\nNote The required vent flow is given in SCFH, this is not temperature or pressure dependent and \\(\\dot{m}\\) is a constant. If the flow given was a true volumetric flow rate, then \\(\\dot{m}\\) would be a function of temperature and pressure in general. This is one of those things that routinely snags inexperienced engineers as a flow in terms of a standard volume looks like a volumetric flow rate, it’s in units of volume, but really isn’t one since the temperature and pressure are set to standard state by definition.\nThe Reynolds number in terms of the mass velocity is\n\\[ Re = \\frac{G D}{\\mu} \\]\nThe only parameter in the Reynolds number which is not a constant is the viscosity, \\(\\mu\\), which is mostly dependent upon temperature and not pressure. So, to a very good first approximation, the Reynolds number is only a function of temperature. Which is very convenient.\n\n# mass flow, recall Q is at standard state so is not a function of \n# temperature or pressure\nm = Q*ρ(pₐ, Tₐ)\n\n# mass velocity\nG(D) = (4*m)/(π*D^2)\n\n# Reynold's number\n# upreferred promotes derived units to combos of base units\n# e.g. converts Pa -> kg/m/s^2\nRe(D, T) = upreferred(G(D)*D/μ(T));" + }, + { + "objectID": "posts/sizing_a_gooseneck_example/index.html#incompressible-flow", + "href": "posts/sizing_a_gooseneck_example/index.html#incompressible-flow", + "title": "Compressible Flow Example - Sizing a Goose Neck Vent", + "section": "Incompressible Flow", + "text": "Incompressible Flow\nFor problems, like this vent, where the pressure drop is expected to be small it is not unreasonable to assume the flow is approximately incompressible. This is very often what is done since, typically, it does not require any iterative methods and one can solve for incompressible flow directly. It is also useful to do even if one plans on performing a more complete compressible flow calculation, since this provides something of a sanity check and can be a good place to start compressible flow iterative calculations, i.e. the initial guess is from the incompressible case\nTo check whether or not the incompressible assumption is reasonable, consider the ratio of density inside the tank (at the max allowable pressure) to the density outside the tank, assuming ambient temperature. For an ideal gas this is\n\\[ { \\rho_1 \\over \\rho_2 } = { {p_1 Mw} \\over {R T_a} } { {R T_a} \\over {p_2 Mw} } = \\frac{p_1}{p_2} = \\frac{p_{max} }{p_a}\\]\n\npₘₐₓ/pₐ\n\n1.0680457267283614\n\n\nTypically flows are considered incompressible if the density varies by less than ~5-10%, so this example (where the density varies by ~7%) is right in that range. You could justify it either way and it’s more a function of how accurate the calculations need to be. Since, ultimately, we are solving for the pipe diameter and choosing the next largest pipe size it’s probably fine to use an incompressible flow assumption. If anything the incompressible flow assumption will overestimate the pressure drop and thus lead to an oversized pipe (erring on the side of caution)\nThe mechanical energy balance for an incompressible fluid is4\n4 Tilton, 6–16.\\[ p_1 - p_2 = \\alpha_2 \\frac{\\rho v_2^2}{2} - \\alpha_1 \\frac{\\rho v_1^2}{2} + \\rho g \\left( h_2 - h_1 \\right) + \\sum_j K_j \\frac{\\rho v^2}{2} \\]\nWith the following simplifications + given the assumption of incompressible flow and a vent with a constant cross-sectional area \\(v_1 = v_2 = v\\), + the flow is uniform throughout \\(\\alpha_1 = \\alpha_2 = 1.0\\) + the contribution due to hydro-static pressure is negligible as the gas density is very small, \\(\\rho g \\left( h_2 - h_1 \\right) \\approx 0\\)\nThis becomes\n\\[ p_1 - p_2 = \\sum_j K_j \\frac{\\rho v^2}{2} \\]\nWhere the velocity, \\(v\\) is\n\\[ v = \\frac{Q}{A} = { Q \\over { \\frac{\\pi}{4} D^2 } } \\]\nWhich can be solved algebraically for \\(D\\), where \\(\\rho\\) is taken at the average pressure. That said it is easier to solve it numerically.\n\nusing Roots: find_zero, Brent\n\nv(D) = Q / ((π/4)*D^2)\n\nDᵢₙ(Dₗ, Dᵤ) = find_zero( \n (D) -> pₘₐₓ - pₐ - 0.5*ΣK(ϵ/D,L/D,Re(D,Tₐ))*ρ((pₘₐₓ + pₐ)/2, Tₐ)*v(D)^2,\n (Dₗ, Dᵤ), # Lower and upper bracket of the root\n Brent() ) # Solve using Brent's method\n\nD0 = Dᵢₙ(4inch, 12inch)\n\nuconvert(u\"inch\", D0)\n\n6.497118827423374 inch\n\n\nAt this point one would typically stop for this example, compressible flow calculations are probably unnecessary." + }, + { + "objectID": "posts/sizing_a_gooseneck_example/index.html#compressible-flow", + "href": "posts/sizing_a_gooseneck_example/index.html#compressible-flow", + "title": "Compressible Flow Example - Sizing a Goose Neck Vent", + "section": "Compressible Flow", + "text": "Compressible Flow\nIn general compressible flow situations can be very difficult to solve, since the density of the working fluid is a function of the pressure and temperature and the pressure and temperature are varying throughout, which means heat transfer must also be accounted for in some way. There are several key simplifying assumptions that take this from the sort of problem solved with CFD to something actually quite simple. The first is to assume an ideal gas, the second is to examine two extreme cases of heat transfer: isothermal flow and adiabatic flow.\nAt these two extreme cases, the first where heat transfer is instantaneous and the second where it doesn’t occur at all, provide bounds on the problem.\n\nIsothermal Compressible Flow\nIsothermal compressible flow of an ideal gas is fairly straight forward. As already mentioned the Reynolds number depends only on temperature, which is constant by definition, so the Reynolds number is a constant. This means the frictional head loss is also constant throughout, and it is a simple matter to calculate the pressure drop.\nThe assumption that the flow is isothermal is very reasonable in this case. We are assuming normal venting from a tank at thermal equilibrium with it’s surroundings, that is that the air flowing through the vent starts and ends in reservoirs of equal temperature. As gases expand the temperature decreases but the pressure drop across the vent is small so this effect should be negligible.\nA quick check is to estimate the ratio of temperatures at the start and end of the vent assuming a friction-less adiabatic expansion\n\\[ p_1^{1-k} T_1^k = p_2^{1-k} T_2^k = \\mathrm{const}\\]\n\\[ \\frac{T_2}{T_1} = \\left( p_1 \\over p_2 \\right)^{ {1-k} \\over k}\\]\n\n(pₘₐₓ/pₐ)^((1-k)/k)\n\n0.9813670503935878\n\n\nSo we expect even in the most extreme case the temperature change is ~2%, justifying the assumption that the venting is isothermal.\nThe isothermal flow of an ideal gas going through a length of piping is5\n5 Tilton, 6–23. This equation also neglects changes in elevation.\\[ p_{1}^{2} = G^{2} \\frac{RT}{Mw} \\left[ \\sum \\limits_{j} K_{j} + 2\\ln \\frac{p_{1} }{p_{2} } \\right] + p_{2}^{2} \\]\nIf we assume the system is at thermal equilibrium with the outside air, then \\(T = T_a\\) and \\(p_2 = p_a\\)\nThe only unknown is p1, which can be solved for numerically.\n\npᵢₜ(G,κ,l,Re) = find_zero(\n p -> p^2 - G^2 * (R*Tₐ/Mw) * (ΣK(κ,l,Re) + 2*log(p/pₐ)) - pₐ^2, \n pₘₐₓ); # initial guess\n\nAt this point we can write a simple function to solve for the minimum diameter that meets our requirement that \\(p_1 \\le p_{max}\\).\n\nDᵢₜ(Dₗ, Dᵤ) = find_zero( \n D -> pₘₐₓ - pᵢₜ( G(D), ϵ/D, L/D, Re(D, Tₐ)), \n (Dₗ, Dᵤ), # Lower and upper bracket of the root\n Brent() ) # Solve using Brent's method\n\nD1 = Dᵢₜ(4inch, 12inch)\n\nuconvert(u\"inch\", D1)\n\n6.491472166277518 inch\n\n\n\n\nAdiabatic (Fanno) Flow\nAdiabatic flow of an ideal gas through a pipe, also called Fanno flow, is somewhat more difficult than isothermal flow – there are more steps in the iterative solution as the temperature along the length of the vent changes and thus the Reynolds number changes. The general process starts by assuming a constant friction factor, calculating the pressure and temperature changes due to the adiabatic expansion of an ideal gas, adjusting the friction factor for the temperature change, and iterating until everything converges.\nThere are a few ways of setting up the calculations. We could assume the gas exits at ambient conditions – both ambient temperature and pressure – or assume the tank starts at thermal equilibrium with the environment but at a higher pressure and the gas exits at ambient pressure and some other temperature – less than ambient due to adiabatic expansion. The first set of assumptions is in some ways easier to calculate, but the second set of assumptions is more physically realistic, and consistent with the assumptions made when solving the isothermal case.\nOne thing we should check before proceeding is whether or not the flow will be choked, essentially will the flow velocity reach \\(Ma = 1\\), the following discussion assumes flow remains subsonic, and this is easy to check. The critical pressure, at which flow becomes sonic, is given by6\n6 Tilton, 6–23.\\[ { p^o \\over p_1 } = \\left(2 \\over k+1 \\right)^{k \\over {k-1} } \\]\nwith the criteria that flow is subsonic if\n\\[ { p_2 \\over p_1 } > { p^o \\over p_1 } \\]\n\n(pₐ / pₘₐₓ) > (2 / (k+1))^(k/(k-1))\n\ntrue\n\n\nThe basic relation of Fanno flow that drives the equations is the relationship between the Fanno parameter and the Mach number7\n7 Tilton, 6–24.\\[ Fa = \\left( \\frac{fL^{*} }{D} \\right) = \\frac{1 - Ma^{2} }{kMa^{2} } + \\frac{k+1}{2k} \\ln \\left( \\frac{ \\left( k+1 \\right) Ma^{2} }{ 2+\\left( k+1 \\right) Ma^{2} } \\right) \\]\nWhere I am defining \\(Fa\\) to be the Fanno parameter. The Fanno parameter is calculated from some point in the flow path through to the critical point, where flow goes sonic. The critical point can be a hypothetical point, assuming the pipe is infinite, or it can be real. In this case I am assuming the flow within the vent will remain subsonic.\nIt is worth noting that elbows near the exit of a pipe may choke the flow even though the \\(Ma < 1\\) due to the nonuniform velocity profile in the elbow. By the design of this goose-neck we know this is the case and should keep that in mind when evaluating the results.\nFor two points along a pipe, 1 and 2, the difference between their Fanno parameters is the frictional loss between those two points8\n8 Tilton, 6–24.\\[ Fa_1 - Fa_2 = \\sum_{j} K_{j} \\]\nWhere the \\(K_j\\) are usually evaluated at the average temperature \\({ {T_1 + T_2} \\over 2}\\).\nThe Mach number at some point i along the pipe, for an ideal gas, is given by9\n9 Derived for an ideal gas:\n\\[ G = \\rho v = { {p Mw} \\over {R T} } v\\]\n\\[ c = \\sqrt{ {k R T} \\over Mw } \\]\n\\[ Ma = { v \\over c } = G { {R T} \\over {p Mw} } \\sqrt{ Mw \\over {k R T} } = \\frac{G}{p} \\sqrt{ \\frac{RT}{kMw} }\\]\\[ Ma_{i} = \\frac{v}{c} = \\frac{G}{p_{i} } \\sqrt{ \\frac{RT_{i} }{kMw} } \\]\nand the pressure can be calculated given a Mach number by rearranging\n\\[ p_{i} = \\frac{G}{Ma_{i} } \\sqrt{ \\frac{RT_{i} }{kMw} } \\]\nand for any two points along the pipe the temperatures are related by10\n10 Tilton, “Fluid and Particle Dynamics” equation 6-116. Taking two points and cancelling out the stagnation temperature.\\[ T_{1} = T_{2} \\frac{2 + \\left( k-1 \\right) Ma_{2}^{2} }{2 + \\left( k-1 \\right) Ma_{1}^{2} } \\]\nPutting all of this together, the procedure for adiabatic ideal gas flow through piping with a given diameter \\(D\\) is:\n\nGiven \\(G\\) calculate \\(Ma_2\\) at ambient conditions, this is the initial guess for the exit conditions\nCalculate \\(\\sum_j K_j\\) at ambient conditions, this is the initial guess for the frictional loss\nCalculate \\(Fa_2\\) with \\(Ma_2\\)\nCalculate \\(Fa_1\\) from \\(Fa_2\\) and \\(\\sum_j K_j\\)\nSolve for \\(Ma_1\\) given \\(Fa_1\\), this is done numerically\nSolve for \\(T_2\\) given \\(Ma_1\\) and letting \\(Ma_2\\) vary with temperature, this is done numerically\nRecalculate \\(Ma_2\\) given \\(T_2\\) and \\(\\sum_j K_j\\) at the average temperature \\({ {T_1 + T_2} \\over 2}\\) and repeat from step 3\nContinue to iterate until \\(p_1\\) stops changing\n\nWhile that looks complicated, each step is fairly easy. In my experience, with subsonic flow, this converges very quickly.\n\n# Fanno parameter\nFa(Ma) = ((1-Ma^2)/(k*Ma^2)) + ((k+1)/(2k))*log( ((k+1)*Ma^2) / (2 + (k+1)*Ma^2))\n\n# Mach number\n# upreferred(...) ensures units cancel appropriately and the Ma is unitless\nMa(G, p, T) = upreferred((G/p)*√((R*T)/(k*Mw))) \n\n\nT2(T₁, Ma₁, G, p) = find_zero(\n T -> (2 + (k-1)*Ma₁^2)*T₁ - (2 + (k-1)*Ma(G, p, T)^2)*T,\n T₁) # Use the isothermal case as an initial guess\n\n\nfunction pfa(D)\n # pre-calculating diameter dependent variables\n Gᵢ = G(D)\n κᵢ = ϵ/D\n lᵢ = L/D\n\n # initial values\n T₂ = Tₐ\n Ma₁ = Ma(Gᵢ, pₐ, Tₐ)\n p₁ⁿᵉʷ = uconvert(u\"Pa\",(Gᵢ/Ma₁)*√((R*Tₐ)/(k*Mw)))\n \n # loop until the error is below the given tolerance, but don't loop forever!\n err, i = 1.0, 0\n rtol, max_count = 1e-9, 1e5\n while (err > rtol) && (i < max_count)\n # Starting up\n Tₐᵥ = 0.5*(Tₐ + T₂)\n Reᵢ = Re(D,Tₐᵥ)\n Ma₂ = Ma(Gᵢ, pₐ, T₂)\n \n # Steps 3 - 6\n Fa₂ = Fa(Ma₂)\n Fa₁ = Fa₂ + ΣK(κᵢ,lᵢ,Reᵢ)\n Ma₁ = find_zero(x -> Fa₁ - Fa(x), (Ma₂ + Ma₁)/2)\n T₂ = T2(Tₐ, Ma₁, Gᵢ, pₐ)\n \n # Check if pressure has converged\n p₁ᵒˡᵈ = p₁ⁿᵉʷ\n p₁ⁿᵉʷ = uconvert(u\"Pa\",(Gᵢ/Ma₁)*√((R*Tₐ)/(k*Mw)))\n err = abs(p₁ⁿᵉʷ - p₁ᵒˡᵈ)/p₁ᵒˡᵈ\n i += 1\n end\n \n # if the loop failed to converge, let me know\n if i >= max_count\n error_msg = \"iterations exceeded max count, remaining error is $err\"\n error(error_msg)\n end\n \n return p₁ⁿᵉʷ\nend;\n\n\nDfa(Dₗ, Dᵤ) = find_zero( \n D -> pₘₐₓ - pfa(D), \n (Dₗ, Dᵤ), # Lower and upper bracket of the root\n Brent() ) # Solve using Brent's method\n\nD2 = Dfa(4inch, 12inch)\n\nuconvert(u\"inch\", D2)\n\n6.485474802835819 inch\n\n\n\n\n\n\n\n\nNoteUpdate\n\n\n\nThe method given here is from Perry’s and, while it works, is an awkward way of calculating the flow from a given pressure drop. A better method, adapted from Coulson and Richardson’s is presented here." + }, + { + "objectID": "posts/sizing_a_gooseneck_example/index.html#minimum-diameter", + "href": "posts/sizing_a_gooseneck_example/index.html#minimum-diameter", + "title": "Compressible Flow Example - Sizing a Goose Neck Vent", + "section": "Minimum Diameter", + "text": "Minimum Diameter\nAt this point we have solved for the minimum vent diameter in three different ways and, more or less, got the same answer three times. The minimum diameter is ~6.4in ID for all cases and the next largest standard pipe size is 8in so regardless of the method, in this particular example, one arrives at the same final answer.\nIn general the incompressible model will always overestimate the pressure drop across the vent, leading to a larger vent size, and the adiabatic flow will provide an underestimate, the true minimum would be somewhere between the two. This is seen much more clearly at vent diameters less than ~5in where the pressure drop is more significant, more analogous to relief piping for a pressure vessel than venting for an atmospheric storage tank. Of course all of this is assuming flow remains subsonic, if the pressure drop leads to sonic flow then things are quite different.\n\n\n\n\n\n\n\n\nFigure 4: Pressure drop versus vent diameter for the three models explored." + }, + { + "objectID": "posts/sizing_a_gooseneck_example/index.html#concluding-remarks", + "href": "posts/sizing_a_gooseneck_example/index.html#concluding-remarks", + "title": "Compressible Flow Example - Sizing a Goose Neck Vent", + "section": "Concluding Remarks", + "text": "Concluding Remarks\nOften things like compressible flow can be intimidating since these problems, even in the simplified ideal gas case, require iterative solutions and often iterative solutions within iterative solutions. However, once the basic pieces are set up, compressible flow can be fairly simple to deal with. There are some pitfalls here that, if one wanted to create a nice generalized set of code, would have to be dealt with.\nThe big one being all the find_zero() calls that rely on the initial guess being a good one, or the bracketed values actually bracketing the answer. It’s more than possible to supply a terrible initial guess, especially for pipe diameter, and have the root solver fail outright. Adding some code to check that guesses are within the domains of functions would be a start, e.g. catching attempts to take log(0) and returning -Inf or something to ensure that the root-finding algorithms respect function domains. This also presents an opportunity to generate better default values, programmatically, prior to solving. As opposed to me just picking reasonable numbers off the top of my head and having everything work out because I’m lucky.\nRelatedly there is a lot of room to fiddle around with which root finding algorithm is employed." + }, + { + "objectID": "posts/sizing_a_gooseneck_example/index.html#references", + "href": "posts/sizing_a_gooseneck_example/index.html#references", + "title": "Compressible Flow Example - Sizing a Goose Neck Vent", + "section": "References", + "text": "References\n\n\nCrane. TP410M Flow of Fluids. Stamford, CT: Crane, 2013.\n\n\nGreen, Don W., ed. Perry’s Chemical Engineers’ Handbook. New York: McGraw Hill, 2007.\n\n\nTilton, James N. “Fluid and Particle Dynamics.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green. New York: McGraw Hill, 2007." + }, + { + "objectID": "posts/vessel_blowdown_dispersion/index.html", + "href": "posts/vessel_blowdown_dispersion/index.html", + "title": "Vessel Blowdown and Dispersion", + "section": "", + "text": "I was thinking, recently, about how venting from vessel blowdown is modelled for screening purposes and how, more often than not, it does not take into account the blowdown curve. This is something that could easily be incorporated for simple Gaussian dispersion, which is what I examine here." + }, + { + "objectID": "posts/vessel_blowdown_dispersion/index.html#background", + "href": "posts/vessel_blowdown_dispersion/index.html#background", + "title": "Vessel Blowdown and Dispersion", + "section": "Background", + "text": "Background\nThe standard approach for assessing the consequences of a release from a pressure vessel is to:1\n1 Center for Chemical Process Safety, Guidelines for Consequence Analysis of Chemical Releases, 11.\nIdentify the source model (gas, liquid, aerosol)\nCalculate the mass release rate\nModel the dispersion of the release\n\nThe 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.\n2 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.\n\nIsothermal Blowdown\nRecalling the isothermal blowdown of an ideal gas, the mass release rate, \\(w\\), is given by\n\\[\nw\\left(t\\right) = w_0 \\exp \\left( - \\frac{t}{\\tau} \\right)\n\\]\nWhere4\n4 This follows from the definition of \\(\\tau\\): \\[ \\tau = {m_0 \\over w_0} \\] \\[ w_0 = {m_0 \\over \\tau} \\] \\[ w_0 = { {\\rho_0 V} \\over \\tau }\\]\\[\nw_0 = { {\\rho_0 V} \\over \\tau }\n\\]\nFor a blowdown through an isentropic nozzle the time constant \\(\\tau\\) is given by\n\\[\n\\frac{1}{\\tau} = \\frac{c_D A}{V} \\sqrt{ {k P_0} \\over \\rho_0 } \\left( 2 \\over {k+1} \\right)^{\\frac{k+1}{2 \\left( k - 1 \\right)} }\n\\]\nWith:\n\n\\(c_D\\) – the discharge coefficient for the blowdown\n\\(A\\) – the flow area of the orifice through which the blowdown is happening (e.g. a PSV)\n\\(V\\) – the total volume of the vessel\n\\(k\\) – the isentropic expansion factor, which for an ideal gas is the ratio of specific heats \\(\\frac{c_p}{c_v}\\)\n\\(P_0\\) – the initial pressure in the vessel\n\\(\\rho_0\\) – the initial density of the gas in the vessel\n\n\n\nThe Single Puff Model\nFor a release centred at the origin with an elevation h, the concentration profile for a single Gaussian puff is given by:5\n5 Center for Chemical Process Safety, Guidelines for Consequence Analysis of Chemical Releases, 90–91.\\[\nc \\left(x,y,z,t \\right) = w \\Delta t \\cdot g_x(x, t) \\cdot g_y(y) \\cdot g_z(z)\n\\]\nWhere the gs are Gaussian functions in the x, y, and z directions\n\\[\ng_x(x,t) = {1 \\over \\sqrt{2\\pi} \\sigma_x } \\exp \\left( -\\frac{1}{2} \\left( x-u t \\over \\sigma_x \\right)^2 \\right)\n\\]\n\ngx(x,t,u,σx) = exp(-0.5*((x-u*t)/σx)^2)/(√(2π)*σx)\n\n\\[\ng_y(y) = {1 \\over \\sqrt{2\\pi} \\sigma_y } \\exp \\left( -\\frac{1}{2} \\left( y \\over \\sigma_y \\right)^2 \\right)\n\\]\n\ngy(y,σy) = exp(-0.5*(y/σy)^2)/(√(2π)*σy)\n\n\\[\ng_z(z) = {1 \\over \\sqrt{2\\pi} \\sigma_z } \\left[ \\exp \\left( -\\frac{1}{2} \\left( z-h \\over \\sigma_z \\right)^2 \\right) + \\exp \\left( -\\frac{1}{2} \\left( z+h \\over \\sigma_z \\right)^2 \\right) \\right]\n\\]\n\ngz(z,h,σz) = ( exp(-0.5*((z-h)/σz)^2)\n + exp(-0.5*((z+h)/σz)^2))/(√(2π)*σz)\n\nWith:\n\n\\(w\\) – the constant mass release rate\n\\(\\Delta t\\) – the duration of the release\n\\(u\\) – the uniform windspeed (acting only in the x direction)\n\\(\\sigma\\)s – the dispersion parameters.\n\nFor 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.\n6 Center for Chemical Process Safety, 90.\n# Puff dispersion parameters for Class D atmospheres\nσx(xc) = 0.06*xc^0.92\nσy(xc) = 0.06*xc^0.92\nσz(xc) = 0.15*xc^0.70\n\nI 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.\nimport Pkg\nPkg.add(url=\"https://github.com/aefarrell/UnitfulCorrelations.jl\")\n\nusing Unitful\nusing UnitfulCorrelations\n\n\n@ucorrel σx u\"m\" u\"m\"\n@ucorrel σy u\"m\" u\"m\"\n@ucorrel σz u\"m\" u\"m\"\n\nA 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.\n\nstruct Puff\n m # mass\n h # release height\n u # velocity\n t # release time\nend\n\nNow 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.\nSince 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.\n\nfunction c(p::Puff,x,y,z,t)\n λ = t - p.t # time since release\n xc = p.u*λ # location of cloud center\n if λ > 0t \n return p.m*gx(x,λ,p.u,σx(xc))*gy(y,σy(xc))*gz(z,p.h,σz(xc))\n else # the puff hasn't been released yet\n return 0*unit(p.m)/unit(xc)^3\n end\nend\n\n\n\nThe Multi-Puff Model\nThe 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.\n\\[\nc(x,y,z,t) = \\sum_{i=0}^{n} w\\left( t_i \\right) \\delta t \\cdot g_x(x, t - t_i ) \\cdot g_y(y) \\cdot g_z(z)\n\\]\nWhere \\(\\delta t\\) is the duration of each puff and \\(t_i\\) is the time when puff i was released.\n\nc(ps::Vector{Puff},x,y,z,t) = sum( c.(ps, x, y, z, t) );\n\nTaking the limit \\(\\delta t \\to 0\\) takes this from a discrete sum to the corresponding integral\n\\[\nc(x,y,z,t) = \\int_{0}^{t} w\\left( t^{\\prime} \\right) \\cdot g_x(x, t - t^{\\prime}) \\cdot g_y(y) \\cdot g_z(z) dt^{\\prime}\n\\]\nFor the Palazzi7 model \\(w(t) = w_0 \\left( H\\left(t - \\Delta t \\right) - H\\left( t \\right) \\right)\\)8 and, assuming the \\(\\sigma\\)s are independent of time, this can be integrated to give:\n7 Palazzi et al., “Diffusion from a Steady Source of Short Duration.”8 \\(H \\left(t \\right)\\) being the Heaviside function\\[\nc(x,y,z,t) = \\frac{w_0}{2u} \\left( \\mathrm{erf}\\left({ {x - u (t-\\Delta t)} \\over \\sqrt{2} \\sigma_x }\\right) - \\mathrm{erf}\\left( { {x - u t} \\over \\sqrt{2} \\sigma_x } \\right) \\right) \\cdot g_y(y) \\cdot g_z(z)\n\\]\n\nusing SpecialFunctions: erf, erfc\n\n\nstruct Palazzi\n w # mass release rate\n h # release height\n u # velocity\n t_f # end of release\nend\n\n\nfunction c(p::Palazzi,x,y,z,t)\n Δt = min(t, p.t_f)\n w, u = p.w, p.u\n xa = u*(t-Δt)\n xb = u*t\n # n.b. erf(b,a) = erf(a) - erf(b)\n return (w/(2u))*erf((x-xb)/(√2*σx(xb)), (x-xa)/(√2*σx(xa))) *\n gy(y,σy(x))*gz(z,h,σz(x))\nend" + }, + { + "objectID": "posts/vessel_blowdown_dispersion/index.html#a-blowdown-dispersion-model", + "href": "posts/vessel_blowdown_dispersion/index.html#a-blowdown-dispersion-model", + "title": "Vessel Blowdown and Dispersion", + "section": "A Blowdown Dispersion Model", + "text": "A Blowdown Dispersion Model\nIt should be pretty obvious where I am going next: instead of assuming \\(w(t)\\) 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.\n\\[\nc(x,y,z,t) = \\int_{0}^{t} w\\left( t^{\\prime} \\right) \\cdot g_x(x, t - t^{\\prime}) \\cdot g_y(y) \\cdot g_z(z) dt^{\\prime}\n\\]\n\\[\nc(x,y,z,t) = \\int_{0}^{t} \\left[ w_0 \\exp\\left( -{t^{\\prime} \\over \\tau} \\right) \\right] \\cdot \\left[ {1 \\over \\sqrt{2\\pi} \\sigma_x } \\exp \\left( -\\frac{1}{2} \\left( x-u (t - t^{\\prime}) \\over \\sigma_x \\right)^2 \\right) \\right] \\cdot g_y(y) \\cdot g_z(z) dt^{\\prime}\n\\]\nSplitting this into elements that depend on time and those that don’t \\[\nc(x,y,z,t) = w_0 \\left[ \\int_{0}^{t} {1 \\over \\sqrt{2\\pi} \\sigma_x } \\exp\\left( -{t^{\\prime} \\over \\tau} -\\frac{1}{2} \\left( x-u (t - t^{\\prime}) \\over \\sigma_x \\right)^2 \\right) dt^{\\prime} \\right] \\cdot g_y(y) \\cdot g_z(z)\n\\]\nLetting everything within the integral equal \\(I(x,t)\\)\n\\[\nc(x,y,z,t) = w_0 \\cdot I(x,t) \\cdot g_y(y) \\cdot g_z(z)\n\\]\nIt makes the integration a little easier to introduce \\(\\lambda = t-t^{\\prime}\\)\n\\[\nI(x,t) = \\int_{0}^{t} {1 \\over \\sqrt{2\\pi} \\sigma_x } \\exp\\left( -{{t - \\lambda} \\over \\tau} -\\frac{1}{2} \\left( x-u \\lambda \\over \\sigma_x \\right)^2 \\right) d\\lambda\n\\]\nBy expanding everything within the \\(\\exp(\\dots)\\), collecting terms and completing the square we arrive at:\n\\[\nI(x,t) = {1 \\over \\sqrt{2\\pi} \\sigma_x } \\exp \\left( {\\sigma_x^2 + 2 u \\tau (x - u t)} \\over {2 u^2 \\tau^2} \\right) \\int_{0}^{t} \\exp\\left( -\\left( {\\sigma_x^2 + u \\tau ( x - u \\lambda ) } \\over {\\sqrt{2} \\sigma_x u \\tau} \\right)^2 \\right) d\\lambda\n\\]\n\\[\nI(x,t) = \\frac{1}{2u} \\exp \\left( {\\sigma_x^2 + 2 u \\tau (x - u t)} \\over {2 u^2 \\tau^2} \\right) \\left[ \\mathrm{erf}\\left( {\\sigma_x^2 + u \\tau x} \\over {\\sqrt{2} \\sigma_x u \\tau} \\right) - \\mathrm{erf}\\left( {\\sigma_x^2 + u \\tau (x - u t)} \\over {\\sqrt{2} \\sigma_x u \\tau} \\right)\\right]\n\\]\nIf we evaluate the \\(\\sigma_x\\)s at the end points then, given that \\(\\sigma_x \\to 0\\) as \\(x_c \\to 0\\), this simplifies to:\n\\[\nI(x,t) = \\frac{1}{2u} \\exp \\left( {\\sigma_x^2 + 2 u \\tau (x - u t)} \\over {2 u^2 \\tau^2} \\right) \\mathrm{erfc}\\left( {\\sigma_x^2 + u \\tau (x - u t)} \\over {\\sqrt{2} \\sigma_x u \\tau} \\right)\n\\]\nGiving a final concentration of:\n\\[\nc(x,y,z,t) = \\frac{w_0}{2u} \\exp \\left( {\\sigma_x^2 + 2 u \\tau (x - u t)} \\over {2 u^2 \\tau^2} \\right) \\mathrm{erfc}\\left( {\\sigma_x^2 + u \\tau (x - u t)} \\over {\\sqrt{2} \\sigma_x u \\tau} \\right) \\cdot g_y(y) \\cdot g_z(z)\n\\]\n\nstruct IsothermalBlowdown\n w_0 # mass release rate\n τ # time constant\n h # release height\n u # velocity\n t_f # end of release\nend\n\n\nfunction c(p::IsothermalBlowdown,x,y,z,t)\n w₀, u, τ = p.w_0, p.u, p.τ\n xb = u*t\n xa = t < p.t_f ? 0*xb : u*(t-p.t_f)\n return (w₀/(2u))*\n exp( (σx(xb)^2 + 2u*τ*(x - xb))/(2*(u*τ)^2) ) *\n erf( (σx(xb)^2 + u*τ*(x - xb))/(√(2)*σx(xb)*u*τ),\n (σx(xa)^2 + u*τ*(x - xa))/(√(2)*σx(xa)*u*τ) )*\n gy(y,σy(x))*gz(z,h,σz(x))\nend\n\n\n\n\n\n\n\nNote\n\n\n\nNote that I have implemented a slightly different version of the model. In the case where \\(t < t_f\\), with \\(t_f\\) being the time at which the blowdown ceases, this simplifies to the model given above, where I implicitly assumed \\(t_f \\to \\infty\\).\nIn the case where \\(t_f\\) is some finite number and \\(t \\ge t_f\\), an extra term is added to, essentially, “turn off” the blowdown.\n\n\n\nAn Example Case\nJust 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.\n\n# The example case\nu = 2.0u\"m/s\"\nh = 2.0u\"m\"\nw₀ = 1.0u\"kg/s\"\nm₀ = 1000.0u\"kg\"\nτ = m₀/w₀\n\nThe mass release rate, per above, is simply the exponential decay.\n\nw(t) = w₀*exp(-t/τ)\n\nThe total mass released by time t is simply the time-integral:\n\\[\nm(t) = \\int_0^t w_0 \\exp \\left( -\\frac{t^{\\prime}}{\\tau} \\right) d t^{\\prime}\n\\]\n\\[\nm(t) = w_0 \\tau \\left( 1 - \\exp \\left( -\\frac{t^{\\prime}}{\\tau} \\right) \\right)\n\\]\n\nm(t) = w₀*τ*(1 - exp(-t/τ))\n\n\n\nDiscrete Puffs\nSuppose that after \\(\\tau\\) 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 \\([ 0, \\tau )\\) into \\(n\\) sub-intervals and releasing a single puff at the start of each interval i with a mass \\(m_i = w(t_i) \\delta t\\).\n\nfunction discrete_puffs(;n=100, t_0=0τ, t_f=τ)\n δt = (t_f - t_0)/(n-1)\n pfs = Vector{Puff}()\n for t_i ∈ range(t_0;stop=t_f,length=n)\n m_i = w(t_i)*δt\n pf = Puff(m_i,h,u,t_i)\n push!(pfs,pf)\n end\n return pfs\nend\n\n\npfs = discrete_puffs(n=25);\n\n\n\n\n\n\n\n\nFigure 1: The release rate for an isothermal vessel blowdown, along with the sequence of discrete puffs generated to approximate it.\n\n\n\n\nFor 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.\n\nm(pfs::Vector{Puff},t) = sum( pf.m for pf in pfs if pf.t < t );\n\n\n\nAfter 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%.\n\n\n\n\nComparing Results\nWith 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 \\(\\sigma\\)s are constant (they aren’t) and integrated that. I then took that result and substituted back in the correlations for the \\(\\sigma\\)s. The hope is that this will be close enough to the full expression that we can use it.\nFor 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.\nAnother 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 \\(w = w_0\\) and the case with a constant mass rate \\(w = w(\\tau)\\). Furthermore, we expect the blowdown case should connect the two curves with something resembling an exponential decay.\n\nbd = IsothermalBlowdown(w₀,τ,h,u,τ)\n\n\n\n\n\n\n\n\nFigure 2: The concentration profile at x=1000m, y=0m, z=2m.\n\n\n\n\nThe 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.\n\n\n\n\n\n\n\nFigure 3: The ground level concentration for the isothermal blowdown.\n\n\n\n\nThe 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." + }, + { + "objectID": "posts/vessel_blowdown_dispersion/index.html#a-note-on-sources", + "href": "posts/vessel_blowdown_dispersion/index.html#a-note-on-sources", + "title": "Vessel Blowdown and Dispersion", + "section": "A Note on Sources", + "text": "A Note on Sources\nIt 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.\nIf this paragraph is still here when you see this, and you know of a published reference for this model, please leave a comment." + }, + { + "objectID": "posts/vessel_blowdown_dispersion/index.html#references", + "href": "posts/vessel_blowdown_dispersion/index.html#references", + "title": "Vessel Blowdown and Dispersion", + "section": "References", + "text": "References\n\n\nCenter for Chemical Process Safety. Guidelines for Consequence Analysis of Chemical Releases. New York: Center for Chemical Process Safety/AIChE, 1999.\n\n\nPalazzi, E, M De Faveri, Giuseppe Fumarola, and G Ferraiolo. “Diffusion from a Steady Source of Short Duration.” Atmospheric Environment 16, no. 12 (1982): 2785–90. https://www.researchgate.net/publication/328744668_DIFFUSION_FROM_A_STEADY_SOURCE_OF_SHORT_DURATION." + }, + { + "objectID": "posts/impossible_bowling/index.html", + "href": "posts/impossible_bowling/index.html", + "title": "Impossible bowling", + "section": "", + "text": "While bowling, this week, an interesting question came up: is it possible to get every score from 1 to 450 in a game of five pin bowling? Or, to flip it around, is there a score that you can never get no matter how fancy your bowling? The answer is not immediately obvious!" + }, + { + "objectID": "posts/impossible_bowling/index.html#the-rules-of-five-pin-bowling", + "href": "posts/impossible_bowling/index.html#the-rules-of-five-pin-bowling", + "title": "Impossible bowling", + "section": "The rules of five pin bowling", + "text": "The rules of five pin bowling\nFive 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.\n\n\n\n\n\n\nFigure 1: The points value of each pin in five-pin bowling.\n\n\n\nLike 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.\nBy 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." + }, + { + "objectID": "posts/impossible_bowling/index.html#trying-everything", + "href": "posts/impossible_bowling/index.html#trying-everything", + "title": "Impossible bowling", + "section": "Trying everything", + "text": "Trying everything\nWhile 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.\nMaybe 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.\nBut 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.\n\n\n\n\n\n\nNote\n\n\n\nI 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.\nFirst 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 \\(m^n\\): \\(4^5 = 1024\\)\nThe tenth frame has some extra rules, so lets go through it:\n\n\n\n\n\n\n\n\nFrame\nNumber of Possibilities\ndescription\n\n\n\n\n? ? ?\n1024\nAny combination of the first set of 5 pins\n\n\nX ? ?\n35 -1 = 242\nEvery follow up to a strike except X–, which has already been counted in ???\n\n\nX X ?\n25 -1 = 31\nEvery follow up to 2 strikes except XX-, which has already been counted in X??\n\n\n? \\ ?\n25 × (25-1) = 992\nEvery follow up to every spare except ?\\- which were counted in ???\n\n\n\nWhich gives 2289 possible ways of bowling the 10th frame.\nSo this gives a total count of possible 5 pin bowling games of \\(2289 \\times 1024^9\\) which is about \\(2.8 \\times 10^{30}\\)" + }, + { + "objectID": "posts/impossible_bowling/index.html#nothing-fancy", + "href": "posts/impossible_bowling/index.html#nothing-fancy", + "title": "Impossible bowling", + "section": "Nothing fancy", + "text": "Nothing fancy\nThe 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.\nThis also leads to a (kinda loose) argument for why you should be able to get anything from 0 to 150 except 1 and 149:\nSuppose you are playing game with n frames and your goal is a score \\(x \\le 15 n\\).\nIf \\(x \\not \\equiv 1 (\\textrm{mod} 15)\\) and \\(x \\not \\equiv 14 (\\textrm{mod} 15)\\) then you can always get from a multiple of 15 to the final score in one frame.\nIf \\(x \\equiv 1 (\\textrm{mod} 15)\\) or \\(x \\equiv 14 (\\textrm{mod} 15)\\) 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.\nSince for any x such that \\(1 \\lt x \\le 15 (n-1)\\) there are \\(\\ge 2\\) frames remaining, all of those scores can thus be achieved.\nWhat remains is the x such that \\(15(n-1) \\lt x \\lt 15 n\\) and \\(x \\equiv 14 (\\textrm{mod} 15)\\), this single score is not achievable in a game with no strikes and spares.\nWhich is all to say there are only two impossible scores: 1 and 15n -1 or 149 in a standard ten frame game.\nI 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.\nThis 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.\n\nbasic_moves = [ n for n in range(16) if n not in [1,14] ]\nbasic_moves.reverse()\n\nThen 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\n1 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.\ndef make_moves(cur_frame, cur_score, max_frame, target, moves=basic_moves):\n if cur_frame == max_frame:\n return [0] if cur_score == target else []\n else:\n next_frame = cur_frame + 1\n mn, mx = min(moves), max(moves)\n n = max_frame - next_frame\n r = target - cur_score\n for move in filter(lambda x: n*mn <= (r-x) <= n*mx, moves):\n new_score = cur_score + move\n advance = make_moves(next_frame, new_score, max_frame, target, moves)\n if len(advance) > 0:\n advance.append(move)\n return advance\n else:\n return []\n\nLooping through all the scores: \\(0 \\le score \\le 150\\) yields the impossible to bowl scores.\n\nfor score in range(151):\n game = make_moves(0,0,10,score)\n if len(game) == 0:\n print(\"Score {0} is not possible\".format(score))\n\nScore 1 is not possible\nScore 149 is not possible\n\n\nWhich is what I expected, good news as I will be building off this general strategy for the cases where strikes and spares are included.\n\nDetour: what about with no gutters?\nAnother 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\n2 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.\n\nno_gutters = [ n for n in range(7,16) if n != 14 ]\nno_gutters.reverse()\n\n\nfor score in range(70,151):\n game = make_moves(0,0,10,score,no_gutters)\n if len(game)==0:\n print(\"Score {0} is not possible, with no gutters\".format(score))\n\nScore 149 is not possible, with no gutters\n\n\nSo 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.\nIt 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\n3 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.\n[ frame for frame in reversed(make_moves(0,0,10,100,no_gutters)) ]\n\n[15, 15, 15, 13, 7, 7, 7, 7, 7, 7, 0]" + }, + { + "objectID": "posts/impossible_bowling/index.html#sparing-no-effort", + "href": "posts/impossible_bowling/index.html#sparing-no-effort", + "title": "Impossible bowling", + "section": "Sparing no effort", + "text": "Sparing no effort\nAdding 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:\n\nwhat was scored in the frame\nwhether a strike or a spare was recorded\nwhether this is the end of the last frame (i.e am I done bowling yet?)\n\nInstead 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?\nThe 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.\n\nclass SingleFrame:\n def __init__(self, score, special=None, end=False):\n self.score = score\n self.special = special\n self.end = end\n\nInstead 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.\n\nsingle_frame_moves = [ SingleFrame(score) for score in basic_moves ]\nsingle_frame_moves.insert(0, SingleFrame(15,\"spare\"))\n\nNow 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.\n\nspares = [ 2*f+s for f in basic_moves \n for s in filter(lambda x: f+x==15,basic_moves)]\n\nspares = list(set(spares))\nspares.sort(reverse=True)\n\nnon_spares = [ 2*f+s+t for f in basic_moves\n for s in filter(lambda x: f+x<15, basic_moves)\n for t in filter(lambda x: f+s+x<=15, basic_moves) ]\n\nnon_spares = list(set(non_spares))\nnon_spares.sort(reverse=True)\n\nNow 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).\n\nspare_frame_moves = [ SingleFrame(score,\"spare\") for score in spares ]\nspare_frame_moves += [ SingleFrame(score) for score in non_spares ]\n\nlen(spare_frame_moves)\n\n42\n\n\nThere are only two scores that are not achievable in the frame following a spare: 1 and 29\n\n[ s for s in range(31) if s not in [x.score for x in spare_frame_moves] ]\n\n[1, 29]\n\n\nAdding 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\n4 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.\ndef valid_spare_moves(move, n, r, mn=0, mx=15):\n if move.special==\"spare\":\n up = (2*n + 1)*mx\n else:\n up = (1 + 2*max(n-1,0) + 1)*mx\n \n return n*mn <= (r - move.score) <= up\n\nThe 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.\n\ndef make_spare_moves(cur_frame, cur_score, max_frame, target, \n moves=single_frame_moves, was_spare=False):\n if cur_frame == max_frame:\n if cur_score == target:\n return [SingleFrame(0,False,True)]\n elif was_spare and (target - cur_score) in basic_moves:\n # extra ball in the last frame\n return [SingleFrame(target-cur_score,False,True)]\n else:\n return []\n else:\n next_frame = cur_frame + 1\n n = max_frame - next_frame\n r = target - cur_score\n for move in filter(lambda x: valid_spare_moves(x,n,r), moves):\n new_score = cur_score + move.score\n if move.special == \"spare\":\n next_moves = spare_frame_moves\n else:\n next_moves = single_frame_moves\n \n advance = make_spare_moves(next_frame, new_score, max_frame, target, next_moves, move.special==\"spare\")\n if len(advance) > 0:\n advance.append(move)\n return advance\n else:\n return []\n\nLooping through all the scores: \\(0 \\le score \\le 300\\) yields the impossible to bowl scores, when spares are allowed.\n\nfor score in range(301):\n game = make_spare_moves(0,0,10,score)\n if len(game)==0:\n print(\"Score {0} is not possible, with only spares allowed\".format(score))\n\nScore 1 is not possible, with only spares allowed\nScore 299 is not possible, with only spares allowed\n\n\nWhich 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." + }, + { + "objectID": "posts/impossible_bowling/index.html#in-striking-distance-of-the-final-answer", + "href": "posts/impossible_bowling/index.html#in-striking-distance-of-the-final-answer", + "title": "Impossible bowling", + "section": "In striking distance of the final answer", + "text": "In striking distance of the final answer\nThis 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.\n\n# all the different scores for a frame following a regular frame\n\nsingle_frame_moves = [ SingleFrame(score) for score in basic_moves ]\nsingle_frame_moves.insert(0, SingleFrame(15,\"spare\"))\nsingle_frame_moves.insert(0, SingleFrame(15,\"strike\"))\n\nlen(single_frame_moves)\n\n16\n\n\nSimilarly 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.\n\n# all the different scores for a frame following a spare\n\nspare_frame_moves = [ move for move in spare_frame_moves if move.score !=30 ]\nspare_frame_moves.insert(0, SingleFrame(30,\"strike\"))\n\nlen(spare_frame_moves)\n\n42\n\n\nFollowing 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.\n\n# all the different scores for a frame following a single strike\n\nnon_spares = [ 2*f+2*s+t for f in filter(lambda x: x!=15,basic_moves)\n for s in filter(lambda x: f+x<15, basic_moves)\n for t in filter(lambda x: f+s+x<=15, basic_moves) ]\n\nnon_spares = list(set(non_spares))\nnon_spares.sort(reverse=True)\n\nsingle_strike_moves = [ SingleFrame(30,\"strike\") ]\nsingle_strike_moves += [ SingleFrame(30,\"spare\") ]\nsingle_strike_moves += [ SingleFrame(score) for score in non_spares ]\n\nlen(single_strike_moves)\n\n30\n\n\nFollowing a double strike the rules are different again: the first ball is triple counted, the second double counted, and the third single counted.\n\n# all the different scores for a frame following 2 strikes\n\nspares = [ 3*f+2*s for f in filter(lambda x: x!=15,basic_moves)\n for s in filter(lambda x: f+x==15,basic_moves)]\n\nspares = list(set(spares))\nspares.sort(reverse=True)\n\nnon_spares = [ 3*f+2*s+t for f in filter(lambda x: x!=15,basic_moves)\n for s in filter(lambda x: f+x<15, basic_moves)\n for t in filter(lambda x: f+s+x<=15, basic_moves) ]\n\nnon_spares = list(set(non_spares))\nnon_spares.sort(reverse=True)\n\ndouble_strike_moves = [ SingleFrame(45,\"strike\") ]\ndouble_strike_moves += [ SingleFrame(score,\"spare\") for score in spares ]\ndouble_strike_moves += [ SingleFrame(score) for score in non_spares ]\n\nlen(double_strike_moves)\n\n55\n\n\nThis 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.\n\ndef valid_full_moves(move, n, r, mn=0, mx=15):\n if move.special==\"strike\":\n # max score is 3 times the max score for the remaining frames\n # plus the multiplier for the remaining balls in the last frame\n up = (3*n + 2 + 1)*mx\n elif move.special==\"spare\":\n # max score is 2 times the max score for the next frame\n # 3 times the max score for the remaining frames\n # plus the multiplier for the remaining balls in the last frame\n up = (2 + 3*max(n-1,0) + 2 + 1)*mx\n else:\n up = (1 + 2*max(n-1,0) + 2 + 1)*mx\n return n*mn <= r - move.score <= up\n\nAt 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.\nIf the last frame starts with a spare, then it is the same as before: single ball, no multiplier.\nIf 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.\n\n# all the different scores for the last 2 balls of the last frame\n# assuming the first ball in the last frame was a strike\nlast_frame_moves = [ f+s for f in basic_moves \n for s in filter(lambda x: f+x<=15,basic_moves) ]\nlast_frame_moves += [ 15 + s for s in basic_moves ]\nlast_frame_moves = set(last_frame_moves)\n\n[ s for s in range(31) if s not in last_frame_moves ]\n\n[1, 16, 29]\n\n\nIf 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.\n\n# all the different scores for the last 2 balls of the last frame\n# assuming the second to last frame was a strike and the first ball\n# in the last frame was a strike\nlast_frame_double_moves = [ 2*f+s for f in basic_moves\n for s in filter(lambda x: f+x<=15,basic_moves) ] \nlast_frame_double_moves += [ 30 + s for s in basic_moves ]\nlast_frame_double_moves = set(last_frame_double_moves)\n\n[ s for s in range(46) if s not in last_frame_double_moves ]\n\n[1, 29, 31, 44]\n\n\nIt 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.\n\ndef last_frame_rule(remaining, last_frame, max_move, mx=45):\n if remaining == 0:\n # hit the target, don't need any additional balls\n return [SingleFrame(0,None,True)]\n elif last_frame == \"strike\" and max_move == mx and remaining in last_frame_double_moves:\n # two extra balls following a double strike\n return [SingleFrame(remaining,None,True)]\n elif last_frame == \"strike\" and remaining in last_frame_moves:\n # two extra balls following a single strike\n return [SingleFrame(remaining,None,True)]\n elif last_frame == \"spare\" and remaining in basic_moves:\n # only one extra ball\n return [SingleFrame(remaining,None,True)]\n else:\n # not possible\n return []\n\nThe 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.\n\ndef make_full_moves(cur_frame, cur_score, max_frame, target, \n moves=single_frame_moves, last_frame=None):\n if cur_frame == max_frame:\n # max_move is used to check if the second-to-last frame was a strike\n max_move = max( s.score for s in moves )\n return last_frame_rule(target-cur_score, last_frame, max_move)\n else:\n next_frame = cur_frame + 1\n n = max_frame - next_frame\n r = target - cur_score\n for move in filter(lambda x: valid_full_moves(x,n,r), moves):\n new_score = cur_score + move.score\n if last_frame == \"strike\" and move.special == \"strike\":\n next_moves = double_strike_moves\n next_last_frame = \"strike\"\n elif move.special == \"strike\":\n next_moves = single_strike_moves\n next_last_frame = \"strike\"\n elif move.special == \"spare\":\n next_moves = spare_frame_moves\n next_last_frame = \"spare\"\n else:\n next_moves = single_frame_moves\n next_last_frame = None\n\n advance = make_full_moves(next_frame, new_score, max_frame, target, next_moves, next_last_frame)\n if len(advance) > 0:\n advance.append(move)\n return advance\n else:\n return []\n\nLooping through all the scores: \\(0 \\le score \\le 450\\) yields the impossible to bowl scores, when spares and strikes are allowed.\n\nfor score in range(451):\n game = make_full_moves(0,0,10,score)\n if len(game)==0:\n print(\"Score {} is not possible, full game\".format(score))\n\nScore 1 is not possible, full game\nScore 434 is not possible, full game\nScore 436 is not possible, full game\nScore 449 is not possible, full game\n\n\nThis 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." + }, + { + "objectID": "posts/turbulent_jet_example/index.html", + "href": "posts/turbulent_jet_example/index.html", + "title": "Turbulent Jet Example - Acetylene Leak", + "section": "", + "text": "In previous examples I discussed release scenarios involving vapour clouds spreading over a large area, carried by the wind. In those examples the momentum of the jet of fluid was not very important relative to the ambient wind conditions and could be ignored. In this example I am looking at the opposite extreme, a release from a pressure vessel inside a building where the momentum of the jet dominates." + }, + { + "objectID": "posts/turbulent_jet_example/index.html#the-scenario", + "href": "posts/turbulent_jet_example/index.html#the-scenario", + "title": "Turbulent Jet Example - Acetylene Leak", + "section": "The Scenario", + "text": "The Scenario\nConsider, for an example, a leak from an acetylene cylinder inside a large building, such as in a warehouse or shop. We imagine, for convenience, that the air within the building is quiescent. For the sake of an example suppose the leak is a 1/4 in. hole, similar in diameter to a typical acetylene hose, and that the operating pressure at that point is 15psig1 We are interested in exploring the concentration distribution as the acetylene jets into the air and mixes, with our reference concentration of interest being half the LEL of 2.5%(vol).\n1 From CGA G-1 2009 the safe operating pressure of an acetylene system\nusing Unitful: @u_str, ustrip\n\ninch = ustrip(u\"m\", 1u\"inch\") # unit conversion inch->m\npsi = ustrip(u\"Pa\", 1u\"psi\") # unit conversion psi->Pa\n\np₂ = 14.7psi # atmospheric pressure, Pa absolute\nT₂ = 25+273.15 # ambient temperature, K\n\nd = 0.25inch # diameter of the hole, m\np₁ = 15psi+p₂ # pressure of the acetylene, Pa absolute\nT₁ = T₂ # the release temperature, K\n\n298.15\n\n\nWe can look up some properties of acetylene in Perry’s2\n2 Poling et al., “Physical and Chemical Data”.\n# universal gas constant, J/mol/K\nR = 8.31446261815324 \n\n# ideal gas density, kg/m³\nρ(p,T;MW) = (p*MW)/(R*T)/1000\n\n# gas viscosity correlation, Pa*s\nμ(T;C) = (C[1]*T^(C[2]))/(1+(C[3]/T)+(C[4]/T^2)) \n\n# Properties of Acetylene\nMWⱼ = 26.037 # molar mass, kg/kmol\nLEL = 0.025 # Lower explosive limit, vol/vol\nk = 1.26 # ratio cp/cv at 15C\nμⱼ = μ(T₁;C=[1.2025e-6,0.4952,291.4,0])\nρ₁ = ρ(p₁,T₁;MW=MWⱼ)\n\n# Properties of Air\nMWₐ = 28.960 # molar mass, kg/kmol\nρ₂ = ρ(p₂,T₂;MW=MWₐ)\n\n1.1840386427594014" + }, + { + "objectID": "posts/turbulent_jet_example/index.html#the-release-rate", + "href": "posts/turbulent_jet_example/index.html#the-release-rate", + "title": "Turbulent Jet Example - Acetylene Leak", + "section": "The Release Rate", + "text": "The Release Rate\nWe can model the release as a gas jet3 where the gas is ideal and the expansion through the jet is an isentropic process4\n3 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases., 29.4 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases. has a mistake in equation 2.16, the version given here is correct.\\[ G = \\rho u = c_d \\sqrt{ \\rho_1 p_1 \\left( 2 k \\over k-1 \\right) \\left[ \\left(p_2 \\over p_1\\right)^{2 \\over k} - \\left(p_2 \\over p_1\\right)^{k+1 \\over k} \\right]} \\]\nfor non-choked flow and\n\\[ G = c_d \\sqrt{ \\rho_1 p_1 k \\left( 2 \\over k+1 \\right)^{k+1 \\over k-1} } \\]\nfor choked flow, which occurs when\n\\[ \\left(p_2 \\over p_1 \\right) \\lt \\left( 2 \\over k+1 \\right)^{k \\over k-1} \\]\nWhere G is the mass velocity of acetylene discharged through the hole (in kg/m²/s), cd is the discharge coefficient which can be assumed to be 0.61,5 and the rest are as defined earlier. I am assuming, here, that the hole is circular for simplicity.\n5 AIChE/CCPS, 30.\n(p₂/p₁) < (2/(k+1))^(k/(k-1)) \n\ntrue\n\n\nTherefore the flow is choked and\n\nc_d = 0.61\n\nG = c_d * √(ρ₁*p₁*k*(2/(k+1))^((k+1)/(k-1)) )\n\n267.1556913840265\n\n\nThe density at the orifice is reduced, through the expansion and, for an isentropic process, is related to the pressure by\n\\[ {\\rho_o \\over \\rho_1} = \\left( p_o \\over p_1 \\right)^{1 \\over k} \\]\nWhere subscript o indicates at the orifice. At this point, after the expansion \\(p_o = p_2\\) and\n\\[ \\rho_o = \\rho_1 \\left( p_o \\over p_1 \\right)^{1 \\over k} \\]\n\nρₒ = ρ₁*(p₂/p₁)^(1/k)\n\n1.2307940295609565\n\n\nThe velocity at the orifice, i.e. after the gas has expanded, is then\n\\[ u_o = {G \\over \\rho_o} \\]\n\nuₒ = G/ρₒ\n\n217.05962571115586" + }, + { + "objectID": "posts/turbulent_jet_example/index.html#jet-behavior", + "href": "posts/turbulent_jet_example/index.html#jet-behavior", + "title": "Turbulent Jet Example - Acetylene Leak", + "section": "Jet Behavior", + "text": "Jet Behavior\nTo model the concentration profile I am going to assume a turbulent jet, from a circular hole, mixing with air. In this case the density of air and acetylene are similar and so a simple turbulent jet model is appropriate. If there was a significant difference in densities then a density correction would be needed, however for many applications “close” means a ratio of ambient to jet densities between6\n6 Poleshaw and Golub., “Jets”.\\[ \\frac{1}{4} \\le { \\rho_{a} \\over \\rho_{j} } \\le 4 \\]\nWhere subscript a indicates the ambient fluid and j the jet.\nCircular turbulent jets expand by entraining ambient fluid, tracing out a cone defined by a jet angle \\(\\alpha \\approx 15-25^\\circ\\). The mixing layer penetrates into the jet forming the potential cone, inside is pure jet material and outside is mixed. After approximately 6 hole diameters the region is fully developed.7\n7 Revill, “Jet Mixing”.\n\n\n\n\n\nFigure 1: A turbulent jet expanding into a quiescent atmosphere.\n\n\n\nEmpirical approximations of the velocity, and concentration, profiles are often given with respect to this jet angle or, equivalently, the slope of line (i.e. \\(\\tan \\frac{\\alpha}{2}\\))\nAnother important factor is the Reynolds number, the jet is fully turbulent when \\(Re \\gt 2000\\), where the Reynolds number is calculated with respect to the initial jet velocity and jet diameter (i.e. the hole diameter)\n\\[ Re = { \\rho u d \\over \\mu } = { G d \\over \\mu }\\]\n\n0.25 < (ρ₂/ρₒ) < 4\n\ntrue\n\n\n\nRe = G*d/μⱼ\n\nRe > 2000\n\ntrue\n\n\nThe densities are within the appropriate range and the flow is fully turbulent, so the turbulent jet model requirements are satisfied.\n\nVelocity and Concentration distributions\nThere are many different empirical velocity distributions as well as velocity distributions derived from theories of turbulent mixing available in various references. Mostly of the same general type (gaussian), but parametrized slightly differently. However, in my experience, there are far fewer concentration distributions available, this is not too critical due to an interesting result in turbulent mass transfer for jets8\n8 Bird, Stewart, and Lightfoot, Transport Phenomena, 416.\\[ { C \\over C_{max} } = \\left( v_z \\over v_{z,max} \\right)^{Sc_t} \\]\nThat is, at a given distance z away from the hole, the concentration profile is the velocity profile raised to the power \\(Sc_t\\) – the turbulent Schmidt number. Experimentally this is approximately 0.7. Note also that \\(C_{max}\\) and \\(v_{z,max}\\) are taken at the centerline. Physically this means that the concentration profile, at a given downstream distance, is wider than the velocity distribution; concentration expands more.\nA similar way of capturing the same phenomenon that is often seen with empirical velocity distributions is to define a width parameter \\(b\\) and note that the equivalent width for the concentration profile is \\(1.17b\\)9 and substitute in accordingly.\n9 Kaye, Khan, and Testik, “Environmental Fluid Mechanics”.10 Lees, Loss Prevention in the Process Industries, 15/140.In this example I am using the empirical concentration given in Lees10 for simplicity\n\\[ {C \\over C_0 } = k_2 \\left( d_h \\over z \\right) \\left( \\rho_z \\over \\rho_o \\right)^{0.5} \\exp \\left( - \\left( k_3 r \\over z \\right)^2 \\right) \\]\nNote also the ratio of densities, the density \\(\\rho_z\\) is the density of the jet at some distance z and it is common to conservatively take this as \\(\\rho_a\\).\nThe parameters \\(k_2\\) and \\(k_3\\) are empirically derived for the particular jet and \\(k_2\\) is a function of Reynolds number below \\(Re \\lt 20000\\).11 The conservative values suggested are 6 and 5 respectively.\n11 Long, “Estimation of the Extent of Hazard Areas Around a Vent”.\nfunction C(r, z; C₀=1.0, k₂=6, k₃=5, d=d, ρz=ρ₂, ρₒ=ρₒ)\n C = C₀ * k₂ * (d/z) * √(ρz/ρₒ) * exp(-(k₃*r/z)^2)\nend\n\nC (generic function with 1 method)\n\n\n\n\n\n\n\n\n\n\nFigure 2: The centerline concentration of acetylene as a concentration of downstream distance.\n\n\n\n\n\nAt this point it is worth pointing out that the model of the jet is independent of the discharge rate. The concentration profile is only a function of the hole diameter and the fluid density. The velocity in the jet, and the amount of air entrained in the jet, do depend strongly on the initial discharge rate but in such a way that the concentration does not. As the jet velocity increases proportionally more air is entrained and the concentration profile remains constant.\n\n\n\n\n\n\n\n\nFigure 3: Concentration contours at the release elevation." + }, + { + "objectID": "posts/turbulent_jet_example/index.html#explosive-mass", + "href": "posts/turbulent_jet_example/index.html#explosive-mass", + "title": "Turbulent Jet Example - Acetylene Leak", + "section": "Explosive Mass", + "text": "Explosive Mass\nNow that we have a model of the jet, showing the concentration of acetylene, the most relevant parameter we would want to know is the explosive mass such that some blast modeling could be done.\nThe most obvious way to do this is to integrate over the jet, using cylindrical coordinates for convenience\n\\[ m_e = \\int \\rho C(r,z) dV = 2\\pi \\rho_o \\int_{0}^{\\infty} \\int_{0}^{\\infty} C(r,z) r dr dz \\]\nExcept that we define the explosive mass to be the volume where \\(C > \\frac{1}{2} LEL\\). A lazy way to do this is to define a function that equals \\(C\\) if it is \\(\\gt \\frac{1}{2} LEL\\) and zero otherwise.\nThe potential core region is poorly described by this model, and the closer to the origin of the jet the more un-physical the results: giving concentrations greater than 100% and being undefined completely at the origin. One way of hand waving this away is to chop off any concentrations above 100%.\n\nfunction igrd(v; lim=0.5*LEL)\n r, z = v\n \n if z>0\n c = C(r,z)\n c = c<lim ? 0 : min(1,c)\n else\n c = 0\n end\n\n return r*c\nend\n\nigrd (generic function with 1 method)\n\n\nIntegrating over some plausible bounds, taken by looking at the plots above, gives the volume of acetylene.\n\nusing HCubature: hcubature\n\nI, err = hcubature(igrd, [0, 0], [0.25, 2.0], atol = 1e-8)\n\n(0.0008207940258726464, 9.999922827914883e-9)\n\n\nWhich can be plugged into the equation to calculate the final explosive mass.\n\nmₑ = 2*π*ρₒ*I\n\n0.006347452155224944\n\n\nTo give a sense of how much this is, the explosive mass is equivalent to ~1s of discharge at the steady state discharge rate.\n\nm = G*(π/4)*d^2\n\nmₑ/m\n\n0.7502356087241902" + }, + { + "objectID": "posts/turbulent_jet_example/index.html#conclusions", + "href": "posts/turbulent_jet_example/index.html#conclusions", + "title": "Turbulent Jet Example - Acetylene Leak", + "section": "Conclusions", + "text": "Conclusions\nTurbulent jet mixing is a much simpler model for estimating releases, especially when using empirical models, compared to models for plumes influences by buoyancy and wind. There are much fewer parameters that need to be estimated.\nOne big weakness to the model as presented here is that it does not take into account the enclosed space. If the assumption is that the warehouse is large and ignition sources are numerous then that likely doesn’t matter, the acetylene leak will ignite before it has a chance to accumulate. However it will grossly underestimate the potential explosive mass that could develop as the acetylene disperses through the air of warehouse, since the model presumes the ambient air has no acetylene in it and is effectively infinite in extent.\nThis limitation would, for me, motivate exploring more detailed models of gas build up in enclosed spaces" + }, + { + "objectID": "posts/turbulent_jet_example/index.html#references", + "href": "posts/turbulent_jet_example/index.html#references", + "title": "Turbulent Jet Example - Acetylene Leak", + "section": "References", + "text": "References\n\n\nAIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999.\n\n\nBird, R. Byron, Warren E. Stewart, and Edwin N. Lightfoot. Transport Phenomena. 2nd ed. Hoboken, NJ: John Wiley & Sons, 2007.\n\n\nKaye, Nigel B., Abdul A. Khan, and Firat Y. Testik. “Environmental Fluid Mechanics.” In Handbook of Environmental Engineering, edited by Myer Kutz. New York: John Wiley & Sons, 2018.\n\n\nLees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996.\n\n\nLong, V. D. “Estimation of the Extent of Hazard Areas Around a Vent.” Second Symposium On Chemical Process Hazards, 1963, 6–14.\n\n\nPoleshaw, Yury V., and V. V. Golub. “Jets.” In Thermopedia, 2013. https://doi.org/10.1615/AtoZ.j.jets.\n\n\nPoling, Bruce E., George H. Thomson, Daniel G. Friend, Richard L. Rowley, and W. Vincent Wilding. “Physical and Chemical Data.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008.\n\n\nRevill, B. K. “Jet Mixing.” In Mixing in the Process Industries, edited by N. Harnby, M. F. Edwards, and A. W. Nienow, 2nd ed. Oxford: Butterworth-Heinemann, 1992." + }, + { + "objectID": "posts/turbulent_jet_notes_part_2/index.html", + "href": "posts/turbulent_jet_notes_part_2/index.html", + "title": "More on Turbulent Jets", + "section": "", + "text": "Previously I worked through the velocity profiles for turbulent jets and left off claiming that everything else of interest followed simply from those profiles. This time I am going to follow through by sketching out how to derive the concentration profile and volumetric flow rate." + }, + { + "objectID": "posts/turbulent_jet_notes_part_2/index.html#concentration", + "href": "posts/turbulent_jet_notes_part_2/index.html#concentration", + "title": "More on Turbulent Jets", + "section": "Concentration", + "text": "Concentration\nFor hazard identification, among other purposes, what one often wants is not the velocity distribution of the jet, but the concentration profile. For example, suppose a vessel develops a small hole and a jet of process fluid is exiting out into the air, to determine how bad that is and what sort of hazard is presented (explosive, toxic, etc.) we first need to determine the concentration profile.\nSuppose the concentration of a species A is cA, for the sake of simplicity let this be a time-averaged concentration done in a way that is consistent with Reynolds averaging.1\n1 Note the concentration is given in units of \\([[ quantity ]] \\times [[length]]^{-3}\\), e.g. kmol/m³2 Bird, Stewart, and Lightfoot, Transport Phenomena, 850.The continuity equation for species A is given by2\n\\[ {\\mathrm{D} \\over \\mathrm{D}t} c_A = - \\nabla \\cdot \\mathbf{J}_A + r_A \\]\nwhere J is the molar flux and r is the rate of reaction3\n3 The molar flux J is the time averaged molar flux and is the sum of both viscous and turbulent terms. \\(\\mathbf{J}_A = \\mathbf{J}_A^{(v)} + \\mathbf{J}_A^{(t)}\\)In cylindrical coordinates this is:\n\\[ {\\partial c_A \\over \\partial t} + \\bar{v}_r {\\partial c_A \\over \\partial r} + {\\bar{v}_{\\theta} \\over r } {\\partial c_A \\over \\partial \\theta} + \\bar{v}_z {\\partial c_A \\over \\partial x} \\]\n\\[ = -\\left[ {1 \\over r} {\\partial \\over \\partial r} \\left(r J_{A,r}\\right) + {1 \\over r} {\\partial \\over \\partial \\theta} J_{A,\\theta} + {\\partial \\over \\partial z} J_{A,z} \\right] + r_A\\]\nMaking the following assumptions:\n\nSteady state ( \\({\\partial \\over \\partial t} \\left( \\dots \\right) = 0\\) )\nAxisymmetric ( \\({\\partial \\over \\partial \\theta} \\left( \\dots \\right) = 0\\) )\nBoundary layer approximation ( \\(\\vert {\\partial c_A \\over \\partial z} \\vert \\ll \\vert {\\partial c_A \\over \\partial r} \\vert\\) )\nNon-reacting ( \\(r_A = 0\\) )\n\nThis simplifies down to\n\\[ \\bar{v}_r {\\partial c_A \\over \\partial r} + \\bar{v}_z {\\partial c_A \\over \\partial z} = {-1 \\over r} {\\partial \\over \\partial r} \\left( r J_{A,r} \\right) \\]\nWe suppose that, much like the velocity profile, the concentration profile is self-similar. That is, at any given downstream distance z the profile has the same shape, just scaled and stretched.\n\\[ c_A = {k_c \\over z} g\\left(\\xi\\right) \\]\nwhere ξ is r/z and kc is some constant to be determined. From this we can work out some useful partial derivatives\n\\[ {\\partial c_A \\over \\partial r} = {k_c \\over z^2} g^{\\prime}\\left(\\xi\\right) \\]\n\\[ {\\partial c_A \\over \\partial z} = {-k_c \\over z^2} \\left[ g\\left(\\xi\\right) + \\xi g^{\\prime}\\left(\\xi\\right) \\right]\\]\nrecalling the velocity profiles in terms of F(ξ) and substituting into the equation of continuity we arrive at\n\\[{ k k_c \\over z^2 } {d \\over d\\xi} \\left(F g\\right) = -{ \\partial \\over \\partial r} \\left( r J_{A,r} \\right)\\]\nWhich gives us our path forward: find a model for \\(J_{A,r}\\) and substitute into the right hand side of the equation, integrate both sides and solve for g in terms of F and ξ.\n\nPrandtl mixing length models\nWe are going to assume that the overall molar flux is proportional to the concentration gradient and some mixing length4 l, that is\n4 Bird, Stewart, and Lightfoot, Transport Phenomena, 659.\\[ J_{A,r} = -l_c^2 \\left\\vert \\partial \\bar{v}_z \\over \\partial r \\right\\vert \\left(\\partial c_A \\over \\partial r \\right)\\]\nwe assume the mixing length is proportional to the downstream distance for the same reasons as when we derived the velocity profile and, anticipating the form of the constants from how it worked out for the velocity distribution, \\(l_c = a_c^{3/2} z\\)\nPutting this into the equation of continuity for A and doing some rearranging gives\n\\[{ k k_c \\over z^2 } {d \\over d\\xi} \\left(F g\\right) = { k k_c \\over z^2 } a_c^3 {d \\over d\\xi}\\left(g^{\\prime}F^{\\prime\\prime} - {g^{\\prime}F^{\\prime} \\over \\xi} \\right)\\]\nCancelling some terms and integrating both sides gives us\n\\[ F g = a_c^3 \\left(g^{\\prime}F^{\\prime\\prime} - {g^{\\prime}F^{\\prime} \\over \\xi} \\right) + \\mathrm{const} \\]\nWhere we can see from the boundary conditions that the constant of integration is zero. We can separate F and g\n\\[ {g^{\\prime} \\over g} = { a_c^{-3} F \\over F^{\\prime \\prime} - {F^{\\prime} \\over \\xi} }\\]\nand integrating both sides again, we arrive at\n\\[ \\log{c_A \\over c_{A,max} } = \\log{g\\left(\\xi\\right) \\over g\\left(0\\right)} = a_c^{-3} \\int_0^{\\xi} {F \\over F^{\\prime \\prime} - {F^{\\prime} \\over \\xi} } d\\xi\\]\nNote that, when setting up the original ode, we had\n\\[ F F^{\\prime} = a^3 \\left( F^{\\prime\\prime} - {F^{\\prime} \\over \\xi} \\right)^2 \\]\nor\n\\[ F^{\\prime\\prime} - {F^{\\prime} \\over \\xi} = \\sqrt{ F F^{\\prime} \\over a^3 } \\]\nMaking the substitution gives us an integral entirely in terms of F, F′ and ξ\n\\[ \\log{g\\left(\\xi\\right) \\over g\\left(0\\right)} = a_c^{-3} a^{3/2} \\int_0^{\\xi} {F \\over \\sqrt{ F F^{\\prime} } } d\\xi\\]\nTaking the exponential of both sides gives us:\n\\[ {g\\left(\\xi\\right) \\over g\\left(0\\right)} = \\left( \\exp \\left(\\int_0^{\\xi} {F \\over \\sqrt{ F F^{\\prime} } } d\\xi \\right)\\right)^{ a_c^{-3} a^{3/2} } \\]\nWhich is something we can compute using the ode solution from last time, by importing the code for the ode from the previous notebook and running it to get the velocity distribution.\n\n# importing just the ODE solution\n\nusing NBInclude\n\n@nbinclude(\"../turbulent_jet_notes/index.ipynb\"; counters=[1 3])\n\nWe can then perform the integration numerically, in this case the cumulative integral\n\nusing NumericalIntegration: cumul_integrate\n\nϕ, F, F′ = sol.t, sol[1,:], sol[2,:]\n\n# trim any unphysical values\nF[ F.>0 ] .= 0.0\nF′[F′.>0] .= 0.0\n\n\nfunction intgrnd(F, F′) \n if F == 0.0\n return 0.0\n elseif F′ == 0.0\n return -Inf\n else\n return F ./ .√(F.*F′)\n end\nend\n \nlog_g = cumul_integrate(ϕ, intgrnd.(F, F′));\n\nFor some context we can plot the concentration along with the velocity, with the constants \\(a_c^{-3} a^{3/2} = 1\\)\n\n\n\n\n\n\n\nFigure 1: Comparison of the concentration profile to the velocity profile, Prandtl mixing length theory.\n\n\n\n\nWell, that’s interesting, it looks like we have arrived at\n\\[ {c_A \\over c_{A,max} } = \\left( \\bar{v}_z \\over \\bar{v}_{z,max} \\right)^{\\mathrm{const} } \\]\nWhich is, in fact, the case and is generally the case – the Prandtl mixing length, eddy diffusion, and Gaussian models work out to the same conclusion.\nConsider the following derivative\n\\[ {d \\over d\\xi} \\log{f\\left(\\xi\\right)} = {d \\over d\\xi} \\log \\left( -F^{\\prime} \\over \\xi \\right) = {1 \\over F^{\\prime} } \\left( F^{\\prime\\prime} - {F^{\\prime} \\over \\xi} \\right)\\]\nRecalling back to our original ode for the Prandtl mixing length velocity distribution, we had the relationship\n\\[ F F^{\\prime} = a^3 \\left( F^{\\prime\\prime} - {F^{\\prime} \\over \\xi} \\right)^2 \\]\nor\n\\[ {F \\over F^{\\prime\\prime} - {F^{\\prime} \\over \\xi} } = a^3 { {F^{\\prime\\prime} - {F^{\\prime} \\over \\xi} } \\over F^{\\prime} } \\]\nand so\n\\[ {d \\over d\\xi} \\log{f\\left(\\xi\\right)} = a^{-3} {F \\over F^{\\prime\\prime} - {F^{\\prime} \\over \\xi} } \\]\nand recalling that the integral we originally wished to solve was\n\\[ \\log{c_A \\over c_{A,max} } = a_c^{-3} \\int_0^{\\xi} {F \\over F^{\\prime \\prime} - {F^{\\prime} \\over \\xi} } d\\xi\\]\nwe get\n\\[ \\log{c_A \\over c_{A,max} } = \\left(a \\over a_c\\right)^{3} \\left[ \\log \\left( f\\left(\\xi\\right) \\over f\\left(0\\right) \\right) \\right] = \\log \\left( \\bar{v}_z \\over \\bar{v}_{z,max} \\right)^{\\left(a \\over a_c\\right)^{3} } \\]\nand finally\n\\[ { c_A \\over c_{A,max} } = \\left( \\bar{v}_z \\over \\bar{v}_{z,max} \\right)^{\\left(a \\over a_c\\right)^{3} }\\]\nWhere, rather pleasingly, the constant \\({\\left(a \\over a_c\\right)^{3} }\\) works out to be the ratio of the mixing lengths, all squared5\n5 Suppose an “equivalent” eddy viscosity for the Prandtl mixing length model of\n\\[\\varepsilon = -l^2 \\left\\vert \\partial \\bar{v}_z \\over \\partial r \\right\\vert\\]\nand eddy diffusivity of\n\\[\\mathscr{D}_{AB} = -l_c^2 \\left\\vert \\partial \\bar{v}_z \\over \\partial r \\right\\vert\\]\nthe turbulent Schmidt number is then\n\\[\\mathrm{Sc} = {\\varepsilon \\over \\mathscr{D}_{AB} } = \\left( l \\over l_c \\right)^2\\]\nmaking the final result\n\\[{c_A \\over c_{A,max} } = \\left( \\bar{v}_z \\over \\bar{v}_{z,max} \\right)^{Sc}\\]\\[ {\\left(a \\over a_c\\right)^{3} } = \\left( l \\over l_c \\right)^2 \\]\nWe can now plot the concentration profile along with the velocity profile and see that the two profiles have a similar shape, with the concentration profile stretched to be wider. Concentration spreads out more than velocity.\n\n\n\n\n\n\n\nFigure 2: The dimensionless concentration profile and velocity profile, Prandtl mixing length theory.\n\n\n\n\n\n\nEddy diffusivity models\nIn the eddy diffusivity model we assume the molar flux is proportional to the concentration gradient with the constant of proportionality being an effective diffusivity, the eddy diffusivity. In some treatments the viscous and turbulent diffusivities are treated separately, in this model we lump it all together into one constant\n\\[ J_{A,r} = -\\mathscr{D}_{AB} {\\partial c_A \\over \\partial r} \\]\nwhere \\(\\mathscr{D}_{AB}\\) is the eddy diffusivity for species A. Using the definition of cA we can work out the right hand side of the equation of continuity for A\n\\[ -{ \\partial \\over \\partial r} \\left( r J_{A,r} \\right) = {k_c \\over z^2} \\mathscr{D}_{AB} {d \\over d\\xi} \\left( \\xi g^{\\prime} \\right) \\]\nputting that into the equation of continuity for A, we get\n\\[{ k k_c \\over z^2 } {d \\over d\\xi} \\left(F g\\right) = {k_c \\over z^2} \\mathscr{D}_{AB} {d \\over d\\xi} \\left( \\xi g^{\\prime} \\right) \\]\ncancelling some terms and integrating once gives us\n\\[k F g = \\mathscr{D}_{AB} \\xi g^{\\prime} + \\mathrm{const}\\]\nwhere, by use of boundary conditions, the constant of integration is zero. This can be rearranged to isolate F and g\n\\[ {g^{\\prime} \\over g} = {k \\over \\mathscr{D}_{AB} } {F \\over \\xi} \\]\nintegrating once more\n\\[ \\log \\left( g\\left(\\xi\\right) \\over g\\left(0\\right) \\right) = {k \\over \\mathscr{D}_{AB} } \\int_0^{\\xi} {F \\over \\xi} d\\xi \\]\nrecalling that, for the eddy diffusivity model\n\\[ F\\left( \\xi \\right) = { - c \\left( C_2 \\xi \\right)^2 \\over {1 + \\frac{1}{4} \\left( C_2 \\xi \\right)^2 } } \\]\n\\[ \\int_0^{\\xi} {F \\over \\xi} d\\xi = {c} \\int_0^{\\xi} { -C_2^2 \\xi \\over {1 + \\frac{1}{4} \\left( C_2 \\xi \\right)^2 } } d\\xi = {c} \\log \\left(1 + \\frac{1}{4} \\left( C_2 \\xi \\right)^2 \\right)^{-2} \\]\nand so we have\n\\[ \\log \\left( g\\left(\\xi\\right) \\over g\\left(0\\right) \\right) = {k c \\over \\mathscr{D}_{AB} } \\log \\left(1 + \\frac{1}{4} \\left( C_2 \\xi \\right)^2 \\right)^{-2} \\]\nrecalling that the constant k is related to the eddy diffusivity \\(\\varepsilon\\) by \\(\\varepsilon=ck\\), we have\n\\[ {c_A \\over c_{A,max} } = \\left( g\\left(\\xi\\right) \\over g\\left(0\\right) \\right) = \\left(1 + \\frac{1}{4} \\left( C_2 \\xi \\right)^2 \\right)^{-2{\\varepsilon \\over \\mathscr{D}_{AB} } } \\]\nand, looking back on the definition of f(ξ) from the eddy viscosity model, we get6\n6 The turbulent Schmidt number is defined as the ratio of the eddy viscosity to the eddy diffusivity\n\\[\\mathrm{Sc} = {\\varepsilon \\over \\mathscr{D}_{AB} }\\]\nmaking the final result\n\\[{c_A \\over c_{A,max} } = \\left( \\bar{v}_z \\over \\bar{v}_{z,max} \\right)^{Sc}\\]\\[ {c_A \\over c_{A,max} } = \\left( \\bar{v}_z \\over \\bar{v}_{z,max} \\right)^{\\varepsilon \\over \\mathscr{D}_{AB} } \\]\nWe can plot the concentration and velocity profiles for the eddy diffusivity model as well, and it is a similar story. The shapes of the profiles are the same but the concentration profile is stretched, such that concentration “spreads out” more than velocity does.\n\n\n\n\n\n\n\nFigure 3: The dimensionless concentration profile and velocity profile, Eddy diffusivity model.\n\n\n\n\n\n\nGaussian models\nThe standard Gaussian model was defined as\n\\[ f\\left(\\xi\\right) = \\exp \\left( -c \\xi^2 \\right) \\]\nwhere the constant c (note: not the concentration) was found to be\n\\[ c = \\log{2} \\left(z \\over b_{1/2}\\right)^2 = \\log{2} \\left(1 \\over \\beta\\right)^2 \\]\nwhere I am introducing the constant β to represent the spreading constant (i.e. the ratio of b to z) mostly to cut down on all the constants I call c given that I am also using c to represent concentrations.\nWe can then write the velocity distribution as\n\\[ f\\left(\\xi\\right) = \\exp \\left( -c \\xi^2 \\right) = \\exp \\left( -\\log{2} \\left(\\xi \\over \\beta\\right)^2 \\right) = \\exp \\left(- \\log 2 \\left(\\xi \\over \\beta\\right)^2 \\right)\\]\nThe concentration distribution is similarly defined empirically in terms of a half-width, \\(b_{1/2,c}\\) and entirely analogously we end up with a distribution\n\\[ g\\left(\\xi\\right) = \\exp \\left( -\\log 2 \\left(\\xi \\over \\beta_c\\right)^2 \\right)\\]\nwhere βc is the spreading constant for the concentration profile. But it’s fairly easy to see that this is equivalent to\n\\[ g\\left(\\xi\\right) = \\exp \\left( -\\log 2 \\left(\\xi \\over \\beta\\right)^2 \\left(\\beta \\over \\beta_c\\right)^2 \\right) = f\\left(\\xi\\right)^{\\left(\\beta \\over \\beta_c\\right)^2} \\]\nor7\n7 We can argue, in a manner analogous to the Prandtl mixing length theory, that the eddy viscosity is proportional to the characteristic length squared, and similarly for the eddy diffusivity and thus the turbulent Schmidt number is then\n\\[\\mathrm{Sc} = {\\varepsilon \\over \\mathscr{D}_{AB} } = \\left( b_{1/2} \\over b_{1/2,c} \\right)^2 = \\left( \\beta \\over \\beta_c \\right)^2\\]\nthus making the final result\n\\[{c_A \\over c_{A,max} } = \\left( \\bar{v}_z \\over \\bar{v}_{z,max} \\right)^{Sc}\\]\\[ {c_A \\over c_{A,max} } = \\left({\\bar{v}_z \\over \\bar{v}_{z,max} }\\right)^{\\left(\\beta \\over \\beta_c\\right)^2} \\]\n\n\n\nThe dimensionless concentration profile and velocity profile, Gaussian empirical model.\n\n\n\n\nSchmidt number\nAll three of the turbulent jet models looked at so far ended up with a concentration profile of\n\\[{c_A \\over c_{A,max} } = \\left( \\bar{v} \\over \\bar{v}_{z,max} \\right)^{Sc} \\]\nwhere I declared the constant Sc to be the turbulent Schmidt number. There are, of course, several ways of arriving at this value but literature generally gives \\(Sc \\approx 0.7\\). This also tends to be the same value given for the turbulent Prandtl number, which is convenient.\n\n\n\nSc\nReference\n\n\n\n\n0.7\nBird, Stewart, and Lightfoot8\n\n\n0.73\nKaye, Khan, and Testik9\n\n\n\n8 Transport Phenomena.9 “Environmental Fluid Mechanics”.\n\nMass balance\nThroughout all of this there has been a constant kc floating around, unaddressed. In practice this is usually a free parameter determined by fitting with experimental data. However it can also be determined by a mass balance.\nThe total molar flux through any plane z=m is the same for all m (in the region of fully developed flow), which is to say\n\\[ n_A = \\int_0^{2\\pi} \\int_0^{\\infty} c_A \\bar{v}_z r dr d\\theta = \\mathrm{const} \\]\nWe also know what total molar flux is from the initial conditions of the jet\n\\[ n_A = c_0 v_0 {\\pi \\over 4} d_0^2 \\]\nand we can write the integral for any downstream distance z\n\\[ n_A = 2\\pi \\int_0^{\\infty} c_A \\bar{v}_z r dr = 2 \\pi k k_c \\int_0^{\\infty} \\left(\\bar{v}_z \\over \\bar{v}_{z,max} \\right)^{Sc+1} \\xi d\\xi \\]\nand so we can write kc in terms of the other parameters\n\\[ k_c = { c_0 v_0 d_0^2 \\over 8 k I_c } \\]\nwhere\n\\[I_c = \\int_0^{\\infty} \\left(\\bar{v}_z \\over \\bar{v}_{z,max} \\right)^{Sc+1} \\xi d\\xi\\]\nrecalling that \\(k = {v_0 d_0 \\over \\sqrt{8I} }\\) where \\[I = \\int_0^{\\infty} \\left(\\bar{v}_z \\over \\bar{v}_{z,max} \\right)^{2} \\xi d\\xi\\]\nwe can simplify this to\n\\[ k_c = \\sqrt{I \\over 8 I_c^2} c_0 d_0 \\]\nor\n\\[ {c_A \\over c_0} = \\sqrt{I \\over 8 I_c^2} {d_0 \\over z} \\left(\\bar{v}_z \\over \\bar{v}_{z,max} \\right)^{Sc} \\]\nThese integrals can get difficult to solve analytically – except for the Gaussian case which is fairly simple – but are very easy to estimate numerically.10\n10 It is worth keeping in mind, when using the empirical constants provided in the literature, that they are often fitted parameters and as such mass and momentum conservation is not necessarily guaranteed.As an example, suppose we wish to calculate the constant for the Prandtl mixing length model, we have already imported the solution to the ode for the velocity profile and it is a simple matter to numerically integrate (using the trapezoidal rule) the two integrals in question.\nWe still need two parameters to complete the integration, the parameter a and ac or, equivalently, β and the Schmidt number Sc.\n\nusing NumericalIntegration: integrate\n\n# solutions of the ode\nϕ, F′ = sol.t, sol[2,:]\n\n# trim any unphysical values\nF′[F′.>0] .= 0\n\n# parameters of the model\nβ = 0.0848\na = β/1.2277667062444657\nSc = 0.7\nf = -F′./ϕ\nξ = a.*ϕ\n\n# momentum balance integrand\nint = (f.^2).*ξ\nint[ξ.≤0] .= 0\n\n# mass balance integrand\nint_c = (f.^(Sc+1)).*ξ\nint_c[ξ.≤0] .= 0\n\nI = integrate(ξ, int)\nI_c = integrate(ξ, int_c)\n\nk_c = √(I/(8*I_c^2))\n\n5.793131625321363\n\n\nThe other two models could also be integrated numerically, for example the eddy diffusivity model\n\nusing QuadGK: quadgk\n\nC₂ = 2*√(√(2)-1)/β\nf_ev(ξ) = ( 1 + (C₂*ξ/2)^2 )^-2\n\nI, err = quadgk((ξ) -> ξ*f_ev(ξ)^2, 0, Inf)\nI_c, err = quadgk((ξ) -> ξ*f_ev(ξ)^(Sc+1), 0, Inf)\n\nk_c = √(I/(8*I_c^2))\n\n5.258197856093409\n\n\nand the Gaussian model\n\nc = log(2)/β^2\nf_emp(ξ) = exp(-c*ξ^2)\n\nI, err = quadgk((ξ) -> ξ*f_emp(ξ)^2, 0, Inf)\nI_c, err = quadgk((ξ) -> ξ*f_emp(ξ)^(Sc+1), 0, Inf)\n\nk_c = √(I/(8*I_c^2))\n\n5.900934664729694\n\n\nIn these cases I followed my convention from the previous notebook of setting each of the velocity distributions to have the same width at half height, and using the same Schmidt number for the concentration profiles. This makes it more of an “apples to apples” comparison.\nLooking at the results we see that the constant for the concentration is smaller than the velocity profiles, indicating that the centerline concentration drops off faster than the velocity. Which makes some sense as we saw above that the concentration also spreads out radially more than the velocity.\nWe can combine the velocity and concentration profiles and get a sense of how the jet expands. Note that the plot is looking at the fully-developed jet, in this case ~4 diameters downstream.\n\n\n\n\n\n\n\nFigure 4: The flowfield and concentration contours for an example turbulent jet, Prandtl mixing length model\n\n\n\n\n\n\nPractical considerations\nIn most practical cases I’ve encountered, by far the easiest approach is to use a standard Gaussian model with parameters from literature. That said, there is a lot of variability of recommended parameters and some thought needs to go into what the model is being used for. Consider the following table giving model parameters for various gaseous jets entering into air.11\n11 Long, “Estimation of the Extent of Hazard Areas Around a Vent,” 7.\\[ {c_A \\over c_0} = k_2 {d_0 \\over z} \\sqrt{ \\rho_a \\over \\rho_j} \\exp \\left( - \\left(k_3 {r \\over z} \\right)^2 \\right) \\]\n\n\n\nJet\nRe\n\\(k_2\\)\n\\(k_3\\)\n\n\n\n\nCO₂\n50 000\n5.4\n9.2\n\n\nN₂\n27 000\n5.4\n7.9\n\n\nHe\n3 400\n4.1\n5.3\n\n\nair + 1.1% towngas\n67 000\n5.3\n8.8\n\n\nhot air\n67 000\n5.3\n8.8\n\n\nair + N₂O tracer\n27-57 000\n4.5\n7.1\n\n\nhot air\n18-25 000\n4.5\n7.1\n\n\nhot air\n30-60 000\n5.3\n7.8\n\n\nhot air\n10-20 000\n5.0\n6.4\n\n\nhot air\n—\n4.8\n7.1\n\n\nhot air\n—\n5.9\n7.7\n\n\n\nBelow is a plot showing the range of values this generates for the Gaussian jet model, along with the recommended parameters for use when estimating the extent of a hazardous area around a vent.12 The range of values is quite wide.\n12 Clearly the recommendation is a conservative approach, which is what you would want for a hazard analysis.\n\n\n\n\n\n\nFigure 5: Gaussian concentration profile, range of values and recommended constants from Long13\n\n13 “Estimation of the Extent of Hazard Areas Around a Vent”.\n\n\nIt is also worth noting that the concentration model breaks down when \\(z<k_2\\), it will register concentrations greater than is possible. The normal way of dealing with this is a so-called top-hat model: chop off any concentrations \\(c_A > c_0\\), though if the region of interest is primarily very close to the hole a different model should be used." + }, + { + "objectID": "posts/turbulent_jet_notes_part_2/index.html#temperature", + "href": "posts/turbulent_jet_notes_part_2/index.html#temperature", + "title": "More on Turbulent Jets", + "section": "Temperature", + "text": "Temperature\nThe temperature profiles follow directly from the velocity profiles in an entirely analogous way to concentration. The temperature profile is given by:\n\\[ {T - T_a \\over T_0 - T_a} = k_T {d_0 \\over z} \\left( \\bar{v}_z \\over \\bar{v}_{z,max} \\right)^{Pr} \\]\nWhere Ta is the ambient temperature, T0 is the jet temperature, and Pr is a turbulent Prandtl number. The constant kT is then determined by an energy balance, again entirely analogously to the case for concentration." + }, + { + "objectID": "posts/turbulent_jet_notes_part_2/index.html#volumetric-flow", + "href": "posts/turbulent_jet_notes_part_2/index.html#volumetric-flow", + "title": "More on Turbulent Jets", + "section": "Volumetric Flow", + "text": "Volumetric Flow\nThe volumetric flow-rate of the jet grows with the downstream distance, as the jet entrains the surrounding fluid. This can be calculated from an integral of the velocity distribution\n\\[ Q = \\int_0^{2\\pi} \\int_0^{\\infty} \\bar{v}_z r dr d\\theta \\]\nrecalling the definition of \\(\\bar{v}_z\\) in terms of the function F and making the change of variables to ξ\n\\[ Q = \\int_0^{2\\pi} \\int_0^{\\infty} {-k \\over z} {F^{\\prime} \\over \\xi} (z \\xi) z d\\xi d\\theta \\]\n\\[ = 2 \\pi k z \\int_0^{\\infty} -F^{\\prime} d\\xi \\]\n\\[ = 2 \\pi k z \\left[ -F\\left(\\xi\\right) \\right]_{0}^{\\infty} \\]\nlet’s define the limit\n\\[ \\lim_{\\xi \\to \\infty} F\\left(\\xi\\right) = F_{\\infty}\\]\nand recall that from boundary conditions F(0)=0, so\n\\[ \\left[ -F\\left(\\xi\\right) \\right]_{0}^{\\infty} = -F_{\\infty} \\]\nrecalling the definition of k\n\\[ k = {v_0 d_0 \\over \\sqrt{8 I} }\\]\nand putting it all together we get\n\\[ Q = -{ 2 \\pi \\over \\sqrt{8 I} } F_{\\infty} v_0 d_0 z \\]\nIt is often more convenient to put things in in dimensionless terms, so let\n\\[ Q_0 = {\\pi \\over 4} v_0 d_0^2 \\]\nwhich gives us\n\\[ {Q \\over Q_0} = - \\sqrt{8 \\over I} F_{\\infty} {z \\over d_0} \\]\nwe can define a new constant kQ\n\\[ k_Q = - \\sqrt{8 \\over I} F_{\\infty} \\]\nand finally\n\\[ {Q \\over Q_0} = k_Q {z \\over d_0} \\]\nWhere \\(k_Q\\) is some constant defined by the particular model and the corresponding model parameter.\nFor the Prandtl mixing length model we can compute this constant numerically\n\na = β/1.2277667062444657\n\nI = integrate(ξ, int)\nF∞ = a^2*sol[1,end]\n\nk_Q = -√(8/I)*F∞\n\n0.3026722591952044\n\n\nFor the eddy viscosity model this can be worked out analytically to be \\[k_Q = {4\\sqrt{3} \\over C_2}\\]\n\nC₂ = 2*√(√(2)-1)/β\n\nk_Q = 4*√3/C₂\n\n0.4564301431180997\n\n\nThe Gaussian model can also be worked out analytically, giving \\[k_Q = {4 \\over \\sqrt{2 c}}\\]\n\nc = log(2)/β^2\n\nk_Q = 4/√(2*c)\n\n0.28808995465769605\n\n\nThe literature14 gives \\(k_Q = 0.32\\) which compares well with the calculations above. Though it’s worth noting that the eddy viscosity model over predicts the volumetric flow rate quite noticeably, this is not surprising considering that it has fatter tails than either the Prandtl mixing length model or the Gaussian model. In the tails is where the eddy viscosity model no longer matches well with the observed data, so this is just a weakness of the model itself.\n14 Rajaratnam, Turbulent Jets." + }, + { + "objectID": "posts/turbulent_jet_notes_part_2/index.html#conclusions", + "href": "posts/turbulent_jet_notes_part_2/index.html#conclusions", + "title": "More on Turbulent Jets", + "section": "Conclusions", + "text": "Conclusions\nThis was just a brief tour of the different parameters that can be calculated from the velocity distribution or stream function, once it is known. In practice, very often the constants encountered along the way are treated like fitting parameters and so it is always worth keeping in mind what the model is being used for and what conditions must be strictly followed or not. For example, if one wants a very accurate fit to concentration, that may result in mass no longer being conserved because a model may not have enough degrees of freedom to fit the one and guarantee the other.\nIf you are not fitting data, there is a wide range of parameters given in the literature and I think it is more important to find a set of parameters that work for the situation of interest – for whichever model they were fit, but generally it is Gaussian – than it is to fiddle around in the margins of whether or not one should use a Prandtl mixing length model versus an eddy viscosity model. Very often the answer is going to be Gaussian because that’s what the best model in the literature was fit to." + }, + { + "objectID": "posts/turbulent_jet_notes_part_2/index.html#references", + "href": "posts/turbulent_jet_notes_part_2/index.html#references", + "title": "More on Turbulent Jets", + "section": "References", + "text": "References\n\n\nBird, R. Byron, Warren E. Stewart, and Edwin N. Lightfoot. Transport Phenomena. 2nd ed. Hoboken, NJ: John Wiley & Sons, 2007.\n\n\nKaye, Nigel B., Abdul A. Khan, and Firat Y. Testik. “Environmental Fluid Mechanics.” In Handbook of Environmental Engineering, edited by Myer Kutz. New York: John Wiley & Sons, 2018.\n\n\nLong, V. D. “Estimation of the Extent of Hazard Areas Around a Vent.” Second Symposium On Chemical Process Hazards, 1963, 6–14.\n\n\nRajaratnam, N. Turbulent Jets. Amsterdam: Elsevier, 1974." + }, + { + "objectID": "posts/building_infiltration_2/index.html", + "href": "posts/building_infiltration_2/index.html", + "title": "Building Infiltration Example – Chlorine Release", + "section": "", + "text": "In a previous notebook, I explored building infiltration due to forest fire smoke and noted that ambient conditions would impact the scenario, in this scenario I continue exploring building infiltration and examine the sensitivity of infiltration to changes in ambient conditions. In this case the scenario is a release of chlorine from a storage cylinder creating a cloud that moves downwind to a building. We would like to know what impact this has on the interior conditions of the building while also taking the opportunity to evaluate the impact of changes in ambient weather conditions." + }, + { + "objectID": "posts/building_infiltration_2/index.html#the-scenario", + "href": "posts/building_infiltration_2/index.html#the-scenario", + "title": "Building Infiltration Example – Chlorine Release", + "section": "The Scenario", + "text": "The Scenario\nThe scenario is the catastrophic failure of a liquid chlorine cylinder, perhaps one being used as part of a water treatment facility. The release is outdoors and a small building is downwind of the release, which can be occupied and so the infiltration of chlorine is important.\n\nRelease Parameters\nFor simplicity suppose the entire contents of the cylinder are released essentially immediately and form a neutrally buoyant cloud. The mass of the release is the mass of chlorine in the cylinder which we can assume to be 68kg. CAMEO Chemicals lists the three emergency response planning guideline (ERPG) levels for chlorine as 1ppm, 3ppm, and 20ppm respectively.\nSuppose the release is 1m off the ground and is otherwise the center of the coordinate system.\n\n\nBuilding Parameters\nThe building is a small one story structure 100m downwind of the release point. For the scenario it will be taken as a given that the building volume is 255 m³ and the equivalent leak area is 690 cm². For simplicity any obstructions around the building are ignored.\n\n\nWeather Parameters\nFor neutrally buoyant gaussian dispersion a class F Pasquill stability is the worst case scenario, this leads to the least dispersion and thus greatest concentrations downwind. The windspeed is initially assumed to be 1.5 m/s.\n\n\n\nm\n68 kg\n\n\nerpg-1\n1 ppm\n\n\nerpg-2\n3 ppm\n\n\nerpg-3\n20 ppm\n\n\nh\n1 m\n\n\nd\n100 m\n\n\n\\(A_l\\)\n6.90×10⁻² m²\n\n\nV\n255 m³\n\n\nstability\nF\n\n\nu\n1.5 m/s\n\n\n\n\nusing Contour, Plots, OrdinaryDiffEq, Statistics, SpecialFunctions, ForwardDiff, Roots\n\n\nm = 68.0 # mass of chlorine released, kg\nMW = 70.9 # molar mass chlorine, kg/kmol\nMVC = 24.465 # molar volume at 25C and 1atm, m3/kmol\n\nppm_to_kg(ppm) = (ppm*1e-6)*(MW/MVC)\nerpg1 = ppm_to_kg(1) # erpgs for chlorine, kg/m3\nerpg2 = ppm_to_kg(3)\nerpg3 = ppm_to_kg(20)\n\nh = 1.0 # height of release point, m\n\nd = 100.0 # downwind distance to building, m\nAₗ = 6.90e-2 # equivalent leak area of building, m2\nV = 255.0 # volume of building, m3\n\nu = 1.5 # windspeed m/s" + }, + { + "objectID": "posts/building_infiltration_2/index.html#air-dispersion", + "href": "posts/building_infiltration_2/index.html#air-dispersion", + "title": "Building Infiltration Example – Chlorine Release", + "section": "Air Dispersion", + "text": "Air Dispersion\n\nGaussian Puff Model\nThe simplest model for a neutrally buoyant cloud is a gaussian disperison model, in this case because the cylinder is assumed to fail catastrophically the release can be treated as instantaneous and so we use a gaussian puff model.\nIt is often worth-while to estimate the initial dimensions of the cloud and then calculate a virtual emission point from which the release is assumed to take place. This is especially useful if the area immediately around the release point is of interest as the gaussian model assumes all of the mass is initially concentrated in a single point. However for a simple screening just using the default dispersion model is likely fine, and more conservative. The model gives the concentration as a gaussian distribution in the x, y, and z directions, while also adding in a term to account for ground reflection (mass cannot disperse below groundlevel)1\n1 AIChE/CCPS, Guidelines for Use of Vapour Cloud Dispersion Models, 75.\\[ c_{puff}(x,y,z,t) = { m \\over { (2 \\pi)^{3/2} \\sigma_x \\sigma_y \\sigma_z } }\n\\exp \\left( -\\frac{1}{2} \\left( {x - ut} \\over \\sigma_x \\right)^2 \\right)\n\\exp \\left( -\\frac{1}{2} \\left( {y} \\over \\sigma_y \\right)^2 \\right) \\]\n\\[ \\times \\left[ \\exp \\left( -\\frac{1}{2} \\left( {z - h} \\over \\sigma_z \\right)^2 \\right) + \\exp \\left( -\\frac{1}{2} \\left( {z + h} \\over \\sigma_z \\right)^2 \\right)\\right]\\]\nThe parameters σx, σy, and σz are generally found using empirically derived correlations that are functions of the downwind distance to the center of the puff xc and atmospheric stability.\n\nfunction c_puff(x,y,z,t; # point in space\n m, u, h, # parameters of the problem\n σx::Function, σy::Function, σz::Function)\n xc = u*t # center of the cloud\n sx = σx(xc)\n sy = σy(xc)\n sz = σz(xc)\n \n C1 = m / ( (2*π)^(1.5) * sx * sy * sz )\n C2 = exp(-0.5*((x-xc)/sx)^2)\n C3 = exp(-0.5*(y/sy)^2)\n C4 = ( exp(-0.5*((z-h)/sz)^2) + exp(-0.5*((z+h)/sz)^2) )\n \n c = C1*C2*C3*C4\n \n return isnan(c) ? 0.0 : c\nend\n\n\n\nDispersion Parameters\nThe dispersion parameters for puff models are not, in general, as well developed as for plume models, the following values were interpolated from a sparser set of correlations and it is worth keeping in mind. It is also worth noting that the dispersion parameters are where the impact of different windspeeds will be made most apparent as stability is a function of windspeed.\n\nDispersion parameters for a Gaussian puff model2\n\n\n\n\n\n\n\n\n\nStability\n\\(\\sigma_x = \\sigma_y\\)\n\\(\\sigma_z\\)\nMax Windspeed\n\n\n\n\nA\n$ 0.18 x^{0.92} $\n$ 0.60 x^{0.75} $\n3 m/s\n\n\nB\n$ 0.14 x^{0.92} $\n$ 0.53 x^{0.73} $\n5 m/s\n\n\nC\n$ 0.10 x^{0.92} $\n$ 0.34 x^{0.71} $\n6 m/s\n\n\nD\n$ 0.06 x^{0.92} $\n$ 0.15 x^{0.70} $\n\n\n\nE\n$ 0.04 x^{0.92} $\n$ 0.10 x^{0.65} $\n5 m/s\n\n\nF\n$ 0.02 x^{0.89} $\n$ 0.05 x^{0.61} $\n3 m/s\n\n\n\n\n# Class F \n\nσx(x) = 0.02*x^0.89\nσy(x) = 0.02*x^0.89\nσz(x) = 0.05*x^0.61\n\n\n2 AIChE/CCPS, Guidelines for Use of Vapour Cloud Dispersion Models.\n\nOutdoor Concentration\nThe outdoor concentration can be calculated at any point using the above equations and below is an animation showing the cloud moving from the release point down to the location of the center of the building. The horizontal slice is at 1m elevation, and the vertical slice is at 0m crosswind distance, the center planes of the cloud.\nAs is clear, for a very stable cloud the dispersion is small relative to the distance traveled (the cross wind distance is exaggerated for visibility) and the bulk of the mass of chlorine is contained in a 10m diameter ball by the time it reaches the building.\n\n\n\n[ Info: Saved animation to /home/allan/Code/notes/notebooks/tmp.gif\n\n\n\n\n\n\n\n\n\nFigure 1: Dispersion of an instantaneous release of chlorine.\n\n\n\n\nThe concentration as a function of time also shows how brief the exposure is at this point. A rapid pulse of chlorine passes over the area and is gone within a few seconds. However the concentration at that time is millions of times larger than the ERPG-1 limit (note the units, the scale is in kg/m³ whereas the ERPG limits are on the order of mg/m³)\n\n\n\n\n\n\n\n\nFigure 2: Outdoor concentration at the receptor as a function of time\n\n\n\n\n\nThe concentration along the centerline of the cloud is also worth looking at, as this provides some context for the extent of the downwind impacts of the release.\n\n\n\n\n\n\n\n\nFigure 3: Centerline concentration of the puff as a function of travel distance.\n\n\n\n\n\nDownwind distance to ERPG-3 (outdoors) 13.5km\nDownwind distance to ERPG-2 (outdoors) 29.9km\nDownwind distance to ERPG-1 (outdoors) 47.3km\n\n\nClearly this release presents a serious hazard, one would have to travel downwind over 10km to be below the ERPG-3 line and nearly 50km to be below the ERPG-1 line. Though, keep in mind, this is for instantaneous exposure and not overall dose." + }, + { + "objectID": "posts/building_infiltration_2/index.html#building-infiltration", + "href": "posts/building_infiltration_2/index.html#building-infiltration", + "title": "Building Infiltration Example – Chlorine Release", + "section": "Building Infiltration", + "text": "Building Infiltration\n\nSingle Zone Model\nThe single zone model assumes the air within the building is generally well mixed and well described by a single concentration. This is approximately true over long timescales, however in the situation of the brief pulse of chlorine passing over the building this assumption may breakdown and is a critical weakness of the discussion that follows.\nVery likely in the ~10s it takes for the cloud to pass very little of it will have had time to diffuse into the interior space of the building and the interior mixing (or lack thereof) will be a significant slow step in the overall mass transfer.\nThe single zone model, however, will work as a first pass at least, and in this model the interior concentration is related to the outside concentration by the following ODE3\n3 Lees, Loss Prevention in the Process Industries, sec. 15.51.\\[\\frac{d}{dt} c_i(t) = f \\left( c_i, \\lambda, t \\right) = \\lambda \\cdot \\left( c_o(t) - c_i(t) \\right) \\]\nWhere cᵢ is the inside concentration, cₒ the outside concentration, and λ the natural ventilation rate of the building.\nThe natural ventilation rate itself is a function of windspeed, the temperature difference between inside and outside, and how leaky the building is.\nThe model is defined in a more generic way for now as this will be more useful later.\n\nf(cᵢ, λ, t; cₒ=zero) = λ*(cₒ(t) - cᵢ)\n\nf(g) = (cᵢ, λ, t) -> f(cᵢ, λ, t; cₒ=g)\n\n\n\nSimplified ASHRAE Model\nThe last parameter we need to estimate before solving the problem is the ventilation rate, λ, which can be estimated using the simplified ASHRAE model4\n4 2017 ASHRAE Handbook - Fundamentals (SI Edition), chap. 16.\\[\\lambda = \\frac{Q}{V} \\\\\nQ = A_L \\sqrt{ C_s \\vert \\Delta T \\vert + C_w u^2 } \\\\\n\\lambda = \\frac{A_L}{V} \\sqrt{ C_s \\vert \\Delta T \\vert + C_w u^2 } \\]\nWhere \\(A_L\\) and \\(V\\) were given earlier, \\(C_s\\) and \\(C_w\\) are tabulated constants, \\(\\Delta T\\) is the difference between indoor and outdoor temperatures, in K, and u the windspeed, in m/s, and the ventilation rate is in s⁻¹.\nIn this case the indoor and outdoor temperature are assumed to be the same for simplicity5\n5 Note that the constants have been adjusted such that the leak area is in m², in the ASHRAE handbook the leak area is in cm²\n\n\n\nShelter Class\n1 Story\n2 Story\n3 Story\n\n\n\n\n\\(C_s\\)\nall\n14.5×10⁻³\n29.0×10⁻³\n43.5×10⁻³\n\n\n\\(C_w\\)\n1\n31.9×10⁻³\n42.0×10⁻³\n49.4×10⁻³\n\n\n\n2\n24.6×10⁻³\n32.5×10⁻³\n38.2×10⁻³\n\n\n\n3\n17.4×10⁻³\n23.1×10⁻³\n27.1×10⁻³\n\n\n\n4\n10.4×10⁻³\n13.7×10⁻³\n16.1×10⁻³\n\n\n\n5\n3.20×10⁻³\n4.20×10⁻³\n4.90×10⁻³\n\n\n\nWith the shelter class defined as 1. No obstructions or local shielding 2. Isolated rural home 3. Another building across the street 4. Urban buildings on larger lots, with the nearest building >1 building height away 5. Immediately adjacent buildings (closer than 1 building height)\nFor this scenario we are assuming the (one-story) building is isolated and there are no obstructions or local shielding, so the class is 1.\n\n# 1 Story building, shelter class 1\n\nλ(ΔT, u; Aₗ, V, Cs, Cw) = (Aₗ/V)*√(Cs*abs(ΔT)+Cw*u^2)\n\nλ₀ = λ(0.0, u, Aₗ=Aₗ, V=V, Cs=14.5e-3, Cw=31.9e-3)\n\n7.249290622734888e-5" + }, + { + "objectID": "posts/building_infiltration_2/index.html#indoor-concentration", + "href": "posts/building_infiltration_2/index.html#indoor-concentration", + "title": "Building Infiltration Example – Chlorine Release", + "section": "Indoor Concentration", + "text": "Indoor Concentration\nThe indoor concentration is calculated simply by solving the ODE with the initial condition that the indoor concentration is zero. Since the pulse of chlorine is so brief it is important to be careful with the integrator, typical variable step integrators can step right over brief pulses and miss them entirely. To counteract that the first phase of the response, up until twice the time it takes for the wind to travel the distance, is solved using a maximum timestep of 1s, the remainder is left free to the solver to adjust as necessary to meet the tolerances.\n\n# puff model, note the units have changed to mg/m³\n\ncₒ(t) = c_puff(d, 0, h, t; m=m, u=u, h=h, σx=σx, σy=σy, σz=σz)*1e6\n\nc0 = 0.0 # initial condition\nsys = f(cₒ)\n\ntsp1 = (0.0, 2*d/u)\nprb1 = ODEProblem(sys, c0, tsp1, λ₀)\nsln1 = solve(prb1, Tsit5(), dtmax=1);\n\n\nc0_2 = sln1[end]\ntsp2 = (tsp1[end], 2.5*(1/λ₀))\nprb2 = ODEProblem(sys, c0_2, tsp2, λ₀)\nsln2 = solve(prb2, Tsit5());\n\nThis is a very conservative model. It assumes all of the mass transfer occurs at a point, which happens to be where the maximum outdoor concentration will be, and that interior mixing is essentially instantaneous. It also has a significant weakness in that it assumes the building does not impede the movement of the cloud. If the building is very small relative to the cloud this might be reasonable, but in this case the cloud is quite concentrated and the deflection around the building is important.\nThe first assumption can be moderated by taking an average outdoor concentration over the building footprint. In this case I assume the building is a 10m×10m square just for demonstration. This tries to capture the reality that not all of the building is being exposed to an equally high concentration and that an effective outdoor concentration might be better estimated as a spatial average. These points are hanging in space at the centerline of the cloud and as such are exposed to the highest concentrations. Instead of a slice like this, a more fully featured box could be used, but at that point it would probably be more useful to look into the ways the building itself is deflecting and shaping the cloud. Recall that the cloud is essentially passing through the building in this model.\n\nbox_x = (d-5):1:(d+5)\nbox_y = -5:1:5\n\nfunction box_average(t)\n cs = [ c_puff(x, y, h, t; m=m, u=u, h=h, σx=σx, σy=σy, σz=σz)*1e6\n for x in box_x, y in box_y ]\n return mean(cs)\nend\n\n\nsys_avg = f(box_average)\n\ntsp_box = (0.0, 2*d/u)\nprb_box = ODEProblem(sys_avg, c0, tsp_box, λ₀)\nsln_box = solve(prb_box, Tsit5(), dtmax=1);\n\n\nc0_box2 = sln_box[end]\ntsp_box2 = (tsp_box[end], 2.5*(1/λ₀))\nprb_box2 = ODEProblem(sys_avg, c0_box2, tsp_box2, λ₀)\nsln_box2 = solve(prb_box2, Tsit5());\n\n\n\n\n\n\n\n\n\nFigure 4: Indoor concentrations assuming a linear ventillation model, showing both the building as a point source and as averaged box.\n\n\n\n\n\nIn either the single point or averaged outdoor concentration models the indoor concentration rapidly rises above the ERPG-3 limit, which is very bad, and then slowly decays over time6. In this case almost immediately after the cloud has passed the indoor space is more concentrated in Chlorine than the outside air. At the very least this suggests that the building is not a good shelter in place location, or at least a much more detailed analysis of building infiltration would be needed to show that it was a good shelter in place location.\n6 Note that the indoor concentrations are in mg/m³ whereas the outdoor concentration peaks in the kg/m³, so the building is doing something, it is reducing the indoor concentration by several orders of magnitude, it just isn’t enough" + }, + { + "objectID": "posts/building_infiltration_2/index.html#sensitivity", + "href": "posts/building_infiltration_2/index.html#sensitivity", + "title": "Building Infiltration Example – Chlorine Release", + "section": "Sensitivity", + "text": "Sensitivity\nFor the scenario modeling I approached the problem in a very general way such that the methods for solving the indoor concentration didn’t depend explicitly upon the model of the outdoor concentration. Which is why it was solved numerically. This is a very useful way of approaching things, especially from a code re-use point of view.\nHowever, in this particular case, if we take the outdoor concentration to be simply the gaussian puff model at a single point then this ODE can be solved analytically and that is useful for exploring the system’s sensitivity to windspeed, atmospheric stability, etc.\nStarting with the original puff model for the outside concentration\n\\[ c_{o}(x,y,z,t) = { m \\over { (2 \\pi)^{3/2} \\sigma_x \\sigma_y \\sigma_z } }\n\\exp \\left( -\\frac{1}{2} \\left( {x - ut} \\over \\sigma_x \\right)^2 \\right)\n\\exp \\left( -\\frac{1}{2} \\left( {y} \\over \\sigma_y \\right)^2 \\right) \\]\n\\[ \\times \\left[ \\exp \\left( -\\frac{1}{2} \\left( {z - h} \\over \\sigma_z \\right)^2 \\right) + \\exp \\left( -\\frac{1}{2} \\left( {z + h} \\over \\sigma_z \\right)^2 \\right)\\right] \\]\nWe can split this into the product of three gaussians:\n\\[ c_{o}(x,y,z,t) = m \\left[{ \\exp \\left( -\\frac{1}{2} \\left( {x - ut} \\over \\sigma_x \\right)^2 \\right) \\over { \\sqrt{2 \\pi} \\sigma_x } } \\right]\n\\left[{ \\exp \\left( -\\frac{1}{2} \\left( {y} \\over \\sigma_y \\right)^2 \\right) \\over { \\sqrt{2 \\pi} \\sigma_y } } \\right] \\]\n\\[ \\times { 1 \\over { \\sqrt{2 \\pi} \\sigma_z } } \\left[ \\exp \\left( -\\frac{1}{2} \\left( {z - h} \\over \\sigma_z \\right)^2 \\right) + \\exp \\left( -\\frac{1}{2} \\left( {z + h} \\over \\sigma_z \\right)^2 \\right)\\right]\\]\n\\[ c_{o}(x,y,z,t) = m C_x(x, t) C_y(x, y) C_z(x, z)\\]\nand noting that only \\(C_x\\) depends on time we can collect the other stuff into a big constant called \\(C_1\\), giving usI am assuming the dispersion parameters are all constant, this is not strictly true as they all depend upon the downwind location of the center of the puff, which is a function of time\n\\[ c_{o}(t) = C_1 { 1 \\over { \\sqrt{2 \\pi} \\sigma_x } }\\exp \\left( -\\frac{1}{2} \\left( {x - ut} \\over \\sigma_x \\right)^2 \\right) \\]\nthis is a gaussian in time, let \\(\\mu = \\frac{x}{u}\\) and \\(\\sigma_t = \\frac{\\sigma_x}{u}\\)\n\\[ c_{o}(t) = { C_1 \\over u } { 1 \\over { \\sqrt{2 \\pi} \\sigma_t } }\\exp \\left( -\\frac{1}{2} \\left( {t - \\mu} \\over \\sigma_t \\right)^2 \\right) \\]\nsuppose the Laplace transform of this is \\(C_{o}(s)\\), and taking the Laplace transform of the ODE we arrive at\n\\[ C_{i}(s) = { \\lambda \\over {s + \\lambda} } C_{o}(s) \\]\nwhere \\(C_{i}(s)\\) is the Laplace transform of \\(c_{i}(t)\\), inverting the Laplace transform leads us to conclude\n\\[ c_{i}(t) = { C_1 \\over u } \\int_{0}^{\\infty} \\lambda \\exp \\left( -\\lambda \\left(t - \\tau \\right) \\right)\n{ 1 \\over { \\sqrt{2 \\pi} \\sigma_t } }\\exp \\left( -\\frac{1}{2} \\left( {\\tau - \\mu} \\over \\sigma_t \\right)^2 \\right) d\\tau\\]\nthat is, the solution is the convolution of the exponential and gaussian times some constants. Conveniently for us this is a well known integral and we can just look up the answer in a book\n\\[ c_{i}(t) = { C_1 \\over u } \\frac{\\lambda}{2} \\exp \\left( \\frac{\\lambda}{2} \\left( 2\\mu + \\lambda \\sigma_t^2 - 2t \\right) \\right) \\mathrm{erfc} \\left( { \\mu + \\lambda \\sigma_t^2 -t } \\over { \\sqrt{2} \\sigma_t } \\right) \\]\nwhere \\(\\mathrm{erfc(x)}\\) is the complementary error function \\(1 - \\mathrm{erf}(x)\\)\nYou could expand all this back out, but it is far more compact and readable in this form, especially when written out as code\n\nfunction cᵢ(x,y,z,t; # point in space\n m, u, h, λ, # parameters of the problem\n σx::Function, σy::Function, σz::Function)\n \n sx = σx(x)\n sy = σy(x)\n sz = σz(x)\n \n # time independent part\n C1 = m / (2*π*sy*sz)\n C1 *= exp(-0.5*(y/sy)^2)\n C1 *= ( exp(-0.5*((z-h)/sz)^2) + exp(-0.5*((z+h)/sz)^2) )\n \n # the convolution\n μ = x/u\n σₜ = sx/u\n \n c = (C1*λ)/(2*u)\n c *= exp(0.5*λ*(2*μ+λ*σₜ^2-2*t))\n c *= erfc((μ+λ*σₜ^2-t)/(√(2)*σₜ))\n \n return isnan(c) ? 0.0 : c\nend\n\nFrom here we can use automatic differentiation to find the max concentration, for the original case\n\n# at the point x=d, y=0, z=h, note the units in mg/m³\n\nc(t) = cᵢ(d, 0, h, t, m=m, u=u, h=h, λ=λ₀, σx=σx, σy=σy, σz=σz)*1e6\n\n∂c∂t(t) = ForwardDiff.derivative(t -> c(t), float(t))\n \ntₘₐₓ = find_zero(∂c∂t, d/u)\n\ncₘₐₓ = c(tₘₐₓ)\n\ntₘₐₓ, cₘₐₓ\n\n(70.04333860265841, 551.5422273514416)\n\n\nAn alternative is to make the approximation \\(\\mathrm{erfc}(-x) \\approx 2 H(x)\\), where \\(H(x)\\) is the Heaviside step function ( \\(\\mathrm{erfc}(-x)\\) runs from 0 to 2 and \\(H(x)\\) runs from 0 to 1 hence the factor of 2)\n\\[ c_{i}(t) = { C_1 \\over u } \\frac{\\lambda}{2} \\exp \\left( \\frac{\\lambda}{2} \\left( 2\\mu + \\lambda \\sigma_t^2 - 2t \\right) \\right) \\mathrm{erfc} \\left( { \\mu + \\lambda \\sigma_t^2 -t } \\over { \\sqrt{2} \\sigma_t } \\right) \\]\n\\[ = { C_1 \\over u } \\frac{\\lambda}{2} \\exp \\left( { \\left( \\lambda \\sigma_t \\right)^2 \\over 2 } \\right) \\exp \\left( -\\lambda \\left( t - \\mu \\right) \\right) \\mathrm{erfc} \\left( { \\mu + \\lambda \\sigma_t^2 -t } \\over { \\sqrt{2} \\sigma_t } \\right) \\]\n\\[ \\approx { \\lambda C_1 \\over u } \\exp \\left( { \\left( \\lambda \\sigma_t \\right)^2 \\over 2 } \\right) \\exp \\left( -\\lambda \\left( t - \\mu \\right) \\right) H( t - \\mu - \\lambda \\sigma_t^2 ) \\]\n\\[ \\approx { \\lambda C_1 \\over u } \\exp \\left( { \\left( \\lambda \\sigma_t \\right)^2 \\over 2 } \\right) \\exp \\left( -\\lambda \\left( t - \\mu \\right) \\right) H( t - \\mu )\n\\]\nthe maximum of this is clearly\n\\[ c_{max} = { \\lambda C_1 \\over u } \\exp \\left( { \\left( \\lambda \\sigma_t \\right)^2 \\over 2 } \\right) \\]\nand occurs when \\(t = \\mu\\)\n\nlet x=d, y=0, z=h, λ=λ₀\n sx = σx(x)\n sy = σy(x)\n sz = σz(x)\n μ = x/u\n σₜ = sx/u\n \n C1 = m / (2*π*sy*sz)\n C1 *= exp(-0.5*(y/sy)^2)\n C1 *= ( exp(-0.5*((z-h)/sz)^2) + exp(-0.5*((z+h)/sz)^2) )\n \n c = (λ*C1)/u\n c *= exp(0.5*(λ*σₜ)^2)\n \n return (μ, c*1e6)\nend\n\n(66.66666666666667, 551.6845234598728)\n\n\nWe can compare the solution from the ODE solver with the two approximations – the convolution and the step-function approximation – to convince ourselves that we are capturing the dynamics well.\n\n\n\n\n\n\n\n\nFigure 5: Approximations to the full integration of the linear ventillation model.\n\n\n\n\n\n\nAtmospheric stability\nThe sensitivity to atmospheric stability, at the max windspeed given in the table of dispersion parameters above, is shown below. Clearly the indoor concentration depends strongly on the atmospheric stability, and that makes sense as more unstable conditions lead to far greater mixing and thus lower outdoor concentrations. This effect is far greater than any increase in ventilation rate due to the greater windspeeds.\n\n\n\n\n\n\n\n\nFigure 6: Sensitivity of max indoor concentration to atmospheric stability\n\n\n\n\n\n\n\nWindspeed\nReturning to the original scenario parameters we explore the impact of changing the windspeed, while assuming the atmospheric stability remains constant, in the figure below. In this case windspeed impacts both the parameters of the gaussian puff model and the natural ventilation rate in the single zone building infiltration model.\n\n\n\n\n\n\n\n\nFigure 7: Sensitivity of indoor concentration to outdoor windspeed.\n\n\n\n\n\nThis was, to me, a surprising result. I expected the max indoor concentration to depend strongly on the windspeed whereas it appears to have far more to do with how quickly the indoor build up of Chlorine dissipates. This is due to the difference in timescales between the puff passing over the building and the single zone building infiltration model.\nThe time-scale of interest for the puff model is the width of the gaussian \\(\\sigma_t\\), whereas the time-scale of interest for the building infiltration model is the time constant of the exponential decay \\(\\tau = \\frac{1}{\\lambda}\\), for this situation with a class F atmospheric stability they are\n\nσₜ = σx(d)/u\n\n0.8034127814324771\n\n\n\nτ = 1/λ₀\n\n13794.453168477568\n\n\n\nσₜ/τ\n\n5.82417274262381e-5\n\n\nThe time it takes the cloud to pass is simply orders of magnitude faster than the response of the infiltration model. So, in effect, the puff is acting like a impulse – causing a step change – and changing the windspeed merely moves the time at which that step change takes place. The faster the windspeed the better the approximation $ (-x) H(x) $ gets and if we take another look at the approximation for max concentration, we see it is independent of windspeed.\nNote that for the simplified ASHRAE model with \\(\\Delta T = 0\\) the natural ventilation rate is directly proportional to windspeed, i.e. $ = k u $ where k is all the vaious constants of that model collected for convenience, plugging this into the approximation we find the windspeed cancels out entirely.\n\\[ { \\lambda C_1 \\over u } \\exp \\left( { \\left( \\lambda \\sigma_t \\right)^2 \\over 2 } \\right) \\]\n\\[ = { { k u C_1 } \\over u } \\exp \\left( { \\left( k u \\frac{\\sigma_x}{u} \\right)^2 \\over 2 } \\right) \\]\n\\[ = k C_1 \\exp \\left( { \\left( k \\sigma_x \\right)^2 \\over 2 } \\right) \\]\nWhich is independent of windspeed.\nWe can perhaps see how small the effect is more clearly by directly varying the ratio \\({ \\sigma_t \\over \\tau}\\) while keeping \\(\\mu\\) constant and looking at the response. The maximum indoor concentration does change, but only slightly.\n\n\n\n\n\n\n\n\nFigure 8: Sensitivity of the indoor concentration to the ratio σt/τ\n\n\n\n\n\nWhich is not to say the model is insensitive to the natural ventilation rate, merely that for a given building the impact of changing windspeed on the ventilation rate is canceled out by the change in the outdoor concentration profile.\n\n\nEquivalent Leak Area\nBelow the equivalent leak area \\(A_L\\) is varied while keeping all other parameters constant. Unsurprisingly building tightness matters, and the impact is approximately linear. This is intuitive, of course, because the outside concentration does not depend in any way on the leakage area and so there is no canceling of effects like was seen with windspeed, and of course having more leaks leads to more of the outside air getting inside.\n\n\n\n\n\n\n\n\nFigure 9: Sensitivity of indoor concentration to the leakage area.\n\n\n\n\n\n\n\nTemperature\nA similar effect as was seen with equivalent leak area is present with changes in the temperature difference between indoors and outdoors. Though in this case the change goes with the square-root of the temperature difference.\n\n\n\n\n\n\n\n\nFigure 10: Sensitivity of indoor concentration to the indoor-outdoor temperature difference\n\n\n\n\n\nOn a summer day like today large temperature differences like this might seem extreme, but in the depths of winter when days around here routinely get to -30°C a standard heated building with a normal indoor temperature around 20°C would have a 50°C temperature difference with the outdoors.\n\n\nDistance\nThe most obvious case of interest, however, is how far downwind would the building have to be such that it did not exceed the ERPG limits. With all other parameters equal to the scenario, the max indoor concentration as a function of building distance is shown in the figure below.\n\n\n\n\n\n\n\n\nFigure 11: Max indoor concentration as a function of downwind distance to the receptor.\n\n\n\n\n\nDownwind distance to ERPG-3 (indoors) 625.0m\nDownwind distance to ERPG-2 (indoors) 2383.0m\nDownwind distance to ERPG-1 (indoors) 5006.0m\n\n\nAs was seen above when examining the outdoor concentrations, this is a significant release and the downwind distance is large. This also shows the value of sheltering in place as an unprotected individual would have to travel downwind for many tens of kilometers to reach a safe distance, whereas indoors that is greatly reduced.\nAgain, this is all considering the instantaneous concentration and not considering dose." + }, + { + "objectID": "posts/building_infiltration_2/index.html#final-remarks", + "href": "posts/building_infiltration_2/index.html#final-remarks", + "title": "Building Infiltration Example – Chlorine Release", + "section": "Final Remarks", + "text": "Final Remarks\nThe results of the scenario speak to something the previous discussion noted, namely that after the cloud has passed the indoor concentration exceeds the outdoor concentration and so an important response, post release, is not just when to shelter in place but when to stop.\nThe worst case indoor concentration far exceeded the ERPG-3 limit, however it was still significantly lower exposure than had one been standing outside. But once the chlorine had leaked into the building it would take hours to fully clear purely by natural ventilation. At that point it would be far more prudent to leave the building and to turn the ventilation system back on. Identifying when this is the case is an interesting problem, because there are no guarantees that the emergency outside the building is fully resolved merely because the toxic release has passed. So one must balance the risks of sheltering in a place that is no longer safe versus evacuating into a potentially dangerous environment. In such a scenario it may be prudent to have indoor and outdoor air monitoring in the shelter in place location and a store of emergency escape respirators, though a better solution would be to move the shelter in place to a safer location.\nThese two examples cover two extremes of building infiltration, the forest fire smoke looked at enormous clouds that take hours to pass and this chlorine example covers very concentrated clouds which pass in under a minute. Most real scenarios at a chemical plant or other facility are likely to be between these extremes, but the same tools would apply." + }, + { + "objectID": "posts/building_infiltration_2/index.html#references", + "href": "posts/building_infiltration_2/index.html#references", + "title": "Building Infiltration Example – Chlorine Release", + "section": "References", + "text": "References\n\n\n2017 ASHRAE Handbook - Fundamentals (SI Edition). Atlanta, GA: American Society of Heating, Refrigerating; Air-Conditioning Engineers, 2017.\n\n\nAIChE/CCPS. Guidelines for Use of Vapour Cloud Dispersion Models. 2nd ed. New York: American Institute of Chemical Engineers, 1996.\n\n\nLees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996." + }, + { + "objectID": "posts/atmotube_data_logging/index.html", + "href": "posts/atmotube_data_logging/index.html", + "title": "Logging data from an Atmotube PRO over Bluetooth", + "section": "", + "text": "I have had an Atmotube Pro for a few years, mostly using it during the summer to keep an eye on poor air quality during wildfire smoke events. I often export data from it, as a csv, to noodle around, but I haven’t really looked at how to log data directly from it with my laptop. Atmotube provides documentation on the bluetooth API and a guide for how to set up an MQTT router. But I couldn’t really find anything on just logging data from it directly, using python.\nThus 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." + }, + { + "objectID": "posts/atmotube_data_logging/index.html#requesting-data-with-gatt", + "href": "posts/atmotube_data_logging/index.html#requesting-data-with-gatt", + "title": "Logging data from an Atmotube PRO over Bluetooth", + "section": "Requesting data with GATT", + "text": "Requesting data with GATT\nThe 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.\nI 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\n\nimport time\n\n\nfrom bleak import BleakScanner, BleakClient\n\n\n# some constants\nATMOTUBE = \"C2:2B:42:15:30:89\" # the mac address of my Atmotube\nSGPC3_UUID = \"DB450002-8E9A-4818-ADD7-6ED94A328AB4\"\nBME280_UUID = \"DB450003-8E9A-4818-ADD7-6ED94A328AB4\"\nSPS30_UUID = \"DB450005-8E9A-4818-ADD7-6ED94A328AB4\"\nSTATUS_UUID = \"DB450004-8E9A-4818-ADD7-6ED94A328AB4\"\n\nThe 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.\n\nasync def scan_and_connect(address):\n device = await BleakScanner.find_device_by_address(address)\n if not device:\n print(\"Device not found\")\n return None\n\n async with BleakClient(device) as client:\n stat = await client.read_gatt_char(STATUS_UUID)\n bme = await client.read_gatt_char(BME280_UUID)\n sgp = await client.read_gatt_char(SGPC3_UUID)\n sps = await client.read_gatt_char(SPS30_UUID)\n ts = time.time()\n return (ts, stat, bme, sgp, sps)\n\nI 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.\n\nres = await scan_and_connect(ATMOTUBE)\n\nThe easiest way to unpack a sequence of bytes is to use the struct standard library. But there are two exceptions:\n\nThe info byte is 8-bits where each bit corresponds to a particular flag. I could pull out each bit one by one using bit-shifting or something, but using a ctype struct lets me map the whole two-byte status characteristic into the various info flags and the battery state in one clean step.\n\n\nimport struct\n\n\nfrom ctypes import LittleEndianStructure, c_uint8, c_int8\n\nclass InfoBytes(LittleEndianStructure):\n _fields_ = [\n (\"pm_sensor\", c_uint8, 1),\n (\"error\", c_uint8, 1),\n (\"bonding\", c_uint8, 1),\n (\"charging\", c_uint8, 1),\n (\"charge_timer\", c_uint8, 1),\n (\"bit_6\", c_uint8, 1),\n (\"pre_heating\", c_uint8, 1),\n (\"bit_8\", c_uint8, 1),\n (\"batt_level\", c_uint8, 8),\n ]\n\n\nThe PM characteristic is a 12-byte sequence where each set of 3-bytes is a 24-bit integer. This is not an integer type that is natively supported by python. I thought I could do the same thing as the Status characteristic and map it onto a ctype struct, but that didn’t work. As a work-around I collect each 3-byte sequence as arrays and convert each to an int as a two-step process. I could also have used int.from_bytes() directly, but I think this is a little neater and easier to read.\n\n\nclass PMBytes(LittleEndianStructure):\n _fields_ = [\n ('_pm1', c_int8*3),\n ('_pm2_5', c_int8*3),\n ('_pm10', c_int8*3),\n ('_pm4', c_int8*3), \n ]\n _pack_ = 1\n\n @property\n def pm1(self):\n return int.from_bytes(self._pm1, 'little', signed=True)\n\n @property\n def pm2_5(self):\n return int.from_bytes(self._pm2_5, 'little', signed=True)\n\n @property\n def pm10(self):\n return int.from_bytes(self._pm10, 'little', signed=True)\n\nWith 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.\nFinally 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.\n\nHEADERS = [\"Timestamp\", \"VOC\", \"RH\", \"T\", \"P\", \"PM1\", \"PM2.5\", \"PM10\"]\n\n\ndef process_gatt_data(data):\n result = dict.fromkeys(HEADERS)\n if res is not None:\n ts, stat, bme, sgp, sps = data\n result[\"Timestamp\"] = ts\n\n # Info and Battery data\n inf_bits = InfoBytes.from_buffer_copy(stat)\n for (fld, _, _) in inf_bits._fields_:\n result[f\"INFO.{fld}\"] = getattr(inf_bits, fld)\n \n # SGPC3 data format\n tvoc, _ = struct.unpack('<hh', sgp)\n result[\"VOC\"] = tvoc/1000\n\n # BME280 data format\n rh, T, P, T_plus = struct.unpack('<bblh', bme)\n result[\"RH\"] = rh\n result[\"T\"] = T_plus/100\n result[\"P\"] = P/1000\n\n # SPS30 data format\n pms = PMBytes.from_buffer_copy(sps)\n result[\"PM1\"] = pms.pm1/100 if pms.pm1 > 0 else None\n result[\"PM2.5\"] = pms.pm2_5/100 if pms.pm2_5 > 0 else None\n result[\"PM10\"] = pms.pm10/100 if pms.pm10 > 0 else None\n\n return result\n\nNow I can process the result I collected earlier.\n\nprocess_gatt_data(res)\n\n{'Timestamp': 1747673644.60206,\n 'VOC': 0.223,\n 'RH': 32,\n 'T': 21.3,\n 'P': 93.37,\n 'PM1': 1.0,\n 'PM2.5': 2.18,\n 'PM10': 3.27,\n 'INFO.pm_sensor': 1,\n 'INFO.error': 0,\n 'INFO.bonding': 0,\n 'INFO.charging': 0,\n 'INFO.charge_timer': 1,\n 'INFO.bit_6': 0,\n 'INFO.pre_heating': 1,\n 'INFO.bit_8': 0,\n 'INFO.batt_level': 63}\n\n\nThe 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.\n1 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." + }, + { + "objectID": "posts/atmotube_data_logging/index.html#collecting-broadcast-data", + "href": "posts/atmotube_data_logging/index.html#collecting-broadcast-data", + "title": "Logging data from an Atmotube PRO over Bluetooth", + "section": "Collecting broadcast data", + "text": "Collecting broadcast data\nThe 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.\nThe scanner runs inside an event loop which starts the scanner, waits until the collection_time has elapsed, then shuts down and returns the results.\n\nimport asyncio\n\n\nasync def collect_data(device_mac, collection_time=600):\n def adv_cb(device, advertising_data):\n if device.address == device_mac:\n results.append((time.time(), device, advertising_data))\n else:\n pass\n return None\n \n async def receiver(event):\n async with BleakScanner(adv_cb, scanning_mode='active') as scanner:\n await event.wait()\n \n results = []\n loop = asyncio.Event()\n task = asyncio.create_task(receiver(loop))\n await asyncio.sleep(collection_time)\n loop.set()\n _ = await asyncio.wait([task])\n return results\n\nRunning this for 10 seconds lets me collect some example data to play with.\n\nbroadcasts = await collect_data(ATMOTUBE, 10)\n\nProcessing 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.\n\ndef process_adv_data(full_data, company_id=int(0xFFFF)):\n result = dict.fromkeys(HEADERS)\n if full_data is None:\n return result\n else:\n timestamp, device, advertising_data = full_data\n result[\"Timestamp\"] = timestamp\n\n # process advertising data\n data = advertising_data.manufacturer_data.get(company_id)\n if len(data) == 12:\n tvoc, devid, rh, T, P, inf, batt = struct.unpack(\">hhbblbb\", data)\n result[\"VOC\"] = tvoc/1000\n result[\"RH\"] = rh\n result[\"T\"] = T\n result[\"P\"] = P/1000\n elif len(data) == 9:\n pm1, pm2_5, pm10, fw_maj, fw_min, fw_bld = struct.unpack(\">hhhbbb\", data)\n result[\"PM1\"] = pm1 if pm1 > 0 else None\n result[\"PM2.5\"] = pm2_5 if pm2_5 > 0 else None\n result[\"PM10\"] = pm10 if pm10 > 0 else None\n else:\n pass\n return result\n\nI can process this and look at examples of the two types of advertising packet\n\nprocess_adv_data(broadcasts[0])\n\n{'Timestamp': 1747673646.9507601,\n 'VOC': None,\n 'RH': None,\n 'T': None,\n 'P': None,\n 'PM1': 1,\n 'PM2.5': 2,\n 'PM10': 3}\n\n\n\nprocess_adv_data(broadcasts[5])\n\n{'Timestamp': 1747673647.2869163,\n 'VOC': 0.208,\n 'RH': 36,\n 'T': 21,\n 'P': 93.357,\n 'PM1': None,\n 'PM2.5': None,\n 'PM10': None}\n\n\nThe 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.\n\nasync def better_collect_data(device_mac, collection_time=600):\n def adv_cb(device, advertising_data):\n if device.address == device_mac:\n row = process_adv_data((time.time(), device, advertising_data))\n if len( [ val for key, val in row.items() if val is not None ]) >1:\n # only collect results when we actually have a measurement\n results.append(row)\n else:\n pass\n return None\n \n async def receiver(event):\n async with BleakScanner(adv_cb) as scanner:\n await event.wait()\n \n results = []\n loop = asyncio.Event()\n task = asyncio.create_task(receiver(loop))\n await asyncio.sleep(collection_time)\n loop.set()\n _ = await asyncio.wait([task])\n return results\n\nWhich I let collect for 5 minutes\n\nnew_broadcasts = await better_collect_data(ATMOTUBE, 300)" + }, + { + "objectID": "posts/atmotube_data_logging/index.html#processing-the-broadcast-data", + "href": "posts/atmotube_data_logging/index.html#processing-the-broadcast-data", + "title": "Logging data from an Atmotube PRO over Bluetooth", + "section": "Processing the broadcast data", + "text": "Processing the broadcast data\nAt 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.\n\nimport pandas as pd\n\n\ndf = pd.DataFrame(new_broadcasts)\n\n\ndf.describe()\n\n\n\n\n\n\n\n\nTimestamp\nVOC\nRH\nT\nP\nPM1\nPM2.5\nPM10\n\n\n\n\ncount\n4.100000e+02\n57.000000\n57.000000\n57.0\n57.000000\n353.0\n353.000000\n353.000000\n\n\nmean\n1.747674e+09\n0.203228\n35.105263\n21.0\n93.351193\n1.0\n2.005666\n3.039660\n\n\nstd\n8.914660e+01\n0.002797\n0.450564\n0.0\n0.004576\n0.0\n0.184550\n0.246825\n\n\nmin\n1.747674e+09\n0.199000\n34.000000\n21.0\n93.343000\n1.0\n1.000000\n2.000000\n\n\n25%\n1.747674e+09\n0.201000\n35.000000\n21.0\n93.348000\n1.0\n2.000000\n3.000000\n\n\n50%\n1.747674e+09\n0.203000\n35.000000\n21.0\n93.351000\n1.0\n2.000000\n3.000000\n\n\n75%\n1.747674e+09\n0.204000\n35.000000\n21.0\n93.355000\n1.0\n2.000000\n3.000000\n\n\nmax\n1.747674e+09\n0.210000\n36.000000\n21.0\n93.361000\n1.0\n3.000000\n4.000000\n\n\n\n\n\n\n\nThis 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.\n\ndf['Time'] = df['Timestamp'] - df.iloc[0]['Timestamp']\n\n\n\n\n\n\n\n\n\nFigure 1: Time series data of indoor VOC and PM concentrations, a 5 minute sample of BLE advertising data\n\n\n\n\n\nPlotting 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." + }, + { + "objectID": "posts/atmotube_data_logging/index.html#logging-to-a-csv", + "href": "posts/atmotube_data_logging/index.html#logging-to-a-csv", + "title": "Logging data from an Atmotube PRO over Bluetooth", + "section": "Logging to a CSV", + "text": "Logging to a CSV\nIf 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.\n\nimport csv\n\n\nasync def log_to_csv(device_mac, collection_time=600, file=\"atmotube.csv\"):\n def adv_cb(device, advertising_data):\n if device.address == device_mac:\n row = process_adv_data((time.time(), device, advertising_data))\n if len( [ val for key, val in row.items() if val is not None ]) >1:\n # only collect results when we actually have a measurement\n with open(file, 'a', newline='') as csvfile:\n writer = csv.DictWriter(csvfile, fieldnames=HEADERS)\n writer.writerow(row)\n else:\n pass\n return None\n \n async def receiver(event):\n async with BleakScanner(adv_cb) as scanner:\n await event.wait()\n \n # prepare csv file\n with open(file, 'w', newline='') as csvfile:\n writer = csv.DictWriter(csvfile, fieldnames=HEADERS)\n writer.writeheader()\n\n # start scanning\n loop = asyncio.Event()\n task = asyncio.create_task(receiver(loop))\n\n # wait until the collection time is up\n await asyncio.sleep(collection_time)\n loop.set()\n _ = await asyncio.wait([task])\n \n return True\n\n\n\n\n\n\n\nWarning\n\n\n\nThe 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.\n\n\nTo 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.\n\nimport math\n\n\nnow = math.floor(time.time())\ntimestamped_file = f\"atmotube-{now}.csv\"\nresult = await log_to_csv(ATMOTUBE, 3600, timestamped_file)\n\nprint(\"Success!\") if result else print(\"Boo\")\n\nSuccess!\n\n\nWhile 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\n\nlogged_data = pd.read_csv(timestamped_file)\n\n\nlogged_data.describe()\n\n\n\n\n\n\n\n\nTimestamp\nVOC\nRH\nT\nP\nPM1\nPM2.5\nPM10\n\n\n\n\ncount\n4.823000e+03\n835.000000\n835.000000\n835.0\n835.000000\n3988.0\n3988.000000\n3988.000000\n\n\nmean\n1.747676e+09\n0.226522\n34.810778\n21.0\n93.320590\n1.0\n1.919007\n2.945587\n\n\nstd\n1.037307e+03\n0.012884\n0.711420\n0.0\n0.018019\n0.0\n0.328726\n0.325025\n\n\nmin\n1.747674e+09\n0.195000\n34.000000\n21.0\n93.283000\n1.0\n1.000000\n2.000000\n\n\n25%\n1.747675e+09\n0.217000\n34.000000\n21.0\n93.303000\n1.0\n2.000000\n3.000000\n\n\n50%\n1.747676e+09\n0.230000\n35.000000\n21.0\n93.325000\n1.0\n2.000000\n3.000000\n\n\n75%\n1.747677e+09\n0.237000\n35.000000\n21.0\n93.337000\n1.0\n2.000000\n3.000000\n\n\nmax\n1.747678e+09\n0.249000\n37.000000\n21.0\n93.355000\n1.0\n3.000000\n4.000000\n\n\n\n\n\n\n\n\nlogged_data['Time'] = logged_data['Timestamp'] - logged_data.iloc[0]['Timestamp']\n\n\n\n\n\n\n\n\n\nFigure 2: Time series data of indoor VOC and PM concentrations, a 1-hr sample of BLE advertising data\n\n\n\n\n\nThe atmotube is also logging data to its internal memory, so I exported that and plotted it against what was broadcast.\n\nexport_data = pd.read_csv('atmotube-export-data.csv')\nexport_data.describe()\n\n\n\n\n\n\n\n\nVOC, ppm\nAQS\nAir quality health index (AQHI) - Canada\nTemperature, °C\nHumidity, %\nPressure, kPa\nPM1, ug/m3\nPM2.5, ug/m3\nPM2.5 (avg 3h), ug/m3\nPM10, ug/m3\nPM10 (avg 3h), ug/m3\nLatitude\nLongitude\n\n\n\n\ncount\n66.000000\n66.000000\n66.0\n66.0\n66.000000\n66.000000\n66.0\n66.000000\n66.000000\n66.000000\n66.000000\n0.0\n0.0\n\n\nmean\n0.239985\n85.045455\n1.0\n21.0\n34.484848\n93.316364\n1.0\n1.530303\n1.559175\n2.545455\n2.861027\nNaN\nNaN\n\n\nstd\n0.018563\n1.156012\n0.0\n0.0\n0.769464\n0.019817\n0.0\n0.502905\n0.036129\n0.501745\n0.041909\nNaN\nNaN\n\n\nmin\n0.212000\n82.000000\n1.0\n21.0\n33.000000\n93.280000\n1.0\n1.000000\n1.466667\n2.000000\n2.722222\nNaN\nNaN\n\n\n25%\n0.228250\n85.000000\n1.0\n21.0\n34.000000\n93.300000\n1.0\n1.000000\n1.550000\n2.000000\n2.866667\nNaN\nNaN\n\n\n50%\n0.238000\n85.000000\n1.0\n21.0\n34.500000\n93.320000\n1.0\n2.000000\n1.561111\n3.000000\n2.877778\nNaN\nNaN\n\n\n75%\n0.244750\n86.000000\n1.0\n21.0\n35.000000\n93.337500\n1.0\n2.000000\n1.583333\n3.000000\n2.888889\nNaN\nNaN\n\n\nmax\n0.295000\n87.000000\n1.0\n21.0\n36.000000\n93.350000\n1.0\n2.000000\n1.616667\n3.000000\n2.888889\nNaN\nNaN\n\n\n\n\n\n\n\n\nfrom datetime import datetime\n\n\nexport_data['Timestamp'] = export_data[['Date']].apply(\n lambda str: datetime.strptime(str.iloc[0], \"%Y/%m/%d %H:%M:%S\").timestamp(), axis=1)\n\n\nexport_data['Time'] = export_data['Timestamp'] - logged_data.iloc[0]['Timestamp']\n\n\n\n\n\n\n\n\n\nFigure 3: Time series data of indoor temperature and pressure, a 1-hr sample of BLE advertising data and data exported from the Atmotube app\n\n\n\n\n\nThe 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.\n\n\n\n\n\n\n\n\nFigure 4: Time series data of indoor VOC concentrations, a 1-hr sample of BLE advertising data and data exported from the Atmotube app\n\n\n\n\n\nI 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.\n\n\n\n\n\n\n\n\nFigure 5: Time series data of indoor PM2.5 concentrations, a 1-hr sample of BLE advertising data and data exported from the Atmotube app\n\n\n\n\n\nThe 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”.\nOne 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." + }, + { + "objectID": "posts/atmotube_data_logging/index.html#final-thoughts", + "href": "posts/atmotube_data_logging/index.html#final-thoughts", + "title": "Logging data from an Atmotube PRO over Bluetooth", + "section": "Final Thoughts", + "text": "Final Thoughts\nHopefully 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.\nThe 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.\nThere 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!" + }, + { + "objectID": "posts/atmotube_data_logging/index.html#update", + "href": "posts/atmotube_data_logging/index.html#update", + "title": "Logging data from an Atmotube PRO over Bluetooth", + "section": "Update", + "text": "Update\n\n\n\n\n\n\nTipUpdate\n\n\n\nI 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.\n\n\nI 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.\nTo 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.\n\nfrom ctypes import LittleEndianStructure, c_ubyte, c_byte, c_short, c_int\n\nclass StatusData(LittleEndianStructure):\n _fields_ = [\n (\"pm_sensor\", c_ubyte, 1),\n (\"error\", c_ubyte, 1),\n (\"bonding\", c_ubyte, 1),\n (\"charging\", c_ubyte, 1),\n (\"charging_timer\", c_ubyte, 1),\n (\"_bit_6\", c_ubyte, 1),\n (\"sgpc3_pre_heating\", c_ubyte, 1),\n (\"_bit_8\", c_ubyte, 1),\n (\"battery_level\", c_ubyte, 8),\n ]\n\n def __new__(cls, ts, data):\n return cls.from_buffer_copy(data)\n\n def __init__(self, ts, data):\n self.timestamp = ts\n\n\nclass SPS30Data(LittleEndianStructure):\n _fields_ = [\n ('_pm1', c_byte*3),\n ('_pm2_5', c_byte*3),\n ('_pm10', c_byte*3),\n ('_pm4', c_byte*3), \n ]\n _pack_ = 1\n\n def __new__(cls, ts, data):\n return cls.from_buffer_copy(data)\n\n def __init__(self, ts, data):\n self.timestamp = ts\n\n @property\n def pm1(self):\n res = int.from_bytes(self._pm1, 'little', signed=True)\n return res/100 if res > 0 else None\n\n @property\n def pm2_5(self):\n res = int.from_bytes(self._pm2_5, 'little', signed=True)\n return res/100 if res > 0 else None\n\n @property\n def pm10(self):\n res = int.from_bytes(self._pm10, 'little', signed=True)\n return res/100 if res > 0 else None\n\n\nclass BME280Data(LittleEndianStructure):\n _fields_ = [\n ('_rh', c_byte),\n ('_T', c_byte),\n ('_P', c_int),\n ('_T_dec', c_short),\n ]\n _pack_ = 1\n\n def __new__(cls, ts, data):\n return cls.from_buffer_copy(data)\n\n def __init__(self, ts, data):\n self.timestamp = ts\n \n @property\n def RH(self):\n return self._rh\n\n @property\n def T(self):\n return self._T_dec/100\n\n @property\n def P(self):\n return self._P/1000\n\n\nclass SGPC3Data(LittleEndianStructure):\n _fields_ = [\n ('_TVOC', c_short),\n ]\n _pack_ = 1\n\n def __new__(cls, ts, data):\n return cls.from_buffer_copy(data)\n\n def __init__(self, ts, data):\n self.timestamp = ts\n\n @property\n def TVOC(self):\n return self._TVOC/1000\n\nWith 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.\nTo 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:\n\nThe collector starts up and scans for the atmotube, by MAC address.\nWhen it finds the device it requests notifications for one of the GATT characteristics, in this case I am requesting the status data and the SPS30 data, which contains the pm concentrations.\nThe collector then waits around for the 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 queue\n\n\nasync def collect_data(mac, queue, collection_time):\n async def status_cb(char, data):\n ts = time.time()\n res = StatusData(ts, data)\n await queue.put(res)\n\n async def sps30_cb(char, data):\n ts = time.time()\n res = SPS30Data(ts, data)\n await queue.put(res)\n \n device = await BleakScanner.find_device_by_address(mac)\n if not device:\n raise Exception(\"Device not found\")\n \n async with BleakClient(device) as client:\n # start notifications\n await client.start_notify(STATUS_UUID, status_cb)\n await client.start_notify(SPS30_UUID, sps30_cb)\n\n # wait for collection period to end\n await asyncio.sleep(collection_time)\n\n # signals end of queue\n await queue.put(None)\n\nConcurrently with that, a logger needs to write things to a csv. The basic idea is this:\n\nWhen the logger starts it creates a new csv file with the given filename, and writes the column headers.\nThe worker waits for data to appear on the queue and, once it does, takes it out (first in first out).\nThe result from the queue is lined up to the right columns in the csv, I check for the attribute battery_level as a lazy check of which type of result it is.\nFinally the worker writes the result as new row on the csv.\nIf the result is None, that is a signal that the collector has finished and the loop exits.\nRegardless, once the logger has processed the data from the queue, it calls task_done() to notify the queue of this and the loop begins again.\n\n\nimport aiofiles, aiocsv\n\n\nHEADERS = [\"Timestamp\", \"PM Sensor\", \"PM1\", \"PM2.5\", \"PM10\"]\n\n\nasync def write_row(filename,row):\n async with aiofiles.open(filename, 'a', newline='') as csvfile:\n writer = aiocsv.AsyncDictWriter(csvfile, fieldnames=HEADERS)\n await writer.writerow(row)\n\n\nasync def log_to_csv(filename, queue):\n # prepare csv file\n async with aiofiles.open(filename, 'w', newline='') as csvfile:\n writer = aiocsv.AsyncDictWriter(csvfile, fieldnames=HEADERS)\n await writer.writeheader()\n\n # log data from queue\n flag = True\n while flag:\n result = await queue.get()\n if result is not None:\n # we have some data to write\n row = dict.fromkeys(HEADERS)\n row[\"Timestamp\"] = result.timestamp\n if hasattr(result, \"battery_level\"):\n # we have a status type\n row[\"PM Sensor\"] = result.pm_sensor\n else:\n # we have pm data\n row[\"PM1\"] = result.pm1\n row[\"PM2.5\"] = result.pm2_5\n row[\"PM10\"] = result.pm10\n\n await write_row(filename,row)\n else:\n # the end of the queue\n flag = False\n queue.task_done()\n\nMy 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.\nIn 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.\nFinally, I put it all together with a simple sequence of tasks:\n\nCreate an empty asyncio Queue\nStart the logger, the worker that logs results to the csv\nStart the collector, the worker that collects packets from the atmotube\nWait for the collector to finish, then close.\n\n\nasync def save_data(mac, csv, collection_time):\n q = asyncio.Queue()\n \n logger = asyncio.create_task(log_to_csv(csv, q))\n collector = asyncio.ensure_future(collect_data(mac, q, collection_time))\n \n await collector\n\nI ran this for an hour in the background as a test and it seems to work fine.\nawait save_data(ATMOTUBE, f\"atmotube-{math.floor(time.time())}.csv\", 3600)\n\ndf = pd.read_csv(\"atmotube-1748482080.csv\")\n\n\ndf['Time'] = df['Timestamp'] - df.iloc[0]['Timestamp']\n\n\ndf.head()\n\n\n\n\n\n\n\n\nTimestamp\nPM Sensor\nPM1\nPM2.5\nPM10\nTime\n\n\n\n\n0\n1.748482e+09\nNaN\n10.92\n13.43\n14.97\n0.000000\n\n\n1\n1.748482e+09\nNaN\n10.93\n13.03\n14.66\n2.610108\n\n\n2\n1.748482e+09\nNaN\n11.05\n13.42\n15.19\n5.220881\n\n\n3\n1.748482e+09\nNaN\n11.35\n13.75\n15.34\n7.784888\n\n\n4\n1.748482e+09\nNaN\n11.59\n14.13\n15.50\n10.395001\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 6: Time series data of indoor PM2.5 concentrations, a 1-hr sample using GATT notifications\n\n\n\n\n\nWith 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.\nIf 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.\n\ndf[36:42]\n\n\n\n\n\n\n\n\nTimestamp\nPM Sensor\nPM1\nPM2.5\nPM10\nTime\n\n\n\n\n36\n1.748482e+09\nNaN\n12.42\n14.34\n15.23\n127.802146\n\n\n37\n1.748482e+09\n0.0\nNaN\nNaN\nNaN\n130.322095\n\n\n38\n1.748482e+09\nNaN\nNaN\nNaN\nNaN\n130.322191\n\n\n39\n1.748483e+09\n1.0\nNaN\nNaN\nNaN\n1035.107224\n\n\n40\n1.748483e+09\nNaN\n7.74\n9.55\n10.21\n1040.146906\n\n\n41\n1.748483e+09\nNaN\n7.81\n9.82\n11.40\n1042.621936\n\n\n\n\n\n\n\nIn 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." + }, + { + "objectID": "archive.html", + "href": "archive.html", + "title": "A Chemical Engineer's Notebook", + "section": "", + "text": "Order By\n Default\n \n Date - Oldest\n \n \n Date - Newest\n \n \n Title\n \n \n \n \n \n \n \n\n\n\n\n\n\n\n\n\n\nDelivering Hydrogen Fuel Gas\n\n\n\njulia\n\nhydrogen\n\n\n\nThinking about hydrogen as a utility fuel gas by way of the relative compression costs.\n\n\n\nAllan Farrell\n\n\nMay 7, 2026\n\n\n\n\n\n\n\n\n\n\n\n\n\nThe Masses of Clouds\n\n\n\njulia\n\ndispersion modelling\n\nexplosions\n\n\n\nCalculating the mass of a Gaussian plume.\n\n\n\nAllan Farrell\n\n\nFeb 22, 2026\n\n\n\n\n\n\n\n\n\n\n\n\n\nVessel Blowdown and Dispersion\n\n\n\njulia\n\nblowdown\n\ndispersion modelling\n\n\n\nConsidering the Gaussian dispersion of an isothermal blowdown case.\n\n\n\nAllan Farrell\n\n\nDec 23, 2025\n\n\n\n\n\n\n\n\n\n\n\n\n\nThe Ooms Plume Model\n\n\n\njulia\n\ndispersion modelling\n\nintegral plume models\n\n\n\nAn integral plume model for buoyant plumes.\n\n\n\nAllan Farrell\n\n\nJun 15, 2025\n\n\n\n\n\n\n\n\n\n\n\nLogging data from an Atmotube PRO over Bluetooth\n\n\n\npython\n\nair quality\n\natmotube\n\n\n\nHaving fun with data logging.\n\n\n\nAllan Farrell\n\n\nMay 19, 2025\n\n\n\n\n\n\n\n\n\n\n\nMapping Pollen Dispersion\n\n\n\njulia\n\ndispersion modelling\n\npollen\n\n\n\nCalculating how far the wind blows.\n\n\n\nAllan Farrell\n\n\nMay 10, 2025\n\n\n\n\n\n\n\n\n\n\n\nVessel Blowdown - Real Gases\n\n\n\njulia\n\ncompressible flow\n\nblowdown\n\nequations of state\n\n\n\nModelling vessel blowdowns using equations of state.\n\n\n\nAllan Farrell\n\n\nMar 19, 2025\n\n\n\n\n\n\n\n\n\n\n\nVessel Blowdown - Ideal Gases\n\n\n\njulia\n\ncompressible flow\n\nblowdown\n\n\n\nEvaluating approaches to ideal gas blowdowns.\n\n\n\nAllan Farrell\n\n\nJan 24, 2025\n\n\n\n\n\n\n\n\n\n\n\nRelief Valve Sizing with Real Gases\n\n\n\njulia\n\npressure relief\n\ncompressible flow\n\nequations of state\n\n\n\nCompressible orifice flow calculations using equations of state.\n\n\n\nAllan Farrell\n\n\nOct 28, 2024\n\n\n\n\n\n\n\n\n\n\n\nModelling Hydrogen Releases Using HyRAM+\n\n\n\npython\n\nhydrogen\n\ndispersion modelling\n\n\n\nHydrogen plume modelling and indoor accumulation.\n\n\n\nAllan Farrell\n\n\nSep 22, 2024\n\n\n\n\n\n\n\n\n\n\n\n\n\nPlastics Recycling and Microplastics\n\n\n\nplastic\n\n\n\nIs plastic recycling a huge source of microplastics?\n\n\n\nAllan Farrell\n\n\nJul 14, 2024\n\n\n\n\n\n\n\n\n\n\n\nEngineering a Cup of Coffee Part Two: Espresso\n\n\n\njulia\n\ncoffee\n\nmass transfer\n\n\n\nModelling espresso bed extraction.\n\n\n\nAllan Farrell\n\n\nMar 23, 2024\n\n\n\n\n\n\n\n\n\n\n\n\n\nEstimating the impact of fugitive emissions\n\n\n\njulia\n\nhydrogen\n\ncompressible flow\n\n\n\nEvaluating the zero emissions fuel.\n\n\n\nAllan Farrell\n\n\nJan 3, 2024\n\n\n\n\n\n\n\n\n\n\n\nImpossible bowling\n\n\n\npython\n\nbowling\n\n\n\nLooking for impossible bowling games.\n\n\n\nAllan Farrell\n\n\nNov 26, 2023\n\n\n\n\n\n\n\n\n\n\n\n\n\nMessing around with model parameters\n\n\n\njulia\n\ndispersion modelling\n\n\n\nThe importance of choosing the right references.\n\n\n\nAllan Farrell\n\n\nOct 30, 2023\n\n\n\n\n\n\n\n\n\n\n\nEngineering a Cup of Coffee\n\n\n\njulia\n\ncoffee\n\nmass transfer\n\n\n\nBetter coffee through chemical engineering.\n\n\n\nAllan Farrell\n\n\nSep 15, 2023\n\n\n\n\n\n\n\n\n\n\n\nMonitoring smoke infiltration\n\n\n\njulia\n\nair quality\n\natmotube\n\nbuilding infiltration\n\n\n\nBetter indoor air quality through data.\n\n\n\nAllan Farrell\n\n\nMay 22, 2023\n\n\n\n\n\n\n\n\n\n\n\n\n\nTaking a second look at the Britter-McQuaid model\n\n\n\njulia\n\ndispersion modelling\n\n\n\nRe-evaluating plume extents and determining the explosive mass\n\n\n\nAllan Farrell\n\n\nMar 12, 2023\n\n\n\n\n\n\n\n\n\n\n\nIntegrating a Gaussian puff - mistakes were made\n\n\n\njulia\n\ndispersion modelling\n\n\n\nSuccessive approximations to … an integrated gaussian puff model.\n\n\n\nAllan Farrell\n\n\nJan 15, 2023\n\n\n\n\n\n\n\n\n\n\n\n\n\nDynamic Mode Decomposition\n\n\n\njulia\n\ndynamical systems\n\n\n\nDynamic mode decomposition of fluid flow problems.\n\n\n\nAllan Farrell\n\n\nDec 18, 2022\n\n\n\n\n\n\n\n\n\n\n\nHydrogen Blending\n\n\n\njulia\n\nhydrogen\n\ncompressible flow\n\n\n\nBlending hydrogen into natural gas.\n\n\n\nAllan Farrell\n\n\nNov 10, 2022\n\n\n\n\n\n\n\n\n\n\n\nAdiabatic Compressible Flow in a Pipe\n\n\n\njulia\n\ncompressible flow\n\n\n\nEvaluating different models of adiabatic pipe flow.\n\n\n\nAllan Farrell\n\n\nSep 23, 2022\n\n\n\n\n\n\n\n\n\n\n\n\n\nBetween a puff and a plume\n\n\n\njulia\n\ndispersion modelling\n\n\n\nAn integrated Gaussian puff model\n\n\n\nAllan Farrell\n\n\nJun 10, 2022\n\n\n\n\n\n\n\n\n\n\n\n\n\nMore on Turbulent Jets\n\n\n\njulia\n\ndispersion modelling\n\nturbulent jets\n\n\n\nCalculating concentrations, temperatures, and flow rates.\n\n\n\nAllan Farrell\n\n\nMay 8, 2022\n\n\n\n\n\n\n\n\n\n\n\nTurbulent Jets\n\n\n\njulia\n\ndispersion modelling\n\nturbulent jets\n\n\n\nNotes on turbulent jets and velocity profiles.\n\n\n\nAllan Farrell\n\n\nApr 8, 2022\n\n\n\n\n\n\n\n\n\n\n\nThe 2021 Canadian Federal Election\n\n\n\njulia\n\nelections\n\n\n\nAn analysis of how exceptionally little changed.\n\n\n\nAllan Farrell\n\n\nSep 22, 2021\n\n\n\n\n\n\n\n\n\n\n\nSmoke Days\n\n\n\njulia\n\nair quality\n\n\n\nFrequency of forest fire smoke events.\n\n\n\nAllan Farrell\n\n\nJul 18, 2021\n\n\n\n\n\n\n\n\n\n\n\n\n\nBuilding Infiltration Example – Chlorine Release\n\n\n\njulia\n\ndispersion modelling\n\nbuilding infiltration\n\nhazard screening\n\n\n\nSingle zone building infiltration model with an instantaneous release\n\n\n\nAllan Farrell\n\n\nJun 19, 2021\n\n\n\n\n\n\n\n\n\n\n\n\n\nBuilding Infiltration Example\n\n\n\njulia\n\nair quality\n\nbuilding infiltration\n\n\n\nSingle zone building infiltration of forest fire smoke.\n\n\n\nAllan Farrell\n\n\nMay 22, 2021\n\n\n\n\n\n\n\n\n\n\n\nTurbulent Jet Example - Acetylene Leak\n\n\n\njulia\n\nchemical releases\n\nhazard screening\n\ndispersion modelling\n\nturbulent jets\n\n\n\nEstimating the explosive mass.\n\n\n\nAllan Farrell\n\n\nApr 10, 2021\n\n\n\n\n\n\n\n\n\n\n\nVCE Example - Butane Vapour Cloud\n\n\n\njulia\n\nchemical releases\n\nhazard screening\n\ndispersion modelling\n\nexplosions\n\n\n\nUsing the Baker-Strehlow-Tang model for a vapour cloud explosion.\n\n\n\nAllan Farrell\n\n\nJan 9, 2021\n\n\n\n\n\n\n\n\n\n\n\nWorst Case Meterological Conditions\n\n\n\njulia\n\ndispersion modelling\n\n\n\nThe worst case weather conditions for air dispersion modeling.\n\n\n\nAllan Farrell\n\n\nDec 12, 2020\n\n\n\n\n\n\n\n\n\n\n\n\n\nAir Dispersion Example - Gaussian Dispersion Model of Stack Emissions\n\n\n\njulia\n\nhazard screening\n\ndispersion modelling\n\n\n\nEstimating the airborne quantity.\n\n\n\nAllan Farrell\n\n\nDec 5, 2020\n\n\n\n\n\n\n\n\n\n\n\nCompressible Flow Example - Sizing a Goose Neck Vent\n\n\n\njulia\n\ncompressible flow\n\npressure relief\n\n\n\nCalculating the minimum diameter in incompressible, isothermal, and adiabatic flow situations.\n\n\n\nAllan Farrell\n\n\nNov 28, 2020\n\n\n\n\n\n\n\n\n\n\n\nChemical Release Screening Example - Butane leak\n\n\n\njulia\n\nchemical releases\n\nhazard screening\n\n\n\nEstimating the airborne quantity.\n\n\n\nAllan Farrell\n\n\nNov 20, 2020\n\n\n\n\n\n\nNo matching items" + }, + { + "objectID": "projects/pymotube/index.html", + "href": "projects/pymotube/index.html", + "title": "PymoTube", + "section": "", + "text": "A python module for logging data from an AtmoTube via bluetooth. Very much in development.\nPymoTube is currently just a set of helper functions and classes for taking bytearrays returned by the AtmoTube and turning it into a basic struct. The actual connection to the AtmoTube is managed by Bleak. A minimal example of connecting to an AtmoTube and logging results into an asynchronous queue is shown here" + }, + { + "objectID": "projects/pymotube/index.html#a-logging-example", + "href": "projects/pymotube/index.html#a-logging-example", + "title": "PymoTube", + "section": "A Logging Example", + "text": "A Logging Example\nAs an example of how to use this, consider the case where you want to connect to your AtmoTube from a PC, log data from it over a pre-defined period, then exit. This will be done asynchronously and the retrieved data put into an asynchronous queue for processing.\nThe first step is to create a function which connects to the AtmoTube, collects data, and then puts that data into a queue.\nfrom bleak import BleakClient, BleakScanner\nfrom atmotube import start_gatt_notifications, get_available_services\nimport asyncio\n\n\nasync def collect_data(mac, queue, collection_time):\n1 async def callback_queue(packet):\n await queue.put(packet)\n\n2 device = await BleakScanner.find_device_by_address(mac)\n if not device:\n raise Exception(\"Device not found\")\n\n3 async with BleakClient(device) as client:\n if not client.is_connected:\n raise Exception(\"Failed to connect to device\")\n4 packet_list = get_available_services(client)\n5 await start_gatt_notifications(client, callback_queue,\n packet_list=packet_list)\n6 await asyncio.sleep(collection_time)\n7 await queue.put(None)\n\n1\n\nDefine a callback function which takes a packet of data and does something with it. In this case it puts it in an asynchronous queue.\n\n2\n\nFind the device using Bleak, in this case by mac address\n\n3\n\nConnect to the device using Bleak\n\n4\n\nCall the get_available_services function with the connected device, this generates a list of GATT services that both the atmotube library knows about and the AtmoTube device supports.\n\n5\n\nStart the GATT notifications for the list of available services. If no list is provided, it will attempt to start GATT notifications for all services supported by the atmotube library.\n\n6\n\nWait while data is collected, for the pre-defined collection time (in seconds)\n\n7\n\nPut a None on the queue to indicate that the collection has ended.\n\n\nWhat ends up on the queue is a series of AtmoTubePacket objects representing the different types of data packets the AtmoTube returns. Each packet has an associated datetime object representing the time when the packet was received. As a somewhat silly example, this takes those packets and logs them to the logger – a more realistic thing to do might be to put the data in a database or append the data to a CSV.\nfrom atmotube import SPS30Packet, StatusPacket, BME280Packet, SGPC3Packet\n\nimport logging\n\n\ndef log_packet(packet):\n1 match packet:\n case AtmotubeProStatus():\n2 logging.info(f\"{str(packet.date_time)} - Status Packet - \"\n f\"Battery: {packet.battery_level}%, \"\n f\"PM Sensor: {packet.pm_sensor_status}, \"\n f\"Pre-heating: {packet.pre_heating}, \"\n f\"Error: {packet.error_flag}\")\n case AtmotubeProSPS30():\n logging.info(f\"{str(packet.date_time)} - SPS30 Packet - \"\n f\"PM1: {packet.pm1} µg/m³, \"\n f\"PM2.5: {packet.pm2_5} µg/m³, \"\n f\"PM4: {packet.pm4} µg/m³, \"\n f\"PM10: {packet.pm10} µg/m³\")\n case AtmotubeProBME280():\n logging.info(f\"{str(packet.date_time)} - BME280 Packet - \"\n f\"Humidity: {packet.humidity}%, \"\n f\"Temperature: {packet.temperature}°C, \"\n f\"Pressure: {packet.pressure} mbar\")\n case AtmotubeProSGPC3():\n logging.info(f\"{str(packet.date_time)} - SGPC3 Packet - \"\n f\"TVOC: {packet.tvoc} ppb\")\n case _:\n logging.info(\"Unknown packet type\")\n\n1\n\nUse structural pattern matching to identify which data has been returned.\n\n2\n\nSend some information about it to the logger\n\n\nFinally, an asynchronous event loop is created which runs the collector and then logs the data.\nATMOTUBE = \"00:00:00:00:00:00\" # the mac address of the ATMOTUBE\n\ndef main():\n mac = ATMOTUBE\n collection_time = 60 # seconds\n1 queue = asyncio.Queue()\n\n2 async def runner():\n3 collector = asyncio.create_task(\n collect_data(mac, queue, collection_time)\n )\n4 while True:\n item = await queue.get()\n if item is None:\n break\n log_packet(item)\n await collector\n\n asyncio.run(runner())\n\n\nif __name__ == \"__main__\":\n logging.basicConfig(level=logging.INFO)\n main()\n\n1\n\nInitialize an asynchronous queue, this will be used to pass the data between the two workers\n\n2\n\nCreate a runner function to handle the main sequence of events\n\n3\n\nStart the collector\n\n4\n\nWait for data to appear on the queue, and then pass the data to log_packet" + }, + { + "objectID": "projects/gas_dispersion_jl/index.html", + "href": "projects/gas_dispersion_jl/index.html", + "title": "GasDispersion.jl", + "section": "", + "text": "GasDispersion.jl aims to bring together several models for dispersion modelling of chemical releases with a consistent interface. Currently it implements several Gaussian dispersion models, the Britter-McQuaid dense gas dispersion model, a subset of the SLAB dense gas dispersion model, and others." + }, + { + "objectID": "projects/unitfulcorrelations_jl/index.html", + "href": "projects/unitfulcorrelations_jl/index.html", + "title": "UnitfulCorrelations.jl", + "section": "", + "text": "A simple macro for working with empirical correlations and Unitful." + }, + { + "objectID": "projects/unitfulcorrelations_jl/index.html#installation", + "href": "projects/unitfulcorrelations_jl/index.html#installation", + "title": "UnitfulCorrelations.jl", + "section": "Installation", + "text": "Installation\nUnitfulCorrelations.jl can be installed using Julia’s built-in package manager. In a Julia session, enter the package manager mode by hitting ], then run the command\npkg> add https://github.com/aefarrell/UnitfulCorrelations.jl" + }, + { + "objectID": "projects/unitfulcorrelations_jl/index.html#examples", + "href": "projects/unitfulcorrelations_jl/index.html#examples", + "title": "UnitfulCorrelations.jl", + "section": "Examples", + "text": "Examples\nSuppose you have an empirical correlation \\(f(x) = 0.92 x^{0.2}\\), where it is given that \\(x\\) is in meters and \\(f\\) is in seconds. You could figure out the units that the constants must have to make everything work out, or write a function that uses ustrip() to manage units, but that can get tedious if there are a lot of these.\nThe macro @ucorrel does this for you, adding another method for the case where the function is called with units. The arguments are: function (or function block), input units, output units.\n\nf(x) = 0.92*x^0.2\n@ucorrel f u\"m\" u\"s\"\n\n# f(2) == 1.0568024865972723\n# f(2 u\"m\") == 1.0568024865972723 u\"s\"\nthis can also be done with a function block\n\n@ucorrel function f(x)\n return 0.92*x^0.2\nend u\"m\" u\"s\"\n\n# f(2) == 1.0568024865972723\n# f(2 u\"m\") == 1.0568024865972723 u\"s\"\nSo far it only supports one dimensional correlations, because I have basically just copied over a macro that I use frequently and have not added anything to its functionality." + }, + { + "objectID": "projects/picocalc/index.html", + "href": "projects/picocalc/index.html", + "title": "PicoMite Library", + "section": "", + "text": "A collection of programs written in PicoMite BASIC for the PicoCalc.\nThe programs are organized into the following categories:\n\nmath\\ BASIC code for doing math\nart\\ BASIC code for generating cool visualizations and art\nutils\\ useful utilities\ntoys\\ little toy programs" + }, + { + "objectID": "posts/vessel_blowdown_real_gases/index.html", + "href": "posts/vessel_blowdown_real_gases/index.html", + "title": "Vessel Blowdown - Real Gases", + "section": "", + "text": "Continuing on from where I left off previously, examining vessel blowdown, it is time to implement real gases. I left the ideal gas case promising that implementing a real gas was easy, well now is the time to prove it. Instead of implementing real gas equations of state myself, I am going to use Clapeyron.jl but, as a first step, it is worthwhile to consider how the problem can be divided up into sub-problems and what data structures would be the most useful. I would like to write code that is general enough that any equation of state can be used with minimal changes. With that in mind, I am going to consider the problem as being composed of three distinct subsets: the vessel, the fluid model, and the ambient conditions." + }, + { + "objectID": "posts/vessel_blowdown_real_gases/index.html#data-structures", + "href": "posts/vessel_blowdown_real_gases/index.html#data-structures", + "title": "Vessel Blowdown - Real Gases", + "section": "Data Structures", + "text": "Data Structures\nThe 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.\nAn important decision must be made regarding which subset of the state variables, \\(P, v, T\\), 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\\), 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.\nbegin\n\nstruct PressureVessel{F <: Number}\n c::F # valve discharge coefficient\n A::F # valve flow area\n V::F # vessel volume\n T::F # vessel temperature\n m::F # total mass of material\nend\n\nPressureVessel(c, A, V, T, m) = \n PressureVessel(promote(c, A, V, T, m)...)\n\nend\nAbstracting 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.\nCollecting the ambient conditions into a data structure does not lead to any spectacular improvements or insights, it is just neat and tidy.\nbegin\n\nstruct Environment{F <: Number}\n P::F\n T::F\nend\n\nEnvironment(P, T) = Environment(promote(P,T)...)\n\nend\nFinally, a data structure for blowdown solutions is useful for dispatch.\nstruct Blowdown{S}\n pv::PressureVessel\n env::Environment\n sol::S\nend\nBase.length(::Blowdown) = 1\nBase.iterate(b::Blowdown, state=1) = state > length(b) ? nothing : (b,state+1)" + }, + { + "objectID": "posts/vessel_blowdown_real_gases/index.html#equations-of-state", + "href": "posts/vessel_blowdown_real_gases/index.html#equations-of-state", + "title": "Vessel Blowdown - Real Gases", + "section": "Equations of State", + "text": "Equations of State\n\nIdeal gases\nThe 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 \\(c_p - c_v = R\\). Starting with the following data structure.\nbegin\n\nconst R = 8.31446261815324 # m³⋅Pa/K/mol\n\nstruct IdealGas{F <: Number}\n cᵥ::F # J/kg/K\n cₚ::F # J/kg/K\n k::F\n R::F # J/kg/K\n MW::F # kg/mol\nend\n\nfunction IdealGas(cᵥ,MW; R=R)\n cᵥ, MW = promote(cᵥ,MW)\n cₚ = cᵥ + R\n k = cₚ/cᵥ\n return IdealGas(cᵥ,cₚ,k,R,MW)\nend\n\nfunction IdealGas(model::Clapeyron.EoSModel; \n P=101325, T=288.15, z=[1.])\n MW = Clapeyron.molecular_weight(model, z) # kg/mol\n cᵥ = Clapeyron.isochoric_heat_capacity(model, P, T, z) # J/mol/K\n return IdealGas(cᵥ, MW)\nend\n \nend\nA 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 \\(\\sqrt{-1}\\) – 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.\nbegin \n\nusing NaNMath\n\n√ = NaNMath.sqrt\nlog = NaNMath.log\n\nend\nThe 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.\npressure(model::IdealGas, v, T) = model.R*T/v\nvolume(model::IdealGas, P, T) = model.R*T/P\nmolecular_weight(model::IdealGas) = model.MW\nmolar_enthalpy(model::IdealGas, v, T) = model.cₚ*T\nmolar_entropy(model::IdealGas, v, T) =\n model.cᵥ*log(T) + model.R*log(v)\nmolar_internal_energy(model::IdealGas, v, T) = model.cᵥ*T\nspeed_of_sound(model::IdealGas, v, T) =\n √(model.k*model.R*T/model.MW)\n\n\nReal Gases with Clapeyron.jl\nThe 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.\nimport Clapeyron\npressure(model::Clapeyron.EoSModel, v, T) =\n Clapeyron.pressure(model, v, T)\nvolume(model::Clapeyron.EoSModel, P, T; v0=nothing) = \n Clapeyron.volume(model, P, T; phase=:vapor, vol0=v0)\nmolecular_weight(model::Clapeyron.EoSModel) =\n Clapeyron.molecular_weight(model)\nmolar_enthalpy(model::Clapeyron.EoSModel, v, T) =\n Clapeyron.VT_enthalpy(model, v, T)\nmolar_entropy(model::Clapeyron.EoSModel, v, T) =\n Clapeyron.VT_entropy(model, v, T)\nmolar_internal_energy(model::Clapeyron.EoSModel, v, T) =\n Clapeyron.VT_internal_energy(model, v, T)\nspeed_of_sound(model::Clapeyron.EoSModel, v, T) =\n Clapeyron.VT_speed_of_sound(model, v, T)" + }, + { + "objectID": "posts/vessel_blowdown_real_gases/index.html#isentropic-nozzle-flow", + "href": "posts/vessel_blowdown_real_gases/index.html#isentropic-nozzle-flow", + "title": "Vessel Blowdown - Real Gases", + "section": "Isentropic Nozzle Flow", + "text": "Isentropic Nozzle Flow\nThe 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.\n\\[\ns_1 = s_t\n\\]\n\\[\nh_1 = h(v_t, T_t) + \\frac{1}{2} M u_t^2\n\\]\nFor 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, \\(v_t, T_t\\), 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.\nusing NonlinearSolve\nfunction choked_nozzle_balance!(obj, y, prms)\n # y = [v; T]\n obj .= [ prms.entropy - molar_entropy(prms.model, y[1], y[2])\n 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 ]\n return nothing\nend\nchoked_nozzle_prob = NonlinearProblem(choked_nozzle_balance!, [0.0; 0.0], \n (model=nothing, env=nothing,\n entropy=0.0, enthalpy=0.0))\nIn 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, \\(u_t, T_t\\).\nfunction non_choked_nozzle_balance!(obj, y, prms)\n # y = [u; T]\n v = volume(prms.model, prms.env.P, y[2])\n obj .= [ prms.entropy - molar_entropy(prms.model, v, y[2])\n prms.enthalpy - molar_enthalpy(prms.model, v, y[2]) - 0.5*molecular_weight(prms.model)*y[1]^2 ]\n return nothing\nend\nnon_choked_nozzle_prob = NonlinearProblem(non_choked_nozzle_balance!,\n [0.0; 0.0],\n (model=nothing, env=nothing,\n entropy=0.0, enthalpy=0.0))\nThe 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.\nMy 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.\nfunction mass_flow(model, pv, env, v, T)\n # calculate the molar entropy and molar enthalpy\n # at vessel conditions\n s₁ = molar_entropy(model, v, T)\n h₁ = molar_enthalpy(model, v, T)\n\n # solve the choked flow energy balance for\n # an isentropic nozzle\n params = (model=model, env=env, entropy=s₁, enthalpy=h₁)\n y₀ = [v; T]\n prob = remake(choked_nozzle_prob, u0=y₀, p=params)\n sol = solve(prob, NewtonRaphson())\n vₜ, Tₜ = sol.u\n Pₜ = pressure(model, vₜ, Tₜ)\n if Pₜ > env.P\n # flow is choked, we're done\n uₜ = speed_of_sound(model, vₜ, Tₜ)\n else\n # flow is not choked, solve the non-choked problem\n v₀ = volume(model, env.P, T)\n y₀ = [ speed_of_sound(model, v₀, T); T ]\n prob = remake(non_choked_nozzle_prob, u0=y₀, p=params)\n sol = solve(prob, NewtonRaphson())\n uₜ, Tₜ = sol.u\n vₜ = volume(model, env.P, Tₜ)\n end\n\n ρₜ = molecular_weight(model)/vₜ\n return pv.c*pv.A*ρₜ*uₜ\nend" + }, + { + "objectID": "posts/vessel_blowdown_real_gases/index.html#adiabatic-blowdown", + "href": "posts/vessel_blowdown_real_gases/index.html#adiabatic-blowdown", + "title": "Vessel Blowdown - Real Gases", + "section": "Adiabatic Blowdown", + "text": "Adiabatic Blowdown\n\nThe Pressure Equation\nThe 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.\nThe first step is to define a basic type, PressureODE; which will allow functions like blowdown_pressure to dispatch on solution type.\nstruct PressureODE{S}\n ode_sol::S\nend\nThe 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.\n\\[\n\\frac{dP}{dt} = -\\frac{c_D A}{V} a^2 G\n\\]\n\\[\n0 = v - volume(P, T)\n\\]\n\\[\n0 = s_0 - entropy(v, T)\n\\]\nThe 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.\nusing OrdinaryDiffEq, DiffEqCallbacks\nfunction adiabatic_vessel!(dy, y, prms, t)\n P, v, T = y\n \n a² = speed_of_sound(prms.model, v, T)^2\n w = mass_flow(prms.model, prms.pv, prms.env, v, T)\n\n dy .= [-w*a²/prms.pv.V\n v - volume(prms.model, P, T)\n prms.init - molar_entropy(prms.model, v, T) ]\n return nothing\nend\nabd_rhs = ODEFunction(adiabatic_vessel!, mass_matrix = [1 0 0\n 0 0 0\n 0 0 0])\nA 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.\ndepressured_callback(y, t, I; reltol=0.001) =\n y[1] - (1+reltol)*I.p.env.P\nThe 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.\nfunction adiabatic_blowdown(model, pv::PressureVessel, \n env::Environment;\n solver=Rodas5(), \n tspan=(0.0, 600.0))\n\n # vessel initial conditions\n V, T₀, m = pv.V, pv.T, pv.m\n n₀ = m/molecular_weight(model)\n v₀ = V/n₀\n P₀ = pressure(model, v₀, T₀)\n \n # defining the parameters\n s₀ = molar_entropy(model, v₀, T₀)\n params = (model=model, pv=pv, env=env, init=s₀)\n\n # callbacks\n dpcb = ContinuousCallback(depressured_callback, terminate!)\n\n # set up the ODEProblem and solve\n y₀ = [P₀; v₀; T₀]\n prob = ODEProblem(abd_rhs, y₀, tspan, params)\n sol = solve(prob, solver; callback=dpcb)\n\n return Blowdown(pv,env,PressureODE(sol))\nend\nFrom the ODE solution the blowdown time, pressure curve, and temperature can be recovered.\nblowdown_time(bd::Blowdown{<:PressureODE}) =\n bd.sol.ode_sol.t[end]\nfunction blowdown_pressure(bd::Blowdown{<:PressureODE}, t)\n bdt = blowdown_time(bd)\n t = min(t, bdt)\n return bd.sol.ode_sol(t; idxs=1)\nend\nfunction blowdown_temperature(bd::Blowdown{<:PressureODE}, t)\n bdt = blowdown_time(bd)\n t = min(t, bdt)\n return bd.sol.ode_sol(t; idxs=3)\nend\n\nThe Ideal Gas Choked Flow Model\nThe 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.\nstruct IdealGasChoked{F <: Number}\n P₀::F\n k::F\n τ::F\nend\nfunction adiabatic_choked_blowdown(model::IdealGas, pv::PressureVessel,\n env::Environment)\n # vessel parameters\n c, A = pv.c, pv.A\n \n # vessel initial conditions\n V, T₀, m = pv.V, pv.T, pv.m\n n₀ = m/molecular_weight(model)\n v₀ = V/n₀\n P₀ = pressure(model, v₀, T₀)\n\n k, R, MW = model.k, model.R, model.MW\n τ = 1/( (c*A/V)*√(k*R*T₀/MW)*(2/(k+1))^((k+1)/(2*(k-1))) )\n return Blowdown(pv,env,IdealGasChoked(P₀,k,τ))\nend\nfunction blowdown_time(bd::Blowdown{<:IdealGasChoked})\n P₀, Pₐ, k, τ = bd.sol.P₀, bd.env.P, bd.sol.k, bd.sol.τ\n return (2τ/(1-k))*(1 - (Pₐ/P₀)^((1-k)/2k))\nend\nfunction blowdown_pressure(bd::Blowdown{<:IdealGasChoked}, t)\n P₀, k, τ = bd.sol.P₀, bd.sol.k, bd.sol.τ\n t = min(t, blowdown_time(bd))\n return P₀*( 1 + 0.5*(k-1)*(t/τ))^((2*k)/(1-k))\nend\nfunction blowdown_temperature(bd::Blowdown{<:IdealGasChoked}, t)\n T₀, P₀, k = bd.pv.T, bd.sol.P₀, bd.sol.k\n t = min(t, blowdown_time(bd))\n P = blowdown_pressure(bd, t)\n return T₀*(P/P₀)^((k-1)/k)\nend\n\n\nChecking our work\nThe 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).\natm = Environment(101325,288.15)\nvessel = let\n c = 0.85\n D = 0.005 # m\n A = 0.25*π*D^2 # m²\n V = 0.01111 # m³\n m = 2.743 # kg\n T = 288.15 # K\n PressureVessel(c, A, V, T, m)\nend\nThe real gas is modelled using a volume translated Peng-Robinson equation of state.\nusing Clapeyron:PR, ReidIdeal, RackettTranslation\nnitrogen = PR([\"nitrogen\"]; idealmodel=ReidIdeal, \n translation=RackettTranslation);\nig_nitrogen = IdealGas(nitrogen);\nchoked_model = adiabatic_choked_blowdown(ig_nitrogen, vessel, atm);\nideal_gas = adiabatic_blowdown(ig_nitrogen, vessel, atm);\nreal_gas = adiabatic_blowdown(nitrogen, vessel, atm);\n\n\n\n\n\n\nFigure 1: The adiabatic blowdown curve for a tank of nitrogen, showing the fully choked ideal gas model model and the DAE solutions for both the ideal gas and real gas case.\n\n\n\nThe 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.\nWhen 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.\n\n\n\n\n\n\nFigure 2: The vessel temperature for the nitrogen blowdown, showing the fully choked ideal gas model model and the DAE solutions for both the ideal gas and real gas case.\n\n\n\nAnother 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.\n\n\n\nThe Energy Equation\nAnother 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:\n\\[\n\\frac{dm}{dt} = -w\n\\]\nThe 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.\n\\[\n\\frac{dU}{dt} = Q_i - Q_o\n\\]\n\\[\n\\frac{dU}{dt} = Q_i - w \\bar{h}\n\\]\nWhere \\(\\bar{h}\\) is the specific enthalpy, note this is at vessel conditions. The boundary for the energy balance is around the vessel, not including the valve.\nThe total internal energy is the product of the mass remaining in the vessel and the specific internal energy, \\(U=m \\bar{u}\\). Applying the chain rule:\n\\[\n\\frac{dU}{dt} = m \\frac{d \\bar{u}}{dt} + \\bar{u} \\frac{dm}{dt} = m \\frac{d \\bar{u}}{dt} - \\bar{u} w\n\\]\nCombining these two expressions:\n\\[\n\\frac{d \\bar{u}}{dt} = \\frac{1}{m} \\left( Q_i + \\left(\\bar{u} - \\bar{h} \\right) w \\right)\n\\]\nThe specific internal energy, \\(\\bar{u}\\), is related to the molar internal energy, \\(u\\), by the molar weight, \\(M \\bar{u} = u\\), similarly for the specific and molar enthalpy. Substituting and multiplying through by the molar weight gives:\n\\[\n\\frac{d u}{dt} = \\frac{1}{m} \\left( M Q_i + \\left(u - h \\right) w \\right)\n\\]\nThe remaining mass, \\(m\\), can be written in terms of the molar volume, \\(v\\):\n\\[\n\\frac{d u}{dt} = \\frac{v}{M V} \\left( M Q_i + \\left(u - h \\right) w \\right)\n\\]\nThe mass balance can also be written in terms of the molar volume:\n\\[\n\\frac{dv}{dt} = \\frac{w v^2}{M V}\n\\]\nThe full system of equations, in terms of \\(u, v, T\\) is then:\n\\[\n\\frac{d u}{dt} = \\left( M Q_i + \\left(u - h \\right) w \\right) \\frac{v}{M V}\n\\]\n\\[\n\\frac{dv}{dt} = \\frac{w v^2}{M V}\n\\]\n\\[\n0 = u - internal\\_energy(v, T)\n\\]\nThe adiabatic case is the special case where \\(Q_i = 0\\).\n\nThe Adiabatic Ideal Gas Case\nIt 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.\nConsider an ideal gas with constant heat capacities such that \\(u = c_v T\\) and \\(h = c_p T\\). For the adiabatic case the energy balance becomes:\n\\[\nc_v \\frac{d T}{dt} = \\frac{1}{m} \\left( c_v T - c_p T \\right) w\n\\]\nIsentropic choked flow of an ideal gas occurs with:\n\\[\nw = c_d A \\rho_t \\sqrt{ \\frac{k R T_t}{M} }\n\\]\nWith nozzle density and temperature related to the vessel conditions by:\n\\[\n\\rho_t = \\rho \\left( 2 \\over {k+1} \\right)^{\\frac{1}{k-1}}\n\\]\n\\[\nT_t = T \\left( 2 \\over {k+1} \\right)\n\\]\nSubstituting all of this into the energy equation and dividing by \\(c_v\\) gives:\n\\[\n\\frac{dT}{dt} = \\frac{c_d A}{V} \\left( 1 - k \\right) \\left(2 \\over {k+1} \\right)^{\\frac{k+1}{2(k-1)}} \\sqrt{ \\frac{k R T}{M} } T\n\\]\nWhere \\(k = \\frac{c_p}{c_v}\\). The time constant \\(\\tau\\) is defined such that:\n\\[\n\\frac{1}{\\tau} = \\frac{c_d A}{V} \\left(2 \\over {k+1} \\right)^{\\frac{k+1}{2(k-1)}} \\sqrt{ \\frac{k R T_0}{M} }\n\\]\nWhich simplifies the ODE to:\n\\[\n\\frac{dT}{dt} = \\frac{1-k}{\\tau} T \\sqrt{\\frac{T}{T_0}}\n\\]\nThis is a separable equation and can be integrated to give:\n\\[\n\\frac{T}{T_0} = \\left( 1 + \\frac{k-1}{2} \\frac{t}{\\tau} \\right)^{-2}\n\\]\nFor an adiabatic expansion of an ideal gas:\n\\[\n\\frac{P}{P_0} = \\left( \\frac{T}{T_0} \\right)^{\\frac{k}{k-1}}\n\\]\nWhich recovers the original solution:\n\\[\n\\frac{P}{P_0} = \\left( 1 + \\frac{k+1}{2} \\frac{t}{\\tau} \\right)^{\\frac{2k}{1-k}}\n\\]\n\n\nImplementing the DAE\nThe governing equations for the vessel blowdown can be implemented as a DAE though, as the state variables, \\(u, v, T\\), 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.\nfunction energy_eqn!(dy, y, prms, t)\n u, v, T = y\n\n h = molar_enthalpy(prms.model, v, T)\n w = mass_flow(prms.model, prms.pv, prms.env, v, T)\n M = molecular_weight(prms.model)\n V = prms.pv.V\n\n dy .= [ (M*prms.Qᵢ(T) + (u-h)*w)*v/(M*V)\n (w*v^2)/(M*V)\n u - molar_internal_energy(prms.model, v, T) ]\n return nothing\nend\nueqn_rhs = ODEFunction(energy_eqn!, mass_matrix = [ 1 0 0\n 0 1 0\n 0 0 0 ])\ndepressured_callback_2(y, t, I; reltol=0.001) =\n pressure(I.p.model, y[2], y[3]) < (1+reltol)*I.p.env.P\nTo 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.\nusing DataInterpolations\nstruct EnergyODE{S,I}\n ode_sol::S\n p_interp::I\nend\nfunction energy_eqn_blowdown(model, pv::PressureVessel, \n env::Environment;\n Qi=(T)->0.0, \n solver=Rodas5(), \n tspan=(0.0, 600.0))\n\n # vessel initial conditions\n V, T₀, m = pv.V, pv.T, pv.m\n Mw = molecular_weight(model)\n v₀ = Mw*V/m\n u₀ = molar_internal_energy(model, v₀, T₀)\n \n # defining the parameters\n params = (model=model, pv=pv, env=env, Qᵢ=Qi)\n\n # callbacks\n svs = SavedValues(Float64, Float64)\n svcb = SavingCallback((y, t, I) -> pressure(I.p.model,y[2],y[3]), svs)\n dpcb = DiscreteCallback(depressured_callback_2, terminate!)\n cbs = CallbackSet(svcb,dpcb)\n\n # set up the ODEProblem and solve\n y₀ = [u₀; v₀; T₀]\n prob = ODEProblem(ueqn_rhs, y₀, tspan, params)\n sol = solve(prob, solver; callback=cbs)\n\n # set up pressure interpolation\n pi = AkimaInterpolation(svs.saveval, svs.t)\n\n return Blowdown(pv,env,EnergyODE(sol,pi))\nend\nThe methods for blowdown time, pressure, and temperature are easily implemented.\nblowdown_time(bd::Blowdown{<:EnergyODE}) =\n bd.sol.ode_sol.t[end]\nfunction blowdown_pressure(bd::Blowdown{<:EnergyODE}, t)\n bdt = blowdown_time(bd)\n t = min(t, bdt)\n return bd.sol.p_interp(t)\nend\nfunction blowdown_temperature(bd::Blowdown{<:EnergyODE}, t)\n bdt = blowdown_time(bd)\n t = min(t, bdt)\n return bd.sol.ode_sol(t; idxs=3)\nend\nThe results from the energy model can be compared to the pressure model, they are functionally identical.\nideal_gas_energybd = energy_eqn_blowdown(ig_nitrogen, vessel, atm);\nreal_gas_energybd = energy_eqn_blowdown(nitrogen, vessel, atm);\n\n\n\n\n\n\nFigure 3: The blowdown curve for the nitrogen blowdown, showing the DAE solutions for both the pressure model and the energy balance model (ideal gas and real gas cases). The curves for the pressure model and energy balance model overlap.\n\n\n\n\n\n\n\n\n\nFigure 4: The vessel temperature for the nitrogen blowdown, showing the DAE solutions for both the pressure model and the energy balance model (ideal gas and real gas cases). The curves for the pressure model and energy balance model overlap.\n\n\n\n\n\n\nPerformance\nI 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.\nGiven those limitations, the performance of the two models can be compared using BenchmarkTools.jl.\n@benchmark adiabatic_blowdown(nitrogen, vessel, atm)\nBenchmarkTools.Trial: 42 samples with 1 evaluation.\n Range (min … max): 111.588 ms … 149.424 ms ┊ GC (min … max): 0.00% … 20.45%\n Time (median): 120.515 ms ┊ GC (median): 6.11%\n Time (mean ± σ): 120.226 ms ± 5.882 ms ┊ GC (mean ± σ): 4.39% ± 4.00%\n\n ▂ ██ ▅█ █ \n ▅██▁▅█▅▁▁▁▅▅███▅████▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▅ ▁\n 112 ms Histogram: frequency by time 149 ms <\n\n Memory estimate: 59.87 MiB, allocs estimate: 948090.\nThe 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.\n@benchmark energy_eqn_blowdown(nitrogen, vessel, atm)\nBenchmarkTools.Trial: 56 samples with 1 evaluation.\n Range (min … max): 82.811 ms … 122.534 ms ┊ GC (min … max): 0.00% … 28.08%\n Time (median): 89.420 ms ┊ GC (median): 0.00%\n Time (mean ± σ): 89.381 ms ± 6.032 ms ┊ GC (mean ± σ): 4.05% ± 5.62%\n\n ▂ ▂ ▂ ▂ █▂ \n █▅▅▅▁▅█▅█▅▅█▅▁▅▅██▅▁▁▁▁▁▁▁▁▁▅▁▁▁▁▅▁▁▁▁▅▅█▅██▁██▅█▁▁▅▅▁▅▁█▁▁▅ ▁\n 82.8 ms Histogram: frequency by time 95.7 ms <\n\n Memory estimate: 44.30 MiB, allocs estimate: 727470.\nThe 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.\n\n\n\n\n\n\nFigure 5: The blowdown curve for the pressure model when molar volume is moved to the RHS of the ODE. The pressure model curves have a weird bump at the end.\n\n\n\nA 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." + }, + { + "objectID": "posts/vessel_blowdown_real_gases/index.html#conclusions", + "href": "posts/vessel_blowdown_real_gases/index.html#conclusions", + "title": "Vessel Blowdown - Real Gases", + "section": "Conclusions", + "text": "Conclusions\nExtending 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.\nRapid 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 \\(Q = k \\left( T - T_a \\right)\\), 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.\nSlower 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).\nI 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." + }, + { + "objectID": "posts/dynamic_mode_decomposition/index.html", + "href": "posts/dynamic_mode_decomposition/index.html", + "title": "Dynamic Mode Decomposition", + "section": "", + "text": "Recently I’ve been playing around with Dynamic Mode Decomposition (DMD) and this notebook compiles my notes and julia code in one place for later reference.\nVery 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\n\\[ \\mathbf{\\dot{x} } = \\mathbf{A x} \\]\nWhere 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." + }, + { + "objectID": "posts/dynamic_mode_decomposition/index.html#example-flow-past-a-cylinder", + "href": "posts/dynamic_mode_decomposition/index.html#example-flow-past-a-cylinder", + "title": "Dynamic Mode Decomposition", + "section": "Example: Flow Past a Cylinder", + "text": "Example: Flow Past a Cylinder\nAs 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.\nThe MAT package allows us to import data from matlab data files directly into julia\n\nusing MAT\n\nfile = matopen(\"data/CYLINDER_ALL.mat\")\n\n# import the data set\ndata = read(file, \"VORTALL\");\n\n# the orinal dimensions of each snapshot\nnx = Int(read(file, \"nx\"))\nny = Int(read(file, \"ny\"))\n\n# the final dimensions of the data matrix\nn, m = size(data)\n\n(89351, 151)\n\n\nThe 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.\n\n\n\n\n\n\nFigure 1: Original data, vorticity of flow past a cylinder.\n\n\n\nThe 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.\n\nsize_A_naive = n*n*8\n\n63868809608" + }, + { + "objectID": "posts/dynamic_mode_decomposition/index.html#exact-dmd", + "href": "posts/dynamic_mode_decomposition/index.html#exact-dmd", + "title": "Dynamic Mode Decomposition", + "section": "Exact DMD", + "text": "Exact DMD\nDMD 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.\n\nBest Fit Matrix\nConsider 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\n\\[ \\| \\mathbf{ A X } - \\mathbf{Y} \\|_{F} \\]\nwhere \\(\\| \\cdots \\|_{F}\\) is the Frobenius norm.\nThe solution to which is\n\\[ \\mathbf{A} = \\mathbf{YX}^{\\dagger} \\]\nwhere X† is the Moore-Penrose pseudoinverse of X.1\n1 I think this can be shown fairly easily by starting with the definition of the Frobenius norm \\(\\| \\mathbf{ A X } - \\mathbf{Y} \\|_{F}^{2} = \\mathrm{Tr}\\left( \\left(\\mathbf{ A X } - \\mathbf{Y}\\right)\\left(\\mathbf{ A X } - \\mathbf{Y} \\right)^{T} \\right)\\) and finding the matrix A that minimizes that using standard matrix calculus, and some properties of the pseudoinverse.\n\nSingular Value Decomposition\nThe conventional way of calculating the Moore-Penrose pseudoinverse is to use the Singular Value Decomposition: for a matrix X with SVD \\(\\mathbf{X}=\\mathbf{U}\\mathbf{\\Sigma}\\mathbf{V}^{*}\\), the pseudoinverse is \\(\\mathbf{X}^{\\dagger} = \\mathbf{V} \\mathbf{\\Sigma}^{-1} \\mathbf{U}^{*}\\). Returning to the best fit matrix A we find\n\\[ \\mathbf{A} = \\mathbf{Y} \\mathbf{V} \\mathbf{\\Sigma}^{-1} \\mathbf{U}^{*} \\]\nWe can calculate a projection of A onto the space of the upper singular vectors U\n\\[ \\tilde{ \\mathbf{A} } = \\mathbf{U}^{*} \\mathbf{A} \\mathbf{U} = \\mathbf{U}^{*} \\mathbf{Y} \\mathbf{V} \\mathbf{\\Sigma}^{-1} \\mathbf{U}^{*} \\mathbf{U} = \\mathbf{U}^{*} \\mathbf{Y} \\mathbf{V} \\mathbf{\\Sigma}^{-1} \\]\nWhich then allows us to reconstruct the matrix A on demand while only needing to store the matrices à and U, by the following \\[ \\mathbf{A} = \\mathbf{U} \\tilde{ \\mathbf{A} } \\mathbf{U}^{*} \\]\nThis 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\n\nsize_A_exact = (n*m + m*m)*8\n\n108118416\n\n\n\nsize_A_exact/size_A_naive\n\n0.0016928202774340957\n\n\nReturning 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\n\\[ \\mathbf{x}_{k+1} = \\mathbf{A} \\mathbf{x}_k \\]\nfor all xk in our data set. Or in other words, to find the best fit matrix A for the system\n\\[ \\mathbf{X}_{2} = \\mathbf{A} \\mathbf{X}_{1} \\]\nwhere X1 is the matrix of all of the vectors xk and X2 is the matrix of the corresponding xk+1’s.\nThough, using DMD, we will instead calculate à and U, leaving us with\n\\[ \\mathbf{x}_{k+1} = \\mathbf{U} \\mathbf{ \\tilde{A} } \\mathbf{U}^{*} \\mathbf{x}_k \\]\nTo start, we divide the data set into X1 and X2\n\nusing LinearAlgebra\n\n\n# dividing into past and future states\nX₁ = data[:, 1:end-1];\nX₂ = data[:, 2:end];\n\nThen compute the SVD of X1.2\n2 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.\n# SVD\nU, Σ, V = svd(X₁)\nΣ = Diagonal(Σ);\n\nThen calculate the projection à (I am pre-computing YVΣ-1 as that will come in handy later)\n\n# projection\nYVΣ⁻¹ = X₂*V*Σ^-1\nà = U'*YVΣ⁻¹\n\nsize(Ã)\n\n(150, 150)\n\n\nWe can then calculate the predicted xk+1’s, without ever having to actually compute (or store) A\n\nX̂₂_exact = (U*(Ã*(U'*X₁)));\n\nAs 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\n\n\n\n\n\n\nFigure 2: Original flow field (top) and reconstructed flow field (bottom).\n\n\n\n\n\nDynamic Modes\nOf course this only solves the problem in the discrete case (for control applications that may be all you need). Consider again the system \\(\\mathbf{\\dot{x} } = \\mathbf{A x}\\), the solution to this differential equation is\n\\[ \\mathbf{x}\\left( t \\right) = e^{\\mathbf{A}t} \\mathbf{x}_{0} \\]\nwhere x0 is the initial conditions. If the matrix A has eigendecomposition ΦΛΦ-1 then this can be written as\n\\[ \\mathbf{x}\\left( t \\right) = \\mathbf{\\Phi} e^{\\mathbf{\\Lambda}t} \\mathbf{\\Phi}^{-1} \\mathbf{x}_{0} \\]\nSo it would be very convenient if we could get those eigenvalues and eigenvectors, preferably without having to actually compute A.\nRecall, 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\n\\[ \\mathbf{ \\tilde{A} } \\mathbf{W} = \\mathbf{W} \\mathbf{\\Lambda} \\]\n\\[ \\mathbf{U}^{*} \\mathbf{A} \\mathbf{U} \\mathbf{W} = \\mathbf{W} \\mathbf{\\Lambda} \\]\n\\[ \\mathbf{U} \\mathbf{U}^{*} \\mathbf{A} \\mathbf{U} \\mathbf{W} = \\mathbf{U} \\mathbf{W} \\mathbf{\\Lambda} \\]\n\\[ \\mathbf{A} \\mathbf{\\Phi} = \\mathbf{\\Phi} \\mathbf{\\Lambda} \\]\nwhere\n\\[ \\mathbf{\\Phi} = \\mathbf{U} \\mathbf{W} \\]\nThis is what is given in the original DMD, however more recent work recommends using\n\\[ \\mathbf{\\Phi} = \\mathbf{Y} \\mathbf{V} \\mathbf{\\Sigma}^{-1} \\mathbf{W} \\]\n\n# calculate eigenvectors and eigenvalues\n# of projection Ã\nΛ, W = eigen(Ã)\n \n# reconstruct eigenvectors of A\nΦ = YVΣ⁻¹*W;\n\nWhether 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.\n\n\n\n\n\n\n\n\nFigure 3: The first and tenth dymanic mode of the system.\n\n\n\n\n\nI’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\n\\[ \\omega_{i} = {\\log{ \\lambda_{i} } \\over \\Delta t} \\]\nwhere Δt is the time step. The eigenvectors are the same, though. So we can generate a function x(t) pretty easily:\n\n# calculate the eigenvalues for \n# the continuous system\nΔt = 1\nΩ = Diagonal(log.(Λ)./Δt)\n\n# precomputing this\nΦ⁻¹x₀ = Φ\\X₁[:,1]\n\n# continuous system\nx̂(t) = real( Φ*exp(Ω .* t)*Φ⁻¹x₀ )\n\n\n\n\n\n\n\nFigure 4: Original flow field (top) and reconstructed flow field (bottom), using the continuous time vector function." + }, + { + "objectID": "posts/dynamic_mode_decomposition/index.html#refactoring", + "href": "posts/dynamic_mode_decomposition/index.html#refactoring", + "title": "Dynamic Mode Decomposition", + "section": "Refactoring", + "text": "Refactoring\nThrough 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.\n\nstruct DMD\n r::Integer # Dimension\n U::Matrix # Upper Singular Vectors\n Ã::Matrix # Projection of A\n Λ::Diagonal # Eigenvalues of A\n Φ::Matrix # Eigenvectors of A\nend\n\nThen 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.\n\nfunction DMD(Y::Matrix, X::Matrix)\n # dimension\n r = rank(X)\n \n # Full SVD\n U, Σ, V = svd(X)\n Σ = Diagonal(Σ)\n \n # projection\n YVΣ⁻¹ = Y*V*Σ^-1\n à = U'*YVΣ⁻¹\n \n # calculate eigenvectors and eigenvalues\n # of projection Ã\n Λ, W = eigen(Ã)\n Λ = Diagonal(Λ)\n \n # reconstruct eigenvectors of A\n Φ = YVΣ⁻¹*W\n \n return DMD(r,U,Ã,Λ,Φ)\nend\n\nfunction DMD(X::Matrix)\n X₁ = X[:, 1:end-1]\n X₂ = X[:, 2:end]\n return DMD(X₂, X₁)\nend\n\nWe can check that this is doing what it is supposed to be doing by comparing with what we have already done\n\nd = DMD(data)\n\n# This produces the same result as before\nd.Φ == Φ && d.Λ == Diagonal(Λ)\n\ntrue\n\n\nIf 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.\n\nDiscrete System\nSince 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)\n\nstruct DiscreteSys\n Ã::Matrix\n U::Matrix\nend\n\nfunction DiscreteSys(d::DMD)\n return DiscreteSys(d.Ã,d.U)\nend\n\nfunction (ds::DiscreteSys)(xₖ)\n return (ds.U*(ds.Ã*(ds.U'*xₖ)))\nend\n\n\nds = DiscreteSys(d)\n\n# This produces the same result as before\nX̂₂_exact == ds(X₁)\n\ntrue\n\n\n\n\nContinuous System\nSimilarly 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\n\nstruct ContinuousSys\n Φ⁻¹x₀::Vector\n Ω::Diagonal\n Φ::Matrix\nend\n\nfunction ContinuousSys(d::DMD, x₀, Δt=1)\n Φ⁻¹x₀ = d.Φ\\x₀\n Ω = Diagonal(log.(d.Λ.diag)./Δt)\n return ContinuousSys(Φ⁻¹x₀, Ω, d.Φ)\nend\n\nfunction (cs::ContinuousSys)(t)\n return real( cs.Φ*exp(cs.Ω .* t)*cs.Φ⁻¹x₀ )\nend\n\n\ncs = ContinuousSys(d, X₁[:,1]);\n\n# This produces the same result as before\nx̂(150) == cs(150)\n\ntrue\n\n\n\n\nLarge Systems\nI 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." + }, + { + "objectID": "posts/dynamic_mode_decomposition/index.html#reduced-dmd", + "href": "posts/dynamic_mode_decomposition/index.html#reduced-dmd", + "title": "Dynamic Mode Decomposition", + "section": "Reduced DMD", + "text": "Reduced DMD\nWhenever 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.\n\nfunction DMD(Y::Matrix, X::Matrix, r::Integer) \n # full SVD\n U, Σ, V = svd(X)\n \n # truncating to rank r\n @assert r ≤ rank(X)\n U = U[:, 1:r]\n Σ = Diagonal(Σ[1:r])\n V = V[:, 1:r]\n \n # projection\n YVΣ⁻¹ = Y*V*Σ^-1\n à = U'*YVΣ⁻¹\n \n # calculate eigenvectors and eigenvalues\n # of projection Ã\n Λ, W = eigen(Ã)\n Λ = Diagonal(Λ)\n \n # reconstruct eigenvectors of A\n Φ = YVΣ⁻¹*W\n \n return DMD(r,U,Ã,Λ,Φ)\nend\n\nfunction DMD(X::Matrix, r::Integer)\n X₁ = X[:, 1:end-1]\n X₂ = X[:, 2:end]\n return DMD(X₂, X₁, r)\nend\n\nOne consequence of truncation, however, is that the resulting matrix Ur is only semi-unitary, in particular\n\\[ \\mathbf{U}_{r}^{*} \\mathbf{U}_{r} = \\mathbf{I}_{r \\times r} \\]\nbut\n\\[ \\mathbf{U}_{r} \\mathbf{U}_{r}^{*} \\ne \\mathbf{I}_{n \\times n} \\]\nThis 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 Ã.\nBut, 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.\n\n\n\n\n\nThe singular values of the system showing a significant elbow at r=45\n\n\n\n\nWe can then generate a set of predictions for the reduced DMD, with r=45, and compare with the exact DMD\n\nds_45 = DiscreteSys(DMD(data, 45))\nX̂₂_45 = ds_45(X₁)\n\nnorm(X₂ - X̂₂_45) # Frobenius norm\n\n0.005459307491383062\n\n\n\nnorm(X₂ - X̂₂_exact)\n\n0.0005597047465277092\n\n\nAn 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.\nThat is to say we calculate the r such that\n\\[ { {\\sum_{i}^{r} \\sigma_i} \\over {\\sum_{i}^{m} \\sigma_i} } \\le p \\]\nwhere σi is the ith singular value (in order of largest to smallest).\n\nfunction DMD(Y::Matrix, X::Matrix, p::AbstractFloat)\n @assert p>0 && p≤1\n \n # full SVD\n U, Σ, V = svd(X)\n \n # determine required rank\n r = minimum( findall( >(p), cumsum(Σ)./sum(Σ)) )\n \n # truncate\n @assert r ≤ rank(X)\n U = U[:, 1:r]\n Σ = Diagonal(Σ[1:r])\n V = V[:, 1:r]\n \n # projection\n YVΣ⁻¹ = Y*V*Σ^-1\n à = U'*YVΣ⁻¹\n \n # calculate eigenvectors and eigenvalues\n # of projection Ã\n Λ, W = eigen(Ã)\n Λ = Diagonal(Λ)\n \n # reconstruct eigenvectors of A\n Φ = YVΣ⁻¹*W\n \n return DMD(r,U,Ã,Λ,Φ)\nend\n\nfunction DMD(X::Matrix, p::AbstractFloat)\n X₁ = X[:, 1:end-1]\n X₂ = X[:, 2:end]\n return DMD(X₂, X₁, p)\nend\n\nCapturing 99% of the variance, in this case, requires only keeping the first 14 singular values.\n\n\n\n\n\n\n\n\nFigure 5: The Frobenius norm of the difference between the original and reconstructed flow field as a function of reduced DMD rank, the point where 99% of the variance has been captured is indicated.\n\n\n\n\n\n\n\n\n\n\n\nFigure 6: Original flow field (top) and reconstructed flow field (bottom), using reduced DMD capturing 99% of the variance.\n\n\n\nThere 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.\nSo, supposing that p=0.99 works for us, how much further have we reduced the size of our matrices?\n\n# for p=0.99, r=14\nr = 14\nsize_A_reduced = (n*r + r*r)*8\n\n10008880\n\n\nTo recover the (approximate) A matrix we only need to store 10MB, a ~91% reduction over the exact DMD\n\nsize_A_reduced/size_A_exact\n\n0.09257331331972159\n\n\nand a >99.98% reduction of the naive case (recall the naive approach of storing the entire A matrix would take ~64GB)\n\nsize_A_reduced/size_A_naive\n\n0.00015670998193688458\n\n\n\nTruncated SVD and Large Systems\nIn 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." + }, + { + "objectID": "posts/dynamic_mode_decomposition/index.html#compressed-dmd", + "href": "posts/dynamic_mode_decomposition/index.html#compressed-dmd", + "title": "Dynamic Mode Decomposition", + "section": "Compressed DMD", + "text": "Compressed DMD\nCompressed DMD attempts to tackle the slowest step in the DMD algorithm: calculating the SVD. An SVD on full data is \\(\\mathcal{O}\\left( n m^2 \\right)\\) if we instead compress the data from n dimensions to k dimensions then the cost of the SVD is reduced to either \\(\\mathcal{O}\\left( k m^2 \\right)\\) (when k>m) or \\(\\mathcal{O}\\left( m k^2 \\right)\\) (when k < m), which for large n can be a dramatic speed-up.\nSuppose 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\n\\[ \\mathbf{X}_c = \\mathbf{C} \\mathbf{X} \\\\ \\mathbf{Y}_c = \\mathbf{C} \\mathbf{Y} \\]\nWe suppose again that X has the SVD X=**UΣV***, then\n\\[ \\mathbf{X}_c = \\mathbf{C} \\mathbf{X} = \\mathbf{C} \\mathbf{U} \\mathbf{\\Sigma} \\mathbf{V}^{*} \\]\nand, since C is unitary, the SVD of Xc is\n\\[ \\mathbf{X}_c = \\mathbf{U}_c \\mathbf{\\Sigma} \\mathbf{V}^{*} \\]\nwhere Uc=CU is the upper singular values of the compressed input matrix.\nThe projection matrix Ãc of the compressed input matrix is\n\\[ \\mathbf{ \\tilde{A} }_c = \\mathbf{U}^{*}_c \\mathbf{Y}_c \\mathbf{V} \\mathbf{\\Sigma}^{-1} \\]\n\\[ = \\left( \\mathbf{CU} \\right)^{*} \\mathbf{C} \\mathbf{Y} \\mathbf{V} \\mathbf{\\Sigma}^{-1} \\]\n\\[ = \\mathbf{U}^{*} \\mathbf{C}^{*} \\mathbf{C} \\mathbf{Y} \\mathbf{V} \\mathbf{\\Sigma}^{-1} \\]\n\\[ = \\mathbf{U}^{*} \\mathbf{C}^{*} \\mathbf{C} \\mathbf{Y} \\mathbf{V} \\mathbf{\\Sigma}^{-1} \\]\n\\[ = \\mathbf{U}^{*} \\mathbf{Y} \\mathbf{V} \\mathbf{\\Sigma}^{-1} = \\mathbf{ \\tilde{A} } \\]\nand so we should recover the same eigenvalues and eigenvectors as from the uncompressed data.\n\nusing SparseArrays\n\nfunction cDMD(Y::Matrix, X::Matrix, C::AbstractSparseMatrix) \n # determining dimensionality\n r = rank(X)\n \n # compress the X and Y\n Xc = C*X\n Yc = C*Y\n \n # singular value decomposition\n Uc, Σc, Vc = svd(Xc)\n Σc = Diagonal(Σc)\n \n # projection\n à = Uc'*Yc*Vc*inv(Σc)\n U = C'*Uc\n \n # calculate eigenvectors and eigenvalues\n # of projection Ã\n Λ, W = eigen(Ã)\n Λ = Diagonal(Λ)\n \n # reconstruct eigenvectors of A\n Φ = Y*Vc*inv(Σc)*W\n \n return DMD(r,U,Ã,Λ,Φ)\nend\n\nfunction cDMD(X::Matrix, C::AbstractSparseMatrix)\n X₁ = X[:, 1:end-1]\n X₂ = X[:, 2:end]\n return cDMD(X₂, X₁, C)\nend\n\nThe 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.\nWe 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.\n\nfunction cDMD(Y::Matrix, X::Matrix, k::Integer)\n n, m = size(X)\n @assert k≤n\n \n # build (sparse) compression matrix\n C = spzeros(k, n)\n for i in 1:k\n C[i,rand(1:n)] = 1\n end\n\n return cDMD(Y, X, C)\nend\n \nfunction cDMD(X::Matrix, k::Integer)\n X₁ = X[:, 1:end-1]\n X₂ = X[:, 2:end]\n return cDMD(X₂, X₁, k)\nend\n\nSuppose we sample at 300 randomly chosen points in the flow field to form the compression matrix\n\nk = 300\n\nC = spzeros(k, n)\nfor i in 1:k\n C[i,rand(1:n)] = 1\nend\n\nThat 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.\n\n\nOriginal flow field with randomly generated sample points for compressed DMD.\n\nWe 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.\n\n\n\n\n\n\n\n\nFigure 7: Compressed DMD performance, as measured by the Frobenius norm of the difference between the original flow field and the reconstructed field, over a range of sample sizes.\n\n\n\n\n\nUsing the compression matrix from above, we can generate a compressed DMD3\n3 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.\n\n\n\n\n\nFigure 8: Original flow field (top) and reconstructed flow field (bottom), using compressed DMD and sampling at 300 points.\n\n\n\nThe 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.\nThere 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." + }, + { + "objectID": "posts/dynamic_mode_decomposition/index.html#physics-informed-dmd", + "href": "posts/dynamic_mode_decomposition/index.html#physics-informed-dmd", + "title": "Dynamic Mode Decomposition", + "section": "Physics Informed DMD", + "text": "Physics Informed DMD\nThe 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\n4 Baddoo et al., “Physics-Informed Dynamic Mode Decomposition (piDMD)” fig 3.\n\n\n\n\n\nFigure 9: A comparison of models trained with exact DMD and with piDMD, also showing the matrix structure of the corresponding piDMD method.5\n\n5 Baddoo et al., fig. 3.\n\nConveniently 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\nWe modify the best fit such that we are looking for the A matrix that minimizes\n\\[ \\| \\mathbf{ A X } - \\mathbf{Y} \\|_{F} \\]\n\\[ \\textrm{ subject to } \\mathbf{A}^{*} \\mathbf{A} = \\mathbf{I} \\]\nFor which the standard solution is to define a matrix M\n\\[ \\mathbf{M} = \\mathbf{Y} \\mathbf{X}^{*} \\]\nsupposing M has SVD\n\\[ \\mathbf{M} = \\mathbf{U}_{M} \\mathbf{\\Sigma}_{M} \\mathbf{V}_{M}^{*} \\]\nthen the solution is\n\\[ \\mathbf{A} = \\mathbf{U}_{M} \\mathbf{V}_{M}^{*} \\]\nOf 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:\n\\[ \\mathbf{X} = \\mathbf{U} \\mathbf{\\Sigma} \\mathbf{V}^{*} \\]\n\\[ \\mathbf{ \\tilde{X} } = \\mathbf{U}^{*} \\mathbf{X} \\]\n\\[ \\mathbf{ \\tilde{Y} } = \\mathbf{U}^{*} \\mathbf{Y} \\]\n\\[ \\mathbf{ \\tilde{M} } = \\mathbf{ \\tilde{Y} } \\mathbf{ \\tilde{X} }^{*} = \\mathbf{U}^{*} \\mathbf{Y} \\mathbf{X}^{*} \\mathbf{U} = \\mathbf{U}^{*} \\mathbf{M} \\mathbf{U}\\]\nsince SVD is invariant to left and right unitary transformations, the SVD of the projected \\(\\mathbf{ \\tilde{M} }\\) is\n\\[ \\mathbf{ \\tilde{M} } = \\mathbf{U}_{ \\tilde{M} } \\mathbf{\\Sigma}_{M} \\mathbf{V}_{ \\tilde{M} }^{*} \\]\nwhere\n\\[ \\mathbf{U}_{ \\tilde{M} } = \\mathbf{U}^{*} \\mathbf{U}_{ M } \\textrm{ and } \\mathbf{V}_{ \\tilde{M} } = \\mathbf{U}^{*} \\mathbf{V}_{M} \\]\nand the A matrix which solves the projected procrustes problem is\n\\[ \\mathbf{ \\tilde{A} } = \\mathbf{U}_{ \\tilde{M} } \\mathbf{V}_{ \\tilde{M} }^{*} = \\mathbf{U}^{*} \\mathbf{U}_{ M } \\mathbf{V}_{M}^{*} \\mathbf{U} = \\mathbf{U}^{*} \\mathbf{A} \\mathbf{U} \\]\nwhich is exactly the projected A matrix we need to proceed with reconstructing the eigenvalues and eigenvectors as per the standard DMD algorithm.\n\n# this is piDMD *only* for the case where A must be unitary\n# see arXiv:2112.04307 for details on the alternative cases\nfunction piDMD(Y::Matrix, X::Matrix)\n # dimension\n r = rank(X)\n \n # Full SVD\n U, _, _ = svd(X)\n \n # projection\n Ỹ = U'*Y\n X̃ = U'*X\n M̃ = Ỹ*X̃'\n \n # solve procrustes problem\n Uₘ, _, Vₘ = svd(M̃)\n à = Uₘ*Vₘ'\n \n # calculate eigenvectors and eigenvalues\n # of projection Ã\n Λ, W = eigen(Ã)\n Λ = Diagonal(Λ)\n \n # reconstruct eigenvectors of A\n Φ = U*W\n \n return DMD(r,U,Ã,Λ,Φ)\nend\n\nfunction piDMD(X::Matrix)\n X₁ = X[:, 1:end-1]\n X₂ = X[:, 2:end]\n return piDMD(X₂, X₁)\nend\n\n\n\n\n\n\n\nFigure 10: Original flow field (top) and reconstructed flow field (bottom), using physics informed DMD.\n\n\n\nWe 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.\n\nnorm(X₂ - X̂₂_pi, 2)\n\n18.35684111920036\n\n\n\nnorm(X₂ - X̂₂_exact, 2)\n\n0.0005597047465277092\n\n\nThe 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.\nSimilarly 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." + }, + { + "objectID": "posts/dynamic_mode_decomposition/index.html#references", + "href": "posts/dynamic_mode_decomposition/index.html#references", + "title": "Dynamic Mode Decomposition", + "section": "References", + "text": "References\n\n\nBaddoo, Peter J., Benjamin Herrmann, Beverley J. McKeon, J. Nathan Kutz, and Steven L. Brunton. “Physics-Informed Dynamic Mode Decomposition (piDMD),” December 8, 2021. https://doi.org/10.48550/arXiv.2112.04307.\n\n\nBai, Zhe, Eurika Kaiser, Joshua L. Proctor, J. Nathan Kutz, and Steven L. Brunton. “Dynamic Mode Decomposition for Compressive System Identification.” AIAA Journal 58 (2020): 561–74. https://doi.org/10.2514/1.J057870.\n\n\nBrunton, Steven L., and J. Nathan Kutz. Data Driven Science and Engineering. Cambridge: Cambridge University Press, 2019. http://databookuw.com.\n\n\nBrunton, Steven L., Joshua L. Proctor, and J. Nathan Kutz. “Compressive Sampling and Dynamic Mode Decomposition,” December 18, 2013. https://doi.org/10.48550/arXiv.1312.5186.\n\n\nBrunton, Steven L., Joshua L. Proctor, Jonathan H. Tu, and J. Nathan Kutz. “Compressed Sensing and Dynamic Mode Decomposition.” Journal of Computational Dynamics 2 (2015): 165–91. https://doi.org/10.3934/jcd.2015002.\n\n\nSchmid, Peter J. “Dynamic Mode Decomposition of Numerical and Experimental Data.” Journal of Fluid Mechanics 656 (2010): 5–28. https://doi.org/10.1017/S0022112010001217.\n\n\nTu, Jonathan H., Clarence W. Rowley, Dirk Martin Luchtenburg, Steven L. Brunton, and J. Nathan Kutz. “On Dynamic Mode Decomposition: Theory and Applications.” Journal of Computational Dynamics 1 (2014): 391–421. https://doi.org/10.3934/jcd.2014.1.391." + }, + { + "objectID": "posts/butane_leak_example/index.html", + "href": "posts/butane_leak_example/index.html", + "title": "Chemical Release Screening Example - Butane leak", + "section": "", + "text": "A routine practice of process safety is to model scenarios for different chemical hazards present at a plant. Often there are more plausible scenarios than there is the time or resources to model at the highest level of fidelity, the more complex models take time to set up and run and often there are only so many software licenses available. There needs to be some prioritization and screening. It’s fairly typical, especially for larger companies, to have screening tools that an engineer can use which incorporate simpler models and make conservative estimates to get a first guess at the impact of a given hazard, if this crosses a preset threshold then it is escalated to a more in depth level of modelling, drilling down to more and more detailed analysis as required.\nMore often than not I’ve seen these simple tools implemented as excel spreadsheets – which is fine, they do the job and everybody has excel on their computers – however overly involved spreadsheets can be rather opaque, it’s often not obvious what they are doing and what assumptions are being made in those calculations. So I am going to work through an example of how one could estimate the airborne quantity, and ultimately the consequences of, an example release of butane from a large storage sphere, while documenting the assumptions and models along the way." + }, + { + "objectID": "posts/butane_leak_example/index.html#the-scenario", + "href": "posts/butane_leak_example/index.html#the-scenario", + "title": "Chemical Release Screening Example - Butane leak", + "section": "The Scenario", + "text": "The Scenario\nAs a simple scenario suppose a leak from a butane storage sphere. These are a fairly common sight around refineries and facilities that process large quantities of hydrocarbons. This sphere is 40ft in diameter and operates under 250psig of pressure, containing primarily n-butane, which I will assume is entirely n-butane for simplicity1. As for the leak itself I am supposing a leak area equivalent to a 2in rupture2. The sphere doesn’t sit directly on the ground, it is supported 10ft above a concrete pad which has a diked area of 500ft². The leak itself at the bottom somewhere, suppose exactly at the bottom for simplicity3. Furthermore I am assuming the release occurs on a day with an ambient temperature of 25°C and that the tank contents and surroundings are at thermal equilibrium.\n1 If the vessel contained a mixture, for the purposes of screening, conservatively choosing the most volatile of the major components would be a reasonable assumption. These simplifications are suitable for screening purposes however if more in depth modeling is required then performing mixture flash calculations would have to be considered, which very quickly becomes a lot of work to set-up outside of a process simulator like Aspen2 There are lots of ways of generating leak scenarios, from the very specific leaks from particular propagating events to simple rules of thumb. The Chemical Exposure Index gives the following rules for determining a leak scenario for a vessel:\nA rupture based on the largest diameter process pipe attached to the vessel using the following:\n\nFor anything less than 2in a full bore rupture (i.e. the full diameter of the pipe)\nFor between 2 and 4in assume a rupture area equal to that of a 2in diameter pipe\nFor >4in assume a rupture area equal to 20% of the pipe cross section area\n\n3 Picking the bottom also ensures the leak occurs at the highest pressure, which gives a larger release and is most conservative. Releases at higher elevations also tend to mix more thoroughly with the air and present less of a hazard to personnel on the ground, and possibly less of an explosion hazard depending on where one supposes the ignition sources are.Key Assumptions\n\nStorage sphere with 40ft diameter\nSphere located on a concrete pad with 500ft² diked area\nSphere contains ~100% n-butane\nLeak area equivalent to a 2in rupture\nLeak located at the bottom of the vessel for maximum release pressure\nVessel pressure is 250psig\nRelease temperature is 25°C\n\n\n\n\n\n\n\nFigure 1: A sketch of the release scenario, adapted from … somewhere\n\n\n\n\nusing Unitful: ustrip, @u_str\n\nft = ustrip(u\"m\", 1u\"ft\") # unit conversion ft->m\ninch = ustrip(u\"m\", 1u\"inch\") # unit conversion inch->m\npsi = ustrip(u\"Pa\", 1u\"psi\") # unit conversion psi->Pa\n\nDᵥ = 40ft # Diameter of the vessel, in m\nAd = 500ft^2 # Dyked area, in m^2\ndₕ = 2inch # Diameter of the hole, in m\nhₗ = 50ft # height of liquid in the vessel\nhᵣ = 10ft # height of release point\n\npₐ= 14.7psi # atmospheric pressure in Pa absolute\np = 250psi + pₐ # pressure of the butane in Pa absolute\nTᵣ= 25 + 273.15; # the release temperature in K\n\nSome relevant thermodynamic properties of butane\n\n# From Perry's, 8th edition\n\nR = 8.31446261815324 # universal gas constant, J/mol/K\n\n# Air\nMWₐᵢᵣ = 28.960\nρa(T) = (pₐ*MWₐᵢᵣ)/(R*T)/1000\nμₐ(T) = (1.425e-6*T^0.5039)/(1 + 108.3/T)\n\n\n# Butane\nMw = 58.122 # molar mass of butane, kg/kmol\nTcr = 425.12 # critical temperature, K\nTb = -0.6 + 273.15 # the normal boiling point of butane, K\n\n# vapour pressure in Pa, T in K\npˢ(T) = exp(66.343 - (4363.2/T) - 7.046*log(T) + 9.4509e-6*T^2)\n\n# density in kg/m^3, T in K\nρₗ(T) = Mw*( 1.0677/0.27188^(1+ (1-T/425.12)^0.28688) )\n\n# heat capacity in J/kmol/K, T in K\ncₚ(T) = 191030 - 1675*T + 12.5*T^2 - 0.03874*T^3 + 4.6121e-5*T^4\n\n# latent heat in J/kmol, T in K\nΔHᵥ(T) = 3.6238e7*(1-(T/Tcr))^(0.8337 - 0.82274*(T/Tcr) + 0.39613*(T/Tcr)^2)\n\n# surface tension, N/m\nσ(T) = 0.05196*(1-(T/Tcr))^(1.2181);\n\nThe vapour pressure of butane at the release temperature is below the storage pressure, so the butane in the storage sphere will be a liquid.\n\npˢ(Tᵣ)<p\n\ntrue" + }, + { + "objectID": "posts/butane_leak_example/index.html#the-release-rate", + "href": "posts/butane_leak_example/index.html#the-release-rate", + "title": "Chemical Release Screening Example - Butane leak", + "section": "The Release Rate", + "text": "The Release Rate\nSince the vapour pressure within the vessel is below the storage pressure, at ambient temperature, the butane within the storage sphere is a liquid. In general one would have to account for flashing and two-phase flow during the release, however for very short discharge distances (<10cm) there is typically not enough time for the liquid to flash during discharge,4 over the thickness of a hole this especially true. The butane discharged from the tank will be a stream of liquid initially and the simple Bernoulli equation for a liquid jet can be used.5\n4 See AIChE/CCPS, Guidelines for Use of Vapour Cloud Dispersion Models, 2nd Ed., 1996, 37 for more of a disussion on two-phase discharge rates.5 This is also known as Toricelli’s equation and can be derived from a mechanical energy balance and is found in a lot of references (e.g. Perry’s), the form of it I’m using here comes from AIChE/CCPS, 29 equation 4-10. This is really a function of time as the liquid height \\(h_l\\) will decrease as it leaks out. Using the discharge rate at the start of the leak throughout the analysis is a conservative assumption, again for the purposes of a simplified screening case. For more detailed modeling one could make this explicitly a function of time and integrate over the release.\\[ Q_l = c_d \\rho_l A_h \\sqrt{ 2 \\left( p - p_a \\over \\rho_l \\right) + 2gh_l } = c_d \\rho_l { {\\pi \\over 4} d_h^2} \\sqrt{ 2 \\left( p - p_a \\over \\rho_l \\right) + 2gh_l } \\]\nWhere \\(Q_l\\) is the mass flow of liquid discharged through the hole (in kg/s), \\(c_d\\) is the discharge coefficient which can be assumed to be 0.61,6 \\(g\\) is the acceleration due to gravity \\(9.81 m/s^2\\) and the rest are as defined earlier. I am assuming, here, that the hole is circular for simplicity.\n6 From AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases., 1999, 27, for sharp edged orifices and Reynolds numbers greater than 30,000 the discharge coefficient approaches 0.61, and the exit velocity is independent of the hole size. For a simple screening calculation one could also use a coefficient of 1.0, though that may be excessively conservative (large over-estimates end up wasting time modeling later).Key Assumptions\n\nLiquid release\nSharp edged hole with discharge coefficient of 0.61\n\n\ncd = 0.61\ng = 9.81 # m/s^2\nQₗ = cd*ρₗ(Tᵣ)*(π/4)*(dₕ^2)*√( 2*(p - pₐ)/ρₗ(Tᵣ) + 2*g*hₗ )\n\n56.31092763613714" + }, + { + "objectID": "posts/butane_leak_example/index.html#flashing-fraction", + "href": "posts/butane_leak_example/index.html#flashing-fraction", + "title": "Chemical Release Screening Example - Butane leak", + "section": "Flashing Fraction", + "text": "Flashing Fraction\nSince the butane is significantly above it’s normal boiling point, as the liquid stream exits the storage sphere it will flash. However not all of it will flash into a vapour as the quantity that can vaporize is limited by the available energy. A simplified model of flashing is to assume the process is so rapid that it is effectively adiabatic and, from a simple steady-state energy balance, one arrives at the following7\n7 This can be easily derived, but the form given here is from AIChE/CCPS, Guidelines for Use of Vapour Cloud Dispersion Models, 2nd Ed., 1996, 31, equation 4-14.\\[f_v = {Q_v \\over Q_l} = { {c_p (T_r - T_b)} \\over {\\Delta H_v} }\\]\nwhere \\(f_v\\) is the mass fraction that flashes and \\(Q_v\\) is the mass flow of liquid that flashes (in kg/s) and recall that the heat capacity and latent heat are functions of temperature.\nKey Assumptions\n\nflashing occurs rapidly and is effectively adiabatic\nheat capacity and latent heat taken at the release temperature\n\n\nfᵥ = cₚ(Tᵣ)*(Tᵣ-Tb)/ΔHᵥ(Tᵣ) \n\n0.17128269541302374\n\n\n\nQᵥ(t) = fᵥ*Qₗ\n\nQᵥ (generic function with 1 method)" + }, + { + "objectID": "posts/butane_leak_example/index.html#aerosol-fraction", + "href": "posts/butane_leak_example/index.html#aerosol-fraction", + "title": "Chemical Release Screening Example - Butane leak", + "section": "Aerosol Fraction", + "text": "Aerosol Fraction\nAs the butane flashes into a gas, some of the liquid stream will be entrained as an aerosol. The presence of aerosolized droplets are a major contributor to the overall mass of a vapour cloud and it is important to include them. There is a wide array of methods for estimating the aerosolized fraction, from as simple as assuming it is 1-2x the flashed fraction to more detailed models that take into account the different mechanisms behind aerosolization and rain-out.\nThe aerosol fraction, \\(f_a\\), the fraction of the liquid remaining in the cloud after flashing, in the form of aerosolized droplets.\n\\[ f_a = {Q_a \\over { Q_l - Q_v } }\\]\nOne method is to estimate the droplet size and from that determine the degree of rain out (i.e. the liquid that does not remain in the cloud) through a model of droplet settling. I am going to use the RELEASE model8 of droplet settling to determine the aerosol fraction.\n8 Johnson and Woodward, RELEASE - a Model with Data to Predict Aerosol Rainout in Accidental Releases.Key Assumptions\n\nrain-out is the only significant mechanism by which liquid drops out to form a pool\nall droplets larger than a critical size drop out, all droplets below that size remain in the cloud\nevaporation is negligible\nthe RELEASE model is used to estimate the degree of rain-out\n\n\nMean droplet diameter\nThere are three important mechanisms of drop formation, capillary breakup which occurs when sub-cooled liquids are discharged through very small holes (<2mm), aerodynamic breakup which occurs with larger holes with sub-cooled liquids or slightly super-heated liquids, and flashing breakup which occurs as super-heated liquids are discharged and flash to vapour in the form of bubbles which breakup the surrounding liquid.\nAerodynamic breakup is correlated with the Weber number, which is the ratio of shear forces on the surface of the liquid to the surface tension.\n\\[ We = { { \\rho_g u_d^2 d_p } \\over \\sigma } \\]\nWhere \\(\\rho_g\\) is the density of the gas, \\(u_d\\) the discharge velocity, \\(d_p\\) the mean droplet diameter, and \\(\\sigma\\) the surface tension. Experimentally, droplet breakup occurs at a critical Weber number between 12 and 22, and so the mean droplet size can be estimated by rearranging9\n9 Woodward, Estimating the Flammable Mass of a Vapour Cloud, 49.\\[ d_p = { { \\sigma We_c } \\over {\\rho_g u_d^2 } } \\]\nand solving at the critical Weber number \\(We_c = 12\\), assuming \\(\\rho_g = \\rho_a\\) to be the density of ambient air, and with the discharge velocity \\(u_d\\) given as:\n\\[ u_d = { Q_l \\over { c_d A_h \\rho_l} } = { Q_l \\over {c_d \\frac{\\pi}{4} d_h^2 \\rho_l} }\\]\n\n# the cloud temperature, assumed to be the boiling point\nTc = Tb\n\n# critical Weber number\nWe = 12 \n\n# release velocity, m/s\nud = Qₗ/( cd * (π/4)*dₕ^2 * ρₗ(Tᵣ)) \n\n# droplet size, m, due to aerodynamic breakup\nda = ( σ(Tc) * We)/(ρa(Tc) * ud^2) \n\n2.188550597862162e-5\n\n\nThe diameter of droplets from flashing breakup can be calculated from the following empirical correlation10 and the mean droplet diameter is simply the smallest of either the aerodynamic or flashing diameter11 In almost all cases that are relevant for release modelling capillary breakup is not significant.\n10 Woodward, 50.11 Johnson and Woodward, RELEASE - a Model with Data to Predict Aerosol Rainout in Accidental Releases, 64.\\[ d_p = { {0.03} \\over {10 + 4.0 \\cdot (T - T_b) } }\\]\n\n\n\n\n\n\nFigure 2: A correlation for droplet size due to flashing breakup.\n\n\n\n\n# droplet size, m, due to flashing breakup\ndf = (0.03)/(10 + 4*(Tᵣ-Tb)) \n\ndₚ = min(da, df)\n\n2.188550597862162e-5\n\n\n\n\nThe RELEASE model\nThe RELEASE model uses a distribution of droplet sizes to determine, based on a simple model of settling dynamics, the fraction of droplets that remain the cloud. The model assumes droplet diameter follows a log-normal distribution and that any droplet greater than a critical diameter, determined from a balance of drag and buoyancy, will rain out. The parameters of the model were fit to experimental data of rain out events.\n\nCritical diameter\nThe critical diameter is a function of a critical velocity which is calculated from a model of the spray jet with a tuning parameter \\(\\beta\\) which captures the expansion of the jet. The default value for \\(\\beta\\) is given to be 4.46°12\n12 Johnson and Woodward, 63.\\[ u_c = u_d \\tan \\beta \\]\n\n# the default value given by RELEASE is 4.46°\nβ = deg2rad(4.46)\nuc = ud * tan(β) # critical velocity, m/s\n\n6.197367132394693\n\n\nThe critical diameter is found by solving the balance of buoyant and drag forces on a droplet\n\\[ F_{buoyant} = F_{drag} \\]\n\\[ \\left( \\rho_l - \\rho_g \\right) g V_{droplet} = \\frac{1}{2} C_D \\rho_g u_c^2 A_{droplet} \\]\n\\[ \\left( \\rho_l - \\rho_g \\right) g \\cdot \\frac{\\pi}{6} d_c^3 = \\frac{1}{2} C_D \\rho_g u_c^2 \\cdot \\frac{\\pi}{4} d_c^2 \\]\n\\[ \\left( \\rho_l - \\rho_g \\right) g \\cdot d_c - \\frac{3}{4} C_D \\rho_g u_c^2 = 0\\]\nWhere \\(C_D\\) is the drag coefficient, which for a solid sphere in viscous flow is given by this correlation13\n13 White, Viscous Fluid Flow. This could be an opportunity for improvement to the RELEASE model as liquid droplets and bubbles do not experience drag in the same way as solids, due to internal flows that can dissipate energy.\\[ C_D = 0.4 + {24 \\over Re} + {6 \\over {1 - \\sqrt{Re} } } \\]\nwith the Reynolds number \\(Re\\) as\n\\[ Re = { {\\rho_g u_c d_c} \\over \\mu_a} \\]\nfor simplicity the gas density \\(\\rho_g\\) can be calculated assuming an ideal gas, and \\(\\mu_a\\) is the viscosity of air.\nThis relationship will have to be solved numerically to get the critical diameter, since the Reynolds number and thus drag coefficient is a function of the critical diameter. Which is fairly straight forward and in this case I use the bounds \\(0.1 \\cdot d_p \\le d_c \\le 10 \\cdot d_p\\) as a very broad starting point.\n\nusing Roots: find_zero\n\nρg(T) = (pₐ * Mw)/(R * T)/1000 # ideal gas law, kg/m^3\n\n# the Reynold's number at the release temperature\nRe(d) = ρg(Tc) * uc * d / μₐ(Tc)\n\n# the drag coefficient\nCD(d) = 0.4 + (24/Re(d)) + 6/(1-√(Re(d))) \n\n# critical diameter\ndc = find_zero( d -> (ρₗ(Tc) - ρg(Tc))*g*d - 0.75*CD(d)*ρg(Tc) * uc^2, (0.1*dₚ, 10*dₚ))\n\n0.00014250630981793824\n\n\n\n\nAerosol Fraction\nThe aerosol fraction, in the RELEASE model, is the mass fraction of droplets with a diameter less than the critical diameter:\n\\[ f_a = { {F_m \\left( d_c \\right)} \\over {F_m \\left( \\infty \\right)} } \\]\nWhere \\(F_m(d)\\) is the cumulative mass distribution function for droplets. This is based on a log-normal distribution and is given as\n\\[ F_m \\left( d \\right) = \\left( \\frac{\\pi}{6} \\rho_l d_p^3 \\right) \\int_0^t { t^2 \\over {\\sqrt{2 \\pi} \\log{\\sigma_G} } } {\\exp \\left( -\\frac{1}{2} \\left( \\log{t} \\over \\log{\\sigma_G} \\right)^2 \\right)} dt\\]\nWhere \\(t = d/d_p\\) and \\(\\sigma_G\\) is another tuning parameter (the default value given is 1.8).\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 3: The distribution of droplet sizes and rain-out region.\n\n\n\n\nWith a change of variables \\(z = \\log{t}\\) and \\(s = \\log{\\sigma_g}\\) the cumulative mass distribution function can be integrated:\n\\[ F_m \\left( d \\right) = \\left( \\frac{\\pi}{6} \\rho_l d_p^3 \\right) \\int_{-\\infty}^z { 1 \\over {\\sqrt{2 \\pi} s} }{\\exp \\left( 3z \\right) \\exp \\left(-\\frac{1}{2} \\left( z \\over s \\right)^2 \\right)} dz \\]\n\\[ = \\left( \\frac{\\pi}{6} \\rho_l d_p^3 \\right) \\frac{-1}{2} \\exp \\left( 9s^2 \\over 2 \\right) \\left[ \\mathrm{erf} \\left( {3s^2 - z} \\over {\\sqrt{2} s} \\right) \\right]_{-\\infty}^z\\]\n\\[= \\left( \\frac{\\pi}{6} \\rho_l d_p^3 \\right) \\exp \\left( 9 \\left( \\log{\\sigma_G} \\right)^2 \\over 2 \\right) \\times \\frac{1}{2} \\left[ 1 - \\mathrm{erf} \\left( {3 \\left( \\log{\\sigma_G} \\right)^2 - \\log{d} + \\log{d_p} } \\over {\\sqrt{2} \\log{\\sigma_G} } \\right) \\right] \\]\nwhere \\(\\mathrm{erf}\\left( x \\right)\\) is the error function. Finally the aerosol fraction is:14\n14 Johnson and Woodward, RELEASE - a Model with Data to Predict Aerosol Rainout in Accidental Releases, 58–60. The integration is not shown in the text, but the fortran code is included on the CD if one wants to verify the final result.\\[ f_a = { {F_m \\left( d_c \\right)} \\over {F_m \\left( \\infty \\right)} } = \\frac{1}{2} \\left[ 1 - \\mathrm{erf} \\left( {3 \\left( \\log{\\sigma_G} \\right)^2 - \\log{d_c} + \\log{d_p} } \\over {\\sqrt{2} \\log{\\sigma_G} } \\right) \\right]\\]\nThe RELEASE code uses this formula and also does a check for extreme cases, defaulting to either 1 or 0.\n\nusing SpecialFunctions: erf\n\nfunction RELEASE_fa(dc, dp; σG=1.8)\n if (dp/dc) >= exp(σG)\n # checks for where erf(x) ~ 1\n return 0.0\n elseif (dc/dp) >= 15*exp(σG)\n # checks for where erf(x) ~ -1\n return 1.0\n else\n return 0.5*( 1 - erf((3*log(σG)^2 -log(dc) + log(dp)) / (√(2) * log(σG)) ))\n end\nend\n\nRELEASE_fa (generic function with 1 method)\n\n\n\nfₐ = RELEASE_fa(dc, dₚ) # calculates the aerosol fraction using the RELEASE method\n\n0.9227949810754577\n\n\n\nQₐ(t) = fₐ*(Qₗ - Qᵥ(t));" + }, + { + "objectID": "posts/butane_leak_example/index.html#pool-evaporation", + "href": "posts/butane_leak_example/index.html#pool-evaporation", + "title": "Chemical Release Screening Example - Butane leak", + "section": "Pool Evaporation", + "text": "Pool Evaporation\nThe droplets that rain out of the cloud will form a pool and, depending on how long the release occurs, evaporation from the pool can be a significant contributor to the overall airborne quantity. In this case the liquid is assumed to be at the boiling point of butane, it cooled through evaporative cooling, and is cryogenic with respect to the ground. There are two major factors that impact the evaporation rate: the area of the pool and the heat transfer into the pool from the environment. Both of these, in general, can be quite complicated time-dependent phenomena with lots of different models capturing a wide array of scenarios.\nSince this is a simple screening calculation, I will be avoiding all of that and use some simple models for pool spread and evaporative flux.\nKey Assumptions\n\nSimple model of pool spread\nEvaporation of pool is driven by heat transferred from the ground by conduction\n\nA simple model of pool spread as a function of time is15\n15 Woodward, Estimating the Flammable Mass of a Vapour Cloud, 57.\\[ A_{pu} = \\frac{\\pi}{4} \\sqrt{\\frac{2048}{81} {Q_{p} \\over \\rho_l} t^3} \\]\nWhere \\(A_{pu}\\) is the unconstrained pool area in m², \\(t\\) is the time since the start of the release in seconds, and \\(Q_{p}\\) is the mass flow of liquid to the pool in kg/s (the total release rate less what was lost to flashing and entrained in the cloud as an aerosol)\n\\[ Q_{p} = Q_l - Q_v - Q_a \\]\nIn practice the area of the pool will be limited to be at most the diked area. For large spills having a diked area is significant, both in the obvious containing of the spill, but also since it can significantly reduce the amount of pool evaporation.\n\n# the liquid temperature, taken to be the boiling point\nTₗ = Tb\n\n# mass flow to the pool, kg/s\nQₚ(t) = Qₗ - Qᵥ(t) - Qₐ(t)\n\n# unconstrained pool area, m^2\nAₚᵤ(t) = (π/4) * √((2048/81) * (Qₚ(t)/ρₗ(Tₗ)) * t^3 )\n\n# Pool area, restricted to at most dyked area, m^2\nAₚ(t) = min( Aₚᵤ(t) , Ad);\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 4: Pool spreading as a function of time for constrained and unconstrained releases.\n\n\n\n\nIn general the evaporation rate is derived from a heat balance accounting for the heat transfer from the ground, from the ambient air, and from solar flux, however in this case a simplifying assumption is that the majority of the heat transferred to the liquid is from the ground.\nFor a cryogenic liquid spilled on land a simple model of the evaporative flux \\(G_e\\) in kg/s/m² is16\n16 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases., 1999, 63.\\[ G_e = { {Mw} \\over {\\Delta H_v} } { k \\left( T_s - T_l \\right) \\over \\sqrt{\\pi \\alpha t} } \\]\nWhere \\(k\\) is the thermal conductivity of the surface (ground) in W/m/K, \\(T_s\\) is the temperature of the surface in K, \\(T_l\\) the temperature of the liquid in K, and \\(\\alpha\\) the thermal diffusivity of the surface in m²/s\nThe overall evaporation rate is the product of the pool area and the evaporative flux\n\\[ Q_e \\left( t \\right) = G_e \\left( t \\right) \\cdot A_p \\left( t \\right) \\]\nIt’s worth taking a moment to note that the evaporative flux will decrease with time. This is because the ground under the spill cools down over time. The overall evaporation rate will grow as the pool grows – in this model the pool grows \\(\\propto t^{3/2}\\) while the flux decreases \\(\\propto t^{-1/2}\\), so the evaporation rate should grow \\(\\propto t\\) – but once it hits the limit of the diked area the overall evaporation rate will decrease over time.\nOne thing worth noting is that the pool area equation does not take into account a mass balance. As time goes on the unconstrained pool only grows, even if the evaporation rate were to exceed the rate of new liquid being added to the pool. This limitation probably doesn’t matter for short duration leaks in which it is expected that the pool evaporation rate is strictly lower than the rate at which new liquid is added to the pool, however for long duration spills or instantaneous spills this not appropriate and a more complex model of pool growth and evaporation should be considered.\n\n# Thermal properties of concrete \n# A. Bejan, Kraus, A. D., Heat Transfer Handbook, John Wiley & Sons, 2003\nk = 1.28 # W/m/K\nα = 6.6e-7 # m^2/s\n\n# surface temperature, taken to be the ambient temperature\nTₛ = Tᵣ \n\n# evaporative flux, kg/s/m^2\nGₑ(t) = (Mw/ΔHᵥ(Tₗ)) * k * (Tₛ - Tₗ) / √(π*α*t)\n\n# evaporation rate, kg/s\nQₑ(t) = min( Gₑ(t)*Aₚ(t), Qₚ(t));\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 5: The pool evaporation rate as a function of time for a constrained release." + }, + { + "objectID": "posts/butane_leak_example/index.html#airborne-quantity", + "href": "posts/butane_leak_example/index.html#airborne-quantity", + "title": "Chemical Release Screening Example - Butane leak", + "section": "Airborne Quantity", + "text": "Airborne Quantity\nThe total airborne quantity is the sum of the flashed vapour, the aerosolized droplets, and the vapour from pool evaporation. So far the calculation has been in terms of rates, but the total airborne quantity depends upon the release duration, \\(t_d\\). There are lots of different ways of deciding on a release duration, in general the release duration of interest is the time it would take for a vapour cloud to find an ignition source – for vapour cloud explosion scenarios – or, more optimistically, the time it would take for the plant to respond and take some action to mitigate the hazard. A common default release duration is 10 minutes17\n17 AIChE/CCPS, 22.One common simplification is to take the vapour and aerosol rates to be a constant, and the pool evaporation rate as a constant at the final time \\(t_d\\), then multiply by the total duration. An alternative is to integrate over time from 0 to \\(t_d\\).\n\\[ Q_{aq} \\left( t \\right) = Q_v \\left( t \\right) + Q_a \\left( t \\right) + Q_e \\left( t \\right)\\]\n\\[ m_{aq} = \\int_0^{t_d} Q_{aq} \\left( t \\right) dt = \\int_0^{t_d} Q_v \\left( t \\right) + Q_a \\left( t \\right) + Q_e \\left( t \\right) dt \\]\nOne could try to integrate this analytically, but for re-useability of code it’s a better idea to integrate numerically – then different models for each of the rates can be swapped in and out with ease.\n\nusing QuadGK: quadgk\n\n# release duration of 10 minutes, seconds\ntd = 10*60 \n\n# total airborne release rate is the sum of the individual \n# mechanism release rates, in kg/s\nQaq(t) = Qᵥ(t) + Qₐ(t) + Qₑ(t) \n\n# total airborne quantity is the integral over time\nmaq, err = quadgk(Qaq, 0, td) \n\n(31737.218210630548, 0.00014433872246399915)\n\n\nA quick sanity check is to make sure that the total airborne quantity is less than the total quantity released, i.e. \\(Q_l \\cdot t_d\\).\n\nmaq <= Qₗ*td\n\ntrue\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 6: The total airborne quantity as a function of time.\n\n\n\n\nIt is often insightful to compare the airborne quantity to the case where there was no secondary containment, i.e. the pool could expand without bound.\n\nQₑᵤ(t) = min( Gₑ(t)*Aₚᵤ(t), Qₚ(t))\nQaqu(t) = Qᵥ(t) + Qₐ(t) + Qₑᵤ(t)\n\nmaqu, err = quadgk(Qaqu, 0, td)\n\n(33426.49125139247, 0.0003172098610093599)\n\n\n\n\nWith secondary containment 31.737 t \nWithout secondary containment 33.426 t \n\n\nIn this case the secondary containment reduced the overall airborne quantity by ~5%, and we wouldn’t expect it to be hugely important for this example as most of the mass of the vapour cloud came from the flashing of the liquid immediately upon release and from entrained droplets." + }, + { + "objectID": "posts/butane_leak_example/index.html#closing-remarks", + "href": "posts/butane_leak_example/index.html#closing-remarks", + "title": "Chemical Release Screening Example - Butane leak", + "section": "Closing Remarks", + "text": "Closing Remarks\nThere are always trade offs to be made with model accuracy, model complexity, and the shear amount of data required to run the models (often overlooked unless you’re the flunky tasked with finding all of these constants). This notebook aimed at creating a simple screening model and made several simplifications along the way. One thing I tried to avoid, though, is the use of gross “rules of thumb” and any pre-calculating of constants. I see this fairly often in older works because, likely, the calculations were being done by hand and this greatly speeds that up. I don’t think the justification for it is still valid, though, for a few reasons. For one many very rough rules of thumb were developed to avoid iterative solutions, but with modern computers there’s really no reason to, the numerical solutions in this notebook took fractions of a second to calculate on my laptop. For another much of the work collecting constants and pre-calculating things simply makes it harder to validate formulas. With a notebook like this not only can I properly typeset the formula more-or-less as presented in the reference (while keeping a consistent nomenclature) but I can also fairly transparently type that into Julia, making it very clear what the code is doing, step by step, and where those formulae came from. This should make it very easy to verify that I haven’t made a typo, for example. In my experience with some older excel tools that used a lot of pre-calculated rearranged equations, it was often entirely not obvious how the reference (if there is one given at all) lead to the final equations in the spreadsheet and verifying that the spreadsheet worked as intended without some written down derivation could take hours.\nOne feature that I didn’t use, but could be a nice addition, is the unit-aware library Unitful, I included it at the beginning for some unit conversions. However it can be used to track the units for each number throughout, ensuring that results are in the appropriate units and that there are no unit mismatches. I did not use that part of things because there are quite a few correlations and figuring out the appropriate units for the various constants in those correlations such that all the units match properly can be a pain in the butt. In general, though, using Unitful is a very powerful tool when working with physical modelling.\nThat said, this notebook is far from perfect. Probably the biggest simplification that could be changed is the assumption that the liquid release rate is a constant and is at the highest rate. This could be made a function of time – as the vessel empties there is less hydro-static pressure – and the rates of flashing and aerosol entrainment made explicitly time dependent as well. Like all things this would take some time to implement – though not much – and would improve model performance for long duration releases. The assumption made is on the conservative side and for short duration, and large vessels, is appropriate for screening purposes. Though, like all things with code, you only have to put the effort in once…\nFor re-useability the lowest hanging fruit for changes would be to link this to a database of substance properties. Probably the most tedious part of using this notebook is finding and filling in all of the correlations at the beginning for the various temperature dependent material properties. There’s no reason why a small database couldn’t be set up, containing everything in a given plant’s inventory, and some code added so the notebook can look up the properties for you.\nThere are also lots of opportunities for embedding some of the decision logic into the notebook, I set up the notebook to do a liquid discharge because I knew what the scenario was. Furthermore I knew that the boiling point of butane is less than ambient and so the pool evaporation would be for a cryogenic liquid spill. There’s no reason why the logic behind those decisions, and others, couldn’t be generalized and the notebook setup to choose which model was appropriate in a clear and transparent way." + }, + { + "objectID": "posts/butane_leak_example/index.html#references", + "href": "posts/butane_leak_example/index.html#references", + "title": "Chemical Release Screening Example - Butane leak", + "section": "References", + "text": "References\n\n\nAIChE/CCPS. Dow’s Chemical Exposure Index Guide. New York: American Institute of Chemical Engineers, 1998.\n\n\n———. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999.\n\n\n———. Guidelines for Use of Vapour Cloud Dispersion Models, 2nd Ed. New York: American Institute of Chemical Engineers, 1996.\n\n\nJohnson, David W., and John L. Woodward. RELEASE - a Model with Data to Predict Aerosol Rainout in Accidental Releases. New York: American Institute of Chemical Engineers, 1999.\n\n\nWhite, F. M. Viscous Fluid Flow. New York: McGraw-Hill, 1974.\n\n\nWoodward, John L. Estimating the Flammable Mass of a Vapour Cloud. New York: American Institute of Chemical Engineers, 1998." + }, + { + "objectID": "posts/indoor_air_quality/index.html", + "href": "posts/indoor_air_quality/index.html", + "title": "Monitoring smoke infiltration", + "section": "", + "text": "A few years ago I mused about using wildfire smoke events to measure the infiltration rate of buildings, in the context of modeling the infiltration of air pollution into buildings. Well it is wildfire season again and this past weekend saw a thick haze descend upon Edmonton, with airborne particulate concentrations, pm2.5 specifically, exceeding 440 μg/m3 in my neighbourhood.\nIn 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." + }, + { + "objectID": "posts/indoor_air_quality/index.html#measuring-building-infiltration", + "href": "posts/indoor_air_quality/index.html#measuring-building-infiltration", + "title": "Monitoring smoke infiltration", + "section": "Measuring building infiltration", + "text": "Measuring building infiltration\nOn 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.\nThe 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.\nA 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.\n\nOutdoor particulate concentration\nThe 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.\n\nusing CSV, DataFrames, Dates, Pipe\n\n\nstart = DateTime(2023,5,21,14,15)\n\n2023-05-21T14:15:00\n\n\nThe 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.\n\nusing Statistics: mean\n\noutdoor = @pipe \"data/22_May_2023_raw-pm25-gm.csv\" |>\n CSV.read( _ , DataFrame, dateformat=\"yyyy-mm-dd HH:MM:SS\") |>\n transform( _ , AsTable([\"Purple Air A\", \"Purple Air B\"]) => ByRow(mean) => :pm25) |>\n transform( _ , :DateTime => ByRow((x) -> Dates.value(x - start)/(3600*1000)) => :time);\n\n\n\nIndoor particulate concentration\nThe 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.\n\nraw_indoor = @pipe \"data/C22B42153089_22_May_2023_00_43_32.csv\" |>\n CSV.read( _ , DataFrame; dateformat=\"yyyy-mm-dd HH:MM:SS\") |>\n sort!( _ , :Date);\n\nTo 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”.\n\nlast_good_data = raw_indoor[!, \"PM2.5, ug/m3\"][1]\nlast_good_datetime = raw_indoor[!, :Date][1]\n\nindoor = DataFrame(datetime = DateTime[], meas = Float64[], time = Float64[])\n\nfor r in eachrow(raw_indoor)\n dt = r[:Date]\n meas = r[\"PM2.5, ug/m3\"]\n time = Dates.value(dt - start)/(3600*1000)\n \n if meas != last_good_data\n last_good_data = meas\n last_good_datetime = dt\n push!(indoor, [dt, meas, time])\n elseif Dates.value(dt - last_good_datetime) > 15*60*1000 # more than 15 minutes\n last_good_data = meas\n last_good_datetime = dt\n push!(indoor, [dt, meas, time])\n else\n continue\n end\nend\n\nPlotting 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.\n\n\n\n\n\n\n\n\nFigure 1: Time series data for indoor and outdoor pm2.5 concentrations with the 1 hour AAQO indicated.\n\n\n\n\n\n\n\nFitting the model\nThe 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.\n\nusing Interpolations: linear_interpolation, Flat\n\ncₒ = linear_interpolation(outdoor.time, outdoor.pm25, extrapolation_bc=Flat());\n\nTo 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\n\\[ {d \\over dt} c = \\lambda \\left( c\\_o - c \\right) \\]\nWhere c is the indoor concentration, co the outdoor concentration, and λ is the ventilation rate in units of h-1.\n\nusing OrdinaryDiffEq\n\n# the model\nf(c, λ, t) = λ*(cₒ(t) - c)\n\n# initial condition\nc0 = indoor.meas[1]\n\n# timespan\ntspan = (0, indoor.time[end])\n\n# parameters\np= [0.5] #initial guess of λ=0.5\n\nprb = ODEProblem(f, c0, tspan, p)\n\nNow I define the fit itself: with the cost function as the L2 loss between the measured indoor concentration and the predicted indoor concentration.\n\nusing DiffEqParamEstim: build_loss_objective, L2Loss\n\nlossfn = L2Loss(indoor.time, indoor.meas)\n\ncost_function = build_loss_objective(prb,Tsit5(),lossfn,\n maxiters=10000,verbose=false);\n\nThen using the Optim package to find the parameter λ which minimizes the cost function.\n\nusing Optim: optimize\n\nresult = optimize(cost_function, 0.0, 1.0)\n\nResults of Optimization Algorithm\n * Algorithm: Brent's Method\n * Search Interval: [0.000000, 1.000000]\n * Minimizer: 7.848374e-02\n * Minimum: 1.517807e+03\n * Iterations: 35\n * Convergence: max(|x - x_upper|, |x - x_lower|) <= 2*(1.5e-08*|x|+2.2e-16): true\n * Objective Function Calls: 36\n\n\nI can then retrieve the ventilation rate for my bedroom\n\nλfit = result.minimizer[1]\n\n0.07848373551388874\n\n\n\nprb = ODEProblem(f, c0, tspan, λfit)\nfit = solve(prb, Tsit5());\n\n\n\n\n\n\n\n\n\nFigure 2: Best fit curve for the simple linear building infiltration model.\n\n\n\n\n\nI 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.\nThere 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.\nBut 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." + }, + { + "objectID": "posts/indoor_air_quality/index.html#using-a-hepa-filter", + "href": "posts/indoor_air_quality/index.html#using-a-hepa-filter", + "title": "Monitoring smoke infiltration", + "section": "Using a HEPA Filter", + "text": "Using a HEPA Filter\nAfter 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.\nI 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.\n\nindoor_hepa = @pipe \"data/C22B42153089_21_May_2023_10_05_00.csv\" |>\n CSV.read( _ , DataFrame; dateformat=\"yyyy-mm-dd HH:MM:SS\");\n\noutdoor_hepa = @pipe \"data/21_May_2023_raw-pm25-gm.csv\" |>\n CSV.read( _ , DataFrame, dateformat=\"yyyy-mm-dd HH:MM:SS\") |>\n transform( _ , AsTable([\"Purple Air A\", \"Purple Air B\"]) => ByRow(mean) => :pm25);\n\n\n\n\n\n\n\n\n\nFigure 3: Response of measured indoor particulate concentrations to air filtration. Note that the outdoor fine particulate concentration remains high throughout the measurement period." + }, + { + "objectID": "posts/indoor_air_quality/index.html#final-thoughts", + "href": "posts/indoor_air_quality/index.html#final-thoughts", + "title": "Monitoring smoke infiltration", + "section": "Final thoughts", + "text": "Final thoughts\nUsing 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.\nThat 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.\nCurrently a lot of the advice is merely to stay indoors, with little acknowledgment that indoors is often severely polluted as well." + }, + { + "objectID": "posts/building_infiltration_example/index.html", + "href": "posts/building_infiltration_example/index.html", + "title": "Building Infiltration Example", + "section": "", + "text": "A common part of emergency planning is the shelter in place. More often than not trying to outrun whatever calamity is happening at a chemical plant is more dangerous than finding a safe place to ride it out, which is usually the lunch room or somewhere like that. Sometimes facilities have different shelter in place locations depending on what the hazard is – for example a severe weather shelter location may not be where you go for a toxic gas release.\nNaturally, when evaluating the consequences of a release, one wants to evaluate the effectiveness of a shelter in place location. This usually involves looking at some building infiltration model.\nAnother situation in which building infiltration has come up in recent years is forest fires, and I think this presents a unique opportunity to validate the models and the selection of shelter in place locations. I’m not talking about sheltering in place because the forest fire is at the plant boundary, I’m talking about smoke days where forest fire smoke blows in and blankets the whole area in higher than usual airborne particulates." + }, + { + "objectID": "posts/building_infiltration_example/index.html#the-scenario", + "href": "posts/building_infiltration_example/index.html#the-scenario", + "title": "Building Infiltration Example", + "section": "The Scenario", + "text": "The Scenario\nAt least where I live, in Alberta, this is not an uncommon event. Every other year, it seems, there is a large forest fire either in the northern boreal forest or in the Rocky Mountains and the smoke from these enormous fires will blanket the entire province.\nThis is the view from my apartment of one of the last big smoke days, on May 30th of 2019\n\n\n\n\n\n\nFigure 1: The view from my apartment on May 30th, 2019, when the city experienced a significant air quality event due to wildfire smoke.\n\n\n\nThis haze is unsurprisingly bad for outdoor air quality, but what does it do for indoor air quality? Typically people shut-down air handling systems to minimize the smoke infiltration and wait it out (which is why I think it is a good proxy for a release scenario), so a model of how quickly smoke, or airborne particulates, can work its way into the building is useful to have." + }, + { + "objectID": "posts/building_infiltration_example/index.html#ambient-air-data", + "href": "posts/building_infiltration_example/index.html#ambient-air-data", + "title": "Building Infiltration Example", + "section": "Ambient Air Data", + "text": "Ambient Air Data\nThe outdoor concentration of airborne particulates, PM2.5, is needed. There are several air monitoring stations throughout Alberta, with the closest one to me being the Edmonton Central station. Hourly air quality data can be downloaded as a csv from Alberta’s Air Data Warehouse and imported into julia as a dataframe.\n\nusing CSV, DataFrames, Dates, Pipe\n\nI am using the Pipe.jl package to streamline the data manipulation process. It takes the output from what is on the left of |> and puts it in where the _ is on the right, which is very convenient when chaining together several single-input-single-output functions.\n\ndata_file = \"data/Edmonton Central pm2.5.csv\"\n\n# import the csv file, remove missing data\n# insert a column for the time since the start of the dataset in hours\n\nambient_data = @pipe data_file |>\n CSV.File( _ ; \n dateformat=\"mm/dd/yyyy HH:MM:SS\", \n types=[DateTime, DateTime, Float64], \n header=16) |>\n DataFrame(_) |>\n rename(_, \"MeasurementValue\" => \"conc\") |>\n rename(_, \"IntervalStart\" => \"date\") |>\n select(_, Not([2])) |>\n hcat(_, Dates.value.( _.date - _.date[1])/3.6e6) |>\n rename(_, \"x1\" => \"time\") |>\n dropmissing(_) \n\nfirst(ambient_data, 6)\n\n6 rows × 3 columnsdateconctimeDateTimeFloat64Float6412019-05-24T00:00:0016.00.022019-05-24T01:00:007.01.032019-05-24T02:00:009.02.042019-05-24T03:00:0013.03.052019-05-24T04:00:009.04.062019-05-24T05:00:0010.05.0\n\n\n\n\n\n\n\n\n\n\nFigure 2: Ambient pm2.5 concentrations from the Central Edmonton air quality monitoring station, May 24th - June 5th 2019.\n\n\n\n\n\nIn this case I restricted the dataset to just the PM2.5 concentration, since that is all I am interested in, and to a window of time around May 30th. The data clearly shows that May 30th had a big spike in airborne particulates, well in excess of the ambient air quality objectives. Though it was somewhat hazy in the days before and after too." + }, + { + "objectID": "posts/building_infiltration_example/index.html#infiltration-models", + "href": "posts/building_infiltration_example/index.html#infiltration-models", + "title": "Building Infiltration Example", + "section": "Infiltration Models", + "text": "Infiltration Models\nBuilding infiltration models can range from highly detailed CFD simulations of indoor airflow to simple “fully mixed” models that assume a single average indoor concentration. This second type is the easiest to use and a good start for screening scenarios. It is a simple differential equation that assumes the rate of infiltration is proportional to a ventilation rate, λ, and the concentration difference between the outside and inside air1\n1 Lees, Loss Prevention in the Process Industries, sec. 15.51.\\[ \\frac{d}{dt} c = f \\left( c, \\lambda, t \\right) = \\lambda \\cdot \\left( c_o(t) - c \\right)\\]\nThe outside concentration, \\(c_o\\), can be a constant, but it is more usefully thought of as a function of time. In practice the ventilation rate, λ, is usually taken to be a constant, but it is a function of ambient conditions and could be implicitly made a function of time as well.\nThis model is for for a single zone or single cell building, where the interior air is assumed to be well mixed and at a single uniform pressure and concentration. This works well for houses, non-segmented industrial buildings, and small open plan commercial buildings. For much larger buildings, with many zones, there are multiple zone models of various scales of complexity.\n\nusing Interpolations, OrdinaryDiffEq\n\nThe function below represents the right-hand-side of the differential equation in standard form, with the outside concentration as a generic function of time that is passed as a parameter. For convenience later on, I also created a function that takes the outside concentration and returns a callable with that pre-set.\nThe order of arguments is important here, OrdinaryDiffEq expects the arguments to be in the order unknowns, parameters, time, where both the unknowns and parameters can be vectors (if there’s more than one)\n\nf(c, λ, t; cₒ=zero) = λ*(cₒ(t) - c)\n\nf(g) = (c, λ, t) -> f(c, λ, t; cₒ=g)\n\n\nNatural Ventilation\nAny structure, unless it is hermetically sealed, has some natural ventilation rate. This comes from leaks around doorframes, through ventilation systems (even when turned off), and other breaks in the building envelope. This natural ventilation rate, λ, is reported in air changes per hour (ACH) and is, in general, a function of ambient conditions inside and outside the structure.\n\n\n\n\n\n\nFigure 3: Natural ventillation mechanisms in a single zone building.\n\n\n\nThe following plot is for a building infiltration model showing a building with windows open versus closed, showing the functional relationship between temperature differences and windspeed and the ventillation rate. Note the first plot is against the square root of the temperature difference.\n\n\n\n\n\n\nFigure 4: Natural ventillation rates as a function of temperature difference and windspeed.\n\n\n\nASHRAE2 gives guidance on how to estimate the natural ventilation rate for single zone buildings, and a basic model of air leakage is\n2 2017 ASHRAE Handbook - Fundamentals (SI Edition), chap. 16.\\[ Q = A_L \\sqrt{ C_s \\vert \\Delta T \\vert + C_w u^2 }\\]\nWhere Q is the air leakage rate, in m³/s, \\(A_L\\) is the effective air leakage area, in m², \\(\\Delta T\\) is the temperature difference between indoors and outdoors, u the windspeed, and \\(C_s\\) and \\(C_w\\) constants tabulated based on building height and extent of shelter from the wind (due to other buildings in the vicinity).\nThe ventilation rate is then simply the leakage rate divided by the building volume\n\\[ \\lambda = {Q \\over V } \\]\nThis model effectively treats the leakage from the building like a leakage through a hole using Toricelli’s law, and the one big unknown that needs to be determined is the effective air leakage area. This can be determined by working through the different parts of the building and counting the elements such as windows and vents, or it can be determined experimentally for a given building, either by using a tracer (SF₆ is common) or by using the a blower system to pressurize the building and measuring how much flow is required to raise the internal pressure by a fixed amount (such as by 50Pa).\nThis is a lot of work for a simple building infiltration screening. However this may have already been done for the design of the building, as the air leakage is a critical component in a building’s overall thermal efficiency. If the data already exists for a given building, for other purposes, then why not use it, but if it isn’t readily available then it is more practical to use tabulated values for representative buildings.\nThis can be tricky, though, as most tabulated values are for houses and how “leaky” a house is depends very strongly on where that house was constructed (and when). Many references, such as Lees’ give values for British homes which are often much greater than equivalent values tabulated elsewhere for typical American and Canadian homes. Which could entirely be a function of local weather. The air tightness of a home where I live in Canada is probably a lot more important, especially on days when it is -30°C, than a similar home in the UK where such extremes are essentially unheard-of. Which is also putting aside the fact that commercial structures are quite different from houses and so these values may not be too representative either.\nTypical values for Canadian homes in urban areas\n\n\n\nLevel\nACH\n\n\n\n\nTight\n0.25\n\n\nAverage\n0.50\n\n\nLeaky\n1.0\n\n\n\nTypical values for houses in the US\n\n\n\nConditions\nTight\nTypical\nLeaky\n\n\n\n\nMild\n0.07\n0.1\n0.4\n\n\nModerate\n0.2\n0.3\n1.0\n\n\nSevere\n0.3\n0.5\n1.6\n\n\n\nIn this model of building infiltration the ASHRAE model could be used and, with a suitable dataset of outdoor temperature and windspeeds, the ventilation rate would be a function of time. But for simplicity I am going to assume that the change in windspeed and temperature from any given hour to the next is quite small and the ventillation rate can be assumed to be a constant.\nThis raises the obvious question of what impact windspeed has on the indoor concentration? At higher windspeeds the building ventilation rate is higher, and so more of what’s outside ends up inside, however at higher windspeeds there is more mixing and the outdoor concentration will generally be lower. I would expect the effect of mixing would dominate, but this might be worth investigating further.\n\n\nBuilding Infiltration\nArmed with some ideas of the building ventilation rate, the differential equation can be solved for different outdoor concentration scenarios. When defining the problem, above, I set the outdoor concentration as generic function of time that was passed as a parameter. This ODE is simple enough that it can be solved by hand for basic cases and easily numerically integrated for any well behaved set of initial conditions and outdoor concentrations.\nFor an example let’s consider a sudden pulse of a pollutant, say the outside air is 100%(vol) during the pulse and 0 otherwise, a square wave.\n\nfunction c_square(t; cmax=1, t1=5, t2=20)\n if (t >=t1) & (t <= t2)\n return cmax\n else\n return 0\n end\nend\n\nAssuming the initial indoor concentration to be zero, and with a suitable ventillation rate, this can be solved numerically.\nThe problem is defined using the ODEProblem function, which creates a problem object which the solver then solves. In this case using the Tsit5 solver, the default for non-stiff problems. The solution returned is an object that contains both a vector of solutions, and times, but can also be called like a function to return an interpolated result. This way the solution acts like a continuous function of time.\n\nλ₀ = 0.25 # ventilation rate, 0.25 h⁻¹\nc0 = 0.0 # initial condition\ntspan = (0.0, 25.0)\nsys = f(c_square)\n\nprb = ODEProblem(sys, c0, tspan, λ₀)\nsln = solve(prb, Tsit5())\n\n\n\n\n\n\n\n\n\nFigure 5: Building infiltration for a step-change in outdoor concentration.\n\n\n\n\n\nSolving for the indoor concentration of pm2.5s using measured outdoor concentrations is essentially the same process, except instead of a simple square wave we have a timeseries of measured values.\nThe first step is to turn that timeseries into a continuous function, in this case I am using a simple linear interpolation.\n\ncₒᵤₜ = LinearInterpolation(ambient_data.time, ambient_data.conc, extrapolation_bc = Interpolations.Flat())\n\nThe remaining steps are the same, since the model for building infiltration is the same (with the parameter and inital conditions as defined earlier). The only difference is the timespan is the full span of the timeseries and the outdoor concentration is the linear interpolation defined above.\n\ntspan = (0.0, ambient_data.time[end])\nsys = f(cₒᵤₜ)\n\nprb = ODEProblem(sys, c0, tspan, λ₀)\nsln = solve(prb, Tsit5())\n\n\n\n\n\n\n\n\n\nFigure 6: Building infiltration using measured outdoor concentrations.\n\n\n\n\n\nWe can see, much like in the square wave model, that the indoor concentration lags behind the outoor concentration but still rises significantly. Once the pulse in high pm2.5 ends the indoor concentration decays, but again with a delay. So there is a period after the smoke has blown over in which the pm2.5 concentration indoors can be higher than outdoors (a good time to open some windows and air the place out)\nFor some context I have added Alberta’s Ambient Air Quality Objective for pm2.5s, clearly bad smoke days exceed that target but also indoor air quality can exceed it as well. Interestingly the indoor air quality may have exceeded workplace limits for airborne particulates through the whole period.\n\n# max outdoor concentration\n\nmaximum(ambient_data.conc)\n\n867.0\n\n\n\n# max indoor concentration\n\nmaximum(sln.u)\n\n501.877462902824\n\n\n\n\nVentilation and Infiltration Time\nIf we assume a constant outdoor concentration, a constant ventilation rate, and an initial indoor concentration of zero, the model can be solved analytically to give\n\\[ { c_i \\over c_o } = 1 - e^{-\\lambda t}\\]\nWhich leads us to ask, how long does it take for the indoor concentration to reach some fraction x of the outdoor concentration?\n\\[ x = 1 - e^{-\\lambda t} \\]\n\\[ t = { -\\ln{\\left( 1 - x \\right)} \\over \\lambda } \\]\nFor simplicity’s sake let’s assume \\(x = 0.5\\).\n\nt_x(λ; x=0.5) = -log(1-x)/λ\n\n\n\n\n\n\n\n\n\nFigure 7: The time to reach 1/2 the outdoor concentration as a function of ventillation rates.\n\n\n\n\n\nThis gives us a sense of how building tightness - the natural ventilation rate - impacts how long a shelter in place would be effective for. If the emergency is lasting for several hours then a shelter in place location would have to be highly air tight to be effective." + }, + { + "objectID": "posts/building_infiltration_example/index.html#model-evaluation", + "href": "posts/building_infiltration_example/index.html#model-evaluation", + "title": "Building Infiltration Example", + "section": "Model Evaluation", + "text": "Model Evaluation\nA realistic shelter in place location is not going to be well mixed with the rest of the building. It will be an enclosed space that can be isolated from the building (e.g. by closing doors), and with an air handling system that can be isolated (unlike, say, a cafeteria where often the vents for clearing the air in the kitchen cannot be easily sealed). In this case the effective ventillation rate for that enclosed space, during a shelter in place, should be smaller than the ventillation rate for the building overall – when the doors are open and a single zone model is perhaps more representative.\nIn this case we can use the outdoor smoke event as a test. Somewhat like a tracer test but in reverse and we are not controlling the tracer. If we knew in advance that a smoke day was coming, which given publicly available modeling such as FireSmoke is reasonable, we could close off the shelter in place location with some indoor monitoring set up in the middle of the room and watch what happens.\nIf things go like they have in the past, at least where I work, the air handling system is shutdown and people try and minimize their time outdoors (and thus time spent opening and closing outside doors). By tracking the indoor concentration as well as outdoor concentrations we can compare the model to reality – does a single zone model work? do we need to incorporate weather conditions? – and estimate an effective ventilation rate by fitting the ODE to the measured data.\nAt least that’s the theory. I don’t have measured indoor air data for the time in question so I am going to simulate some by assuming the model works and adding some random noise. In this case I am adding ±10% random noise to the results calculated earlier for λ=0.25.\n\nusing DiffEqParamEstim, Optim\n\nHere I make a copy of the ambient data dataframe, df, and create a new column called cin for indoor concentration. This is the solution found earlier times a random error ±10%, then for good measure any columns with the concentration below zero are chopped off at zero since that is unphysical.\n\n# Dummy data\n\ndf = deepcopy(ambient_data)\n\ndf.cin = sln.(df.time) .* ( 1 .+ 0.10*randn(size(df.time)) )\ndf.cin[ df.cin .< 0 ] .= 0\n\n\n\n\n\n\n\n\n\nFigure 8: Actual measured outdoor data and generated indoor data.\n\n\n\n\n\nThe model can be fit to these two sets of data, essentially taking the outdoor concentration as a given and finding the best fit curve to the indoor concentration by solving the ODE repeatedly for different values of the parameter λ.\nThe major difference between this and simply solving the ODE is defining the loss function, in this case an L2 loss which is analogous to least squares, and then optimizing.\n\ntspan = (0, df.time[end])\nc0 = df.cin[1] # initial condition\n\nsys = f(cₒᵤₜ)\np = [0.5] #initial guess of λ=0.5\n\nprb = ODEProblem(sys, c0[1], tspan, p)\nlossfn = L2Loss(df.time, df.cin)\n\ncost_function = build_loss_objective(prb,Tsit5(),lossfn,\n maxiters=10000,verbose=false)\n\nThe optimization is looking for the parameter that minimizes the cost function, which in this case is the least-squares difference between the model and the “measured” data for indoor concentration. The minimum is well defined and close to λ=0.25, which is what we would expect given that was how the data was generated.\nNote: the plot below is nice to look at but not something one would normally generate, since calculating each point involves solving the ODE and could be fairly resource intensive for any problem more complex than this simple model. It sort of defeats the point of using an optimization algorithm to find the minimum. It’s just a nice visualization of what is happening in the background.\n\n\n\n\n\n\n\n\nFigure 9: The cost landscape showing the optimal parameter λ\n\n\n\n\n\n\n# optimize the cost function for parameters between 0 and 1\n\nresult = optimize(cost_function, 0.0, 1.0)\n\nResults of Optimization Algorithm\n * Algorithm: Brent's Method\n * Search Interval: [0.000000, 1.000000]\n * Minimizer: 2.363617e-01\n * Minimum: 9.520426e+03\n * Iterations: 27\n * Convergence: max(|x - x_upper|, |x - x_lower|) <= 2*(1.5e-08*|x|+2.2e-16): true\n * Objective Function Calls: 28\n\n\n\nλfit = result.minimizer[1]\n\n0.2363617224674848\n\n\n\nλfit/λ₀\n\n0.9454468898699392\n\n\nThe best fit ventillation rate is quite close to the actual ventillation rate used to generate the data, which is what we would expect.\nWith an effective ventilation rate we can generate a best fit line\n\nprb = ODEProblem(sys, c0[1], tspan, λfit)\nfit = solve(prb, Tsit5())\n\n\n\n\n\n\n\n\n\nFigure 10: The linear ventillation model fitted to the indoor concentration." + }, + { + "objectID": "posts/building_infiltration_example/index.html#a-control-systems-approach", + "href": "posts/building_infiltration_example/index.html#a-control-systems-approach", + "title": "Building Infiltration Example", + "section": "A Control Systems Approach", + "text": "A Control Systems Approach\nFor people with a background in control systems and process dynamics, an obvious alternative way of writing the problem is in term of Laplace transforms and transfer functions.\n\\[ \\frac{d}{dt} c = \\lambda \\cdot \\left( c_o(t) - c \\right)\\]\nwith the change of variables \\(y = c(t) - c(0)\\) and \\(u = c_o\\)\n\\[ y^\\prime = \\lambda u - \\lambda y \\]\nTaking the Laplace transform of both sides \\[ s Y = \\lambda U - \\lambda Y \\]\n\\[ Y = { \\lambda \\over { s + \\lambda} } U \\]\nThis can then be solved analytically for various inputs, \\(U\\), or numerically for a given timeseries. In Julia this can be done with the ControlSystems.jl and ControlSystemIdentification.jl packages. This lets you define a system in terms of transfer functions and solve them that way.\nI showed the more generic ODE approach at the start because this is more easily generalized to more complex models (e.g. by incorporating the functional dependence of λ on temperature and windspeed). Though the transfer function approach lends itself more simply to using different inputs to the same system, simply change \\(u\\) and you get a different result, whereas the generic ODE has the input as part of the system definition, which I think is sort of messy (though maybe there’s a better way of doing this that I don’t know about?).\n\nBuilding Infiltration Model\nThe building infilration model is set up by simply defining the transfer function for the system and then simulating the response to the given input (the outdoor concentration in this case)\n\nusing ControlSystems, ControlSystemIdentification\n\n\nu(x, t) = [cₒᵤₜ(t)]\nt = 0:1:ambient_data.time[end]\n\nsys = tf([λ₀], [1, λ₀])\n\nTransferFunction{Continuous, ControlSystems.SisoRational{Float64}}\n 0.25\n-----------\n1.0s + 0.25\n\nContinuous-time transfer function model\n\n\nUnlike the previous package, this returns the output, y, and time, t, as vectors with no interpolation or other information.\n\ny, t, x = lsim(sys, u, t)\n\n\n\n\n\n\n\n\n\nFigure 11: The linear ventillation model, using ControlSystems.jl\n\n\n\n\n\n\nmaximum(y)\n\n508.2191947911374\n\n\nThe two approaches produce almost identical answers, which is not too surprising as the same ODE package and solver (Tsit5) is being used under the hood of ControlSystems.jl to solve this (I believe the only difference is one is using a variable time-step and the other a fixed time step, but I could be wrong).\n\n\nModel Evaluation\nFor the purposes of fitting the model to the data, we note that the model is an ARX model, i.e. it is of the form\n\\[ A(s) Y = B(s) U + D \\]\nwhere \\(A(s)\\) and \\(B(s)\\) are polynomials in s, and use the ControlSystemIdentification.jl package to fit the model, generating a fitted transfer function.\nIn this simple approach the parameters of \\(A(s)\\) and \\(B(s)\\) are allowed to be different, and in general you could fit a variety of models and use this as a jumping off point to explore potentially better models of infiltration.\nIf you are more interested in an empirical model, fitted to timeseries data, this approach can be much simpler than the model driven approach taken above with fitting the ODE to the data. Especially when incorporating other elements like windspeed and temperature.\n\n# generates the discrete timeseries data with a sample time of 1\n\nd = iddata(df.cin, df.conc, 1)\n\nInputOutput data of length 311 with 1 outputs and 1 inputs\n\n\n\n# finds the best fit transfer function with numerator order 1 and denominator order 1\n\nmodel_tf = arx(d, 1, 1)\n\nTransferFunction{Discrete{Int64}, ControlSystems.SisoRational{Float64}}\n 0.22305776166128505\n------------------------\n1.0z - 0.738488961457766\n\nSample Time: 1 (seconds)\nDiscrete-time transfer function model\n\n\n\n# generate the best fit line\n\ny_fit, t_fit, _ = lsim(model_tf, u, t)\n\n\n\n\n\n\n\n\n\nFigure 12: The linear ventillation model fitted to indoor concentrations using ControlSystemIdentification.jl" + }, + { + "objectID": "posts/building_infiltration_example/index.html#references", + "href": "posts/building_infiltration_example/index.html#references", + "title": "Building Infiltration Example", + "section": "References", + "text": "References\n\n\n2017 ASHRAE Handbook - Fundamentals (SI Edition). Atlanta, GA: American Society of Heating, Refrigerating; Air-Conditioning Engineers, 2017.\n\n\nLees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996." + }, + { + "objectID": "posts/engineering_a_cup_of_coffee/index.html", + "href": "posts/engineering_a_cup_of_coffee/index.html", + "title": "Engineering a Cup of Coffee", + "section": "", + "text": "While making coffee one day, I started thinking about how the coffee making process is both a perfect representation of the sorts of systems chemical engineers work on every day and also a weird edge case unlike most of the unit operations in the standard repertoire of process engineering.\nMaking 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" + }, + { + "objectID": "posts/engineering_a_cup_of_coffee/index.html#extraction-and-the-coffee-control-chart", + "href": "posts/engineering_a_cup_of_coffee/index.html#extraction-and-the-coffee-control-chart", + "title": "Engineering a Cup of Coffee", + "section": "Extraction and the Coffee Control Chart", + "text": "Extraction and the Coffee Control Chart\nI 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.\nSo 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.\n\n\n\n\n\n\nFigure 1: The standard coffee control chart.2\n\n2 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.\n\nThe 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:\n3 For a given dose of coffee, the concentration and extraction are directly proportional to one another.\n\\[ \\mathrm{Dose} = D = { m_{beans} \\over V_{water} } \\approx { m_{beans} \\over V_{cup} } \\]\n\\[ \\mathrm{Extraction} = E = { m_{cup} \\over m_{beans} } = { { c_{cup} V_{cup} } \\over m_{beans} } \\]\n\\[ c_{cup} = D \\cdot E \\]\nwhere 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\n\n\n\n\n\n\nFigure 2: A Rotocel extractor, you are unlikely to see one of these at your local coffee shop.\n\n\n\nExtractors 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.\nCoffee 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.\n4 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." + }, + { + "objectID": "posts/engineering_a_cup_of_coffee/index.html#the-simplest-coffee-maker", + "href": "posts/engineering_a_cup_of_coffee/index.html#the-simplest-coffee-maker", + "title": "Engineering a Cup of Coffee", + "section": "The simplest coffee maker", + "text": "The simplest coffee maker\nPerhaps 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.\nIn 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.\nModeling 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.\n\nBrew temperature\nBrew 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\n5 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, \\(\\mathscr{D} \\propto T\\).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.\nTo 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).\nSuppose 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 \\(\\mathscr{D} \\propto T\\), the percent change in the rate constant is equal to the percent change in (absolute) temperature\n\\[ { { \\Delta \\mathscr{D} } \\over \\mathscr{D} } = { { \\Delta T } \\over T } \\]\n\n\nΔT = 20\nT = 368.15\nΔT / T = 0.054325682466385986\n\n\nEven 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).\n\n\nGrind size and uniformity\nGrind 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.\nWhy 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\n\\[ a_v = {S \\over V} = { {4 \\pi b^2} \\over { \\frac{4}{3} \\pi b^3} } = {3 \\over b} \\]\nwhere 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.\nOf 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\n\\[ b_{s} = {3 \\over a_v } \\]\nIt 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.\n\n\nMixing and rate constants\nMass 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.\nIn 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.\n8 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 \\(\\frac{\\mathscr{D}_s}{\\mathscr{D}_l} = 0.1-0.2\\),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.\nDiffusion 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.\nThe 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\n\\[ K = \\frac{ q^{*} }{ c^{*} } \\]\nWhere 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\n\\[ \\mathrm{coffee}_{s} \\xrightarrow{k_1} \\mathrm{coffee}_{l} \\]\n\\[ \\mathrm{coffee}_{l} \\xrightarrow{k_2} \\mathrm{coffee}_{s} \\]\nAt equilibrium the rates of these two processes are equal\n\\[ k_1 q^{*} = k_2 c^{*} \\Leftrightarrow \\frac{k_2}{k_1} = \\frac{ q^{*} }{ c^{*} } = {K} \\]\nTypically 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)\n\n\nAn example brew\nAt 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.\n\n# properties of the coffee grounds\n# equilibrium parameters\n# Moroney et al. 2015\nq_sat = 118.95 # kg/m³\nc_sat = 212.4 # kg/m³\nK = q_sat/c_sat\n\n# effective diffusivity\n# Moroney et al. 2015; Schwartzberg 1987, 557\n𝒟ₗ = 2.2e-9 # m²/s\n𝒟ₛ = 0.1*𝒟ₗ\n\n# particle size\n# Moroney et al. 2015\nb = 569.45e-6 # m\n\n# density, medium roast \n# Rodrigues et al. 2002, 8\nρₛ = 314.0 # kg/m³\n\n# dose\n# assumed, 22.5g in 500mL\nmₛ = 0.0225 # kg\nVₛ = mₛ/ρₛ # m³\nVₗ = 500e-6 # m³\n\n\n# properties of the water\n# density\n# Poling et al. 2007, 2-103\nMW = 18.015 #kg/kmol\nfunction ρₗ(T)\n τ = 1 - T/647.096\n mol_dens = 17.863 + 58.606*τ^0.35 - 95.396*τ^(2/3) + 213.89*τ - 141.26*τ^(4/3)\n return mol_dens*MW\nend\n\n# viscosity\n# Poling et al. 2007, 2-432\nμₗ(T) = exp(-52.843 + 3703.6/T + 5.866*log(T) - 5.879e-29*T^10)\nνₗ(T) = μₗ(T)/ρₗ(T)\n\n# brew temperature\n# assumed, 95°C\nTₗ = 95+273.15 #K\n\n# initial concentration\nc₀ = 0.0 # kg/m³\n\n# final (max) concentration\nc_max = min(q_sat*Vₛ/Vₗ + c₀, c_sat) # kg/m³\n\nThese 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.\n\nstruct InfusionBrew{T}\n K::T\n q_max::T\n c_max::T\n Vₗ::T\n Vₛ::T\n mₛ::T\n 𝒟ₛ::T\n 𝒟ₗ::T\n b::T\nend \n\n\nbrew = InfusionBrew(K,q_sat,c_max,Vₗ,Vₛ,mₛ,𝒟ₛ,𝒟ₗ,b);" + }, + { + "objectID": "posts/engineering_a_cup_of_coffee/index.html#a-mass-transfer-model-of-coffee-brewing", + "href": "posts/engineering_a_cup_of_coffee/index.html#a-mass-transfer-model-of-coffee-brewing", + "title": "Engineering a Cup of Coffee", + "section": "A mass transfer model of coffee brewing", + "text": "A mass transfer model of coffee brewing\nPulling 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:\n\nThe system is isothermal with brew temperature Tl\nCoffee grounds are spherical and have constant radius b\nThe coffee matrix is a pseudo-homogeneous solid, diffusion through the solid follows Fick’s second law with diffusivity \\(\\mathscr{D}_s\\) and diffusion is only relevant in the radial direction r\nThe liquid phase is well mixed, i.e. the bulk concentration c is spatially homogeneous and is only a function of time\nMass transfer into the liquid phase occurs through a thin film with a mass transfer coefficient h\nAt the interface between the thin film and the solid coffee, the concentration of solubles is in equilibrium with equilibrium constant K\n\nWe 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.\n\n\n\n\n\n\nFigure 3: The mass transfer system for making coffee with a French press.\n\n\n\nThere are two rates important processes governing the extraction of coffee:\n\nDiffusion across the interface into the thin film, governed by Fick’s first law\nTransfer from the thin film into the bulk liquid\n\nStarting with (1) the mass flux into the thin film is given by Fick’s first law (in spherical coordinates)\n\\[ J_1 = - \\mathscr{D}_s \\left( { \\partial q } \\over { \\partial r} \\right)_{r=b} \\]\nOf 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)\n\\[ { {\\partial q} \\over {\\partial t} } = \\frac{1}{r^2} { \\partial \\over {\\partial r} } \\left( r^2 \\mathscr{D}_s { {\\partial q} \\over {\\partial r} } \\right) \\]\nTurning to (2) the mass flux from the thin film into the bulk liquid is given by\n\\[ J_2 = - h \\left( c - c_s \\right) \\]\nWhere c is the concentration in the bulk liquid and cs is the concentration at the surface.\nThe 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:\n\\[ V_l { { \\partial c} \\over {\\partial t} } = a_v V_s J_2 = \\frac{3}{b} V_s J_2 \\]\n\\[ { { \\partial c} \\over {\\partial t} } = \\frac{3}{b} \\frac{V_s}{V_l} J_2 \\]\nThe solution to this partial differential equation depends upon which of these mass transfer processes, (1) or (2), is dominant.\n\nThe dominant rate\nThe standard approach to solving this problem is to look at the limiting cases, where the Biot number is either very large or very small10\n10 Seader, Henley, and Roper, Separation Process Principles, 663.\nBi < 0.001 : the mass transfer through the film dominates, a simple exponential model is appropriate\n0.001 < Bi < 200 : use an intermediate method11\nBi > 200 : the mass transfer through the coffee particles dominates, the more complicated solution from Carslaw and Jaeger is best\n\n11 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\n\\[ \\mathrm{Bi} = { {h b} \\over {K \\mathscr{D}_s} } = {\\mathscr{D}_l \\over \\mathscr{D}_s} { \\mathrm{Sh} \\over K }\\]\nWhere Sh is the Sherwood number, defined as\n\\[ \\mathrm{Sh} = { {h b} \\over \\mathscr{D}_l } \\]\nDefining 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\n14 Hottel et al., “Heat and Mass Transfer,” 5–69.\\[ \\mathrm{Sh} = 2 + 0.552 \\mathrm{Re}^{1/2} \\mathrm{Sc}^{1/3} \\]\nwith 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.\n\\[ \\mathrm{Bi} = {\\mathscr{D}_l \\over \\mathscr{D}_s} { 1 \\over K } \\left( 2 + 0.552 \\mathrm{Re}^{1/2} \\mathrm{Sc}^{1/3} \\right) \\]\n\\[ \\mathrm{Bi} = { 20 \\over K } + { 5.52 \\over K } \\mathrm{Re}^{1/2} \\mathrm{Sc}^{1/3} \\]\nWhere \\({\\mathscr{D}_s \\over \\mathscr{D}_l}\\) = 0.1 is assumed from Schwartzberg15 The Schmidt number, Sc, and equilibrium constant, K, can be calculated\n15 “Leaching – Organic Materials,” 557.\n\nSc = νₗ(Tₗ) / 𝒟ₗ = 139.98370415887905\nK = q_sat / c_sat = 0.5600282485875706\n\nBi = 35.7124842370744 + 51.178588534736114 √Re\n\n\nUnder this model, any flow with Re > 10.3 corresponds to Bi > 200, which occurs when the velocity is\n\nRe = 10.3\n\nv = Re*νₗ(Tₗ)/b\n\n0.005570341094459917\n\n\nthat 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.\nRegardless it is unlikely that \\(\\mathrm{Bi}<0.001\\) and thus the simple exponential model is probably not a good fit, we turn instead to the model from Carslaw and Jaeger.16\n16 Conduction of Heat in Solids.\n\nBoundary conditions\nIn 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.\nFirst 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\n\nt = 0 : q = q0 and c = c0\nr = b : q = K c\nr = 0 : q is finite\n\n\n\nThe Carslaw and Jaeger model\nIt 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.\n17 Carslaw and Jaeger.First step, to put the PDE in dimensionless form we make the substitutions:\n\\[ \\xi = {r \\over b} \\]\n\\[ \\tau = { {\\mathscr{D}_s t} \\over b^2} \\]\n\\[ u = { { q - q^{*} } \\over { q_0 - q^{*} } }\\]\n\\[ u_f = { {c - c^{*} } \\over { c_0 - c^{*} } }\\]\nAfter which the PDE becomes\n\\[ { {\\partial u} \\over {\\partial \\tau} } = \\frac{1}{\\xi^2} { \\partial \\over {\\partial \\xi} } \\left( \\xi^2 {\\partial \\over {\\partial \\xi} } \\right) \\]\nWith boundary conditions\n\nτ = 0 : u = 0\nξ = 1 : u = uf\nξ = 0 : u is finite\n\nAnd the mass transfer into the liquid bulk becomes\n\\[ { {\\partial u_f} \\over {\\partial \\tau} } = -\\frac{3}{\\alpha} \\left. { {\\partial u} \\over {\\partial \\xi} } \\right|_{\\xi=1} \\]\nWith \\(\\alpha = { V_l \\over {K V_s} }\\) and boundary condition\n\nτ = 0 : uf = 1\n\nThis 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\n18 Carslaw and Jaeger, 240–41; Bird, Stewart, and Lightfoot, Transport Phenomena, 379–81.\\[ u_f = 6α (α+1) \\sum_{k=1}^{\\infty} { \\exp(-τ x_k^2 )\\over { 9(α+1) + (α x_k)^2 } } \\]\nWhere the xk s are the roots of the equation\n\\[ \\tan(x) = { {3 x} \\over { 3 + \\alpha x^2 } } \\]\n(the particular form shown here comes from Schwartzberg19)\n19 “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.\nA better approach is to re-write it in a different way\n\\[ \\tan(x) = { \\sin(x) \\over \\cos(x) } \\]\n\\[ \\tan(x) - { {3 x} \\over { 3 + \\alpha x^2 } } = 0 \\Leftrightarrow \\left( 3 + \\alpha x^2 \\right) \\sin(x) - 3 x \\cos(x) = 0 \\]\nThis latter form is nice and continuous, with no singularities.\n\nusing IntervalRootFinding\nusing Roots\n\nα = Vₗ/(K*Vₛ)\n\nf(x) = tan(x) - 3x/(3+α*x^2)\ng(x) = (3 + α*x^2)*sin(x) - 3x*cos(x)\n\n# find the first 5 roots\nk=5\nxk = find_zeros(g, 0, (k+1/2)*π)\n\n6-element Vector{Float64}:\n 0.0\n 3.214656575481129\n 6.321030394109289\n 9.45018248676156\n 12.585470558898335\n 15.723260568418107\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 4: The roots of the equations f(x) and g(x), note the repeated singularities in f(x).\n\n\n\n\nSince α 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.\n\nfunction getroots(n)\n if n ≤ length(xk)\n return xk[2:n]\n else\n new_roots = find_zeros(x -> g(x), xk[end], (n+1/2)*π)\n append!(xk, new_roots)\n return xk[2:end]\n end\nend\n\nThe standard approach to calculating an infinite series is to use Richardson extrapolation as this accelerates convergence and allows for an error estimate.\n\nusing Richardson:extrapolate\n\nfunction u_f(τ)\n val, err = extrapolate(1, x0=Inf) do N\n xk = getroots(Int(N))\n 6α*(α+1)*sum( exp.(-τ.*(xk.^2))./((9*(α+1)).+(α.*xk).^2) )\n end\n return val\nend\n\nNow we can put together a bulk concentration function\n\nfunction c(t)\n τ = (𝒟ₛ*t)/b^2\n c = (c₀ - c_max)*u_f(τ) + c_max\n return c\nend\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 5: The concentration of solubles in the extract over time.\n\n\n\n\nExtraction is simply concentration over dose\n\nextraction(t) = c(t)*Vₗ/mₛ\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 6: The extraction of coffee solubles over time.\n\n\n\n\n\n\nPackaging the final result\nAt this point we have enough to put together a struct to contain the parameters needed for the Carslaw and Jaeger model\n\nstruct CarslawSolution{T}\n α::T\n τ₁::T\n xk::Vector{T}\n ib::InfusionBrew{T}\nend\n\nfunction CarslawSolution(ib::InfusionBrew)\n α = ib.Vₗ/(ib.K*ib.Vₛ)\n τ₁ = ib.𝒟ₛ/ib.b^2\n xk = find_zeros( x -> (3 + α*x^2)*sin(x) - 3x*cos(x) , 0, (10.5)*π)\n return CarslawSolution(α, τ₁, xk, ib)\nend\n\nand update our code to add some methods for calculating the concentration and extraction based on a Carslaw and Jaeger model for the infusion brew.\n\nfunction getroots(n, model::CarslawSolution)\n if n ≤ length(model.xk)\n return model.xk[2:n]\n else\n new_roots = find_zeros(x -> (3 + model.α*x^2)*sin(x) - 3x*cos(x), \n model.xk[end], (n+1/2)*π)\n append!(model.xk, new_roots)\n return model.xk[2:end]\n end\nend\n\nfunction c(t, model::CarslawSolution)\n τ = model.τ₁*t\n α = model.α\n u_f, err = extrapolate(1, x0=Inf) do N\n xk = getroots(Int(N), model)\n 6α*(α+1)*sum( exp.(-τ.*(xk.^2))./((9*(α+1)).+(α.*xk).^2) )\n end\n c = (c₀ - c_max)*u_f + c_max\n return c\nend\n\nextraction(t, model::CarslawSolution) = c(t, model)*model.ib.Vₗ/model.ib.mₛ\n\n\nsol = CarslawSolution(brew);\n\nThe 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).\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 7: The evolution of coffee extraction over time for several grind sizes. Note that the smallest grind sizes extract faster, achieving equilibrium, whereas the largest grind sizes extract more slowly." + }, + { + "objectID": "posts/engineering_a_cup_of_coffee/index.html#final-thoughts", + "href": "posts/engineering_a_cup_of_coffee/index.html#final-thoughts", + "title": "Engineering a Cup of Coffee", + "section": "Final thoughts", + "text": "Final thoughts\nI 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).\nThe 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." + }, + { + "objectID": "posts/engineering_a_cup_of_coffee/index.html#references", + "href": "posts/engineering_a_cup_of_coffee/index.html#references", + "title": "Engineering a Cup of Coffee", + "section": "References", + "text": "References\n\n\nBatali, Mackenzie E., Lik Xian Lim, Jiexin Liang, Sara E. Yeager, Ashley N. Thompson, Juliet Han, William D. Ristenpart, and Jean-Xavier Guinard. “Sensory Analysis of Full Immersion Coffee: Cold Brew Is More Floral, and Less Bitter, Sour, and Rubbery Than Hot Brew.” Foods 11, no. 16 (2022): 2440. https://doi.org/10.3390/foods11162440.\n\n\nBatali, Mackenzie E., William D. Ristenpart, and Jean‑Xavier Guinard. “Brew Temperature, at Fixed Brew Strength and Extraction, Has Little Impact on the Sensory Profile of Drip Brew Coffee.” Scientific Reports 10 (2020): 16450. https://doi.org/10.1038/s41598-020-73341-4.\n\n\nBird, R. Byron, Warren E. Stewart, and Edwin N. Lightfoot. Transport Phenomena. 2nd ed. Hoboken, NJ: John Wiley & Sons, 2007.\n\n\nCarslaw, Horatio S., and John C. Jaeger. Conduction of Heat in Solids. 2nd ed. London: Oxford University Press, 1959.\n\n\nGreen, Don W., ed. Perry’s Chemical Engineers’ Handbook. 8th ed. New York: McGraw Hill, 2008.\n\n\nHottel, Hoyt C., James J. Noble, Adel F. Sarofim, Geoffrey D. Silcox, Phillip C. Wankat, and Kent S. Knaebel. “Heat and Mass Transfer.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008.\n\n\nMoroney, Kevin M., William T. Lee, Stephen B. G. O’Brien, Freek Suijver, and Johan Marra. “Modelling of Coffee Extraction During Brewing Using Multiscale Methods: An Experimentally Validated Model.” Chemical Engineering Science 137 (2015): 216–34. https://doi.org/10.1016/j.ces.2015.06.003.\n\n\nPoling, Bruce E., John M. Prausnitz, and John P. O’Connell. The Properties of Gases and Liquids. 5th ed. New York: McGraw Hill, 2001.\n\n\nPoling, Bruce E., George H. Thomson, Daniel G. Friend, Richard L. Rowley, and W. Vincent Wilding. “Physical and Chemical Data.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008.\n\n\nRodrigues, Melissa A. A., Maria Lúcia A. Borges, Adriana S. Franca, Leandro S. Oliveira, and Paulo C. Corrêa. “Evaluation of Physical Properties of Coffee During Roasting.” Agricultural Engineering International: The CIGR Journal of Scientific Research and Development V (2003). https://www.researchgate.net/publication/267858074.\n\n\nRousseau, Ronald W., ed. Handbook of Seperation Process Technology. Hoboken, NJ: John Wiley & Sons, 1987.\n\n\nSchwartzberg, Henry G. “Leaching – Organic Materials.” In Handbook of Seperation Process Technology, edited by Ronald W. Rousseau. Hoboken, NJ: John Wiley & Sons, 1987.\n\n\nSeader, J. D., Ernest J. Henley, and D. Keith Roper. Separation Process Principles. 3rd ed. Hoboken, NJ: John Wiley & Sons, 2011." + }, + { + "objectID": "posts/vessel_blowdown_ideal_gases/index.html", + "href": "posts/vessel_blowdown_ideal_gases/index.html", + "title": "Vessel Blowdown - Ideal Gases", + "section": "", + "text": "A recurring task of mine is to look at some old calculations, done by some previous engineer whose identity is lost to time and organizational flux, and update them to match current reality. Depending on the state of the spreadsheet, and its lack of documentation, this can also mean going down a rabbit hole of research to find where, exactly, a given equation came from and what all the constants in it represent. This post is the result of one of those journeys, trying to track down the source of a model for depressuring a vessel.\nConsider 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." + }, + { + "objectID": "posts/vessel_blowdown_ideal_gases/index.html#the-adiabatic-case", + "href": "posts/vessel_blowdown_ideal_gases/index.html#the-adiabatic-case", + "title": "Vessel Blowdown - Ideal Gases", + "section": "The Adiabatic Case", + "text": "The Adiabatic Case\nThe 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.\nStarting with a mass balance on the vessel:\n\\[\n\\frac{dm}{dt} = - w\n\\]\nwhere 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\n\\[\nV \\frac{d \\rho}{dt} = - w\n\\]\nWe can perform a change of variables from ρ to P\n\\[\nV \\left( \\frac{\\partial \\rho}{\\partial P} \\right)_S \\frac{dP}{dt} = - w\n\\]\nThe partial derivative is taken along an isentropic path as the adiabatic expansion within the vessel is isentropic (not because the valve is isentropic).\nWe can write the mass flow through the nozzle in terms of the theoretical, friction less, mass velocity G, the discharge coefficient \\(c_D\\), and the flow area A.\n\\[\nw = c_D A G\n\\]\ngiving1\n1 Botros, Jungowski, and Weiss, “Models and Methods of Simulating Gas Pipeline Blowdown”.\\[\n\\frac{dP}{dt} = - \\frac{c_D A}{V} \\left( \\frac{\\partial P}{\\partial \\rho} \\right)_S G\n\\]\n\nFully Choked Flow\nAssuming the flow through the valve is choked, the velocity in the throat is the sonic velocity \\(a_t\\) which, for an ideal gas, is given by\n\\[\na = \\sqrt{ {k R T} \\over M} = \\sqrt{ {k P} \\over \\rho}\n\\]\nAn 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\n2 Any physical chemistry textbook, such as Laidler, Meiser, and Sanctuary, Physical Chemistry, 79–81.\\[\n\\frac{\\rho_t}{\\rho} = \\left( \\frac{P_t}{P} \\right)^{\\frac{1}{k} }\n\\]\nand, for choked flow, the pressure ratio is at maximum at3\n3 Tilton, “Fluid and Particle Dynamics,” 6-22-6-23.\\[\n\\frac{P_t}{P} = \\left( { 2 \\over {k+1} } \\right)^{\\frac{k}{k-1} }\n\\]\nputting this all together we can write G in terms of vessel conditions \\(\\rho\\) and \\(P\\)\n\\[\nG = \\rho_t u_t = \\rho_t a_t = \\rho_t \\sqrt{ {k P_t} \\over \\rho_t} = \\sqrt{k \\rho_t P_t}\n\\]\n\\[\nG = \\sqrt{k \\rho P} \\left( 2 \\over {k+1} \\right)^{\\frac{k+1}{2 \\left( k - 1 \\right)} }\n\\]\nFrom thermodynamics we know\n\\[\n\\left( \\frac{\\partial P}{\\partial \\rho} \\right)_S = a^2 = \\frac{k P}{\\rho}\n\\]\nand we can put this all together to get\n\\[\n\\frac{dP}{dt} = - \\frac{c_D A}{V} \\left( {k P} \\over \\rho \\right) \\sqrt{k \\rho P} \\left( 2 \\over {k+1} \\right)^{\\frac{k+1}{2 \\left( k - 1 \\right)} }\n\\]\nAt this point, it is standard to introduce a time constant \\(\\tau\\)\n\\[\n\\tau = \\frac{m_0}{w_0} = \\frac{\\rho_0 V}{c_D A \\sqrt{k \\rho_0 P_0} } \\left( 2 \\over {k+1} \\right)^{-\\frac{k+1}{2 \\left( k - 1 \\right)} }\n\\]\nor, more clearly,\n\\[\n\\frac{1}{\\tau} = \\frac{c_D A}{V} \\sqrt{ {k P_0} \\over \\rho_0 } \\left( 2 \\over {k+1} \\right)^{\\frac{k+1}{2 \\left( k - 1 \\right)} }\n\\]\nWhere the subscript 0 indicates the initial conditions in the vessel. This simplifies the expression to\n\\[\n\\frac{dP}{dt} = -\\frac{k}{\\tau} P \\left( \\frac{P}{P_0} \\right)^{\\frac{k-1}{2k} }\n\\]\nWhich is separable and can be integrated to give (after some rearrangement)\n\\[\n\\frac{P}{P_0} = \\left( 1 + \\left( {k-1} \\over 2 \\right) \\frac{t}{\\tau} \\right)^{\\frac{2k}{1-k} }\n\\]\nand the depressure time is\n\\[\nt = \\frac{2\\tau}{1-k} \\left( 1 - \\left( \\frac{P_a}{P_0} \\right)^{\\frac{1-k}{2k} } \\right)\n\\]\nAnother useful thing to determine is the mass flow rate over time, which can be recovered rather easily recalling\n\\[\nw = -\\frac{V}{a^2} \\frac{dP}{dt} = -\\frac{\\rho V}{k P} \\frac{dP}{dt}\n\\]\nand\n\\[\n\\frac{dP}{dt} = -\\frac{k}{\\tau} P \\left( \\frac{P}{P_0} \\right)^{\\frac{k-1}{2k} }\n\\]\nwe get\n\\[\nw = \\frac{\\rho V}{\\tau} \\left( \\frac{P}{P_0} \\right)^{\\frac{k-1}{2k} } = \\frac{\\rho_0 V}{\\tau} \\left( \\frac{\\rho}{\\rho_0} \\right) \\left( \\frac{P}{P_0} \\right)^{\\frac{k-1}{2k} }\n\\]\nBy recalling the definition of \\(\\tau\\) this simplifies to\n\\[\n\\frac{w}{w_0} = \\left( \\frac{P}{P_0} \\right)^{ {k+1} \\over {2k} } = \\left( 1 + \\left({k-1} \\over 2 \\right) \\frac{t}{\\tau} \\right)^{\\frac{1+k}{1-k} }\n\\]\nThis 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.\n\n\nIn the Literature\nThe 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.\n4 Lees, Loss Prevention in the Process Industries, 15/44.5 Crowl et al., “Process Safety.” 23–57.\n\n\n\n\n\nNote\n\n\n\nPerry’s gives the following\n\\[\n\\frac{w}{w_0} = \\left( 1 + \\left(\\mathbf{k + 1} \\over 2 \\right) \\frac{t}{\\tau} \\right)^{\\frac{1+k}{1-k} }\n\\]\nNote the sign change, it should be k-1 not k+1, given typical values of k~1.4 this actually a huge difference.\n\n\nPerry’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.\n\n\nThe Complete ODE\nThere 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.\n\\[\n\\frac{dP}{dt} = -\\frac{c_D A}{V} a^2 G\n\\]\nWe can solve this numerically given\n\\[\n\\rho = \\rho_0 \\left(\\frac{P}{P_0}\\right)^{\\frac{1}{k} }\n\\]\n\\[\nG = \\sqrt{ \\rho P \\left( {2k} \\over {k - 1} \\right) \\left( \\left( \\frac{P_t}{P}\\right)^{ \\frac{2}{k} } - \\left( \\frac{P_t}{P} \\right)^{ \\frac{k+1}{k} } \\right) }\n\\]\nfunction isentropic_mass_flow(P, ρ; k=1.4, Pₐ=101325)\n η = max( Pₐ/P, (2/(k+1))^(k/(k-1)))\n G² = ρ*P*(2k/(k-1))*( η^(2/k) - η^((k+1)/k) )\n G = G² > 0 ? √(G²) : 0\n return G\nend\nfunction speed_of_sound(P, ρ; k=1.4)\n a = √(k*P/ρ)\n return a\nend\nfunction adiabatic_vessel(P, params, t)\n c, A, V, k, ρ₀, P₀, Pₐ = params\n ρ = ρ₀*(P/P₀)^(1/k)\n a² = speed_of_sound(P, ρ; k=k)^2\n G = isentropic_mass_flow(P, ρ; k=k, Pₐ=Pₐ)\n return-c*A*a²*G/V\nend\nwith a callback function to terminate the integration once the vessel is fully depressured\nfunction depressured_callback(P, t, integrator; tol=0.001)\n c, A, V, k, ρ₀, P₀, Pₐ = integrator.p\n return P - (1+tol)*Pₐ\nend\n\n\nA Motivating Example\nJust 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?\n# Ambient conditions\nbegin\n Pₐ = 101.325e3 # Pa\n Tₐ = 288.15 # K\n ρₐ = 1.21 # kg/m³\nend;\nI 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.\n#Vessel conditions\nbegin\n c = 0.85\n D = 0.001 # m\n A = 0.25*π*D^2 # m²\n V = 0.01111 # m³\n P₀ = 20.68e6 # Pa\n T₀ = Tₐ\n ρ₀ = ρₐ*(P₀/Pₐ) # ideal gas law\n k = 1.4\nend;\nI then set up the differential equation and integrate to get the blowdown curve.\nusing OrdinaryDiffEq, Plots\nbegin\n params = (c, A, V, k, ρ₀, P₀, Pₐ)\n t_span = (0.0, 12.0)\n prob = ODEProblem(adiabatic_vessel, P₀, t_span, params)\n sol = solve(prob, Tsit5(),\n callback=ContinuousCallback(depressured_callback, terminate!))\nend;\n\n\n\n\n\n\nFigure 2: The adiabatic blowdown curve for a fully charged SCUBA tank, showing both the fully choked model and the ODE solution.\n\n\n\nThis 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.\nRegarding 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.\nTo 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.\nbegin\n\nstruct PressureVessel{F <: Number}\n c::F\n A::F\n V::F\n k::F\n ρ₀::F\n P₀::F\n Pₐ::F\n τ::F\nend\n\nPressureVessel(c, A, V, k, ρ₀, P₀, Pₐ, τ) = \n PressureVessel(promote(c, A, V, k, ρ₀, P₀, Pₐ, τ)...)\n\nfunction PressureVessel(c, A, V, k, ρ₀, P₀, Pₐ)\n τ = 1/( (c*A/V)*√(k*P₀/ρ₀)*(2/(k+1))^((k+1)/(2*(k-1))) )\n return PressureVessel(c, A, V, k, ρ₀, P₀, Pₐ, τ)\nend\n\nend;\nWhere 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.\nRecreating the results from above, I start with a definition of the vessel\nvessel = PressureVessel(c, A, V, k, ρ₀, P₀, Pₐ);\nI 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.\nabstract type Blowdown end\nstruct AdiabaticBlowdown{S} <: Blowdown\n pv::PressureVessel\n sol::S\nend\nHere 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\nmy_function.(blowdown, time_vector)\nwhere I want it to broadcast over the time_vector.\nBase.length(::Blowdown) = 1\nBase.iterate(b::Blowdown, state=1) = state > length(b) ? nothing : (b,state+1)\nFor the simple choked model this is fairly straight forward.\nadiabatic_blowdown_choked(vessel::PressureVessel) = \n AdiabaticBlowdown(vessel,nothing)\nfunction blowdown_pressure(bd::AdiabaticBlowdown, t)\n P₀, k, τ = bd.pv.P₀, bd.pv.k, bd.pv.τ\n return P₀*( 1 + 0.5*(k-1)*(t/τ))^((2*k)/(1-k))\nend\nfunction blowdown_mass_rate(bd::AdiabaticBlowdown, t)\n ρ₀, V, P₀, k, τ = bd.pv.ρ₀, bd.pv.V, bd.pv.P₀, bd.pv.k, \n bd.pv.τ\n m₀ = ρ₀*V\n w₀ = m₀/τ\n P = blowdown_pressure(bd, t)\n return w₀*(P/P₀)^((k+1)/(2k))\nend\nfunction blowdown_time(bd::AdiabaticBlowdown)\n P₀, Pₐ, k, τ = bd.pv.P₀, bd.pv.Pₐ, bd.pv.k, bd.pv.τ\n return (2τ/(1-k))*(1 - (Pₐ/P₀)^((1-k)/2k))\nend\nFor 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.\nfunction adiabatic_blowdown_full(vessel::PressureVessel; solver=Tsit5())\n # unpack the parameters\n c, A, V, k, ρ₀, P₀, Pₐ = vessel.c, vessel.A, vessel.V, \n vessel.k, vessel.ρ₀, vessel.P₀,\n vessel.Pₐ\n params = (c, A, V, k, ρ₀, P₀, Pₐ)\n\n # estimate the time span needed to fully blowdown\n τ = vessel.τ\n t_bd = (2τ/(1-k))*(1 - (Pₐ/P₀)^((1-k)/2k))\n t_span = (0.0, 10t_bd)\n\n # set up the ODEProblem and solve\n prob = ODEProblem(adiabatic_vessel, P₀, t_span, params)\n sol = solve(prob, solver,\n callback=ContinuousCallback(depressured_callback, terminate!))\n\n return AdiabaticBlowdown(vessel,sol)\nend\nfunction blowdown_pressure(bd::AdiabaticBlowdown{<:ODESolution}, t)\n if t < blowdown_time(bd)\n return bd.sol(t)\n else\n return bd.sol.u[end]\n end\nend\nfunction blowdown_mass_rate(bd::AdiabaticBlowdown{<:ODESolution}, t)\n if t < blowdown_time(bd)\n # unpack the parameters\n c, A, k, ρ₀, P₀, Pₐ = bd.pv.c, bd.pv.A, bd.pv.k, \n bd.pv.ρ₀, bd.pv.P₀, bd.pv.Pₐ\n \n # calculate w = c*A*G\n P = blowdown_pressure(bd, t)\n ρ = ρ₀*(P/P₀)^(1/k)\n G = isentropic_mass_flow(P, ρ; k=k, Pₐ=Pₐ)\n \n return c*A*G\n else\n return 0.0\n end\nend\nblowdown_time(bd::AdiabaticBlowdown{<:ODESolution}) = \n bd.sol.t[end]\n\n\n\n\n\n\nFigure 3: The adiabatic blowdown curves for a fully charged SCUBA tank, showing both the fully choked model and the ODE solution for pressure (top) and mass flow rate (bottom).\n\n\n\nAt 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.\ntest_vessel = PressureVessel(c, A, V, k, ρ₀, 1.5Pₐ, Pₐ);\n\n\n\n\n\n\nFigure 4: The adiabatic blowdown curve for a partially charged SCUBA tank, showing both the fully choked model and the ODE solution.\n\n\n\nNow 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.\nThat 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." + }, + { + "objectID": "posts/vessel_blowdown_ideal_gases/index.html#the-isothermal-case", + "href": "posts/vessel_blowdown_ideal_gases/index.html#the-isothermal-case", + "title": "Vessel Blowdown - Ideal Gases", + "section": "The Isothermal Case", + "text": "The Isothermal Case\nThe 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.\nRecalling, for the adiabatic case, we had the following\n\\[\n\\frac{dP}{dt} = - \\frac{c_D A}{V} \\left( \\frac{\\partial P}{\\partial \\rho} \\right)_S G\n\\]\nFor the isothermal case the vessel is being depressured along an isothermal path (not an isentropic path) and so we substitute the appropriate partial derivative6\n6 Botros, Jungowski, and Weiss, “Models and Methods of Simulating Gas Pipeline Blowdown”.\\[\n\\frac{dP}{dt} = - \\frac{c_D A}{V} \\left( \\frac{\\partial P}{\\partial \\rho} \\right)_T G\n\\]\n\nFully Choked Flow\nAs before, the blowdown is through an isentropic nozzle and we assume that flow is choked\n\\[\nG = \\sqrt{k \\rho P} \\left( \\frac{2}{k+1} \\right)^{\\frac{k+1}{2 \\left( k-1 \\right)} } = \\rho \\sqrt{ {k P} \\over \\rho} \\left( \\frac{2}{k+1} \\right)^{\\frac{k+1}{2 \\left( k-1 \\right)} }\n\\]\nFrom thermodynamics we can write the partial derivative as\n\\[\n\\left( \\frac{\\partial P}{\\partial \\rho} \\right)_T = \\frac{a^2}{k} = \\frac{P}{\\rho}\n\\]\nThus\n\\[\n\\frac{dP}{dt} = - \\frac{c_D A}{V} \\frac{P}{\\rho} \\rho \\sqrt{ {k P} \\over \\rho} \\left( \\frac{2}{k+1} \\right)^{\\frac{k+1}{2 \\left( k-1 \\right)} }\n\\]\nwhere the densities, \\(\\rho\\), can be cancelled and, since the vessel is isothermal (i.e. \\(\\frac{P}{\\rho}\\) is a constant), the various constants can be collected to give\n\\[\n\\frac{dP}{dt} = -\\frac{P}{\\tau}\n\\]\nWhere \\(\\tau\\) is as defined for the adiabatic case. This can easily be integrated to give\n\\[\n\\frac{P}{P_0} = \\exp \\left( \\frac{-t}{\\tau} \\right)\n\\]\nit also follows, from the ideal gas law, that\n\\[\n\\frac{\\rho}{\\rho_0} = \\exp \\left( \\frac{-t}{\\tau} \\right)\n\\]\nand\n\\[\n\\frac{w}{w_0} = \\exp \\left( \\frac{-t}{\\tau} \\right)\n\\]\nThis can also be rearranged to give the blowdown time7\n7 N.B. the \\(\\log \\left( \\dots \\right)\\) is the natural log, this matches the convention used in julia\\[\nt = \\tau \\log \\left( \\frac{P_0}{P_a} \\right)\n\\]\n\n\nIn the Literature\nThis 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.\nThe time constant, \\(\\tau\\), can be broken up to look like this\n\\[\n\\tau = \\frac{V}{c_D A} \\sqrt{\\frac{M}{M_{air} Z_0 T_0} } \\sqrt{\\frac{M_{air} }{kR} } \\left( 2 \\over {k+1} \\right)^{\\frac{-1}{2} \\frac{k+1}{k-1} }\n\\]\nWhere we have made the substitution \\(Z_0 R T_0\\) for \\(R T_0\\) to account for non-ideal behaviour. If the gas has a value of k ~ 1.4, we can write\n\\[\n\\tau = \\mathrm{const} \\frac{V}{c_D A} \\sqrt{ \\frac{SG}{Z_0 T_0} }\n\\]\nWhere 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\n8 Campbell, Gas Conditioning and Processing, 2:29; VANEC, “Pressure Volume-Blowdown Time Calculation”.\\[\nt = 5.5 \\frac{V}{c_D A} \\sqrt{ \\frac{SG}{Z_0 T_0} } \\log \\left( \\frac{P_0}{P_a} \\right)\n\\]\nwith the units\n\nBlowdown time, t, in seconds\nVessel volume, V, in cubic feet\nValve flow area, A, in square inches\nInitial temperature, \\(T_0\\), in Rankine\nInitial pressure, \\(P_0\\), in psia\nAmbient pressure, \\(P_a\\), in psia\n\nI 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.\n9 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.\nThe isothermal fully-choked model can be implemented building on the types already created, by first creating an IsothermalBlowdown type and associated blowdown functions\nstruct IsothermalBlowdown{S} <: Blowdown\n pv::PressureVessel\n sol::S\nend\nisothermal_blowdown_choked(vessel::PressureVessel) = \n IsothermalBlowdown(vessel,nothing)\nfunction blowdown_pressure(bd::IsothermalBlowdown, t)\n P₀, τ = bd.pv.P₀, bd.pv.τ\n return P₀*exp(-t/τ)\nend\nfunction blowdown_mass_rate(bd::IsothermalBlowdown, t)\n ρ₀, V, τ = bd.pv.ρ₀, bd.pv.V, bd.pv.τ\n m₀ = ρ₀*V\n w₀ = m₀/τ\n return w₀*exp(-t/τ)\nend\nblowdown_time(bd::IsothermalBlowdown) = \n bd.pv.τ*log(bd.pv.P₀/bd.pv.Pₐ)\nIn 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\n\\[\n\\frac{dP}{dt} = -\\frac{c_D A}{V} \\frac{a^2}{k} G\n\\]\nWe can solve this numerically given that, for an isothermal system, the density is given by\n\\[\n\\rho = \\rho_0 \\left(\\frac{P}{P_0}\\right)\n\\]\nand using the definition of G given in the adiabatic case.\nfunction isothermal_vessel(P, params, t)\n c, A, V, k, ρ₀, P₀, Pₐ = params\n ρ = ρ₀*(P/P₀)\n a² = speed_of_sound(P, ρ; k=k)^2\n G = isentropic_mass_flow(P, ρ; k=k, Pₐ=Pₐ)\n return-c*A*a²*G/(k*V)\nend\nfunction isothermal_blowdown_full(vessel::PressureVessel; solver=Tsit5())\n # unpack the parameters\n c, A, V, k, ρ₀, P₀, Pₐ = vessel.c, vessel.A, vessel.V, \n vessel.k, vessel.ρ₀, vessel.P₀,\n vessel.Pₐ\n params = (c, A, V, k, ρ₀, P₀, Pₐ)\n\n # estimate the time span needed to fully blowdown\n τ = vessel.τ\n t_bd = τ*log(P₀/Pₐ)\n t_span = (0.0, 10t_bd)\n\n # set up the ODEProblem and solve\n prob = ODEProblem(isothermal_vessel, P₀, t_span, params)\n sol = solve(prob, solver,\n callback=ContinuousCallback(depressured_callback, terminate!))\n\n return IsothermalBlowdown(vessel,sol)\nend\nfunction blowdown_pressure(bd::IsothermalBlowdown{<:ODESolution}, t)\n if t < blowdown_time(bd)\n return bd.sol(t)\n else\n return bd.sol.u[end]\n end\nend\nfunction blowdown_mass_rate(bd::IsothermalBlowdown{<:ODESolution}, t)\n if t < blowdown_time(bd)\n # unpack the parameters\n c, A, k, ρ₀, P₀, Pₐ = bd.pv.c, bd.pv.A, bd.pv.k, \n bd.pv.ρ₀, bd.pv.P₀, bd.pv.Pₐ\n \n # calculate w = c*A*G\n P = blowdown_pressure(bd, t)\n ρ = ρ₀*(P/P₀)\n G = isentropic_mass_flow(P, ρ; k=k, Pₐ=Pₐ)\n \n return c*A*G\n else\n return 0.0\n end\nend\nblowdown_time(bd::IsothermalBlowdown{<:ODESolution}) = \n bd.sol.t[end]\n\n\n\n\n\n\nFigure 5: The isothermal blowdown curves for a fully charged SCUBA tank, showing both the fully choked model and the ODE solution for pressure (top) and mass flow rate (bottom).\n\n\n\nIt 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.\nIn most practical situations, the difference would likely be swamped by two much greater problems with these models:\n\nthe gases are assumed be ideal with constant k\nthe vessel is perfectly isothermal (or adiabatic)\n\nBoth 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." + }, + { + "objectID": "posts/vessel_blowdown_ideal_gases/index.html#comparing-blowdown-models", + "href": "posts/vessel_blowdown_ideal_gases/index.html#comparing-blowdown-models", + "title": "Vessel Blowdown - Ideal Gases", + "section": "Comparing Blowdown Models", + "text": "Comparing Blowdown Models\nI 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.\n\n\n\n\n\n\nFigure 6: The adiabatic and isothermal blowdown curves for a fully charged SCUBA tank, in dimensionless form.\n\n\n\nIn the high pressure case the blowdown terminates much closer to \\(\\frac{P}{P_0}=0\\) and most of the curve is fully choked.\n\n\n\n\n\n\nFigure 7: The adiabatic and isothermal blowdown curves for a partially charged SCUBA tank, in dimensionless form.\n\n\n\nIn 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.\nIt 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 \\(\\exp \\left( \\frac{-t}{\\tau} \\right)\\).\nThe 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.\n11 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 \\(\\frac{t}{\\tau}\\) and so it is not always the case that the isothermal model is more conservative. Something worth noting." + }, + { + "objectID": "posts/vessel_blowdown_ideal_gases/index.html#final-thoughts", + "href": "posts/vessel_blowdown_ideal_gases/index.html#final-thoughts", + "title": "Vessel Blowdown - Ideal Gases", + "section": "Final Thoughts", + "text": "Final Thoughts\nI 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:\n\nthe speed of sound\nthe density as a function of pressure, either along an isentropic path (in the adiabatic case) or along an isothermal path\nthe isentropic mass velocity, G\n\nPlugging 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.\nIn 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." + }, + { + "objectID": "posts/vessel_blowdown_ideal_gases/index.html#references", + "href": "posts/vessel_blowdown_ideal_gases/index.html#references", + "title": "Vessel Blowdown - Ideal Gases", + "section": "References", + "text": "References\n\n\nBotros, K. K., W. M. Jungowski, and M. H. Weiss. “Models and Methods of Simulating Gas Pipeline Blowdown.” The Canadian Journal of Chemical Engineering 67 (1989): 529–39. https://doi.org/10.1002/cjce.5450670402.\n\n\nBotros, Kamal K., and Thomas Van Hardeveld. Pipeline Pumping and Compression Systems - a Practical Approach. 3rd ed. New York: ASME Press, 2018.\n\n\nCampbell, John M. Gas Conditioning and Processing. Vol. 2. Tulsa, OK: John M. Campbell & Co, 1992.\n\n\nCrowl, Daniel A., Lawrence G. Britton, Walter L. Frank, Stanley Grossel, Dennis Hendershot, W. G. High, Robert W. Johnson, et al. “Process Safety.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008.\n\n\nEngineers Edge. “Blowdown Time in Unsteady Gas Flow Calculator and Equation,” 2025. https://www.engineersedge.com/calculators/blowdown_time_in_unsteady_gas_16011.htm.\n\n\nLaidler, Keith J., John H. Meiser, and Bryan C. Sanctuary. Physical Chemistry. 4th ed. Boston, MA: Houghton Mifflin Co, 2003.\n\n\nLees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996.\n\n\nSaad, Michel A. Compressible Fluid Flow. Englewood Cliffs, NJ: Prentiss-Hall, 1985.\n\n\nTemizel, Cenk, Tayfun Tuna, Mehmet Melik Oskay, and Luigi Saputelli. Formulas and Calculations for Petroleum Engineering. Cambridge, MA: Gulf Professional Publishing, 2019.\n\n\nTilton, James N. “Fluid and Particle Dynamics.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008.\n\n\nVANEC. “Pressure Volume-Blowdown Time Calculation,” 2025. https://www.vanec.com/pressurized-volume-blowdown-time-calculation.html.\n\n\nWheeler, Dean R. “Tank Blowdown Math,” 2019. http://www.et.byu.edu/~wheeler/Tank_Blowdown_Math.pdf." + }, + { + "objectID": "posts/hydrogen_blending/index.html", + "href": "posts/hydrogen_blending/index.html", + "title": "Hydrogen Blending", + "section": "", + "text": "November this year came in with a bang where I live: the temperature outside is currently -20°C, there is a pile of snow, and suddenly staying in and staying warm is a very important activity. At the same time, with COP27 taking place in Egypt, climate change is top of mind for everyone (Edmonton is evaluating its net zero strategy) and I thought it would be worthwhile to look at one of the largest sources of household energy use in Canada: space heating. Space heating accounts for 61.6% of household energy use, and in the province of Alberta that predominantly comes from burning natural gas. If we are going to meet our net zero goals as a municipality, we will need to address the carbon emissions that come from simply living here.\nA commonly bandied about tool for reducing household carbon emissions is hydrogen blending, such as this project in the Edmonton area. The idea is to gradually increase the hydrogen content of the natural gas and, since hydrogen burns without producing any carbon dioxide, the carbon emissions will decline. This has the obvious advantage of using the existing gas distribution infrastructure and existing appliances (e.g. people’s furnaces), thus avoiding costly retrofits.\nBut is this actually a useful thing to do? A common criticism of hydrogen is its low energy density: for a given flowrate you would expect to get ~1/3 the energy from pure hydrogen than from natural gas (assuming you are combusting it). But hydrogen is also incredibly light and, since flowrate is \\(\\propto \\frac{1}{\\sqrt{\\rho} }\\), for the same pressure drop across a pipe you would expect a greater flowrate. So which is it? Does the energy content decrease, increase, or stay the same? Instead of just waving my hands and guessing, I thought it might be worthwhile to work through some simple calculations to get a sense of the scale of things.\nThat said, it is rarely the case in engineering that decisions are clear cut. Blending hydrogen will have advantages and disadvantages, and whether the one out-weighs the other depends greatly on the particular location, its needs, infrastructure, and a host of other factors." + }, + { + "objectID": "posts/hydrogen_blending/index.html#energy-density", + "href": "posts/hydrogen_blending/index.html#energy-density", + "title": "Hydrogen Blending", + "section": "Energy density", + "text": "Energy density\nThe obvious place to start, and where I have found people often end, is with the heating value. For convenience I am going to use higher heating values, given here per standard cubic meter. Where standard in this case means at 15°C and 1atm.\n\nusing Unitful\n\n# Standard State\nR = 8.31446261815324u\"m^3*Pa/K/mol\"\nTᵣ = 288.15u\"K\"\nPᵣ = 101325u\"Pa\"\n\n# Hydrogen, from the GPSA handbook, 13th ed.\nHHV_H2 = 12.109u\"MJ/m^3\"\n\n# Natural Gas, typical\nHHV_NG = 35.396u\"MJ/m^3\"\n\nWe can use a simple mixing rule to determine what the heating value would be with x% hydrogen by volume.\n\nHHV(x) = x*HHV_H2 + (1-x)*HHV_NG\n\n\n\n\n\n\n\n\n\nFigure 1: Higher heating value of blended natural gas/hydrogen fuel gas as a function of hydrogen content, assuming an ideal gas and simple mixing rule.\n\n\n\n\n\nWhich clearly shows that increasing the hydrogen content decreases the overall heating value of the fuel. At 100% hydrogen the fuel gas has lost ~66% of it’s heat content.\nSuppose you are a customer whose natural gas was transitioned entirely to hydrogen, now you are receiving about a third of the energy per unit volume than you were before. Well the obvious thing to do would be to increase the volume that you use (by a factor of three) to make up the difference. This does raise the obvious question of can you actually do that? Superficially, it looks like you are asking to triple the demand on the current infrastructure." + }, + { + "objectID": "posts/hydrogen_blending/index.html#energy-per-unit-of-pressure-drop", + "href": "posts/hydrogen_blending/index.html#energy-per-unit-of-pressure-drop", + "title": "Hydrogen Blending", + "section": "Energy per unit of pressure drop", + "text": "Energy per unit of pressure drop\nThe glaring omission with the previous analysis is that we know that flowrate, generally, is \\(\\propto \\frac{1}{\\sqrt{\\rho} }\\), and natural gas is something like 9× denser than hydrogen. So, you would expect, for the same pressure drop in the same pipes, that you would get around 3× the flow of hydrogen.\nSuppose we are only looking at the “last mile” of the distribution network, perhaps the pipe connecting your house to the main, reducing the problem to that of simple pipe flow. Assuming the flow is nearly isothermal, and an ideal gas, the mass velocity, G, of the fuel gas arriving at your house is given by:\n\\[ G = \\sqrt{ \\rho_1 P_1 } \\sqrt{ \\left(1 - \\left( P_2 \\over P_1 \\right)^2 \\right) \\over { K - 2\\log \\left( P_2 \\over P_1 \\right)} } \\]\nWhere 1 is the point just after the tee and 2 is the point just before your meter (e.g. a straight length of pipe). The volumetric flow rate, Q, at the upstream point 1, is then given by\n\\[ Q_1 = {\\pi \\over 4} D^2 {G \\over \\rho_1} \\]\nwhich, when corrected to the reference (standard) state is\n\\[ Q_s = Q_1 \\cdot {v_{r} \\over v_1} \\]\nWhere v is the ideal gas molar volume (RT/P), a constant independent of the gas.\nThe heat rate, q, is simply the higher heating value times the volumetric flowrate (at standard state)\n\\[ q = HHV \\cdot Q_s = HHV \\cdot {\\pi \\over 4} D^2 \\sqrt{P_1 \\over \\rho_1} \\sqrt{ \\left(1 - \\left( P_2 \\over P_1 \\right)^2 \\right) \\over { K - 2\\log \\left( P_2 \\over P_1 \\right)} } \\cdot {v_{r} \\over v_1} \\]\nThis looks like a lot however most of that is a constant, i.e. it is a function of the system and not the gas moving through it.\n\\[ q = { HHV \\over \\sqrt{\\rho_1} } \\times \\textrm{a constant} \\]\nSo, assuming a constant pressure drop, along an identical pipe, with fully developed turbulent flow (i.e. K is constant) the ratio of heat delivered by hydrogen to that of natural gas is given by:\n\\[ { q_{H_2} \\over q_{NG} } = { HHV_{H_2} \\over HHV_{NG} } \\sqrt{ \\rho_{NG} \\over \\rho_{H_2} } = { HHV_{H_2} \\over HHV_{NG} } \\sqrt{ MW_{NG} \\over MW_{H_2} }\\]\n\n# Hydrogen\nMW_H2 = 2.016e-3u\"kg/mol\"\n\n# Natural Gas\nMW_NG = 19.5e-3u\"kg/mol\"\n\nheat_ratio = (HHV_H2/HHV_NG)*√(MW_NG/MW_H2)\n\n1.0639620426132184\n\n\nSo in total opposition to what we expected from merely looking at energy density we now expect, for the same system operating at the same pressures, to receive slightly more energy when transitioned over to pure hydrogen.\nBut what about the in-between, when hydrogen is blended into natural gas? Is it just a straight line connecting these two?\nWe can explore this more closely by first looking at how density, and thus volumetric flowrate, changes with the hydrogen content. I am assuming an ideal gas case and so the mixing rule is quite simple\n\\[ \\rho = x_{H_2} \\rho_{H_2} + x_{NG} \\rho_{NG} \\]\nwhere, for an ideal gas\n\\[ \\rho = MW {P \\over {R T} } \\]\ngiving\n\\[ \\rho = {P \\over {R T} } \\left( x_{H_2} MW_{H_2} + x_{NG} MW_{NG} \\right)\\]\n\n# ideal gas density\nρ(x, T, P) = (P/(R*T))*( x*MW_H2 + (1-x)*MW_NG );\n\n\n\n\n\n\n\n\n\nFigure 2: Density and relative flowrate for blended natural gas/hydrogen fuel gas, assuming an ideal gas.\n\n\n\n\n\nSo we have two competing effects: as the mole fraction increases the heating value of the gas decreases but at the same time the flowrate increases. We can explore this further by plotting the ratio of the heat rate with blended fuel gas to the heat rate with straight natural gas.\n\n# heat rate for blended fuel gas relative to natural gas\nq_ratio(x) = (HHV(x)/HHV_NG)*√(ρ(0,Tᵣ,Pᵣ)/ρ(x,Tᵣ,Pᵣ));\n\n\n\n\n\n\n\n\n\nFigure 3: The amount of energy delivered, in higher heating value, for a blended natural gas/hydrogen fuel gas system at constant operating conditions, relative to natural gas\n\n\n\n\n\nInitially the loss of heating value “wins out” and increasing the hydrogen content merely decreases the energy supplied at a given pressure. But once the stream is predominantly hydrogen, the lower density takes over and the heat rate increases.\nThe minimum ratio can be found by setting the derivative to zero\n\nusing ForwardDiff: derivative\nusing Roots: find_zero\n\n∂q_ratio(x) = derivative(q_ratio,x)\nxₘᵢₙ = find_zero(∂q_ratio,(0,1))\n\nxₘᵢₙ, q_ratio(xₘᵢₙ)\n\n(0.7106211503798253, 0.8839840357969662)\n\n\nInitially, blending hydrogen decreases the overall energy delivered, bottoming out at ~12% less, when hydrogen makes up 71% of the fuel gas. While this is not the 66% decline predicted by a naive look at energy density, neither is it nothing.\nAnother important point is where the ratio becomes one: the concentration where the blended hydrogen fuel gas reattains the energy content of the original natural gas stream\n\nxₑᵥₑₙ = find_zero( (x)-> q_ratio(x)-1, (xₘᵢₙ,1))\n\n0.9684672945947692\n\n\nThe system doesn’t recover the original energy supply until the hydrogen content is >96.8%, at which point a whole host of other concerns may become more relevant – burning pure and nearly pure hydrogen comes with its own issues." + }, + { + "objectID": "posts/hydrogen_blending/index.html#greenhouse-gas-emissions", + "href": "posts/hydrogen_blending/index.html#greenhouse-gas-emissions", + "title": "Hydrogen Blending", + "section": "Greenhouse gas emissions", + "text": "Greenhouse gas emissions\nThe whole point of doing this is to decrease the carbon emissions associated with space heating (plus the other uses of household natural gas, but mostly space heating). So it is worth circling back to answer the question: does this actually do that? and by how much?\nThe dominant greenhouse gas associated with combustion is carbon dioxide, and the carbon dioxide emissions from combustion are fairly easy to calculate from stoichiometry, for a generic hydrocarbon the combustion equation is\n\\[ C_n H_m + \\left( n + {m \\over 4} \\right) O_2 \\rightarrow n CO_2 + {m \\over 2} H_2 O \\]\nIf we presume the natural gas is mostly methane and n≈1, then there is one mole of carbon dioxide produced per mole of natural gas delivered (assuming perfectly complete combustion). When combusting hydrogen there is no carbon dioxide produced, and so the moles of carbon dioxide produced from the combustion of a blended hydrogen fuel gas is\n\\[ \\dot{n}_{CO_2} = \\left( 1 - x_{H_2} \\right) \\dot{n}_{FG} \\]\nWhere \\(\\dot{n}\\) is the molar flowrate. We don’t actually know the molar flowrate of fuel gas, but we can calculate it from the ideal gas law and the volumetric flowrate at standard state Qs\n\\[ \\dot{n}_{FG} = {P_r \\over {R T_r} } Q_s \\]\n\\[ \\dot{n}_{CO_2} = \\left( 1 - x_{H_2} \\right) {P_r \\over {R T_r} } Q_s \\]\nWhat we want is the mass flowrate of carbon dioxide, so simply multiply both sides by the molar weight\n\\[ \\dot{m}_{CO_2} = \\left( 1 - x_{H_2} \\right) MW_{CO_2} {P_r \\over {R T_r} } Q_s \\\\ = \\left( 1 - x_{H_2} \\right) \\rho_{CO_2,r} Q_s \\]\nIf we assume that the users of fuel gas are using a fixed amount of energy, regardless of the actual flowrate, then what we want is the carbon intensity of the fuel: how much carbon dioxide is emitted per Megajoule of heat generated?\n\\[ E = \\frac{\\dot{m}_{CO_2} }{q} = { {\\left( 1 - x_{H_2} \\right) \\rho_{CO_2,r} Q_s} \\over {HHV(x) Q_s} } = { { \\left( 1 - x_{H_2} \\right) \\rho_{CO_2,r} } \\over {HHV(x)} }\\]\n\n# Carbon Dioxide\nMW_CO2 = 44.009e-3u\"kg/mol\"\nρ_CO2 = MW_CO2*Pᵣ/(R*Tᵣ)\n\nE(x) = (1-x)*ρ_CO2/HHV(x)\n\n\n\n\n\n\n\n\n\nFigure 4: The carbon dioxide emissions intensity for a blended natural gas/hydrogen fuel gas, over a range of hydrogen content.\n\n\n\n\n\nSo there are emissions reductions but at a cost, beyond whatever method is used to generate the hydrogen in the first place. The system must be operated at greater pressures to supply the same amount of energy, which itself takes some energy, at least until the hydrogen exceeds 96.8%. At that high level the system seems like an easy win: it takes less pressure to supply the same amount of energy and the emissions intensity is a ~8.7% that of natural gas (a ~91% reduction)\n\nE(xₑᵥₑₙ)/E(0)\n\n0.08690379085511468\n\n\nThere are a few caveats with this: for one carbon dioxide is not the only significant greenhouse gas that comes from combustion, nitrous oxide is also produced and has a global warming potential ~300× that of carbon dioxide. Unlike carbon dioxide, nitrous oxide is producded when hydrogen is combusted with air because, like many other nitrogen oxides, it is generated from the high temperature reaction of the nitrogen and oxygen from the air. So burning pure hydrogen is only net zero for very particular definitions of zero, it is not net zero greenhouse gas emissions though it is net zero carbon emissions." + }, + { + "objectID": "posts/hydrogen_blending/index.html#material-concerns", + "href": "posts/hydrogen_blending/index.html#material-concerns", + "title": "Hydrogen Blending", + "section": "Material concerns", + "text": "Material concerns\nSo far the analysis has completely ignored the material issues that hydrogen brings. At high temperatures (such as, say, inside a furnace that is burning hydrogen) high temperature hydrogen attack is a real concern and using hydrogen as a fuel gas would eventually destroy most burners that were designed for use with natural gas. Similarly hydrogen embrittlement would be a concern for the entire system, wherever steel is used. Neither of these are insurmountable but they would require extensive retrofitting with different materials and special alloys. This dampens a lot of the advantages of hydrogen blending, namely being able to use the existing infrastructure.\nTo skip over the details (I’m not a materials engineer), I think it is fair to say that the mechanical integrity of the system is strongly dependent on the hydrogen content and it likely will be a limiting factor in any hydrogen blending project." + }, + { + "objectID": "posts/hydrogen_blending/index.html#conclusions", + "href": "posts/hydrogen_blending/index.html#conclusions", + "title": "Hydrogen Blending", + "section": "Conclusions", + "text": "Conclusions\nAt the level of “back of the envelope” calculations like I have done above, it is fairly clear that blending hydrogen into the utility natural gas system is not a panacea, but then neither is it completely infeasible. I think there are several other factors that need to be considered when evaluating the possible role of hydrogen blending in the future energy mix:\n\nAvailability of hydrogen - in areas like Edmonton, there are already industrial suppliers of hydrogen (and large industrial consumers), with a roadmap to both expand that capacity and bring it to net zero emissions. Tying into that existing network significantly lowers the barrier for a blending project and can realize real emissions reductions now.\nFeasibility of alternatives - it seems to be accepted wisdom that, at least for now, air source heat pumps are not very effective below -20°C. It is entirely possible that, while retrofitting to add heat pumps to homes would be hugely effective for most of the year, households in Edmonton would still require some additional source of space heating for those extremely cold days. Not only are -20°C days fairly normal in the winter, it is not at all uncommon to exceed -30°C and periodically it gets to -40°C. Hydrogen blending could be part of that energy future, as people with heat pumps keep their furnaces around.\nExisting housing stock/pace of retrofits - it may be the case that, after performing a full life cycle analysis, heat pumps + resistive heating is the better technology. But that may fail to acknowledge the greater metropolitan area of 1.4M people that is Edmonton who, almost universally, live in buildings that do not have heat pumps and resistive heating, and instead rely on natural gas fired heaters (e.g. furnaces, boilers). With the majority of household energy use being space heating, hydrogen blending may have a role in realizing significant emissions reductions while the existing housing stock is transitioned over.\n\nPersonally I think hydrogen’s role in the future is over-hyped. A lot of people working in the fossil fuel space have pinned their industry’s future on hydrogen, which comes with a certain amount of motivated reasoning. Also hydrogen is appealing as it looks like the easy solution: swap the burning of one fuel for the burning of another, and we don’t have to make sweeping and systemic change, except that hydrogen brings its own host of issues (low energy density, material incompatibility). I think the answer will turn out not to be one silver bullet, like hydrogen, but an entire ecosystem of different technologies, often hyper specific to different locations, and what will connect them all will be the electrification of everything. That said, we have a vast, globe spanning, infrastructure and centuries of know-how in burning things and that gives hydrogen a big leg-up as a transitional solution." + }, + { + "objectID": "posts/hydrogen_blending/index.html#an-example-system", + "href": "posts/hydrogen_blending/index.html#an-example-system", + "title": "Hydrogen Blending", + "section": "An example system", + "text": "An example system\nI thought I would end with a basic pipe flow example, if you wanted to look at specific numbers this is how you might start that. This is also an example of the life changing magic of solving problems with code: once you have solved them once you never have to solve them again. Since I have frequently worked out pipe flow problems with julia, I can throw together a more detailed than is at all necessary model through the magic of copying and pasting.\n\nMixture viscosity\nWe’ve already worked out the mixture density and heating value, and the next most important material property is viscosity. I don’t have a curve for natural gas, so I am just going to use methane as a proxy. I am already treating natural gas like a homogeneous substance, so this is simply an extension of that.\n\nusing UnitfulCorrelations\n\n# Hydrogen - from Perry's, 8th ed.\nμ_H2(T) = (1.797e-7*T^0.685)/(1-0.59/T+140/(T^2));\n@ucorrel μ_H2 u\"K\" u\"Pa*s\"\n\n# Methane (Natural Gas) - from Perry's, 8th ed. \nμ_NG(T) = (5.2546e-7*T^0.59006)/(1+105.67/T);\n@ucorrel μ_NG u\"K\" u\"Pa*s\"\n\nWhere I have used a macro that I wrote previously to turn correlations into correlations with units.\nAt standard conditions the viscosity of hydrogen and that of natural gas (methane) are not too different, so we can get away with using a simple method for estimating the viscosity of the overall mixture.\n\nμ_H2(Tᵣ)/μ_NG(Tᵣ)\n\n0.8004989026814741\n\n\nI happen to already have Wilke’s method for a binary mixture worked out, I just need to swap in what the two components are. A more fulsome analysis would have a complete composition of natural gas (broken down into methane, ethane, propane, etc.) in which case the generalized Wilke’s method could be used as well.\n\n# mixture viscosity using Wilke method\n# from *The Properties of Gases and Liquids* 5th ed.\nfunction μ(x,T)\n μ₁ = μ_H2(T)\n M₁ = MW_H2\n y₁ = x\n \n μ₂ = μ_NG(T)\n M₂ = MW_NG\n y₂ = 1-x\n \n ϕ₁₂ = ((1+√((μ₁/μ₂)*√(M₂/M₁)))^2)/√(8*(1+(M₁/M₂)))\n ϕ₂₁ = ϕ₁₂*(μ₂/μ₁)*(M₁/M₂)\n \n μ = (y₁*μ₁/(y₁+y₂*ϕ₁₂)) + (y₂*μ₂/(y₂+y₁*ϕ₂₁))\n return μ\nend;\n\n\n\n\n\n\n\n\n\nFigure 5: The viscosity of blended natural gas/hydrogen for a range of hydrogen content. For a wide range the viscosity is nearly constant.\n\n\n\n\n\n\n\nPipe dimensions and friction\nFor the sake of having something to calculate I am just assuming a 20m length of 2in steel pipe. But you could put in really anything here.\n\n# Pipe dimensions\nL = 20u\"m\" # length\nD = 52.5u\"mm\" # diameter\nϵ = 0.0457u\"mm\" # roughness\n\nA = 0.25*π*D^2 # cross-sectional area\nl = L/D # relative length\nκ = ϵ/D # relative roughness\n\nThe Reynold’s number is simply a function of the mass velocity, G, the pipe diameter, D, and the mixture viscosity μ\n\n# Reynold's number\nRe(x,T,G) = G*D/μ(x,T);\n\nI am using my favourite correlation for the Darcy friction factor, f,\n\n# Churchill correlation, from Perry's\nfunction churchill(Re)\n A = (2.457 * log(1/((7/Re)^0.9 + 0.27*κ)))^16\n B = (37530/Re)^16\n return 8*((8/Re)^12 + 1/(A+B)^(3/2))^(1/12)\nend;\n\nSince this is just a straight length of pipe, the K factor is simply fL/D, defaulting back to the Nikuradse rough pipe law for fully developed turbulent flow (i.e. very high Reynold’s numbers)\n\nKf() = l/(2*log10(3.7/κ))^2 # Nikuradse\nKf(Re) = l*churchill(Re) # Churchill\n\n\n\nVolumetric flowrate\nThe volumetric flowrate for an isothermal ideal gas is simply the mass velocity, G, multiplied by the cross sectional area and divided by the density GA/ρ. It is very easy to modify some code I had previously written to solve for the volumetric flowrate.\n\n# Isothermal ideal gas pipeflow\nfunction Q₁(x, T₁, P₁, P₂, K::Number)\n ρ₁ = ρ(x, T₁, P₁)\n v̄₁ = 1/ρ₁\n q = P₂/P₁\n Q₁ = A*√((v̄₁*P₁*(1-q^2))/(K-2*log(q)))\n return upreferred(Q₁)\nend\n\nfunction Q₁(x, T₁, P₁, P₂, K::Function)\n # Initialize Parameters\n ρ₁ = ρ(x, T₁, P₁)\n q = P₂/P₁\n \n # Initial Guesses\n Q₀ = Q₁(x, T₁, P₁, P₂, K())\n G₀ = Q₀*ρ₁/A\n\n # Numerically solve for G\n obj(G) = (K(Re(x,T₁,G))- 2*log(q))*(G^2) - ρ₁*P₁*(1-q^2)\n G = find_zero(obj, G₀)\n \n return upreferred(G*A/ρ₁)\nend\n\nThis uses julia’s multiple dispatch to handle two cases: for large Reynold’s numbers where K is a constant, and for cases where K is a function of the Reynold’s number (and thus the volumetric flowrate).\nThe volumetric flowrate at standard state is then the flowrate from above, corrected to the reference pressure and temperature1\n1 I have been using upreferred to force Unitful to cancel out and simplify units.\nQₛ(x, T₁, P₁, P₂) = upreferred((P₁/Pᵣ)*(Tᵣ/T₁)*Q₁(x, T₁, P₁, P₂, Kf))\n\n\n\nHeat rate\nThe heat rate is then the heating value, already worked out, times the volumetric flowrate at standard state\n\nq(x, T₁, P₁, P₂) = HHV(x)*Qₛ(x, T₁, P₁, P₂)\n\n\n\n\n\n\n\n\n\nFigure 6: The energy supplied by the example fuel gas delivery system for a range of pressure drops. Pure natural gas and pure hydrogen deliver nearly the same energy for the same pressure drop.\n\n\n\n\n\nWe see the same ordering that we expect, given the previous analysis, namely that the 0% and 100% cases are pretty close to each other, followed by the in-between hydrogen contents.\nAnother way of looking at this is to pick a required heat rate and look at the pressure drop as a function of hydrogen content.\n\n\n\n\n\n\n\n\nFigure 7: The pressure drop required to deliver a fixed heat rate for blended natural gas/hydrogen fuel gas in the example system.\n\n\n\n\n\nAll of this has been done assuming the ideal gas case. The next logical step is to start incorporating non-ideal gas models, say a cubic equation of state, and so on." + }, + { + "objectID": "posts/hydrogen_blending/index.html#references", + "href": "posts/hydrogen_blending/index.html#references", + "title": "Hydrogen Blending", + "section": "References", + "text": "References\n\n\nGPSA. Engineering Data Book. 13th ed. Tulsa, OK: Gas Processors Suppliers Association, 2012.\n\n\nGreen, Don W., ed. Perry’s Chemical Engineers’ Handbook. 8th ed. New York: McGraw Hill, 2008.\n\n\nPoling, Bruce E., John M. Prausnitz, and John P. O’Connell. The Properties of Gases and Liquids. 5th ed. New York: McGraw Hill, 2001.\n\n\nPoling, Bruce E., George H. Thomson, Daniel G. Friend, Richard L. Rowley, and W. Vincent Wilding. “Physical and Chemical Data.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008." + }, + { + "objectID": "posts/federal_election/index.html", + "href": "posts/federal_election/index.html", + "title": "The 2021 Canadian Federal Election", + "section": "", + "text": "On Monday, September 20 2021, Canadians went to the polls and ended up electing a parliament that looked very much like the one we had in August, prior to the election. Very notably so. I’m not much of a political watcher, but I did wonder was this really so notably similar? Or do we just have short memories?\nThis is easy enough to answer." + }, + { + "objectID": "posts/federal_election/index.html#methodology", + "href": "posts/federal_election/index.html#methodology", + "title": "The 2021 Canadian Federal Election", + "section": "Methodology", + "text": "Methodology\nWhat I would like to do is take a table with the number of seats each party got in each election and calculate the change in seats from one election to the next, then add that up. I can’t simply add it up though, as the total number of seats (usually) remains constant and any party’s gain is another party’s loss: the total would always be zero. Instead I am going to add up the absolute value of the change, which effectively double counts each seat (it is counted when one party loses it and again when another party gains it). Also the total number of seats in the house of commons has not always been 338, to adjust for this I will take the absolute value of the change in percentage of seats. So, for example, if party A enters an election with 20% of the seats and leaves with 20% of the seats then this counts as no change, though if they entered with 20 seats and left with 20 seats but the overall number of seats had increased, then that counts as a change.\nI can calculate this for each election and see how much of an outlier 2021 was.\n\nusing CSV, DataFrames, Statistics, Pipe, Plots\n\n\n# takes a dataframe of the form\n# | YEAR | party1 | party 2 | ... | party n |\n# |------|--------|---------|-----|---------|\n# | 1 | 100 | 50 | ... | 0 |\n# | : | : | : | : | : |\n# | m | 30 | 160 | ... | 1 |\n# and returns a length m vector with the relative change for each year\nfunction seat_change(df)\n \n # the first election seat change is undefined\n changes = [NaN]\n \n for i in 2:nrow(df)\n # starting with the second election\n prev = df[i-1, Not(:YEAR)]\n prev_total = sum(prev)\n \n curr = df[i, Not(:YEAR)]\n curr_total = sum(curr)\n \n Δseats = 0\n \n # for each party, calculate the absolute difference\n for j in 1:length(curr)\n \n prev_pct = prev[j]/prev_total\n curr_pct = curr[j]/curr_total\n Δseats += abs(curr_pct - prev_pct)\n end\n \n # add the change to the list\n push!(changes, Δseats)\n end\n \n return changes\nend" + }, + { + "objectID": "posts/federal_election/index.html#dataset", + "href": "posts/federal_election/index.html#dataset", + "title": "The 2021 Canadian Federal Election", + "section": "Dataset", + "text": "Dataset\nI pulled the seat count for each federal election since 1867 from wikipedia as a CSV, with a little bit of finessing in the data entry. We have had a lot of political parties in our short time as a country and many of them either never ended up with any seats or only one or two before disappearing from history – I have elected to lump these in with the independents as “Other”. We have also had several parties that merged or changed, for example the CCF ultimately became the NDP and the Reform party became part of the Canadian Alliance, I have chosen to treat those as the same party.\nRunning this through the function I defined earlier gives the relative absolute seat change per election.\n\ndata_file = \"data/federal-electon-results.csv\"\n\nresults = @pipe data_file |>\n CSV.File( _ ; header=1 ) |>\n DataFrame(_) |>\n hcat(_, seat_change(_)) |>\n rename(_, \"x1\" => \"Change\")\n\nshow(first(results, 6), allcols=true)\n\n\n6×14 DataFrame\n\n Row │ YEAR Other Liberal Conservatives CCF/NDP BQ Progressive Anti-Confederate Social Credit United Farmers Reform/Canadian Alliance Liberal Progressive Unionist Coalition Change \n\n │ Int64 Int64 Int64 Int64 Int64 Int64 Int64 Int64 Int64 Int64 Int64 Int64 Int64 Float64 \n\n─────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n\n 1 │ 1867 0 62 100 0 0 0 18 0 0 0 0 0 NaN\n\n 2 │ 1872 5 95 100 0 0 0 0 0 0 0 0 0 0.311111\n\n 3 │ 1874 12 129 65 0 0 0 0 0 0 0 0 0 0.368932\n\n 4 │ 1878 9 63 134 0 0 0 0 0 0 0 0 0 0.669903\n\n 5 │ 1882 4 73 134 0 0 0 0 0 0 0 0 0 0.0802926\n\n 6 │ 1887 11 80 124 0 0 0 0 0 0 0 0 0 0.116654" + }, + { + "objectID": "posts/federal_election/index.html#results", + "href": "posts/federal_election/index.html#results", + "title": "The 2021 Canadian Federal Election", + "section": "Results", + "text": "Results\nPlotting the results gives us some interesting years to think about, such as 1917 when the government was composed of the Unionist Coalition, a coalition of mostly Conservatives and some Liberals and others, that basically only existed for the war in what was, apparently, one of the most bitter campaigns in Canadian history. For the next election the coalition dissolved back into it’s original parties, hence an enormous change going in and going out of that parliament. There are other large changes, like the 1993 election in which the Conservatives went into the election with 156 seats and left with 2, nearly being wiped out of parliament entirely – the largest change in history according to this metric.\nThere have been periods of low change, the red-line on the plot indicates a change of less than 10%, but none as low as 2021. I do find it interesting that in the late 1800s and the early 1900s we had successive governments with very little change in overall composition but after 1908 things are a lot more variable.\n\n\n\n\n\n\n\n\nFigure 1: Change in seats per Canadian Federal Election, 1867-2021\n\n\n\n\n\nWe can filter out the low-change elections and get a sense of not just the 2021 election, but the neighbourhood of low-change elections.\n\nlowest = filter(row -> row[:Change] < 0.10, results)\n\nsort!(lowest, [:Change]);\n\n\n\n\n7×9 DataFrame\n\n Row │ YEAR Liberal Conservatives BQ CCF/NDP Social Credit Liberal Progressive Other Change \n\n │ Int64 Int64 Int64 Int64 Int64 Int64 Int64 Int64 Float64 \n\n─────┼─────────────────────────────────────────────────────────────────────────────────────────────────────\n\n 1 │ 2021 158 119 34 25 0 0 2 0.0236686\n\n 2 │ 1940 179 39 0 8 10 3 6 0.0653061\n\n 3 │ 1965 131 97 0 21 14 0 2 0.0754717\n\n 4 │ 1908 133 85 0 0 0 0 3 0.0767539\n\n 5 │ 1904 137 75 0 0 0 0 2 0.0784959\n\n 6 │ 1882 73 134 0 0 0 0 4 0.0802926\n\n 7 │ 1891 90 118 0 0 0 0 7 0.0930233\n\n\n\nThis is an exceptionally low change, the next lowest year (1940) had >2× as many seats change hands. Also, the last time the overall seat change was even close to this low was decades ago, the next previous year with a relative change <10% was 1965 and in that case >3× as many seats changed hands.\nThis result may change, as of right now several ridings are still too-close to call without mail in ballots, but for some of those if they flip it will actually lower the overall change in seats, not increase it. For example Edmonton Center is currently undecided with the Liberal candidate ahead, but if it flips to the incumbent Conservative the overall relative change for this election would go down." + }, + { + "objectID": "posts/ooms_plume_model/index.html", + "href": "posts/ooms_plume_model/index.html", + "title": "The Ooms Plume Model", + "section": "", + "text": "I have been interested in the Ooms plume model1 for a long time, but I haven’t really set aside the time to really play around with it because the implementation details are surprisingly sparse. A recent weekend project of mine was to sit down and work out what the actual model equations are and get it running in julia. Something which might be useful to you if you are looking to run one of the O.G. integral plume models." + }, + { + "objectID": "posts/ooms_plume_model/index.html#the-ooms-plume-model", + "href": "posts/ooms_plume_model/index.html#the-ooms-plume-model", + "title": "The Ooms Plume Model", + "section": "The Ooms Plume Model", + "text": "The Ooms Plume Model\nThe 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).\nHowever, 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.\n\n\n\n\n\n\nFigure 1: A sketch of the plume and the coordinate system.\n\n\n\nConsider 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.\nTaking 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 \\(\\sqrt{2}b\\), with b a characteristic length which is a function of distance along the center-line.\nZooming 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.\n\n\n\n\n\n\nFigure 2: A differential element of the plume along the plume center-line.\n\n\n\nThe Ooms model comes from the conservation relations for this differential element.\n\nConservation of…\n\nMass\nThe mass exiting the differential element is equal to the mass entering through the plume plus the entrained air.\n\\[ m_{out} = m_{in} + m_{ent} \\]\nThe mass of entrained air is simply the product of the mass flux (ρE) and the area:\n\\[ m_{ent} = \\rho_a E \\cdot 2\\pi \\left( \\sqrt{2} b \\right) ds \\]\nGiving a mass balance equation:\n\\[ \\frac{d}{ds} m = 2\\pi \\rho_a b \\left( \\sqrt{2} E \\right) \\]\nThe mass passing through a surface is simply the mass flux G = ρ u integrated over the surface area:\n\\[ m = \\int_{A_{in}} \\rho u dA = \\int_0^{2\\pi} \\int_0^{\\sqrt{2}b} \\rho u r dr d\\phi \\] \\[ m = 2\\pi \\int_{0}^{\\sqrt{2}b} \\rho u r dr \\]\nFinally giving\n\\[ 2\\pi \\frac{d}{ds} \\int_{0}^{\\sqrt{2}b} \\rho u r dr = 2\\pi \\rho_a b \\left( \\sqrt{2} E \\right) \\] \\[ \\frac{d}{ds} \\int_{0}^{\\sqrt{2}b} \\rho u r dr = \\rho_a b E \\]\n\n\n\n\n\n\nNote\n\n\n\nAn errant \\(\\sqrt{2}\\) 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 = \\(\\sqrt{2}b_{gauss}\\), when the constants are scaled by a factor of \\(\\sqrt{2}\\) the two look the same.\n\n\n2 “A New Method for the Calculation of the Plume Path of Gases Emitted by a Stack”.\n\nSpecies\nThe total mass of the vented substance is conserved as the plume expands. Assuming the vent is some species i with mass concentration c:\n\\[ \\frac{d}{ds} m_{i} = 0 \\] \\[ m_i = \\int_0^{2\\pi} \\int_0^{\\sqrt{2}b} c u r dr d\\phi = 2\\pi \\int_0^{\\sqrt{2}b} c u r dr \\] \\[ \\frac{d}{ds} \\int_0^{\\sqrt{2}b} c u r dr = 0 \\]\n\n\nMomentum\nThere 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 \\(u_a\\).\nIn the x-direction the total momentum into the differential element is the mass in times the velocity component in the x direction:\n\\[ p_{x,in} = \\int_{A_{in}} \\rho_{in} u_{in} u_{x,in} dA = \\int_{A_{in}} \\rho_{in} u_{in}^2 \\cos\\theta_{in} dA \\]\nAnd similarly for the total momentum leaving the element\n\\[ p_{x,out} = \\int_{A_{out}} \\rho_{out} u_{out} u_{x,out} dA = \\int_{A_{out}} \\rho_{out} u_{out}^2 \\cos\\theta_{out} dA \\]\nThe 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.\n\\[ p_{x,out} - p_{x,in} = m_{ent} u_a + F_{d,x} \\]\nOoms 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, \\(u_a \\sin \\theta\\). Drag then follows the standard relationship, with the area being the outside surface area of the cylinder.\n\\[ F_{d} = \\frac{1}{2} C_d A_{\\perp} \\rho_a v^2 = \\frac{1}{2} C_d A_{\\perp} \\rho_a u_a^2 \\sin^2 \\theta \\]\nThe drag force in the x-direction is acting on the area perpendicular to the x-direction\n\\[ F_{d,x} = \\frac{1}{2} C_d \\rho_a u_a^2 \\sin^2 \\theta 2\\pi \\left(\\sqrt{2}b\\right) | \\sin \\theta | ds = \\pi b C_d \\rho_a u_a^2 | \\sin^3 \\theta | ds\\]\nWhere the absolute value comes from the drag force being always positive.\nGiving\n\\[ 2 \\frac{d}{ds} \\int_{0}^{\\sqrt{2}b} \\rho u^2 \\cos \\theta r dr = 2 b \\rho_a u_a E + \\pi b C_d \\rho_a u_a^2 | \\sin^3 \\theta | \\]\nIn 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:\n\\[ F_b = \\int_V g \\left(\\rho_a - \\rho \\right) dV = 2\\pi ds \\int_0^{\\sqrt{2}b} g \\left(\\rho_a - \\rho \\right) rdr \\cdot \\]\nAssuming 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:\n\\[ 2 \\frac{d}{ds} \\int_{0}^{\\sqrt{2}b} \\rho u^2 \\sin \\theta r dr = 2 \\int_0^{\\sqrt{2}b} g \\left(\\rho_a - \\rho \\right) r dr + \\mathrm{sgn}\\theta \\cdot \\pi b C_d \\rho_a u_a^2 \\sin^2 \\theta \\cos \\theta \\]\nWhere \\(\\mathrm{sgn} \\theta\\) ensures the drag force is acting in the right direction.\n\n\nEnergy\nStarting from an energy balance, using the ambient temperature as the reference temperature, the enthalpy entering the differential element is:\n\\[ H_{in} = \\int_{A_{in}} \\rho u_{in} c_p \\left( T - T_{a,0} \\right) dA \\]\nSimilarly for the enthalpy out, giving an enthalpy change over the element of:\n\\[ d \\left( \\int_{A} \\rho u_{in} c_p \\left( T - T_{a,0} \\right) dA \\right) = d \\left( 2\\pi \\int_0^{\\sqrt{2}b} \\rho u c_p \\left( T - T_{a,0} \\right) r dr \\right) \\]\nTo 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:\n\\[ \\rho_a E c_{p,a} \\left( T_a - T_{a,0} \\right) \\cdot 2\\pi b ds \\]\nPutting it all together we get the energy balance:\n\\[ \\frac{d}{ds} \\int_0^{\\sqrt{2}b} \\rho u c_p \\left( T - T_{a,0} \\right) r dr = b \\rho_a E c_{p,a} \\left( T_a - T_{a,0} \\right) \\]\nAssuming the ideal gas law, we can make the substitution:\n\\[ T = {{ P {MW} } \\over {R \\rho}} \\]\nFurthermore, if we assume \\(MW = MW_a\\) and \\(c_p = c_{p,a}\\) then we can cancel all those constants giving:\n\\[ \\frac{d}{ds} \\int_0^{\\sqrt{2}b} \\rho u \\left( \\frac{1}{\\rho} - \\frac{1}{\\rho_{a,0}} \\right) r dr = b \\rho_a E \\left( \\frac{1}{\\rho_a} - \\frac{1}{\\rho_{a,0}} \\right) \\]\nThese 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).\n\n\n\nCoordinate Transforms\nUp 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:\n\\[ \\frac{dx}{ds} = \\cos \\theta \\] \\[ \\frac{dz}{ds} = \\sin \\theta \\]\n\n\nEntrainment\nOne of the most important parts of the model is how it accounts for entrainment. Ooms considers entrainment to be the sum of three processes.\nIn 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:\n\\[ E_1 = \\alpha_1 | u - u_a \\cos \\theta | \\]\nWhere \\(u_a \\cos \\theta\\) is the component of the wind velocity parallel to the jet. The parameter \\(\\alpha_1\\) is called the entrainment coefficient for a free jet and is independent of Reynolds’ number when \\(\\mathrm{Re} > 10^4\\). Ooms gives this as \\(\\alpha_1 = 0.057\\).\nAt distances further down the plume axis, when \\(u \\approx u_a\\), the entrainment is taken to be the same as a cylindrical thermal in a stagnant atmosphere, given as:\n\\[ E_2 = \\alpha_2 u_a | \\sin \\theta | \\]\nWhere \\(\\alpha_2\\) is called the entrainment coefficient for a line thermal, it is similarly a constant at large Reynolds’ numbers. Ooms gives this as \\(\\alpha_2 = 0.5\\)\nTo connect these two regimes, Ooms multiplies the line thermal term by \\(\\cos \\theta\\). 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.\nFinally, 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 \\(u^{\\prime}\\)\n\\[ E_3 = \\alpha_3 u^{\\prime} \\]\nWhere \\(\\alpha_3\\) is the entrainment coefficient due to turbulence, which is taken to be \\(\\alpha_1 = 1.0\\). The entrainment velocity due to turbulence can be accounted for in one of two ways:\n\nFollowing Briggs, \\(u^{\\prime} = \\sqrt[3]{\\epsilon b}\\) where \\(\\epsilon\\) is the eddy energy dissipation and is a function of atmospheric stability and elevation.\nEmpirically by the root-mean-square of the wind velocity fluctuation \\(u^{\\prime} = \\sqrt{u_a^2}\\)\n\nThe total entrainment is then:\n\\[ E = E_1 + E_2 \\cos \\theta + E_3 \\]\n\\[ E = \\alpha_1 | u - u_a \\cos \\theta | + \\alpha_2 u_a | \\sin \\theta | \\cos \\theta + \\alpha_3 u^{\\prime} \\]\nor\n\\[ E = \\alpha_1 | u^{*} | + \\alpha_2 u_a | \\sin \\theta | \\cos \\theta + \\alpha_3 u^{\\prime} \\]\nWhere \\(u^{*}\\) is defined in the next section.\n\n\nSimilarity Profiles\nEarlier 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\n3 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:\n\\[ u = u_a \\cos \\theta + u^{*} \\exp \\left( - \\left(r \\over b\\right)^2 \\right) \\]\nThe plume density, similarly, is the air density plus an excess density:\n\\[ \\rho = \\rho_a + \\rho^{*} \\exp \\left( - \\left(r \\over {\\lambda b} \\right)^2 \\right) \\]\nFinally, the concentration simply follows a Gaussian profile:\n\\[ c = c^{*} \\exp \\left( - \\left(r \\over {\\lambda b} \\right)^2 \\right) \\]\nWhere \\(\\frac{1}{\\lambda^2}\\) 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.\nOoms uses a value of \\(\\lambda^2 = 1.35\\) or \\(\\mathrm{Sc}_t = 0.741\\), which is consistent with observations of free jets." + }, + { + "objectID": "posts/ooms_plume_model/index.html#practical-necessities", + "href": "posts/ooms_plume_model/index.html#practical-necessities", + "title": "The Ooms Plume Model", + "section": "Practical Necessities", + "text": "Practical Necessities\nThe 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 λ.\n4 Havens and Spicer, “A Dispersion Model for Elevated Dense Gas Jet Chemical Releases,” 7–13.\n\n\n\n\n\nFigure 3: The model constants from Havens,5 note the misprint in \\(k_{14}\\) (should read 2.227186)\n\n5 Havens and Spicer, 12.\n\nThe 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.\nI did my own working out here because I wanted two things:\n\nThe relationship between the model constants (e.g λ) and the integration constants (the k’s in DEGADIS)\nTo re-create the model that allows for more structure to the atmosphere.\n\n\nA Series of Tedious Integrals\nThe 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 \\(\\int \\exp(-ar^2) r dr\\) which has a nice closed form solution.\nI worked out five different constants that are integrals of the Gaussian profiles and the products of them:\n\\[ C_1 = 2 \\int_0^{\\sqrt{2}} \\exp \\left( - \\xi^2 \\right) \\xi d\\xi = 1 - \\exp \\left( -2 \\right)\\] \\[ C_2 = 2 \\int_0^{\\sqrt{2}} \\exp \\left( - \\left( \\frac{\\xi}{\\lambda} \\right)^2 \\right) \\xi d\\xi = \\lambda^2 \\left( 1 - \\exp \\left( -\\frac{2}{\\lambda^2} \\right) \\right)\\] \\[ C_3 = 2 \\int_0^{\\sqrt{2}} \\exp \\left( - \\xi^2 - \\left( \\frac{\\xi}{\\lambda} \\right)^2 \\right) \\xi d\\xi = \\frac{\\lambda^2}{\\lambda^2 + 1} \\left( 1 - \\exp \\left( -\\frac{2 \\left(\\lambda^2 + 1\\right)}{\\lambda^2} \\right) \\right)\\] \\[ C_4 = \\int_0^{\\sqrt{2}} \\exp \\left( - 2\\xi^2 \\right) \\xi d\\xi = \\frac{1}{4} \\left( 1 - \\exp \\left( -4 \\right) \\right)\\] \\[ C_5 = \\int_0^{\\sqrt{2}} \\exp \\left( - 2\\xi^2 - \\left( \\frac{\\xi}{\\lambda} \\right)^2 \\right) \\xi d\\xi = \\frac{\\lambda^2}{4\\lambda^2 + 2} \\left( 1 - \\exp \\left( -\\frac{4\\lambda^2 + 2}{\\lambda^2} \\right) \\right)\\]\nThese 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 \\(\\xi = \\frac{r}{b}\\) such that every integral of a Gaussian in the model becomes \\(b^2 C\\) 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 \\(k_1\\) and \\(k_2\\) they are integer scaling factors, for the first two they \\(\\frac{1}{\\lambda^2}\\) times \\(C_2\\) and \\(C_3\\) respectively. Below is a table showing the concordance.\n\n\n\nTable 1: Integration Constants\n\n\n\n\n\n\n\n\n\nDEGADIS6\nMe\n\n\n\n\n\\(k_{1 }\\)\n\\(\\frac{C_2}{\\lambda^2}\\)\n\n\n\\(k_{2 }\\)\n\\(\\frac{C_3}{\\lambda^2}\\)\n\n\n\\(k_{3 }\\)\n\\(C_1\\)\n\n\n\\(k_{4 }\\)\n\\(C_2\\)\n\n\n\\(k_{5 }\\)\n\\(C_3\\)\n\n\n\\(k_{6 }\\)\n\\(2C_1\\)\n\n\n\\(k_{7 }\\)\n\\(2C_4\\)\n\n\n\\(k_{8 }\\)\n\\(2C_3\\)\n\n\n\\(k_{9 }\\)\n\\(2C_5\\)\n\n\n\\(k_{10}\\)\n\\(4C_4\\)\n\n\n\\(k_{11}\\)\n\\(4C_5\\)\n\n\n\\(k_{12}\\)\n\\(4C_1\\)\n\n\n\\(k_{13}\\)\n\\(3C_2\\)\n\n\n\\(k_{14}\\)\n\\(4C_3\\)\n\n\n\\(k_{15}\\)\n\\(\\frac{C_2}{2}\\)\n\n\n\\(k_{16}\\)\n\\(\\frac{C_1}{2}\\)\n\n\n\\(k_{17}\\)\n\\(\\frac{C_3}{2}\\)\n\n\n\n6 Havens and Spicer, 12.\n\n\n\n\nDimensionless Form\nIt is decidedly easier to put everything in dimensionless form first, using the following (where a bar over the variable indicates that it is dimensionless):\n\\[ \\bar{s} = \\frac{s}{D} \\] \\[ \\bar{c} = \\frac{c^{*}}{c_0} \\] \\[ \\bar{b} = \\frac{b}{D} \\] \\[ \\bar{u} = \\frac{u^{*}}{u_a} \\] \\[ \\bar{\\rho} = \\frac{\\rho^{*}}{\\rho_a} \\] \\[ \\bar{x} = \\frac{x}{D} \\] \\[ \\bar{z} = \\frac{z}{D} \\]\nWhere 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.\n\n\nThe Full Equations\n\nConservation of Mass\nThusly equipped, we can work out the integrals and subsequently all the derivatives. Starting with the conservation of mass:\n\\[ \\int_0^{\\sqrt{2}b} \\rho u r dr = \\int_0^{\\sqrt{2}b} \\rho_a u_a \\left( 1 + \\bar{\\rho} \\exp \\left( - \\left(r \\over {\\lambda b} \\right)^2 \\right) \\right) \\left( \\cos \\theta + \\bar{u} \\exp \\left( - \\left(r \\over {b} \\right)^2 \\right) \\right) r dr\\]\n\\[ = \\rho_a u_a b^2 \\left( \\cos \\theta + \\bar{\\rho} \\cos \\theta \\int_0^{\\sqrt{2}} \\exp \\left( - \\left(\\xi \\over {\\lambda } \\right)^2 \\right) \\xi d\\xi + \\bar{u} \\int_0^{\\sqrt{2}} \\exp \\left( - \\xi^2 \\right) \\xi d\\xi + \\bar{\\rho} \\bar{u} \\int_0^{\\sqrt{2}} \\exp \\left( - \\xi \\left(\\xi \\over {\\lambda } \\right)^2 \\right) \\xi d\\xi \\right)\\] \\[ = \\frac{1}{2} \\rho_a u_a D^2 \\bar{b}^2 \\left( \\left( C_1 + C_3 \\bar{\\rho} \\right) \\bar{u} + \\left(2 + C_2 \\bar{\\rho} \\right) \\cos \\theta \\right) \\]\nSo the balance equation is:\n\\[ \\frac{1}{2} \\rho_a u_a \\frac{d}{d\\bar{s}} \\bar{b}^2 \\left( \\left( C_1 + C_3 \\bar{\\rho} \\right) \\bar{u} + \\left(2 + C_2 \\bar{\\rho} \\right) \\cos \\theta \\right) = b \\rho_a u_a \\bar{E} \\] \\[ \\frac{d}{d\\bar{s}} \\bar{b}^2 \\left( \\left( C_1 + C_3 \\bar{\\rho} \\right) \\bar{u} + \\left(2 + C_2 \\bar{\\rho} \\right) \\cos \\theta \\right) = b \\bar{E} \\]\nWhere \\(\\bar{E} = \\frac{E}{u_a}\\) is the dimensionless entrainment velocity.\nExpanding out the derivatives and dividing through by b, we get:\n\\[ \\left( 2\\cos \\theta \\left( 2 + C_2 \\bar{\\rho} \\right) + 2 \\bar{u} \\left( C_1 + C_3\\bar{\\rho} \\right) \\right) \\frac{d\\bar{b}}{d\\bar{s}} \\] \\[ + \\bar{b} \\left( C_1 + C_3\\bar{\\rho} \\right) \\frac{d\\bar{u}}{d\\bar{s}} \\] \\[ - \\bar{b} \\sin \\theta \\left( 2 + C_2\\bar{\\rho} \\right) \\frac{d\\theta}{d\\bar{s}} \\] \\[ + \\bar{b} \\left( C_2\\cos \\theta + C_3\\bar{u} \\right) \\frac{d\\bar{\\rho}}{d\\bar{s}} = 2 \\bar{E}\\]\n\n\nConservation of Species\nIn 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.\nThe balance equation is:\n\\[ \\frac{d}{d\\bar{s}} \\left( \\bar{c}\\bar{b}^2 \\left( C_2 \\cos \\theta + C_3 \\bar{u} \\right) \\right) = 0 \\]\nThe final form is:\n\\[ \\bar{b} \\left(C_3\\bar{u} + C_2 \\cos \\theta \\right) \\frac{d\\bar{c}}{d\\bar{s}} \\] \\[ + 2 \\bar{c} \\left(C_3\\bar{u} + C_2 \\cos \\theta \\right) \\frac{d\\bar{b}}{d\\bar{s}} \\] \\[ + C_3 \\bar{c} \\bar{b} \\frac{d\\bar{u}}{d\\bar{s}} \\] \\[ - C_2 \\bar{c} \\bar{b} \\sin \\theta \\frac{d\\theta}{d\\bar{s}} = 0\\]\n\n\nConservation of Momentum\nThe balance equation in the x-direction is:\n\\[ \\frac{d}{d\\bar{s}} \\left[ \\bar{b}^2 \\cos \\theta \\left( 2\\bar{u}^2 \\left( C_4 + C_5\\bar{\\rho} \\right) + 2\\bar{u} \\cos\\theta \\left(C_1 + C_3 \\bar{\\rho} \\right) + \\cos^2 \\theta \\left( 2 + C_2 \\bar{\\rho} \\right) \\right) \\right] = \\bar{b} \\left( 2\\bar{E} + C_d | \\sin^3 \\theta | \\right) \\]\nThe final form is:\n\\[ 2\\cos\\theta \\left[ 2\\bar{u}^2 \\left( C_4 + C_5 \\bar{\\rho} \\right) + 2\\bar{u} \\cos\\theta \\left(C_1 + C_3 \\bar{\\rho} \\right) + \\cos^2 \\theta \\left( 2 + C_2 \\bar{\\rho} \\right) \\right] \\frac{d\\bar{b}}{d\\bar{s}} \\] \\[ + 2\\bar{b} \\cos\\theta \\left[ 2\\bar{u} \\left( C_4 + C_5\\bar{\\rho} \\right) + \\cos\\theta \\left(C_1 + C_3 \\bar{\\rho} \\right) \\right] \\frac{d\\bar{u}}{d\\bar{s}} \\] \\[ - \\bar{b} \\sin\\theta \\left[ 2\\bar{u}^2 \\left( C_4 + C_5 \\bar{\\rho} \\right) + \\cos \\theta \\left( 4\\bar{u} \\left(C_1 + C_3 \\bar{\\rho} \\right) + 3\\cos \\theta \\left( 2 + C_2 \\bar{\\rho} \\right) \\right) \\right] \\frac{d\\theta}{d\\bar{s}} \\] \\[ + \\bar{b} \\cos\\theta \\left[ C_2 \\cos^2 \\theta + 2C_3 \\bar{u} \\cos \\theta + 2 C_5 \\bar{u}^2 \\right] \\frac{d\\bar{\\rho}}{d\\bar{s}} = 2 \\bar{E} + C_d | \\sin^3 \\theta |\\]\nThe balance equation in the z-direction is:\n\\[ \\frac{d}{d\\bar{s}} \\left[ \\bar{b}^2 \\sin \\theta \\left( 2\\bar{u}^2 \\left( C_4 + C_5\\bar{\\rho} \\right) + 2\\bar{u} \\cos\\theta \\left(C_1 + C_3 \\bar{\\rho} \\right) + \\cos^2 \\theta \\left( 2 + C_2 \\bar{\\rho} \\right) \\right) \\right] = -C_2 \\bar{b}^2 \\bar{\\rho} \\bar{g} + \\mathrm{sgn}\\theta \\cdot C_d \\bar{b} \\sin^2 \\theta \\cos \\theta\\]\nWhere \\(\\bar{g} = \\frac{Dg}{u_a^2}\\) is the dimensionless gravity.\nThe final form is:\n\\[ 2\\sin\\theta \\left[ 2\\bar{u}^2 \\left( C_4 + C_5 \\bar{\\rho} \\right) + 2\\bar{u} \\cos\\theta \\left(C_1 + C_3 \\bar{\\rho} \\right) + \\cos^2 \\theta \\left( 2 + C_2 \\bar{\\rho} \\right) \\right] \\frac{d\\bar{b}}{d\\bar{s}} \\] \\[ + 2\\bar{b} \\sin\\theta \\left[ 2\\bar{u} \\left( C_4 + C_5\\bar{\\rho} \\right) + \\cos\\theta \\left(C_1 + C_3 \\bar{\\rho} \\right) \\right] \\frac{d\\bar{u}}{d\\bar{s}} \\] \\[ + \\bar{b} \\left[ 2\\bar{u}^2 \\cos\\theta \\left(C_4 + C_5 \\bar{\\rho} \\right) + 2\\bar{u} \\left(\\cos^2 \\theta - \\sin^2 \\theta \\right) \\left( C_1 - C_3 \\bar{\\rho} \\right) + \\left(1 - 3\\sin^2 \\theta \\right)\\cos\\theta \\left(2 + C_2 \\bar{\\rho} \\right) \\right] \\frac{d\\theta}{d\\bar{s}} \\] \\[ + \\bar{b} \\sin\\theta \\left[ C_2 \\cos^2 \\theta + 2C_3 \\bar{u} \\cos \\theta + 2 C_5 \\bar{u}^2 \\right] \\frac{d\\bar{\\rho}}{d\\bar{s}} = -C_2 \\bar{b} \\bar{\\rho} \\bar{g} + \\mathrm{sgn}\\theta \\cdot C_d \\sin^2 \\theta \\cos \\theta\\]\n\n\nConservation of Energy\nThe balance equation is:\n\\[ \\frac{d}{d\\bar{s}} \\left[ \\bar{b}^2 \\left( 2\\cos\\theta + C_1 \\bar{u} - \\bar{\\rho_a} \\left( \\bar{u} \\left(C_1 + C_3 \\bar{\\rho} \\right) + \\cos \\theta \\left( 2 + C_2\\bar{\\rho} \\right) \\right) \\right)\\right] = 2\\bar{b} \\left( 1 - \\bar{\\rho_a} \\right) \\bar{E}\\]\nWhere \\(\\bar{\\rho_a} = \\frac{\\rho_a}{\\rho_{a,0}}\\) is the dimensionless air density.\nThe final form is:\n\\[ 2\\left( 2\\cos\\theta + C_1 \\bar{u} - \\bar{\\rho_a} \\left( \\bar{u} \\left(C_1 + C_3 \\bar{\\rho} \\right) + \\cos \\theta \\left( 2 + C_2\\bar{\\rho} \\right) \\right)\\right) \\frac{d\\bar{b}}{d\\bar{s}} \\] \\[ + b\\left( C_1 - \\bar{\\rho_a} \\left(C_1 + C_3 \\bar{\\rho} \\right) \\right) \\frac{d\\bar{u}}{d\\bar{s}} \\] \\[ -b\\sin\\theta \\left( 2 - \\bar{\\rho_a} \\left( 2 + C_2\\bar{\\rho} \\right) \\right) \\frac{d\\theta}{d\\bar{s}} \\] \\[ - \\bar{b} \\bar{\\rho_a} \\left( C_3 \\bar{u} + C_2 \\cos \\theta \\right) \\frac{d\\bar{\\rho}}{d\\bar{s}} = 2 \\left( 1 - \\bar{\\rho_a} \\right) \\bar{E}\\]" + }, + { + "objectID": "posts/ooms_plume_model/index.html#implementing-the-ooms-plume-model", + "href": "posts/ooms_plume_model/index.html#implementing-the-ooms-plume-model", + "title": "The Ooms Plume Model", + "section": "Implementing the Ooms Plume Model", + "text": "Implementing the Ooms Plume Model\nImplementing this in julia is very straightforward, starting with the model constants\n\n# constants from Ooms 1972\nconst λ² = 1.35\nconst α₁ = 0.057\nconst α₂ = 0.5\nconst α₃ = 1.0\nconst ϵ = 0.0\nconst Cd = 0.3\n\n\n# integration constants\nconst C₁ = 1-exp(-2)\nconst C₂ = λ²*(1-exp(-2/λ²))\nconst C₃ = (λ²/(λ²+1))*(1-exp(-2*(λ²+1)/λ²))\nconst C₄ = (1-exp(-4))/4\nconst C₅ = (λ²/(4λ²+2))*(1-exp(-(4λ²+2)/λ²))\n\n\n# physical constants\nconst g = 9.80665 # standard gravity, m/s²\nconst MWₐ = 0.0289652 # molar weight dry air, kg/mol\nconst cpₐ = 1.006 # specific heat dry air, kJ/kg/K\nconst ρₐ₀ = 1.2250 # standard density dry air, kg/m³\n\nThe standard way of writing a differential algebraic equation is in the form of a mass matrix, M:\n\\[ M \\frac{d}{d\\bar{s}} \\mathrm{state} = f\\left(\\mathrm{state}, s\\right) \\]\nWhere 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.\n\nfunction ooms_matrix!(M,state,p,s)\n # unpack variables for readability\n c, b, u, θ, ρ, x, z = state\n\n # calculate atmospheric conditons at centerline elevation \n ρₐ_bar = p.rhoa_bar(z)\n\n # species balance\n M[1,1] = b*( C₃*u + C₂*cos(θ) )\n M[1,2] = 2*c*( C₃*u + C₂*cos(θ) )\n M[1,3] = C₃*c*b\n M[1,4] = -C₂*c*b*sin(θ)\n M[1,5] = 0\n M[1,6] = 0\n M[1,7] = 0\n\n # overall mass balance\n M[2,1] = 0\n M[2,2] = 2cos(θ)*(2 + C₂*ρ) + 2u*(C₁ + C₃*ρ)\n M[2,3] = b*(C₁ + C₃*ρ)\n M[2,4] = -b*sin(θ)*(2 + C₂*ρ)\n M[2,5] = b*(C₂*cos(θ) + C₃*u)\n M[2,6] = 0\n M[2,7] = 0\n\n # x momentum balance\n M[3,1] = 0\n M[3,2] = 2cos(θ)*( 2u^2*(C₄ + C₅*ρ) + 2u*cos(θ)*(C₁ + C₃*ρ) \n + cos(θ)^2*(2 + C₂*ρ))\n M[3,3] = 2b*cos(θ)*( cos(θ)*(C₁ + C₃*ρ) + 2u*(C₄ + C₅*ρ) )\n M[3,4] = -b*sin(θ)*( cos(θ)*( 4u*(C₁ + C₃*ρ) + 3cos(θ)*(2 + C₂*ρ) )\n + 2u^2*(C₄ + C₅*ρ) )\n M[3,5] = b*cos(θ)*( C₂*cos(θ)^2 + 2C₃*u*cos(θ) + 2C₅*u^2 )\n M[3,6] = 0\n M[3,7] = 0\n\n # z momentum balance\n M[4,1] = 0\n M[4,2] = 2sin(θ)*( 2u*cos(θ)*(C₁ + C₃*ρ) + cos(θ)^2*(2 + C₂*ρ) \n + 2u^2*(C₄ + C₅*ρ))\n M[4,3] = 2b*sin(θ)*(cos(θ)*(C₁ + C₃*ρ) +2u*(C₄ + C₅*ρ))\n M[4,4] = b*(2u*(cos(θ)^2 - sin(θ)^2)*(C₁ + C₃*ρ) \n + (1-3sin(θ)^2)*cos(θ)*(2 + C₂*ρ) \n + 2u^2*cos(θ)*(C₄ + C₅*ρ))\n M[4,5] = b*sin(θ)*(C₂*cos(θ)^2 + 2C₃*u*cos(θ) + 2C₅*u^2)\n M[4,6] = 0\n M[4,7] = 0\n\n # energy balance\n M[5,1] = 0\n M[5,2] = 2(2cos(θ) + C₁*u - ρₐ_bar*(u*(C₁ + C₃*ρ) + cos(θ)*(2 + C₂*ρ)) )\n M[5,3] = b*( C₁ - ρₐ_bar*(C₁ + C₃*ρ) )\n M[5,4] = -b*sin(θ)*( 2 - ρₐ_bar*(2 + C₂*ρ) )\n M[5,5] = -b*ρₐ_bar*( C₂*cos(θ) + C₃*u )\n M[5,6] = 0\n M[5,7] = 0\n\n # x coordinate\n M[6,1:5] .= 0\n M[6,6] = 1\n M[6,7] = 0\n\n # z coordinate\n M[7,1:6] .= 0\n M[7,7] = 1\nend\n\nIn dimensionless form, the only parameter of the system that is relevant to the mass matrix is \\(\\bar{\\rho_a}\\) which is a function of the (dimensionless) elevation.\nThe right-hand-side of the system of equations is below, and is also in place. In this case there are three parameters: \\(\\bar{\\rho_a}\\), \\(\\bar{g}\\) and \\(\\bar{u^{\\prime}} = \\frac{u^{\\prime}}{u_a}\\). These are all functions of elevation, the latter two because \\(u_a\\) is a function of elevation.\n\nfunction ooms_rhs!(f,state,p,s)\n # unpack variables for readability\n c, b, u, θ, ρ, x, z = state\n\n # calculate atmospheric conditons at centerline elevation\n ρₐ_bar = p.rhoa_bar(z)\n g_bar = p.g_bar(z)\n \n # entrainment\n u′ = p.uprime_bar(b, z)\n E = α₁*abs(u) + α₂*abs(sin(θ))*cos(θ) + α₃*u′\n sgn = θ<0 ? -1 : +1\n\n f .= [ 0 # species balance\n 2E # overall mass balance\n 2E + Cd*abs(sin(θ)^3) # x momentum balance\n -C₂*b*ρ*g_bar + sign(θ)*Cd*sin(θ)^2*cos(θ) # z momentum balance\n 2*(1-ρₐ_bar)*E # energy balance\n cos(θ) # x coordinate\n sin(θ)] # z coordinate\nend\n\n\n… as an ODE\nThe most direct way of implementing the model is as an ODE where\n\\[ \\frac{d}{d\\bar{s}} \\mathrm{state} = M^{-1} f \\]\nThough 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.\n\nusing OrdinaryDiffEq\n\n\nfunction ode_rhs!(dstate,state,p,s)\n ooms_matrix!(p.M,state,p,s)\n ooms_rhs!(p.f,state,p,s)\n dstate[:] = p.M\\p.f\nend\n\nInstead 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.\nFor 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\n\nconst dρₐdz = 0.0\nconst uₐ₀ = 2.0 # m/s\n\nThe 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.\n\nconst MWⱼ = MWₐ # kg/m³\nconst cpⱼ = cpₐ # kJ/kg/K\nconst ρⱼ = ρₐ₀/2 # kg/m³\nconst D = 0.2 # m\nconst u₀ = 10.0 # m/s\nconst h = 2.0 # m\nconst θ₀ = π/2\nconst c₀ = ρⱼ\n\nThe 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.\n\nparams = (M = zeros(7,7),\n f = zeros(7),\n rhoa_bar = (z) -> 1.0 + (dρₐdz/ρₐ₀)*D*z,\n g_bar = (z) -> (g*D)/uₐ₀^2,\n uprime_bar = (b, z) -> ∛(ϵ*b*D)/uₐ₀)\n\nThe initial state, in dimensionless form, is then\n\nstate0 = [ 1.0 ,# c\n 1/(2√(2)) ,# b\n u₀/uₐ₀ ,# u\n θ₀ ,# θ\n (ρⱼ - ρₐ₀)/ρₐ₀ ,# ρ\n 0.0 ,# x\n h/D ]# z\n\n\n\n\n\n\n\nNote\n\n\n\nThe initial value for \\(\\bar{b}_{0}\\) 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 \\(\\sqrt{2}{b}\\) so, if the plume initially has a radius equal to the vent \\(\\sqrt{2}b_0 = \\frac{D}{2}\\) and \\(\\bar{b}_0 = \\frac{b}{D} = \\frac{1}{2\\sqrt{2}}\\)\n\n\nIntegrating out 100 stack diameters along the plume\n\nspan = (0.0, 100)\nprob = ODEProblem(ode_rhs!, state0, span, params)\n\n\nsol = solve(prob, Tsit5())\n\nsol.retcode\n\nReturnCode.Success = 1\n\n\n\n\n\n\n\n\n\nFigure 4: The plume height as a function of downwind distance.\n\n\n\n\n\n\n… as a DAE\nNesting 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.\nDAE solvers expect to be solving a differential algebraic equation of the form:\n\\[ f\\left( \\mathrm{state}^{\\prime}, \\mathrm{state}, s \\right) = 0\\]\nUsing the matrix and rhs functions defined earlier this easy enough to do, in this case the function is in-place.\n\nfunction dae_lhs!(resid,dstate,state,p,s)\n ooms_matrix!(p.M,state,p,s)\n ooms_rhs!(p.f,state,p,s)\n resid[:] = p.M*dstate - p.f\nend\n\nThe 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.\n\nM0 = zeros(7,7)\nooms_matrix!(M0,state0,params,0)\n\nf0 = zeros(7)\nooms_rhs!(f0,state0,params,0)\n\ndstate0 = M0\\f0\n\n\ndiff_vars = fill(true, 7)\ndaeprob = DAEProblem(dae_lhs!, dstate0, state0, span, params; \n differential_vars = diff_vars)\n\nThe 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.\nThe DAE solver I am going to use is IDA from Sundials.\n\nusing Sundials\n\n\ndaesol = solve(daeprob, IDA())\n\ndaesol.retcode\n\nReturnCode.Success = 1\n\n\n\n\n\n\n\n\n\nFigure 5: The plume height as a function of downwind distance, solutions using the DifferentialEquations.jl solver Tsit5 and the Sundials DAE solver IDA.\n\n\n\n\nThis works as well as the lazy method, slightly slower but it has not been implemented in a particularly optimal way.\n\n\n… using ModelingToolkit\nIf 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.\n\nusing ModelingToolkit, Symbolics\nusing ModelingToolkit: t_nounits as s, D_nounits as ∂\n\n# I would use D for derivative but I'm already using \n# that for jet diameter so I'm using ∂ instead\n\nFirst I define the system variables, again these are in dimensionless form.\n\nvars = @variables c(s) b(s) u(s) θ(s) ρ(s) x(s) z(s)\n\n\\[ \\begin{equation}\n\\left[\n\\begin{array}{c}\nc\\left( t \\right) \\\\\nb\\left( t \\right) \\\\\nu\\left( t \\right) \\\\\n\\theta\\left( t \\right) \\\\\n\\rho\\left( t \\right) \\\\\nx\\left( t \\right) \\\\\nz\\left( t \\right) \\\\\n\\end{array}\n\\right]\n\\end{equation}\n\\]\n\n\nIf 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 \\(C_1\\).\n\n# conservation of mass\n∫ρurdr = b^2*( (C₁ + C₃*ρ)*u + (2 + C₂*ρ)*cos(θ) )\n\nE = α₁*abs(u) + α₂*abs(sin(θ))*cos(θ) + α₃*∛(ϵ*b*D)/uₐ₀\n\neqn1 = expand_derivatives( ∂( ∫ρurdr ) ) ~ 2*b*E\n\n\\[ \\begin{equation}\n2 \\left( u\\left( t \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) + \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) \\right) \\frac{\\mathrm{d} b\\left( t \\right)}{\\mathrm{d}t} b\\left( t \\right) + \\left( b\\left( t \\right) \\right)^{2} \\left( 0.5568 u\\left( t \\right) \\frac{\\mathrm{d} \\rho\\left( t \\right)}{\\mathrm{d}t} + \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\frac{\\mathrm{d} u\\left( t \\right)}{\\mathrm{d}t} + 1.0431 \\frac{\\mathrm{d} \\rho\\left( t \\right)}{\\mathrm{d}t} \\cos\\left( \\theta\\left( t \\right) \\right) - \\sin\\left( \\theta\\left( t \\right) \\right) \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\frac{\\mathrm{d} \\theta\\left( t \\right)}{\\mathrm{d}t} \\right) = 2 \\left( 0.057 \\left|u\\left( t \\right)\\right| + 0.5 \\left|\\sin\\left( \\theta\\left( t \\right) \\right)\\right| \\cos\\left( \\theta\\left( t \\right) \\right) \\right) b\\left( t \\right)\n\\end{equation}\n\\]\n\n\n\n# conservation of species\n∫curdr = c*b^2*(C₂*cos(θ) + C₃*u)\n\neqn2 = expand_derivatives( ∂( ∫curdr ) ) ~ 0\n\n\\[ \\begin{equation}\n2 \\left( 0.5568 u\\left( t \\right) + 1.0431 \\cos\\left( \\theta\\left( t \\right) \\right) \\right) c\\left( t \\right) \\frac{\\mathrm{d} b\\left( t \\right)}{\\mathrm{d}t} b\\left( t \\right) + \\left( b\\left( t \\right) \\right)^{2} \\left( 0.5568 u\\left( t \\right) + 1.0431 \\cos\\left( \\theta\\left( t \\right) \\right) \\right) \\frac{\\mathrm{d} c\\left( t \\right)}{\\mathrm{d}t} + \\left( b\\left( t \\right) \\right)^{2} \\left( 0.5568 \\frac{\\mathrm{d} u\\left( t \\right)}{\\mathrm{d}t} - 1.0431 \\sin\\left( \\theta\\left( t \\right) \\right) \\frac{\\mathrm{d} \\theta\\left( t \\right)}{\\mathrm{d}t} \\right) c\\left( t \\right) = 0\n\\end{equation}\n\\]\n\n\n\n# conservation of momentum\n# x-direction\n∫ρu²cosθrdr = b^2*cos(θ)*(2u*cos(θ)*(C₁ + C₃*ρ) + 2u^2*(C₄ + C₅*ρ) \n + cos(θ)^2*(2 + C₂*ρ))\n\neqn3 = expand_derivatives( ∂( ∫ρu²cosθrdr ) ) ~ \n b*( 2E + Cd*abs(sin(θ)^3) )\n\n\\[ \\begin{equation}\n2 \\left( 2 \\left( u\\left( t \\right) \\right)^{2} \\left( 0.24542 + 0.18167 \\rho\\left( t \\right) \\right) + 2 u\\left( t \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) + \\cos^{2}\\left( \\theta\\left( t \\right) \\right) \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\right) \\frac{\\mathrm{d} b\\left( t \\right)}{\\mathrm{d}t} \\cos\\left( \\theta\\left( t \\right) \\right) b\\left( t \\right) - \\left( b\\left( t \\right) \\right)^{2} \\left( 2 \\left( u\\left( t \\right) \\right)^{2} \\left( 0.24542 + 0.18167 \\rho\\left( t \\right) \\right) + 2 u\\left( t \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) + \\cos^{2}\\left( \\theta\\left( t \\right) \\right) \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\right) \\sin\\left( \\theta\\left( t \\right) \\right) \\frac{\\mathrm{d} \\theta\\left( t \\right)}{\\mathrm{d}t} + \\left( b\\left( t \\right) \\right)^{2} \\left( 0.36335 \\left( u\\left( t \\right) \\right)^{2} \\frac{\\mathrm{d} \\rho\\left( t \\right)}{\\mathrm{d}t} + 4 u\\left( t \\right) \\left( 0.24542 + 0.18167 \\rho\\left( t \\right) \\right) \\frac{\\mathrm{d} u\\left( t \\right)}{\\mathrm{d}t} + 1.1136 u\\left( t \\right) \\frac{\\mathrm{d} \\rho\\left( t \\right)}{\\mathrm{d}t} \\cos\\left( \\theta\\left( t \\right) \\right) + 2 \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\frac{\\mathrm{d} u\\left( t \\right)}{\\mathrm{d}t} \\cos\\left( \\theta\\left( t \\right) \\right) + 1.0431 \\cos^{2}\\left( \\theta\\left( t \\right) \\right) \\frac{\\mathrm{d} \\rho\\left( t \\right)}{\\mathrm{d}t} - 2 u\\left( t \\right) \\sin\\left( \\theta\\left( t \\right) \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\frac{\\mathrm{d} \\theta\\left( t \\right)}{\\mathrm{d}t} - 2 \\sin\\left( \\theta\\left( t \\right) \\right) \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) \\frac{\\mathrm{d} \\theta\\left( t \\right)}{\\mathrm{d}t} \\right) \\cos\\left( \\theta\\left( t \\right) \\right) = \\left( 0.3 \\left|\\sin^{3}\\left( \\theta\\left( t \\right) \\right)\\right| + 2 \\left( 0.057 \\left|u\\left( t \\right)\\right| + 0.5 \\left|\\sin\\left( \\theta\\left( t \\right) \\right)\\right| \\cos\\left( \\theta\\left( t \\right) \\right) \\right) \\right) b\\left( t \\right)\n\\end{equation}\n\\]\n\n\n\n# z-direction\n∫ρu²sinθrdr = b^2*sin(θ)*(2u*cos(θ)*(C₁ + C₃*ρ) + 2u^2*(C₄ + C₅*ρ) \n + cos(θ)^2*(2 + C₂*ρ))\n\neqn4 = expand_derivatives( ∂( ∫ρu²sinθrdr ) ) ~ \n -C₂*b^2*ρ*(g*D/uₐ₀^2) + sign(θ)*Cd*b*sin(θ)^2*cos(θ)\n\n\\[ \\begin{equation}\n2 \\left( 2 \\left( u\\left( t \\right) \\right)^{2} \\left( 0.24542 + 0.18167 \\rho\\left( t \\right) \\right) + 2 u\\left( t \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) + \\cos^{2}\\left( \\theta\\left( t \\right) \\right) \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\right) \\sin\\left( \\theta\\left( t \\right) \\right) \\frac{\\mathrm{d} b\\left( t \\right)}{\\mathrm{d}t} b\\left( t \\right) + \\left( b\\left( t \\right) \\right)^{2} \\left( 2 \\left( u\\left( t \\right) \\right)^{2} \\left( 0.24542 + 0.18167 \\rho\\left( t \\right) \\right) + 2 u\\left( t \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) + \\cos^{2}\\left( \\theta\\left( t \\right) \\right) \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) \\frac{\\mathrm{d} \\theta\\left( t \\right)}{\\mathrm{d}t} + \\left( b\\left( t \\right) \\right)^{2} \\left( 0.36335 \\left( u\\left( t \\right) \\right)^{2} \\frac{\\mathrm{d} \\rho\\left( t \\right)}{\\mathrm{d}t} + 4 u\\left( t \\right) \\left( 0.24542 + 0.18167 \\rho\\left( t \\right) \\right) \\frac{\\mathrm{d} u\\left( t \\right)}{\\mathrm{d}t} + 1.1136 u\\left( t \\right) \\frac{\\mathrm{d} \\rho\\left( t \\right)}{\\mathrm{d}t} \\cos\\left( \\theta\\left( t \\right) \\right) + 2 \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\frac{\\mathrm{d} u\\left( t \\right)}{\\mathrm{d}t} \\cos\\left( \\theta\\left( t \\right) \\right) + 1.0431 \\cos^{2}\\left( \\theta\\left( t \\right) \\right) \\frac{\\mathrm{d} \\rho\\left( t \\right)}{\\mathrm{d}t} - 2 u\\left( t \\right) \\sin\\left( \\theta\\left( t \\right) \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\frac{\\mathrm{d} \\theta\\left( t \\right)}{\\mathrm{d}t} - 2 \\sin\\left( \\theta\\left( t \\right) \\right) \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) \\frac{\\mathrm{d} \\theta\\left( t \\right)}{\\mathrm{d}t} \\right) \\sin\\left( \\theta\\left( t \\right) \\right) = - 0.51149 \\left( b\\left( t \\right) \\right)^{2} \\rho\\left( t \\right) + 0.3 \\sin^{2}\\left( \\theta\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) b\\left( t \\right) sign\\left( \\theta\\left( t \\right) \\right)\n\\end{equation}\n\\]\n\n\n\n# energy balance\nρₐ_bar = 1 + dρₐdz*D*z/ρₐ₀\n\n∫ρucₚΔTrdr = b^2*(2cos(θ) + C₁*u - ρₐ_bar*( u*(C₁ + C₃*ρ) \n + cos(θ)*(2 + C₂*ρ) ))\n\neqn5 = expand_derivatives( ∂( ∫ρucₚΔTrdr ) ) ~ 2*b*(1 - ρₐ_bar)*E\n\n\\[ \\begin{equation}\n2 \\left( 0.86466 u\\left( t \\right) + 2 \\cos\\left( \\theta\\left( t \\right) \\right) - u\\left( t \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) - \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) \\right) \\frac{\\mathrm{d} b\\left( t \\right)}{\\mathrm{d}t} b\\left( t \\right) + \\left( b\\left( t \\right) \\right)^{2} \\left( 0.86466 \\frac{\\mathrm{d} u\\left( t \\right)}{\\mathrm{d}t} - 0.5568 u\\left( t \\right) \\frac{\\mathrm{d} \\rho\\left( t \\right)}{\\mathrm{d}t} - 2 \\sin\\left( \\theta\\left( t \\right) \\right) \\frac{\\mathrm{d} \\theta\\left( t \\right)}{\\mathrm{d}t} + \\left( -0.86466 - 0.5568 \\rho\\left( t \\right) \\right) \\frac{\\mathrm{d} u\\left( t \\right)}{\\mathrm{d}t} - 1.0431 \\frac{\\mathrm{d} \\rho\\left( t \\right)}{\\mathrm{d}t} \\cos\\left( \\theta\\left( t \\right) \\right) - \\sin\\left( \\theta\\left( t \\right) \\right) \\left( -2 - 1.0431 \\rho\\left( t \\right) \\right) \\frac{\\mathrm{d} \\theta\\left( t \\right)}{\\mathrm{d}t} \\right) = 0\n\\end{equation}\n\\]\n\n\n\n# The full system of equations\n\neqns = [ eqn1\n eqn2\n eqn3\n eqn4\n eqn5\n ∂(x) ~ cos(θ)\n ∂(z) ~ sin(θ) ]\n\nSymbolics.jl has done all the derivatives and set up all the equations, what remains is to build ODESystem and solve.\n\n@named sys = ODESystem(eqns, s)\nsys = structural_simplify(sys)\n\n\\[ \\begin{align}\n\\frac{\\mathrm{d} b\\left( t \\right)}{\\mathrm{d}t} &= \\mathtt{bˍt}\\left( t \\right) \\\\\n\\frac{\\mathrm{d} \\rho\\left( t \\right)}{\\mathrm{d}t} &= \\mathtt{{\\rho}ˍt}\\left( t \\right) \\\\\n\\frac{\\mathrm{d} \\theta\\left( t \\right)}{\\mathrm{d}t} &= \\mathtt{{\\theta}ˍt}\\left( t \\right) \\\\\n\\frac{\\mathrm{d} u\\left( t \\right)}{\\mathrm{d}t} &= \\mathtt{uˍt}\\left( t \\right) \\\\\n0 &= 2 \\left( 0.057 \\left|u\\left( t \\right)\\right| + 0.5 \\left|\\sin\\left( \\theta\\left( t \\right) \\right)\\right| \\cos\\left( \\theta\\left( t \\right) \\right) \\right) b\\left( t \\right) - 2 \\left( u\\left( t \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) + \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) \\right) b\\left( t \\right) \\mathtt{bˍt}\\left( t \\right) + \\left( b\\left( t \\right) \\right)^{2} \\left( - 0.5568 u\\left( t \\right) \\mathtt{{\\rho}ˍt}\\left( t \\right) - \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\mathtt{uˍt}\\left( t \\right) - 1.0431 \\mathtt{{\\rho}ˍt}\\left( t \\right) \\cos\\left( \\theta\\left( t \\right) \\right) + \\sin\\left( \\theta\\left( t \\right) \\right) \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\mathtt{{\\theta}ˍt}\\left( t \\right) \\right) \\\\\n0 &= - 2 \\left( 0.86466 u\\left( t \\right) + 2 \\cos\\left( \\theta\\left( t \\right) \\right) - u\\left( t \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) + \\left( -2 - 1.0431 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) \\right) b\\left( t \\right) \\mathtt{bˍt}\\left( t \\right) + \\left( b\\left( t \\right) \\right)^{2} \\left( - 0.86466 \\mathtt{uˍt}\\left( t \\right) + 0.5568 u\\left( t \\right) \\mathtt{{\\rho}ˍt}\\left( t \\right) + 2 \\sin\\left( \\theta\\left( t \\right) \\right) \\mathtt{{\\theta}ˍt}\\left( t \\right) - \\left( -0.86466 - 0.5568 \\rho\\left( t \\right) \\right) \\mathtt{uˍt}\\left( t \\right) + 1.0431 \\mathtt{{\\rho}ˍt}\\left( t \\right) \\cos\\left( \\theta\\left( t \\right) \\right) + \\sin\\left( \\theta\\left( t \\right) \\right) \\left( -2 - 1.0431 \\rho\\left( t \\right) \\right) \\mathtt{{\\theta}ˍt}\\left( t \\right) \\right) \\\\\n0 &= \\left( 0.3 \\left|\\sin^{3}\\left( \\theta\\left( t \\right) \\right)\\right| + 2 \\left( 0.057 \\left|u\\left( t \\right)\\right| + 0.5 \\left|\\sin\\left( \\theta\\left( t \\right) \\right)\\right| \\cos\\left( \\theta\\left( t \\right) \\right) \\right) \\right) b\\left( t \\right) - 2 \\left( 2 \\left( u\\left( t \\right) \\right)^{2} \\left( 0.24542 + 0.18167 \\rho\\left( t \\right) \\right) + 2 u\\left( t \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) + \\cos^{2}\\left( \\theta\\left( t \\right) \\right) \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) b\\left( t \\right) \\mathtt{bˍt}\\left( t \\right) + \\left( b\\left( t \\right) \\right)^{2} \\left( 2 \\left( u\\left( t \\right) \\right)^{2} \\left( 0.24542 + 0.18167 \\rho\\left( t \\right) \\right) + 2 u\\left( t \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) + \\cos^{2}\\left( \\theta\\left( t \\right) \\right) \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\right) \\sin\\left( \\theta\\left( t \\right) \\right) \\mathtt{{\\theta}ˍt}\\left( t \\right) + \\left( b\\left( t \\right) \\right)^{2} \\left( - 0.36335 \\left( u\\left( t \\right) \\right)^{2} \\mathtt{{\\rho}ˍt}\\left( t \\right) - 4 u\\left( t \\right) \\left( 0.24542 + 0.18167 \\rho\\left( t \\right) \\right) \\mathtt{uˍt}\\left( t \\right) - 1.1136 u\\left( t \\right) \\mathtt{{\\rho}ˍt}\\left( t \\right) \\cos\\left( \\theta\\left( t \\right) \\right) - 2 \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) \\mathtt{uˍt}\\left( t \\right) - 1.0431 \\cos^{2}\\left( \\theta\\left( t \\right) \\right) \\mathtt{{\\rho}ˍt}\\left( t \\right) + 2 u\\left( t \\right) \\sin\\left( \\theta\\left( t \\right) \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\mathtt{{\\theta}ˍt}\\left( t \\right) + 2 \\sin\\left( \\theta\\left( t \\right) \\right) \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) \\mathtt{{\\theta}ˍt}\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) \\\\\n0 &= - 0.51149 \\left( b\\left( t \\right) \\right)^{2} \\rho\\left( t \\right) + 0.3 \\sin^{2}\\left( \\theta\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) b\\left( t \\right) sign\\left( \\theta\\left( t \\right) \\right) - 2 \\left( 2 \\left( u\\left( t \\right) \\right)^{2} \\left( 0.24542 + 0.18167 \\rho\\left( t \\right) \\right) + 2 u\\left( t \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) + \\cos^{2}\\left( \\theta\\left( t \\right) \\right) \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\right) \\sin\\left( \\theta\\left( t \\right) \\right) b\\left( t \\right) \\mathtt{bˍt}\\left( t \\right) + \\left( b\\left( t \\right) \\right)^{2} \\left( - 2 \\left( u\\left( t \\right) \\right)^{2} \\left( 0.24542 + 0.18167 \\rho\\left( t \\right) \\right) - 2 u\\left( t \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) + \\cos^{2}\\left( \\theta\\left( t \\right) \\right) \\left( -2 - 1.0431 \\rho\\left( t \\right) \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) \\mathtt{{\\theta}ˍt}\\left( t \\right) - \\left( b\\left( t \\right) \\right)^{2} \\left( 0.36335 \\left( u\\left( t \\right) \\right)^{2} \\mathtt{{\\rho}ˍt}\\left( t \\right) + 4 u\\left( t \\right) \\left( 0.24542 + 0.18167 \\rho\\left( t \\right) \\right) \\mathtt{uˍt}\\left( t \\right) + 1.1136 u\\left( t \\right) \\mathtt{{\\rho}ˍt}\\left( t \\right) \\cos\\left( \\theta\\left( t \\right) \\right) + 2 \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) \\mathtt{uˍt}\\left( t \\right) + 1.0431 \\cos^{2}\\left( \\theta\\left( t \\right) \\right) \\mathtt{{\\rho}ˍt}\\left( t \\right) - 2 u\\left( t \\right) \\sin\\left( \\theta\\left( t \\right) \\right) \\left( 0.86466 + 0.5568 \\rho\\left( t \\right) \\right) \\mathtt{{\\theta}ˍt}\\left( t \\right) - 2 \\sin\\left( \\theta\\left( t \\right) \\right) \\left( 2 + 1.0431 \\rho\\left( t \\right) \\right) \\cos\\left( \\theta\\left( t \\right) \\right) \\mathtt{{\\theta}ˍt}\\left( t \\right) \\right) \\sin\\left( \\theta\\left( t \\right) \\right) \\\\\n\\frac{\\mathrm{d} c\\left( t \\right)}{\\mathrm{d}t} &= \\mathtt{cˍt}\\left( t \\right) \\\\\n0 &= - 2 \\left( 0.5568 u\\left( t \\right) + 1.0431 \\cos\\left( \\theta\\left( t \\right) \\right) \\right) c\\left( t \\right) b\\left( t \\right) \\mathtt{bˍt}\\left( t \\right) - \\left( b\\left( t \\right) \\right)^{2} \\left( 0.5568 u\\left( t \\right) + 1.0431 \\cos\\left( \\theta\\left( t \\right) \\right) \\right) \\mathtt{cˍt}\\left( t \\right) - \\left( b\\left( t \\right) \\right)^{2} \\left( 0.5568 \\mathtt{uˍt}\\left( t \\right) - 1.0431 \\sin\\left( \\theta\\left( t \\right) \\right) \\mathtt{{\\theta}ˍt}\\left( t \\right) \\right) c\\left( t \\right) \\\\\n\\frac{\\mathrm{d} z\\left( t \\right)}{\\mathrm{d}t} &= \\sin\\left( \\theta\\left( t \\right) \\right) \\\\\n\\frac{\\mathrm{d} x\\left( t \\right)}{\\mathrm{d}t} &= \\cos\\left( \\theta\\left( t \\right) \\right)\n\\end{align}\n\\]\n\n\nIn this case there are no model parameters as I inserted the equations for the dimensionless groups directly into the model.\n\nmtk_params = ()\n\nThe 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.\n\ninitial_vals = [ c => state0[1],\n b => state0[2],\n u => state0[3],\n θ => state0[4],\n ρ => state0[5],\n x => state0[6],\n z => state0[7] ]\n\n\nmtk_prob = ODEProblem(sys, initial_vals, span)\n\n\nmtk_sol = solve(mtk_prob, Rodas5P())\n\nmtk_sol.retcode\n\nReturnCode.Success = 1\n\n\n\n\n\n\n\n\n\nFigure 6: The plume height as a function of downwind distance, solutions using the lazy approach with the Tsit5 solver and ModelingToolkit using Rodas5P.\n\n\n\n\nIn 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.\n\n\nDead Ends and Failures\nAnother 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\n\nusing SciMLOperators\n\n\nM = MatrixOperator(zeros(7,7); update_func! = ooms_matrix!)\n\n\nmassprob = ODEProblem(ODEFunction(ooms_rhs!, mass_matrix=M), state0, span, params)\n\n\nmass_sol = solve(massprob,Rodas5P(); initializealg=BrownFullBasicInit())\n\nmass_sol.retcode\n\n\n┌ 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).\n\n└ @ SciMLBase ~/.julia/packages/SciMLBase/rvXrA/src/integrator_interface.jl:623\n\n\n\n\nReturnCode.Unstable = 7\n\n\nI 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.\nIf 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." + }, + { + "objectID": "posts/ooms_plume_model/index.html#the-problem-of-concentration", + "href": "posts/ooms_plume_model/index.html#the-problem-of-concentration", + "title": "The Ooms Plume Model", + "section": "The Problem of Concentration", + "text": "The Problem of Concentration\nThe 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.\n\nCalculating the isopleths in the x-z plane\nCalculating the isopleths at an arbitrary elevation \\(z=a\\)\nCalculating the concentration at an arbitrary point \\(x,y,z\\)\n\nThese 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.\n\nIsopleths in the x-z Plane\nThe easiest problem to solve is the isopleths in the plane \\(y=0\\). Suppose we want to calculate the isopleth for some concentration \\(c = c_l\\). Recalling the concentration profile:\n\\[ \\bar{c}_l = \\bar{c}_o \\exp \\left( - \\left( \\frac{r}{\\lambda b} \\right)^2 \\right) \\]\nWhere \\(\\bar{c}_o\\) is the center line concentration at that point along the plume axis. We first solve for \\(r\\), the distance from the plume axis:\n\\[ r = b \\lambda \\sqrt{ \\log \\left( \\bar{c}_o \\over \\bar{c}_l \\right) } \\]\nConverting from cylindrical coordinates to Cartesian coordinates, \\(x^{\\prime}, y^{\\prime}, z^{\\prime}\\), aligned such that \\(x^{\\prime}\\) is aligned with the plume axis, the radius is\n\\[ r^2 = \\left(y^{\\prime}\\right)^2 + \\left(z^{\\prime}\\right)^2 \\]\nSince we are confined to the plane \\(y^{\\prime} = 0\\), we find \\(z^{\\prime} = \\pm r\\). Then we rotate the axis to align with the problem coordinate system and translate the origin to the problem origin.\n\\[ x = x_o \\mp r \\sin \\theta \\] \\[ z = z_o \\pm r \\cos \\theta \\]\nWhere \\(x_o\\) and \\(z_o\\) 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.\n\n\n\n\n\n\nNote\n\n\n\nCasal7 provides an alternative form of these isopleths:\n\\[ z = z_o \\pm \\sqrt{ { {\\lambda^2 b^2} \\over { 1 + \\tan^2 \\theta }} \\log \\left(\\frac{c_o}{c_l} \\right) } \\]\nand\n\\[ {{z - z_o} \\over {x - x_o}} = -\\cot \\theta \\]\nThese are actually equivalent, using the identity \\(\\sec^2 = 1 + \\tan^2 \\theta\\) and the definition \\(\\sec \\theta = \\frac{1}{\\cos \\theta}\\), the first equation can be written as:\n\\[ z = z_o \\pm \\sqrt{ { {\\lambda^2 b^2} \\over { \\sec^2 \\theta }} \\log \\left(\\frac{c_o}{c_l} \\right) } = z_o \\pm \\sqrt{ \\lambda^2 b^2 \\cos^2 \\theta \\log \\left(\\frac{c_o}{c_l} \\right) } \\] \\[ = z_o \\pm \\lambda b \\sqrt{ \\log \\left(\\frac{c_o}{c_l} \\right) } \\cos \\theta = z_o \\pm r \\cos \\theta\\]\nThe second equation can be re-written to solve for x:\n\\[ {{z - z_o} \\over {x - x_o}} = -\\cot \\theta \\] \\[ {z - z_o} = -\\left(x - x_o\\right)\\cot \\theta \\] \\[ \\pm r \\cos \\theta = -\\left(x - x_o\\right)\\cot \\theta \\] \\[ \\pm r \\cos \\theta = - \\left(x - x_o\\right) \\frac{\\cos \\theta}{\\sin \\theta}\\] \\[ x = x_o \\mp r \\sin \\theta \\]\n\n\n7 Casal, “Atmospheric Dispersion of Toxic or Flammable Clouds,” 306.\nfunction upper_isopleth(solution, s, c)\n cₒ, bₒ, uₒ, θₒ, ρₒ, xₒ, zₒ = solution(s)\n\n if c ≈ cₒ\n return Point(xₒ,zₒ)\n elseif c > cₒ\n return nothing\n else\n r = bₒ * √(λ²*log(cₒ/c))\n x = xₒ - r*sin(θₒ)\n z = zₒ + r*cos(θₒ)\n return Point(x,z)\n end\nend\n\n\nfunction lower_isopleth(solution, s, c)\n cₒ, bₒ, uₒ, θₒ, ρₒ, xₒ, zₒ = solution(s)\n\n if c ≈ cₒ\n return Point(xₒ,zₒ)\n elseif c > cₒ\n return nothing\n else\n r = bₒ * √(λ²*log(cₒ/c))\n x = xₒ + r*sin(θₒ)\n z = zₒ - r*cos(θₒ)\n return Point(x,z)\n end\nend\n\nFor an example, suppose we want the isopleth for \\(c/c_0 = 2\\%\\)\n\ncₗ = 0.02 # c/c₀ = 2%\n\nFirst, 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.\n\nusing Roots: find_zero\n\n\ni_end = findfirst(sol[1,:] .< cₗ )\n\n20\n\n\n\ns_end = find_zero( (s) -> sol(s, idxs = 1) - cₗ, sol.t[i_end])\n\n46.23790952011145\n\n\nThen 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%.\n\nupper_points = [ upper_isopleth(sol, s, cₗ) for s in LinRange(0.0, s_end, 100) \n if !isnothing(upper_isopleth(sol, s, cₗ))];\nlower_points = [ lower_isopleth(sol, s, cₗ) for s in LinRange(0.0, s_end, 100) \n if !isnothing(lower_isopleth(sol, s, cₗ))];\n\n\n\n\n\n\n\n\nFigure 7: Plume vertical isopleths, 2%(vol)\n\n\n\n\n\n\nIsopleths at z=a\nA 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.\n\nfunction cross_isopleth(solution, s, c, a)\n cₒ, bₒ, uₒ, θₒ, ρₒ, xₒ, zₒ = solution(s)\n\n if c ≈ cₒ\n return Point(xₒ,0.0)\n elseif c > cₒ\n # isopleth doesn't exist here\n return nothing\n else\n # find the x coordinate\n xₒ′ = xₒ*cos(θₒ) + zₒ*sin(θₒ)\n x = (xₒ′ - a*sin(θₒ))*sec(θₒ)\n \n # find the y coordinate\n r² = bₒ^2 * λ²*log(cₒ/c)\n z′² = ((a - zₒ)*cos(θₒ) - (x - xₒ)*sin(θₒ))^2\n\n if z′² > r²\n # the isosurface doesn't intersect z=a\n return nothing\n else\n y′ = √( r² - z′²)\n y = y′\n return Point(x,y)\n end\n end\nend\n\nPicking an arbitraty height of 20 stack diameters in elevation.\n\na = 20 # 20 stack diameters\n\nWe 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.\n\n# the start of the isopleth\ns_start = find_zero( (s) -> upper_isopleth(sol, s, cₗ)[2] - a, 14)\n\n14.87383455152286\n\n\n\n# the end of the isopleth\ns_end = find_zero( (s) -> lower_isopleth(sol, s, cₗ)[2] - a, 33)\n\n33.55677883331305\n\n\n\ncross_points = [ cross_isopleth(sol, s, cₗ, a) for s in LinRange(s_start+1e-3, s_end, 100) \n if !isnothing(cross_isopleth(sol, s, cₗ, a))]\n\n\nflipped_points = [ Point( pt[1], -1*pt[2] ) for pt in cross_points ]\n\n\n\n\n\n\n\n\nFigure 8: Plume crosswind isopleths at z/D = 20, 2%(vol)\n\n\n\n\n\n\nThe Concentration at an Arbitrary Point\nCalculating 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.\nTo 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.\n\nfunction find_centerline(solution,x,y,z)\n function perp_test(s)\n θₒ, _, xₒ, zₒ = solution(s, idxs=4:7)\n x′ = x*cos(θₒ) + z*sin(θₒ)\n xₒ′ = xₒ*cos(θₒ) + zₒ*sin(θₒ)\n return x′ - xₒ′\n end\n\n # find initial guess\n i0 = argmin( [ abs(perp_test(s)) for s in sol.t ] )\n s0 = sol.t[i0]\n\n # find the zero point\n return find_zero(perp_test, s0)\nend\n\nThe 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.\n\nfunction concentration(solution,x,y,z)\n # get the point on the centerline that governs this point\n sₒ = find_centerline(solution,x,y,z)\n cₒ, bₒ, uₒ, θₒ, ρₒ, xₒ, zₒ = sol(sₒ)\n\n # rotate the coordinate system to the plume axis\n y′ = y\n z′ = (z - zₒ)*cos(θₒ) - (x - xₒ)*sin(θₒ)\n r² = (y′)^2 + (z′)^2\n\n # calculate concentration\n c = cₒ*exp(-r²/(bₒ^2*λ²))\n\n return c\nend\n\nHow 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%).\n\nupper_concentrations = [ concentration(sol, pt[1], 0, pt[2]) for pt in upper_points ]\nlower_concentrations = [ concentration(sol, pt[1], 0, pt[2]) for pt in lower_points ]\ncross_concentrations = [ concentration(sol, pt[1], pt[2], a) for pt in cross_points ]\n\n\n\n\n\n\n\n\nFigure 9: A scatterplot showing how well the concentration function recovered the concentration at the points on the isopleths\n\n\n\n\nIndeed 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." + }, + { + "objectID": "posts/ooms_plume_model/index.html#capturing-dense-gas-behaviour", + "href": "posts/ooms_plume_model/index.html#capturing-dense-gas-behaviour", + "title": "The Ooms Plume Model", + "section": "Capturing Dense Gas Behaviour", + "text": "Capturing Dense Gas Behaviour\nThe 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 \\(\\bar{z}_0 = \\frac{h+\\delta}{D}\\) 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.\n\nBouncing Plume with a Standard Callback\n\nground_check(state, s, i) = state[7] # z\n\n\nfunction reflect_plume!(integrator)\n # bounce off the ground\n integrator.u[4] = abs(integrator.u[4]) # θ\n integrator.u[7] = 0 # z\nend\n\n\nground_cb = ContinuousCallback(ground_check, reflect_plume!)\n\nThis 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.\n\ndense_state0 = [ 1.0 ,# c\n 1/(2√(2)) ,# b\n u₀/uₐ₀ ,# u\n θ₀ ,# θ\n 10.0 ,# ρ\n 0.0 ,# x\n h/D ]# z\n\n\ndense_prob = ODEProblem(ode_rhs!, dense_state0, span.*2, params)\n\n\ndense_sol = solve(dense_prob, Tsit5(); callback=ground_cb)\n\ndense_sol.retcode\n\nReturnCode.Success = 1\n\n\n\n\nBouncing Plume with ModelingToolkit\nModelingToolkit implements callbacks a little bit differently, as symbolic equations.\n\nground = [ z ~ 0 ]\nreflect = [ θ ~ -Pre(θ) ]\n\nWhich are then added to the ODESystem\n\n@named dense_sys = ODESystem(eqns, s; continuous_events= ground => reflect)\ndense_sys = structural_simplify(dense_sys)\n\n\ndense_vals = [ c => dense_state0[1],\n b => dense_state0[2],\n u => dense_state0[3],\n θ => dense_state0[4],\n ρ => dense_state0[5],\n x => dense_state0[6],\n z => dense_state0[7] ]\n\n\ndense_prob_mtk = ODEProblem(dense_sys, dense_vals, span.*2)\n\n\ndense_sol_mtk = solve(dense_prob_mtk, Rodas5P())\n\ndense_sol_mtk.retcode\n\nReturnCode.Success = 1\n\n\n\n\n\n\n\n\n\nFigure 10: The plume height as a function of downwind distance, vent gas eleven times denser than ambient air.\n\n\n\n\nI 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, \\(z - \\sqrt{2}b \\sin \\theta = 0\\), 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." + }, + { + "objectID": "posts/ooms_plume_model/index.html#validating-the-model", + "href": "posts/ooms_plume_model/index.html#validating-the-model", + "title": "The Ooms Plume Model", + "section": "Validating the Model", + "text": "Validating the Model\nIt 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.\n8 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:\n\nThe initial plume dimension \\(\\bar{b}_{0}\\)\nThe length of the zone of flow establishment δ\n\nThe 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 \\(\\bar{b}_{0} = \\frac{1}{2\\sqrt{2}}\\), 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.\nPutting 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.\n\n\n\n\n\n\nWarningUpdate\n\n\n\nI 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.\n\n\n9 Fan, “Turbulent Buoyant Jets into Stratified or Flowing Ambient Fluids”.\n# x/D z/D\nlfn_data = [ 16.628 19.953;\n 43.054 29.86;\n 46.366 32.233;\n 61.684 36.698;\n 84.591 42.558;\n 109.085 48.977]\n\n\nlfn_prms = (M = zeros(7,7),\n f = zeros(7),\n rhoa_bar = (z) -> 1.0,\n g_bar = (z) -> 4.278,\n uprime_bar = (b,z) -> 0.0)\n\n\n# initial values\nlfn_state0 = [ 1.0 ,# c\n 1/(2√(2)) ,# b\n 8.0 ,# u\n π/2 ,# θ\n -0.148 ,# ρ\n 0.0 ,# x\n 6.5 ]# z\n\n\nlfn_prob = ODEProblem(ode_rhs!, lfn_state0, (0.0,150.0), lfn_prms)\n\n\nlfn_sol = solve(lfn_prob, Tsit5())\n\n\n\n\n\n\n\n\nFigure 11: A recreation of figure 3 from Ooms.\n\n\n\n\nThis 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.\nThat 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." + }, + { + "objectID": "posts/ooms_plume_model/index.html#future-opportunities", + "href": "posts/ooms_plume_model/index.html#future-opportunities", + "title": "The Ooms Plume Model", + "section": "Future Opportunities", + "text": "Future Opportunities\nI 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.\nAnother 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." + }, + { + "objectID": "posts/ooms_plume_model/index.html#references", + "href": "posts/ooms_plume_model/index.html#references", + "title": "The Ooms Plume Model", + "section": "References", + "text": "References\n\n\nCasal, Joaquim. “Atmospheric Dispersion of Toxic or Flammable Clouds.” Amsterdam: Elsevier, 2018. https://app.knovel.com/hotlink/khtml/id:kt0125Q791/evaluation-effects-consequences/atmospheric-dispersion.\n\n\nFan, Loh-Nien. “Turbulent Buoyant Jets into Stratified or Flowing Ambient Fluids.” PhD thesis, California Institute of Technology, 1967. https://doi.org/10.7907/C69V-BE23.\n\n\nHavens, Jerry, and Thomas Spicer. “A Dispersion Model for Elevated Dense Gas Jet Chemical Releases.” Office of Air Quality Planning and Standards, US EPA, 1988. https://nepis.epa.gov/Exe/ZyPURL.cgi?DocKey=2000NACQ.txt.\n\n\nKeffer, J. F., and W. D. Baines. “The Round Turbulent Jet in a Cross-Wind.” Journal of Fluid Mechanics 15, no. 4 (1963): 481–96. https://doi.org/10.1017/S0022112063000409.\n\n\nOoms, Gijsbert. “A New Method for the Calculation of the Plume Path of Gases Emitted by a Stack.” Atmospheric Environment (1967) 6, no. 12 (1972): 899–909. https://doi.org/10.1016/0004-6981(72)90098-4." + }, + { + "objectID": "posts/hydrogen_compression/index.html", + "href": "posts/hydrogen_compression/index.html", + "title": "Delivering Hydrogen Fuel Gas", + "section": "", + "text": "Previously I evaluated hydrogen as a fuel gas from the perspective of an end user – someone who purchases utility natural gas, at pressure, for use in combustion devices like boilers and heaters. From that perspective, hydrogen is not an unreasonable conversion, with material compatibility being the primary concern. In this post I’m going to look at it from the perspective of the gas utility.\nFrom 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?\nThe 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." + }, + { + "objectID": "posts/hydrogen_compression/index.html#the-situation", + "href": "posts/hydrogen_compression/index.html#the-situation", + "title": "Delivering Hydrogen Fuel Gas", + "section": "The Situation", + "text": "The Situation\nWe 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.\n1 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?\nThe standard equation for determining the work, \\(\\dot{w}_{g}\\), to compress a mass flowrate \\(\\dot{m}\\) of gas from a pressure of \\(p_1\\) to \\(p_2\\) is234\n2 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\\[\n\\dot{w}_{g} = \\dot{m} \\int_{p_1}^{p_2} v dp\n\\]\nThis is related to the isentropic work through the isentropic efficiency, \\(\\varepsilon_{i}\\)\n\\[\n\\varepsilon_{i} \\dot{w}_{g} = \\dot{m} \\int_{p_1}^{p_2} v dp \\vert_{isentropic}\n\\]\nWhere the integral of the specific volume \\(v\\) 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.\nI 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, \\(r\\), is then\n\\[\nr = { {\\dot{w}_{g}}_{H2} \\over {\\dot{w}_{g}}_{NG} } = { \\left( \\varepsilon_{i} \\dot{w}_{g} \\right)_{H2} \\over \\left( \\varepsilon_{i} \\dot{w}_{g} \\right)_{NG} } = { \\left( \\dot{m} \\int_{p_1}^{p_2} v dp \\right)_{H2} \\over \\left( \\dot{m} \\int_{p_1}^{p_2} v dp \\right)_{NG} }\n\\]\nThe integrals, though, do not have to be tackled directly, recalling the differential for (specific) enthalpy\n\\[\ndh = v dp + T ds\n\\]\nIntegrating from state 1 to state 2 along an isentropic path (i.e. \\(ds = 0\\)) gives:\n\\[\n\\int_{h_1}^{h_2} dh = h_2 - h_1 = \\int_{p_1}^{p_2} v dp\n\\]\nThus the ratio we’re looking for is given by:\n\\[\nr = { \\dot{m}_{H2} \\over \\dot{m}_{NG} } { {\\Delta h}_{H2} \\over {\\Delta h}_{NG} }\n\\]\nIt 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 \\(p_2\\) and a temperature \\(T_2\\) defined by \\(s_1 = s_2\\) and the entropy of hydrogen and natural gas are, in principle, different.\nCompressors 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.\nSuppose 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\n\n\n\\[\\begin{align}\n\\eta_t &= 100\\;\\text{ }(\\text{total compression ratio})\n\\\\[10pt]\nn &= 3\\;\\text{ }(\\text{number of stages})\n\\\\[10pt]\n\\eta &= \\eta_t^{\\frac{1}{n}}\n\\\\[10pt]\n&= 100^{\\frac{1}{3}}\n\\\\[10pt]\n&= 4.64\n\\end{align}\\]\n\n\n\n\n\n\n\n\nFigure 1: A three stage compressor with interstage cooling.\n\n\n\n\n\nSuppose, for simplicity, the interstage coolers bring the gas temperature down to 15C:\n\nthe first stage compresses the gas from 1 bar to 4.6 bar\nthe second stage compresses the gas from 4.6 bar to 21.5 bar\nthe last stage compresses the gas from 21.5 bar to 100.0 bar\n\nWith the inlet gas to each stage being at 15C and exiting at some temperature which is determined from the energy balance and isentropic efficiency.\n\n\nThe total work required to compress the gas is then\n\\[\nr_{T} = { \\dot{m}_{H2} \\over \\dot{m}_{NG} } { \\left( {\\Delta h}_{1 \\to 2} + {\\Delta h}_{3 \\to 4} + {\\Delta h}_{5 \\to 6} \\right)_{H2} \\over \\left( {\\Delta h}_{1 \\to 2} + {\\Delta h}_{3 \\to 4} + {\\Delta h}_{5 \\to 6} \\right)_{NG} }\n\\]" + }, + { + "objectID": "posts/hydrogen_compression/index.html#the-ideal-gas-case", + "href": "posts/hydrogen_compression/index.html#the-ideal-gas-case", + "title": "Delivering Hydrogen Fuel Gas", + "section": "The Ideal Gas Case", + "text": "The Ideal Gas Case\nA 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\n5 for chemical engineers at least6 Gmehling et al., Chemical Thermodynamics for Process Simulation, 596.\\[\ns_0 + \\int_{T_0}^{T_1} {c_p \\over T} dT - R \\log { p_1 \\over p_0 } = s_0 + \\int_{T_0}^{T_2} {c_p \\over T} dT - R \\log { p_2 \\over p_0 }\n\\]\nAssuming \\(c_p\\) is a constant this simplifies to\n\\[\nc_p \\log{ T_2 \\over T_1 } = R \\log{p_2 \\over p_1}\n\\]\nFor an ideal gas \\(c_p - c_v = R\\), giving\n\\[\n\\log{T_2 \\over T_1} = { {c_p - c_v} \\over c_p} \\log{ p_2 \\over p_1}\n\\]\n\\[\n{T_2 \\over T_1} = \\left( p_2 \\over p_1 \\right)^{1 - \\frac{1}{k}}\n\\]\nA well known result. The enthalpy of an ideal gas with constant \\(c_p\\) is just \\(c_p T\\), so we have:\n\\[\n\\Delta h = c_p \\Delta T = c_p \\left( T_2 - T_1 \\right)\n\\]\n\\[\n= c_p T_1 \\left( {T_2 \\over T_1} -1 \\right)\n\\]\n\\[\n= c_p T_1 \\left( \\left( p_2 \\over p_1 \\right)^{1 - \\frac{1}{k}} - 1 \\right)\n\\]\nFrom the ideal gas law, \\(T = \\frac{pv}{R}\\)\n\\[\n\\Delta h = \\frac{c_p}{R} p_1 v_1 \\left( \\left( p_2 \\over p_1 \\right)^{1 - \\frac{1}{k}} - 1 \\right)\n\\]\n\\[\n= {k \\over {k-1}} p_1 v_1 \\left( \\left( p_2 \\over p_1 \\right)^{\\frac{k-1}{k}} - 1 \\right)\n\\]\nWhich allows us to write the work ratio, for a single stage, compressing an ideal gas with constant heat capacity, as:\n\\[\nr = { \\dot{m}_{H2} \\over \\dot{m}_{NG} } { {v_1}_{H2} \\over {v_1}_{NG} } {C_{NG} \\over C_{H2}} { { \\eta^{C_{H2}} -1 } \\over { \\eta^{C_{NG}} - 1} }\n\\]\nwhere \\(C = \\frac{k-1}{k}\\). From the ideal gas law the ratio of specific volumes is just the ratio of molar weights \\[\n{ {v_1}_{H2} \\over {v_1}_{NG} } = { MW_{NG} \\over MW_{H2} }\n\\]\n\\[\nr = { \\dot{m}_{H2} \\over \\dot{m}_{NG} } { MW_{H2} \\over MW_{NG} } {C_{NG} \\over C_{H2}} { { \\eta^{C_{H2}} -1 } \\over { \\eta^{C_{NG}} - 1} }\n\\]\nand, since the inlet streams are all at the same temperature\n\\[\nr_T = r\n\\]\nFurthermore, if we assume \\(k_{NG} \\approx k_{H2}\\) then\n\\[\nr = { \\dot{m}_{H2} \\over \\dot{m}_{NG} } { MW_{NG} \\over MW_{H2} }\n\\]\nThis is where I’ve encountered what I consider a serious error: assuming an equal mass flowrate of the two fuels. Making this assumption gives\n\\[\nr = { MW_{NG} \\over MW_{H2} }\n\\]\n\nusing Unitful, Clapeyron\n\n\nideal_hydrogen = ReidIdeal([\"hydrogen\"])\nideal_natural_gas = ReidIdeal([\"methane\"])\n\n\n\n\\[\\begin{align}\nMW_{NG} &= 16.04\\,\\mathrm{kg}\\,\\mathrm{kmol}^{-1}\n\\\\[10pt]\nMW_{H2} &= 2.02\\,\\mathrm{kg}\\,\\mathrm{kmol}^{-1}\n\\\\[10pt]\nr &= \\frac{MW_{NG}}{MW_{H2}}\n\\\\[10pt]\n&= \\frac{16.04\\,\\mathrm{kg}\\,\\mathrm{kmol}^{-1}}{2.02\\,\\mathrm{kg}\\,\\mathrm{kmol}^{-1}}\n\\\\[10pt]\n&= 7.94\n\\end{align}\\]\n\n\n\n\nThis 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.\n\n\nI 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\n\\[\nhv_{H2} \\dot{m}_{H2} = hv_{NG} \\dot{m}_{NG}\n\\]\n\\[\n{ \\dot{m}_{H2} \\over \\dot{m}_{NG} } = { hv_{NG} \\over hv_{H2} }\n\\]\nand so\n\\[\nr = { hv_{NG} \\over hv_{H2} } { MW_{NG} \\over MW_{H2} }\n\\]\nwhere \\(hv\\) is the specific higher heating value (or gross heating value)\n\n\n\\[\\begin{align}\nhv_{NG} &= 55.58\\,\\mathrm{MJ}\\,\\mathrm{kg}^{-1}\\;\\text{ }(\\text{GPSA Handbook})\n\\\\[10pt]\nhv_{H2} &= 141.95\\,\\mathrm{MJ}\\,\\mathrm{kg}^{-1}\\;\\text{ }(\\text{GPSA Handbook})\n\\\\[10pt]\nr &= \\frac{hv_{NG}}{hv_{H2}} \\cdot \\frac{MW_{NG}}{MW_{H2}}\n\\\\[10pt]\n&= \\frac{55.58\\,\\mathrm{MJ}\\,\\mathrm{kg}^{-1}}{141.95\\,\\mathrm{MJ}\\,\\mathrm{kg}^{-1}} \\cdot \\frac{16.04\\,\\mathrm{kg}\\,\\mathrm{kmol}^{-1}}{2.02\\,\\mathrm{kg}\\,\\mathrm{kmol}^{-1}}\n\\\\[10pt]\n&= 3.11\n\\end{align}\\]\n\n\n\n\nThis gives a work ratio of 3.1, quite a bit smaller of an estimate.\n\n\nBut the assumption that \\(k_{H2} \\approx k_{NG}\\) is perhaps not a good one, so we should explore how compression effects differ even as ideal gases.\n\nk(gas) = isobaric_heat_capacity(gas, 1u\"bar\", 288.15u\"K\") / \n isochoric_heat_capacity(gas, 1u\"bar\", 288.15u\"K\")\n\n\n\n\\[\\begin{align}\nk_{H2} &= 1.41\\;\\text{ }(\\text{Clapeyron.jl, at 1bar and 15C})\n\\\\[10pt]\nC_{H2} &= 1 - \\frac{1}{k_{H2}}\n\\\\[10pt]\n&= 1 - \\frac{1}{1.41}\n\\\\[10pt]\n&= 0.29\n\\\\[10pt]\nk_{NG} &= 1.31\\;\\text{ }(\\text{Clapeyron.jl, at 1bar and 15C})\n\\\\[10pt]\nC_{NG} &= 1 - \\frac{1}{k_{NG}}\n\\\\[10pt]\n&= 1 - \\frac{1}{1.31}\n\\\\[10pt]\n&= 0.23\n\\\\[10pt]\nr_{ig} &= \\frac{hv_{NG}}{hv_{H2}} \\cdot \\frac{MW_{NG}}{MW_{H2}} \\cdot \\frac{C_{NG} \\cdot \\left( \\eta^{C_{H2}} - 1 \\right)}{C_{H2} \\cdot \\left( \\eta^{C_{NG}} - 1 \\right)}\n\\\\[10pt]\n&= \\frac{55.58\\,\\mathrm{MJ}\\,\\mathrm{kg}^{-1}}{141.95\\,\\mathrm{MJ}\\,\\mathrm{kg}^{-1}} \\cdot \\frac{16.04\\,\\mathrm{kg}\\,\\mathrm{kmol}^{-1}}{2.02\\,\\mathrm{kg}\\,\\mathrm{kmol}^{-1}} \\cdot \\frac{0.23 \\cdot \\left( 4.64^{0.29} - 1 \\right)}{0.29 \\cdot \\left( 4.64^{0.23} - 1 \\right)}\n\\\\[10pt]\n&= 3.25\n\\end{align}\\]\n\n\n\n\nThis gives a work ratio of 3.25, which shows that our original approximation was reasonable: accounting for differences in isentropic expansion factor, \\(k\\), changes our estimate by only 5.0%." + }, + { + "objectID": "posts/hydrogen_compression/index.html#the-real-gas-case", + "href": "posts/hydrogen_compression/index.html#the-real-gas-case", + "title": "Delivering Hydrogen Fuel Gas", + "section": "The Real Gas Case", + "text": "The Real Gas Case\nTo 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.\nI am going to use a volume translated Peng Robinson cubic equation of state for both hydrogen and methane.\n\nreal_hydrogen = PR([\"hydrogen\"]; \n idealmodel=ReidIdeal, \n alpha=TwuAlpha, \n translation=PenelouxTranslation)\n\n\nreal_natural_gas = PR([\"methane\"]; \n idealmodel=ReidIdeal, \n alpha=TwuAlpha, \n translation=PenelouxTranslation)\n\nClapeyron.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.\n\nusing Roots: find_zero\n\nfunction isentropic_temperature(gas, p1, T1, p2)\n s1 = entropy(gas, p1, T1)\n k_ig = k(gas)\n T2_guess = T1*(p2/p1)^(1-1/k_ig)\n T2 = find_zero( T -> entropy(gas, p2, T) - s1, T2_guess)\n return T2\nend\n\nFirst, the specific enthalpy difference for hydrogen\n\n\n\\[\\begin{align}\np_{1} &= 1\\,\\mathrm{bar}\n\\\\[10pt]\nT_{1} &= 288.15\\,\\mathrm{K}\n\\\\[10pt]\np_{2} &= 4.64\\,\\mathrm{bar}\n\\\\[10pt]\nT_{2H2} &= 447.3\\,\\mathrm{K}\\;\\text{ }(\\text{Clapeyron.jl})\n\\\\[10pt]\nH_{1H2} &= -283.16\\,\\mathrm{J}\\,\\mathrm{mol}^{-1}\\;\\text{ }(\\text{Clapeyron.jl})\n\\\\[10pt]\nH_{2H2} &= 4344.03\\,\\mathrm{J}\\,\\mathrm{mol}^{-1}\\;\\text{ }(\\text{Clapeyron.jl})\n\\\\[10pt]\n{\\Delta}h_{H2} &= \\frac{H_{2H2} - H_{1H2}}{MW_{H2}}\n\\\\[10pt]\n&= \\frac{4344.03\\,\\mathrm{J}\\,\\mathrm{mol}^{-1} + 283.16\\,\\mathrm{J}\\,\\mathrm{mol}^{-1}}{2.02\\,\\mathrm{kg}\\,\\mathrm{kmol}^{-1}}\n\\\\[10pt]\n&= 2290.68\\,\\mathrm{kJ}\\,\\mathrm{kg}^{-1}\n\\end{align}\\]\n\n\nThen the specific enthalpy difference for natural gas\n\n\n\\[\\begin{align}\nT_{2NG} &= 404.45\\,\\mathrm{K}\\;\\text{ }(\\text{Clapeyron.jl})\n\\\\[10pt]\nH_{1NG} &= -369.79\\,\\mathrm{J}\\,\\mathrm{mol}^{-1}\\;\\text{ }(\\text{Clapeyron.jl})\n\\\\[10pt]\nH_{2NG} &= 4015.67\\,\\mathrm{J}\\,\\mathrm{mol}^{-1}\\;\\text{ }(\\text{Clapeyron.jl})\n\\\\[10pt]\n{\\Delta}h_{NG} &= \\frac{H_{2NG} - H_{1NG}}{MW_{NG}}\n\\\\[10pt]\n&= \\frac{4015.67\\,\\mathrm{J}\\,\\mathrm{mol}^{-1} + 369.79\\,\\mathrm{J}\\,\\mathrm{mol}^{-1}}{16.04\\,\\mathrm{kg}\\,\\mathrm{kmol}^{-1}}\n\\\\[10pt]\n&= 273.41\\,\\mathrm{kJ}\\,\\mathrm{kg}^{-1}\n\\end{align}\\]\n\n\nFinally, the work ratio of compressing hydrogen versus natural gas\n\n\n\\[\\begin{align}\nr_{rg} &= \\frac{hv_{NG}}{hv_{H2}} \\cdot \\frac{{\\Delta}h_{H2}}{{\\Delta}h_{NG}}\n\\\\[10pt]\n&= \\frac{55.58\\,\\mathrm{MJ}\\,\\mathrm{kg}^{-1}}{141.95\\,\\mathrm{MJ}\\,\\mathrm{kg}^{-1}} \\cdot \\frac{2290.68\\,\\mathrm{kJ}\\,\\mathrm{kg}^{-1}}{273.41\\,\\mathrm{kJ}\\,\\mathrm{kg}^{-1}}\n\\\\[10pt]\n&= 3.28\n\\end{align}\\]\n\n\n\n\nIn this case the ideal gas law estimate and the estimate using a cubic equation of state differ by only 1.0%.\n\n\n\n\n\n\n\n\n\nFigure 2: Relative work required to compress hydrogen versus methane for the 3 compressor stages.\n\n\n\n\n\n\nThe 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%.\n\n\nIn 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.\nThere 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." + }, + { + "objectID": "posts/hydrogen_compression/index.html#final-thoughts", + "href": "posts/hydrogen_compression/index.html#final-thoughts", + "title": "Delivering Hydrogen Fuel Gas", + "section": "Final Thoughts", + "text": "Final Thoughts\nI 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.\n7 is this all just an extended response to a thread on mastodon? I mean… sort ofIs 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.\nI 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.\nI 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." + }, + { + "objectID": "posts/hydrogen_compression/index.html#references", + "href": "posts/hydrogen_compression/index.html#references", + "title": "Delivering Hydrogen Fuel Gas", + "section": "References", + "text": "References\n\n\nBoyce, Meherwan P., Victor H. Edwards, Terry W. Cowley, Hugh D. Kaiser, Wayne B. Geyer, David Nadel, Larry Skoda, Shawn Testone, and Kenneth L. Walter. “Transport and Storage of Fluids.” In Perry’s Chemical Engineers’ Handbook, edited by Don W. Green, 8th ed. New York: McGraw Hill, 2008.\n\n\nGmehling, Jürgen, Bärbel Kolbe, Michael Kleiber, and Jürgen Rary. Chemical Thermodynamics for Process Simulation. Weinheim, DE: Wiley-VCH Verlag & Co., 2012.\n\n\nGPSA. Engineering Data Book. 13th ed. Tulsa, OK: Gas Processors Suppliers Association, 2012.\n\n\nSendehboudi, Sohrab, and Bahram Gharbani. Hydrogen Production, Transportation, Storage, and Utilization. Amsterdam: Elsevier, 2025." + }, + { + "objectID": "posts/dispersion_parameter_sensitivity/index.html", + "href": "posts/dispersion_parameter_sensitivity/index.html", + "title": "Messing around with model parameters", + "section": "", + "text": "Recently I added some alternative correlations to GasDispersion.jl, the julia package I put together for basic chemical release modeling, and I thought it would be worthwhile to circle back and look at some of those in more depth.\nTypically, 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.\nThis 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." + }, + { + "objectID": "posts/dispersion_parameter_sensitivity/index.html#windspeed", + "href": "posts/dispersion_parameter_sensitivity/index.html#windspeed", + "title": "Messing around with model parameters", + "section": "Windspeed", + "text": "Windspeed\nThe windspeed correlations I am looking at here are the basic power law\n\\[ u = u_R \\left( z \\over z_R \\right)^p \\]\nwhere 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.\nThere 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\n1 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).\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\nFigure 1: Windspeed correlations for class A, B, C, and D stability.\n\n\n\n\nFor 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.\n5 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.\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\nFigure 2: Windspeed correlations for class E and F stability." + }, + { + "objectID": "posts/dispersion_parameter_sensitivity/index.html#plume-dispersion", + "href": "posts/dispersion_parameter_sensitivity/index.html#plume-dispersion", + "title": "Messing around with model parameters", + "section": "Plume Dispersion", + "text": "Plume Dispersion\nThe 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.\n6 Workbook of Atmospheric Dispersion Estimates.\nCrosswind Dispersion\nCrosswind 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\n7 Briggs, “Diffusion Estimation for Small Emissions. Preliminary Report” page 38; Note that the correlations are given with respect to half-width/half-depthThe default correlation is a simple set of correlations of the form\n\\[ \\sigma_y = a x^b \\]\nwhich attempts to fit the Turner curves.\nThe 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\n8 Briggs.9 Turner, Workbook of Atmospheric Dispersion Estimates.10 Bakkum and Duijm., “Vapour Cloud Dispersion”.11 Lees, Loss Prevention in the Process Industries.\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 3: Crosswind dispersion correlations.\n\n\n\n\nZooming 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.\n12 Turner, Workbook of Atmospheric Dispersion Estimates.\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 4: Crosswind dispersion correlations, class F stability.\n\n\n\n\n\n\nVertical Dispersion\nThe vertical dispersion correlations are decidedly more varied. Varied enough that I’m just going to show them all at full scale13\n13 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.\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 5: Vertical dispersion correlations.\n\n\n\nFor 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." + }, + { + "objectID": "posts/dispersion_parameter_sensitivity/index.html#an-example", + "href": "posts/dispersion_parameter_sensitivity/index.html#an-example", + "title": "Messing around with model parameters", + "section": "An Example", + "text": "An Example\nJust 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.\nFrom 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.\n\n# TransAlta Sundance - Stack 2\nm = 3200/3600 # mass emission rate: 3200kg/h in kg/s\nh = 155.5 # stack height, m\nd = 7.3 # stack diameter, m\nv = 35.6 # stack exit velocity, m/s\nT = 439.7 # stack exit temperature, K\n\nFor 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.\n\n# assumed weather conditions\nuᵣ = 2 # windspeed, m/s\nzᵣ = 10 # windspeed elevation, m\nstability = ClassD\n\n# standard state\nPₛ = 101325 # Pa\nTₛ = 273.15 # K\n\nWe can construct the relevant scenario for GasDispersion.jl directly.\n\nr = VerticalJet(m, Inf, d, v, h, Pₛ, T, 0.0)\n\na = SimpleAtmosphere(pressure=Pₛ, temperature=Tₛ, windspeed=uᵣ, windspeed_height=zᵣ, stability=stability)\n\n# a dummy substance, since I know a gaussian plume doesn't require any material\n# properties I have just left them as NaNs\nSO2 = Substance(name=:SulfurDioxide,molar_weight=0.064066,liquid_density=1,boiling_temp=1,\n latent_heat=1,gas_heat_capacity=1,liquid_heat_capacity=1)\n\nscn = Scenario(SO2,r,a)\n\nSubstance: SulfurDioxide \n MW: 0.064066 kg/mol \n P_v: GasDispersion.Antoine{Float64}(0.007705368698167287, 0.007705368698167287, 0.0) Pa \n ρ_g: 2.7095140841291006 kg/m^3 \n ρ_l: 1 kg/m^3 \n T_ref: 288.15 K \n P_ref: 101325.0 Pa \n k: 1.4 \n T_b: 1.0 K \n Δh_v: 1 J/kg \n Cp_g: 1 J/kg/K \n Cp_l: 1 J/kg/K \nVerticalJet release:\n ṁ: 0.8888888888888888 kg/s \n Δt: Inf s \n d: 7.3 m \n u: 35.6 m/s \n h: 155.5 m \n P: 101325.0 Pa \n T: 439.7 K \n f_l: 0.0 \nSimpleAtmosphere atmosphere:\n P: 101325.0 Pa \n T: 273.15 K \n u: 2.0 m/s \n h: 10.0 m \n rh: 0.0 % \n stability: ClassD \n\n\nThe Gaussian plume model is then given by the following, neglecting the effect of plume rise.\n\nconc = plume(scn, GaussianPlume; plumerise=false);\n\nPlotted 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.\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 6: Downwind concentration of sulfur dioxide, at 2m elevation, as predicted by the default, CCPS, ISC3, TNO, and Turner correlations, neglecting plume rise.\n\n\n\n\nPlotted 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.\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 7: Concentration isopleths for sulfur dioxide, at 2m elevation, as predicted by the default, CCPS, ISC3, TNO, and Turner correlations, neglecting plume rise.\n\n\n\n\nThe above was assuming no plume rise, however the relative differences are much more pronounced when plume rise is included.\n\nconc = plume(scn, GaussianPlume; plumerise=true);\n\nPlotted 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.\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 8: Downwind concentration of sulfur dioxide, at 2m elevation, as predicted by the default, CCPS, ISC3, TNO, and Turner correlations, using Briggs’ plume rise correlations." + }, + { + "objectID": "posts/dispersion_parameter_sensitivity/index.html#final-thoughts", + "href": "posts/dispersion_parameter_sensitivity/index.html#final-thoughts", + "title": "Messing around with model parameters", + "section": "Final Thoughts", + "text": "Final Thoughts\nI 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.\nIt 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.\nThere 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)." + }, + { + "objectID": "posts/dispersion_parameter_sensitivity/index.html#references", + "href": "posts/dispersion_parameter_sensitivity/index.html#references", + "title": "Messing around with model parameters", + "section": "References", + "text": "References\n\n\nAIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999.\n\n\nBakkum, E. A., and N. J. Duijm. “Vapour Cloud Dispersion.” In Methods for the Calculation of Physical Effects, CPR 14E, edited by C. J. H. van den Bosch and R. A. P. M. Weterings, 3rd ed. The Hague: TNO, 2005.\n\n\nBriggs, Gary A. “Diffusion Estimation for Small Emissions. Preliminary Report.” Oak Ridge, TN: Air Resources Atmospheric Turbulence; Diffusion Laboratory, National Oceanic; Atmospheric Administration, 1973. https://doi.org/10.2172/5118833.\n\n\nEPA-454/b-95-003b: User’s Guide for the ISC3 Dispersion Models. Vol. 2. Environmental Protection Agency, 1995.\n\n\nHanna, Steven R., Gary A. Briggs, and Rayford P. Hosker Jr. Handbook on Atmospheric Diffusion. Springfield, VA: National Technical Information Service, 1982. https://doi.org/10.2172/5591108.\n\n\nLees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996.\n\n\nSpicer, Thomas O., and Jerry A. Havens. EPA-450/4-89-019: User’s Guide for the DEGADIS 2.1 Dense Gas Dispersion Model. Research Triangle Park, NC: Office of Air Quality Planning; Standards, United States Environmental Protection Agency, 1989. https://nepis.epa.gov/Exe/ZyNET.exe/2000J5GU.txt.\n\n\nTurner, D. Bruce. Workbook of Atmospheric Dispersion Estimates. Research Triangle Park, NC: Office of Air Programs, United States Environmental Protection Agency, 1989." + }, + { + "objectID": "posts/Britter-McQuaid/index.html", + "href": "posts/Britter-McQuaid/index.html", + "title": "Taking a second look at the Britter-McQuaid model", + "section": "", + "text": "I recently spent some time looking in detail at the Britter-McQuaid workbook model for dense gas dispersion and I thought the plume model deserved some extra attention. Firstly because I believe there is an error in the plume dimensions, and secondly because I think an important feature of top-hat models is often neglected and the Britter-McQuaid workbook model should be used more.\nAs 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." + }, + { + "objectID": "posts/Britter-McQuaid/index.html#a-motivating-example", + "href": "posts/Britter-McQuaid/index.html#a-motivating-example", + "title": "Taking a second look at the Britter-McQuaid model", + "section": "A motivating example", + "text": "A motivating example\nJust 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:\n2 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases., 122.\nrelease temperature: -162°C\nrelease rate: 0.23 m³/s (liquid)\nrelease duration: 174 s\nwindspeed at 10m: 10.9 m/s\nLNG liquid density (at release conditions): 425.6 kg/m³\nLNG gas density (at release conditions): 1.76 kg/m³\n\nThe 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.\n\nusing Unitful\n\nTₐ = 288.15u\"K\" # ambient air temperature \nρₐ = 1.225u\"kg/m^3\" # density of air at 15°C and 1atm\nu₁₀ = 10.9u\"m/s\" # windspeed at 10m \n\nρₗ = 425.6u\"kg/m^3\" # liquid density of LNG, given\nρᵥ = 1.76u\"kg/m^3\" # vapour density of LNG, given\nṁ = ρₗ*0.23u\"m^3/s\" # mass release rate\nTᵣ = (273.15 - 162)u\"K\" # boiling point of LNG, given\nLFL = 0.05 # lower flammability limit, volume fraction\n\nQₒ = ṁ/ρᵥ # gas volumetric flowrate: mass flowrate divided by gas density\n\nFirst calculate the critical length, D, and the dimensionless parameter α for the model\n\nD = √(Qₒ/u₁₀)\n\ng = 9.806u\"m/s^2\"\ngₒ = g * (ρᵥ - ρₐ )/ ρₐ\nα = 0.2*log10(gₒ^2 * Qₒ / u₁₀^5)\n\nThen, using digitized curves,3 work out the points for the linear interpolation in terms of \\(\\beta = \\log_{10}(x/D)\\)\n3 AIChE/CCPS, 118.\nCs = [ 0.1, 0.05, 0.02, 0.01, 0.005, 0.002]\nβ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]\n\nThese points only cover the middle region of the concentration curve, where the concentration ratio, \\({ c_m \\over c_0 }\\), is between 0.1 and 0.002, there is a near-field correlation that needs to be connected for concentration ratios >0.1\n\nfunction Cm_nf(x′)\n if x′ > 0\n return 306/(306 + x′^2)\n else\n return 1.0\n end\nend\n\nxnf = 30\nβnf = log10(xnf)\nCnf = Cm_nf(xnf)\n\nAnd 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\n\nxff = 10^(maximum(βs))\nA = minimum(Cs)*xff^2\n\nfunction Cm_ff(x′; A=A)\n return A/x′^2\nend\n\nFinally, 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)\n\nusing Interpolations\n\nitp = interpolate( ([βnf; βs],), [Cnf; Cs], Gridded(Linear()) )\n\n\nfunction Cm(x::Quantity; xnf=xnf, xff=xff, D=D, T′=Tᵣ/Tₐ)\n x′ = x/D\n c′ = if x′ < xnf\n Cm_nf(x′)\n elseif xnf ≤ x′ < xff\n itp(log10(x′))\n else\n Cm_ff(x′)\n end\n \n c = c′ / (c′ + (1 - c′)*T′)\n \n return c\nend\n\n\n\n\n\n\n\n\n\nFigure 1: The concentration profile for the Britter-McQuaid dense gas model, with the LFL shown for reference.\n\n\n\n\n\nIf 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.\nFrom the concentration profile calculating the downwind distance to the LFL is very straight-forward.\n\nusing Roots\n\nxn = find_zero((x) -> Cm(x) - LFL, (300,400).*1u\"m\", Roots.Brent())\n\n354.5630187009715 m" + }, + { + "objectID": "posts/Britter-McQuaid/index.html#looking-again-at-plume-dimensions", + "href": "posts/Britter-McQuaid/index.html#looking-again-at-plume-dimensions", + "title": "Taking a second look at the Britter-McQuaid model", + "section": "Looking again at plume dimensions", + "text": "Looking again at plume dimensions\nAt first glance the workbook seems to be giving the user everything they need to workout the size of the plume, giving the following diagram\n\n\n\n\n\n\nFigure 2: Dense plume concentration contour.4\n\n4 Britter and McQuaid, “Workbook on the Dispersion of Dense Gases,” fig. 10.\n\nand the following relations for the labeled distances\n\\[ L_U = {D \\over 2} + 2 l_b \\]\n\\[ L_{Ho} = D + 8 l_b \\]\n\\[ L_H = L_{Ho} + 2.5 \\sqrt[3]{ l_b x^2 } \\]\nwith the buoyancy scale lb defined as \\[ l_b = { { g_o Q_o } \\over u_{ref}^{3} } \\]\n\nlb = (gₒ*Qₒ)/u₁₀^3\n\n0.18392758812310803 m\n\n\n\nLᵤ = D/2 + 2lb\n\n1.4973003373658906 m\n\n\n\nLₕₒ = D + 8lb\n\n3.7303110272242135 m\n\n\n\nLₕ(x) = Lₕₒ + 2.5∛(lb*x^2)\n\n\nUpwind region\nThe 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.\nA 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.\n5 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: \\(L_H = L_{Ho} \\left( {x + L_U} \\over L_U \\right)^{2/3}\\) 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.\n\n\n\n\n\n\n\n\nFigure 3: The various approaches to estimating the upwind plume extent, black dots are a digitization of the corresponding diagram from Britter and McQuaid shown for reference.\n\n\n\n\n\nFor 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.\n\n\nVertical extent\nThe 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.\n\\[ L_V = {Q_o \\over {u_{ref} L_H} } = { D^2 \\over L_H } \\]\nSuppose 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\n\n\n\nimage.png\n\n\nConsider the steady state mass balance\n\\[ \\textrm{mass in} = \\textrm{mass out} \\]\n\\[ c_o Q_o = \\iint_A c u \\,dA = \\int_{0}^{\\infty} \\int_{-\\infty}^{\\infty} c(x,y,z) u(x,y,z) \\,dy \\,dz \\]\nBy 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\n\\[ \\iint_A c u \\,dA = c_m u \\int_{0}^{L_V} \\int_{-L_H}^{L_H} \\,dy \\,dz = 2 c_m u L_H L_V \\]\nThe steady state mass balance is then\n\\[ c_o Q_o = 2 c_m u L_H L_V \\]\nand the vertical extent can be solved for with some simple re-arrangement\n\\[ L_V = { { c_o Q_o } \\over { 2 c_m u L_H } } = {1 \\over 2}{ c_o \\over c_m } { Q_o \\over {u L_H} } \\]\nSetting the advection velocity of the plume to the reference windspeed gives\n\\[ L_V = {1 \\over 2}{ c_o \\over c_m } { Q_o \\over {u_{ref} L_H} } = {1 \\over 2} { c_o \\over c_m } { D^2 \\over L_H }\\]\n\nLᵥ(x) = D^2/(2*Cm(x)*Lₕ(x))\n\nThis is definitely similar to what is given by Britter and McQuaid but with two big differences:\n\nit depends upon the concentration\nit is divided by two\n\nThe 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?\nThe TNO Yellow Book gives a different equation6 for the vertical extent:\n6 Bakkum and Duijm. equation 4.104.\\[ L_V = {1 \\over 2} { Q_o \\over {u_{ref} L_H} } = {1 \\over 2} { D^2 \\over L_H }\\]\nWhich 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.\nI 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.\nThis 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. \\(c_m u A\\) )\n\n\n\n\n\n\n\n\nFigure 4: Approaches to plume height estimation (top) and the corresponding conservation of mass (bottom).\n\n\n\n\n\nI 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).\nAn 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. \\(u = 0.4 u_{ref}\\), which is what Britter and McQuaid use for the instantaneous model.\nAnother 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.\n\\[ \\bar{u} = { { \\iint_A u \\,dA } \\over A } = { { \\int_{0}^{L_V} u(z) \\,dz } \\over L_V } \\]\nAssuming the windspeed follows a powerlaw distribution \\(u = u_{ref} \\left( z \\over z_{ref} \\right)^p\\) gives\n\\[ \\bar{u} = { { \\int_{0}^{L_V} u(z) \\,dz } \\over L_V } \\]\n\\[ = {1 \\over L_V} \\int_{0}^{L_V} u_{ref} \\left( z \\over z_{ref} \\right)^p \\,dz \\]\n\\[ = { u_{ref} \\over {p+1} } \\left( L_V \\over z_{ref} \\right)^p \\]\nplugging it into the simple mass balance\n\\[ c_o Q_o = c_m \\bar{u} A \\]\n\\[ = c_m {u_{ref} \\over {p+1} } \\left( L_V \\over z_{ref} \\right)^p { 2 L_H L_V } \\]\nre-arranging to solve for LV\n\\[ L_V = \\left( { {p+1} \\over 2 } { c_o \\over c_m } z_{ref}^p {Q_o \\over {u_{ref} L_H} } \\right)^{1 \\over {p+1} } \\]\n\\[ = \\left( { {p+1} \\over 2 } { c_o \\over c_m } z_{ref}^p {D^2 \\over L_H } \\right)^{1 \\over {p+1} }\\]\nThe red curve in the figure above is this model, using p = 0.15.7\n7 AIChE/CCPS, Guidelines for Consequence Analysis of Chemical Releases., 83.This could also be done using the logarithmic windspeed curve \\(u = {u_{\\star} \\over \\kappa} \\log \\left( z \\over z_) \\right)\\) where \\(u_{\\star}\\) is the friction velocity and z0 is the roughness length. Though I don’t imagine the expression would work out as nicely.\n\n\nRecommendations\nFor 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.\nThe 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." + }, + { + "objectID": "posts/Britter-McQuaid/index.html#calculating-the-explosive-mass", + "href": "posts/Britter-McQuaid/index.html#calculating-the-explosive-mass", + "title": "Taking a second look at the Britter-McQuaid model", + "section": "Calculating the explosive mass", + "text": "Calculating the explosive mass\nThe explosive mass in the cloud is the given by the volume integral\n\\[ m_e = \\iiint_V c dV \\]\nwhere V is defined as the region where c ≥ LFL.8\n8 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:\n\nwithin the plume, and\nthe concentration is ≥ LFL\n\nTo determine the explosive mass in the downwind region this might be done by the following\n\ncₒ = ustrip(u\"kg/m^3\", ṁ/Qₒ)\n\nLₕ(x::Number) = ustrip(u\"m\", Lₕ(x*1u\"m\"))\nLᵥ(x::Number) = ustrip(u\"m\", D)^2/(2*Cm(x)*Lₕ(x))\n\nfunction c(x,y,z; lim=LFL)\n c_ = Cm(x)\n \n if c_ ≥ lim\n if (abs(y) ≤ Lₕ(x)) && (z ≤ Lᵥ(x))\n return cₒ*c_\n else\n return 0.0\n end\n else\n return 0.0\n end\nend\n\nusing HCubature: hcubature\n\nx_min, x_max = 0, xn\ny_min, y_max = -Lₕ(xn), Lₕ(xn)\nz_min, z_max = 0, Lᵥ(xn)\n\nm_e, err = hcubature( c, [x_min, y_min, z_min], [x_max, y_max, z_max])\nThis 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.\n\nA nice property of top hat models\nReturning to the integral for the explosive mass, the plume can be divided into an upwind region (x < 0) and a downwind region (x ≥ 0)\n\\[ m_e = \\iiint_V c \\,dV = m_{e,u} + m_{e,d} \\]\nwith the explosive mass of the downwind region being\n\\[ m_{e,d} = \\int_0^{x_n} \\iint_A c \\,dA \\,dx \\]\nFor a top-hat model, since the concentration at a given downwind distance is constant everywhere within the plume cross-section \\(\\iint_A c dA = c_m A\\), and, from a mass balance on the plume\n\\[ c_m A u = c_o Q_o \\]\n\\[ c_m A = { {c_o Q_o} \\over u} \\]\nwhich is a constant, thus\n\\[ m_{e,d} = \\int_0^{x_n} c_m A \\,dx \\]\n\\[ = \\int_0^{x_n} { {c_o Q_o} \\over u} \\,dx \\]\n\\[ = { {c_o Q_o} \\over u} x_n \\]\nFor the explosive mass of the upwind region a simple box model gives \\(m_{e,u} = 2 c_o L_U L_{Ho} L_{Vo}\\). Putting everything together9\n9 This is not specific to the Britter-McQuaid model, it works for any top hat model.\\[ m_e = 2 c_o L_U L_{Ho} L_{Vo} + { {c_o Q_o} \\over u} x_n \\]\nThis can be simplified greatly by setting the advection velocity to uref\n\\[ m_e = 2 c_o L_U L_{Ho} L_{Vo} + { {c_o Q_o} \\over u_{ref} } x_n \\]\n\\[ = 2 c_o L_U L_{Ho} {1 \\over 2}{D^2 \\over L_{Ho} } + c_o D^2 x_n \\]\n\\[ = c_o D^2 \\left( L_U + x_n \\right)\\]\n\ncₒ = ṁ/Qₒ\n\nmₑ = cₒ*D^2*(Lᵤ+xn)\n\n3197.617661470163 kg\n\n\nThis 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\n10 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 \\(m_e = c_o D^2 \\left( x_{n,LFL} - x_{n,UFL} \\right)\\)If this seems too good to be true, the integration can be performed numerically by taking\n\\[ \\iint_A c \\,dA = c_m \\cdot 2L_H \\cdot L_V \\]\n\nusing QuadGK: quadgk\n\nfunction ∫∫cdA(x)\n if Cm(x) ≥ LFL\n return cₒ*Cm(x)*(2Lₕ(x))*Lᵥ(x)\n else\n return 0.0u\"kg/m\"\n end\nend\n\nm_ed, err = quadgk(∫∫cdA, 0u\"m\", xn)\n\nm_eu = 2*cₒ*Lᵤ*Lₕₒ*Lᵥ(0u\"m\")\n\nm_eu + m_ed\n\n3197.617661470163 kg\n\n\nWhich is exactly the same.\nAbove 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.\n\nLᵤ/(Lᵤ+xn)\n\n0.004205187316042018\n\n\nSince the mass in the upwind region is <0.5% of the total mass in the cloud, I think the simple box model is justified.\n\n\nAdded complications\nAccording 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.\nFor 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 )\n\\[ m_{e, \\textrm{cut off} } = m_{e,u} + m_{e,d1} + m_{e,d2} \\]\nThe 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.\n\\[ m_{e,d2} = \\int_{2/3 x_n}^{x_n} \\iint_A c \\,dA \\,dx \\]\nThe integral can be re-written to take advantage of cmA being an invariant for a top-hat model,\n\\[ m_{e,d2} = \\int_{2/3 x_n}^{x_n} \\iint_A c \\,dA \\,dx \\]\n\\[ = c_m A_{\\textrm{original} } \\int_{2/3 x_n}^{x_n} { A_{\\textrm{cut off} } \\over A_{\\textrm{original} } } \\,dx \\]\nAssuming the vertical extent remains unchanged in this operation, the ratio of areas is the same as the ratio of horizontal extents\n\\[ { A_{\\textrm{cut off} } \\over A_{\\textrm{original} } } = { L_{H, \\textrm{cut off} } \\over L_{H, \\textrm{original} } } \\]\nFrom some simple geometry, the horizontal extent is\n\\[ L_{H, \\textrm{cut off} } = 3 L_{H, 2/3 x_n} { {x_n - x} \\over x_n }\\]\nWhich then leads to\n\\[ m_{e,d2} = 3 c_o D^2 \\int_{2/3 x_n}^{x_n} { L_{H, 2/3 x_n} \\over L_H } { {x_n - x} \\over x_n } \\,dx \\]\nThere is probably a closed form for this integral but it is just as easy to integrate that numerically.\n\\[ m_{e, \\textrm{cut off} } = c_o D^2 \\left( L_U + \\frac{2}{3}x_n + 3 L_{H, 2/3 x_n} \\int_{2/3 x_n}^{x_n} { 1 \\over L_H(x) } { {x_n - x} \\over x_n } \\,dx \\right)\\]\n\nmₑ_cutoff = cₒ*D^2*(Lᵤ + (2/3)*xn \n + 3*Lₕ((2/3)*xn)*quadgk( (x) -> (xn - x)/(xn*Lₕ(x)), (2/3)*xn, xn)[1] )\n\n2620.489605856347 kg\n\n\nThis works out to be about 20% less than the original explosive mass.\n\nmₑ_cutoff/mₑ\n\n0.8195131136008078" + }, + { + "objectID": "posts/Britter-McQuaid/index.html#final-thoughts", + "href": "posts/Britter-McQuaid/index.html#final-thoughts", + "title": "Taking a second look at the Britter-McQuaid model", + "section": "Final thoughts", + "text": "Final thoughts\nI 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.\n12 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.\nThe 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)." + }, + { + "objectID": "posts/Britter-McQuaid/index.html#references", + "href": "posts/Britter-McQuaid/index.html#references", + "title": "Taking a second look at the Britter-McQuaid model", + "section": "References", + "text": "References\n\n\nAIChE/CCPS. Guidelines for Consequence Analysis of Chemical Releases. New York: American Institute of Chemical Engineers, 1999.\n\n\nBakkum, E. A., and N. J. Duijm. “Vapour Cloud Dispersion.” In Methods for the Calculation of Physical Effects, CPR 14E, edited by C. J. H. van den Bosch and R. A. P. M. Weterings, 3rd ed. The Hague: TNO, 2005.\n\n\nBritter, Rex E., and J. McQuaid. “Workbook on the Dispersion of Dense Gases. HSE Contract Research Report No. 17/1988,” 1988.\n\n\nCasal, Joachim. Evaluation of the Effects of Consequences of Major Accidents in Industrial Plants. 2nd ed. Amsterdam: Elsevier, 2018.\n\n\nLees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996.\n\n\nWoodward, John L. Estimating the Flammable Mass of a Vapour Cloud. New York: American Institute of Chemical Engineers, 1998." + }, + { + "objectID": "posts/smoke_days/index.html", + "href": "posts/smoke_days/index.html", + "title": "Smoke Days", + "section": "", + "text": "Recently wildfire smoke has returned and blanketed the city in haze, causing the air quality health index (AQHI) to sky rocket, and as a result I’ve been spending a lot more time inside. This feels like it is a lot more common event than it used to be, but I’m not sure if that’s true or if it merely feels true because I’m looking out at a hazy skyline.\nI would like to look into this more using air quality data and see if this truly is a recent change, or maybe Edmonton has always been like this and I’ve simply forgotten." + }, + { + "objectID": "posts/smoke_days/index.html#particulates-as-a-proxy", + "href": "posts/smoke_days/index.html#particulates-as-a-proxy", + "title": "Smoke Days", + "section": "Particulates as a proxy", + "text": "Particulates as a proxy\nAlberta has a series of air quality monitoring stations set up around the province and I can pull a data-set from the Edmonton Central station (the closest one to me) and look at airborne particulates (pm2.5) as a proxy for wildfire smoke. Though the smoke itself is more than just particulates <2.5μm in diameter, it is those particulates that cause the AQHI to rise significantly.\nHowever there are more sources of pm2.5 than just wildfires, vehicles are a major source for one, and in the winter atmospheric inversions can lead to really poor air quality during which time the pm2.5 concentration rises. Additionally farmers around the city often burn stubble and other stuff in the fall, leading to smoke days that have nothing to do with wildfires.\nSo this is a proxy for wildfire smoke, but not a great one.\n\nAmbient Air Data\nI downloaded just the pm2.5 measurements for Edmonton Central from October 2000, the earliest reported values, through to the end of December 2020, the latest values in the database at this time, from Alberta’s Ambient Air Data Warehouse. This is a csv with 177,072 rows of data and several columns each corresponding to, I’m guessing, a different instrument. Over time the station has swapped out instruments for measuring pm2.5s and those are recorded as a different measurement type.\n\nusing CSV, DataFrames, Dates, Pipe, Plots\n\n\ndata_file = \"data/Long Term pm2.5 Edmonton Central.csv\"\n\nambient_data = @pipe data_file |>\n CSV.File( _ ; \n dateformat=\"mm/dd/yyyy HH:MM:SS\", \n types=[DateTime, DateTime, Float64, Float64, Float64, Float64], \n header=16, silencewarnings=true) |>\n DataFrame(_);\n\n\n\n\n177072×6 DataFrame\n\n6×6 DataFrame\n\n Row │ IntervalStart IntervalEnd MeasurementValue MeasurementValue_1 MeasurementValue_2 MeasurementValue_3 \n\n │ DateTime DateTime Float64? Float64? Float64? Float64? \n\n─────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n\n 1 │ 2000-10-20T00:00:00 2000-10-20T00:59:00 missing missing missing 2.3\n\n 2 │ 2000-10-20T01:00:00 2000-10-20T01:59:00 missing missing missing 1.8\n\n 3 │ 2000-10-20T02:00:00 2000-10-20T02:59:00 missing missing missing 1.8\n\n 4 │ 2000-10-20T03:00:00 2000-10-20T03:59:00 missing missing missing 1.0\n\n 5 │ 2000-10-20T04:00:00 2000-10-20T04:59:00 missing missing missing 1.8\n\n 6 │ 2000-10-20T05:00:00 2000-10-20T05:59:00 missing missing missing 2.0\n\n\n\n\n\nParsing the Data\nWhat I want to know is when smoke events happened and how long they were. To estimate that I am going to assume a smoke event is a period in which the hourly pm2.5 exceed the ambient air quality guideline for pm2.5s. The event starts at the first hour greater than that limit and ends on the first hour less than that limit. This has the obvious weakness that sometimes the clouds of wildfire smoke has breaks in it, so what feels like a week long smoke event would end up as a series of smaller events as the pm2.5 count might dip overnight or something. But this is a start.\nOne complication is that the dataset has four columns of pm2.5 data that are full of mostly missing values since they each only correspond to the period in which the given instrument is running. So I first need to collect those measurement values, drop the missing values, and take the mean of what remains. I assume there is no overlap and so it’s the mean of one number, but I haven’t checked to see if that’s true and the mean seems like the most sensible thing to do if there is overlap.\nIf there is a missing hour entirely, i.e. no instrument has a reading, then I skip it. That neither counts as the start nor the end of a smoke event and I move to the next row.\n\nusing Statistics\n\nlim_1h = 80.0 #μg/m³ 1-hr limit\nlim_24h = 29.0 #μg/m³ 24-hr limit\n\nfunction exceedences(df; limit=lim_1h)\n\n results = DataFrame(start_date = DateTime[], end_date = DateTime[], month = Int64[], year = Int64[], duration = Float64[], max_conc = Float64[])\n\n flag = false\n start_date = nothing\n end_date = nothing\n max_conc = 0.0\n\n for r in eachrow(ambient_data)\n measurements = [r[:MeasurementValue], r[:MeasurementValue_1], r[:MeasurementValue_2], r[:MeasurementValue_3]]\n measurements = collect( skipmissing(measurements) )\n conc = if (sizeof(measurements)>0) mean(measurements) else missing end\n\n if typeof(conc) == Missing\n # ignore missing data\n elseif conc > limit\n if flag == true # we are already in a sequence\n end_date = r[:IntervalEnd]\n max_conc = max(conc, max_conc)\n else # we are starting a sequence\n flag = true\n start_date = r[:IntervalStart]\n end_date = r[:IntervalEnd]\n max_conc = max(conc, max_conc)\n end\n else\n if flag == true # we are ending a sequence\n flag = false\n duration = Dates.value.(end_date - start_date)/3.6e6\n push!(results, [start_date, end_date, month(start_date), year(start_date), duration, max_conc])\n max_conc = 0.0\n end\n end\n end\n \n return results\nend\n\n\nresult_1hr = exceedences(ambient_data, limit=lim_1h)\n\n\n\n\nResults: 53×6 DataFrame\n\n\n\nSummary: \n\n6×7 DataFrame\n\n Row │ variable mean min median max ⋯\n\n │ Symbol Union… Any Union… Any ⋯\n\n─────┼──────────────────────────────────────────────────────────────────────────\n\n 1 │ start_date 2001-05-24T11:00:00 2019-05-31T15:00:00 ⋯\n\n 2 │ end_date 2001-05-24T12:59:00 2019-05-31T15:59:00\n\n 3 │ month 5.84906 1 7.0 12\n\n 4 │ year 2011.15 2001 2010.0 2019\n\n 5 │ duration 3.85126 0.983333 1.98333 26.9833 ⋯\n\n 6 │ max_conc 135.9 80.3 93.9 867.0\n\n 2 columns omitted\n\n\n\nOver the past 20yrs there were 53 periods with the pm2.5 concentration above the limit, these range from 1hr to 27hrs long and a max concentration observed of 867μg/m³" + }, + { + "objectID": "posts/smoke_days/index.html#results", + "href": "posts/smoke_days/index.html#results", + "title": "Smoke Days", + "section": "Results", + "text": "Results\nA plot of the results, showing each period in excess of the hourly limit and the duration of that period, is very suggestive that these are becoming more frequent events. If we also plot the maximum hourly concentration observed it appears that the extreme smoke days are a more recent phenomenon. Though with the big caveat that the data only goes back 20 years, it could be that the period between 2000 and 2010 was an abnormally smoke-less period.\n\n\n\n\n\n\n\n\nFigure 1: Duration of AAQO exceedances in Edmonton from 2001 through 2019\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 2: Observed concentrations for AAQO exceedances in Edmonton, 2001 through 2019\n\n\n\n\n\nThe plots below aggregate the events by year, and it certainly seems like smoke events are becoming more frequent and lasting longer, with more time spent in haze than in the early 2000s. But there are notable years such as 2010 and 2018 which could simply be outliers. Interestingly the most notable, in the news, years for wildfires are not obvious ones here – the Slave Lake fire of 2011 and Ft. McMurray fire of 2016. I think it is often the case that the wildfire smoke in Alberta has less to do with fires in Alberta itself and more to do with smoke being carried in from neighbouring states and provinces. That is certainly true now when the major wildfires are in BC, northern Saskatchewan, and northern Manitoba.\n\n\n\n\n\n\n\n\nFigure 3: Frequency of smoke days in Edmonton, 2001 through 2019\n\n\n\n\n\nOne thing these plots may mask is that while the median duration perhaps hasn’t changed much, it’s clear from the scatter plots that the outlier periods are more common in the last decade than the one preceding it.\n\n\n\n\n\n\n\n\nFigure 4: Frequency of smoke days exceeding 5 hours in duration, Edmonton 2001-2019.\n\n\n\n\n\nWhen grouped by month, we can see winter months have notable representation, which is likely those atmospheric inversions trapping pollutants near ground level, but the summer months appears to be when the hazy periods are longest and that likely corresponds to wildfire smoke.\n\n\n\n\n\n\n\n\nFigure 5: Frequency of smoke events by month, Edmonton 2001-2019\n\n\n\n\n\nFiltering out only the extended periods, with a duration >5 hrs, we see that prolonged periods of excess pm2.5 appears to be a summer phenomena, especially August. Which is certainly consistent with my experience of noticeable smokey days, corresponding with wildfire season.\n\n\n\n\n\n\n\n\nFigure 6: Frequency of smoke events exceeding 5 hours in duration by month, Edmonton 2001-2019" + }, + { + "objectID": "posts/smoke_days/index.html#limitations-and-opportunities", + "href": "posts/smoke_days/index.html#limitations-and-opportunities", + "title": "Smoke Days", + "section": "Limitations and Opportunities", + "text": "Limitations and Opportunities\nAn opportunity for further analysis would be to look for correlations between pm2.5 and other pollutants, say NOx, to allow one to exclude pm2.5s from vehicle emissions. That would still leave road dust, construction dust, and just farmers burning stubble, but I imagine that would go pretty far in terms of removing unrelated bad air quality days from the dataset. If one was only concerned with the most extreme cases, when the sky turns orange and visibility drops to only a few blocks, well that’s visible from space and could presumably be pulled out a dataset of satellite images, taking care to distinguish smoke from cloud cover.\nI would like to see a longer dataset. The dataset I was looking at was relatively short, only twenty years, which doesn’t allow me to answer the question of whether or not extended periods of wildfire smoke is truly a recent phenomenon versus a “return to normal”, i.e. it could be that 2000-2010 were the abnormal years and that is equally consistent with this dataset. Just looking around the air data warehouse it doesn’t look like this kind of air analysis was routine before the 2000s, but I could simply be ignorant of some other studies or datasets.\nFinally I picked pm2.5s since that is the variable responsible for the high risk AQHI levels, but there could be much better proxies for wildfire smoke that have better datasets. I can’t think of anything off the top of my head, but I’m a chemical engineer not an air quality expert." + }, + { + "objectID": "posts/turbulent_jet_notes/index.html", + "href": "posts/turbulent_jet_notes/index.html", + "title": "Turbulent Jets", + "section": "", + "text": "In a previous post I worked through a chemical release modeled as a turbulent jet and while I mentioned there were several ways modeling the jet, I didn’t go into any of them. I’m taking the opportunity here to collect my notes on turbulent jets, some different ways of modeling the jets, and the relative performance of each approach." + }, + { + "objectID": "posts/turbulent_jet_notes/index.html#observations-on-turbulent-jets", + "href": "posts/turbulent_jet_notes/index.html#observations-on-turbulent-jets", + "title": "Turbulent Jets", + "section": "Observations on Turbulent Jets", + "text": "Observations on Turbulent Jets\nWe are considering a submerged circular jet, issuing from a surface, with the coordinate system centered on the jet. Since it is circular, the natural coordinate system is cylindrical with a downstream distance z, radial distance r, and angular coordinate θ. The jet is fully turbulent when the Reynolds number, \\(Re \\gt 2000\\), where the Reynolds number is calculated with respect to the initial jet velocity and jet diameter\n\\[ Re = { \\rho_j v_0 d_0 \\over \\mu_j } \\]\nWe are also considering the case where the densities of the two fluids are similar, where we take “similar” to mean \\[ \\frac{1}{4} \\le { \\rho_{a} \\over \\rho_{j} } \\le 4 \\]\nWhere subscript a indicates the ambient fluid and j the jet. For much the experimental data the jet and ambient fluid are the same fluid, e.g. a jet of air into air or water into water.\nTurbulent jets expand by entraining ambient fluid, tracing out a cone defined by a jet angle \\(\\alpha \\approx 15-25^\\circ\\). The mixing layer penetrates into the jet forming the potential core, inside is pure jet material and outside is mixed. After approximately 6 diameters the region is fully developed.\n\n\n\n\n\n\nFigure 1: A turbulent jet emitted from a circular orifice.\n\n\n\nEmpirical approximations of the velocity profile are often given with respect to this jet angle or, equivalently, the slope of the line (i.e. \\(\\tan \\frac{\\alpha}{2}\\)). A related way of parameterizing the jet is in terms of a width parameter b. Typically this is the width of the velocity profile at half-height \\(b_{1/2}\\) (though not always). With a constant jet angle and a self-similar velocity profile the width is directly proportional to the downstream distance \\(b_{1/2} = \\tan \\left( \\frac{\\alpha_{1/2} }{2} \\right) z = c z\\).\nWhere the value of c is can be found in the literature\n\n\n\nc\nReference\n\n\n\n\n0.082 - 0.097\nGarde1\n\n\n0.0848\nBird, Stewart, and Lightfoot2\n\n\n0.10\nRajaratnam3\n\n\n\n1 Turbulent Flows.2 Transport Phenomena.3 Turbulent Jets.At this point it is common to introduce a variable \\(\\xi = {r \\over b_{1/2} }\\) or \\(\\xi = {r \\over z }\\) where we are taking advantage of the fact that \\(b_{1/2} \\propto z\\). This is a scaled radial distance, using the width at half-height as a characteristic length. It is important to keep track of which definition of ξ is being used as they differ by a scaling factor. The reason for this change of variables is the observation that the shape of the velocity profile is the same at any downstream point, it is merely scaled down in height and wider as one travels downstream. That is \\({ \\bar{v}_z \\over \\bar{v}_{max} } = f \\left( \\xi \\right)\\) is the same for all downstream distances (in the region where the jet is fully developed).\nAnother important observation is that the center-line velocity, the max velocity in the jet, decays with the inverse of the downstream distance, i.e.\n\\[ \\bar{v}_{max} \\propto z^{-1} \\]\nPutting those two observations together we expect the velocity profile to have the form\n\\[ \\bar{v}_z = { \\mathrm{const} \\over z } f \\left( \\xi \\right)\\]" + }, + { + "objectID": "posts/turbulent_jet_notes/index.html#modeling-turbulent-jets", + "href": "posts/turbulent_jet_notes/index.html#modeling-turbulent-jets", + "title": "Turbulent Jets", + "section": "Modeling Turbulent Jets", + "text": "Modeling Turbulent Jets\nTo set up our system we consider the case of a jet coming out of a point on an infinite surface into a quiescent medium, and that the jet and medium have the same density. This is a major simplification, but it makes the math easier to deal with. The coordinate system is centered at this point and all momentum in the jet ultimately comes from the origin.\nThe boundary conditions for the problem are:\n\nat the center-line, r=0, the velocity is entirely in the z-direction\nat the center-line, r=0, the velocity in the z-direction is at a maximum\nas the radius increases, r → ∞ , the velocity in the z-direction goes to zero\n\n\nTime Averaged Values\nSince we are concerned with turbulent flow, we can employ Reynolds decomposition to transform the velocities like so\n\\[ v_z = \\bar{v}_z + v^{\\prime}_{z} \\]\n\\[ v_r = \\bar{v}_r + v^{\\prime}_{r} \\]\nwhere \\(\\bar{v}\\) is the time-smoothed velocity and \\(v^{\\prime}\\) is an instantaneous deviation such that \\(\\bar{v^{\\prime} } = 0\\) and the time-averaging operator follows the Reynolds criteria.\n\n\nEquations of Motion\nThe equations of motion in terms of time-smoothed velocities are \\[ \\rho {D \\mathbf{\\bar{v} } \\over D t } = - \\nabla \\bar{p} - \\nabla \\cdot \\mathbf{ \\bar{\\tau} } + \\rho \\mathbf{g} \\]\nWhere \\(\\mathbf{ \\bar{\\tau} }\\) is the turbulent stress and includes the Reynolds stresses.\nWith the z component, in cylindrical coordinates4\n4 Bird, Stewart, and Lightfoot, Transport Phenomena, 847.\\[ \\rho \\left( {\\partial \\over \\partial t} \\bar{v}_z + \\bar{v}_r {\\partial \\bar{v}_z \\over \\partial r} + {\\bar{v}_\\theta \\over r} {\\partial \\bar{v}_z \\over \\partial \\theta} + \\bar{v}_z {\\partial \\bar{v}_z \\over \\partial z} \\right) \\]\n\\[ = - {\\partial \\bar{p} \\over \\partial z} - {1 \\over r} {\\partial \\left( r \\bar{\\tau}_{rz} \\right) \\over \\partial r } - {1 \\over r} {\\partial \\bar{\\tau}_{\\theta z} \\over \\partial \\theta } - {\\partial \\bar{\\tau}_{z z} \\over \\partial z } + \\rho g_z\\]\nMaking the assumptions:\n\nZero pressure gradient ( \\({\\partial p \\over \\partial z} = 0\\) )\nSteady state ( \\({\\partial \\over \\partial t} \\left( \\cdots \\right) = 0\\) )\nAxisymmetric ( \\({\\partial \\over \\partial \\theta} \\left( \\cdots \\right) = 0\\) )\nEffect of gravity can be neglected ( \\(\\rho g_z \\approx 0\\) )\nWithin the jet \\(\\mid v_z \\mid \\gg \\mid v_r \\mid\\) and, by boundary layer approximation, \\(\\bar{\\tau}_{z z}\\) can be neglected5\n\n5 The boundary layer approximation is that\n\\[ { \\partial^2 \\bar{v}_z \\over \\partial z^2 } \\ll { \\partial^2 \\bar{v}_z \\over \\partial r^2 } \\]\nand if we suppose that\n\\[ \\bar{\\tau}_{z z} \\propto { \\partial \\bar{v}_z \\over \\partial z } \\]\nand\n\\[ \\bar{\\tau}_{r z} \\propto {\\partial \\bar{v}_z \\over \\partial r} \\]\nwe find\n\\[ { \\partial \\bar{\\tau}_{z z} \\over \\partial z } \\propto {\\partial^2 \\bar{v}_z \\over \\partial z^2} \\ll { \\partial r \\bar{\\tau}_{r z} \\over \\partial r} \\propto {\\partial^2 \\bar{v}_z \\over \\partial r^2} \\]\nand thus we can assume the free turbulence is dominated by \\(\\bar{\\tau}_{r z}\\) and\n\\[ { \\partial \\bar{\\tau}_{z z} \\over \\partial z} \\approx 0 \\]The equations of motion, in the z direction, simplifies to\n\\[ \\bar{v}_r {\\partial \\bar{v}_z \\over \\partial r} + \\bar{v}_z {\\partial \\bar{v}_z \\over \\partial z} = - {1 \\over \\rho r} {\\partial \\left( r \\bar{\\tau}_{rz} \\right) \\over \\partial r } \\]\n\n\nEquation of Continuity\nThe continuity equation in terms of time-smoothed velocities is\n\\[ {\\partial \\rho \\over \\partial t} + \\nabla \\cdot \\rho \\mathbf{ \\bar{v} } = 0 \\]\nIn cylindrical coordinates6\n6 Bird, Stewart, and Lightfoot, Transport Phenomena.\\[ {\\partial \\rho \\over \\partial t} + {1 \\over r} {\\partial \\rho r \\bar{v}_r \\over \\partial r} + {1 \\over r} { \\partial \\rho \\bar{v}_\\theta \\over \\partial \\theta} + {\\partial \\rho \\bar{v}_z \\over \\partial z} = 0 \\]\nMaking the assumptions:\n\nSteady state ( \\({\\partial \\over \\partial t} \\left( \\cdots \\right) = 0\\) )\nAxisymmetric ( \\({\\partial \\over \\partial \\theta} \\left( \\cdots \\right) = 0\\) )\nIncompressible ( \\({\\partial \\rho \\over \\partial z} = {\\partial \\rho \\over \\partial r} = {\\partial \\rho \\over \\partial \\theta} = 0\\) )\n\nThe equation of continuity simplifies to\n\\[ {1 \\over r} {\\partial r \\bar{v}_r \\over \\partial r} + {\\partial \\bar{v}_z \\over \\partial z} = 0 \\]\n\n\nStokes Stream Function\nTo simplify things down to working with one dependent variable we introduce a Stokes stream function \\(\\psi\\) defined such that\n\\[ \\bar{v}_z = -{1 \\over r} {\\partial \\psi \\over \\partial r} \\]\nand\n\\[ \\bar{v}_r = {1 \\over r} {\\partial \\psi \\over \\partial z} \\]\nThis definition ensures that the equation of continuity is satisfied. Suppose that \\(\\psi = k z F\\left(\\xi\\right)\\), where F is a unitless function of \\(\\xi = \\frac{r}{z}\\) and \\(k\\) is a constant with units \\([[ \\mathrm{length} ]]^2 \\times [[ \\mathrm{time} ]]^{-1}\\), then\n\\[ \\bar{v}_z = -{1 \\over r} {\\partial \\xi \\over \\partial r} {\\partial \\psi \\over \\partial \\xi}\n= -{1 \\over r} {1 \\over z} {k z F^{\\prime} } \\]\n\\[= -{k \\over z} {F^{\\prime} \\over \\xi} = { \\mathrm{const} \\over z } f \\left( \\xi \\right)\\]\nWhich matches what we expect from the empirical observations (which is why we supposed that form of the stream function in the first place). We can use this definition to work out some other useful terms\n\\[ {\\partial \\bar{v}_z \\over \\partial z} = {k \\over z^2} F^{\\prime \\prime} \\]\n\\[ {\\partial \\bar{v}_z \\over \\partial r} = -{k \\over z^2} \\left( { F^{\\prime \\prime} \\over \\xi} - { F^{\\prime} \\over \\xi^2} \\right) \\]\n\\[ \\bar{v}_r = { k \\over z } \\left( { F \\over \\xi } - F^{\\prime} \\right) \\]\nSubstituting these back into the equation of motion, in the z direction, leads to\n\\[ \\left( k \\over z \\right)^2 \\left[ { F F^{\\prime \\prime} \\over \\xi } - {F F^{\\prime} \\over \\xi^2} + { \\left( F^{\\prime} \\right)^2 \\over \\xi } \\right] = {1 \\over \\rho} {\\partial \\over \\partial r} \\left( r \\bar{\\tau}_{rz} \\right) \\]\n\\[ \\left( k \\over z \\right)^2 { d \\over d \\xi } \\left( F F^{\\prime} \\over \\xi \\right) = {1 \\over \\rho} {\\partial \\over \\partial r} \\left( r \\bar{\\tau}_{rz} \\right) \\]\nWhich is suggestive of the overall approach to follow: find an expression for the right hand side of this differential equation, integrate both sides with respect to ξ, and solve for F(ξ)\n\n\nBoundary Conditions\nThe initial boundary conditions of the problem were that:\n\n\\(\\bar{v}_r = 0\\) at r=0\n\\({\\partial \\bar{v}_z \\over \\partial r} = 0\\) at r=0 (i.e. the velocity is at a maximum)\n\\(\\bar{v}_z \\to 0\\) as r → ∞ (i.e. the velocity decays to zero)\n\nIn terms of F and ξ these become:\n\n\\({F \\over \\xi} - F^{\\prime} = 0\\) at ξ=0, which implies F=0 at ξ=0\n\\(F^{\\prime \\prime} - {F^{\\prime} \\over \\xi} = 0\\) at ξ=0\n\\({F^{\\prime} \\over \\xi} \\to 0\\) as ξ → ∞\n\n\n\nMomentum Balance\nTo determine the constant k we use a momentum balance: the momentum flux, J, in the z direction is constant. Initially the momentum flux is\n\\[ J = \\rho v_0^2 A_0 = \\rho v_0^2 {\\pi \\over 4} d_0^2\\]\nand at some point z downstream of the origin we have\n\\[ J = \\int_{0}^{2\\pi} \\int_{0}^{\\infty} \\rho \\bar{v}_z^2 r dr d\\theta \\]\n\\[ = 2 \\pi \\rho \\int_{0}^{\\infty} \\bar{v}_{z,max}^2 \\left( \\bar{v}_z \\over \\bar{v}_{z,max} \\right)^2 r dr \\]\n\\[ = 2 \\pi \\rho \\bar{v}_{z,max}^2 \\int_{0}^{\\infty} \\left( \\bar{v}_z \\over \\bar{v}_{z,max} \\right)^2 r dr \\]\n\\[ = 2 \\pi \\rho k^2 \\int_{0}^{\\infty} \\left( f\\left( \\xi \\right) \\right)^2 \\xi d \\xi \\]\nTaking the integral to be I, and equating the initial momentum flux with the momentum flux at point z7\n7 I’ve played a little fast and loose with the definition of \\(\\bar{v}_z\\) in that I am implicitly assuming \\(f(\\xi) = {-F^{\\prime}(\\xi) \\over \\xi}\\) which isn’t strictly true, there can be scaling factor. In practice all of these are collected together into one constant so it doesn’t matter, but that is something to be aware of as the definition of k here is really \\(k\\times \\mathrm{const}\\) where \\(\\mathrm{const} = {-F^{\\prime}(\\xi) \\over \\xi} \\div f(\\xi)\\)\\[ J = \\rho v_0^2 {\\pi \\over 4} d_0^2 = 2 \\pi \\rho k^2 I \\]\n\\[ k = \\sqrt{1 \\over 8 I } v_0 d_0 \\]" + }, + { + "objectID": "posts/turbulent_jet_notes/index.html#prandtl-mixing-length", + "href": "posts/turbulent_jet_notes/index.html#prandtl-mixing-length", + "title": "Turbulent Jets", + "section": "Prandtl Mixing Length", + "text": "Prandtl Mixing Length\nThe Prandtl mixing length model makes the assumption that momentum transfer occurs over some “mixing length” l such that\n\\[ \\bar{\\tau}_{rz} = -\\rho l^2 \\left| {\\partial \\bar{v}_z \\over \\partial r} \\right| \\left( {\\partial \\bar{v}_z \\over \\partial r} \\right)\\]\nWe suppose that the mixing length is proportional to the width of the velocity profile \\(b_{1/2}\\), the characteristic length for the velocity profile, which we know is proportional to the downstream distance z\n\\[ l \\propto b_{1/2} \\propto z\\]\n\\[ l = c z \\]\nWhere \\(c\\) is some unitless constant. Making the observation that \\({\\partial \\bar{v}_z \\over \\partial r} < 0\\) we can make the simplification\n\\[ \\bar{\\tau}_{rz} = \\rho c^2 z^2 \\left( {\\partial \\bar{v}_z \\over \\partial r} \\right)^2\\]\n\nSetting up the ODE\nRecall that the equation of motion in the z direction is (in terms of the unitless function F) is\n\\[ \\left( k \\over z \\right)^2 { d \\over d \\xi } \\left( F F^{\\prime} \\over \\xi \\right) = {1 \\over \\rho} {\\partial \\over \\partial r} \\left( r \\bar{\\tau}_{rz} \\right) \\]\nSubstituting the expression for \\(\\bar{\\tau}_{rz}\\) we have\n\\[ \\left( k \\over z \\right)^2 { d \\over d \\xi } \\left( F F^{\\prime} \\over \\xi \\right) = c^2 z^2 {\\partial \\over \\partial r} \\left( r \\left( \\partial \\bar{v}_{z} \\over \\partial r \\right)^2 \\right) \\]\n\\[ \\left( k \\over z \\right)^2 { d \\over d \\xi } \\left( F F^{\\prime} \\over \\xi \\right) = c^2 z^2 \\left( \\left( \\partial \\bar{v}_{z} \\over \\partial r \\right)^2 + 2r \\left( \\partial \\bar{v}_{z} \\over \\partial r \\right) \\left( \\partial^2 \\bar{v}_{z} \\over \\partial r^2 \\right) \\right) \\]\nSubstituting in the expressions for \\({\\partial \\bar{v}_z \\over \\partial r}\\) and \\({\\partial^2 \\bar{v}_z \\over \\partial r^2}\\) we arrive at\n\\[ \\left( k \\over z \\right)^2 { d \\over d \\xi } \\left( F F^{\\prime} \\over \\xi \\right) = c^2 \\left(k \\over z \\right)^2 \\left( 1 \\over \\xi \\right)\\left( F^{\\prime \\prime} - { F^{\\prime} \\over \\xi } \\right) \\left( 2 F^{\\prime \\prime \\prime} - 3 { F^{\\prime \\prime} \\over \\xi } + { F^{\\prime} \\over \\xi^2 }\\right) \\]\n\\[ { d \\over d \\xi } \\left( F F^{\\prime} \\over \\xi \\right) = c^2 { d \\over d \\xi } \\left( 1 \\over \\xi \\right)\\left( F^{\\prime \\prime} - { F^{\\prime} \\over \\xi } \\right)^2 \\]\nIntegrating both sides\n\\[ \\left( F F^{\\prime} \\over \\xi \\right) = c^2 \\left( 1 \\over \\xi \\right)\\left( F^{\\prime \\prime} - { F^{\\prime} \\over \\xi } \\right)^2 + \\mathrm{const}\\]\nBy applying the boundary conditions we find the constant of integration is zero, thus\n\\[ F F^{\\prime} = c^2 \\left( F^{\\prime \\prime} - { F^{\\prime} \\over \\xi } \\right)^2 \\]\nMaking the substitution \\(\\phi = a^{-1} \\xi\\) where \\(a = c^{2/3}\\)\n\\[ F F^{\\prime} = \\left( F^{\\prime \\prime} - { F^{\\prime} \\over \\phi } \\right)^2 \\]\n\\[ F^{\\prime \\prime} = { F^{\\prime} \\over \\phi } + \\sqrt{ F F^{\\prime} } \\]\nWhich is in a form that can be solved numerically.\n\n\nSolving the ODE\nWe can solve the ODE and perform the integral needed for the momentum balance at the same time. First we define a vector u such that:\n\\[ \\mathbf{u} = \\begin{bmatrix} u_{1} \\\\ u_{2} \\end{bmatrix} = \\begin{bmatrix} F \\\\ F^{\\prime} \\end{bmatrix}\\]\nThe ODE then becomes:\n\\[ {d \\mathbf{u} \\over dt } = \\begin{bmatrix} F^{\\prime} \\\\ F^{\\prime \\prime} \\end{bmatrix} = \\begin{bmatrix} u_{2} \\\\ \\frac{ u_{2} }{t} + \\sqrt{ u_{1} u_{2} } \\end{bmatrix} \\]\nWhich has a singularity at t=0, but one that can be easily dealt with by setting the initial value of the derivatives to8\n8 From the boundary conditions we know F’(0) = 0 but what about F’’? Taking the ratio\n\\[ { \\bar{v}_z \\over \\bar{v}_{z,max} }_{r=0} = - {F^{\\prime} \\over \\phi }_{\\phi=0} = 1 \\]\nwe find \\({F^{\\prime} \\over \\phi } = -1\\) at φ = 0 and, from the boundary conditions,\n\\[ F^{\\prime \\prime} = {F^{\\prime} \\over \\phi } \\]\nat φ = 0, therefore F’’(0) = -1\\[ {d \\mathbf{u} \\over dt }_{t=0} = \\begin{bmatrix} 0 \\\\ -1 \\end{bmatrix}\\]\nPutting that together, the ODE can be integrated easily9\n9 Because of how \\(\\bar{v}_z\\) and \\(\\bar{v}_r\\) were defined \\(-{ F^{\\prime} \\over \\phi } \\ge 0\\), i.e. \\({ F^{\\prime} \\over \\phi } \\le 0\\). For the signs to work out, \\(F \\le 0\\) and \\(F^{\\prime} \\le 0\\) (since \\(F F^{\\prime} \\ge 0\\))\nusing StaticArrays\nusing DifferentialEquations: ODEProblem, Tsit5, solve, TerminateSteadyState\n\nfunction sys(u,p,t)\n u₁, u₂ = u[1], u[2]\n if t > 0.0\n du₁ = u₂\n du₂ = u₂/t + √(max((u₁*u₂),0))\n else\n du₁ = 0.0\n du₂ = -1.0\n end\n \n return SA[du₁; du₂]\nend\n\nu0 = SA[0.0; 0.0]\ntspan = (0.0, 6.0)\nprob = ODEProblem(sys, u0, tspan)\nsol = solve(prob, Tsit5(), dtmax=0.1, callback=TerminateSteadyState())\n\nprint(sol.retcode)\n\nSuccess\n\n\n\n\n\n\n\n\n\n\nFigure 2: The numerical integration of F(φ), Prandtl mixing length theory.\n\n\n\n\n\nUsing the solution in terms of φ we can write a function f(ξ)\n\nfunction f_pml(ξ; a=0.066)\n ϕ = abs(ξ)/a\n \n if ϕ >0\n F, F′ = sol(ϕ)\n f = -F′/ϕ\n f = max(f, 0)\n else\n f = 1\n end\n \n return f\nend\n\n\n\nComparison with Tollmien\nThe classic treatment of the Prandtl mixing length model is from Tollmien10 in which, instead of solving numerically in the way shown above, the ODE is further transformed and a series expansion is used to generate a table of results. More often than not it is these tabulated values, or similar ones,11 that are presented as the solution to the model.\n10 “Berechnung Turbulenter Ausbreitungsvorgänge”.11 Rajaratnam, Turbulent Jets, 39. The table has an error at φ=1: the value of \\({F^{\\prime} \\over \\phi }\\) should be 0.606 but is given as 0.505 (presumably a typo).We can easily compare the result here with the tabulated values and verify for ourselves that we have indeed solved the right differential equation. Though by solving numerically in this way we can control the level of precision and easily generate smooth interpolations. In my opinion, this makes using the ODE solution far more convenient than the tabulated values.\n\n\n\n\n\n\n\n\nFigure 3: This solution versus the tabulated results of Tollmien, demonstrating that this is the correct solution but by a different means.\n\n\n\n\n\n\n\nWidth at Half Height\nThe width at half height, \\(b_{1/2}\\), is an important parameter and often velocity profiles are scaled relative to this. To compare different models on a fair basis, it is a good idea to determine what the model parameters are relative to \\(b_{1/2}\\). Then each model can be scaled to the same \\(b_{1/2}\\) and compared, apples-to-apples.\nIn this case we don’t have a closed form for the velocity profile so we need to solve for φ such that f(φ)=0 numerically.\n\nusing Roots: find_zero\n\nϕ_half = find_zero( ϕ -> f_pml(ϕ; a=1)-0.5, (1, 1.25))\n\n1.2277665940765845\n\n\nand we then write the model parameter a in terms of \\(b_{1/2}\\)\n\n\n\\[ a = \\frac{1}{\\phi_{1/2}} \\frac{b_{1/2}}{z} = 0.814 \\frac{b_{1/2}}{z} \\]\n\n\nUsing a default value for \\({ b_{1/2} \\over z } = 0.0848\\) we arrive at\n\nb_half = 0.0848\n\na = b_half/ϕ_half\n\n0.06906850244103516\n\n\nSeveral sources have tabulated values for a\n\n\n\na\nReference\n\n\n\n\n0.063\nTollmien12\n\n\n0.066\nRajaratnam13\n\n\n\n12 “Berechnung Turbulenter Ausbreitungsvorgänge”.13 Turbulent Jets.and the result of this notebook compares with those\n\n\nVelocity Profile\nNow that we have completed the integration we can calculate the parameter k, using the equation derived from the momentum balance\n\\[ k = \\sqrt{1 \\over 8 I } v_0 d_0 \\]\nwith the value of the integral coming directly from the ode solver\n\nusing NumericalIntegration: integrate\n\nϕ, F′ = sol.t, sol[2,:]\n\n# trim any unphysical values\nF′[F′.>0] .= 0.0\n\nfunction integrand(ϕ, F′)\n if ϕ>0\n return F′^2/ϕ\n else\n return 0\n end\nend\n\nI = integrate(ϕ, integrand.(ϕ, F′))\nI = a^2 * I\n\n0.002573069044757039\n\n\nAllowing us to write the velocity profile as\n\n\n\\[ \\bar{v}_z = 6.97 { v_0 d_0 \\over z} f(\\xi) \\]" + }, + { + "objectID": "posts/turbulent_jet_notes/index.html#eddy-viscosity", + "href": "posts/turbulent_jet_notes/index.html#eddy-viscosity", + "title": "Turbulent Jets", + "section": "Eddy Viscosity", + "text": "Eddy Viscosity\nThe eddy viscosity model makes the assumption that the turbulent shear stress depends on the rate of strain in a manner that is analogous to laminar flow, with the constant of proportionality being the eddy viscosity ε:\n\\[ \\bar{\\tau}_{rz} = - \\rho \\varepsilon {\\partial \\bar{v}_z \\over \\partial r}\\]\n\nSetting up the ODE\nRecall that the equation of motion in the z direction is (in terms of the unitless function F)\n\\[ \\left( k \\over z \\right)^2 { d \\over d \\xi } \\left( F F^{\\prime} \\over \\xi \\right) = {1 \\over \\rho} {\\partial \\over \\partial r} \\left( r \\bar{\\tau}_{rz} \\right) \\]\nSubstituting the expression for \\(\\bar{\\tau}_{rz}\\) we have\n\\[ \\left( k \\over z \\right)^2 { d \\over d \\xi } \\left( F F^{\\prime} \\over \\xi \\right) = - \\varepsilon {\\partial \\over \\partial r} \\left( r \\left( \\partial \\bar{v}_{z} \\over \\partial r \\right) \\right) \\]\n\\[ \\left( k \\over z \\right)^2 { d \\over d \\xi } \\left( F F^{\\prime} \\over \\xi \\right) = - \\varepsilon \\left( \\left( \\partial \\bar{v}_{rz} \\over \\partial r \\right) + r \\left( \\partial^2 \\bar{v}_{rz} \\over \\partial r^2 \\right) \\right) \\]\nSubstituting in the expressions for \\({\\partial \\bar{v}_z \\over \\partial r}\\) and \\({\\partial^2 \\bar{v}_z \\over \\partial r^2}\\) we arrive at\n\\[ \\left( k \\over z \\right)^2 { d \\over d \\xi } \\left( F F^{\\prime} \\over \\xi \\right) = { k \\varepsilon \\over z^2} \\left( F^{\\prime \\prime \\prime} - { F^{\\prime \\prime} \\over \\xi } + { F^{\\prime} \\over \\xi^2 } \\right) \\]\n\\[ \\left( k \\over z \\right)^2 { d \\over d \\xi } \\left( F F^{\\prime} \\over \\xi \\right) = { k \\varepsilon \\over z^2} { d \\over d \\xi } \\left( F^{\\prime \\prime} - { F^{\\prime} \\over \\xi } \\right) \\]\nat this point we note that k and ε have the same units of \\([[ \\mathrm{length} ]]^2 \\times [[ \\mathrm{time} ]]^{-1}\\) and are independent of z and ξ, so we propose that \\(\\varepsilon = c k\\) where c is some unknown constant of proportionality.\n\\[ \\left( k \\over z \\right)^2 { d \\over d \\xi } \\left( F F^{\\prime} \\over \\xi \\right) = c \\left( k \\over z \\right)^2 { d \\over d \\xi } \\left( F^{\\prime \\prime} - { F^{\\prime} \\over \\xi } \\right) \\]\n\\[ { d \\over d \\xi } \\left( F F^{\\prime} \\over \\xi \\right) = c { d \\over d \\xi } \\left( F^{\\prime \\prime} - { F^{\\prime} \\over \\xi } \\right) \\]\nIntegrating both sides\n\\[ { F F^{\\prime} \\over \\xi } = c \\left( F^{\\prime \\prime} - { F^{\\prime} \\over \\xi } \\right) + \\mathrm{const}\\]\nBy applying the boundary conditions we find the constant of integration is zero, thus\n\\[ F F^{\\prime} = c \\left( \\xi F^{\\prime \\prime} - F^{\\prime} \\right) \\]\n\\[{ d \\over d \\xi } \\left( \\frac{1}{2} F^2 \\right) = c { d \\over d \\xi } \\left( \\xi F^{\\prime} - 2 F \\right) \\]\nIntegrating both sides\n\\[ \\frac{1}{2} F^2 = c \\left( \\xi F^{\\prime} - 2 F \\right) + \\mathrm{const}\\]\nBy applying the boundary conditions we find the constant of integration is zero, thus\n\\[ c \\xi F^{\\prime} = \\frac{1}{2} F^2 + 2c F \\]\nWhich is separable\n\\[ \\int { d \\xi \\over \\xi} = \\int { c \\over {\\frac{1}{2} F^2 + 2c F} } dF \\]\nIntegrating one last time\n\\[ \\log \\left( C_1 \\xi \\right) = \\frac{1}{2} \\log \\left( F \\over F + 4 c \\right) \\]\nWhere C1 is an undetermined constant of integration. Re-arranging and solving for F we arrive at\n\\[ F\\left( \\xi \\right) = { 4 c C_1 \\xi^2 \\over {1 - C_1 \\xi^2 } } \\]\nA common substitution is \\(C_1 = - \\left( C_2 \\over 2 \\right)^2\\) then\n\\[ F\\left( \\xi \\right) = { - c \\left( C_2 \\xi \\right)^2 \\over {1 + \\frac{1}{4} \\left( C_2 \\xi \\right)^2 } } \\]\nWhat we need, for the velocity profile, is the first derivative of F, which is\n\\[ F^{\\prime}\\left( \\xi \\right) = { - 2 c C_2^2 \\xi \\over \\left( 1 + \\left( C_2 \\xi \\over 2 \\right)^2 \\right)^2 } \\]\nand finally\n\\[ \\bar{v}_z = -{k \\over z} {F^{\\prime} \\over \\xi} \\]\n\\[ = -{k \\over z} { 1 \\over \\xi }{ - 2 c C_2^2 \\xi \\over \\left( 1 + \\left( C_2 \\xi \\over 2 \\right)^2 \\right)^2 } \\]\n\\[ = {2 \\varepsilon C_2^2 \\over z} \\left( 1 + \\left( C_2 \\xi \\over 2 \\right)^2 \\right)^{-2} \\]\n\\[ f \\left( \\xi \\right) = { \\bar{v}_z \\over \\bar{v}_{z,max} } = \\left( 1 + \\left( C_2 \\xi \\over 2 \\right)^2 \\right)^{-2} \\]\n\nf_ev(ξ; C₂=15.1) = ( 1 + (C₂*ξ/2)^2 )^-2\n\n\n\nWidth at Half Height\nSince we have a convenient closed form for the velocity profile, we can calculate what the parameter \\(C_2\\) is in terms of the width at half height rather easily.\n\\[ f(\\xi) = \\left( 1 + \\left( C_2 \\xi \\over 2 \\right)^2 \\right)^{-2} \\]\n\\[ \\frac{1}{2} = \\left( 1 + \\left( {C_2 \\over 2} { b_{1/2} \\over z }\\right)^2 \\right)^{-2} \\]\n\\[ C_2 = 2 \\sqrt{\\sqrt{2}-1} \\frac{z}{ b_{1/2} } \\]\nusing the same parameterization as above we get\n\nC₂ = 2*√(√(2)-1)/b_half\n\n15.179109738339214\n\n\n\n\nVelocity Profile\nReturning to the momentum balance, we need to solve the integral:\n\\[ I = \\int_{0}^{\\infty} f\\left( \\xi \\right)^2 \\xi d \\xi\\\\\n= \\int_{0}^{\\infty} \\xi \\left( 1 + \\left( C_2 \\xi \\over 2 \\right)^2 \\right)^{-4} d\\xi \\]\nWhich can be integrated to give \\[ I = {2 \\over 3} C_2^{-2} \\]\nand finally\n\\[ k = \\sqrt{ 3 \\over 16 } C_2 v_0 d_0 \\]\nwith the velocity profile as\n\n\n\\[ \\bar{v}_z = 6.57 { v_0 d_0 \\over z} \\left( 1 + 57.6 \\xi^2 \\right)^{-2} \\]" + }, + { + "objectID": "posts/turbulent_jet_notes/index.html#empirical-velocity-profiles", + "href": "posts/turbulent_jet_notes/index.html#empirical-velocity-profiles", + "title": "Turbulent Jets", + "section": "Empirical Velocity Profiles", + "text": "Empirical Velocity Profiles\nPerhaps the most widely used turbulent jet model is simply an empirical gaussian fit to the data. These are easy to use – no solving of ODEs required – and fitting them to data is relatively straight forward. There is no real theoretical basis that I am aware of, merely based on the observation that a gaussian function fits the velocity profile well.\n\\[ f \\left( \\xi \\right) = \\exp \\left( -c \\xi^2 \\right) \\]\nWhere c is a parameter determined by fitting to a dataset.\n\nf_emp(ξ; c=72) = exp(-c*ξ^2)\n\n\nWidth at Half Height\nSince we have a convenient closed form for the velocity profile, we can calculate what the parameter \\(c\\) is in terms of the width at half height rather easily\n\\[ f(\\xi) = \\exp \\left( -c \\xi^2 \\right) \\]\n\\[ \\frac{1}{2} = \\exp \\left( - c \\left( \\frac{ b_{1/2} }{z} \\right)^2 \\right) \\]\n\\[ c = \\ln \\left( 2 \\right) \\left( \\frac{z}{ b_{1/2} } \\right)^2 \\]\nusing the same parameterization as above we get\n\nc = log(2)/b_half^2\n\n96.39039423504045\n\n\n\n\nVelocity Profile\nReturning to the momentum balance, we need to solve the integral:\n\\[ I = \\int_{0}^{\\infty} f\\left( \\xi \\right)^2 \\xi d \\xi\\\\\n= \\int_{0}^{\\infty} \\xi \\exp \\left( -2 c \\xi^2 \\right) d\\xi \\]\nWhich can be integrated to give\n\\[ I = {1 \\over 4 c} \\]\nand finally\n\\[ k = \\sqrt{ c \\over 2 } v_0 d_0 \\]\nwith the velocity profile as\n\n\n\\[ \\bar{v}_z = 6.94 { v_0 d_0 \\over z} \\exp\\left( -193.0 \\xi^2 \\right) \\]" + }, + { + "objectID": "posts/turbulent_jet_notes/index.html#comparing-the-models", + "href": "posts/turbulent_jet_notes/index.html#comparing-the-models", + "title": "Turbulent Jets", + "section": "Comparing the Models", + "text": "Comparing the Models\nAt this point two models of velocity were derived using different models of the free turbulent stress and one purely empirical model was introduced. Each of these models uses a different set of parameters, and have different strengths and weaknesses in terms of usability. To compare them like-for-like we can scale each to the same width at half height, which is shown below along with some measured data14\n14 Pope, Turbulent Flows, points captured from a figure using WebPlotDigitizer.We can also calculate a Mean Square Error (MSE) and evaluate which model is a better fit to the observed velocity profile.\n\n\n\n\n\n\n\n\nFigure 4: Comparing all three turbulent jet models to the observed velocity profile.\n\n\n\n\n\nPrandtl Mixing Length Model MSE 0.00051\nEddy Viscosity Model MSE 0.00092\nGaussian (empirical) Model MSE 0.00054\n\n\nInterestingly the Prandtl mixing length model works the best, though the gaussian fit is close enough as to be essentially the same given this data set. Which is convenient as a gaussian fit is easier to work with. The eddy viscosity model is the easiest to derive, however it clearly does not work as well for the outer parts of the jet.\nThe above approach, the one you will most likely see in the literature, compares each model scaled to the same height and width. Which is sensible if one is planning on fitting data, and allowing that the height and width to be free parameters. However we know, from the analysis above, that the height of each model is dependent upon the width, so might be instructive to look at how that plays out in practice.\nSuppose we are looking at a velocity profile far enough downstream to be in the fully developed flow, say \\(z = 7 d_0\\)\n\n\n\n\n\n\n\n\nFigure 5: A comparison of the three turbulent jet models with an identical half-width, with the height calculated from the momentum balance.\n\n\n\n\n\nNote that in the region near the center-line the three models are no longer particularly close to one another and the eddy viscosity and prandtl mixing length models have changed places. Relative to the predicted \\(v_{max}\\) the the eddy viscosity model stays high when compared to the prandtl mixing length model, however the eddy viscosity model predicts a lower \\(v_{max}\\) such that the effect is entirely reversed.\nIt’s also worth noting that the gaussian fit and the prandtl mixing length model track one another reasonably well. I have seen a gaussian fit of the Tollmien tabulated results used in some papers when a smooth interpolation of the intermediate values is required and this suggests that may not be a bad idea. Though, to me, just solving the ode is easier. On a modern machine it takes milliseconds or less and a good ode package like DifferentialEquations.jl provides a higher-order interpolation for free.\nThis comparison has been done with each of the model parameters set based on a shared width. However there are as many different ways of arriving at the model parameters as there are datasets to fit against. There is a wide spread in tabulated values in the literature and so the predictions of two independently arrived at models can be quite different due all of these factors coming together." + }, + { + "objectID": "posts/turbulent_jet_notes/index.html#where-to-go-from-here", + "href": "posts/turbulent_jet_notes/index.html#where-to-go-from-here", + "title": "Turbulent Jets", + "section": "Where to go from here", + "text": "Where to go from here\nAll of this work was to determine the velocity field, which is not necessarily what anyone cares about. In a release scenario, for example, it is concentration that is most relevant. For a heat transfer application, perhaps, you may care about the temperature field instead. However, with the velocity field the concentrations, temperatures, total entrained flow, etc. can be easily derived." + }, + { + "objectID": "posts/turbulent_jet_notes/index.html#references", + "href": "posts/turbulent_jet_notes/index.html#references", + "title": "Turbulent Jets", + "section": "References", + "text": "References\n\n\nBird, R. Byron, Warren E. Stewart, and Edwin N. Lightfoot. Transport Phenomena. 2nd ed. Hoboken, NJ: John Wiley & Sons, 2007.\n\n\nGarde, R. J. Turbulent Flows. 3rd ed. London: New Academic Science, 2010.\n\n\nPope, Stephen B. Turbulent Flows. Cambridge: Cambridge University Press, 2000.\n\n\nRajaratnam, N. Turbulent Jets. Amsterdam: Elsevier, 1974.\n\n\nTollmien, Walter. “Berechnung Turbulenter Ausbreitungsvorgänge.” Zeitschrift Für Angewandte Mathematik Und Mechanik 6 (1926): 468–78. https://doi.org/10.1002/zamm.19260060604reprinted and translated in NACA-TM-1085." + }, + { + "objectID": "posts/fugitive-hydrogen/index.html", + "href": "posts/fugitive-hydrogen/index.html", + "title": "Estimating the impact of fugitive emissions", + "section": "", + "text": "As Alberta continues down it’s path to the hydrogen economy, with more industrial facilities transitioning to hydrogen as a fuel, and more producers of hydrogen announcing new plants and expansions, questions around the impact of fugitive hydrogen emissions linger." + }, + { + "objectID": "posts/fugitive-hydrogen/index.html#the-climate-impacts-of-fugitive-hydrogen", + "href": "posts/fugitive-hydrogen/index.html#the-climate-impacts-of-fugitive-hydrogen", + "title": "Estimating the impact of fugitive emissions", + "section": "The climate impacts of fugitive hydrogen", + "text": "The climate impacts of fugitive hydrogen\nHydrogen 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”.\n1 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.\nHydrogen 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." + }, + { + "objectID": "posts/fugitive-hydrogen/index.html#a-first-look-at-estimating-leak-rates", + "href": "posts/fugitive-hydrogen/index.html#a-first-look-at-estimating-leak-rates", + "title": "Estimating the impact of fugitive emissions", + "section": "A first look at estimating leak rates", + "text": "A first look at estimating leak rates\nThe 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:\n\nNatural gas is entirely methane and the hydrogen fuel gas is pure hydrogen.\nMethane and hydrogen are ideal gases\nFugitive emissions all come from leaks, which are just holes in the pressure envelope\nThe system pressure is high enough that flow is choked\n\nThe 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.\nThe 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.\nThe 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.\nThe 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.\nPulling 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:\n\\[ \\dot{m} = c_d A_h \\sqrt{ \\rho_1 P_1 k \\left( 2 \\over k+1 \\right)^{k+1 \\over k-1} } \\]\nThe ratio of mass flow of hydrogen to that of methane is then:\n\\[ {\\dot{m}_{H2} \\over \\dot{m}_{CH4} } = \\sqrt{ {\\rho_{H2} \\over \\rho_{CH4}} {P_{1,H2} \\over P_{1,CH4}} { {k_{H2} \\left( 2 \\over k_{H2}+1 \\right)^{k_{H2}+1 \\over k_{H2}-1} } \\over {k_{CH4} \\left( 2 \\over k_{CH4}+1 \\right)^{k_{CH4}+1 \\over k_{CH4}-1} } } }\\]\nAssuming the system pressure, P1, after having switched to hydrogen, is the same as the system pressure when operating natural gas.\n\\[ {\\dot{m}_{H2} \\over \\dot{m}_{CH4} } = \\sqrt{ {\\rho_{H2} \\over \\rho_{CH4}} { {k_{H2} \\left( 2 \\over k_{H2}+1 \\right)^{k_{H2}+1 \\over k_{H2}-1} } \\over {k_{CH4} \\left( 2 \\over k_{CH4}+1 \\right)^{k_{CH4}+1 \\over k_{CH4}-1} } } }\\]\nFor 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.\nBecause 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 \\(k_{H2}\\) is within 10% of \\(k_{CH4}\\) then\n\\[ { {k_{H2} \\left( 2 \\over k_{H2}+1 \\right)^{k_{H2}+1 \\over k_{H2}-1} } \\over {k_{CH4} \\left( 2 \\over k_{CH4}+1 \\right)^{k_{CH4}+1 \\over k_{CH4}-1} } } \\approx 1 \\]\nand thus\n\\[ {\\dot{m}_{H2} \\over \\dot{m}_{CH4} } = \\sqrt{ {\\rho_{H2} \\over \\rho_{CH4}} } = \\sqrt{ {MW_{H2} \\over MW_{CH4}} } \\]\nputting this in terms of emissions in CO2-e, with \\(E_i = GWP_i \\cdot \\dot{m}_i\\)\n\\[ { E_{H2} \\over E_{CH4} } = { {GWP}_{H2} \\over {GWP}_{CH4} } \\sqrt{ MW_{H2} \\over MW_{CH4} } \\approx \\frac{12}{30} \\sqrt{ \\frac{2}{16} } \\approx 0.13 \\]\nand so we expect a ~87% reduction in fugitive emissions (in CO2-e) after having transitioned the system from natural gas to hydrogen.\nSince 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\n5 Crane, “TP410M”.\nusing Unitful\n\n# GWPs: Forster et al. \"The Earth's Energy Budget,\" 1017.\n# Sand et al. \"Multi Model Assessment,\" 5.\n#\n# Fluid properties: Crane's *Flow of Fluids*, A-6 and A-9\n\n# Methane\nGWP_CH4 = 29.8 # t-CO2e/t\nMW_CH4 = 16.043u\"g/mol\"\nμ_CH4 = 0.01103u\"cP\" # at 20°C\nk_CH4 = 1.31\n\n# Hydrogen\nGWP_H2 = 11.6 # t-CO2e/t\nMW_H2 = 2.016u\"g/mol\"\nμ_H2 = 0.008804u\"cP\" # at 20°C\nk_H2 = 1.41\n\n\ng(k) = k*(2/(k+1))^((k+1)/(k-1))\n\nE_H2 = GWP_H2*√(MW_H2*g(k_H2))\nE_CH4 = GWP_CH4*√(MW_CH4*g(k_CH4))\n\nE_H2/E_CH4\n\n0.1415674991761294\n\n\nI 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\n\\[ {P_1 \\over P_2} \\lt \\left( 2 \\over {k+1} \\right)^{ -k \\over {k-1} } \\]\nwhere (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.\n\n# choking condition\nη_c(k) = (2/(k+1))^(-k/(k-1))\n\nP₂ = 101.325u\"kPa\" # atmospheric pressure\n\nP₁(k) = η_c(k)*P₂\n\nPₘᵢₙ = min(P₁(k_H2),P₁(k_CH4))\n\n186.28417600555758 kPa\n\n\nor in terms of psi (absolute)\n\nuconvert(u\"psi\",Pₘᵢₙ)\n\n27.018235462782194 psi\n\n\nSystem 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\n6 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." + }, + { + "objectID": "posts/fugitive-hydrogen/index.html#leaks-as-a-series-of-tubes", + "href": "posts/fugitive-hydrogen/index.html#leaks-as-a-series-of-tubes", + "title": "Estimating the impact of fugitive emissions", + "section": "Leaks as a series of tubes", + "text": "Leaks as a series of tubes\nAfter 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.\nThe 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.\n7 Mejia, Brouwer, and Kinnon; Swain and Swain, “A Comparison of \\(H_2\\), \\(CH_4\\) and \\(C_3 H_8\\) 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.\nStarting from the Darcy-Weisbach equation, in terms of the Fanning friction factor, f, for incompressible flow\n\\[ \\Delta P = 2 f \\frac{L}{D} \\rho u^2 \\]\n\\[ u = \\sqrt{ {\\Delta P D} \\over {2 \\rho f L} } \\]\nThe volumetric flow, Q, would be\n\\[ Q = \\frac{\\pi}{4} u D^2 \\]\n\\[ Q = \\frac{ \\sqrt{2} }{8} \\pi \\sqrt{ {\\Delta P D^5} \\over {2 \\rho f L} } \\]\nwhere Δ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\n\\[ { Q_{H2} \\over Q_{CH4} } = \\sqrt{ { \\rho_{CH4} \\over \\rho_{H2} } { f_{CH4} \\over f_{H2} } }\\]\nThis 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)\n\\[ { Q_{H2} \\over Q_{CH4} } = \\sqrt{ { \\rho_{CH4} \\over \\rho_{H2} } } = \\sqrt{ MW_{CH4} \\over MW_{H2} }\\]\nIf we assume laminar flow \\(f = \\frac{16}{ \\mathrm{Re} }\\) and\n\\[ { Q_{H2} \\over Q_{CH4} } = \\sqrt{ { \\rho_{CH4} \\over \\rho_{H2} } { \\mathrm{Re}_{H2} \\over \\mathrm{Re}_{CH4} } } \\]\nFor pipe-flow \\(\\mathrm{Re} = \\frac{4}{\\pi} { { \\rho Q } \\over { \\mu D } }\\), which after substitution gives\n\\[ { Q_{H2} \\over Q_{CH4} } = \\sqrt{ { Q_{H2} \\over Q_{CH4} } { \\mu_{CH4} \\over \\mu_{H2} } } \\]\nand, after squaring both sides and canceling\n\\[ { Q_{H2} \\over Q_{CH4} } = { \\mu_{CH4} \\over \\mu_{H2} } \\]\nThese two equations are the ultimate source for most of the bounds given on the relative leak-rate of hydrogen fugitives versus natural gas fugitives.\n\nturbulent_leak_ratio = √(MW_CH4/MW_H2)\n\n2.8209638958319374\n\n\n\nlaminar_leak_ratio = μ_CH4/μ_H2\n\n1.2528396183552932\n\n\nI 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.\nFor turbulent flow:\n\\[ { \\dot{m}_{H2} \\over \\dot{m}_{CH4} } = \\sqrt{ { \\rho_{H2} \\over \\rho_{CH4} } } = \\sqrt{ MW_{H2} \\over MW_{CH4} }\\]\nand for laminar flow:\n\\[ { \\dot{m}_{H2} \\over \\dot{m}_{CH4} } = { \\rho_{H2} \\over \\rho_{CH4} } { \\mu_{CH4} \\over \\mu_{H2} } = { MW_{H2} \\over MW_{CH4} } { \\mu_{CH4} \\over \\mu_{H2} } \\]\n\nturbulent_mass_ratio = √(MW_H2/MW_CH4)\n\n0.3544887623260728\n\n\n\nlaminar_mass_ratio = (MW_H2/MW_CH4)*(μ_CH4/μ_H2)\n\n0.1574346861936216\n\n\nThe 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\n8 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 \\(H_2\\), \\(CH_4\\) and \\(C_3 H_8\\) 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\n\nMolecular flow\nIt 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.\n10 Mejia, Brouwer, and Kinnon, “Hydrogen Leaks at the Same Rate as Natural Gas in Typical Low-Pressure Gas Infrastructure,” 8814–15.\nmolecular_flow_mass_ratio = MW_H2/MW_CH4\n\n0.12566228261547094" + }, + { + "objectID": "posts/fugitive-hydrogen/index.html#relative-importance-of-fugitive-emissions", + "href": "posts/fugitive-hydrogen/index.html#relative-importance-of-fugitive-emissions", + "title": "Estimating the impact of fugitive emissions", + "section": "Relative importance of fugitive emissions", + "text": "Relative importance of fugitive emissions\nFugitive 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.\nAs 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).\nMy model for fugitive emissions will be quite simple: some fraction η of flow is lost from the system and the emissions, in CO2 equivalents is\n\\[ E_f = GWP_{H2} \\cdot \\rho_{H2} \\cdot \\eta \\cdot Q_{H2} \\]\nWhen hydrogen undergoes combustion it produces water\n\\[ H_2 + \\frac{1}{2}O_2 \\rightarrow H_2O \\]\nSince 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\n11 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..\\[ E_c = GWP_{N2O} \\cdot EF_{N2O} \\cdot HHV_{H2} \\cdot (1-\\eta) \\cdot Q_{H2} \\]\nWhere EF is the emission factor for nitrous oxide and HHV is the higher heating value of hydrogen.\nThe ratio of fugitive to combustion emissions is then12\n12 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.\\[ { E_f \\over E_c } = { {GWP_{H2} \\cdot \\rho_{H2}} \\over { GWP_{N2O} \\cdot EF_{N2O} \\cdot HHV_{H2} } } \\cdot {\\eta \\over {1-\\eta}} \\]\n\nSG_H2 = 0.0696 # GPSA\nρ_air = 1.225u\"kg/m^3\" # GPSA, at 15°C and 1atm\nρ_H2 = SG_H2*ρ_air\n\nGWP_N2O = 273 # Forster et al., 1017.\nEF_N2O = 8.7e-7u\"kg/MJ\" # AEPA, 1-9 Industrial\nHHV_H2 = 12.102u\"MJ/m^3\" # GPSA, at 15°C and 1atm\n\nfugitives_to_combustion(η) = ((GWP_H2*ρ_H2)/(GWP_N2O*EF_N2O*HHV_H2))*(η/(1-η));\n\nAssuming that the leak rate is 1% we then have\n\nfugitives_to_combustion(0.01)\n\n3.4755942870304137\n\n\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 1: The ratio of fugitive emissions to combustion emissions, as a function of leakage rate.\n\n\n\n\nAt any appreciable leak percentage the amount of hydrogen lost to fugitive emissions rivals the stack emissions for climate impact." + }, + { + "objectID": "posts/fugitive-hydrogen/index.html#fugitive-hydrogen-and-net-zero", + "href": "posts/fugitive-hydrogen/index.html#fugitive-hydrogen-and-net-zero", + "title": "Estimating the impact of fugitive emissions", + "section": "Fugitive hydrogen and “net zero”", + "text": "Fugitive hydrogen and “net zero”\nMore 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.\nFor the natural gas system the fugitive emissions are similar, except that I am assuming the only climate relevant component of natural gas is methane\n\\[ E_f = GWP_{CH4} \\cdot \\rho_{CH4} \\cdot x_{CH4} \\cdot \\eta \\cdot Q_{NG}\\]\nand the combustion emissions now include carbon dioxide and methane along with nitrous oxide\n\\[ E_c = \\left( GWP_{CO2} \\cdot EF_{CO2} + GWP_{CH4} \\cdot EF_{CH4} + GWP_{N2O} \\cdot EF_{N2O} \\right) \\left( 1 - \\eta \\right) Q_{NG} \\]\nTotal emissions are just \\(E_T = E_f + E_c\\).\nWhat we are interested in is the ratio\n\\[ { E_{T,H2} \\over E_{T,NG} } \\]\n\nSome more simplifying assumptions\nThere 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.\nThe 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.\nSuppose the leaks are all in the turbulent regime, so\n\\[ {Q_{leak,H2} \\over Q_{leak,NG}} = \\sqrt{\\rho_{NG} \\over \\rho_{H2}} \\]\nFor fully developed turbulent pipe flow we know the ratio of line flow rates is also\n\\[ { Q_{H2} \\over Q_{NG} } = \\sqrt{\\rho_{NG} \\over \\rho_{H2}} \\]\nBy the definition of η\n\\[ \\eta_{H2} = { Q_{leak,H2} \\over Q_{H2} } = { Q_{leak,H2} \\over Q_{leak,CH4} } { Q_{CH4} \\over Q_{H2} } { Q_{leak,CH4} \\over Q_{CH4} } \\]\n\\[ \\eta_{H2} = \\sqrt{\\rho_{NG} \\over \\rho_{H2}} \\sqrt{\\rho_{H2} \\over \\rho_{NG}} \\eta_{CH4} \\]\n\\[ \\eta_{H2} = \\eta_{CH4} \\]\n\n\nRelative emissions of switching to hydrogen\nTo make the math a little less tedious to type out, I am going to define two emission factors, the fugitive emission factor13\n13 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.\\[ EF_f = {E_f \\over Q_T} \\]\nand the combustion emission factor\n\\[ EF_c = {E_c \\over Q_T} \\]\nFinally we can answer the question of “how much do the total emissions go down after switching to hydrogen?”\n\\[ { E_{T,H2} \\over E_{T,NG} } = { \\left[ EF_{c} ( 1 - \\eta ) + EF_{f} \\eta \\right]_{H2} \\over \\left[ EF_{c} ( 1 - \\eta ) + EF_{f} \\eta \\right]_{NG} } { Q_{H2} \\over Q_{NG} }\\]\n\\[ { E_{T,H2} \\over E_{T,NG} } = { \\left[ EF_{c} ( 1 - \\eta ) + EF_{f} \\eta \\right]_{H2} \\over \\left[ EF_{c} ( 1 - \\eta ) + EF_{f} \\eta \\right]_{NG} } \\sqrt{ \\rho_{NG} \\over \\rho_{H2} }\\]\n\\[ { E_{T,H2} \\over E_{T,NG} } = { \\left[ EF_{c} ( 1 - \\eta ) + EF_{f} \\eta \\right]_{H2} \\over \\left[ EF_{c} ( 1 - \\eta ) + EF_{f} \\eta \\right]_{NG} } \\sqrt{ {SG}_{NG} \\over {SG}_{H2} }\\]\n\n# hydrogen\nEF_f_H2 = GWP_H2*ρ_H2\nEF_c_H2 = GWP_N2O*EF_N2O*HHV_H2\n\n# methane\nSG_CH4 = 0.5539 # GPSA\nρ_CH4 = SG_CH4*ρ_air\n\n# natural gas\nx_CH4 = 0.90 # Alberta typical\nSG_NG = 0.61 # Alberta typical\nEF_CO2_NG = 1.962u\"kg/m^3\" # ECCC, 3.\nEF_CH4_NG = 3.7e-5u\"kg/m^3\" # ECCC, 3.\nEF_N2O_NG = 3.3e-5u\"kg/m^3\" # ECCC, 3.\n\nEF_f_NG = GWP_CH4*ρ_CH4*x_CH4\nEF_c_NG = EF_CO2_NG + GWP_CH4*EF_CH4_NG + GWP_N2O*EF_N2O_NG\n\n# Final answer\nemissions_ratio(η) = ((EF_c_H2*(1-η)+EF_f_H2*η)/(EF_c_NG*(1-η)+EF_f_NG*η))*√(SG_NG/SG_H2);\n\n\nemissions_ratio(0.01)\n\n0.017665064441514864\n\n\nSo 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.\n\n\n\n\n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nFigure 2: The total emissions, in CO2-e, of hydrogen relative to natural gas." + }, + { + "objectID": "posts/fugitive-hydrogen/index.html#final-thoughts", + "href": "posts/fugitive-hydrogen/index.html#final-thoughts", + "title": "Estimating the impact of fugitive emissions", + "section": "Final thoughts", + "text": "Final thoughts\nEven 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.\nHydrogen 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." + }, + { + "objectID": "posts/fugitive-hydrogen/index.html#references", + "href": "posts/fugitive-hydrogen/index.html#references", + "title": "Estimating the impact of fugitive emissions", + "section": "References", + "text": "References\n\n\nAlberta Greenhouse Gas Quantification Methodologies (version 2.3). Edmonton, AB: Alberta Environment; Protected Areas, 2023. https://open.alberta.ca/publications/alberta-greenhouse-gas-quantification-methodologies.\n\n\nBertagni, Matteo B., Stephen W. Pacala, Fabien Paulot, and Amilcare Porporato. “Risk of the Hydrogen Economy for Atmospheric Methane.” Nature Communications 13 (2023). https://doi.org/10.1038/s41467-022-35419-7.\n\n\nColorado, Andrés, Vincent McDonell, and Scott Samuelsen. “Direct Emissions of Nitrous Oxide from Combustion of Gaseous Fuels.” International Journal of Hydrogen Energy 42, no. 1 (2017): 711–19. https://doi.org/10.1016/j.ijhydene.2016.09.202.\n\n\nCrane. “TP410M: Flow of Fluids.” Stamford, CT: Crane, 2013.\n\n\nDutta, Indranil, Rajesh Kumar Parsapur, Sudipta Chatterjee, Amol M. Hengne, Davin Tan, Karthik Peramaiah, Theis I. Solling, Ole John Nielsen, and Kuo-Wei Huang. “The Role of Fugitive Hydrogen Emissions in Selecting Hydrogen Carriers.” ACS Energy Letters 8, no. 7 (2023): 3251–57. https://doi.org/10.1021/acsenergylett.3c01098.\n\n\nEmission Factors and Reference Values (version 1.1). Gatineau, QC: Environment; Climate Change Canada, 2023. https://publications.gc.ca/collections/collection_2023/eccc/En84-294-2023-eng.pdf.\n\n\nForster, Piers, Trude Storelvmo, Kyle Armour, William Collins, Jean-Louis Dufresne, David Frame, Daniel J. Lunt, et al. “The Earth’s Energy Budget, Climate Feedbacks, and Climate Sensitivity.” In Climate Change 2021: The Physical Science Basis. Contribution of Working Group i to the Sixth Assessment Report of the Intergovernmental Panel on Climate Change, edited by Valérie Masson-Delmonte, Panmao Zhai, Anna Pirani, Sarah L. Connors, Clotilde Péan, Yang Chen, Leah Goldfarb, et al., 923–1054. Cambridge: Cambridge University Press, 2023.\n\n\nFrazer-Nash Consultancy. “Fugitive Hydrogen Emissions in a Future Hydrogen Economy.” London, UK: UK Department for Business, Energy,; Industrial Strategy, 2022. https://www.gov.uk/government/publications/fugitive-hydrogen-emissions-in-a-future-hydrogen-economy/.\n\n\nGPSA. Engineering Data Book. 13th ed. Tulsa, OK: Gas Processors Suppliers Association, 2012.\n\n\nMasson-Delmonte, Valérie, Panmao Zhai, Anna Pirani, Sarah L. Connors, Clotilde Péan, Yang Chen, Leah Goldfarb, et al., eds. Climate Change 2021: The Physical Science Basis. Contribution of Working Group i to the Sixth Assessment Report of the Intergovernmental Panel on Climate Change. Cambridge: Cambridge University Press, 2023.\n\n\nMejia, Alejandra Hormaza, Jacob Brouwer, and Michael Mac Kinnon. “Hydrogen Leaks at the Same Rate as Natural Gas in Typical Low-Pressure Gas Infrastructure.” International Journal of Hydrogen Energy 45, no. 15 (2020): 8810–25. https://doi.org/10.1016/j.ijhydene.2019.12.159.\n\n\nOcko, Ilissa B., and Steven P. Hamburg. “Climate Consequences of Hydrogen Emissions.” Atmospheric Chemistry and Physics 22, no. 14 (2022): 9349–68. https://doi.org/10.5194/acp-22-9349-2022.\n\n\nSand, Maria, Ragnhild Bieltvedt Skeie, Marit Sandstad, Srinath Krishnan, Gunnar Myhre, Hannah Bryant, Richard Derwent, et al. “A Multi-Model Assessment of the Global Warming Potential of Hydrogen.” Communications Earth & Environment 4 (2023): 203. https://doi.org/10.1038/s43247-023-00857-8.\n\n\nSchefer, R. W., W. G. Houf, C. San Marchi, W. P. Chernicoff, and L. Englom. “Characterization of Leaks from Compressed Hydrogen Dispensing Systems and Related Components.” International Journal of Hydrogen Energy 31, no. 9 (2006): 1247–60. https://doi.org/10.1016/j.ijhydene.2005.09.003.\n\n\nSmith, Chris, Zebedee R. J. Nicholls, Kyle Armour, William Collins, Piers Forster, Malte Meinshausen, Matthew D. Palmer, et al. “The Earth’s Energy Budget, Climate Feedbacks, and Climate Sensitivity Supplementary Material.” In Climate Change 2021: The Physical Science Basis. Contribution of Working Group i to the Sixth Assessment Report of the Intergovernmental Panel on Climate Change, edited by Valérie Masson-Delmonte, Panmao Zhai, Anna Pirani, Sarah L. Connors, Clotilde Péan, Yang Chen, Leah Goldfarb, et al. Cambridge: Cambridge University Press, 2023.\n\n\nSwain, M. R., and M. N. Swain. “A Comparison of \\(H_2\\), \\(CH_4\\) and \\(C_3 H_8\\) Fuel Leakage in Residential Settings.” International Journal of Hydrogen Energy 17, no. 10 (1992). https://doi.org/10.1016/0360-3199(92)90025-R." + }, + { + "objectID": "posts/gaussian_dispersion_example/index.html", + "href": "posts/gaussian_dispersion_example/index.html", + "title": "Air Dispersion Example - Gaussian Dispersion Model of Stack Emissions", + "section": "", + "text": "This is an interesting example that came up in conversation with another engineer related to a construction project happening at an existing facility. Imagine construction involving scaffolding and workers at an elevation that potentially puts them within the plume of an existing stack – say from an adjacent boiler. If the facility is still operating while this construction work happens then it is possible that workers will be exposed to combustion products in excess of the occupational exposure limits. The operating boiler does not have to be all that close by for the plume – which is very visible this time of year in the cold weather – to envelope a similarly tall set of scaffolding.\nSo, how would one determine whether or not the operating stack presents a hazard to the workers? In practice by hiring a consultant to do detailed modelling, because safety issues like this are not the time to pencil-whip some number. But we may want to come up with a rough estimate regardless, and for that a Gaussian dispersion model of the stack can be a useful first start." + }, + { + "objectID": "posts/gaussian_dispersion_example/index.html#the-scenario", + "href": "posts/gaussian_dispersion_example/index.html#the-scenario", + "title": "Air Dispersion Example - Gaussian Dispersion Model of Stack Emissions", + "section": "The Scenario", + "text": "The Scenario\nSuppose a natural gas boiler with a rated capacity, \\(W\\), of 300GJ/h, stack height, \\(h_s\\), of 10m and diameter, \\(D_s\\) of 2m, and an exit temperature of 450K.\nIn this case we are interested in the carbon monoxide concentrations at a work platform at the same height as the stack and 100m away, we also would like to know the concentration for a worker at ground level. I am approximately 2m tall let’s suppose the relevant height is 2m (for anyone shorter than that the concentration should be lower and thus this is conservative).\nAdditionally I am assuming ambient conditions of 25°C and 1atm\n\nusing Unitful\n\nW = uconvert(u\"GJ/s\", 300u\"GJ/hr\")\nhₛ = 10u\"m\" # stack height\nDₛ = 2u\"m\" # stack diameter\nTₛ = 450u\"K\" # stack exit temperature\n\nh₁ = hₛ # height of platform, m\nx₁ = 100u\"m\" # distance to platform, m\n\npₐ = 101.325u\"kPa\" # ambient pressure, 1atm\nTₐ = 298.15u\"K\" # ambient temperature, 25°C\n\nPrior to any dispersion modelling, the following parameters need to be collected:\n\nthe mass emission rate of the species, carbon monoxide, in kg/s\nthe concentration of interest, in this case the occupational exposure limit of carbon monoxide in kg/m3\nthe wind speed and atmospheric stability\nthe effective stack height, in m\n\n\nMass Emission Rate\nThe EPA has tabulated emission factors for most combustion products in EPA AP-42 and for a natural gas boiler it is 84 lb/10^6 SCF1 with a reference higher heating value of 1020 MMBTU/10^6 SCF.\n1 EPA, “AP 42” Table 1.4-1. The emission factor is relative to the volume of natural gas consumed not the volume of stack gas emitted.If we suppose the boiler is operating at max rates then the mass emission rate is\n\\[ Q = { W \\cdot EF \\over HV} \\]\nWhere EF is the emission factor and HV the higher heating value.\n\nHV = uconvert(u\"GJ/m^3\", 1020u\"btu/ft^3\")\nEF = uconvert(u\"kg/m^3\", 84*1e-6u\"lb/ft^3\")\n\nQ = EF * W / HV # mass emission rate in kg/s\n\n0.002950437713234783 kg s^-1\n\n\nThis gives a mass flow rate of carbon monoxide in the plume, but we will also need some sense of how large the plume is in general, i.e. what is the volumetric flow rate of stack gas exiting the stack?\n\n\nVolumetric Flow Rate of Flue Gas\nThere are several ways the volumetric flow rate of flue gas could be estimated. One simple method is to use EPA Method 192 with the equation\n2 EPA, “Method 19”.\\[ V_s^o = F_w { 20.9 \\over 20.9 \\left( 1 - B_{wa} \\right) - \\%O_{2w} } \\cdot W\\]\nWhere \\(V_s^o\\) is the volumetric flow of flue gas at standard conditions, \\(B_{wa}\\) the moisture fraction of ambient air, \\(\\%O_{2w}\\) the percentage of oxygen on a wet basis, and the parameter \\(F_w\\) captures the differences in combustion stoichiometry for different fuels and is tabulated. Alternatively one could work out the volume of stack gas from the stoichiometry of combustion, this is just a shortcut.\n\nthe default value for \\(B_{wa} = 0.027\\)\n\\(\\%O_{2w}\\) usually ranges from 2-6% and for this case I am assuming \\(\\%O_{2w} = 4\\)\nfrom Method 19 for natural gas, \\(F_w = 2.85 \\times 10^{-7} \\mathrm{sm^3 \\over J}\\)\n\n\nFw = 2.85e-7u\"m^3/J\"\npct_O2 = 4\nBwa = 0.027\n\nVₛᵒ = Fw * (20.9 / (20.9*(1-Bwa) - pct_O2)) * W\n\nVₛᵒ = upreferred(Vₛᵒ)\n\n30.385903267077627 m^3 s^-1\n\n\nThe actual volumetric flow rate can be calculated assuming the ideal gas law\n\\[ { p^o V_s^o \\over T^o } = { p_a V_s \\over T_s } \\]\n\\[ V_s = { T_s \\over T^o } { p^o \\over p_a } V_s^o \\]\nWhere the standard conditions of Method 19 are \\(T^o = 20 \\mathrm{C}\\) and \\(p^o = 760 \\mathrm{mm Hg}\\)\n\n# Unitful doesn't know what \"mm Hg\" is\n@unit mmHg \"mm Hg\" MillimetersMercury 133.322387415u\"Pa\" false \n\nTᵒ = uconvert(u\"K\", 20u\"°C\")\npᵒ = uconvert(u\"kPa\", 760mmHg)\n\nVₛ = (Tₛ / Tᵒ) * (pᵒ / pₐ) * Vₛᵒ\n\n46.6438970432218 m^3 s^-1\n\n\n\n\nThe Concentration of Interest\nThis analysis is fundamentally about identifying whether a worker on the work platform would experience flue gases in excess of some concentration of interest. In this case I am supposing the Occupational Exposure Limit (OEL) for carbon monoxide alone because it is simple. In practice, since flue gas is a mixture of many substances that each have an associated OEL, one would have to look at the cumulative impact of all of these substances instead of treating them all individually3\n3 For example CCOHS recommends calculating the sum \\[ \\sum_i {C_i \\over T_i } \\] for each substance i where C is the observed concentration and T is the threshold, and this sum should be less than one.4 From the NIOSH Handbook, using the conversion 1.15 mg/m^3 per ppmFor carbon monoxide there are three concentrations of interest worth considering4\n\nthe Time Weighted Average (TWA) concentration which represents the limit for workers in that environment for a standard shift and 40 hours per week\nthe Ceiling concentration which is the level that the concentration cannot exceed\nthe Immediately Dangerous to Life and Health (IDLH) limit which is a concentration that could either kill a worker outright or render them incapable of saving themselves\n\n\n\n\nLimit\nppm\nmg/m^3\n\n\n\n\nTWA\n35\n40\n\n\nCeiling\n200\n229\n\n\nIDLH\n1200\n1380\n\n\n\n\nTWA = uconvert(u\"kg/m^3\", 40u\"mg/m^3\")\nCeil = uconvert(u\"kg/m^3\", 229u\"mg/m^3\")\nIDLH = uconvert(u\"kg/m^3\", 1380u\"mg/m^3\");\n\nWe can check if the stack gas concentration at the exit exceeds the TWA. If it does not exceed the TWA then there is no reason to proceed with the calculations as a worker could work in the stack and not exceed the limits and they certainly would not exceed the limits after the plume mixed with ambient air.\n\nuconvert(u\"mg/m^3\", Q/Vₛᵒ)\n\n97.0988977125952 mg m^-3\n\n\n\nQ/Vₛᵒ > TWA\n\ntrue\n\n\nThe concentration in the flue gas is above the limit for long term work exposure but below the ceiling. At this point we are justified in continuing on to estimate the concentration at the work platform." + }, + { + "objectID": "posts/gaussian_dispersion_example/index.html#meteorological-conditions", + "href": "posts/gaussian_dispersion_example/index.html#meteorological-conditions", + "title": "Air Dispersion Example - Gaussian Dispersion Model of Stack Emissions", + "section": "Meteorological Conditions", + "text": "Meteorological Conditions\nThe ambient conditions impact the release in some obvious ways and in some non-obvious ways. Obviously the wind speed impacts how far the plume is moved, through advection. Somewhat non-obviously the ambient conditions also govern how high the plume will rise due to buoyancy as well as the extent of mixing as the plume moves through the air.\nSuppose a wind speed of 1.5m/s at the stack height, just arbitrarily.\n\nuₛ = 1.5u\"m/s\"\n\n1.5 m s^-1\n\n\n\nAtmospheric Stability\nThe atmospheric stability relates to the vertical mixing of the air due to a temperature gradient, during the day air temperature decreases with elevation and this temperature gradient induces a vertical flow that leads to vertical mixing.\n\n\n\n\n\n\nFigure 2: The effect of atmospheric stability on plume dispersion.\n\n\n\nThis is captured by the atmospheric stability parameter \\(s\\) which is given by5\n5 EPA, “EPA-454/b-95-003b,” 2:1–9.\\[ s = \\frac{g}{T_a} { \\partial \\theta \\over \\partial z } \\]\nWhere \\(\\partial \\theta \\over \\partial z\\) is the lapse rate in K/m\nThe “worst case” is the case with the least mixing and corresponds to a class F Pasquill stability, i.e. very stable, which has a corresponding default lapse rate of \\({ \\partial \\theta \\over \\partial z } = 0.035 K/m\\).6\n6 EPA, 2:1–9.\n\n\n\n\n\nWarningAddendum\n\n\n\nThis isn’t entirely true. For neutrally buoyant plumes released at ground level, or in this case level with the elevated work platform, class F is likely the worst case. For buoyant plumes released at elevation the minimal vertical dispersion with stable atmospheres means the bulk of the plume will rise and be dispersed far above the ground and another class and wind speed should be considered. See Guidelines for Use of Vapour Cloud Dispersion Models, 2nd ed. section 5.8 for more details\n\n\n\n# acceleration due to gravity\ng = 9.80616u\"m/s^2\"\n\n# default lapse rate for class F\nΓ = 0.035u\"K/m\"\n\n# stability parameter\ns = (g/Tₐ) * Γ\n\n0.0011511507630387393 s^-2\n\n\n\n\nEffective Stack Height\nThe plume rising out of the stack will rise higher than the stack height due to buoyancy – in this case because the stack gas is at a higher temperature than the ambient air – and because the stack gas is ejected with some kinetic energy. What follows is essentially a simplified version of the Brigg’s model for plume rise for stable plumes.\nAs a first check, verify that stack down wash will not be relevant. For low momentum releases the effective stack height of the plume is reduced by vortices shed downwind of the stack that pull the plume downwards. This is only really relevant when \\(v_s \\lt 1.5 u\\)\nWhere \\(v_s\\) is the stack exit velocity and is calculated from the volumetric flow as\n\\[ v_s = { V_s \\over A_s} = { V_s \\over \\frac{\\pi}{4} D^2 } \\]\n\nvₛ = Vₛ / ((π/4)*Dₛ^2)\n\nvₛ > 1.5uₛ\n\ntrue\n\n\nThe following assumes a stable plume rise, recall that Pasquill stability class F corresponds to very stable conditions.\nThe first question that must be answered is whether or not the plume rise is dominated by buoyancy or by momentum. For buoyant plume rise to dominate the actual temperature difference – the difference between the stack exit temperature and the ambient temperature – must be greater than a critical temperature difference7\n7 EPA, 2:1–9.\\[ T_s - T_a = \\Delta T \\gt \\left( \\Delta T \\right)_c = 0.019582 T_s v_s \\sqrt{s} \\]\n\nΔTc = 0.019582u\"m^-1*s^2\" * Tₛ * vₛ * √(s)\n\n(Tₛ - Tₐ) > ΔTc\n\ntrue\n\n\nIn this case buoyant plume rise is dominant, and the stable plume rise equation is8\n8 EPA, 2:1–9.\\[ \\Delta h = 2.6 \\left( F_b \\over u_s s \\right)^{1/3} \\]\nwhere \\(\\Delta h\\) is the increase in effective stack height due to plume rise, and \\(F_b\\) is the buoyancy flux parameter9\n9 EPA, 2:1–6.\\[ F_b = g v_s D_s^2 { \\left( T_s - T_a \\right) \\over 4 T_s } \\]\nPlume rise is not instantaneous and the distance to the final rise, \\(x_f\\) is given by10\n10 EPA, 2:1–9.\\[ x_f = 2.0715 {u_s \\over \\sqrt{s} } \\]\nwith any distance closer to the source than \\(x_f\\) experiencing a lesser plume rise, given by11\n11 EPA, 2:1–10.\\[ \\Delta h = 1.60 \\left( F_b x^2 \\over u_s^3 \\right)^{1/3} \\]\nthis can be put together into a function that calculates \\(\\Delta h\\) as a function of distance x\n\nFb = g * vₛ * Dₛ^2 * (Tₛ - Tₐ) / (4Tₛ)\n\n49.1299376393856 m^4 s^-3\n\n\n\nxf = 2.0715*uₛ/√(s)\n\n91.58199372993636 m\n\n\n\nfunction Δh(x)\n xf = 2.0715*uₛ/√(s)\n \n if x < xf\n return 1.60*(Fb*x^2/uₛ^3)^(1/3)\n else\n return 2.6*(Fb/(uₛ*s))^(1/3)\n end\n \nend;\n\n\n\n\n\n\n\n\nFigure 3: Plume rise as a function of downwind distance.\n\n\n\n\nPlume rise is impacted by the wind speed at the stack height, as the following plot shows, but with several large caveats. For one the model for plume rise given is not defined at no wind speed and for very low wind speeds the value should be treated with suspicion. Similarly for very large wind speeds the assumption of stable rise is likely quite invalid.\n\n\n\n\n\n\n\nFigure 4: Plume rise as a function of windspeed." + }, + { + "objectID": "posts/gaussian_dispersion_example/index.html#gaussian-dispersion-model", + "href": "posts/gaussian_dispersion_example/index.html#gaussian-dispersion-model", + "title": "Air Dispersion Example - Gaussian Dispersion Model of Stack Emissions", + "section": "Gaussian Dispersion Model", + "text": "Gaussian Dispersion Model\nAs the plume is carried downwind it will mix with the ambient air and the pollutant, carbon monoxide, will be dispersed. A simple model of this is a Gaussian dispersion model, the derivation for which is sketched out as follows.\n\nA Differential Mass Balance\nStarting with a coordinate system centred at the top of the stack, emitting a mass flow of Q kg/s, which is assumed to be released from a point, the advection-diffusion equation for mass can be written as\n\\[ {\\partial C \\over \\partial t} = - \\nabla \\cdot \\mathbf{D} \\cdot \\nabla C + \\nabla \\cdot \\mathbf{u} C \\]\nWhere \\(\\mathbf{D}\\) is the diffusivity, \\(C\\) the concentration of the species, and \\(\\mathbf{u}\\) the wind speed. The diffusivity in this case is a vector and depends upon the direction, i.e. \\(D_x \\ne D_y \\ne D_z\\) and represents an eddy diffusion as opposed to a simple Fickian diffusion.\nSome simplifying assumptions can be made\n\nthe wind speed u is a constant everywhere\nthe air is moving entirely in the x direction, i.e. \\(u_{y} = u_{z} = 0\\) and \\(u_x = u\\) and thus \\(\\nabla \\cdot \\mathbf{u} C = u {\\partial C \\over \\partial x}\\)\nthe diffusivities \\(D_x\\), \\(D_y\\), and \\(D_z\\) are constant everywhere\nadvection is much more significant than diffusion in the x direction i.e. \\(\\left \\vert {\\partial \\over \\partial x} C u \\right \\vert \\gg \\left \\vert {\\partial^{2} \\over \\partial x^{2} } D_{x} C \\right \\vert\\), leading to \\(\\nabla \\cdot \\mathbf{D} \\cdot \\nabla C = D_y {\\partial^2 C \\over \\partial y^2} + D_z {\\partial^2 C \\over \\partial z^2}\\)\nthe system is at steady state, \\({\\partial C \\over \\partial t} = 0\\)\n\nReducing the PDE to\n\\[ u {\\partial C \\over \\partial x} = D_{y} {\\partial^{2} C \\over \\partial y^{2} } + D_{z} {\\partial^{2} C \\over \\partial z^{2} } \\]\nWhich has solutions for particular boundary conditions\n\\[ C = {k \\over x} \\exp \\left[ - \\left( {y^{2} \\over D_{y} } + {z^{2} \\over D_{z} } \\right) { u \\over 4x } \\right] \\]\nWhere k is a constant set by the boundary conditions.\n\n\nBoundary Conditions\nTo solve for k note that Q is assumed to be constant and that mass must be conserved as it is carried downwind which has the effect that for any given x the flux through the y-z plane is Q.\n\\[ Q = \\int \\int C u dy dz \\]\n\\[ Q = \\int_{0}^{\\infty} \\int_{-\\infty}^{\\infty} {k u \\over x} \\exp \\left[ - \\left( {y^{2} \\over D_{y} } + {z^{2} \\over D_{z} } \\right) { u \\over 4x } \\right] dy dz \\]\nwhere the release point is assumed to be at ground level (z=0).\nMaking the change of variables \\(y' = {y \\over \\sqrt{D_{y} } }\\) and \\(z' = {z \\over \\sqrt{D_{z} } }\\) gives\n\\[ Q = {k u \\over x} \\sqrt{D_{y} D_{z} } \\int_{-\\infty}^{\\infty} \\exp \\left[ - {u \\over 4 x} y'^{2} \\right] dy' \\int_{0}^{\\infty} \\exp \\left[ - {u \\over 4 x} z'^{2} \\right] dz' \\]\nwhich are gaussian integrals that can be integrated\n\\[ Q = {k u \\over x} \\sqrt{D_{y} D_{z} } \\left( \\sqrt{\\pi} \\over \\sqrt{u \\over 4x} \\right) \\left( \\sqrt{\\pi} \\over 2 \\sqrt{u \\over 4x} \\right) \\]\nsimplifying\n\\[ Q = 2 \\pi k \\sqrt{D_{y} D_{z} } \\]\nand solving for k\n\\[ k = {Q \\over 2 \\pi \\sqrt{D_{y} D_{z} } }\\]\n\n\nGaussian Model\nSubstituting k back into the model gives the gaussian dispersion model.\n\\[ C = {Q \\over 2 \\pi x \\sqrt{D_{y} D_{z} } } \\exp \\left[ - \\left( {y^{2} \\over D_{y} } + {z^{2} \\over D_{z} } \\right) { u \\over 4x } \\right] \\]\nHowever this is more commonly expressed in terms of dispersion by letting\n\\[ \\sigma_{y}^{2} = {2 D_{y} x \\over u}\\]\n\\[ \\sigma_{z}^{2} = {2 D_{z} x \\over u}\\]\nwhich gives a more explicitly gaussian distribution of concentration at a given point x\n\\[ C = {Q \\over \\pi u \\sigma_{y} \\sigma_{z} } \\exp \\left[ -\\frac{1}{2} \\left( {y^{2} \\over \\sigma_{y}^{2} } + {z^{2} \\over \\sigma_{z}^{2} } \\right) \\right] \\]\nNote the parameters \\(\\sigma_y\\) and \\(\\sigma_z\\) have units of length.\n\n\nGround Reflection\nWhen solving for k, I assumed the release point was at ground level, this simplified the integration by making one of the bounds of the integral zero.\nHowever what we want is a generalized equation with the emissions released at some elevation h. The plume can disperse downwards but only to a distance h below the release point, at which point the mass can neither disperse further downwards (pass through the ground) nor does it just disappear. This is ground reflection.\n\n\n\n\n\n\nFigure 5: A sketch of ground reflection by method of images.\n\n\n\nOne way to capture this is to integrate z from \\(-\\infty\\) to \\(\\infty\\) (recall that the release point is at the origin) and introduce a mirror image of the stack shifted 2h below. The ground being the x-y plane at z = -h. By symmetry the portion of the mirror plume extending up above this plane is the same as the portion of the plume that, in this simple model, has extended below the ground. By adding the stack and the mirror stack together and shifting the z-coordinate so z = 0 is the ground, ground reflection is captured and the expression for a release point at elevation h is given by\n\\[ C = {Q \\over 2 \\pi u \\sigma_{y} \\sigma_{z} } \\exp \\left[ -\\frac{1}{2} \\left( y \\over \\sigma_{y} \\right)^2 \\right] \\]\n\\[ \\times \\left\\{ \\exp \\left[ -\\frac{1}{2} \\left( { z -h } \\over \\sigma_{z} \\right)^2 \\right] + \\exp \\left[ -\\frac{1}{2} \\left( { z + h } \\over \\sigma_{z} \\right)^2 \\right] \\right\\} \\]" + }, + { + "objectID": "posts/gaussian_dispersion_example/index.html#pasquill-gifford-model", + "href": "posts/gaussian_dispersion_example/index.html#pasquill-gifford-model", + "title": "Air Dispersion Example - Gaussian Dispersion Model of Stack Emissions", + "section": "Pasquill-Gifford Model", + "text": "Pasquill-Gifford Model\nThe \\(\\sigma_{y}\\) and \\(\\sigma_{z}\\) are functions of the downwind distance x. In the derivation of the model they were assumed to be linear in x however in practice they are typically of the form:\n\\[ \\sigma_{y} = a x^{b} \\]\n\\[ \\sigma_{z} = c x^{d} \\]\nWith the constants tabulated based on the Pasquill stability class criteria.\nThese particular correlations come from Lees12 and are for a Pasquill stability class F\n12 Lees, Loss Prevention in the Process Industries, 15/113. There is a typo in the 4th edition of Lees’ for the \\(\\sigma_{z}\\) corresponding to class F stability. For \\(x>500\\) it is given as \\[ \\sigma_{z} = 10^{(1.91 - 1.37 \\log(x) - 0.119 \\log(x)^2)} \\] when it should be (note the signs) \\[ \\sigma_{z} = 10^{(-1.91 + 1.37 \\log(x) - 0.119 \\log(x)^2)} \\]. I happen to have the paper version of the 2nd edition at home, which does not have the typo, whereas the standard version I use at work is the 4th edition on Knovel.\nσy(x) = 0.067*x^0.90\n\nfunction σz(x)\n if x < 500.0\n return 0.057*x^0.80\n else\n # Note: Lee's gives the commented out form but it is wrong\n # 10^(1.91 - 1.37*log10(x) - 0.119*log10(x)^2)\n return 10^(-1.91 + 1.37*log10(x) - 0.119*log10(x)^2)\n end\nend;\n\nThese correlations are currently not unit-aware, so we can add that using a macro\n\n# this macro adds a method to handle units\nmacro correl(f::Symbol, in_unit::Expr, out_unit::Expr)\n quote\n function $(esc(f))(x::Quantity)::Quantity\n x = ustrip($in_unit, x)\n res = $f(x)\n return res*$out_unit\n end\n end\nend\n\n@correl σy u\"m\" u\"m\"\n@correl σz u\"m\" u\"m\"\n\nσz (generic function with 2 methods)\n\n\n\n\n\n\n\n\n\nFigure 6: Pasquill-Gifford dispersion parameters as a function of downwind distance, for class F atmospheric stability.\n\n\n\n\n\nEffect of Plume Rise\nThe effect of plume rise on this model is to shift from the actual stack height to an effective stack height \\(h_e = h_s + \\Delta h\\) with \\(\\Delta h\\) given by the plume rise model already discussed. Additionally the dispersion is adjusted by the following13\n13 Vallero, Fundamentals of Air Pollution, 696–97.\\[ \\sigma_{ze}^2 = \\left( \\Delta h \\over 3.5 \\right)^2 + \\sigma_z^2 \\]\n\\[ \\sigma_{ye}^2 = \\left( \\Delta h \\over 3.5 \\right)^2 + \\sigma_y^2 \\]\nand the final model of concentration is given in respect to the effective stack height\n\\[ C = {Q \\over 2 \\pi u \\sigma_{ye} \\sigma_{ze} } \\exp \\left[ -\\frac{1}{2} \\left( y \\over \\sigma_{ye} \\right)^2 \\right] \\]\n\\[ \\times \\left\\{ \\exp \\left[ -\\frac{1}{2} \\left( { z -h_e } \\over \\sigma_{ze} \\right)^2 \\right] + \\exp \\left[ -\\frac{1}{2} \\left( { z + h_e } \\over \\sigma_{ze} \\right)^2 \\right] \\right\\} \\]\n\nfunction C(x, y, z)\n hₑ = hₛ + Δh(x)\n σyₑ = √( (Δh(x)/3.5)^2 + σy(x)^2 )\n σzₑ = √( (Δh(x)/3.5)^2 + σz(x)^2 )\n \n C = (Q/(2*π*uₛ*σyₑ*σzₑ)) *\n exp(-0.5*(y/σyₑ)^2) *\n ( exp(-0.5*((z-hₑ)/σzₑ)^2) + exp(-0.5*((z+hₑ)/σzₑ)^2) )\n \nend" + }, + { + "objectID": "posts/gaussian_dispersion_example/index.html#modelling-dispersion", + "href": "posts/gaussian_dispersion_example/index.html#modelling-dispersion", + "title": "Air Dispersion Example - Gaussian Dispersion Model of Stack Emissions", + "section": "Modelling Dispersion", + "text": "Modelling Dispersion\nThere are two cases worth considering\n\nwithout accounting for plume rise\nwith plume rise\n\nThe first case would be very conservative and the stack plume would immediately point directly downwind, at the stack height, this is far more likely to impact the work platform and any workers on the ground, though it is also quite unrealistic.\nNote the following contour plots max out at the time weighted average concentration, shown in mg/m^3\n\n\n\n\n\n\n\nFigure 7: Contour plots of concentration with no plume rise.\n\n\n\n\nThis clearly represents something of an extreme case, and I believe illustrates something of interest. While the work platform is ultimately below the TWA, to get even close to that concentration at the work platform the model is assuming extremely little mixing and no plume rise.\nA more realistic model would take into account the buoyant rise of hot stack gases.\n\n\n\n\n\n\n\nFigure 8: Contour plots of concentration with using Briggs’ plume rise equations.\n\n\n\n\nIn this model the plume clearly rises significantly and, as it goes, mixes into the air column to such an extent that there is hardly any carbon monoxide at the elevations of interest downwind of the stack.\n\nuconvert(u\"mg/m^3\",C(x₁, 0u\"m\", h₁))\n\n0.0014282911474771348 mg m^-3\n\n\n\nC(x₁, 0u\"m\", h₁) > TWA\n\nfalse" + }, + { + "objectID": "posts/gaussian_dispersion_example/index.html#concluding-remarks", + "href": "posts/gaussian_dispersion_example/index.html#concluding-remarks", + "title": "Air Dispersion Example - Gaussian Dispersion Model of Stack Emissions", + "section": "Concluding Remarks", + "text": "Concluding Remarks\nThis model assumed a continuous, steady-state, flow of stack gases. Boilers don’t always operate that way and the model did not, for example, consider startup or upset conditions that could lead to higher in-stack concentrations of carbon monoxide.\nThe model also assumed mixing was captured by a simple Gaussian dispersion model. This model does not, for example, account for variability of wind speed either with time or spatially – wind speed typically increases with height – in this case I believe the model underestimates the degree of mixing. Nor does it account for interactions with buildings and potential down wash, which can be very significant.\nThis also assumes no other sources of carbon monoxide, both at the facility surrounding the worksite but also potentially from some portable equipment.\nI think that, while modelling like this might be informative about the potential hazards, it is always good practise to develop a monitoring plan for the work area that includes the flue gases and any other potential substances to ensure workers on the scaffolding are not being exposed." + }, + { + "objectID": "posts/gaussian_dispersion_example/index.html#references", + "href": "posts/gaussian_dispersion_example/index.html#references", + "title": "Air Dispersion Example - Gaussian Dispersion Model of Stack Emissions", + "section": "References", + "text": "References\n\n\nAIChE/CCPS. Guidelines for Use of Vapour Cloud Dispersion Models, 2nd Ed. New York: American Institute of Chemical Engineers, 1996.\n\n\nEPA. “AP 42: Compilation of Air Emissions Factors.” 5th ed. Research Triangle Park, NC: Environmental Protection Agency, 1995. https://www.epa.gov/air-emissions-factors-and-quantification/ap-42-compilation-air-emissions-factors.\n\n\n———. “EPA-454/b-95-003b: User’s Guide for the ISC3 Dispersion Models.” Vol. 2. Environmental Protection Agency, 1995.\n\n\n———. “Method 19: Determination of Sulfur Dioxide Removal Efficiency and Particulate Matter, Sulfur Dioxide, and Nitrogen Oxide Emission Rates.” Environmental Protection Agency, 2017. https://www.epa.gov/sites/default/files/2017-08/documents/method_19.pdf.\n\n\nLees, Frank P. Loss Prevention in the Process Industries. 2nd ed. Oxford: Butterworth-Heinemann, 1996.\n\n\nVallero, Daniel. Fundamentals of Air Pollution. 5th ed. Amsterdam: Elsevier, 2014." + }, + { + "objectID": "posts/plastics-recycling-microplastics/index.html", + "href": "posts/plastics-recycling-microplastics/index.html", + "title": "Plastics Recycling and Microplastics", + "section": "", + "text": "As perhaps just a hazard of my profession, any time an article comes out on the merits (or lack of) of recycling and plastic waste in general, people send it my way. Several times in the last month I was sent this article in Quillette.1 (and associated YouTube video) about how plastics recycling may be a massive source of the microplastics being discharged into the environment, adding to the long list of reasons why recycling has not lived up to the promises made by industry, and undermining our path towards a more circular economy. At first glance though, some of the numbers presented and the math struck me as rather sus, so I would like to take a moment to dive into it a bit more. tl;dr much the math in that essay doesn’t really work or comes with big caveats, but the broader point about the value of recycling and how we may not be fully appreciating the environmental impacts may hold up." + }, + { + "objectID": "posts/plastics-recycling-microplastics/index.html#how-large-of-a-source-of-microplastics-is-the-recycling-industry", + "href": "posts/plastics-recycling-microplastics/index.html#how-large-of-a-source-of-microplastics-is-the-recycling-industry", + "title": "Plastics Recycling and Microplastics", + "section": "How Large of a Source of Microplastics is the Recycling Industry?", + "text": "How Large of a Source of Microplastics is the Recycling Industry?\nCelia 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:\n2 These are so called “primary” microplastics, as opposed to “secondary” microplastics which are generated from plastic waste already in the environment\nthat the recycling industry discharges up to 2Mt/y of microplastics into the environment\nthat the total amount of primary microplastics discharged into the environment from all sources is 3Mt/y\n\n\nThe Direct Discharge of Microplastics\nCelia 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.\n3 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.\nThe 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.\nAt 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.\n\n\n\nestimate\nlow end (t/y)\nhigh end (t/y)\n\n\n\n\nbefore filter upgrade\n96\n2933\n\n\nafter filter upgrade\n4\n1366\n\n\n\nI 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.\n5 Ryberg, Laurent, and Hauschild, “Mapping of Global Plastics Value Chain and Plastics Losses to the Environment” page 56, estimates 0.005% loss;\nBoucher and Friot, “Primary Microplastics in the Oceans” page 37, estimates 0.00033 - 0.001% loss;\nThe 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%\n\nThe Total Amount of Primary Microplastics losses\nI 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.\n6 Ryberg, Laurent, and Hauschild, “Mapping of Global Plastics Value Chain and Plastics Losses to the Environment” estimates 3.1Mt/y;\nOECD, “Global Plastics Outlook” estimates 2.7Mt/y;\nBoucher and Friot, “Primary Microplastics in the Oceans” estimates 1.8 - 5Mt/yBefore 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.\nSupposing 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.\n7 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.\nBut 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." + }, + { + "objectID": "posts/plastics-recycling-microplastics/index.html#the-climate-impacts-of-landfilling", + "href": "posts/plastics-recycling-microplastics/index.html#the-climate-impacts-of-landfilling", + "title": "Plastics Recycling and Microplastics", + "section": "The Climate Impacts of Landfilling", + "text": "The Climate Impacts of Landfilling\nCelia 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.\n9 Gao et al., “Comprehensive Meta-Analysis Reveals the Impact of Non-Biodegradable Plastic Pollution on Methane Production in Anaerobic Digestion”." + }, + { + "objectID": "posts/plastics-recycling-microplastics/index.html#conclusions-and-take-aways", + "href": "posts/plastics-recycling-microplastics/index.html#conclusions-and-take-aways", + "title": "Plastics Recycling and Microplastics", + "section": "Conclusions and Take Aways", + "text": "Conclusions and Take Aways\nThere 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.\nI 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." + }, + { + "objectID": "posts/plastics-recycling-microplastics/index.html#references", + "href": "posts/plastics-recycling-microplastics/index.html#references", + "title": "Plastics Recycling and Microplastics", + "section": "References", + "text": "References\n\n\nBoucher, Julien, and Damien Friot. “Primary Microplastics in the Oceans.” Gland, CH: International Union for Conservation of Nature, 2017. https://doi.org/10.2305/IUCN.CH.2017.01.en.\n\n\nBrown, Erina, Anna MacDonald, Steve Allen, and Deonie Allen. “The Potential for a Plastic Recycling Facility to Release Microplastic Pollution and Possible Filtration Remediation Effectiveness.” Journal of Hazardous Materials Advances 10 (2023): 100309. https://doi.org/10.1016/j.hazadv.2023.100309.\n\n\nCelia, Frank. “Recycling Plastic Is a Dangerous Waste of Time.” Quillette, June 17, 2024. https://quillette.com/2024/06/17/recycling-plastic-is-a-dangerous-waste-of-time-microplastics-health/.\n\n\nGao, Zhenghui, Hang Qian, Tianyi Cui, Zongqiang Ren, and Xingjie Wang. “Comprehensive Meta-Analysis Reveals the Impact of Non-Biodegradable Plastic Pollution on Methane Production in Anaerobic Digestion.” Chemical Engineering Journal 484 (2024): 149703. https://doi.org/10.1016/j.cej.2024.149703.\n\n\nOECD. “Global Plastics Outlook.” Paris: OECD Publishing, 2022. https://doi.org/10.1787/de747aef-en.\n\n\nRyberg, Morten W., Alexis Laurent, and Michael Hauschild. “Mapping of Global Plastics Value Chain and Plastics Losses to the Environment.” Nairobi: United Nations Environment Programme, 2018. https://www.unep.org/resources/report/mapping-global-plastics-value-chain-and-plastics-losses-environment-particular/." + }, + { + "objectID": "projects.html", + "href": "projects.html", + "title": "Projects", + "section": "", + "text": "GasDispersion.jl\n\n\nA julia package for dispersion modeling of chemical releases\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nPicoMite Library\n\n\nA collection of PicoMite BASIC programs written for the PicoCalc\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nPymoTube\n\n\nA python module for logging data from an AtmoTube over bluetooth.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nUnitfulCorrelations.jl\n\n\nA julia module for using correlations with Unitful\n\n\n\n\n\n\n\n\n\n\nNo matching items" + } +] \ No newline at end of file diff --git a/site_libs/bootstrap/bootstrap-a74e2cdc3b80062cae06dc0c30a79fd5.min.css b/site_libs/bootstrap/bootstrap-a74e2cdc3b80062cae06dc0c30a79fd5.min.css new file mode 100644 index 0000000..d448124 --- /dev/null +++ b/site_libs/bootstrap/bootstrap-a74e2cdc3b80062cae06dc0c30a79fd5.min.css @@ -0,0 +1,12 @@ +/*! + * Bootstrap v5.3.1 (https://getbootstrap.com/) + * Copyright 2011-2023 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */@import"https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;700&display=swap";:root,[data-bs-theme=light]{--bs-blue: #007bff;--bs-indigo: #6610f2;--bs-purple: #772953;--bs-pink: #e83e8c;--bs-red: #df382c;--bs-orange: #e95420;--bs-yellow: #efb73e;--bs-green: #38b44a;--bs-teal: #20c997;--bs-cyan: #17a2b8;--bs-black: #000;--bs-white: #fff;--bs-gray: #6c757d;--bs-gray-dark: #343a40;--bs-gray-100: #f8f9fa;--bs-gray-200: #e9ecef;--bs-gray-300: #dee2e6;--bs-gray-400: #ced4da;--bs-gray-500: #adb5bd;--bs-gray-600: #6c757d;--bs-gray-700: #495057;--bs-gray-800: #343a40;--bs-gray-900: #212529;--bs-default: #adb5bd;--bs-primary: #e95420;--bs-secondary: #adb5bd;--bs-success: #38b44a;--bs-info: #17a2b8;--bs-warning: #efb73e;--bs-danger: #df382c;--bs-light: #e9ecef;--bs-dark: #772953;--bs-default-rgb: 173, 181, 189;--bs-primary-rgb: 233, 84, 32;--bs-secondary-rgb: 173, 181, 189;--bs-success-rgb: 56, 180, 74;--bs-info-rgb: 23, 162, 184;--bs-warning-rgb: 239, 183, 62;--bs-danger-rgb: 223, 56, 44;--bs-light-rgb: 233, 236, 239;--bs-dark-rgb: 119, 41, 83;--bs-primary-text-emphasis: rgb(93.2, 33.6, 12.8);--bs-secondary-text-emphasis: rgb(69.2, 72.4, 75.6);--bs-success-text-emphasis: rgb(22.4, 72, 29.6);--bs-info-text-emphasis: rgb(9.2, 64.8, 73.6);--bs-warning-text-emphasis: rgb(95.6, 73.2, 24.8);--bs-danger-text-emphasis: rgb(89.2, 22.4, 17.6);--bs-light-text-emphasis: #495057;--bs-dark-text-emphasis: #495057;--bs-primary-bg-subtle: rgb(250.6, 220.8, 210.4);--bs-secondary-bg-subtle: rgb(238.6, 240.2, 241.8);--bs-success-bg-subtle: rgb(215.2, 240, 218.8);--bs-info-bg-subtle: rgb(208.6, 236.4, 240.8);--bs-warning-bg-subtle: rgb(251.8, 240.6, 216.4);--bs-danger-bg-subtle: rgb(248.6, 215.2, 212.8);--bs-light-bg-subtle: rgb(251.5, 252, 252.5);--bs-dark-bg-subtle: #ced4da;--bs-primary-border-subtle: rgb(246.2, 186.6, 165.8);--bs-secondary-border-subtle: rgb(222.2, 225.4, 228.6);--bs-success-border-subtle: rgb(175.4, 225, 182.6);--bs-info-border-subtle: rgb(162.2, 217.8, 226.6);--bs-warning-border-subtle: rgb(248.6, 226.2, 177.8);--bs-danger-border-subtle: rgb(242.2, 175.4, 170.6);--bs-light-border-subtle: #e9ecef;--bs-dark-border-subtle: #adb5bd;--bs-white-rgb: 255, 255, 255;--bs-black-rgb: 0, 0, 0;--bs-font-sans-serif: Ubuntu, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-root-font-size: 17px;--bs-body-font-family: Ubuntu, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";--bs-body-font-size:1rem;--bs-body-font-weight: 400;--bs-body-line-height: 1.5;--bs-body-color: #343a40;--bs-body-color-rgb: 52, 58, 64;--bs-body-bg: #fff;--bs-body-bg-rgb: 255, 255, 255;--bs-emphasis-color: #000;--bs-emphasis-color-rgb: 0, 0, 0;--bs-secondary-color: rgba(52, 58, 64, 0.75);--bs-secondary-color-rgb: 52, 58, 64;--bs-secondary-bg: #e9ecef;--bs-secondary-bg-rgb: 233, 236, 239;--bs-tertiary-color: rgba(52, 58, 64, 0.5);--bs-tertiary-color-rgb: 52, 58, 64;--bs-tertiary-bg: #f8f9fa;--bs-tertiary-bg-rgb: 248, 249, 250;--bs-heading-color: inherit;--bs-link-color: #e95420;--bs-link-color-rgb: 233, 84, 32;--bs-link-decoration: underline;--bs-link-hover-color: rgb(186.4, 67.2, 25.6);--bs-link-hover-color-rgb: 186, 67, 26;--bs-code-color: #7d12ba;--bs-highlight-bg: rgb(251.8, 240.6, 216.4);--bs-border-width: 1px;--bs-border-style: solid;--bs-border-color: #dee2e6;--bs-border-color-translucent: rgba(0, 0, 0, 0.175);--bs-border-radius: 0.25rem;--bs-border-radius-sm: 0.2em;--bs-border-radius-lg: 0.5rem;--bs-border-radius-xl: 1rem;--bs-border-radius-xxl: 2rem;--bs-border-radius-2xl: var(--bs-border-radius-xxl);--bs-border-radius-pill: 50rem;--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width: 0.25rem;--bs-focus-ring-opacity: 0.25;--bs-focus-ring-color: rgba(233, 84, 32, 0.25);--bs-form-valid-color: #38b44a;--bs-form-valid-border-color: #38b44a;--bs-form-invalid-color: #df382c;--bs-form-invalid-border-color: #df382c}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color: #dee2e6;--bs-body-color-rgb: 222, 226, 230;--bs-body-bg: #212529;--bs-body-bg-rgb: 33, 37, 41;--bs-emphasis-color: #fff;--bs-emphasis-color-rgb: 255, 255, 255;--bs-secondary-color: rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb: 222, 226, 230;--bs-secondary-bg: #343a40;--bs-secondary-bg-rgb: 52, 58, 64;--bs-tertiary-color: rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb: 222, 226, 230;--bs-tertiary-bg: rgb(42.5, 47.5, 52.5);--bs-tertiary-bg-rgb: 43, 48, 53;--bs-primary-text-emphasis: rgb(241.8, 152.4, 121.2);--bs-secondary-text-emphasis: rgb(205.8, 210.6, 215.4);--bs-success-text-emphasis: rgb(135.6, 210, 146.4);--bs-info-text-emphasis: rgb(115.8, 199.2, 212.4);--bs-warning-text-emphasis: rgb(245.4, 211.8, 139.2);--bs-danger-text-emphasis: rgb(235.8, 135.6, 128.4);--bs-light-text-emphasis: #f8f9fa;--bs-dark-text-emphasis: #dee2e6;--bs-primary-bg-subtle: rgb(46.6, 16.8, 6.4);--bs-secondary-bg-subtle: rgb(34.6, 36.2, 37.8);--bs-success-bg-subtle: rgb(11.2, 36, 14.8);--bs-info-bg-subtle: rgb(4.6, 32.4, 36.8);--bs-warning-bg-subtle: rgb(47.8, 36.6, 12.4);--bs-danger-bg-subtle: rgb(44.6, 11.2, 8.8);--bs-light-bg-subtle: #343a40;--bs-dark-bg-subtle: #1a1d20;--bs-primary-border-subtle: rgb(139.8, 50.4, 19.2);--bs-secondary-border-subtle: rgb(103.8, 108.6, 113.4);--bs-success-border-subtle: rgb(33.6, 108, 44.4);--bs-info-border-subtle: rgb(13.8, 97.2, 110.4);--bs-warning-border-subtle: rgb(143.4, 109.8, 37.2);--bs-danger-border-subtle: rgb(133.8, 33.6, 26.4);--bs-light-border-subtle: #495057;--bs-dark-border-subtle: #343a40;--bs-heading-color: inherit;--bs-link-color: rgb(241.8, 152.4, 121.2);--bs-link-hover-color: rgb(244.44, 172.92, 147.96);--bs-link-color-rgb: 242, 152, 121;--bs-link-hover-color-rgb: 244, 173, 148;--bs-code-color: white;--bs-border-color: #495057;--bs-border-color-translucent: rgba(255, 255, 255, 0.15);--bs-form-valid-color: rgb(135.6, 210, 146.4);--bs-form-valid-border-color: rgb(135.6, 210, 146.4);--bs-form-invalid-color: rgb(235.8, 135.6, 128.4);--bs-form-invalid-border-color: rgb(235.8, 135.6, 128.4)}*,*::before,*::after{box-sizing:border-box}:root{font-size:var(--bs-root-font-size)}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0)}hr{margin:1rem 0;color:inherit;border:0;border-top:1px solid;opacity:.25}h6,.h6,h5,.h5,h4,.h4,h3,.h3,h2,.h2,h1,.h1{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}h1,.h1{font-size:calc(1.325rem + 0.9vw)}@media(min-width: 1200px){h1,.h1{font-size:2rem}}h2,.h2{font-size:calc(1.29rem + 0.48vw)}@media(min-width: 1200px){h2,.h2{font-size:1.65rem}}h3,.h3{font-size:calc(1.27rem + 0.24vw)}@media(min-width: 1200px){h3,.h3{font-size:1.45rem}}h4,.h4{font-size:1.25rem}h5,.h5{font-size:1.1rem}h6,.h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{text-decoration:underline dotted;-webkit-text-decoration:underline dotted;-moz-text-decoration:underline dotted;-ms-text-decoration:underline dotted;-o-text-decoration:underline dotted;cursor:help;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}ol,ul,dl{margin-top:0;margin-bottom:1rem}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem;padding:.625rem 1.25rem;border-left:.25rem solid #e9ecef}blockquote p:last-child,blockquote ul:last-child,blockquote ol:last-child{margin-bottom:0}b,strong{font-weight:bolder}small,.small{font-size:0.875em}mark,.mark{padding:.1875em;background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:0.75em;line-height:0;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}a{color:rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));text-decoration:underline;-webkit-text-decoration:underline;-moz-text-decoration:underline;-ms-text-decoration:underline;-o-text-decoration:underline}a:hover{--bs-link-color-rgb: var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}pre,code,kbd,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:0.875em;color:#000;background-color:#f8f9fa;line-height:1.5;padding:.5rem;border:1px solid var(--bs-border-color, #dee2e6);border-radius:.25rem}pre code{background-color:rgba(0,0,0,0);font-size:inherit;color:inherit;word-break:normal}code{font-size:0.875em;color:var(--bs-code-color);background-color:#f8f9fa;border-radius:.25rem;padding:.125rem .25rem;word-wrap:break-word}a>code{color:inherit}kbd{padding:.4rem .4rem;font-size:0.875em;color:#fff;background-color:#343a40;border-radius:.2em}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:rgba(52,58,64,.75);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}thead,tbody,tfoot,tr,td,th{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}input,button,select,optgroup,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none !important}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button:not(:disabled),[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + 0.3vw);line-height:inherit}@media(min-width: 1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-text,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none !important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media(min-width: 1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media(min-width: 1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media(min-width: 1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media(min-width: 1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media(min-width: 1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media(min-width: 1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:0.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:0.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:0.875em;color:rgba(52,58,64,.75)}.container,.container-fluid,.container-xxl,.container-xl,.container-lg,.container-md,.container-sm{--bs-gutter-x: 1.5rem;--bs-gutter-y: 0;width:100%;padding-right:calc(var(--bs-gutter-x)*.5);padding-left:calc(var(--bs-gutter-x)*.5);margin-right:auto;margin-left:auto}@media(min-width: 576px){.container-sm,.container{max-width:540px}}@media(min-width: 768px){.container-md,.container-sm,.container{max-width:720px}}@media(min-width: 992px){.container-lg,.container-md,.container-sm,.container{max-width:960px}}@media(min-width: 1200px){.container-xl,.container-lg,.container-md,.container-sm,.container{max-width:1140px}}@media(min-width: 1400px){.container-xxl,.container-xl,.container-lg,.container-md,.container-sm,.container{max-width:1320px}}body.quarto-light .dark-content{display:none !important}body.quarto-dark .light-content{display:none !important}:root{--bs-breakpoint-xs: 0;--bs-breakpoint-sm: 576px;--bs-breakpoint-md: 768px;--bs-breakpoint-lg: 992px;--bs-breakpoint-xl: 1200px;--bs-breakpoint-xxl: 1400px}.grid{display:grid;grid-template-rows:repeat(var(--bs-rows, 1), 1fr);grid-template-columns:repeat(var(--bs-columns, 12), 1fr);gap:var(--bs-gap, 1.5rem)}.grid .g-col-1{grid-column:auto/span 1}.grid .g-col-2{grid-column:auto/span 2}.grid .g-col-3{grid-column:auto/span 3}.grid .g-col-4{grid-column:auto/span 4}.grid .g-col-5{grid-column:auto/span 5}.grid .g-col-6{grid-column:auto/span 6}.grid .g-col-7{grid-column:auto/span 7}.grid .g-col-8{grid-column:auto/span 8}.grid .g-col-9{grid-column:auto/span 9}.grid .g-col-10{grid-column:auto/span 10}.grid .g-col-11{grid-column:auto/span 11}.grid .g-col-12{grid-column:auto/span 12}.grid .g-start-1{grid-column-start:1}.grid .g-start-2{grid-column-start:2}.grid .g-start-3{grid-column-start:3}.grid .g-start-4{grid-column-start:4}.grid .g-start-5{grid-column-start:5}.grid .g-start-6{grid-column-start:6}.grid .g-start-7{grid-column-start:7}.grid .g-start-8{grid-column-start:8}.grid .g-start-9{grid-column-start:9}.grid .g-start-10{grid-column-start:10}.grid .g-start-11{grid-column-start:11}@media(min-width: 576px){.grid .g-col-sm-1{grid-column:auto/span 1}.grid .g-col-sm-2{grid-column:auto/span 2}.grid .g-col-sm-3{grid-column:auto/span 3}.grid .g-col-sm-4{grid-column:auto/span 4}.grid .g-col-sm-5{grid-column:auto/span 5}.grid .g-col-sm-6{grid-column:auto/span 6}.grid .g-col-sm-7{grid-column:auto/span 7}.grid .g-col-sm-8{grid-column:auto/span 8}.grid .g-col-sm-9{grid-column:auto/span 9}.grid .g-col-sm-10{grid-column:auto/span 10}.grid .g-col-sm-11{grid-column:auto/span 11}.grid .g-col-sm-12{grid-column:auto/span 12}.grid .g-start-sm-1{grid-column-start:1}.grid .g-start-sm-2{grid-column-start:2}.grid .g-start-sm-3{grid-column-start:3}.grid .g-start-sm-4{grid-column-start:4}.grid .g-start-sm-5{grid-column-start:5}.grid .g-start-sm-6{grid-column-start:6}.grid .g-start-sm-7{grid-column-start:7}.grid .g-start-sm-8{grid-column-start:8}.grid .g-start-sm-9{grid-column-start:9}.grid .g-start-sm-10{grid-column-start:10}.grid .g-start-sm-11{grid-column-start:11}}@media(min-width: 768px){.grid .g-col-md-1{grid-column:auto/span 1}.grid .g-col-md-2{grid-column:auto/span 2}.grid .g-col-md-3{grid-column:auto/span 3}.grid .g-col-md-4{grid-column:auto/span 4}.grid .g-col-md-5{grid-column:auto/span 5}.grid .g-col-md-6{grid-column:auto/span 6}.grid .g-col-md-7{grid-column:auto/span 7}.grid .g-col-md-8{grid-column:auto/span 8}.grid .g-col-md-9{grid-column:auto/span 9}.grid .g-col-md-10{grid-column:auto/span 10}.grid .g-col-md-11{grid-column:auto/span 11}.grid .g-col-md-12{grid-column:auto/span 12}.grid .g-start-md-1{grid-column-start:1}.grid .g-start-md-2{grid-column-start:2}.grid .g-start-md-3{grid-column-start:3}.grid .g-start-md-4{grid-column-start:4}.grid .g-start-md-5{grid-column-start:5}.grid .g-start-md-6{grid-column-start:6}.grid .g-start-md-7{grid-column-start:7}.grid .g-start-md-8{grid-column-start:8}.grid .g-start-md-9{grid-column-start:9}.grid .g-start-md-10{grid-column-start:10}.grid .g-start-md-11{grid-column-start:11}}@media(min-width: 992px){.grid .g-col-lg-1{grid-column:auto/span 1}.grid .g-col-lg-2{grid-column:auto/span 2}.grid .g-col-lg-3{grid-column:auto/span 3}.grid .g-col-lg-4{grid-column:auto/span 4}.grid .g-col-lg-5{grid-column:auto/span 5}.grid .g-col-lg-6{grid-column:auto/span 6}.grid .g-col-lg-7{grid-column:auto/span 7}.grid .g-col-lg-8{grid-column:auto/span 8}.grid .g-col-lg-9{grid-column:auto/span 9}.grid .g-col-lg-10{grid-column:auto/span 10}.grid .g-col-lg-11{grid-column:auto/span 11}.grid .g-col-lg-12{grid-column:auto/span 12}.grid .g-start-lg-1{grid-column-start:1}.grid .g-start-lg-2{grid-column-start:2}.grid .g-start-lg-3{grid-column-start:3}.grid .g-start-lg-4{grid-column-start:4}.grid .g-start-lg-5{grid-column-start:5}.grid .g-start-lg-6{grid-column-start:6}.grid .g-start-lg-7{grid-column-start:7}.grid .g-start-lg-8{grid-column-start:8}.grid .g-start-lg-9{grid-column-start:9}.grid .g-start-lg-10{grid-column-start:10}.grid .g-start-lg-11{grid-column-start:11}}@media(min-width: 1200px){.grid .g-col-xl-1{grid-column:auto/span 1}.grid .g-col-xl-2{grid-column:auto/span 2}.grid .g-col-xl-3{grid-column:auto/span 3}.grid .g-col-xl-4{grid-column:auto/span 4}.grid .g-col-xl-5{grid-column:auto/span 5}.grid .g-col-xl-6{grid-column:auto/span 6}.grid .g-col-xl-7{grid-column:auto/span 7}.grid .g-col-xl-8{grid-column:auto/span 8}.grid .g-col-xl-9{grid-column:auto/span 9}.grid .g-col-xl-10{grid-column:auto/span 10}.grid .g-col-xl-11{grid-column:auto/span 11}.grid .g-col-xl-12{grid-column:auto/span 12}.grid .g-start-xl-1{grid-column-start:1}.grid .g-start-xl-2{grid-column-start:2}.grid .g-start-xl-3{grid-column-start:3}.grid .g-start-xl-4{grid-column-start:4}.grid .g-start-xl-5{grid-column-start:5}.grid .g-start-xl-6{grid-column-start:6}.grid .g-start-xl-7{grid-column-start:7}.grid .g-start-xl-8{grid-column-start:8}.grid .g-start-xl-9{grid-column-start:9}.grid .g-start-xl-10{grid-column-start:10}.grid .g-start-xl-11{grid-column-start:11}}@media(min-width: 1400px){.grid .g-col-xxl-1{grid-column:auto/span 1}.grid .g-col-xxl-2{grid-column:auto/span 2}.grid .g-col-xxl-3{grid-column:auto/span 3}.grid .g-col-xxl-4{grid-column:auto/span 4}.grid .g-col-xxl-5{grid-column:auto/span 5}.grid .g-col-xxl-6{grid-column:auto/span 6}.grid .g-col-xxl-7{grid-column:auto/span 7}.grid .g-col-xxl-8{grid-column:auto/span 8}.grid .g-col-xxl-9{grid-column:auto/span 9}.grid .g-col-xxl-10{grid-column:auto/span 10}.grid .g-col-xxl-11{grid-column:auto/span 11}.grid .g-col-xxl-12{grid-column:auto/span 12}.grid .g-start-xxl-1{grid-column-start:1}.grid .g-start-xxl-2{grid-column-start:2}.grid .g-start-xxl-3{grid-column-start:3}.grid .g-start-xxl-4{grid-column-start:4}.grid .g-start-xxl-5{grid-column-start:5}.grid .g-start-xxl-6{grid-column-start:6}.grid .g-start-xxl-7{grid-column-start:7}.grid .g-start-xxl-8{grid-column-start:8}.grid .g-start-xxl-9{grid-column-start:9}.grid .g-start-xxl-10{grid-column-start:10}.grid .g-start-xxl-11{grid-column-start:11}}.table{--bs-table-color-type: initial;--bs-table-bg-type: initial;--bs-table-color-state: initial;--bs-table-bg-state: initial;--bs-table-color: #343a40;--bs-table-bg: #fff;--bs-table-border-color: #dee2e6;--bs-table-accent-bg: transparent;--bs-table-striped-color: #343a40;--bs-table-striped-bg: rgba(0, 0, 0, 0.05);--bs-table-active-color: #343a40;--bs-table-active-bg: rgba(0, 0, 0, 0.1);--bs-table-hover-color: #343a40;--bs-table-hover-bg: rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(1px*2) solid rgb(153.5,156.5,159.5)}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type: var(--bs-table-striped-color);--bs-table-bg-type: var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(even){--bs-table-color-type: var(--bs-table-striped-color);--bs-table-bg-type: var(--bs-table-striped-bg)}.table-active{--bs-table-color-state: var(--bs-table-active-color);--bs-table-bg-state: var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state: var(--bs-table-hover-color);--bs-table-bg-state: var(--bs-table-hover-bg)}.table-primary{--bs-table-color: #000;--bs-table-bg: rgb(250.6, 220.8, 210.4);--bs-table-border-color: rgb(225.54, 198.72, 189.36);--bs-table-striped-bg: rgb(238.07, 209.76, 199.88);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(225.54, 198.72, 189.36);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(231.805, 204.24, 194.62);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color: #000;--bs-table-bg: rgb(238.6, 240.2, 241.8);--bs-table-border-color: rgb(214.74, 216.18, 217.62);--bs-table-striped-bg: rgb(226.67, 228.19, 229.71);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(214.74, 216.18, 217.62);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(220.705, 222.185, 223.665);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color: #000;--bs-table-bg: rgb(215.2, 240, 218.8);--bs-table-border-color: rgb(193.68, 216, 196.92);--bs-table-striped-bg: rgb(204.44, 228, 207.86);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(193.68, 216, 196.92);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(199.06, 222, 202.39);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color: #000;--bs-table-bg: rgb(208.6, 236.4, 240.8);--bs-table-border-color: rgb(187.74, 212.76, 216.72);--bs-table-striped-bg: rgb(198.17, 224.58, 228.76);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(187.74, 212.76, 216.72);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(192.955, 218.67, 222.74);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color: #000;--bs-table-bg: rgb(251.8, 240.6, 216.4);--bs-table-border-color: rgb(226.62, 216.54, 194.76);--bs-table-striped-bg: rgb(239.21, 228.57, 205.58);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(226.62, 216.54, 194.76);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(232.915, 222.555, 200.17);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color: #000;--bs-table-bg: rgb(248.6, 215.2, 212.8);--bs-table-border-color: rgb(223.74, 193.68, 191.52);--bs-table-striped-bg: rgb(236.17, 204.44, 202.16);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(223.74, 193.68, 191.52);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(229.955, 199.06, 196.84);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color: #000;--bs-table-bg: #e9ecef;--bs-table-border-color: rgb(209.7, 212.4, 215.1);--bs-table-striped-bg: rgb(221.35, 224.2, 227.05);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(209.7, 212.4, 215.1);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(215.525, 218.3, 221.075);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color: #fff;--bs-table-bg: #772953;--bs-table-border-color: rgb(132.6, 62.4, 100.2);--bs-table-striped-bg: rgb(125.8, 51.7, 91.6);--bs-table-striped-color: #fff;--bs-table-active-bg: rgb(132.6, 62.4, 100.2);--bs-table-active-color: #fff;--bs-table-hover-bg: rgb(129.2, 57.05, 95.9);--bs-table-hover-color: #fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media(max-width: 575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media(max-width: 767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media(max-width: 991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media(max-width: 1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media(max-width: 1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label,.shiny-input-container .control-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(0.375rem + 1px);padding-bottom:calc(0.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(0.5rem + 1px);padding-bottom:calc(0.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(0.25rem + 1px);padding-bottom:calc(0.25rem + 1px);font-size:0.875rem}.form-text{margin-top:.25rem;font-size:0.875em;color:rgba(52,58,64,.75)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#343a40;appearance:none;-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:#fff;background-clip:padding-box;border:1px solid #dee2e6;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#343a40;background-color:#fff;border-color:rgb(244,169.5,143.5);outline:0;box-shadow:0 0 0 .25rem rgba(233,84,32,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::placeholder{color:rgba(52,58,64,.75);opacity:1}.form-control:disabled{background-color:#e9ecef;opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-0.375rem -0.75rem;margin-inline-end:.75rem;color:#343a40;background-color:#f8f9fa;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#e9ecef}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#343a40;background-color:rgba(0,0,0,0);border:solid rgba(0,0,0,0);border-width:1px 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-sm,.form-control-plaintext.form-control-lg{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + 0.5rem + calc(1px * 2));padding:.25rem .5rem;font-size:0.875rem;border-radius:.2em}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-0.25rem -0.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(1px * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:.5rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-0.5rem -1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + 0.75rem + calc(1px * 2))}textarea.form-control-sm{min-height:calc(1.5em + 0.5rem + calc(1px * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(1px * 2))}.form-control-color{width:3rem;height:calc(1.5em + 0.75rem + calc(1px * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0 !important;border-radius:.25rem}.form-control-color::-webkit-color-swatch{border:0 !important;border-radius:.25rem}.form-control-color.form-control-sm{height:calc(1.5em + 0.5rem + calc(1px * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(1px * 2))}.form-select{--bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#343a40;appearance:none;-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:#fff;background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon, none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #dee2e6;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.form-select{transition:none}}.form-select:focus{border-color:rgb(244,169.5,143.5);outline:0;box-shadow:0 0 0 .25rem rgba(233,84,32,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:rgba(0,0,0,0);text-shadow:0 0 0 #343a40}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:0.875rem;border-radius:.2em}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:.5rem}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check,.shiny-input-container .checkbox,.shiny-input-container .radio{display:block;min-height:1.5rem;padding-left:0;margin-bottom:.125rem}.form-check .form-check-input,.form-check .shiny-input-container .checkbox input,.form-check .shiny-input-container .radio input,.shiny-input-container .checkbox .form-check-input,.shiny-input-container .checkbox .shiny-input-container .checkbox input,.shiny-input-container .checkbox .shiny-input-container .radio input,.shiny-input-container .radio .form-check-input,.shiny-input-container .radio .shiny-input-container .checkbox input,.shiny-input-container .radio .shiny-input-container .radio input{float:left;margin-left:0}.form-check-reverse{padding-right:0;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:0;margin-left:0}.form-check-input,.shiny-input-container .checkbox input,.shiny-input-container .checkbox-inline input,.shiny-input-container .radio input,.shiny-input-container .radio-inline input{--bs-form-check-bg: #fff;width:1em;height:1em;margin-top:.25em;vertical-align:top;appearance:none;-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid #dee2e6;print-color-adjust:exact}.form-check-input[type=checkbox],.shiny-input-container .checkbox input[type=checkbox],.shiny-input-container .checkbox-inline input[type=checkbox],.shiny-input-container .radio input[type=checkbox],.shiny-input-container .radio-inline input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio],.shiny-input-container .checkbox input[type=radio],.shiny-input-container .checkbox-inline input[type=radio],.shiny-input-container .radio input[type=radio],.shiny-input-container .radio-inline input[type=radio]{border-radius:50%}.form-check-input:active,.shiny-input-container .checkbox input:active,.shiny-input-container .checkbox-inline input:active,.shiny-input-container .radio input:active,.shiny-input-container .radio-inline input:active{filter:brightness(90%)}.form-check-input:focus,.shiny-input-container .checkbox input:focus,.shiny-input-container .checkbox-inline input:focus,.shiny-input-container .radio input:focus,.shiny-input-container .radio-inline input:focus{border-color:rgb(244,169.5,143.5);outline:0;box-shadow:0 0 0 .25rem rgba(233,84,32,.25)}.form-check-input:checked,.shiny-input-container .checkbox input:checked,.shiny-input-container .checkbox-inline input:checked,.shiny-input-container .radio input:checked,.shiny-input-container .radio-inline input:checked{background-color:#e95420;border-color:#e95420}.form-check-input:checked[type=checkbox],.shiny-input-container .checkbox input:checked[type=checkbox],.shiny-input-container .checkbox-inline input:checked[type=checkbox],.shiny-input-container .radio input:checked[type=checkbox],.shiny-input-container .radio-inline input:checked[type=checkbox]{--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio],.shiny-input-container .checkbox input:checked[type=radio],.shiny-input-container .checkbox-inline input:checked[type=radio],.shiny-input-container .radio input:checked[type=radio],.shiny-input-container .radio-inline input:checked[type=radio]{--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate,.shiny-input-container .checkbox input[type=checkbox]:indeterminate,.shiny-input-container .checkbox-inline input[type=checkbox]:indeterminate,.shiny-input-container .radio input[type=checkbox]:indeterminate,.shiny-input-container .radio-inline input[type=checkbox]:indeterminate{background-color:#e95420;border-color:#e95420;--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled,.shiny-input-container .checkbox input:disabled,.shiny-input-container .checkbox-inline input:disabled,.shiny-input-container .radio input:disabled,.shiny-input-container .radio-inline input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input[disabled]~.form-check-label,.form-check-input[disabled]~span,.form-check-input:disabled~.form-check-label,.form-check-input:disabled~span,.shiny-input-container .checkbox input[disabled]~.form-check-label,.shiny-input-container .checkbox input[disabled]~span,.shiny-input-container .checkbox input:disabled~.form-check-label,.shiny-input-container .checkbox input:disabled~span,.shiny-input-container .checkbox-inline input[disabled]~.form-check-label,.shiny-input-container .checkbox-inline input[disabled]~span,.shiny-input-container .checkbox-inline input:disabled~.form-check-label,.shiny-input-container .checkbox-inline input:disabled~span,.shiny-input-container .radio input[disabled]~.form-check-label,.shiny-input-container .radio input[disabled]~span,.shiny-input-container .radio input:disabled~.form-check-label,.shiny-input-container .radio input:disabled~span,.shiny-input-container .radio-inline input[disabled]~.form-check-label,.shiny-input-container .radio-inline input[disabled]~span,.shiny-input-container .radio-inline input:disabled~.form-check-label,.shiny-input-container .radio-inline input:disabled~span{cursor:default;opacity:.5}.form-check-label,.shiny-input-container .checkbox label,.shiny-input-container .checkbox-inline label,.shiny-input-container .radio label,.shiny-input-container .radio-inline label{cursor:pointer}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media(prefers-reduced-motion: reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgb%28244, 169.5, 143.5%29'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0, 0, 0, 0);pointer-events:none}.btn-check[disabled]+.btn,.btn-check:disabled+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;appearance:none;-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:rgba(0,0,0,0)}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(233,84,32,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(233,84,32,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-0.25rem;appearance:none;-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:#e95420;border:0;border-radius:1rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.form-range::-webkit-slider-thumb{transition:none}}.form-range::-webkit-slider-thumb:active{background-color:rgb(248.4,203.7,188.1)}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:rgba(0,0,0,0);cursor:pointer;background-color:#f8f9fa;border-color:rgba(0,0,0,0);border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;appearance:none;-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:#e95420;border:0;border-radius:1rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.form-range::-moz-range-thumb{transition:none}}.form-range::-moz-range-thumb:active{background-color:rgb(248.4,203.7,188.1)}.form-range::-moz-range-track{width:100%;height:.5rem;color:rgba(0,0,0,0);cursor:pointer;background-color:#f8f9fa;border-color:rgba(0,0,0,0);border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:rgba(52,58,64,.75)}.form-range:disabled::-moz-range-thumb{background-color:rgba(52,58,64,.75)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(1px * 2));min-height:calc(3.5rem + calc(1px * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:1px solid rgba(0,0,0,0);transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media(prefers-reduced-motion: reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control::placeholder,.form-floating>.form-control-plaintext::placeholder{color:rgba(0,0,0,0)}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown),.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill,.form-floating>.form-control-plaintext:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-control-plaintext~label,.form-floating>.form-select~label{color:rgba(var(--bs-body-color-rgb), 0.65);transform:scale(0.85) translateY(-0.5rem) translateX(0.15rem)}.form-floating>.form-control:focus~label::after,.form-floating>.form-control:not(:placeholder-shown)~label::after,.form-floating>.form-control-plaintext~label::after,.form-floating>.form-select~label::after{position:absolute;inset:1rem .375rem;z-index:-1;height:1.5em;content:"";background-color:#fff;border-radius:.25rem}.form-floating>.form-control:-webkit-autofill~label{color:rgba(var(--bs-body-color-rgb), 0.65);transform:scale(0.85) translateY(-0.5rem) translateX(0.15rem)}.form-floating>.form-control-plaintext~label{border-width:1px 0}.form-floating>:disabled~label,.form-floating>.form-control:disabled~label{color:#6c757d}.form-floating>:disabled~label::after,.form-floating>.form-control:disabled~label::after{background-color:#e9ecef}.input-group{position:relative;display:flex;display:-webkit-flex;flex-wrap:wrap;-webkit-flex-wrap:wrap;align-items:stretch;-webkit-align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select,.input-group>.form-floating{position:relative;flex:1 1 auto;-webkit-flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus,.input-group>.form-floating:focus-within{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;display:-webkit-flex;align-items:center;-webkit-align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#343a40;text-align:center;white-space:nowrap;background-color:#f8f9fa;border:1px solid #dee2e6;border-radius:.25rem}.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text,.input-group-lg>.btn{padding:.5rem 1rem;font-size:1.25rem;border-radius:.5rem}.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text,.input-group-sm>.btn{padding:.25rem .5rem;font-size:0.875rem;border-radius:.2em}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating),.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select{border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating),.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(1px*-1);border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:0.875em;color:#38b44a}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:0.875rem;color:#fff;background-color:#38b44a;border-radius:.25rem}.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip,.is-valid~.valid-feedback,.is-valid~.valid-tooltip{display:block}.was-validated .form-control:valid,.form-control.is-valid{border-color:#38b44a;padding-right:calc(1.5em + 0.75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2338b44a' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(0.375em + 0.1875rem) center;background-size:calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .form-control:valid:focus,.form-control.is-valid:focus{border-color:#38b44a;box-shadow:0 0 0 .25rem rgba(56,180,74,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + 0.75rem);background-position:top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem)}.was-validated .form-select:valid,.form-select.is-valid{border-color:#38b44a}.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"],.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"]{--bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2338b44a' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .form-select:valid:focus,.form-select.is-valid:focus{border-color:#38b44a;box-shadow:0 0 0 .25rem rgba(56,180,74,.25)}.was-validated .form-control-color:valid,.form-control-color.is-valid{width:calc(3rem + calc(1.5em + 0.75rem))}.was-validated .form-check-input:valid,.form-check-input.is-valid{border-color:#38b44a}.was-validated .form-check-input:valid:checked,.form-check-input.is-valid:checked{background-color:#38b44a}.was-validated .form-check-input:valid:focus,.form-check-input.is-valid:focus{box-shadow:0 0 0 .25rem rgba(56,180,74,.25)}.was-validated .form-check-input:valid~.form-check-label,.form-check-input.is-valid~.form-check-label{color:#38b44a}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.was-validated .input-group>.form-control:not(:focus):valid,.input-group>.form-control:not(:focus).is-valid,.was-validated .input-group>.form-select:not(:focus):valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.input-group>.form-floating:not(:focus-within).is-valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:0.875em;color:#df382c}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:0.875rem;color:#fff;background-color:#df382c;border-radius:.25rem}.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip,.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip{display:block}.was-validated .form-control:invalid,.form-control.is-invalid{border-color:#df382c;padding-right:calc(1.5em + 0.75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23df382c'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23df382c' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(0.375em + 0.1875rem) center;background-size:calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .form-control:invalid:focus,.form-control.is-invalid:focus{border-color:#df382c;box-shadow:0 0 0 .25rem rgba(223,56,44,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + 0.75rem);background-position:top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem)}.was-validated .form-select:invalid,.form-select.is-invalid{border-color:#df382c}.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"],.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23df382c'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23df382c' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .form-select:invalid:focus,.form-select.is-invalid:focus{border-color:#df382c;box-shadow:0 0 0 .25rem rgba(223,56,44,.25)}.was-validated .form-control-color:invalid,.form-control-color.is-invalid{width:calc(3rem + calc(1.5em + 0.75rem))}.was-validated .form-check-input:invalid,.form-check-input.is-invalid{border-color:#df382c}.was-validated .form-check-input:invalid:checked,.form-check-input.is-invalid:checked{background-color:#df382c}.was-validated .form-check-input:invalid:focus,.form-check-input.is-invalid:focus{box-shadow:0 0 0 .25rem rgba(223,56,44,.25)}.was-validated .form-check-input:invalid~.form-check-label,.form-check-input.is-invalid~.form-check-label{color:#df382c}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.was-validated .input-group>.form-control:not(:focus):invalid,.input-group>.form-control:not(:focus).is-invalid,.was-validated .input-group>.form-select:not(:focus):invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.input-group>.form-floating:not(:focus-within).is-invalid{z-index:4}.btn{--bs-btn-padding-x: 0.75rem;--bs-btn-padding-y: 0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight: 400;--bs-btn-line-height: 1.5;--bs-btn-color: #343a40;--bs-btn-bg: transparent;--bs-btn-border-width: 1px;--bs-btn-border-color: transparent;--bs-btn-border-radius: 0.25rem;--bs-btn-hover-border-color: transparent;--bs-btn-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity: 0.65;--bs-btn-focus-box-shadow: 0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;-webkit-text-decoration:none;-moz-text-decoration:none;-ms-text-decoration:none;-o-text-decoration:none;vertical-align:middle;cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,:not(.btn-check)+.btn:active,.btn:first-child:active,.btn.active,.btn.show{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,:not(.btn-check)+.btn:active:focus-visible,.btn:first-child:active:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn:disabled,.btn.disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-default{--bs-btn-color: #fff;--bs-btn-bg: #adb5bd;--bs-btn-border-color: #adb5bd;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(147.05, 153.85, 160.65);--bs-btn-hover-border-color: rgb(138.4, 144.8, 151.2);--bs-btn-focus-shadow-rgb: 185, 192, 199;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(138.4, 144.8, 151.2);--bs-btn-active-border-color: rgb(129.75, 135.75, 141.75);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #adb5bd;--bs-btn-disabled-border-color: #adb5bd}.btn-primary{--bs-btn-color: #fff;--bs-btn-bg: #e95420;--bs-btn-border-color: #e95420;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(198.05, 71.4, 27.2);--bs-btn-hover-border-color: rgb(186.4, 67.2, 25.6);--bs-btn-focus-shadow-rgb: 236, 110, 65;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(186.4, 67.2, 25.6);--bs-btn-active-border-color: rgb(174.75, 63, 24);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #e95420;--bs-btn-disabled-border-color: #e95420}.btn-secondary{--bs-btn-color: #fff;--bs-btn-bg: #adb5bd;--bs-btn-border-color: #adb5bd;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(147.05, 153.85, 160.65);--bs-btn-hover-border-color: rgb(138.4, 144.8, 151.2);--bs-btn-focus-shadow-rgb: 185, 192, 199;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(138.4, 144.8, 151.2);--bs-btn-active-border-color: rgb(129.75, 135.75, 141.75);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #adb5bd;--bs-btn-disabled-border-color: #adb5bd}.btn-success{--bs-btn-color: #fff;--bs-btn-bg: #38b44a;--bs-btn-border-color: #38b44a;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(47.6, 153, 62.9);--bs-btn-hover-border-color: rgb(44.8, 144, 59.2);--bs-btn-focus-shadow-rgb: 86, 191, 101;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(44.8, 144, 59.2);--bs-btn-active-border-color: rgb(42, 135, 55.5);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #38b44a;--bs-btn-disabled-border-color: #38b44a}.btn-info{--bs-btn-color: #fff;--bs-btn-bg: #17a2b8;--bs-btn-border-color: #17a2b8;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(19.55, 137.7, 156.4);--bs-btn-hover-border-color: rgb(18.4, 129.6, 147.2);--bs-btn-focus-shadow-rgb: 58, 176, 195;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(18.4, 129.6, 147.2);--bs-btn-active-border-color: rgb(17.25, 121.5, 138);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #17a2b8;--bs-btn-disabled-border-color: #17a2b8}.btn-warning{--bs-btn-color: #fff;--bs-btn-bg: #efb73e;--bs-btn-border-color: #efb73e;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(203.15, 155.55, 52.7);--bs-btn-hover-border-color: rgb(191.2, 146.4, 49.6);--bs-btn-focus-shadow-rgb: 241, 194, 91;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(191.2, 146.4, 49.6);--bs-btn-active-border-color: rgb(179.25, 137.25, 46.5);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #efb73e;--bs-btn-disabled-border-color: #efb73e}.btn-danger{--bs-btn-color: #fff;--bs-btn-bg: #df382c;--bs-btn-border-color: #df382c;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(189.55, 47.6, 37.4);--bs-btn-hover-border-color: rgb(178.4, 44.8, 35.2);--bs-btn-focus-shadow-rgb: 228, 86, 76;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(178.4, 44.8, 35.2);--bs-btn-active-border-color: rgb(167.25, 42, 33);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #df382c;--bs-btn-disabled-border-color: #df382c}.btn-light{--bs-btn-color: #000;--bs-btn-bg: #e9ecef;--bs-btn-border-color: #e9ecef;--bs-btn-hover-color: #000;--bs-btn-hover-bg: rgb(198.05, 200.6, 203.15);--bs-btn-hover-border-color: rgb(186.4, 188.8, 191.2);--bs-btn-focus-shadow-rgb: 198, 201, 203;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(186.4, 188.8, 191.2);--bs-btn-active-border-color: rgb(174.75, 177, 179.25);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #000;--bs-btn-disabled-bg: #e9ecef;--bs-btn-disabled-border-color: #e9ecef}.btn-dark{--bs-btn-color: #fff;--bs-btn-bg: #772953;--bs-btn-border-color: #772953;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(139.4, 73.1, 108.8);--bs-btn-hover-border-color: rgb(132.6, 62.4, 100.2);--bs-btn-focus-shadow-rgb: 139, 73, 109;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(146.2, 83.8, 117.4);--bs-btn-active-border-color: rgb(132.6, 62.4, 100.2);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #772953;--bs-btn-disabled-border-color: #772953}.btn-outline-default{--bs-btn-color: #adb5bd;--bs-btn-border-color: #adb5bd;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #adb5bd;--bs-btn-hover-border-color: #adb5bd;--bs-btn-focus-shadow-rgb: 173, 181, 189;--bs-btn-active-color: #fff;--bs-btn-active-bg: #adb5bd;--bs-btn-active-border-color: #adb5bd;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #adb5bd;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #adb5bd;--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-primary{--bs-btn-color: #e95420;--bs-btn-border-color: #e95420;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #e95420;--bs-btn-hover-border-color: #e95420;--bs-btn-focus-shadow-rgb: 233, 84, 32;--bs-btn-active-color: #fff;--bs-btn-active-bg: #e95420;--bs-btn-active-border-color: #e95420;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #e95420;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #e95420;--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-secondary{--bs-btn-color: #adb5bd;--bs-btn-border-color: #adb5bd;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #adb5bd;--bs-btn-hover-border-color: #adb5bd;--bs-btn-focus-shadow-rgb: 173, 181, 189;--bs-btn-active-color: #fff;--bs-btn-active-bg: #adb5bd;--bs-btn-active-border-color: #adb5bd;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #adb5bd;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #adb5bd;--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-success{--bs-btn-color: #38b44a;--bs-btn-border-color: #38b44a;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #38b44a;--bs-btn-hover-border-color: #38b44a;--bs-btn-focus-shadow-rgb: 56, 180, 74;--bs-btn-active-color: #fff;--bs-btn-active-bg: #38b44a;--bs-btn-active-border-color: #38b44a;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #38b44a;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #38b44a;--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-info{--bs-btn-color: #17a2b8;--bs-btn-border-color: #17a2b8;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #17a2b8;--bs-btn-hover-border-color: #17a2b8;--bs-btn-focus-shadow-rgb: 23, 162, 184;--bs-btn-active-color: #fff;--bs-btn-active-bg: #17a2b8;--bs-btn-active-border-color: #17a2b8;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #17a2b8;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #17a2b8;--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-warning{--bs-btn-color: #efb73e;--bs-btn-border-color: #efb73e;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #efb73e;--bs-btn-hover-border-color: #efb73e;--bs-btn-focus-shadow-rgb: 239, 183, 62;--bs-btn-active-color: #fff;--bs-btn-active-bg: #efb73e;--bs-btn-active-border-color: #efb73e;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #efb73e;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #efb73e;--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-danger{--bs-btn-color: #df382c;--bs-btn-border-color: #df382c;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #df382c;--bs-btn-hover-border-color: #df382c;--bs-btn-focus-shadow-rgb: 223, 56, 44;--bs-btn-active-color: #fff;--bs-btn-active-bg: #df382c;--bs-btn-active-border-color: #df382c;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #df382c;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #df382c;--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-light{--bs-btn-color: #e9ecef;--bs-btn-border-color: #e9ecef;--bs-btn-hover-color: #000;--bs-btn-hover-bg: #e9ecef;--bs-btn-hover-border-color: #e9ecef;--bs-btn-focus-shadow-rgb: 233, 236, 239;--bs-btn-active-color: #000;--bs-btn-active-bg: #e9ecef;--bs-btn-active-border-color: #e9ecef;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #e9ecef;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #e9ecef;--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-dark{--bs-btn-color: #772953;--bs-btn-border-color: #772953;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #772953;--bs-btn-hover-border-color: #772953;--bs-btn-focus-shadow-rgb: 119, 41, 83;--bs-btn-active-color: #fff;--bs-btn-active-bg: #772953;--bs-btn-active-border-color: #772953;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #772953;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #772953;--bs-btn-bg: transparent;--bs-gradient: none}.btn-link{--bs-btn-font-weight: 400;--bs-btn-color: #e95420;--bs-btn-bg: transparent;--bs-btn-border-color: transparent;--bs-btn-hover-color: rgb(186.4, 67.2, 25.6);--bs-btn-hover-border-color: transparent;--bs-btn-active-color: rgb(186.4, 67.2, 25.6);--bs-btn-active-border-color: transparent;--bs-btn-disabled-color: #6c757d;--bs-btn-disabled-border-color: transparent;--bs-btn-box-shadow: 0 0 0 #000;--bs-btn-focus-shadow-rgb: 236, 110, 65;text-decoration:underline;-webkit-text-decoration:underline;-moz-text-decoration:underline;-ms-text-decoration:underline;-o-text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-lg,.btn-group-lg>.btn{--bs-btn-padding-y: 0.5rem;--bs-btn-padding-x: 1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius: 0.5rem}.btn-sm,.btn-group-sm>.btn{--bs-btn-padding-y: 0.25rem;--bs-btn-padding-x: 0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius: 0.2em}.fade{transition:opacity .15s linear}@media(prefers-reduced-motion: reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .2s ease}@media(prefers-reduced-motion: reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media(prefers-reduced-motion: reduce){.collapsing.collapse-horizontal{transition:none}}.dropup,.dropend,.dropdown,.dropstart,.dropup-center,.dropdown-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid rgba(0,0,0,0);border-bottom:0;border-left:.3em solid rgba(0,0,0,0)}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex: 1000;--bs-dropdown-min-width: 10rem;--bs-dropdown-padding-x: 0;--bs-dropdown-padding-y: 0.5rem;--bs-dropdown-spacer: 0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color: #343a40;--bs-dropdown-bg: #fff;--bs-dropdown-border-color: rgba(0, 0, 0, 0.175);--bs-dropdown-border-radius: 0.25rem;--bs-dropdown-border-width: 1px;--bs-dropdown-inner-border-radius: calc(0.25rem - 1px);--bs-dropdown-divider-bg: rgba(0, 0, 0, 0.175);--bs-dropdown-divider-margin-y: 0.5rem;--bs-dropdown-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-dropdown-link-color: #343a40;--bs-dropdown-link-hover-color: #343a40;--bs-dropdown-link-hover-bg: #f8f9fa;--bs-dropdown-link-active-color: #fff;--bs-dropdown-link-active-bg: #e95420;--bs-dropdown-link-disabled-color: rgba(52, 58, 64, 0.5);--bs-dropdown-item-padding-x: 1rem;--bs-dropdown-item-padding-y: 0.25rem;--bs-dropdown-header-color: #6c757d;--bs-dropdown-header-padding-x: 1rem;--bs-dropdown-header-padding-y: 0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position: start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position: end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media(min-width: 576px){.dropdown-menu-sm-start{--bs-position: start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position: end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media(min-width: 768px){.dropdown-menu-md-start{--bs-position: start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position: end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media(min-width: 992px){.dropdown-menu-lg-start{--bs-position: start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position: end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media(min-width: 1200px){.dropdown-menu-xl-start{--bs-position: start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position: end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media(min-width: 1400px){.dropdown-menu-xxl-start{--bs-position: start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position: end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid rgba(0,0,0,0);border-bottom:.3em solid;border-left:.3em solid rgba(0,0,0,0)}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid rgba(0,0,0,0);border-right:0;border-bottom:.3em solid rgba(0,0,0,0);border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid rgba(0,0,0,0);border-right:.3em solid;border-bottom:.3em solid rgba(0,0,0,0)}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;-webkit-text-decoration:none;-moz-text-decoration:none;-ms-text-decoration:none;-o-text-decoration:none;white-space:nowrap;background-color:rgba(0,0,0,0);border:0;border-radius:var(--bs-dropdown-item-border-radius, 0)}.dropdown-item:hover,.dropdown-item:focus{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:rgba(0,0,0,0)}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:0.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color: #dee2e6;--bs-dropdown-bg: #343a40;--bs-dropdown-border-color: rgba(0, 0, 0, 0.175);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color: #dee2e6;--bs-dropdown-link-hover-color: #fff;--bs-dropdown-divider-bg: rgba(0, 0, 0, 0.175);--bs-dropdown-link-hover-bg: rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color: #fff;--bs-dropdown-link-active-bg: #e95420;--bs-dropdown-link-disabled-color: #adb5bd;--bs-dropdown-header-color: #adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;flex:1 1 auto;-webkit-flex:1 1 auto}.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn:hover,.btn-group>.btn:focus,.btn-group>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn:hover,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn.active{z-index:1}.btn-toolbar{display:flex;display:-webkit-flex;flex-wrap:wrap;-webkit-flex-wrap:wrap;justify-content:flex-start;-webkit-justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:.25rem}.btn-group>:not(.btn-check:first-child)+.btn,.btn-group>.btn-group:not(:first-child){margin-left:calc(1px*-1)}.btn-group>.btn:not(:last-child):not(.dropdown-toggle),.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn-group:not(:last-child)>.btn{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn,.btn-group>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-sm+.dropdown-toggle-split,.btn-group-sm>.btn+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-lg+.dropdown-toggle-split,.btn-group-lg>.btn+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;-webkit-flex-direction:column;align-items:flex-start;-webkit-align-items:flex-start;justify-content:center;-webkit-justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn:not(:first-child),.btn-group-vertical>.btn-group:not(:first-child){margin-top:calc(1px*-1)}.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle),.btn-group-vertical>.btn-group:not(:last-child)>.btn{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn~.btn,.btn-group-vertical>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x: 1rem;--bs-nav-link-padding-y: 0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color: #e95420;--bs-nav-link-hover-color: rgb(186.4, 67.2, 25.6);--bs-nav-link-disabled-color: rgba(52, 58, 64, 0.75);display:flex;display:-webkit-flex;flex-wrap:wrap;-webkit-flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;-webkit-text-decoration:none;-moz-text-decoration:none;-ms-text-decoration:none;-o-text-decoration:none;background:none;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media(prefers-reduced-motion: reduce){.nav-link{transition:none}}.nav-link:hover,.nav-link:focus{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(233,84,32,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width: 1px;--bs-nav-tabs-border-color: #dee2e6;--bs-nav-tabs-border-radius: 0.25rem;--bs-nav-tabs-link-hover-border-color: #e9ecef #e9ecef #dee2e6;--bs-nav-tabs-link-active-color: #000;--bs-nav-tabs-link-active-bg: #fff;--bs-nav-tabs-link-active-border-color: #dee2e6 #dee2e6 #fff;border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1*var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid rgba(0,0,0,0);border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:hover,.nav-tabs .nav-link:focus{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.active,.nav-tabs .nav-item.show .nav-link{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1*var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius: 0.25rem;--bs-nav-pills-link-active-color: #fff;--bs-nav-pills-link-active-bg: #e95420}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap: 1rem;--bs-nav-underline-border-width: 0.125rem;--bs-nav-underline-link-active-color: #000;gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid rgba(0,0,0,0)}.nav-underline .nav-link:hover,.nav-underline .nav-link:focus{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill>.nav-link,.nav-fill .nav-item{flex:1 1 auto;-webkit-flex:1 1 auto;text-align:center}.nav-justified>.nav-link,.nav-justified .nav-item{flex-basis:0;-webkit-flex-basis:0;flex-grow:1;-webkit-flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x: 0;--bs-navbar-padding-y: 0.5rem;--bs-navbar-color: rgba(255, 255, 255, 0.8);--bs-navbar-hover-color: rgba(255, 255, 255, 0.8);--bs-navbar-disabled-color: rgba(255, 255, 255, 0.75);--bs-navbar-active-color: #fff;--bs-navbar-brand-padding-y: 0.3125rem;--bs-navbar-brand-margin-end: 1rem;--bs-navbar-brand-font-size: 1.25rem;--bs-navbar-brand-color: rgba(255, 255, 255, 0.8);--bs-navbar-brand-hover-color: #fff;--bs-navbar-nav-link-padding-x: 0.5rem;--bs-navbar-toggler-padding-y: 0.25;--bs-navbar-toggler-padding-x: 0;--bs-navbar-toggler-font-size: 1.25rem;--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.8%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color: rgba(255, 255, 255, 0);--bs-navbar-toggler-border-radius: 0.25rem;--bs-navbar-toggler-focus-width: 0.25rem;--bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out;position:relative;display:flex;display:-webkit-flex;flex-wrap:wrap;-webkit-flex-wrap:wrap;align-items:center;-webkit-align-items:center;justify-content:space-between;-webkit-justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-sm,.navbar>.container-md,.navbar>.container-lg,.navbar>.container-xl,.navbar>.container-xxl{display:flex;display:-webkit-flex;flex-wrap:inherit;-webkit-flex-wrap:inherit;align-items:center;-webkit-align-items:center;justify-content:space-between;-webkit-justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;-webkit-text-decoration:none;-moz-text-decoration:none;-ms-text-decoration:none;-o-text-decoration:none;white-space:nowrap}.navbar-brand:hover,.navbar-brand:focus{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x: 0;--bs-nav-link-padding-y: 0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color: var(--bs-navbar-color);--bs-nav-link-hover-color: var(--bs-navbar-hover-color);--bs-nav-link-disabled-color: var(--bs-navbar-disabled-color);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:hover,.navbar-text a:focus{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;-webkit-flex-basis:100%;flex-grow:1;-webkit-flex-grow:1;align-items:center;-webkit-align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:rgba(0,0,0,0);border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media(prefers-reduced-motion: reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height, 75vh);overflow-y:auto}@media(min-width: 576px){.navbar-expand-sm{flex-wrap:nowrap;-webkit-flex-wrap:nowrap;justify-content:flex-start;-webkit-justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row;-webkit-flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex !important;display:-webkit-flex !important;flex-basis:auto;-webkit-flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;-webkit-flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:rgba(0,0,0,0) !important;border:0 !important;transform:none !important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible}}@media(min-width: 768px){.navbar-expand-md{flex-wrap:nowrap;-webkit-flex-wrap:nowrap;justify-content:flex-start;-webkit-justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row;-webkit-flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex !important;display:-webkit-flex !important;flex-basis:auto;-webkit-flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;-webkit-flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:rgba(0,0,0,0) !important;border:0 !important;transform:none !important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible}}@media(min-width: 992px){.navbar-expand-lg{flex-wrap:nowrap;-webkit-flex-wrap:nowrap;justify-content:flex-start;-webkit-justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row;-webkit-flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex !important;display:-webkit-flex !important;flex-basis:auto;-webkit-flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;-webkit-flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:rgba(0,0,0,0) !important;border:0 !important;transform:none !important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible}}@media(min-width: 1200px){.navbar-expand-xl{flex-wrap:nowrap;-webkit-flex-wrap:nowrap;justify-content:flex-start;-webkit-justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row;-webkit-flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex !important;display:-webkit-flex !important;flex-basis:auto;-webkit-flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;-webkit-flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:rgba(0,0,0,0) !important;border:0 !important;transform:none !important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible}}@media(min-width: 1400px){.navbar-expand-xxl{flex-wrap:nowrap;-webkit-flex-wrap:nowrap;justify-content:flex-start;-webkit-justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row;-webkit-flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex !important;display:-webkit-flex !important;flex-basis:auto;-webkit-flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;-webkit-flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:rgba(0,0,0,0) !important;border:0 !important;transform:none !important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;-webkit-flex-wrap:nowrap;justify-content:flex-start;-webkit-justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row;-webkit-flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex !important;display:-webkit-flex !important;flex-basis:auto;-webkit-flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;-webkit-flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:rgba(0,0,0,0) !important;border:0 !important;transform:none !important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color: rgba(255, 255, 255, 0.8);--bs-navbar-hover-color: rgba(255, 255, 255, 0.8);--bs-navbar-disabled-color: rgba(255, 255, 255, 0.75);--bs-navbar-active-color: #fff;--bs-navbar-brand-color: rgba(255, 255, 255, 0.8);--bs-navbar-brand-hover-color: #fff;--bs-navbar-toggler-border-color: rgba(255, 255, 255, 0);--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.8%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.8%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y: 1rem;--bs-card-spacer-x: 1rem;--bs-card-title-spacer-y: 0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width: 1px;--bs-card-border-color: rgba(0, 0, 0, 0.175);--bs-card-border-radius: 0.25rem;--bs-card-box-shadow: ;--bs-card-inner-border-radius: calc(0.25rem - 1px);--bs-card-cap-padding-y: 0.5rem;--bs-card-cap-padding-x: 1rem;--bs-card-cap-bg: rgba(52, 58, 64, 0.25);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg: #fff;--bs-card-img-overlay-padding: 1rem;--bs-card-group-margin: 0.75rem;position:relative;display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;-webkit-flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-0.5*var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-0.5*var(--bs-card-cap-padding-x));margin-bottom:calc(-1*var(--bs-card-cap-padding-y));margin-left:calc(-0.5*var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-0.5*var(--bs-card-cap-padding-x));margin-left:calc(-0.5*var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-top,.card-img-bottom{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media(min-width: 576px){.card-group{display:flex;display:-webkit-flex;flex-flow:row wrap;-webkit-flex-flow:row wrap}.card-group>.card{flex:1 0 0%;-webkit-flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-img-top,.card-group>.card:not(:last-child) .card-header{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-img-bottom,.card-group>.card:not(:last-child) .card-footer{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-img-top,.card-group>.card:not(:first-child) .card-header{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-img-bottom,.card-group>.card:not(:first-child) .card-footer{border-bottom-left-radius:0}}.accordion{--bs-accordion-color: #343a40;--bs-accordion-bg: #fff;--bs-accordion-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, border-radius 0.15s ease;--bs-accordion-border-color: #dee2e6;--bs-accordion-border-width: 1px;--bs-accordion-border-radius: 0.25rem;--bs-accordion-inner-border-radius: calc(0.25rem - 1px);--bs-accordion-btn-padding-x: 1.25rem;--bs-accordion-btn-padding-y: 1rem;--bs-accordion-btn-color: #343a40;--bs-accordion-btn-bg: #fff;--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23343a40'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width: 1.25rem;--bs-accordion-btn-icon-transform: rotate(-180deg);--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='rgb%2893.2, 33.6, 12.8%29'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-focus-border-color: rgb(244, 169.5, 143.5);--bs-accordion-btn-focus-box-shadow: 0 0 0 0.25rem rgba(233, 84, 32, 0.25);--bs-accordion-body-padding-x: 1.25rem;--bs-accordion-body-padding-y: 1rem;--bs-accordion-active-color: rgb(93.2, 33.6, 12.8);--bs-accordion-active-bg: rgb(250.6, 220.8, 210.4)}.accordion-button{position:relative;display:flex;display:-webkit-flex;align-items:center;-webkit-align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media(prefers-reduced-motion: reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1*var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;-webkit-flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media(prefers-reduced-motion: reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:var(--bs-accordion-btn-focus-border-color);outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button,.accordion-flush .accordion-item .accordion-button.collapsed{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='rgb%28241.8, 152.4, 121.2%29'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='rgb%28241.8, 152.4, 121.2%29'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x: 0;--bs-breadcrumb-padding-y: 0;--bs-breadcrumb-margin-bottom: 1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color: rgba(52, 58, 64, 0.75);--bs-breadcrumb-item-padding-x: 0.5rem;--bs-breadcrumb-item-active-color: rgba(52, 58, 64, 0.75);display:flex;display:-webkit-flex;flex-wrap:wrap;-webkit-flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, ">") /* rtl: var(--bs-breadcrumb-divider, ">") */}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x: 0.75rem;--bs-pagination-padding-y: 0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color: #e95420;--bs-pagination-bg: #fff;--bs-pagination-border-width: 1px;--bs-pagination-border-color: #dee2e6;--bs-pagination-border-radius: 0.25rem;--bs-pagination-hover-color: rgb(186.4, 67.2, 25.6);--bs-pagination-hover-bg: #f8f9fa;--bs-pagination-hover-border-color: #dee2e6;--bs-pagination-focus-color: rgb(186.4, 67.2, 25.6);--bs-pagination-focus-bg: #e9ecef;--bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(233, 84, 32, 0.25);--bs-pagination-active-color: #fff;--bs-pagination-active-bg: #e95420;--bs-pagination-active-border-color: #e95420;--bs-pagination-disabled-color: rgba(52, 58, 64, 0.75);--bs-pagination-disabled-bg: #e9ecef;--bs-pagination-disabled-border-color: #dee2e6;display:flex;display:-webkit-flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;-webkit-text-decoration:none;-moz-text-decoration:none;-ms-text-decoration:none;-o-text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.page-link.active,.active>.page-link{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.page-link.disabled,.disabled>.page-link{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(1px*-1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x: 1.5rem;--bs-pagination-padding-y: 0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius: 0.5rem}.pagination-sm{--bs-pagination-padding-x: 0.5rem;--bs-pagination-padding-y: 0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius: 0.2em}.badge{--bs-badge-padding-x: 0.65em;--bs-badge-padding-y: 0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight: 700;--bs-badge-color: #fff;--bs-badge-border-radius: 0.25rem;display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg: transparent;--bs-alert-padding-x: 1rem;--bs-alert-padding-y: 1rem;--bs-alert-margin-bottom: 1rem;--bs-alert-color: inherit;--bs-alert-border-color: transparent;--bs-alert-border: 1px solid var(--bs-alert-border-color);--bs-alert-border-radius: 0.25rem;--bs-alert-link-color: inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-default{--bs-alert-color: var(--bs-default-text-emphasis);--bs-alert-bg: var(--bs-default-bg-subtle);--bs-alert-border-color: var(--bs-default-border-subtle);--bs-alert-link-color: var(--bs-default-text-emphasis)}.alert-primary{--bs-alert-color: var(--bs-primary-text-emphasis);--bs-alert-bg: var(--bs-primary-bg-subtle);--bs-alert-border-color: var(--bs-primary-border-subtle);--bs-alert-link-color: var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color: var(--bs-secondary-text-emphasis);--bs-alert-bg: var(--bs-secondary-bg-subtle);--bs-alert-border-color: var(--bs-secondary-border-subtle);--bs-alert-link-color: var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color: var(--bs-success-text-emphasis);--bs-alert-bg: var(--bs-success-bg-subtle);--bs-alert-border-color: var(--bs-success-border-subtle);--bs-alert-link-color: var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color: var(--bs-info-text-emphasis);--bs-alert-bg: var(--bs-info-bg-subtle);--bs-alert-border-color: var(--bs-info-border-subtle);--bs-alert-link-color: var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color: var(--bs-warning-text-emphasis);--bs-alert-bg: var(--bs-warning-bg-subtle);--bs-alert-border-color: var(--bs-warning-border-subtle);--bs-alert-link-color: var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color: var(--bs-danger-text-emphasis);--bs-alert-bg: var(--bs-danger-bg-subtle);--bs-alert-border-color: var(--bs-danger-border-subtle);--bs-alert-link-color: var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color: var(--bs-light-text-emphasis);--bs-alert-bg: var(--bs-light-bg-subtle);--bs-alert-border-color: var(--bs-light-border-subtle);--bs-alert-link-color: var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color: var(--bs-dark-text-emphasis);--bs-alert-bg: var(--bs-dark-bg-subtle);--bs-alert-border-color: var(--bs-dark-border-subtle);--bs-alert-link-color: var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress,.progress-stacked{--bs-progress-height: 1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg: #e9ecef;--bs-progress-border-radius: 0.25rem;--bs-progress-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-progress-bar-color: #fff;--bs-progress-bar-bg: #e95420;--bs-progress-bar-transition: width 0.6s ease;display:flex;display:-webkit-flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;justify-content:center;-webkit-justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media(prefers-reduced-motion: reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media(prefers-reduced-motion: reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color: #343a40;--bs-list-group-bg: #fff;--bs-list-group-border-color: #dee2e6;--bs-list-group-border-width: 1px;--bs-list-group-border-radius: 0.25rem;--bs-list-group-item-padding-x: 1rem;--bs-list-group-item-padding-y: 0.5rem;--bs-list-group-action-color: rgba(52, 58, 64, 0.75);--bs-list-group-action-hover-color: #000;--bs-list-group-action-hover-bg: #f8f9fa;--bs-list-group-action-active-color: #343a40;--bs-list-group-action-active-bg: #e9ecef;--bs-list-group-disabled-color: rgba(52, 58, 64, 0.75);--bs-list-group-disabled-bg: #fff;--bs-list-group-active-color: #fff;--bs-list-group-active-bg: #e95420;--bs-list-group-active-border-color: #e95420;display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:hover,.list-group-item-action:focus{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;-webkit-text-decoration:none;-moz-text-decoration:none;-ms-text-decoration:none;-o-text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1*var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row;-webkit-flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1*var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media(min-width: 576px){.list-group-horizontal-sm{flex-direction:row;-webkit-flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1*var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media(min-width: 768px){.list-group-horizontal-md{flex-direction:row;-webkit-flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1*var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media(min-width: 992px){.list-group-horizontal-lg{flex-direction:row;-webkit-flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1*var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media(min-width: 1200px){.list-group-horizontal-xl{flex-direction:row;-webkit-flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1*var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media(min-width: 1400px){.list-group-horizontal-xxl{flex-direction:row;-webkit-flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1*var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-default{--bs-list-group-color: var(--bs-default-text-emphasis);--bs-list-group-bg: var(--bs-default-bg-subtle);--bs-list-group-border-color: var(--bs-default-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-default-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-default-border-subtle);--bs-list-group-active-color: var(--bs-default-bg-subtle);--bs-list-group-active-bg: var(--bs-default-text-emphasis);--bs-list-group-active-border-color: var(--bs-default-text-emphasis)}.list-group-item-primary{--bs-list-group-color: var(--bs-primary-text-emphasis);--bs-list-group-bg: var(--bs-primary-bg-subtle);--bs-list-group-border-color: var(--bs-primary-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-primary-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-primary-border-subtle);--bs-list-group-active-color: var(--bs-primary-bg-subtle);--bs-list-group-active-bg: var(--bs-primary-text-emphasis);--bs-list-group-active-border-color: var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color: var(--bs-secondary-text-emphasis);--bs-list-group-bg: var(--bs-secondary-bg-subtle);--bs-list-group-border-color: var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-secondary-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-secondary-border-subtle);--bs-list-group-active-color: var(--bs-secondary-bg-subtle);--bs-list-group-active-bg: var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color: var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color: var(--bs-success-text-emphasis);--bs-list-group-bg: var(--bs-success-bg-subtle);--bs-list-group-border-color: var(--bs-success-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-success-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-success-border-subtle);--bs-list-group-active-color: var(--bs-success-bg-subtle);--bs-list-group-active-bg: var(--bs-success-text-emphasis);--bs-list-group-active-border-color: var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color: var(--bs-info-text-emphasis);--bs-list-group-bg: var(--bs-info-bg-subtle);--bs-list-group-border-color: var(--bs-info-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-info-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-info-border-subtle);--bs-list-group-active-color: var(--bs-info-bg-subtle);--bs-list-group-active-bg: var(--bs-info-text-emphasis);--bs-list-group-active-border-color: var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color: var(--bs-warning-text-emphasis);--bs-list-group-bg: var(--bs-warning-bg-subtle);--bs-list-group-border-color: var(--bs-warning-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-warning-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-warning-border-subtle);--bs-list-group-active-color: var(--bs-warning-bg-subtle);--bs-list-group-active-bg: var(--bs-warning-text-emphasis);--bs-list-group-active-border-color: var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color: var(--bs-danger-text-emphasis);--bs-list-group-bg: var(--bs-danger-bg-subtle);--bs-list-group-border-color: var(--bs-danger-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-danger-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-danger-border-subtle);--bs-list-group-active-color: var(--bs-danger-bg-subtle);--bs-list-group-active-bg: var(--bs-danger-text-emphasis);--bs-list-group-active-border-color: var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color: var(--bs-light-text-emphasis);--bs-list-group-bg: var(--bs-light-bg-subtle);--bs-list-group-border-color: var(--bs-light-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-light-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-light-border-subtle);--bs-list-group-active-color: var(--bs-light-bg-subtle);--bs-list-group-active-bg: var(--bs-light-text-emphasis);--bs-list-group-active-border-color: var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color: var(--bs-dark-text-emphasis);--bs-list-group-bg: var(--bs-dark-bg-subtle);--bs-list-group-border-color: var(--bs-dark-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-dark-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-dark-border-subtle);--bs-list-group-active-color: var(--bs-dark-bg-subtle);--bs-list-group-active-bg: var(--bs-dark-text-emphasis);--bs-list-group-active-border-color: var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color: #000;--bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity: 0.5;--bs-btn-close-hover-opacity: 0.75;--bs-btn-close-focus-shadow: 0 0 0 0.25rem rgba(233, 84, 32, 0.25);--bs-btn-close-focus-opacity: 1;--bs-btn-close-disabled-opacity: 0.25;--bs-btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:rgba(0,0,0,0) var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.25rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close:disabled,.btn-close.disabled{pointer-events:none;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{filter:var(--bs-btn-close-white-filter)}[data-bs-theme=dark] .btn-close{filter:var(--bs-btn-close-white-filter)}.toast{--bs-toast-zindex: 1090;--bs-toast-padding-x: 0.75rem;--bs-toast-padding-y: 0.5rem;--bs-toast-spacing: 1.5rem;--bs-toast-max-width: 350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg: rgba(255, 255, 255, 0.85);--bs-toast-border-width: 1px;--bs-toast-border-color: rgba(0, 0, 0, 0.175);--bs-toast-border-radius: 0.25rem;--bs-toast-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-toast-header-color: rgba(52, 58, 64, 0.75);--bs-toast-header-bg: rgba(255, 255, 255, 0.85);--bs-toast-header-border-color: rgba(0, 0, 0, 0.175);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex: 1090;position:absolute;z-index:var(--bs-toast-zindex);width:max-content;width:-webkit-max-content;width:-moz-max-content;width:-ms-max-content;width:-o-max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;display:-webkit-flex;align-items:center;-webkit-align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-0.5*var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex: 1055;--bs-modal-width: 500px;--bs-modal-padding: 1rem;--bs-modal-margin: 0.5rem;--bs-modal-color: ;--bs-modal-bg: #fff;--bs-modal-border-color: rgba(0, 0, 0, 0.175);--bs-modal-border-width: 1px;--bs-modal-border-radius: 0.5rem;--bs-modal-box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-modal-inner-border-radius: calc(0.5rem - 1px);--bs-modal-header-padding-x: 1rem;--bs-modal-header-padding-y: 1rem;--bs-modal-header-padding: 1rem 1rem;--bs-modal-header-border-color: #dee2e6;--bs-modal-header-border-width: 1px;--bs-modal-title-line-height: 1.5;--bs-modal-footer-gap: 0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color: #dee2e6;--bs-modal-footer-border-width: 1px;position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0, -50px)}@media(prefers-reduced-motion: reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin)*2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;display:-webkit-flex;align-items:center;-webkit-align-items:center;min-height:calc(100% - var(--bs-modal-margin)*2)}.modal-content{position:relative;display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex: 1050;--bs-backdrop-bg: #000;--bs-backdrop-opacity: 0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;display:-webkit-flex;flex-shrink:0;-webkit-flex-shrink:0;align-items:center;-webkit-align-items:center;justify-content:space-between;-webkit-justify-content:space-between;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y)*.5) calc(var(--bs-modal-header-padding-x)*.5);margin:calc(-0.5*var(--bs-modal-header-padding-y)) calc(-0.5*var(--bs-modal-header-padding-x)) calc(-0.5*var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;-webkit-flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;display:-webkit-flex;flex-shrink:0;-webkit-flex-shrink:0;flex-wrap:wrap;-webkit-flex-wrap:wrap;align-items:center;-webkit-align-items:center;justify-content:flex-end;-webkit-justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap)*.5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap)*.5)}@media(min-width: 576px){.modal{--bs-modal-margin: 1.75rem;--bs-modal-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width: 300px}}@media(min-width: 992px){.modal-lg,.modal-xl{--bs-modal-width: 800px}}@media(min-width: 1200px){.modal-xl{--bs-modal-width: 1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header,.modal-fullscreen .modal-footer{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media(max-width: 575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header,.modal-fullscreen-sm-down .modal-footer{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media(max-width: 767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header,.modal-fullscreen-md-down .modal-footer{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media(max-width: 991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header,.modal-fullscreen-lg-down .modal-footer{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media(max-width: 1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header,.modal-fullscreen-xl-down .modal-footer{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media(max-width: 1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header,.modal-fullscreen-xxl-down .modal-footer{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex: 1080;--bs-tooltip-max-width: 200px;--bs-tooltip-padding-x: 0.5rem;--bs-tooltip-padding-y: 0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color: #fff;--bs-tooltip-bg: #000;--bs-tooltip-border-radius: 0.25rem;--bs-tooltip-opacity: 0.9;--bs-tooltip-arrow-width: 0.8rem;--bs-tooltip-arrow-height: 0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:Ubuntu,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:rgba(0,0,0,0);border-style:solid}.bs-tooltip-top .tooltip-arrow,.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow{bottom:calc(-1*var(--bs-tooltip-arrow-height))}.bs-tooltip-top .tooltip-arrow::before,.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width)*.5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-end .tooltip-arrow,.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow{left:calc(-1*var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-end .tooltip-arrow::before,.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width)*.5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width)*.5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-bottom .tooltip-arrow,.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow{top:calc(-1*var(--bs-tooltip-arrow-height))}.bs-tooltip-bottom .tooltip-arrow::before,.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width)*.5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-start .tooltip-arrow,.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow{right:calc(-1*var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-start .tooltip-arrow::before,.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width)*.5) 0 calc(var(--bs-tooltip-arrow-width)*.5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex: 1070;--bs-popover-max-width: 276px;--bs-popover-font-size:0.875rem;--bs-popover-bg: #fff;--bs-popover-border-width: 1px;--bs-popover-border-color: rgba(0, 0, 0, 0.175);--bs-popover-border-radius: 0.5rem;--bs-popover-inner-border-radius: calc(0.5rem - 1px);--bs-popover-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-popover-header-padding-x: 1rem;--bs-popover-header-padding-y: 0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color: inherit;--bs-popover-header-bg: #e9ecef;--bs-popover-body-padding-x: 1rem;--bs-popover-body-padding-y: 1rem;--bs-popover-body-color: #343a40;--bs-popover-arrow-width: 1rem;--bs-popover-arrow-height: 0.5rem;--bs-popover-arrow-border: var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:Ubuntu,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::before,.popover .popover-arrow::after{position:absolute;display:block;content:"";border-color:rgba(0,0,0,0);border-style:solid;border-width:0}.bs-popover-top>.popover-arrow,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow{bottom:calc(-1*(var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-top>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width)*.5) 0}.bs-popover-top>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-top>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-end>.popover-arrow,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow{left:calc(-1*(var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-end>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after{border-width:calc(var(--bs-popover-arrow-width)*.5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width)*.5) 0}.bs-popover-end>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-end>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-bottom>.popover-arrow,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow{top:calc(-1*(var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-bottom>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after{border-width:0 calc(var(--bs-popover-arrow-width)*.5) var(--bs-popover-arrow-height)}.bs-popover-bottom>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-bottom>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-bottom .popover-header::before,.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-0.5*var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-start>.popover-arrow,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow{right:calc(-1*(var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-start>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after{border-width:calc(var(--bs-popover-arrow-width)*.5) 0 calc(var(--bs-popover-arrow-width)*.5) var(--bs-popover-arrow-height)}.bs-popover-start>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-start>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y;-webkit-touch-action:pan-y;-moz-touch-action:pan-y;-ms-touch-action:pan-y;-o-touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;backface-visibility:hidden;-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;-ms-backface-visibility:hidden;-o-backface-visibility:hidden;transition:transform .6s ease-in-out}@media(prefers-reduced-motion: reduce){.carousel-item{transition:none}}.carousel-item.active,.carousel-item-next,.carousel-item-prev{display:block}.carousel-item-next:not(.carousel-item-start),.active.carousel-item-end{transform:translateX(100%)}.carousel-item-prev:not(.carousel-item-end),.active.carousel-item-start{transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item.active,.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end{z-index:1;opacity:1}.carousel-fade .active.carousel-item-start,.carousel-fade .active.carousel-item-end{z-index:0;opacity:0;transition:opacity 0s .6s}@media(prefers-reduced-motion: reduce){.carousel-fade .active.carousel-item-start,.carousel-fade .active.carousel-item-end{transition:none}}.carousel-control-prev,.carousel-control-next{position:absolute;top:0;bottom:0;z-index:1;display:flex;display:-webkit-flex;align-items:center;-webkit-align-items:center;justify-content:center;-webkit-justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:none;border:0;opacity:.5;transition:opacity .15s ease}@media(prefers-reduced-motion: reduce){.carousel-control-prev,.carousel-control-next{transition:none}}.carousel-control-prev:hover,.carousel-control-prev:focus,.carousel-control-next:hover,.carousel-control-next:focus{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-prev-icon,.carousel-control-next-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;display:-webkit-flex;justify-content:center;-webkit-justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;-webkit-flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid rgba(0,0,0,0);border-bottom:10px solid rgba(0,0,0,0);opacity:.5;transition:opacity .6s ease}@media(prefers-reduced-motion: reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-prev-icon,.carousel-dark .carousel-control-next-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}[data-bs-theme=dark] .carousel .carousel-control-prev-icon,[data-bs-theme=dark] .carousel .carousel-control-next-icon,[data-bs-theme=dark].carousel .carousel-control-prev-icon,[data-bs-theme=dark].carousel .carousel-control-next-icon{filter:invert(1) grayscale(100)}[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target],[data-bs-theme=dark].carousel .carousel-indicators [data-bs-target]{background-color:#000}[data-bs-theme=dark] .carousel .carousel-caption,[data-bs-theme=dark].carousel .carousel-caption{color:#000}.spinner-grow,.spinner-border{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg) /* rtl:ignore */}}.spinner-border{--bs-spinner-width: 2rem;--bs-spinner-height: 2rem;--bs-spinner-vertical-align: -0.125em;--bs-spinner-border-width: 0.25em;--bs-spinner-animation-speed: 0.75s;--bs-spinner-animation-name: spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:rgba(0,0,0,0)}.spinner-border-sm{--bs-spinner-width: 1rem;--bs-spinner-height: 1rem;--bs-spinner-border-width: 0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width: 2rem;--bs-spinner-height: 2rem;--bs-spinner-vertical-align: -0.125em;--bs-spinner-animation-speed: 0.75s;--bs-spinner-animation-name: spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width: 1rem;--bs-spinner-height: 1rem}@media(prefers-reduced-motion: reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed: 1.5s}}.offcanvas,.offcanvas-xxl,.offcanvas-xl,.offcanvas-lg,.offcanvas-md,.offcanvas-sm{--bs-offcanvas-zindex: 1045;--bs-offcanvas-width: 400px;--bs-offcanvas-height: 30vh;--bs-offcanvas-padding-x: 1rem;--bs-offcanvas-padding-y: 1rem;--bs-offcanvas-color: #343a40;--bs-offcanvas-bg: #fff;--bs-offcanvas-border-width: 1px;--bs-offcanvas-border-color: rgba(0, 0, 0, 0.175);--bs-offcanvas-box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-offcanvas-transition: transform 0.3s ease-in-out;--bs-offcanvas-title-line-height: 1.5}@media(max-width: 575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media(max-width: 575.98px)and (prefers-reduced-motion: reduce){.offcanvas-sm{transition:none}}@media(max-width: 575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.showing,.offcanvas-sm.show:not(.hiding){transform:none}.offcanvas-sm.showing,.offcanvas-sm.hiding,.offcanvas-sm.show{visibility:visible}}@media(min-width: 576px){.offcanvas-sm{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:rgba(0,0,0,0) !important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible;background-color:rgba(0,0,0,0) !important}}@media(max-width: 767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media(max-width: 767.98px)and (prefers-reduced-motion: reduce){.offcanvas-md{transition:none}}@media(max-width: 767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.showing,.offcanvas-md.show:not(.hiding){transform:none}.offcanvas-md.showing,.offcanvas-md.hiding,.offcanvas-md.show{visibility:visible}}@media(min-width: 768px){.offcanvas-md{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:rgba(0,0,0,0) !important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible;background-color:rgba(0,0,0,0) !important}}@media(max-width: 991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media(max-width: 991.98px)and (prefers-reduced-motion: reduce){.offcanvas-lg{transition:none}}@media(max-width: 991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.showing,.offcanvas-lg.show:not(.hiding){transform:none}.offcanvas-lg.showing,.offcanvas-lg.hiding,.offcanvas-lg.show{visibility:visible}}@media(min-width: 992px){.offcanvas-lg{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:rgba(0,0,0,0) !important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible;background-color:rgba(0,0,0,0) !important}}@media(max-width: 1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media(max-width: 1199.98px)and (prefers-reduced-motion: reduce){.offcanvas-xl{transition:none}}@media(max-width: 1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.showing,.offcanvas-xl.show:not(.hiding){transform:none}.offcanvas-xl.showing,.offcanvas-xl.hiding,.offcanvas-xl.show{visibility:visible}}@media(min-width: 1200px){.offcanvas-xl{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:rgba(0,0,0,0) !important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible;background-color:rgba(0,0,0,0) !important}}@media(max-width: 1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media(max-width: 1399.98px)and (prefers-reduced-motion: reduce){.offcanvas-xxl{transition:none}}@media(max-width: 1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.showing,.offcanvas-xxl.show:not(.hiding){transform:none}.offcanvas-xxl.showing,.offcanvas-xxl.hiding,.offcanvas-xxl.show{visibility:visible}}@media(min-width: 1400px){.offcanvas-xxl{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:rgba(0,0,0,0) !important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible;background-color:rgba(0,0,0,0) !important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media(prefers-reduced-motion: reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.showing,.offcanvas.show:not(.hiding){transform:none}.offcanvas.showing,.offcanvas.hiding,.offcanvas.show{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;display:-webkit-flex;align-items:center;-webkit-align-items:center;justify-content:space-between;-webkit-justify-content:space-between;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y)*.5) calc(var(--bs-offcanvas-padding-x)*.5);margin-top:calc(-0.5*var(--bs-offcanvas-padding-y));margin-right:calc(-0.5*var(--bs-offcanvas-padding-x));margin-bottom:calc(-0.5*var(--bs-offcanvas-padding-y))}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;-webkit-flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{mask-image:linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%);-webkit-mask-image:linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%);mask-size:200% 100%;-webkit-mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{mask-position:-200% 0%;-webkit-mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-default{color:#fff !important;background-color:RGBA(var(--bs-default-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-primary{color:#fff !important;background-color:RGBA(var(--bs-primary-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-secondary{color:#fff !important;background-color:RGBA(var(--bs-secondary-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-success{color:#fff !important;background-color:RGBA(var(--bs-success-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-info{color:#fff !important;background-color:RGBA(var(--bs-info-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-warning{color:#fff !important;background-color:RGBA(var(--bs-warning-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-danger{color:#fff !important;background-color:RGBA(var(--bs-danger-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-light{color:#000 !important;background-color:RGBA(var(--bs-light-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-dark{color:#fff !important;background-color:RGBA(var(--bs-dark-rgb), var(--bs-bg-opacity, 1)) !important}.link-default{color:RGBA(var(--bs-default-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-default-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-default:hover,.link-default:focus{color:RGBA(138, 145, 151, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(138, 145, 151, var(--bs-link-underline-opacity, 1)) !important}.link-primary{color:RGBA(var(--bs-primary-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-primary-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-primary:hover,.link-primary:focus{color:RGBA(186, 67, 26, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(186, 67, 26, var(--bs-link-underline-opacity, 1)) !important}.link-secondary{color:RGBA(var(--bs-secondary-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-secondary-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-secondary:hover,.link-secondary:focus{color:RGBA(138, 145, 151, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(138, 145, 151, var(--bs-link-underline-opacity, 1)) !important}.link-success{color:RGBA(var(--bs-success-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-success-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-success:hover,.link-success:focus{color:RGBA(45, 144, 59, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(45, 144, 59, var(--bs-link-underline-opacity, 1)) !important}.link-info{color:RGBA(var(--bs-info-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-info-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-info:hover,.link-info:focus{color:RGBA(18, 130, 147, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(18, 130, 147, var(--bs-link-underline-opacity, 1)) !important}.link-warning{color:RGBA(var(--bs-warning-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-warning-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-warning:hover,.link-warning:focus{color:RGBA(191, 146, 50, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(191, 146, 50, var(--bs-link-underline-opacity, 1)) !important}.link-danger{color:RGBA(var(--bs-danger-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-danger-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-danger:hover,.link-danger:focus{color:RGBA(178, 45, 35, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(178, 45, 35, var(--bs-link-underline-opacity, 1)) !important}.link-light{color:RGBA(var(--bs-light-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-light-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-light:hover,.link-light:focus{color:RGBA(237, 240, 242, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(237, 240, 242, var(--bs-link-underline-opacity, 1)) !important}.link-dark{color:RGBA(var(--bs-dark-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-dark-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-dark:hover,.link-dark:focus{color:RGBA(95, 33, 66, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(95, 33, 66, var(--bs-link-underline-opacity, 1)) !important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-body-emphasis:hover,.link-body-emphasis:focus{color:RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-opacity, 0.75)) !important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 0.75)) !important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x, 0) var(--bs-focus-ring-y, 0) var(--bs-focus-ring-blur, 0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-align-items:center;text-decoration-color:rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 0.5));text-underline-offset:.25em;backface-visibility:hidden;-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;-ms-backface-visibility:hidden;-o-backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;-webkit-flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media(prefers-reduced-motion: reduce){.icon-link>.bi{transition:none}}.icon-link-hover:hover>.bi,.icon-link-hover:focus-visible>.bi{transform:var(--bs-icon-link-transform, translate3d(0.25em, 0, 0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio: 100%}.ratio-4x3{--bs-aspect-ratio: 75%}.ratio-16x9{--bs-aspect-ratio: 56.25%}.ratio-21x9{--bs-aspect-ratio: 42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:sticky;top:0;z-index:1020}.sticky-bottom{position:sticky;bottom:0;z-index:1020}@media(min-width: 576px){.sticky-sm-top{position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:sticky;bottom:0;z-index:1020}}@media(min-width: 768px){.sticky-md-top{position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:sticky;bottom:0;z-index:1020}}@media(min-width: 992px){.sticky-lg-top{position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:sticky;bottom:0;z-index:1020}}@media(min-width: 1200px){.sticky-xl-top{position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:sticky;bottom:0;z-index:1020}}@media(min-width: 1400px){.sticky-xxl-top{position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;display:-webkit-flex;flex-direction:row;-webkit-flex-direction:row;align-items:center;-webkit-align-items:center;align-self:stretch;-webkit-align-self:stretch}.vstack{display:flex;display:-webkit-flex;flex:1 1 auto;-webkit-flex:1 1 auto;flex-direction:column;-webkit-flex-direction:column;align-self:stretch;-webkit-align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px !important;height:1px !important;padding:0 !important;margin:-1px !important;overflow:hidden !important;clip:rect(0, 0, 0, 0) !important;white-space:nowrap !important;border:0 !important}.visually-hidden:not(caption),.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption){position:absolute !important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;-webkit-align-self:stretch;width:1px;min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline !important}.align-top{vertical-align:top !important}.align-middle{vertical-align:middle !important}.align-bottom{vertical-align:bottom !important}.align-text-bottom{vertical-align:text-bottom !important}.align-text-top{vertical-align:text-top !important}.float-start{float:left !important}.float-end{float:right !important}.float-none{float:none !important}.object-fit-contain{object-fit:contain !important}.object-fit-cover{object-fit:cover !important}.object-fit-fill{object-fit:fill !important}.object-fit-scale{object-fit:scale-down !important}.object-fit-none{object-fit:none !important}.opacity-0{opacity:0 !important}.opacity-25{opacity:.25 !important}.opacity-50{opacity:.5 !important}.opacity-75{opacity:.75 !important}.opacity-100{opacity:1 !important}.overflow-auto{overflow:auto !important}.overflow-hidden{overflow:hidden !important}.overflow-visible{overflow:visible !important}.overflow-scroll{overflow:scroll !important}.overflow-x-auto{overflow-x:auto !important}.overflow-x-hidden{overflow-x:hidden !important}.overflow-x-visible{overflow-x:visible !important}.overflow-x-scroll{overflow-x:scroll !important}.overflow-y-auto{overflow-y:auto !important}.overflow-y-hidden{overflow-y:hidden !important}.overflow-y-visible{overflow-y:visible !important}.overflow-y-scroll{overflow-y:scroll !important}.d-inline{display:inline !important}.d-inline-block{display:inline-block !important}.d-block{display:block !important}.d-grid{display:grid !important}.d-inline-grid{display:inline-grid !important}.d-table{display:table !important}.d-table-row{display:table-row !important}.d-table-cell{display:table-cell !important}.d-flex{display:flex !important}.d-inline-flex{display:inline-flex !important}.d-none{display:none !important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15) !important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075) !important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175) !important}.shadow-none{box-shadow:none !important}.focus-ring-default{--bs-focus-ring-color: rgba(var(--bs-default-rgb), var(--bs-focus-ring-opacity))}.focus-ring-primary{--bs-focus-ring-color: rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color: rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color: rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color: rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color: rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color: rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color: rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color: rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static !important}.position-relative{position:relative !important}.position-absolute{position:absolute !important}.position-fixed{position:fixed !important}.position-sticky{position:sticky !important}.top-0{top:0 !important}.top-50{top:50% !important}.top-100{top:100% !important}.bottom-0{bottom:0 !important}.bottom-50{bottom:50% !important}.bottom-100{bottom:100% !important}.start-0{left:0 !important}.start-50{left:50% !important}.start-100{left:100% !important}.end-0{right:0 !important}.end-50{right:50% !important}.end-100{right:100% !important}.translate-middle{transform:translate(-50%, -50%) !important}.translate-middle-x{transform:translateX(-50%) !important}.translate-middle-y{transform:translateY(-50%) !important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important}.border-0{border:0 !important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important}.border-top-0{border-top:0 !important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important}.border-end-0{border-right:0 !important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important}.border-bottom-0{border-bottom:0 !important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important}.border-start-0{border-left:0 !important}.border-default{--bs-border-opacity: 1;border-color:rgba(var(--bs-default-rgb), var(--bs-border-opacity)) !important}.border-primary{--bs-border-opacity: 1;border-color:rgba(var(--bs-primary-rgb), var(--bs-border-opacity)) !important}.border-secondary{--bs-border-opacity: 1;border-color:rgba(var(--bs-secondary-rgb), var(--bs-border-opacity)) !important}.border-success{--bs-border-opacity: 1;border-color:rgba(var(--bs-success-rgb), var(--bs-border-opacity)) !important}.border-info{--bs-border-opacity: 1;border-color:rgba(var(--bs-info-rgb), var(--bs-border-opacity)) !important}.border-warning{--bs-border-opacity: 1;border-color:rgba(var(--bs-warning-rgb), var(--bs-border-opacity)) !important}.border-danger{--bs-border-opacity: 1;border-color:rgba(var(--bs-danger-rgb), var(--bs-border-opacity)) !important}.border-light{--bs-border-opacity: 1;border-color:rgba(var(--bs-light-rgb), var(--bs-border-opacity)) !important}.border-dark{--bs-border-opacity: 1;border-color:rgba(var(--bs-dark-rgb), var(--bs-border-opacity)) !important}.border-black{--bs-border-opacity: 1;border-color:rgba(var(--bs-black-rgb), var(--bs-border-opacity)) !important}.border-white{--bs-border-opacity: 1;border-color:rgba(var(--bs-white-rgb), var(--bs-border-opacity)) !important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle) !important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle) !important}.border-success-subtle{border-color:var(--bs-success-border-subtle) !important}.border-info-subtle{border-color:var(--bs-info-border-subtle) !important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle) !important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle) !important}.border-light-subtle{border-color:var(--bs-light-border-subtle) !important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle) !important}.border-1{border-width:1px !important}.border-2{border-width:2px !important}.border-3{border-width:3px !important}.border-4{border-width:4px !important}.border-5{border-width:5px !important}.border-opacity-10{--bs-border-opacity: 0.1}.border-opacity-25{--bs-border-opacity: 0.25}.border-opacity-50{--bs-border-opacity: 0.5}.border-opacity-75{--bs-border-opacity: 0.75}.border-opacity-100{--bs-border-opacity: 1}.w-25{width:25% !important}.w-50{width:50% !important}.w-75{width:75% !important}.w-100{width:100% !important}.w-auto{width:auto !important}.mw-100{max-width:100% !important}.vw-100{width:100vw !important}.min-vw-100{min-width:100vw !important}.h-25{height:25% !important}.h-50{height:50% !important}.h-75{height:75% !important}.h-100{height:100% !important}.h-auto{height:auto !important}.mh-100{max-height:100% !important}.vh-100{height:100vh !important}.min-vh-100{min-height:100vh !important}.flex-fill{flex:1 1 auto !important}.flex-row{flex-direction:row !important}.flex-column{flex-direction:column !important}.flex-row-reverse{flex-direction:row-reverse !important}.flex-column-reverse{flex-direction:column-reverse !important}.flex-grow-0{flex-grow:0 !important}.flex-grow-1{flex-grow:1 !important}.flex-shrink-0{flex-shrink:0 !important}.flex-shrink-1{flex-shrink:1 !important}.flex-wrap{flex-wrap:wrap !important}.flex-nowrap{flex-wrap:nowrap !important}.flex-wrap-reverse{flex-wrap:wrap-reverse !important}.justify-content-start{justify-content:flex-start !important}.justify-content-end{justify-content:flex-end !important}.justify-content-center{justify-content:center !important}.justify-content-between{justify-content:space-between !important}.justify-content-around{justify-content:space-around !important}.justify-content-evenly{justify-content:space-evenly !important}.align-items-start{align-items:flex-start !important}.align-items-end{align-items:flex-end !important}.align-items-center{align-items:center !important}.align-items-baseline{align-items:baseline !important}.align-items-stretch{align-items:stretch !important}.align-content-start{align-content:flex-start !important}.align-content-end{align-content:flex-end !important}.align-content-center{align-content:center !important}.align-content-between{align-content:space-between !important}.align-content-around{align-content:space-around !important}.align-content-stretch{align-content:stretch !important}.align-self-auto{align-self:auto !important}.align-self-start{align-self:flex-start !important}.align-self-end{align-self:flex-end !important}.align-self-center{align-self:center !important}.align-self-baseline{align-self:baseline !important}.align-self-stretch{align-self:stretch !important}.order-first{order:-1 !important}.order-0{order:0 !important}.order-1{order:1 !important}.order-2{order:2 !important}.order-3{order:3 !important}.order-4{order:4 !important}.order-5{order:5 !important}.order-last{order:6 !important}.m-0{margin:0 !important}.m-1{margin:.25rem !important}.m-2{margin:.5rem !important}.m-3{margin:1rem !important}.m-4{margin:1.5rem !important}.m-5{margin:3rem !important}.m-auto{margin:auto !important}.mx-0{margin-right:0 !important;margin-left:0 !important}.mx-1{margin-right:.25rem !important;margin-left:.25rem !important}.mx-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-3{margin-right:1rem !important;margin-left:1rem !important}.mx-4{margin-right:1.5rem !important;margin-left:1.5rem !important}.mx-5{margin-right:3rem !important;margin-left:3rem !important}.mx-auto{margin-right:auto !important;margin-left:auto !important}.my-0{margin-top:0 !important;margin-bottom:0 !important}.my-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.my-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.my-3{margin-top:1rem !important;margin-bottom:1rem !important}.my-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.my-5{margin-top:3rem !important;margin-bottom:3rem !important}.my-auto{margin-top:auto !important;margin-bottom:auto !important}.mt-0{margin-top:0 !important}.mt-1{margin-top:.25rem !important}.mt-2{margin-top:.5rem !important}.mt-3{margin-top:1rem !important}.mt-4{margin-top:1.5rem !important}.mt-5{margin-top:3rem !important}.mt-auto{margin-top:auto !important}.me-0{margin-right:0 !important}.me-1{margin-right:.25rem !important}.me-2{margin-right:.5rem !important}.me-3{margin-right:1rem !important}.me-4{margin-right:1.5rem !important}.me-5{margin-right:3rem !important}.me-auto{margin-right:auto !important}.mb-0{margin-bottom:0 !important}.mb-1{margin-bottom:.25rem !important}.mb-2{margin-bottom:.5rem !important}.mb-3{margin-bottom:1rem !important}.mb-4{margin-bottom:1.5rem !important}.mb-5{margin-bottom:3rem !important}.mb-auto{margin-bottom:auto !important}.ms-0{margin-left:0 !important}.ms-1{margin-left:.25rem !important}.ms-2{margin-left:.5rem !important}.ms-3{margin-left:1rem !important}.ms-4{margin-left:1.5rem !important}.ms-5{margin-left:3rem !important}.ms-auto{margin-left:auto !important}.p-0{padding:0 !important}.p-1{padding:.25rem !important}.p-2{padding:.5rem !important}.p-3{padding:1rem !important}.p-4{padding:1.5rem !important}.p-5{padding:3rem !important}.px-0{padding-right:0 !important;padding-left:0 !important}.px-1{padding-right:.25rem !important;padding-left:.25rem !important}.px-2{padding-right:.5rem !important;padding-left:.5rem !important}.px-3{padding-right:1rem !important;padding-left:1rem !important}.px-4{padding-right:1.5rem !important;padding-left:1.5rem !important}.px-5{padding-right:3rem !important;padding-left:3rem !important}.py-0{padding-top:0 !important;padding-bottom:0 !important}.py-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.py-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.py-3{padding-top:1rem !important;padding-bottom:1rem !important}.py-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.py-5{padding-top:3rem !important;padding-bottom:3rem !important}.pt-0{padding-top:0 !important}.pt-1{padding-top:.25rem !important}.pt-2{padding-top:.5rem !important}.pt-3{padding-top:1rem !important}.pt-4{padding-top:1.5rem !important}.pt-5{padding-top:3rem !important}.pe-0{padding-right:0 !important}.pe-1{padding-right:.25rem !important}.pe-2{padding-right:.5rem !important}.pe-3{padding-right:1rem !important}.pe-4{padding-right:1.5rem !important}.pe-5{padding-right:3rem !important}.pb-0{padding-bottom:0 !important}.pb-1{padding-bottom:.25rem !important}.pb-2{padding-bottom:.5rem !important}.pb-3{padding-bottom:1rem !important}.pb-4{padding-bottom:1.5rem !important}.pb-5{padding-bottom:3rem !important}.ps-0{padding-left:0 !important}.ps-1{padding-left:.25rem !important}.ps-2{padding-left:.5rem !important}.ps-3{padding-left:1rem !important}.ps-4{padding-left:1.5rem !important}.ps-5{padding-left:3rem !important}.gap-0{gap:0 !important}.gap-1{gap:.25rem !important}.gap-2{gap:.5rem !important}.gap-3{gap:1rem !important}.gap-4{gap:1.5rem !important}.gap-5{gap:3rem !important}.row-gap-0{row-gap:0 !important}.row-gap-1{row-gap:.25rem !important}.row-gap-2{row-gap:.5rem !important}.row-gap-3{row-gap:1rem !important}.row-gap-4{row-gap:1.5rem !important}.row-gap-5{row-gap:3rem !important}.column-gap-0{column-gap:0 !important}.column-gap-1{column-gap:.25rem !important}.column-gap-2{column-gap:.5rem !important}.column-gap-3{column-gap:1rem !important}.column-gap-4{column-gap:1.5rem !important}.column-gap-5{column-gap:3rem !important}.font-monospace{font-family:var(--bs-font-monospace) !important}.fs-1{font-size:calc(1.325rem + 0.9vw) !important}.fs-2{font-size:calc(1.29rem + 0.48vw) !important}.fs-3{font-size:calc(1.27rem + 0.24vw) !important}.fs-4{font-size:1.25rem !important}.fs-5{font-size:1.1rem !important}.fs-6{font-size:1rem !important}.fst-italic{font-style:italic !important}.fst-normal{font-style:normal !important}.fw-lighter{font-weight:lighter !important}.fw-light{font-weight:300 !important}.fw-normal{font-weight:400 !important}.fw-medium{font-weight:500 !important}.fw-semibold{font-weight:600 !important}.fw-bold{font-weight:700 !important}.fw-bolder{font-weight:bolder !important}.lh-1{line-height:1 !important}.lh-sm{line-height:1.25 !important}.lh-base{line-height:1.5 !important}.lh-lg{line-height:2 !important}.text-start{text-align:left !important}.text-end{text-align:right !important}.text-center{text-align:center !important}.text-decoration-none{text-decoration:none !important}.text-decoration-underline{text-decoration:underline !important}.text-decoration-line-through{text-decoration:line-through !important}.text-lowercase{text-transform:lowercase !important}.text-uppercase{text-transform:uppercase !important}.text-capitalize{text-transform:capitalize !important}.text-wrap{white-space:normal !important}.text-nowrap{white-space:nowrap !important}.text-break{word-wrap:break-word !important;word-break:break-word !important}.text-default{--bs-text-opacity: 1;color:rgba(var(--bs-default-rgb), var(--bs-text-opacity)) !important}.text-primary{--bs-text-opacity: 1;color:rgba(var(--bs-primary-rgb), var(--bs-text-opacity)) !important}.text-secondary{--bs-text-opacity: 1;color:rgba(var(--bs-secondary-rgb), var(--bs-text-opacity)) !important}.text-success{--bs-text-opacity: 1;color:rgba(var(--bs-success-rgb), var(--bs-text-opacity)) !important}.text-info{--bs-text-opacity: 1;color:rgba(var(--bs-info-rgb), var(--bs-text-opacity)) !important}.text-warning{--bs-text-opacity: 1;color:rgba(var(--bs-warning-rgb), var(--bs-text-opacity)) !important}.text-danger{--bs-text-opacity: 1;color:rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important}.text-light{--bs-text-opacity: 1;color:rgba(var(--bs-light-rgb), var(--bs-text-opacity)) !important}.text-dark{--bs-text-opacity: 1;color:rgba(var(--bs-dark-rgb), var(--bs-text-opacity)) !important}.text-black{--bs-text-opacity: 1;color:rgba(var(--bs-black-rgb), var(--bs-text-opacity)) !important}.text-white{--bs-text-opacity: 1;color:rgba(var(--bs-white-rgb), var(--bs-text-opacity)) !important}.text-body{--bs-text-opacity: 1;color:rgba(var(--bs-body-color-rgb), var(--bs-text-opacity)) !important}.text-muted{--bs-text-opacity: 1;color:var(--bs-secondary-color) !important}.text-black-50{--bs-text-opacity: 1;color:rgba(0,0,0,.5) !important}.text-white-50{--bs-text-opacity: 1;color:hsla(0,0%,100%,.5) !important}.text-body-secondary{--bs-text-opacity: 1;color:var(--bs-secondary-color) !important}.text-body-tertiary{--bs-text-opacity: 1;color:var(--bs-tertiary-color) !important}.text-body-emphasis{--bs-text-opacity: 1;color:var(--bs-emphasis-color) !important}.text-reset{--bs-text-opacity: 1;color:inherit !important}.text-opacity-25{--bs-text-opacity: 0.25}.text-opacity-50{--bs-text-opacity: 0.5}.text-opacity-75{--bs-text-opacity: 0.75}.text-opacity-100{--bs-text-opacity: 1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis) !important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis) !important}.text-success-emphasis{color:var(--bs-success-text-emphasis) !important}.text-info-emphasis{color:var(--bs-info-text-emphasis) !important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis) !important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis) !important}.text-light-emphasis{color:var(--bs-light-text-emphasis) !important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis) !important}.link-opacity-10{--bs-link-opacity: 0.1}.link-opacity-10-hover:hover{--bs-link-opacity: 0.1}.link-opacity-25{--bs-link-opacity: 0.25}.link-opacity-25-hover:hover{--bs-link-opacity: 0.25}.link-opacity-50{--bs-link-opacity: 0.5}.link-opacity-50-hover:hover{--bs-link-opacity: 0.5}.link-opacity-75{--bs-link-opacity: 0.75}.link-opacity-75-hover:hover{--bs-link-opacity: 0.75}.link-opacity-100{--bs-link-opacity: 1}.link-opacity-100-hover:hover{--bs-link-opacity: 1}.link-offset-1{text-underline-offset:.125em !important}.link-offset-1-hover:hover{text-underline-offset:.125em !important}.link-offset-2{text-underline-offset:.25em !important}.link-offset-2-hover:hover{text-underline-offset:.25em !important}.link-offset-3{text-underline-offset:.375em !important}.link-offset-3-hover:hover{text-underline-offset:.375em !important}.link-underline-default{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-default-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-primary{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-primary-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-secondary{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-secondary-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-success{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-success-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-info{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-info-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-warning{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-warning-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-danger{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-danger-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-light{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-light-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-dark{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-dark-rgb), var(--bs-link-underline-opacity)) !important}.link-underline{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-link-color-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-underline-opacity-0{--bs-link-underline-opacity: 0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity: 0}.link-underline-opacity-10{--bs-link-underline-opacity: 0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity: 0.1}.link-underline-opacity-25{--bs-link-underline-opacity: 0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity: 0.25}.link-underline-opacity-50{--bs-link-underline-opacity: 0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity: 0.5}.link-underline-opacity-75{--bs-link-underline-opacity: 0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity: 0.75}.link-underline-opacity-100{--bs-link-underline-opacity: 1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity: 1}.bg-default{--bs-bg-opacity: 1;background-color:rgba(var(--bs-default-rgb), var(--bs-bg-opacity)) !important}.bg-primary{--bs-bg-opacity: 1;background-color:rgba(var(--bs-primary-rgb), var(--bs-bg-opacity)) !important}.bg-secondary{--bs-bg-opacity: 1;background-color:rgba(var(--bs-secondary-rgb), var(--bs-bg-opacity)) !important}.bg-success{--bs-bg-opacity: 1;background-color:rgba(var(--bs-success-rgb), var(--bs-bg-opacity)) !important}.bg-info{--bs-bg-opacity: 1;background-color:rgba(var(--bs-info-rgb), var(--bs-bg-opacity)) !important}.bg-warning{--bs-bg-opacity: 1;background-color:rgba(var(--bs-warning-rgb), var(--bs-bg-opacity)) !important}.bg-danger{--bs-bg-opacity: 1;background-color:rgba(var(--bs-danger-rgb), var(--bs-bg-opacity)) !important}.bg-light{--bs-bg-opacity: 1;background-color:rgba(var(--bs-light-rgb), var(--bs-bg-opacity)) !important}.bg-dark{--bs-bg-opacity: 1;background-color:rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important}.bg-black{--bs-bg-opacity: 1;background-color:rgba(var(--bs-black-rgb), var(--bs-bg-opacity)) !important}.bg-white{--bs-bg-opacity: 1;background-color:rgba(var(--bs-white-rgb), var(--bs-bg-opacity)) !important}.bg-body{--bs-bg-opacity: 1;background-color:rgba(var(--bs-body-bg-rgb), var(--bs-bg-opacity)) !important}.bg-transparent{--bs-bg-opacity: 1;background-color:rgba(0,0,0,0) !important}.bg-body-secondary{--bs-bg-opacity: 1;background-color:rgba(var(--bs-secondary-bg-rgb), var(--bs-bg-opacity)) !important}.bg-body-tertiary{--bs-bg-opacity: 1;background-color:rgba(var(--bs-tertiary-bg-rgb), var(--bs-bg-opacity)) !important}.bg-opacity-10{--bs-bg-opacity: 0.1}.bg-opacity-25{--bs-bg-opacity: 0.25}.bg-opacity-50{--bs-bg-opacity: 0.5}.bg-opacity-75{--bs-bg-opacity: 0.75}.bg-opacity-100{--bs-bg-opacity: 1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle) !important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle) !important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle) !important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle) !important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle) !important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle) !important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle) !important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle) !important}.bg-gradient{background-image:var(--bs-gradient) !important}.user-select-all{user-select:all !important}.user-select-auto{user-select:auto !important}.user-select-none{user-select:none !important}.pe-none{pointer-events:none !important}.pe-auto{pointer-events:auto !important}.rounded{border-radius:var(--bs-border-radius) !important}.rounded-0{border-radius:0 !important}.rounded-1{border-radius:var(--bs-border-radius-sm) !important}.rounded-2{border-radius:var(--bs-border-radius) !important}.rounded-3{border-radius:var(--bs-border-radius-lg) !important}.rounded-4{border-radius:var(--bs-border-radius-xl) !important}.rounded-5{border-radius:var(--bs-border-radius-xxl) !important}.rounded-circle{border-radius:50% !important}.rounded-pill{border-radius:var(--bs-border-radius-pill) !important}.rounded-top{border-top-left-radius:var(--bs-border-radius) !important;border-top-right-radius:var(--bs-border-radius) !important}.rounded-top-0{border-top-left-radius:0 !important;border-top-right-radius:0 !important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm) !important;border-top-right-radius:var(--bs-border-radius-sm) !important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius) !important;border-top-right-radius:var(--bs-border-radius) !important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg) !important;border-top-right-radius:var(--bs-border-radius-lg) !important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl) !important;border-top-right-radius:var(--bs-border-radius-xl) !important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl) !important;border-top-right-radius:var(--bs-border-radius-xxl) !important}.rounded-top-circle{border-top-left-radius:50% !important;border-top-right-radius:50% !important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill) !important;border-top-right-radius:var(--bs-border-radius-pill) !important}.rounded-end{border-top-right-radius:var(--bs-border-radius) !important;border-bottom-right-radius:var(--bs-border-radius) !important}.rounded-end-0{border-top-right-radius:0 !important;border-bottom-right-radius:0 !important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm) !important;border-bottom-right-radius:var(--bs-border-radius-sm) !important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius) !important;border-bottom-right-radius:var(--bs-border-radius) !important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg) !important;border-bottom-right-radius:var(--bs-border-radius-lg) !important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl) !important;border-bottom-right-radius:var(--bs-border-radius-xl) !important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl) !important;border-bottom-right-radius:var(--bs-border-radius-xxl) !important}.rounded-end-circle{border-top-right-radius:50% !important;border-bottom-right-radius:50% !important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill) !important;border-bottom-right-radius:var(--bs-border-radius-pill) !important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius) !important;border-bottom-left-radius:var(--bs-border-radius) !important}.rounded-bottom-0{border-bottom-right-radius:0 !important;border-bottom-left-radius:0 !important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm) !important;border-bottom-left-radius:var(--bs-border-radius-sm) !important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius) !important;border-bottom-left-radius:var(--bs-border-radius) !important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg) !important;border-bottom-left-radius:var(--bs-border-radius-lg) !important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl) !important;border-bottom-left-radius:var(--bs-border-radius-xl) !important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl) !important;border-bottom-left-radius:var(--bs-border-radius-xxl) !important}.rounded-bottom-circle{border-bottom-right-radius:50% !important;border-bottom-left-radius:50% !important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill) !important;border-bottom-left-radius:var(--bs-border-radius-pill) !important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius) !important;border-top-left-radius:var(--bs-border-radius) !important}.rounded-start-0{border-bottom-left-radius:0 !important;border-top-left-radius:0 !important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm) !important;border-top-left-radius:var(--bs-border-radius-sm) !important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius) !important;border-top-left-radius:var(--bs-border-radius) !important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg) !important;border-top-left-radius:var(--bs-border-radius-lg) !important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl) !important;border-top-left-radius:var(--bs-border-radius-xl) !important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl) !important;border-top-left-radius:var(--bs-border-radius-xxl) !important}.rounded-start-circle{border-bottom-left-radius:50% !important;border-top-left-radius:50% !important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill) !important;border-top-left-radius:var(--bs-border-radius-pill) !important}.visible{visibility:visible !important}.invisible{visibility:hidden !important}.z-n1{z-index:-1 !important}.z-0{z-index:0 !important}.z-1{z-index:1 !important}.z-2{z-index:2 !important}.z-3{z-index:3 !important}@media(min-width: 576px){.float-sm-start{float:left !important}.float-sm-end{float:right !important}.float-sm-none{float:none !important}.object-fit-sm-contain{object-fit:contain !important}.object-fit-sm-cover{object-fit:cover !important}.object-fit-sm-fill{object-fit:fill !important}.object-fit-sm-scale{object-fit:scale-down !important}.object-fit-sm-none{object-fit:none !important}.d-sm-inline{display:inline !important}.d-sm-inline-block{display:inline-block !important}.d-sm-block{display:block !important}.d-sm-grid{display:grid !important}.d-sm-inline-grid{display:inline-grid !important}.d-sm-table{display:table !important}.d-sm-table-row{display:table-row !important}.d-sm-table-cell{display:table-cell !important}.d-sm-flex{display:flex !important}.d-sm-inline-flex{display:inline-flex !important}.d-sm-none{display:none !important}.flex-sm-fill{flex:1 1 auto !important}.flex-sm-row{flex-direction:row !important}.flex-sm-column{flex-direction:column !important}.flex-sm-row-reverse{flex-direction:row-reverse !important}.flex-sm-column-reverse{flex-direction:column-reverse !important}.flex-sm-grow-0{flex-grow:0 !important}.flex-sm-grow-1{flex-grow:1 !important}.flex-sm-shrink-0{flex-shrink:0 !important}.flex-sm-shrink-1{flex-shrink:1 !important}.flex-sm-wrap{flex-wrap:wrap !important}.flex-sm-nowrap{flex-wrap:nowrap !important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse !important}.justify-content-sm-start{justify-content:flex-start !important}.justify-content-sm-end{justify-content:flex-end !important}.justify-content-sm-center{justify-content:center !important}.justify-content-sm-between{justify-content:space-between !important}.justify-content-sm-around{justify-content:space-around !important}.justify-content-sm-evenly{justify-content:space-evenly !important}.align-items-sm-start{align-items:flex-start !important}.align-items-sm-end{align-items:flex-end !important}.align-items-sm-center{align-items:center !important}.align-items-sm-baseline{align-items:baseline !important}.align-items-sm-stretch{align-items:stretch !important}.align-content-sm-start{align-content:flex-start !important}.align-content-sm-end{align-content:flex-end !important}.align-content-sm-center{align-content:center !important}.align-content-sm-between{align-content:space-between !important}.align-content-sm-around{align-content:space-around !important}.align-content-sm-stretch{align-content:stretch !important}.align-self-sm-auto{align-self:auto !important}.align-self-sm-start{align-self:flex-start !important}.align-self-sm-end{align-self:flex-end !important}.align-self-sm-center{align-self:center !important}.align-self-sm-baseline{align-self:baseline !important}.align-self-sm-stretch{align-self:stretch !important}.order-sm-first{order:-1 !important}.order-sm-0{order:0 !important}.order-sm-1{order:1 !important}.order-sm-2{order:2 !important}.order-sm-3{order:3 !important}.order-sm-4{order:4 !important}.order-sm-5{order:5 !important}.order-sm-last{order:6 !important}.m-sm-0{margin:0 !important}.m-sm-1{margin:.25rem !important}.m-sm-2{margin:.5rem !important}.m-sm-3{margin:1rem !important}.m-sm-4{margin:1.5rem !important}.m-sm-5{margin:3rem !important}.m-sm-auto{margin:auto !important}.mx-sm-0{margin-right:0 !important;margin-left:0 !important}.mx-sm-1{margin-right:.25rem !important;margin-left:.25rem !important}.mx-sm-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-sm-3{margin-right:1rem !important;margin-left:1rem !important}.mx-sm-4{margin-right:1.5rem !important;margin-left:1.5rem !important}.mx-sm-5{margin-right:3rem !important;margin-left:3rem !important}.mx-sm-auto{margin-right:auto !important;margin-left:auto !important}.my-sm-0{margin-top:0 !important;margin-bottom:0 !important}.my-sm-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.my-sm-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.my-sm-3{margin-top:1rem !important;margin-bottom:1rem !important}.my-sm-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.my-sm-5{margin-top:3rem !important;margin-bottom:3rem !important}.my-sm-auto{margin-top:auto !important;margin-bottom:auto !important}.mt-sm-0{margin-top:0 !important}.mt-sm-1{margin-top:.25rem !important}.mt-sm-2{margin-top:.5rem !important}.mt-sm-3{margin-top:1rem !important}.mt-sm-4{margin-top:1.5rem !important}.mt-sm-5{margin-top:3rem !important}.mt-sm-auto{margin-top:auto !important}.me-sm-0{margin-right:0 !important}.me-sm-1{margin-right:.25rem !important}.me-sm-2{margin-right:.5rem !important}.me-sm-3{margin-right:1rem !important}.me-sm-4{margin-right:1.5rem !important}.me-sm-5{margin-right:3rem !important}.me-sm-auto{margin-right:auto !important}.mb-sm-0{margin-bottom:0 !important}.mb-sm-1{margin-bottom:.25rem !important}.mb-sm-2{margin-bottom:.5rem !important}.mb-sm-3{margin-bottom:1rem !important}.mb-sm-4{margin-bottom:1.5rem !important}.mb-sm-5{margin-bottom:3rem !important}.mb-sm-auto{margin-bottom:auto !important}.ms-sm-0{margin-left:0 !important}.ms-sm-1{margin-left:.25rem !important}.ms-sm-2{margin-left:.5rem !important}.ms-sm-3{margin-left:1rem !important}.ms-sm-4{margin-left:1.5rem !important}.ms-sm-5{margin-left:3rem !important}.ms-sm-auto{margin-left:auto !important}.p-sm-0{padding:0 !important}.p-sm-1{padding:.25rem !important}.p-sm-2{padding:.5rem !important}.p-sm-3{padding:1rem !important}.p-sm-4{padding:1.5rem !important}.p-sm-5{padding:3rem !important}.px-sm-0{padding-right:0 !important;padding-left:0 !important}.px-sm-1{padding-right:.25rem !important;padding-left:.25rem !important}.px-sm-2{padding-right:.5rem !important;padding-left:.5rem !important}.px-sm-3{padding-right:1rem !important;padding-left:1rem !important}.px-sm-4{padding-right:1.5rem !important;padding-left:1.5rem !important}.px-sm-5{padding-right:3rem !important;padding-left:3rem !important}.py-sm-0{padding-top:0 !important;padding-bottom:0 !important}.py-sm-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.py-sm-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.py-sm-3{padding-top:1rem !important;padding-bottom:1rem !important}.py-sm-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.py-sm-5{padding-top:3rem !important;padding-bottom:3rem !important}.pt-sm-0{padding-top:0 !important}.pt-sm-1{padding-top:.25rem !important}.pt-sm-2{padding-top:.5rem !important}.pt-sm-3{padding-top:1rem !important}.pt-sm-4{padding-top:1.5rem !important}.pt-sm-5{padding-top:3rem !important}.pe-sm-0{padding-right:0 !important}.pe-sm-1{padding-right:.25rem !important}.pe-sm-2{padding-right:.5rem !important}.pe-sm-3{padding-right:1rem !important}.pe-sm-4{padding-right:1.5rem !important}.pe-sm-5{padding-right:3rem !important}.pb-sm-0{padding-bottom:0 !important}.pb-sm-1{padding-bottom:.25rem !important}.pb-sm-2{padding-bottom:.5rem !important}.pb-sm-3{padding-bottom:1rem !important}.pb-sm-4{padding-bottom:1.5rem !important}.pb-sm-5{padding-bottom:3rem !important}.ps-sm-0{padding-left:0 !important}.ps-sm-1{padding-left:.25rem !important}.ps-sm-2{padding-left:.5rem !important}.ps-sm-3{padding-left:1rem !important}.ps-sm-4{padding-left:1.5rem !important}.ps-sm-5{padding-left:3rem !important}.gap-sm-0{gap:0 !important}.gap-sm-1{gap:.25rem !important}.gap-sm-2{gap:.5rem !important}.gap-sm-3{gap:1rem !important}.gap-sm-4{gap:1.5rem !important}.gap-sm-5{gap:3rem !important}.row-gap-sm-0{row-gap:0 !important}.row-gap-sm-1{row-gap:.25rem !important}.row-gap-sm-2{row-gap:.5rem !important}.row-gap-sm-3{row-gap:1rem !important}.row-gap-sm-4{row-gap:1.5rem !important}.row-gap-sm-5{row-gap:3rem !important}.column-gap-sm-0{column-gap:0 !important}.column-gap-sm-1{column-gap:.25rem !important}.column-gap-sm-2{column-gap:.5rem !important}.column-gap-sm-3{column-gap:1rem !important}.column-gap-sm-4{column-gap:1.5rem !important}.column-gap-sm-5{column-gap:3rem !important}.text-sm-start{text-align:left !important}.text-sm-end{text-align:right !important}.text-sm-center{text-align:center !important}}@media(min-width: 768px){.float-md-start{float:left !important}.float-md-end{float:right !important}.float-md-none{float:none !important}.object-fit-md-contain{object-fit:contain !important}.object-fit-md-cover{object-fit:cover !important}.object-fit-md-fill{object-fit:fill !important}.object-fit-md-scale{object-fit:scale-down !important}.object-fit-md-none{object-fit:none !important}.d-md-inline{display:inline !important}.d-md-inline-block{display:inline-block !important}.d-md-block{display:block !important}.d-md-grid{display:grid !important}.d-md-inline-grid{display:inline-grid !important}.d-md-table{display:table !important}.d-md-table-row{display:table-row !important}.d-md-table-cell{display:table-cell !important}.d-md-flex{display:flex !important}.d-md-inline-flex{display:inline-flex !important}.d-md-none{display:none !important}.flex-md-fill{flex:1 1 auto !important}.flex-md-row{flex-direction:row !important}.flex-md-column{flex-direction:column !important}.flex-md-row-reverse{flex-direction:row-reverse !important}.flex-md-column-reverse{flex-direction:column-reverse !important}.flex-md-grow-0{flex-grow:0 !important}.flex-md-grow-1{flex-grow:1 !important}.flex-md-shrink-0{flex-shrink:0 !important}.flex-md-shrink-1{flex-shrink:1 !important}.flex-md-wrap{flex-wrap:wrap !important}.flex-md-nowrap{flex-wrap:nowrap !important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse !important}.justify-content-md-start{justify-content:flex-start !important}.justify-content-md-end{justify-content:flex-end !important}.justify-content-md-center{justify-content:center !important}.justify-content-md-between{justify-content:space-between !important}.justify-content-md-around{justify-content:space-around !important}.justify-content-md-evenly{justify-content:space-evenly !important}.align-items-md-start{align-items:flex-start !important}.align-items-md-end{align-items:flex-end !important}.align-items-md-center{align-items:center !important}.align-items-md-baseline{align-items:baseline !important}.align-items-md-stretch{align-items:stretch !important}.align-content-md-start{align-content:flex-start !important}.align-content-md-end{align-content:flex-end !important}.align-content-md-center{align-content:center !important}.align-content-md-between{align-content:space-between !important}.align-content-md-around{align-content:space-around !important}.align-content-md-stretch{align-content:stretch !important}.align-self-md-auto{align-self:auto !important}.align-self-md-start{align-self:flex-start !important}.align-self-md-end{align-self:flex-end !important}.align-self-md-center{align-self:center !important}.align-self-md-baseline{align-self:baseline !important}.align-self-md-stretch{align-self:stretch !important}.order-md-first{order:-1 !important}.order-md-0{order:0 !important}.order-md-1{order:1 !important}.order-md-2{order:2 !important}.order-md-3{order:3 !important}.order-md-4{order:4 !important}.order-md-5{order:5 !important}.order-md-last{order:6 !important}.m-md-0{margin:0 !important}.m-md-1{margin:.25rem !important}.m-md-2{margin:.5rem !important}.m-md-3{margin:1rem !important}.m-md-4{margin:1.5rem !important}.m-md-5{margin:3rem !important}.m-md-auto{margin:auto !important}.mx-md-0{margin-right:0 !important;margin-left:0 !important}.mx-md-1{margin-right:.25rem !important;margin-left:.25rem !important}.mx-md-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-md-3{margin-right:1rem !important;margin-left:1rem !important}.mx-md-4{margin-right:1.5rem !important;margin-left:1.5rem !important}.mx-md-5{margin-right:3rem !important;margin-left:3rem !important}.mx-md-auto{margin-right:auto !important;margin-left:auto !important}.my-md-0{margin-top:0 !important;margin-bottom:0 !important}.my-md-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.my-md-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.my-md-3{margin-top:1rem !important;margin-bottom:1rem !important}.my-md-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.my-md-5{margin-top:3rem !important;margin-bottom:3rem !important}.my-md-auto{margin-top:auto !important;margin-bottom:auto !important}.mt-md-0{margin-top:0 !important}.mt-md-1{margin-top:.25rem !important}.mt-md-2{margin-top:.5rem !important}.mt-md-3{margin-top:1rem !important}.mt-md-4{margin-top:1.5rem !important}.mt-md-5{margin-top:3rem !important}.mt-md-auto{margin-top:auto !important}.me-md-0{margin-right:0 !important}.me-md-1{margin-right:.25rem !important}.me-md-2{margin-right:.5rem !important}.me-md-3{margin-right:1rem !important}.me-md-4{margin-right:1.5rem !important}.me-md-5{margin-right:3rem !important}.me-md-auto{margin-right:auto !important}.mb-md-0{margin-bottom:0 !important}.mb-md-1{margin-bottom:.25rem !important}.mb-md-2{margin-bottom:.5rem !important}.mb-md-3{margin-bottom:1rem !important}.mb-md-4{margin-bottom:1.5rem !important}.mb-md-5{margin-bottom:3rem !important}.mb-md-auto{margin-bottom:auto !important}.ms-md-0{margin-left:0 !important}.ms-md-1{margin-left:.25rem !important}.ms-md-2{margin-left:.5rem !important}.ms-md-3{margin-left:1rem !important}.ms-md-4{margin-left:1.5rem !important}.ms-md-5{margin-left:3rem !important}.ms-md-auto{margin-left:auto !important}.p-md-0{padding:0 !important}.p-md-1{padding:.25rem !important}.p-md-2{padding:.5rem !important}.p-md-3{padding:1rem !important}.p-md-4{padding:1.5rem !important}.p-md-5{padding:3rem !important}.px-md-0{padding-right:0 !important;padding-left:0 !important}.px-md-1{padding-right:.25rem !important;padding-left:.25rem !important}.px-md-2{padding-right:.5rem !important;padding-left:.5rem !important}.px-md-3{padding-right:1rem !important;padding-left:1rem !important}.px-md-4{padding-right:1.5rem !important;padding-left:1.5rem !important}.px-md-5{padding-right:3rem !important;padding-left:3rem !important}.py-md-0{padding-top:0 !important;padding-bottom:0 !important}.py-md-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.py-md-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.py-md-3{padding-top:1rem !important;padding-bottom:1rem !important}.py-md-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.py-md-5{padding-top:3rem !important;padding-bottom:3rem !important}.pt-md-0{padding-top:0 !important}.pt-md-1{padding-top:.25rem !important}.pt-md-2{padding-top:.5rem !important}.pt-md-3{padding-top:1rem !important}.pt-md-4{padding-top:1.5rem !important}.pt-md-5{padding-top:3rem !important}.pe-md-0{padding-right:0 !important}.pe-md-1{padding-right:.25rem !important}.pe-md-2{padding-right:.5rem !important}.pe-md-3{padding-right:1rem !important}.pe-md-4{padding-right:1.5rem !important}.pe-md-5{padding-right:3rem !important}.pb-md-0{padding-bottom:0 !important}.pb-md-1{padding-bottom:.25rem !important}.pb-md-2{padding-bottom:.5rem !important}.pb-md-3{padding-bottom:1rem !important}.pb-md-4{padding-bottom:1.5rem !important}.pb-md-5{padding-bottom:3rem !important}.ps-md-0{padding-left:0 !important}.ps-md-1{padding-left:.25rem !important}.ps-md-2{padding-left:.5rem !important}.ps-md-3{padding-left:1rem !important}.ps-md-4{padding-left:1.5rem !important}.ps-md-5{padding-left:3rem !important}.gap-md-0{gap:0 !important}.gap-md-1{gap:.25rem !important}.gap-md-2{gap:.5rem !important}.gap-md-3{gap:1rem !important}.gap-md-4{gap:1.5rem !important}.gap-md-5{gap:3rem !important}.row-gap-md-0{row-gap:0 !important}.row-gap-md-1{row-gap:.25rem !important}.row-gap-md-2{row-gap:.5rem !important}.row-gap-md-3{row-gap:1rem !important}.row-gap-md-4{row-gap:1.5rem !important}.row-gap-md-5{row-gap:3rem !important}.column-gap-md-0{column-gap:0 !important}.column-gap-md-1{column-gap:.25rem !important}.column-gap-md-2{column-gap:.5rem !important}.column-gap-md-3{column-gap:1rem !important}.column-gap-md-4{column-gap:1.5rem !important}.column-gap-md-5{column-gap:3rem !important}.text-md-start{text-align:left !important}.text-md-end{text-align:right !important}.text-md-center{text-align:center !important}}@media(min-width: 992px){.float-lg-start{float:left !important}.float-lg-end{float:right !important}.float-lg-none{float:none !important}.object-fit-lg-contain{object-fit:contain !important}.object-fit-lg-cover{object-fit:cover !important}.object-fit-lg-fill{object-fit:fill !important}.object-fit-lg-scale{object-fit:scale-down !important}.object-fit-lg-none{object-fit:none !important}.d-lg-inline{display:inline !important}.d-lg-inline-block{display:inline-block !important}.d-lg-block{display:block !important}.d-lg-grid{display:grid !important}.d-lg-inline-grid{display:inline-grid !important}.d-lg-table{display:table !important}.d-lg-table-row{display:table-row !important}.d-lg-table-cell{display:table-cell !important}.d-lg-flex{display:flex !important}.d-lg-inline-flex{display:inline-flex !important}.d-lg-none{display:none !important}.flex-lg-fill{flex:1 1 auto !important}.flex-lg-row{flex-direction:row !important}.flex-lg-column{flex-direction:column !important}.flex-lg-row-reverse{flex-direction:row-reverse !important}.flex-lg-column-reverse{flex-direction:column-reverse !important}.flex-lg-grow-0{flex-grow:0 !important}.flex-lg-grow-1{flex-grow:1 !important}.flex-lg-shrink-0{flex-shrink:0 !important}.flex-lg-shrink-1{flex-shrink:1 !important}.flex-lg-wrap{flex-wrap:wrap !important}.flex-lg-nowrap{flex-wrap:nowrap !important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse !important}.justify-content-lg-start{justify-content:flex-start !important}.justify-content-lg-end{justify-content:flex-end !important}.justify-content-lg-center{justify-content:center !important}.justify-content-lg-between{justify-content:space-between !important}.justify-content-lg-around{justify-content:space-around !important}.justify-content-lg-evenly{justify-content:space-evenly !important}.align-items-lg-start{align-items:flex-start !important}.align-items-lg-end{align-items:flex-end !important}.align-items-lg-center{align-items:center !important}.align-items-lg-baseline{align-items:baseline !important}.align-items-lg-stretch{align-items:stretch !important}.align-content-lg-start{align-content:flex-start !important}.align-content-lg-end{align-content:flex-end !important}.align-content-lg-center{align-content:center !important}.align-content-lg-between{align-content:space-between !important}.align-content-lg-around{align-content:space-around !important}.align-content-lg-stretch{align-content:stretch !important}.align-self-lg-auto{align-self:auto !important}.align-self-lg-start{align-self:flex-start !important}.align-self-lg-end{align-self:flex-end !important}.align-self-lg-center{align-self:center !important}.align-self-lg-baseline{align-self:baseline !important}.align-self-lg-stretch{align-self:stretch !important}.order-lg-first{order:-1 !important}.order-lg-0{order:0 !important}.order-lg-1{order:1 !important}.order-lg-2{order:2 !important}.order-lg-3{order:3 !important}.order-lg-4{order:4 !important}.order-lg-5{order:5 !important}.order-lg-last{order:6 !important}.m-lg-0{margin:0 !important}.m-lg-1{margin:.25rem !important}.m-lg-2{margin:.5rem !important}.m-lg-3{margin:1rem !important}.m-lg-4{margin:1.5rem !important}.m-lg-5{margin:3rem !important}.m-lg-auto{margin:auto !important}.mx-lg-0{margin-right:0 !important;margin-left:0 !important}.mx-lg-1{margin-right:.25rem !important;margin-left:.25rem !important}.mx-lg-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-lg-3{margin-right:1rem !important;margin-left:1rem !important}.mx-lg-4{margin-right:1.5rem !important;margin-left:1.5rem !important}.mx-lg-5{margin-right:3rem !important;margin-left:3rem !important}.mx-lg-auto{margin-right:auto !important;margin-left:auto !important}.my-lg-0{margin-top:0 !important;margin-bottom:0 !important}.my-lg-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.my-lg-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.my-lg-3{margin-top:1rem !important;margin-bottom:1rem !important}.my-lg-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.my-lg-5{margin-top:3rem !important;margin-bottom:3rem !important}.my-lg-auto{margin-top:auto !important;margin-bottom:auto !important}.mt-lg-0{margin-top:0 !important}.mt-lg-1{margin-top:.25rem !important}.mt-lg-2{margin-top:.5rem !important}.mt-lg-3{margin-top:1rem !important}.mt-lg-4{margin-top:1.5rem !important}.mt-lg-5{margin-top:3rem !important}.mt-lg-auto{margin-top:auto !important}.me-lg-0{margin-right:0 !important}.me-lg-1{margin-right:.25rem !important}.me-lg-2{margin-right:.5rem !important}.me-lg-3{margin-right:1rem !important}.me-lg-4{margin-right:1.5rem !important}.me-lg-5{margin-right:3rem !important}.me-lg-auto{margin-right:auto !important}.mb-lg-0{margin-bottom:0 !important}.mb-lg-1{margin-bottom:.25rem !important}.mb-lg-2{margin-bottom:.5rem !important}.mb-lg-3{margin-bottom:1rem !important}.mb-lg-4{margin-bottom:1.5rem !important}.mb-lg-5{margin-bottom:3rem !important}.mb-lg-auto{margin-bottom:auto !important}.ms-lg-0{margin-left:0 !important}.ms-lg-1{margin-left:.25rem !important}.ms-lg-2{margin-left:.5rem !important}.ms-lg-3{margin-left:1rem !important}.ms-lg-4{margin-left:1.5rem !important}.ms-lg-5{margin-left:3rem !important}.ms-lg-auto{margin-left:auto !important}.p-lg-0{padding:0 !important}.p-lg-1{padding:.25rem !important}.p-lg-2{padding:.5rem !important}.p-lg-3{padding:1rem !important}.p-lg-4{padding:1.5rem !important}.p-lg-5{padding:3rem !important}.px-lg-0{padding-right:0 !important;padding-left:0 !important}.px-lg-1{padding-right:.25rem !important;padding-left:.25rem !important}.px-lg-2{padding-right:.5rem !important;padding-left:.5rem !important}.px-lg-3{padding-right:1rem !important;padding-left:1rem !important}.px-lg-4{padding-right:1.5rem !important;padding-left:1.5rem !important}.px-lg-5{padding-right:3rem !important;padding-left:3rem !important}.py-lg-0{padding-top:0 !important;padding-bottom:0 !important}.py-lg-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.py-lg-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.py-lg-3{padding-top:1rem !important;padding-bottom:1rem !important}.py-lg-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.py-lg-5{padding-top:3rem !important;padding-bottom:3rem !important}.pt-lg-0{padding-top:0 !important}.pt-lg-1{padding-top:.25rem !important}.pt-lg-2{padding-top:.5rem !important}.pt-lg-3{padding-top:1rem !important}.pt-lg-4{padding-top:1.5rem !important}.pt-lg-5{padding-top:3rem !important}.pe-lg-0{padding-right:0 !important}.pe-lg-1{padding-right:.25rem !important}.pe-lg-2{padding-right:.5rem !important}.pe-lg-3{padding-right:1rem !important}.pe-lg-4{padding-right:1.5rem !important}.pe-lg-5{padding-right:3rem !important}.pb-lg-0{padding-bottom:0 !important}.pb-lg-1{padding-bottom:.25rem !important}.pb-lg-2{padding-bottom:.5rem !important}.pb-lg-3{padding-bottom:1rem !important}.pb-lg-4{padding-bottom:1.5rem !important}.pb-lg-5{padding-bottom:3rem !important}.ps-lg-0{padding-left:0 !important}.ps-lg-1{padding-left:.25rem !important}.ps-lg-2{padding-left:.5rem !important}.ps-lg-3{padding-left:1rem !important}.ps-lg-4{padding-left:1.5rem !important}.ps-lg-5{padding-left:3rem !important}.gap-lg-0{gap:0 !important}.gap-lg-1{gap:.25rem !important}.gap-lg-2{gap:.5rem !important}.gap-lg-3{gap:1rem !important}.gap-lg-4{gap:1.5rem !important}.gap-lg-5{gap:3rem !important}.row-gap-lg-0{row-gap:0 !important}.row-gap-lg-1{row-gap:.25rem !important}.row-gap-lg-2{row-gap:.5rem !important}.row-gap-lg-3{row-gap:1rem !important}.row-gap-lg-4{row-gap:1.5rem !important}.row-gap-lg-5{row-gap:3rem !important}.column-gap-lg-0{column-gap:0 !important}.column-gap-lg-1{column-gap:.25rem !important}.column-gap-lg-2{column-gap:.5rem !important}.column-gap-lg-3{column-gap:1rem !important}.column-gap-lg-4{column-gap:1.5rem !important}.column-gap-lg-5{column-gap:3rem !important}.text-lg-start{text-align:left !important}.text-lg-end{text-align:right !important}.text-lg-center{text-align:center !important}}@media(min-width: 1200px){.float-xl-start{float:left !important}.float-xl-end{float:right !important}.float-xl-none{float:none !important}.object-fit-xl-contain{object-fit:contain !important}.object-fit-xl-cover{object-fit:cover !important}.object-fit-xl-fill{object-fit:fill !important}.object-fit-xl-scale{object-fit:scale-down !important}.object-fit-xl-none{object-fit:none !important}.d-xl-inline{display:inline !important}.d-xl-inline-block{display:inline-block !important}.d-xl-block{display:block !important}.d-xl-grid{display:grid !important}.d-xl-inline-grid{display:inline-grid !important}.d-xl-table{display:table !important}.d-xl-table-row{display:table-row !important}.d-xl-table-cell{display:table-cell !important}.d-xl-flex{display:flex !important}.d-xl-inline-flex{display:inline-flex !important}.d-xl-none{display:none !important}.flex-xl-fill{flex:1 1 auto !important}.flex-xl-row{flex-direction:row !important}.flex-xl-column{flex-direction:column !important}.flex-xl-row-reverse{flex-direction:row-reverse !important}.flex-xl-column-reverse{flex-direction:column-reverse !important}.flex-xl-grow-0{flex-grow:0 !important}.flex-xl-grow-1{flex-grow:1 !important}.flex-xl-shrink-0{flex-shrink:0 !important}.flex-xl-shrink-1{flex-shrink:1 !important}.flex-xl-wrap{flex-wrap:wrap !important}.flex-xl-nowrap{flex-wrap:nowrap !important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse !important}.justify-content-xl-start{justify-content:flex-start !important}.justify-content-xl-end{justify-content:flex-end !important}.justify-content-xl-center{justify-content:center !important}.justify-content-xl-between{justify-content:space-between !important}.justify-content-xl-around{justify-content:space-around !important}.justify-content-xl-evenly{justify-content:space-evenly !important}.align-items-xl-start{align-items:flex-start !important}.align-items-xl-end{align-items:flex-end !important}.align-items-xl-center{align-items:center !important}.align-items-xl-baseline{align-items:baseline !important}.align-items-xl-stretch{align-items:stretch !important}.align-content-xl-start{align-content:flex-start !important}.align-content-xl-end{align-content:flex-end !important}.align-content-xl-center{align-content:center !important}.align-content-xl-between{align-content:space-between !important}.align-content-xl-around{align-content:space-around !important}.align-content-xl-stretch{align-content:stretch !important}.align-self-xl-auto{align-self:auto !important}.align-self-xl-start{align-self:flex-start !important}.align-self-xl-end{align-self:flex-end !important}.align-self-xl-center{align-self:center !important}.align-self-xl-baseline{align-self:baseline !important}.align-self-xl-stretch{align-self:stretch !important}.order-xl-first{order:-1 !important}.order-xl-0{order:0 !important}.order-xl-1{order:1 !important}.order-xl-2{order:2 !important}.order-xl-3{order:3 !important}.order-xl-4{order:4 !important}.order-xl-5{order:5 !important}.order-xl-last{order:6 !important}.m-xl-0{margin:0 !important}.m-xl-1{margin:.25rem !important}.m-xl-2{margin:.5rem !important}.m-xl-3{margin:1rem !important}.m-xl-4{margin:1.5rem !important}.m-xl-5{margin:3rem !important}.m-xl-auto{margin:auto !important}.mx-xl-0{margin-right:0 !important;margin-left:0 !important}.mx-xl-1{margin-right:.25rem !important;margin-left:.25rem !important}.mx-xl-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-xl-3{margin-right:1rem !important;margin-left:1rem !important}.mx-xl-4{margin-right:1.5rem !important;margin-left:1.5rem !important}.mx-xl-5{margin-right:3rem !important;margin-left:3rem !important}.mx-xl-auto{margin-right:auto !important;margin-left:auto !important}.my-xl-0{margin-top:0 !important;margin-bottom:0 !important}.my-xl-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.my-xl-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.my-xl-3{margin-top:1rem !important;margin-bottom:1rem !important}.my-xl-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.my-xl-5{margin-top:3rem !important;margin-bottom:3rem !important}.my-xl-auto{margin-top:auto !important;margin-bottom:auto !important}.mt-xl-0{margin-top:0 !important}.mt-xl-1{margin-top:.25rem !important}.mt-xl-2{margin-top:.5rem !important}.mt-xl-3{margin-top:1rem !important}.mt-xl-4{margin-top:1.5rem !important}.mt-xl-5{margin-top:3rem !important}.mt-xl-auto{margin-top:auto !important}.me-xl-0{margin-right:0 !important}.me-xl-1{margin-right:.25rem !important}.me-xl-2{margin-right:.5rem !important}.me-xl-3{margin-right:1rem !important}.me-xl-4{margin-right:1.5rem !important}.me-xl-5{margin-right:3rem !important}.me-xl-auto{margin-right:auto !important}.mb-xl-0{margin-bottom:0 !important}.mb-xl-1{margin-bottom:.25rem !important}.mb-xl-2{margin-bottom:.5rem !important}.mb-xl-3{margin-bottom:1rem !important}.mb-xl-4{margin-bottom:1.5rem !important}.mb-xl-5{margin-bottom:3rem !important}.mb-xl-auto{margin-bottom:auto !important}.ms-xl-0{margin-left:0 !important}.ms-xl-1{margin-left:.25rem !important}.ms-xl-2{margin-left:.5rem !important}.ms-xl-3{margin-left:1rem !important}.ms-xl-4{margin-left:1.5rem !important}.ms-xl-5{margin-left:3rem !important}.ms-xl-auto{margin-left:auto !important}.p-xl-0{padding:0 !important}.p-xl-1{padding:.25rem !important}.p-xl-2{padding:.5rem !important}.p-xl-3{padding:1rem !important}.p-xl-4{padding:1.5rem !important}.p-xl-5{padding:3rem !important}.px-xl-0{padding-right:0 !important;padding-left:0 !important}.px-xl-1{padding-right:.25rem !important;padding-left:.25rem !important}.px-xl-2{padding-right:.5rem !important;padding-left:.5rem !important}.px-xl-3{padding-right:1rem !important;padding-left:1rem !important}.px-xl-4{padding-right:1.5rem !important;padding-left:1.5rem !important}.px-xl-5{padding-right:3rem !important;padding-left:3rem !important}.py-xl-0{padding-top:0 !important;padding-bottom:0 !important}.py-xl-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.py-xl-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.py-xl-3{padding-top:1rem !important;padding-bottom:1rem !important}.py-xl-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.py-xl-5{padding-top:3rem !important;padding-bottom:3rem !important}.pt-xl-0{padding-top:0 !important}.pt-xl-1{padding-top:.25rem !important}.pt-xl-2{padding-top:.5rem !important}.pt-xl-3{padding-top:1rem !important}.pt-xl-4{padding-top:1.5rem !important}.pt-xl-5{padding-top:3rem !important}.pe-xl-0{padding-right:0 !important}.pe-xl-1{padding-right:.25rem !important}.pe-xl-2{padding-right:.5rem !important}.pe-xl-3{padding-right:1rem !important}.pe-xl-4{padding-right:1.5rem !important}.pe-xl-5{padding-right:3rem !important}.pb-xl-0{padding-bottom:0 !important}.pb-xl-1{padding-bottom:.25rem !important}.pb-xl-2{padding-bottom:.5rem !important}.pb-xl-3{padding-bottom:1rem !important}.pb-xl-4{padding-bottom:1.5rem !important}.pb-xl-5{padding-bottom:3rem !important}.ps-xl-0{padding-left:0 !important}.ps-xl-1{padding-left:.25rem !important}.ps-xl-2{padding-left:.5rem !important}.ps-xl-3{padding-left:1rem !important}.ps-xl-4{padding-left:1.5rem !important}.ps-xl-5{padding-left:3rem !important}.gap-xl-0{gap:0 !important}.gap-xl-1{gap:.25rem !important}.gap-xl-2{gap:.5rem !important}.gap-xl-3{gap:1rem !important}.gap-xl-4{gap:1.5rem !important}.gap-xl-5{gap:3rem !important}.row-gap-xl-0{row-gap:0 !important}.row-gap-xl-1{row-gap:.25rem !important}.row-gap-xl-2{row-gap:.5rem !important}.row-gap-xl-3{row-gap:1rem !important}.row-gap-xl-4{row-gap:1.5rem !important}.row-gap-xl-5{row-gap:3rem !important}.column-gap-xl-0{column-gap:0 !important}.column-gap-xl-1{column-gap:.25rem !important}.column-gap-xl-2{column-gap:.5rem !important}.column-gap-xl-3{column-gap:1rem !important}.column-gap-xl-4{column-gap:1.5rem !important}.column-gap-xl-5{column-gap:3rem !important}.text-xl-start{text-align:left !important}.text-xl-end{text-align:right !important}.text-xl-center{text-align:center !important}}@media(min-width: 1400px){.float-xxl-start{float:left !important}.float-xxl-end{float:right !important}.float-xxl-none{float:none !important}.object-fit-xxl-contain{object-fit:contain !important}.object-fit-xxl-cover{object-fit:cover !important}.object-fit-xxl-fill{object-fit:fill !important}.object-fit-xxl-scale{object-fit:scale-down !important}.object-fit-xxl-none{object-fit:none !important}.d-xxl-inline{display:inline !important}.d-xxl-inline-block{display:inline-block !important}.d-xxl-block{display:block !important}.d-xxl-grid{display:grid !important}.d-xxl-inline-grid{display:inline-grid !important}.d-xxl-table{display:table !important}.d-xxl-table-row{display:table-row !important}.d-xxl-table-cell{display:table-cell !important}.d-xxl-flex{display:flex !important}.d-xxl-inline-flex{display:inline-flex !important}.d-xxl-none{display:none !important}.flex-xxl-fill{flex:1 1 auto !important}.flex-xxl-row{flex-direction:row !important}.flex-xxl-column{flex-direction:column !important}.flex-xxl-row-reverse{flex-direction:row-reverse !important}.flex-xxl-column-reverse{flex-direction:column-reverse !important}.flex-xxl-grow-0{flex-grow:0 !important}.flex-xxl-grow-1{flex-grow:1 !important}.flex-xxl-shrink-0{flex-shrink:0 !important}.flex-xxl-shrink-1{flex-shrink:1 !important}.flex-xxl-wrap{flex-wrap:wrap !important}.flex-xxl-nowrap{flex-wrap:nowrap !important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse !important}.justify-content-xxl-start{justify-content:flex-start !important}.justify-content-xxl-end{justify-content:flex-end !important}.justify-content-xxl-center{justify-content:center !important}.justify-content-xxl-between{justify-content:space-between !important}.justify-content-xxl-around{justify-content:space-around !important}.justify-content-xxl-evenly{justify-content:space-evenly !important}.align-items-xxl-start{align-items:flex-start !important}.align-items-xxl-end{align-items:flex-end !important}.align-items-xxl-center{align-items:center !important}.align-items-xxl-baseline{align-items:baseline !important}.align-items-xxl-stretch{align-items:stretch !important}.align-content-xxl-start{align-content:flex-start !important}.align-content-xxl-end{align-content:flex-end !important}.align-content-xxl-center{align-content:center !important}.align-content-xxl-between{align-content:space-between !important}.align-content-xxl-around{align-content:space-around !important}.align-content-xxl-stretch{align-content:stretch !important}.align-self-xxl-auto{align-self:auto !important}.align-self-xxl-start{align-self:flex-start !important}.align-self-xxl-end{align-self:flex-end !important}.align-self-xxl-center{align-self:center !important}.align-self-xxl-baseline{align-self:baseline !important}.align-self-xxl-stretch{align-self:stretch !important}.order-xxl-first{order:-1 !important}.order-xxl-0{order:0 !important}.order-xxl-1{order:1 !important}.order-xxl-2{order:2 !important}.order-xxl-3{order:3 !important}.order-xxl-4{order:4 !important}.order-xxl-5{order:5 !important}.order-xxl-last{order:6 !important}.m-xxl-0{margin:0 !important}.m-xxl-1{margin:.25rem !important}.m-xxl-2{margin:.5rem !important}.m-xxl-3{margin:1rem !important}.m-xxl-4{margin:1.5rem !important}.m-xxl-5{margin:3rem !important}.m-xxl-auto{margin:auto !important}.mx-xxl-0{margin-right:0 !important;margin-left:0 !important}.mx-xxl-1{margin-right:.25rem !important;margin-left:.25rem !important}.mx-xxl-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-xxl-3{margin-right:1rem !important;margin-left:1rem !important}.mx-xxl-4{margin-right:1.5rem !important;margin-left:1.5rem !important}.mx-xxl-5{margin-right:3rem !important;margin-left:3rem !important}.mx-xxl-auto{margin-right:auto !important;margin-left:auto !important}.my-xxl-0{margin-top:0 !important;margin-bottom:0 !important}.my-xxl-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.my-xxl-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.my-xxl-3{margin-top:1rem !important;margin-bottom:1rem !important}.my-xxl-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.my-xxl-5{margin-top:3rem !important;margin-bottom:3rem !important}.my-xxl-auto{margin-top:auto !important;margin-bottom:auto !important}.mt-xxl-0{margin-top:0 !important}.mt-xxl-1{margin-top:.25rem !important}.mt-xxl-2{margin-top:.5rem !important}.mt-xxl-3{margin-top:1rem !important}.mt-xxl-4{margin-top:1.5rem !important}.mt-xxl-5{margin-top:3rem !important}.mt-xxl-auto{margin-top:auto !important}.me-xxl-0{margin-right:0 !important}.me-xxl-1{margin-right:.25rem !important}.me-xxl-2{margin-right:.5rem !important}.me-xxl-3{margin-right:1rem !important}.me-xxl-4{margin-right:1.5rem !important}.me-xxl-5{margin-right:3rem !important}.me-xxl-auto{margin-right:auto !important}.mb-xxl-0{margin-bottom:0 !important}.mb-xxl-1{margin-bottom:.25rem !important}.mb-xxl-2{margin-bottom:.5rem !important}.mb-xxl-3{margin-bottom:1rem !important}.mb-xxl-4{margin-bottom:1.5rem !important}.mb-xxl-5{margin-bottom:3rem !important}.mb-xxl-auto{margin-bottom:auto !important}.ms-xxl-0{margin-left:0 !important}.ms-xxl-1{margin-left:.25rem !important}.ms-xxl-2{margin-left:.5rem !important}.ms-xxl-3{margin-left:1rem !important}.ms-xxl-4{margin-left:1.5rem !important}.ms-xxl-5{margin-left:3rem !important}.ms-xxl-auto{margin-left:auto !important}.p-xxl-0{padding:0 !important}.p-xxl-1{padding:.25rem !important}.p-xxl-2{padding:.5rem !important}.p-xxl-3{padding:1rem !important}.p-xxl-4{padding:1.5rem !important}.p-xxl-5{padding:3rem !important}.px-xxl-0{padding-right:0 !important;padding-left:0 !important}.px-xxl-1{padding-right:.25rem !important;padding-left:.25rem !important}.px-xxl-2{padding-right:.5rem !important;padding-left:.5rem !important}.px-xxl-3{padding-right:1rem !important;padding-left:1rem !important}.px-xxl-4{padding-right:1.5rem !important;padding-left:1.5rem !important}.px-xxl-5{padding-right:3rem !important;padding-left:3rem !important}.py-xxl-0{padding-top:0 !important;padding-bottom:0 !important}.py-xxl-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.py-xxl-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.py-xxl-3{padding-top:1rem !important;padding-bottom:1rem !important}.py-xxl-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.py-xxl-5{padding-top:3rem !important;padding-bottom:3rem !important}.pt-xxl-0{padding-top:0 !important}.pt-xxl-1{padding-top:.25rem !important}.pt-xxl-2{padding-top:.5rem !important}.pt-xxl-3{padding-top:1rem !important}.pt-xxl-4{padding-top:1.5rem !important}.pt-xxl-5{padding-top:3rem !important}.pe-xxl-0{padding-right:0 !important}.pe-xxl-1{padding-right:.25rem !important}.pe-xxl-2{padding-right:.5rem !important}.pe-xxl-3{padding-right:1rem !important}.pe-xxl-4{padding-right:1.5rem !important}.pe-xxl-5{padding-right:3rem !important}.pb-xxl-0{padding-bottom:0 !important}.pb-xxl-1{padding-bottom:.25rem !important}.pb-xxl-2{padding-bottom:.5rem !important}.pb-xxl-3{padding-bottom:1rem !important}.pb-xxl-4{padding-bottom:1.5rem !important}.pb-xxl-5{padding-bottom:3rem !important}.ps-xxl-0{padding-left:0 !important}.ps-xxl-1{padding-left:.25rem !important}.ps-xxl-2{padding-left:.5rem !important}.ps-xxl-3{padding-left:1rem !important}.ps-xxl-4{padding-left:1.5rem !important}.ps-xxl-5{padding-left:3rem !important}.gap-xxl-0{gap:0 !important}.gap-xxl-1{gap:.25rem !important}.gap-xxl-2{gap:.5rem !important}.gap-xxl-3{gap:1rem !important}.gap-xxl-4{gap:1.5rem !important}.gap-xxl-5{gap:3rem !important}.row-gap-xxl-0{row-gap:0 !important}.row-gap-xxl-1{row-gap:.25rem !important}.row-gap-xxl-2{row-gap:.5rem !important}.row-gap-xxl-3{row-gap:1rem !important}.row-gap-xxl-4{row-gap:1.5rem !important}.row-gap-xxl-5{row-gap:3rem !important}.column-gap-xxl-0{column-gap:0 !important}.column-gap-xxl-1{column-gap:.25rem !important}.column-gap-xxl-2{column-gap:.5rem !important}.column-gap-xxl-3{column-gap:1rem !important}.column-gap-xxl-4{column-gap:1.5rem !important}.column-gap-xxl-5{column-gap:3rem !important}.text-xxl-start{text-align:left !important}.text-xxl-end{text-align:right !important}.text-xxl-center{text-align:center !important}}.bg-default{color:#fff}.bg-primary{color:#fff}.bg-secondary{color:#fff}.bg-success{color:#fff}.bg-info{color:#fff}.bg-warning{color:#fff}.bg-danger{color:#fff}.bg-light{color:#000}.bg-dark{color:#fff}@media(min-width: 1200px){.fs-1{font-size:2rem !important}.fs-2{font-size:1.65rem !important}.fs-3{font-size:1.45rem !important}}@media print{.d-print-inline{display:inline !important}.d-print-inline-block{display:inline-block !important}.d-print-block{display:block !important}.d-print-grid{display:grid !important}.d-print-inline-grid{display:inline-grid !important}.d-print-table{display:table !important}.d-print-table-row{display:table-row !important}.d-print-table-cell{display:table-cell !important}.d-print-flex{display:flex !important}.d-print-inline-flex{display:inline-flex !important}.d-print-none{display:none !important}}.tab-content>.tab-pane.html-fill-container{display:none}.tab-content>.active.html-fill-container{display:flex}.tab-content.html-fill-container{padding:0}.bg-blue{--bslib-color-bg: #007bff;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-blue{--bslib-color-fg: #007bff;color:var(--bslib-color-fg)}.bg-indigo{--bslib-color-bg: #6610f2;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-indigo{--bslib-color-fg: #6610f2;color:var(--bslib-color-fg)}.bg-purple{--bslib-color-bg: #772953;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-purple{--bslib-color-fg: #772953;color:var(--bslib-color-fg)}.bg-pink{--bslib-color-bg: #e83e8c;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-pink{--bslib-color-fg: #e83e8c;color:var(--bslib-color-fg)}.bg-red{--bslib-color-bg: #df382c;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-red{--bslib-color-fg: #df382c;color:var(--bslib-color-fg)}.bg-orange{--bslib-color-bg: #e95420;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-orange{--bslib-color-fg: #e95420;color:var(--bslib-color-fg)}.bg-yellow{--bslib-color-bg: #efb73e;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-yellow{--bslib-color-fg: #efb73e;color:var(--bslib-color-fg)}.bg-green{--bslib-color-bg: #38b44a;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-green{--bslib-color-fg: #38b44a;color:var(--bslib-color-fg)}.bg-teal{--bslib-color-bg: #20c997;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-teal{--bslib-color-fg: #20c997;color:var(--bslib-color-fg)}.bg-cyan{--bslib-color-bg: #17a2b8;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-cyan{--bslib-color-fg: #17a2b8;color:var(--bslib-color-fg)}.text-default{--bslib-color-fg: #adb5bd}.bg-default{--bslib-color-bg: #adb5bd;--bslib-color-fg: #fff}.text-primary{--bslib-color-fg: #e95420}.bg-primary{--bslib-color-bg: #e95420;--bslib-color-fg: #fff}.text-secondary{--bslib-color-fg: #adb5bd}.bg-secondary{--bslib-color-bg: #adb5bd;--bslib-color-fg: #fff}.text-success{--bslib-color-fg: #38b44a}.bg-success{--bslib-color-bg: #38b44a;--bslib-color-fg: #fff}.text-info{--bslib-color-fg: #17a2b8}.bg-info{--bslib-color-bg: #17a2b8;--bslib-color-fg: #fff}.text-warning{--bslib-color-fg: #efb73e}.bg-warning{--bslib-color-bg: #efb73e;--bslib-color-fg: #fff}.text-danger{--bslib-color-fg: #df382c}.bg-danger{--bslib-color-bg: #df382c;--bslib-color-fg: #fff}.text-light{--bslib-color-fg: #e9ecef}.bg-light{--bslib-color-bg: #e9ecef;--bslib-color-fg: #000}.text-dark{--bslib-color-fg: #772953}.bg-dark{--bslib-color-bg: #772953;--bslib-color-fg: #fff}.bg-gradient-blue-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(40.8, 80.2, 249.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(40.8,80.2,249.8);color:#fff}.bg-gradient-blue-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(47.6, 90.2, 186.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(47.6,90.2,186.2);color:#fff}.bg-gradient-blue-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(92.8, 98.6, 209);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(92.8,98.6,209);color:#fff}.bg-gradient-blue-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(89.2, 96.2, 170.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(89.2,96.2,170.6);color:#fff}.bg-gradient-blue-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(93.2, 107.4, 165.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(93.2,107.4,165.8);color:#fff}.bg-gradient-blue-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(95.6, 147, 177.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(95.6,147,177.8);color:#fff}.bg-gradient-blue-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(22.4, 145.8, 182.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(22.4,145.8,182.6);color:#fff}.bg-gradient-blue-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(12.8, 154.2, 213.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(12.8,154.2,213.4);color:#fff}.bg-gradient-blue-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(9.2, 138.6, 226.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(9.2,138.6,226.6);color:#fff}.bg-gradient-indigo-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(61.2, 58.8, 247.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(61.2,58.8,247.2);color:#fff}.bg-gradient-indigo-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(108.8, 26, 178.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(108.8,26,178.4);color:#fff}.bg-gradient-indigo-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(154, 34.4, 201.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(154,34.4,201.2);color:#fff}.bg-gradient-indigo-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(150.4, 32, 162.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(150.4,32,162.8);color:#fff}.bg-gradient-indigo-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(154.4, 43.2, 158);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(154.4,43.2,158);color:#fff}.bg-gradient-indigo-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(156.8, 82.8, 170);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(156.8,82.8,170);color:#fff}.bg-gradient-indigo-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(83.6, 81.6, 174.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(83.6,81.6,174.8);color:#fff}.bg-gradient-indigo-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(74, 90, 205.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(74,90,205.6);color:#fff}.bg-gradient-indigo-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(70.4, 74.4, 218.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(70.4,74.4,218.8);color:#fff}.bg-gradient-purple-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(71.4, 73.8, 151.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(71.4,73.8,151.8);color:#fff}.bg-gradient-purple-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(112.2, 31, 146.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(112.2,31,146.6);color:#fff}.bg-gradient-purple-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(164.2, 49.4, 105.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(164.2,49.4,105.8);color:#fff}.bg-gradient-purple-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(160.6, 47, 67.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(160.6,47,67.4);color:#fff}.bg-gradient-purple-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(164.6, 58.2, 62.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(164.6,58.2,62.6);color:#fff}.bg-gradient-purple-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(167, 97.8, 74.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(167,97.8,74.6);color:#fff}.bg-gradient-purple-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(93.8, 96.6, 79.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(93.8,96.6,79.4);color:#fff}.bg-gradient-purple-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(84.2, 105, 110.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(84.2,105,110.2);color:#fff}.bg-gradient-purple-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(80.6, 89.4, 123.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(80.6,89.4,123.4);color:#fff}.bg-gradient-pink-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(139.2, 86.4, 186);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(139.2,86.4,186);color:#fff}.bg-gradient-pink-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(180, 43.6, 180.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(180,43.6,180.8);color:#fff}.bg-gradient-pink-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(186.8, 53.6, 117.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(186.8,53.6,117.2);color:#fff}.bg-gradient-pink-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(228.4, 59.6, 101.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(228.4,59.6,101.6);color:#fff}.bg-gradient-pink-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(232.4, 70.8, 96.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(232.4,70.8,96.8);color:#fff}.bg-gradient-pink-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(234.8, 110.4, 108.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(234.8,110.4,108.8);color:#fff}.bg-gradient-pink-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(161.6, 109.2, 113.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(161.6,109.2,113.6);color:#fff}.bg-gradient-pink-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(152, 117.6, 144.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(152,117.6,144.4);color:#fff}.bg-gradient-pink-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(148.4, 102, 157.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(148.4,102,157.6);color:#fff}.bg-gradient-red-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(133.8, 82.8, 128.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(133.8,82.8,128.4);color:#fff}.bg-gradient-red-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(174.6, 40, 123.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(174.6,40,123.2);color:#fff}.bg-gradient-red-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(181.4, 50, 59.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(181.4,50,59.6);color:#fff}.bg-gradient-red-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(226.6, 58.4, 82.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(226.6,58.4,82.4);color:#fff}.bg-gradient-red-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(227, 67.2, 39.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(227,67.2,39.2);color:#fff}.bg-gradient-red-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(229.4, 106.8, 51.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(229.4,106.8,51.2);color:#fff}.bg-gradient-red-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(156.2, 105.6, 56);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(156.2,105.6,56);color:#fff}.bg-gradient-red-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(146.6, 114, 86.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(146.6,114,86.8);color:#fff}.bg-gradient-red-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(143, 98.4, 100);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(143,98.4,100);color:#fff}.bg-gradient-orange-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(139.8, 99.6, 121.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(139.8,99.6,121.2);color:#fff}.bg-gradient-orange-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(180.6, 56.8, 116);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(180.6,56.8,116);color:#fff}.bg-gradient-orange-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(187.4, 66.8, 52.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(187.4,66.8,52.4);color:#fff}.bg-gradient-orange-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(232.6, 75.2, 75.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(232.6,75.2,75.2);color:#fff}.bg-gradient-orange-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(229, 72.8, 36.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(229,72.8,36.8);color:#fff}.bg-gradient-orange-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(235.4, 123.6, 44);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(235.4,123.6,44);color:#fff}.bg-gradient-orange-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(162.2, 122.4, 48.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(162.2,122.4,48.8);color:#fff}.bg-gradient-orange-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(152.6, 130.8, 79.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(152.6,130.8,79.6);color:#fff}.bg-gradient-orange-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(149, 115.2, 92.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(149,115.2,92.8);color:#fff}.bg-gradient-yellow-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(143.4, 159, 139.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(143.4,159,139.2);color:#fff}.bg-gradient-yellow-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(184.2, 116.2, 134);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(184.2,116.2,134);color:#fff}.bg-gradient-yellow-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(191, 126.2, 70.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(191,126.2,70.4);color:#fff}.bg-gradient-yellow-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(236.2, 134.6, 93.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(236.2,134.6,93.2);color:#fff}.bg-gradient-yellow-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(232.6, 132.2, 54.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(232.6,132.2,54.8);color:#fff}.bg-gradient-yellow-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(236.6, 143.4, 50);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(236.6,143.4,50);color:#fff}.bg-gradient-yellow-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(165.8, 181.8, 66.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(165.8,181.8,66.8);color:#fff}.bg-gradient-yellow-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(156.2, 190.2, 97.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(156.2,190.2,97.6);color:#fff}.bg-gradient-yellow-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(152.6, 174.6, 110.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(152.6,174.6,110.8);color:#fff}.bg-gradient-green-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(33.6, 157.2, 146.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(33.6,157.2,146.4);color:#fff}.bg-gradient-green-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(74.4, 114.4, 141.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(74.4,114.4,141.2);color:#fff}.bg-gradient-green-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(81.2, 124.4, 77.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(81.2,124.4,77.6);color:#fff}.bg-gradient-green-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(126.4, 132.8, 100.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(126.4,132.8,100.4);color:#fff}.bg-gradient-green-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(122.8, 130.4, 62);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(122.8,130.4,62);color:#fff}.bg-gradient-green-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(126.8, 141.6, 57.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(126.8,141.6,57.2);color:#fff}.bg-gradient-green-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(129.2, 181.2, 69.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(129.2,181.2,69.2);color:#fff}.bg-gradient-green-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(46.4, 188.4, 104.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(46.4,188.4,104.8);color:#fff}.bg-gradient-green-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(42.8, 172.8, 118);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(42.8,172.8,118);color:#fff}.bg-gradient-teal-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(19.2, 169.8, 192.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(19.2,169.8,192.6);color:#fff}.bg-gradient-teal-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(60, 127, 187.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(60,127,187.4);color:#fff}.bg-gradient-teal-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(66.8, 137, 123.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(66.8,137,123.8);color:#fff}.bg-gradient-teal-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(112, 145.4, 146.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(112,145.4,146.6);color:#fff}.bg-gradient-teal-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(108.4, 143, 108.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(108.4,143,108.2);color:#fff}.bg-gradient-teal-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(112.4, 154.2, 103.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(112.4,154.2,103.4);color:#fff}.bg-gradient-teal-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(114.8, 193.8, 115.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(114.8,193.8,115.4);color:#fff}.bg-gradient-teal-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(41.6, 192.6, 120.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(41.6,192.6,120.2);color:#fff}.bg-gradient-teal-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(28.4, 185.4, 164.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(28.4,185.4,164.2);color:#fff}.bg-gradient-cyan-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(13.8, 146.4, 212.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(13.8,146.4,212.4);color:#fff}.bg-gradient-cyan-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(54.6, 103.6, 207.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(54.6,103.6,207.2);color:#fff}.bg-gradient-cyan-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(61.4, 113.6, 143.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(61.4,113.6,143.6);color:#fff}.bg-gradient-cyan-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(106.6, 122, 166.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(106.6,122,166.4);color:#fff}.bg-gradient-cyan-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(103, 119.6, 128);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(103,119.6,128);color:#fff}.bg-gradient-cyan-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(107, 130.8, 123.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(107,130.8,123.2);color:#fff}.bg-gradient-cyan-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(109.4, 170.4, 135.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(109.4,170.4,135.2);color:#fff}.bg-gradient-cyan-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(36.2, 169.2, 140);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(36.2,169.2,140);color:#fff}.bg-gradient-cyan-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(26.6, 177.6, 170.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(26.6,177.6,170.8);color:#fff}:root{--bslib-spacer: 1rem;--bslib-mb-spacer: var(--bslib-spacer, 1rem)}.bslib-mb-spacing{margin-bottom:var(--bslib-mb-spacer)}.bslib-gap-spacing{gap:var(--bslib-mb-spacer)}.bslib-gap-spacing>.bslib-mb-spacing,.bslib-gap-spacing>.form-group,.bslib-gap-spacing>p,.bslib-gap-spacing>pre{margin-bottom:0}.html-fill-container>.html-fill-item.bslib-mb-spacing{margin-bottom:0}:root{--bslib-spacer: 1rem;--bslib-mb-spacer: var(--bslib-spacer, 1rem)}.bslib-mb-spacing{margin-bottom:var(--bslib-mb-spacer)}.bslib-gap-spacing{gap:var(--bslib-mb-spacer)}.bslib-gap-spacing>.bslib-mb-spacing,.bslib-gap-spacing>.form-group,.bslib-gap-spacing>p,.bslib-gap-spacing>pre{margin-bottom:0}.html-fill-container>.html-fill-item.bslib-mb-spacing{margin-bottom:0}.tab-content>.tab-pane.html-fill-container{display:none}.tab-content>.active.html-fill-container{display:flex}.tab-content.html-fill-container{padding:0}.bg-blue{--bslib-color-bg: #007bff;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-blue{--bslib-color-fg: #007bff;color:var(--bslib-color-fg)}.bg-indigo{--bslib-color-bg: #6610f2;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-indigo{--bslib-color-fg: #6610f2;color:var(--bslib-color-fg)}.bg-purple{--bslib-color-bg: #772953;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-purple{--bslib-color-fg: #772953;color:var(--bslib-color-fg)}.bg-pink{--bslib-color-bg: #e83e8c;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-pink{--bslib-color-fg: #e83e8c;color:var(--bslib-color-fg)}.bg-red{--bslib-color-bg: #df382c;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-red{--bslib-color-fg: #df382c;color:var(--bslib-color-fg)}.bg-orange{--bslib-color-bg: #e95420;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-orange{--bslib-color-fg: #e95420;color:var(--bslib-color-fg)}.bg-yellow{--bslib-color-bg: #efb73e;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-yellow{--bslib-color-fg: #efb73e;color:var(--bslib-color-fg)}.bg-green{--bslib-color-bg: #38b44a;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-green{--bslib-color-fg: #38b44a;color:var(--bslib-color-fg)}.bg-teal{--bslib-color-bg: #20c997;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-teal{--bslib-color-fg: #20c997;color:var(--bslib-color-fg)}.bg-cyan{--bslib-color-bg: #17a2b8;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-cyan{--bslib-color-fg: #17a2b8;color:var(--bslib-color-fg)}.text-default{--bslib-color-fg: #adb5bd}.bg-default{--bslib-color-bg: #adb5bd;--bslib-color-fg: #fff}.text-primary{--bslib-color-fg: #e95420}.bg-primary{--bslib-color-bg: #e95420;--bslib-color-fg: #fff}.text-secondary{--bslib-color-fg: #adb5bd}.bg-secondary{--bslib-color-bg: #adb5bd;--bslib-color-fg: #fff}.text-success{--bslib-color-fg: #38b44a}.bg-success{--bslib-color-bg: #38b44a;--bslib-color-fg: #fff}.text-info{--bslib-color-fg: #17a2b8}.bg-info{--bslib-color-bg: #17a2b8;--bslib-color-fg: #fff}.text-warning{--bslib-color-fg: #efb73e}.bg-warning{--bslib-color-bg: #efb73e;--bslib-color-fg: #fff}.text-danger{--bslib-color-fg: #df382c}.bg-danger{--bslib-color-bg: #df382c;--bslib-color-fg: #fff}.text-light{--bslib-color-fg: #e9ecef}.bg-light{--bslib-color-bg: #e9ecef;--bslib-color-fg: #000}.text-dark{--bslib-color-fg: #772953}.bg-dark{--bslib-color-bg: #772953;--bslib-color-fg: #fff}.bg-gradient-blue-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(40.8, 80.2, 249.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(40.8,80.2,249.8);color:#fff}.bg-gradient-blue-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(47.6, 90.2, 186.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(47.6,90.2,186.2);color:#fff}.bg-gradient-blue-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(92.8, 98.6, 209);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(92.8,98.6,209);color:#fff}.bg-gradient-blue-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(89.2, 96.2, 170.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(89.2,96.2,170.6);color:#fff}.bg-gradient-blue-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(93.2, 107.4, 165.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(93.2,107.4,165.8);color:#fff}.bg-gradient-blue-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(95.6, 147, 177.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(95.6,147,177.8);color:#fff}.bg-gradient-blue-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(22.4, 145.8, 182.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(22.4,145.8,182.6);color:#fff}.bg-gradient-blue-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(12.8, 154.2, 213.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(12.8,154.2,213.4);color:#fff}.bg-gradient-blue-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(9.2, 138.6, 226.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #007bff var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(9.2,138.6,226.6);color:#fff}.bg-gradient-indigo-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(61.2, 58.8, 247.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(61.2,58.8,247.2);color:#fff}.bg-gradient-indigo-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(108.8, 26, 178.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(108.8,26,178.4);color:#fff}.bg-gradient-indigo-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(154, 34.4, 201.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(154,34.4,201.2);color:#fff}.bg-gradient-indigo-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(150.4, 32, 162.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(150.4,32,162.8);color:#fff}.bg-gradient-indigo-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(154.4, 43.2, 158);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(154.4,43.2,158);color:#fff}.bg-gradient-indigo-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(156.8, 82.8, 170);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(156.8,82.8,170);color:#fff}.bg-gradient-indigo-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(83.6, 81.6, 174.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(83.6,81.6,174.8);color:#fff}.bg-gradient-indigo-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(74, 90, 205.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(74,90,205.6);color:#fff}.bg-gradient-indigo-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(70.4, 74.4, 218.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(70.4,74.4,218.8);color:#fff}.bg-gradient-purple-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(71.4, 73.8, 151.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(71.4,73.8,151.8);color:#fff}.bg-gradient-purple-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(112.2, 31, 146.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(112.2,31,146.6);color:#fff}.bg-gradient-purple-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(164.2, 49.4, 105.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(164.2,49.4,105.8);color:#fff}.bg-gradient-purple-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(160.6, 47, 67.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(160.6,47,67.4);color:#fff}.bg-gradient-purple-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(164.6, 58.2, 62.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(164.6,58.2,62.6);color:#fff}.bg-gradient-purple-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(167, 97.8, 74.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(167,97.8,74.6);color:#fff}.bg-gradient-purple-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(93.8, 96.6, 79.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(93.8,96.6,79.4);color:#fff}.bg-gradient-purple-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(84.2, 105, 110.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(84.2,105,110.2);color:#fff}.bg-gradient-purple-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(80.6, 89.4, 123.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #772953 var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(80.6,89.4,123.4);color:#fff}.bg-gradient-pink-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(139.2, 86.4, 186);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(139.2,86.4,186);color:#fff}.bg-gradient-pink-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(180, 43.6, 180.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(180,43.6,180.8);color:#fff}.bg-gradient-pink-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(186.8, 53.6, 117.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(186.8,53.6,117.2);color:#fff}.bg-gradient-pink-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(228.4, 59.6, 101.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(228.4,59.6,101.6);color:#fff}.bg-gradient-pink-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(232.4, 70.8, 96.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(232.4,70.8,96.8);color:#fff}.bg-gradient-pink-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(234.8, 110.4, 108.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(234.8,110.4,108.8);color:#fff}.bg-gradient-pink-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(161.6, 109.2, 113.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(161.6,109.2,113.6);color:#fff}.bg-gradient-pink-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(152, 117.6, 144.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(152,117.6,144.4);color:#fff}.bg-gradient-pink-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(148.4, 102, 157.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(148.4,102,157.6);color:#fff}.bg-gradient-red-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(133.8, 82.8, 128.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(133.8,82.8,128.4);color:#fff}.bg-gradient-red-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(174.6, 40, 123.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(174.6,40,123.2);color:#fff}.bg-gradient-red-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(181.4, 50, 59.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(181.4,50,59.6);color:#fff}.bg-gradient-red-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(226.6, 58.4, 82.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(226.6,58.4,82.4);color:#fff}.bg-gradient-red-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(227, 67.2, 39.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(227,67.2,39.2);color:#fff}.bg-gradient-red-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(229.4, 106.8, 51.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(229.4,106.8,51.2);color:#fff}.bg-gradient-red-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(156.2, 105.6, 56);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(156.2,105.6,56);color:#fff}.bg-gradient-red-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(146.6, 114, 86.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(146.6,114,86.8);color:#fff}.bg-gradient-red-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(143, 98.4, 100);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df382c var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(143,98.4,100);color:#fff}.bg-gradient-orange-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(139.8, 99.6, 121.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(139.8,99.6,121.2);color:#fff}.bg-gradient-orange-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(180.6, 56.8, 116);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(180.6,56.8,116);color:#fff}.bg-gradient-orange-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(187.4, 66.8, 52.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(187.4,66.8,52.4);color:#fff}.bg-gradient-orange-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(232.6, 75.2, 75.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(232.6,75.2,75.2);color:#fff}.bg-gradient-orange-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(229, 72.8, 36.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(229,72.8,36.8);color:#fff}.bg-gradient-orange-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(235.4, 123.6, 44);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(235.4,123.6,44);color:#fff}.bg-gradient-orange-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(162.2, 122.4, 48.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(162.2,122.4,48.8);color:#fff}.bg-gradient-orange-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(152.6, 130.8, 79.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(152.6,130.8,79.6);color:#fff}.bg-gradient-orange-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(149, 115.2, 92.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e95420 var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(149,115.2,92.8);color:#fff}.bg-gradient-yellow-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(143.4, 159, 139.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(143.4,159,139.2);color:#fff}.bg-gradient-yellow-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(184.2, 116.2, 134);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(184.2,116.2,134);color:#fff}.bg-gradient-yellow-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(191, 126.2, 70.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(191,126.2,70.4);color:#fff}.bg-gradient-yellow-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(236.2, 134.6, 93.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(236.2,134.6,93.2);color:#fff}.bg-gradient-yellow-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(232.6, 132.2, 54.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(232.6,132.2,54.8);color:#fff}.bg-gradient-yellow-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(236.6, 143.4, 50);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(236.6,143.4,50);color:#fff}.bg-gradient-yellow-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(165.8, 181.8, 66.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(165.8,181.8,66.8);color:#fff}.bg-gradient-yellow-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(156.2, 190.2, 97.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(156.2,190.2,97.6);color:#fff}.bg-gradient-yellow-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(152.6, 174.6, 110.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #efb73e var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(152.6,174.6,110.8);color:#fff}.bg-gradient-green-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(33.6, 157.2, 146.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(33.6,157.2,146.4);color:#fff}.bg-gradient-green-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(74.4, 114.4, 141.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(74.4,114.4,141.2);color:#fff}.bg-gradient-green-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(81.2, 124.4, 77.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(81.2,124.4,77.6);color:#fff}.bg-gradient-green-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(126.4, 132.8, 100.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(126.4,132.8,100.4);color:#fff}.bg-gradient-green-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(122.8, 130.4, 62);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(122.8,130.4,62);color:#fff}.bg-gradient-green-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(126.8, 141.6, 57.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(126.8,141.6,57.2);color:#fff}.bg-gradient-green-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(129.2, 181.2, 69.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(129.2,181.2,69.2);color:#fff}.bg-gradient-green-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(46.4, 188.4, 104.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(46.4,188.4,104.8);color:#fff}.bg-gradient-green-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(42.8, 172.8, 118);background:linear-gradient(var(--bg-gradient-deg, 140deg), #38b44a var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(42.8,172.8,118);color:#fff}.bg-gradient-teal-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(19.2, 169.8, 192.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(19.2,169.8,192.6);color:#fff}.bg-gradient-teal-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(60, 127, 187.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(60,127,187.4);color:#fff}.bg-gradient-teal-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(66.8, 137, 123.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(66.8,137,123.8);color:#fff}.bg-gradient-teal-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(112, 145.4, 146.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(112,145.4,146.6);color:#fff}.bg-gradient-teal-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(108.4, 143, 108.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(108.4,143,108.2);color:#fff}.bg-gradient-teal-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(112.4, 154.2, 103.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(112.4,154.2,103.4);color:#fff}.bg-gradient-teal-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(114.8, 193.8, 115.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(114.8,193.8,115.4);color:#fff}.bg-gradient-teal-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(41.6, 192.6, 120.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(41.6,192.6,120.2);color:#fff}.bg-gradient-teal-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(28.4, 185.4, 164.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #17a2b8 var(--bg-gradient-end, 180%)) rgb(28.4,185.4,164.2);color:#fff}.bg-gradient-cyan-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(13.8, 146.4, 212.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #007bff var(--bg-gradient-end, 180%)) rgb(13.8,146.4,212.4);color:#fff}.bg-gradient-cyan-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(54.6, 103.6, 207.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(54.6,103.6,207.2);color:#fff}.bg-gradient-cyan-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(61.4, 113.6, 143.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #772953 var(--bg-gradient-end, 180%)) rgb(61.4,113.6,143.6);color:#fff}.bg-gradient-cyan-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(106.6, 122, 166.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(106.6,122,166.4);color:#fff}.bg-gradient-cyan-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(103, 119.6, 128);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #df382c var(--bg-gradient-end, 180%)) rgb(103,119.6,128);color:#fff}.bg-gradient-cyan-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(107, 130.8, 123.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #e95420 var(--bg-gradient-end, 180%)) rgb(107,130.8,123.2);color:#fff}.bg-gradient-cyan-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(109.4, 170.4, 135.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #efb73e var(--bg-gradient-end, 180%)) rgb(109.4,170.4,135.2);color:#fff}.bg-gradient-cyan-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(36.2, 169.2, 140);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #38b44a var(--bg-gradient-end, 180%)) rgb(36.2,169.2,140);color:#fff}.bg-gradient-cyan-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(26.6, 177.6, 170.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #17a2b8 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(26.6,177.6,170.8);color:#fff}html{height:100%}.bslib-page-fill{width:100%;height:100%;margin:0;padding:var(--bslib-spacer, 1rem);gap:var(--bslib-spacer, 1rem)}@media(max-width: 575.98px){.bslib-page-fill{height:var(--bslib-page-fill-mobile-height, auto)}}.navbar+.container-fluid:has(>.tab-content>.tab-pane.active.html-fill-container),.navbar+.container-sm:has(>.tab-content>.tab-pane.active.html-fill-container),.navbar+.container-md:has(>.tab-content>.tab-pane.active.html-fill-container),.navbar+.container-lg:has(>.tab-content>.tab-pane.active.html-fill-container),.navbar+.container-xl:has(>.tab-content>.tab-pane.active.html-fill-container),.navbar+.container-xxl:has(>.tab-content>.tab-pane.active.html-fill-container){padding-left:0;padding-right:0}.navbar+.container-fluid>.tab-content>.tab-pane.active.html-fill-container,.navbar+.container-sm>.tab-content>.tab-pane.active.html-fill-container,.navbar+.container-md>.tab-content>.tab-pane.active.html-fill-container,.navbar+.container-lg>.tab-content>.tab-pane.active.html-fill-container,.navbar+.container-xl>.tab-content>.tab-pane.active.html-fill-container,.navbar+.container-xxl>.tab-content>.tab-pane.active.html-fill-container{padding:var(--bslib-spacer, 1rem);gap:var(--bslib-spacer, 1rem)}.navbar+.container-fluid>.tab-content>.tab-pane.active.html-fill-container:has(>.bslib-sidebar-layout:only-child),.navbar+.container-sm>.tab-content>.tab-pane.active.html-fill-container:has(>.bslib-sidebar-layout:only-child),.navbar+.container-md>.tab-content>.tab-pane.active.html-fill-container:has(>.bslib-sidebar-layout:only-child),.navbar+.container-lg>.tab-content>.tab-pane.active.html-fill-container:has(>.bslib-sidebar-layout:only-child),.navbar+.container-xl>.tab-content>.tab-pane.active.html-fill-container:has(>.bslib-sidebar-layout:only-child),.navbar+.container-xxl>.tab-content>.tab-pane.active.html-fill-container:has(>.bslib-sidebar-layout:only-child){padding:0}.navbar+.container-fluid>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border=true]),.navbar+.container-sm>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border=true]),.navbar+.container-md>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border=true]),.navbar+.container-lg>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border=true]),.navbar+.container-xl>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border=true]),.navbar+.container-xxl>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border=true]){border-left:none;border-right:none;border-bottom:none}.navbar+.container-fluid>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border-radius=true]),.navbar+.container-sm>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border-radius=true]),.navbar+.container-md>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border-radius=true]),.navbar+.container-lg>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border-radius=true]),.navbar+.container-xl>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border-radius=true]),.navbar+.container-xxl>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border-radius=true]){border-radius:0}.navbar+div>.bslib-sidebar-layout{border-top:var(--bslib-sidebar-border)}@media(min-width: 576px){.nav:not(.nav-hidden){display:flex !important;display:-webkit-flex !important}.nav:not(.nav-hidden):not(.nav-stacked):not(.flex-column){float:none !important}.nav:not(.nav-hidden):not(.nav-stacked):not(.flex-column)>.bslib-nav-spacer{margin-left:auto !important}.nav:not(.nav-hidden):not(.nav-stacked):not(.flex-column)>.form-inline{margin-top:auto;margin-bottom:auto}.nav:not(.nav-hidden).nav-stacked{flex-direction:column;-webkit-flex-direction:column;height:100%}.nav:not(.nav-hidden).nav-stacked>.bslib-nav-spacer{margin-top:auto !important}}.bslib-grid{display:grid !important;gap:var(--bslib-spacer, 1rem);height:var(--bslib-grid-height)}.bslib-grid.grid{grid-template-columns:repeat(var(--bs-columns, 12), minmax(0, 1fr));grid-template-rows:unset;grid-auto-rows:var(--bslib-grid--row-heights);--bslib-grid--row-heights--xs: unset;--bslib-grid--row-heights--sm: unset;--bslib-grid--row-heights--md: unset;--bslib-grid--row-heights--lg: unset;--bslib-grid--row-heights--xl: unset;--bslib-grid--row-heights--xxl: unset}.bslib-grid.grid.bslib-grid--row-heights--xs{--bslib-grid--row-heights: var(--bslib-grid--row-heights--xs)}@media(min-width: 576px){.bslib-grid.grid.bslib-grid--row-heights--sm{--bslib-grid--row-heights: var(--bslib-grid--row-heights--sm)}}@media(min-width: 768px){.bslib-grid.grid.bslib-grid--row-heights--md{--bslib-grid--row-heights: var(--bslib-grid--row-heights--md)}}@media(min-width: 992px){.bslib-grid.grid.bslib-grid--row-heights--lg{--bslib-grid--row-heights: var(--bslib-grid--row-heights--lg)}}@media(min-width: 1200px){.bslib-grid.grid.bslib-grid--row-heights--xl{--bslib-grid--row-heights: var(--bslib-grid--row-heights--xl)}}@media(min-width: 1400px){.bslib-grid.grid.bslib-grid--row-heights--xxl{--bslib-grid--row-heights: var(--bslib-grid--row-heights--xxl)}}.bslib-grid>*>.shiny-input-container{width:100%}.bslib-grid-item{grid-column:auto/span 1}@media(max-width: 767.98px){.bslib-grid-item{grid-column:1/-1}}@media(max-width: 575.98px){.bslib-grid{grid-template-columns:1fr !important;height:var(--bslib-grid-height-mobile)}.bslib-grid.grid{height:unset !important;grid-auto-rows:var(--bslib-grid--row-heights--xs, auto)}}:root{--bslib-page-sidebar-title-bg: #e95420;--bslib-page-sidebar-title-color: #fff}.bslib-page-title{background-color:var(--bslib-page-sidebar-title-bg);color:var(--bslib-page-sidebar-title-color);font-size:1.25rem;font-weight:300;padding:var(--bslib-spacer, 1rem);padding-left:1.5rem;margin-bottom:0;border-bottom:1px solid #dee2e6}.bslib-sidebar-layout{--bslib-sidebar-transition-duration: 500ms;--bslib-sidebar-transition-easing-x: cubic-bezier(0.8, 0.78, 0.22, 1.07);--bslib-sidebar-border: var(--bs-card-border-width, 1px) solid var(--bs-card-border-color, rgba(0, 0, 0, 0.175));--bslib-sidebar-border-radius: var(--bs-border-radius);--bslib-sidebar-vert-border: var(--bs-card-border-width, 1px) solid var(--bs-card-border-color, rgba(0, 0, 0, 0.175));--bslib-sidebar-bg: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05);--bslib-sidebar-fg: var(--bs-emphasis-color, black);--bslib-sidebar-main-fg: var(--bs-card-color, var(--bs-body-color));--bslib-sidebar-main-bg: var(--bs-card-bg, var(--bs-body-bg));--bslib-sidebar-toggle-bg: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.1);--bslib-sidebar-padding: calc(var(--bslib-spacer) * 1.5);--bslib-sidebar-icon-size: var(--bslib-spacer, 1rem);--bslib-sidebar-icon-button-size: calc(var(--bslib-sidebar-icon-size, 1rem) * 2);--bslib-sidebar-padding-icon: calc(var(--bslib-sidebar-icon-button-size, 2rem) * 1.5);--bslib-collapse-toggle-border-radius: var(--bs-border-radius, 0.25rem);--bslib-collapse-toggle-transform: 0deg;--bslib-sidebar-toggle-transition-easing: cubic-bezier(1, 0, 0, 1);--bslib-collapse-toggle-right-transform: 180deg;--bslib-sidebar-column-main: minmax(0, 1fr);display:grid !important;grid-template-columns:min(100% - var(--bslib-sidebar-icon-size),var(--bslib-sidebar-width, 250px)) var(--bslib-sidebar-column-main);position:relative;transition:grid-template-columns ease-in-out var(--bslib-sidebar-transition-duration);border:var(--bslib-sidebar-border);border-radius:var(--bslib-sidebar-border-radius)}@media(prefers-reduced-motion: reduce){.bslib-sidebar-layout{transition:none}}.bslib-sidebar-layout[data-bslib-sidebar-border=false]{border:none}.bslib-sidebar-layout[data-bslib-sidebar-border-radius=false]{border-radius:initial}.bslib-sidebar-layout>.main,.bslib-sidebar-layout>.sidebar{grid-row:1/2;border-radius:inherit;overflow:auto}.bslib-sidebar-layout>.main{grid-column:2/3;border-top-left-radius:0;border-bottom-left-radius:0;padding:var(--bslib-sidebar-padding);transition:padding var(--bslib-sidebar-transition-easing-x) var(--bslib-sidebar-transition-duration);color:var(--bslib-sidebar-main-fg);background-color:var(--bslib-sidebar-main-bg)}.bslib-sidebar-layout>.sidebar{grid-column:1/2;width:100%;height:100%;border-right:var(--bslib-sidebar-vert-border);border-top-right-radius:0;border-bottom-right-radius:0;color:var(--bslib-sidebar-fg);background-color:var(--bslib-sidebar-bg);backdrop-filter:blur(5px)}.bslib-sidebar-layout>.sidebar>.sidebar-content{display:flex;flex-direction:column;gap:var(--bslib-spacer, 1rem);padding:var(--bslib-sidebar-padding);padding-top:var(--bslib-sidebar-padding-icon)}.bslib-sidebar-layout>.sidebar>.sidebar-content>:last-child:not(.sidebar-title){margin-bottom:0}.bslib-sidebar-layout>.sidebar>.sidebar-content>.accordion{margin-left:calc(-1*var(--bslib-sidebar-padding));margin-right:calc(-1*var(--bslib-sidebar-padding))}.bslib-sidebar-layout>.sidebar>.sidebar-content>.accordion:last-child{margin-bottom:calc(-1*var(--bslib-sidebar-padding))}.bslib-sidebar-layout>.sidebar>.sidebar-content>.accordion:not(:last-child){margin-bottom:1rem}.bslib-sidebar-layout>.sidebar>.sidebar-content>.accordion .accordion-body{display:flex;flex-direction:column}.bslib-sidebar-layout>.sidebar>.sidebar-content>.accordion:not(:first-child) .accordion-item:first-child{border-top:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.bslib-sidebar-layout>.sidebar>.sidebar-content>.accordion:not(:last-child) .accordion-item:last-child{border-bottom:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.bslib-sidebar-layout>.sidebar>.sidebar-content.has-accordion>.sidebar-title{border-bottom:none;padding-bottom:0}.bslib-sidebar-layout>.sidebar .shiny-input-container{width:100%}.bslib-sidebar-layout[data-bslib-sidebar-open=always]>.sidebar>.sidebar-content{padding-top:var(--bslib-sidebar-padding)}.bslib-sidebar-layout>.collapse-toggle{grid-row:1/2;grid-column:1/2;display:inline-flex;align-items:center;position:absolute;right:calc(var(--bslib-sidebar-icon-size));top:calc(var(--bslib-sidebar-icon-size, 1rem)/2);border:none;border-radius:var(--bslib-collapse-toggle-border-radius);height:var(--bslib-sidebar-icon-button-size, 2rem);width:var(--bslib-sidebar-icon-button-size, 2rem);display:flex;align-items:center;justify-content:center;padding:0;color:var(--bslib-sidebar-fg);background-color:unset;transition:color var(--bslib-sidebar-transition-easing-x) var(--bslib-sidebar-transition-duration),top var(--bslib-sidebar-transition-easing-x) var(--bslib-sidebar-transition-duration),right var(--bslib-sidebar-transition-easing-x) var(--bslib-sidebar-transition-duration),left var(--bslib-sidebar-transition-easing-x) var(--bslib-sidebar-transition-duration)}.bslib-sidebar-layout>.collapse-toggle:hover{background-color:var(--bslib-sidebar-toggle-bg)}.bslib-sidebar-layout>.collapse-toggle>.collapse-icon{opacity:.8;width:var(--bslib-sidebar-icon-size);height:var(--bslib-sidebar-icon-size);transform:rotateY(var(--bslib-collapse-toggle-transform));transition:transform var(--bslib-sidebar-toggle-transition-easing) var(--bslib-sidebar-transition-duration)}.bslib-sidebar-layout>.collapse-toggle:hover>.collapse-icon{opacity:1}.bslib-sidebar-layout .sidebar-title{font-size:1.25rem;line-height:1.25;margin-top:0;margin-bottom:1rem;padding-bottom:1rem;border-bottom:var(--bslib-sidebar-border)}.bslib-sidebar-layout.sidebar-right{grid-template-columns:var(--bslib-sidebar-column-main) min(100% - var(--bslib-sidebar-icon-size),var(--bslib-sidebar-width, 250px))}.bslib-sidebar-layout.sidebar-right>.main{grid-column:1/2;border-top-right-radius:0;border-bottom-right-radius:0;border-top-left-radius:inherit;border-bottom-left-radius:inherit}.bslib-sidebar-layout.sidebar-right>.sidebar{grid-column:2/3;border-right:none;border-left:var(--bslib-sidebar-vert-border);border-top-left-radius:0;border-bottom-left-radius:0}.bslib-sidebar-layout.sidebar-right>.collapse-toggle{grid-column:2/3;left:var(--bslib-sidebar-icon-size);right:unset;border:var(--bslib-collapse-toggle-border)}.bslib-sidebar-layout.sidebar-right>.collapse-toggle>.collapse-icon{transform:rotateY(var(--bslib-collapse-toggle-right-transform))}.bslib-sidebar-layout.sidebar-collapsed{--bslib-collapse-toggle-transform: 180deg;--bslib-collapse-toggle-right-transform: 0deg;--bslib-sidebar-vert-border: none;grid-template-columns:0 minmax(0, 1fr)}.bslib-sidebar-layout.sidebar-collapsed.sidebar-right{grid-template-columns:minmax(0, 1fr) 0}.bslib-sidebar-layout.sidebar-collapsed:not(.transitioning)>.sidebar>*{display:none}.bslib-sidebar-layout.sidebar-collapsed>.main{border-radius:inherit}.bslib-sidebar-layout.sidebar-collapsed:not(.sidebar-right)>.main{padding-left:var(--bslib-sidebar-padding-icon)}.bslib-sidebar-layout.sidebar-collapsed.sidebar-right>.main{padding-right:var(--bslib-sidebar-padding-icon)}.bslib-sidebar-layout.sidebar-collapsed>.collapse-toggle{color:var(--bslib-sidebar-main-fg);top:calc(var(--bslib-sidebar-overlap-counter, 0)*(var(--bslib-sidebar-icon-size) + var(--bslib-sidebar-padding)) + var(--bslib-sidebar-icon-size, 1rem)/2);right:calc(-2.5*var(--bslib-sidebar-icon-size) - var(--bs-card-border-width, 1px))}.bslib-sidebar-layout.sidebar-collapsed.sidebar-right>.collapse-toggle{left:calc(-2.5*var(--bslib-sidebar-icon-size) - var(--bs-card-border-width, 1px));right:unset}@media(min-width: 576px){.bslib-sidebar-layout.transitioning>.sidebar>.sidebar-content{display:none}}@media(max-width: 575.98px){.bslib-sidebar-layout[data-bslib-sidebar-open=desktop]{--bslib-sidebar-js-init-collapsed: true}.bslib-sidebar-layout>.sidebar,.bslib-sidebar-layout.sidebar-right>.sidebar{border:none}.bslib-sidebar-layout>.main,.bslib-sidebar-layout.sidebar-right>.main{grid-column:1/3}.bslib-sidebar-layout[data-bslib-sidebar-open=always]{display:block !important}.bslib-sidebar-layout[data-bslib-sidebar-open=always]>.sidebar{max-height:var(--bslib-sidebar-max-height-mobile);overflow-y:auto;border-top:var(--bslib-sidebar-vert-border)}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]){grid-template-columns:100% 0}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]):not(.sidebar-collapsed)>.sidebar{z-index:1}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]):not(.sidebar-collapsed)>.collapse-toggle{z-index:1}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]).sidebar-right{grid-template-columns:0 100%}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]).sidebar-collapsed{grid-template-columns:0 100%}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]).sidebar-collapsed.sidebar-right{grid-template-columns:100% 0}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]):not(.sidebar-right)>.main{padding-left:var(--bslib-sidebar-padding-icon)}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]).sidebar-right>.main{padding-right:var(--bslib-sidebar-padding-icon)}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always])>.main{opacity:0;transition:opacity var(--bslib-sidebar-transition-easing-x) var(--bslib-sidebar-transition-duration)}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]).sidebar-collapsed>.main{opacity:1}}.accordion .accordion-header{font-size:calc(1.29rem + 0.48vw);margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color);margin-bottom:0}@media(min-width: 1200px){.accordion .accordion-header{font-size:1.65rem}}.accordion .accordion-icon:not(:empty){margin-right:.75rem;display:flex}.accordion .accordion-button:not(.collapsed){box-shadow:none}.accordion .accordion-button:not(.collapsed):focus{box-shadow:var(--bs-accordion-btn-focus-box-shadow)}:root{--bslib-value-box-shadow: none;--bslib-value-box-border-width-auto-yes: var(--bslib-value-box-border-width-baseline);--bslib-value-box-border-width-auto-no: 0;--bslib-value-box-border-width-baseline: 1px}.bslib-value-box{border-width:var(--bslib-value-box-border-width-auto-no, var(--bslib-value-box-border-width-baseline));container-name:bslib-value-box;container-type:inline-size}.bslib-value-box.card{box-shadow:var(--bslib-value-box-shadow)}.bslib-value-box.border-auto{border-width:var(--bslib-value-box-border-width-auto-yes, var(--bslib-value-box-border-width-baseline))}.bslib-value-box.default{--bslib-value-box-bg-default: var(--bs-card-bg, #fff);--bslib-value-box-border-color-default: var(--bs-card-border-color, rgba(0, 0, 0, 0.175));color:var(--bslib-value-box-color);background-color:var(--bslib-value-box-bg, var(--bslib-value-box-bg-default));border-color:var(--bslib-value-box-border-color, var(--bslib-value-box-border-color-default))}.bslib-value-box .value-box-grid{display:grid;grid-template-areas:"left right";align-items:center;overflow:hidden}.bslib-value-box .value-box-showcase{height:100%;max-height:var(---bslib-value-box-showcase-max-h, 100%)}.bslib-value-box .value-box-showcase,.bslib-value-box .value-box-showcase>.html-fill-item{width:100%}.bslib-value-box[data-full-screen=true] .value-box-showcase{max-height:var(---bslib-value-box-showcase-max-h-fs, 100%)}@media screen and (min-width: 575.98px){@container bslib-value-box (max-width: 300px){.bslib-value-box:not(.showcase-bottom) .value-box-grid{grid-template-columns:1fr !important;grid-template-rows:auto auto;grid-template-areas:"top" "bottom"}.bslib-value-box:not(.showcase-bottom) .value-box-grid .value-box-showcase{grid-area:top !important}.bslib-value-box:not(.showcase-bottom) .value-box-grid .value-box-area{grid-area:bottom !important;justify-content:end}}}.bslib-value-box .value-box-area{justify-content:center;padding:1.5rem 1rem;font-size:.9rem;font-weight:500}.bslib-value-box .value-box-area *{margin-bottom:0;margin-top:0}.bslib-value-box .value-box-title{font-size:1rem;margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.bslib-value-box .value-box-title:empty::after{content:" "}.bslib-value-box .value-box-value{font-size:calc(1.29rem + 0.48vw);margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}@media(min-width: 1200px){.bslib-value-box .value-box-value{font-size:1.65rem}}.bslib-value-box .value-box-value:empty::after{content:" "}.bslib-value-box .value-box-showcase{align-items:center;justify-content:center;margin-top:auto;margin-bottom:auto;padding:1rem}.bslib-value-box .value-box-showcase .bi,.bslib-value-box .value-box-showcase .fa,.bslib-value-box .value-box-showcase .fab,.bslib-value-box .value-box-showcase .fas,.bslib-value-box .value-box-showcase .far{opacity:.85;min-width:50px;max-width:125%}.bslib-value-box .value-box-showcase .bi,.bslib-value-box .value-box-showcase .fa,.bslib-value-box .value-box-showcase .fab,.bslib-value-box .value-box-showcase .fas,.bslib-value-box .value-box-showcase .far{font-size:4rem}.bslib-value-box.showcase-top-right .value-box-grid{grid-template-columns:1fr var(---bslib-value-box-showcase-w, 50%)}.bslib-value-box.showcase-top-right .value-box-grid .value-box-showcase{grid-area:right;margin-left:auto;align-self:start;align-items:end;padding-left:0;padding-bottom:0}.bslib-value-box.showcase-top-right .value-box-grid .value-box-area{grid-area:left;align-self:end}.bslib-value-box.showcase-top-right[data-full-screen=true] .value-box-grid{grid-template-columns:auto var(---bslib-value-box-showcase-w-fs, 1fr)}.bslib-value-box.showcase-top-right[data-full-screen=true] .value-box-grid>div{align-self:center}.bslib-value-box.showcase-top-right:not([data-full-screen=true]) .value-box-showcase{margin-top:0}@container bslib-value-box (max-width: 300px){.bslib-value-box.showcase-top-right:not([data-full-screen=true]) .value-box-grid .value-box-showcase{padding-left:1rem}}.bslib-value-box.showcase-left-center .value-box-grid{grid-template-columns:var(---bslib-value-box-showcase-w, 30%) auto}.bslib-value-box.showcase-left-center[data-full-screen=true] .value-box-grid{grid-template-columns:var(---bslib-value-box-showcase-w-fs, 1fr) auto}.bslib-value-box.showcase-left-center:not([data-fill-screen=true]) .value-box-grid .value-box-showcase{grid-area:left}.bslib-value-box.showcase-left-center:not([data-fill-screen=true]) .value-box-grid .value-box-area{grid-area:right}.bslib-value-box.showcase-bottom .value-box-grid{grid-template-columns:1fr;grid-template-rows:1fr var(---bslib-value-box-showcase-h, auto);grid-template-areas:"top" "bottom";overflow:hidden}.bslib-value-box.showcase-bottom .value-box-grid .value-box-showcase{grid-area:bottom;padding:0;margin:0}.bslib-value-box.showcase-bottom .value-box-grid .value-box-area{grid-area:top}.bslib-value-box.showcase-bottom[data-full-screen=true] .value-box-grid{grid-template-rows:1fr var(---bslib-value-box-showcase-h-fs, 2fr)}.bslib-value-box.showcase-bottom[data-full-screen=true] .value-box-grid .value-box-showcase{padding:1rem}[data-bs-theme=dark] .bslib-value-box{--bslib-value-box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 50%)}.bslib-card{overflow:auto}.bslib-card .card-body+.card-body{padding-top:0}.bslib-card .card-body{overflow:auto}.bslib-card .card-body p{margin-top:0}.bslib-card .card-body p:last-child{margin-bottom:0}.bslib-card .card-body{max-height:var(--bslib-card-body-max-height, none)}.bslib-card[data-full-screen=true]>.card-body{max-height:var(--bslib-card-body-max-height-full-screen, none)}.bslib-card .card-header .form-group{margin-bottom:0}.bslib-card .card-header .selectize-control{margin-bottom:0}.bslib-card .card-header .selectize-control .item{margin-right:1.15rem}.bslib-card .card-footer{margin-top:auto}.bslib-card .bslib-navs-card-title{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center}.bslib-card .bslib-navs-card-title .nav{margin-left:auto}.bslib-card .bslib-sidebar-layout:not([data-bslib-sidebar-border=true]){border:none}.bslib-card .bslib-sidebar-layout:not([data-bslib-sidebar-border-radius=true]){border-top-left-radius:0;border-top-right-radius:0}[data-full-screen=true]{position:fixed;inset:3.5rem 1rem 1rem;height:auto !important;max-height:none !important;width:auto !important;z-index:1070}.bslib-full-screen-enter{display:none;position:absolute;bottom:var(--bslib-full-screen-enter-bottom, 0.2rem);right:var(--bslib-full-screen-enter-right, 0);top:var(--bslib-full-screen-enter-top);left:var(--bslib-full-screen-enter-left);color:var(--bslib-color-fg, var(--bs-card-color));background-color:var(--bslib-color-bg, var(--bs-card-bg, var(--bs-body-bg)));border:var(--bs-card-border-width) solid var(--bslib-color-fg, var(--bs-card-border-color));box-shadow:0 2px 4px rgba(0,0,0,.15);margin:.2rem .4rem;padding:.55rem !important;font-size:.8rem;cursor:pointer;opacity:.7;z-index:1070}.bslib-full-screen-enter:hover{opacity:1}.card[data-full-screen=false]:hover>*>.bslib-full-screen-enter{display:block}.bslib-has-full-screen .card:hover>*>.bslib-full-screen-enter{display:none}@media(max-width: 575.98px){.bslib-full-screen-enter{display:none !important}}.bslib-full-screen-exit{position:relative;top:1.35rem;font-size:.9rem;cursor:pointer;text-decoration:none;display:flex;float:right;margin-right:2.15rem;align-items:center;color:rgba(var(--bs-body-bg-rgb), 0.8)}.bslib-full-screen-exit:hover{color:rgba(var(--bs-body-bg-rgb), 1)}.bslib-full-screen-exit svg{margin-left:.5rem;font-size:1.5rem}#bslib-full-screen-overlay{position:fixed;inset:0;background-color:rgba(var(--bs-body-color-rgb), 0.6);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);z-index:1069;animation:bslib-full-screen-overlay-enter 400ms cubic-bezier(0.6, 0.02, 0.65, 1) forwards}@keyframes bslib-full-screen-overlay-enter{0%{opacity:0}100%{opacity:1}}.html-fill-container{display:flex;flex-direction:column;min-height:0;min-width:0}.html-fill-container>.html-fill-item{flex:1 1 auto;min-height:0;min-width:0}.html-fill-container>:not(.html-fill-item){flex:0 0 auto}.quarto-container{min-height:calc(100vh - 132px)}body.hypothesis-enabled #quarto-header{margin-right:16px}footer.footer .nav-footer,#quarto-header>nav{padding-left:1em;padding-right:1em}footer.footer div.nav-footer p:first-child{margin-top:0}footer.footer div.nav-footer p:last-child{margin-bottom:0}#quarto-content>*{padding-top:14px}#quarto-content>#quarto-sidebar-glass{padding-top:0px}@media(max-width: 991.98px){#quarto-content>*{padding-top:0}#quarto-content .subtitle{padding-top:14px}#quarto-content section:first-of-type h2:first-of-type,#quarto-content section:first-of-type .h2:first-of-type{margin-top:1rem}}.headroom-target,header.headroom{will-change:transform;transition:position 200ms linear;transition:all 200ms linear}header.headroom--pinned{transform:translateY(0%)}header.headroom--unpinned{transform:translateY(-100%)}.navbar-container{width:100%}.navbar-brand{overflow:hidden;text-overflow:ellipsis}.navbar-brand-container{max-width:calc(100% - 115px);min-width:0;display:flex;align-items:center}@media(min-width: 992px){.navbar-brand-container{margin-right:1em}}.navbar-brand.navbar-brand-logo{margin-right:4px;display:inline-flex}.navbar-toggler{flex-basis:content;flex-shrink:0}.navbar .navbar-brand-container{order:2}.navbar .navbar-toggler{order:1}.navbar .navbar-container>.navbar-nav{order:20}.navbar .navbar-container>.navbar-brand-container{margin-left:0 !important;margin-right:0 !important}.navbar .navbar-collapse{order:20}.navbar #quarto-search{order:4;margin-left:auto}.navbar .navbar-toggler{margin-right:.5em}.navbar-collapse .quarto-navbar-tools{margin-left:.5em}.navbar-logo{max-height:24px;width:auto;padding-right:4px}nav .nav-item:not(.compact){padding-top:1px}nav .nav-link i,nav .dropdown-item i{padding-right:1px}.navbar-expand-lg .navbar-nav .nav-link{padding-left:.6rem;padding-right:.6rem}nav .nav-item.compact .nav-link{padding-left:.5rem;padding-right:.5rem;font-size:1.1rem}.navbar .quarto-navbar-tools{order:3}.navbar .quarto-navbar-tools div.dropdown{display:inline-block}.navbar .quarto-navbar-tools .quarto-navigation-tool{color:hsla(0,0%,100%,.8)}.navbar .quarto-navbar-tools .quarto-navigation-tool:hover{color:#fff}.navbar-nav .dropdown-menu{min-width:220px;font-size:.9rem}.navbar .navbar-nav .nav-link.dropdown-toggle::after{opacity:.75;vertical-align:.175em}.navbar ul.dropdown-menu{padding-top:0;padding-bottom:0}.navbar .dropdown-header{text-transform:uppercase;font-size:.8rem;padding:0 .5rem}.navbar .dropdown-item{padding:.4rem .5rem}.navbar .dropdown-item>i.bi{margin-left:.1rem;margin-right:.25em}.sidebar #quarto-search{margin-top:-1px}.sidebar #quarto-search svg.aa-SubmitIcon{width:16px;height:16px}.sidebar-navigation a{color:inherit}.sidebar-title{margin-top:.25rem;padding-bottom:.5rem;font-size:1.3rem;line-height:1.6rem;visibility:visible}.sidebar-title>a{font-size:inherit;text-decoration:none}.sidebar-title .sidebar-tools-main{margin-top:-6px}@media(max-width: 991.98px){#quarto-sidebar div.sidebar-header{padding-top:.2em}}.sidebar-header-stacked .sidebar-title{margin-top:.6rem}.sidebar-logo{max-width:90%;padding-bottom:.5rem}.sidebar-logo-link{text-decoration:none}.sidebar-navigation li a{text-decoration:none}.sidebar-navigation .quarto-navigation-tool{opacity:.7;font-size:.875rem}#quarto-sidebar>nav>.sidebar-tools-main{margin-left:14px}.sidebar-tools-main{display:inline-flex;margin-left:0px;order:2}.sidebar-tools-main:not(.tools-wide){vertical-align:middle}.sidebar-navigation .quarto-navigation-tool.dropdown-toggle::after{display:none}.sidebar.sidebar-navigation>*{padding-top:1em}.sidebar-item{margin-bottom:.2em;line-height:1rem;margin-top:.4rem}.sidebar-section{padding-left:.5em;padding-bottom:.2em}.sidebar-item .sidebar-item-container{display:flex;justify-content:space-between;cursor:pointer}.sidebar-item-toggle:hover{cursor:pointer}.sidebar-item .sidebar-item-toggle .bi{font-size:.7rem;text-align:center}.sidebar-item .sidebar-item-toggle .bi-chevron-right::before{transition:transform 200ms ease}.sidebar-item .sidebar-item-toggle[aria-expanded=false] .bi-chevron-right::before{transform:none}.sidebar-item .sidebar-item-toggle[aria-expanded=true] .bi-chevron-right::before{transform:rotate(90deg)}.sidebar-item-text{width:100%}.sidebar-navigation .sidebar-divider{margin-left:0;margin-right:0;margin-top:.5rem;margin-bottom:.5rem}@media(max-width: 991.98px){.quarto-secondary-nav{display:block}.quarto-secondary-nav button.quarto-search-button{padding-right:0em;padding-left:2em}.quarto-secondary-nav button.quarto-btn-toggle{margin-left:-0.75rem;margin-right:.15rem}.quarto-secondary-nav nav.quarto-title-breadcrumbs{display:none}.quarto-secondary-nav nav.quarto-page-breadcrumbs{display:flex;align-items:center;padding-right:1em;margin-left:-0.25em}.quarto-secondary-nav nav.quarto-page-breadcrumbs a{text-decoration:none}.quarto-secondary-nav nav.quarto-page-breadcrumbs ol.breadcrumb{margin-bottom:0}}@media(min-width: 992px){.quarto-secondary-nav{display:none}}.quarto-title-breadcrumbs .breadcrumb{margin-bottom:.5em;font-size:.9rem}.quarto-title-breadcrumbs .breadcrumb li:last-of-type a{color:#6c757d}.quarto-secondary-nav .quarto-btn-toggle{color:hsl(0,0%,35%)}.quarto-secondary-nav[aria-expanded=false] .quarto-btn-toggle .bi-chevron-right::before{transform:none}.quarto-secondary-nav[aria-expanded=true] .quarto-btn-toggle .bi-chevron-right::before{transform:rotate(90deg)}.quarto-secondary-nav .quarto-btn-toggle .bi-chevron-right::before{transition:transform 200ms ease}.quarto-secondary-nav{cursor:pointer}.no-decor{text-decoration:none}.quarto-secondary-nav-title{margin-top:.3em;color:hsl(0,0%,35%);padding-top:4px}.quarto-secondary-nav nav.quarto-page-breadcrumbs{color:hsl(0,0%,35%)}.quarto-secondary-nav nav.quarto-page-breadcrumbs a{color:hsl(0,0%,35%)}.quarto-secondary-nav nav.quarto-page-breadcrumbs a:hover{color:rgba(156.11,56.28,21.44,.8)}.quarto-secondary-nav nav.quarto-page-breadcrumbs .breadcrumb-item::before{color:hsl(0,0%,55%)}.breadcrumb-item{line-height:1.2rem}div.sidebar-item-container{color:hsl(0,0%,35%)}div.sidebar-item-container:hover,div.sidebar-item-container:focus{color:rgba(156.11,56.28,21.44,.8)}div.sidebar-item-container.disabled{color:hsla(0,0%,35%,.75)}div.sidebar-item-container .active,div.sidebar-item-container .show>.nav-link,div.sidebar-item-container .sidebar-link>code{color:rgb(156.11,56.28,21.44)}div.sidebar.sidebar-navigation.rollup.quarto-sidebar-toggle-contents,nav.sidebar.sidebar-navigation:not(.rollup){background-color:#fff}@media(max-width: 991.98px){.sidebar-navigation .sidebar-item a,.nav-page .nav-page-text,.sidebar-navigation{font-size:1rem}.sidebar-navigation ul.sidebar-section.depth1 .sidebar-section-item{font-size:1.1rem}.sidebar-logo{display:none}.sidebar.sidebar-navigation{position:static;border-bottom:1px solid #dee2e6}.sidebar.sidebar-navigation.collapsing{position:fixed;z-index:1000}.sidebar.sidebar-navigation.show{position:fixed;z-index:1000}.sidebar.sidebar-navigation{min-height:100%}nav.quarto-secondary-nav{background-color:#fff;border-bottom:1px solid #dee2e6}.quarto-banner nav.quarto-secondary-nav{background-color:#e95420;color:hsla(0,0%,100%,.8);border-top:1px solid #dee2e6}.sidebar .sidebar-footer{visibility:visible;padding-top:1rem;position:inherit}.sidebar-tools-collapse{display:block}}#quarto-sidebar{transition:width .15s ease-in}#quarto-sidebar>*{padding-right:1em}@media(max-width: 991.98px){#quarto-sidebar .sidebar-menu-container{white-space:nowrap;min-width:225px}#quarto-sidebar.show{transition:width .15s ease-out}}@media(min-width: 992px){#quarto-sidebar{display:flex;flex-direction:column}.nav-page .nav-page-text,.sidebar-navigation .sidebar-section .sidebar-item{font-size:.875rem}.sidebar-navigation .sidebar-item{font-size:.925rem}.sidebar.sidebar-navigation{display:block;position:sticky}.sidebar-search{width:100%}.sidebar .sidebar-footer{visibility:visible}}@media(min-width: 992px){#quarto-sidebar-glass{display:none}}@media(max-width: 991.98px){#quarto-sidebar-glass{position:fixed;top:0;bottom:0;left:0;right:0;background-color:hsla(0,0%,100%,0);transition:background-color .15s ease-in;z-index:-1}#quarto-sidebar-glass.collapsing{z-index:1000}#quarto-sidebar-glass.show{transition:background-color .15s ease-out;background-color:hsla(0,0%,40%,.4);z-index:1000}}.sidebar .sidebar-footer{padding:.5rem 1rem;align-self:flex-end;color:#6c757d;width:100%}.quarto-page-breadcrumbs .breadcrumb-item+.breadcrumb-item,.quarto-page-breadcrumbs .breadcrumb-item{padding-right:.33em;padding-left:0}.quarto-page-breadcrumbs .breadcrumb-item::before{padding-right:.33em}.quarto-sidebar-footer{font-size:.875em}.sidebar-section .bi-chevron-right{vertical-align:middle}.sidebar-section .bi-chevron-right::before{font-size:.9em}.notransition{-webkit-transition:none !important;-moz-transition:none !important;-o-transition:none !important;transition:none !important}.btn:focus:not(:focus-visible){box-shadow:none}.page-navigation{display:flex;justify-content:space-between}.nav-page{padding-bottom:.75em}.nav-page .bi{font-size:1.8rem;vertical-align:middle}.nav-page .nav-page-text{padding-left:.25em;padding-right:.25em}.nav-page a{color:#6c757d;text-decoration:none;display:flex;align-items:center}.nav-page a:hover{color:rgb(186.4,67.2,25.6)}.nav-footer .toc-actions{padding-bottom:.5em;padding-top:.5em}.nav-footer .toc-actions a,.nav-footer .toc-actions a:hover{text-decoration:none}.nav-footer .toc-actions ul{display:flex;list-style:none}.nav-footer .toc-actions ul :first-child{margin-left:auto}.nav-footer .toc-actions ul :last-child{margin-right:auto}.nav-footer .toc-actions ul li{padding-right:1.5em}.nav-footer .toc-actions ul li i.bi{padding-right:.4em}.nav-footer .toc-actions ul li:last-of-type{padding-right:0}.nav-footer{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between;align-items:baseline;text-align:center;padding-top:.5rem;padding-bottom:.5rem;background-color:#fff}body.nav-fixed{padding-top:64px}.nav-footer-contents{color:#6c757d;margin-top:.25rem}.nav-footer{min-height:3.5em;color:hsl(0,0%,46%)}.nav-footer a{color:hsl(0,0%,46%)}.nav-footer .nav-footer-left{font-size:.825em}.nav-footer .nav-footer-center{font-size:.825em}.nav-footer .nav-footer-right{font-size:.825em}.nav-footer-left .footer-items,.nav-footer-center .footer-items,.nav-footer-right .footer-items{display:inline-flex;padding-top:.3em;padding-bottom:.3em;margin-bottom:0em}.nav-footer-left .footer-items .nav-link,.nav-footer-center .footer-items .nav-link,.nav-footer-right .footer-items .nav-link{padding-left:.6em;padding-right:.6em}@media(min-width: 768px){.nav-footer-left{flex:1 1 0px;text-align:left}}@media(max-width: 575.98px){.nav-footer-left{margin-bottom:1em;flex:100%}}@media(min-width: 768px){.nav-footer-right{flex:1 1 0px;text-align:right}}@media(max-width: 575.98px){.nav-footer-right{margin-bottom:1em;flex:100%}}.nav-footer-center{text-align:center;min-height:3em}@media(min-width: 768px){.nav-footer-center{flex:1 1 0px}}.nav-footer-center .footer-items{justify-content:center}@media(max-width: 767.98px){.nav-footer-center{margin-bottom:1em;flex:100%}}@media(max-width: 767.98px){.nav-footer-center{margin-top:3em;order:10}}.navbar .quarto-reader-toggle.reader .quarto-reader-toggle-btn{background-color:hsla(0,0%,100%,.8);border-radius:3px}@media(max-width: 991.98px){.quarto-reader-toggle{display:none}}.quarto-reader-toggle.reader.quarto-navigation-tool .quarto-reader-toggle-btn{background-color:hsl(0,0%,35%);border-radius:3px}.quarto-reader-toggle .quarto-reader-toggle-btn{display:inline-flex;padding-left:.2em;padding-right:.2em;margin-left:-0.2em;margin-right:-0.2em;text-align:center}.navbar .quarto-reader-toggle:not(.reader) .bi::before{background-image:url('data:image/svg+xml,')}.navbar .quarto-reader-toggle.reader .bi::before{background-image:url('data:image/svg+xml,')}.sidebar-navigation .quarto-reader-toggle:not(.reader) .bi::before{background-image:url('data:image/svg+xml,')}.sidebar-navigation .quarto-reader-toggle.reader .bi::before{background-image:url('data:image/svg+xml,')}#quarto-back-to-top{display:none;position:fixed;bottom:50px;background-color:#fff;border-radius:.25rem;box-shadow:0 .2rem .5rem #6c757d,0 0 .05rem #6c757d;color:#6c757d;text-decoration:none;font-size:.9em;text-align:center;left:50%;padding:.4rem .8rem;transform:translate(-50%, 0)}#quarto-announcement{padding:.5em;display:flex;justify-content:space-between;margin-bottom:0;font-size:.9em}#quarto-announcement .quarto-announcement-content{margin-right:auto}#quarto-announcement .quarto-announcement-content p{margin-bottom:0}#quarto-announcement .quarto-announcement-icon{margin-right:.5em;font-size:1.2em;margin-top:-0.15em}#quarto-announcement .quarto-announcement-action{cursor:pointer}.aa-DetachedSearchButtonQuery{display:none}.aa-DetachedOverlay ul.aa-List,#quarto-search-results ul.aa-List{list-style:none;padding-left:0}.aa-DetachedOverlay .aa-Panel,#quarto-search-results .aa-Panel{background-color:#fff;position:absolute;z-index:2000}#quarto-search-results .aa-Panel{max-width:400px}#quarto-search input{font-size:.925rem}@media(min-width: 992px){.navbar #quarto-search{margin-left:.25rem;order:999}}.navbar.navbar-expand-sm #quarto-search,.navbar.navbar-expand-md #quarto-search{order:999}@media(min-width: 992px){.navbar .quarto-navbar-tools{order:900}}@media(min-width: 992px){.navbar .quarto-navbar-tools.tools-end{margin-left:auto !important}}@media(max-width: 991.98px){#quarto-sidebar .sidebar-search{display:none}}#quarto-sidebar .sidebar-search .aa-Autocomplete{width:100%}.navbar .aa-Autocomplete .aa-Form{width:180px}.navbar #quarto-search.type-overlay .aa-Autocomplete{width:40px}.navbar #quarto-search.type-overlay .aa-Autocomplete .aa-Form{background-color:inherit;border:none}.navbar #quarto-search.type-overlay .aa-Autocomplete .aa-Form:focus-within{box-shadow:none;outline:none}.navbar #quarto-search.type-overlay .aa-Autocomplete .aa-Form .aa-InputWrapper{display:none}.navbar #quarto-search.type-overlay .aa-Autocomplete .aa-Form .aa-InputWrapper:focus-within{display:inherit}.navbar #quarto-search.type-overlay .aa-Autocomplete .aa-Form .aa-Label svg,.navbar #quarto-search.type-overlay .aa-Autocomplete .aa-Form .aa-LoadingIndicator svg{width:26px;height:26px;color:hsla(0,0%,100%,.8);opacity:1}.navbar #quarto-search.type-overlay .aa-Autocomplete svg.aa-SubmitIcon{width:26px;height:26px;color:hsla(0,0%,100%,.8);opacity:1}.aa-Autocomplete .aa-Form,.aa-DetachedFormContainer .aa-Form{align-items:center;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;color:#343a40;display:flex;line-height:1em;margin:0;position:relative;width:100%}.aa-Autocomplete .aa-Form:focus-within,.aa-DetachedFormContainer .aa-Form:focus-within{box-shadow:rgba(233,84,32,.6) 0 0 0 1px;outline:currentColor none medium}.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix{align-items:center;display:flex;flex-shrink:0;order:1}.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-Label,.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-Label,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator{cursor:initial;flex-shrink:0;padding:0;text-align:left}.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-Label svg,.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator svg,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-Label svg,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator svg{color:#343a40;opacity:.5}.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-SubmitButton,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-SubmitButton{appearance:none;background:none;border:0;margin:0}.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator{align-items:center;display:flex;justify-content:center}.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator[hidden],.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator[hidden]{display:none}.aa-Autocomplete .aa-Form .aa-InputWrapper,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper{order:3;position:relative;width:100%}.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input{appearance:none;background:none;border:0;color:#343a40;font:inherit;height:calc(1.5em + .1rem + 2px);padding:0;width:100%}.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input::placeholder,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input::placeholder{color:#343a40;opacity:.8}.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input:focus,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input:focus{border-color:none;box-shadow:none;outline:none}.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-decoration,.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-cancel-button,.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-results-button,.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-results-decoration,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-decoration,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-cancel-button,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-results-button,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-results-decoration{display:none}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix{align-items:center;display:flex;order:4}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-ClearButton,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-ClearButton{align-items:center;background:none;border:0;color:#343a40;opacity:.8;cursor:pointer;display:flex;margin:0;width:calc(1.5em + .1rem + 2px)}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-ClearButton:hover,.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-ClearButton:focus,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-ClearButton:hover,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-ClearButton:focus{color:#343a40;opacity:.8}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-ClearButton[hidden],.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-ClearButton[hidden]{display:none}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-ClearButton svg,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-ClearButton svg{width:calc(1.5em + 0.75rem + calc(1px * 2))}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-CopyButton,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-CopyButton{border:none;align-items:center;background:none;color:#343a40;opacity:.4;font-size:.7rem;cursor:pointer;display:none;margin:0;width:calc(1em + .1rem + 2px)}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-CopyButton:hover,.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-CopyButton:focus,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-CopyButton:hover,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-CopyButton:focus{color:#343a40;opacity:.8}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-CopyButton[hidden],.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-CopyButton[hidden]{display:none}.aa-PanelLayout:empty{display:none}.quarto-search-no-results.no-query{display:none}.aa-Source:has(.no-query){display:none}#quarto-search-results .aa-Panel{border:solid #dee2e6 1px}#quarto-search-results .aa-SourceNoResults{width:398px}.aa-DetachedOverlay .aa-Panel,#quarto-search-results .aa-Panel{max-height:65vh;overflow-y:auto;font-size:.925rem}.aa-DetachedOverlay .aa-SourceNoResults,#quarto-search-results .aa-SourceNoResults{height:60px;display:flex;justify-content:center;align-items:center}.aa-DetachedOverlay .search-error,#quarto-search-results .search-error{padding-top:10px;padding-left:20px;padding-right:20px;cursor:default}.aa-DetachedOverlay .search-error .search-error-title,#quarto-search-results .search-error .search-error-title{font-size:1.1rem;margin-bottom:.5rem}.aa-DetachedOverlay .search-error .search-error-title .search-error-icon,#quarto-search-results .search-error .search-error-title .search-error-icon{margin-right:8px}.aa-DetachedOverlay .search-error .search-error-text,#quarto-search-results .search-error .search-error-text{font-weight:300}.aa-DetachedOverlay .search-result-text,#quarto-search-results .search-result-text{font-weight:300;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.2rem;max-height:2.4rem}.aa-DetachedOverlay .aa-SourceHeader .search-result-header,#quarto-search-results .aa-SourceHeader .search-result-header{font-size:.875rem;background-color:hsl(0,0%,95%);padding-left:14px;padding-bottom:4px;padding-top:4px}.aa-DetachedOverlay .aa-SourceHeader .search-result-header-no-results,#quarto-search-results .aa-SourceHeader .search-result-header-no-results{display:none}.aa-DetachedOverlay .aa-SourceFooter .algolia-search-logo,#quarto-search-results .aa-SourceFooter .algolia-search-logo{width:110px;opacity:.85;margin:8px;float:right}.aa-DetachedOverlay .search-result-section,#quarto-search-results .search-result-section{font-size:.925em}.aa-DetachedOverlay a.search-result-link,#quarto-search-results a.search-result-link{color:inherit;text-decoration:none}.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item,#quarto-search-results li.aa-Item[aria-selected=true] .search-item{background-color:#e95420}.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item.search-result-more,.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item .search-result-section,.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item .search-result-text,.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item .search-result-title-container,.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item .search-result-text-container,#quarto-search-results li.aa-Item[aria-selected=true] .search-item.search-result-more,#quarto-search-results li.aa-Item[aria-selected=true] .search-item .search-result-section,#quarto-search-results li.aa-Item[aria-selected=true] .search-item .search-result-text,#quarto-search-results li.aa-Item[aria-selected=true] .search-item .search-result-title-container,#quarto-search-results li.aa-Item[aria-selected=true] .search-item .search-result-text-container{color:#fff;background-color:#e95420}.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item mark.search-match,.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item .search-match.mark,#quarto-search-results li.aa-Item[aria-selected=true] .search-item mark.search-match,#quarto-search-results li.aa-Item[aria-selected=true] .search-item .search-match.mark{color:#fff;background-color:rgb(236.6636734694,112.4767346939,69.1363265306)}.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item,#quarto-search-results li.aa-Item[aria-selected=false] .search-item{background-color:#fff}.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item.search-result-more,.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item .search-result-section,.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item .search-result-text,.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item .search-result-title-container,.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item .search-result-text-container,#quarto-search-results li.aa-Item[aria-selected=false] .search-item.search-result-more,#quarto-search-results li.aa-Item[aria-selected=false] .search-item .search-result-section,#quarto-search-results li.aa-Item[aria-selected=false] .search-item .search-result-text,#quarto-search-results li.aa-Item[aria-selected=false] .search-item .search-result-title-container,#quarto-search-results li.aa-Item[aria-selected=false] .search-item .search-result-text-container{color:#343a40}.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item mark.search-match,.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item .search-match.mark,#quarto-search-results li.aa-Item[aria-selected=false] .search-item mark.search-match,#quarto-search-results li.aa-Item[aria-selected=false] .search-item .search-match.mark{color:inherit;background-color:hsl(15.5223880597,82.0408163265%,93.9607843137%)}.aa-DetachedOverlay .aa-Item .search-result-doc:not(.document-selectable) .search-result-title-container,#quarto-search-results .aa-Item .search-result-doc:not(.document-selectable) .search-result-title-container{background-color:#fff;color:#343a40}.aa-DetachedOverlay .aa-Item .search-result-doc:not(.document-selectable) .search-result-text-container,#quarto-search-results .aa-Item .search-result-doc:not(.document-selectable) .search-result-text-container{padding-top:0px}.aa-DetachedOverlay li.aa-Item .search-result-doc.document-selectable .search-result-text-container,#quarto-search-results li.aa-Item .search-result-doc.document-selectable .search-result-text-container{margin-top:-4px}.aa-DetachedOverlay .aa-Item,#quarto-search-results .aa-Item{cursor:pointer}.aa-DetachedOverlay .aa-Item .search-item,#quarto-search-results .aa-Item .search-item{border-left:none;border-right:none;border-top:none;background-color:#fff;border-color:#dee2e6;color:#343a40}.aa-DetachedOverlay .aa-Item .search-item p,#quarto-search-results .aa-Item .search-item p{margin-top:0;margin-bottom:0}.aa-DetachedOverlay .aa-Item .search-item i.bi,#quarto-search-results .aa-Item .search-item i.bi{padding-left:8px;padding-right:8px;font-size:1.3em}.aa-DetachedOverlay .aa-Item .search-item .search-result-title,#quarto-search-results .aa-Item .search-item .search-result-title{margin-top:.3em;margin-bottom:0em}.aa-DetachedOverlay .aa-Item .search-item .search-result-crumbs,#quarto-search-results .aa-Item .search-item .search-result-crumbs{white-space:nowrap;text-overflow:ellipsis;font-size:.8em;font-weight:300;margin-right:1em}.aa-DetachedOverlay .aa-Item .search-item .search-result-crumbs:not(.search-result-crumbs-wrap),#quarto-search-results .aa-Item .search-item .search-result-crumbs:not(.search-result-crumbs-wrap){max-width:30%;margin-left:auto;margin-top:.5em;margin-bottom:.1rem}.aa-DetachedOverlay .aa-Item .search-item .search-result-crumbs.search-result-crumbs-wrap,#quarto-search-results .aa-Item .search-item .search-result-crumbs.search-result-crumbs-wrap{flex-basis:100%;margin-top:0em;margin-bottom:.2em;margin-left:37px}.aa-DetachedOverlay .aa-Item .search-result-title-container,#quarto-search-results .aa-Item .search-result-title-container{font-size:1em;display:flex;flex-wrap:wrap;padding:6px 4px 6px 4px}.aa-DetachedOverlay .aa-Item .search-result-text-container,#quarto-search-results .aa-Item .search-result-text-container{padding-bottom:8px;padding-right:8px;margin-left:42px}.aa-DetachedOverlay .aa-Item .search-result-doc-section,.aa-DetachedOverlay .aa-Item .search-result-more,#quarto-search-results .aa-Item .search-result-doc-section,#quarto-search-results .aa-Item .search-result-more{padding-top:8px;padding-bottom:8px;padding-left:44px}.aa-DetachedOverlay .aa-Item .search-result-more,#quarto-search-results .aa-Item .search-result-more{font-size:.8em;font-weight:400}.aa-DetachedOverlay .aa-Item .search-result-doc,#quarto-search-results .aa-Item .search-result-doc{border-top:1px solid #dee2e6}.aa-DetachedSearchButton{background:none;border:none}.aa-DetachedSearchButton .aa-DetachedSearchButtonPlaceholder{display:none}.navbar .aa-DetachedSearchButton .aa-DetachedSearchButtonIcon{color:hsla(0,0%,100%,.8)}.sidebar-tools-collapse #quarto-search,.sidebar-tools-main #quarto-search{display:inline}.sidebar-tools-collapse #quarto-search .aa-Autocomplete,.sidebar-tools-main #quarto-search .aa-Autocomplete{display:inline}.sidebar-tools-collapse #quarto-search .aa-DetachedSearchButton,.sidebar-tools-main #quarto-search .aa-DetachedSearchButton{padding-left:4px;padding-right:4px}.sidebar-tools-collapse #quarto-search .aa-DetachedSearchButton .aa-DetachedSearchButtonIcon,.sidebar-tools-main #quarto-search .aa-DetachedSearchButton .aa-DetachedSearchButtonIcon{color:hsl(0,0%,35%)}.sidebar-tools-collapse #quarto-search .aa-DetachedSearchButton .aa-DetachedSearchButtonIcon .aa-SubmitIcon,.sidebar-tools-main #quarto-search .aa-DetachedSearchButton .aa-DetachedSearchButtonIcon .aa-SubmitIcon{margin-top:-3px}.aa-DetachedContainer{background:hsla(0,0%,100%,.65);width:90%;bottom:0;box-shadow:rgba(222,226,230,.6) 0 0 0 1px;outline:currentColor none medium;display:flex;flex-direction:column;left:0;margin:0;overflow:hidden;padding:0;position:fixed;right:0;top:0;z-index:1101}.aa-DetachedContainer::after{height:32px}.aa-DetachedContainer .aa-SourceHeader{margin:var(--aa-spacing-half) 0 var(--aa-spacing-half) 2px}.aa-DetachedContainer .aa-Panel{background-color:#fff;border-radius:0;box-shadow:none;flex-grow:1;margin:0;padding:0;position:relative}.aa-DetachedContainer .aa-PanelLayout{bottom:0;box-shadow:none;left:0;margin:0;max-height:none;overflow-y:auto;position:absolute;right:0;top:0;width:100%}.aa-DetachedFormContainer{background-color:#fff;border-bottom:1px solid #dee2e6;display:flex;flex-direction:row;justify-content:space-between;margin:0;padding:.5em}.aa-DetachedCancelButton{background:none;font-size:.8em;border:0;border-radius:3px;color:#343a40;cursor:pointer;margin:0 0 0 .5em;padding:0 .5em}.aa-DetachedCancelButton:hover,.aa-DetachedCancelButton:focus{box-shadow:rgba(233,84,32,.6) 0 0 0 1px;outline:currentColor none medium}.aa-DetachedContainer--modal{bottom:inherit;height:auto;margin:0 auto;position:absolute;top:100px;border-radius:6px;max-width:850px}@media(max-width: 575.98px){.aa-DetachedContainer--modal{width:100%;top:0px;border-radius:0px;border:none}}.aa-DetachedContainer--modal .aa-PanelLayout{max-height:var(--aa-detached-modal-max-height);padding-bottom:var(--aa-spacing-half);position:static}.aa-Detached{height:100vh;overflow:hidden}.aa-DetachedOverlay{background-color:rgba(52,58,64,.4);position:fixed;left:0;right:0;top:0;margin:0;padding:0;height:100vh;z-index:1100}.quarto-dashboard.nav-fixed.dashboard-sidebar #quarto-content.quarto-dashboard-content{padding:0em}.quarto-dashboard #quarto-content.quarto-dashboard-content{padding:1em}.quarto-dashboard #quarto-content.quarto-dashboard-content>*{padding-top:0}@media(min-width: 576px){.quarto-dashboard{height:100%}}.quarto-dashboard .card.valuebox.bslib-card.bg-primary{background-color:#5c9bbc !important}.quarto-dashboard .card.valuebox.bslib-card.bg-secondary{background-color:#adb5bd !important}.quarto-dashboard .card.valuebox.bslib-card.bg-success{background-color:#60a545 !important}.quarto-dashboard .card.valuebox.bslib-card.bg-info{background-color:#3d9dd1 !important}.quarto-dashboard .card.valuebox.bslib-card.bg-warning{background-color:#9a9623 !important}.quarto-dashboard .card.valuebox.bslib-card.bg-danger{background-color:#c48282 !important}.quarto-dashboard .card.valuebox.bslib-card.bg-light{background-color:#e9ecef !important}.quarto-dashboard .card.valuebox.bslib-card.bg-dark{background-color:#772953 !important}.quarto-dashboard.dashboard-fill{display:flex;flex-direction:column}.quarto-dashboard #quarto-appendix{display:none}.quarto-dashboard #quarto-header #quarto-dashboard-header{border-top:solid 1px rgb(237.5795918367,119.5959183673,78.4204081633);border-bottom:solid 1px rgb(237.5795918367,119.5959183673,78.4204081633)}.quarto-dashboard #quarto-header #quarto-dashboard-header>nav{padding-left:1em;padding-right:1em}.quarto-dashboard #quarto-header #quarto-dashboard-header>nav .navbar-brand-container{padding-left:0}.quarto-dashboard #quarto-header #quarto-dashboard-header .navbar-toggler{margin-right:0}.quarto-dashboard #quarto-header #quarto-dashboard-header .navbar-toggler-icon{height:1em;width:1em;background-image:url('data:image/svg+xml,')}.quarto-dashboard #quarto-header #quarto-dashboard-header .navbar-brand-container{padding-right:1em}.quarto-dashboard #quarto-header #quarto-dashboard-header .navbar-title{font-size:1.1em}.quarto-dashboard #quarto-header #quarto-dashboard-header .navbar-nav{font-size:.9em}.quarto-dashboard #quarto-dashboard-header .navbar{padding:0}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-container{padding-left:1em}.quarto-dashboard #quarto-dashboard-header .navbar.slim .navbar-brand-container .nav-link,.quarto-dashboard #quarto-dashboard-header .navbar.slim .navbar-nav .nav-link{padding:.7em}.quarto-dashboard #quarto-dashboard-header .navbar .quarto-color-scheme-toggle{order:9}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-toggler{margin-left:.5em;order:10}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-nav .nav-link{padding:.5em;height:100%;display:flex;align-items:center}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-nav .active{background-color:rgb(236.6636734694,112.4767346939,69.1363265306)}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-brand-container{padding:.5em .5em .5em 0;display:flex;flex-direction:row;margin-right:2em;align-items:center}@media(max-width: 767.98px){.quarto-dashboard #quarto-dashboard-header .navbar .navbar-brand-container{margin-right:auto}}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-collapse{align-self:stretch}@media(min-width: 768px){.quarto-dashboard #quarto-dashboard-header .navbar .navbar-collapse{order:8}}@media(max-width: 767.98px){.quarto-dashboard #quarto-dashboard-header .navbar .navbar-collapse{order:1000;padding-bottom:.5em}}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-collapse .navbar-nav{align-self:stretch}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-title{font-size:1.25em;line-height:1.1em;display:flex;flex-direction:row;flex-wrap:wrap;align-items:baseline}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-title .navbar-title-text{margin-right:.4em}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-title a{text-decoration:none;color:inherit}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-subtitle,.quarto-dashboard #quarto-dashboard-header .navbar .navbar-author{font-size:.9rem;margin-right:.5em}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-author{margin-left:auto}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-logo{max-height:48px;min-height:30px;object-fit:cover;margin-right:1em}.quarto-dashboard #quarto-dashboard-header .navbar .quarto-dashboard-links{order:9;padding-right:1em}.quarto-dashboard #quarto-dashboard-header .navbar .quarto-dashboard-link-text{margin-left:.25em}.quarto-dashboard #quarto-dashboard-header .navbar .quarto-dashboard-link{padding-right:0em;padding-left:.7em;text-decoration:none;color:hsla(0,0%,100%,.8)}.quarto-dashboard .page-layout-custom .tab-content{padding:0;border:none}.quarto-dashboard-img-contain{height:100%;width:100%;object-fit:contain}@media(max-width: 575.98px){.quarto-dashboard .bslib-grid{grid-template-rows:minmax(1em, max-content) !important}.quarto-dashboard .sidebar-content{height:inherit}.quarto-dashboard .page-layout-custom{min-height:100vh}}.quarto-dashboard.dashboard-toolbar>.page-layout-custom,.quarto-dashboard.dashboard-sidebar>.page-layout-custom{padding:0}.quarto-dashboard .quarto-dashboard-content.quarto-dashboard-pages{padding:0}.quarto-dashboard .callout{margin-bottom:0;margin-top:0}.quarto-dashboard .html-fill-container figure{overflow:hidden}.quarto-dashboard bslib-tooltip .rounded-pill{border:solid #6c757d 1px}.quarto-dashboard bslib-tooltip .rounded-pill .svg{fill:#343a40}.quarto-dashboard .tabset .dashboard-card-no-title .nav-tabs{margin-left:0;margin-right:auto}.quarto-dashboard .tabset .tab-content{border:none}.quarto-dashboard .tabset .card-header .nav-link[role=tab]{margin-top:-6px;padding-top:6px;padding-bottom:6px}.quarto-dashboard .card.valuebox,.quarto-dashboard .card.bslib-value-box{min-height:3rem}.quarto-dashboard .card.valuebox .card-body,.quarto-dashboard .card.bslib-value-box .card-body{padding:0}.quarto-dashboard .bslib-value-box .value-box-value{font-size:clamp(.1em,15cqw,5em)}.quarto-dashboard .bslib-value-box .value-box-showcase .bi{font-size:clamp(.1em,max(18cqw,5.2cqh),5em);text-align:center;height:1em}.quarto-dashboard .bslib-value-box .value-box-showcase .bi::before{vertical-align:1em}.quarto-dashboard .bslib-value-box .value-box-area{margin-top:auto;margin-bottom:auto}.quarto-dashboard .card figure.quarto-float{display:flex;flex-direction:column;align-items:center}.quarto-dashboard .dashboard-scrolling{padding:1em}.quarto-dashboard .full-height{height:100%}.quarto-dashboard .showcase-bottom .value-box-grid{display:grid;grid-template-columns:1fr;grid-template-rows:1fr auto;grid-template-areas:"top" "bottom"}.quarto-dashboard .showcase-bottom .value-box-grid .value-box-showcase{grid-area:bottom;padding:0;margin:0}.quarto-dashboard .showcase-bottom .value-box-grid .value-box-showcase i.bi{font-size:4rem}.quarto-dashboard .showcase-bottom .value-box-grid .value-box-area{grid-area:top}.quarto-dashboard .tab-content{margin-bottom:0}.quarto-dashboard .bslib-card .bslib-navs-card-title{justify-content:stretch;align-items:end}.quarto-dashboard .card-header{display:flex;flex-wrap:wrap;justify-content:space-between}.quarto-dashboard .card-header .card-title{display:flex;flex-direction:column;justify-content:center;margin-bottom:0}.quarto-dashboard .tabset .card-toolbar{margin-bottom:1em}.quarto-dashboard .bslib-grid>.bslib-sidebar-layout{border:none;gap:var(--bslib-spacer, 1rem)}.quarto-dashboard .bslib-grid>.bslib-sidebar-layout>.main{padding:0}.quarto-dashboard .bslib-grid>.bslib-sidebar-layout>.sidebar{border-radius:.25rem;border:1px solid rgba(0,0,0,.175)}.quarto-dashboard .bslib-grid>.bslib-sidebar-layout>.collapse-toggle{display:none}@media(max-width: 767.98px){.quarto-dashboard .bslib-grid>.bslib-sidebar-layout{grid-template-columns:1fr;grid-template-rows:max-content 1fr}.quarto-dashboard .bslib-grid>.bslib-sidebar-layout>.main{grid-column:1;grid-row:2}.quarto-dashboard .bslib-grid>.bslib-sidebar-layout .sidebar{grid-column:1;grid-row:1}}.quarto-dashboard .sidebar-right .sidebar{padding-left:2.5em}.quarto-dashboard .sidebar-right .collapse-toggle{left:2px}.quarto-dashboard .quarto-dashboard .sidebar-right button.collapse-toggle:not(.transitioning){left:unset}.quarto-dashboard aside.sidebar{padding-left:1em;padding-right:1em;background-color:rgba(52,58,64,.25);color:#343a40}.quarto-dashboard .bslib-sidebar-layout>div.main{padding:.7em}.quarto-dashboard .bslib-sidebar-layout button.collapse-toggle{margin-top:.3em}.quarto-dashboard .bslib-sidebar-layout .collapse-toggle{top:0}.quarto-dashboard .bslib-sidebar-layout.sidebar-collapsed:not(.transitioning):not(.sidebar-right) .collapse-toggle{left:2px}.quarto-dashboard .sidebar>section>.h3:first-of-type{margin-top:0em}.quarto-dashboard .sidebar .h3,.quarto-dashboard .sidebar .h4,.quarto-dashboard .sidebar .h5,.quarto-dashboard .sidebar .h6{margin-top:.5em}.quarto-dashboard .sidebar form{flex-direction:column;align-items:start;margin-bottom:1em}.quarto-dashboard .sidebar form div[class*=oi-][class$=-input]{flex-direction:column}.quarto-dashboard .sidebar form[class*=oi-][class$=-toggle]{flex-direction:row-reverse;align-items:center;justify-content:start}.quarto-dashboard .sidebar form input[type=range]{margin-top:.5em;margin-right:.8em;margin-left:1em}.quarto-dashboard .sidebar label{width:fit-content}.quarto-dashboard .sidebar .card-body{margin-bottom:2em}.quarto-dashboard .sidebar .shiny-input-container{margin-bottom:1em}.quarto-dashboard .sidebar .shiny-options-group{margin-top:0}.quarto-dashboard .sidebar .control-label{margin-bottom:.3em}.quarto-dashboard .card .card-body .quarto-layout-row{align-items:stretch}.quarto-dashboard .toolbar{font-size:.9em;display:flex;flex-direction:row;border-top:solid 1px hsl(210,10.8108108108%,84.1960784314%);padding:1em;flex-wrap:wrap;background-color:rgba(52,58,64,.25)}.quarto-dashboard .toolbar .cell-output-display{display:flex}.quarto-dashboard .toolbar .shiny-input-container{padding-bottom:.5em;margin-bottom:.5em;width:inherit}.quarto-dashboard .toolbar .shiny-input-container>.checkbox:first-child{margin-top:6px}.quarto-dashboard .toolbar>*:last-child{margin-right:0}.quarto-dashboard .toolbar>*>*{margin-right:1em;align-items:baseline}.quarto-dashboard .toolbar>*>*>a{text-decoration:none;margin-top:auto;margin-bottom:auto}.quarto-dashboard .toolbar .shiny-input-container{padding-bottom:0;margin-bottom:0}.quarto-dashboard .toolbar .shiny-input-container>*{flex-shrink:0;flex-grow:0}.quarto-dashboard .toolbar .form-group.shiny-input-container:not([role=group])>label{margin-bottom:0}.quarto-dashboard .toolbar .shiny-input-container.no-baseline{align-items:start;padding-top:6px}.quarto-dashboard .toolbar .shiny-input-container{display:flex;align-items:baseline}.quarto-dashboard .toolbar .shiny-input-container label{padding-right:.4em}.quarto-dashboard .toolbar .shiny-input-container .bslib-input-switch{margin-top:6px}.quarto-dashboard .toolbar input[type=text]{line-height:1;width:inherit}.quarto-dashboard .toolbar .input-daterange{width:inherit}.quarto-dashboard .toolbar .input-daterange input[type=text]{height:2.4em;width:10em}.quarto-dashboard .toolbar .input-daterange .input-group-addon{height:auto;padding:0;margin-left:-5px !important;margin-right:-5px}.quarto-dashboard .toolbar .input-daterange .input-group-addon .input-group-text{padding-top:0;padding-bottom:0;height:100%}.quarto-dashboard .toolbar span.irs.irs--shiny{width:10em}.quarto-dashboard .toolbar span.irs.irs--shiny .irs-line{top:9px}.quarto-dashboard .toolbar span.irs.irs--shiny .irs-min,.quarto-dashboard .toolbar span.irs.irs--shiny .irs-max,.quarto-dashboard .toolbar span.irs.irs--shiny .irs-from,.quarto-dashboard .toolbar span.irs.irs--shiny .irs-to,.quarto-dashboard .toolbar span.irs.irs--shiny .irs-single{top:20px}.quarto-dashboard .toolbar span.irs.irs--shiny .irs-bar{top:8px}.quarto-dashboard .toolbar span.irs.irs--shiny .irs-handle{top:0px}.quarto-dashboard .toolbar .shiny-input-checkboxgroup>label{margin-top:6px}.quarto-dashboard .toolbar .shiny-input-checkboxgroup>.shiny-options-group{margin-top:0;align-items:baseline}.quarto-dashboard .toolbar .shiny-input-radiogroup>label{margin-top:6px}.quarto-dashboard .toolbar .shiny-input-radiogroup>.shiny-options-group{align-items:baseline;margin-top:0}.quarto-dashboard .toolbar .shiny-input-radiogroup>.shiny-options-group>.radio{margin-right:.3em}.quarto-dashboard .toolbar .form-select{padding-top:.2em;padding-bottom:.2em}.quarto-dashboard .toolbar .shiny-input-select{min-width:6em}.quarto-dashboard .toolbar div.checkbox{margin-bottom:0px}.quarto-dashboard .toolbar>.checkbox:first-child{margin-top:6px}.quarto-dashboard .toolbar form{width:fit-content}.quarto-dashboard .toolbar form label{padding-top:.2em;padding-bottom:.2em;width:fit-content}.quarto-dashboard .toolbar form input[type=date]{width:fit-content}.quarto-dashboard .toolbar form input[type=color]{width:3em}.quarto-dashboard .toolbar form button{padding:.4em}.quarto-dashboard .toolbar form select{width:fit-content}.quarto-dashboard .toolbar>*{font-size:.9em;flex-grow:0}.quarto-dashboard .toolbar .shiny-input-container label{margin-bottom:1px}.quarto-dashboard .toolbar-bottom{margin-top:1em;margin-bottom:0 !important;order:2}.quarto-dashboard .quarto-dashboard-content>.dashboard-toolbar-container>.toolbar-content>.tab-content>.tab-pane>*:not(.bslib-sidebar-layout){padding:1em}.quarto-dashboard .quarto-dashboard-content>.dashboard-toolbar-container>.toolbar-content>*:not(.tab-content){padding:1em}.quarto-dashboard .quarto-dashboard-content>.tab-content>.dashboard-page>.dashboard-toolbar-container>.toolbar-content,.quarto-dashboard .quarto-dashboard-content>.tab-content>.dashboard-page:not(.dashboard-sidebar-container)>*:not(.dashboard-toolbar-container){padding:1em}.quarto-dashboard .toolbar-content{padding:0}.quarto-dashboard .quarto-dashboard-content.quarto-dashboard-pages .tab-pane>.dashboard-toolbar-container .toolbar{border-radius:0;margin-bottom:0}.quarto-dashboard .dashboard-toolbar-container.toolbar-toplevel .toolbar{border-bottom:1px solid rgba(0,0,0,.175)}.quarto-dashboard .dashboard-toolbar-container.toolbar-toplevel .toolbar-bottom{margin-top:0}.quarto-dashboard .dashboard-toolbar-container:not(.toolbar-toplevel) .toolbar{margin-bottom:1em;border-top:none;border-radius:.25rem;border:1px solid rgba(0,0,0,.175)}.quarto-dashboard .vega-embed.has-actions details{width:1.7em;height:2em;position:absolute !important;top:0;right:0}.quarto-dashboard .dashboard-toolbar-container{padding:0}.quarto-dashboard .card .card-header p:last-child,.quarto-dashboard .card .card-footer p:last-child{margin-bottom:0}.quarto-dashboard .card .card-body>.h4:first-child{margin-top:0}.quarto-dashboard .card .card-body{z-index:4}@media(max-width: 767.98px){.quarto-dashboard .card .card-body .itables div.dataTables_wrapper div.dataTables_length,.quarto-dashboard .card .card-body .itables div.dataTables_wrapper div.dataTables_info,.quarto-dashboard .card .card-body .itables div.dataTables_wrapper div.dataTables_paginate{text-align:initial}.quarto-dashboard .card .card-body .itables div.dataTables_wrapper div.dataTables_filter{text-align:right}.quarto-dashboard .card .card-body .itables div.dataTables_wrapper div.dataTables_paginate ul.pagination{justify-content:initial}}.quarto-dashboard .card .card-body .itables .dataTables_wrapper{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;padding-top:0}.quarto-dashboard .card .card-body .itables .dataTables_wrapper table{flex-shrink:0}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dt-buttons{margin-bottom:.5em;margin-left:auto;width:fit-content;float:right}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dt-buttons.btn-group{background:#fff;border:none}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dt-buttons .btn-secondary{background-color:#fff;background-image:none;border:solid #dee2e6 1px;padding:.2em .7em}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dt-buttons .btn span{font-size:.8em;color:#343a40}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_info{margin-left:.5em;margin-bottom:.5em;padding-top:0}@media(min-width: 768px){.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_info{font-size:.875em}}@media(max-width: 767.98px){.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_info{font-size:.8em}}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_filter{margin-bottom:.5em;font-size:.875em}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_filter input[type=search]{padding:1px 5px 1px 5px;font-size:.875em}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_length{flex-basis:1 1 50%;margin-bottom:.5em;font-size:.875em}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_length select{padding:.4em 3em .4em .5em;font-size:.875em;margin-left:.2em;margin-right:.2em}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_paginate{flex-shrink:0}@media(min-width: 768px){.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_paginate{margin-left:auto}}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_paginate ul.pagination .paginate_button .page-link{font-size:.8em}.quarto-dashboard .card .card-footer{font-size:.9em}.quarto-dashboard .card .card-toolbar{display:flex;flex-grow:1;flex-direction:row;width:100%;flex-wrap:wrap}.quarto-dashboard .card .card-toolbar>*{font-size:.8em;flex-grow:0}.quarto-dashboard .card .card-toolbar>.card-title{font-size:1em;flex-grow:1;align-self:flex-start;margin-top:.1em}.quarto-dashboard .card .card-toolbar .cell-output-display{display:flex}.quarto-dashboard .card .card-toolbar .shiny-input-container{padding-bottom:.5em;margin-bottom:.5em;width:inherit}.quarto-dashboard .card .card-toolbar .shiny-input-container>.checkbox:first-child{margin-top:6px}.quarto-dashboard .card .card-toolbar>*:last-child{margin-right:0}.quarto-dashboard .card .card-toolbar>*>*{margin-right:1em;align-items:baseline}.quarto-dashboard .card .card-toolbar>*>*>a{text-decoration:none;margin-top:auto;margin-bottom:auto}.quarto-dashboard .card .card-toolbar form{width:fit-content}.quarto-dashboard .card .card-toolbar form label{padding-top:.2em;padding-bottom:.2em;width:fit-content}.quarto-dashboard .card .card-toolbar form input[type=date]{width:fit-content}.quarto-dashboard .card .card-toolbar form input[type=color]{width:3em}.quarto-dashboard .card .card-toolbar form button{padding:.4em}.quarto-dashboard .card .card-toolbar form select{width:fit-content}.quarto-dashboard .card .card-toolbar .cell-output-display{display:flex}.quarto-dashboard .card .card-toolbar .shiny-input-container{padding-bottom:.5em;margin-bottom:.5em;width:inherit}.quarto-dashboard .card .card-toolbar .shiny-input-container>.checkbox:first-child{margin-top:6px}.quarto-dashboard .card .card-toolbar>*:last-child{margin-right:0}.quarto-dashboard .card .card-toolbar>*>*{margin-right:1em;align-items:baseline}.quarto-dashboard .card .card-toolbar>*>*>a{text-decoration:none;margin-top:auto;margin-bottom:auto}.quarto-dashboard .card .card-toolbar .shiny-input-container{padding-bottom:0;margin-bottom:0}.quarto-dashboard .card .card-toolbar .shiny-input-container>*{flex-shrink:0;flex-grow:0}.quarto-dashboard .card .card-toolbar .form-group.shiny-input-container:not([role=group])>label{margin-bottom:0}.quarto-dashboard .card .card-toolbar .shiny-input-container.no-baseline{align-items:start;padding-top:6px}.quarto-dashboard .card .card-toolbar .shiny-input-container{display:flex;align-items:baseline}.quarto-dashboard .card .card-toolbar .shiny-input-container label{padding-right:.4em}.quarto-dashboard .card .card-toolbar .shiny-input-container .bslib-input-switch{margin-top:6px}.quarto-dashboard .card .card-toolbar input[type=text]{line-height:1;width:inherit}.quarto-dashboard .card .card-toolbar .input-daterange{width:inherit}.quarto-dashboard .card .card-toolbar .input-daterange input[type=text]{height:2.4em;width:10em}.quarto-dashboard .card .card-toolbar .input-daterange .input-group-addon{height:auto;padding:0;margin-left:-5px !important;margin-right:-5px}.quarto-dashboard .card .card-toolbar .input-daterange .input-group-addon .input-group-text{padding-top:0;padding-bottom:0;height:100%}.quarto-dashboard .card .card-toolbar span.irs.irs--shiny{width:10em}.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-line{top:9px}.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-min,.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-max,.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-from,.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-to,.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-single{top:20px}.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-bar{top:8px}.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-handle{top:0px}.quarto-dashboard .card .card-toolbar .shiny-input-checkboxgroup>label{margin-top:6px}.quarto-dashboard .card .card-toolbar .shiny-input-checkboxgroup>.shiny-options-group{margin-top:0;align-items:baseline}.quarto-dashboard .card .card-toolbar .shiny-input-radiogroup>label{margin-top:6px}.quarto-dashboard .card .card-toolbar .shiny-input-radiogroup>.shiny-options-group{align-items:baseline;margin-top:0}.quarto-dashboard .card .card-toolbar .shiny-input-radiogroup>.shiny-options-group>.radio{margin-right:.3em}.quarto-dashboard .card .card-toolbar .form-select{padding-top:.2em;padding-bottom:.2em}.quarto-dashboard .card .card-toolbar .shiny-input-select{min-width:6em}.quarto-dashboard .card .card-toolbar div.checkbox{margin-bottom:0px}.quarto-dashboard .card .card-toolbar>.checkbox:first-child{margin-top:6px}.quarto-dashboard .card-body>table>thead{border-top:none}.quarto-dashboard .card-body>.table>:not(caption)>*>*{background-color:#fff}.tableFloatingHeaderOriginal{background-color:#fff;position:sticky !important;top:0 !important}.dashboard-data-table{margin-top:-1px}div.value-box-area span.observablehq--number{font-size:calc(clamp(.1em,15cqw,5em)*1.25);line-height:1.2;color:inherit;font-family:var(--bs-body-font-family)}.quarto-listing{padding-bottom:1em}.listing-pagination{padding-top:.5em}ul.pagination{float:right;padding-left:8px;padding-top:.5em}ul.pagination li{padding-right:.75em}ul.pagination li.disabled a,ul.pagination li.active a{color:#fff;text-decoration:none}ul.pagination li:last-of-type{padding-right:0}.listing-actions-group{display:flex}.quarto-listing-filter{margin-bottom:1em;width:200px;margin-left:auto}.quarto-listing-sort{margin-bottom:1em;margin-right:auto;width:auto}.quarto-listing-sort .input-group-text{font-size:.8em}.input-group-text{border-right:none}.quarto-listing-sort select.form-select{font-size:.8em}.listing-no-matching{text-align:center;padding-top:2em;padding-bottom:3em;font-size:1em}#quarto-margin-sidebar .quarto-listing-category{padding-top:0;font-size:1rem}#quarto-margin-sidebar .quarto-listing-category-title{cursor:pointer;font-weight:600;font-size:1rem}.quarto-listing-category .category{cursor:pointer}.quarto-listing-category .category.active{font-weight:600}.quarto-listing-category.category-cloud{display:flex;flex-wrap:wrap;align-items:baseline}.quarto-listing-category.category-cloud .category{padding-right:5px}.quarto-listing-category.category-cloud .category-cloud-1{font-size:.75em}.quarto-listing-category.category-cloud .category-cloud-2{font-size:.95em}.quarto-listing-category.category-cloud .category-cloud-3{font-size:1.15em}.quarto-listing-category.category-cloud .category-cloud-4{font-size:1.35em}.quarto-listing-category.category-cloud .category-cloud-5{font-size:1.55em}.quarto-listing-category.category-cloud .category-cloud-6{font-size:1.75em}.quarto-listing-category.category-cloud .category-cloud-7{font-size:1.95em}.quarto-listing-category.category-cloud .category-cloud-8{font-size:2.15em}.quarto-listing-category.category-cloud .category-cloud-9{font-size:2.35em}.quarto-listing-category.category-cloud .category-cloud-10{font-size:2.55em}.quarto-listing-cols-1{grid-template-columns:repeat(1, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-1{grid-template-columns:repeat(1, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-1{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-2{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-2{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-2{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-3{grid-template-columns:repeat(3, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-3{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-3{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-4{grid-template-columns:repeat(4, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-4{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-4{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-5{grid-template-columns:repeat(5, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-5{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-5{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-6{grid-template-columns:repeat(6, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-6{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-6{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-7{grid-template-columns:repeat(7, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-7{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-7{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-8{grid-template-columns:repeat(8, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-8{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-8{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-9{grid-template-columns:repeat(9, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-9{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-9{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-10{grid-template-columns:repeat(10, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-10{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-10{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-11{grid-template-columns:repeat(11, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-11{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-11{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-12{grid-template-columns:repeat(12, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-12{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-12{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-grid{gap:1.5em}.quarto-grid-item.borderless{border:none}.quarto-grid-item.borderless .listing-categories .listing-category:last-of-type,.quarto-grid-item.borderless .listing-categories .listing-category:first-of-type{padding-left:0}.quarto-grid-item.borderless .listing-categories .listing-category{border:0}.quarto-grid-link{text-decoration:none;color:inherit}.quarto-grid-link:hover{text-decoration:none;color:inherit}.quarto-grid-item h5.title,.quarto-grid-item .title.h5{margin-top:0;margin-bottom:0}.quarto-grid-item .card-footer{display:flex;justify-content:space-between;font-size:.8em}.quarto-grid-item .card-footer p{margin-bottom:0}.quarto-grid-item p.card-img-top{margin-bottom:0}.quarto-grid-item p.card-img-top>img{object-fit:cover}.quarto-grid-item .card-other-values{margin-top:.5em;font-size:.8em}.quarto-grid-item .card-other-values tr{margin-bottom:.5em}.quarto-grid-item .card-other-values tr>td:first-of-type{font-weight:600;padding-right:1em;padding-left:1em;vertical-align:top}.quarto-grid-item div.post-contents{display:flex;flex-direction:column;text-decoration:none;height:100%}.quarto-grid-item .listing-item-img-placeholder{background-color:rgba(52,58,64,.25);flex-shrink:0}.quarto-grid-item .card-attribution{padding-top:1em;display:flex;gap:1em;text-transform:uppercase;color:#6c757d;font-weight:500;flex-grow:10;align-items:flex-end}.quarto-grid-item .description{padding-bottom:1em}.quarto-grid-item .card-attribution .date{align-self:flex-end}.quarto-grid-item .card-attribution.justify{justify-content:space-between}.quarto-grid-item .card-attribution.start{justify-content:flex-start}.quarto-grid-item .card-attribution.end{justify-content:flex-end}.quarto-grid-item .card-title{margin-bottom:.1em}.quarto-grid-item .card-subtitle{padding-top:.25em}.quarto-grid-item .card-text{font-size:.9em}.quarto-grid-item .listing-reading-time{padding-bottom:.25em}.quarto-grid-item .card-text-small{font-size:.8em}.quarto-grid-item .card-subtitle.subtitle{font-size:.9em;font-weight:600;padding-bottom:.5em}.quarto-grid-item .listing-categories{display:flex;flex-wrap:wrap;padding-bottom:5px}.quarto-grid-item .listing-categories .listing-category{color:#6c757d;border:solid 1px #dee2e6;border-radius:.25rem;text-transform:uppercase;font-size:.65em;padding-left:.5em;padding-right:.5em;padding-top:.15em;padding-bottom:.15em;cursor:pointer;margin-right:4px;margin-bottom:4px}.quarto-grid-item.card-right{text-align:right}.quarto-grid-item.card-right .listing-categories{justify-content:flex-end}.quarto-grid-item.card-left{text-align:left}.quarto-grid-item.card-center{text-align:center}.quarto-grid-item.card-center .listing-description{text-align:justify}.quarto-grid-item.card-center .listing-categories{justify-content:center}table.quarto-listing-table td.image{padding:0px}table.quarto-listing-table td.image img{width:100%;max-width:50px;object-fit:contain}table.quarto-listing-table a{text-decoration:none;word-break:keep-all}table.quarto-listing-table th a{color:inherit}table.quarto-listing-table th a.asc:after{margin-bottom:-2px;margin-left:5px;display:inline-block;height:1rem;width:1rem;background-repeat:no-repeat;background-size:1rem 1rem;background-image:url('data:image/svg+xml,');content:""}table.quarto-listing-table th a.desc:after{margin-bottom:-2px;margin-left:5px;display:inline-block;height:1rem;width:1rem;background-repeat:no-repeat;background-size:1rem 1rem;background-image:url('data:image/svg+xml,');content:""}table.quarto-listing-table.table-hover td{cursor:pointer}.quarto-post.image-left{flex-direction:row}.quarto-post.image-right{flex-direction:row-reverse}@media(max-width: 767.98px){.quarto-post.image-right,.quarto-post.image-left{gap:0em;flex-direction:column}.quarto-post .metadata{padding-bottom:1em;order:2}.quarto-post .body{order:1}.quarto-post .thumbnail{order:3}}.list.quarto-listing-default div:last-of-type{border-bottom:none}@media(min-width: 992px){.quarto-listing-container-default{margin-right:2em}}div.quarto-post{display:flex;gap:2em;margin-bottom:1.5em;border-bottom:1px solid #dee2e6}@media(max-width: 767.98px){div.quarto-post{padding-bottom:1em}}div.quarto-post .metadata{flex-basis:20%;flex-grow:0;margin-top:.2em;flex-shrink:10}div.quarto-post .thumbnail{flex-basis:30%;flex-grow:0;flex-shrink:0}div.quarto-post .thumbnail img{margin-top:.4em;width:100%;object-fit:cover}div.quarto-post .body{flex-basis:45%;flex-grow:1;flex-shrink:0}div.quarto-post .body h3.listing-title,div.quarto-post .body .listing-title.h3{margin-top:0px;margin-bottom:0px;border-bottom:none}div.quarto-post .body .listing-subtitle{font-size:.875em;margin-bottom:.5em;margin-top:.2em}div.quarto-post .body .description{font-size:.9em}div.quarto-post .body pre code{white-space:pre-wrap}div.quarto-post a{color:#343a40;text-decoration:none}div.quarto-post .metadata{display:flex;flex-direction:column;font-size:.8em;font-family:Ubuntu,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";flex-basis:33%}div.quarto-post .listing-categories{display:flex;flex-wrap:wrap;padding-bottom:5px}div.quarto-post .listing-categories .listing-category{color:#6c757d;border:solid 1px #dee2e6;border-radius:.25rem;text-transform:uppercase;font-size:.65em;padding-left:.5em;padding-right:.5em;padding-top:.15em;padding-bottom:.15em;cursor:pointer;margin-right:4px;margin-bottom:4px}div.quarto-post .listing-description{margin-bottom:.5em}div.quarto-about-jolla{display:flex !important;flex-direction:column;align-items:center;margin-top:10%;padding-bottom:1em}div.quarto-about-jolla .about-image{object-fit:cover;margin-left:auto;margin-right:auto;margin-bottom:1.5em}div.quarto-about-jolla img.round{border-radius:50%}div.quarto-about-jolla img.rounded{border-radius:10px}div.quarto-about-jolla .quarto-title h1.title,div.quarto-about-jolla .quarto-title .title.h1{text-align:center}div.quarto-about-jolla .quarto-title .description{text-align:center}div.quarto-about-jolla h2,div.quarto-about-jolla .h2{border-bottom:none}div.quarto-about-jolla .about-sep{width:60%}div.quarto-about-jolla main{text-align:center}div.quarto-about-jolla .about-links{display:flex}@media(min-width: 992px){div.quarto-about-jolla .about-links{flex-direction:row;column-gap:.8em;row-gap:15px;flex-wrap:wrap}}@media(max-width: 991.98px){div.quarto-about-jolla .about-links{flex-direction:column;row-gap:1em;width:100%;padding-bottom:1.5em}}div.quarto-about-jolla .about-link{color:rgb(97.724137931,109,120.275862069);text-decoration:none;border:solid 1px}@media(min-width: 992px){div.quarto-about-jolla .about-link{font-size:.8em;padding:.25em .5em;border-radius:4px}}@media(max-width: 991.98px){div.quarto-about-jolla .about-link{font-size:1.1em;padding:.5em .5em;text-align:center;border-radius:6px}}div.quarto-about-jolla .about-link:hover{color:#e95420}div.quarto-about-jolla .about-link i.bi{margin-right:.15em}div.quarto-about-solana{display:flex !important;flex-direction:column;padding-top:3em !important;padding-bottom:1em}div.quarto-about-solana .about-entity{display:flex !important;align-items:start;justify-content:space-between}@media(min-width: 992px){div.quarto-about-solana .about-entity{flex-direction:row}}@media(max-width: 991.98px){div.quarto-about-solana .about-entity{flex-direction:column-reverse;align-items:center;text-align:center}}div.quarto-about-solana .about-entity .entity-contents{display:flex;flex-direction:column}@media(max-width: 767.98px){div.quarto-about-solana .about-entity .entity-contents{width:100%}}div.quarto-about-solana .about-entity .about-image{object-fit:cover}@media(max-width: 991.98px){div.quarto-about-solana .about-entity .about-image{margin-bottom:1.5em}}div.quarto-about-solana .about-entity img.round{border-radius:50%}div.quarto-about-solana .about-entity img.rounded{border-radius:10px}div.quarto-about-solana .about-entity .about-links{display:flex;justify-content:left;padding-bottom:1.2em}@media(min-width: 992px){div.quarto-about-solana .about-entity .about-links{flex-direction:row;column-gap:.8em;row-gap:15px;flex-wrap:wrap}}@media(max-width: 991.98px){div.quarto-about-solana .about-entity .about-links{flex-direction:column;row-gap:1em;width:100%;padding-bottom:1.5em}}div.quarto-about-solana .about-entity .about-link{color:rgb(97.724137931,109,120.275862069);text-decoration:none;border:solid 1px}@media(min-width: 992px){div.quarto-about-solana .about-entity .about-link{font-size:.8em;padding:.25em .5em;border-radius:4px}}@media(max-width: 991.98px){div.quarto-about-solana .about-entity .about-link{font-size:1.1em;padding:.5em .5em;text-align:center;border-radius:6px}}div.quarto-about-solana .about-entity .about-link:hover{color:#e95420}div.quarto-about-solana .about-entity .about-link i.bi{margin-right:.15em}div.quarto-about-solana .about-contents{padding-right:1.5em;flex-basis:0;flex-grow:1}div.quarto-about-solana .about-contents main.content{margin-top:0}div.quarto-about-solana .about-contents h2,div.quarto-about-solana .about-contents .h2{border-bottom:none}div.quarto-about-trestles{display:flex !important;flex-direction:row;padding-top:3em !important;padding-bottom:1em}@media(max-width: 991.98px){div.quarto-about-trestles{flex-direction:column;padding-top:0em !important}}div.quarto-about-trestles .about-entity{display:flex !important;flex-direction:column;align-items:center;text-align:center;padding-right:1em}@media(min-width: 992px){div.quarto-about-trestles .about-entity{flex:0 0 42%}}div.quarto-about-trestles .about-entity .about-image{object-fit:cover;margin-bottom:1.5em}div.quarto-about-trestles .about-entity img.round{border-radius:50%}div.quarto-about-trestles .about-entity img.rounded{border-radius:10px}div.quarto-about-trestles .about-entity .about-links{display:flex;justify-content:center}@media(min-width: 992px){div.quarto-about-trestles .about-entity .about-links{flex-direction:row;column-gap:.8em;row-gap:15px;flex-wrap:wrap}}@media(max-width: 991.98px){div.quarto-about-trestles .about-entity .about-links{flex-direction:column;row-gap:1em;width:100%;padding-bottom:1.5em}}div.quarto-about-trestles .about-entity .about-link{color:rgb(97.724137931,109,120.275862069);text-decoration:none;border:solid 1px}@media(min-width: 992px){div.quarto-about-trestles .about-entity .about-link{font-size:.8em;padding:.25em .5em;border-radius:4px}}@media(max-width: 991.98px){div.quarto-about-trestles .about-entity .about-link{font-size:1.1em;padding:.5em .5em;text-align:center;border-radius:6px}}div.quarto-about-trestles .about-entity .about-link:hover{color:#e95420}div.quarto-about-trestles .about-entity .about-link i.bi{margin-right:.15em}div.quarto-about-trestles .about-contents{flex-basis:0;flex-grow:1}div.quarto-about-trestles .about-contents h2,div.quarto-about-trestles .about-contents .h2{border-bottom:none}@media(min-width: 992px){div.quarto-about-trestles .about-contents{border-left:solid 1px #dee2e6;padding-left:1.5em}}div.quarto-about-trestles .about-contents main.content{margin-top:0}div.quarto-about-marquee{padding-bottom:1em}div.quarto-about-marquee .about-contents{display:flex;flex-direction:column}div.quarto-about-marquee .about-image{max-height:550px;margin-bottom:1.5em;object-fit:cover}div.quarto-about-marquee img.round{border-radius:50%}div.quarto-about-marquee img.rounded{border-radius:10px}div.quarto-about-marquee h2,div.quarto-about-marquee .h2{border-bottom:none}div.quarto-about-marquee .about-links{display:flex;justify-content:center;padding-top:1.5em}@media(min-width: 992px){div.quarto-about-marquee .about-links{flex-direction:row;column-gap:.8em;row-gap:15px;flex-wrap:wrap}}@media(max-width: 991.98px){div.quarto-about-marquee .about-links{flex-direction:column;row-gap:1em;width:100%;padding-bottom:1.5em}}div.quarto-about-marquee .about-link{color:rgb(97.724137931,109,120.275862069);text-decoration:none;border:solid 1px}@media(min-width: 992px){div.quarto-about-marquee .about-link{font-size:.8em;padding:.25em .5em;border-radius:4px}}@media(max-width: 991.98px){div.quarto-about-marquee .about-link{font-size:1.1em;padding:.5em .5em;text-align:center;border-radius:6px}}div.quarto-about-marquee .about-link:hover{color:#e95420}div.quarto-about-marquee .about-link i.bi{margin-right:.15em}@media(min-width: 992px){div.quarto-about-marquee .about-link{border:none}}div.quarto-about-broadside{display:flex;flex-direction:column;padding-bottom:1em}div.quarto-about-broadside .about-main{display:flex !important;padding-top:0 !important}@media(min-width: 992px){div.quarto-about-broadside .about-main{flex-direction:row;align-items:flex-start}}@media(max-width: 991.98px){div.quarto-about-broadside .about-main{flex-direction:column}}@media(max-width: 991.98px){div.quarto-about-broadside .about-main .about-entity{flex-shrink:0;width:100%;height:450px;margin-bottom:1.5em;background-size:cover;background-repeat:no-repeat}}@media(min-width: 992px){div.quarto-about-broadside .about-main .about-entity{flex:0 10 50%;margin-right:1.5em;width:100%;height:100%;background-size:100%;background-repeat:no-repeat}}div.quarto-about-broadside .about-main .about-contents{padding-top:14px;flex:0 0 50%}div.quarto-about-broadside h2,div.quarto-about-broadside .h2{border-bottom:none}div.quarto-about-broadside .about-sep{margin-top:1.5em;width:60%;align-self:center}div.quarto-about-broadside .about-links{display:flex;justify-content:center;column-gap:20px;padding-top:1.5em}@media(min-width: 992px){div.quarto-about-broadside .about-links{flex-direction:row;column-gap:.8em;row-gap:15px;flex-wrap:wrap}}@media(max-width: 991.98px){div.quarto-about-broadside .about-links{flex-direction:column;row-gap:1em;width:100%;padding-bottom:1.5em}}div.quarto-about-broadside .about-link{color:rgb(97.724137931,109,120.275862069);text-decoration:none;border:solid 1px}@media(min-width: 992px){div.quarto-about-broadside .about-link{font-size:.8em;padding:.25em .5em;border-radius:4px}}@media(max-width: 991.98px){div.quarto-about-broadside .about-link{font-size:1.1em;padding:.5em .5em;text-align:center;border-radius:6px}}div.quarto-about-broadside .about-link:hover{color:#e95420}div.quarto-about-broadside .about-link i.bi{margin-right:.15em}@media(min-width: 992px){div.quarto-about-broadside .about-link{border:none}}.tippy-box[data-theme~=quarto]{background-color:#fff;border:solid 1px #dee2e6;border-radius:.25rem;color:#343a40;font-size:.875rem}.tippy-box[data-theme~=quarto]>.tippy-backdrop{background-color:#fff}.tippy-box[data-theme~=quarto]>.tippy-arrow:after,.tippy-box[data-theme~=quarto]>.tippy-svg-arrow:after{content:"";position:absolute;z-index:-1}.tippy-box[data-theme~=quarto]>.tippy-arrow:after{border-color:rgba(0,0,0,0);border-style:solid}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-6px}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-6px}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-6px}.tippy-box[data-placement^=left]>.tippy-arrow:before{right:-6px}.tippy-box[data-theme~=quarto][data-placement^=top]>.tippy-arrow:before{border-top-color:#fff}.tippy-box[data-theme~=quarto][data-placement^=top]>.tippy-arrow:after{border-top-color:#dee2e6;border-width:7px 7px 0;top:17px;left:1px}.tippy-box[data-theme~=quarto][data-placement^=top]>.tippy-svg-arrow>svg{top:16px}.tippy-box[data-theme~=quarto][data-placement^=top]>.tippy-svg-arrow:after{top:17px}.tippy-box[data-theme~=quarto][data-placement^=bottom]>.tippy-arrow:before{border-bottom-color:#fff;bottom:16px}.tippy-box[data-theme~=quarto][data-placement^=bottom]>.tippy-arrow:after{border-bottom-color:#dee2e6;border-width:0 7px 7px;bottom:17px;left:1px}.tippy-box[data-theme~=quarto][data-placement^=bottom]>.tippy-svg-arrow>svg{bottom:15px}.tippy-box[data-theme~=quarto][data-placement^=bottom]>.tippy-svg-arrow:after{bottom:17px}.tippy-box[data-theme~=quarto][data-placement^=left]>.tippy-arrow:before{border-left-color:#fff}.tippy-box[data-theme~=quarto][data-placement^=left]>.tippy-arrow:after{border-left-color:#dee2e6;border-width:7px 0 7px 7px;left:17px;top:1px}.tippy-box[data-theme~=quarto][data-placement^=left]>.tippy-svg-arrow>svg{left:11px}.tippy-box[data-theme~=quarto][data-placement^=left]>.tippy-svg-arrow:after{left:12px}.tippy-box[data-theme~=quarto][data-placement^=right]>.tippy-arrow:before{border-right-color:#fff;right:16px}.tippy-box[data-theme~=quarto][data-placement^=right]>.tippy-arrow:after{border-width:7px 7px 7px 0;right:17px;top:1px;border-right-color:#dee2e6}.tippy-box[data-theme~=quarto][data-placement^=right]>.tippy-svg-arrow>svg{right:11px}.tippy-box[data-theme~=quarto][data-placement^=right]>.tippy-svg-arrow:after{right:12px}.tippy-box[data-theme~=quarto]>.tippy-svg-arrow{fill:#343a40}.tippy-box[data-theme~=quarto]>.tippy-svg-arrow:after{background-image:url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMCA2czEuNzk2LS4wMTMgNC42Ny0zLjYxNUM1Ljg1MS45IDYuOTMuMDA2IDggMGMxLjA3LS4wMDYgMi4xNDguODg3IDMuMzQzIDIuMzg1QzE0LjIzMyA2LjAwNSAxNiA2IDE2IDZIMHoiIGZpbGw9InJnYmEoMCwgOCwgMTYsIDAuMikiLz48L3N2Zz4=);background-size:16px 6px;width:16px;height:6px}.top-right{position:absolute;top:1em;right:1em}.visually-hidden{border:0;clip:rect(0 0 0 0);height:auto;margin:0;overflow:hidden;padding:0;position:absolute;width:1px;white-space:nowrap}.hidden{display:none !important}.zindex-bottom{z-index:-1 !important}figure.figure{display:block}.quarto-layout-panel{margin-bottom:1em}.quarto-layout-panel>figure{width:100%}.quarto-layout-panel>figure>figcaption,.quarto-layout-panel>.panel-caption{margin-top:10pt}.quarto-layout-panel>.table-caption{margin-top:0px}.table-caption p{margin-bottom:.5em}.quarto-layout-row{display:flex;flex-direction:row;align-items:flex-start}.quarto-layout-valign-top{align-items:flex-start}.quarto-layout-valign-bottom{align-items:flex-end}.quarto-layout-valign-center{align-items:center}.quarto-layout-cell{position:relative;margin-right:20px}.quarto-layout-cell:last-child{margin-right:0}.quarto-layout-cell figure,.quarto-layout-cell>p{margin:.2em}.quarto-layout-cell img{max-width:100%}.quarto-layout-cell .html-widget{width:100% !important}.quarto-layout-cell div figure p{margin:0}.quarto-layout-cell figure{display:block;margin-inline-start:0;margin-inline-end:0}.quarto-layout-cell table{display:inline-table}.quarto-layout-cell-subref figcaption,figure .quarto-layout-row figure figcaption{text-align:center;font-style:italic}.quarto-figure{position:relative;margin-bottom:1em}.quarto-figure>figure{width:100%;margin-bottom:0}.quarto-figure-left>figure>p,.quarto-figure-left>figure>div{text-align:left}.quarto-figure-center>figure>p,.quarto-figure-center>figure>div{text-align:center}.quarto-figure-right>figure>p,.quarto-figure-right>figure>div{text-align:right}.quarto-figure>figure>div.cell-annotation,.quarto-figure>figure>div code{text-align:left}figure>p:empty{display:none}figure>p:first-child{margin-top:0;margin-bottom:0}figure>figcaption.quarto-float-caption-bottom{margin-bottom:.5em}figure>figcaption.quarto-float-caption-top{margin-top:.5em}div[id^=tbl-]{position:relative}.quarto-figure>.anchorjs-link{position:absolute;top:.6em;right:.5em}div[id^=tbl-]>.anchorjs-link{position:absolute;top:.7em;right:.3em}.quarto-figure:hover>.anchorjs-link,div[id^=tbl-]:hover>.anchorjs-link,h2:hover>.anchorjs-link,.h2:hover>.anchorjs-link,h3:hover>.anchorjs-link,.h3:hover>.anchorjs-link,h4:hover>.anchorjs-link,.h4:hover>.anchorjs-link,h5:hover>.anchorjs-link,.h5:hover>.anchorjs-link,h6:hover>.anchorjs-link,.h6:hover>.anchorjs-link,.reveal-anchorjs-link>.anchorjs-link{opacity:1}#title-block-header{margin-block-end:1rem;position:relative;margin-top:-1px}#title-block-header .abstract{margin-block-start:1rem}#title-block-header .abstract .abstract-title{font-weight:600}#title-block-header a{text-decoration:none}#title-block-header .author,#title-block-header .date,#title-block-header .doi{margin-block-end:.2rem}#title-block-header .quarto-title-block>div{display:flex}#title-block-header .quarto-title-block>div>h1,#title-block-header .quarto-title-block>div>.h1{flex-grow:1}#title-block-header .quarto-title-block>div>button{flex-shrink:0;height:2.25rem;margin-top:0}@media(min-width: 992px){#title-block-header .quarto-title-block>div>button{margin-top:5px}}tr.header>th>p:last-of-type{margin-bottom:0px}table,table.table{margin-top:.5rem;margin-bottom:.5rem}caption,.table-caption{padding-top:.5rem;padding-bottom:.5rem;text-align:center}figure.quarto-float-tbl figcaption.quarto-float-caption-top{margin-top:.5rem;margin-bottom:.25rem;text-align:center}figure.quarto-float-tbl figcaption.quarto-float-caption-bottom{padding-top:.25rem;margin-bottom:.5rem;text-align:center}.utterances{max-width:none;margin-left:-8px}iframe{margin-bottom:1em}details{margin-bottom:1em}details[show]{margin-bottom:0}details>summary{color:#6c757d}details>summary>p:only-child{display:inline}div.code-copy-outer-scaffold{position:relative}dd code:not(.sourceCode),p code:not(.sourceCode){white-space:pre-wrap}code{white-space:pre}@media print{code{white-space:pre-wrap}}pre>code{display:block}pre>code.sourceCode{white-space:pre}pre>code.sourceCode>span>a:first-child::before{text-decoration:none}pre.code-overflow-wrap>code.sourceCode{white-space:pre-wrap}pre.code-overflow-scroll>code.sourceCode{white-space:pre}code a:any-link{color:inherit;text-decoration:none}code a:hover{color:inherit;text-decoration:underline}ul.task-list{padding-left:1em}[data-tippy-root]{display:inline-block}.tippy-content .footnote-back{display:none}.footnote-back{margin-left:.2em}.tippy-content{overflow-x:auto}.quarto-embedded-source-code{display:none}.quarto-unresolved-ref{font-weight:600}.quarto-cover-image{max-width:35%;float:right;margin-left:30px}.cell-output-display .widget-subarea{margin-bottom:1em}.cell-output-display:not(.no-overflow-x),.knitsql-table:not(.no-overflow-x){overflow-x:auto}.panel-input{margin-bottom:1em}.panel-input>div,.panel-input>div>div{display:inline-block;vertical-align:top;padding-right:12px}.panel-input>p:last-child{margin-bottom:0}.layout-sidebar{margin-bottom:1em}.layout-sidebar .tab-content{border:none}.tab-content>.page-columns.active{display:grid}div.sourceCode>iframe{width:100%;height:300px;margin-bottom:-0.5em}a{text-underline-offset:3px}.callout pre.sourceCode{padding-left:0}div.ansi-escaped-output{font-family:monospace;display:block}/*! +* +* ansi colors from IPython notebook's +* +* we also add `bright-[color]-` synonyms for the `-[color]-intense` classes since +* that seems to be what ansi_up emits +* +*/.ansi-black-fg{color:#3e424d}.ansi-black-bg{background-color:#3e424d}.ansi-black-intense-black,.ansi-bright-black-fg{color:#282c36}.ansi-black-intense-black,.ansi-bright-black-bg{background-color:#282c36}.ansi-red-fg{color:#e75c58}.ansi-red-bg{background-color:#e75c58}.ansi-red-intense-red,.ansi-bright-red-fg{color:#b22b31}.ansi-red-intense-red,.ansi-bright-red-bg{background-color:#b22b31}.ansi-green-fg{color:#00a250}.ansi-green-bg{background-color:#00a250}.ansi-green-intense-green,.ansi-bright-green-fg{color:#007427}.ansi-green-intense-green,.ansi-bright-green-bg{background-color:#007427}.ansi-yellow-fg{color:#ddb62b}.ansi-yellow-bg{background-color:#ddb62b}.ansi-yellow-intense-yellow,.ansi-bright-yellow-fg{color:#b27d12}.ansi-yellow-intense-yellow,.ansi-bright-yellow-bg{background-color:#b27d12}.ansi-blue-fg{color:#208ffb}.ansi-blue-bg{background-color:#208ffb}.ansi-blue-intense-blue,.ansi-bright-blue-fg{color:#0065ca}.ansi-blue-intense-blue,.ansi-bright-blue-bg{background-color:#0065ca}.ansi-magenta-fg{color:#d160c4}.ansi-magenta-bg{background-color:#d160c4}.ansi-magenta-intense-magenta,.ansi-bright-magenta-fg{color:#a03196}.ansi-magenta-intense-magenta,.ansi-bright-magenta-bg{background-color:#a03196}.ansi-cyan-fg{color:#60c6c8}.ansi-cyan-bg{background-color:#60c6c8}.ansi-cyan-intense-cyan,.ansi-bright-cyan-fg{color:#258f8f}.ansi-cyan-intense-cyan,.ansi-bright-cyan-bg{background-color:#258f8f}.ansi-white-fg{color:#c5c1b4}.ansi-white-bg{background-color:#c5c1b4}.ansi-white-intense-white,.ansi-bright-white-fg{color:#a1a6b2}.ansi-white-intense-white,.ansi-bright-white-bg{background-color:#a1a6b2}.ansi-default-inverse-fg{color:#fff}.ansi-default-inverse-bg{background-color:#000}.ansi-bold{font-weight:bold}.ansi-underline{text-decoration:underline}:root{--quarto-body-bg: #fff;--quarto-body-color: #343a40;--quarto-text-muted: #6c757d;--quarto-border-color: #dee2e6;--quarto-border-width: 1px;--quarto-border-radius: 0.25rem}table.gt_table{color:var(--quarto-body-color);font-size:1em;width:100%;background-color:rgba(0,0,0,0);border-top-width:inherit;border-bottom-width:inherit;border-color:var(--quarto-border-color)}table.gt_table th.gt_column_spanner_outer{color:var(--quarto-body-color);background-color:rgba(0,0,0,0);border-top-width:inherit;border-bottom-width:inherit;border-color:var(--quarto-border-color)}table.gt_table th.gt_col_heading{color:var(--quarto-body-color);font-weight:bold;background-color:rgba(0,0,0,0)}table.gt_table thead.gt_col_headings{border-bottom:1px solid currentColor;border-top-width:inherit;border-top-color:var(--quarto-border-color)}table.gt_table thead.gt_col_headings:not(:first-child){border-top-width:1px;border-top-color:var(--quarto-border-color)}table.gt_table td.gt_row{border-bottom-width:1px;border-bottom-color:var(--quarto-border-color);border-top-width:0px}table.gt_table tbody.gt_table_body{border-top-width:1px;border-bottom-width:1px;border-bottom-color:var(--quarto-border-color);border-top-color:currentColor}div.columns{display:initial;gap:initial}div.column{display:inline-block;overflow-x:initial;vertical-align:top;width:50%}.code-annotation-tip-content{word-wrap:break-word}.code-annotation-container-hidden{display:none !important}dl.code-annotation-container-grid{display:grid;grid-template-columns:min-content auto}dl.code-annotation-container-grid dt{grid-column:1}dl.code-annotation-container-grid dd{grid-column:2}pre.sourceCode.code-annotation-code{padding-right:0}code.sourceCode .code-annotation-anchor{z-index:100;position:relative;float:right;background-color:rgba(0,0,0,0)}input[type=checkbox]{margin-right:.5ch}:root{--mermaid-bg-color: #fff;--mermaid-edge-color: #adb5bd;--mermaid-node-fg-color: #343a40;--mermaid-fg-color: #343a40;--mermaid-fg-color--lighter: rgb(74.8620689655, 83.5, 92.1379310345);--mermaid-fg-color--lightest: rgb(97.724137931, 109, 120.275862069);--mermaid-font-family: Ubuntu, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;--mermaid-label-bg-color: #fff;--mermaid-label-fg-color: #e95420;--mermaid-node-bg-color: rgba(233, 84, 32, 0.1);--mermaid-node-fg-color: #343a40}@media print{:root{font-size:11pt}#quarto-sidebar,#TOC,.nav-page{display:none}.page-columns .content{grid-column-start:page-start}.fixed-top{position:relative}.panel-caption,.figure-caption,figcaption{color:#666}}.code-copy-button{position:absolute;top:0;right:0;border:0;margin-top:5px;margin-right:5px;background-color:rgba(0,0,0,0);z-index:3}.code-copy-button-tooltip{font-size:.75em}div.code-copy-outer-scaffold:hover>.code-copy-button>.bi::before{display:inline-block;height:1rem;width:1rem;content:"";vertical-align:-0.125em;background-image:url('data:image/svg+xml,');background-repeat:no-repeat;background-size:1rem 1rem}div.code-copy-outer-scaffold:hover>.code-copy-button-checked>.bi::before{background-image:url('data:image/svg+xml,')}div.code-copy-outer-scaffold:hover>.code-copy-button:hover>.bi::before{background-image:url('data:image/svg+xml,')}div.code-copy-outer-scaffold:hover>.code-copy-button-checked:hover>.bi::before{background-image:url('data:image/svg+xml,')}main ol ol,main ul ul,main ol ul,main ul ol{margin-bottom:1em}ul>li:not(:has(>p))>ul,ol>li:not(:has(>p))>ul,ul>li:not(:has(>p))>ol,ol>li:not(:has(>p))>ol{margin-bottom:0}ul>li:not(:has(>p))>ul>li:has(>p),ol>li:not(:has(>p))>ul>li:has(>p),ul>li:not(:has(>p))>ol>li:has(>p),ol>li:not(:has(>p))>ol>li:has(>p){margin-top:1rem}body{margin:0}main.page-columns>header>h1.title,main.page-columns>header>.title.h1{margin-bottom:0}@media(min-width: 992px){body .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset] 35px [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(500px, calc(850px - 3em)) [body-content-end] 1.5em [body-end] 35px [body-end-outset] minmax(75px, 145px) [page-end-inset] 35px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.fullcontent:not(.floating):not(.docked) .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset] 35px [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(500px, calc(850px - 3em)) [body-content-end] 1.5em [body-end] 35px [body-end-outset] 35px [page-end-inset page-end] 5fr [screen-end-inset] 1.5em}body.slimcontent:not(.floating):not(.docked) .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset] 35px [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(500px, calc(850px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(0px, 200px) [page-end-inset] 35px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.listing:not(.floating):not(.docked) .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start] minmax(50px, 100px) [page-start-inset] 50px [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(500px, calc(850px - 3em)) [body-content-end] 3em [body-end] 50px [body-end-outset] minmax(0px, 250px) [page-end-inset] minmax(50px, 100px) [page-end] 1fr [screen-end-inset] 1.5em [screen-end]}body:not(.floating):not(.docked) .page-columns.toc-left{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] 35px [page-start-inset] minmax(0px, 175px) [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(450px, calc(800px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(0px, 200px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body:not(.floating):not(.docked) .page-columns.toc-left .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] 35px [page-start-inset] minmax(0px, 175px) [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(450px, calc(800px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(0px, 200px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] minmax(25px, 50px) [page-start-inset] minmax(50px, 150px) [body-start-outset] minmax(25px, 50px) [body-start] 1.5em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end] minmax(25px, 50px) [body-end-outset] minmax(50px, 150px) [page-end-inset] minmax(25px, 50px) [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start] minmax(50px, 100px) [page-start-inset] 50px [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(500px, calc(1000px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(50px, 100px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked.fullcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start] minmax(50px, 100px) [page-start-inset] 50px [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(500px, calc(1000px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating.fullcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] 50px [page-start-inset] minmax(50px, 150px) [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked.slimcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start] minmax(50px, 100px) [page-start-inset] 50px [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(450px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(0px, 200px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked.listing .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start] minmax(50px, 100px) [page-start-inset] 50px [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(500px, calc(1000px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(0px, 200px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating.slimcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] 50px [page-start-inset] minmax(50px, 150px) [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(450px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(50px, 150px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating.listing .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] minmax(25px, 50px) [page-start-inset] minmax(50px, 150px) [body-start-outset] minmax(25px, 50px) [body-start] 1.5em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end] minmax(25px, 50px) [body-end-outset] minmax(50px, 150px) [page-end-inset] minmax(25px, 50px) [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}}@media(max-width: 991.98px){body .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset] 5fr [body-start] 1.5em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end] 35px [body-end-outset] minmax(75px, 145px) [page-end-inset] 35px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.fullcontent:not(.floating):not(.docked) .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset] 5fr [body-start] 1.5em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.slimcontent:not(.floating):not(.docked) .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset] 5fr [body-start] 1.5em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end] 35px [body-end-outset] minmax(75px, 145px) [page-end-inset] 35px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.listing:not(.floating):not(.docked) .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset] 5fr [body-start] 1.5em [body-content-start] minmax(500px, calc(1250px - 3em)) [body-content-end body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body:not(.floating):not(.docked) .page-columns.toc-left{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] 35px [page-start-inset] minmax(0px, 145px) [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(450px, calc(800px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body:not(.floating):not(.docked) .page-columns.toc-left .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] 35px [page-start-inset] minmax(0px, 145px) [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(450px, calc(800px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset body-start-outset body-start] 1.5em [body-content-start] minmax(500px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(75px, 150px) [page-end-inset] 25px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(500px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(25px, 50px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked.fullcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(500px, calc(1000px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating.fullcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset body-start-outset body-start] 1em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 4fr [screen-end-inset] 1.5em [screen-end]}body.docked.slimcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(500px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(25px, 50px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked.listing .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(500px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(25px, 50px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating.slimcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset body-start-outset body-start] 1em [body-content-start] minmax(500px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 35px [body-end-outset] minmax(75px, 145px) [page-end-inset] 35px [page-end] 4fr [screen-end-inset] 1.5em [screen-end]}body.floating.listing .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset body-start-outset body-start] 1em [body-content-start] minmax(500px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(75px, 150px) [page-end-inset] 25px [page-end] 4fr [screen-end-inset] 1.5em [screen-end]}}@media(max-width: 767.98px){body .page-columns,body.fullcontent:not(.floating):not(.docked) .page-columns,body.slimcontent:not(.floating):not(.docked) .page-columns,body.docked .page-columns,body.docked.slimcontent .page-columns,body.docked.fullcontent .page-columns,body.floating .page-columns,body.floating.slimcontent .page-columns,body.floating.fullcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(0px, 1fr) [body-content-end body-end body-end-outset page-end-inset page-end screen-end-inset] 1.5em [screen-end]}body:not(.floating):not(.docked) .page-columns.toc-left{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(0px, 1fr) [body-content-end body-end body-end-outset page-end-inset page-end screen-end-inset] 1.5em [screen-end]}body:not(.floating):not(.docked) .page-columns.toc-left .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(0px, 1fr) [body-content-end body-end body-end-outset page-end-inset page-end screen-end-inset] 1.5em [screen-end]}nav[role=doc-toc]{display:none}}body,.page-row-navigation{grid-template-rows:[page-top] max-content [contents-top] max-content [contents-bottom] max-content [page-bottom]}.page-rows-contents{grid-template-rows:[content-top] minmax(max-content, 1fr) [content-bottom] minmax(60px, max-content) [page-bottom]}.page-full{grid-column:screen-start/screen-end !important}.page-columns>*{grid-column:body-content-start/body-content-end}.page-columns.column-page>*{grid-column:page-start/page-end}.page-columns.column-page-left .page-columns.page-full>*,.page-columns.column-page-left>*{grid-column:page-start/body-content-end}.page-columns.column-page-right .page-columns.page-full>*,.page-columns.column-page-right>*{grid-column:body-content-start/page-end}.page-rows{grid-auto-rows:auto}.header{grid-column:screen-start/screen-end;grid-row:page-top/contents-top}#quarto-content{padding:0;grid-column:screen-start/screen-end;grid-row:contents-top/contents-bottom}body.floating .sidebar.sidebar-navigation{grid-column:page-start/body-start;grid-row:content-top/page-bottom}body.docked .sidebar.sidebar-navigation{grid-column:screen-start/body-start;grid-row:content-top/page-bottom}.sidebar.toc-left{grid-column:page-start/body-start;grid-row:content-top/page-bottom}.sidebar.margin-sidebar{grid-column:body-end/page-end;grid-row:content-top/page-bottom}.page-columns .content{grid-column:body-content-start/body-content-end;grid-row:content-top/content-bottom;align-content:flex-start}.page-columns .page-navigation{grid-column:body-content-start/body-content-end;grid-row:content-bottom/page-bottom}.page-columns .footer{grid-column:screen-start/screen-end;grid-row:contents-bottom/page-bottom}.page-columns .column-body{grid-column:body-content-start/body-content-end}.page-columns .column-body-fullbleed{grid-column:body-start/body-end}.page-columns .column-body-outset{grid-column:body-start-outset/body-end-outset;z-index:998;opacity:.999}.page-columns .column-body-outset table{background:#fff}.page-columns .column-body-outset-left{grid-column:body-start-outset/body-content-end;z-index:998;opacity:.999}.page-columns .column-body-outset-left table{background:#fff}.page-columns .column-body-outset-right{grid-column:body-content-start/body-end-outset;z-index:998;opacity:.999}.page-columns .column-body-outset-right table{background:#fff}.page-columns .column-page{grid-column:page-start/page-end;z-index:998;opacity:.999}.page-columns .column-page table{background:#fff}.page-columns .column-page-inset{grid-column:page-start-inset/page-end-inset;z-index:998;opacity:.999}.page-columns .column-page-inset table{background:#fff}.page-columns .column-page-inset-left{grid-column:page-start-inset/body-content-end;z-index:998;opacity:.999}.page-columns .column-page-inset-left table{background:#fff}.page-columns .column-page-inset-right{grid-column:body-content-start/page-end-inset;z-index:998;opacity:.999}.page-columns .column-page-inset-right figcaption table{background:#fff}.page-columns .column-page-left{grid-column:page-start/body-content-end;z-index:998;opacity:.999}.page-columns .column-page-left table{background:#fff}.page-columns .column-page-right{grid-column:body-content-start/page-end;z-index:998;opacity:.999}.page-columns .column-page-right figcaption table{background:#fff}#quarto-content.page-columns #quarto-margin-sidebar,#quarto-content.page-columns #quarto-sidebar{z-index:1}@media(max-width: 991.98px){#quarto-content.page-columns #quarto-margin-sidebar.collapse,#quarto-content.page-columns #quarto-sidebar.collapse,#quarto-content.page-columns #quarto-margin-sidebar.collapsing,#quarto-content.page-columns #quarto-sidebar.collapsing{z-index:1055}}#quarto-content.page-columns main.column-page,#quarto-content.page-columns main.column-page-right,#quarto-content.page-columns main.column-page-left{z-index:0}.page-columns .column-screen-inset{grid-column:screen-start-inset/screen-end-inset;z-index:998;opacity:.999}.page-columns .column-screen-inset table{background:#fff}.page-columns .column-screen-inset-left{grid-column:screen-start-inset/body-content-end;z-index:998;opacity:.999}.page-columns .column-screen-inset-left table{background:#fff}.page-columns .column-screen-inset-right{grid-column:body-content-start/screen-end-inset;z-index:998;opacity:.999}.page-columns .column-screen-inset-right table{background:#fff}.page-columns .column-screen{grid-column:screen-start/screen-end;z-index:998;opacity:.999}.page-columns .column-screen table{background:#fff}.page-columns .column-screen-left{grid-column:screen-start/body-content-end;z-index:998;opacity:.999}.page-columns .column-screen-left table{background:#fff}.page-columns .column-screen-right{grid-column:body-content-start/screen-end;z-index:998;opacity:.999}.page-columns .column-screen-right table{background:#fff}.page-columns .column-screen-inset-shaded{grid-column:screen-start/screen-end;padding:1em;background:#e9ecef;z-index:998;opacity:.999;margin-bottom:1em}.zindex-content{z-index:998;opacity:.999}.zindex-modal{z-index:1055;opacity:.999}.zindex-over-content{z-index:999;opacity:.999}img.img-fluid.column-screen,img.img-fluid.column-screen-inset-shaded,img.img-fluid.column-screen-inset,img.img-fluid.column-screen-inset-left,img.img-fluid.column-screen-inset-right,img.img-fluid.column-screen-left,img.img-fluid.column-screen-right{width:100%}@media(min-width: 992px){.margin-caption,div.aside,aside:not(.footnotes):not(.sidebar),.column-margin{grid-column:body-end/page-end !important;z-index:998}.column-sidebar{grid-column:page-start/body-start !important;z-index:998}.column-leftmargin{grid-column:screen-start-inset/body-start !important;z-index:998}.no-row-height{height:1em;overflow:visible}}@media(max-width: 991.98px){.margin-caption,div.aside,aside:not(.footnotes):not(.sidebar),.column-margin{grid-column:body-end/page-end !important;z-index:998}.no-row-height{height:1em;overflow:visible}.page-columns.page-full{overflow:visible}.page-columns.toc-left .margin-caption,.page-columns.toc-left div.aside,.page-columns.toc-left aside:not(.footnotes):not(.sidebar),.page-columns.toc-left .column-margin{grid-column:body-content-start/body-content-end !important;z-index:998;opacity:.999}.page-columns.toc-left .no-row-height{height:initial;overflow:initial}}@media(max-width: 767.98px){.margin-caption,div.aside,aside:not(.footnotes):not(.sidebar),.column-margin{grid-column:body-content-start/body-content-end !important;z-index:998;opacity:.999}.no-row-height{height:initial;overflow:initial}#quarto-margin-sidebar{display:none}#quarto-sidebar-toc-left{display:none}.hidden-sm{display:none}}.panel-grid{display:grid;grid-template-rows:repeat(1, 1fr);grid-template-columns:repeat(24, 1fr);gap:1em}.panel-grid .g-col-1{grid-column:auto/span 1}.panel-grid .g-col-2{grid-column:auto/span 2}.panel-grid .g-col-3{grid-column:auto/span 3}.panel-grid .g-col-4{grid-column:auto/span 4}.panel-grid .g-col-5{grid-column:auto/span 5}.panel-grid .g-col-6{grid-column:auto/span 6}.panel-grid .g-col-7{grid-column:auto/span 7}.panel-grid .g-col-8{grid-column:auto/span 8}.panel-grid .g-col-9{grid-column:auto/span 9}.panel-grid .g-col-10{grid-column:auto/span 10}.panel-grid .g-col-11{grid-column:auto/span 11}.panel-grid .g-col-12{grid-column:auto/span 12}.panel-grid .g-col-13{grid-column:auto/span 13}.panel-grid .g-col-14{grid-column:auto/span 14}.panel-grid .g-col-15{grid-column:auto/span 15}.panel-grid .g-col-16{grid-column:auto/span 16}.panel-grid .g-col-17{grid-column:auto/span 17}.panel-grid .g-col-18{grid-column:auto/span 18}.panel-grid .g-col-19{grid-column:auto/span 19}.panel-grid .g-col-20{grid-column:auto/span 20}.panel-grid .g-col-21{grid-column:auto/span 21}.panel-grid .g-col-22{grid-column:auto/span 22}.panel-grid .g-col-23{grid-column:auto/span 23}.panel-grid .g-col-24{grid-column:auto/span 24}.panel-grid .g-start-1{grid-column-start:1}.panel-grid .g-start-2{grid-column-start:2}.panel-grid .g-start-3{grid-column-start:3}.panel-grid .g-start-4{grid-column-start:4}.panel-grid .g-start-5{grid-column-start:5}.panel-grid .g-start-6{grid-column-start:6}.panel-grid .g-start-7{grid-column-start:7}.panel-grid .g-start-8{grid-column-start:8}.panel-grid .g-start-9{grid-column-start:9}.panel-grid .g-start-10{grid-column-start:10}.panel-grid .g-start-11{grid-column-start:11}.panel-grid .g-start-12{grid-column-start:12}.panel-grid .g-start-13{grid-column-start:13}.panel-grid .g-start-14{grid-column-start:14}.panel-grid .g-start-15{grid-column-start:15}.panel-grid .g-start-16{grid-column-start:16}.panel-grid .g-start-17{grid-column-start:17}.panel-grid .g-start-18{grid-column-start:18}.panel-grid .g-start-19{grid-column-start:19}.panel-grid .g-start-20{grid-column-start:20}.panel-grid .g-start-21{grid-column-start:21}.panel-grid .g-start-22{grid-column-start:22}.panel-grid .g-start-23{grid-column-start:23}@media(min-width: 576px){.panel-grid .g-col-sm-1{grid-column:auto/span 1}.panel-grid .g-col-sm-2{grid-column:auto/span 2}.panel-grid .g-col-sm-3{grid-column:auto/span 3}.panel-grid .g-col-sm-4{grid-column:auto/span 4}.panel-grid .g-col-sm-5{grid-column:auto/span 5}.panel-grid .g-col-sm-6{grid-column:auto/span 6}.panel-grid .g-col-sm-7{grid-column:auto/span 7}.panel-grid .g-col-sm-8{grid-column:auto/span 8}.panel-grid .g-col-sm-9{grid-column:auto/span 9}.panel-grid .g-col-sm-10{grid-column:auto/span 10}.panel-grid .g-col-sm-11{grid-column:auto/span 11}.panel-grid .g-col-sm-12{grid-column:auto/span 12}.panel-grid .g-col-sm-13{grid-column:auto/span 13}.panel-grid .g-col-sm-14{grid-column:auto/span 14}.panel-grid .g-col-sm-15{grid-column:auto/span 15}.panel-grid .g-col-sm-16{grid-column:auto/span 16}.panel-grid .g-col-sm-17{grid-column:auto/span 17}.panel-grid .g-col-sm-18{grid-column:auto/span 18}.panel-grid .g-col-sm-19{grid-column:auto/span 19}.panel-grid .g-col-sm-20{grid-column:auto/span 20}.panel-grid .g-col-sm-21{grid-column:auto/span 21}.panel-grid .g-col-sm-22{grid-column:auto/span 22}.panel-grid .g-col-sm-23{grid-column:auto/span 23}.panel-grid .g-col-sm-24{grid-column:auto/span 24}.panel-grid .g-start-sm-1{grid-column-start:1}.panel-grid .g-start-sm-2{grid-column-start:2}.panel-grid .g-start-sm-3{grid-column-start:3}.panel-grid .g-start-sm-4{grid-column-start:4}.panel-grid .g-start-sm-5{grid-column-start:5}.panel-grid .g-start-sm-6{grid-column-start:6}.panel-grid .g-start-sm-7{grid-column-start:7}.panel-grid .g-start-sm-8{grid-column-start:8}.panel-grid .g-start-sm-9{grid-column-start:9}.panel-grid .g-start-sm-10{grid-column-start:10}.panel-grid .g-start-sm-11{grid-column-start:11}.panel-grid .g-start-sm-12{grid-column-start:12}.panel-grid .g-start-sm-13{grid-column-start:13}.panel-grid .g-start-sm-14{grid-column-start:14}.panel-grid .g-start-sm-15{grid-column-start:15}.panel-grid .g-start-sm-16{grid-column-start:16}.panel-grid .g-start-sm-17{grid-column-start:17}.panel-grid .g-start-sm-18{grid-column-start:18}.panel-grid .g-start-sm-19{grid-column-start:19}.panel-grid .g-start-sm-20{grid-column-start:20}.panel-grid .g-start-sm-21{grid-column-start:21}.panel-grid .g-start-sm-22{grid-column-start:22}.panel-grid .g-start-sm-23{grid-column-start:23}}@media(min-width: 768px){.panel-grid .g-col-md-1{grid-column:auto/span 1}.panel-grid .g-col-md-2{grid-column:auto/span 2}.panel-grid .g-col-md-3{grid-column:auto/span 3}.panel-grid .g-col-md-4{grid-column:auto/span 4}.panel-grid .g-col-md-5{grid-column:auto/span 5}.panel-grid .g-col-md-6{grid-column:auto/span 6}.panel-grid .g-col-md-7{grid-column:auto/span 7}.panel-grid .g-col-md-8{grid-column:auto/span 8}.panel-grid .g-col-md-9{grid-column:auto/span 9}.panel-grid .g-col-md-10{grid-column:auto/span 10}.panel-grid .g-col-md-11{grid-column:auto/span 11}.panel-grid .g-col-md-12{grid-column:auto/span 12}.panel-grid .g-col-md-13{grid-column:auto/span 13}.panel-grid .g-col-md-14{grid-column:auto/span 14}.panel-grid .g-col-md-15{grid-column:auto/span 15}.panel-grid .g-col-md-16{grid-column:auto/span 16}.panel-grid .g-col-md-17{grid-column:auto/span 17}.panel-grid .g-col-md-18{grid-column:auto/span 18}.panel-grid .g-col-md-19{grid-column:auto/span 19}.panel-grid .g-col-md-20{grid-column:auto/span 20}.panel-grid .g-col-md-21{grid-column:auto/span 21}.panel-grid .g-col-md-22{grid-column:auto/span 22}.panel-grid .g-col-md-23{grid-column:auto/span 23}.panel-grid .g-col-md-24{grid-column:auto/span 24}.panel-grid .g-start-md-1{grid-column-start:1}.panel-grid .g-start-md-2{grid-column-start:2}.panel-grid .g-start-md-3{grid-column-start:3}.panel-grid .g-start-md-4{grid-column-start:4}.panel-grid .g-start-md-5{grid-column-start:5}.panel-grid .g-start-md-6{grid-column-start:6}.panel-grid .g-start-md-7{grid-column-start:7}.panel-grid .g-start-md-8{grid-column-start:8}.panel-grid .g-start-md-9{grid-column-start:9}.panel-grid .g-start-md-10{grid-column-start:10}.panel-grid .g-start-md-11{grid-column-start:11}.panel-grid .g-start-md-12{grid-column-start:12}.panel-grid .g-start-md-13{grid-column-start:13}.panel-grid .g-start-md-14{grid-column-start:14}.panel-grid .g-start-md-15{grid-column-start:15}.panel-grid .g-start-md-16{grid-column-start:16}.panel-grid .g-start-md-17{grid-column-start:17}.panel-grid .g-start-md-18{grid-column-start:18}.panel-grid .g-start-md-19{grid-column-start:19}.panel-grid .g-start-md-20{grid-column-start:20}.panel-grid .g-start-md-21{grid-column-start:21}.panel-grid .g-start-md-22{grid-column-start:22}.panel-grid .g-start-md-23{grid-column-start:23}}@media(min-width: 992px){.panel-grid .g-col-lg-1{grid-column:auto/span 1}.panel-grid .g-col-lg-2{grid-column:auto/span 2}.panel-grid .g-col-lg-3{grid-column:auto/span 3}.panel-grid .g-col-lg-4{grid-column:auto/span 4}.panel-grid .g-col-lg-5{grid-column:auto/span 5}.panel-grid .g-col-lg-6{grid-column:auto/span 6}.panel-grid .g-col-lg-7{grid-column:auto/span 7}.panel-grid .g-col-lg-8{grid-column:auto/span 8}.panel-grid .g-col-lg-9{grid-column:auto/span 9}.panel-grid .g-col-lg-10{grid-column:auto/span 10}.panel-grid .g-col-lg-11{grid-column:auto/span 11}.panel-grid .g-col-lg-12{grid-column:auto/span 12}.panel-grid .g-col-lg-13{grid-column:auto/span 13}.panel-grid .g-col-lg-14{grid-column:auto/span 14}.panel-grid .g-col-lg-15{grid-column:auto/span 15}.panel-grid .g-col-lg-16{grid-column:auto/span 16}.panel-grid .g-col-lg-17{grid-column:auto/span 17}.panel-grid .g-col-lg-18{grid-column:auto/span 18}.panel-grid .g-col-lg-19{grid-column:auto/span 19}.panel-grid .g-col-lg-20{grid-column:auto/span 20}.panel-grid .g-col-lg-21{grid-column:auto/span 21}.panel-grid .g-col-lg-22{grid-column:auto/span 22}.panel-grid .g-col-lg-23{grid-column:auto/span 23}.panel-grid .g-col-lg-24{grid-column:auto/span 24}.panel-grid .g-start-lg-1{grid-column-start:1}.panel-grid .g-start-lg-2{grid-column-start:2}.panel-grid .g-start-lg-3{grid-column-start:3}.panel-grid .g-start-lg-4{grid-column-start:4}.panel-grid .g-start-lg-5{grid-column-start:5}.panel-grid .g-start-lg-6{grid-column-start:6}.panel-grid .g-start-lg-7{grid-column-start:7}.panel-grid .g-start-lg-8{grid-column-start:8}.panel-grid .g-start-lg-9{grid-column-start:9}.panel-grid .g-start-lg-10{grid-column-start:10}.panel-grid .g-start-lg-11{grid-column-start:11}.panel-grid .g-start-lg-12{grid-column-start:12}.panel-grid .g-start-lg-13{grid-column-start:13}.panel-grid .g-start-lg-14{grid-column-start:14}.panel-grid .g-start-lg-15{grid-column-start:15}.panel-grid .g-start-lg-16{grid-column-start:16}.panel-grid .g-start-lg-17{grid-column-start:17}.panel-grid .g-start-lg-18{grid-column-start:18}.panel-grid .g-start-lg-19{grid-column-start:19}.panel-grid .g-start-lg-20{grid-column-start:20}.panel-grid .g-start-lg-21{grid-column-start:21}.panel-grid .g-start-lg-22{grid-column-start:22}.panel-grid .g-start-lg-23{grid-column-start:23}}@media(min-width: 1200px){.panel-grid .g-col-xl-1{grid-column:auto/span 1}.panel-grid .g-col-xl-2{grid-column:auto/span 2}.panel-grid .g-col-xl-3{grid-column:auto/span 3}.panel-grid .g-col-xl-4{grid-column:auto/span 4}.panel-grid .g-col-xl-5{grid-column:auto/span 5}.panel-grid .g-col-xl-6{grid-column:auto/span 6}.panel-grid .g-col-xl-7{grid-column:auto/span 7}.panel-grid .g-col-xl-8{grid-column:auto/span 8}.panel-grid .g-col-xl-9{grid-column:auto/span 9}.panel-grid .g-col-xl-10{grid-column:auto/span 10}.panel-grid .g-col-xl-11{grid-column:auto/span 11}.panel-grid .g-col-xl-12{grid-column:auto/span 12}.panel-grid .g-col-xl-13{grid-column:auto/span 13}.panel-grid .g-col-xl-14{grid-column:auto/span 14}.panel-grid .g-col-xl-15{grid-column:auto/span 15}.panel-grid .g-col-xl-16{grid-column:auto/span 16}.panel-grid .g-col-xl-17{grid-column:auto/span 17}.panel-grid .g-col-xl-18{grid-column:auto/span 18}.panel-grid .g-col-xl-19{grid-column:auto/span 19}.panel-grid .g-col-xl-20{grid-column:auto/span 20}.panel-grid .g-col-xl-21{grid-column:auto/span 21}.panel-grid .g-col-xl-22{grid-column:auto/span 22}.panel-grid .g-col-xl-23{grid-column:auto/span 23}.panel-grid .g-col-xl-24{grid-column:auto/span 24}.panel-grid .g-start-xl-1{grid-column-start:1}.panel-grid .g-start-xl-2{grid-column-start:2}.panel-grid .g-start-xl-3{grid-column-start:3}.panel-grid .g-start-xl-4{grid-column-start:4}.panel-grid .g-start-xl-5{grid-column-start:5}.panel-grid .g-start-xl-6{grid-column-start:6}.panel-grid .g-start-xl-7{grid-column-start:7}.panel-grid .g-start-xl-8{grid-column-start:8}.panel-grid .g-start-xl-9{grid-column-start:9}.panel-grid .g-start-xl-10{grid-column-start:10}.panel-grid .g-start-xl-11{grid-column-start:11}.panel-grid .g-start-xl-12{grid-column-start:12}.panel-grid .g-start-xl-13{grid-column-start:13}.panel-grid .g-start-xl-14{grid-column-start:14}.panel-grid .g-start-xl-15{grid-column-start:15}.panel-grid .g-start-xl-16{grid-column-start:16}.panel-grid .g-start-xl-17{grid-column-start:17}.panel-grid .g-start-xl-18{grid-column-start:18}.panel-grid .g-start-xl-19{grid-column-start:19}.panel-grid .g-start-xl-20{grid-column-start:20}.panel-grid .g-start-xl-21{grid-column-start:21}.panel-grid .g-start-xl-22{grid-column-start:22}.panel-grid .g-start-xl-23{grid-column-start:23}}@media(min-width: 1400px){.panel-grid .g-col-xxl-1{grid-column:auto/span 1}.panel-grid .g-col-xxl-2{grid-column:auto/span 2}.panel-grid .g-col-xxl-3{grid-column:auto/span 3}.panel-grid .g-col-xxl-4{grid-column:auto/span 4}.panel-grid .g-col-xxl-5{grid-column:auto/span 5}.panel-grid .g-col-xxl-6{grid-column:auto/span 6}.panel-grid .g-col-xxl-7{grid-column:auto/span 7}.panel-grid .g-col-xxl-8{grid-column:auto/span 8}.panel-grid .g-col-xxl-9{grid-column:auto/span 9}.panel-grid .g-col-xxl-10{grid-column:auto/span 10}.panel-grid .g-col-xxl-11{grid-column:auto/span 11}.panel-grid .g-col-xxl-12{grid-column:auto/span 12}.panel-grid .g-col-xxl-13{grid-column:auto/span 13}.panel-grid .g-col-xxl-14{grid-column:auto/span 14}.panel-grid .g-col-xxl-15{grid-column:auto/span 15}.panel-grid .g-col-xxl-16{grid-column:auto/span 16}.panel-grid .g-col-xxl-17{grid-column:auto/span 17}.panel-grid .g-col-xxl-18{grid-column:auto/span 18}.panel-grid .g-col-xxl-19{grid-column:auto/span 19}.panel-grid .g-col-xxl-20{grid-column:auto/span 20}.panel-grid .g-col-xxl-21{grid-column:auto/span 21}.panel-grid .g-col-xxl-22{grid-column:auto/span 22}.panel-grid .g-col-xxl-23{grid-column:auto/span 23}.panel-grid .g-col-xxl-24{grid-column:auto/span 24}.panel-grid .g-start-xxl-1{grid-column-start:1}.panel-grid .g-start-xxl-2{grid-column-start:2}.panel-grid .g-start-xxl-3{grid-column-start:3}.panel-grid .g-start-xxl-4{grid-column-start:4}.panel-grid .g-start-xxl-5{grid-column-start:5}.panel-grid .g-start-xxl-6{grid-column-start:6}.panel-grid .g-start-xxl-7{grid-column-start:7}.panel-grid .g-start-xxl-8{grid-column-start:8}.panel-grid .g-start-xxl-9{grid-column-start:9}.panel-grid .g-start-xxl-10{grid-column-start:10}.panel-grid .g-start-xxl-11{grid-column-start:11}.panel-grid .g-start-xxl-12{grid-column-start:12}.panel-grid .g-start-xxl-13{grid-column-start:13}.panel-grid .g-start-xxl-14{grid-column-start:14}.panel-grid .g-start-xxl-15{grid-column-start:15}.panel-grid .g-start-xxl-16{grid-column-start:16}.panel-grid .g-start-xxl-17{grid-column-start:17}.panel-grid .g-start-xxl-18{grid-column-start:18}.panel-grid .g-start-xxl-19{grid-column-start:19}.panel-grid .g-start-xxl-20{grid-column-start:20}.panel-grid .g-start-xxl-21{grid-column-start:21}.panel-grid .g-start-xxl-22{grid-column-start:22}.panel-grid .g-start-xxl-23{grid-column-start:23}}main{margin-top:1em;margin-bottom:1em}h1,.h1,h2,.h2{color:inherit;margin-top:2rem;margin-bottom:1rem;font-weight:600}h1.title,.title.h1{margin-top:0}main.content>p:has(+section){margin-bottom:2rem}main.content>section:first-of-type>h2:nth-child(1),main.content>section:first-of-type>.h2:nth-child(1){margin-top:0}h2,.h2{border-bottom:1px solid #dee2e6;padding-bottom:.5rem}h3,.h3{font-weight:600}h3,.h3,h4,.h4{opacity:.9;margin-top:1.5rem}h5,.h5,h6,.h6{opacity:.9}.header-section-number{color:hsl(210,10.3448275862%,47.7450980392%)}.nav-link.active .header-section-number{color:inherit}mark,.mark{padding:0em}.panel-caption,.figure-caption,.subfigure-caption,.table-caption,figcaption,caption{font-size:.9rem;color:hsl(210,10.3448275862%,47.7450980392%)}.quarto-layout-cell[data-ref-parent] caption{color:hsl(210,10.3448275862%,47.7450980392%)}.column-margin figcaption,.margin-caption,div.aside,aside,.column-margin{color:hsl(210,10.3448275862%,47.7450980392%);font-size:.825rem}.panel-caption.margin-caption{text-align:inherit}.column-margin.column-container p{margin-bottom:0}.column-margin.column-container>*:not(.collapse):first-child{padding-bottom:.5em;display:block}.column-margin.column-container>*:not(.collapse):not(:first-child){padding-top:.5em;padding-bottom:.5em;display:block}.column-margin.column-container>*.collapse:not(.show){display:none}@media(min-width: 768px){.column-margin.column-container .callout-margin-content:first-child{margin-top:4.5em}.column-margin.column-container .callout-margin-content-simple:first-child{margin-top:3.5em}}.margin-caption>*{padding-top:.5em;padding-bottom:.5em}@media(max-width: 767.98px){.quarto-layout-row{flex-direction:column}}.nav-tabs .nav-item{margin-top:1px;cursor:pointer}.tab-content{margin-top:0px;border-left:#dee2e6 1px solid;border-right:#dee2e6 1px solid;border-bottom:#dee2e6 1px solid;margin-left:0;padding:1em;margin-bottom:1em}@media(max-width: 767.98px){.layout-sidebar{margin-left:0;margin-right:0}}.panel-sidebar,.panel-sidebar .form-control,.panel-input,.panel-input .form-control,.selectize-dropdown{font-size:.9rem}.panel-sidebar .form-control,.panel-input .form-control{padding-top:.1rem}.tab-pane div.sourceCode{margin-top:0px}.tab-pane>p{padding-top:0}.tab-pane>p:nth-child(1){padding-top:0}.tab-pane>p:last-child{margin-bottom:0}.tab-pane>pre:last-child{margin-bottom:0}.tab-content>.tab-pane:not(.active){display:none !important}div.sourceCode{background-color:rgba(233,236,239,.65);border:1px solid rgba(233,236,239,.65);border-radius:.25rem}pre.sourceCode{background-color:rgba(0,0,0,0)}pre.sourceCode{border:none;font-size:.875em;overflow-y:visible !important;padding:.4em}div.sourceCode{overflow-y:hidden}.callout div.sourceCode{margin-left:initial}.blockquote{font-size:inherit;padding-left:1rem;padding-right:1.5rem;color:hsl(210,10.3448275862%,47.7450980392%)}.blockquote h1:first-child,.blockquote .h1:first-child,.blockquote h2:first-child,.blockquote .h2:first-child,.blockquote h3:first-child,.blockquote .h3:first-child,.blockquote h4:first-child,.blockquote .h4:first-child,.blockquote h5:first-child,.blockquote .h5:first-child{margin-top:0}pre{background-color:initial;padding:initial;border:initial}p code.sourceCode,li code.sourceCode,td code.sourceCode{background-color:rgba(233,236,239,.65)}p pre code:not(.sourceCode),li pre code:not(.sourceCode),pre code:not(.sourceCode){background-color:initial}p code:not(.sourceCode),li code:not(.sourceCode),td code:not(.sourceCode){background-color:rgba(233,236,239,.65);padding:.2em}nav p code:not(.sourceCode),nav li code:not(.sourceCode),nav td code:not(.sourceCode){background-color:rgba(0,0,0,0);padding:0}td code:not(.sourceCode){white-space:pre-wrap}#quarto-embedded-source-code-modal>.modal-dialog{max-width:1000px;padding-left:1.75rem;padding-right:1.75rem}#quarto-embedded-source-code-modal>.modal-dialog>.modal-content>.modal-body{padding:0}#quarto-embedded-source-code-modal>.modal-dialog>.modal-content>.modal-body div.sourceCode{margin:0;padding:.2rem .2rem;border-radius:0px;border:none}#quarto-embedded-source-code-modal>.modal-dialog>.modal-content>.modal-header{padding:.7rem}.code-tools-button{font-size:1rem;padding:.15rem .15rem;margin-left:5px;color:#6c757d;background-color:rgba(0,0,0,0);transition:initial;cursor:pointer}.code-tools-button>.bi::before{display:inline-block;height:1rem;width:1rem;content:"";vertical-align:-0.125em;background-image:url('data:image/svg+xml,');background-repeat:no-repeat;background-size:1rem 1rem}.code-tools-button:hover>.bi::before{background-image:url('data:image/svg+xml,')}#quarto-embedded-source-code-modal .code-copy-button>.bi::before{background-image:url('data:image/svg+xml,')}#quarto-embedded-source-code-modal .code-copy-button-checked>.bi::before{background-image:url('data:image/svg+xml,')}.sidebar{will-change:top;transition:top 200ms linear;position:sticky;overflow-y:auto;padding-top:1.2em;max-height:100vh}.sidebar.toc-left,.sidebar.margin-sidebar{top:0px;padding-top:1em}.sidebar.quarto-banner-title-block-sidebar>*{padding-top:1.65em}figure .quarto-notebook-link{margin-top:.5em}.quarto-notebook-link{font-size:.75em;color:#6c757d;margin-bottom:1em;text-decoration:none;display:block}.quarto-notebook-link:hover{text-decoration:underline;color:#e95420}.quarto-notebook-link::before{display:inline-block;height:.75rem;width:.75rem;margin-bottom:0em;margin-right:.25em;content:"";vertical-align:-0.125em;background-image:url('data:image/svg+xml,');background-repeat:no-repeat;background-size:.75rem .75rem}.toc-actions i.bi,.quarto-code-links i.bi,.quarto-other-links i.bi,.quarto-alternate-notebooks i.bi,.quarto-alternate-formats i.bi{margin-right:.4em;font-size:.8rem}.quarto-other-links-text-target .quarto-code-links i.bi,.quarto-other-links-text-target .quarto-other-links i.bi{margin-right:.2em}.quarto-other-formats-text-target .quarto-alternate-formats i.bi{margin-right:.1em}.toc-actions i.bi.empty,.quarto-code-links i.bi.empty,.quarto-other-links i.bi.empty,.quarto-alternate-notebooks i.bi.empty,.quarto-alternate-formats i.bi.empty{padding-left:1em}.quarto-notebook h2,.quarto-notebook .h2{border-bottom:none}.quarto-notebook .cell-container{display:flex}.quarto-notebook .cell-container .cell{flex-grow:4}.quarto-notebook .cell-container .cell-decorator{padding-top:1.5em;padding-right:1em;text-align:right}.quarto-notebook .cell-container.code-fold .cell-decorator{padding-top:3em}.quarto-notebook .cell-code code{white-space:pre-wrap}.quarto-notebook .cell .cell-output-stderr pre code,.quarto-notebook .cell .cell-output-stdout pre code{white-space:pre-wrap;overflow-wrap:anywhere}.toc-actions,.quarto-alternate-formats,.quarto-other-links,.quarto-code-links,.quarto-alternate-notebooks{padding-left:0em}.sidebar .toc-actions a,.sidebar .quarto-alternate-formats a,.sidebar .quarto-other-links a,.sidebar .quarto-code-links a,.sidebar .quarto-alternate-notebooks a,.sidebar nav[role=doc-toc] a{text-decoration:none}.sidebar .toc-actions a:hover,.sidebar .quarto-other-links a:hover,.sidebar .quarto-code-links a:hover,.sidebar .quarto-alternate-formats a:hover,.sidebar .quarto-alternate-notebooks a:hover{color:#e95420}.sidebar .toc-actions h2,.sidebar .toc-actions .h2,.sidebar .quarto-code-links h2,.sidebar .quarto-code-links .h2,.sidebar .quarto-other-links h2,.sidebar .quarto-other-links .h2,.sidebar .quarto-alternate-notebooks h2,.sidebar .quarto-alternate-notebooks .h2,.sidebar .quarto-alternate-formats h2,.sidebar .quarto-alternate-formats .h2,.sidebar nav[role=doc-toc]>h2,.sidebar nav[role=doc-toc]>.h2{font-weight:500;margin-bottom:.2rem;margin-top:.3rem;font-family:inherit;border-bottom:0;padding-bottom:0;padding-top:0px}.sidebar .toc-actions>h2,.sidebar .toc-actions>.h2,.sidebar .quarto-code-links>h2,.sidebar .quarto-code-links>.h2,.sidebar .quarto-other-links>h2,.sidebar .quarto-other-links>.h2,.sidebar .quarto-alternate-notebooks>h2,.sidebar .quarto-alternate-notebooks>.h2,.sidebar .quarto-alternate-formats>h2,.sidebar .quarto-alternate-formats>.h2{font-size:.8rem}.sidebar nav[role=doc-toc]>h2,.sidebar nav[role=doc-toc]>.h2{font-size:.875rem}.sidebar nav[role=doc-toc]>ul a{border-left:1px solid #e9ecef;padding-left:.6rem}.sidebar .toc-actions h2>ul a,.sidebar .toc-actions .h2>ul a,.sidebar .quarto-code-links h2>ul a,.sidebar .quarto-code-links .h2>ul a,.sidebar .quarto-other-links h2>ul a,.sidebar .quarto-other-links .h2>ul a,.sidebar .quarto-alternate-notebooks h2>ul a,.sidebar .quarto-alternate-notebooks .h2>ul a,.sidebar .quarto-alternate-formats h2>ul a,.sidebar .quarto-alternate-formats .h2>ul a{border-left:none;padding-left:.6rem}.sidebar .toc-actions ul a:empty,.sidebar .quarto-code-links ul a:empty,.sidebar .quarto-other-links ul a:empty,.sidebar .quarto-alternate-notebooks ul a:empty,.sidebar .quarto-alternate-formats ul a:empty,.sidebar nav[role=doc-toc]>ul a:empty{display:none}.sidebar .toc-actions ul,.sidebar .quarto-code-links ul,.sidebar .quarto-other-links ul,.sidebar .quarto-alternate-notebooks ul,.sidebar .quarto-alternate-formats ul{padding-left:0;list-style:none}.sidebar nav[role=doc-toc] ul{list-style:none;padding-left:0;list-style:none}.sidebar nav[role=doc-toc]>ul{margin-left:.45em}.quarto-margin-sidebar nav[role=doc-toc]{padding-left:.5em}.sidebar .toc-actions>ul,.sidebar .quarto-code-links>ul,.sidebar .quarto-other-links>ul,.sidebar .quarto-alternate-notebooks>ul,.sidebar .quarto-alternate-formats>ul{font-size:.8rem}.sidebar nav[role=doc-toc]>ul{font-size:.875rem}.sidebar .toc-actions ul li a,.sidebar .quarto-code-links ul li a,.sidebar .quarto-other-links ul li a,.sidebar .quarto-alternate-notebooks ul li a,.sidebar .quarto-alternate-formats ul li a,.sidebar nav[role=doc-toc]>ul li a{line-height:1.1rem;padding-bottom:.2rem;padding-top:.2rem;color:inherit}.sidebar nav[role=doc-toc] ul>li>ul>li>a{padding-left:1.2em}.sidebar nav[role=doc-toc] ul>li>ul>li>ul>li>a{padding-left:2.4em}.sidebar nav[role=doc-toc] ul>li>ul>li>ul>li>ul>li>a{padding-left:3.6em}.sidebar nav[role=doc-toc] ul>li>ul>li>ul>li>ul>li>ul>li>a{padding-left:4.8em}.sidebar nav[role=doc-toc] ul>li>ul>li>ul>li>ul>li>ul>li>ul>li>a{padding-left:6em}.sidebar nav[role=doc-toc] ul>li>a.active,.sidebar nav[role=doc-toc] ul>li>ul>li>a.active{border-left:1px solid #e95420;color:#e95420 !important}.sidebar nav[role=doc-toc] ul>li>a:hover,.sidebar nav[role=doc-toc] ul>li>ul>li>a:hover{color:#e95420 !important}kbd,.kbd{color:#343a40;background-color:#f8f9fa;border:1px solid;border-radius:5px;border-color:#dee2e6}.quarto-appendix-contents div.hanging-indent{margin-left:0em}.quarto-appendix-contents div.hanging-indent div.csl-entry{margin-left:1em;text-indent:-1em}.citation a,.footnote-ref{text-decoration:none}.footnotes ol{padding-left:1em}.tippy-content>*{margin-bottom:.7em}.tippy-content>*:last-child{margin-bottom:0}.callout{margin-top:1.25rem;margin-bottom:1.25rem;border-radius:.25rem;overflow-wrap:break-word}.callout .callout-title-container{overflow-wrap:anywhere}.callout.callout-style-simple{padding:.4em .7em;border-left:5px solid;border-right:1px solid #dee2e6;border-top:1px solid #dee2e6;border-bottom:1px solid #dee2e6}.callout.callout-style-default{border-left:5px solid;border-right:1px solid #dee2e6;border-top:1px solid #dee2e6;border-bottom:1px solid #dee2e6}.callout .callout-body-container{flex-grow:1}.callout.callout-style-simple .callout-body{font-size:.9rem;font-weight:400;margin-bottom:-0.4em;margin-top:.5em}.callout.callout-style-default .callout-body{font-size:.9rem;font-weight:400}.callout:not(.no-icon).callout-titled.callout-style-simple .callout-body{padding-left:1.6em}.callout.callout-titled>.callout-header{padding-top:.2em;margin-bottom:-0.2em}.callout.callout-empty-content>.callout-header{margin-bottom:0em;border-bottom-right-radius:calc(0.25rem + -1px)}.callout>.callout-header.collapsed{border-bottom-right-radius:calc(0.25rem + -1px)}.callout.callout-style-simple>div.callout-header{border-bottom:none;font-size:.9rem;font-weight:600;opacity:75%}.callout.callout-style-default>div.callout-header{border-bottom:none;font-weight:600;opacity:85%;font-size:.9rem;padding-left:.5em;padding-right:.5em;border-top-right-radius:calc(0.25rem + -1px)}.callout.callout-style-default .callout-body{padding-left:.5em;padding-right:.5em}.callout.callout-style-default .callout-body>:first-child{padding-top:.5rem;margin-top:0}.callout>div.callout-header[data-bs-toggle=collapse]{cursor:pointer}.callout.callout-style-default .callout-header[aria-expanded=false],.callout.callout-style-default .callout-header[aria-expanded=true]{padding-top:0px;margin-bottom:0px;align-items:center}.callout.callout-titled .callout-body>:last-child:not(.sourceCode),.callout.callout-titled .callout-body>div>:last-child:not(.sourceCode){padding-bottom:.5rem;margin-bottom:0}.callout:not(.callout-titled) .callout-body>:first-child,.callout:not(.callout-titled) .callout-body>div>:first-child{margin-top:.25rem}.callout:not(.callout-titled) .callout-body>:last-child,.callout:not(.callout-titled) .callout-body>div>:last-child{margin-bottom:.2rem}.callout.callout-style-simple .callout-icon::before,.callout.callout-style-simple .callout-toggle::before{height:1rem;width:1rem;display:inline-block;content:"";background-repeat:no-repeat;background-size:1rem 1rem}.callout.callout-style-default .callout-icon::before,.callout.callout-style-default .callout-toggle::before{height:.9rem;width:.9rem;display:inline-block;content:"";background-repeat:no-repeat;background-size:.9rem .9rem}.callout.callout-style-default .callout-toggle::before{margin-top:5px}.callout .callout-btn-toggle .callout-toggle::before{transition:transform .2s linear}.callout .callout-header[aria-expanded=false] .callout-toggle::before{transform:rotate(-90deg)}.callout .callout-header[aria-expanded=true] .callout-toggle::before{transform:none}.callout.callout-style-simple:not(.no-icon) div.callout-icon-container{padding-top:.2em;padding-right:.55em}.callout.callout-style-default:not(.no-icon) div.callout-icon-container{padding-top:.1em;padding-right:.35em}.callout.callout-style-default:not(.no-icon) div.callout-title-container{margin-top:-1px}.callout.callout-style-default.callout-caution:not(.no-icon) div.callout-icon-container{padding-top:.3em;padding-right:.35em}.callout>.callout-body>.callout-icon-container>.no-icon,.callout>.callout-header>.callout-icon-container>.no-icon{display:none}div.callout.callout{border-left-color:#6c757d}div.callout.callout-style-default>.callout-header{background-color:#6c757d}div.callout-note.callout{border-left-color:#007bff}div.callout-note.callout-style-default>.callout-header{background-color:rgb(229.5,241.8,255)}div.callout-note:not(.callout-titled) .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-note.callout-titled .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-note .callout-toggle::before{background-image:url('data:image/svg+xml,')}div.callout-tip.callout{border-left-color:#38b44a}div.callout-tip.callout-style-default>.callout-header{background-color:rgb(235.1,247.5,236.9)}div.callout-tip:not(.callout-titled) .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-tip.callout-titled .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-tip .callout-toggle::before{background-image:url('data:image/svg+xml,')}div.callout-warning.callout{border-left-color:#efb73e}div.callout-warning.callout-style-default>.callout-header{background-color:rgb(253.4,247.8,235.7)}div.callout-warning:not(.callout-titled) .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-warning.callout-titled .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-warning .callout-toggle::before{background-image:url('data:image/svg+xml,')}div.callout-caution.callout{border-left-color:#e95420}div.callout-caution.callout-style-default>.callout-header{background-color:rgb(252.8,237.9,232.7)}div.callout-caution:not(.callout-titled) .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-caution.callout-titled .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-caution .callout-toggle::before{background-image:url('data:image/svg+xml,')}div.callout-important.callout{border-left-color:#df382c}div.callout-important.callout-style-default>.callout-header{background-color:rgb(251.8,235.1,233.9)}div.callout-important:not(.callout-titled) .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-important.callout-titled .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-important .callout-toggle::before{background-image:url('data:image/svg+xml,')}.quarto-toggle-container{display:flex;align-items:center}.quarto-reader-toggle .bi::before,.quarto-color-scheme-toggle .bi::before{display:inline-block;height:1rem;width:1rem;content:"";background-repeat:no-repeat;background-size:1rem 1rem}.sidebar-navigation{padding-left:20px}.navbar{background-color:#e95420;color:hsla(0,0%,100%,.8)}.navbar .quarto-color-scheme-toggle:not(.alternate) .bi::before{background-image:url('data:image/svg+xml,')}.navbar .quarto-color-scheme-toggle.alternate .bi::before{background-image:url('data:image/svg+xml,')}.sidebar-navigation .quarto-color-scheme-toggle:not(.alternate) .bi::before{background-image:url('data:image/svg+xml,')}.sidebar-navigation .quarto-color-scheme-toggle.alternate .bi::before{background-image:url('data:image/svg+xml,')}.quarto-sidebar-toggle{border-color:#dee2e6;border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem;border-style:solid;border-width:1px;overflow:hidden;border-top-width:0px;padding-top:0px !important}.quarto-sidebar-toggle-title{cursor:pointer;padding-bottom:2px;margin-left:.25em;text-align:center;font-weight:400;font-size:.775em}#quarto-content .quarto-sidebar-toggle{background:hsl(0,0%,98%)}#quarto-content .quarto-sidebar-toggle-title{color:#343a40}.quarto-sidebar-toggle-icon{color:#dee2e6;margin-right:.5em;float:right;transition:transform .2s ease}.quarto-sidebar-toggle-icon::before{padding-top:5px}.quarto-sidebar-toggle.expanded .quarto-sidebar-toggle-icon{transform:rotate(-180deg)}.quarto-sidebar-toggle.expanded .quarto-sidebar-toggle-title{border-bottom:solid #dee2e6 1px}.quarto-sidebar-toggle-contents{background-color:#fff;padding-right:10px;padding-left:10px;margin-top:0px !important;transition:max-height .5s ease}.quarto-sidebar-toggle.expanded .quarto-sidebar-toggle-contents{padding-top:1em;padding-bottom:10px}@media(max-width: 767.98px){.sidebar-menu-container{padding-bottom:5em}}.quarto-sidebar-toggle:not(.expanded) .quarto-sidebar-toggle-contents{padding-top:0px !important;padding-bottom:0px}nav[role=doc-toc]{z-index:1020}#quarto-sidebar>*,nav[role=doc-toc]>*{transition:opacity .1s ease,border .1s ease}#quarto-sidebar.slow>*,nav[role=doc-toc].slow>*{transition:opacity .4s ease,border .4s ease}.quarto-color-scheme-toggle:not(.alternate).top-right .bi::before{background-image:url('data:image/svg+xml,')}.quarto-color-scheme-toggle.alternate.top-right .bi::before{background-image:url('data:image/svg+xml,')}#quarto-appendix.default{border-top:1px solid #dee2e6}#quarto-appendix.default{background-color:#fff;padding-top:1.5em;margin-top:2em;z-index:998}#quarto-appendix.default .quarto-appendix-heading{margin-top:0;line-height:1.4em;font-weight:600;opacity:.9;border-bottom:none;margin-bottom:0}#quarto-appendix.default .footnotes ol,#quarto-appendix.default .footnotes ol li>p:last-of-type,#quarto-appendix.default .quarto-appendix-contents>p:last-of-type{margin-bottom:0}#quarto-appendix.default .footnotes ol{margin-left:.5em}#quarto-appendix.default .quarto-appendix-secondary-label{margin-bottom:.4em}#quarto-appendix.default .quarto-appendix-bibtex{font-size:.7em;padding:1em;border:solid 1px #dee2e6;margin-bottom:1em}#quarto-appendix.default .quarto-appendix-bibtex code.sourceCode{white-space:pre-wrap}#quarto-appendix.default .quarto-appendix-citeas{font-size:.9em;padding:1em;border:solid 1px #dee2e6;margin-bottom:1em}#quarto-appendix.default .quarto-appendix-heading{font-size:1em !important}#quarto-appendix.default *[role=doc-endnotes]>ol,#quarto-appendix.default .quarto-appendix-contents>*:not(h2):not(.h2){font-size:.9em}#quarto-appendix.default section{padding-bottom:1.5em}#quarto-appendix.default section *[role=doc-endnotes],#quarto-appendix.default section>*:not(a){opacity:.9;word-wrap:break-word}.btn.btn-quarto,div.cell-output-display .btn-quarto{--bs-btn-color: rgb(38.06, 39.82, 41.58);--bs-btn-bg: #adb5bd;--bs-btn-border-color: #adb5bd;--bs-btn-hover-color: rgb(38.06, 39.82, 41.58);--bs-btn-hover-bg: rgb(185.3, 192.1, 198.9);--bs-btn-hover-border-color: rgb(181.2, 188.4, 195.6);--bs-btn-focus-shadow-rgb: 153, 160, 167;--bs-btn-active-color: #000;--bs-btn-active-bg: rgb(189.4, 195.8, 202.2);--bs-btn-active-border-color: rgb(181.2, 188.4, 195.6);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #adb5bd;--bs-btn-disabled-border-color: #adb5bd}nav.quarto-secondary-nav.color-navbar{background-color:#e95420;color:hsla(0,0%,100%,.8)}nav.quarto-secondary-nav.color-navbar h1,nav.quarto-secondary-nav.color-navbar .h1,nav.quarto-secondary-nav.color-navbar .quarto-btn-toggle{color:hsla(0,0%,100%,.8)}@media(max-width: 991.98px){body.nav-sidebar .quarto-title-banner{margin-bottom:0;padding-bottom:1em}body.nav-sidebar #title-block-header{margin-block-end:0}}p.subtitle{margin-top:.25em;margin-bottom:.5em}code a:any-link{color:inherit;text-decoration-color:#6c757d}/*! light */div.observablehq table thead tr th{background-color:var(--bs-body-bg)}input,button,select,optgroup,textarea{background-color:var(--bs-body-bg)}.code-annotated .code-copy-button{margin-right:1.25em;margin-top:0;padding-bottom:0;padding-top:3px}.code-annotation-gutter-bg{background-color:#fff}.code-annotation-gutter{background-color:rgba(233,236,239,.65)}.code-annotation-gutter,.code-annotation-gutter-bg{height:100%;width:calc(20px + .5em);position:absolute;top:0;right:0}dl.code-annotation-container-grid dt{margin-right:1em;margin-top:.25rem}dl.code-annotation-container-grid dt{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;color:rgb(74.8620689655,83.5,92.1379310345);border:solid rgb(74.8620689655,83.5,92.1379310345) 1px;border-radius:50%;height:22px;width:22px;line-height:22px;font-size:11px;text-align:center;vertical-align:middle;text-decoration:none}dl.code-annotation-container-grid dt[data-target-cell]{cursor:pointer}dl.code-annotation-container-grid dt[data-target-cell].code-annotation-active{color:#fff;border:solid #aaa 1px;background-color:#aaa}pre.code-annotation-code{padding-top:0;padding-bottom:0}pre.code-annotation-code code{z-index:3}#code-annotation-line-highlight-gutter{width:100%;border-top:solid rgba(170,170,170,.2666666667) 1px;border-bottom:solid rgba(170,170,170,.2666666667) 1px;z-index:2;background-color:rgba(170,170,170,.1333333333)}#code-annotation-line-highlight{margin-left:-4em;width:calc(100% + 4em);border-top:solid rgba(170,170,170,.2666666667) 1px;border-bottom:solid rgba(170,170,170,.2666666667) 1px;z-index:2;background-color:rgba(170,170,170,.1333333333)}code.sourceCode .code-annotation-anchor.code-annotation-active{background-color:var(--quarto-hl-normal-color, #aaaaaa);border:solid var(--quarto-hl-normal-color, #aaaaaa) 1px;color:#e9ecef;font-weight:bolder}code.sourceCode .code-annotation-anchor{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;color:var(--quarto-hl-co-color);border:solid var(--quarto-hl-co-color) 1px;border-radius:50%;height:18px;width:18px;font-size:9px;margin-top:2px}code.sourceCode button.code-annotation-anchor{padding:2px;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none}code.sourceCode a.code-annotation-anchor{line-height:18px;text-align:center;vertical-align:middle;cursor:default;text-decoration:none}@media print{.page-columns .column-screen-inset{grid-column:page-start-inset/page-end-inset;z-index:998;opacity:.999}.page-columns .column-screen-inset table{background:#fff}.page-columns .column-screen-inset-left{grid-column:page-start-inset/body-content-end;z-index:998;opacity:.999}.page-columns .column-screen-inset-left table{background:#fff}.page-columns .column-screen-inset-right{grid-column:body-content-start/page-end-inset;z-index:998;opacity:.999}.page-columns .column-screen-inset-right table{background:#fff}.page-columns .column-screen{grid-column:page-start/page-end;z-index:998;opacity:.999}.page-columns .column-screen table{background:#fff}.page-columns .column-screen-left{grid-column:page-start/body-content-end;z-index:998;opacity:.999}.page-columns .column-screen-left table{background:#fff}.page-columns .column-screen-right{grid-column:body-content-start/page-end;z-index:998;opacity:.999}.page-columns .column-screen-right table{background:#fff}.page-columns .column-screen-inset-shaded{grid-column:page-start-inset/page-end-inset;padding:1em;background:#e9ecef;z-index:998;opacity:.999;margin-bottom:1em}}.quarto-video{margin-bottom:1em}.table{border-top:1px solid rgb(214.4,215.6,216.8);border-bottom:1px solid rgb(214.4,215.6,216.8)}.table>thead{border-top-width:0;border-bottom:1px solid rgb(153.5,156.5,159.5)}.table a{word-break:break-word}.table>:not(caption)>*>*{background-color:unset;color:unset}#quarto-document-content .crosstalk-input .checkbox input[type=checkbox],#quarto-document-content .crosstalk-input .checkbox-inline input[type=checkbox]{position:unset;margin-top:unset;margin-left:unset}#quarto-document-content .row{margin-left:unset;margin-right:unset}.quarto-xref{white-space:nowrap}#quarto-draft-alert{margin-top:0px;margin-bottom:0px;padding:.3em;text-align:center;font-size:.9em}#quarto-draft-alert i{margin-right:.3em}#quarto-back-to-top{z-index:1000}pre{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:0.875em;font-weight:400}pre code{font-family:inherit;font-size:inherit;font-weight:inherit}code{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:0.875em;font-weight:400}a{background-color:rgba(0,0,0,0);font-weight:400;text-decoration:underline}.screen-reader-only{position:absolute;clip:rect(0 0 0 0);border:0;height:1px;margin:-1px;overflow:hidden;padding:0;width:1px}a.external:after{content:"";background-image:url('data:image/svg+xml,');background-size:contain;background-repeat:no-repeat;background-position:center center;margin-left:.2em;padding-right:.75em}div.sourceCode code a.external:after{content:none}a.external:after:hover{cursor:pointer}.quarto-ext-icon{display:inline-block;font-size:.75em;padding-left:.3em}.code-with-filename .code-with-filename-file{margin-bottom:0;padding-bottom:2px;padding-top:2px;padding-left:.7em;border:var(--quarto-border-width) solid var(--quarto-border-color);border-radius:var(--quarto-border-radius);border-bottom:0;border-bottom-left-radius:0%;border-bottom-right-radius:0%}.code-with-filename div.sourceCode,.reveal .code-with-filename div.sourceCode{margin-top:0;border-top-left-radius:0%;border-top-right-radius:0%}.code-with-filename .code-with-filename-file pre{margin-bottom:0}.code-with-filename .code-with-filename-file{background-color:rgba(219,219,219,.8)}.quarto-dark .code-with-filename .code-with-filename-file{background-color:#555}.code-with-filename .code-with-filename-file strong{font-weight:400}.quarto-title-banner{margin-bottom:1em;color:hsla(0,0%,100%,.8);background:#e95420}.quarto-title-banner a{color:hsla(0,0%,100%,.8)}.quarto-title-banner h1,.quarto-title-banner .h1,.quarto-title-banner h2,.quarto-title-banner .h2{color:hsla(0,0%,100%,.8)}.quarto-title-banner .code-tools-button{color:hsla(0,0%,80%,.8)}.quarto-title-banner .code-tools-button:hover{color:hsla(0,0%,100%,.8)}.quarto-title-banner .code-tools-button>.bi::before{background-image:url('data:image/svg+xml,')}.quarto-title-banner .code-tools-button:hover>.bi::before{background-image:url('data:image/svg+xml,')}.quarto-title-banner .quarto-title .title{font-weight:600}.quarto-title-banner .quarto-categories{margin-top:.75em}@media(min-width: 992px){.quarto-title-banner{padding-top:2.5em;padding-bottom:2.5em}}@media(max-width: 991.98px){.quarto-title-banner{padding-top:1em;padding-bottom:1em}}@media(max-width: 767.98px){body.hypothesis-enabled #title-block-header>*{padding-right:20px}}main.quarto-banner-title-block>section:first-child>h2,main.quarto-banner-title-block>section:first-child>.h2,main.quarto-banner-title-block>section:first-child>h3,main.quarto-banner-title-block>section:first-child>.h3,main.quarto-banner-title-block>section:first-child>h4,main.quarto-banner-title-block>section:first-child>.h4{margin-top:0}.quarto-title .quarto-categories{display:flex;flex-wrap:wrap;row-gap:.5em;column-gap:.4em;padding-bottom:.5em;margin-top:.75em}.quarto-title .quarto-categories .quarto-category{padding:.25em .75em;font-size:.65em;text-transform:uppercase;border:solid 1px;border-radius:.25rem;opacity:.6}.quarto-title .quarto-categories .quarto-category a{color:inherit}.quarto-title-meta-container{display:grid;grid-template-columns:1fr auto}.quarto-title-meta-column-end{display:flex;flex-direction:column;padding-left:1em}.quarto-title-meta-column-end a .bi{margin-right:.3em}#title-block-header.quarto-title-block.default .quarto-title-meta{display:grid;grid-template-columns:repeat(2, 1fr);grid-column-gap:1em}#title-block-header.quarto-title-block.default .quarto-title .title{margin-bottom:0}#title-block-header.quarto-title-block.default .quarto-title-author-orcid img{margin-top:-0.2em;height:.8em;width:.8em}#title-block-header.quarto-title-block.default .quarto-title-author-email{opacity:.7}#title-block-header.quarto-title-block.default .quarto-description p:last-of-type{margin-bottom:0}#title-block-header.quarto-title-block.default .quarto-title-meta-contents p,#title-block-header.quarto-title-block.default .quarto-title-authors p,#title-block-header.quarto-title-block.default .quarto-title-affiliations p{margin-bottom:.1em}#title-block-header.quarto-title-block.default .quarto-title-meta-heading{text-transform:uppercase;margin-top:1em;font-size:.8em;opacity:.8;font-weight:400}#title-block-header.quarto-title-block.default .quarto-title-meta-contents{font-size:.9em}#title-block-header.quarto-title-block.default .quarto-title-meta-contents p.affiliation:last-of-type{margin-bottom:.1em}#title-block-header.quarto-title-block.default p.affiliation{margin-bottom:.1em}#title-block-header.quarto-title-block.default .keywords,#title-block-header.quarto-title-block.default .description,#title-block-header.quarto-title-block.default .abstract{margin-top:0}#title-block-header.quarto-title-block.default .keywords>p,#title-block-header.quarto-title-block.default .description>p,#title-block-header.quarto-title-block.default .abstract>p{font-size:.9em}#title-block-header.quarto-title-block.default .keywords>p:last-of-type,#title-block-header.quarto-title-block.default .description>p:last-of-type,#title-block-header.quarto-title-block.default .abstract>p:last-of-type{margin-bottom:0}#title-block-header.quarto-title-block.default .keywords .block-title,#title-block-header.quarto-title-block.default .description .block-title,#title-block-header.quarto-title-block.default .abstract .block-title{margin-top:1em;text-transform:uppercase;font-size:.8em;opacity:.8;font-weight:400}#title-block-header.quarto-title-block.default .quarto-title-meta-author{display:grid;grid-template-columns:minmax(max-content, 1fr) 1fr;grid-column-gap:1em}.quarto-title-tools-only{display:flex;justify-content:right}.badge.bg-light{color:#343a40}:root{--quarto-scss-export-gray-300: #dee2e6;--quarto-scss-export-gray-500: #adb5bd;--quarto-scss-export-gray-600: #6c757d;--quarto-scss-export-gray-800: #343a40;--quarto-scss-export-card-cap-bg: rgba(52, 58, 64, 0.25);--quarto-scss-export-border-color: #dee2e6;--quarto-scss-export-text-muted: #6c757d;--quarto-scss-export-white: #fff;--quarto-scss-export-gray-100: #f8f9fa;--quarto-scss-export-gray-200: #e9ecef;--quarto-scss-export-gray-400: #ced4da;--quarto-scss-export-gray-700: #495057;--quarto-scss-export-gray-900: #212529;--quarto-scss-export-black: #000;--quarto-scss-export-blue: #007bff;--quarto-scss-export-indigo: #6610f2;--quarto-scss-export-purple: #772953;--quarto-scss-export-pink: #e83e8c;--quarto-scss-export-red: #df382c;--quarto-scss-export-orange: #e95420;--quarto-scss-export-yellow: #efb73e;--quarto-scss-export-green: #38b44a;--quarto-scss-export-teal: #20c997;--quarto-scss-export-cyan: #17a2b8;--quarto-scss-export-primary: #e95420;--quarto-scss-export-secondary: #adb5bd;--quarto-scss-export-success: #38b44a;--quarto-scss-export-info: #17a2b8;--quarto-scss-export-warning: #efb73e;--quarto-scss-export-danger: #df382c;--quarto-scss-export-light: #e9ecef;--quarto-scss-export-dark: #772953;--quarto-scss-export-body-color: #343a40;--quarto-scss-export-table-dark-bg: #772953;--quarto-scss-export-table-dark-border-color: rgb(100.034375, 34.465625, 69.771875);--quarto-scss-export-title-banner-color: ;--quarto-scss-export-title-banner-bg: ;--quarto-scss-export-btn-code-copy-color: #5E5E5E;--quarto-scss-export-btn-code-copy-color-active: #4758AB;--quarto-scss-export-sidebar-bg: #fff;--quarto-scss-export-link-color: #e95420;--quarto-scss-export-link-color-bg: transparent;--quarto-scss-export-code-color: #7d12ba;--quarto-scss-export-code-bg: #f8f9fa;--quarto-scss-export-toc-color: #e95420;--quarto-scss-export-toc-active-border: #e95420;--quarto-scss-export-toc-inactive-border: #e9ecef;--quarto-scss-export-navbar-default: #e95420;--quarto-scss-export-navbar-hl-override: false;--quarto-scss-export-navbar-bg: #e95420;--quarto-scss-export-btn-bg: #adb5bd;--quarto-scss-export-btn-fg: rgb(38.06, 39.82, 41.58);--quarto-scss-export-body-contrast-bg: #fff;--quarto-scss-export-body-contrast-color: #343a40;--quarto-scss-export-navbar-fg: rgba(255, 255, 255, 0.8);--quarto-scss-export-navbar-hl: #fff;--quarto-scss-export-navbar-brand: rgba(255, 255, 255, 0.8);--quarto-scss-export-navbar-brand-hl: #fff;--quarto-scss-export-navbar-toggler-border-color: rgba(255, 255, 255, 0);--quarto-scss-export-navbar-hover-color: rgba(255, 255, 255, 0.8);--quarto-scss-export-navbar-disabled-color: rgba(255, 255, 255, 0.75);--quarto-scss-export-sidebar-fg: rgb(89.25, 89.25, 89.25);--quarto-scss-export-title-block-color: #343a40;--quarto-scss-export-title-block-contast-color: #fff;--quarto-scss-export-footer-bg: #fff;--quarto-scss-export-footer-fg: rgb(117.3, 117.3, 117.3);--quarto-scss-export-popover-bg: #fff;--quarto-scss-export-input-bg: #fff;--quarto-scss-export-input-border-color: #dee2e6;--quarto-scss-export-code-annotation-higlight-color: rgba(170, 170, 170, 0.2666666667);--quarto-scss-export-code-annotation-higlight-bg: rgba(170, 170, 170, 0.1333333333);--quarto-scss-export-table-group-separator-color: rgb(153.5, 156.5, 159.5);--quarto-scss-export-table-group-separator-color-lighter: rgb(214.4, 215.6, 216.8);--quarto-scss-export-link-decoration: underline;--quarto-scss-export-table-border-color: #dee2e6;--quarto-scss-export-sidebar-glass-bg: rgba(102, 102, 102, 0.4);--quarto-scss-export-color-contrast-dark: #000;--quarto-scss-export-color-contrast-light: #fff;--quarto-scss-export-blue-100: rgb(204, 228.6, 255);--quarto-scss-export-blue-200: rgb(153, 202.2, 255);--quarto-scss-export-blue-300: rgb(102, 175.8, 255);--quarto-scss-export-blue-400: rgb(51, 149.4, 255);--quarto-scss-export-blue-500: #007bff;--quarto-scss-export-blue-600: rgb(0, 98.4, 204);--quarto-scss-export-blue-700: rgb(0, 73.8, 153);--quarto-scss-export-blue-800: rgb(0, 49.2, 102);--quarto-scss-export-blue-900: rgb(0, 24.6, 51);--quarto-scss-export-indigo-100: rgb(224.4, 207.2, 252.4);--quarto-scss-export-indigo-200: rgb(193.8, 159.4, 249.8);--quarto-scss-export-indigo-300: rgb(163.2, 111.6, 247.2);--quarto-scss-export-indigo-400: rgb(132.6, 63.8, 244.6);--quarto-scss-export-indigo-500: #6610f2;--quarto-scss-export-indigo-600: rgb(81.6, 12.8, 193.6);--quarto-scss-export-indigo-700: rgb(61.2, 9.6, 145.2);--quarto-scss-export-indigo-800: rgb(40.8, 6.4, 96.8);--quarto-scss-export-indigo-900: rgb(20.4, 3.2, 48.4);--quarto-scss-export-purple-100: rgb(227.8, 212.2, 220.6);--quarto-scss-export-purple-200: rgb(200.6, 169.4, 186.2);--quarto-scss-export-purple-300: rgb(173.4, 126.6, 151.8);--quarto-scss-export-purple-400: rgb(146.2, 83.8, 117.4);--quarto-scss-export-purple-500: #772953;--quarto-scss-export-purple-600: rgb(95.2, 32.8, 66.4);--quarto-scss-export-purple-700: rgb(71.4, 24.6, 49.8);--quarto-scss-export-purple-800: rgb(47.6, 16.4, 33.2);--quarto-scss-export-purple-900: rgb(23.8, 8.2, 16.6);--quarto-scss-export-pink-100: rgb(250.4, 216.4, 232);--quarto-scss-export-pink-200: rgb(245.8, 177.8, 209);--quarto-scss-export-pink-300: rgb(241.2, 139.2, 186);--quarto-scss-export-pink-400: rgb(236.6, 100.6, 163);--quarto-scss-export-pink-500: #e83e8c;--quarto-scss-export-pink-600: rgb(185.6, 49.6, 112);--quarto-scss-export-pink-700: rgb(139.2, 37.2, 84);--quarto-scss-export-pink-800: rgb(92.8, 24.8, 56);--quarto-scss-export-pink-900: rgb(46.4, 12.4, 28);--quarto-scss-export-red-100: rgb(248.6, 215.2, 212.8);--quarto-scss-export-red-200: rgb(242.2, 175.4, 170.6);--quarto-scss-export-red-300: rgb(235.8, 135.6, 128.4);--quarto-scss-export-red-400: rgb(229.4, 95.8, 86.2);--quarto-scss-export-red-500: #df382c;--quarto-scss-export-red-600: rgb(178.4, 44.8, 35.2);--quarto-scss-export-red-700: rgb(133.8, 33.6, 26.4);--quarto-scss-export-red-800: rgb(89.2, 22.4, 17.6);--quarto-scss-export-red-900: rgb(44.6, 11.2, 8.8);--quarto-scss-export-orange-100: rgb(250.6, 220.8, 210.4);--quarto-scss-export-orange-200: rgb(246.2, 186.6, 165.8);--quarto-scss-export-orange-300: rgb(241.8, 152.4, 121.2);--quarto-scss-export-orange-400: rgb(237.4, 118.2, 76.6);--quarto-scss-export-orange-500: #e95420;--quarto-scss-export-orange-600: rgb(186.4, 67.2, 25.6);--quarto-scss-export-orange-700: rgb(139.8, 50.4, 19.2);--quarto-scss-export-orange-800: rgb(93.2, 33.6, 12.8);--quarto-scss-export-orange-900: rgb(46.6, 16.8, 6.4);--quarto-scss-export-yellow-100: rgb(251.8, 240.6, 216.4);--quarto-scss-export-yellow-200: rgb(248.6, 226.2, 177.8);--quarto-scss-export-yellow-300: rgb(245.4, 211.8, 139.2);--quarto-scss-export-yellow-400: rgb(242.2, 197.4, 100.6);--quarto-scss-export-yellow-500: #efb73e;--quarto-scss-export-yellow-600: rgb(191.2, 146.4, 49.6);--quarto-scss-export-yellow-700: rgb(143.4, 109.8, 37.2);--quarto-scss-export-yellow-800: rgb(95.6, 73.2, 24.8);--quarto-scss-export-yellow-900: rgb(47.8, 36.6, 12.4);--quarto-scss-export-green-100: rgb(215.2, 240, 218.8);--quarto-scss-export-green-200: rgb(175.4, 225, 182.6);--quarto-scss-export-green-300: rgb(135.6, 210, 146.4);--quarto-scss-export-green-400: rgb(95.8, 195, 110.2);--quarto-scss-export-green-500: #38b44a;--quarto-scss-export-green-600: rgb(44.8, 144, 59.2);--quarto-scss-export-green-700: rgb(33.6, 108, 44.4);--quarto-scss-export-green-800: rgb(22.4, 72, 29.6);--quarto-scss-export-green-900: rgb(11.2, 36, 14.8);--quarto-scss-export-teal-100: rgb(210.4, 244.2, 234.2);--quarto-scss-export-teal-200: rgb(165.8, 233.4, 213.4);--quarto-scss-export-teal-300: rgb(121.2, 222.6, 192.6);--quarto-scss-export-teal-400: rgb(76.6, 211.8, 171.8);--quarto-scss-export-teal-500: #20c997;--quarto-scss-export-teal-600: rgb(25.6, 160.8, 120.8);--quarto-scss-export-teal-700: rgb(19.2, 120.6, 90.6);--quarto-scss-export-teal-800: rgb(12.8, 80.4, 60.4);--quarto-scss-export-teal-900: rgb(6.4, 40.2, 30.2);--quarto-scss-export-cyan-100: rgb(208.6, 236.4, 240.8);--quarto-scss-export-cyan-200: rgb(162.2, 217.8, 226.6);--quarto-scss-export-cyan-300: rgb(115.8, 199.2, 212.4);--quarto-scss-export-cyan-400: rgb(69.4, 180.6, 198.2);--quarto-scss-export-cyan-500: #17a2b8;--quarto-scss-export-cyan-600: rgb(18.4, 129.6, 147.2);--quarto-scss-export-cyan-700: rgb(13.8, 97.2, 110.4);--quarto-scss-export-cyan-800: rgb(9.2, 64.8, 73.6);--quarto-scss-export-cyan-900: rgb(4.6, 32.4, 36.8);--quarto-scss-export-default: #adb5bd;--quarto-scss-export-primary-text-emphasis: rgb(93.2, 33.6, 12.8);--quarto-scss-export-secondary-text-emphasis: rgb(69.2, 72.4, 75.6);--quarto-scss-export-success-text-emphasis: rgb(22.4, 72, 29.6);--quarto-scss-export-info-text-emphasis: rgb(9.2, 64.8, 73.6);--quarto-scss-export-warning-text-emphasis: rgb(95.6, 73.2, 24.8);--quarto-scss-export-danger-text-emphasis: rgb(89.2, 22.4, 17.6);--quarto-scss-export-light-text-emphasis: #495057;--quarto-scss-export-dark-text-emphasis: #495057;--quarto-scss-export-primary-bg-subtle: rgb(250.6, 220.8, 210.4);--quarto-scss-export-secondary-bg-subtle: rgb(238.6, 240.2, 241.8);--quarto-scss-export-success-bg-subtle: rgb(215.2, 240, 218.8);--quarto-scss-export-info-bg-subtle: rgb(208.6, 236.4, 240.8);--quarto-scss-export-warning-bg-subtle: rgb(251.8, 240.6, 216.4);--quarto-scss-export-danger-bg-subtle: rgb(248.6, 215.2, 212.8);--quarto-scss-export-light-bg-subtle: rgb(251.5, 252, 252.5);--quarto-scss-export-dark-bg-subtle: #ced4da;--quarto-scss-export-primary-border-subtle: rgb(246.2, 186.6, 165.8);--quarto-scss-export-secondary-border-subtle: rgb(222.2, 225.4, 228.6);--quarto-scss-export-success-border-subtle: rgb(175.4, 225, 182.6);--quarto-scss-export-info-border-subtle: rgb(162.2, 217.8, 226.6);--quarto-scss-export-warning-border-subtle: rgb(248.6, 226.2, 177.8);--quarto-scss-export-danger-border-subtle: rgb(242.2, 175.4, 170.6);--quarto-scss-export-light-border-subtle: #e9ecef;--quarto-scss-export-dark-border-subtle: #adb5bd;--quarto-scss-export-body-text-align: ;--quarto-scss-export-body-bg: #fff;--quarto-scss-export-body-secondary-color: rgba(52, 58, 64, 0.75);--quarto-scss-export-body-secondary-bg: #e9ecef;--quarto-scss-export-body-tertiary-color: rgba(52, 58, 64, 0.5);--quarto-scss-export-body-tertiary-bg: #f8f9fa;--quarto-scss-export-body-emphasis-color: #000;--quarto-scss-export-link-hover-color: rgb(186.4, 67.2, 25.6);--quarto-scss-export-link-hover-decoration: ;--quarto-scss-export-border-color-translucent: rgba(0, 0, 0, 0.175);--quarto-scss-export-component-active-bg: #e95420;--quarto-scss-export-component-active-color: #fff;--quarto-scss-export-focus-ring-color: rgba(233, 84, 32, 0.25);--quarto-scss-export-headings-font-family: ;--quarto-scss-export-headings-font-style: ;--quarto-scss-export-display-font-family: ;--quarto-scss-export-display-font-style: ;--quarto-scss-export-blockquote-footer-color: #6c757d;--quarto-scss-export-blockquote-border-color: #e9ecef;--quarto-scss-export-hr-bg-color: ;--quarto-scss-export-hr-height: ;--quarto-scss-export-hr-border-color: ;--quarto-scss-export-legend-font-weight: ;--quarto-scss-export-mark-bg: rgb(251.8, 240.6, 216.4);--quarto-scss-export-table-color: #343a40;--quarto-scss-export-table-bg: #fff;--quarto-scss-export-table-accent-bg: transparent;--quarto-scss-export-table-th-font-weight: ;--quarto-scss-export-table-striped-color: #343a40;--quarto-scss-export-table-striped-bg: rgba(0, 0, 0, 0.05);--quarto-scss-export-table-active-color: #343a40;--quarto-scss-export-table-active-bg: rgba(0, 0, 0, 0.1);--quarto-scss-export-table-hover-color: #343a40;--quarto-scss-export-table-hover-bg: rgba(0, 0, 0, 0.075);--quarto-scss-export-table-caption-color: rgba(52, 58, 64, 0.75);--quarto-scss-export-input-btn-font-family: ;--quarto-scss-export-input-btn-focus-color: rgba(233, 84, 32, 0.25);--quarto-scss-export-btn-color: #343a40;--quarto-scss-export-btn-font-family: ;--quarto-scss-export-btn-white-space: ;--quarto-scss-export-btn-link-color: #e95420;--quarto-scss-export-btn-link-hover-color: rgb(186.4, 67.2, 25.6);--quarto-scss-export-btn-link-disabled-color: #6c757d;--quarto-scss-export-form-text-font-style: ;--quarto-scss-export-form-text-font-weight: ;--quarto-scss-export-form-text-color: rgba(52, 58, 64, 0.75);--quarto-scss-export-form-label-font-size: ;--quarto-scss-export-form-label-font-style: ;--quarto-scss-export-form-label-font-weight: ;--quarto-scss-export-form-label-color: ;--quarto-scss-export-input-font-family: ;--quarto-scss-export-input-disabled-color: ;--quarto-scss-export-input-disabled-bg: #e9ecef;--quarto-scss-export-input-disabled-border-color: ;--quarto-scss-export-input-color: #343a40;--quarto-scss-export-input-focus-bg: #fff;--quarto-scss-export-input-focus-border-color: rgb(244, 169.5, 143.5);--quarto-scss-export-input-focus-color: #343a40;--quarto-scss-export-input-placeholder-color: rgba(52, 58, 64, 0.75);--quarto-scss-export-input-plaintext-color: #343a40;--quarto-scss-export-form-check-label-color: ;--quarto-scss-export-form-check-transition: ;--quarto-scss-export-form-check-input-bg: #fff;--quarto-scss-export-form-check-input-focus-border: rgb(244, 169.5, 143.5);--quarto-scss-export-form-check-input-checked-color: #fff;--quarto-scss-export-form-check-input-checked-bg-color: #e95420;--quarto-scss-export-form-check-input-checked-border-color: #e95420;--quarto-scss-export-form-check-input-indeterminate-color: #fff;--quarto-scss-export-form-check-input-indeterminate-bg-color: #e95420;--quarto-scss-export-form-check-input-indeterminate-border-color: #e95420;--quarto-scss-export-form-switch-color: rgba(0, 0, 0, 0.25);--quarto-scss-export-form-switch-focus-color: rgb(244, 169.5, 143.5);--quarto-scss-export-form-switch-checked-color: #fff;--quarto-scss-export-input-group-addon-color: #343a40;--quarto-scss-export-input-group-addon-bg: #f8f9fa;--quarto-scss-export-input-group-addon-border-color: #dee2e6;--quarto-scss-export-form-select-font-family: ;--quarto-scss-export-form-select-color: #343a40;--quarto-scss-export-form-select-bg: #fff;--quarto-scss-export-form-select-disabled-color: ;--quarto-scss-export-form-select-disabled-bg: #e9ecef;--quarto-scss-export-form-select-disabled-border-color: ;--quarto-scss-export-form-select-indicator-color: #343a40;--quarto-scss-export-form-select-border-color: #dee2e6;--quarto-scss-export-form-select-focus-border-color: rgb(244, 169.5, 143.5);--quarto-scss-export-form-range-track-bg: #f8f9fa;--quarto-scss-export-form-range-thumb-bg: #e95420;--quarto-scss-export-form-range-thumb-active-bg: rgb(248.4, 203.7, 188.1);--quarto-scss-export-form-range-thumb-disabled-bg: rgba(52, 58, 64, 0.75);--quarto-scss-export-form-file-button-color: #343a40;--quarto-scss-export-form-file-button-bg: #f8f9fa;--quarto-scss-export-form-file-button-hover-bg: #e9ecef;--quarto-scss-export-form-floating-label-disabled-color: #6c757d;--quarto-scss-export-form-feedback-font-style: ;--quarto-scss-export-form-feedback-valid-color: #38b44a;--quarto-scss-export-form-feedback-invalid-color: #df382c;--quarto-scss-export-form-feedback-icon-valid-color: #38b44a;--quarto-scss-export-form-feedback-icon-invalid-color: #df382c;--quarto-scss-export-form-valid-color: #38b44a;--quarto-scss-export-form-valid-border-color: #38b44a;--quarto-scss-export-form-invalid-color: #df382c;--quarto-scss-export-form-invalid-border-color: #df382c;--quarto-scss-export-nav-link-font-size: ;--quarto-scss-export-nav-link-font-weight: ;--quarto-scss-export-nav-link-color: #e95420;--quarto-scss-export-nav-link-hover-color: rgb(186.4, 67.2, 25.6);--quarto-scss-export-nav-link-disabled-color: rgba(52, 58, 64, 0.75);--quarto-scss-export-nav-tabs-border-color: #dee2e6;--quarto-scss-export-nav-tabs-link-hover-border-color: #e9ecef #e9ecef #dee2e6;--quarto-scss-export-nav-tabs-link-active-color: #000;--quarto-scss-export-nav-tabs-link-active-bg: #fff;--quarto-scss-export-nav-pills-link-active-bg: #e95420;--quarto-scss-export-nav-pills-link-active-color: #fff;--quarto-scss-export-nav-underline-link-active-color: #000;--quarto-scss-export-navbar-padding-x: ;--quarto-scss-export-navbar-light-contrast: #fff;--quarto-scss-export-navbar-dark-contrast: #fff;--quarto-scss-export-navbar-light-icon-color: rgba(255, 255, 255, 0.75);--quarto-scss-export-navbar-dark-icon-color: rgba(255, 255, 255, 0.75);--quarto-scss-export-dropdown-color: #343a40;--quarto-scss-export-dropdown-bg: #fff;--quarto-scss-export-dropdown-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-dropdown-divider-bg: rgba(0, 0, 0, 0.175);--quarto-scss-export-dropdown-link-color: #343a40;--quarto-scss-export-dropdown-link-hover-color: #343a40;--quarto-scss-export-dropdown-link-hover-bg: #f8f9fa;--quarto-scss-export-dropdown-link-active-bg: #e95420;--quarto-scss-export-dropdown-link-active-color: #fff;--quarto-scss-export-dropdown-link-disabled-color: rgba(52, 58, 64, 0.5);--quarto-scss-export-dropdown-header-color: #6c757d;--quarto-scss-export-dropdown-dark-color: #dee2e6;--quarto-scss-export-dropdown-dark-bg: #343a40;--quarto-scss-export-dropdown-dark-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-dropdown-dark-divider-bg: rgba(0, 0, 0, 0.175);--quarto-scss-export-dropdown-dark-box-shadow: ;--quarto-scss-export-dropdown-dark-link-color: #dee2e6;--quarto-scss-export-dropdown-dark-link-hover-color: #fff;--quarto-scss-export-dropdown-dark-link-hover-bg: rgba(255, 255, 255, 0.15);--quarto-scss-export-dropdown-dark-link-active-color: #fff;--quarto-scss-export-dropdown-dark-link-active-bg: #e95420;--quarto-scss-export-dropdown-dark-link-disabled-color: #adb5bd;--quarto-scss-export-dropdown-dark-header-color: #adb5bd;--quarto-scss-export-pagination-color: #e95420;--quarto-scss-export-pagination-bg: #fff;--quarto-scss-export-pagination-border-color: #dee2e6;--quarto-scss-export-pagination-focus-color: rgb(186.4, 67.2, 25.6);--quarto-scss-export-pagination-focus-bg: #e9ecef;--quarto-scss-export-pagination-hover-color: rgb(186.4, 67.2, 25.6);--quarto-scss-export-pagination-hover-bg: #f8f9fa;--quarto-scss-export-pagination-hover-border-color: #dee2e6;--quarto-scss-export-pagination-active-color: #fff;--quarto-scss-export-pagination-active-bg: #e95420;--quarto-scss-export-pagination-active-border-color: #e95420;--quarto-scss-export-pagination-disabled-color: rgba(52, 58, 64, 0.75);--quarto-scss-export-pagination-disabled-bg: #e9ecef;--quarto-scss-export-pagination-disabled-border-color: #dee2e6;--quarto-scss-export-card-title-color: ;--quarto-scss-export-card-subtitle-color: ;--quarto-scss-export-card-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-card-box-shadow: ;--quarto-scss-export-card-cap-color: ;--quarto-scss-export-card-height: ;--quarto-scss-export-card-color: ;--quarto-scss-export-card-bg: #fff;--quarto-scss-export-accordion-color: #343a40;--quarto-scss-export-accordion-bg: #fff;--quarto-scss-export-accordion-border-color: #dee2e6;--quarto-scss-export-accordion-button-color: #343a40;--quarto-scss-export-accordion-button-bg: #fff;--quarto-scss-export-accordion-button-active-bg: rgb(250.6, 220.8, 210.4);--quarto-scss-export-accordion-button-active-color: rgb(93.2, 33.6, 12.8);--quarto-scss-export-accordion-button-focus-border-color: rgb(244, 169.5, 143.5);--quarto-scss-export-accordion-icon-color: #343a40;--quarto-scss-export-accordion-icon-active-color: rgb(93.2, 33.6, 12.8);--quarto-scss-export-tooltip-color: #fff;--quarto-scss-export-tooltip-bg: #000;--quarto-scss-export-tooltip-margin: ;--quarto-scss-export-tooltip-arrow-color: ;--quarto-scss-export-form-feedback-tooltip-line-height: ;--quarto-scss-export-popover-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-popover-header-bg: #e9ecef;--quarto-scss-export-popover-body-color: #343a40;--quarto-scss-export-popover-arrow-color: #fff;--quarto-scss-export-popover-arrow-outer-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-toast-color: ;--quarto-scss-export-toast-background-color: rgba(255, 255, 255, 0.85);--quarto-scss-export-toast-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-toast-header-color: rgba(52, 58, 64, 0.75);--quarto-scss-export-toast-header-background-color: rgba(255, 255, 255, 0.85);--quarto-scss-export-toast-header-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-badge-color: #fff;--quarto-scss-export-modal-content-color: ;--quarto-scss-export-modal-content-bg: #fff;--quarto-scss-export-modal-content-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-modal-backdrop-bg: #000;--quarto-scss-export-modal-header-border-color: #dee2e6;--quarto-scss-export-modal-footer-bg: ;--quarto-scss-export-modal-footer-border-color: #dee2e6;--quarto-scss-export-progress-bg: #e9ecef;--quarto-scss-export-progress-bar-color: #fff;--quarto-scss-export-progress-bar-bg: #e95420;--quarto-scss-export-list-group-color: #343a40;--quarto-scss-export-list-group-bg: #fff;--quarto-scss-export-list-group-border-color: #dee2e6;--quarto-scss-export-list-group-hover-bg: #f8f9fa;--quarto-scss-export-list-group-active-bg: #e95420;--quarto-scss-export-list-group-active-color: #fff;--quarto-scss-export-list-group-active-border-color: #e95420;--quarto-scss-export-list-group-disabled-color: rgba(52, 58, 64, 0.75);--quarto-scss-export-list-group-disabled-bg: #fff;--quarto-scss-export-list-group-action-color: rgba(52, 58, 64, 0.75);--quarto-scss-export-list-group-action-hover-color: #000;--quarto-scss-export-list-group-action-active-color: #343a40;--quarto-scss-export-list-group-action-active-bg: #e9ecef;--quarto-scss-export-thumbnail-bg: #fff;--quarto-scss-export-thumbnail-border-color: #dee2e6;--quarto-scss-export-figure-caption-color: rgba(52, 58, 64, 0.75);--quarto-scss-export-breadcrumb-font-size: ;--quarto-scss-export-breadcrumb-bg: ;--quarto-scss-export-breadcrumb-divider-color: rgba(52, 58, 64, 0.75);--quarto-scss-export-breadcrumb-active-color: rgba(52, 58, 64, 0.75);--quarto-scss-export-breadcrumb-border-radius: ;--quarto-scss-export-carousel-control-color: #fff;--quarto-scss-export-carousel-indicator-active-bg: #fff;--quarto-scss-export-carousel-caption-color: #fff;--quarto-scss-export-carousel-dark-indicator-active-bg: #000;--quarto-scss-export-carousel-dark-caption-color: #000;--quarto-scss-export-btn-close-color: #000;--quarto-scss-export-offcanvas-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-offcanvas-bg-color: #fff;--quarto-scss-export-offcanvas-color: #343a40;--quarto-scss-export-offcanvas-backdrop-bg: #000;--quarto-scss-export-code-color-dark: white;--quarto-scss-export-kbd-color: #fff;--quarto-scss-export-kbd-bg: #343a40;--quarto-scss-export-nested-kbd-font-weight: ;--quarto-scss-export-pre-bg: #f8f9fa;--quarto-scss-export-pre-color: #000;--quarto-scss-export-bslib-page-sidebar-title-bg: #e95420;--quarto-scss-export-bslib-page-sidebar-title-color: #fff;--quarto-scss-export-bslib-sidebar-bg: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05);--quarto-scss-export-bslib-sidebar-toggle-bg: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.1);--quarto-scss-export-sidebar-color: rgb(89.25, 89.25, 89.25);--quarto-scss-export-sidebar-hover-color: rgba(156.11, 56.28, 21.44, 0.8);--quarto-scss-export-sidebar-disabled-color: rgba(89.25, 89.25, 89.25, 0.75);--quarto-scss-export-valuebox-bg-primary: #5c9bbc;--quarto-scss-export-valuebox-bg-secondary: #adb5bd;--quarto-scss-export-valuebox-bg-success: #60a545;--quarto-scss-export-valuebox-bg-info: #3d9dd1;--quarto-scss-export-valuebox-bg-warning: #9a9623;--quarto-scss-export-valuebox-bg-danger: #c48282;--quarto-scss-export-valuebox-bg-light: #e9ecef;--quarto-scss-export-valuebox-bg-dark: #772953;--quarto-scss-export-mermaid-bg-color: #fff;--quarto-scss-export-mermaid-edge-color: #adb5bd;--quarto-scss-export-mermaid-node-fg-color: #343a40;--quarto-scss-export-mermaid-fg-color: #343a40;--quarto-scss-export-mermaid-fg-color--lighter: rgb(74.8620689655, 83.5, 92.1379310345);--quarto-scss-export-mermaid-fg-color--lightest: rgb(97.724137931, 109, 120.275862069);--quarto-scss-export-mermaid-label-bg-color: #fff;--quarto-scss-export-mermaid-label-fg-color: #e95420;--quarto-scss-export-mermaid-node-bg-color: rgba(233, 84, 32, 0.1);--quarto-scss-export-code-block-border-left-color: #dee2e6;--quarto-scss-export-callout-color-note: #007bff;--quarto-scss-export-callout-color-tip: #38b44a;--quarto-scss-export-callout-color-important: #df382c;--quarto-scss-export-callout-color-caution: #e95420;--quarto-scss-export-callout-color-warning: #efb73e} \ No newline at end of file diff --git a/site_libs/bootstrap/bootstrap-dark-451e46ac69b7dce1f7bf1afa5bdb676d.min.css b/site_libs/bootstrap/bootstrap-dark-451e46ac69b7dce1f7bf1afa5bdb676d.min.css new file mode 100644 index 0000000..2058a80 --- /dev/null +++ b/site_libs/bootstrap/bootstrap-dark-451e46ac69b7dce1f7bf1afa5bdb676d.min.css @@ -0,0 +1,12 @@ +/*! + * Bootstrap v5.3.1 (https://getbootstrap.com/) + * Copyright 2011-2023 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */@import"https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap";:root,[data-bs-theme=light]{--bs-blue: #4c9be8;--bs-indigo: #6610f2;--bs-purple: #6f42c1;--bs-pink: #e83e8c;--bs-red: #d9534f;--bs-orange: #df6919;--bs-yellow: #ffc107;--bs-green: #5cb85c;--bs-teal: #20c997;--bs-cyan: #5bc0de;--bs-black: #000;--bs-white: #fff;--bs-gray: #6c757d;--bs-gray-dark: #343a40;--bs-gray-100: #ebebeb;--bs-gray-200: #dee2e6;--bs-gray-300: #dee2e6;--bs-gray-400: #adb5bd;--bs-gray-500: #adb5bd;--bs-gray-600: #6c757d;--bs-gray-700: #495057;--bs-gray-800: #343a40;--bs-gray-900: #212529;--bs-default: rgb(59, 76.6, 91);--bs-primary: #df6919;--bs-secondary: rgb(59, 76.6, 91);--bs-success: #5cb85c;--bs-info: #5bc0de;--bs-warning: #ffc107;--bs-danger: #d9534f;--bs-light: rgb(129.4, 139.96, 148.6);--bs-dark: rgb(59, 76.6, 91);--bs-default-rgb: 59, 77, 91;--bs-primary-rgb: 223, 105, 25;--bs-secondary-rgb: 59, 77, 91;--bs-success-rgb: 92, 184, 92;--bs-info-rgb: 91, 192, 222;--bs-warning-rgb: 255, 193, 7;--bs-danger-rgb: 217, 83, 79;--bs-light-rgb: 129, 140, 149;--bs-dark-rgb: 59, 77, 91;--bs-primary-text-emphasis: rgb(89.2, 42, 10);--bs-secondary-text-emphasis: rgb(23.6, 30.64, 36.4);--bs-success-text-emphasis: rgb(36.8, 73.6, 36.8);--bs-info-text-emphasis: rgb(36.4, 76.8, 88.8);--bs-warning-text-emphasis: rgb(102, 77.2, 2.8);--bs-danger-text-emphasis: rgb(86.8, 33.2, 31.6);--bs-light-text-emphasis: #495057;--bs-dark-text-emphasis: #495057;--bs-primary-bg-subtle: rgb(248.6, 225, 209);--bs-secondary-bg-subtle: rgb(215.8, 219.32, 222.2);--bs-success-bg-subtle: rgb(222.4, 240.8, 222.4);--bs-info-bg-subtle: rgb(222.2, 242.4, 248.4);--bs-warning-bg-subtle: rgb(255, 242.6, 205.4);--bs-danger-bg-subtle: rgb(247.4, 220.6, 219.8);--bs-light-bg-subtle: whitesmoke;--bs-dark-bg-subtle: #adb5bd;--bs-primary-border-subtle: rgb(242.2, 195, 163);--bs-secondary-border-subtle: rgb(176.6, 183.64, 189.4);--bs-success-border-subtle: rgb(189.8, 226.6, 189.8);--bs-info-border-subtle: rgb(189.4, 229.8, 241.8);--bs-warning-border-subtle: rgb(255, 230.2, 155.8);--bs-danger-border-subtle: rgb(239.8, 186.2, 184.6);--bs-light-border-subtle: #dee2e6;--bs-dark-border-subtle: #adb5bd;--bs-white-rgb: 255, 255, 255;--bs-black-rgb: 0, 0, 0;--bs-font-sans-serif: Lato, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-root-font-size: 17px;--bs-body-font-family: Lato, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";--bs-body-font-size:1rem;--bs-body-font-weight: 400;--bs-body-line-height: 1.5;--bs-body-color: #ebebeb;--bs-body-color-rgb: 235, 235, 235;--bs-body-bg: #0f2537;--bs-body-bg-rgb: 15, 37, 55;--bs-emphasis-color: #000;--bs-emphasis-color-rgb: 0, 0, 0;--bs-secondary-color: rgba(235, 235, 235, 0.75);--bs-secondary-color-rgb: 235, 235, 235;--bs-secondary-bg: #dee2e6;--bs-secondary-bg-rgb: 222, 226, 230;--bs-tertiary-color: rgba(235, 235, 235, 0.5);--bs-tertiary-color-rgb: 235, 235, 235;--bs-tertiary-bg: #ebebeb;--bs-tertiary-bg-rgb: 235, 235, 235;--bs-heading-color: inherit;--bs-link-color: #df6919;--bs-link-color-rgb: 223, 105, 25;--bs-link-decoration: underline;--bs-link-hover-color: rgb(178.4, 84, 20);--bs-link-hover-color-rgb: 178, 84, 20;--bs-code-color: inherit;--bs-highlight-bg: rgb(255, 242.6, 205.4);--bs-border-width: 1px;--bs-border-style: solid;--bs-border-color: #dee2e6;--bs-border-color-translucent: rgba(0, 0, 0, 0.175);--bs-border-radius: 0.25rem;--bs-border-radius-sm: 0.2em;--bs-border-radius-lg: 0;--bs-border-radius-xl: 1rem;--bs-border-radius-xxl: 2rem;--bs-border-radius-2xl: var(--bs-border-radius-xxl);--bs-border-radius-pill: 50rem;--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width: 0.25rem;--bs-focus-ring-opacity: 0.25;--bs-focus-ring-color: rgba(223, 105, 25, 0.25);--bs-form-valid-color: #5cb85c;--bs-form-valid-border-color: #5cb85c;--bs-form-invalid-color: #d9534f;--bs-form-invalid-border-color: #d9534f}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color: #dee2e6;--bs-body-color-rgb: 222, 226, 230;--bs-body-bg: #212529;--bs-body-bg-rgb: 33, 37, 41;--bs-emphasis-color: #fff;--bs-emphasis-color-rgb: 255, 255, 255;--bs-secondary-color: rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb: 222, 226, 230;--bs-secondary-bg: #343a40;--bs-secondary-bg-rgb: 52, 58, 64;--bs-tertiary-color: rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb: 222, 226, 230;--bs-tertiary-bg: rgb(42.5, 47.5, 52.5);--bs-tertiary-bg-rgb: 43, 48, 53;--bs-primary-text-emphasis: rgb(235.8, 165, 117);--bs-secondary-text-emphasis: rgb(137.4, 147.96, 156.6);--bs-success-text-emphasis: rgb(157.2, 212.4, 157.2);--bs-info-text-emphasis: rgb(156.6, 217.2, 235.2);--bs-warning-text-emphasis: rgb(255, 217.8, 106.2);--bs-danger-text-emphasis: rgb(232.2, 151.8, 149.4);--bs-light-text-emphasis: #ebebeb;--bs-dark-text-emphasis: #dee2e6;--bs-primary-bg-subtle: rgb(44.6, 21, 5);--bs-secondary-bg-subtle: rgb(11.8, 15.32, 18.2);--bs-success-bg-subtle: rgb(18.4, 36.8, 18.4);--bs-info-bg-subtle: rgb(18.2, 38.4, 44.4);--bs-warning-bg-subtle: rgb(51, 38.6, 1.4);--bs-danger-bg-subtle: rgb(43.4, 16.6, 15.8);--bs-light-bg-subtle: #343a40;--bs-dark-bg-subtle: #1a1d20;--bs-primary-border-subtle: rgb(133.8, 63, 15);--bs-secondary-border-subtle: rgb(35.4, 45.96, 54.6);--bs-success-border-subtle: rgb(55.2, 110.4, 55.2);--bs-info-border-subtle: rgb(54.6, 115.2, 133.2);--bs-warning-border-subtle: rgb(153, 115.8, 4.2);--bs-danger-border-subtle: rgb(130.2, 49.8, 47.4);--bs-light-border-subtle: #495057;--bs-dark-border-subtle: #343a40;--bs-heading-color: inherit;--bs-link-color: rgb(235.8, 165, 117);--bs-link-hover-color: rgb(239.64, 183, 144.6);--bs-link-color-rgb: 236, 165, 117;--bs-link-hover-color-rgb: 240, 183, 145;--bs-code-color: white;--bs-border-color: #495057;--bs-border-color-translucent: rgba(255, 255, 255, 0.15);--bs-form-valid-color: rgb(157.2, 212.4, 157.2);--bs-form-valid-border-color: rgb(157.2, 212.4, 157.2);--bs-form-invalid-color: rgb(232.2, 151.8, 149.4);--bs-form-invalid-border-color: rgb(232.2, 151.8, 149.4)}*,*::before,*::after{box-sizing:border-box}:root{font-size:var(--bs-root-font-size)}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0)}hr{margin:1rem 0;color:inherit;border:0;border-top:1px solid;opacity:.25}h6,.h6,h5,.h5,h4,.h4,h3,.h3,h2,.h2,h1,.h1{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}h1,.h1{font-size:calc(1.325rem + 0.9vw)}@media(min-width: 1200px){h1,.h1{font-size:2rem}}h2,.h2{font-size:calc(1.29rem + 0.48vw)}@media(min-width: 1200px){h2,.h2{font-size:1.65rem}}h3,.h3{font-size:calc(1.27rem + 0.24vw)}@media(min-width: 1200px){h3,.h3{font-size:1.45rem}}h4,.h4{font-size:1.25rem}h5,.h5{font-size:1.1rem}h6,.h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{text-decoration:underline dotted;-webkit-text-decoration:underline dotted;-moz-text-decoration:underline dotted;-ms-text-decoration:underline dotted;-o-text-decoration:underline dotted;cursor:help;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}ol,ul,dl{margin-top:0;margin-bottom:1rem}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem;padding:.625rem 1.25rem;border-left:.25rem solid #dee2e6}blockquote p:last-child,blockquote ul:last-child,blockquote ol:last-child{margin-bottom:0}b,strong{font-weight:bolder}small,.small{font-size:0.875em}mark,.mark{padding:.1875em;background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:0.75em;line-height:0;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}a{color:rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));text-decoration:underline;-webkit-text-decoration:underline;-moz-text-decoration:underline;-ms-text-decoration:underline;-o-text-decoration:underline}a:hover{--bs-link-color-rgb: var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}pre,code,kbd,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:0.875em;color:inherit;background-color:#ebebeb;line-height:1.5;padding:.5rem;border:1px solid var(--bs-border-color, #dee2e6);border-radius:.25rem}pre code{background-color:rgba(0,0,0,0);font-size:inherit;color:inherit;word-break:normal}code{font-size:0.875em;color:var(--bs-code-color);background-color:#ebebeb;border-radius:.25rem;padding:.125rem .25rem;word-wrap:break-word}a>code{color:inherit}kbd{padding:.4rem .4rem;font-size:0.875em;color:#0f2537;background-color:#ebebeb;border-radius:.2em}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:rgba(235,235,235,.75);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}thead,tbody,tfoot,tr,td,th{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}input,button,select,optgroup,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none !important}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button:not(:disabled),[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + 0.3vw);line-height:inherit}@media(min-width: 1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-text,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none !important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media(min-width: 1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media(min-width: 1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media(min-width: 1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media(min-width: 1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media(min-width: 1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media(min-width: 1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:0.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:0.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#0f2537;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:0.875em;color:rgba(235,235,235,.75)}.container,.container-fluid,.container-xxl,.container-xl,.container-lg,.container-md,.container-sm{--bs-gutter-x: 1.5rem;--bs-gutter-y: 0;width:100%;padding-right:calc(var(--bs-gutter-x)*.5);padding-left:calc(var(--bs-gutter-x)*.5);margin-right:auto;margin-left:auto}@media(min-width: 576px){.container-sm,.container{max-width:540px}}@media(min-width: 768px){.container-md,.container-sm,.container{max-width:720px}}@media(min-width: 992px){.container-lg,.container-md,.container-sm,.container{max-width:960px}}@media(min-width: 1200px){.container-xl,.container-lg,.container-md,.container-sm,.container{max-width:1140px}}@media(min-width: 1400px){.container-xxl,.container-xl,.container-lg,.container-md,.container-sm,.container{max-width:1320px}}body.quarto-light .dark-content{display:none !important}body.quarto-dark .light-content{display:none !important}:root{--bs-breakpoint-xs: 0;--bs-breakpoint-sm: 576px;--bs-breakpoint-md: 768px;--bs-breakpoint-lg: 992px;--bs-breakpoint-xl: 1200px;--bs-breakpoint-xxl: 1400px}.grid{display:grid;grid-template-rows:repeat(var(--bs-rows, 1), 1fr);grid-template-columns:repeat(var(--bs-columns, 12), 1fr);gap:var(--bs-gap, 1.5rem)}.grid .g-col-1{grid-column:auto/span 1}.grid .g-col-2{grid-column:auto/span 2}.grid .g-col-3{grid-column:auto/span 3}.grid .g-col-4{grid-column:auto/span 4}.grid .g-col-5{grid-column:auto/span 5}.grid .g-col-6{grid-column:auto/span 6}.grid .g-col-7{grid-column:auto/span 7}.grid .g-col-8{grid-column:auto/span 8}.grid .g-col-9{grid-column:auto/span 9}.grid .g-col-10{grid-column:auto/span 10}.grid .g-col-11{grid-column:auto/span 11}.grid .g-col-12{grid-column:auto/span 12}.grid .g-start-1{grid-column-start:1}.grid .g-start-2{grid-column-start:2}.grid .g-start-3{grid-column-start:3}.grid .g-start-4{grid-column-start:4}.grid .g-start-5{grid-column-start:5}.grid .g-start-6{grid-column-start:6}.grid .g-start-7{grid-column-start:7}.grid .g-start-8{grid-column-start:8}.grid .g-start-9{grid-column-start:9}.grid .g-start-10{grid-column-start:10}.grid .g-start-11{grid-column-start:11}@media(min-width: 576px){.grid .g-col-sm-1{grid-column:auto/span 1}.grid .g-col-sm-2{grid-column:auto/span 2}.grid .g-col-sm-3{grid-column:auto/span 3}.grid .g-col-sm-4{grid-column:auto/span 4}.grid .g-col-sm-5{grid-column:auto/span 5}.grid .g-col-sm-6{grid-column:auto/span 6}.grid .g-col-sm-7{grid-column:auto/span 7}.grid .g-col-sm-8{grid-column:auto/span 8}.grid .g-col-sm-9{grid-column:auto/span 9}.grid .g-col-sm-10{grid-column:auto/span 10}.grid .g-col-sm-11{grid-column:auto/span 11}.grid .g-col-sm-12{grid-column:auto/span 12}.grid .g-start-sm-1{grid-column-start:1}.grid .g-start-sm-2{grid-column-start:2}.grid .g-start-sm-3{grid-column-start:3}.grid .g-start-sm-4{grid-column-start:4}.grid .g-start-sm-5{grid-column-start:5}.grid .g-start-sm-6{grid-column-start:6}.grid .g-start-sm-7{grid-column-start:7}.grid .g-start-sm-8{grid-column-start:8}.grid .g-start-sm-9{grid-column-start:9}.grid .g-start-sm-10{grid-column-start:10}.grid .g-start-sm-11{grid-column-start:11}}@media(min-width: 768px){.grid .g-col-md-1{grid-column:auto/span 1}.grid .g-col-md-2{grid-column:auto/span 2}.grid .g-col-md-3{grid-column:auto/span 3}.grid .g-col-md-4{grid-column:auto/span 4}.grid .g-col-md-5{grid-column:auto/span 5}.grid .g-col-md-6{grid-column:auto/span 6}.grid .g-col-md-7{grid-column:auto/span 7}.grid .g-col-md-8{grid-column:auto/span 8}.grid .g-col-md-9{grid-column:auto/span 9}.grid .g-col-md-10{grid-column:auto/span 10}.grid .g-col-md-11{grid-column:auto/span 11}.grid .g-col-md-12{grid-column:auto/span 12}.grid .g-start-md-1{grid-column-start:1}.grid .g-start-md-2{grid-column-start:2}.grid .g-start-md-3{grid-column-start:3}.grid .g-start-md-4{grid-column-start:4}.grid .g-start-md-5{grid-column-start:5}.grid .g-start-md-6{grid-column-start:6}.grid .g-start-md-7{grid-column-start:7}.grid .g-start-md-8{grid-column-start:8}.grid .g-start-md-9{grid-column-start:9}.grid .g-start-md-10{grid-column-start:10}.grid .g-start-md-11{grid-column-start:11}}@media(min-width: 992px){.grid .g-col-lg-1{grid-column:auto/span 1}.grid .g-col-lg-2{grid-column:auto/span 2}.grid .g-col-lg-3{grid-column:auto/span 3}.grid .g-col-lg-4{grid-column:auto/span 4}.grid .g-col-lg-5{grid-column:auto/span 5}.grid .g-col-lg-6{grid-column:auto/span 6}.grid .g-col-lg-7{grid-column:auto/span 7}.grid .g-col-lg-8{grid-column:auto/span 8}.grid .g-col-lg-9{grid-column:auto/span 9}.grid .g-col-lg-10{grid-column:auto/span 10}.grid .g-col-lg-11{grid-column:auto/span 11}.grid .g-col-lg-12{grid-column:auto/span 12}.grid .g-start-lg-1{grid-column-start:1}.grid .g-start-lg-2{grid-column-start:2}.grid .g-start-lg-3{grid-column-start:3}.grid .g-start-lg-4{grid-column-start:4}.grid .g-start-lg-5{grid-column-start:5}.grid .g-start-lg-6{grid-column-start:6}.grid .g-start-lg-7{grid-column-start:7}.grid .g-start-lg-8{grid-column-start:8}.grid .g-start-lg-9{grid-column-start:9}.grid .g-start-lg-10{grid-column-start:10}.grid .g-start-lg-11{grid-column-start:11}}@media(min-width: 1200px){.grid .g-col-xl-1{grid-column:auto/span 1}.grid .g-col-xl-2{grid-column:auto/span 2}.grid .g-col-xl-3{grid-column:auto/span 3}.grid .g-col-xl-4{grid-column:auto/span 4}.grid .g-col-xl-5{grid-column:auto/span 5}.grid .g-col-xl-6{grid-column:auto/span 6}.grid .g-col-xl-7{grid-column:auto/span 7}.grid .g-col-xl-8{grid-column:auto/span 8}.grid .g-col-xl-9{grid-column:auto/span 9}.grid .g-col-xl-10{grid-column:auto/span 10}.grid .g-col-xl-11{grid-column:auto/span 11}.grid .g-col-xl-12{grid-column:auto/span 12}.grid .g-start-xl-1{grid-column-start:1}.grid .g-start-xl-2{grid-column-start:2}.grid .g-start-xl-3{grid-column-start:3}.grid .g-start-xl-4{grid-column-start:4}.grid .g-start-xl-5{grid-column-start:5}.grid .g-start-xl-6{grid-column-start:6}.grid .g-start-xl-7{grid-column-start:7}.grid .g-start-xl-8{grid-column-start:8}.grid .g-start-xl-9{grid-column-start:9}.grid .g-start-xl-10{grid-column-start:10}.grid .g-start-xl-11{grid-column-start:11}}@media(min-width: 1400px){.grid .g-col-xxl-1{grid-column:auto/span 1}.grid .g-col-xxl-2{grid-column:auto/span 2}.grid .g-col-xxl-3{grid-column:auto/span 3}.grid .g-col-xxl-4{grid-column:auto/span 4}.grid .g-col-xxl-5{grid-column:auto/span 5}.grid .g-col-xxl-6{grid-column:auto/span 6}.grid .g-col-xxl-7{grid-column:auto/span 7}.grid .g-col-xxl-8{grid-column:auto/span 8}.grid .g-col-xxl-9{grid-column:auto/span 9}.grid .g-col-xxl-10{grid-column:auto/span 10}.grid .g-col-xxl-11{grid-column:auto/span 11}.grid .g-col-xxl-12{grid-column:auto/span 12}.grid .g-start-xxl-1{grid-column-start:1}.grid .g-start-xxl-2{grid-column-start:2}.grid .g-start-xxl-3{grid-column-start:3}.grid .g-start-xxl-4{grid-column-start:4}.grid .g-start-xxl-5{grid-column-start:5}.grid .g-start-xxl-6{grid-column-start:6}.grid .g-start-xxl-7{grid-column-start:7}.grid .g-start-xxl-8{grid-column-start:8}.grid .g-start-xxl-9{grid-column-start:9}.grid .g-start-xxl-10{grid-column-start:10}.grid .g-start-xxl-11{grid-column-start:11}}.table{--bs-table-color-type: initial;--bs-table-bg-type: initial;--bs-table-color-state: initial;--bs-table-bg-state: initial;--bs-table-color: #ebebeb;--bs-table-bg: #0f2537;--bs-table-border-color: rgba(0, 0, 0, 0.15);--bs-table-accent-bg: rgba(255, 255, 255, 0.05);--bs-table-striped-color: #ebebeb;--bs-table-striped-bg: rgba(0, 0, 0, 0.05);--bs-table-active-color: #ebebeb;--bs-table-active-bg: rgba(0, 0, 0, 0.1);--bs-table-hover-color: #ebebeb;--bs-table-hover-bg: rgba(255, 255, 255, 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(1px*2) solid #7d8891}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type: var(--bs-table-striped-color);--bs-table-bg-type: var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(even){--bs-table-color-type: var(--bs-table-striped-color);--bs-table-bg-type: var(--bs-table-striped-bg)}.table-active{--bs-table-color-state: var(--bs-table-active-color);--bs-table-bg-state: var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state: var(--bs-table-hover-color);--bs-table-bg-state: var(--bs-table-hover-bg)}.table-primary{--bs-table-color: #fff;--bs-table-bg: #df6919;--bs-table-border-color: rgb(226.2, 120, 48);--bs-table-striped-bg: rgb(224.6, 112.5, 36.5);--bs-table-striped-color: #fff;--bs-table-active-bg: rgb(226.2, 120, 48);--bs-table-active-color: #fff;--bs-table-hover-bg: rgb(225.4, 116.25, 42.25);--bs-table-hover-color: #fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color: #fff;--bs-table-bg: rgb(59, 76.6, 91);--bs-table-border-color: rgb(78.6, 94.44, 107.4);--bs-table-striped-bg: rgb(68.8, 85.52, 99.2);--bs-table-striped-color: #fff;--bs-table-active-bg: rgb(78.6, 94.44, 107.4);--bs-table-active-color: #fff;--bs-table-hover-bg: rgb(73.7, 89.98, 103.3);--bs-table-hover-color: #fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color: #fff;--bs-table-bg: #5cb85c;--bs-table-border-color: rgb(108.3, 191.1, 108.3);--bs-table-striped-bg: rgb(100.15, 187.55, 100.15);--bs-table-striped-color: #fff;--bs-table-active-bg: rgb(108.3, 191.1, 108.3);--bs-table-active-color: #fff;--bs-table-hover-bg: rgb(104.225, 189.325, 104.225);--bs-table-hover-color: #fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color: #fff;--bs-table-bg: #5bc0de;--bs-table-border-color: rgb(107.4, 198.3, 225.3);--bs-table-striped-bg: rgb(99.2, 195.15, 223.65);--bs-table-striped-color: #fff;--bs-table-active-bg: rgb(107.4, 198.3, 225.3);--bs-table-active-color: #fff;--bs-table-hover-bg: rgb(103.3, 196.725, 224.475);--bs-table-hover-color: #fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color: #fff;--bs-table-bg: #ffc107;--bs-table-border-color: rgb(255, 199.2, 31.8);--bs-table-striped-bg: rgb(255, 196.1, 19.4);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(255, 199.2, 31.8);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(255, 197.65, 25.6);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color: #fff;--bs-table-bg: #d9534f;--bs-table-border-color: rgb(220.8, 100.2, 96.6);--bs-table-striped-bg: rgb(218.9, 91.6, 87.8);--bs-table-striped-color: #fff;--bs-table-active-bg: rgb(220.8, 100.2, 96.6);--bs-table-active-color: #fff;--bs-table-hover-bg: rgb(219.85, 95.9, 92.2);--bs-table-hover-color: #fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color: #fff;--bs-table-bg: rgb(129.4, 139.96, 148.6);--bs-table-border-color: rgb(141.96, 151.464, 159.24);--bs-table-striped-bg: rgb(135.68, 145.712, 153.92);--bs-table-striped-color: #fff;--bs-table-active-bg: rgb(141.96, 151.464, 159.24);--bs-table-active-color: #fff;--bs-table-hover-bg: rgb(138.82, 148.588, 156.58);--bs-table-hover-color: #fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color: #fff;--bs-table-bg: rgb(59, 76.6, 91);--bs-table-border-color: rgb(78.6, 94.44, 107.4);--bs-table-striped-bg: rgb(68.8, 85.52, 99.2);--bs-table-striped-color: #fff;--bs-table-active-bg: rgb(78.6, 94.44, 107.4);--bs-table-active-color: #fff;--bs-table-hover-bg: rgb(73.7, 89.98, 103.3);--bs-table-hover-color: #fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media(max-width: 575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media(max-width: 767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media(max-width: 991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media(max-width: 1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media(max-width: 1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label,.shiny-input-container .control-label{margin-bottom:.5rem}.col-form-label{padding-top:.375rem;padding-bottom:.375rem;margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:.5rem;padding-bottom:.5rem;font-size:1.25rem}.col-form-label-sm{padding-top:.25rem;padding-bottom:.25rem;font-size:0.875rem}.form-text{margin-top:.25rem;font-size:0.875em;color:rgba(235,235,235,.75)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;appearance:none;-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:#fff;background-clip:padding-box;border:0 solid rgba(0,0,0,0);border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#efb48c;outline:0;box-shadow:0 0 0 .25rem rgba(223,105,25,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::placeholder{color:#adb5bd;opacity:1}.form-control:disabled{color:rgb(59,76.6,91);background-color:#ebebeb;opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-0.375rem -0.75rem;margin-inline-end:.75rem;color:#ebebeb;background-color:rgb(59,76.6,91);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:0;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:rgb(48.97,63.578,75.53)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#ebebeb;background-color:rgba(0,0,0,0);border:solid rgba(0,0,0,0);border-width:0 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-sm,.form-control-plaintext.form-control-lg{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + 0.5rem + calc(0 * 2));padding:.25rem .5rem;font-size:0.875rem;border-radius:.2em}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-0.25rem -0.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(0 * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:0}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-0.5rem -1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + 0.75rem + calc(0 * 2))}textarea.form-control-sm{min-height:calc(1.5em + 0.5rem + calc(0 * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(0 * 2))}.form-control-color{width:3rem;height:calc(1.5em + 0.75rem + calc(0 * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0 !important;border-radius:.25rem}.form-control-color::-webkit-color-swatch{border:0 !important;border-radius:.25rem}.form-control-color.form-control-sm{height:calc(1.5em + 0.5rem + calc(0 * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(0 * 2))}.form-select{--bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;appearance:none;-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:#fff;background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon, none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:0 solid rgba(0,0,0,0);border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.form-select{transition:none}}.form-select:focus{border-color:#efb48c;outline:0;box-shadow:0 0 0 .25rem rgba(223,105,25,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{color:rgb(59,76.6,91);background-color:#ebebeb}.form-select:-moz-focusring{color:rgba(0,0,0,0);text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:0.875rem;border-radius:.2em}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:0}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check,.shiny-input-container .checkbox,.shiny-input-container .radio{display:block;min-height:1.5rem;padding-left:0;margin-bottom:.125rem}.form-check .form-check-input,.form-check .shiny-input-container .checkbox input,.form-check .shiny-input-container .radio input,.shiny-input-container .checkbox .form-check-input,.shiny-input-container .checkbox .shiny-input-container .checkbox input,.shiny-input-container .checkbox .shiny-input-container .radio input,.shiny-input-container .radio .form-check-input,.shiny-input-container .radio .shiny-input-container .checkbox input,.shiny-input-container .radio .shiny-input-container .radio input{float:left;margin-left:0}.form-check-reverse{padding-right:0;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:0;margin-left:0}.form-check-input,.shiny-input-container .checkbox input,.shiny-input-container .checkbox-inline input,.shiny-input-container .radio input,.shiny-input-container .radio-inline input{--bs-form-check-bg: #fff;width:1em;height:1em;margin-top:.25em;vertical-align:top;appearance:none;-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:none;print-color-adjust:exact}.form-check-input[type=checkbox],.shiny-input-container .checkbox input[type=checkbox],.shiny-input-container .checkbox-inline input[type=checkbox],.shiny-input-container .radio input[type=checkbox],.shiny-input-container .radio-inline input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio],.shiny-input-container .checkbox input[type=radio],.shiny-input-container .checkbox-inline input[type=radio],.shiny-input-container .radio input[type=radio],.shiny-input-container .radio-inline input[type=radio]{border-radius:50%}.form-check-input:active,.shiny-input-container .checkbox input:active,.shiny-input-container .checkbox-inline input:active,.shiny-input-container .radio input:active,.shiny-input-container .radio-inline input:active{filter:brightness(90%)}.form-check-input:focus,.shiny-input-container .checkbox input:focus,.shiny-input-container .checkbox-inline input:focus,.shiny-input-container .radio input:focus,.shiny-input-container .radio-inline input:focus{border-color:#efb48c;outline:0;box-shadow:0 0 0 .25rem rgba(223,105,25,.25)}.form-check-input:checked,.shiny-input-container .checkbox input:checked,.shiny-input-container .checkbox-inline input:checked,.shiny-input-container .radio input:checked,.shiny-input-container .radio-inline input:checked{background-color:#df6919;border-color:#df6919}.form-check-input:checked[type=checkbox],.shiny-input-container .checkbox input:checked[type=checkbox],.shiny-input-container .checkbox-inline input:checked[type=checkbox],.shiny-input-container .radio input:checked[type=checkbox],.shiny-input-container .radio-inline input:checked[type=checkbox]{--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio],.shiny-input-container .checkbox input:checked[type=radio],.shiny-input-container .checkbox-inline input:checked[type=radio],.shiny-input-container .radio input:checked[type=radio],.shiny-input-container .radio-inline input:checked[type=radio]{--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate,.shiny-input-container .checkbox input[type=checkbox]:indeterminate,.shiny-input-container .checkbox-inline input[type=checkbox]:indeterminate,.shiny-input-container .radio input[type=checkbox]:indeterminate,.shiny-input-container .radio-inline input[type=checkbox]:indeterminate{background-color:#df6919;border-color:#df6919;--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled,.shiny-input-container .checkbox input:disabled,.shiny-input-container .checkbox-inline input:disabled,.shiny-input-container .radio input:disabled,.shiny-input-container .radio-inline input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input[disabled]~.form-check-label,.form-check-input[disabled]~span,.form-check-input:disabled~.form-check-label,.form-check-input:disabled~span,.shiny-input-container .checkbox input[disabled]~.form-check-label,.shiny-input-container .checkbox input[disabled]~span,.shiny-input-container .checkbox input:disabled~.form-check-label,.shiny-input-container .checkbox input:disabled~span,.shiny-input-container .checkbox-inline input[disabled]~.form-check-label,.shiny-input-container .checkbox-inline input[disabled]~span,.shiny-input-container .checkbox-inline input:disabled~.form-check-label,.shiny-input-container .checkbox-inline input:disabled~span,.shiny-input-container .radio input[disabled]~.form-check-label,.shiny-input-container .radio input[disabled]~span,.shiny-input-container .radio input:disabled~.form-check-label,.shiny-input-container .radio input:disabled~span,.shiny-input-container .radio-inline input[disabled]~.form-check-label,.shiny-input-container .radio-inline input[disabled]~span,.shiny-input-container .radio-inline input:disabled~.form-check-label,.shiny-input-container .radio-inline input:disabled~span{cursor:default;opacity:.5}.form-check-label,.shiny-input-container .checkbox label,.shiny-input-container .checkbox-inline label,.shiny-input-container .radio label,.shiny-input-container .radio-inline label{cursor:pointer}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media(prefers-reduced-motion: reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23efb48c'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0, 0, 0, 0);pointer-events:none}.btn-check[disabled]+.btn,.btn-check:disabled+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;appearance:none;-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:rgba(0,0,0,0)}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #0f2537,0 0 0 .25rem rgba(223,105,25,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #0f2537,0 0 0 .25rem rgba(223,105,25,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-0.25rem;appearance:none;-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:#df6919;border:0;border-radius:1rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.form-range::-webkit-slider-thumb{transition:none}}.form-range::-webkit-slider-thumb:active{background-color:rgb(245.4,210,186)}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:rgba(0,0,0,0);cursor:pointer;background-color:#ebebeb;border-color:rgba(0,0,0,0);border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;appearance:none;-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:#df6919;border:0;border-radius:1rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.form-range::-moz-range-thumb{transition:none}}.form-range::-moz-range-thumb:active{background-color:rgb(245.4,210,186)}.form-range::-moz-range-track{width:100%;height:.5rem;color:rgba(0,0,0,0);cursor:pointer;background-color:#ebebeb;border-color:rgba(0,0,0,0);border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:rgba(235,235,235,.75)}.form-range:disabled::-moz-range-thumb{background-color:rgba(235,235,235,.75)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(0 * 2));min-height:calc(3.5rem + calc(0 * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:0 solid rgba(0,0,0,0);transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media(prefers-reduced-motion: reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control::placeholder,.form-floating>.form-control-plaintext::placeholder{color:rgba(0,0,0,0)}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown),.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill,.form-floating>.form-control-plaintext:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-control-plaintext~label,.form-floating>.form-select~label{color:rgba(var(--bs-body-color-rgb), 1);transform:scale(0.85) translateY(-0.5rem) translateX(0.15rem)}.form-floating>.form-control:focus~label::after,.form-floating>.form-control:not(:placeholder-shown)~label::after,.form-floating>.form-control-plaintext~label::after,.form-floating>.form-select~label::after{position:absolute;inset:1rem .375rem;z-index:-1;height:1.5em;content:"";background-color:#fff;border-radius:.25rem}.form-floating>.form-control:-webkit-autofill~label{color:rgba(var(--bs-body-color-rgb), 1);transform:scale(0.85) translateY(-0.5rem) translateX(0.15rem)}.form-floating>.form-control-plaintext~label{border-width:0 0}.form-floating>:disabled~label,.form-floating>.form-control:disabled~label{color:#6c757d}.form-floating>:disabled~label::after,.form-floating>.form-control:disabled~label::after{background-color:#ebebeb}.input-group{position:relative;display:flex;display:-webkit-flex;flex-wrap:wrap;-webkit-flex-wrap:wrap;align-items:stretch;-webkit-align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select,.input-group>.form-floating{position:relative;flex:1 1 auto;-webkit-flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus,.input-group>.form-floating:focus-within{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;display:-webkit-flex;align-items:center;-webkit-align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#ebebeb;text-align:center;white-space:nowrap;background-color:rgb(59,76.6,91);border:0 solid rgba(0,0,0,0);border-radius:.25rem}.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text,.input-group-lg>.btn{padding:.5rem 1rem;font-size:1.25rem;border-radius:0}.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text,.input-group-sm>.btn{padding:.25rem .5rem;font-size:0.875rem;border-radius:.2em}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating),.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select{border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating),.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(0*-1);border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:0.875em;color:#5cb85c}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:0.875rem;color:#fff;background-color:#5cb85c;border-radius:.25rem}.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip,.is-valid~.valid-feedback,.is-valid~.valid-tooltip{display:block}.was-validated .form-control:valid,.form-control.is-valid{border-color:#5cb85c;padding-right:calc(1.5em + 0.75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%235cb85c' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(0.375em + 0.1875rem) center;background-size:calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .form-control:valid:focus,.form-control.is-valid:focus{border-color:#5cb85c;box-shadow:0 0 0 .25rem rgba(92,184,92,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + 0.75rem);background-position:top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem)}.was-validated .form-select:valid,.form-select.is-valid{border-color:#5cb85c}.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"],.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"]{--bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%235cb85c' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .form-select:valid:focus,.form-select.is-valid:focus{border-color:#5cb85c;box-shadow:0 0 0 .25rem rgba(92,184,92,.25)}.was-validated .form-control-color:valid,.form-control-color.is-valid{width:calc(3rem + calc(1.5em + 0.75rem))}.was-validated .form-check-input:valid,.form-check-input.is-valid{border-color:#5cb85c}.was-validated .form-check-input:valid:checked,.form-check-input.is-valid:checked{background-color:#5cb85c}.was-validated .form-check-input:valid:focus,.form-check-input.is-valid:focus{box-shadow:0 0 0 .25rem rgba(92,184,92,.25)}.was-validated .form-check-input:valid~.form-check-label,.form-check-input.is-valid~.form-check-label{color:#5cb85c}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.was-validated .input-group>.form-control:not(:focus):valid,.input-group>.form-control:not(:focus).is-valid,.was-validated .input-group>.form-select:not(:focus):valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.input-group>.form-floating:not(:focus-within).is-valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:0.875em;color:#d9534f}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:0.875rem;color:#fff;background-color:#d9534f;border-radius:.25rem}.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip,.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip{display:block}.was-validated .form-control:invalid,.form-control.is-invalid{border-color:#d9534f;padding-right:calc(1.5em + 0.75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23d9534f'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23d9534f' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(0.375em + 0.1875rem) center;background-size:calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .form-control:invalid:focus,.form-control.is-invalid:focus{border-color:#d9534f;box-shadow:0 0 0 .25rem rgba(217,83,79,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + 0.75rem);background-position:top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem)}.was-validated .form-select:invalid,.form-select.is-invalid{border-color:#d9534f}.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"],.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23d9534f'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23d9534f' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .form-select:invalid:focus,.form-select.is-invalid:focus{border-color:#d9534f;box-shadow:0 0 0 .25rem rgba(217,83,79,.25)}.was-validated .form-control-color:invalid,.form-control-color.is-invalid{width:calc(3rem + calc(1.5em + 0.75rem))}.was-validated .form-check-input:invalid,.form-check-input.is-invalid{border-color:#d9534f}.was-validated .form-check-input:invalid:checked,.form-check-input.is-invalid:checked{background-color:#d9534f}.was-validated .form-check-input:invalid:focus,.form-check-input.is-invalid:focus{box-shadow:0 0 0 .25rem rgba(217,83,79,.25)}.was-validated .form-check-input:invalid~.form-check-label,.form-check-input.is-invalid~.form-check-label{color:#d9534f}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.was-validated .input-group>.form-control:not(:focus):invalid,.input-group>.form-control:not(:focus).is-invalid,.was-validated .input-group>.form-select:not(:focus):invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.input-group>.form-floating:not(:focus-within).is-invalid{z-index:4}.btn{--bs-btn-padding-x: 0.75rem;--bs-btn-padding-y: 0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight: 400;--bs-btn-line-height: 1.5;--bs-btn-color: #ebebeb;--bs-btn-bg: transparent;--bs-btn-border-width: 1px;--bs-btn-border-color: transparent;--bs-btn-border-radius: 0.25rem;--bs-btn-hover-border-color: transparent;--bs-btn-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity: 0.65;--bs-btn-focus-box-shadow: 0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;-webkit-text-decoration:none;-moz-text-decoration:none;-ms-text-decoration:none;-o-text-decoration:none;vertical-align:middle;cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,:not(.btn-check)+.btn:active,.btn:first-child:active,.btn.active,.btn.show{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,:not(.btn-check)+.btn:active:focus-visible,.btn:first-child:active:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn:disabled,.btn.disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-default{--bs-btn-color: #fff;--bs-btn-bg: rgb(59, 76.6, 91);--bs-btn-border-color: rgb(59, 76.6, 91);--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(50.15, 65.11, 77.35);--bs-btn-hover-border-color: rgb(47.2, 61.28, 72.8);--bs-btn-focus-shadow-rgb: 88, 103, 116;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(47.2, 61.28, 72.8);--bs-btn-active-border-color: rgb(44.25, 57.45, 68.25);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: rgb(59, 76.6, 91);--bs-btn-disabled-border-color: rgb(59, 76.6, 91)}.btn-primary{--bs-btn-color: #fff;--bs-btn-bg: #df6919;--bs-btn-border-color: #df6919;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(189.55, 89.25, 21.25);--bs-btn-hover-border-color: rgb(178.4, 84, 20);--bs-btn-focus-shadow-rgb: 228, 128, 60;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(178.4, 84, 20);--bs-btn-active-border-color: rgb(167.25, 78.75, 18.75);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #df6919;--bs-btn-disabled-border-color: #df6919}.btn-secondary{--bs-btn-color: #fff;--bs-btn-bg: rgb(59, 76.6, 91);--bs-btn-border-color: rgb(59, 76.6, 91);--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(50.15, 65.11, 77.35);--bs-btn-hover-border-color: rgb(47.2, 61.28, 72.8);--bs-btn-focus-shadow-rgb: 88, 103, 116;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(47.2, 61.28, 72.8);--bs-btn-active-border-color: rgb(44.25, 57.45, 68.25);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: rgb(59, 76.6, 91);--bs-btn-disabled-border-color: rgb(59, 76.6, 91)}.btn-success{--bs-btn-color: #fff;--bs-btn-bg: #5cb85c;--bs-btn-border-color: #5cb85c;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(78.2, 156.4, 78.2);--bs-btn-hover-border-color: rgb(73.6, 147.2, 73.6);--bs-btn-focus-shadow-rgb: 116, 195, 116;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(73.6, 147.2, 73.6);--bs-btn-active-border-color: #458a45;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #5cb85c;--bs-btn-disabled-border-color: #5cb85c}.btn-info{--bs-btn-color: #fff;--bs-btn-bg: #5bc0de;--bs-btn-border-color: #5bc0de;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(77.35, 163.2, 188.7);--bs-btn-hover-border-color: rgb(72.8, 153.6, 177.6);--bs-btn-focus-shadow-rgb: 116, 201, 227;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(72.8, 153.6, 177.6);--bs-btn-active-border-color: rgb(68.25, 144, 166.5);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #5bc0de;--bs-btn-disabled-border-color: #5bc0de}.btn-warning{--bs-btn-color: #fff;--bs-btn-bg: #ffc107;--bs-btn-border-color: #ffc107;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(216.75, 164.05, 5.95);--bs-btn-hover-border-color: rgb(204, 154.4, 5.6);--bs-btn-focus-shadow-rgb: 255, 202, 44;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(204, 154.4, 5.6);--bs-btn-active-border-color: rgb(191.25, 144.75, 5.25);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #ffc107;--bs-btn-disabled-border-color: #ffc107}.btn-danger{--bs-btn-color: #fff;--bs-btn-bg: #d9534f;--bs-btn-border-color: #d9534f;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(184.45, 70.55, 67.15);--bs-btn-hover-border-color: rgb(173.6, 66.4, 63.2);--bs-btn-focus-shadow-rgb: 223, 109, 105;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(173.6, 66.4, 63.2);--bs-btn-active-border-color: rgb(162.75, 62.25, 59.25);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #d9534f;--bs-btn-disabled-border-color: #d9534f}.btn-light{--bs-btn-color: #fff;--bs-btn-bg: rgb(129.4, 139.96, 148.6);--bs-btn-border-color: rgb(129.4, 139.96, 148.6);--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(109.99, 118.966, 126.31);--bs-btn-hover-border-color: rgb(103.52, 111.968, 118.88);--bs-btn-focus-shadow-rgb: 148, 157, 165;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(103.52, 111.968, 118.88);--bs-btn-active-border-color: rgb(97.05, 104.97, 111.45);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: rgb(129.4, 139.96, 148.6);--bs-btn-disabled-border-color: rgb(129.4, 139.96, 148.6)}.btn-dark{--bs-btn-color: #fff;--bs-btn-bg: rgb(59, 76.6, 91);--bs-btn-border-color: rgb(59, 76.6, 91);--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(88.4, 103.36, 115.6);--bs-btn-hover-border-color: rgb(78.6, 94.44, 107.4);--bs-btn-focus-shadow-rgb: 88, 103, 116;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(98.2, 112.28, 123.8);--bs-btn-active-border-color: rgb(78.6, 94.44, 107.4);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: rgb(59, 76.6, 91);--bs-btn-disabled-border-color: rgb(59, 76.6, 91)}.btn-outline-default{--bs-btn-color: rgb(59, 76.6, 91);--bs-btn-border-color: rgb(59, 76.6, 91);--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(59, 76.6, 91);--bs-btn-hover-border-color: rgb(59, 76.6, 91);--bs-btn-focus-shadow-rgb: 59, 77, 91;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(59, 76.6, 91);--bs-btn-active-border-color: rgb(59, 76.6, 91);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: rgb(59, 76.6, 91);--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: rgb(59, 76.6, 91);--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-primary{--bs-btn-color: #df6919;--bs-btn-border-color: #df6919;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #df6919;--bs-btn-hover-border-color: #df6919;--bs-btn-focus-shadow-rgb: 223, 105, 25;--bs-btn-active-color: #fff;--bs-btn-active-bg: #df6919;--bs-btn-active-border-color: #df6919;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #df6919;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #df6919;--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-secondary{--bs-btn-color: rgb(59, 76.6, 91);--bs-btn-border-color: rgb(59, 76.6, 91);--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(59, 76.6, 91);--bs-btn-hover-border-color: rgb(59, 76.6, 91);--bs-btn-focus-shadow-rgb: 59, 77, 91;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(59, 76.6, 91);--bs-btn-active-border-color: rgb(59, 76.6, 91);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: rgb(59, 76.6, 91);--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: rgb(59, 76.6, 91);--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-success{--bs-btn-color: #5cb85c;--bs-btn-border-color: #5cb85c;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #5cb85c;--bs-btn-hover-border-color: #5cb85c;--bs-btn-focus-shadow-rgb: 92, 184, 92;--bs-btn-active-color: #fff;--bs-btn-active-bg: #5cb85c;--bs-btn-active-border-color: #5cb85c;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #5cb85c;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #5cb85c;--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-info{--bs-btn-color: #5bc0de;--bs-btn-border-color: #5bc0de;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #5bc0de;--bs-btn-hover-border-color: #5bc0de;--bs-btn-focus-shadow-rgb: 91, 192, 222;--bs-btn-active-color: #fff;--bs-btn-active-bg: #5bc0de;--bs-btn-active-border-color: #5bc0de;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #5bc0de;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #5bc0de;--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-warning{--bs-btn-color: #ffc107;--bs-btn-border-color: #ffc107;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #ffc107;--bs-btn-hover-border-color: #ffc107;--bs-btn-focus-shadow-rgb: 255, 193, 7;--bs-btn-active-color: #fff;--bs-btn-active-bg: #ffc107;--bs-btn-active-border-color: #ffc107;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #ffc107;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #ffc107;--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-danger{--bs-btn-color: #d9534f;--bs-btn-border-color: #d9534f;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #d9534f;--bs-btn-hover-border-color: #d9534f;--bs-btn-focus-shadow-rgb: 217, 83, 79;--bs-btn-active-color: #fff;--bs-btn-active-bg: #d9534f;--bs-btn-active-border-color: #d9534f;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #d9534f;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #d9534f;--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-light{--bs-btn-color: rgb(129.4, 139.96, 148.6);--bs-btn-border-color: rgb(129.4, 139.96, 148.6);--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(129.4, 139.96, 148.6);--bs-btn-hover-border-color: rgb(129.4, 139.96, 148.6);--bs-btn-focus-shadow-rgb: 129, 140, 149;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(129.4, 139.96, 148.6);--bs-btn-active-border-color: rgb(129.4, 139.96, 148.6);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: rgb(129.4, 139.96, 148.6);--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: rgb(129.4, 139.96, 148.6);--bs-btn-bg: transparent;--bs-gradient: none}.btn-outline-dark{--bs-btn-color: rgb(59, 76.6, 91);--bs-btn-border-color: rgb(59, 76.6, 91);--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(59, 76.6, 91);--bs-btn-hover-border-color: rgb(59, 76.6, 91);--bs-btn-focus-shadow-rgb: 59, 77, 91;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(59, 76.6, 91);--bs-btn-active-border-color: rgb(59, 76.6, 91);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: rgb(59, 76.6, 91);--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: rgb(59, 76.6, 91);--bs-btn-bg: transparent;--bs-gradient: none}.btn-link{--bs-btn-font-weight: 400;--bs-btn-color: #df6919;--bs-btn-bg: transparent;--bs-btn-border-color: transparent;--bs-btn-hover-color: rgb(178.4, 84, 20);--bs-btn-hover-border-color: transparent;--bs-btn-active-color: rgb(178.4, 84, 20);--bs-btn-active-border-color: transparent;--bs-btn-disabled-color: #6c757d;--bs-btn-disabled-border-color: transparent;--bs-btn-box-shadow: 0 0 0 #000;--bs-btn-focus-shadow-rgb: 228, 128, 60;text-decoration:underline;-webkit-text-decoration:underline;-moz-text-decoration:underline;-ms-text-decoration:underline;-o-text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-lg,.btn-group-lg>.btn{--bs-btn-padding-y: 0.5rem;--bs-btn-padding-x: 1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius: 0}.btn-sm,.btn-group-sm>.btn{--bs-btn-padding-y: 0.25rem;--bs-btn-padding-x: 0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius: 0.2em}.fade{transition:opacity .15s linear}@media(prefers-reduced-motion: reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .2s ease}@media(prefers-reduced-motion: reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media(prefers-reduced-motion: reduce){.collapsing.collapse-horizontal{transition:none}}.dropup,.dropend,.dropdown,.dropstart,.dropup-center,.dropdown-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid rgba(0,0,0,0);border-bottom:0;border-left:.3em solid rgba(0,0,0,0)}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex: 1000;--bs-dropdown-min-width: 10rem;--bs-dropdown-padding-x: 0;--bs-dropdown-padding-y: 0.5rem;--bs-dropdown-spacer: 0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color: #ebebeb;--bs-dropdown-bg: rgb(59, 76.6, 91);--bs-dropdown-border-color: rgba(0, 0, 0, 0.175);--bs-dropdown-border-radius: 0.25rem;--bs-dropdown-border-width: 1px;--bs-dropdown-inner-border-radius: calc(0.25rem - 1px);--bs-dropdown-divider-bg: rgba(0, 0, 0, 0.15);--bs-dropdown-divider-margin-y: 0.5rem;--bs-dropdown-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-dropdown-link-color: #ebebeb;--bs-dropdown-link-hover-color: #ebebeb;--bs-dropdown-link-hover-bg: rgba(255, 255, 255, 0.075);--bs-dropdown-link-active-color: #fff;--bs-dropdown-link-active-bg: #df6919;--bs-dropdown-link-disabled-color: rgba(235, 235, 235, 0.5);--bs-dropdown-item-padding-x: 1rem;--bs-dropdown-item-padding-y: 0.25rem;--bs-dropdown-header-color: #6c757d;--bs-dropdown-header-padding-x: 1rem;--bs-dropdown-header-padding-y: 0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position: start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position: end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media(min-width: 576px){.dropdown-menu-sm-start{--bs-position: start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position: end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media(min-width: 768px){.dropdown-menu-md-start{--bs-position: start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position: end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media(min-width: 992px){.dropdown-menu-lg-start{--bs-position: start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position: end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media(min-width: 1200px){.dropdown-menu-xl-start{--bs-position: start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position: end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media(min-width: 1400px){.dropdown-menu-xxl-start{--bs-position: start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position: end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid rgba(0,0,0,0);border-bottom:.3em solid;border-left:.3em solid rgba(0,0,0,0)}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid rgba(0,0,0,0);border-right:0;border-bottom:.3em solid rgba(0,0,0,0);border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid rgba(0,0,0,0);border-right:.3em solid;border-bottom:.3em solid rgba(0,0,0,0)}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;-webkit-text-decoration:none;-moz-text-decoration:none;-ms-text-decoration:none;-o-text-decoration:none;white-space:nowrap;background-color:rgba(0,0,0,0);border:0;border-radius:var(--bs-dropdown-item-border-radius, 0)}.dropdown-item:hover,.dropdown-item:focus{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:rgba(0,0,0,0)}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:0.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color: #dee2e6;--bs-dropdown-bg: #343a40;--bs-dropdown-border-color: rgba(0, 0, 0, 0.175);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color: #dee2e6;--bs-dropdown-link-hover-color: #fff;--bs-dropdown-divider-bg: rgba(0, 0, 0, 0.15);--bs-dropdown-link-hover-bg: rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color: #fff;--bs-dropdown-link-active-bg: #df6919;--bs-dropdown-link-disabled-color: #adb5bd;--bs-dropdown-header-color: #adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;flex:1 1 auto;-webkit-flex:1 1 auto}.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn:hover,.btn-group>.btn:focus,.btn-group>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn:hover,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn.active{z-index:1}.btn-toolbar{display:flex;display:-webkit-flex;flex-wrap:wrap;-webkit-flex-wrap:wrap;justify-content:flex-start;-webkit-justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:.25rem}.btn-group>:not(.btn-check:first-child)+.btn,.btn-group>.btn-group:not(:first-child){margin-left:calc(1px*-1)}.btn-group>.btn:not(:last-child):not(.dropdown-toggle),.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn-group:not(:last-child)>.btn{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn,.btn-group>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-sm+.dropdown-toggle-split,.btn-group-sm>.btn+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-lg+.dropdown-toggle-split,.btn-group-lg>.btn+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;-webkit-flex-direction:column;align-items:flex-start;-webkit-align-items:flex-start;justify-content:center;-webkit-justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn:not(:first-child),.btn-group-vertical>.btn-group:not(:first-child){margin-top:calc(1px*-1)}.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle),.btn-group-vertical>.btn-group:not(:last-child)>.btn{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn~.btn,.btn-group-vertical>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x: 1rem;--bs-nav-link-padding-y: 0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color: #df6919;--bs-nav-link-hover-color: rgb(178.4, 84, 20);--bs-nav-link-disabled-color: rgba(255, 255, 255, 0.4);display:flex;display:-webkit-flex;flex-wrap:wrap;-webkit-flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;-webkit-text-decoration:none;-moz-text-decoration:none;-ms-text-decoration:none;-o-text-decoration:none;background:none;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media(prefers-reduced-motion: reduce){.nav-link{transition:none}}.nav-link:hover,.nav-link:focus{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(223,105,25,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width: 1px;--bs-nav-tabs-border-color: rgb(59, 76.6, 91);--bs-nav-tabs-border-radius: 0.25rem;--bs-nav-tabs-link-hover-border-color: #dee2e6 #dee2e6 rgb(59, 76.6, 91);--bs-nav-tabs-link-active-color: #ebebeb;--bs-nav-tabs-link-active-bg: #0f2537;--bs-nav-tabs-link-active-border-color: rgb(59, 76.6, 91);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1*var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid rgba(0,0,0,0);border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:hover,.nav-tabs .nav-link:focus{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.active,.nav-tabs .nav-item.show .nav-link{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1*var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius: 0.25rem;--bs-nav-pills-link-active-color: #fff;--bs-nav-pills-link-active-bg: #df6919}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap: 1rem;--bs-nav-underline-border-width: 0.125rem;--bs-nav-underline-link-active-color: #000;gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid rgba(0,0,0,0)}.nav-underline .nav-link:hover,.nav-underline .nav-link:focus{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill>.nav-link,.nav-fill .nav-item{flex:1 1 auto;-webkit-flex:1 1 auto;text-align:center}.nav-justified>.nav-link,.nav-justified .nav-item{flex-basis:0;-webkit-flex-basis:0;flex-grow:1;-webkit-flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x: 0;--bs-navbar-padding-y: 0.5rem;--bs-navbar-color: rgb(229.52, 231.808, 233.68);--bs-navbar-hover-color: rgba(255, 255, 255, 0.8);--bs-navbar-disabled-color: rgba(229.52, 231.808, 233.68, 0.75);--bs-navbar-active-color: #fff;--bs-navbar-brand-padding-y: 0.3125rem;--bs-navbar-brand-margin-end: 1rem;--bs-navbar-brand-font-size: 1.25rem;--bs-navbar-brand-color: rgb(229.52, 231.808, 233.68);--bs-navbar-brand-hover-color: #fff;--bs-navbar-nav-link-padding-x: 0.5rem;--bs-navbar-toggler-padding-y: 0.25;--bs-navbar-toggler-padding-x: 0;--bs-navbar-toggler-font-size: 1.25rem;--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgb%28229.52, 231.808, 233.68%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color: rgba(229.52, 231.808, 233.68, 0);--bs-navbar-toggler-border-radius: 0.25rem;--bs-navbar-toggler-focus-width: 0.25rem;--bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out;position:relative;display:flex;display:-webkit-flex;flex-wrap:wrap;-webkit-flex-wrap:wrap;align-items:center;-webkit-align-items:center;justify-content:space-between;-webkit-justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-sm,.navbar>.container-md,.navbar>.container-lg,.navbar>.container-xl,.navbar>.container-xxl{display:flex;display:-webkit-flex;flex-wrap:inherit;-webkit-flex-wrap:inherit;align-items:center;-webkit-align-items:center;justify-content:space-between;-webkit-justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;-webkit-text-decoration:none;-moz-text-decoration:none;-ms-text-decoration:none;-o-text-decoration:none;white-space:nowrap}.navbar-brand:hover,.navbar-brand:focus{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x: 0;--bs-nav-link-padding-y: 0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color: var(--bs-navbar-color);--bs-nav-link-hover-color: var(--bs-navbar-hover-color);--bs-nav-link-disabled-color: var(--bs-navbar-disabled-color);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:hover,.navbar-text a:focus{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;-webkit-flex-basis:100%;flex-grow:1;-webkit-flex-grow:1;align-items:center;-webkit-align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:rgba(0,0,0,0);border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media(prefers-reduced-motion: reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height, 75vh);overflow-y:auto}@media(min-width: 576px){.navbar-expand-sm{flex-wrap:nowrap;-webkit-flex-wrap:nowrap;justify-content:flex-start;-webkit-justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row;-webkit-flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex !important;display:-webkit-flex !important;flex-basis:auto;-webkit-flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;-webkit-flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:rgba(0,0,0,0) !important;border:0 !important;transform:none !important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible}}@media(min-width: 768px){.navbar-expand-md{flex-wrap:nowrap;-webkit-flex-wrap:nowrap;justify-content:flex-start;-webkit-justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row;-webkit-flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex !important;display:-webkit-flex !important;flex-basis:auto;-webkit-flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;-webkit-flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:rgba(0,0,0,0) !important;border:0 !important;transform:none !important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible}}@media(min-width: 992px){.navbar-expand-lg{flex-wrap:nowrap;-webkit-flex-wrap:nowrap;justify-content:flex-start;-webkit-justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row;-webkit-flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex !important;display:-webkit-flex !important;flex-basis:auto;-webkit-flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;-webkit-flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:rgba(0,0,0,0) !important;border:0 !important;transform:none !important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible}}@media(min-width: 1200px){.navbar-expand-xl{flex-wrap:nowrap;-webkit-flex-wrap:nowrap;justify-content:flex-start;-webkit-justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row;-webkit-flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex !important;display:-webkit-flex !important;flex-basis:auto;-webkit-flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;-webkit-flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:rgba(0,0,0,0) !important;border:0 !important;transform:none !important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible}}@media(min-width: 1400px){.navbar-expand-xxl{flex-wrap:nowrap;-webkit-flex-wrap:nowrap;justify-content:flex-start;-webkit-justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row;-webkit-flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex !important;display:-webkit-flex !important;flex-basis:auto;-webkit-flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;-webkit-flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:rgba(0,0,0,0) !important;border:0 !important;transform:none !important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;-webkit-flex-wrap:nowrap;justify-content:flex-start;-webkit-justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row;-webkit-flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex !important;display:-webkit-flex !important;flex-basis:auto;-webkit-flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;-webkit-flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:rgba(0,0,0,0) !important;border:0 !important;transform:none !important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color: rgb(229.52, 231.808, 233.68);--bs-navbar-hover-color: rgba(255, 255, 255, 0.8);--bs-navbar-disabled-color: rgba(229.52, 231.808, 233.68, 0.75);--bs-navbar-active-color: #fff;--bs-navbar-brand-color: rgb(229.52, 231.808, 233.68);--bs-navbar-brand-hover-color: #fff;--bs-navbar-toggler-border-color: rgba(229.52, 231.808, 233.68, 0);--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgb%28229.52, 231.808, 233.68%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgb%28229.52, 231.808, 233.68%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y: 1rem;--bs-card-spacer-x: 1rem;--bs-card-title-spacer-y: 0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width: 1px;--bs-card-border-color: rgba(0, 0, 0, 0.175);--bs-card-border-radius: 0.25rem;--bs-card-box-shadow: ;--bs-card-inner-border-radius: 0;--bs-card-cap-padding-y: 0.5rem;--bs-card-cap-padding-x: 1rem;--bs-card-cap-bg: rgba(52, 58, 64, 0.25);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg: rgb(59, 76.6, 91);--bs-card-img-overlay-padding: 1rem;--bs-card-group-margin: 0.75rem;position:relative;display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;-webkit-flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-0.5*var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-0.5*var(--bs-card-cap-padding-x));margin-bottom:calc(-1*var(--bs-card-cap-padding-y));margin-left:calc(-0.5*var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-0.5*var(--bs-card-cap-padding-x));margin-left:calc(-0.5*var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-top,.card-img-bottom{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media(min-width: 576px){.card-group{display:flex;display:-webkit-flex;flex-flow:row wrap;-webkit-flex-flow:row wrap}.card-group>.card{flex:1 0 0%;-webkit-flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-img-top,.card-group>.card:not(:last-child) .card-header{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-img-bottom,.card-group>.card:not(:last-child) .card-footer{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-img-top,.card-group>.card:not(:first-child) .card-header{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-img-bottom,.card-group>.card:not(:first-child) .card-footer{border-bottom-left-radius:0}}.accordion{--bs-accordion-color: #ebebeb;--bs-accordion-bg: rgb(59, 76.6, 91);--bs-accordion-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, border-radius 0.15s ease;--bs-accordion-border-color: #dee2e6;--bs-accordion-border-width: 0;--bs-accordion-border-radius: 0.25rem;--bs-accordion-inner-border-radius: 0.25rem;--bs-accordion-btn-padding-x: 1.25rem;--bs-accordion-btn-padding-y: 1rem;--bs-accordion-btn-color: #ebebeb;--bs-accordion-btn-bg: rgba(52, 58, 64, 0.25);--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23ebebeb'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width: 1.25rem;--bs-accordion-btn-icon-transform: rotate(-180deg);--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='rgb%2889.2, 42, 10%29'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-focus-border-color: #efb48c;--bs-accordion-btn-focus-box-shadow: 0 0 0 0.25rem rgba(223, 105, 25, 0.25);--bs-accordion-body-padding-x: 1.25rem;--bs-accordion-body-padding-y: 1rem;--bs-accordion-active-color: #ebebeb;--bs-accordion-active-bg: #df6919}.accordion-button{position:relative;display:flex;display:-webkit-flex;align-items:center;-webkit-align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media(prefers-reduced-motion: reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1*var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;-webkit-flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media(prefers-reduced-motion: reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:var(--bs-accordion-btn-focus-border-color);outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button,.accordion-flush .accordion-item .accordion-button.collapsed{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='rgb%28235.8, 165, 117%29'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='rgb%28235.8, 165, 117%29'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x: 0.75rem;--bs-breadcrumb-padding-y: 0.375rem;--bs-breadcrumb-margin-bottom: 1rem;--bs-breadcrumb-bg: rgb(59, 76.6, 91);--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color: #ebebeb;--bs-breadcrumb-item-padding-x: 0.5rem;--bs-breadcrumb-item-active-color: #ebebeb;display:flex;display:-webkit-flex;flex-wrap:wrap;-webkit-flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, ">") /* rtl: var(--bs-breadcrumb-divider, ">") */}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x: 0.75rem;--bs-pagination-padding-y: 0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color: #fff;--bs-pagination-bg: rgb(59, 76.6, 91);--bs-pagination-border-width: 1px;--bs-pagination-border-color: transparent;--bs-pagination-border-radius: 0.25rem;--bs-pagination-hover-color: #fff;--bs-pagination-hover-bg: rgba(255, 255, 255, 0.4);--bs-pagination-hover-border-color: transparent;--bs-pagination-focus-color: rgb(178.4, 84, 20);--bs-pagination-focus-bg: #dee2e6;--bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(223, 105, 25, 0.25);--bs-pagination-active-color: #fff;--bs-pagination-active-bg: #df6919;--bs-pagination-active-border-color: #df6919;--bs-pagination-disabled-color: rgba(255, 255, 255, 0.4);--bs-pagination-disabled-bg: rgb(59, 76.6, 91);--bs-pagination-disabled-border-color: transparent;display:flex;display:-webkit-flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;-webkit-text-decoration:none;-moz-text-decoration:none;-ms-text-decoration:none;-o-text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media(prefers-reduced-motion: reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.page-link.active,.active>.page-link{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.page-link.disabled,.disabled>.page-link{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(1px*-1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x: 1.5rem;--bs-pagination-padding-y: 0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius: 0}.pagination-sm{--bs-pagination-padding-x: 0.5rem;--bs-pagination-padding-y: 0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius: 0.2em}.badge{--bs-badge-padding-x: 0.65em;--bs-badge-padding-y: 0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight: 700;--bs-badge-color: #fff;--bs-badge-border-radius: 0.25rem;display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg: transparent;--bs-alert-padding-x: 1rem;--bs-alert-padding-y: 1rem;--bs-alert-margin-bottom: 1rem;--bs-alert-color: inherit;--bs-alert-border-color: transparent;--bs-alert-border: 1px solid var(--bs-alert-border-color);--bs-alert-border-radius: 0.25rem;--bs-alert-link-color: inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-default{--bs-alert-color: var(--bs-default-text-emphasis);--bs-alert-bg: var(--bs-default-bg-subtle);--bs-alert-border-color: var(--bs-default-border-subtle);--bs-alert-link-color: var(--bs-default-text-emphasis)}.alert-primary{--bs-alert-color: var(--bs-primary-text-emphasis);--bs-alert-bg: var(--bs-primary-bg-subtle);--bs-alert-border-color: var(--bs-primary-border-subtle);--bs-alert-link-color: var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color: var(--bs-secondary-text-emphasis);--bs-alert-bg: var(--bs-secondary-bg-subtle);--bs-alert-border-color: var(--bs-secondary-border-subtle);--bs-alert-link-color: var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color: var(--bs-success-text-emphasis);--bs-alert-bg: var(--bs-success-bg-subtle);--bs-alert-border-color: var(--bs-success-border-subtle);--bs-alert-link-color: var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color: var(--bs-info-text-emphasis);--bs-alert-bg: var(--bs-info-bg-subtle);--bs-alert-border-color: var(--bs-info-border-subtle);--bs-alert-link-color: var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color: var(--bs-warning-text-emphasis);--bs-alert-bg: var(--bs-warning-bg-subtle);--bs-alert-border-color: var(--bs-warning-border-subtle);--bs-alert-link-color: var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color: var(--bs-danger-text-emphasis);--bs-alert-bg: var(--bs-danger-bg-subtle);--bs-alert-border-color: var(--bs-danger-border-subtle);--bs-alert-link-color: var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color: var(--bs-light-text-emphasis);--bs-alert-bg: var(--bs-light-bg-subtle);--bs-alert-border-color: var(--bs-light-border-subtle);--bs-alert-link-color: var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color: var(--bs-dark-text-emphasis);--bs-alert-bg: var(--bs-dark-bg-subtle);--bs-alert-border-color: var(--bs-dark-border-subtle);--bs-alert-link-color: var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress,.progress-stacked{--bs-progress-height: 1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg: rgb(59, 76.6, 91);--bs-progress-border-radius: 0.25rem;--bs-progress-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-progress-bar-color: #fff;--bs-progress-bar-bg: #df6919;--bs-progress-bar-transition: width 0.6s ease;display:flex;display:-webkit-flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;justify-content:center;-webkit-justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media(prefers-reduced-motion: reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media(prefers-reduced-motion: reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color: #fff;--bs-list-group-bg: rgb(59, 76.6, 91);--bs-list-group-border-color: transparent;--bs-list-group-border-width: 1px;--bs-list-group-border-radius: 0.25rem;--bs-list-group-item-padding-x: 1rem;--bs-list-group-item-padding-y: 0.5rem;--bs-list-group-action-color: #fff;--bs-list-group-action-hover-color: #fff;--bs-list-group-action-hover-bg: rgba(255, 255, 255, 0.4);--bs-list-group-action-active-color: #ebebeb;--bs-list-group-action-active-bg: #dee2e6;--bs-list-group-disabled-color: rgba(255, 255, 255, 0.4);--bs-list-group-disabled-bg: rgb(59, 76.6, 91);--bs-list-group-active-color: #fff;--bs-list-group-active-bg: #df6919;--bs-list-group-active-border-color: #df6919;display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:hover,.list-group-item-action:focus{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;-webkit-text-decoration:none;-moz-text-decoration:none;-ms-text-decoration:none;-o-text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1*var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row;-webkit-flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1*var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media(min-width: 576px){.list-group-horizontal-sm{flex-direction:row;-webkit-flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1*var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media(min-width: 768px){.list-group-horizontal-md{flex-direction:row;-webkit-flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1*var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media(min-width: 992px){.list-group-horizontal-lg{flex-direction:row;-webkit-flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1*var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media(min-width: 1200px){.list-group-horizontal-xl{flex-direction:row;-webkit-flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1*var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media(min-width: 1400px){.list-group-horizontal-xxl{flex-direction:row;-webkit-flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1*var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-default{--bs-list-group-color: var(--bs-default-text-emphasis);--bs-list-group-bg: var(--bs-default-bg-subtle);--bs-list-group-border-color: var(--bs-default-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-default-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-default-border-subtle);--bs-list-group-active-color: var(--bs-default-bg-subtle);--bs-list-group-active-bg: var(--bs-default-text-emphasis);--bs-list-group-active-border-color: var(--bs-default-text-emphasis)}.list-group-item-primary{--bs-list-group-color: var(--bs-primary-text-emphasis);--bs-list-group-bg: var(--bs-primary-bg-subtle);--bs-list-group-border-color: var(--bs-primary-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-primary-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-primary-border-subtle);--bs-list-group-active-color: var(--bs-primary-bg-subtle);--bs-list-group-active-bg: var(--bs-primary-text-emphasis);--bs-list-group-active-border-color: var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color: var(--bs-secondary-text-emphasis);--bs-list-group-bg: var(--bs-secondary-bg-subtle);--bs-list-group-border-color: var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-secondary-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-secondary-border-subtle);--bs-list-group-active-color: var(--bs-secondary-bg-subtle);--bs-list-group-active-bg: var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color: var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color: var(--bs-success-text-emphasis);--bs-list-group-bg: var(--bs-success-bg-subtle);--bs-list-group-border-color: var(--bs-success-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-success-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-success-border-subtle);--bs-list-group-active-color: var(--bs-success-bg-subtle);--bs-list-group-active-bg: var(--bs-success-text-emphasis);--bs-list-group-active-border-color: var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color: var(--bs-info-text-emphasis);--bs-list-group-bg: var(--bs-info-bg-subtle);--bs-list-group-border-color: var(--bs-info-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-info-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-info-border-subtle);--bs-list-group-active-color: var(--bs-info-bg-subtle);--bs-list-group-active-bg: var(--bs-info-text-emphasis);--bs-list-group-active-border-color: var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color: var(--bs-warning-text-emphasis);--bs-list-group-bg: var(--bs-warning-bg-subtle);--bs-list-group-border-color: var(--bs-warning-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-warning-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-warning-border-subtle);--bs-list-group-active-color: var(--bs-warning-bg-subtle);--bs-list-group-active-bg: var(--bs-warning-text-emphasis);--bs-list-group-active-border-color: var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color: var(--bs-danger-text-emphasis);--bs-list-group-bg: var(--bs-danger-bg-subtle);--bs-list-group-border-color: var(--bs-danger-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-danger-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-danger-border-subtle);--bs-list-group-active-color: var(--bs-danger-bg-subtle);--bs-list-group-active-bg: var(--bs-danger-text-emphasis);--bs-list-group-active-border-color: var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color: var(--bs-light-text-emphasis);--bs-list-group-bg: var(--bs-light-bg-subtle);--bs-list-group-border-color: var(--bs-light-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-light-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-light-border-subtle);--bs-list-group-active-color: var(--bs-light-bg-subtle);--bs-list-group-active-bg: var(--bs-light-text-emphasis);--bs-list-group-active-border-color: var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color: var(--bs-dark-text-emphasis);--bs-list-group-bg: var(--bs-dark-bg-subtle);--bs-list-group-border-color: var(--bs-dark-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-dark-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-dark-border-subtle);--bs-list-group-active-color: var(--bs-dark-bg-subtle);--bs-list-group-active-bg: var(--bs-dark-text-emphasis);--bs-list-group-active-border-color: var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color: #fff;--bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity: 0.5;--bs-btn-close-hover-opacity: 1;--bs-btn-close-focus-shadow: 0 0 0 0.25rem rgba(223, 105, 25, 0.25);--bs-btn-close-focus-opacity: 1;--bs-btn-close-disabled-opacity: 0.25;--bs-btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:rgba(0,0,0,0) var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.25rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close:disabled,.btn-close.disabled{pointer-events:none;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{filter:var(--bs-btn-close-white-filter)}[data-bs-theme=dark] .btn-close{filter:var(--bs-btn-close-white-filter)}.toast{--bs-toast-zindex: 1090;--bs-toast-padding-x: 0.75rem;--bs-toast-padding-y: 0.5rem;--bs-toast-spacing: 1.5rem;--bs-toast-max-width: 350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg: rgb(59, 76.6, 91);--bs-toast-border-width: 1px;--bs-toast-border-color: rgba(0, 0, 0, 0.2);--bs-toast-border-radius: 0.25rem;--bs-toast-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-toast-header-color: #ebebeb;--bs-toast-header-bg: rgb(59, 76.6, 91);--bs-toast-header-border-color: rgba(0, 0, 0, 0.2);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex: 1090;position:absolute;z-index:var(--bs-toast-zindex);width:max-content;width:-webkit-max-content;width:-moz-max-content;width:-ms-max-content;width:-o-max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;display:-webkit-flex;align-items:center;-webkit-align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-0.5*var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex: 1055;--bs-modal-width: 500px;--bs-modal-padding: 1rem;--bs-modal-margin: 0.5rem;--bs-modal-color: ;--bs-modal-bg: rgb(59, 76.6, 91);--bs-modal-border-color: rgba(0, 0, 0, 0.175);--bs-modal-border-width: 1px;--bs-modal-border-radius: 0;--bs-modal-box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-modal-inner-border-radius: -1px;--bs-modal-header-padding-x: 1rem;--bs-modal-header-padding-y: 1rem;--bs-modal-header-padding: 1rem 1rem;--bs-modal-header-border-color: rgba(0, 0, 0, 0.2);--bs-modal-header-border-width: 1px;--bs-modal-title-line-height: 1.5;--bs-modal-footer-gap: 0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color: rgba(0, 0, 0, 0.2);--bs-modal-footer-border-width: 1px;position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0, -50px)}@media(prefers-reduced-motion: reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin)*2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;display:-webkit-flex;align-items:center;-webkit-align-items:center;min-height:calc(100% - var(--bs-modal-margin)*2)}.modal-content{position:relative;display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex: 1050;--bs-backdrop-bg: #000;--bs-backdrop-opacity: 0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;display:-webkit-flex;flex-shrink:0;-webkit-flex-shrink:0;align-items:center;-webkit-align-items:center;justify-content:space-between;-webkit-justify-content:space-between;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y)*.5) calc(var(--bs-modal-header-padding-x)*.5);margin:calc(-0.5*var(--bs-modal-header-padding-y)) calc(-0.5*var(--bs-modal-header-padding-x)) calc(-0.5*var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;-webkit-flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;display:-webkit-flex;flex-shrink:0;-webkit-flex-shrink:0;flex-wrap:wrap;-webkit-flex-wrap:wrap;align-items:center;-webkit-align-items:center;justify-content:flex-end;-webkit-justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap)*.5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap)*.5)}@media(min-width: 576px){.modal{--bs-modal-margin: 1.75rem;--bs-modal-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width: 300px}}@media(min-width: 992px){.modal-lg,.modal-xl{--bs-modal-width: 800px}}@media(min-width: 1200px){.modal-xl{--bs-modal-width: 1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header,.modal-fullscreen .modal-footer{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media(max-width: 575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header,.modal-fullscreen-sm-down .modal-footer{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media(max-width: 767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header,.modal-fullscreen-md-down .modal-footer{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media(max-width: 991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header,.modal-fullscreen-lg-down .modal-footer{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media(max-width: 1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header,.modal-fullscreen-xl-down .modal-footer{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media(max-width: 1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header,.modal-fullscreen-xxl-down .modal-footer{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex: 1080;--bs-tooltip-max-width: 200px;--bs-tooltip-padding-x: 0.5rem;--bs-tooltip-padding-y: 0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color: #0f2537;--bs-tooltip-bg: #000;--bs-tooltip-border-radius: 0.25rem;--bs-tooltip-opacity: 0.9;--bs-tooltip-arrow-width: 0.8rem;--bs-tooltip-arrow-height: 0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:Lato,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:rgba(0,0,0,0);border-style:solid}.bs-tooltip-top .tooltip-arrow,.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow{bottom:calc(-1*var(--bs-tooltip-arrow-height))}.bs-tooltip-top .tooltip-arrow::before,.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width)*.5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-end .tooltip-arrow,.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow{left:calc(-1*var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-end .tooltip-arrow::before,.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width)*.5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width)*.5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-bottom .tooltip-arrow,.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow{top:calc(-1*var(--bs-tooltip-arrow-height))}.bs-tooltip-bottom .tooltip-arrow::before,.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width)*.5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-start .tooltip-arrow,.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow{right:calc(-1*var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-start .tooltip-arrow::before,.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width)*.5) 0 calc(var(--bs-tooltip-arrow-width)*.5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex: 1070;--bs-popover-max-width: 276px;--bs-popover-font-size:0.875rem;--bs-popover-bg: rgb(59, 76.6, 91);--bs-popover-border-width: 1px;--bs-popover-border-color: rgba(0, 0, 0, 0.175);--bs-popover-border-radius: 0;--bs-popover-inner-border-radius: calc(0 - 1px);--bs-popover-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-popover-header-padding-x: 1rem;--bs-popover-header-padding-y: 0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color: inherit;--bs-popover-header-bg: rgba(255, 255, 255, 0.075);--bs-popover-body-padding-x: 1rem;--bs-popover-body-padding-y: 1rem;--bs-popover-body-color: #ebebeb;--bs-popover-arrow-width: 1rem;--bs-popover-arrow-height: 0.5rem;--bs-popover-arrow-border: var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:Lato,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::before,.popover .popover-arrow::after{position:absolute;display:block;content:"";border-color:rgba(0,0,0,0);border-style:solid;border-width:0}.bs-popover-top>.popover-arrow,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow{bottom:calc(-1*(var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-top>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width)*.5) 0}.bs-popover-top>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-top>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-end>.popover-arrow,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow{left:calc(-1*(var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-end>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after{border-width:calc(var(--bs-popover-arrow-width)*.5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width)*.5) 0}.bs-popover-end>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-end>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-bottom>.popover-arrow,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow{top:calc(-1*(var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-bottom>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after{border-width:0 calc(var(--bs-popover-arrow-width)*.5) var(--bs-popover-arrow-height)}.bs-popover-bottom>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-bottom>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-bottom .popover-header::before,.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-0.5*var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-start>.popover-arrow,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow{right:calc(-1*(var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-start>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after{border-width:calc(var(--bs-popover-arrow-width)*.5) 0 calc(var(--bs-popover-arrow-width)*.5) var(--bs-popover-arrow-height)}.bs-popover-start>.popover-arrow::before,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-start>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y;-webkit-touch-action:pan-y;-moz-touch-action:pan-y;-ms-touch-action:pan-y;-o-touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;backface-visibility:hidden;-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;-ms-backface-visibility:hidden;-o-backface-visibility:hidden;transition:transform .6s ease-in-out}@media(prefers-reduced-motion: reduce){.carousel-item{transition:none}}.carousel-item.active,.carousel-item-next,.carousel-item-prev{display:block}.carousel-item-next:not(.carousel-item-start),.active.carousel-item-end{transform:translateX(100%)}.carousel-item-prev:not(.carousel-item-end),.active.carousel-item-start{transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item.active,.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end{z-index:1;opacity:1}.carousel-fade .active.carousel-item-start,.carousel-fade .active.carousel-item-end{z-index:0;opacity:0;transition:opacity 0s .6s}@media(prefers-reduced-motion: reduce){.carousel-fade .active.carousel-item-start,.carousel-fade .active.carousel-item-end{transition:none}}.carousel-control-prev,.carousel-control-next{position:absolute;top:0;bottom:0;z-index:1;display:flex;display:-webkit-flex;align-items:center;-webkit-align-items:center;justify-content:center;-webkit-justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:none;border:0;opacity:.5;transition:opacity .15s ease}@media(prefers-reduced-motion: reduce){.carousel-control-prev,.carousel-control-next{transition:none}}.carousel-control-prev:hover,.carousel-control-prev:focus,.carousel-control-next:hover,.carousel-control-next:focus{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-prev-icon,.carousel-control-next-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;display:-webkit-flex;justify-content:center;-webkit-justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;-webkit-flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid rgba(0,0,0,0);border-bottom:10px solid rgba(0,0,0,0);opacity:.5;transition:opacity .6s ease}@media(prefers-reduced-motion: reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-prev-icon,.carousel-dark .carousel-control-next-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}[data-bs-theme=dark] .carousel .carousel-control-prev-icon,[data-bs-theme=dark] .carousel .carousel-control-next-icon,[data-bs-theme=dark].carousel .carousel-control-prev-icon,[data-bs-theme=dark].carousel .carousel-control-next-icon{filter:invert(1) grayscale(100)}[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target],[data-bs-theme=dark].carousel .carousel-indicators [data-bs-target]{background-color:#000}[data-bs-theme=dark] .carousel .carousel-caption,[data-bs-theme=dark].carousel .carousel-caption{color:#000}.spinner-grow,.spinner-border{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg) /* rtl:ignore */}}.spinner-border{--bs-spinner-width: 2rem;--bs-spinner-height: 2rem;--bs-spinner-vertical-align: -0.125em;--bs-spinner-border-width: 0.25em;--bs-spinner-animation-speed: 0.75s;--bs-spinner-animation-name: spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:rgba(0,0,0,0)}.spinner-border-sm{--bs-spinner-width: 1rem;--bs-spinner-height: 1rem;--bs-spinner-border-width: 0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width: 2rem;--bs-spinner-height: 2rem;--bs-spinner-vertical-align: -0.125em;--bs-spinner-animation-speed: 0.75s;--bs-spinner-animation-name: spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width: 1rem;--bs-spinner-height: 1rem}@media(prefers-reduced-motion: reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed: 1.5s}}.offcanvas,.offcanvas-xxl,.offcanvas-xl,.offcanvas-lg,.offcanvas-md,.offcanvas-sm{--bs-offcanvas-zindex: 1045;--bs-offcanvas-width: 400px;--bs-offcanvas-height: 30vh;--bs-offcanvas-padding-x: 1rem;--bs-offcanvas-padding-y: 1rem;--bs-offcanvas-color: #ebebeb;--bs-offcanvas-bg: #0f2537;--bs-offcanvas-border-width: 1px;--bs-offcanvas-border-color: rgba(0, 0, 0, 0.175);--bs-offcanvas-box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-offcanvas-transition: transform 0.3s ease-in-out;--bs-offcanvas-title-line-height: 1.5}@media(max-width: 575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media(max-width: 575.98px)and (prefers-reduced-motion: reduce){.offcanvas-sm{transition:none}}@media(max-width: 575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.showing,.offcanvas-sm.show:not(.hiding){transform:none}.offcanvas-sm.showing,.offcanvas-sm.hiding,.offcanvas-sm.show{visibility:visible}}@media(min-width: 576px){.offcanvas-sm{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:rgba(0,0,0,0) !important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible;background-color:rgba(0,0,0,0) !important}}@media(max-width: 767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media(max-width: 767.98px)and (prefers-reduced-motion: reduce){.offcanvas-md{transition:none}}@media(max-width: 767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.showing,.offcanvas-md.show:not(.hiding){transform:none}.offcanvas-md.showing,.offcanvas-md.hiding,.offcanvas-md.show{visibility:visible}}@media(min-width: 768px){.offcanvas-md{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:rgba(0,0,0,0) !important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible;background-color:rgba(0,0,0,0) !important}}@media(max-width: 991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media(max-width: 991.98px)and (prefers-reduced-motion: reduce){.offcanvas-lg{transition:none}}@media(max-width: 991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.showing,.offcanvas-lg.show:not(.hiding){transform:none}.offcanvas-lg.showing,.offcanvas-lg.hiding,.offcanvas-lg.show{visibility:visible}}@media(min-width: 992px){.offcanvas-lg{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:rgba(0,0,0,0) !important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible;background-color:rgba(0,0,0,0) !important}}@media(max-width: 1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media(max-width: 1199.98px)and (prefers-reduced-motion: reduce){.offcanvas-xl{transition:none}}@media(max-width: 1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.showing,.offcanvas-xl.show:not(.hiding){transform:none}.offcanvas-xl.showing,.offcanvas-xl.hiding,.offcanvas-xl.show{visibility:visible}}@media(min-width: 1200px){.offcanvas-xl{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:rgba(0,0,0,0) !important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible;background-color:rgba(0,0,0,0) !important}}@media(max-width: 1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media(max-width: 1399.98px)and (prefers-reduced-motion: reduce){.offcanvas-xxl{transition:none}}@media(max-width: 1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.showing,.offcanvas-xxl.show:not(.hiding){transform:none}.offcanvas-xxl.showing,.offcanvas-xxl.hiding,.offcanvas-xxl.show{visibility:visible}}@media(min-width: 1400px){.offcanvas-xxl{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:rgba(0,0,0,0) !important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;display:-webkit-flex;flex-grow:0;-webkit-flex-grow:0;padding:0;overflow-y:visible;background-color:rgba(0,0,0,0) !important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;display:-webkit-flex;flex-direction:column;-webkit-flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media(prefers-reduced-motion: reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.showing,.offcanvas.show:not(.hiding){transform:none}.offcanvas.showing,.offcanvas.hiding,.offcanvas.show{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;display:-webkit-flex;align-items:center;-webkit-align-items:center;justify-content:space-between;-webkit-justify-content:space-between;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y)*.5) calc(var(--bs-offcanvas-padding-x)*.5);margin-top:calc(-0.5*var(--bs-offcanvas-padding-y));margin-right:calc(-0.5*var(--bs-offcanvas-padding-x));margin-bottom:calc(-0.5*var(--bs-offcanvas-padding-y))}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;-webkit-flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{mask-image:linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%);-webkit-mask-image:linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%);mask-size:200% 100%;-webkit-mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{mask-position:-200% 0%;-webkit-mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-default{color:#fff !important;background-color:RGBA(var(--bs-default-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-primary{color:#fff !important;background-color:RGBA(var(--bs-primary-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-secondary{color:#fff !important;background-color:RGBA(var(--bs-secondary-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-success{color:#fff !important;background-color:RGBA(var(--bs-success-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-info{color:#fff !important;background-color:RGBA(var(--bs-info-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-warning{color:#fff !important;background-color:RGBA(var(--bs-warning-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-danger{color:#fff !important;background-color:RGBA(var(--bs-danger-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-light{color:#fff !important;background-color:RGBA(var(--bs-light-rgb), var(--bs-bg-opacity, 1)) !important}.text-bg-dark{color:#fff !important;background-color:RGBA(var(--bs-dark-rgb), var(--bs-bg-opacity, 1)) !important}.link-default{color:RGBA(var(--bs-default-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-default-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-default:hover,.link-default:focus{color:RGBA(47, 61, 73, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(47, 61, 73, var(--bs-link-underline-opacity, 1)) !important}.link-primary{color:RGBA(var(--bs-primary-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-primary-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-primary:hover,.link-primary:focus{color:RGBA(178, 84, 20, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(178, 84, 20, var(--bs-link-underline-opacity, 1)) !important}.link-secondary{color:RGBA(var(--bs-secondary-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-secondary-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-secondary:hover,.link-secondary:focus{color:RGBA(47, 61, 73, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(47, 61, 73, var(--bs-link-underline-opacity, 1)) !important}.link-success{color:RGBA(var(--bs-success-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-success-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-success:hover,.link-success:focus{color:RGBA(74, 147, 74, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(74, 147, 74, var(--bs-link-underline-opacity, 1)) !important}.link-info{color:RGBA(var(--bs-info-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-info-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-info:hover,.link-info:focus{color:RGBA(73, 154, 178, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(73, 154, 178, var(--bs-link-underline-opacity, 1)) !important}.link-warning{color:RGBA(var(--bs-warning-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-warning-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-warning:hover,.link-warning:focus{color:RGBA(204, 154, 6, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(204, 154, 6, var(--bs-link-underline-opacity, 1)) !important}.link-danger{color:RGBA(var(--bs-danger-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-danger-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-danger:hover,.link-danger:focus{color:RGBA(174, 66, 63, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(174, 66, 63, var(--bs-link-underline-opacity, 1)) !important}.link-light{color:RGBA(var(--bs-light-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-light-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-light:hover,.link-light:focus{color:RGBA(104, 112, 119, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(104, 112, 119, var(--bs-link-underline-opacity, 1)) !important}.link-dark{color:RGBA(var(--bs-dark-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-dark-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-dark:hover,.link-dark:focus{color:RGBA(47, 61, 73, var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(47, 61, 73, var(--bs-link-underline-opacity, 1)) !important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-opacity, 1)) !important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-body-emphasis:hover,.link-body-emphasis:focus{color:RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-opacity, 0.75)) !important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 0.75)) !important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x, 0) var(--bs-focus-ring-y, 0) var(--bs-focus-ring-blur, 0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-align-items:center;text-decoration-color:rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 0.5));text-underline-offset:.25em;backface-visibility:hidden;-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;-ms-backface-visibility:hidden;-o-backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;-webkit-flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media(prefers-reduced-motion: reduce){.icon-link>.bi{transition:none}}.icon-link-hover:hover>.bi,.icon-link-hover:focus-visible>.bi{transform:var(--bs-icon-link-transform, translate3d(0.25em, 0, 0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio: 100%}.ratio-4x3{--bs-aspect-ratio: 75%}.ratio-16x9{--bs-aspect-ratio: 56.25%}.ratio-21x9{--bs-aspect-ratio: 42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:sticky;top:0;z-index:1020}.sticky-bottom{position:sticky;bottom:0;z-index:1020}@media(min-width: 576px){.sticky-sm-top{position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:sticky;bottom:0;z-index:1020}}@media(min-width: 768px){.sticky-md-top{position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:sticky;bottom:0;z-index:1020}}@media(min-width: 992px){.sticky-lg-top{position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:sticky;bottom:0;z-index:1020}}@media(min-width: 1200px){.sticky-xl-top{position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:sticky;bottom:0;z-index:1020}}@media(min-width: 1400px){.sticky-xxl-top{position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;display:-webkit-flex;flex-direction:row;-webkit-flex-direction:row;align-items:center;-webkit-align-items:center;align-self:stretch;-webkit-align-self:stretch}.vstack{display:flex;display:-webkit-flex;flex:1 1 auto;-webkit-flex:1 1 auto;flex-direction:column;-webkit-flex-direction:column;align-self:stretch;-webkit-align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px !important;height:1px !important;padding:0 !important;margin:-1px !important;overflow:hidden !important;clip:rect(0, 0, 0, 0) !important;white-space:nowrap !important;border:0 !important}.visually-hidden:not(caption),.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption){position:absolute !important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;-webkit-align-self:stretch;width:1px;min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline !important}.align-top{vertical-align:top !important}.align-middle{vertical-align:middle !important}.align-bottom{vertical-align:bottom !important}.align-text-bottom{vertical-align:text-bottom !important}.align-text-top{vertical-align:text-top !important}.float-start{float:left !important}.float-end{float:right !important}.float-none{float:none !important}.object-fit-contain{object-fit:contain !important}.object-fit-cover{object-fit:cover !important}.object-fit-fill{object-fit:fill !important}.object-fit-scale{object-fit:scale-down !important}.object-fit-none{object-fit:none !important}.opacity-0{opacity:0 !important}.opacity-25{opacity:.25 !important}.opacity-50{opacity:.5 !important}.opacity-75{opacity:.75 !important}.opacity-100{opacity:1 !important}.overflow-auto{overflow:auto !important}.overflow-hidden{overflow:hidden !important}.overflow-visible{overflow:visible !important}.overflow-scroll{overflow:scroll !important}.overflow-x-auto{overflow-x:auto !important}.overflow-x-hidden{overflow-x:hidden !important}.overflow-x-visible{overflow-x:visible !important}.overflow-x-scroll{overflow-x:scroll !important}.overflow-y-auto{overflow-y:auto !important}.overflow-y-hidden{overflow-y:hidden !important}.overflow-y-visible{overflow-y:visible !important}.overflow-y-scroll{overflow-y:scroll !important}.d-inline{display:inline !important}.d-inline-block{display:inline-block !important}.d-block{display:block !important}.d-grid{display:grid !important}.d-inline-grid{display:inline-grid !important}.d-table{display:table !important}.d-table-row{display:table-row !important}.d-table-cell{display:table-cell !important}.d-flex{display:flex !important}.d-inline-flex{display:inline-flex !important}.d-none{display:none !important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15) !important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075) !important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175) !important}.shadow-none{box-shadow:none !important}.focus-ring-default{--bs-focus-ring-color: rgba(var(--bs-default-rgb), var(--bs-focus-ring-opacity))}.focus-ring-primary{--bs-focus-ring-color: rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color: rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color: rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color: rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color: rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color: rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color: rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color: rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static !important}.position-relative{position:relative !important}.position-absolute{position:absolute !important}.position-fixed{position:fixed !important}.position-sticky{position:sticky !important}.top-0{top:0 !important}.top-50{top:50% !important}.top-100{top:100% !important}.bottom-0{bottom:0 !important}.bottom-50{bottom:50% !important}.bottom-100{bottom:100% !important}.start-0{left:0 !important}.start-50{left:50% !important}.start-100{left:100% !important}.end-0{right:0 !important}.end-50{right:50% !important}.end-100{right:100% !important}.translate-middle{transform:translate(-50%, -50%) !important}.translate-middle-x{transform:translateX(-50%) !important}.translate-middle-y{transform:translateY(-50%) !important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important}.border-0{border:0 !important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important}.border-top-0{border-top:0 !important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important}.border-end-0{border-right:0 !important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important}.border-bottom-0{border-bottom:0 !important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important}.border-start-0{border-left:0 !important}.border-default{--bs-border-opacity: 1;border-color:rgba(var(--bs-default-rgb), var(--bs-border-opacity)) !important}.border-primary{--bs-border-opacity: 1;border-color:rgba(var(--bs-primary-rgb), var(--bs-border-opacity)) !important}.border-secondary{--bs-border-opacity: 1;border-color:rgba(var(--bs-secondary-rgb), var(--bs-border-opacity)) !important}.border-success{--bs-border-opacity: 1;border-color:rgba(var(--bs-success-rgb), var(--bs-border-opacity)) !important}.border-info{--bs-border-opacity: 1;border-color:rgba(var(--bs-info-rgb), var(--bs-border-opacity)) !important}.border-warning{--bs-border-opacity: 1;border-color:rgba(var(--bs-warning-rgb), var(--bs-border-opacity)) !important}.border-danger{--bs-border-opacity: 1;border-color:rgba(var(--bs-danger-rgb), var(--bs-border-opacity)) !important}.border-light{--bs-border-opacity: 1;border-color:rgba(var(--bs-light-rgb), var(--bs-border-opacity)) !important}.border-dark{--bs-border-opacity: 1;border-color:rgba(var(--bs-dark-rgb), var(--bs-border-opacity)) !important}.border-black{--bs-border-opacity: 1;border-color:rgba(var(--bs-black-rgb), var(--bs-border-opacity)) !important}.border-white{--bs-border-opacity: 1;border-color:rgba(var(--bs-white-rgb), var(--bs-border-opacity)) !important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle) !important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle) !important}.border-success-subtle{border-color:var(--bs-success-border-subtle) !important}.border-info-subtle{border-color:var(--bs-info-border-subtle) !important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle) !important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle) !important}.border-light-subtle{border-color:var(--bs-light-border-subtle) !important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle) !important}.border-1{border-width:1px !important}.border-2{border-width:2px !important}.border-3{border-width:3px !important}.border-4{border-width:4px !important}.border-5{border-width:5px !important}.border-opacity-10{--bs-border-opacity: 0.1}.border-opacity-25{--bs-border-opacity: 0.25}.border-opacity-50{--bs-border-opacity: 0.5}.border-opacity-75{--bs-border-opacity: 0.75}.border-opacity-100{--bs-border-opacity: 1}.w-25{width:25% !important}.w-50{width:50% !important}.w-75{width:75% !important}.w-100{width:100% !important}.w-auto{width:auto !important}.mw-100{max-width:100% !important}.vw-100{width:100vw !important}.min-vw-100{min-width:100vw !important}.h-25{height:25% !important}.h-50{height:50% !important}.h-75{height:75% !important}.h-100{height:100% !important}.h-auto{height:auto !important}.mh-100{max-height:100% !important}.vh-100{height:100vh !important}.min-vh-100{min-height:100vh !important}.flex-fill{flex:1 1 auto !important}.flex-row{flex-direction:row !important}.flex-column{flex-direction:column !important}.flex-row-reverse{flex-direction:row-reverse !important}.flex-column-reverse{flex-direction:column-reverse !important}.flex-grow-0{flex-grow:0 !important}.flex-grow-1{flex-grow:1 !important}.flex-shrink-0{flex-shrink:0 !important}.flex-shrink-1{flex-shrink:1 !important}.flex-wrap{flex-wrap:wrap !important}.flex-nowrap{flex-wrap:nowrap !important}.flex-wrap-reverse{flex-wrap:wrap-reverse !important}.justify-content-start{justify-content:flex-start !important}.justify-content-end{justify-content:flex-end !important}.justify-content-center{justify-content:center !important}.justify-content-between{justify-content:space-between !important}.justify-content-around{justify-content:space-around !important}.justify-content-evenly{justify-content:space-evenly !important}.align-items-start{align-items:flex-start !important}.align-items-end{align-items:flex-end !important}.align-items-center{align-items:center !important}.align-items-baseline{align-items:baseline !important}.align-items-stretch{align-items:stretch !important}.align-content-start{align-content:flex-start !important}.align-content-end{align-content:flex-end !important}.align-content-center{align-content:center !important}.align-content-between{align-content:space-between !important}.align-content-around{align-content:space-around !important}.align-content-stretch{align-content:stretch !important}.align-self-auto{align-self:auto !important}.align-self-start{align-self:flex-start !important}.align-self-end{align-self:flex-end !important}.align-self-center{align-self:center !important}.align-self-baseline{align-self:baseline !important}.align-self-stretch{align-self:stretch !important}.order-first{order:-1 !important}.order-0{order:0 !important}.order-1{order:1 !important}.order-2{order:2 !important}.order-3{order:3 !important}.order-4{order:4 !important}.order-5{order:5 !important}.order-last{order:6 !important}.m-0{margin:0 !important}.m-1{margin:.25rem !important}.m-2{margin:.5rem !important}.m-3{margin:1rem !important}.m-4{margin:1.5rem !important}.m-5{margin:3rem !important}.m-auto{margin:auto !important}.mx-0{margin-right:0 !important;margin-left:0 !important}.mx-1{margin-right:.25rem !important;margin-left:.25rem !important}.mx-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-3{margin-right:1rem !important;margin-left:1rem !important}.mx-4{margin-right:1.5rem !important;margin-left:1.5rem !important}.mx-5{margin-right:3rem !important;margin-left:3rem !important}.mx-auto{margin-right:auto !important;margin-left:auto !important}.my-0{margin-top:0 !important;margin-bottom:0 !important}.my-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.my-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.my-3{margin-top:1rem !important;margin-bottom:1rem !important}.my-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.my-5{margin-top:3rem !important;margin-bottom:3rem !important}.my-auto{margin-top:auto !important;margin-bottom:auto !important}.mt-0{margin-top:0 !important}.mt-1{margin-top:.25rem !important}.mt-2{margin-top:.5rem !important}.mt-3{margin-top:1rem !important}.mt-4{margin-top:1.5rem !important}.mt-5{margin-top:3rem !important}.mt-auto{margin-top:auto !important}.me-0{margin-right:0 !important}.me-1{margin-right:.25rem !important}.me-2{margin-right:.5rem !important}.me-3{margin-right:1rem !important}.me-4{margin-right:1.5rem !important}.me-5{margin-right:3rem !important}.me-auto{margin-right:auto !important}.mb-0{margin-bottom:0 !important}.mb-1{margin-bottom:.25rem !important}.mb-2{margin-bottom:.5rem !important}.mb-3{margin-bottom:1rem !important}.mb-4{margin-bottom:1.5rem !important}.mb-5{margin-bottom:3rem !important}.mb-auto{margin-bottom:auto !important}.ms-0{margin-left:0 !important}.ms-1{margin-left:.25rem !important}.ms-2{margin-left:.5rem !important}.ms-3{margin-left:1rem !important}.ms-4{margin-left:1.5rem !important}.ms-5{margin-left:3rem !important}.ms-auto{margin-left:auto !important}.p-0{padding:0 !important}.p-1{padding:.25rem !important}.p-2{padding:.5rem !important}.p-3{padding:1rem !important}.p-4{padding:1.5rem !important}.p-5{padding:3rem !important}.px-0{padding-right:0 !important;padding-left:0 !important}.px-1{padding-right:.25rem !important;padding-left:.25rem !important}.px-2{padding-right:.5rem !important;padding-left:.5rem !important}.px-3{padding-right:1rem !important;padding-left:1rem !important}.px-4{padding-right:1.5rem !important;padding-left:1.5rem !important}.px-5{padding-right:3rem !important;padding-left:3rem !important}.py-0{padding-top:0 !important;padding-bottom:0 !important}.py-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.py-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.py-3{padding-top:1rem !important;padding-bottom:1rem !important}.py-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.py-5{padding-top:3rem !important;padding-bottom:3rem !important}.pt-0{padding-top:0 !important}.pt-1{padding-top:.25rem !important}.pt-2{padding-top:.5rem !important}.pt-3{padding-top:1rem !important}.pt-4{padding-top:1.5rem !important}.pt-5{padding-top:3rem !important}.pe-0{padding-right:0 !important}.pe-1{padding-right:.25rem !important}.pe-2{padding-right:.5rem !important}.pe-3{padding-right:1rem !important}.pe-4{padding-right:1.5rem !important}.pe-5{padding-right:3rem !important}.pb-0{padding-bottom:0 !important}.pb-1{padding-bottom:.25rem !important}.pb-2{padding-bottom:.5rem !important}.pb-3{padding-bottom:1rem !important}.pb-4{padding-bottom:1.5rem !important}.pb-5{padding-bottom:3rem !important}.ps-0{padding-left:0 !important}.ps-1{padding-left:.25rem !important}.ps-2{padding-left:.5rem !important}.ps-3{padding-left:1rem !important}.ps-4{padding-left:1.5rem !important}.ps-5{padding-left:3rem !important}.gap-0{gap:0 !important}.gap-1{gap:.25rem !important}.gap-2{gap:.5rem !important}.gap-3{gap:1rem !important}.gap-4{gap:1.5rem !important}.gap-5{gap:3rem !important}.row-gap-0{row-gap:0 !important}.row-gap-1{row-gap:.25rem !important}.row-gap-2{row-gap:.5rem !important}.row-gap-3{row-gap:1rem !important}.row-gap-4{row-gap:1.5rem !important}.row-gap-5{row-gap:3rem !important}.column-gap-0{column-gap:0 !important}.column-gap-1{column-gap:.25rem !important}.column-gap-2{column-gap:.5rem !important}.column-gap-3{column-gap:1rem !important}.column-gap-4{column-gap:1.5rem !important}.column-gap-5{column-gap:3rem !important}.font-monospace{font-family:var(--bs-font-monospace) !important}.fs-1{font-size:calc(1.325rem + 0.9vw) !important}.fs-2{font-size:calc(1.29rem + 0.48vw) !important}.fs-3{font-size:calc(1.27rem + 0.24vw) !important}.fs-4{font-size:1.25rem !important}.fs-5{font-size:1.1rem !important}.fs-6{font-size:1rem !important}.fst-italic{font-style:italic !important}.fst-normal{font-style:normal !important}.fw-lighter{font-weight:lighter !important}.fw-light{font-weight:300 !important}.fw-normal{font-weight:400 !important}.fw-medium{font-weight:500 !important}.fw-semibold{font-weight:600 !important}.fw-bold{font-weight:700 !important}.fw-bolder{font-weight:bolder !important}.lh-1{line-height:1 !important}.lh-sm{line-height:1.25 !important}.lh-base{line-height:1.5 !important}.lh-lg{line-height:2 !important}.text-start{text-align:left !important}.text-end{text-align:right !important}.text-center{text-align:center !important}.text-decoration-none{text-decoration:none !important}.text-decoration-underline{text-decoration:underline !important}.text-decoration-line-through{text-decoration:line-through !important}.text-lowercase{text-transform:lowercase !important}.text-uppercase{text-transform:uppercase !important}.text-capitalize{text-transform:capitalize !important}.text-wrap{white-space:normal !important}.text-nowrap{white-space:nowrap !important}.text-break{word-wrap:break-word !important;word-break:break-word !important}.text-default{--bs-text-opacity: 1;color:rgba(var(--bs-default-rgb), var(--bs-text-opacity)) !important}.text-primary{--bs-text-opacity: 1;color:rgba(var(--bs-primary-rgb), var(--bs-text-opacity)) !important}.text-secondary{--bs-text-opacity: 1;color:rgba(var(--bs-secondary-rgb), var(--bs-text-opacity)) !important}.text-success{--bs-text-opacity: 1;color:rgba(var(--bs-success-rgb), var(--bs-text-opacity)) !important}.text-info{--bs-text-opacity: 1;color:rgba(var(--bs-info-rgb), var(--bs-text-opacity)) !important}.text-warning{--bs-text-opacity: 1;color:rgba(var(--bs-warning-rgb), var(--bs-text-opacity)) !important}.text-danger{--bs-text-opacity: 1;color:rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important}.text-light{--bs-text-opacity: 1;color:rgba(var(--bs-light-rgb), var(--bs-text-opacity)) !important}.text-dark{--bs-text-opacity: 1;color:rgba(var(--bs-dark-rgb), var(--bs-text-opacity)) !important}.text-black{--bs-text-opacity: 1;color:rgba(var(--bs-black-rgb), var(--bs-text-opacity)) !important}.text-white{--bs-text-opacity: 1;color:rgba(var(--bs-white-rgb), var(--bs-text-opacity)) !important}.text-body{--bs-text-opacity: 1;color:rgba(var(--bs-body-color-rgb), var(--bs-text-opacity)) !important}.text-muted{--bs-text-opacity: 1;color:var(--bs-secondary-color) !important}.text-black-50{--bs-text-opacity: 1;color:rgba(0,0,0,.5) !important}.text-white-50{--bs-text-opacity: 1;color:hsla(0,0%,100%,.5) !important}.text-body-secondary{--bs-text-opacity: 1;color:var(--bs-secondary-color) !important}.text-body-tertiary{--bs-text-opacity: 1;color:var(--bs-tertiary-color) !important}.text-body-emphasis{--bs-text-opacity: 1;color:var(--bs-emphasis-color) !important}.text-reset{--bs-text-opacity: 1;color:inherit !important}.text-opacity-25{--bs-text-opacity: 0.25}.text-opacity-50{--bs-text-opacity: 0.5}.text-opacity-75{--bs-text-opacity: 0.75}.text-opacity-100{--bs-text-opacity: 1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis) !important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis) !important}.text-success-emphasis{color:var(--bs-success-text-emphasis) !important}.text-info-emphasis{color:var(--bs-info-text-emphasis) !important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis) !important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis) !important}.text-light-emphasis{color:var(--bs-light-text-emphasis) !important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis) !important}.link-opacity-10{--bs-link-opacity: 0.1}.link-opacity-10-hover:hover{--bs-link-opacity: 0.1}.link-opacity-25{--bs-link-opacity: 0.25}.link-opacity-25-hover:hover{--bs-link-opacity: 0.25}.link-opacity-50{--bs-link-opacity: 0.5}.link-opacity-50-hover:hover{--bs-link-opacity: 0.5}.link-opacity-75{--bs-link-opacity: 0.75}.link-opacity-75-hover:hover{--bs-link-opacity: 0.75}.link-opacity-100{--bs-link-opacity: 1}.link-opacity-100-hover:hover{--bs-link-opacity: 1}.link-offset-1{text-underline-offset:.125em !important}.link-offset-1-hover:hover{text-underline-offset:.125em !important}.link-offset-2{text-underline-offset:.25em !important}.link-offset-2-hover:hover{text-underline-offset:.25em !important}.link-offset-3{text-underline-offset:.375em !important}.link-offset-3-hover:hover{text-underline-offset:.375em !important}.link-underline-default{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-default-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-primary{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-primary-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-secondary{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-secondary-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-success{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-success-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-info{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-info-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-warning{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-warning-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-danger{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-danger-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-light{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-light-rgb), var(--bs-link-underline-opacity)) !important}.link-underline-dark{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-dark-rgb), var(--bs-link-underline-opacity)) !important}.link-underline{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-link-color-rgb), var(--bs-link-underline-opacity, 1)) !important}.link-underline-opacity-0{--bs-link-underline-opacity: 0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity: 0}.link-underline-opacity-10{--bs-link-underline-opacity: 0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity: 0.1}.link-underline-opacity-25{--bs-link-underline-opacity: 0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity: 0.25}.link-underline-opacity-50{--bs-link-underline-opacity: 0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity: 0.5}.link-underline-opacity-75{--bs-link-underline-opacity: 0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity: 0.75}.link-underline-opacity-100{--bs-link-underline-opacity: 1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity: 1}.bg-default{--bs-bg-opacity: 1;background-color:rgba(var(--bs-default-rgb), var(--bs-bg-opacity)) !important}.bg-primary{--bs-bg-opacity: 1;background-color:rgba(var(--bs-primary-rgb), var(--bs-bg-opacity)) !important}.bg-secondary{--bs-bg-opacity: 1;background-color:rgba(var(--bs-secondary-rgb), var(--bs-bg-opacity)) !important}.bg-success{--bs-bg-opacity: 1;background-color:rgba(var(--bs-success-rgb), var(--bs-bg-opacity)) !important}.bg-info{--bs-bg-opacity: 1;background-color:rgba(var(--bs-info-rgb), var(--bs-bg-opacity)) !important}.bg-warning{--bs-bg-opacity: 1;background-color:rgba(var(--bs-warning-rgb), var(--bs-bg-opacity)) !important}.bg-danger{--bs-bg-opacity: 1;background-color:rgba(var(--bs-danger-rgb), var(--bs-bg-opacity)) !important}.bg-light{--bs-bg-opacity: 1;background-color:rgba(var(--bs-light-rgb), var(--bs-bg-opacity)) !important}.bg-dark{--bs-bg-opacity: 1;background-color:rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important}.bg-black{--bs-bg-opacity: 1;background-color:rgba(var(--bs-black-rgb), var(--bs-bg-opacity)) !important}.bg-white{--bs-bg-opacity: 1;background-color:rgba(var(--bs-white-rgb), var(--bs-bg-opacity)) !important}.bg-body{--bs-bg-opacity: 1;background-color:rgba(var(--bs-body-bg-rgb), var(--bs-bg-opacity)) !important}.bg-transparent{--bs-bg-opacity: 1;background-color:rgba(0,0,0,0) !important}.bg-body-secondary{--bs-bg-opacity: 1;background-color:rgba(var(--bs-secondary-bg-rgb), var(--bs-bg-opacity)) !important}.bg-body-tertiary{--bs-bg-opacity: 1;background-color:rgba(var(--bs-tertiary-bg-rgb), var(--bs-bg-opacity)) !important}.bg-opacity-10{--bs-bg-opacity: 0.1}.bg-opacity-25{--bs-bg-opacity: 0.25}.bg-opacity-50{--bs-bg-opacity: 0.5}.bg-opacity-75{--bs-bg-opacity: 0.75}.bg-opacity-100{--bs-bg-opacity: 1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle) !important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle) !important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle) !important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle) !important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle) !important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle) !important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle) !important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle) !important}.bg-gradient{background-image:var(--bs-gradient) !important}.user-select-all{user-select:all !important}.user-select-auto{user-select:auto !important}.user-select-none{user-select:none !important}.pe-none{pointer-events:none !important}.pe-auto{pointer-events:auto !important}.rounded{border-radius:var(--bs-border-radius) !important}.rounded-0{border-radius:0 !important}.rounded-1{border-radius:var(--bs-border-radius-sm) !important}.rounded-2{border-radius:var(--bs-border-radius) !important}.rounded-3{border-radius:var(--bs-border-radius-lg) !important}.rounded-4{border-radius:var(--bs-border-radius-xl) !important}.rounded-5{border-radius:var(--bs-border-radius-xxl) !important}.rounded-circle{border-radius:50% !important}.rounded-pill{border-radius:var(--bs-border-radius-pill) !important}.rounded-top{border-top-left-radius:var(--bs-border-radius) !important;border-top-right-radius:var(--bs-border-radius) !important}.rounded-top-0{border-top-left-radius:0 !important;border-top-right-radius:0 !important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm) !important;border-top-right-radius:var(--bs-border-radius-sm) !important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius) !important;border-top-right-radius:var(--bs-border-radius) !important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg) !important;border-top-right-radius:var(--bs-border-radius-lg) !important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl) !important;border-top-right-radius:var(--bs-border-radius-xl) !important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl) !important;border-top-right-radius:var(--bs-border-radius-xxl) !important}.rounded-top-circle{border-top-left-radius:50% !important;border-top-right-radius:50% !important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill) !important;border-top-right-radius:var(--bs-border-radius-pill) !important}.rounded-end{border-top-right-radius:var(--bs-border-radius) !important;border-bottom-right-radius:var(--bs-border-radius) !important}.rounded-end-0{border-top-right-radius:0 !important;border-bottom-right-radius:0 !important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm) !important;border-bottom-right-radius:var(--bs-border-radius-sm) !important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius) !important;border-bottom-right-radius:var(--bs-border-radius) !important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg) !important;border-bottom-right-radius:var(--bs-border-radius-lg) !important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl) !important;border-bottom-right-radius:var(--bs-border-radius-xl) !important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl) !important;border-bottom-right-radius:var(--bs-border-radius-xxl) !important}.rounded-end-circle{border-top-right-radius:50% !important;border-bottom-right-radius:50% !important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill) !important;border-bottom-right-radius:var(--bs-border-radius-pill) !important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius) !important;border-bottom-left-radius:var(--bs-border-radius) !important}.rounded-bottom-0{border-bottom-right-radius:0 !important;border-bottom-left-radius:0 !important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm) !important;border-bottom-left-radius:var(--bs-border-radius-sm) !important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius) !important;border-bottom-left-radius:var(--bs-border-radius) !important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg) !important;border-bottom-left-radius:var(--bs-border-radius-lg) !important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl) !important;border-bottom-left-radius:var(--bs-border-radius-xl) !important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl) !important;border-bottom-left-radius:var(--bs-border-radius-xxl) !important}.rounded-bottom-circle{border-bottom-right-radius:50% !important;border-bottom-left-radius:50% !important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill) !important;border-bottom-left-radius:var(--bs-border-radius-pill) !important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius) !important;border-top-left-radius:var(--bs-border-radius) !important}.rounded-start-0{border-bottom-left-radius:0 !important;border-top-left-radius:0 !important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm) !important;border-top-left-radius:var(--bs-border-radius-sm) !important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius) !important;border-top-left-radius:var(--bs-border-radius) !important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg) !important;border-top-left-radius:var(--bs-border-radius-lg) !important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl) !important;border-top-left-radius:var(--bs-border-radius-xl) !important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl) !important;border-top-left-radius:var(--bs-border-radius-xxl) !important}.rounded-start-circle{border-bottom-left-radius:50% !important;border-top-left-radius:50% !important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill) !important;border-top-left-radius:var(--bs-border-radius-pill) !important}.visible{visibility:visible !important}.invisible{visibility:hidden !important}.z-n1{z-index:-1 !important}.z-0{z-index:0 !important}.z-1{z-index:1 !important}.z-2{z-index:2 !important}.z-3{z-index:3 !important}@media(min-width: 576px){.float-sm-start{float:left !important}.float-sm-end{float:right !important}.float-sm-none{float:none !important}.object-fit-sm-contain{object-fit:contain !important}.object-fit-sm-cover{object-fit:cover !important}.object-fit-sm-fill{object-fit:fill !important}.object-fit-sm-scale{object-fit:scale-down !important}.object-fit-sm-none{object-fit:none !important}.d-sm-inline{display:inline !important}.d-sm-inline-block{display:inline-block !important}.d-sm-block{display:block !important}.d-sm-grid{display:grid !important}.d-sm-inline-grid{display:inline-grid !important}.d-sm-table{display:table !important}.d-sm-table-row{display:table-row !important}.d-sm-table-cell{display:table-cell !important}.d-sm-flex{display:flex !important}.d-sm-inline-flex{display:inline-flex !important}.d-sm-none{display:none !important}.flex-sm-fill{flex:1 1 auto !important}.flex-sm-row{flex-direction:row !important}.flex-sm-column{flex-direction:column !important}.flex-sm-row-reverse{flex-direction:row-reverse !important}.flex-sm-column-reverse{flex-direction:column-reverse !important}.flex-sm-grow-0{flex-grow:0 !important}.flex-sm-grow-1{flex-grow:1 !important}.flex-sm-shrink-0{flex-shrink:0 !important}.flex-sm-shrink-1{flex-shrink:1 !important}.flex-sm-wrap{flex-wrap:wrap !important}.flex-sm-nowrap{flex-wrap:nowrap !important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse !important}.justify-content-sm-start{justify-content:flex-start !important}.justify-content-sm-end{justify-content:flex-end !important}.justify-content-sm-center{justify-content:center !important}.justify-content-sm-between{justify-content:space-between !important}.justify-content-sm-around{justify-content:space-around !important}.justify-content-sm-evenly{justify-content:space-evenly !important}.align-items-sm-start{align-items:flex-start !important}.align-items-sm-end{align-items:flex-end !important}.align-items-sm-center{align-items:center !important}.align-items-sm-baseline{align-items:baseline !important}.align-items-sm-stretch{align-items:stretch !important}.align-content-sm-start{align-content:flex-start !important}.align-content-sm-end{align-content:flex-end !important}.align-content-sm-center{align-content:center !important}.align-content-sm-between{align-content:space-between !important}.align-content-sm-around{align-content:space-around !important}.align-content-sm-stretch{align-content:stretch !important}.align-self-sm-auto{align-self:auto !important}.align-self-sm-start{align-self:flex-start !important}.align-self-sm-end{align-self:flex-end !important}.align-self-sm-center{align-self:center !important}.align-self-sm-baseline{align-self:baseline !important}.align-self-sm-stretch{align-self:stretch !important}.order-sm-first{order:-1 !important}.order-sm-0{order:0 !important}.order-sm-1{order:1 !important}.order-sm-2{order:2 !important}.order-sm-3{order:3 !important}.order-sm-4{order:4 !important}.order-sm-5{order:5 !important}.order-sm-last{order:6 !important}.m-sm-0{margin:0 !important}.m-sm-1{margin:.25rem !important}.m-sm-2{margin:.5rem !important}.m-sm-3{margin:1rem !important}.m-sm-4{margin:1.5rem !important}.m-sm-5{margin:3rem !important}.m-sm-auto{margin:auto !important}.mx-sm-0{margin-right:0 !important;margin-left:0 !important}.mx-sm-1{margin-right:.25rem !important;margin-left:.25rem !important}.mx-sm-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-sm-3{margin-right:1rem !important;margin-left:1rem !important}.mx-sm-4{margin-right:1.5rem !important;margin-left:1.5rem !important}.mx-sm-5{margin-right:3rem !important;margin-left:3rem !important}.mx-sm-auto{margin-right:auto !important;margin-left:auto !important}.my-sm-0{margin-top:0 !important;margin-bottom:0 !important}.my-sm-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.my-sm-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.my-sm-3{margin-top:1rem !important;margin-bottom:1rem !important}.my-sm-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.my-sm-5{margin-top:3rem !important;margin-bottom:3rem !important}.my-sm-auto{margin-top:auto !important;margin-bottom:auto !important}.mt-sm-0{margin-top:0 !important}.mt-sm-1{margin-top:.25rem !important}.mt-sm-2{margin-top:.5rem !important}.mt-sm-3{margin-top:1rem !important}.mt-sm-4{margin-top:1.5rem !important}.mt-sm-5{margin-top:3rem !important}.mt-sm-auto{margin-top:auto !important}.me-sm-0{margin-right:0 !important}.me-sm-1{margin-right:.25rem !important}.me-sm-2{margin-right:.5rem !important}.me-sm-3{margin-right:1rem !important}.me-sm-4{margin-right:1.5rem !important}.me-sm-5{margin-right:3rem !important}.me-sm-auto{margin-right:auto !important}.mb-sm-0{margin-bottom:0 !important}.mb-sm-1{margin-bottom:.25rem !important}.mb-sm-2{margin-bottom:.5rem !important}.mb-sm-3{margin-bottom:1rem !important}.mb-sm-4{margin-bottom:1.5rem !important}.mb-sm-5{margin-bottom:3rem !important}.mb-sm-auto{margin-bottom:auto !important}.ms-sm-0{margin-left:0 !important}.ms-sm-1{margin-left:.25rem !important}.ms-sm-2{margin-left:.5rem !important}.ms-sm-3{margin-left:1rem !important}.ms-sm-4{margin-left:1.5rem !important}.ms-sm-5{margin-left:3rem !important}.ms-sm-auto{margin-left:auto !important}.p-sm-0{padding:0 !important}.p-sm-1{padding:.25rem !important}.p-sm-2{padding:.5rem !important}.p-sm-3{padding:1rem !important}.p-sm-4{padding:1.5rem !important}.p-sm-5{padding:3rem !important}.px-sm-0{padding-right:0 !important;padding-left:0 !important}.px-sm-1{padding-right:.25rem !important;padding-left:.25rem !important}.px-sm-2{padding-right:.5rem !important;padding-left:.5rem !important}.px-sm-3{padding-right:1rem !important;padding-left:1rem !important}.px-sm-4{padding-right:1.5rem !important;padding-left:1.5rem !important}.px-sm-5{padding-right:3rem !important;padding-left:3rem !important}.py-sm-0{padding-top:0 !important;padding-bottom:0 !important}.py-sm-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.py-sm-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.py-sm-3{padding-top:1rem !important;padding-bottom:1rem !important}.py-sm-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.py-sm-5{padding-top:3rem !important;padding-bottom:3rem !important}.pt-sm-0{padding-top:0 !important}.pt-sm-1{padding-top:.25rem !important}.pt-sm-2{padding-top:.5rem !important}.pt-sm-3{padding-top:1rem !important}.pt-sm-4{padding-top:1.5rem !important}.pt-sm-5{padding-top:3rem !important}.pe-sm-0{padding-right:0 !important}.pe-sm-1{padding-right:.25rem !important}.pe-sm-2{padding-right:.5rem !important}.pe-sm-3{padding-right:1rem !important}.pe-sm-4{padding-right:1.5rem !important}.pe-sm-5{padding-right:3rem !important}.pb-sm-0{padding-bottom:0 !important}.pb-sm-1{padding-bottom:.25rem !important}.pb-sm-2{padding-bottom:.5rem !important}.pb-sm-3{padding-bottom:1rem !important}.pb-sm-4{padding-bottom:1.5rem !important}.pb-sm-5{padding-bottom:3rem !important}.ps-sm-0{padding-left:0 !important}.ps-sm-1{padding-left:.25rem !important}.ps-sm-2{padding-left:.5rem !important}.ps-sm-3{padding-left:1rem !important}.ps-sm-4{padding-left:1.5rem !important}.ps-sm-5{padding-left:3rem !important}.gap-sm-0{gap:0 !important}.gap-sm-1{gap:.25rem !important}.gap-sm-2{gap:.5rem !important}.gap-sm-3{gap:1rem !important}.gap-sm-4{gap:1.5rem !important}.gap-sm-5{gap:3rem !important}.row-gap-sm-0{row-gap:0 !important}.row-gap-sm-1{row-gap:.25rem !important}.row-gap-sm-2{row-gap:.5rem !important}.row-gap-sm-3{row-gap:1rem !important}.row-gap-sm-4{row-gap:1.5rem !important}.row-gap-sm-5{row-gap:3rem !important}.column-gap-sm-0{column-gap:0 !important}.column-gap-sm-1{column-gap:.25rem !important}.column-gap-sm-2{column-gap:.5rem !important}.column-gap-sm-3{column-gap:1rem !important}.column-gap-sm-4{column-gap:1.5rem !important}.column-gap-sm-5{column-gap:3rem !important}.text-sm-start{text-align:left !important}.text-sm-end{text-align:right !important}.text-sm-center{text-align:center !important}}@media(min-width: 768px){.float-md-start{float:left !important}.float-md-end{float:right !important}.float-md-none{float:none !important}.object-fit-md-contain{object-fit:contain !important}.object-fit-md-cover{object-fit:cover !important}.object-fit-md-fill{object-fit:fill !important}.object-fit-md-scale{object-fit:scale-down !important}.object-fit-md-none{object-fit:none !important}.d-md-inline{display:inline !important}.d-md-inline-block{display:inline-block !important}.d-md-block{display:block !important}.d-md-grid{display:grid !important}.d-md-inline-grid{display:inline-grid !important}.d-md-table{display:table !important}.d-md-table-row{display:table-row !important}.d-md-table-cell{display:table-cell !important}.d-md-flex{display:flex !important}.d-md-inline-flex{display:inline-flex !important}.d-md-none{display:none !important}.flex-md-fill{flex:1 1 auto !important}.flex-md-row{flex-direction:row !important}.flex-md-column{flex-direction:column !important}.flex-md-row-reverse{flex-direction:row-reverse !important}.flex-md-column-reverse{flex-direction:column-reverse !important}.flex-md-grow-0{flex-grow:0 !important}.flex-md-grow-1{flex-grow:1 !important}.flex-md-shrink-0{flex-shrink:0 !important}.flex-md-shrink-1{flex-shrink:1 !important}.flex-md-wrap{flex-wrap:wrap !important}.flex-md-nowrap{flex-wrap:nowrap !important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse !important}.justify-content-md-start{justify-content:flex-start !important}.justify-content-md-end{justify-content:flex-end !important}.justify-content-md-center{justify-content:center !important}.justify-content-md-between{justify-content:space-between !important}.justify-content-md-around{justify-content:space-around !important}.justify-content-md-evenly{justify-content:space-evenly !important}.align-items-md-start{align-items:flex-start !important}.align-items-md-end{align-items:flex-end !important}.align-items-md-center{align-items:center !important}.align-items-md-baseline{align-items:baseline !important}.align-items-md-stretch{align-items:stretch !important}.align-content-md-start{align-content:flex-start !important}.align-content-md-end{align-content:flex-end !important}.align-content-md-center{align-content:center !important}.align-content-md-between{align-content:space-between !important}.align-content-md-around{align-content:space-around !important}.align-content-md-stretch{align-content:stretch !important}.align-self-md-auto{align-self:auto !important}.align-self-md-start{align-self:flex-start !important}.align-self-md-end{align-self:flex-end !important}.align-self-md-center{align-self:center !important}.align-self-md-baseline{align-self:baseline !important}.align-self-md-stretch{align-self:stretch !important}.order-md-first{order:-1 !important}.order-md-0{order:0 !important}.order-md-1{order:1 !important}.order-md-2{order:2 !important}.order-md-3{order:3 !important}.order-md-4{order:4 !important}.order-md-5{order:5 !important}.order-md-last{order:6 !important}.m-md-0{margin:0 !important}.m-md-1{margin:.25rem !important}.m-md-2{margin:.5rem !important}.m-md-3{margin:1rem !important}.m-md-4{margin:1.5rem !important}.m-md-5{margin:3rem !important}.m-md-auto{margin:auto !important}.mx-md-0{margin-right:0 !important;margin-left:0 !important}.mx-md-1{margin-right:.25rem !important;margin-left:.25rem !important}.mx-md-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-md-3{margin-right:1rem !important;margin-left:1rem !important}.mx-md-4{margin-right:1.5rem !important;margin-left:1.5rem !important}.mx-md-5{margin-right:3rem !important;margin-left:3rem !important}.mx-md-auto{margin-right:auto !important;margin-left:auto !important}.my-md-0{margin-top:0 !important;margin-bottom:0 !important}.my-md-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.my-md-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.my-md-3{margin-top:1rem !important;margin-bottom:1rem !important}.my-md-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.my-md-5{margin-top:3rem !important;margin-bottom:3rem !important}.my-md-auto{margin-top:auto !important;margin-bottom:auto !important}.mt-md-0{margin-top:0 !important}.mt-md-1{margin-top:.25rem !important}.mt-md-2{margin-top:.5rem !important}.mt-md-3{margin-top:1rem !important}.mt-md-4{margin-top:1.5rem !important}.mt-md-5{margin-top:3rem !important}.mt-md-auto{margin-top:auto !important}.me-md-0{margin-right:0 !important}.me-md-1{margin-right:.25rem !important}.me-md-2{margin-right:.5rem !important}.me-md-3{margin-right:1rem !important}.me-md-4{margin-right:1.5rem !important}.me-md-5{margin-right:3rem !important}.me-md-auto{margin-right:auto !important}.mb-md-0{margin-bottom:0 !important}.mb-md-1{margin-bottom:.25rem !important}.mb-md-2{margin-bottom:.5rem !important}.mb-md-3{margin-bottom:1rem !important}.mb-md-4{margin-bottom:1.5rem !important}.mb-md-5{margin-bottom:3rem !important}.mb-md-auto{margin-bottom:auto !important}.ms-md-0{margin-left:0 !important}.ms-md-1{margin-left:.25rem !important}.ms-md-2{margin-left:.5rem !important}.ms-md-3{margin-left:1rem !important}.ms-md-4{margin-left:1.5rem !important}.ms-md-5{margin-left:3rem !important}.ms-md-auto{margin-left:auto !important}.p-md-0{padding:0 !important}.p-md-1{padding:.25rem !important}.p-md-2{padding:.5rem !important}.p-md-3{padding:1rem !important}.p-md-4{padding:1.5rem !important}.p-md-5{padding:3rem !important}.px-md-0{padding-right:0 !important;padding-left:0 !important}.px-md-1{padding-right:.25rem !important;padding-left:.25rem !important}.px-md-2{padding-right:.5rem !important;padding-left:.5rem !important}.px-md-3{padding-right:1rem !important;padding-left:1rem !important}.px-md-4{padding-right:1.5rem !important;padding-left:1.5rem !important}.px-md-5{padding-right:3rem !important;padding-left:3rem !important}.py-md-0{padding-top:0 !important;padding-bottom:0 !important}.py-md-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.py-md-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.py-md-3{padding-top:1rem !important;padding-bottom:1rem !important}.py-md-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.py-md-5{padding-top:3rem !important;padding-bottom:3rem !important}.pt-md-0{padding-top:0 !important}.pt-md-1{padding-top:.25rem !important}.pt-md-2{padding-top:.5rem !important}.pt-md-3{padding-top:1rem !important}.pt-md-4{padding-top:1.5rem !important}.pt-md-5{padding-top:3rem !important}.pe-md-0{padding-right:0 !important}.pe-md-1{padding-right:.25rem !important}.pe-md-2{padding-right:.5rem !important}.pe-md-3{padding-right:1rem !important}.pe-md-4{padding-right:1.5rem !important}.pe-md-5{padding-right:3rem !important}.pb-md-0{padding-bottom:0 !important}.pb-md-1{padding-bottom:.25rem !important}.pb-md-2{padding-bottom:.5rem !important}.pb-md-3{padding-bottom:1rem !important}.pb-md-4{padding-bottom:1.5rem !important}.pb-md-5{padding-bottom:3rem !important}.ps-md-0{padding-left:0 !important}.ps-md-1{padding-left:.25rem !important}.ps-md-2{padding-left:.5rem !important}.ps-md-3{padding-left:1rem !important}.ps-md-4{padding-left:1.5rem !important}.ps-md-5{padding-left:3rem !important}.gap-md-0{gap:0 !important}.gap-md-1{gap:.25rem !important}.gap-md-2{gap:.5rem !important}.gap-md-3{gap:1rem !important}.gap-md-4{gap:1.5rem !important}.gap-md-5{gap:3rem !important}.row-gap-md-0{row-gap:0 !important}.row-gap-md-1{row-gap:.25rem !important}.row-gap-md-2{row-gap:.5rem !important}.row-gap-md-3{row-gap:1rem !important}.row-gap-md-4{row-gap:1.5rem !important}.row-gap-md-5{row-gap:3rem !important}.column-gap-md-0{column-gap:0 !important}.column-gap-md-1{column-gap:.25rem !important}.column-gap-md-2{column-gap:.5rem !important}.column-gap-md-3{column-gap:1rem !important}.column-gap-md-4{column-gap:1.5rem !important}.column-gap-md-5{column-gap:3rem !important}.text-md-start{text-align:left !important}.text-md-end{text-align:right !important}.text-md-center{text-align:center !important}}@media(min-width: 992px){.float-lg-start{float:left !important}.float-lg-end{float:right !important}.float-lg-none{float:none !important}.object-fit-lg-contain{object-fit:contain !important}.object-fit-lg-cover{object-fit:cover !important}.object-fit-lg-fill{object-fit:fill !important}.object-fit-lg-scale{object-fit:scale-down !important}.object-fit-lg-none{object-fit:none !important}.d-lg-inline{display:inline !important}.d-lg-inline-block{display:inline-block !important}.d-lg-block{display:block !important}.d-lg-grid{display:grid !important}.d-lg-inline-grid{display:inline-grid !important}.d-lg-table{display:table !important}.d-lg-table-row{display:table-row !important}.d-lg-table-cell{display:table-cell !important}.d-lg-flex{display:flex !important}.d-lg-inline-flex{display:inline-flex !important}.d-lg-none{display:none !important}.flex-lg-fill{flex:1 1 auto !important}.flex-lg-row{flex-direction:row !important}.flex-lg-column{flex-direction:column !important}.flex-lg-row-reverse{flex-direction:row-reverse !important}.flex-lg-column-reverse{flex-direction:column-reverse !important}.flex-lg-grow-0{flex-grow:0 !important}.flex-lg-grow-1{flex-grow:1 !important}.flex-lg-shrink-0{flex-shrink:0 !important}.flex-lg-shrink-1{flex-shrink:1 !important}.flex-lg-wrap{flex-wrap:wrap !important}.flex-lg-nowrap{flex-wrap:nowrap !important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse !important}.justify-content-lg-start{justify-content:flex-start !important}.justify-content-lg-end{justify-content:flex-end !important}.justify-content-lg-center{justify-content:center !important}.justify-content-lg-between{justify-content:space-between !important}.justify-content-lg-around{justify-content:space-around !important}.justify-content-lg-evenly{justify-content:space-evenly !important}.align-items-lg-start{align-items:flex-start !important}.align-items-lg-end{align-items:flex-end !important}.align-items-lg-center{align-items:center !important}.align-items-lg-baseline{align-items:baseline !important}.align-items-lg-stretch{align-items:stretch !important}.align-content-lg-start{align-content:flex-start !important}.align-content-lg-end{align-content:flex-end !important}.align-content-lg-center{align-content:center !important}.align-content-lg-between{align-content:space-between !important}.align-content-lg-around{align-content:space-around !important}.align-content-lg-stretch{align-content:stretch !important}.align-self-lg-auto{align-self:auto !important}.align-self-lg-start{align-self:flex-start !important}.align-self-lg-end{align-self:flex-end !important}.align-self-lg-center{align-self:center !important}.align-self-lg-baseline{align-self:baseline !important}.align-self-lg-stretch{align-self:stretch !important}.order-lg-first{order:-1 !important}.order-lg-0{order:0 !important}.order-lg-1{order:1 !important}.order-lg-2{order:2 !important}.order-lg-3{order:3 !important}.order-lg-4{order:4 !important}.order-lg-5{order:5 !important}.order-lg-last{order:6 !important}.m-lg-0{margin:0 !important}.m-lg-1{margin:.25rem !important}.m-lg-2{margin:.5rem !important}.m-lg-3{margin:1rem !important}.m-lg-4{margin:1.5rem !important}.m-lg-5{margin:3rem !important}.m-lg-auto{margin:auto !important}.mx-lg-0{margin-right:0 !important;margin-left:0 !important}.mx-lg-1{margin-right:.25rem !important;margin-left:.25rem !important}.mx-lg-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-lg-3{margin-right:1rem !important;margin-left:1rem !important}.mx-lg-4{margin-right:1.5rem !important;margin-left:1.5rem !important}.mx-lg-5{margin-right:3rem !important;margin-left:3rem !important}.mx-lg-auto{margin-right:auto !important;margin-left:auto !important}.my-lg-0{margin-top:0 !important;margin-bottom:0 !important}.my-lg-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.my-lg-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.my-lg-3{margin-top:1rem !important;margin-bottom:1rem !important}.my-lg-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.my-lg-5{margin-top:3rem !important;margin-bottom:3rem !important}.my-lg-auto{margin-top:auto !important;margin-bottom:auto !important}.mt-lg-0{margin-top:0 !important}.mt-lg-1{margin-top:.25rem !important}.mt-lg-2{margin-top:.5rem !important}.mt-lg-3{margin-top:1rem !important}.mt-lg-4{margin-top:1.5rem !important}.mt-lg-5{margin-top:3rem !important}.mt-lg-auto{margin-top:auto !important}.me-lg-0{margin-right:0 !important}.me-lg-1{margin-right:.25rem !important}.me-lg-2{margin-right:.5rem !important}.me-lg-3{margin-right:1rem !important}.me-lg-4{margin-right:1.5rem !important}.me-lg-5{margin-right:3rem !important}.me-lg-auto{margin-right:auto !important}.mb-lg-0{margin-bottom:0 !important}.mb-lg-1{margin-bottom:.25rem !important}.mb-lg-2{margin-bottom:.5rem !important}.mb-lg-3{margin-bottom:1rem !important}.mb-lg-4{margin-bottom:1.5rem !important}.mb-lg-5{margin-bottom:3rem !important}.mb-lg-auto{margin-bottom:auto !important}.ms-lg-0{margin-left:0 !important}.ms-lg-1{margin-left:.25rem !important}.ms-lg-2{margin-left:.5rem !important}.ms-lg-3{margin-left:1rem !important}.ms-lg-4{margin-left:1.5rem !important}.ms-lg-5{margin-left:3rem !important}.ms-lg-auto{margin-left:auto !important}.p-lg-0{padding:0 !important}.p-lg-1{padding:.25rem !important}.p-lg-2{padding:.5rem !important}.p-lg-3{padding:1rem !important}.p-lg-4{padding:1.5rem !important}.p-lg-5{padding:3rem !important}.px-lg-0{padding-right:0 !important;padding-left:0 !important}.px-lg-1{padding-right:.25rem !important;padding-left:.25rem !important}.px-lg-2{padding-right:.5rem !important;padding-left:.5rem !important}.px-lg-3{padding-right:1rem !important;padding-left:1rem !important}.px-lg-4{padding-right:1.5rem !important;padding-left:1.5rem !important}.px-lg-5{padding-right:3rem !important;padding-left:3rem !important}.py-lg-0{padding-top:0 !important;padding-bottom:0 !important}.py-lg-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.py-lg-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.py-lg-3{padding-top:1rem !important;padding-bottom:1rem !important}.py-lg-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.py-lg-5{padding-top:3rem !important;padding-bottom:3rem !important}.pt-lg-0{padding-top:0 !important}.pt-lg-1{padding-top:.25rem !important}.pt-lg-2{padding-top:.5rem !important}.pt-lg-3{padding-top:1rem !important}.pt-lg-4{padding-top:1.5rem !important}.pt-lg-5{padding-top:3rem !important}.pe-lg-0{padding-right:0 !important}.pe-lg-1{padding-right:.25rem !important}.pe-lg-2{padding-right:.5rem !important}.pe-lg-3{padding-right:1rem !important}.pe-lg-4{padding-right:1.5rem !important}.pe-lg-5{padding-right:3rem !important}.pb-lg-0{padding-bottom:0 !important}.pb-lg-1{padding-bottom:.25rem !important}.pb-lg-2{padding-bottom:.5rem !important}.pb-lg-3{padding-bottom:1rem !important}.pb-lg-4{padding-bottom:1.5rem !important}.pb-lg-5{padding-bottom:3rem !important}.ps-lg-0{padding-left:0 !important}.ps-lg-1{padding-left:.25rem !important}.ps-lg-2{padding-left:.5rem !important}.ps-lg-3{padding-left:1rem !important}.ps-lg-4{padding-left:1.5rem !important}.ps-lg-5{padding-left:3rem !important}.gap-lg-0{gap:0 !important}.gap-lg-1{gap:.25rem !important}.gap-lg-2{gap:.5rem !important}.gap-lg-3{gap:1rem !important}.gap-lg-4{gap:1.5rem !important}.gap-lg-5{gap:3rem !important}.row-gap-lg-0{row-gap:0 !important}.row-gap-lg-1{row-gap:.25rem !important}.row-gap-lg-2{row-gap:.5rem !important}.row-gap-lg-3{row-gap:1rem !important}.row-gap-lg-4{row-gap:1.5rem !important}.row-gap-lg-5{row-gap:3rem !important}.column-gap-lg-0{column-gap:0 !important}.column-gap-lg-1{column-gap:.25rem !important}.column-gap-lg-2{column-gap:.5rem !important}.column-gap-lg-3{column-gap:1rem !important}.column-gap-lg-4{column-gap:1.5rem !important}.column-gap-lg-5{column-gap:3rem !important}.text-lg-start{text-align:left !important}.text-lg-end{text-align:right !important}.text-lg-center{text-align:center !important}}@media(min-width: 1200px){.float-xl-start{float:left !important}.float-xl-end{float:right !important}.float-xl-none{float:none !important}.object-fit-xl-contain{object-fit:contain !important}.object-fit-xl-cover{object-fit:cover !important}.object-fit-xl-fill{object-fit:fill !important}.object-fit-xl-scale{object-fit:scale-down !important}.object-fit-xl-none{object-fit:none !important}.d-xl-inline{display:inline !important}.d-xl-inline-block{display:inline-block !important}.d-xl-block{display:block !important}.d-xl-grid{display:grid !important}.d-xl-inline-grid{display:inline-grid !important}.d-xl-table{display:table !important}.d-xl-table-row{display:table-row !important}.d-xl-table-cell{display:table-cell !important}.d-xl-flex{display:flex !important}.d-xl-inline-flex{display:inline-flex !important}.d-xl-none{display:none !important}.flex-xl-fill{flex:1 1 auto !important}.flex-xl-row{flex-direction:row !important}.flex-xl-column{flex-direction:column !important}.flex-xl-row-reverse{flex-direction:row-reverse !important}.flex-xl-column-reverse{flex-direction:column-reverse !important}.flex-xl-grow-0{flex-grow:0 !important}.flex-xl-grow-1{flex-grow:1 !important}.flex-xl-shrink-0{flex-shrink:0 !important}.flex-xl-shrink-1{flex-shrink:1 !important}.flex-xl-wrap{flex-wrap:wrap !important}.flex-xl-nowrap{flex-wrap:nowrap !important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse !important}.justify-content-xl-start{justify-content:flex-start !important}.justify-content-xl-end{justify-content:flex-end !important}.justify-content-xl-center{justify-content:center !important}.justify-content-xl-between{justify-content:space-between !important}.justify-content-xl-around{justify-content:space-around !important}.justify-content-xl-evenly{justify-content:space-evenly !important}.align-items-xl-start{align-items:flex-start !important}.align-items-xl-end{align-items:flex-end !important}.align-items-xl-center{align-items:center !important}.align-items-xl-baseline{align-items:baseline !important}.align-items-xl-stretch{align-items:stretch !important}.align-content-xl-start{align-content:flex-start !important}.align-content-xl-end{align-content:flex-end !important}.align-content-xl-center{align-content:center !important}.align-content-xl-between{align-content:space-between !important}.align-content-xl-around{align-content:space-around !important}.align-content-xl-stretch{align-content:stretch !important}.align-self-xl-auto{align-self:auto !important}.align-self-xl-start{align-self:flex-start !important}.align-self-xl-end{align-self:flex-end !important}.align-self-xl-center{align-self:center !important}.align-self-xl-baseline{align-self:baseline !important}.align-self-xl-stretch{align-self:stretch !important}.order-xl-first{order:-1 !important}.order-xl-0{order:0 !important}.order-xl-1{order:1 !important}.order-xl-2{order:2 !important}.order-xl-3{order:3 !important}.order-xl-4{order:4 !important}.order-xl-5{order:5 !important}.order-xl-last{order:6 !important}.m-xl-0{margin:0 !important}.m-xl-1{margin:.25rem !important}.m-xl-2{margin:.5rem !important}.m-xl-3{margin:1rem !important}.m-xl-4{margin:1.5rem !important}.m-xl-5{margin:3rem !important}.m-xl-auto{margin:auto !important}.mx-xl-0{margin-right:0 !important;margin-left:0 !important}.mx-xl-1{margin-right:.25rem !important;margin-left:.25rem !important}.mx-xl-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-xl-3{margin-right:1rem !important;margin-left:1rem !important}.mx-xl-4{margin-right:1.5rem !important;margin-left:1.5rem !important}.mx-xl-5{margin-right:3rem !important;margin-left:3rem !important}.mx-xl-auto{margin-right:auto !important;margin-left:auto !important}.my-xl-0{margin-top:0 !important;margin-bottom:0 !important}.my-xl-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.my-xl-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.my-xl-3{margin-top:1rem !important;margin-bottom:1rem !important}.my-xl-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.my-xl-5{margin-top:3rem !important;margin-bottom:3rem !important}.my-xl-auto{margin-top:auto !important;margin-bottom:auto !important}.mt-xl-0{margin-top:0 !important}.mt-xl-1{margin-top:.25rem !important}.mt-xl-2{margin-top:.5rem !important}.mt-xl-3{margin-top:1rem !important}.mt-xl-4{margin-top:1.5rem !important}.mt-xl-5{margin-top:3rem !important}.mt-xl-auto{margin-top:auto !important}.me-xl-0{margin-right:0 !important}.me-xl-1{margin-right:.25rem !important}.me-xl-2{margin-right:.5rem !important}.me-xl-3{margin-right:1rem !important}.me-xl-4{margin-right:1.5rem !important}.me-xl-5{margin-right:3rem !important}.me-xl-auto{margin-right:auto !important}.mb-xl-0{margin-bottom:0 !important}.mb-xl-1{margin-bottom:.25rem !important}.mb-xl-2{margin-bottom:.5rem !important}.mb-xl-3{margin-bottom:1rem !important}.mb-xl-4{margin-bottom:1.5rem !important}.mb-xl-5{margin-bottom:3rem !important}.mb-xl-auto{margin-bottom:auto !important}.ms-xl-0{margin-left:0 !important}.ms-xl-1{margin-left:.25rem !important}.ms-xl-2{margin-left:.5rem !important}.ms-xl-3{margin-left:1rem !important}.ms-xl-4{margin-left:1.5rem !important}.ms-xl-5{margin-left:3rem !important}.ms-xl-auto{margin-left:auto !important}.p-xl-0{padding:0 !important}.p-xl-1{padding:.25rem !important}.p-xl-2{padding:.5rem !important}.p-xl-3{padding:1rem !important}.p-xl-4{padding:1.5rem !important}.p-xl-5{padding:3rem !important}.px-xl-0{padding-right:0 !important;padding-left:0 !important}.px-xl-1{padding-right:.25rem !important;padding-left:.25rem !important}.px-xl-2{padding-right:.5rem !important;padding-left:.5rem !important}.px-xl-3{padding-right:1rem !important;padding-left:1rem !important}.px-xl-4{padding-right:1.5rem !important;padding-left:1.5rem !important}.px-xl-5{padding-right:3rem !important;padding-left:3rem !important}.py-xl-0{padding-top:0 !important;padding-bottom:0 !important}.py-xl-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.py-xl-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.py-xl-3{padding-top:1rem !important;padding-bottom:1rem !important}.py-xl-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.py-xl-5{padding-top:3rem !important;padding-bottom:3rem !important}.pt-xl-0{padding-top:0 !important}.pt-xl-1{padding-top:.25rem !important}.pt-xl-2{padding-top:.5rem !important}.pt-xl-3{padding-top:1rem !important}.pt-xl-4{padding-top:1.5rem !important}.pt-xl-5{padding-top:3rem !important}.pe-xl-0{padding-right:0 !important}.pe-xl-1{padding-right:.25rem !important}.pe-xl-2{padding-right:.5rem !important}.pe-xl-3{padding-right:1rem !important}.pe-xl-4{padding-right:1.5rem !important}.pe-xl-5{padding-right:3rem !important}.pb-xl-0{padding-bottom:0 !important}.pb-xl-1{padding-bottom:.25rem !important}.pb-xl-2{padding-bottom:.5rem !important}.pb-xl-3{padding-bottom:1rem !important}.pb-xl-4{padding-bottom:1.5rem !important}.pb-xl-5{padding-bottom:3rem !important}.ps-xl-0{padding-left:0 !important}.ps-xl-1{padding-left:.25rem !important}.ps-xl-2{padding-left:.5rem !important}.ps-xl-3{padding-left:1rem !important}.ps-xl-4{padding-left:1.5rem !important}.ps-xl-5{padding-left:3rem !important}.gap-xl-0{gap:0 !important}.gap-xl-1{gap:.25rem !important}.gap-xl-2{gap:.5rem !important}.gap-xl-3{gap:1rem !important}.gap-xl-4{gap:1.5rem !important}.gap-xl-5{gap:3rem !important}.row-gap-xl-0{row-gap:0 !important}.row-gap-xl-1{row-gap:.25rem !important}.row-gap-xl-2{row-gap:.5rem !important}.row-gap-xl-3{row-gap:1rem !important}.row-gap-xl-4{row-gap:1.5rem !important}.row-gap-xl-5{row-gap:3rem !important}.column-gap-xl-0{column-gap:0 !important}.column-gap-xl-1{column-gap:.25rem !important}.column-gap-xl-2{column-gap:.5rem !important}.column-gap-xl-3{column-gap:1rem !important}.column-gap-xl-4{column-gap:1.5rem !important}.column-gap-xl-5{column-gap:3rem !important}.text-xl-start{text-align:left !important}.text-xl-end{text-align:right !important}.text-xl-center{text-align:center !important}}@media(min-width: 1400px){.float-xxl-start{float:left !important}.float-xxl-end{float:right !important}.float-xxl-none{float:none !important}.object-fit-xxl-contain{object-fit:contain !important}.object-fit-xxl-cover{object-fit:cover !important}.object-fit-xxl-fill{object-fit:fill !important}.object-fit-xxl-scale{object-fit:scale-down !important}.object-fit-xxl-none{object-fit:none !important}.d-xxl-inline{display:inline !important}.d-xxl-inline-block{display:inline-block !important}.d-xxl-block{display:block !important}.d-xxl-grid{display:grid !important}.d-xxl-inline-grid{display:inline-grid !important}.d-xxl-table{display:table !important}.d-xxl-table-row{display:table-row !important}.d-xxl-table-cell{display:table-cell !important}.d-xxl-flex{display:flex !important}.d-xxl-inline-flex{display:inline-flex !important}.d-xxl-none{display:none !important}.flex-xxl-fill{flex:1 1 auto !important}.flex-xxl-row{flex-direction:row !important}.flex-xxl-column{flex-direction:column !important}.flex-xxl-row-reverse{flex-direction:row-reverse !important}.flex-xxl-column-reverse{flex-direction:column-reverse !important}.flex-xxl-grow-0{flex-grow:0 !important}.flex-xxl-grow-1{flex-grow:1 !important}.flex-xxl-shrink-0{flex-shrink:0 !important}.flex-xxl-shrink-1{flex-shrink:1 !important}.flex-xxl-wrap{flex-wrap:wrap !important}.flex-xxl-nowrap{flex-wrap:nowrap !important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse !important}.justify-content-xxl-start{justify-content:flex-start !important}.justify-content-xxl-end{justify-content:flex-end !important}.justify-content-xxl-center{justify-content:center !important}.justify-content-xxl-between{justify-content:space-between !important}.justify-content-xxl-around{justify-content:space-around !important}.justify-content-xxl-evenly{justify-content:space-evenly !important}.align-items-xxl-start{align-items:flex-start !important}.align-items-xxl-end{align-items:flex-end !important}.align-items-xxl-center{align-items:center !important}.align-items-xxl-baseline{align-items:baseline !important}.align-items-xxl-stretch{align-items:stretch !important}.align-content-xxl-start{align-content:flex-start !important}.align-content-xxl-end{align-content:flex-end !important}.align-content-xxl-center{align-content:center !important}.align-content-xxl-between{align-content:space-between !important}.align-content-xxl-around{align-content:space-around !important}.align-content-xxl-stretch{align-content:stretch !important}.align-self-xxl-auto{align-self:auto !important}.align-self-xxl-start{align-self:flex-start !important}.align-self-xxl-end{align-self:flex-end !important}.align-self-xxl-center{align-self:center !important}.align-self-xxl-baseline{align-self:baseline !important}.align-self-xxl-stretch{align-self:stretch !important}.order-xxl-first{order:-1 !important}.order-xxl-0{order:0 !important}.order-xxl-1{order:1 !important}.order-xxl-2{order:2 !important}.order-xxl-3{order:3 !important}.order-xxl-4{order:4 !important}.order-xxl-5{order:5 !important}.order-xxl-last{order:6 !important}.m-xxl-0{margin:0 !important}.m-xxl-1{margin:.25rem !important}.m-xxl-2{margin:.5rem !important}.m-xxl-3{margin:1rem !important}.m-xxl-4{margin:1.5rem !important}.m-xxl-5{margin:3rem !important}.m-xxl-auto{margin:auto !important}.mx-xxl-0{margin-right:0 !important;margin-left:0 !important}.mx-xxl-1{margin-right:.25rem !important;margin-left:.25rem !important}.mx-xxl-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-xxl-3{margin-right:1rem !important;margin-left:1rem !important}.mx-xxl-4{margin-right:1.5rem !important;margin-left:1.5rem !important}.mx-xxl-5{margin-right:3rem !important;margin-left:3rem !important}.mx-xxl-auto{margin-right:auto !important;margin-left:auto !important}.my-xxl-0{margin-top:0 !important;margin-bottom:0 !important}.my-xxl-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.my-xxl-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.my-xxl-3{margin-top:1rem !important;margin-bottom:1rem !important}.my-xxl-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.my-xxl-5{margin-top:3rem !important;margin-bottom:3rem !important}.my-xxl-auto{margin-top:auto !important;margin-bottom:auto !important}.mt-xxl-0{margin-top:0 !important}.mt-xxl-1{margin-top:.25rem !important}.mt-xxl-2{margin-top:.5rem !important}.mt-xxl-3{margin-top:1rem !important}.mt-xxl-4{margin-top:1.5rem !important}.mt-xxl-5{margin-top:3rem !important}.mt-xxl-auto{margin-top:auto !important}.me-xxl-0{margin-right:0 !important}.me-xxl-1{margin-right:.25rem !important}.me-xxl-2{margin-right:.5rem !important}.me-xxl-3{margin-right:1rem !important}.me-xxl-4{margin-right:1.5rem !important}.me-xxl-5{margin-right:3rem !important}.me-xxl-auto{margin-right:auto !important}.mb-xxl-0{margin-bottom:0 !important}.mb-xxl-1{margin-bottom:.25rem !important}.mb-xxl-2{margin-bottom:.5rem !important}.mb-xxl-3{margin-bottom:1rem !important}.mb-xxl-4{margin-bottom:1.5rem !important}.mb-xxl-5{margin-bottom:3rem !important}.mb-xxl-auto{margin-bottom:auto !important}.ms-xxl-0{margin-left:0 !important}.ms-xxl-1{margin-left:.25rem !important}.ms-xxl-2{margin-left:.5rem !important}.ms-xxl-3{margin-left:1rem !important}.ms-xxl-4{margin-left:1.5rem !important}.ms-xxl-5{margin-left:3rem !important}.ms-xxl-auto{margin-left:auto !important}.p-xxl-0{padding:0 !important}.p-xxl-1{padding:.25rem !important}.p-xxl-2{padding:.5rem !important}.p-xxl-3{padding:1rem !important}.p-xxl-4{padding:1.5rem !important}.p-xxl-5{padding:3rem !important}.px-xxl-0{padding-right:0 !important;padding-left:0 !important}.px-xxl-1{padding-right:.25rem !important;padding-left:.25rem !important}.px-xxl-2{padding-right:.5rem !important;padding-left:.5rem !important}.px-xxl-3{padding-right:1rem !important;padding-left:1rem !important}.px-xxl-4{padding-right:1.5rem !important;padding-left:1.5rem !important}.px-xxl-5{padding-right:3rem !important;padding-left:3rem !important}.py-xxl-0{padding-top:0 !important;padding-bottom:0 !important}.py-xxl-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.py-xxl-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.py-xxl-3{padding-top:1rem !important;padding-bottom:1rem !important}.py-xxl-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.py-xxl-5{padding-top:3rem !important;padding-bottom:3rem !important}.pt-xxl-0{padding-top:0 !important}.pt-xxl-1{padding-top:.25rem !important}.pt-xxl-2{padding-top:.5rem !important}.pt-xxl-3{padding-top:1rem !important}.pt-xxl-4{padding-top:1.5rem !important}.pt-xxl-5{padding-top:3rem !important}.pe-xxl-0{padding-right:0 !important}.pe-xxl-1{padding-right:.25rem !important}.pe-xxl-2{padding-right:.5rem !important}.pe-xxl-3{padding-right:1rem !important}.pe-xxl-4{padding-right:1.5rem !important}.pe-xxl-5{padding-right:3rem !important}.pb-xxl-0{padding-bottom:0 !important}.pb-xxl-1{padding-bottom:.25rem !important}.pb-xxl-2{padding-bottom:.5rem !important}.pb-xxl-3{padding-bottom:1rem !important}.pb-xxl-4{padding-bottom:1.5rem !important}.pb-xxl-5{padding-bottom:3rem !important}.ps-xxl-0{padding-left:0 !important}.ps-xxl-1{padding-left:.25rem !important}.ps-xxl-2{padding-left:.5rem !important}.ps-xxl-3{padding-left:1rem !important}.ps-xxl-4{padding-left:1.5rem !important}.ps-xxl-5{padding-left:3rem !important}.gap-xxl-0{gap:0 !important}.gap-xxl-1{gap:.25rem !important}.gap-xxl-2{gap:.5rem !important}.gap-xxl-3{gap:1rem !important}.gap-xxl-4{gap:1.5rem !important}.gap-xxl-5{gap:3rem !important}.row-gap-xxl-0{row-gap:0 !important}.row-gap-xxl-1{row-gap:.25rem !important}.row-gap-xxl-2{row-gap:.5rem !important}.row-gap-xxl-3{row-gap:1rem !important}.row-gap-xxl-4{row-gap:1.5rem !important}.row-gap-xxl-5{row-gap:3rem !important}.column-gap-xxl-0{column-gap:0 !important}.column-gap-xxl-1{column-gap:.25rem !important}.column-gap-xxl-2{column-gap:.5rem !important}.column-gap-xxl-3{column-gap:1rem !important}.column-gap-xxl-4{column-gap:1.5rem !important}.column-gap-xxl-5{column-gap:3rem !important}.text-xxl-start{text-align:left !important}.text-xxl-end{text-align:right !important}.text-xxl-center{text-align:center !important}}.bg-default{color:#fff}.bg-primary{color:#fff}.bg-secondary{color:#fff}.bg-success{color:#fff}.bg-info{color:#fff}.bg-warning{color:#fff}.bg-danger{color:#fff}.bg-light{color:#fff}.bg-dark{color:#fff}@media(min-width: 1200px){.fs-1{font-size:2rem !important}.fs-2{font-size:1.65rem !important}.fs-3{font-size:1.45rem !important}}@media print{.d-print-inline{display:inline !important}.d-print-inline-block{display:inline-block !important}.d-print-block{display:block !important}.d-print-grid{display:grid !important}.d-print-inline-grid{display:inline-grid !important}.d-print-table{display:table !important}.d-print-table-row{display:table-row !important}.d-print-table-cell{display:table-cell !important}.d-print-flex{display:flex !important}.d-print-inline-flex{display:inline-flex !important}.d-print-none{display:none !important}}.tab-content>.tab-pane.html-fill-container{display:none}.tab-content>.active.html-fill-container{display:flex}.tab-content.html-fill-container{padding:0}.bg-blue{--bslib-color-bg: #4c9be8;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-blue{--bslib-color-fg: #4c9be8;color:var(--bslib-color-fg)}.bg-indigo{--bslib-color-bg: #6610f2;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-indigo{--bslib-color-fg: #6610f2;color:var(--bslib-color-fg)}.bg-purple{--bslib-color-bg: #6f42c1;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-purple{--bslib-color-fg: #6f42c1;color:var(--bslib-color-fg)}.bg-pink{--bslib-color-bg: #e83e8c;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-pink{--bslib-color-fg: #e83e8c;color:var(--bslib-color-fg)}.bg-red{--bslib-color-bg: #d9534f;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-red{--bslib-color-fg: #d9534f;color:var(--bslib-color-fg)}.bg-orange{--bslib-color-bg: #df6919;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-orange{--bslib-color-fg: #df6919;color:var(--bslib-color-fg)}.bg-yellow{--bslib-color-bg: #ffc107;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-yellow{--bslib-color-fg: #ffc107;color:var(--bslib-color-fg)}.bg-green{--bslib-color-bg: #5cb85c;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-green{--bslib-color-fg: #5cb85c;color:var(--bslib-color-fg)}.bg-teal{--bslib-color-bg: #20c997;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-teal{--bslib-color-fg: #20c997;color:var(--bslib-color-fg)}.bg-cyan{--bslib-color-bg: #5bc0de;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-cyan{--bslib-color-fg: #5bc0de;color:var(--bslib-color-fg)}.text-default{--bslib-color-fg: rgb(59, 76.6, 91)}.bg-default{--bslib-color-bg: rgb(59, 76.6, 91);--bslib-color-fg: #fff}.text-primary{--bslib-color-fg: #df6919}.bg-primary{--bslib-color-bg: #df6919;--bslib-color-fg: #fff}.text-secondary{--bslib-color-fg: rgb(59, 76.6, 91)}.bg-secondary{--bslib-color-bg: rgb(59, 76.6, 91);--bslib-color-fg: #fff}.text-success{--bslib-color-fg: #5cb85c}.bg-success{--bslib-color-bg: #5cb85c;--bslib-color-fg: #fff}.text-info{--bslib-color-fg: #5bc0de}.bg-info{--bslib-color-bg: #5bc0de;--bslib-color-fg: #fff}.text-warning{--bslib-color-fg: #ffc107}.bg-warning{--bslib-color-bg: #ffc107;--bslib-color-fg: #fff}.text-danger{--bslib-color-fg: #d9534f}.bg-danger{--bslib-color-bg: #d9534f;--bslib-color-fg: #fff}.text-light{--bslib-color-fg: rgb(129.4, 139.96, 148.6)}.bg-light{--bslib-color-bg: rgb(129.4, 139.96, 148.6);--bslib-color-fg: #fff}.text-dark{--bslib-color-fg: rgb(59, 76.6, 91)}.bg-dark{--bslib-color-bg: rgb(59, 76.6, 91);--bslib-color-fg: #fff}.bg-gradient-blue-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(86.4, 99.4, 236);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(86.4,99.4,236);color:#fff}.bg-gradient-blue-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(90, 119.4, 216.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(90,119.4,216.4);color:#fff}.bg-gradient-blue-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(138.4, 117.8, 195.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(138.4,117.8,195.2);color:#fff}.bg-gradient-blue-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(132.4, 126.2, 170.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(132.4,126.2,170.8);color:#fff}.bg-gradient-blue-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(134.8, 135, 149.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(134.8,135,149.2);color:#fff}.bg-gradient-blue-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(147.6, 170.2, 142);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(147.6,170.2,142);color:#fff}.bg-gradient-blue-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(82.4, 166.6, 176);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(82.4,166.6,176);color:#fff}.bg-gradient-blue-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(58.4, 173.4, 199.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(58.4,173.4,199.6);color:#fff}.bg-gradient-blue-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(82, 169.8, 228);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(82,169.8,228);color:#fff}.bg-gradient-indigo-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(91.6, 71.6, 238);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(91.6,71.6,238);color:#fff}.bg-gradient-indigo-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(105.6, 36, 222.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(105.6,36,222.4);color:#fff}.bg-gradient-indigo-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(154, 34.4, 201.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(154,34.4,201.2);color:#fff}.bg-gradient-indigo-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(148, 42.8, 176.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(148,42.8,176.8);color:#fff}.bg-gradient-indigo-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(150.4, 51.6, 155.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(150.4,51.6,155.2);color:#fff}.bg-gradient-indigo-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(163.2, 86.8, 148);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(163.2,86.8,148);color:#fff}.bg-gradient-indigo-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(98, 83.2, 182);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(98,83.2,182);color:#fff}.bg-gradient-indigo-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(74, 90, 205.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(74,90,205.6);color:#fff}.bg-gradient-indigo-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(97.6, 86.4, 234);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(97.6,86.4,234);color:#fff}.bg-gradient-purple-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(97, 101.6, 208.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(97,101.6,208.6);color:#fff}.bg-gradient-purple-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(107.4, 46, 212.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(107.4,46,212.6);color:#fff}.bg-gradient-purple-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(159.4, 64.4, 171.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(159.4,64.4,171.8);color:#fff}.bg-gradient-purple-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(153.4, 72.8, 147.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(153.4,72.8,147.4);color:#fff}.bg-gradient-purple-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(155.8, 81.6, 125.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(155.8,81.6,125.8);color:#fff}.bg-gradient-purple-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(168.6, 116.8, 118.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(168.6,116.8,118.6);color:#fff}.bg-gradient-purple-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(103.4, 113.2, 152.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(103.4,113.2,152.6);color:#fff}.bg-gradient-purple-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(79.4, 120, 176.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(79.4,120,176.2);color:#fff}.bg-gradient-purple-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(103, 116.4, 204.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(103,116.4,204.6);color:#fff}.bg-gradient-pink-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(169.6, 99.2, 176.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(169.6,99.2,176.8);color:#fff}.bg-gradient-pink-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(180, 43.6, 180.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(180,43.6,180.8);color:#fff}.bg-gradient-pink-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(183.6, 63.6, 161.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(183.6,63.6,161.2);color:#fff}.bg-gradient-pink-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(226, 70.4, 115.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(226,70.4,115.6);color:#fff}.bg-gradient-pink-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(228.4, 79.2, 94);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(228.4,79.2,94);color:#fff}.bg-gradient-pink-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(241.2, 114.4, 86.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(241.2,114.4,86.8);color:#fff}.bg-gradient-pink-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(176, 110.8, 120.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(176,110.8,120.8);color:#fff}.bg-gradient-pink-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(152, 117.6, 144.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(152,117.6,144.4);color:#fff}.bg-gradient-pink-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(175.6, 114, 172.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(175.6,114,172.8);color:#fff}.bg-gradient-red-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(160.6, 111.8, 140.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(160.6,111.8,140.2);color:#fff}.bg-gradient-red-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(171, 56.2, 144.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(171,56.2,144.2);color:#fff}.bg-gradient-red-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(174.6, 76.2, 124.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(174.6,76.2,124.6);color:#fff}.bg-gradient-red-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(223, 74.6, 103.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(223,74.6,103.4);color:#fff}.bg-gradient-red-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(219.4, 91.8, 57.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(219.4,91.8,57.4);color:#fff}.bg-gradient-red-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(232.2, 127, 50.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(232.2,127,50.2);color:#fff}.bg-gradient-red-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(167, 123.4, 84.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(167,123.4,84.2);color:#fff}.bg-gradient-red-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(143, 130.2, 107.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(143,130.2,107.8);color:#fff}.bg-gradient-red-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(166.6, 126.6, 136.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(166.6,126.6,136.2);color:#fff}.bg-gradient-orange-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(164.2, 125, 107.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(164.2,125,107.8);color:#fff}.bg-gradient-orange-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(174.6, 69.4, 111.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(174.6,69.4,111.8);color:#fff}.bg-gradient-orange-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(178.2, 89.4, 92.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(178.2,89.4,92.2);color:#fff}.bg-gradient-orange-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(226.6, 87.8, 71);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(226.6,87.8,71);color:#fff}.bg-gradient-orange-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(220.6, 96.2, 46.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(220.6,96.2,46.6);color:#fff}.bg-gradient-orange-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(235.8, 140.2, 17.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(235.8,140.2,17.8);color:#fff}.bg-gradient-orange-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(170.6, 136.6, 51.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(170.6,136.6,51.8);color:#fff}.bg-gradient-orange-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(146.6, 143.4, 75.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(146.6,143.4,75.4);color:#fff}.bg-gradient-orange-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(170.2, 139.8, 103.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(170.2,139.8,103.8);color:#fff}.bg-gradient-yellow-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(183.4, 177.8, 97);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(183.4,177.8,97);color:#fff}.bg-gradient-yellow-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(193.8, 122.2, 101);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(193.8,122.2,101);color:#fff}.bg-gradient-yellow-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(197.4, 142.2, 81.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(197.4,142.2,81.4);color:#fff}.bg-gradient-yellow-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(245.8, 140.6, 60.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(245.8,140.6,60.2);color:#fff}.bg-gradient-yellow-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(239.8, 149, 35.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(239.8,149,35.8);color:#fff}.bg-gradient-yellow-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(242.2, 157.8, 14.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(242.2,157.8,14.2);color:#fff}.bg-gradient-yellow-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(189.8, 189.4, 41);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(189.8,189.4,41);color:#fff}.bg-gradient-yellow-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(165.8, 196.2, 64.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(165.8,196.2,64.6);color:#fff}.bg-gradient-yellow-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(189.4, 192.6, 93);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(189.4,192.6,93);color:#fff}.bg-gradient-green-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(85.6, 172.4, 148);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(85.6,172.4,148);color:#fff}.bg-gradient-green-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(96, 116.8, 152);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(96,116.8,152);color:#fff}.bg-gradient-green-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(99.6, 136.8, 132.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(99.6,136.8,132.4);color:#fff}.bg-gradient-green-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(148, 135.2, 111.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(148,135.2,111.2);color:#fff}.bg-gradient-green-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(142, 143.6, 86.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(142,143.6,86.8);color:#fff}.bg-gradient-green-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(144.4, 152.4, 65.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(144.4,152.4,65.2);color:#fff}.bg-gradient-green-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(157.2, 187.6, 58);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(157.2,187.6,58);color:#fff}.bg-gradient-green-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(68, 190.8, 115.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(68,190.8,115.6);color:#fff}.bg-gradient-green-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(91.6, 187.2, 144);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(91.6,187.2,144);color:#fff}.bg-gradient-teal-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(49.6, 182.6, 183.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(49.6,182.6,183.4);color:#fff}.bg-gradient-teal-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(60, 127, 187.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(60,127,187.4);color:#fff}.bg-gradient-teal-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(63.6, 147, 167.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(63.6,147,167.8);color:#fff}.bg-gradient-teal-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(112, 145.4, 146.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(112,145.4,146.6);color:#fff}.bg-gradient-teal-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(106, 153.8, 122.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(106,153.8,122.2);color:#fff}.bg-gradient-teal-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(108.4, 162.6, 100.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(108.4,162.6,100.6);color:#fff}.bg-gradient-teal-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(121.2, 197.8, 93.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(121.2,197.8,93.4);color:#fff}.bg-gradient-teal-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(56, 194.2, 127.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(56,194.2,127.4);color:#fff}.bg-gradient-teal-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(55.6, 197.4, 179.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(55.6,197.4,179.4);color:#fff}.bg-gradient-cyan-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(85, 177.2, 226);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(85,177.2,226);color:#fff}.bg-gradient-cyan-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(95.4, 121.6, 230);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(95.4,121.6,230);color:#fff}.bg-gradient-cyan-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(99, 141.6, 210.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(99,141.6,210.4);color:#fff}.bg-gradient-cyan-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(147.4, 140, 189.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(147.4,140,189.2);color:#fff}.bg-gradient-cyan-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(141.4, 148.4, 164.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(141.4,148.4,164.8);color:#fff}.bg-gradient-cyan-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(143.8, 157.2, 143.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(143.8,157.2,143.2);color:#fff}.bg-gradient-cyan-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(156.6, 192.4, 136);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(156.6,192.4,136);color:#fff}.bg-gradient-cyan-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(91.4, 188.8, 170);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(91.4,188.8,170);color:#fff}.bg-gradient-cyan-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(67.4, 195.6, 193.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(67.4,195.6,193.6);color:#fff}:root{--bslib-spacer: 1rem;--bslib-mb-spacer: var(--bslib-spacer, 1rem)}.bslib-mb-spacing{margin-bottom:var(--bslib-mb-spacer)}.bslib-gap-spacing{gap:var(--bslib-mb-spacer)}.bslib-gap-spacing>.bslib-mb-spacing,.bslib-gap-spacing>.form-group,.bslib-gap-spacing>p,.bslib-gap-spacing>pre{margin-bottom:0}.html-fill-container>.html-fill-item.bslib-mb-spacing{margin-bottom:0}:root{--bslib-spacer: 1rem;--bslib-mb-spacer: var(--bslib-spacer, 1rem)}.bslib-mb-spacing{margin-bottom:var(--bslib-mb-spacer)}.bslib-gap-spacing{gap:var(--bslib-mb-spacer)}.bslib-gap-spacing>.bslib-mb-spacing,.bslib-gap-spacing>.form-group,.bslib-gap-spacing>p,.bslib-gap-spacing>pre{margin-bottom:0}.html-fill-container>.html-fill-item.bslib-mb-spacing{margin-bottom:0}.tab-content>.tab-pane.html-fill-container{display:none}.tab-content>.active.html-fill-container{display:flex}.tab-content.html-fill-container{padding:0}.bg-blue{--bslib-color-bg: #4c9be8;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-blue{--bslib-color-fg: #4c9be8;color:var(--bslib-color-fg)}.bg-indigo{--bslib-color-bg: #6610f2;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-indigo{--bslib-color-fg: #6610f2;color:var(--bslib-color-fg)}.bg-purple{--bslib-color-bg: #6f42c1;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-purple{--bslib-color-fg: #6f42c1;color:var(--bslib-color-fg)}.bg-pink{--bslib-color-bg: #e83e8c;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-pink{--bslib-color-fg: #e83e8c;color:var(--bslib-color-fg)}.bg-red{--bslib-color-bg: #d9534f;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-red{--bslib-color-fg: #d9534f;color:var(--bslib-color-fg)}.bg-orange{--bslib-color-bg: #df6919;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-orange{--bslib-color-fg: #df6919;color:var(--bslib-color-fg)}.bg-yellow{--bslib-color-bg: #ffc107;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-yellow{--bslib-color-fg: #ffc107;color:var(--bslib-color-fg)}.bg-green{--bslib-color-bg: #5cb85c;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-green{--bslib-color-fg: #5cb85c;color:var(--bslib-color-fg)}.bg-teal{--bslib-color-bg: #20c997;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-teal{--bslib-color-fg: #20c997;color:var(--bslib-color-fg)}.bg-cyan{--bslib-color-bg: #5bc0de;--bslib-color-fg: #fff;background-color:var(--bslib-color-bg);color:var(--bslib-color-fg)}.text-cyan{--bslib-color-fg: #5bc0de;color:var(--bslib-color-fg)}.text-default{--bslib-color-fg: rgb(59, 76.6, 91)}.bg-default{--bslib-color-bg: rgb(59, 76.6, 91);--bslib-color-fg: #fff}.text-primary{--bslib-color-fg: #df6919}.bg-primary{--bslib-color-bg: #df6919;--bslib-color-fg: #fff}.text-secondary{--bslib-color-fg: rgb(59, 76.6, 91)}.bg-secondary{--bslib-color-bg: rgb(59, 76.6, 91);--bslib-color-fg: #fff}.text-success{--bslib-color-fg: #5cb85c}.bg-success{--bslib-color-bg: #5cb85c;--bslib-color-fg: #fff}.text-info{--bslib-color-fg: #5bc0de}.bg-info{--bslib-color-bg: #5bc0de;--bslib-color-fg: #fff}.text-warning{--bslib-color-fg: #ffc107}.bg-warning{--bslib-color-bg: #ffc107;--bslib-color-fg: #fff}.text-danger{--bslib-color-fg: #d9534f}.bg-danger{--bslib-color-bg: #d9534f;--bslib-color-fg: #fff}.text-light{--bslib-color-fg: rgb(129.4, 139.96, 148.6)}.bg-light{--bslib-color-bg: rgb(129.4, 139.96, 148.6);--bslib-color-fg: #fff}.text-dark{--bslib-color-fg: rgb(59, 76.6, 91)}.bg-dark{--bslib-color-bg: rgb(59, 76.6, 91);--bslib-color-fg: #fff}.bg-gradient-blue-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(86.4, 99.4, 236);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(86.4,99.4,236);color:#fff}.bg-gradient-blue-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(90, 119.4, 216.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(90,119.4,216.4);color:#fff}.bg-gradient-blue-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(138.4, 117.8, 195.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(138.4,117.8,195.2);color:#fff}.bg-gradient-blue-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(132.4, 126.2, 170.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(132.4,126.2,170.8);color:#fff}.bg-gradient-blue-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(134.8, 135, 149.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(134.8,135,149.2);color:#fff}.bg-gradient-blue-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(147.6, 170.2, 142);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(147.6,170.2,142);color:#fff}.bg-gradient-blue-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(82.4, 166.6, 176);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(82.4,166.6,176);color:#fff}.bg-gradient-blue-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(58.4, 173.4, 199.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(58.4,173.4,199.6);color:#fff}.bg-gradient-blue-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(82, 169.8, 228);background:linear-gradient(var(--bg-gradient-deg, 140deg), #4c9be8 var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(82,169.8,228);color:#fff}.bg-gradient-indigo-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(91.6, 71.6, 238);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(91.6,71.6,238);color:#fff}.bg-gradient-indigo-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(105.6, 36, 222.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(105.6,36,222.4);color:#fff}.bg-gradient-indigo-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(154, 34.4, 201.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(154,34.4,201.2);color:#fff}.bg-gradient-indigo-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(148, 42.8, 176.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(148,42.8,176.8);color:#fff}.bg-gradient-indigo-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(150.4, 51.6, 155.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(150.4,51.6,155.2);color:#fff}.bg-gradient-indigo-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(163.2, 86.8, 148);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(163.2,86.8,148);color:#fff}.bg-gradient-indigo-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(98, 83.2, 182);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(98,83.2,182);color:#fff}.bg-gradient-indigo-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(74, 90, 205.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(74,90,205.6);color:#fff}.bg-gradient-indigo-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(97.6, 86.4, 234);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6610f2 var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(97.6,86.4,234);color:#fff}.bg-gradient-purple-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(97, 101.6, 208.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(97,101.6,208.6);color:#fff}.bg-gradient-purple-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(107.4, 46, 212.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(107.4,46,212.6);color:#fff}.bg-gradient-purple-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(159.4, 64.4, 171.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(159.4,64.4,171.8);color:#fff}.bg-gradient-purple-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(153.4, 72.8, 147.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(153.4,72.8,147.4);color:#fff}.bg-gradient-purple-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(155.8, 81.6, 125.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(155.8,81.6,125.8);color:#fff}.bg-gradient-purple-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(168.6, 116.8, 118.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(168.6,116.8,118.6);color:#fff}.bg-gradient-purple-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(103.4, 113.2, 152.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(103.4,113.2,152.6);color:#fff}.bg-gradient-purple-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(79.4, 120, 176.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(79.4,120,176.2);color:#fff}.bg-gradient-purple-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(103, 116.4, 204.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #6f42c1 var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(103,116.4,204.6);color:#fff}.bg-gradient-pink-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(169.6, 99.2, 176.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(169.6,99.2,176.8);color:#fff}.bg-gradient-pink-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(180, 43.6, 180.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(180,43.6,180.8);color:#fff}.bg-gradient-pink-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(183.6, 63.6, 161.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(183.6,63.6,161.2);color:#fff}.bg-gradient-pink-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(226, 70.4, 115.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(226,70.4,115.6);color:#fff}.bg-gradient-pink-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(228.4, 79.2, 94);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(228.4,79.2,94);color:#fff}.bg-gradient-pink-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(241.2, 114.4, 86.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(241.2,114.4,86.8);color:#fff}.bg-gradient-pink-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(176, 110.8, 120.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(176,110.8,120.8);color:#fff}.bg-gradient-pink-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(152, 117.6, 144.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(152,117.6,144.4);color:#fff}.bg-gradient-pink-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(175.6, 114, 172.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #e83e8c var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(175.6,114,172.8);color:#fff}.bg-gradient-red-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(160.6, 111.8, 140.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(160.6,111.8,140.2);color:#fff}.bg-gradient-red-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(171, 56.2, 144.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(171,56.2,144.2);color:#fff}.bg-gradient-red-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(174.6, 76.2, 124.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(174.6,76.2,124.6);color:#fff}.bg-gradient-red-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(223, 74.6, 103.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(223,74.6,103.4);color:#fff}.bg-gradient-red-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(219.4, 91.8, 57.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(219.4,91.8,57.4);color:#fff}.bg-gradient-red-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(232.2, 127, 50.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(232.2,127,50.2);color:#fff}.bg-gradient-red-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(167, 123.4, 84.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(167,123.4,84.2);color:#fff}.bg-gradient-red-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(143, 130.2, 107.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(143,130.2,107.8);color:#fff}.bg-gradient-red-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(166.6, 126.6, 136.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #d9534f var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(166.6,126.6,136.2);color:#fff}.bg-gradient-orange-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(164.2, 125, 107.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(164.2,125,107.8);color:#fff}.bg-gradient-orange-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(174.6, 69.4, 111.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(174.6,69.4,111.8);color:#fff}.bg-gradient-orange-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(178.2, 89.4, 92.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(178.2,89.4,92.2);color:#fff}.bg-gradient-orange-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(226.6, 87.8, 71);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(226.6,87.8,71);color:#fff}.bg-gradient-orange-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(220.6, 96.2, 46.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(220.6,96.2,46.6);color:#fff}.bg-gradient-orange-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(235.8, 140.2, 17.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(235.8,140.2,17.8);color:#fff}.bg-gradient-orange-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(170.6, 136.6, 51.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(170.6,136.6,51.8);color:#fff}.bg-gradient-orange-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(146.6, 143.4, 75.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(146.6,143.4,75.4);color:#fff}.bg-gradient-orange-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(170.2, 139.8, 103.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #df6919 var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(170.2,139.8,103.8);color:#fff}.bg-gradient-yellow-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(183.4, 177.8, 97);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(183.4,177.8,97);color:#fff}.bg-gradient-yellow-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(193.8, 122.2, 101);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(193.8,122.2,101);color:#fff}.bg-gradient-yellow-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(197.4, 142.2, 81.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(197.4,142.2,81.4);color:#fff}.bg-gradient-yellow-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(245.8, 140.6, 60.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(245.8,140.6,60.2);color:#fff}.bg-gradient-yellow-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(239.8, 149, 35.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(239.8,149,35.8);color:#fff}.bg-gradient-yellow-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(242.2, 157.8, 14.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(242.2,157.8,14.2);color:#fff}.bg-gradient-yellow-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(189.8, 189.4, 41);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(189.8,189.4,41);color:#fff}.bg-gradient-yellow-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(165.8, 196.2, 64.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(165.8,196.2,64.6);color:#fff}.bg-gradient-yellow-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(189.4, 192.6, 93);background:linear-gradient(var(--bg-gradient-deg, 140deg), #ffc107 var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(189.4,192.6,93);color:#fff}.bg-gradient-green-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(85.6, 172.4, 148);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(85.6,172.4,148);color:#fff}.bg-gradient-green-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(96, 116.8, 152);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(96,116.8,152);color:#fff}.bg-gradient-green-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(99.6, 136.8, 132.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(99.6,136.8,132.4);color:#fff}.bg-gradient-green-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(148, 135.2, 111.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(148,135.2,111.2);color:#fff}.bg-gradient-green-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(142, 143.6, 86.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(142,143.6,86.8);color:#fff}.bg-gradient-green-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(144.4, 152.4, 65.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(144.4,152.4,65.2);color:#fff}.bg-gradient-green-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(157.2, 187.6, 58);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(157.2,187.6,58);color:#fff}.bg-gradient-green-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(68, 190.8, 115.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(68,190.8,115.6);color:#fff}.bg-gradient-green-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(91.6, 187.2, 144);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5cb85c var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(91.6,187.2,144);color:#fff}.bg-gradient-teal-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(49.6, 182.6, 183.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(49.6,182.6,183.4);color:#fff}.bg-gradient-teal-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(60, 127, 187.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(60,127,187.4);color:#fff}.bg-gradient-teal-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(63.6, 147, 167.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(63.6,147,167.8);color:#fff}.bg-gradient-teal-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(112, 145.4, 146.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(112,145.4,146.6);color:#fff}.bg-gradient-teal-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(106, 153.8, 122.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(106,153.8,122.2);color:#fff}.bg-gradient-teal-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(108.4, 162.6, 100.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(108.4,162.6,100.6);color:#fff}.bg-gradient-teal-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(121.2, 197.8, 93.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(121.2,197.8,93.4);color:#fff}.bg-gradient-teal-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(56, 194.2, 127.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(56,194.2,127.4);color:#fff}.bg-gradient-teal-cyan{--bslib-color-fg: #fff;--bslib-color-bg: rgb(55.6, 197.4, 179.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #20c997 var(--bg-gradient-start, 36%), #5bc0de var(--bg-gradient-end, 180%)) rgb(55.6,197.4,179.4);color:#fff}.bg-gradient-cyan-blue{--bslib-color-fg: #fff;--bslib-color-bg: rgb(85, 177.2, 226);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #4c9be8 var(--bg-gradient-end, 180%)) rgb(85,177.2,226);color:#fff}.bg-gradient-cyan-indigo{--bslib-color-fg: #fff;--bslib-color-bg: rgb(95.4, 121.6, 230);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #6610f2 var(--bg-gradient-end, 180%)) rgb(95.4,121.6,230);color:#fff}.bg-gradient-cyan-purple{--bslib-color-fg: #fff;--bslib-color-bg: rgb(99, 141.6, 210.4);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #6f42c1 var(--bg-gradient-end, 180%)) rgb(99,141.6,210.4);color:#fff}.bg-gradient-cyan-pink{--bslib-color-fg: #fff;--bslib-color-bg: rgb(147.4, 140, 189.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #e83e8c var(--bg-gradient-end, 180%)) rgb(147.4,140,189.2);color:#fff}.bg-gradient-cyan-red{--bslib-color-fg: #fff;--bslib-color-bg: rgb(141.4, 148.4, 164.8);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #d9534f var(--bg-gradient-end, 180%)) rgb(141.4,148.4,164.8);color:#fff}.bg-gradient-cyan-orange{--bslib-color-fg: #fff;--bslib-color-bg: rgb(143.8, 157.2, 143.2);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #df6919 var(--bg-gradient-end, 180%)) rgb(143.8,157.2,143.2);color:#fff}.bg-gradient-cyan-yellow{--bslib-color-fg: #fff;--bslib-color-bg: rgb(156.6, 192.4, 136);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #ffc107 var(--bg-gradient-end, 180%)) rgb(156.6,192.4,136);color:#fff}.bg-gradient-cyan-green{--bslib-color-fg: #fff;--bslib-color-bg: rgb(91.4, 188.8, 170);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #5cb85c var(--bg-gradient-end, 180%)) rgb(91.4,188.8,170);color:#fff}.bg-gradient-cyan-teal{--bslib-color-fg: #fff;--bslib-color-bg: rgb(67.4, 195.6, 193.6);background:linear-gradient(var(--bg-gradient-deg, 140deg), #5bc0de var(--bg-gradient-start, 36%), #20c997 var(--bg-gradient-end, 180%)) rgb(67.4,195.6,193.6);color:#fff}html{height:100%}.bslib-page-fill{width:100%;height:100%;margin:0;padding:var(--bslib-spacer, 1rem);gap:var(--bslib-spacer, 1rem)}@media(max-width: 575.98px){.bslib-page-fill{height:var(--bslib-page-fill-mobile-height, auto)}}.navbar+.container-fluid:has(>.tab-content>.tab-pane.active.html-fill-container),.navbar+.container-sm:has(>.tab-content>.tab-pane.active.html-fill-container),.navbar+.container-md:has(>.tab-content>.tab-pane.active.html-fill-container),.navbar+.container-lg:has(>.tab-content>.tab-pane.active.html-fill-container),.navbar+.container-xl:has(>.tab-content>.tab-pane.active.html-fill-container),.navbar+.container-xxl:has(>.tab-content>.tab-pane.active.html-fill-container){padding-left:0;padding-right:0}.navbar+.container-fluid>.tab-content>.tab-pane.active.html-fill-container,.navbar+.container-sm>.tab-content>.tab-pane.active.html-fill-container,.navbar+.container-md>.tab-content>.tab-pane.active.html-fill-container,.navbar+.container-lg>.tab-content>.tab-pane.active.html-fill-container,.navbar+.container-xl>.tab-content>.tab-pane.active.html-fill-container,.navbar+.container-xxl>.tab-content>.tab-pane.active.html-fill-container{padding:var(--bslib-spacer, 1rem);gap:var(--bslib-spacer, 1rem)}.navbar+.container-fluid>.tab-content>.tab-pane.active.html-fill-container:has(>.bslib-sidebar-layout:only-child),.navbar+.container-sm>.tab-content>.tab-pane.active.html-fill-container:has(>.bslib-sidebar-layout:only-child),.navbar+.container-md>.tab-content>.tab-pane.active.html-fill-container:has(>.bslib-sidebar-layout:only-child),.navbar+.container-lg>.tab-content>.tab-pane.active.html-fill-container:has(>.bslib-sidebar-layout:only-child),.navbar+.container-xl>.tab-content>.tab-pane.active.html-fill-container:has(>.bslib-sidebar-layout:only-child),.navbar+.container-xxl>.tab-content>.tab-pane.active.html-fill-container:has(>.bslib-sidebar-layout:only-child){padding:0}.navbar+.container-fluid>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border=true]),.navbar+.container-sm>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border=true]),.navbar+.container-md>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border=true]),.navbar+.container-lg>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border=true]),.navbar+.container-xl>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border=true]),.navbar+.container-xxl>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border=true]){border-left:none;border-right:none;border-bottom:none}.navbar+.container-fluid>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border-radius=true]),.navbar+.container-sm>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border-radius=true]),.navbar+.container-md>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border-radius=true]),.navbar+.container-lg>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border-radius=true]),.navbar+.container-xl>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border-radius=true]),.navbar+.container-xxl>.tab-content>.tab-pane.active.html-fill-container>.bslib-sidebar-layout:only-child:not([data-bslib-sidebar-border-radius=true]){border-radius:0}.navbar+div>.bslib-sidebar-layout{border-top:var(--bslib-sidebar-border)}@media(min-width: 576px){.nav:not(.nav-hidden){display:flex !important;display:-webkit-flex !important}.nav:not(.nav-hidden):not(.nav-stacked):not(.flex-column){float:none !important}.nav:not(.nav-hidden):not(.nav-stacked):not(.flex-column)>.bslib-nav-spacer{margin-left:auto !important}.nav:not(.nav-hidden):not(.nav-stacked):not(.flex-column)>.form-inline{margin-top:auto;margin-bottom:auto}.nav:not(.nav-hidden).nav-stacked{flex-direction:column;-webkit-flex-direction:column;height:100%}.nav:not(.nav-hidden).nav-stacked>.bslib-nav-spacer{margin-top:auto !important}}.bslib-grid{display:grid !important;gap:var(--bslib-spacer, 1rem);height:var(--bslib-grid-height)}.bslib-grid.grid{grid-template-columns:repeat(var(--bs-columns, 12), minmax(0, 1fr));grid-template-rows:unset;grid-auto-rows:var(--bslib-grid--row-heights);--bslib-grid--row-heights--xs: unset;--bslib-grid--row-heights--sm: unset;--bslib-grid--row-heights--md: unset;--bslib-grid--row-heights--lg: unset;--bslib-grid--row-heights--xl: unset;--bslib-grid--row-heights--xxl: unset}.bslib-grid.grid.bslib-grid--row-heights--xs{--bslib-grid--row-heights: var(--bslib-grid--row-heights--xs)}@media(min-width: 576px){.bslib-grid.grid.bslib-grid--row-heights--sm{--bslib-grid--row-heights: var(--bslib-grid--row-heights--sm)}}@media(min-width: 768px){.bslib-grid.grid.bslib-grid--row-heights--md{--bslib-grid--row-heights: var(--bslib-grid--row-heights--md)}}@media(min-width: 992px){.bslib-grid.grid.bslib-grid--row-heights--lg{--bslib-grid--row-heights: var(--bslib-grid--row-heights--lg)}}@media(min-width: 1200px){.bslib-grid.grid.bslib-grid--row-heights--xl{--bslib-grid--row-heights: var(--bslib-grid--row-heights--xl)}}@media(min-width: 1400px){.bslib-grid.grid.bslib-grid--row-heights--xxl{--bslib-grid--row-heights: var(--bslib-grid--row-heights--xxl)}}.bslib-grid>*>.shiny-input-container{width:100%}.bslib-grid-item{grid-column:auto/span 1}@media(max-width: 767.98px){.bslib-grid-item{grid-column:1/-1}}@media(max-width: 575.98px){.bslib-grid{grid-template-columns:1fr !important;height:var(--bslib-grid-height-mobile)}.bslib-grid.grid{height:unset !important;grid-auto-rows:var(--bslib-grid--row-heights--xs, auto)}}:root{--bslib-page-sidebar-title-bg: rgb(59, 76.6, 91);--bslib-page-sidebar-title-color: #fff}.bslib-page-title{background-color:var(--bslib-page-sidebar-title-bg);color:var(--bslib-page-sidebar-title-color);font-size:1.25rem;font-weight:300;padding:var(--bslib-spacer, 1rem);padding-left:1.5rem;margin-bottom:0;border-bottom:1px solid #dee2e6}.bslib-sidebar-layout{--bslib-sidebar-transition-duration: 500ms;--bslib-sidebar-transition-easing-x: cubic-bezier(0.8, 0.78, 0.22, 1.07);--bslib-sidebar-border: var(--bs-card-border-width, 1px) solid var(--bs-card-border-color, rgba(0, 0, 0, 0.175));--bslib-sidebar-border-radius: var(--bs-border-radius);--bslib-sidebar-vert-border: var(--bs-card-border-width, 1px) solid var(--bs-card-border-color, rgba(0, 0, 0, 0.175));--bslib-sidebar-bg: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05);--bslib-sidebar-fg: var(--bs-emphasis-color, black);--bslib-sidebar-main-fg: var(--bs-card-color, var(--bs-body-color));--bslib-sidebar-main-bg: var(--bs-card-bg, var(--bs-body-bg));--bslib-sidebar-toggle-bg: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.1);--bslib-sidebar-padding: calc(var(--bslib-spacer) * 1.5);--bslib-sidebar-icon-size: var(--bslib-spacer, 1rem);--bslib-sidebar-icon-button-size: calc(var(--bslib-sidebar-icon-size, 1rem) * 2);--bslib-sidebar-padding-icon: calc(var(--bslib-sidebar-icon-button-size, 2rem) * 1.5);--bslib-collapse-toggle-border-radius: var(--bs-border-radius, 0.25rem);--bslib-collapse-toggle-transform: 0deg;--bslib-sidebar-toggle-transition-easing: cubic-bezier(1, 0, 0, 1);--bslib-collapse-toggle-right-transform: 180deg;--bslib-sidebar-column-main: minmax(0, 1fr);display:grid !important;grid-template-columns:min(100% - var(--bslib-sidebar-icon-size),var(--bslib-sidebar-width, 250px)) var(--bslib-sidebar-column-main);position:relative;transition:grid-template-columns ease-in-out var(--bslib-sidebar-transition-duration);border:var(--bslib-sidebar-border);border-radius:var(--bslib-sidebar-border-radius)}@media(prefers-reduced-motion: reduce){.bslib-sidebar-layout{transition:none}}.bslib-sidebar-layout[data-bslib-sidebar-border=false]{border:none}.bslib-sidebar-layout[data-bslib-sidebar-border-radius=false]{border-radius:initial}.bslib-sidebar-layout>.main,.bslib-sidebar-layout>.sidebar{grid-row:1/2;border-radius:inherit;overflow:auto}.bslib-sidebar-layout>.main{grid-column:2/3;border-top-left-radius:0;border-bottom-left-radius:0;padding:var(--bslib-sidebar-padding);transition:padding var(--bslib-sidebar-transition-easing-x) var(--bslib-sidebar-transition-duration);color:var(--bslib-sidebar-main-fg);background-color:var(--bslib-sidebar-main-bg)}.bslib-sidebar-layout>.sidebar{grid-column:1/2;width:100%;height:100%;border-right:var(--bslib-sidebar-vert-border);border-top-right-radius:0;border-bottom-right-radius:0;color:var(--bslib-sidebar-fg);background-color:var(--bslib-sidebar-bg);backdrop-filter:blur(5px)}.bslib-sidebar-layout>.sidebar>.sidebar-content{display:flex;flex-direction:column;gap:var(--bslib-spacer, 1rem);padding:var(--bslib-sidebar-padding);padding-top:var(--bslib-sidebar-padding-icon)}.bslib-sidebar-layout>.sidebar>.sidebar-content>:last-child:not(.sidebar-title){margin-bottom:0}.bslib-sidebar-layout>.sidebar>.sidebar-content>.accordion{margin-left:calc(-1*var(--bslib-sidebar-padding));margin-right:calc(-1*var(--bslib-sidebar-padding))}.bslib-sidebar-layout>.sidebar>.sidebar-content>.accordion:last-child{margin-bottom:calc(-1*var(--bslib-sidebar-padding))}.bslib-sidebar-layout>.sidebar>.sidebar-content>.accordion:not(:last-child){margin-bottom:1rem}.bslib-sidebar-layout>.sidebar>.sidebar-content>.accordion .accordion-body{display:flex;flex-direction:column}.bslib-sidebar-layout>.sidebar>.sidebar-content>.accordion:not(:first-child) .accordion-item:first-child{border-top:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.bslib-sidebar-layout>.sidebar>.sidebar-content>.accordion:not(:last-child) .accordion-item:last-child{border-bottom:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.bslib-sidebar-layout>.sidebar>.sidebar-content.has-accordion>.sidebar-title{border-bottom:none;padding-bottom:0}.bslib-sidebar-layout>.sidebar .shiny-input-container{width:100%}.bslib-sidebar-layout[data-bslib-sidebar-open=always]>.sidebar>.sidebar-content{padding-top:var(--bslib-sidebar-padding)}.bslib-sidebar-layout>.collapse-toggle{grid-row:1/2;grid-column:1/2;display:inline-flex;align-items:center;position:absolute;right:calc(var(--bslib-sidebar-icon-size));top:calc(var(--bslib-sidebar-icon-size, 1rem)/2);border:none;border-radius:var(--bslib-collapse-toggle-border-radius);height:var(--bslib-sidebar-icon-button-size, 2rem);width:var(--bslib-sidebar-icon-button-size, 2rem);display:flex;align-items:center;justify-content:center;padding:0;color:var(--bslib-sidebar-fg);background-color:unset;transition:color var(--bslib-sidebar-transition-easing-x) var(--bslib-sidebar-transition-duration),top var(--bslib-sidebar-transition-easing-x) var(--bslib-sidebar-transition-duration),right var(--bslib-sidebar-transition-easing-x) var(--bslib-sidebar-transition-duration),left var(--bslib-sidebar-transition-easing-x) var(--bslib-sidebar-transition-duration)}.bslib-sidebar-layout>.collapse-toggle:hover{background-color:var(--bslib-sidebar-toggle-bg)}.bslib-sidebar-layout>.collapse-toggle>.collapse-icon{opacity:.8;width:var(--bslib-sidebar-icon-size);height:var(--bslib-sidebar-icon-size);transform:rotateY(var(--bslib-collapse-toggle-transform));transition:transform var(--bslib-sidebar-toggle-transition-easing) var(--bslib-sidebar-transition-duration)}.bslib-sidebar-layout>.collapse-toggle:hover>.collapse-icon{opacity:1}.bslib-sidebar-layout .sidebar-title{font-size:1.25rem;line-height:1.25;margin-top:0;margin-bottom:1rem;padding-bottom:1rem;border-bottom:var(--bslib-sidebar-border)}.bslib-sidebar-layout.sidebar-right{grid-template-columns:var(--bslib-sidebar-column-main) min(100% - var(--bslib-sidebar-icon-size),var(--bslib-sidebar-width, 250px))}.bslib-sidebar-layout.sidebar-right>.main{grid-column:1/2;border-top-right-radius:0;border-bottom-right-radius:0;border-top-left-radius:inherit;border-bottom-left-radius:inherit}.bslib-sidebar-layout.sidebar-right>.sidebar{grid-column:2/3;border-right:none;border-left:var(--bslib-sidebar-vert-border);border-top-left-radius:0;border-bottom-left-radius:0}.bslib-sidebar-layout.sidebar-right>.collapse-toggle{grid-column:2/3;left:var(--bslib-sidebar-icon-size);right:unset;border:var(--bslib-collapse-toggle-border)}.bslib-sidebar-layout.sidebar-right>.collapse-toggle>.collapse-icon{transform:rotateY(var(--bslib-collapse-toggle-right-transform))}.bslib-sidebar-layout.sidebar-collapsed{--bslib-collapse-toggle-transform: 180deg;--bslib-collapse-toggle-right-transform: 0deg;--bslib-sidebar-vert-border: none;grid-template-columns:0 minmax(0, 1fr)}.bslib-sidebar-layout.sidebar-collapsed.sidebar-right{grid-template-columns:minmax(0, 1fr) 0}.bslib-sidebar-layout.sidebar-collapsed:not(.transitioning)>.sidebar>*{display:none}.bslib-sidebar-layout.sidebar-collapsed>.main{border-radius:inherit}.bslib-sidebar-layout.sidebar-collapsed:not(.sidebar-right)>.main{padding-left:var(--bslib-sidebar-padding-icon)}.bslib-sidebar-layout.sidebar-collapsed.sidebar-right>.main{padding-right:var(--bslib-sidebar-padding-icon)}.bslib-sidebar-layout.sidebar-collapsed>.collapse-toggle{color:var(--bslib-sidebar-main-fg);top:calc(var(--bslib-sidebar-overlap-counter, 0)*(var(--bslib-sidebar-icon-size) + var(--bslib-sidebar-padding)) + var(--bslib-sidebar-icon-size, 1rem)/2);right:calc(-2.5*var(--bslib-sidebar-icon-size) - var(--bs-card-border-width, 1px))}.bslib-sidebar-layout.sidebar-collapsed.sidebar-right>.collapse-toggle{left:calc(-2.5*var(--bslib-sidebar-icon-size) - var(--bs-card-border-width, 1px));right:unset}@media(min-width: 576px){.bslib-sidebar-layout.transitioning>.sidebar>.sidebar-content{display:none}}@media(max-width: 575.98px){.bslib-sidebar-layout[data-bslib-sidebar-open=desktop]{--bslib-sidebar-js-init-collapsed: true}.bslib-sidebar-layout>.sidebar,.bslib-sidebar-layout.sidebar-right>.sidebar{border:none}.bslib-sidebar-layout>.main,.bslib-sidebar-layout.sidebar-right>.main{grid-column:1/3}.bslib-sidebar-layout[data-bslib-sidebar-open=always]{display:block !important}.bslib-sidebar-layout[data-bslib-sidebar-open=always]>.sidebar{max-height:var(--bslib-sidebar-max-height-mobile);overflow-y:auto;border-top:var(--bslib-sidebar-vert-border)}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]){grid-template-columns:100% 0}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]):not(.sidebar-collapsed)>.sidebar{z-index:1}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]):not(.sidebar-collapsed)>.collapse-toggle{z-index:1}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]).sidebar-right{grid-template-columns:0 100%}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]).sidebar-collapsed{grid-template-columns:0 100%}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]).sidebar-collapsed.sidebar-right{grid-template-columns:100% 0}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]):not(.sidebar-right)>.main{padding-left:var(--bslib-sidebar-padding-icon)}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]).sidebar-right>.main{padding-right:var(--bslib-sidebar-padding-icon)}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always])>.main{opacity:0;transition:opacity var(--bslib-sidebar-transition-easing-x) var(--bslib-sidebar-transition-duration)}.bslib-sidebar-layout:not([data-bslib-sidebar-open=always]).sidebar-collapsed>.main{opacity:1}}.accordion .accordion-header{font-size:calc(1.29rem + 0.48vw);margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color);margin-bottom:0}@media(min-width: 1200px){.accordion .accordion-header{font-size:1.65rem}}.accordion .accordion-icon:not(:empty){margin-right:.75rem;display:flex}.accordion .accordion-button:not(.collapsed){box-shadow:none}.accordion .accordion-button:not(.collapsed):focus{box-shadow:var(--bs-accordion-btn-focus-box-shadow)}:root{--bslib-value-box-shadow: none;--bslib-value-box-border-width-auto-yes: var(--bslib-value-box-border-width-baseline);--bslib-value-box-border-width-auto-no: 0;--bslib-value-box-border-width-baseline: 1px}.bslib-value-box{border-width:var(--bslib-value-box-border-width-auto-no, var(--bslib-value-box-border-width-baseline));container-name:bslib-value-box;container-type:inline-size}.bslib-value-box.card{box-shadow:var(--bslib-value-box-shadow)}.bslib-value-box.border-auto{border-width:var(--bslib-value-box-border-width-auto-yes, var(--bslib-value-box-border-width-baseline))}.bslib-value-box.default{--bslib-value-box-bg-default: var(--bs-card-bg, #0f2537);--bslib-value-box-border-color-default: var(--bs-card-border-color, rgba(0, 0, 0, 0.175));color:var(--bslib-value-box-color);background-color:var(--bslib-value-box-bg, var(--bslib-value-box-bg-default));border-color:var(--bslib-value-box-border-color, var(--bslib-value-box-border-color-default))}.bslib-value-box .value-box-grid{display:grid;grid-template-areas:"left right";align-items:center;overflow:hidden}.bslib-value-box .value-box-showcase{height:100%;max-height:var(---bslib-value-box-showcase-max-h, 100%)}.bslib-value-box .value-box-showcase,.bslib-value-box .value-box-showcase>.html-fill-item{width:100%}.bslib-value-box[data-full-screen=true] .value-box-showcase{max-height:var(---bslib-value-box-showcase-max-h-fs, 100%)}@media screen and (min-width: 575.98px){@container bslib-value-box (max-width: 300px){.bslib-value-box:not(.showcase-bottom) .value-box-grid{grid-template-columns:1fr !important;grid-template-rows:auto auto;grid-template-areas:"top" "bottom"}.bslib-value-box:not(.showcase-bottom) .value-box-grid .value-box-showcase{grid-area:top !important}.bslib-value-box:not(.showcase-bottom) .value-box-grid .value-box-area{grid-area:bottom !important;justify-content:end}}}.bslib-value-box .value-box-area{justify-content:center;padding:1.5rem 1rem;font-size:.9rem;font-weight:500}.bslib-value-box .value-box-area *{margin-bottom:0;margin-top:0}.bslib-value-box .value-box-title{font-size:1rem;margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.bslib-value-box .value-box-title:empty::after{content:" "}.bslib-value-box .value-box-value{font-size:calc(1.29rem + 0.48vw);margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}@media(min-width: 1200px){.bslib-value-box .value-box-value{font-size:1.65rem}}.bslib-value-box .value-box-value:empty::after{content:" "}.bslib-value-box .value-box-showcase{align-items:center;justify-content:center;margin-top:auto;margin-bottom:auto;padding:1rem}.bslib-value-box .value-box-showcase .bi,.bslib-value-box .value-box-showcase .fa,.bslib-value-box .value-box-showcase .fab,.bslib-value-box .value-box-showcase .fas,.bslib-value-box .value-box-showcase .far{opacity:.85;min-width:50px;max-width:125%}.bslib-value-box .value-box-showcase .bi,.bslib-value-box .value-box-showcase .fa,.bslib-value-box .value-box-showcase .fab,.bslib-value-box .value-box-showcase .fas,.bslib-value-box .value-box-showcase .far{font-size:4rem}.bslib-value-box.showcase-top-right .value-box-grid{grid-template-columns:1fr var(---bslib-value-box-showcase-w, 50%)}.bslib-value-box.showcase-top-right .value-box-grid .value-box-showcase{grid-area:right;margin-left:auto;align-self:start;align-items:end;padding-left:0;padding-bottom:0}.bslib-value-box.showcase-top-right .value-box-grid .value-box-area{grid-area:left;align-self:end}.bslib-value-box.showcase-top-right[data-full-screen=true] .value-box-grid{grid-template-columns:auto var(---bslib-value-box-showcase-w-fs, 1fr)}.bslib-value-box.showcase-top-right[data-full-screen=true] .value-box-grid>div{align-self:center}.bslib-value-box.showcase-top-right:not([data-full-screen=true]) .value-box-showcase{margin-top:0}@container bslib-value-box (max-width: 300px){.bslib-value-box.showcase-top-right:not([data-full-screen=true]) .value-box-grid .value-box-showcase{padding-left:1rem}}.bslib-value-box.showcase-left-center .value-box-grid{grid-template-columns:var(---bslib-value-box-showcase-w, 30%) auto}.bslib-value-box.showcase-left-center[data-full-screen=true] .value-box-grid{grid-template-columns:var(---bslib-value-box-showcase-w-fs, 1fr) auto}.bslib-value-box.showcase-left-center:not([data-fill-screen=true]) .value-box-grid .value-box-showcase{grid-area:left}.bslib-value-box.showcase-left-center:not([data-fill-screen=true]) .value-box-grid .value-box-area{grid-area:right}.bslib-value-box.showcase-bottom .value-box-grid{grid-template-columns:1fr;grid-template-rows:1fr var(---bslib-value-box-showcase-h, auto);grid-template-areas:"top" "bottom";overflow:hidden}.bslib-value-box.showcase-bottom .value-box-grid .value-box-showcase{grid-area:bottom;padding:0;margin:0}.bslib-value-box.showcase-bottom .value-box-grid .value-box-area{grid-area:top}.bslib-value-box.showcase-bottom[data-full-screen=true] .value-box-grid{grid-template-rows:1fr var(---bslib-value-box-showcase-h-fs, 2fr)}.bslib-value-box.showcase-bottom[data-full-screen=true] .value-box-grid .value-box-showcase{padding:1rem}[data-bs-theme=dark] .bslib-value-box{--bslib-value-box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 50%)}.bslib-card{overflow:auto}.bslib-card .card-body+.card-body{padding-top:0}.bslib-card .card-body{overflow:auto}.bslib-card .card-body p{margin-top:0}.bslib-card .card-body p:last-child{margin-bottom:0}.bslib-card .card-body{max-height:var(--bslib-card-body-max-height, none)}.bslib-card[data-full-screen=true]>.card-body{max-height:var(--bslib-card-body-max-height-full-screen, none)}.bslib-card .card-header .form-group{margin-bottom:0}.bslib-card .card-header .selectize-control{margin-bottom:0}.bslib-card .card-header .selectize-control .item{margin-right:1.15rem}.bslib-card .card-footer{margin-top:auto}.bslib-card .bslib-navs-card-title{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center}.bslib-card .bslib-navs-card-title .nav{margin-left:auto}.bslib-card .bslib-sidebar-layout:not([data-bslib-sidebar-border=true]){border:none}.bslib-card .bslib-sidebar-layout:not([data-bslib-sidebar-border-radius=true]){border-top-left-radius:0;border-top-right-radius:0}[data-full-screen=true]{position:fixed;inset:3.5rem 1rem 1rem;height:auto !important;max-height:none !important;width:auto !important;z-index:1070}.bslib-full-screen-enter{display:none;position:absolute;bottom:var(--bslib-full-screen-enter-bottom, 0.2rem);right:var(--bslib-full-screen-enter-right, 0);top:var(--bslib-full-screen-enter-top);left:var(--bslib-full-screen-enter-left);color:var(--bslib-color-fg, var(--bs-card-color));background-color:var(--bslib-color-bg, var(--bs-card-bg, var(--bs-body-bg)));border:var(--bs-card-border-width) solid var(--bslib-color-fg, var(--bs-card-border-color));box-shadow:0 2px 4px rgba(0,0,0,.15);margin:.2rem .4rem;padding:.55rem !important;font-size:.8rem;cursor:pointer;opacity:.7;z-index:1070}.bslib-full-screen-enter:hover{opacity:1}.card[data-full-screen=false]:hover>*>.bslib-full-screen-enter{display:block}.bslib-has-full-screen .card:hover>*>.bslib-full-screen-enter{display:none}@media(max-width: 575.98px){.bslib-full-screen-enter{display:none !important}}.bslib-full-screen-exit{position:relative;top:1.35rem;font-size:.9rem;cursor:pointer;text-decoration:none;display:flex;float:right;margin-right:2.15rem;align-items:center;color:rgba(var(--bs-body-bg-rgb), 0.8)}.bslib-full-screen-exit:hover{color:rgba(var(--bs-body-bg-rgb), 1)}.bslib-full-screen-exit svg{margin-left:.5rem;font-size:1.5rem}#bslib-full-screen-overlay{position:fixed;inset:0;background-color:rgba(var(--bs-body-color-rgb), 0.6);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);z-index:1069;animation:bslib-full-screen-overlay-enter 400ms cubic-bezier(0.6, 0.02, 0.65, 1) forwards}@keyframes bslib-full-screen-overlay-enter{0%{opacity:0}100%{opacity:1}}.html-fill-container{display:flex;flex-direction:column;min-height:0;min-width:0}.html-fill-container>.html-fill-item{flex:1 1 auto;min-height:0;min-width:0}.html-fill-container>:not(.html-fill-item){flex:0 0 auto}.quarto-container{min-height:calc(100vh - 132px)}body.hypothesis-enabled #quarto-header{margin-right:16px}footer.footer .nav-footer,#quarto-header>nav{padding-left:1em;padding-right:1em}footer.footer div.nav-footer p:first-child{margin-top:0}footer.footer div.nav-footer p:last-child{margin-bottom:0}#quarto-content>*{padding-top:14px}#quarto-content>#quarto-sidebar-glass{padding-top:0px}@media(max-width: 991.98px){#quarto-content>*{padding-top:0}#quarto-content .subtitle{padding-top:14px}#quarto-content section:first-of-type h2:first-of-type,#quarto-content section:first-of-type .h2:first-of-type{margin-top:1rem}}.headroom-target,header.headroom{will-change:transform;transition:position 200ms linear;transition:all 200ms linear}header.headroom--pinned{transform:translateY(0%)}header.headroom--unpinned{transform:translateY(-100%)}.navbar-container{width:100%}.navbar-brand{overflow:hidden;text-overflow:ellipsis}.navbar-brand-container{max-width:calc(100% - 115px);min-width:0;display:flex;align-items:center}@media(min-width: 992px){.navbar-brand-container{margin-right:1em}}.navbar-brand.navbar-brand-logo{margin-right:4px;display:inline-flex}.navbar-toggler{flex-basis:content;flex-shrink:0}.navbar .navbar-brand-container{order:2}.navbar .navbar-toggler{order:1}.navbar .navbar-container>.navbar-nav{order:20}.navbar .navbar-container>.navbar-brand-container{margin-left:0 !important;margin-right:0 !important}.navbar .navbar-collapse{order:20}.navbar #quarto-search{order:4;margin-left:auto}.navbar .navbar-toggler{margin-right:.5em}.navbar-collapse .quarto-navbar-tools{margin-left:.5em}.navbar-logo{max-height:24px;width:auto;padding-right:4px}nav .nav-item:not(.compact){padding-top:1px}nav .nav-link i,nav .dropdown-item i{padding-right:1px}.navbar-expand-lg .navbar-nav .nav-link{padding-left:.6rem;padding-right:.6rem}nav .nav-item.compact .nav-link{padding-left:.5rem;padding-right:.5rem;font-size:1.1rem}.navbar .quarto-navbar-tools{order:3}.navbar .quarto-navbar-tools div.dropdown{display:inline-block}.navbar .quarto-navbar-tools .quarto-navigation-tool{color:rgb(229.52,231.808,233.68)}.navbar .quarto-navbar-tools .quarto-navigation-tool:hover{color:#fff}.navbar-nav .dropdown-menu{min-width:220px;font-size:.9rem}.navbar .navbar-nav .nav-link.dropdown-toggle::after{opacity:.75;vertical-align:.175em}.navbar ul.dropdown-menu{padding-top:0;padding-bottom:0}.navbar .dropdown-header{text-transform:uppercase;font-size:.8rem;padding:0 .5rem}.navbar .dropdown-item{padding:.4rem .5rem}.navbar .dropdown-item>i.bi{margin-left:.1rem;margin-right:.25em}.sidebar #quarto-search{margin-top:-1px}.sidebar #quarto-search svg.aa-SubmitIcon{width:16px;height:16px}.sidebar-navigation a{color:inherit}.sidebar-title{margin-top:.25rem;padding-bottom:.5rem;font-size:1.3rem;line-height:1.6rem;visibility:visible}.sidebar-title>a{font-size:inherit;text-decoration:none}.sidebar-title .sidebar-tools-main{margin-top:-6px}@media(max-width: 991.98px){#quarto-sidebar div.sidebar-header{padding-top:.2em}}.sidebar-header-stacked .sidebar-title{margin-top:.6rem}.sidebar-logo{max-width:90%;padding-bottom:.5rem}.sidebar-logo-link{text-decoration:none}.sidebar-navigation li a{text-decoration:none}.sidebar-navigation .quarto-navigation-tool{opacity:.7;font-size:.875rem}#quarto-sidebar>nav>.sidebar-tools-main{margin-left:14px}.sidebar-tools-main{display:inline-flex;margin-left:0px;order:2}.sidebar-tools-main:not(.tools-wide){vertical-align:middle}.sidebar-navigation .quarto-navigation-tool.dropdown-toggle::after{display:none}.sidebar.sidebar-navigation>*{padding-top:1em}.sidebar-item{margin-bottom:.2em;line-height:1rem;margin-top:.4rem}.sidebar-section{padding-left:.5em;padding-bottom:.2em}.sidebar-item .sidebar-item-container{display:flex;justify-content:space-between;cursor:pointer}.sidebar-item-toggle:hover{cursor:pointer}.sidebar-item .sidebar-item-toggle .bi{font-size:.7rem;text-align:center}.sidebar-item .sidebar-item-toggle .bi-chevron-right::before{transition:transform 200ms ease}.sidebar-item .sidebar-item-toggle[aria-expanded=false] .bi-chevron-right::before{transform:none}.sidebar-item .sidebar-item-toggle[aria-expanded=true] .bi-chevron-right::before{transform:rotate(90deg)}.sidebar-item-text{width:100%}.sidebar-navigation .sidebar-divider{margin-left:0;margin-right:0;margin-top:.5rem;margin-bottom:.5rem}@media(max-width: 991.98px){.quarto-secondary-nav{display:block}.quarto-secondary-nav button.quarto-search-button{padding-right:0em;padding-left:2em}.quarto-secondary-nav button.quarto-btn-toggle{margin-left:-0.75rem;margin-right:.15rem}.quarto-secondary-nav nav.quarto-title-breadcrumbs{display:none}.quarto-secondary-nav nav.quarto-page-breadcrumbs{display:flex;align-items:center;padding-right:1em;margin-left:-0.25em}.quarto-secondary-nav nav.quarto-page-breadcrumbs a{text-decoration:none}.quarto-secondary-nav nav.quarto-page-breadcrumbs ol.breadcrumb{margin-bottom:0}}@media(min-width: 992px){.quarto-secondary-nav{display:none}}.quarto-title-breadcrumbs .breadcrumb{margin-bottom:.5em;font-size:.9rem}.quarto-title-breadcrumbs .breadcrumb li:last-of-type a{color:#6c757d}.quarto-secondary-nav .quarto-btn-toggle{color:rgb(168.6,176.52,183)}.quarto-secondary-nav[aria-expanded=false] .quarto-btn-toggle .bi-chevron-right::before{transform:none}.quarto-secondary-nav[aria-expanded=true] .quarto-btn-toggle .bi-chevron-right::before{transform:rotate(90deg)}.quarto-secondary-nav .quarto-btn-toggle .bi-chevron-right::before{transition:transform 200ms ease}.quarto-secondary-nav{cursor:pointer}.no-decor{text-decoration:none}.quarto-secondary-nav-title{margin-top:.3em;color:rgb(168.6,176.52,183);padding-top:4px}.quarto-secondary-nav nav.quarto-page-breadcrumbs{color:rgb(168.6,176.52,183)}.quarto-secondary-nav nav.quarto-page-breadcrumbs a{color:rgb(168.6,176.52,183)}.quarto-secondary-nav nav.quarto-page-breadcrumbs a:hover{color:rgba(233.88,156,103.2,.8)}.quarto-secondary-nav nav.quarto-page-breadcrumbs .breadcrumb-item::before{color:hsl(207,9.0909090909%,48.9411764706%)}.breadcrumb-item{line-height:1.2rem}div.sidebar-item-container{color:rgb(168.6,176.52,183)}div.sidebar-item-container:hover,div.sidebar-item-container:focus{color:rgba(233.88,156,103.2,.8)}div.sidebar-item-container.disabled{color:rgba(168.6,176.52,183,.75)}div.sidebar-item-container .active,div.sidebar-item-container .show>.nav-link,div.sidebar-item-container .sidebar-link>code{color:rgb(233.88,156,103.2)}div.sidebar.sidebar-navigation.rollup.quarto-sidebar-toggle-contents,nav.sidebar.sidebar-navigation:not(.rollup){background-color:#0f2537}@media(max-width: 991.98px){.sidebar-navigation .sidebar-item a,.nav-page .nav-page-text,.sidebar-navigation{font-size:1rem}.sidebar-navigation ul.sidebar-section.depth1 .sidebar-section-item{font-size:1.1rem}.sidebar-logo{display:none}.sidebar.sidebar-navigation{position:static;border-bottom:1px solid rgba(0,0,0,.15)}.sidebar.sidebar-navigation.collapsing{position:fixed;z-index:1000}.sidebar.sidebar-navigation.show{position:fixed;z-index:1000}.sidebar.sidebar-navigation{min-height:100%}nav.quarto-secondary-nav{background-color:#0f2537;border-bottom:1px solid rgba(0,0,0,.15)}.quarto-banner nav.quarto-secondary-nav{background-color:rgb(59,76.6,91);color:rgb(229.52,231.808,233.68);border-top:1px solid rgba(0,0,0,.15)}.sidebar .sidebar-footer{visibility:visible;padding-top:1rem;position:inherit}.sidebar-tools-collapse{display:block}}#quarto-sidebar{transition:width .15s ease-in}#quarto-sidebar>*{padding-right:1em}@media(max-width: 991.98px){#quarto-sidebar .sidebar-menu-container{white-space:nowrap;min-width:225px}#quarto-sidebar.show{transition:width .15s ease-out}}@media(min-width: 992px){#quarto-sidebar{display:flex;flex-direction:column}.nav-page .nav-page-text,.sidebar-navigation .sidebar-section .sidebar-item{font-size:.875rem}.sidebar-navigation .sidebar-item{font-size:.925rem}.sidebar.sidebar-navigation{display:block;position:sticky}.sidebar-search{width:100%}.sidebar .sidebar-footer{visibility:visible}}@media(min-width: 992px){#quarto-sidebar-glass{display:none}}@media(max-width: 991.98px){#quarto-sidebar-glass{position:fixed;top:0;bottom:0;left:0;right:0;background-color:hsla(0,0%,100%,0);transition:background-color .15s ease-in;z-index:-1}#quarto-sidebar-glass.collapsing{z-index:1000}#quarto-sidebar-glass.show{transition:background-color .15s ease-out;background-color:hsla(0,0%,40%,.4);z-index:1000}}.sidebar .sidebar-footer{padding:.5rem 1rem;align-self:flex-end;color:#6c757d;width:100%}.quarto-page-breadcrumbs .breadcrumb-item+.breadcrumb-item,.quarto-page-breadcrumbs .breadcrumb-item{padding-right:.33em;padding-left:0}.quarto-page-breadcrumbs .breadcrumb-item::before{padding-right:.33em}.quarto-sidebar-footer{font-size:.875em}.sidebar-section .bi-chevron-right{vertical-align:middle}.sidebar-section .bi-chevron-right::before{font-size:.9em}.notransition{-webkit-transition:none !important;-moz-transition:none !important;-o-transition:none !important;transition:none !important}.btn:focus:not(:focus-visible){box-shadow:none}.page-navigation{display:flex;justify-content:space-between}.nav-page{padding-bottom:.75em}.nav-page .bi{font-size:1.8rem;vertical-align:middle}.nav-page .nav-page-text{padding-left:.25em;padding-right:.25em}.nav-page a{color:#6c757d;text-decoration:none;display:flex;align-items:center}.nav-page a:hover{color:rgb(178.4,84,20)}.nav-footer .toc-actions{padding-bottom:.5em;padding-top:.5em}.nav-footer .toc-actions a,.nav-footer .toc-actions a:hover{text-decoration:none}.nav-footer .toc-actions ul{display:flex;list-style:none}.nav-footer .toc-actions ul :first-child{margin-left:auto}.nav-footer .toc-actions ul :last-child{margin-right:auto}.nav-footer .toc-actions ul li{padding-right:1.5em}.nav-footer .toc-actions ul li i.bi{padding-right:.4em}.nav-footer .toc-actions ul li:last-of-type{padding-right:0}.nav-footer{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between;align-items:baseline;text-align:center;padding-top:.5rem;padding-bottom:.5rem;background-color:#0f2537}body.nav-fixed{padding-top:64px}.nav-footer-contents{color:#6c757d;margin-top:.25rem}.nav-footer{min-height:3.5em;color:rgb(127.8,139.46,149)}.nav-footer a{color:rgb(127.8,139.46,149)}.nav-footer .nav-footer-left{font-size:.825em}.nav-footer .nav-footer-center{font-size:.825em}.nav-footer .nav-footer-right{font-size:.825em}.nav-footer-left .footer-items,.nav-footer-center .footer-items,.nav-footer-right .footer-items{display:inline-flex;padding-top:.3em;padding-bottom:.3em;margin-bottom:0em}.nav-footer-left .footer-items .nav-link,.nav-footer-center .footer-items .nav-link,.nav-footer-right .footer-items .nav-link{padding-left:.6em;padding-right:.6em}@media(min-width: 768px){.nav-footer-left{flex:1 1 0px;text-align:left}}@media(max-width: 575.98px){.nav-footer-left{margin-bottom:1em;flex:100%}}@media(min-width: 768px){.nav-footer-right{flex:1 1 0px;text-align:right}}@media(max-width: 575.98px){.nav-footer-right{margin-bottom:1em;flex:100%}}.nav-footer-center{text-align:center;min-height:3em}@media(min-width: 768px){.nav-footer-center{flex:1 1 0px}}.nav-footer-center .footer-items{justify-content:center}@media(max-width: 767.98px){.nav-footer-center{margin-bottom:1em;flex:100%}}@media(max-width: 767.98px){.nav-footer-center{margin-top:3em;order:10}}.navbar .quarto-reader-toggle.reader .quarto-reader-toggle-btn{background-color:rgb(229.52,231.808,233.68);border-radius:3px}@media(max-width: 991.98px){.quarto-reader-toggle{display:none}}.quarto-reader-toggle.reader.quarto-navigation-tool .quarto-reader-toggle-btn{background-color:rgb(168.6,176.52,183);border-radius:3px}.quarto-reader-toggle .quarto-reader-toggle-btn{display:inline-flex;padding-left:.2em;padding-right:.2em;margin-left:-0.2em;margin-right:-0.2em;text-align:center}.navbar .quarto-reader-toggle:not(.reader) .bi::before{background-image:url('data:image/svg+xml,')}.navbar .quarto-reader-toggle.reader .bi::before{background-image:url('data:image/svg+xml,')}.sidebar-navigation .quarto-reader-toggle:not(.reader) .bi::before{background-image:url('data:image/svg+xml,')}.sidebar-navigation .quarto-reader-toggle.reader .bi::before{background-image:url('data:image/svg+xml,')}#quarto-back-to-top{display:none;position:fixed;bottom:50px;background-color:#0f2537;border-radius:.25rem;box-shadow:0 .2rem .5rem #6c757d,0 0 .05rem #6c757d;color:#6c757d;text-decoration:none;font-size:.9em;text-align:center;left:50%;padding:.4rem .8rem;transform:translate(-50%, 0)}#quarto-announcement{padding:.5em;display:flex;justify-content:space-between;margin-bottom:0;font-size:.9em}#quarto-announcement .quarto-announcement-content{margin-right:auto}#quarto-announcement .quarto-announcement-content p{margin-bottom:0}#quarto-announcement .quarto-announcement-icon{margin-right:.5em;font-size:1.2em;margin-top:-0.15em}#quarto-announcement .quarto-announcement-action{cursor:pointer}.aa-DetachedSearchButtonQuery{display:none}.aa-DetachedOverlay ul.aa-List,#quarto-search-results ul.aa-List{list-style:none;padding-left:0}.aa-DetachedOverlay .aa-Panel,#quarto-search-results .aa-Panel{background-color:#0f2537;position:absolute;z-index:2000}#quarto-search-results .aa-Panel{max-width:400px}#quarto-search input{font-size:.925rem}@media(min-width: 992px){.navbar #quarto-search{margin-left:.25rem;order:999}}.navbar.navbar-expand-sm #quarto-search,.navbar.navbar-expand-md #quarto-search{order:999}@media(min-width: 992px){.navbar .quarto-navbar-tools{order:900}}@media(min-width: 992px){.navbar .quarto-navbar-tools.tools-end{margin-left:auto !important}}@media(max-width: 991.98px){#quarto-sidebar .sidebar-search{display:none}}#quarto-sidebar .sidebar-search .aa-Autocomplete{width:100%}.navbar .aa-Autocomplete .aa-Form{width:180px}.navbar #quarto-search.type-overlay .aa-Autocomplete{width:40px}.navbar #quarto-search.type-overlay .aa-Autocomplete .aa-Form{background-color:inherit;border:none}.navbar #quarto-search.type-overlay .aa-Autocomplete .aa-Form:focus-within{box-shadow:none;outline:none}.navbar #quarto-search.type-overlay .aa-Autocomplete .aa-Form .aa-InputWrapper{display:none}.navbar #quarto-search.type-overlay .aa-Autocomplete .aa-Form .aa-InputWrapper:focus-within{display:inherit}.navbar #quarto-search.type-overlay .aa-Autocomplete .aa-Form .aa-Label svg,.navbar #quarto-search.type-overlay .aa-Autocomplete .aa-Form .aa-LoadingIndicator svg{width:26px;height:26px;color:rgb(229.52,231.808,233.68);opacity:1}.navbar #quarto-search.type-overlay .aa-Autocomplete svg.aa-SubmitIcon{width:26px;height:26px;color:rgb(229.52,231.808,233.68);opacity:1}.aa-Autocomplete .aa-Form,.aa-DetachedFormContainer .aa-Form{align-items:center;background-color:#fff;border:0 solid rgba(0,0,0,0);border-radius:.25rem;color:#212529;display:flex;line-height:1em;margin:0;position:relative;width:100%}.aa-Autocomplete .aa-Form:focus-within,.aa-DetachedFormContainer .aa-Form:focus-within{box-shadow:rgba(223,105,25,.6) 0 0 0 1px;outline:currentColor none medium}.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix{align-items:center;display:flex;flex-shrink:0;order:1}.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-Label,.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-Label,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator{cursor:initial;flex-shrink:0;padding:0;text-align:left}.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-Label svg,.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator svg,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-Label svg,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator svg{color:#212529;opacity:.5}.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-SubmitButton,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-SubmitButton{appearance:none;background:none;border:0;margin:0}.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator{align-items:center;display:flex;justify-content:center}.aa-Autocomplete .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator[hidden],.aa-DetachedFormContainer .aa-Form .aa-InputWrapperPrefix .aa-LoadingIndicator[hidden]{display:none}.aa-Autocomplete .aa-Form .aa-InputWrapper,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper{order:3;position:relative;width:100%}.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input{appearance:none;background:none;border:0;color:#212529;font:inherit;height:calc(1.5em + .1rem + 2px);padding:0;width:100%}.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input::placeholder,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input::placeholder{color:#212529;opacity:.8}.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input:focus,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input:focus{border-color:none;box-shadow:none;outline:none}.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-decoration,.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-cancel-button,.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-results-button,.aa-Autocomplete .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-results-decoration,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-decoration,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-cancel-button,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-results-button,.aa-DetachedFormContainer .aa-Form .aa-InputWrapper .aa-Input::-webkit-search-results-decoration{display:none}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix{align-items:center;display:flex;order:4}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-ClearButton,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-ClearButton{align-items:center;background:none;border:0;color:#212529;opacity:.8;cursor:pointer;display:flex;margin:0;width:calc(1.5em + .1rem + 2px)}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-ClearButton:hover,.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-ClearButton:focus,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-ClearButton:hover,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-ClearButton:focus{color:#212529;opacity:.8}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-ClearButton[hidden],.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-ClearButton[hidden]{display:none}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-ClearButton svg,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-ClearButton svg{width:calc(1.5em + 0.75rem + calc(0 * 2))}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-CopyButton,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-CopyButton{border:none;align-items:center;background:none;color:#212529;opacity:.4;font-size:.7rem;cursor:pointer;display:none;margin:0;width:calc(1em + .1rem + 2px)}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-CopyButton:hover,.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-CopyButton:focus,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-CopyButton:hover,.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-CopyButton:focus{color:#212529;opacity:.8}.aa-Autocomplete .aa-Form .aa-InputWrapperSuffix .aa-CopyButton[hidden],.aa-DetachedFormContainer .aa-Form .aa-InputWrapperSuffix .aa-CopyButton[hidden]{display:none}.aa-PanelLayout:empty{display:none}.quarto-search-no-results.no-query{display:none}.aa-Source:has(.no-query){display:none}#quarto-search-results .aa-Panel{border:solid rgba(0,0,0,0) 0}#quarto-search-results .aa-SourceNoResults{width:400px}.aa-DetachedOverlay .aa-Panel,#quarto-search-results .aa-Panel{max-height:65vh;overflow-y:auto;font-size:.925rem}.aa-DetachedOverlay .aa-SourceNoResults,#quarto-search-results .aa-SourceNoResults{height:60px;display:flex;justify-content:center;align-items:center}.aa-DetachedOverlay .search-error,#quarto-search-results .search-error{padding-top:10px;padding-left:20px;padding-right:20px;cursor:default}.aa-DetachedOverlay .search-error .search-error-title,#quarto-search-results .search-error .search-error-title{font-size:1.1rem;margin-bottom:.5rem}.aa-DetachedOverlay .search-error .search-error-title .search-error-icon,#quarto-search-results .search-error .search-error-title .search-error-icon{margin-right:8px}.aa-DetachedOverlay .search-error .search-error-text,#quarto-search-results .search-error .search-error-text{font-weight:300}.aa-DetachedOverlay .search-result-text,#quarto-search-results .search-result-text{font-weight:300;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.2rem;max-height:2.4rem}.aa-DetachedOverlay .aa-SourceHeader .search-result-header,#quarto-search-results .aa-SourceHeader .search-result-header{font-size:.875rem;background-color:hsl(207,57.1428571429%,18.7254901961%);padding-left:14px;padding-bottom:4px;padding-top:4px}.aa-DetachedOverlay .aa-SourceHeader .search-result-header-no-results,#quarto-search-results .aa-SourceHeader .search-result-header-no-results{display:none}.aa-DetachedOverlay .aa-SourceFooter .algolia-search-logo,#quarto-search-results .aa-SourceFooter .algolia-search-logo{width:110px;opacity:.85;margin:8px;float:right}.aa-DetachedOverlay .search-result-section,#quarto-search-results .search-result-section{font-size:.925em}.aa-DetachedOverlay a.search-result-link,#quarto-search-results a.search-result-link{color:inherit;text-decoration:none}.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item,#quarto-search-results li.aa-Item[aria-selected=true] .search-item{background-color:#df6919}.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item.search-result-more,.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item .search-result-section,.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item .search-result-text,.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item .search-result-title-container,.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item .search-result-text-container,#quarto-search-results li.aa-Item[aria-selected=true] .search-item.search-result-more,#quarto-search-results li.aa-Item[aria-selected=true] .search-item .search-result-section,#quarto-search-results li.aa-Item[aria-selected=true] .search-item .search-result-text,#quarto-search-results li.aa-Item[aria-selected=true] .search-item .search-result-title-container,#quarto-search-results li.aa-Item[aria-selected=true] .search-item .search-result-text-container{color:#fff;background-color:#df6919}.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item mark.search-match,.aa-DetachedOverlay li.aa-Item[aria-selected=true] .search-item .search-match.mark,#quarto-search-results li.aa-Item[aria-selected=true] .search-item mark.search-match,#quarto-search-results li.aa-Item[aria-selected=true] .search-item .search-match.mark{color:#fff;background-color:rgb(186.3129032258,87.7258064516,20.8870967742)}.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item,#quarto-search-results li.aa-Item[aria-selected=false] .search-item{background-color:rgb(59,76.6,91)}.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item.search-result-more,.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item .search-result-section,.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item .search-result-text,.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item .search-result-title-container,.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item .search-result-text-container,#quarto-search-results li.aa-Item[aria-selected=false] .search-item.search-result-more,#quarto-search-results li.aa-Item[aria-selected=false] .search-item .search-result-section,#quarto-search-results li.aa-Item[aria-selected=false] .search-item .search-result-text,#quarto-search-results li.aa-Item[aria-selected=false] .search-item .search-result-title-container,#quarto-search-results li.aa-Item[aria-selected=false] .search-item .search-result-text-container{color:#ebebeb}.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item mark.search-match,.aa-DetachedOverlay li.aa-Item[aria-selected=false] .search-item .search-match.mark,#quarto-search-results li.aa-Item[aria-selected=false] .search-item mark.search-match,#quarto-search-results li.aa-Item[aria-selected=false] .search-item .search-match.mark{color:inherit;background-color:rgb(30.3927419355,14.310483871,3.4072580645)}.aa-DetachedOverlay .aa-Item .search-result-doc:not(.document-selectable) .search-result-title-container,#quarto-search-results .aa-Item .search-result-doc:not(.document-selectable) .search-result-title-container{background-color:rgb(59,76.6,91);color:#ebebeb}.aa-DetachedOverlay .aa-Item .search-result-doc:not(.document-selectable) .search-result-text-container,#quarto-search-results .aa-Item .search-result-doc:not(.document-selectable) .search-result-text-container{padding-top:0px}.aa-DetachedOverlay li.aa-Item .search-result-doc.document-selectable .search-result-text-container,#quarto-search-results li.aa-Item .search-result-doc.document-selectable .search-result-text-container{margin-top:-4px}.aa-DetachedOverlay .aa-Item,#quarto-search-results .aa-Item{cursor:pointer}.aa-DetachedOverlay .aa-Item .search-item,#quarto-search-results .aa-Item .search-item{border-left:none;border-right:none;border-top:none;background-color:rgb(59,76.6,91);border-color:rgba(0,0,0,0);color:#ebebeb}.aa-DetachedOverlay .aa-Item .search-item p,#quarto-search-results .aa-Item .search-item p{margin-top:0;margin-bottom:0}.aa-DetachedOverlay .aa-Item .search-item i.bi,#quarto-search-results .aa-Item .search-item i.bi{padding-left:8px;padding-right:8px;font-size:1.3em}.aa-DetachedOverlay .aa-Item .search-item .search-result-title,#quarto-search-results .aa-Item .search-item .search-result-title{margin-top:.3em;margin-bottom:0em}.aa-DetachedOverlay .aa-Item .search-item .search-result-crumbs,#quarto-search-results .aa-Item .search-item .search-result-crumbs{white-space:nowrap;text-overflow:ellipsis;font-size:.8em;font-weight:300;margin-right:1em}.aa-DetachedOverlay .aa-Item .search-item .search-result-crumbs:not(.search-result-crumbs-wrap),#quarto-search-results .aa-Item .search-item .search-result-crumbs:not(.search-result-crumbs-wrap){max-width:30%;margin-left:auto;margin-top:.5em;margin-bottom:.1rem}.aa-DetachedOverlay .aa-Item .search-item .search-result-crumbs.search-result-crumbs-wrap,#quarto-search-results .aa-Item .search-item .search-result-crumbs.search-result-crumbs-wrap{flex-basis:100%;margin-top:0em;margin-bottom:.2em;margin-left:37px}.aa-DetachedOverlay .aa-Item .search-result-title-container,#quarto-search-results .aa-Item .search-result-title-container{font-size:1em;display:flex;flex-wrap:wrap;padding:6px 4px 6px 4px}.aa-DetachedOverlay .aa-Item .search-result-text-container,#quarto-search-results .aa-Item .search-result-text-container{padding-bottom:8px;padding-right:8px;margin-left:42px}.aa-DetachedOverlay .aa-Item .search-result-doc-section,.aa-DetachedOverlay .aa-Item .search-result-more,#quarto-search-results .aa-Item .search-result-doc-section,#quarto-search-results .aa-Item .search-result-more{padding-top:8px;padding-bottom:8px;padding-left:44px}.aa-DetachedOverlay .aa-Item .search-result-more,#quarto-search-results .aa-Item .search-result-more{font-size:.8em;font-weight:400}.aa-DetachedOverlay .aa-Item .search-result-doc,#quarto-search-results .aa-Item .search-result-doc{border-top:0 solid rgba(0,0,0,0)}.aa-DetachedSearchButton{background:none;border:none}.aa-DetachedSearchButton .aa-DetachedSearchButtonPlaceholder{display:none}.navbar .aa-DetachedSearchButton .aa-DetachedSearchButtonIcon{color:rgb(229.52,231.808,233.68)}.sidebar-tools-collapse #quarto-search,.sidebar-tools-main #quarto-search{display:inline}.sidebar-tools-collapse #quarto-search .aa-Autocomplete,.sidebar-tools-main #quarto-search .aa-Autocomplete{display:inline}.sidebar-tools-collapse #quarto-search .aa-DetachedSearchButton,.sidebar-tools-main #quarto-search .aa-DetachedSearchButton{padding-left:4px;padding-right:4px}.sidebar-tools-collapse #quarto-search .aa-DetachedSearchButton .aa-DetachedSearchButtonIcon,.sidebar-tools-main #quarto-search .aa-DetachedSearchButton .aa-DetachedSearchButtonIcon{color:rgb(168.6,176.52,183)}.sidebar-tools-collapse #quarto-search .aa-DetachedSearchButton .aa-DetachedSearchButtonIcon .aa-SubmitIcon,.sidebar-tools-main #quarto-search .aa-DetachedSearchButton .aa-DetachedSearchButtonIcon .aa-SubmitIcon{margin-top:-3px}.aa-DetachedContainer{background:rgba(15,37,55,.65);width:90%;bottom:0;box-shadow:rgba(0,0,0,.6) 0 0 0 1px;outline:currentColor none medium;display:flex;flex-direction:column;left:0;margin:0;overflow:hidden;padding:0;position:fixed;right:0;top:0;z-index:1101}.aa-DetachedContainer::after{height:32px}.aa-DetachedContainer .aa-SourceHeader{margin:var(--aa-spacing-half) 0 var(--aa-spacing-half) 2px}.aa-DetachedContainer .aa-Panel{background-color:#0f2537;border-radius:0;box-shadow:none;flex-grow:1;margin:0;padding:0;position:relative}.aa-DetachedContainer .aa-PanelLayout{bottom:0;box-shadow:none;left:0;margin:0;max-height:none;overflow-y:auto;position:absolute;right:0;top:0;width:100%}.aa-DetachedFormContainer{background-color:#0f2537;border-bottom:0 solid rgba(0,0,0,0);display:flex;flex-direction:row;justify-content:space-between;margin:0;padding:.5em}.aa-DetachedCancelButton{background:none;font-size:.8em;border:0;border-radius:3px;color:#ebebeb;cursor:pointer;margin:0 0 0 .5em;padding:0 .5em}.aa-DetachedCancelButton:hover,.aa-DetachedCancelButton:focus{box-shadow:rgba(223,105,25,.6) 0 0 0 1px;outline:currentColor none medium}.aa-DetachedContainer--modal{bottom:inherit;height:auto;margin:0 auto;position:absolute;top:100px;border-radius:6px;max-width:850px}@media(max-width: 575.98px){.aa-DetachedContainer--modal{width:100%;top:0px;border-radius:0px;border:none}}.aa-DetachedContainer--modal .aa-PanelLayout{max-height:var(--aa-detached-modal-max-height);padding-bottom:var(--aa-spacing-half);position:static}.aa-Detached{height:100vh;overflow:hidden}.aa-DetachedOverlay{background-color:rgba(235,235,235,.4);position:fixed;left:0;right:0;top:0;margin:0;padding:0;height:100vh;z-index:1100}.quarto-dashboard.nav-fixed.dashboard-sidebar #quarto-content.quarto-dashboard-content{padding:0em}.quarto-dashboard #quarto-content.quarto-dashboard-content{padding:1em}.quarto-dashboard #quarto-content.quarto-dashboard-content>*{padding-top:0}@media(min-width: 576px){.quarto-dashboard{height:100%}}.quarto-dashboard .card.valuebox.bslib-card.bg-primary{background-color:#df6919 !important}.quarto-dashboard .card.valuebox.bslib-card.bg-secondary{background-color:rgb(59,76.6,91) !important}.quarto-dashboard .card.valuebox.bslib-card.bg-success{background-color:#5cb85c !important}.quarto-dashboard .card.valuebox.bslib-card.bg-info{background-color:#5bc0de !important}.quarto-dashboard .card.valuebox.bslib-card.bg-warning{background-color:#ffc107 !important}.quarto-dashboard .card.valuebox.bslib-card.bg-danger{background-color:#d9534f !important}.quarto-dashboard .card.valuebox.bslib-card.bg-light{background-color:rgb(129.4,139.96,148.6) !important}.quarto-dashboard .card.valuebox.bslib-card.bg-dark{background-color:rgb(59,76.6,91) !important}.quarto-dashboard.dashboard-fill{display:flex;flex-direction:column}.quarto-dashboard #quarto-appendix{display:none}.quarto-dashboard #quarto-header #quarto-dashboard-header{border-top:solid 1px rgb(79.06,102.644,121.94);border-bottom:solid 1px rgb(79.06,102.644,121.94)}.quarto-dashboard #quarto-header #quarto-dashboard-header>nav{padding-left:1em;padding-right:1em}.quarto-dashboard #quarto-header #quarto-dashboard-header>nav .navbar-brand-container{padding-left:0}.quarto-dashboard #quarto-header #quarto-dashboard-header .navbar-toggler{margin-right:0}.quarto-dashboard #quarto-header #quarto-dashboard-header .navbar-toggler-icon{height:1em;width:1em;background-image:url('data:image/svg+xml,')}.quarto-dashboard #quarto-header #quarto-dashboard-header .navbar-brand-container{padding-right:1em}.quarto-dashboard #quarto-header #quarto-dashboard-header .navbar-title{font-size:1.1em}.quarto-dashboard #quarto-header #quarto-dashboard-header .navbar-nav{font-size:.9em}.quarto-dashboard #quarto-dashboard-header .navbar{padding:0}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-container{padding-left:1em}.quarto-dashboard #quarto-dashboard-header .navbar.slim .navbar-brand-container .nav-link,.quarto-dashboard #quarto-dashboard-header .navbar.slim .navbar-nav .nav-link{padding:.7em}.quarto-dashboard #quarto-dashboard-header .navbar .quarto-color-scheme-toggle{order:9}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-toggler{margin-left:.5em;order:10}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-nav .nav-link{padding:.5em;height:100%;display:flex;align-items:center}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-nav .active{background-color:rgb(75.048,97.4352,115.752)}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-brand-container{padding:.5em .5em .5em 0;display:flex;flex-direction:row;margin-right:2em;align-items:center}@media(max-width: 767.98px){.quarto-dashboard #quarto-dashboard-header .navbar .navbar-brand-container{margin-right:auto}}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-collapse{align-self:stretch}@media(min-width: 768px){.quarto-dashboard #quarto-dashboard-header .navbar .navbar-collapse{order:8}}@media(max-width: 767.98px){.quarto-dashboard #quarto-dashboard-header .navbar .navbar-collapse{order:1000;padding-bottom:.5em}}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-collapse .navbar-nav{align-self:stretch}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-title{font-size:1.25em;line-height:1.1em;display:flex;flex-direction:row;flex-wrap:wrap;align-items:baseline}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-title .navbar-title-text{margin-right:.4em}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-title a{text-decoration:none;color:inherit}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-subtitle,.quarto-dashboard #quarto-dashboard-header .navbar .navbar-author{font-size:.9rem;margin-right:.5em}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-author{margin-left:auto}.quarto-dashboard #quarto-dashboard-header .navbar .navbar-logo{max-height:48px;min-height:30px;object-fit:cover;margin-right:1em}.quarto-dashboard #quarto-dashboard-header .navbar .quarto-dashboard-links{order:9;padding-right:1em}.quarto-dashboard #quarto-dashboard-header .navbar .quarto-dashboard-link-text{margin-left:.25em}.quarto-dashboard #quarto-dashboard-header .navbar .quarto-dashboard-link{padding-right:0em;padding-left:.7em;text-decoration:none;color:rgb(229.52,231.808,233.68)}.quarto-dashboard .page-layout-custom .tab-content{padding:0;border:none}.quarto-dashboard-img-contain{height:100%;width:100%;object-fit:contain}@media(max-width: 575.98px){.quarto-dashboard .bslib-grid{grid-template-rows:minmax(1em, max-content) !important}.quarto-dashboard .sidebar-content{height:inherit}.quarto-dashboard .page-layout-custom{min-height:100vh}}.quarto-dashboard.dashboard-toolbar>.page-layout-custom,.quarto-dashboard.dashboard-sidebar>.page-layout-custom{padding:0}.quarto-dashboard .quarto-dashboard-content.quarto-dashboard-pages{padding:0}.quarto-dashboard .callout{margin-bottom:0;margin-top:0}.quarto-dashboard .html-fill-container figure{overflow:hidden}.quarto-dashboard bslib-tooltip .rounded-pill{border:solid #6c757d 1px}.quarto-dashboard bslib-tooltip .rounded-pill .svg{fill:#ebebeb}.quarto-dashboard .tabset .dashboard-card-no-title .nav-tabs{margin-left:0;margin-right:auto}.quarto-dashboard .tabset .tab-content{border:none}.quarto-dashboard .tabset .card-header .nav-link[role=tab]{margin-top:-6px;padding-top:6px;padding-bottom:6px}.quarto-dashboard .card.valuebox,.quarto-dashboard .card.bslib-value-box{min-height:3rem}.quarto-dashboard .card.valuebox .card-body,.quarto-dashboard .card.bslib-value-box .card-body{padding:0}.quarto-dashboard .bslib-value-box .value-box-value{font-size:clamp(.1em,15cqw,5em)}.quarto-dashboard .bslib-value-box .value-box-showcase .bi{font-size:clamp(.1em,max(18cqw,5.2cqh),5em);text-align:center;height:1em}.quarto-dashboard .bslib-value-box .value-box-showcase .bi::before{vertical-align:1em}.quarto-dashboard .bslib-value-box .value-box-area{margin-top:auto;margin-bottom:auto}.quarto-dashboard .card figure.quarto-float{display:flex;flex-direction:column;align-items:center}.quarto-dashboard .dashboard-scrolling{padding:1em}.quarto-dashboard .full-height{height:100%}.quarto-dashboard .showcase-bottom .value-box-grid{display:grid;grid-template-columns:1fr;grid-template-rows:1fr auto;grid-template-areas:"top" "bottom"}.quarto-dashboard .showcase-bottom .value-box-grid .value-box-showcase{grid-area:bottom;padding:0;margin:0}.quarto-dashboard .showcase-bottom .value-box-grid .value-box-showcase i.bi{font-size:4rem}.quarto-dashboard .showcase-bottom .value-box-grid .value-box-area{grid-area:top}.quarto-dashboard .tab-content{margin-bottom:0}.quarto-dashboard .bslib-card .bslib-navs-card-title{justify-content:stretch;align-items:end}.quarto-dashboard .card-header{display:flex;flex-wrap:wrap;justify-content:space-between}.quarto-dashboard .card-header .card-title{display:flex;flex-direction:column;justify-content:center;margin-bottom:0}.quarto-dashboard .tabset .card-toolbar{margin-bottom:1em}.quarto-dashboard .bslib-grid>.bslib-sidebar-layout{border:none;gap:var(--bslib-spacer, 1rem)}.quarto-dashboard .bslib-grid>.bslib-sidebar-layout>.main{padding:0}.quarto-dashboard .bslib-grid>.bslib-sidebar-layout>.sidebar{border-radius:.25rem;border:1px solid rgba(0,0,0,.175)}.quarto-dashboard .bslib-grid>.bslib-sidebar-layout>.collapse-toggle{display:none}@media(max-width: 767.98px){.quarto-dashboard .bslib-grid>.bslib-sidebar-layout{grid-template-columns:1fr;grid-template-rows:max-content 1fr}.quarto-dashboard .bslib-grid>.bslib-sidebar-layout>.main{grid-column:1;grid-row:2}.quarto-dashboard .bslib-grid>.bslib-sidebar-layout .sidebar{grid-column:1;grid-row:1}}.quarto-dashboard .sidebar-right .sidebar{padding-left:2.5em}.quarto-dashboard .sidebar-right .collapse-toggle{left:2px}.quarto-dashboard .quarto-dashboard .sidebar-right button.collapse-toggle:not(.transitioning){left:unset}.quarto-dashboard aside.sidebar{padding-left:1em;padding-right:1em;background-color:rgba(52,58,64,.25);color:#ebebeb}.quarto-dashboard .bslib-sidebar-layout>div.main{padding:.7em}.quarto-dashboard .bslib-sidebar-layout button.collapse-toggle{margin-top:.3em}.quarto-dashboard .bslib-sidebar-layout .collapse-toggle{top:0}.quarto-dashboard .bslib-sidebar-layout.sidebar-collapsed:not(.transitioning):not(.sidebar-right) .collapse-toggle{left:2px}.quarto-dashboard .sidebar>section>.h3:first-of-type{margin-top:0em}.quarto-dashboard .sidebar .h3,.quarto-dashboard .sidebar .h4,.quarto-dashboard .sidebar .h5,.quarto-dashboard .sidebar .h6{margin-top:.5em}.quarto-dashboard .sidebar form{flex-direction:column;align-items:start;margin-bottom:1em}.quarto-dashboard .sidebar form div[class*=oi-][class$=-input]{flex-direction:column}.quarto-dashboard .sidebar form[class*=oi-][class$=-toggle]{flex-direction:row-reverse;align-items:center;justify-content:start}.quarto-dashboard .sidebar form input[type=range]{margin-top:.5em;margin-right:.8em;margin-left:1em}.quarto-dashboard .sidebar label{width:fit-content}.quarto-dashboard .sidebar .card-body{margin-bottom:2em}.quarto-dashboard .sidebar .shiny-input-container{margin-bottom:1em}.quarto-dashboard .sidebar .shiny-options-group{margin-top:0}.quarto-dashboard .sidebar .control-label{margin-bottom:.3em}.quarto-dashboard .card .card-body .quarto-layout-row{align-items:stretch}.quarto-dashboard .toolbar{font-size:.9em;display:flex;flex-direction:row;border-top:solid 1px hsl(207,8.8888888889%,75.8823529412%);padding:1em;flex-wrap:wrap;background-color:rgba(52,58,64,.25)}.quarto-dashboard .toolbar .cell-output-display{display:flex}.quarto-dashboard .toolbar .shiny-input-container{padding-bottom:.5em;margin-bottom:.5em;width:inherit}.quarto-dashboard .toolbar .shiny-input-container>.checkbox:first-child{margin-top:6px}.quarto-dashboard .toolbar>*:last-child{margin-right:0}.quarto-dashboard .toolbar>*>*{margin-right:1em;align-items:baseline}.quarto-dashboard .toolbar>*>*>a{text-decoration:none;margin-top:auto;margin-bottom:auto}.quarto-dashboard .toolbar .shiny-input-container{padding-bottom:0;margin-bottom:0}.quarto-dashboard .toolbar .shiny-input-container>*{flex-shrink:0;flex-grow:0}.quarto-dashboard .toolbar .form-group.shiny-input-container:not([role=group])>label{margin-bottom:0}.quarto-dashboard .toolbar .shiny-input-container.no-baseline{align-items:start;padding-top:6px}.quarto-dashboard .toolbar .shiny-input-container{display:flex;align-items:baseline}.quarto-dashboard .toolbar .shiny-input-container label{padding-right:.4em}.quarto-dashboard .toolbar .shiny-input-container .bslib-input-switch{margin-top:6px}.quarto-dashboard .toolbar input[type=text]{line-height:1;width:inherit}.quarto-dashboard .toolbar .input-daterange{width:inherit}.quarto-dashboard .toolbar .input-daterange input[type=text]{height:2.4em;width:10em}.quarto-dashboard .toolbar .input-daterange .input-group-addon{height:auto;padding:0;margin-left:-5px !important;margin-right:-5px}.quarto-dashboard .toolbar .input-daterange .input-group-addon .input-group-text{padding-top:0;padding-bottom:0;height:100%}.quarto-dashboard .toolbar span.irs.irs--shiny{width:10em}.quarto-dashboard .toolbar span.irs.irs--shiny .irs-line{top:9px}.quarto-dashboard .toolbar span.irs.irs--shiny .irs-min,.quarto-dashboard .toolbar span.irs.irs--shiny .irs-max,.quarto-dashboard .toolbar span.irs.irs--shiny .irs-from,.quarto-dashboard .toolbar span.irs.irs--shiny .irs-to,.quarto-dashboard .toolbar span.irs.irs--shiny .irs-single{top:20px}.quarto-dashboard .toolbar span.irs.irs--shiny .irs-bar{top:8px}.quarto-dashboard .toolbar span.irs.irs--shiny .irs-handle{top:0px}.quarto-dashboard .toolbar .shiny-input-checkboxgroup>label{margin-top:6px}.quarto-dashboard .toolbar .shiny-input-checkboxgroup>.shiny-options-group{margin-top:0;align-items:baseline}.quarto-dashboard .toolbar .shiny-input-radiogroup>label{margin-top:6px}.quarto-dashboard .toolbar .shiny-input-radiogroup>.shiny-options-group{align-items:baseline;margin-top:0}.quarto-dashboard .toolbar .shiny-input-radiogroup>.shiny-options-group>.radio{margin-right:.3em}.quarto-dashboard .toolbar .form-select{padding-top:.2em;padding-bottom:.2em}.quarto-dashboard .toolbar .shiny-input-select{min-width:6em}.quarto-dashboard .toolbar div.checkbox{margin-bottom:0px}.quarto-dashboard .toolbar>.checkbox:first-child{margin-top:6px}.quarto-dashboard .toolbar form{width:fit-content}.quarto-dashboard .toolbar form label{padding-top:.2em;padding-bottom:.2em;width:fit-content}.quarto-dashboard .toolbar form input[type=date]{width:fit-content}.quarto-dashboard .toolbar form input[type=color]{width:3em}.quarto-dashboard .toolbar form button{padding:.4em}.quarto-dashboard .toolbar form select{width:fit-content}.quarto-dashboard .toolbar>*{font-size:.9em;flex-grow:0}.quarto-dashboard .toolbar .shiny-input-container label{margin-bottom:1px}.quarto-dashboard .toolbar-bottom{margin-top:1em;margin-bottom:0 !important;order:2}.quarto-dashboard .quarto-dashboard-content>.dashboard-toolbar-container>.toolbar-content>.tab-content>.tab-pane>*:not(.bslib-sidebar-layout){padding:1em}.quarto-dashboard .quarto-dashboard-content>.dashboard-toolbar-container>.toolbar-content>*:not(.tab-content){padding:1em}.quarto-dashboard .quarto-dashboard-content>.tab-content>.dashboard-page>.dashboard-toolbar-container>.toolbar-content,.quarto-dashboard .quarto-dashboard-content>.tab-content>.dashboard-page:not(.dashboard-sidebar-container)>*:not(.dashboard-toolbar-container){padding:1em}.quarto-dashboard .toolbar-content{padding:0}.quarto-dashboard .quarto-dashboard-content.quarto-dashboard-pages .tab-pane>.dashboard-toolbar-container .toolbar{border-radius:0;margin-bottom:0}.quarto-dashboard .dashboard-toolbar-container.toolbar-toplevel .toolbar{border-bottom:1px solid rgba(0,0,0,.175)}.quarto-dashboard .dashboard-toolbar-container.toolbar-toplevel .toolbar-bottom{margin-top:0}.quarto-dashboard .dashboard-toolbar-container:not(.toolbar-toplevel) .toolbar{margin-bottom:1em;border-top:none;border-radius:.25rem;border:1px solid rgba(0,0,0,.175)}.quarto-dashboard .vega-embed.has-actions details{width:1.7em;height:2em;position:absolute !important;top:0;right:0}.quarto-dashboard .dashboard-toolbar-container{padding:0}.quarto-dashboard .card .card-header p:last-child,.quarto-dashboard .card .card-footer p:last-child{margin-bottom:0}.quarto-dashboard .card .card-body>.h4:first-child{margin-top:0}.quarto-dashboard .card .card-body{z-index:4}@media(max-width: 767.98px){.quarto-dashboard .card .card-body .itables div.dataTables_wrapper div.dataTables_length,.quarto-dashboard .card .card-body .itables div.dataTables_wrapper div.dataTables_info,.quarto-dashboard .card .card-body .itables div.dataTables_wrapper div.dataTables_paginate{text-align:initial}.quarto-dashboard .card .card-body .itables div.dataTables_wrapper div.dataTables_filter{text-align:right}.quarto-dashboard .card .card-body .itables div.dataTables_wrapper div.dataTables_paginate ul.pagination{justify-content:initial}}.quarto-dashboard .card .card-body .itables .dataTables_wrapper{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;padding-top:0}.quarto-dashboard .card .card-body .itables .dataTables_wrapper table{flex-shrink:0}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dt-buttons{margin-bottom:.5em;margin-left:auto;width:fit-content;float:right}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dt-buttons.btn-group{background:#0f2537;border:none}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dt-buttons .btn-secondary{background-color:#0f2537;background-image:none;border:solid #dee2e6 1px;padding:.2em .7em}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dt-buttons .btn span{font-size:.8em;color:#ebebeb}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_info{margin-left:.5em;margin-bottom:.5em;padding-top:0}@media(min-width: 768px){.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_info{font-size:.875em}}@media(max-width: 767.98px){.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_info{font-size:.8em}}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_filter{margin-bottom:.5em;font-size:.875em}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_filter input[type=search]{padding:1px 5px 1px 5px;font-size:.875em}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_length{flex-basis:1 1 50%;margin-bottom:.5em;font-size:.875em}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_length select{padding:.4em 3em .4em .5em;font-size:.875em;margin-left:.2em;margin-right:.2em}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_paginate{flex-shrink:0}@media(min-width: 768px){.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_paginate{margin-left:auto}}.quarto-dashboard .card .card-body .itables .dataTables_wrapper .dataTables_paginate ul.pagination .paginate_button .page-link{font-size:.8em}.quarto-dashboard .card .card-footer{font-size:.9em}.quarto-dashboard .card .card-toolbar{display:flex;flex-grow:1;flex-direction:row;width:100%;flex-wrap:wrap}.quarto-dashboard .card .card-toolbar>*{font-size:.8em;flex-grow:0}.quarto-dashboard .card .card-toolbar>.card-title{font-size:1em;flex-grow:1;align-self:flex-start;margin-top:.1em}.quarto-dashboard .card .card-toolbar .cell-output-display{display:flex}.quarto-dashboard .card .card-toolbar .shiny-input-container{padding-bottom:.5em;margin-bottom:.5em;width:inherit}.quarto-dashboard .card .card-toolbar .shiny-input-container>.checkbox:first-child{margin-top:6px}.quarto-dashboard .card .card-toolbar>*:last-child{margin-right:0}.quarto-dashboard .card .card-toolbar>*>*{margin-right:1em;align-items:baseline}.quarto-dashboard .card .card-toolbar>*>*>a{text-decoration:none;margin-top:auto;margin-bottom:auto}.quarto-dashboard .card .card-toolbar form{width:fit-content}.quarto-dashboard .card .card-toolbar form label{padding-top:.2em;padding-bottom:.2em;width:fit-content}.quarto-dashboard .card .card-toolbar form input[type=date]{width:fit-content}.quarto-dashboard .card .card-toolbar form input[type=color]{width:3em}.quarto-dashboard .card .card-toolbar form button{padding:.4em}.quarto-dashboard .card .card-toolbar form select{width:fit-content}.quarto-dashboard .card .card-toolbar .cell-output-display{display:flex}.quarto-dashboard .card .card-toolbar .shiny-input-container{padding-bottom:.5em;margin-bottom:.5em;width:inherit}.quarto-dashboard .card .card-toolbar .shiny-input-container>.checkbox:first-child{margin-top:6px}.quarto-dashboard .card .card-toolbar>*:last-child{margin-right:0}.quarto-dashboard .card .card-toolbar>*>*{margin-right:1em;align-items:baseline}.quarto-dashboard .card .card-toolbar>*>*>a{text-decoration:none;margin-top:auto;margin-bottom:auto}.quarto-dashboard .card .card-toolbar .shiny-input-container{padding-bottom:0;margin-bottom:0}.quarto-dashboard .card .card-toolbar .shiny-input-container>*{flex-shrink:0;flex-grow:0}.quarto-dashboard .card .card-toolbar .form-group.shiny-input-container:not([role=group])>label{margin-bottom:0}.quarto-dashboard .card .card-toolbar .shiny-input-container.no-baseline{align-items:start;padding-top:6px}.quarto-dashboard .card .card-toolbar .shiny-input-container{display:flex;align-items:baseline}.quarto-dashboard .card .card-toolbar .shiny-input-container label{padding-right:.4em}.quarto-dashboard .card .card-toolbar .shiny-input-container .bslib-input-switch{margin-top:6px}.quarto-dashboard .card .card-toolbar input[type=text]{line-height:1;width:inherit}.quarto-dashboard .card .card-toolbar .input-daterange{width:inherit}.quarto-dashboard .card .card-toolbar .input-daterange input[type=text]{height:2.4em;width:10em}.quarto-dashboard .card .card-toolbar .input-daterange .input-group-addon{height:auto;padding:0;margin-left:-5px !important;margin-right:-5px}.quarto-dashboard .card .card-toolbar .input-daterange .input-group-addon .input-group-text{padding-top:0;padding-bottom:0;height:100%}.quarto-dashboard .card .card-toolbar span.irs.irs--shiny{width:10em}.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-line{top:9px}.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-min,.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-max,.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-from,.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-to,.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-single{top:20px}.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-bar{top:8px}.quarto-dashboard .card .card-toolbar span.irs.irs--shiny .irs-handle{top:0px}.quarto-dashboard .card .card-toolbar .shiny-input-checkboxgroup>label{margin-top:6px}.quarto-dashboard .card .card-toolbar .shiny-input-checkboxgroup>.shiny-options-group{margin-top:0;align-items:baseline}.quarto-dashboard .card .card-toolbar .shiny-input-radiogroup>label{margin-top:6px}.quarto-dashboard .card .card-toolbar .shiny-input-radiogroup>.shiny-options-group{align-items:baseline;margin-top:0}.quarto-dashboard .card .card-toolbar .shiny-input-radiogroup>.shiny-options-group>.radio{margin-right:.3em}.quarto-dashboard .card .card-toolbar .form-select{padding-top:.2em;padding-bottom:.2em}.quarto-dashboard .card .card-toolbar .shiny-input-select{min-width:6em}.quarto-dashboard .card .card-toolbar div.checkbox{margin-bottom:0px}.quarto-dashboard .card .card-toolbar>.checkbox:first-child{margin-top:6px}.quarto-dashboard .card-body>table>thead{border-top:none}.quarto-dashboard .card-body>.table>:not(caption)>*>*{background-color:rgb(59,76.6,91)}.tableFloatingHeaderOriginal{background-color:rgb(59,76.6,91);position:sticky !important;top:0 !important}.dashboard-data-table{margin-top:-1px}div.value-box-area span.observablehq--number{font-size:calc(clamp(.1em,15cqw,5em)*1.25);line-height:1.2;color:inherit;font-family:var(--bs-body-font-family)}.quarto-listing{padding-bottom:1em}.listing-pagination{padding-top:.5em}ul.pagination{float:right;padding-left:8px;padding-top:.5em}ul.pagination li{padding-right:.75em}ul.pagination li.disabled a,ul.pagination li.active a{color:#fff;text-decoration:none}ul.pagination li:last-of-type{padding-right:0}.listing-actions-group{display:flex}.quarto-listing-filter{margin-bottom:1em;width:200px;margin-left:auto}.quarto-listing-sort{margin-bottom:1em;margin-right:auto;width:auto}.quarto-listing-sort .input-group-text{font-size:.8em}.input-group input,.input-group select{border:solid #6c757d 1px;background-color:#0f2537;color:#ebebeb}.input-group-text{border:solid #6c757d 1px;background-color:#0f2537;color:#ebebeb;border-right:none}.quarto-listing-sort select.form-select{font-size:.8em}.listing-no-matching{text-align:center;padding-top:2em;padding-bottom:3em;font-size:1em}#quarto-margin-sidebar .quarto-listing-category{padding-top:0;font-size:1rem}#quarto-margin-sidebar .quarto-listing-category-title{cursor:pointer;font-weight:600;font-size:1rem}.quarto-listing-category .category{cursor:pointer}.quarto-listing-category .category.active{font-weight:600}.quarto-listing-category.category-cloud{display:flex;flex-wrap:wrap;align-items:baseline}.quarto-listing-category.category-cloud .category{padding-right:5px}.quarto-listing-category.category-cloud .category-cloud-1{font-size:.75em}.quarto-listing-category.category-cloud .category-cloud-2{font-size:.95em}.quarto-listing-category.category-cloud .category-cloud-3{font-size:1.15em}.quarto-listing-category.category-cloud .category-cloud-4{font-size:1.35em}.quarto-listing-category.category-cloud .category-cloud-5{font-size:1.55em}.quarto-listing-category.category-cloud .category-cloud-6{font-size:1.75em}.quarto-listing-category.category-cloud .category-cloud-7{font-size:1.95em}.quarto-listing-category.category-cloud .category-cloud-8{font-size:2.15em}.quarto-listing-category.category-cloud .category-cloud-9{font-size:2.35em}.quarto-listing-category.category-cloud .category-cloud-10{font-size:2.55em}.quarto-listing-cols-1{grid-template-columns:repeat(1, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-1{grid-template-columns:repeat(1, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-1{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-2{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-2{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-2{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-3{grid-template-columns:repeat(3, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-3{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-3{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-4{grid-template-columns:repeat(4, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-4{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-4{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-5{grid-template-columns:repeat(5, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-5{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-5{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-6{grid-template-columns:repeat(6, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-6{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-6{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-7{grid-template-columns:repeat(7, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-7{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-7{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-8{grid-template-columns:repeat(8, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-8{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-8{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-9{grid-template-columns:repeat(9, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-9{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-9{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-10{grid-template-columns:repeat(10, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-10{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-10{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-11{grid-template-columns:repeat(11, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-11{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-11{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-cols-12{grid-template-columns:repeat(12, minmax(0, 1fr));gap:1.5em}@media(max-width: 767.98px){.quarto-listing-cols-12{grid-template-columns:repeat(2, minmax(0, 1fr));gap:1.5em}}@media(max-width: 575.98px){.quarto-listing-cols-12{grid-template-columns:minmax(0, 1fr);gap:1.5em}}.quarto-listing-grid{gap:1.5em}.quarto-grid-item.borderless{border:none}.quarto-grid-item.borderless .listing-categories .listing-category:last-of-type,.quarto-grid-item.borderless .listing-categories .listing-category:first-of-type{padding-left:0}.quarto-grid-item.borderless .listing-categories .listing-category{border:0}.quarto-grid-link{text-decoration:none;color:inherit}.quarto-grid-link:hover{text-decoration:none;color:inherit}.quarto-grid-item h5.title,.quarto-grid-item .title.h5{margin-top:0;margin-bottom:0}.quarto-grid-item .card-footer{display:flex;justify-content:space-between;font-size:.8em}.quarto-grid-item .card-footer p{margin-bottom:0}.quarto-grid-item p.card-img-top{margin-bottom:0}.quarto-grid-item p.card-img-top>img{object-fit:cover}.quarto-grid-item .card-other-values{margin-top:.5em;font-size:.8em}.quarto-grid-item .card-other-values tr{margin-bottom:.5em}.quarto-grid-item .card-other-values tr>td:first-of-type{font-weight:600;padding-right:1em;padding-left:1em;vertical-align:top}.quarto-grid-item div.post-contents{display:flex;flex-direction:column;text-decoration:none;height:100%}.quarto-grid-item .listing-item-img-placeholder{background-color:rgba(52,58,64,.25);flex-shrink:0}.quarto-grid-item .card-attribution{padding-top:1em;display:flex;gap:1em;text-transform:uppercase;color:#6c757d;font-weight:500;flex-grow:10;align-items:flex-end}.quarto-grid-item .description{padding-bottom:1em}.quarto-grid-item .card-attribution .date{align-self:flex-end}.quarto-grid-item .card-attribution.justify{justify-content:space-between}.quarto-grid-item .card-attribution.start{justify-content:flex-start}.quarto-grid-item .card-attribution.end{justify-content:flex-end}.quarto-grid-item .card-title{margin-bottom:.1em}.quarto-grid-item .card-subtitle{padding-top:.25em}.quarto-grid-item .card-text{font-size:.9em}.quarto-grid-item .listing-reading-time{padding-bottom:.25em}.quarto-grid-item .card-text-small{font-size:.8em}.quarto-grid-item .card-subtitle.subtitle{font-size:.9em;font-weight:600;padding-bottom:.5em}.quarto-grid-item .listing-categories{display:flex;flex-wrap:wrap;padding-bottom:5px}.quarto-grid-item .listing-categories .listing-category{color:#6c757d;border:solid #6c757d 1px;border-radius:.25rem;text-transform:uppercase;font-size:.65em;padding-left:.5em;padding-right:.5em;padding-top:.15em;padding-bottom:.15em;cursor:pointer;margin-right:4px;margin-bottom:4px}.quarto-grid-item.card-right{text-align:right}.quarto-grid-item.card-right .listing-categories{justify-content:flex-end}.quarto-grid-item.card-left{text-align:left}.quarto-grid-item.card-center{text-align:center}.quarto-grid-item.card-center .listing-description{text-align:justify}.quarto-grid-item.card-center .listing-categories{justify-content:center}table.quarto-listing-table td.image{padding:0px}table.quarto-listing-table td.image img{width:100%;max-width:50px;object-fit:contain}table.quarto-listing-table a{text-decoration:none;word-break:keep-all}table.quarto-listing-table th a{color:inherit}table.quarto-listing-table th a.asc:after{margin-bottom:-2px;margin-left:5px;display:inline-block;height:1rem;width:1rem;background-repeat:no-repeat;background-size:1rem 1rem;background-image:url('data:image/svg+xml,');content:""}table.quarto-listing-table th a.desc:after{margin-bottom:-2px;margin-left:5px;display:inline-block;height:1rem;width:1rem;background-repeat:no-repeat;background-size:1rem 1rem;background-image:url('data:image/svg+xml,');content:""}table.quarto-listing-table.table-hover td{cursor:pointer}.quarto-post.image-left{flex-direction:row}.quarto-post.image-right{flex-direction:row-reverse}@media(max-width: 767.98px){.quarto-post.image-right,.quarto-post.image-left{gap:0em;flex-direction:column}.quarto-post .metadata{padding-bottom:1em;order:2}.quarto-post .body{order:1}.quarto-post .thumbnail{order:3}}.list.quarto-listing-default div:last-of-type{border-bottom:none}@media(min-width: 992px){.quarto-listing-container-default{margin-right:2em}}div.quarto-post{display:flex;gap:2em;margin-bottom:1.5em;border-bottom:1px solid #dee2e6}@media(max-width: 767.98px){div.quarto-post{padding-bottom:1em}}div.quarto-post .metadata{flex-basis:20%;flex-grow:0;margin-top:.2em;flex-shrink:10}div.quarto-post .thumbnail{flex-basis:30%;flex-grow:0;flex-shrink:0}div.quarto-post .thumbnail img{margin-top:.4em;width:100%;object-fit:cover}div.quarto-post .body{flex-basis:45%;flex-grow:1;flex-shrink:0}div.quarto-post .body h3.listing-title,div.quarto-post .body .listing-title.h3{margin-top:0px;margin-bottom:0px;border-bottom:none}div.quarto-post .body .listing-subtitle{font-size:.875em;margin-bottom:.5em;margin-top:.2em}div.quarto-post .body .description{font-size:.9em}div.quarto-post .body pre code{white-space:pre-wrap}div.quarto-post a{color:#ebebeb;text-decoration:none}div.quarto-post .metadata{display:flex;flex-direction:column;font-size:.8em;font-family:Lato,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";flex-basis:33%}div.quarto-post .listing-categories{display:flex;flex-wrap:wrap;padding-bottom:5px}div.quarto-post .listing-categories .listing-category{color:#6c757d;border:solid #6c757d 1px;border-radius:.25rem;text-transform:uppercase;font-size:.65em;padding-left:.5em;padding-right:.5em;padding-top:.15em;padding-bottom:.15em;cursor:pointer;margin-right:4px;margin-bottom:4px}div.quarto-post .listing-description{margin-bottom:.5em}div.quarto-about-jolla{display:flex !important;flex-direction:column;align-items:center;margin-top:10%;padding-bottom:1em}div.quarto-about-jolla .about-image{object-fit:cover;margin-left:auto;margin-right:auto;margin-bottom:1.5em}div.quarto-about-jolla img.round{border-radius:50%}div.quarto-about-jolla img.rounded{border-radius:10px}div.quarto-about-jolla .quarto-title h1.title,div.quarto-about-jolla .quarto-title .title.h1{text-align:center}div.quarto-about-jolla .quarto-title .description{text-align:center}div.quarto-about-jolla h2,div.quarto-about-jolla .h2{border-bottom:none}div.quarto-about-jolla .about-sep{width:60%}div.quarto-about-jolla main{text-align:center}div.quarto-about-jolla .about-links{display:flex}@media(min-width: 992px){div.quarto-about-jolla .about-links{flex-direction:row;column-gap:.8em;row-gap:15px;flex-wrap:wrap}}@media(max-width: 991.98px){div.quarto-about-jolla .about-links{flex-direction:column;row-gap:1em;width:100%;padding-bottom:1.5em}}div.quarto-about-jolla .about-link{color:#fff;text-decoration:none;border:solid 1px}@media(min-width: 992px){div.quarto-about-jolla .about-link{font-size:.8em;padding:.25em .5em;border-radius:4px}}@media(max-width: 991.98px){div.quarto-about-jolla .about-link{font-size:1.1em;padding:.5em .5em;text-align:center;border-radius:6px}}div.quarto-about-jolla .about-link:hover{color:#df6919}div.quarto-about-jolla .about-link i.bi{margin-right:.15em}div.quarto-about-solana{display:flex !important;flex-direction:column;padding-top:3em !important;padding-bottom:1em}div.quarto-about-solana .about-entity{display:flex !important;align-items:start;justify-content:space-between}@media(min-width: 992px){div.quarto-about-solana .about-entity{flex-direction:row}}@media(max-width: 991.98px){div.quarto-about-solana .about-entity{flex-direction:column-reverse;align-items:center;text-align:center}}div.quarto-about-solana .about-entity .entity-contents{display:flex;flex-direction:column}@media(max-width: 767.98px){div.quarto-about-solana .about-entity .entity-contents{width:100%}}div.quarto-about-solana .about-entity .about-image{object-fit:cover}@media(max-width: 991.98px){div.quarto-about-solana .about-entity .about-image{margin-bottom:1.5em}}div.quarto-about-solana .about-entity img.round{border-radius:50%}div.quarto-about-solana .about-entity img.rounded{border-radius:10px}div.quarto-about-solana .about-entity .about-links{display:flex;justify-content:left;padding-bottom:1.2em}@media(min-width: 992px){div.quarto-about-solana .about-entity .about-links{flex-direction:row;column-gap:.8em;row-gap:15px;flex-wrap:wrap}}@media(max-width: 991.98px){div.quarto-about-solana .about-entity .about-links{flex-direction:column;row-gap:1em;width:100%;padding-bottom:1.5em}}div.quarto-about-solana .about-entity .about-link{color:#fff;text-decoration:none;border:solid 1px}@media(min-width: 992px){div.quarto-about-solana .about-entity .about-link{font-size:.8em;padding:.25em .5em;border-radius:4px}}@media(max-width: 991.98px){div.quarto-about-solana .about-entity .about-link{font-size:1.1em;padding:.5em .5em;text-align:center;border-radius:6px}}div.quarto-about-solana .about-entity .about-link:hover{color:#df6919}div.quarto-about-solana .about-entity .about-link i.bi{margin-right:.15em}div.quarto-about-solana .about-contents{padding-right:1.5em;flex-basis:0;flex-grow:1}div.quarto-about-solana .about-contents main.content{margin-top:0}div.quarto-about-solana .about-contents h2,div.quarto-about-solana .about-contents .h2{border-bottom:none}div.quarto-about-trestles{display:flex !important;flex-direction:row;padding-top:3em !important;padding-bottom:1em}@media(max-width: 991.98px){div.quarto-about-trestles{flex-direction:column;padding-top:0em !important}}div.quarto-about-trestles .about-entity{display:flex !important;flex-direction:column;align-items:center;text-align:center;padding-right:1em}@media(min-width: 992px){div.quarto-about-trestles .about-entity{flex:0 0 42%}}div.quarto-about-trestles .about-entity .about-image{object-fit:cover;margin-bottom:1.5em}div.quarto-about-trestles .about-entity img.round{border-radius:50%}div.quarto-about-trestles .about-entity img.rounded{border-radius:10px}div.quarto-about-trestles .about-entity .about-links{display:flex;justify-content:center}@media(min-width: 992px){div.quarto-about-trestles .about-entity .about-links{flex-direction:row;column-gap:.8em;row-gap:15px;flex-wrap:wrap}}@media(max-width: 991.98px){div.quarto-about-trestles .about-entity .about-links{flex-direction:column;row-gap:1em;width:100%;padding-bottom:1.5em}}div.quarto-about-trestles .about-entity .about-link{color:#fff;text-decoration:none;border:solid 1px}@media(min-width: 992px){div.quarto-about-trestles .about-entity .about-link{font-size:.8em;padding:.25em .5em;border-radius:4px}}@media(max-width: 991.98px){div.quarto-about-trestles .about-entity .about-link{font-size:1.1em;padding:.5em .5em;text-align:center;border-radius:6px}}div.quarto-about-trestles .about-entity .about-link:hover{color:#df6919}div.quarto-about-trestles .about-entity .about-link i.bi{margin-right:.15em}div.quarto-about-trestles .about-contents{flex-basis:0;flex-grow:1}div.quarto-about-trestles .about-contents h2,div.quarto-about-trestles .about-contents .h2{border-bottom:none}@media(min-width: 992px){div.quarto-about-trestles .about-contents{border-left:solid 1px #dee2e6;padding-left:1.5em}}div.quarto-about-trestles .about-contents main.content{margin-top:0}div.quarto-about-marquee{padding-bottom:1em}div.quarto-about-marquee .about-contents{display:flex;flex-direction:column}div.quarto-about-marquee .about-image{max-height:550px;margin-bottom:1.5em;object-fit:cover}div.quarto-about-marquee img.round{border-radius:50%}div.quarto-about-marquee img.rounded{border-radius:10px}div.quarto-about-marquee h2,div.quarto-about-marquee .h2{border-bottom:none}div.quarto-about-marquee .about-links{display:flex;justify-content:center;padding-top:1.5em}@media(min-width: 992px){div.quarto-about-marquee .about-links{flex-direction:row;column-gap:.8em;row-gap:15px;flex-wrap:wrap}}@media(max-width: 991.98px){div.quarto-about-marquee .about-links{flex-direction:column;row-gap:1em;width:100%;padding-bottom:1.5em}}div.quarto-about-marquee .about-link{color:#fff;text-decoration:none;border:solid 1px}@media(min-width: 992px){div.quarto-about-marquee .about-link{font-size:.8em;padding:.25em .5em;border-radius:4px}}@media(max-width: 991.98px){div.quarto-about-marquee .about-link{font-size:1.1em;padding:.5em .5em;text-align:center;border-radius:6px}}div.quarto-about-marquee .about-link:hover{color:#df6919}div.quarto-about-marquee .about-link i.bi{margin-right:.15em}@media(min-width: 992px){div.quarto-about-marquee .about-link{border:none}}div.quarto-about-broadside{display:flex;flex-direction:column;padding-bottom:1em}div.quarto-about-broadside .about-main{display:flex !important;padding-top:0 !important}@media(min-width: 992px){div.quarto-about-broadside .about-main{flex-direction:row;align-items:flex-start}}@media(max-width: 991.98px){div.quarto-about-broadside .about-main{flex-direction:column}}@media(max-width: 991.98px){div.quarto-about-broadside .about-main .about-entity{flex-shrink:0;width:100%;height:450px;margin-bottom:1.5em;background-size:cover;background-repeat:no-repeat}}@media(min-width: 992px){div.quarto-about-broadside .about-main .about-entity{flex:0 10 50%;margin-right:1.5em;width:100%;height:100%;background-size:100%;background-repeat:no-repeat}}div.quarto-about-broadside .about-main .about-contents{padding-top:14px;flex:0 0 50%}div.quarto-about-broadside h2,div.quarto-about-broadside .h2{border-bottom:none}div.quarto-about-broadside .about-sep{margin-top:1.5em;width:60%;align-self:center}div.quarto-about-broadside .about-links{display:flex;justify-content:center;column-gap:20px;padding-top:1.5em}@media(min-width: 992px){div.quarto-about-broadside .about-links{flex-direction:row;column-gap:.8em;row-gap:15px;flex-wrap:wrap}}@media(max-width: 991.98px){div.quarto-about-broadside .about-links{flex-direction:column;row-gap:1em;width:100%;padding-bottom:1.5em}}div.quarto-about-broadside .about-link{color:#fff;text-decoration:none;border:solid 1px}@media(min-width: 992px){div.quarto-about-broadside .about-link{font-size:.8em;padding:.25em .5em;border-radius:4px}}@media(max-width: 991.98px){div.quarto-about-broadside .about-link{font-size:1.1em;padding:.5em .5em;text-align:center;border-radius:6px}}div.quarto-about-broadside .about-link:hover{color:#df6919}div.quarto-about-broadside .about-link i.bi{margin-right:.15em}@media(min-width: 992px){div.quarto-about-broadside .about-link{border:none}}.tippy-box[data-theme~=quarto]{background-color:#0f2537;border:solid 1px #dee2e6;border-radius:.25rem;color:#ebebeb;font-size:.875rem}.tippy-box[data-theme~=quarto]>.tippy-backdrop{background-color:#0f2537}.tippy-box[data-theme~=quarto]>.tippy-arrow:after,.tippy-box[data-theme~=quarto]>.tippy-svg-arrow:after{content:"";position:absolute;z-index:-1}.tippy-box[data-theme~=quarto]>.tippy-arrow:after{border-color:rgba(0,0,0,0);border-style:solid}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-6px}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-6px}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-6px}.tippy-box[data-placement^=left]>.tippy-arrow:before{right:-6px}.tippy-box[data-theme~=quarto][data-placement^=top]>.tippy-arrow:before{border-top-color:#0f2537}.tippy-box[data-theme~=quarto][data-placement^=top]>.tippy-arrow:after{border-top-color:#dee2e6;border-width:7px 7px 0;top:17px;left:1px}.tippy-box[data-theme~=quarto][data-placement^=top]>.tippy-svg-arrow>svg{top:16px}.tippy-box[data-theme~=quarto][data-placement^=top]>.tippy-svg-arrow:after{top:17px}.tippy-box[data-theme~=quarto][data-placement^=bottom]>.tippy-arrow:before{border-bottom-color:#0f2537;bottom:16px}.tippy-box[data-theme~=quarto][data-placement^=bottom]>.tippy-arrow:after{border-bottom-color:#dee2e6;border-width:0 7px 7px;bottom:17px;left:1px}.tippy-box[data-theme~=quarto][data-placement^=bottom]>.tippy-svg-arrow>svg{bottom:15px}.tippy-box[data-theme~=quarto][data-placement^=bottom]>.tippy-svg-arrow:after{bottom:17px}.tippy-box[data-theme~=quarto][data-placement^=left]>.tippy-arrow:before{border-left-color:#0f2537}.tippy-box[data-theme~=quarto][data-placement^=left]>.tippy-arrow:after{border-left-color:#dee2e6;border-width:7px 0 7px 7px;left:17px;top:1px}.tippy-box[data-theme~=quarto][data-placement^=left]>.tippy-svg-arrow>svg{left:11px}.tippy-box[data-theme~=quarto][data-placement^=left]>.tippy-svg-arrow:after{left:12px}.tippy-box[data-theme~=quarto][data-placement^=right]>.tippy-arrow:before{border-right-color:#0f2537;right:16px}.tippy-box[data-theme~=quarto][data-placement^=right]>.tippy-arrow:after{border-width:7px 7px 7px 0;right:17px;top:1px;border-right-color:#dee2e6}.tippy-box[data-theme~=quarto][data-placement^=right]>.tippy-svg-arrow>svg{right:11px}.tippy-box[data-theme~=quarto][data-placement^=right]>.tippy-svg-arrow:after{right:12px}.tippy-box[data-theme~=quarto]>.tippy-svg-arrow{fill:#ebebeb}.tippy-box[data-theme~=quarto]>.tippy-svg-arrow:after{background-image:url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMCA2czEuNzk2LS4wMTMgNC42Ny0zLjYxNUM1Ljg1MS45IDYuOTMuMDA2IDggMGMxLjA3LS4wMDYgMi4xNDguODg3IDMuMzQzIDIuMzg1QzE0LjIzMyA2LjAwNSAxNiA2IDE2IDZIMHoiIGZpbGw9InJnYmEoMCwgOCwgMTYsIDAuMikiLz48L3N2Zz4=);background-size:16px 6px;width:16px;height:6px}.top-right{position:absolute;top:1em;right:1em}.visually-hidden{border:0;clip:rect(0 0 0 0);height:auto;margin:0;overflow:hidden;padding:0;position:absolute;width:1px;white-space:nowrap}.hidden{display:none !important}.zindex-bottom{z-index:-1 !important}figure.figure{display:block}.quarto-layout-panel{margin-bottom:1em}.quarto-layout-panel>figure{width:100%}.quarto-layout-panel>figure>figcaption,.quarto-layout-panel>.panel-caption{margin-top:10pt}.quarto-layout-panel>.table-caption{margin-top:0px}.table-caption p{margin-bottom:.5em}.quarto-layout-row{display:flex;flex-direction:row;align-items:flex-start}.quarto-layout-valign-top{align-items:flex-start}.quarto-layout-valign-bottom{align-items:flex-end}.quarto-layout-valign-center{align-items:center}.quarto-layout-cell{position:relative;margin-right:20px}.quarto-layout-cell:last-child{margin-right:0}.quarto-layout-cell figure,.quarto-layout-cell>p{margin:.2em}.quarto-layout-cell img{max-width:100%}.quarto-layout-cell .html-widget{width:100% !important}.quarto-layout-cell div figure p{margin:0}.quarto-layout-cell figure{display:block;margin-inline-start:0;margin-inline-end:0}.quarto-layout-cell table{display:inline-table}.quarto-layout-cell-subref figcaption,figure .quarto-layout-row figure figcaption{text-align:center;font-style:italic}.quarto-figure{position:relative;margin-bottom:1em}.quarto-figure>figure{width:100%;margin-bottom:0}.quarto-figure-left>figure>p,.quarto-figure-left>figure>div{text-align:left}.quarto-figure-center>figure>p,.quarto-figure-center>figure>div{text-align:center}.quarto-figure-right>figure>p,.quarto-figure-right>figure>div{text-align:right}.quarto-figure>figure>div.cell-annotation,.quarto-figure>figure>div code{text-align:left}figure>p:empty{display:none}figure>p:first-child{margin-top:0;margin-bottom:0}figure>figcaption.quarto-float-caption-bottom{margin-bottom:.5em}figure>figcaption.quarto-float-caption-top{margin-top:.5em}div[id^=tbl-]{position:relative}.quarto-figure>.anchorjs-link{position:absolute;top:.6em;right:.5em}div[id^=tbl-]>.anchorjs-link{position:absolute;top:.7em;right:.3em}.quarto-figure:hover>.anchorjs-link,div[id^=tbl-]:hover>.anchorjs-link,h2:hover>.anchorjs-link,.h2:hover>.anchorjs-link,h3:hover>.anchorjs-link,.h3:hover>.anchorjs-link,h4:hover>.anchorjs-link,.h4:hover>.anchorjs-link,h5:hover>.anchorjs-link,.h5:hover>.anchorjs-link,h6:hover>.anchorjs-link,.h6:hover>.anchorjs-link,.reveal-anchorjs-link>.anchorjs-link{opacity:1}#title-block-header{margin-block-end:1rem;position:relative;margin-top:-1px}#title-block-header .abstract{margin-block-start:1rem}#title-block-header .abstract .abstract-title{font-weight:600}#title-block-header a{text-decoration:none}#title-block-header .author,#title-block-header .date,#title-block-header .doi{margin-block-end:.2rem}#title-block-header .quarto-title-block>div{display:flex}#title-block-header .quarto-title-block>div>h1,#title-block-header .quarto-title-block>div>.h1{flex-grow:1}#title-block-header .quarto-title-block>div>button{flex-shrink:0;height:2.25rem;margin-top:0}@media(min-width: 992px){#title-block-header .quarto-title-block>div>button{margin-top:5px}}tr.header>th>p:last-of-type{margin-bottom:0px}table,table.table{margin-top:.5rem;margin-bottom:.5rem}caption,.table-caption{padding-top:.5rem;padding-bottom:.5rem;text-align:center}figure.quarto-float-tbl figcaption.quarto-float-caption-top{margin-top:.5rem;margin-bottom:.25rem;text-align:center}figure.quarto-float-tbl figcaption.quarto-float-caption-bottom{padding-top:.25rem;margin-bottom:.5rem;text-align:center}.utterances{max-width:none;margin-left:-8px}iframe{margin-bottom:1em}details{margin-bottom:1em}details[show]{margin-bottom:0}details>summary{color:#6c757d}details>summary>p:only-child{display:inline}div.code-copy-outer-scaffold{position:relative}dd code:not(.sourceCode),p code:not(.sourceCode){white-space:pre-wrap}code{white-space:pre}@media print{code{white-space:pre-wrap}}pre>code{display:block}pre>code.sourceCode{white-space:pre}pre>code.sourceCode>span>a:first-child::before{text-decoration:none}pre.code-overflow-wrap>code.sourceCode{white-space:pre-wrap}pre.code-overflow-scroll>code.sourceCode{white-space:pre}code a:any-link{color:inherit;text-decoration:none}code a:hover{color:inherit;text-decoration:underline}ul.task-list{padding-left:1em}[data-tippy-root]{display:inline-block}.tippy-content .footnote-back{display:none}.footnote-back{margin-left:.2em}.tippy-content{overflow-x:auto}.quarto-embedded-source-code{display:none}.quarto-unresolved-ref{font-weight:600}.quarto-cover-image{max-width:35%;float:right;margin-left:30px}.cell-output-display .widget-subarea{margin-bottom:1em}.cell-output-display:not(.no-overflow-x),.knitsql-table:not(.no-overflow-x){overflow-x:auto}.panel-input{margin-bottom:1em}.panel-input>div,.panel-input>div>div{display:inline-block;vertical-align:top;padding-right:12px}.panel-input>p:last-child{margin-bottom:0}.layout-sidebar{margin-bottom:1em}.layout-sidebar .tab-content{border:none}.tab-content>.page-columns.active{display:grid}div.sourceCode>iframe{width:100%;height:300px;margin-bottom:-0.5em}a{text-underline-offset:3px}.callout pre.sourceCode{padding-left:0}div.ansi-escaped-output{font-family:monospace;display:block}/*! +* +* ansi colors from IPython notebook's +* +* we also add `bright-[color]-` synonyms for the `-[color]-intense` classes since +* that seems to be what ansi_up emits +* +*/.ansi-black-fg{color:#3e424d}.ansi-black-bg{background-color:#3e424d}.ansi-black-intense-black,.ansi-bright-black-fg{color:#282c36}.ansi-black-intense-black,.ansi-bright-black-bg{background-color:#282c36}.ansi-red-fg{color:#e75c58}.ansi-red-bg{background-color:#e75c58}.ansi-red-intense-red,.ansi-bright-red-fg{color:#b22b31}.ansi-red-intense-red,.ansi-bright-red-bg{background-color:#b22b31}.ansi-green-fg{color:#00a250}.ansi-green-bg{background-color:#00a250}.ansi-green-intense-green,.ansi-bright-green-fg{color:#007427}.ansi-green-intense-green,.ansi-bright-green-bg{background-color:#007427}.ansi-yellow-fg{color:#ddb62b}.ansi-yellow-bg{background-color:#ddb62b}.ansi-yellow-intense-yellow,.ansi-bright-yellow-fg{color:#b27d12}.ansi-yellow-intense-yellow,.ansi-bright-yellow-bg{background-color:#b27d12}.ansi-blue-fg{color:#208ffb}.ansi-blue-bg{background-color:#208ffb}.ansi-blue-intense-blue,.ansi-bright-blue-fg{color:#0065ca}.ansi-blue-intense-blue,.ansi-bright-blue-bg{background-color:#0065ca}.ansi-magenta-fg{color:#d160c4}.ansi-magenta-bg{background-color:#d160c4}.ansi-magenta-intense-magenta,.ansi-bright-magenta-fg{color:#a03196}.ansi-magenta-intense-magenta,.ansi-bright-magenta-bg{background-color:#a03196}.ansi-cyan-fg{color:#60c6c8}.ansi-cyan-bg{background-color:#60c6c8}.ansi-cyan-intense-cyan,.ansi-bright-cyan-fg{color:#258f8f}.ansi-cyan-intense-cyan,.ansi-bright-cyan-bg{background-color:#258f8f}.ansi-white-fg{color:#c5c1b4}.ansi-white-bg{background-color:#c5c1b4}.ansi-white-intense-white,.ansi-bright-white-fg{color:#a1a6b2}.ansi-white-intense-white,.ansi-bright-white-bg{background-color:#a1a6b2}.ansi-default-inverse-fg{color:#fff}.ansi-default-inverse-bg{background-color:#000}.ansi-bold{font-weight:bold}.ansi-underline{text-decoration:underline}:root{--quarto-body-bg: #0f2537;--quarto-body-color: #ebebeb;--quarto-text-muted: #6c757d;--quarto-border-color: rgba(0, 0, 0, 0.15);--quarto-border-width: 1px;--quarto-border-radius: 0.25rem}table.gt_table{color:var(--quarto-body-color);font-size:1em;width:100%;background-color:rgba(0,0,0,0);border-top-width:inherit;border-bottom-width:inherit;border-color:var(--quarto-border-color)}table.gt_table th.gt_column_spanner_outer{color:var(--quarto-body-color);background-color:rgba(0,0,0,0);border-top-width:inherit;border-bottom-width:inherit;border-color:var(--quarto-border-color)}table.gt_table th.gt_col_heading{color:var(--quarto-body-color);font-weight:bold;background-color:rgba(0,0,0,0)}table.gt_table thead.gt_col_headings{border-bottom:1px solid currentColor;border-top-width:inherit;border-top-color:var(--quarto-border-color)}table.gt_table thead.gt_col_headings:not(:first-child){border-top-width:1px;border-top-color:var(--quarto-border-color)}table.gt_table td.gt_row{border-bottom-width:1px;border-bottom-color:var(--quarto-border-color);border-top-width:0px}table.gt_table tbody.gt_table_body{border-top-width:1px;border-bottom-width:1px;border-bottom-color:var(--quarto-border-color);border-top-color:currentColor}div.columns{display:initial;gap:initial}div.column{display:inline-block;overflow-x:initial;vertical-align:top;width:50%}.code-annotation-tip-content{word-wrap:break-word}.code-annotation-container-hidden{display:none !important}dl.code-annotation-container-grid{display:grid;grid-template-columns:min-content auto}dl.code-annotation-container-grid dt{grid-column:1}dl.code-annotation-container-grid dd{grid-column:2}pre.sourceCode.code-annotation-code{padding-right:0}code.sourceCode .code-annotation-anchor{z-index:100;position:relative;float:right;background-color:rgba(0,0,0,0)}input[type=checkbox]{margin-right:.5ch}:root{--mermaid-bg-color: #0f2537;--mermaid-edge-color: rgb(59, 76.6, 91);--mermaid-node-fg-color: #ebebeb;--mermaid-fg-color: #ebebeb;--mermaid-fg-color--lighter: white;--mermaid-fg-color--lightest: white;--mermaid-font-family: Lato, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;--mermaid-label-bg-color: #0f2537;--mermaid-label-fg-color: #df6919;--mermaid-node-bg-color: rgba(223, 105, 25, 0.1);--mermaid-node-fg-color: #ebebeb}@media print{:root{font-size:11pt}#quarto-sidebar,#TOC,.nav-page{display:none}.page-columns .content{grid-column-start:page-start}.fixed-top{position:relative}.panel-caption,.figure-caption,figcaption{color:#666}}.code-copy-button{position:absolute;top:0;right:0;border:0;margin-top:5px;margin-right:5px;background-color:rgba(0,0,0,0);z-index:3}.code-copy-button-tooltip{font-size:.75em}div.code-copy-outer-scaffold:hover>.code-copy-button>.bi::before{display:inline-block;height:1rem;width:1rem;content:"";vertical-align:-0.125em;background-image:url('data:image/svg+xml,');background-repeat:no-repeat;background-size:1rem 1rem}div.code-copy-outer-scaffold:hover>.code-copy-button-checked>.bi::before{background-image:url('data:image/svg+xml,')}div.code-copy-outer-scaffold:hover>.code-copy-button:hover>.bi::before{background-image:url('data:image/svg+xml,')}div.code-copy-outer-scaffold:hover>.code-copy-button-checked:hover>.bi::before{background-image:url('data:image/svg+xml,')}main ol ol,main ul ul,main ol ul,main ul ol{margin-bottom:1em}ul>li:not(:has(>p))>ul,ol>li:not(:has(>p))>ul,ul>li:not(:has(>p))>ol,ol>li:not(:has(>p))>ol{margin-bottom:0}ul>li:not(:has(>p))>ul>li:has(>p),ol>li:not(:has(>p))>ul>li:has(>p),ul>li:not(:has(>p))>ol>li:has(>p),ol>li:not(:has(>p))>ol>li:has(>p){margin-top:1rem}body{margin:0}main.page-columns>header>h1.title,main.page-columns>header>.title.h1{margin-bottom:0}@media(min-width: 992px){body .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset] 35px [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(500px, calc(850px - 3em)) [body-content-end] 1.5em [body-end] 35px [body-end-outset] minmax(75px, 145px) [page-end-inset] 35px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.fullcontent:not(.floating):not(.docked) .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset] 35px [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(500px, calc(850px - 3em)) [body-content-end] 1.5em [body-end] 35px [body-end-outset] 35px [page-end-inset page-end] 5fr [screen-end-inset] 1.5em}body.slimcontent:not(.floating):not(.docked) .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset] 35px [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(500px, calc(850px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(0px, 200px) [page-end-inset] 35px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.listing:not(.floating):not(.docked) .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start] minmax(50px, 100px) [page-start-inset] 50px [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(500px, calc(850px - 3em)) [body-content-end] 3em [body-end] 50px [body-end-outset] minmax(0px, 250px) [page-end-inset] minmax(50px, 100px) [page-end] 1fr [screen-end-inset] 1.5em [screen-end]}body:not(.floating):not(.docked) .page-columns.toc-left{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] 35px [page-start-inset] minmax(0px, 175px) [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(450px, calc(800px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(0px, 200px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body:not(.floating):not(.docked) .page-columns.toc-left .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] 35px [page-start-inset] minmax(0px, 175px) [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(450px, calc(800px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(0px, 200px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] minmax(25px, 50px) [page-start-inset] minmax(50px, 150px) [body-start-outset] minmax(25px, 50px) [body-start] 1.5em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end] minmax(25px, 50px) [body-end-outset] minmax(50px, 150px) [page-end-inset] minmax(25px, 50px) [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start] minmax(50px, 100px) [page-start-inset] 50px [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(500px, calc(1000px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(50px, 100px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked.fullcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start] minmax(50px, 100px) [page-start-inset] 50px [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(500px, calc(1000px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating.fullcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] 50px [page-start-inset] minmax(50px, 150px) [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked.slimcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start] minmax(50px, 100px) [page-start-inset] 50px [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(450px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(0px, 200px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked.listing .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start] minmax(50px, 100px) [page-start-inset] 50px [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(500px, calc(1000px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(0px, 200px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating.slimcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] 50px [page-start-inset] minmax(50px, 150px) [body-start-outset] 50px [body-start] 1.5em [body-content-start] minmax(450px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(50px, 150px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating.listing .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] minmax(25px, 50px) [page-start-inset] minmax(50px, 150px) [body-start-outset] minmax(25px, 50px) [body-start] 1.5em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end] minmax(25px, 50px) [body-end-outset] minmax(50px, 150px) [page-end-inset] minmax(25px, 50px) [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}}@media(max-width: 991.98px){body .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset] 5fr [body-start] 1.5em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end] 35px [body-end-outset] minmax(75px, 145px) [page-end-inset] 35px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.fullcontent:not(.floating):not(.docked) .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset] 5fr [body-start] 1.5em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.slimcontent:not(.floating):not(.docked) .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset] 5fr [body-start] 1.5em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end] 35px [body-end-outset] minmax(75px, 145px) [page-end-inset] 35px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.listing:not(.floating):not(.docked) .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset] 5fr [body-start] 1.5em [body-content-start] minmax(500px, calc(1250px - 3em)) [body-content-end body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body:not(.floating):not(.docked) .page-columns.toc-left{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] 35px [page-start-inset] minmax(0px, 145px) [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(450px, calc(800px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body:not(.floating):not(.docked) .page-columns.toc-left .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start] 35px [page-start-inset] minmax(0px, 145px) [body-start-outset] 35px [body-start] 1.5em [body-content-start] minmax(450px, calc(800px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset body-start-outset body-start] 1.5em [body-content-start] minmax(500px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(75px, 150px) [page-end-inset] 25px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(500px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(25px, 50px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked.fullcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(500px, calc(1000px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating.fullcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset body-start-outset body-start] 1em [body-content-start] minmax(500px, calc(800px - 3em)) [body-content-end] 1.5em [body-end body-end-outset page-end-inset page-end] 4fr [screen-end-inset] 1.5em [screen-end]}body.docked.slimcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(500px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(25px, 50px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.docked.listing .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(500px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(25px, 50px) [page-end-inset] 50px [page-end] 5fr [screen-end-inset] 1.5em [screen-end]}body.floating.slimcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset body-start-outset body-start] 1em [body-content-start] minmax(500px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 35px [body-end-outset] minmax(75px, 145px) [page-end-inset] 35px [page-end] 4fr [screen-end-inset] 1.5em [screen-end]}body.floating.listing .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset] 5fr [page-start page-start-inset body-start-outset body-start] 1em [body-content-start] minmax(500px, calc(750px - 3em)) [body-content-end] 1.5em [body-end] 50px [body-end-outset] minmax(75px, 150px) [page-end-inset] 25px [page-end] 4fr [screen-end-inset] 1.5em [screen-end]}}@media(max-width: 767.98px){body .page-columns,body.fullcontent:not(.floating):not(.docked) .page-columns,body.slimcontent:not(.floating):not(.docked) .page-columns,body.docked .page-columns,body.docked.slimcontent .page-columns,body.docked.fullcontent .page-columns,body.floating .page-columns,body.floating.slimcontent .page-columns,body.floating.fullcontent .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(0px, 1fr) [body-content-end body-end body-end-outset page-end-inset page-end screen-end-inset] 1.5em [screen-end]}body:not(.floating):not(.docked) .page-columns.toc-left{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(0px, 1fr) [body-content-end body-end body-end-outset page-end-inset page-end screen-end-inset] 1.5em [screen-end]}body:not(.floating):not(.docked) .page-columns.toc-left .page-columns{display:grid;gap:0;grid-template-columns:[screen-start] 1.5em [screen-start-inset page-start page-start-inset body-start-outset body-start body-content-start] minmax(0px, 1fr) [body-content-end body-end body-end-outset page-end-inset page-end screen-end-inset] 1.5em [screen-end]}nav[role=doc-toc]{display:none}}body,.page-row-navigation{grid-template-rows:[page-top] max-content [contents-top] max-content [contents-bottom] max-content [page-bottom]}.page-rows-contents{grid-template-rows:[content-top] minmax(max-content, 1fr) [content-bottom] minmax(60px, max-content) [page-bottom]}.page-full{grid-column:screen-start/screen-end !important}.page-columns>*{grid-column:body-content-start/body-content-end}.page-columns.column-page>*{grid-column:page-start/page-end}.page-columns.column-page-left .page-columns.page-full>*,.page-columns.column-page-left>*{grid-column:page-start/body-content-end}.page-columns.column-page-right .page-columns.page-full>*,.page-columns.column-page-right>*{grid-column:body-content-start/page-end}.page-rows{grid-auto-rows:auto}.header{grid-column:screen-start/screen-end;grid-row:page-top/contents-top}#quarto-content{padding:0;grid-column:screen-start/screen-end;grid-row:contents-top/contents-bottom}body.floating .sidebar.sidebar-navigation{grid-column:page-start/body-start;grid-row:content-top/page-bottom}body.docked .sidebar.sidebar-navigation{grid-column:screen-start/body-start;grid-row:content-top/page-bottom}.sidebar.toc-left{grid-column:page-start/body-start;grid-row:content-top/page-bottom}.sidebar.margin-sidebar{grid-column:body-end/page-end;grid-row:content-top/page-bottom}.page-columns .content{grid-column:body-content-start/body-content-end;grid-row:content-top/content-bottom;align-content:flex-start}.page-columns .page-navigation{grid-column:body-content-start/body-content-end;grid-row:content-bottom/page-bottom}.page-columns .footer{grid-column:screen-start/screen-end;grid-row:contents-bottom/page-bottom}.page-columns .column-body{grid-column:body-content-start/body-content-end}.page-columns .column-body-fullbleed{grid-column:body-start/body-end}.page-columns .column-body-outset{grid-column:body-start-outset/body-end-outset;z-index:998;opacity:.999}.page-columns .column-body-outset table{background:#0f2537}.page-columns .column-body-outset-left{grid-column:body-start-outset/body-content-end;z-index:998;opacity:.999}.page-columns .column-body-outset-left table{background:#0f2537}.page-columns .column-body-outset-right{grid-column:body-content-start/body-end-outset;z-index:998;opacity:.999}.page-columns .column-body-outset-right table{background:#0f2537}.page-columns .column-page{grid-column:page-start/page-end;z-index:998;opacity:.999}.page-columns .column-page table{background:#0f2537}.page-columns .column-page-inset{grid-column:page-start-inset/page-end-inset;z-index:998;opacity:.999}.page-columns .column-page-inset table{background:#0f2537}.page-columns .column-page-inset-left{grid-column:page-start-inset/body-content-end;z-index:998;opacity:.999}.page-columns .column-page-inset-left table{background:#0f2537}.page-columns .column-page-inset-right{grid-column:body-content-start/page-end-inset;z-index:998;opacity:.999}.page-columns .column-page-inset-right figcaption table{background:#0f2537}.page-columns .column-page-left{grid-column:page-start/body-content-end;z-index:998;opacity:.999}.page-columns .column-page-left table{background:#0f2537}.page-columns .column-page-right{grid-column:body-content-start/page-end;z-index:998;opacity:.999}.page-columns .column-page-right figcaption table{background:#0f2537}#quarto-content.page-columns #quarto-margin-sidebar,#quarto-content.page-columns #quarto-sidebar{z-index:1}@media(max-width: 991.98px){#quarto-content.page-columns #quarto-margin-sidebar.collapse,#quarto-content.page-columns #quarto-sidebar.collapse,#quarto-content.page-columns #quarto-margin-sidebar.collapsing,#quarto-content.page-columns #quarto-sidebar.collapsing{z-index:1055}}#quarto-content.page-columns main.column-page,#quarto-content.page-columns main.column-page-right,#quarto-content.page-columns main.column-page-left{z-index:0}.page-columns .column-screen-inset{grid-column:screen-start-inset/screen-end-inset;z-index:998;opacity:.999}.page-columns .column-screen-inset table{background:#0f2537}.page-columns .column-screen-inset-left{grid-column:screen-start-inset/body-content-end;z-index:998;opacity:.999}.page-columns .column-screen-inset-left table{background:#0f2537}.page-columns .column-screen-inset-right{grid-column:body-content-start/screen-end-inset;z-index:998;opacity:.999}.page-columns .column-screen-inset-right table{background:#0f2537}.page-columns .column-screen{grid-column:screen-start/screen-end;z-index:998;opacity:.999}.page-columns .column-screen table{background:#0f2537}.page-columns .column-screen-left{grid-column:screen-start/body-content-end;z-index:998;opacity:.999}.page-columns .column-screen-left table{background:#0f2537}.page-columns .column-screen-right{grid-column:body-content-start/screen-end;z-index:998;opacity:.999}.page-columns .column-screen-right table{background:#0f2537}.page-columns .column-screen-inset-shaded{grid-column:screen-start/screen-end;padding:1em;background:rgb(129.4,139.96,148.6);z-index:998;opacity:.999;margin-bottom:1em}.zindex-content{z-index:998;opacity:.999}.zindex-modal{z-index:1055;opacity:.999}.zindex-over-content{z-index:999;opacity:.999}img.img-fluid.column-screen,img.img-fluid.column-screen-inset-shaded,img.img-fluid.column-screen-inset,img.img-fluid.column-screen-inset-left,img.img-fluid.column-screen-inset-right,img.img-fluid.column-screen-left,img.img-fluid.column-screen-right{width:100%}@media(min-width: 992px){.margin-caption,div.aside,aside:not(.footnotes):not(.sidebar),.column-margin{grid-column:body-end/page-end !important;z-index:998}.column-sidebar{grid-column:page-start/body-start !important;z-index:998}.column-leftmargin{grid-column:screen-start-inset/body-start !important;z-index:998}.no-row-height{height:1em;overflow:visible}}@media(max-width: 991.98px){.margin-caption,div.aside,aside:not(.footnotes):not(.sidebar),.column-margin{grid-column:body-end/page-end !important;z-index:998}.no-row-height{height:1em;overflow:visible}.page-columns.page-full{overflow:visible}.page-columns.toc-left .margin-caption,.page-columns.toc-left div.aside,.page-columns.toc-left aside:not(.footnotes):not(.sidebar),.page-columns.toc-left .column-margin{grid-column:body-content-start/body-content-end !important;z-index:998;opacity:.999}.page-columns.toc-left .no-row-height{height:initial;overflow:initial}}@media(max-width: 767.98px){.margin-caption,div.aside,aside:not(.footnotes):not(.sidebar),.column-margin{grid-column:body-content-start/body-content-end !important;z-index:998;opacity:.999}.no-row-height{height:initial;overflow:initial}#quarto-margin-sidebar{display:none}#quarto-sidebar-toc-left{display:none}.hidden-sm{display:none}}.panel-grid{display:grid;grid-template-rows:repeat(1, 1fr);grid-template-columns:repeat(24, 1fr);gap:1em}.panel-grid .g-col-1{grid-column:auto/span 1}.panel-grid .g-col-2{grid-column:auto/span 2}.panel-grid .g-col-3{grid-column:auto/span 3}.panel-grid .g-col-4{grid-column:auto/span 4}.panel-grid .g-col-5{grid-column:auto/span 5}.panel-grid .g-col-6{grid-column:auto/span 6}.panel-grid .g-col-7{grid-column:auto/span 7}.panel-grid .g-col-8{grid-column:auto/span 8}.panel-grid .g-col-9{grid-column:auto/span 9}.panel-grid .g-col-10{grid-column:auto/span 10}.panel-grid .g-col-11{grid-column:auto/span 11}.panel-grid .g-col-12{grid-column:auto/span 12}.panel-grid .g-col-13{grid-column:auto/span 13}.panel-grid .g-col-14{grid-column:auto/span 14}.panel-grid .g-col-15{grid-column:auto/span 15}.panel-grid .g-col-16{grid-column:auto/span 16}.panel-grid .g-col-17{grid-column:auto/span 17}.panel-grid .g-col-18{grid-column:auto/span 18}.panel-grid .g-col-19{grid-column:auto/span 19}.panel-grid .g-col-20{grid-column:auto/span 20}.panel-grid .g-col-21{grid-column:auto/span 21}.panel-grid .g-col-22{grid-column:auto/span 22}.panel-grid .g-col-23{grid-column:auto/span 23}.panel-grid .g-col-24{grid-column:auto/span 24}.panel-grid .g-start-1{grid-column-start:1}.panel-grid .g-start-2{grid-column-start:2}.panel-grid .g-start-3{grid-column-start:3}.panel-grid .g-start-4{grid-column-start:4}.panel-grid .g-start-5{grid-column-start:5}.panel-grid .g-start-6{grid-column-start:6}.panel-grid .g-start-7{grid-column-start:7}.panel-grid .g-start-8{grid-column-start:8}.panel-grid .g-start-9{grid-column-start:9}.panel-grid .g-start-10{grid-column-start:10}.panel-grid .g-start-11{grid-column-start:11}.panel-grid .g-start-12{grid-column-start:12}.panel-grid .g-start-13{grid-column-start:13}.panel-grid .g-start-14{grid-column-start:14}.panel-grid .g-start-15{grid-column-start:15}.panel-grid .g-start-16{grid-column-start:16}.panel-grid .g-start-17{grid-column-start:17}.panel-grid .g-start-18{grid-column-start:18}.panel-grid .g-start-19{grid-column-start:19}.panel-grid .g-start-20{grid-column-start:20}.panel-grid .g-start-21{grid-column-start:21}.panel-grid .g-start-22{grid-column-start:22}.panel-grid .g-start-23{grid-column-start:23}@media(min-width: 576px){.panel-grid .g-col-sm-1{grid-column:auto/span 1}.panel-grid .g-col-sm-2{grid-column:auto/span 2}.panel-grid .g-col-sm-3{grid-column:auto/span 3}.panel-grid .g-col-sm-4{grid-column:auto/span 4}.panel-grid .g-col-sm-5{grid-column:auto/span 5}.panel-grid .g-col-sm-6{grid-column:auto/span 6}.panel-grid .g-col-sm-7{grid-column:auto/span 7}.panel-grid .g-col-sm-8{grid-column:auto/span 8}.panel-grid .g-col-sm-9{grid-column:auto/span 9}.panel-grid .g-col-sm-10{grid-column:auto/span 10}.panel-grid .g-col-sm-11{grid-column:auto/span 11}.panel-grid .g-col-sm-12{grid-column:auto/span 12}.panel-grid .g-col-sm-13{grid-column:auto/span 13}.panel-grid .g-col-sm-14{grid-column:auto/span 14}.panel-grid .g-col-sm-15{grid-column:auto/span 15}.panel-grid .g-col-sm-16{grid-column:auto/span 16}.panel-grid .g-col-sm-17{grid-column:auto/span 17}.panel-grid .g-col-sm-18{grid-column:auto/span 18}.panel-grid .g-col-sm-19{grid-column:auto/span 19}.panel-grid .g-col-sm-20{grid-column:auto/span 20}.panel-grid .g-col-sm-21{grid-column:auto/span 21}.panel-grid .g-col-sm-22{grid-column:auto/span 22}.panel-grid .g-col-sm-23{grid-column:auto/span 23}.panel-grid .g-col-sm-24{grid-column:auto/span 24}.panel-grid .g-start-sm-1{grid-column-start:1}.panel-grid .g-start-sm-2{grid-column-start:2}.panel-grid .g-start-sm-3{grid-column-start:3}.panel-grid .g-start-sm-4{grid-column-start:4}.panel-grid .g-start-sm-5{grid-column-start:5}.panel-grid .g-start-sm-6{grid-column-start:6}.panel-grid .g-start-sm-7{grid-column-start:7}.panel-grid .g-start-sm-8{grid-column-start:8}.panel-grid .g-start-sm-9{grid-column-start:9}.panel-grid .g-start-sm-10{grid-column-start:10}.panel-grid .g-start-sm-11{grid-column-start:11}.panel-grid .g-start-sm-12{grid-column-start:12}.panel-grid .g-start-sm-13{grid-column-start:13}.panel-grid .g-start-sm-14{grid-column-start:14}.panel-grid .g-start-sm-15{grid-column-start:15}.panel-grid .g-start-sm-16{grid-column-start:16}.panel-grid .g-start-sm-17{grid-column-start:17}.panel-grid .g-start-sm-18{grid-column-start:18}.panel-grid .g-start-sm-19{grid-column-start:19}.panel-grid .g-start-sm-20{grid-column-start:20}.panel-grid .g-start-sm-21{grid-column-start:21}.panel-grid .g-start-sm-22{grid-column-start:22}.panel-grid .g-start-sm-23{grid-column-start:23}}@media(min-width: 768px){.panel-grid .g-col-md-1{grid-column:auto/span 1}.panel-grid .g-col-md-2{grid-column:auto/span 2}.panel-grid .g-col-md-3{grid-column:auto/span 3}.panel-grid .g-col-md-4{grid-column:auto/span 4}.panel-grid .g-col-md-5{grid-column:auto/span 5}.panel-grid .g-col-md-6{grid-column:auto/span 6}.panel-grid .g-col-md-7{grid-column:auto/span 7}.panel-grid .g-col-md-8{grid-column:auto/span 8}.panel-grid .g-col-md-9{grid-column:auto/span 9}.panel-grid .g-col-md-10{grid-column:auto/span 10}.panel-grid .g-col-md-11{grid-column:auto/span 11}.panel-grid .g-col-md-12{grid-column:auto/span 12}.panel-grid .g-col-md-13{grid-column:auto/span 13}.panel-grid .g-col-md-14{grid-column:auto/span 14}.panel-grid .g-col-md-15{grid-column:auto/span 15}.panel-grid .g-col-md-16{grid-column:auto/span 16}.panel-grid .g-col-md-17{grid-column:auto/span 17}.panel-grid .g-col-md-18{grid-column:auto/span 18}.panel-grid .g-col-md-19{grid-column:auto/span 19}.panel-grid .g-col-md-20{grid-column:auto/span 20}.panel-grid .g-col-md-21{grid-column:auto/span 21}.panel-grid .g-col-md-22{grid-column:auto/span 22}.panel-grid .g-col-md-23{grid-column:auto/span 23}.panel-grid .g-col-md-24{grid-column:auto/span 24}.panel-grid .g-start-md-1{grid-column-start:1}.panel-grid .g-start-md-2{grid-column-start:2}.panel-grid .g-start-md-3{grid-column-start:3}.panel-grid .g-start-md-4{grid-column-start:4}.panel-grid .g-start-md-5{grid-column-start:5}.panel-grid .g-start-md-6{grid-column-start:6}.panel-grid .g-start-md-7{grid-column-start:7}.panel-grid .g-start-md-8{grid-column-start:8}.panel-grid .g-start-md-9{grid-column-start:9}.panel-grid .g-start-md-10{grid-column-start:10}.panel-grid .g-start-md-11{grid-column-start:11}.panel-grid .g-start-md-12{grid-column-start:12}.panel-grid .g-start-md-13{grid-column-start:13}.panel-grid .g-start-md-14{grid-column-start:14}.panel-grid .g-start-md-15{grid-column-start:15}.panel-grid .g-start-md-16{grid-column-start:16}.panel-grid .g-start-md-17{grid-column-start:17}.panel-grid .g-start-md-18{grid-column-start:18}.panel-grid .g-start-md-19{grid-column-start:19}.panel-grid .g-start-md-20{grid-column-start:20}.panel-grid .g-start-md-21{grid-column-start:21}.panel-grid .g-start-md-22{grid-column-start:22}.panel-grid .g-start-md-23{grid-column-start:23}}@media(min-width: 992px){.panel-grid .g-col-lg-1{grid-column:auto/span 1}.panel-grid .g-col-lg-2{grid-column:auto/span 2}.panel-grid .g-col-lg-3{grid-column:auto/span 3}.panel-grid .g-col-lg-4{grid-column:auto/span 4}.panel-grid .g-col-lg-5{grid-column:auto/span 5}.panel-grid .g-col-lg-6{grid-column:auto/span 6}.panel-grid .g-col-lg-7{grid-column:auto/span 7}.panel-grid .g-col-lg-8{grid-column:auto/span 8}.panel-grid .g-col-lg-9{grid-column:auto/span 9}.panel-grid .g-col-lg-10{grid-column:auto/span 10}.panel-grid .g-col-lg-11{grid-column:auto/span 11}.panel-grid .g-col-lg-12{grid-column:auto/span 12}.panel-grid .g-col-lg-13{grid-column:auto/span 13}.panel-grid .g-col-lg-14{grid-column:auto/span 14}.panel-grid .g-col-lg-15{grid-column:auto/span 15}.panel-grid .g-col-lg-16{grid-column:auto/span 16}.panel-grid .g-col-lg-17{grid-column:auto/span 17}.panel-grid .g-col-lg-18{grid-column:auto/span 18}.panel-grid .g-col-lg-19{grid-column:auto/span 19}.panel-grid .g-col-lg-20{grid-column:auto/span 20}.panel-grid .g-col-lg-21{grid-column:auto/span 21}.panel-grid .g-col-lg-22{grid-column:auto/span 22}.panel-grid .g-col-lg-23{grid-column:auto/span 23}.panel-grid .g-col-lg-24{grid-column:auto/span 24}.panel-grid .g-start-lg-1{grid-column-start:1}.panel-grid .g-start-lg-2{grid-column-start:2}.panel-grid .g-start-lg-3{grid-column-start:3}.panel-grid .g-start-lg-4{grid-column-start:4}.panel-grid .g-start-lg-5{grid-column-start:5}.panel-grid .g-start-lg-6{grid-column-start:6}.panel-grid .g-start-lg-7{grid-column-start:7}.panel-grid .g-start-lg-8{grid-column-start:8}.panel-grid .g-start-lg-9{grid-column-start:9}.panel-grid .g-start-lg-10{grid-column-start:10}.panel-grid .g-start-lg-11{grid-column-start:11}.panel-grid .g-start-lg-12{grid-column-start:12}.panel-grid .g-start-lg-13{grid-column-start:13}.panel-grid .g-start-lg-14{grid-column-start:14}.panel-grid .g-start-lg-15{grid-column-start:15}.panel-grid .g-start-lg-16{grid-column-start:16}.panel-grid .g-start-lg-17{grid-column-start:17}.panel-grid .g-start-lg-18{grid-column-start:18}.panel-grid .g-start-lg-19{grid-column-start:19}.panel-grid .g-start-lg-20{grid-column-start:20}.panel-grid .g-start-lg-21{grid-column-start:21}.panel-grid .g-start-lg-22{grid-column-start:22}.panel-grid .g-start-lg-23{grid-column-start:23}}@media(min-width: 1200px){.panel-grid .g-col-xl-1{grid-column:auto/span 1}.panel-grid .g-col-xl-2{grid-column:auto/span 2}.panel-grid .g-col-xl-3{grid-column:auto/span 3}.panel-grid .g-col-xl-4{grid-column:auto/span 4}.panel-grid .g-col-xl-5{grid-column:auto/span 5}.panel-grid .g-col-xl-6{grid-column:auto/span 6}.panel-grid .g-col-xl-7{grid-column:auto/span 7}.panel-grid .g-col-xl-8{grid-column:auto/span 8}.panel-grid .g-col-xl-9{grid-column:auto/span 9}.panel-grid .g-col-xl-10{grid-column:auto/span 10}.panel-grid .g-col-xl-11{grid-column:auto/span 11}.panel-grid .g-col-xl-12{grid-column:auto/span 12}.panel-grid .g-col-xl-13{grid-column:auto/span 13}.panel-grid .g-col-xl-14{grid-column:auto/span 14}.panel-grid .g-col-xl-15{grid-column:auto/span 15}.panel-grid .g-col-xl-16{grid-column:auto/span 16}.panel-grid .g-col-xl-17{grid-column:auto/span 17}.panel-grid .g-col-xl-18{grid-column:auto/span 18}.panel-grid .g-col-xl-19{grid-column:auto/span 19}.panel-grid .g-col-xl-20{grid-column:auto/span 20}.panel-grid .g-col-xl-21{grid-column:auto/span 21}.panel-grid .g-col-xl-22{grid-column:auto/span 22}.panel-grid .g-col-xl-23{grid-column:auto/span 23}.panel-grid .g-col-xl-24{grid-column:auto/span 24}.panel-grid .g-start-xl-1{grid-column-start:1}.panel-grid .g-start-xl-2{grid-column-start:2}.panel-grid .g-start-xl-3{grid-column-start:3}.panel-grid .g-start-xl-4{grid-column-start:4}.panel-grid .g-start-xl-5{grid-column-start:5}.panel-grid .g-start-xl-6{grid-column-start:6}.panel-grid .g-start-xl-7{grid-column-start:7}.panel-grid .g-start-xl-8{grid-column-start:8}.panel-grid .g-start-xl-9{grid-column-start:9}.panel-grid .g-start-xl-10{grid-column-start:10}.panel-grid .g-start-xl-11{grid-column-start:11}.panel-grid .g-start-xl-12{grid-column-start:12}.panel-grid .g-start-xl-13{grid-column-start:13}.panel-grid .g-start-xl-14{grid-column-start:14}.panel-grid .g-start-xl-15{grid-column-start:15}.panel-grid .g-start-xl-16{grid-column-start:16}.panel-grid .g-start-xl-17{grid-column-start:17}.panel-grid .g-start-xl-18{grid-column-start:18}.panel-grid .g-start-xl-19{grid-column-start:19}.panel-grid .g-start-xl-20{grid-column-start:20}.panel-grid .g-start-xl-21{grid-column-start:21}.panel-grid .g-start-xl-22{grid-column-start:22}.panel-grid .g-start-xl-23{grid-column-start:23}}@media(min-width: 1400px){.panel-grid .g-col-xxl-1{grid-column:auto/span 1}.panel-grid .g-col-xxl-2{grid-column:auto/span 2}.panel-grid .g-col-xxl-3{grid-column:auto/span 3}.panel-grid .g-col-xxl-4{grid-column:auto/span 4}.panel-grid .g-col-xxl-5{grid-column:auto/span 5}.panel-grid .g-col-xxl-6{grid-column:auto/span 6}.panel-grid .g-col-xxl-7{grid-column:auto/span 7}.panel-grid .g-col-xxl-8{grid-column:auto/span 8}.panel-grid .g-col-xxl-9{grid-column:auto/span 9}.panel-grid .g-col-xxl-10{grid-column:auto/span 10}.panel-grid .g-col-xxl-11{grid-column:auto/span 11}.panel-grid .g-col-xxl-12{grid-column:auto/span 12}.panel-grid .g-col-xxl-13{grid-column:auto/span 13}.panel-grid .g-col-xxl-14{grid-column:auto/span 14}.panel-grid .g-col-xxl-15{grid-column:auto/span 15}.panel-grid .g-col-xxl-16{grid-column:auto/span 16}.panel-grid .g-col-xxl-17{grid-column:auto/span 17}.panel-grid .g-col-xxl-18{grid-column:auto/span 18}.panel-grid .g-col-xxl-19{grid-column:auto/span 19}.panel-grid .g-col-xxl-20{grid-column:auto/span 20}.panel-grid .g-col-xxl-21{grid-column:auto/span 21}.panel-grid .g-col-xxl-22{grid-column:auto/span 22}.panel-grid .g-col-xxl-23{grid-column:auto/span 23}.panel-grid .g-col-xxl-24{grid-column:auto/span 24}.panel-grid .g-start-xxl-1{grid-column-start:1}.panel-grid .g-start-xxl-2{grid-column-start:2}.panel-grid .g-start-xxl-3{grid-column-start:3}.panel-grid .g-start-xxl-4{grid-column-start:4}.panel-grid .g-start-xxl-5{grid-column-start:5}.panel-grid .g-start-xxl-6{grid-column-start:6}.panel-grid .g-start-xxl-7{grid-column-start:7}.panel-grid .g-start-xxl-8{grid-column-start:8}.panel-grid .g-start-xxl-9{grid-column-start:9}.panel-grid .g-start-xxl-10{grid-column-start:10}.panel-grid .g-start-xxl-11{grid-column-start:11}.panel-grid .g-start-xxl-12{grid-column-start:12}.panel-grid .g-start-xxl-13{grid-column-start:13}.panel-grid .g-start-xxl-14{grid-column-start:14}.panel-grid .g-start-xxl-15{grid-column-start:15}.panel-grid .g-start-xxl-16{grid-column-start:16}.panel-grid .g-start-xxl-17{grid-column-start:17}.panel-grid .g-start-xxl-18{grid-column-start:18}.panel-grid .g-start-xxl-19{grid-column-start:19}.panel-grid .g-start-xxl-20{grid-column-start:20}.panel-grid .g-start-xxl-21{grid-column-start:21}.panel-grid .g-start-xxl-22{grid-column-start:22}.panel-grid .g-start-xxl-23{grid-column-start:23}}main{margin-top:1em;margin-bottom:1em}h1,.h1,h2,.h2{color:inherit;margin-top:2rem;margin-bottom:1rem;font-weight:600}h1.title,.title.h1{margin-top:0}main.content>p:has(+section){margin-bottom:2rem}main.content>section:first-of-type>h2:nth-child(1),main.content>section:first-of-type>.h2:nth-child(1){margin-top:0}h2,.h2{border-bottom:1px solid rgba(0,0,0,.15);padding-bottom:.5rem}h3,.h3{font-weight:600}h3,.h3,h4,.h4{opacity:.9;margin-top:1.5rem}h5,.h5,h6,.h6{opacity:.9}.header-section-number{color:hsl(0,0%,67.1568627451%)}.nav-link.active .header-section-number{color:inherit}mark,.mark{padding:0em}.panel-caption,.figure-caption,.subfigure-caption,.table-caption,figcaption,caption{font-size:.9rem;color:hsl(0,0%,67.1568627451%)}.quarto-layout-cell[data-ref-parent] caption{color:hsl(0,0%,67.1568627451%)}.column-margin figcaption,.margin-caption,div.aside,aside,.column-margin{color:hsl(0,0%,67.1568627451%);font-size:.825rem}.panel-caption.margin-caption{text-align:inherit}.column-margin.column-container p{margin-bottom:0}.column-margin.column-container>*:not(.collapse):first-child{padding-bottom:.5em;display:block}.column-margin.column-container>*:not(.collapse):not(:first-child){padding-top:.5em;padding-bottom:.5em;display:block}.column-margin.column-container>*.collapse:not(.show){display:none}@media(min-width: 768px){.column-margin.column-container .callout-margin-content:first-child{margin-top:4.5em}.column-margin.column-container .callout-margin-content-simple:first-child{margin-top:3.5em}}.margin-caption>*{padding-top:.5em;padding-bottom:.5em}@media(max-width: 767.98px){.quarto-layout-row{flex-direction:column}}.nav-tabs .nav-item{margin-top:1px;cursor:pointer}.tab-content{margin-top:0px;border-left:rgb(59,76.6,91) 1px solid;border-right:rgb(59,76.6,91) 1px solid;border-bottom:rgb(59,76.6,91) 1px solid;margin-left:0;padding:1em;margin-bottom:1em}@media(max-width: 767.98px){.layout-sidebar{margin-left:0;margin-right:0}}.panel-sidebar,.panel-sidebar .form-control,.panel-input,.panel-input .form-control,.selectize-dropdown{font-size:.9rem}.panel-sidebar .form-control,.panel-input .form-control{padding-top:.1rem}.tab-pane div.sourceCode{margin-top:0px}.tab-pane>p{padding-top:0}.tab-pane>p:nth-child(1){padding-top:0}.tab-pane>p:last-child{margin-bottom:0}.tab-pane>pre:last-child{margin-bottom:0}.tab-content>.tab-pane:not(.active){display:none !important}div.sourceCode{background-color:rgba(59,76.6,91,.65);border:1px solid rgba(59,76.6,91,.65);border-radius:.25rem}pre.sourceCode{background-color:rgba(0,0,0,0)}pre.sourceCode{border:none;font-size:.875em;overflow-y:visible !important;padding:.4em}div.sourceCode{overflow-y:hidden}.callout div.sourceCode{margin-left:initial}.blockquote{font-size:inherit;padding-left:1rem;padding-right:1.5rem;color:hsl(0,0%,67.1568627451%)}.blockquote h1:first-child,.blockquote .h1:first-child,.blockquote h2:first-child,.blockquote .h2:first-child,.blockquote h3:first-child,.blockquote .h3:first-child,.blockquote h4:first-child,.blockquote .h4:first-child,.blockquote h5:first-child,.blockquote .h5:first-child{margin-top:0}pre{background-color:initial;padding:initial;border:initial}p code.sourceCode,li code.sourceCode,td code.sourceCode{background-color:rgba(59,76.6,91,.65)}p pre code:not(.sourceCode),li pre code:not(.sourceCode),pre code:not(.sourceCode){background-color:initial}p code:not(.sourceCode),li code:not(.sourceCode),td code:not(.sourceCode){background-color:rgba(59,76.6,91,.65);padding:.2em}nav p code:not(.sourceCode),nav li code:not(.sourceCode),nav td code:not(.sourceCode){background-color:rgba(0,0,0,0);padding:0}td code:not(.sourceCode){white-space:pre-wrap}#quarto-embedded-source-code-modal>.modal-dialog{max-width:1000px;padding-left:1.75rem;padding-right:1.75rem}#quarto-embedded-source-code-modal>.modal-dialog>.modal-content>.modal-body{padding:0}#quarto-embedded-source-code-modal>.modal-dialog>.modal-content>.modal-body div.sourceCode{margin:0;padding:.2rem .2rem;border-radius:0px;border:none}#quarto-embedded-source-code-modal>.modal-dialog>.modal-content>.modal-header{padding:.7rem}.code-tools-button{font-size:1rem;padding:.15rem .15rem;margin-left:5px;color:#6c757d;background-color:rgba(0,0,0,0);transition:initial;cursor:pointer}.code-tools-button>.bi::before{display:inline-block;height:1rem;width:1rem;content:"";vertical-align:-0.125em;background-image:url('data:image/svg+xml,');background-repeat:no-repeat;background-size:1rem 1rem}.code-tools-button:hover>.bi::before{background-image:url('data:image/svg+xml,')}#quarto-embedded-source-code-modal .code-copy-button>.bi::before{background-image:url('data:image/svg+xml,')}#quarto-embedded-source-code-modal .code-copy-button-checked>.bi::before{background-image:url('data:image/svg+xml,')}.sidebar{will-change:top;transition:top 200ms linear;position:sticky;overflow-y:auto;padding-top:1.2em;max-height:100vh}.sidebar.toc-left,.sidebar.margin-sidebar{top:0px;padding-top:1em}.sidebar.quarto-banner-title-block-sidebar>*{padding-top:1.65em}figure .quarto-notebook-link{margin-top:.5em}.quarto-notebook-link{font-size:.75em;color:#6c757d;margin-bottom:1em;text-decoration:none;display:block}.quarto-notebook-link:hover{text-decoration:underline;color:#df6919}.quarto-notebook-link::before{display:inline-block;height:.75rem;width:.75rem;margin-bottom:0em;margin-right:.25em;content:"";vertical-align:-0.125em;background-image:url('data:image/svg+xml,');background-repeat:no-repeat;background-size:.75rem .75rem}.toc-actions i.bi,.quarto-code-links i.bi,.quarto-other-links i.bi,.quarto-alternate-notebooks i.bi,.quarto-alternate-formats i.bi{margin-right:.4em;font-size:.8rem}.quarto-other-links-text-target .quarto-code-links i.bi,.quarto-other-links-text-target .quarto-other-links i.bi{margin-right:.2em}.quarto-other-formats-text-target .quarto-alternate-formats i.bi{margin-right:.1em}.toc-actions i.bi.empty,.quarto-code-links i.bi.empty,.quarto-other-links i.bi.empty,.quarto-alternate-notebooks i.bi.empty,.quarto-alternate-formats i.bi.empty{padding-left:1em}.quarto-notebook h2,.quarto-notebook .h2{border-bottom:none}.quarto-notebook .cell-container{display:flex}.quarto-notebook .cell-container .cell{flex-grow:4}.quarto-notebook .cell-container .cell-decorator{padding-top:1.5em;padding-right:1em;text-align:right}.quarto-notebook .cell-container.code-fold .cell-decorator{padding-top:3em}.quarto-notebook .cell-code code{white-space:pre-wrap}.quarto-notebook .cell .cell-output-stderr pre code,.quarto-notebook .cell .cell-output-stdout pre code{white-space:pre-wrap;overflow-wrap:anywhere}.toc-actions,.quarto-alternate-formats,.quarto-other-links,.quarto-code-links,.quarto-alternate-notebooks{padding-left:0em}.sidebar .toc-actions a,.sidebar .quarto-alternate-formats a,.sidebar .quarto-other-links a,.sidebar .quarto-code-links a,.sidebar .quarto-alternate-notebooks a,.sidebar nav[role=doc-toc] a{text-decoration:none}.sidebar .toc-actions a:hover,.sidebar .quarto-other-links a:hover,.sidebar .quarto-code-links a:hover,.sidebar .quarto-alternate-formats a:hover,.sidebar .quarto-alternate-notebooks a:hover{color:#df6919}.sidebar .toc-actions h2,.sidebar .toc-actions .h2,.sidebar .quarto-code-links h2,.sidebar .quarto-code-links .h2,.sidebar .quarto-other-links h2,.sidebar .quarto-other-links .h2,.sidebar .quarto-alternate-notebooks h2,.sidebar .quarto-alternate-notebooks .h2,.sidebar .quarto-alternate-formats h2,.sidebar .quarto-alternate-formats .h2,.sidebar nav[role=doc-toc]>h2,.sidebar nav[role=doc-toc]>.h2{font-weight:500;margin-bottom:.2rem;margin-top:.3rem;font-family:inherit;border-bottom:0;padding-bottom:0;padding-top:0px}.sidebar .toc-actions>h2,.sidebar .toc-actions>.h2,.sidebar .quarto-code-links>h2,.sidebar .quarto-code-links>.h2,.sidebar .quarto-other-links>h2,.sidebar .quarto-other-links>.h2,.sidebar .quarto-alternate-notebooks>h2,.sidebar .quarto-alternate-notebooks>.h2,.sidebar .quarto-alternate-formats>h2,.sidebar .quarto-alternate-formats>.h2{font-size:.8rem}.sidebar nav[role=doc-toc]>h2,.sidebar nav[role=doc-toc]>.h2{font-size:.875rem}.sidebar nav[role=doc-toc]>ul a{border-left:1px solid #dee2e6;padding-left:.6rem}.sidebar .toc-actions h2>ul a,.sidebar .toc-actions .h2>ul a,.sidebar .quarto-code-links h2>ul a,.sidebar .quarto-code-links .h2>ul a,.sidebar .quarto-other-links h2>ul a,.sidebar .quarto-other-links .h2>ul a,.sidebar .quarto-alternate-notebooks h2>ul a,.sidebar .quarto-alternate-notebooks .h2>ul a,.sidebar .quarto-alternate-formats h2>ul a,.sidebar .quarto-alternate-formats .h2>ul a{border-left:none;padding-left:.6rem}.sidebar .toc-actions ul a:empty,.sidebar .quarto-code-links ul a:empty,.sidebar .quarto-other-links ul a:empty,.sidebar .quarto-alternate-notebooks ul a:empty,.sidebar .quarto-alternate-formats ul a:empty,.sidebar nav[role=doc-toc]>ul a:empty{display:none}.sidebar .toc-actions ul,.sidebar .quarto-code-links ul,.sidebar .quarto-other-links ul,.sidebar .quarto-alternate-notebooks ul,.sidebar .quarto-alternate-formats ul{padding-left:0;list-style:none}.sidebar nav[role=doc-toc] ul{list-style:none;padding-left:0;list-style:none}.sidebar nav[role=doc-toc]>ul{margin-left:.45em}.quarto-margin-sidebar nav[role=doc-toc]{padding-left:.5em}.sidebar .toc-actions>ul,.sidebar .quarto-code-links>ul,.sidebar .quarto-other-links>ul,.sidebar .quarto-alternate-notebooks>ul,.sidebar .quarto-alternate-formats>ul{font-size:.8rem}.sidebar nav[role=doc-toc]>ul{font-size:.875rem}.sidebar .toc-actions ul li a,.sidebar .quarto-code-links ul li a,.sidebar .quarto-other-links ul li a,.sidebar .quarto-alternate-notebooks ul li a,.sidebar .quarto-alternate-formats ul li a,.sidebar nav[role=doc-toc]>ul li a{line-height:1.1rem;padding-bottom:.2rem;padding-top:.2rem;color:inherit}.sidebar nav[role=doc-toc] ul>li>ul>li>a{padding-left:1.2em}.sidebar nav[role=doc-toc] ul>li>ul>li>ul>li>a{padding-left:2.4em}.sidebar nav[role=doc-toc] ul>li>ul>li>ul>li>ul>li>a{padding-left:3.6em}.sidebar nav[role=doc-toc] ul>li>ul>li>ul>li>ul>li>ul>li>a{padding-left:4.8em}.sidebar nav[role=doc-toc] ul>li>ul>li>ul>li>ul>li>ul>li>ul>li>a{padding-left:6em}.sidebar nav[role=doc-toc] ul>li>a.active,.sidebar nav[role=doc-toc] ul>li>ul>li>a.active{border-left:1px solid #df6919;color:#df6919 !important}.sidebar nav[role=doc-toc] ul>li>a:hover,.sidebar nav[role=doc-toc] ul>li>ul>li>a:hover{color:#df6919 !important}kbd,.kbd{color:#ebebeb;background-color:rgb(70.5,70.5,70.5);border:1px solid;border-radius:5px;border-color:rgba(0,0,0,.15)}.quarto-appendix-contents div.hanging-indent{margin-left:0em}.quarto-appendix-contents div.hanging-indent div.csl-entry{margin-left:1em;text-indent:-1em}.citation a,.footnote-ref{text-decoration:none}.footnotes ol{padding-left:1em}.tippy-content>*{margin-bottom:.7em}.tippy-content>*:last-child{margin-bottom:0}.callout{margin-top:1.25rem;margin-bottom:1.25rem;border-radius:.25rem;overflow-wrap:break-word}.callout .callout-title-container{overflow-wrap:anywhere}.callout.callout-style-simple{padding:.4em .7em;border-left:5px solid;border-right:1px solid rgba(0,0,0,.15);border-top:1px solid rgba(0,0,0,.15);border-bottom:1px solid rgba(0,0,0,.15)}.callout.callout-style-default{border-left:5px solid;border-right:1px solid rgba(0,0,0,.15);border-top:1px solid rgba(0,0,0,.15);border-bottom:1px solid rgba(0,0,0,.15)}.callout .callout-body-container{flex-grow:1}.callout.callout-style-simple .callout-body{font-size:.9rem;font-weight:400;margin-bottom:-0.4em;margin-top:.5em}.callout.callout-style-default .callout-body{font-size:.9rem;font-weight:400}.callout:not(.no-icon).callout-titled.callout-style-simple .callout-body{padding-left:1.6em}.callout.callout-titled>.callout-header{padding-top:.2em;margin-bottom:-0.2em}.callout.callout-empty-content>.callout-header{margin-bottom:0em;border-bottom-right-radius:calc(0.25rem + -1px)}.callout>.callout-header.collapsed{border-bottom-right-radius:calc(0.25rem + -1px)}.callout.callout-style-simple>div.callout-header{border-bottom:none;font-size:.9rem;font-weight:600;opacity:75%}.callout.callout-style-default>div.callout-header{border-bottom:none;font-weight:600;opacity:85%;font-size:.9rem;padding-left:.5em;padding-right:.5em;border-top-right-radius:calc(0.25rem + -1px)}.callout.callout-style-default .callout-body{padding-left:.5em;padding-right:.5em}.callout.callout-style-default .callout-body>:first-child{padding-top:.5rem;margin-top:0}.callout>div.callout-header[data-bs-toggle=collapse]{cursor:pointer}.callout.callout-style-default .callout-header[aria-expanded=false],.callout.callout-style-default .callout-header[aria-expanded=true]{padding-top:0px;margin-bottom:0px;align-items:center}.callout.callout-titled .callout-body>:last-child:not(.sourceCode),.callout.callout-titled .callout-body>div>:last-child:not(.sourceCode){padding-bottom:.5rem;margin-bottom:0}.callout:not(.callout-titled) .callout-body>:first-child,.callout:not(.callout-titled) .callout-body>div>:first-child{margin-top:.25rem}.callout:not(.callout-titled) .callout-body>:last-child,.callout:not(.callout-titled) .callout-body>div>:last-child{margin-bottom:.2rem}.callout.callout-style-simple .callout-icon::before,.callout.callout-style-simple .callout-toggle::before{height:1rem;width:1rem;display:inline-block;content:"";background-repeat:no-repeat;background-size:1rem 1rem}.callout.callout-style-default .callout-icon::before,.callout.callout-style-default .callout-toggle::before{height:.9rem;width:.9rem;display:inline-block;content:"";background-repeat:no-repeat;background-size:.9rem .9rem}.callout.callout-style-default .callout-toggle::before{margin-top:5px}.callout .callout-btn-toggle .callout-toggle::before{transition:transform .2s linear}.callout .callout-header[aria-expanded=false] .callout-toggle::before{transform:rotate(-90deg)}.callout .callout-header[aria-expanded=true] .callout-toggle::before{transform:none}.callout.callout-style-simple:not(.no-icon) div.callout-icon-container{padding-top:.2em;padding-right:.55em}.callout.callout-style-default:not(.no-icon) div.callout-icon-container{padding-top:.1em;padding-right:.35em}.callout.callout-style-default:not(.no-icon) div.callout-title-container{margin-top:-1px}.callout.callout-style-default.callout-caution:not(.no-icon) div.callout-icon-container{padding-top:.3em;padding-right:.35em}.callout>.callout-body>.callout-icon-container>.no-icon,.callout>.callout-header>.callout-icon-container>.no-icon{display:none}div.callout.callout{border-left-color:#6c757d}div.callout.callout-style-default>.callout-header{background-color:#6c757d}div.callout-note.callout{border-left-color:#4c9be8}div.callout-note.callout-style-default>.callout-header{background-color:rgb(22.8,46.5,69.6)}div.callout-note:not(.callout-titled) .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-note.callout-titled .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-note .callout-toggle::before{background-image:url('data:image/svg+xml,')}div.callout-tip.callout{border-left-color:#5cb85c}div.callout-tip.callout-style-default>.callout-header{background-color:rgb(27.6,55.2,27.6)}div.callout-tip:not(.callout-titled) .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-tip.callout-titled .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-tip .callout-toggle::before{background-image:url('data:image/svg+xml,')}div.callout-warning.callout{border-left-color:#ffc107}div.callout-warning.callout-style-default>.callout-header{background-color:rgb(76.5,57.9,2.1)}div.callout-warning:not(.callout-titled) .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-warning.callout-titled .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-warning .callout-toggle::before{background-image:url('data:image/svg+xml,')}div.callout-caution.callout{border-left-color:#df6919}div.callout-caution.callout-style-default>.callout-header{background-color:rgb(66.9,31.5,7.5)}div.callout-caution:not(.callout-titled) .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-caution.callout-titled .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-caution .callout-toggle::before{background-image:url('data:image/svg+xml,')}div.callout-important.callout{border-left-color:#d9534f}div.callout-important.callout-style-default>.callout-header{background-color:rgb(65.1,24.9,23.7)}div.callout-important:not(.callout-titled) .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-important.callout-titled .callout-icon::before{background-image:url('data:image/svg+xml,');}div.callout-important .callout-toggle::before{background-image:url('data:image/svg+xml,')}.quarto-toggle-container{display:flex;align-items:center}.quarto-reader-toggle .bi::before,.quarto-color-scheme-toggle .bi::before{display:inline-block;height:1rem;width:1rem;content:"";background-repeat:no-repeat;background-size:1rem 1rem}.sidebar-navigation{padding-left:20px}.navbar{background-color:rgb(59,76.6,91);color:rgb(229.52,231.808,233.68)}.navbar .quarto-color-scheme-toggle:not(.alternate) .bi::before{background-image:url('data:image/svg+xml,')}.navbar .quarto-color-scheme-toggle.alternate .bi::before{background-image:url('data:image/svg+xml,')}.sidebar-navigation .quarto-color-scheme-toggle:not(.alternate) .bi::before{background-image:url('data:image/svg+xml,')}.sidebar-navigation .quarto-color-scheme-toggle.alternate .bi::before{background-image:url('data:image/svg+xml,')}.quarto-sidebar-toggle{border-color:#dee2e6;border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem;border-style:solid;border-width:1px;overflow:hidden;border-top-width:0px;padding-top:0px !important}.quarto-sidebar-toggle-title{cursor:pointer;padding-bottom:2px;margin-left:.25em;text-align:center;font-weight:400;font-size:.775em}#quarto-content .quarto-sidebar-toggle{background:hsl(207,57.1428571429%,15.7254901961%)}#quarto-content .quarto-sidebar-toggle-title{color:#ebebeb}.quarto-sidebar-toggle-icon{color:#dee2e6;margin-right:.5em;float:right;transition:transform .2s ease}.quarto-sidebar-toggle-icon::before{padding-top:5px}.quarto-sidebar-toggle.expanded .quarto-sidebar-toggle-icon{transform:rotate(-180deg)}.quarto-sidebar-toggle.expanded .quarto-sidebar-toggle-title{border-bottom:solid #dee2e6 1px}.quarto-sidebar-toggle-contents{background-color:#0f2537;padding-right:10px;padding-left:10px;margin-top:0px !important;transition:max-height .5s ease}.quarto-sidebar-toggle.expanded .quarto-sidebar-toggle-contents{padding-top:1em;padding-bottom:10px}@media(max-width: 767.98px){.sidebar-menu-container{padding-bottom:5em}}.quarto-sidebar-toggle:not(.expanded) .quarto-sidebar-toggle-contents{padding-top:0px !important;padding-bottom:0px}nav[role=doc-toc]{z-index:1020}#quarto-sidebar>*,nav[role=doc-toc]>*{transition:opacity .1s ease,border .1s ease}#quarto-sidebar.slow>*,nav[role=doc-toc].slow>*{transition:opacity .4s ease,border .4s ease}.quarto-color-scheme-toggle:not(.alternate).top-right .bi::before{background-image:url('data:image/svg+xml,')}.quarto-color-scheme-toggle.alternate.top-right .bi::before{background-image:url('data:image/svg+xml,')}#quarto-appendix.default{border-top:1px solid #dee2e6}#quarto-appendix.default{background-color:#0f2537;padding-top:1.5em;margin-top:2em;z-index:998}#quarto-appendix.default .quarto-appendix-heading{margin-top:0;line-height:1.4em;font-weight:600;opacity:.9;border-bottom:none;margin-bottom:0}#quarto-appendix.default .footnotes ol,#quarto-appendix.default .footnotes ol li>p:last-of-type,#quarto-appendix.default .quarto-appendix-contents>p:last-of-type{margin-bottom:0}#quarto-appendix.default .footnotes ol{margin-left:.5em}#quarto-appendix.default .quarto-appendix-secondary-label{margin-bottom:.4em}#quarto-appendix.default .quarto-appendix-bibtex{font-size:.7em;padding:1em;border:solid 1px #dee2e6;margin-bottom:1em}#quarto-appendix.default .quarto-appendix-bibtex code.sourceCode{white-space:pre-wrap}#quarto-appendix.default .quarto-appendix-citeas{font-size:.9em;padding:1em;border:solid 1px #dee2e6;margin-bottom:1em}#quarto-appendix.default .quarto-appendix-heading{font-size:1em !important}#quarto-appendix.default *[role=doc-endnotes]>ol,#quarto-appendix.default .quarto-appendix-contents>*:not(h2):not(.h2){font-size:.9em}#quarto-appendix.default section{padding-bottom:1.5em}#quarto-appendix.default section *[role=doc-endnotes],#quarto-appendix.default section>*:not(a){opacity:.9;word-wrap:break-word}.btn.btn-quarto,div.cell-output-display .btn-quarto{--bs-btn-color: rgb(229.52, 231.808, 233.68);--bs-btn-bg: rgb(59, 76.6, 91);--bs-btn-border-color: rgb(59, 76.6, 91);--bs-btn-hover-color: rgb(229.52, 231.808, 233.68);--bs-btn-hover-bg: rgb(88.4, 103.36, 115.6);--bs-btn-hover-border-color: rgb(78.6, 94.44, 107.4);--bs-btn-focus-shadow-rgb: 85, 100, 112;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(98.2, 112.28, 123.8);--bs-btn-active-border-color: rgb(78.6, 94.44, 107.4);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: rgb(59, 76.6, 91);--bs-btn-disabled-border-color: rgb(59, 76.6, 91)}nav.quarto-secondary-nav.color-navbar{background-color:rgb(59,76.6,91);color:rgb(229.52,231.808,233.68)}nav.quarto-secondary-nav.color-navbar h1,nav.quarto-secondary-nav.color-navbar .h1,nav.quarto-secondary-nav.color-navbar .quarto-btn-toggle{color:rgb(229.52,231.808,233.68)}@media(max-width: 991.98px){body.nav-sidebar .quarto-title-banner{margin-bottom:0;padding-bottom:1em}body.nav-sidebar #title-block-header{margin-block-end:0}}p.subtitle{margin-top:.25em;margin-bottom:.5em}code a:any-link{color:inherit;text-decoration-color:#6c757d}/*! dark */div.observablehq table thead tr th{background-color:var(--bs-body-bg)}input,button,select,optgroup,textarea{background-color:var(--bs-body-bg)}.code-annotated .code-copy-button{margin-right:1.25em;margin-top:0;padding-bottom:0;padding-top:3px}.code-annotation-gutter-bg{background-color:#0f2537}.code-annotation-gutter{background-color:rgba(59,76.6,91,.65)}.code-annotation-gutter,.code-annotation-gutter-bg{height:100%;width:calc(20px + .5em);position:absolute;top:0;right:0}dl.code-annotation-container-grid dt{margin-right:1em;margin-top:.25rem}dl.code-annotation-container-grid dt{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;color:rgb(209.5,209.5,209.5);border:solid rgb(209.5,209.5,209.5) 1px;border-radius:50%;height:22px;width:22px;line-height:22px;font-size:11px;text-align:center;vertical-align:middle;text-decoration:none}dl.code-annotation-container-grid dt[data-target-cell]{cursor:pointer}dl.code-annotation-container-grid dt[data-target-cell].code-annotation-active{color:#0f2537;border:solid #aaa 1px;background-color:#aaa}pre.code-annotation-code{padding-top:0;padding-bottom:0}pre.code-annotation-code code{z-index:3}#code-annotation-line-highlight-gutter{width:100%;border-top:solid rgba(170,170,170,.2666666667) 1px;border-bottom:solid rgba(170,170,170,.2666666667) 1px;z-index:2;background-color:rgba(170,170,170,.1333333333)}#code-annotation-line-highlight{margin-left:-4em;width:calc(100% + 4em);border-top:solid rgba(170,170,170,.2666666667) 1px;border-bottom:solid rgba(170,170,170,.2666666667) 1px;z-index:2;background-color:rgba(170,170,170,.1333333333)}code.sourceCode .code-annotation-anchor.code-annotation-active{background-color:var(--quarto-hl-normal-color, #aaaaaa);border:solid var(--quarto-hl-normal-color, #aaaaaa) 1px;color:#3b4d5b;font-weight:bolder}code.sourceCode .code-annotation-anchor{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;color:var(--quarto-hl-co-color);border:solid var(--quarto-hl-co-color) 1px;border-radius:50%;height:18px;width:18px;font-size:9px;margin-top:2px}code.sourceCode button.code-annotation-anchor{padding:2px;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none}code.sourceCode a.code-annotation-anchor{line-height:18px;text-align:center;vertical-align:middle;cursor:default;text-decoration:none}@media print{.page-columns .column-screen-inset{grid-column:page-start-inset/page-end-inset;z-index:998;opacity:.999}.page-columns .column-screen-inset table{background:#0f2537}.page-columns .column-screen-inset-left{grid-column:page-start-inset/body-content-end;z-index:998;opacity:.999}.page-columns .column-screen-inset-left table{background:#0f2537}.page-columns .column-screen-inset-right{grid-column:body-content-start/page-end-inset;z-index:998;opacity:.999}.page-columns .column-screen-inset-right table{background:#0f2537}.page-columns .column-screen{grid-column:page-start/page-end;z-index:998;opacity:.999}.page-columns .column-screen table{background:#0f2537}.page-columns .column-screen-left{grid-column:page-start/body-content-end;z-index:998;opacity:.999}.page-columns .column-screen-left table{background:#0f2537}.page-columns .column-screen-right{grid-column:body-content-start/page-end;z-index:998;opacity:.999}.page-columns .column-screen-right table{background:#0f2537}.page-columns .column-screen-inset-shaded{grid-column:page-start-inset/page-end-inset;padding:1em;background:rgb(129.4,139.96,148.6);z-index:998;opacity:.999;margin-bottom:1em}}.quarto-video{margin-bottom:1em}.table{border-top:1px solid rgb(59,76.6,91);border-bottom:1px solid rgb(59,76.6,91)}.table>thead{border-top-width:0;border-bottom:1px solid #7d8891}.table a{word-break:break-word}.table>:not(caption)>*>*{background-color:unset;color:unset}#quarto-document-content .crosstalk-input .checkbox input[type=checkbox],#quarto-document-content .crosstalk-input .checkbox-inline input[type=checkbox]{position:unset;margin-top:unset;margin-left:unset}#quarto-document-content .row{margin-left:unset;margin-right:unset}.quarto-xref{white-space:nowrap}#quarto-draft-alert{margin-top:0px;margin-bottom:0px;padding:.3em;text-align:center;font-size:.9em}#quarto-draft-alert i{margin-right:.3em}#quarto-back-to-top{z-index:1000}pre{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:0.875em;font-weight:400}pre code{font-family:inherit;font-size:inherit;font-weight:inherit}code{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:0.875em;font-weight:400}a{background-color:rgba(0,0,0,0);font-weight:400;text-decoration:underline}.screen-reader-only{position:absolute;clip:rect(0 0 0 0);border:0;height:1px;margin:-1px;overflow:hidden;padding:0;width:1px}a.external:after{content:"";background-image:url('data:image/svg+xml,');background-size:contain;background-repeat:no-repeat;background-position:center center;margin-left:.2em;padding-right:.75em}div.sourceCode code a.external:after{content:none}a.external:after:hover{cursor:pointer}.quarto-ext-icon{display:inline-block;font-size:.75em;padding-left:.3em}.code-with-filename .code-with-filename-file{margin-bottom:0;padding-bottom:2px;padding-top:2px;padding-left:.7em;border:var(--quarto-border-width) solid var(--quarto-border-color);border-radius:var(--quarto-border-radius);border-bottom:0;border-bottom-left-radius:0%;border-bottom-right-radius:0%}.code-with-filename div.sourceCode,.reveal .code-with-filename div.sourceCode{margin-top:0;border-top-left-radius:0%;border-top-right-radius:0%}.code-with-filename .code-with-filename-file pre{margin-bottom:0}.code-with-filename .code-with-filename-file{background-color:rgba(219,219,219,.8)}.quarto-dark .code-with-filename .code-with-filename-file{background-color:#555}.code-with-filename .code-with-filename-file strong{font-weight:400}.quarto-title-banner{margin-bottom:1em;color:rgb(229.52,231.808,233.68);background:rgb(59,76.6,91)}.quarto-title-banner a{color:rgb(229.52,231.808,233.68)}.quarto-title-banner h1,.quarto-title-banner .h1,.quarto-title-banner h2,.quarto-title-banner .h2{color:rgb(229.52,231.808,233.68)}.quarto-title-banner .code-tools-button{color:hsl(207,8.8888888889%,70.8235294118%)}.quarto-title-banner .code-tools-button:hover{color:rgb(229.52,231.808,233.68)}.quarto-title-banner .code-tools-button>.bi::before{background-image:url('data:image/svg+xml,')}.quarto-title-banner .code-tools-button:hover>.bi::before{background-image:url('data:image/svg+xml,')}.quarto-title-banner .quarto-title .title{font-weight:600}.quarto-title-banner .quarto-categories{margin-top:.75em}@media(min-width: 992px){.quarto-title-banner{padding-top:2.5em;padding-bottom:2.5em}}@media(max-width: 991.98px){.quarto-title-banner{padding-top:1em;padding-bottom:1em}}@media(max-width: 767.98px){body.hypothesis-enabled #title-block-header>*{padding-right:20px}}main.quarto-banner-title-block>section:first-child>h2,main.quarto-banner-title-block>section:first-child>.h2,main.quarto-banner-title-block>section:first-child>h3,main.quarto-banner-title-block>section:first-child>.h3,main.quarto-banner-title-block>section:first-child>h4,main.quarto-banner-title-block>section:first-child>.h4{margin-top:0}.quarto-title .quarto-categories{display:flex;flex-wrap:wrap;row-gap:.5em;column-gap:.4em;padding-bottom:.5em;margin-top:.75em}.quarto-title .quarto-categories .quarto-category{padding:.25em .75em;font-size:.65em;text-transform:uppercase;border:solid 1px;border-radius:.25rem;opacity:.6}.quarto-title .quarto-categories .quarto-category a{color:inherit}.quarto-title-meta-container{display:grid;grid-template-columns:1fr auto}.quarto-title-meta-column-end{display:flex;flex-direction:column;padding-left:1em}.quarto-title-meta-column-end a .bi{margin-right:.3em}#title-block-header.quarto-title-block.default .quarto-title-meta{display:grid;grid-template-columns:repeat(2, 1fr);grid-column-gap:1em}#title-block-header.quarto-title-block.default .quarto-title .title{margin-bottom:0}#title-block-header.quarto-title-block.default .quarto-title-author-orcid img{margin-top:-0.2em;height:.8em;width:.8em}#title-block-header.quarto-title-block.default .quarto-title-author-email{opacity:.7}#title-block-header.quarto-title-block.default .quarto-description p:last-of-type{margin-bottom:0}#title-block-header.quarto-title-block.default .quarto-title-meta-contents p,#title-block-header.quarto-title-block.default .quarto-title-authors p,#title-block-header.quarto-title-block.default .quarto-title-affiliations p{margin-bottom:.1em}#title-block-header.quarto-title-block.default .quarto-title-meta-heading{text-transform:uppercase;margin-top:1em;font-size:.8em;opacity:.8;font-weight:400}#title-block-header.quarto-title-block.default .quarto-title-meta-contents{font-size:.9em}#title-block-header.quarto-title-block.default .quarto-title-meta-contents p.affiliation:last-of-type{margin-bottom:.1em}#title-block-header.quarto-title-block.default p.affiliation{margin-bottom:.1em}#title-block-header.quarto-title-block.default .keywords,#title-block-header.quarto-title-block.default .description,#title-block-header.quarto-title-block.default .abstract{margin-top:0}#title-block-header.quarto-title-block.default .keywords>p,#title-block-header.quarto-title-block.default .description>p,#title-block-header.quarto-title-block.default .abstract>p{font-size:.9em}#title-block-header.quarto-title-block.default .keywords>p:last-of-type,#title-block-header.quarto-title-block.default .description>p:last-of-type,#title-block-header.quarto-title-block.default .abstract>p:last-of-type{margin-bottom:0}#title-block-header.quarto-title-block.default .keywords .block-title,#title-block-header.quarto-title-block.default .description .block-title,#title-block-header.quarto-title-block.default .abstract .block-title{margin-top:1em;text-transform:uppercase;font-size:.8em;opacity:.8;font-weight:400}#title-block-header.quarto-title-block.default .quarto-title-meta-author{display:grid;grid-template-columns:minmax(max-content, 1fr) 1fr;grid-column-gap:1em}.quarto-title-tools-only{display:flex;justify-content:right}.btn-default{background-color:rgb(59,76.6,91)}.btn-primary{background-color:#df6919}.btn-secondary{background-color:rgb(59,76.6,91)}.btn-success{background-color:#5cb85c}.btn-info{background-color:#5bc0de}.btn-warning{background-color:#ffc107}.btn-danger{background-color:#d9534f}.btn-light{background-color:rgb(129.4,139.96,148.6)}.btn-dark{background-color:rgb(59,76.6,91)}.dropdown-menu{font-size:.875rem}.dropdown-header{font-size:.875rem}.blockquote-footer{color:#ebebeb}.table{font-size:.875rem}.table .thead-dark th{color:#fff}.table a:not(.btn){color:#fff;text-decoration:underline}.table .dropdown-menu a{text-decoration:none}.table .text-muted{color:#6c757d}label,.radio label,.checkbox label,.help-block{font-size:.875rem}.form-floating>label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label{color:#adb5bd}.nav-tabs .nav-link,.nav-tabs .nav-link:hover,.nav-pills .nav-link,.nav-pills .nav-link:hover{color:#ebebeb}.nav-tabs .nav-link.disabled,.nav-pills .nav-link.disabled{color:hsla(0,0%,100%,.4)}.page-link:hover,.page-link:focus{color:#fff;text-decoration:none}.alert{color:#fff;border:none}.alert a,.alert .alert-link{color:#fff;text-decoration:underline}.alert-default{background-color:rgb(59,76.6,91)}.alert-primary{background-color:#df6919}.alert-secondary{background-color:rgb(59,76.6,91)}.alert-success{background-color:#5cb85c}.alert-info{background-color:#5bc0de}.alert-warning{background-color:#ffc107}.alert-danger{background-color:#d9534f}.alert-light{background-color:rgb(129.4,139.96,148.6)}.alert-dark{background-color:rgb(59,76.6,91)}.badge-warning,.badge-info{color:#fff}.tooltip{--bs-tooltip-bg: var(--bs-tertiary-bg);--bs-tooltip-color: var(--bs-emphasis-color)}.popover-header{border-top-left-radius:0;border-top-right-radius:0}.modal-header,.modal-footer{background-color:hsla(0,0%,100%,.075)}:root{--quarto-scss-export-gray-300: #dee2e6;--quarto-scss-export-gray-500: #adb5bd;--quarto-scss-export-gray-600: #6c757d;--quarto-scss-export-gray-800: #343a40;--quarto-scss-export-card-cap-bg: rgba(52, 58, 64, 0.25);--quarto-scss-export-border-color: #dee2e6;--quarto-scss-export-text-muted: #6c757d;--quarto-scss-export-white: #fff;--quarto-scss-export-gray-100: #ebebeb;--quarto-scss-export-gray-200: #dee2e6;--quarto-scss-export-gray-400: #adb5bd;--quarto-scss-export-gray-700: #495057;--quarto-scss-export-gray-900: #212529;--quarto-scss-export-black: #000;--quarto-scss-export-blue: #4c9be8;--quarto-scss-export-indigo: #6610f2;--quarto-scss-export-purple: #6f42c1;--quarto-scss-export-pink: #e83e8c;--quarto-scss-export-red: #d9534f;--quarto-scss-export-orange: #df6919;--quarto-scss-export-yellow: #ffc107;--quarto-scss-export-green: #5cb85c;--quarto-scss-export-teal: #20c997;--quarto-scss-export-cyan: #5bc0de;--quarto-scss-export-body-bg: #0f2537;--quarto-scss-export-body-color: #ebebeb;--quarto-scss-export-contrast-bg: #fff;--quarto-scss-export-contrast-fg: #000;--quarto-scss-export-primary: #df6919;--quarto-scss-export-success: #5cb85c;--quarto-scss-export-info: #5bc0de;--quarto-scss-export-warning: #ffc107;--quarto-scss-export-danger: #d9534f;--quarto-scss-export-table-accent-bg: rgba(255, 255, 255, 0.05);--quarto-scss-export-table-hover-bg: rgba(255, 255, 255, 0.075);--quarto-scss-export-table-border-color: rgba(0, 0, 0, 0.15);--quarto-scss-export-table-dark-color: #0f2537;--quarto-scss-export-input-bg: #fff;--quarto-scss-export-input-disabled-bg: #ebebeb;--quarto-scss-export-input-color: #212529;--quarto-scss-export-input-border-color: transparent;--quarto-scss-export-input-placeholder-color: #adb5bd;--quarto-scss-export-input-group-addon-color: #ebebeb;--quarto-scss-export-form-select-disabled-bg: #ebebeb;--quarto-scss-export-form-check-input-bg: #fff;--quarto-scss-export-form-file-button-color: #ebebeb;--quarto-scss-export-form-file-button-hover-bg: rgb(48.97, 63.578, 75.53);--quarto-scss-export-dropdown-divider-bg: rgba(0, 0, 0, 0.15);--quarto-scss-export-dropdown-link-color: #ebebeb;--quarto-scss-export-dropdown-link-hover-color: #ebebeb;--quarto-scss-export-dropdown-link-hover-bg: rgba(255, 255, 255, 0.075);--quarto-scss-export-nav-link-disabled-color: rgba(255, 255, 255, 0.4);--quarto-scss-export-nav-tabs-link-active-color: #ebebeb;--quarto-scss-export-pagination-color: #fff;--quarto-scss-export-pagination-border-color: transparent;--quarto-scss-export-pagination-hover-color: #fff;--quarto-scss-export-pagination-hover-bg: rgba(255, 255, 255, 0.4);--quarto-scss-export-pagination-hover-border-color: transparent;--quarto-scss-export-pagination-disabled-color: rgba(255, 255, 255, 0.4);--quarto-scss-export-pagination-disabled-border-color: transparent;--quarto-scss-export-accordion-button-bg: rgba(52, 58, 64, 0.25);--quarto-scss-export-accordion-button-active-bg: #df6919;--quarto-scss-export-accordion-button-active-color: #ebebeb;--quarto-scss-export-popover-header-bg: rgba(255, 255, 255, 0.075);--quarto-scss-export-toast-border-color: rgba(0, 0, 0, 0.2);--quarto-scss-export-toast-header-color: #ebebeb;--quarto-scss-export-toast-header-border-color: rgba(0, 0, 0, 0.2);--quarto-scss-export-modal-header-border-color: rgba(0, 0, 0, 0.2);--quarto-scss-export-list-group-color: #fff;--quarto-scss-export-list-group-border-color: transparent;--quarto-scss-export-list-group-hover-bg: rgba(255, 255, 255, 0.4);--quarto-scss-export-list-group-disabled-color: rgba(255, 255, 255, 0.4);--quarto-scss-export-list-group-action-color: #fff;--quarto-scss-export-list-group-action-hover-color: #fff;--quarto-scss-export-breadcrumb-divider-color: #ebebeb;--quarto-scss-export-breadcrumb-active-color: #ebebeb;--quarto-scss-export-btn-close-color: #fff;--quarto-scss-export-title-banner-color: ;--quarto-scss-export-title-banner-bg: ;--quarto-scss-export-btn-code-copy-color: #f8f8f2;--quarto-scss-export-btn-code-copy-color-active: #ffa07a;--quarto-scss-export-sidebar-bg: #0f2537;--quarto-scss-export-link-color: #df6919;--quarto-scss-export-link-color-bg: transparent;--quarto-scss-export-code-bg: #ebebeb;--quarto-scss-export-toc-color: #df6919;--quarto-scss-export-toc-active-border: #df6919;--quarto-scss-export-toc-inactive-border: #dee2e6;--quarto-scss-export-navbar-default: #df6919;--quarto-scss-export-navbar-hl-override: false;--quarto-scss-export-navbar-bg: rgb(59, 76.6, 91);--quarto-scss-export-btn-bg: rgb(59, 76.6, 91);--quarto-scss-export-btn-fg: rgb(229.52, 231.808, 233.68);--quarto-scss-export-body-contrast-bg: #0f2537;--quarto-scss-export-body-contrast-color: #ebebeb;--quarto-scss-export-navbar-fg: rgb(229.52, 231.808, 233.68);--quarto-scss-export-navbar-hl: #fff;--quarto-scss-export-navbar-brand: rgb(229.52, 231.808, 233.68);--quarto-scss-export-navbar-brand-hl: #fff;--quarto-scss-export-navbar-toggler-border-color: rgba(229.52, 231.808, 233.68, 0);--quarto-scss-export-navbar-hover-color: rgba(255, 255, 255, 0.8);--quarto-scss-export-navbar-disabled-color: rgba(229.52, 231.808, 233.68, 0.75);--quarto-scss-export-sidebar-fg: rgb(168.6, 176.52, 183);--quarto-scss-export-title-block-color: #ebebeb;--quarto-scss-export-title-block-contast-color: #0f2537;--quarto-scss-export-footer-bg: #0f2537;--quarto-scss-export-footer-fg: rgb(127.8, 139.46, 149);--quarto-scss-export-code-annotation-higlight-color: rgba(170, 170, 170, 0.2666666667);--quarto-scss-export-code-annotation-higlight-bg: rgba(170, 170, 170, 0.1333333333);--quarto-scss-export-table-group-separator-color: #7d8891;--quarto-scss-export-table-group-separator-color-lighter: rgb(59, 76.6, 91);--quarto-scss-export-link-decoration: underline;--quarto-scss-export-sidebar-glass-bg: rgba(102, 102, 102, 0.4);--quarto-scss-export-color-contrast-dark: #000;--quarto-scss-export-color-contrast-light: #fff;--quarto-scss-export-blue-100: rgb(219.2, 235, 250.4);--quarto-scss-export-blue-200: rgb(183.4, 215, 245.8);--quarto-scss-export-blue-300: rgb(147.6, 195, 241.2);--quarto-scss-export-blue-400: rgb(111.8, 175, 236.6);--quarto-scss-export-blue-500: #4c9be8;--quarto-scss-export-blue-600: rgb(60.8, 124, 185.6);--quarto-scss-export-blue-700: rgb(45.6, 93, 139.2);--quarto-scss-export-blue-800: rgb(30.4, 62, 92.8);--quarto-scss-export-blue-900: rgb(15.2, 31, 46.4);--quarto-scss-export-indigo-100: rgb(224.4, 207.2, 252.4);--quarto-scss-export-indigo-200: rgb(193.8, 159.4, 249.8);--quarto-scss-export-indigo-300: rgb(163.2, 111.6, 247.2);--quarto-scss-export-indigo-400: rgb(132.6, 63.8, 244.6);--quarto-scss-export-indigo-500: #6610f2;--quarto-scss-export-indigo-600: rgb(81.6, 12.8, 193.6);--quarto-scss-export-indigo-700: rgb(61.2, 9.6, 145.2);--quarto-scss-export-indigo-800: rgb(40.8, 6.4, 96.8);--quarto-scss-export-indigo-900: rgb(20.4, 3.2, 48.4);--quarto-scss-export-purple-100: rgb(226.2, 217.2, 242.6);--quarto-scss-export-purple-200: rgb(197.4, 179.4, 230.2);--quarto-scss-export-purple-300: rgb(168.6, 141.6, 217.8);--quarto-scss-export-purple-400: rgb(139.8, 103.8, 205.4);--quarto-scss-export-purple-500: #6f42c1;--quarto-scss-export-purple-600: rgb(88.8, 52.8, 154.4);--quarto-scss-export-purple-700: rgb(66.6, 39.6, 115.8);--quarto-scss-export-purple-800: rgb(44.4, 26.4, 77.2);--quarto-scss-export-purple-900: rgb(22.2, 13.2, 38.6);--quarto-scss-export-pink-100: rgb(250.4, 216.4, 232);--quarto-scss-export-pink-200: rgb(245.8, 177.8, 209);--quarto-scss-export-pink-300: rgb(241.2, 139.2, 186);--quarto-scss-export-pink-400: rgb(236.6, 100.6, 163);--quarto-scss-export-pink-500: #e83e8c;--quarto-scss-export-pink-600: rgb(185.6, 49.6, 112);--quarto-scss-export-pink-700: rgb(139.2, 37.2, 84);--quarto-scss-export-pink-800: rgb(92.8, 24.8, 56);--quarto-scss-export-pink-900: rgb(46.4, 12.4, 28);--quarto-scss-export-red-100: rgb(247.4, 220.6, 219.8);--quarto-scss-export-red-200: rgb(239.8, 186.2, 184.6);--quarto-scss-export-red-300: rgb(232.2, 151.8, 149.4);--quarto-scss-export-red-400: rgb(224.6, 117.4, 114.2);--quarto-scss-export-red-500: #d9534f;--quarto-scss-export-red-600: rgb(173.6, 66.4, 63.2);--quarto-scss-export-red-700: rgb(130.2, 49.8, 47.4);--quarto-scss-export-red-800: rgb(86.8, 33.2, 31.6);--quarto-scss-export-red-900: rgb(43.4, 16.6, 15.8);--quarto-scss-export-orange-100: rgb(248.6, 225, 209);--quarto-scss-export-orange-200: rgb(242.2, 195, 163);--quarto-scss-export-orange-300: rgb(235.8, 165, 117);--quarto-scss-export-orange-400: rgb(229.4, 135, 71);--quarto-scss-export-orange-500: #df6919;--quarto-scss-export-orange-600: rgb(178.4, 84, 20);--quarto-scss-export-orange-700: rgb(133.8, 63, 15);--quarto-scss-export-orange-800: rgb(89.2, 42, 10);--quarto-scss-export-orange-900: rgb(44.6, 21, 5);--quarto-scss-export-yellow-100: rgb(255, 242.6, 205.4);--quarto-scss-export-yellow-200: rgb(255, 230.2, 155.8);--quarto-scss-export-yellow-300: rgb(255, 217.8, 106.2);--quarto-scss-export-yellow-400: rgb(255, 205.4, 56.6);--quarto-scss-export-yellow-500: #ffc107;--quarto-scss-export-yellow-600: rgb(204, 154.4, 5.6);--quarto-scss-export-yellow-700: rgb(153, 115.8, 4.2);--quarto-scss-export-yellow-800: rgb(102, 77.2, 2.8);--quarto-scss-export-yellow-900: rgb(51, 38.6, 1.4);--quarto-scss-export-green-100: rgb(222.4, 240.8, 222.4);--quarto-scss-export-green-200: rgb(189.8, 226.6, 189.8);--quarto-scss-export-green-300: rgb(157.2, 212.4, 157.2);--quarto-scss-export-green-400: rgb(124.6, 198.2, 124.6);--quarto-scss-export-green-500: #5cb85c;--quarto-scss-export-green-600: rgb(73.6, 147.2, 73.6);--quarto-scss-export-green-700: rgb(55.2, 110.4, 55.2);--quarto-scss-export-green-800: rgb(36.8, 73.6, 36.8);--quarto-scss-export-green-900: rgb(18.4, 36.8, 18.4);--quarto-scss-export-teal-100: rgb(210.4, 244.2, 234.2);--quarto-scss-export-teal-200: rgb(165.8, 233.4, 213.4);--quarto-scss-export-teal-300: rgb(121.2, 222.6, 192.6);--quarto-scss-export-teal-400: rgb(76.6, 211.8, 171.8);--quarto-scss-export-teal-500: #20c997;--quarto-scss-export-teal-600: rgb(25.6, 160.8, 120.8);--quarto-scss-export-teal-700: rgb(19.2, 120.6, 90.6);--quarto-scss-export-teal-800: rgb(12.8, 80.4, 60.4);--quarto-scss-export-teal-900: rgb(6.4, 40.2, 30.2);--quarto-scss-export-cyan-100: rgb(222.2, 242.4, 248.4);--quarto-scss-export-cyan-200: rgb(189.4, 229.8, 241.8);--quarto-scss-export-cyan-300: rgb(156.6, 217.2, 235.2);--quarto-scss-export-cyan-400: rgb(123.8, 204.6, 228.6);--quarto-scss-export-cyan-500: #5bc0de;--quarto-scss-export-cyan-600: rgb(72.8, 153.6, 177.6);--quarto-scss-export-cyan-700: rgb(54.6, 115.2, 133.2);--quarto-scss-export-cyan-800: rgb(36.4, 76.8, 88.8);--quarto-scss-export-cyan-900: rgb(18.2, 38.4, 44.4);--quarto-scss-export-default: rgb(59, 76.6, 91);--quarto-scss-export-primary-text-emphasis: rgb(89.2, 42, 10);--quarto-scss-export-secondary-text-emphasis: rgb(23.6, 30.64, 36.4);--quarto-scss-export-success-text-emphasis: rgb(36.8, 73.6, 36.8);--quarto-scss-export-info-text-emphasis: rgb(36.4, 76.8, 88.8);--quarto-scss-export-warning-text-emphasis: rgb(102, 77.2, 2.8);--quarto-scss-export-danger-text-emphasis: rgb(86.8, 33.2, 31.6);--quarto-scss-export-light-text-emphasis: #495057;--quarto-scss-export-dark-text-emphasis: #495057;--quarto-scss-export-primary-bg-subtle: rgb(248.6, 225, 209);--quarto-scss-export-secondary-bg-subtle: rgb(215.8, 219.32, 222.2);--quarto-scss-export-success-bg-subtle: rgb(222.4, 240.8, 222.4);--quarto-scss-export-info-bg-subtle: rgb(222.2, 242.4, 248.4);--quarto-scss-export-warning-bg-subtle: rgb(255, 242.6, 205.4);--quarto-scss-export-danger-bg-subtle: rgb(247.4, 220.6, 219.8);--quarto-scss-export-light-bg-subtle: whitesmoke;--quarto-scss-export-dark-bg-subtle: #adb5bd;--quarto-scss-export-primary-border-subtle: rgb(242.2, 195, 163);--quarto-scss-export-secondary-border-subtle: rgb(176.6, 183.64, 189.4);--quarto-scss-export-success-border-subtle: rgb(189.8, 226.6, 189.8);--quarto-scss-export-info-border-subtle: rgb(189.4, 229.8, 241.8);--quarto-scss-export-warning-border-subtle: rgb(255, 230.2, 155.8);--quarto-scss-export-danger-border-subtle: rgb(239.8, 186.2, 184.6);--quarto-scss-export-light-border-subtle: #dee2e6;--quarto-scss-export-dark-border-subtle: #adb5bd;--quarto-scss-export-body-text-align: ;--quarto-scss-export-body-secondary-color: rgba(235, 235, 235, 0.75);--quarto-scss-export-body-secondary-bg: #dee2e6;--quarto-scss-export-body-tertiary-color: rgba(235, 235, 235, 0.5);--quarto-scss-export-body-tertiary-bg: #ebebeb;--quarto-scss-export-body-emphasis-color: #000;--quarto-scss-export-link-hover-color: rgb(178.4, 84, 20);--quarto-scss-export-link-hover-decoration: ;--quarto-scss-export-border-color-translucent: rgba(0, 0, 0, 0.175);--quarto-scss-export-component-active-bg: #df6919;--quarto-scss-export-component-active-color: #fff;--quarto-scss-export-focus-ring-color: rgba(223, 105, 25, 0.25);--quarto-scss-export-headings-font-family: ;--quarto-scss-export-headings-font-style: ;--quarto-scss-export-display-font-family: ;--quarto-scss-export-display-font-style: ;--quarto-scss-export-blockquote-footer-color: #6c757d;--quarto-scss-export-blockquote-border-color: #dee2e6;--quarto-scss-export-hr-bg-color: ;--quarto-scss-export-hr-height: ;--quarto-scss-export-hr-border-color: ;--quarto-scss-export-legend-font-weight: ;--quarto-scss-export-mark-bg: rgb(255, 242.6, 205.4);--quarto-scss-export-table-color: #ebebeb;--quarto-scss-export-table-bg: #0f2537;--quarto-scss-export-table-th-font-weight: ;--quarto-scss-export-table-striped-color: #ebebeb;--quarto-scss-export-table-striped-bg: rgba(0, 0, 0, 0.05);--quarto-scss-export-table-active-color: #ebebeb;--quarto-scss-export-table-active-bg: rgba(0, 0, 0, 0.1);--quarto-scss-export-table-hover-color: #ebebeb;--quarto-scss-export-table-caption-color: rgba(235, 235, 235, 0.75);--quarto-scss-export-input-btn-font-family: ;--quarto-scss-export-input-btn-focus-color: rgba(223, 105, 25, 0.25);--quarto-scss-export-btn-color: #ebebeb;--quarto-scss-export-btn-font-family: ;--quarto-scss-export-btn-white-space: ;--quarto-scss-export-btn-link-color: #df6919;--quarto-scss-export-btn-link-hover-color: rgb(178.4, 84, 20);--quarto-scss-export-btn-link-disabled-color: #6c757d;--quarto-scss-export-form-text-font-style: ;--quarto-scss-export-form-text-font-weight: ;--quarto-scss-export-form-text-color: rgba(235, 235, 235, 0.75);--quarto-scss-export-form-label-font-size: ;--quarto-scss-export-form-label-font-style: ;--quarto-scss-export-form-label-font-weight: ;--quarto-scss-export-form-label-color: ;--quarto-scss-export-input-font-family: ;--quarto-scss-export-input-disabled-border-color: ;--quarto-scss-export-input-focus-bg: #fff;--quarto-scss-export-input-focus-border-color: #efb48c;--quarto-scss-export-input-focus-color: #212529;--quarto-scss-export-input-plaintext-color: #ebebeb;--quarto-scss-export-form-check-label-color: ;--quarto-scss-export-form-check-transition: ;--quarto-scss-export-form-check-input-focus-border: #efb48c;--quarto-scss-export-form-check-input-checked-color: #fff;--quarto-scss-export-form-check-input-checked-bg-color: #df6919;--quarto-scss-export-form-check-input-checked-border-color: #df6919;--quarto-scss-export-form-check-input-indeterminate-color: #fff;--quarto-scss-export-form-check-input-indeterminate-bg-color: #df6919;--quarto-scss-export-form-check-input-indeterminate-border-color: #df6919;--quarto-scss-export-form-switch-color: rgba(0, 0, 0, 0.25);--quarto-scss-export-form-switch-focus-color: #efb48c;--quarto-scss-export-form-switch-checked-color: #fff;--quarto-scss-export-input-group-addon-border-color: transparent;--quarto-scss-export-form-select-font-family: ;--quarto-scss-export-form-select-color: #212529;--quarto-scss-export-form-select-bg: #fff;--quarto-scss-export-form-select-disabled-border-color: ;--quarto-scss-export-form-select-indicator-color: #343a40;--quarto-scss-export-form-select-border-color: transparent;--quarto-scss-export-form-select-focus-border-color: #efb48c;--quarto-scss-export-form-range-track-bg: #ebebeb;--quarto-scss-export-form-range-thumb-bg: #df6919;--quarto-scss-export-form-range-thumb-active-bg: rgb(245.4, 210, 186);--quarto-scss-export-form-range-thumb-disabled-bg: rgba(235, 235, 235, 0.75);--quarto-scss-export-form-floating-label-disabled-color: #6c757d;--quarto-scss-export-form-feedback-font-style: ;--quarto-scss-export-form-feedback-valid-color: #5cb85c;--quarto-scss-export-form-feedback-invalid-color: #d9534f;--quarto-scss-export-form-feedback-icon-valid-color: #5cb85c;--quarto-scss-export-form-feedback-icon-invalid-color: #d9534f;--quarto-scss-export-form-valid-color: #5cb85c;--quarto-scss-export-form-valid-border-color: #5cb85c;--quarto-scss-export-form-invalid-color: #d9534f;--quarto-scss-export-form-invalid-border-color: #d9534f;--quarto-scss-export-nav-link-font-size: ;--quarto-scss-export-nav-link-font-weight: ;--quarto-scss-export-nav-link-color: #df6919;--quarto-scss-export-nav-link-hover-color: rgb(178.4, 84, 20);--quarto-scss-export-nav-tabs-link-hover-border-color: #dee2e6 #dee2e6 rgb(59, 76.6, 91);--quarto-scss-export-nav-tabs-link-active-bg: #0f2537;--quarto-scss-export-nav-pills-link-active-bg: #df6919;--quarto-scss-export-nav-pills-link-active-color: #fff;--quarto-scss-export-nav-underline-link-active-color: #000;--quarto-scss-export-navbar-padding-x: ;--quarto-scss-export-navbar-light-contrast: #fff;--quarto-scss-export-navbar-dark-contrast: #fff;--quarto-scss-export-navbar-light-icon-color: rgba(255, 255, 255, 0.75);--quarto-scss-export-navbar-dark-icon-color: rgba(255, 255, 255, 0.75);--quarto-scss-export-dropdown-color: #ebebeb;--quarto-scss-export-dropdown-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-dropdown-link-active-bg: #df6919;--quarto-scss-export-dropdown-link-active-color: #fff;--quarto-scss-export-dropdown-link-disabled-color: rgba(235, 235, 235, 0.5);--quarto-scss-export-dropdown-header-color: #6c757d;--quarto-scss-export-dropdown-dark-color: #dee2e6;--quarto-scss-export-dropdown-dark-bg: #343a40;--quarto-scss-export-dropdown-dark-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-dropdown-dark-divider-bg: rgba(0, 0, 0, 0.15);--quarto-scss-export-dropdown-dark-box-shadow: ;--quarto-scss-export-dropdown-dark-link-color: #dee2e6;--quarto-scss-export-dropdown-dark-link-hover-color: #fff;--quarto-scss-export-dropdown-dark-link-hover-bg: rgba(255, 255, 255, 0.15);--quarto-scss-export-dropdown-dark-link-active-color: #fff;--quarto-scss-export-dropdown-dark-link-active-bg: #df6919;--quarto-scss-export-dropdown-dark-link-disabled-color: #adb5bd;--quarto-scss-export-dropdown-dark-header-color: #adb5bd;--quarto-scss-export-pagination-focus-color: rgb(178.4, 84, 20);--quarto-scss-export-pagination-focus-bg: #dee2e6;--quarto-scss-export-pagination-active-color: #fff;--quarto-scss-export-pagination-active-bg: #df6919;--quarto-scss-export-pagination-active-border-color: #df6919;--quarto-scss-export-card-title-color: ;--quarto-scss-export-card-subtitle-color: ;--quarto-scss-export-card-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-card-box-shadow: ;--quarto-scss-export-card-cap-color: ;--quarto-scss-export-card-height: ;--quarto-scss-export-card-color: ;--quarto-scss-export-accordion-color: #ebebeb;--quarto-scss-export-accordion-border-color: #dee2e6;--quarto-scss-export-accordion-button-color: #ebebeb;--quarto-scss-export-accordion-button-focus-border-color: #efb48c;--quarto-scss-export-accordion-icon-color: #ebebeb;--quarto-scss-export-accordion-icon-active-color: rgb(89.2, 42, 10);--quarto-scss-export-tooltip-color: #0f2537;--quarto-scss-export-tooltip-bg: #000;--quarto-scss-export-tooltip-margin: ;--quarto-scss-export-tooltip-arrow-color: ;--quarto-scss-export-form-feedback-tooltip-line-height: ;--quarto-scss-export-popover-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-popover-body-color: #ebebeb;--quarto-scss-export-popover-arrow-outer-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-toast-color: ;--quarto-scss-export-badge-color: #fff;--quarto-scss-export-modal-content-color: ;--quarto-scss-export-modal-content-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-modal-backdrop-bg: #000;--quarto-scss-export-modal-footer-bg: ;--quarto-scss-export-modal-footer-border-color: rgba(0, 0, 0, 0.2);--quarto-scss-export-progress-bar-color: #fff;--quarto-scss-export-progress-bar-bg: #df6919;--quarto-scss-export-list-group-active-bg: #df6919;--quarto-scss-export-list-group-active-color: #fff;--quarto-scss-export-list-group-active-border-color: #df6919;--quarto-scss-export-list-group-action-active-color: #ebebeb;--quarto-scss-export-list-group-action-active-bg: #dee2e6;--quarto-scss-export-thumbnail-bg: #0f2537;--quarto-scss-export-thumbnail-border-color: #dee2e6;--quarto-scss-export-figure-caption-color: rgba(235, 235, 235, 0.75);--quarto-scss-export-breadcrumb-font-size: ;--quarto-scss-export-breadcrumb-border-radius: ;--quarto-scss-export-carousel-control-color: #fff;--quarto-scss-export-carousel-indicator-active-bg: #fff;--quarto-scss-export-carousel-caption-color: #fff;--quarto-scss-export-carousel-dark-indicator-active-bg: #000;--quarto-scss-export-carousel-dark-caption-color: #000;--quarto-scss-export-offcanvas-border-color: rgba(0, 0, 0, 0.175);--quarto-scss-export-offcanvas-bg-color: #0f2537;--quarto-scss-export-offcanvas-color: #ebebeb;--quarto-scss-export-offcanvas-backdrop-bg: #000;--quarto-scss-export-code-color-dark: white;--quarto-scss-export-kbd-color: #0f2537;--quarto-scss-export-kbd-bg: #ebebeb;--quarto-scss-export-nested-kbd-font-weight: ;--quarto-scss-export-pre-bg: #ebebeb;--quarto-scss-export-bslib-page-sidebar-title-bg: rgb(59, 76.6, 91);--quarto-scss-export-bslib-page-sidebar-title-color: #fff;--quarto-scss-export-bslib-sidebar-bg: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05);--quarto-scss-export-bslib-sidebar-toggle-bg: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.1);--quarto-scss-export-sidebar-color: rgb(168.6, 176.52, 183);--quarto-scss-export-sidebar-hover-color: rgba(233.88, 156, 103.2, 0.8);--quarto-scss-export-sidebar-disabled-color: rgba(168.6, 176.52, 183, 0.75);--quarto-scss-export-valuebox-bg-primary: #df6919;--quarto-scss-export-valuebox-bg-success: #5cb85c;--quarto-scss-export-valuebox-bg-info: #5bc0de;--quarto-scss-export-valuebox-bg-warning: #ffc107;--quarto-scss-export-valuebox-bg-danger: #d9534f;--quarto-scss-export-mermaid-bg-color: #0f2537;--quarto-scss-export-mermaid-node-fg-color: #ebebeb;--quarto-scss-export-mermaid-fg-color: #ebebeb;--quarto-scss-export-mermaid-fg-color--lighter: white;--quarto-scss-export-mermaid-fg-color--lightest: white;--quarto-scss-export-mermaid-label-bg-color: #0f2537;--quarto-scss-export-mermaid-label-fg-color: #df6919;--quarto-scss-export-mermaid-node-bg-color: rgba(223, 105, 25, 0.1);--quarto-scss-export-code-block-border-left-color: rgba(0, 0, 0, 0.15);--quarto-scss-export-callout-color-note: #4c9be8;--quarto-scss-export-callout-color-tip: #5cb85c;--quarto-scss-export-callout-color-important: #d9534f;--quarto-scss-export-callout-color-caution: #df6919;--quarto-scss-export-callout-color-warning: #ffc107} \ No newline at end of file diff --git a/site_libs/bootstrap/bootstrap-icons.css b/site_libs/bootstrap/bootstrap-icons.css new file mode 100644 index 0000000..82b40f5 --- /dev/null +++ b/site_libs/bootstrap/bootstrap-icons.css @@ -0,0 +1,2106 @@ +/*! + * Bootstrap Icons v1.13.1 (https://icons.getbootstrap.com/) + * Copyright 2019-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) + */ + +@font-face { + font-display: block; + font-family: "bootstrap-icons"; + src: +url("./bootstrap-icons.woff?e34853135f9e39acf64315236852cd5a") format("woff"); +} + +.bi::before, +[class^="bi-"]::before, +[class*=" bi-"]::before { + display: inline-block; + font-family: bootstrap-icons !important; + font-style: normal; + font-weight: normal !important; + font-variant: normal; + text-transform: none; + line-height: 1; + vertical-align: -.125em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.bi-123::before { content: "\f67f"; } +.bi-alarm-fill::before { content: "\f101"; } +.bi-alarm::before { content: "\f102"; } +.bi-align-bottom::before { content: "\f103"; } +.bi-align-center::before { content: "\f104"; } +.bi-align-end::before { content: "\f105"; } +.bi-align-middle::before { content: "\f106"; } +.bi-align-start::before { content: "\f107"; } +.bi-align-top::before { content: "\f108"; } +.bi-alt::before { content: "\f109"; } +.bi-app-indicator::before { content: "\f10a"; } +.bi-app::before { content: "\f10b"; } +.bi-archive-fill::before { content: "\f10c"; } +.bi-archive::before { content: "\f10d"; } +.bi-arrow-90deg-down::before { content: "\f10e"; } +.bi-arrow-90deg-left::before { content: "\f10f"; } +.bi-arrow-90deg-right::before { content: "\f110"; } +.bi-arrow-90deg-up::before { content: "\f111"; } +.bi-arrow-bar-down::before { content: "\f112"; } +.bi-arrow-bar-left::before { content: "\f113"; } +.bi-arrow-bar-right::before { content: "\f114"; } +.bi-arrow-bar-up::before { content: "\f115"; } +.bi-arrow-clockwise::before { content: "\f116"; } +.bi-arrow-counterclockwise::before { content: "\f117"; } +.bi-arrow-down-circle-fill::before { content: "\f118"; } +.bi-arrow-down-circle::before { content: "\f119"; } +.bi-arrow-down-left-circle-fill::before { content: "\f11a"; } +.bi-arrow-down-left-circle::before { content: "\f11b"; } +.bi-arrow-down-left-square-fill::before { content: "\f11c"; } +.bi-arrow-down-left-square::before { content: "\f11d"; } +.bi-arrow-down-left::before { content: "\f11e"; } +.bi-arrow-down-right-circle-fill::before { content: "\f11f"; } +.bi-arrow-down-right-circle::before { content: "\f120"; } +.bi-arrow-down-right-square-fill::before { content: "\f121"; } +.bi-arrow-down-right-square::before { content: "\f122"; } +.bi-arrow-down-right::before { content: "\f123"; } +.bi-arrow-down-short::before { content: "\f124"; } +.bi-arrow-down-square-fill::before { content: "\f125"; } +.bi-arrow-down-square::before { content: "\f126"; } +.bi-arrow-down-up::before { content: "\f127"; } +.bi-arrow-down::before { content: "\f128"; } +.bi-arrow-left-circle-fill::before { content: "\f129"; } +.bi-arrow-left-circle::before { content: "\f12a"; } +.bi-arrow-left-right::before { content: "\f12b"; } +.bi-arrow-left-short::before { content: "\f12c"; } +.bi-arrow-left-square-fill::before { content: "\f12d"; } +.bi-arrow-left-square::before { content: "\f12e"; } +.bi-arrow-left::before { content: "\f12f"; } +.bi-arrow-repeat::before { content: "\f130"; } +.bi-arrow-return-left::before { content: "\f131"; } +.bi-arrow-return-right::before { content: "\f132"; } +.bi-arrow-right-circle-fill::before { content: "\f133"; } +.bi-arrow-right-circle::before { content: "\f134"; } +.bi-arrow-right-short::before { content: "\f135"; } +.bi-arrow-right-square-fill::before { content: "\f136"; } +.bi-arrow-right-square::before { content: "\f137"; } +.bi-arrow-right::before { content: "\f138"; } +.bi-arrow-up-circle-fill::before { content: "\f139"; } +.bi-arrow-up-circle::before { content: "\f13a"; } +.bi-arrow-up-left-circle-fill::before { content: "\f13b"; } +.bi-arrow-up-left-circle::before { content: "\f13c"; } +.bi-arrow-up-left-square-fill::before { content: "\f13d"; } +.bi-arrow-up-left-square::before { content: "\f13e"; } +.bi-arrow-up-left::before { content: "\f13f"; } +.bi-arrow-up-right-circle-fill::before { content: "\f140"; } +.bi-arrow-up-right-circle::before { content: "\f141"; } +.bi-arrow-up-right-square-fill::before { content: "\f142"; } +.bi-arrow-up-right-square::before { content: "\f143"; } +.bi-arrow-up-right::before { content: "\f144"; } +.bi-arrow-up-short::before { content: "\f145"; } +.bi-arrow-up-square-fill::before { content: "\f146"; } +.bi-arrow-up-square::before { content: "\f147"; } +.bi-arrow-up::before { content: "\f148"; } +.bi-arrows-angle-contract::before { content: "\f149"; } +.bi-arrows-angle-expand::before { content: "\f14a"; } +.bi-arrows-collapse::before { content: "\f14b"; } +.bi-arrows-expand::before { content: "\f14c"; } +.bi-arrows-fullscreen::before { content: "\f14d"; } +.bi-arrows-move::before { content: "\f14e"; } +.bi-aspect-ratio-fill::before { content: "\f14f"; } +.bi-aspect-ratio::before { content: "\f150"; } +.bi-asterisk::before { content: "\f151"; } +.bi-at::before { content: "\f152"; } +.bi-award-fill::before { content: "\f153"; } +.bi-award::before { content: "\f154"; } +.bi-back::before { content: "\f155"; } +.bi-backspace-fill::before { content: "\f156"; } +.bi-backspace-reverse-fill::before { content: "\f157"; } +.bi-backspace-reverse::before { content: "\f158"; } +.bi-backspace::before { content: "\f159"; } +.bi-badge-3d-fill::before { content: "\f15a"; } +.bi-badge-3d::before { content: "\f15b"; } +.bi-badge-4k-fill::before { content: "\f15c"; } +.bi-badge-4k::before { content: "\f15d"; } +.bi-badge-8k-fill::before { content: "\f15e"; } +.bi-badge-8k::before { content: "\f15f"; } +.bi-badge-ad-fill::before { content: "\f160"; } +.bi-badge-ad::before { content: "\f161"; } +.bi-badge-ar-fill::before { content: "\f162"; } +.bi-badge-ar::before { content: "\f163"; } +.bi-badge-cc-fill::before { content: "\f164"; } +.bi-badge-cc::before { content: "\f165"; } +.bi-badge-hd-fill::before { content: "\f166"; } +.bi-badge-hd::before { content: "\f167"; } +.bi-badge-tm-fill::before { content: "\f168"; } +.bi-badge-tm::before { content: "\f169"; } +.bi-badge-vo-fill::before { content: "\f16a"; } +.bi-badge-vo::before { content: "\f16b"; } +.bi-badge-vr-fill::before { content: "\f16c"; } +.bi-badge-vr::before { content: "\f16d"; } +.bi-badge-wc-fill::before { content: "\f16e"; } +.bi-badge-wc::before { content: "\f16f"; } +.bi-bag-check-fill::before { content: "\f170"; } +.bi-bag-check::before { content: "\f171"; } +.bi-bag-dash-fill::before { content: "\f172"; } +.bi-bag-dash::before { content: "\f173"; } +.bi-bag-fill::before { content: "\f174"; } +.bi-bag-plus-fill::before { content: "\f175"; } +.bi-bag-plus::before { content: "\f176"; } +.bi-bag-x-fill::before { content: "\f177"; } +.bi-bag-x::before { content: "\f178"; } +.bi-bag::before { content: "\f179"; } +.bi-bar-chart-fill::before { content: "\f17a"; } +.bi-bar-chart-line-fill::before { content: "\f17b"; } +.bi-bar-chart-line::before { content: "\f17c"; } +.bi-bar-chart-steps::before { content: "\f17d"; } +.bi-bar-chart::before { content: "\f17e"; } +.bi-basket-fill::before { content: "\f17f"; } +.bi-basket::before { content: "\f180"; } +.bi-basket2-fill::before { content: "\f181"; } +.bi-basket2::before { content: "\f182"; } +.bi-basket3-fill::before { content: "\f183"; } +.bi-basket3::before { content: "\f184"; } +.bi-battery-charging::before { content: "\f185"; } +.bi-battery-full::before { content: "\f186"; } +.bi-battery-half::before { content: "\f187"; } +.bi-battery::before { content: "\f188"; } +.bi-bell-fill::before { content: "\f189"; } +.bi-bell::before { content: "\f18a"; } +.bi-bezier::before { content: "\f18b"; } +.bi-bezier2::before { content: "\f18c"; } +.bi-bicycle::before { content: "\f18d"; } +.bi-binoculars-fill::before { content: "\f18e"; } +.bi-binoculars::before { content: "\f18f"; } +.bi-blockquote-left::before { content: "\f190"; } +.bi-blockquote-right::before { content: "\f191"; } +.bi-book-fill::before { content: "\f192"; } +.bi-book-half::before { content: "\f193"; } +.bi-book::before { content: "\f194"; } +.bi-bookmark-check-fill::before { content: "\f195"; } +.bi-bookmark-check::before { content: "\f196"; } +.bi-bookmark-dash-fill::before { content: "\f197"; } +.bi-bookmark-dash::before { content: "\f198"; } +.bi-bookmark-fill::before { content: "\f199"; } +.bi-bookmark-heart-fill::before { content: "\f19a"; } +.bi-bookmark-heart::before { content: "\f19b"; } +.bi-bookmark-plus-fill::before { content: "\f19c"; } +.bi-bookmark-plus::before { content: "\f19d"; } +.bi-bookmark-star-fill::before { content: "\f19e"; } +.bi-bookmark-star::before { content: "\f19f"; } +.bi-bookmark-x-fill::before { content: "\f1a0"; } +.bi-bookmark-x::before { content: "\f1a1"; } +.bi-bookmark::before { content: "\f1a2"; } +.bi-bookmarks-fill::before { content: "\f1a3"; } +.bi-bookmarks::before { content: "\f1a4"; } +.bi-bookshelf::before { content: "\f1a5"; } +.bi-bootstrap-fill::before { content: "\f1a6"; } +.bi-bootstrap-reboot::before { content: "\f1a7"; } +.bi-bootstrap::before { content: "\f1a8"; } +.bi-border-all::before { content: "\f1a9"; } +.bi-border-bottom::before { content: "\f1aa"; } +.bi-border-center::before { content: "\f1ab"; } +.bi-border-inner::before { content: "\f1ac"; } +.bi-border-left::before { content: "\f1ad"; } +.bi-border-middle::before { content: "\f1ae"; } +.bi-border-outer::before { content: "\f1af"; } +.bi-border-right::before { content: "\f1b0"; } +.bi-border-style::before { content: "\f1b1"; } +.bi-border-top::before { content: "\f1b2"; } +.bi-border-width::before { content: "\f1b3"; } +.bi-border::before { content: "\f1b4"; } +.bi-bounding-box-circles::before { content: "\f1b5"; } +.bi-bounding-box::before { content: "\f1b6"; } +.bi-box-arrow-down-left::before { content: "\f1b7"; } +.bi-box-arrow-down-right::before { content: "\f1b8"; } +.bi-box-arrow-down::before { content: "\f1b9"; } +.bi-box-arrow-in-down-left::before { content: "\f1ba"; } +.bi-box-arrow-in-down-right::before { content: "\f1bb"; } +.bi-box-arrow-in-down::before { content: "\f1bc"; } +.bi-box-arrow-in-left::before { content: "\f1bd"; } +.bi-box-arrow-in-right::before { content: "\f1be"; } +.bi-box-arrow-in-up-left::before { content: "\f1bf"; } +.bi-box-arrow-in-up-right::before { content: "\f1c0"; } +.bi-box-arrow-in-up::before { content: "\f1c1"; } +.bi-box-arrow-left::before { content: "\f1c2"; } +.bi-box-arrow-right::before { content: "\f1c3"; } +.bi-box-arrow-up-left::before { content: "\f1c4"; } +.bi-box-arrow-up-right::before { content: "\f1c5"; } +.bi-box-arrow-up::before { content: "\f1c6"; } +.bi-box-seam::before { content: "\f1c7"; } +.bi-box::before { content: "\f1c8"; } +.bi-braces::before { content: "\f1c9"; } +.bi-bricks::before { content: "\f1ca"; } +.bi-briefcase-fill::before { content: "\f1cb"; } +.bi-briefcase::before { content: "\f1cc"; } +.bi-brightness-alt-high-fill::before { content: "\f1cd"; } +.bi-brightness-alt-high::before { content: "\f1ce"; } +.bi-brightness-alt-low-fill::before { content: "\f1cf"; } +.bi-brightness-alt-low::before { content: "\f1d0"; } +.bi-brightness-high-fill::before { content: "\f1d1"; } +.bi-brightness-high::before { content: "\f1d2"; } +.bi-brightness-low-fill::before { content: "\f1d3"; } +.bi-brightness-low::before { content: "\f1d4"; } +.bi-broadcast-pin::before { content: "\f1d5"; } +.bi-broadcast::before { content: "\f1d6"; } +.bi-brush-fill::before { content: "\f1d7"; } +.bi-brush::before { content: "\f1d8"; } +.bi-bucket-fill::before { content: "\f1d9"; } +.bi-bucket::before { content: "\f1da"; } +.bi-bug-fill::before { content: "\f1db"; } +.bi-bug::before { content: "\f1dc"; } +.bi-building::before { content: "\f1dd"; } +.bi-bullseye::before { content: "\f1de"; } +.bi-calculator-fill::before { content: "\f1df"; } +.bi-calculator::before { content: "\f1e0"; } +.bi-calendar-check-fill::before { content: "\f1e1"; } +.bi-calendar-check::before { content: "\f1e2"; } +.bi-calendar-date-fill::before { content: "\f1e3"; } +.bi-calendar-date::before { content: "\f1e4"; } +.bi-calendar-day-fill::before { content: "\f1e5"; } +.bi-calendar-day::before { content: "\f1e6"; } +.bi-calendar-event-fill::before { content: "\f1e7"; } +.bi-calendar-event::before { content: "\f1e8"; } +.bi-calendar-fill::before { content: "\f1e9"; } +.bi-calendar-minus-fill::before { content: "\f1ea"; } +.bi-calendar-minus::before { content: "\f1eb"; } +.bi-calendar-month-fill::before { content: "\f1ec"; } +.bi-calendar-month::before { content: "\f1ed"; } +.bi-calendar-plus-fill::before { content: "\f1ee"; } +.bi-calendar-plus::before { content: "\f1ef"; } +.bi-calendar-range-fill::before { content: "\f1f0"; } +.bi-calendar-range::before { content: "\f1f1"; } +.bi-calendar-week-fill::before { content: "\f1f2"; } +.bi-calendar-week::before { content: "\f1f3"; } +.bi-calendar-x-fill::before { content: "\f1f4"; } +.bi-calendar-x::before { content: "\f1f5"; } +.bi-calendar::before { content: "\f1f6"; } +.bi-calendar2-check-fill::before { content: "\f1f7"; } +.bi-calendar2-check::before { content: "\f1f8"; } +.bi-calendar2-date-fill::before { content: "\f1f9"; } +.bi-calendar2-date::before { content: "\f1fa"; } +.bi-calendar2-day-fill::before { content: "\f1fb"; } +.bi-calendar2-day::before { content: "\f1fc"; } +.bi-calendar2-event-fill::before { content: "\f1fd"; } +.bi-calendar2-event::before { content: "\f1fe"; } +.bi-calendar2-fill::before { content: "\f1ff"; } +.bi-calendar2-minus-fill::before { content: "\f200"; } +.bi-calendar2-minus::before { content: "\f201"; } +.bi-calendar2-month-fill::before { content: "\f202"; } +.bi-calendar2-month::before { content: "\f203"; } +.bi-calendar2-plus-fill::before { content: "\f204"; } +.bi-calendar2-plus::before { content: "\f205"; } +.bi-calendar2-range-fill::before { content: "\f206"; } +.bi-calendar2-range::before { content: "\f207"; } +.bi-calendar2-week-fill::before { content: "\f208"; } +.bi-calendar2-week::before { content: "\f209"; } +.bi-calendar2-x-fill::before { content: "\f20a"; } +.bi-calendar2-x::before { content: "\f20b"; } +.bi-calendar2::before { content: "\f20c"; } +.bi-calendar3-event-fill::before { content: "\f20d"; } +.bi-calendar3-event::before { content: "\f20e"; } +.bi-calendar3-fill::before { content: "\f20f"; } +.bi-calendar3-range-fill::before { content: "\f210"; } +.bi-calendar3-range::before { content: "\f211"; } +.bi-calendar3-week-fill::before { content: "\f212"; } +.bi-calendar3-week::before { content: "\f213"; } +.bi-calendar3::before { content: "\f214"; } +.bi-calendar4-event::before { content: "\f215"; } +.bi-calendar4-range::before { content: "\f216"; } +.bi-calendar4-week::before { content: "\f217"; } +.bi-calendar4::before { content: "\f218"; } +.bi-camera-fill::before { content: "\f219"; } +.bi-camera-reels-fill::before { content: "\f21a"; } +.bi-camera-reels::before { content: "\f21b"; } +.bi-camera-video-fill::before { content: "\f21c"; } +.bi-camera-video-off-fill::before { content: "\f21d"; } +.bi-camera-video-off::before { content: "\f21e"; } +.bi-camera-video::before { content: "\f21f"; } +.bi-camera::before { content: "\f220"; } +.bi-camera2::before { content: "\f221"; } +.bi-capslock-fill::before { content: "\f222"; } +.bi-capslock::before { content: "\f223"; } +.bi-card-checklist::before { content: "\f224"; } +.bi-card-heading::before { content: "\f225"; } +.bi-card-image::before { content: "\f226"; } +.bi-card-list::before { content: "\f227"; } +.bi-card-text::before { content: "\f228"; } +.bi-caret-down-fill::before { content: "\f229"; } +.bi-caret-down-square-fill::before { content: "\f22a"; } +.bi-caret-down-square::before { content: "\f22b"; } +.bi-caret-down::before { content: "\f22c"; } +.bi-caret-left-fill::before { content: "\f22d"; } +.bi-caret-left-square-fill::before { content: "\f22e"; } +.bi-caret-left-square::before { content: "\f22f"; } +.bi-caret-left::before { content: "\f230"; } +.bi-caret-right-fill::before { content: "\f231"; } +.bi-caret-right-square-fill::before { content: "\f232"; } +.bi-caret-right-square::before { content: "\f233"; } +.bi-caret-right::before { content: "\f234"; } +.bi-caret-up-fill::before { content: "\f235"; } +.bi-caret-up-square-fill::before { content: "\f236"; } +.bi-caret-up-square::before { content: "\f237"; } +.bi-caret-up::before { content: "\f238"; } +.bi-cart-check-fill::before { content: "\f239"; } +.bi-cart-check::before { content: "\f23a"; } +.bi-cart-dash-fill::before { content: "\f23b"; } +.bi-cart-dash::before { content: "\f23c"; } +.bi-cart-fill::before { content: "\f23d"; } +.bi-cart-plus-fill::before { content: "\f23e"; } +.bi-cart-plus::before { content: "\f23f"; } +.bi-cart-x-fill::before { content: "\f240"; } +.bi-cart-x::before { content: "\f241"; } +.bi-cart::before { content: "\f242"; } +.bi-cart2::before { content: "\f243"; } +.bi-cart3::before { content: "\f244"; } +.bi-cart4::before { content: "\f245"; } +.bi-cash-stack::before { content: "\f246"; } +.bi-cash::before { content: "\f247"; } +.bi-cast::before { content: "\f248"; } +.bi-chat-dots-fill::before { content: "\f249"; } +.bi-chat-dots::before { content: "\f24a"; } +.bi-chat-fill::before { content: "\f24b"; } +.bi-chat-left-dots-fill::before { content: "\f24c"; } +.bi-chat-left-dots::before { content: "\f24d"; } +.bi-chat-left-fill::before { content: "\f24e"; } +.bi-chat-left-quote-fill::before { content: "\f24f"; } +.bi-chat-left-quote::before { content: "\f250"; } +.bi-chat-left-text-fill::before { content: "\f251"; } +.bi-chat-left-text::before { content: "\f252"; } +.bi-chat-left::before { content: "\f253"; } +.bi-chat-quote-fill::before { content: "\f254"; } +.bi-chat-quote::before { content: "\f255"; } +.bi-chat-right-dots-fill::before { content: "\f256"; } +.bi-chat-right-dots::before { content: "\f257"; } +.bi-chat-right-fill::before { content: "\f258"; } +.bi-chat-right-quote-fill::before { content: "\f259"; } +.bi-chat-right-quote::before { content: "\f25a"; } +.bi-chat-right-text-fill::before { content: "\f25b"; } +.bi-chat-right-text::before { content: "\f25c"; } +.bi-chat-right::before { content: "\f25d"; } +.bi-chat-square-dots-fill::before { content: "\f25e"; } +.bi-chat-square-dots::before { content: "\f25f"; } +.bi-chat-square-fill::before { content: "\f260"; } +.bi-chat-square-quote-fill::before { content: "\f261"; } +.bi-chat-square-quote::before { content: "\f262"; } +.bi-chat-square-text-fill::before { content: "\f263"; } +.bi-chat-square-text::before { content: "\f264"; } +.bi-chat-square::before { content: "\f265"; } +.bi-chat-text-fill::before { content: "\f266"; } +.bi-chat-text::before { content: "\f267"; } +.bi-chat::before { content: "\f268"; } +.bi-check-all::before { content: "\f269"; } +.bi-check-circle-fill::before { content: "\f26a"; } +.bi-check-circle::before { content: "\f26b"; } +.bi-check-square-fill::before { content: "\f26c"; } +.bi-check-square::before { content: "\f26d"; } +.bi-check::before { content: "\f26e"; } +.bi-check2-all::before { content: "\f26f"; } +.bi-check2-circle::before { content: "\f270"; } +.bi-check2-square::before { content: "\f271"; } +.bi-check2::before { content: "\f272"; } +.bi-chevron-bar-contract::before { content: "\f273"; } +.bi-chevron-bar-down::before { content: "\f274"; } +.bi-chevron-bar-expand::before { content: "\f275"; } +.bi-chevron-bar-left::before { content: "\f276"; } +.bi-chevron-bar-right::before { content: "\f277"; } +.bi-chevron-bar-up::before { content: "\f278"; } +.bi-chevron-compact-down::before { content: "\f279"; } +.bi-chevron-compact-left::before { content: "\f27a"; } +.bi-chevron-compact-right::before { content: "\f27b"; } +.bi-chevron-compact-up::before { content: "\f27c"; } +.bi-chevron-contract::before { content: "\f27d"; } +.bi-chevron-double-down::before { content: "\f27e"; } +.bi-chevron-double-left::before { content: "\f27f"; } +.bi-chevron-double-right::before { content: "\f280"; } +.bi-chevron-double-up::before { content: "\f281"; } +.bi-chevron-down::before { content: "\f282"; } +.bi-chevron-expand::before { content: "\f283"; } +.bi-chevron-left::before { content: "\f284"; } +.bi-chevron-right::before { content: "\f285"; } +.bi-chevron-up::before { content: "\f286"; } +.bi-circle-fill::before { content: "\f287"; } +.bi-circle-half::before { content: "\f288"; } +.bi-circle-square::before { content: "\f289"; } +.bi-circle::before { content: "\f28a"; } +.bi-clipboard-check::before { content: "\f28b"; } +.bi-clipboard-data::before { content: "\f28c"; } +.bi-clipboard-minus::before { content: "\f28d"; } +.bi-clipboard-plus::before { content: "\f28e"; } +.bi-clipboard-x::before { content: "\f28f"; } +.bi-clipboard::before { content: "\f290"; } +.bi-clock-fill::before { content: "\f291"; } +.bi-clock-history::before { content: "\f292"; } +.bi-clock::before { content: "\f293"; } +.bi-cloud-arrow-down-fill::before { content: "\f294"; } +.bi-cloud-arrow-down::before { content: "\f295"; } +.bi-cloud-arrow-up-fill::before { content: "\f296"; } +.bi-cloud-arrow-up::before { content: "\f297"; } +.bi-cloud-check-fill::before { content: "\f298"; } +.bi-cloud-check::before { content: "\f299"; } +.bi-cloud-download-fill::before { content: "\f29a"; } +.bi-cloud-download::before { content: "\f29b"; } +.bi-cloud-drizzle-fill::before { content: "\f29c"; } +.bi-cloud-drizzle::before { content: "\f29d"; } +.bi-cloud-fill::before { content: "\f29e"; } +.bi-cloud-fog-fill::before { content: "\f29f"; } +.bi-cloud-fog::before { content: "\f2a0"; } +.bi-cloud-fog2-fill::before { content: "\f2a1"; } +.bi-cloud-fog2::before { content: "\f2a2"; } +.bi-cloud-hail-fill::before { content: "\f2a3"; } +.bi-cloud-hail::before { content: "\f2a4"; } +.bi-cloud-haze-fill::before { content: "\f2a6"; } +.bi-cloud-haze::before { content: "\f2a7"; } +.bi-cloud-haze2-fill::before { content: "\f2a8"; } +.bi-cloud-lightning-fill::before { content: "\f2a9"; } +.bi-cloud-lightning-rain-fill::before { content: "\f2aa"; } +.bi-cloud-lightning-rain::before { content: "\f2ab"; } +.bi-cloud-lightning::before { content: "\f2ac"; } +.bi-cloud-minus-fill::before { content: "\f2ad"; } +.bi-cloud-minus::before { content: "\f2ae"; } +.bi-cloud-moon-fill::before { content: "\f2af"; } +.bi-cloud-moon::before { content: "\f2b0"; } +.bi-cloud-plus-fill::before { content: "\f2b1"; } +.bi-cloud-plus::before { content: "\f2b2"; } +.bi-cloud-rain-fill::before { content: "\f2b3"; } +.bi-cloud-rain-heavy-fill::before { content: "\f2b4"; } +.bi-cloud-rain-heavy::before { content: "\f2b5"; } +.bi-cloud-rain::before { content: "\f2b6"; } +.bi-cloud-slash-fill::before { content: "\f2b7"; } +.bi-cloud-slash::before { content: "\f2b8"; } +.bi-cloud-sleet-fill::before { content: "\f2b9"; } +.bi-cloud-sleet::before { content: "\f2ba"; } +.bi-cloud-snow-fill::before { content: "\f2bb"; } +.bi-cloud-snow::before { content: "\f2bc"; } +.bi-cloud-sun-fill::before { content: "\f2bd"; } +.bi-cloud-sun::before { content: "\f2be"; } +.bi-cloud-upload-fill::before { content: "\f2bf"; } +.bi-cloud-upload::before { content: "\f2c0"; } +.bi-cloud::before { content: "\f2c1"; } +.bi-clouds-fill::before { content: "\f2c2"; } +.bi-clouds::before { content: "\f2c3"; } +.bi-cloudy-fill::before { content: "\f2c4"; } +.bi-cloudy::before { content: "\f2c5"; } +.bi-code-slash::before { content: "\f2c6"; } +.bi-code-square::before { content: "\f2c7"; } +.bi-code::before { content: "\f2c8"; } +.bi-collection-fill::before { content: "\f2c9"; } +.bi-collection-play-fill::before { content: "\f2ca"; } +.bi-collection-play::before { content: "\f2cb"; } +.bi-collection::before { content: "\f2cc"; } +.bi-columns-gap::before { content: "\f2cd"; } +.bi-columns::before { content: "\f2ce"; } +.bi-command::before { content: "\f2cf"; } +.bi-compass-fill::before { content: "\f2d0"; } +.bi-compass::before { content: "\f2d1"; } +.bi-cone-striped::before { content: "\f2d2"; } +.bi-cone::before { content: "\f2d3"; } +.bi-controller::before { content: "\f2d4"; } +.bi-cpu-fill::before { content: "\f2d5"; } +.bi-cpu::before { content: "\f2d6"; } +.bi-credit-card-2-back-fill::before { content: "\f2d7"; } +.bi-credit-card-2-back::before { content: "\f2d8"; } +.bi-credit-card-2-front-fill::before { content: "\f2d9"; } +.bi-credit-card-2-front::before { content: "\f2da"; } +.bi-credit-card-fill::before { content: "\f2db"; } +.bi-credit-card::before { content: "\f2dc"; } +.bi-crop::before { content: "\f2dd"; } +.bi-cup-fill::before { content: "\f2de"; } +.bi-cup-straw::before { content: "\f2df"; } +.bi-cup::before { content: "\f2e0"; } +.bi-cursor-fill::before { content: "\f2e1"; } +.bi-cursor-text::before { content: "\f2e2"; } +.bi-cursor::before { content: "\f2e3"; } +.bi-dash-circle-dotted::before { content: "\f2e4"; } +.bi-dash-circle-fill::before { content: "\f2e5"; } +.bi-dash-circle::before { content: "\f2e6"; } +.bi-dash-square-dotted::before { content: "\f2e7"; } +.bi-dash-square-fill::before { content: "\f2e8"; } +.bi-dash-square::before { content: "\f2e9"; } +.bi-dash::before { content: "\f2ea"; } +.bi-diagram-2-fill::before { content: "\f2eb"; } +.bi-diagram-2::before { content: "\f2ec"; } +.bi-diagram-3-fill::before { content: "\f2ed"; } +.bi-diagram-3::before { content: "\f2ee"; } +.bi-diamond-fill::before { content: "\f2ef"; } +.bi-diamond-half::before { content: "\f2f0"; } +.bi-diamond::before { content: "\f2f1"; } +.bi-dice-1-fill::before { content: "\f2f2"; } +.bi-dice-1::before { content: "\f2f3"; } +.bi-dice-2-fill::before { content: "\f2f4"; } +.bi-dice-2::before { content: "\f2f5"; } +.bi-dice-3-fill::before { content: "\f2f6"; } +.bi-dice-3::before { content: "\f2f7"; } +.bi-dice-4-fill::before { content: "\f2f8"; } +.bi-dice-4::before { content: "\f2f9"; } +.bi-dice-5-fill::before { content: "\f2fa"; } +.bi-dice-5::before { content: "\f2fb"; } +.bi-dice-6-fill::before { content: "\f2fc"; } +.bi-dice-6::before { content: "\f2fd"; } +.bi-disc-fill::before { content: "\f2fe"; } +.bi-disc::before { content: "\f2ff"; } +.bi-discord::before { content: "\f300"; } +.bi-display-fill::before { content: "\f301"; } +.bi-display::before { content: "\f302"; } +.bi-distribute-horizontal::before { content: "\f303"; } +.bi-distribute-vertical::before { content: "\f304"; } +.bi-door-closed-fill::before { content: "\f305"; } +.bi-door-closed::before { content: "\f306"; } +.bi-door-open-fill::before { content: "\f307"; } +.bi-door-open::before { content: "\f308"; } +.bi-dot::before { content: "\f309"; } +.bi-download::before { content: "\f30a"; } +.bi-droplet-fill::before { content: "\f30b"; } +.bi-droplet-half::before { content: "\f30c"; } +.bi-droplet::before { content: "\f30d"; } +.bi-earbuds::before { content: "\f30e"; } +.bi-easel-fill::before { content: "\f30f"; } +.bi-easel::before { content: "\f310"; } +.bi-egg-fill::before { content: "\f311"; } +.bi-egg-fried::before { content: "\f312"; } +.bi-egg::before { content: "\f313"; } +.bi-eject-fill::before { content: "\f314"; } +.bi-eject::before { content: "\f315"; } +.bi-emoji-angry-fill::before { content: "\f316"; } +.bi-emoji-angry::before { content: "\f317"; } +.bi-emoji-dizzy-fill::before { content: "\f318"; } +.bi-emoji-dizzy::before { content: "\f319"; } +.bi-emoji-expressionless-fill::before { content: "\f31a"; } +.bi-emoji-expressionless::before { content: "\f31b"; } +.bi-emoji-frown-fill::before { content: "\f31c"; } +.bi-emoji-frown::before { content: "\f31d"; } +.bi-emoji-heart-eyes-fill::before { content: "\f31e"; } +.bi-emoji-heart-eyes::before { content: "\f31f"; } +.bi-emoji-laughing-fill::before { content: "\f320"; } +.bi-emoji-laughing::before { content: "\f321"; } +.bi-emoji-neutral-fill::before { content: "\f322"; } +.bi-emoji-neutral::before { content: "\f323"; } +.bi-emoji-smile-fill::before { content: "\f324"; } +.bi-emoji-smile-upside-down-fill::before { content: "\f325"; } +.bi-emoji-smile-upside-down::before { content: "\f326"; } +.bi-emoji-smile::before { content: "\f327"; } +.bi-emoji-sunglasses-fill::before { content: "\f328"; } +.bi-emoji-sunglasses::before { content: "\f329"; } +.bi-emoji-wink-fill::before { content: "\f32a"; } +.bi-emoji-wink::before { content: "\f32b"; } +.bi-envelope-fill::before { content: "\f32c"; } +.bi-envelope-open-fill::before { content: "\f32d"; } +.bi-envelope-open::before { content: "\f32e"; } +.bi-envelope::before { content: "\f32f"; } +.bi-eraser-fill::before { content: "\f330"; } +.bi-eraser::before { content: "\f331"; } +.bi-exclamation-circle-fill::before { content: "\f332"; } +.bi-exclamation-circle::before { content: "\f333"; } +.bi-exclamation-diamond-fill::before { content: "\f334"; } +.bi-exclamation-diamond::before { content: "\f335"; } +.bi-exclamation-octagon-fill::before { content: "\f336"; } +.bi-exclamation-octagon::before { content: "\f337"; } +.bi-exclamation-square-fill::before { content: "\f338"; } +.bi-exclamation-square::before { content: "\f339"; } +.bi-exclamation-triangle-fill::before { content: "\f33a"; } +.bi-exclamation-triangle::before { content: "\f33b"; } +.bi-exclamation::before { content: "\f33c"; } +.bi-exclude::before { content: "\f33d"; } +.bi-eye-fill::before { content: "\f33e"; } +.bi-eye-slash-fill::before { content: "\f33f"; } +.bi-eye-slash::before { content: "\f340"; } +.bi-eye::before { content: "\f341"; } +.bi-eyedropper::before { content: "\f342"; } +.bi-eyeglasses::before { content: "\f343"; } +.bi-facebook::before { content: "\f344"; } +.bi-file-arrow-down-fill::before { content: "\f345"; } +.bi-file-arrow-down::before { content: "\f346"; } +.bi-file-arrow-up-fill::before { content: "\f347"; } +.bi-file-arrow-up::before { content: "\f348"; } +.bi-file-bar-graph-fill::before { content: "\f349"; } +.bi-file-bar-graph::before { content: "\f34a"; } +.bi-file-binary-fill::before { content: "\f34b"; } +.bi-file-binary::before { content: "\f34c"; } +.bi-file-break-fill::before { content: "\f34d"; } +.bi-file-break::before { content: "\f34e"; } +.bi-file-check-fill::before { content: "\f34f"; } +.bi-file-check::before { content: "\f350"; } +.bi-file-code-fill::before { content: "\f351"; } +.bi-file-code::before { content: "\f352"; } +.bi-file-diff-fill::before { content: "\f353"; } +.bi-file-diff::before { content: "\f354"; } +.bi-file-earmark-arrow-down-fill::before { content: "\f355"; } +.bi-file-earmark-arrow-down::before { content: "\f356"; } +.bi-file-earmark-arrow-up-fill::before { content: "\f357"; } +.bi-file-earmark-arrow-up::before { content: "\f358"; } +.bi-file-earmark-bar-graph-fill::before { content: "\f359"; } +.bi-file-earmark-bar-graph::before { content: "\f35a"; } +.bi-file-earmark-binary-fill::before { content: "\f35b"; } +.bi-file-earmark-binary::before { content: "\f35c"; } +.bi-file-earmark-break-fill::before { content: "\f35d"; } +.bi-file-earmark-break::before { content: "\f35e"; } +.bi-file-earmark-check-fill::before { content: "\f35f"; } +.bi-file-earmark-check::before { content: "\f360"; } +.bi-file-earmark-code-fill::before { content: "\f361"; } +.bi-file-earmark-code::before { content: "\f362"; } +.bi-file-earmark-diff-fill::before { content: "\f363"; } +.bi-file-earmark-diff::before { content: "\f364"; } +.bi-file-earmark-easel-fill::before { content: "\f365"; } +.bi-file-earmark-easel::before { content: "\f366"; } +.bi-file-earmark-excel-fill::before { content: "\f367"; } +.bi-file-earmark-excel::before { content: "\f368"; } +.bi-file-earmark-fill::before { content: "\f369"; } +.bi-file-earmark-font-fill::before { content: "\f36a"; } +.bi-file-earmark-font::before { content: "\f36b"; } +.bi-file-earmark-image-fill::before { content: "\f36c"; } +.bi-file-earmark-image::before { content: "\f36d"; } +.bi-file-earmark-lock-fill::before { content: "\f36e"; } +.bi-file-earmark-lock::before { content: "\f36f"; } +.bi-file-earmark-lock2-fill::before { content: "\f370"; } +.bi-file-earmark-lock2::before { content: "\f371"; } +.bi-file-earmark-medical-fill::before { content: "\f372"; } +.bi-file-earmark-medical::before { content: "\f373"; } +.bi-file-earmark-minus-fill::before { content: "\f374"; } +.bi-file-earmark-minus::before { content: "\f375"; } +.bi-file-earmark-music-fill::before { content: "\f376"; } +.bi-file-earmark-music::before { content: "\f377"; } +.bi-file-earmark-person-fill::before { content: "\f378"; } +.bi-file-earmark-person::before { content: "\f379"; } +.bi-file-earmark-play-fill::before { content: "\f37a"; } +.bi-file-earmark-play::before { content: "\f37b"; } +.bi-file-earmark-plus-fill::before { content: "\f37c"; } +.bi-file-earmark-plus::before { content: "\f37d"; } +.bi-file-earmark-post-fill::before { content: "\f37e"; } +.bi-file-earmark-post::before { content: "\f37f"; } +.bi-file-earmark-ppt-fill::before { content: "\f380"; } +.bi-file-earmark-ppt::before { content: "\f381"; } +.bi-file-earmark-richtext-fill::before { content: "\f382"; } +.bi-file-earmark-richtext::before { content: "\f383"; } +.bi-file-earmark-ruled-fill::before { content: "\f384"; } +.bi-file-earmark-ruled::before { content: "\f385"; } +.bi-file-earmark-slides-fill::before { content: "\f386"; } +.bi-file-earmark-slides::before { content: "\f387"; } +.bi-file-earmark-spreadsheet-fill::before { content: "\f388"; } +.bi-file-earmark-spreadsheet::before { content: "\f389"; } +.bi-file-earmark-text-fill::before { content: "\f38a"; } +.bi-file-earmark-text::before { content: "\f38b"; } +.bi-file-earmark-word-fill::before { content: "\f38c"; } +.bi-file-earmark-word::before { content: "\f38d"; } +.bi-file-earmark-x-fill::before { content: "\f38e"; } +.bi-file-earmark-x::before { content: "\f38f"; } +.bi-file-earmark-zip-fill::before { content: "\f390"; } +.bi-file-earmark-zip::before { content: "\f391"; } +.bi-file-earmark::before { content: "\f392"; } +.bi-file-easel-fill::before { content: "\f393"; } +.bi-file-easel::before { content: "\f394"; } +.bi-file-excel-fill::before { content: "\f395"; } +.bi-file-excel::before { content: "\f396"; } +.bi-file-fill::before { content: "\f397"; } +.bi-file-font-fill::before { content: "\f398"; } +.bi-file-font::before { content: "\f399"; } +.bi-file-image-fill::before { content: "\f39a"; } +.bi-file-image::before { content: "\f39b"; } +.bi-file-lock-fill::before { content: "\f39c"; } +.bi-file-lock::before { content: "\f39d"; } +.bi-file-lock2-fill::before { content: "\f39e"; } +.bi-file-lock2::before { content: "\f39f"; } +.bi-file-medical-fill::before { content: "\f3a0"; } +.bi-file-medical::before { content: "\f3a1"; } +.bi-file-minus-fill::before { content: "\f3a2"; } +.bi-file-minus::before { content: "\f3a3"; } +.bi-file-music-fill::before { content: "\f3a4"; } +.bi-file-music::before { content: "\f3a5"; } +.bi-file-person-fill::before { content: "\f3a6"; } +.bi-file-person::before { content: "\f3a7"; } +.bi-file-play-fill::before { content: "\f3a8"; } +.bi-file-play::before { content: "\f3a9"; } +.bi-file-plus-fill::before { content: "\f3aa"; } +.bi-file-plus::before { content: "\f3ab"; } +.bi-file-post-fill::before { content: "\f3ac"; } +.bi-file-post::before { content: "\f3ad"; } +.bi-file-ppt-fill::before { content: "\f3ae"; } +.bi-file-ppt::before { content: "\f3af"; } +.bi-file-richtext-fill::before { content: "\f3b0"; } +.bi-file-richtext::before { content: "\f3b1"; } +.bi-file-ruled-fill::before { content: "\f3b2"; } +.bi-file-ruled::before { content: "\f3b3"; } +.bi-file-slides-fill::before { content: "\f3b4"; } +.bi-file-slides::before { content: "\f3b5"; } +.bi-file-spreadsheet-fill::before { content: "\f3b6"; } +.bi-file-spreadsheet::before { content: "\f3b7"; } +.bi-file-text-fill::before { content: "\f3b8"; } +.bi-file-text::before { content: "\f3b9"; } +.bi-file-word-fill::before { content: "\f3ba"; } +.bi-file-word::before { content: "\f3bb"; } +.bi-file-x-fill::before { content: "\f3bc"; } +.bi-file-x::before { content: "\f3bd"; } +.bi-file-zip-fill::before { content: "\f3be"; } +.bi-file-zip::before { content: "\f3bf"; } +.bi-file::before { content: "\f3c0"; } +.bi-files-alt::before { content: "\f3c1"; } +.bi-files::before { content: "\f3c2"; } +.bi-film::before { content: "\f3c3"; } +.bi-filter-circle-fill::before { content: "\f3c4"; } +.bi-filter-circle::before { content: "\f3c5"; } +.bi-filter-left::before { content: "\f3c6"; } +.bi-filter-right::before { content: "\f3c7"; } +.bi-filter-square-fill::before { content: "\f3c8"; } +.bi-filter-square::before { content: "\f3c9"; } +.bi-filter::before { content: "\f3ca"; } +.bi-flag-fill::before { content: "\f3cb"; } +.bi-flag::before { content: "\f3cc"; } +.bi-flower1::before { content: "\f3cd"; } +.bi-flower2::before { content: "\f3ce"; } +.bi-flower3::before { content: "\f3cf"; } +.bi-folder-check::before { content: "\f3d0"; } +.bi-folder-fill::before { content: "\f3d1"; } +.bi-folder-minus::before { content: "\f3d2"; } +.bi-folder-plus::before { content: "\f3d3"; } +.bi-folder-symlink-fill::before { content: "\f3d4"; } +.bi-folder-symlink::before { content: "\f3d5"; } +.bi-folder-x::before { content: "\f3d6"; } +.bi-folder::before { content: "\f3d7"; } +.bi-folder2-open::before { content: "\f3d8"; } +.bi-folder2::before { content: "\f3d9"; } +.bi-fonts::before { content: "\f3da"; } +.bi-forward-fill::before { content: "\f3db"; } +.bi-forward::before { content: "\f3dc"; } +.bi-front::before { content: "\f3dd"; } +.bi-fullscreen-exit::before { content: "\f3de"; } +.bi-fullscreen::before { content: "\f3df"; } +.bi-funnel-fill::before { content: "\f3e0"; } +.bi-funnel::before { content: "\f3e1"; } +.bi-gear-fill::before { content: "\f3e2"; } +.bi-gear-wide-connected::before { content: "\f3e3"; } +.bi-gear-wide::before { content: "\f3e4"; } +.bi-gear::before { content: "\f3e5"; } +.bi-gem::before { content: "\f3e6"; } +.bi-geo-alt-fill::before { content: "\f3e7"; } +.bi-geo-alt::before { content: "\f3e8"; } +.bi-geo-fill::before { content: "\f3e9"; } +.bi-geo::before { content: "\f3ea"; } +.bi-gift-fill::before { content: "\f3eb"; } +.bi-gift::before { content: "\f3ec"; } +.bi-github::before { content: "\f3ed"; } +.bi-globe::before { content: "\f3ee"; } +.bi-globe2::before { content: "\f3ef"; } +.bi-google::before { content: "\f3f0"; } +.bi-graph-down::before { content: "\f3f1"; } +.bi-graph-up::before { content: "\f3f2"; } +.bi-grid-1x2-fill::before { content: "\f3f3"; } +.bi-grid-1x2::before { content: "\f3f4"; } +.bi-grid-3x2-gap-fill::before { content: "\f3f5"; } +.bi-grid-3x2-gap::before { content: "\f3f6"; } +.bi-grid-3x2::before { content: "\f3f7"; } +.bi-grid-3x3-gap-fill::before { content: "\f3f8"; } +.bi-grid-3x3-gap::before { content: "\f3f9"; } +.bi-grid-3x3::before { content: "\f3fa"; } +.bi-grid-fill::before { content: "\f3fb"; } +.bi-grid::before { content: "\f3fc"; } +.bi-grip-horizontal::before { content: "\f3fd"; } +.bi-grip-vertical::before { content: "\f3fe"; } +.bi-hammer::before { content: "\f3ff"; } +.bi-hand-index-fill::before { content: "\f400"; } +.bi-hand-index-thumb-fill::before { content: "\f401"; } +.bi-hand-index-thumb::before { content: "\f402"; } +.bi-hand-index::before { content: "\f403"; } +.bi-hand-thumbs-down-fill::before { content: "\f404"; } +.bi-hand-thumbs-down::before { content: "\f405"; } +.bi-hand-thumbs-up-fill::before { content: "\f406"; } +.bi-hand-thumbs-up::before { content: "\f407"; } +.bi-handbag-fill::before { content: "\f408"; } +.bi-handbag::before { content: "\f409"; } +.bi-hash::before { content: "\f40a"; } +.bi-hdd-fill::before { content: "\f40b"; } +.bi-hdd-network-fill::before { content: "\f40c"; } +.bi-hdd-network::before { content: "\f40d"; } +.bi-hdd-rack-fill::before { content: "\f40e"; } +.bi-hdd-rack::before { content: "\f40f"; } +.bi-hdd-stack-fill::before { content: "\f410"; } +.bi-hdd-stack::before { content: "\f411"; } +.bi-hdd::before { content: "\f412"; } +.bi-headphones::before { content: "\f413"; } +.bi-headset::before { content: "\f414"; } +.bi-heart-fill::before { content: "\f415"; } +.bi-heart-half::before { content: "\f416"; } +.bi-heart::before { content: "\f417"; } +.bi-heptagon-fill::before { content: "\f418"; } +.bi-heptagon-half::before { content: "\f419"; } +.bi-heptagon::before { content: "\f41a"; } +.bi-hexagon-fill::before { content: "\f41b"; } +.bi-hexagon-half::before { content: "\f41c"; } +.bi-hexagon::before { content: "\f41d"; } +.bi-hourglass-bottom::before { content: "\f41e"; } +.bi-hourglass-split::before { content: "\f41f"; } +.bi-hourglass-top::before { content: "\f420"; } +.bi-hourglass::before { content: "\f421"; } +.bi-house-door-fill::before { content: "\f422"; } +.bi-house-door::before { content: "\f423"; } +.bi-house-fill::before { content: "\f424"; } +.bi-house::before { content: "\f425"; } +.bi-hr::before { content: "\f426"; } +.bi-hurricane::before { content: "\f427"; } +.bi-image-alt::before { content: "\f428"; } +.bi-image-fill::before { content: "\f429"; } +.bi-image::before { content: "\f42a"; } +.bi-images::before { content: "\f42b"; } +.bi-inbox-fill::before { content: "\f42c"; } +.bi-inbox::before { content: "\f42d"; } +.bi-inboxes-fill::before { content: "\f42e"; } +.bi-inboxes::before { content: "\f42f"; } +.bi-info-circle-fill::before { content: "\f430"; } +.bi-info-circle::before { content: "\f431"; } +.bi-info-square-fill::before { content: "\f432"; } +.bi-info-square::before { content: "\f433"; } +.bi-info::before { content: "\f434"; } +.bi-input-cursor-text::before { content: "\f435"; } +.bi-input-cursor::before { content: "\f436"; } +.bi-instagram::before { content: "\f437"; } +.bi-intersect::before { content: "\f438"; } +.bi-journal-album::before { content: "\f439"; } +.bi-journal-arrow-down::before { content: "\f43a"; } +.bi-journal-arrow-up::before { content: "\f43b"; } +.bi-journal-bookmark-fill::before { content: "\f43c"; } +.bi-journal-bookmark::before { content: "\f43d"; } +.bi-journal-check::before { content: "\f43e"; } +.bi-journal-code::before { content: "\f43f"; } +.bi-journal-medical::before { content: "\f440"; } +.bi-journal-minus::before { content: "\f441"; } +.bi-journal-plus::before { content: "\f442"; } +.bi-journal-richtext::before { content: "\f443"; } +.bi-journal-text::before { content: "\f444"; } +.bi-journal-x::before { content: "\f445"; } +.bi-journal::before { content: "\f446"; } +.bi-journals::before { content: "\f447"; } +.bi-joystick::before { content: "\f448"; } +.bi-justify-left::before { content: "\f449"; } +.bi-justify-right::before { content: "\f44a"; } +.bi-justify::before { content: "\f44b"; } +.bi-kanban-fill::before { content: "\f44c"; } +.bi-kanban::before { content: "\f44d"; } +.bi-key-fill::before { content: "\f44e"; } +.bi-key::before { content: "\f44f"; } +.bi-keyboard-fill::before { content: "\f450"; } +.bi-keyboard::before { content: "\f451"; } +.bi-ladder::before { content: "\f452"; } +.bi-lamp-fill::before { content: "\f453"; } +.bi-lamp::before { content: "\f454"; } +.bi-laptop-fill::before { content: "\f455"; } +.bi-laptop::before { content: "\f456"; } +.bi-layer-backward::before { content: "\f457"; } +.bi-layer-forward::before { content: "\f458"; } +.bi-layers-fill::before { content: "\f459"; } +.bi-layers-half::before { content: "\f45a"; } +.bi-layers::before { content: "\f45b"; } +.bi-layout-sidebar-inset-reverse::before { content: "\f45c"; } +.bi-layout-sidebar-inset::before { content: "\f45d"; } +.bi-layout-sidebar-reverse::before { content: "\f45e"; } +.bi-layout-sidebar::before { content: "\f45f"; } +.bi-layout-split::before { content: "\f460"; } +.bi-layout-text-sidebar-reverse::before { content: "\f461"; } +.bi-layout-text-sidebar::before { content: "\f462"; } +.bi-layout-text-window-reverse::before { content: "\f463"; } +.bi-layout-text-window::before { content: "\f464"; } +.bi-layout-three-columns::before { content: "\f465"; } +.bi-layout-wtf::before { content: "\f466"; } +.bi-life-preserver::before { content: "\f467"; } +.bi-lightbulb-fill::before { content: "\f468"; } +.bi-lightbulb-off-fill::before { content: "\f469"; } +.bi-lightbulb-off::before { content: "\f46a"; } +.bi-lightbulb::before { content: "\f46b"; } +.bi-lightning-charge-fill::before { content: "\f46c"; } +.bi-lightning-charge::before { content: "\f46d"; } +.bi-lightning-fill::before { content: "\f46e"; } +.bi-lightning::before { content: "\f46f"; } +.bi-link-45deg::before { content: "\f470"; } +.bi-link::before { content: "\f471"; } +.bi-linkedin::before { content: "\f472"; } +.bi-list-check::before { content: "\f473"; } +.bi-list-nested::before { content: "\f474"; } +.bi-list-ol::before { content: "\f475"; } +.bi-list-stars::before { content: "\f476"; } +.bi-list-task::before { content: "\f477"; } +.bi-list-ul::before { content: "\f478"; } +.bi-list::before { content: "\f479"; } +.bi-lock-fill::before { content: "\f47a"; } +.bi-lock::before { content: "\f47b"; } +.bi-mailbox::before { content: "\f47c"; } +.bi-mailbox2::before { content: "\f47d"; } +.bi-map-fill::before { content: "\f47e"; } +.bi-map::before { content: "\f47f"; } +.bi-markdown-fill::before { content: "\f480"; } +.bi-markdown::before { content: "\f481"; } +.bi-mask::before { content: "\f482"; } +.bi-megaphone-fill::before { content: "\f483"; } +.bi-megaphone::before { content: "\f484"; } +.bi-menu-app-fill::before { content: "\f485"; } +.bi-menu-app::before { content: "\f486"; } +.bi-menu-button-fill::before { content: "\f487"; } +.bi-menu-button-wide-fill::before { content: "\f488"; } +.bi-menu-button-wide::before { content: "\f489"; } +.bi-menu-button::before { content: "\f48a"; } +.bi-menu-down::before { content: "\f48b"; } +.bi-menu-up::before { content: "\f48c"; } +.bi-mic-fill::before { content: "\f48d"; } +.bi-mic-mute-fill::before { content: "\f48e"; } +.bi-mic-mute::before { content: "\f48f"; } +.bi-mic::before { content: "\f490"; } +.bi-minecart-loaded::before { content: "\f491"; } +.bi-minecart::before { content: "\f492"; } +.bi-moisture::before { content: "\f493"; } +.bi-moon-fill::before { content: "\f494"; } +.bi-moon-stars-fill::before { content: "\f495"; } +.bi-moon-stars::before { content: "\f496"; } +.bi-moon::before { content: "\f497"; } +.bi-mouse-fill::before { content: "\f498"; } +.bi-mouse::before { content: "\f499"; } +.bi-mouse2-fill::before { content: "\f49a"; } +.bi-mouse2::before { content: "\f49b"; } +.bi-mouse3-fill::before { content: "\f49c"; } +.bi-mouse3::before { content: "\f49d"; } +.bi-music-note-beamed::before { content: "\f49e"; } +.bi-music-note-list::before { content: "\f49f"; } +.bi-music-note::before { content: "\f4a0"; } +.bi-music-player-fill::before { content: "\f4a1"; } +.bi-music-player::before { content: "\f4a2"; } +.bi-newspaper::before { content: "\f4a3"; } +.bi-node-minus-fill::before { content: "\f4a4"; } +.bi-node-minus::before { content: "\f4a5"; } +.bi-node-plus-fill::before { content: "\f4a6"; } +.bi-node-plus::before { content: "\f4a7"; } +.bi-nut-fill::before { content: "\f4a8"; } +.bi-nut::before { content: "\f4a9"; } +.bi-octagon-fill::before { content: "\f4aa"; } +.bi-octagon-half::before { content: "\f4ab"; } +.bi-octagon::before { content: "\f4ac"; } +.bi-option::before { content: "\f4ad"; } +.bi-outlet::before { content: "\f4ae"; } +.bi-paint-bucket::before { content: "\f4af"; } +.bi-palette-fill::before { content: "\f4b0"; } +.bi-palette::before { content: "\f4b1"; } +.bi-palette2::before { content: "\f4b2"; } +.bi-paperclip::before { content: "\f4b3"; } +.bi-paragraph::before { content: "\f4b4"; } +.bi-patch-check-fill::before { content: "\f4b5"; } +.bi-patch-check::before { content: "\f4b6"; } +.bi-patch-exclamation-fill::before { content: "\f4b7"; } +.bi-patch-exclamation::before { content: "\f4b8"; } +.bi-patch-minus-fill::before { content: "\f4b9"; } +.bi-patch-minus::before { content: "\f4ba"; } +.bi-patch-plus-fill::before { content: "\f4bb"; } +.bi-patch-plus::before { content: "\f4bc"; } +.bi-patch-question-fill::before { content: "\f4bd"; } +.bi-patch-question::before { content: "\f4be"; } +.bi-pause-btn-fill::before { content: "\f4bf"; } +.bi-pause-btn::before { content: "\f4c0"; } +.bi-pause-circle-fill::before { content: "\f4c1"; } +.bi-pause-circle::before { content: "\f4c2"; } +.bi-pause-fill::before { content: "\f4c3"; } +.bi-pause::before { content: "\f4c4"; } +.bi-peace-fill::before { content: "\f4c5"; } +.bi-peace::before { content: "\f4c6"; } +.bi-pen-fill::before { content: "\f4c7"; } +.bi-pen::before { content: "\f4c8"; } +.bi-pencil-fill::before { content: "\f4c9"; } +.bi-pencil-square::before { content: "\f4ca"; } +.bi-pencil::before { content: "\f4cb"; } +.bi-pentagon-fill::before { content: "\f4cc"; } +.bi-pentagon-half::before { content: "\f4cd"; } +.bi-pentagon::before { content: "\f4ce"; } +.bi-people-fill::before { content: "\f4cf"; } +.bi-people::before { content: "\f4d0"; } +.bi-percent::before { content: "\f4d1"; } +.bi-person-badge-fill::before { content: "\f4d2"; } +.bi-person-badge::before { content: "\f4d3"; } +.bi-person-bounding-box::before { content: "\f4d4"; } +.bi-person-check-fill::before { content: "\f4d5"; } +.bi-person-check::before { content: "\f4d6"; } +.bi-person-circle::before { content: "\f4d7"; } +.bi-person-dash-fill::before { content: "\f4d8"; } +.bi-person-dash::before { content: "\f4d9"; } +.bi-person-fill::before { content: "\f4da"; } +.bi-person-lines-fill::before { content: "\f4db"; } +.bi-person-plus-fill::before { content: "\f4dc"; } +.bi-person-plus::before { content: "\f4dd"; } +.bi-person-square::before { content: "\f4de"; } +.bi-person-x-fill::before { content: "\f4df"; } +.bi-person-x::before { content: "\f4e0"; } +.bi-person::before { content: "\f4e1"; } +.bi-phone-fill::before { content: "\f4e2"; } +.bi-phone-landscape-fill::before { content: "\f4e3"; } +.bi-phone-landscape::before { content: "\f4e4"; } +.bi-phone-vibrate-fill::before { content: "\f4e5"; } +.bi-phone-vibrate::before { content: "\f4e6"; } +.bi-phone::before { content: "\f4e7"; } +.bi-pie-chart-fill::before { content: "\f4e8"; } +.bi-pie-chart::before { content: "\f4e9"; } +.bi-pin-angle-fill::before { content: "\f4ea"; } +.bi-pin-angle::before { content: "\f4eb"; } +.bi-pin-fill::before { content: "\f4ec"; } +.bi-pin::before { content: "\f4ed"; } +.bi-pip-fill::before { content: "\f4ee"; } +.bi-pip::before { content: "\f4ef"; } +.bi-play-btn-fill::before { content: "\f4f0"; } +.bi-play-btn::before { content: "\f4f1"; } +.bi-play-circle-fill::before { content: "\f4f2"; } +.bi-play-circle::before { content: "\f4f3"; } +.bi-play-fill::before { content: "\f4f4"; } +.bi-play::before { content: "\f4f5"; } +.bi-plug-fill::before { content: "\f4f6"; } +.bi-plug::before { content: "\f4f7"; } +.bi-plus-circle-dotted::before { content: "\f4f8"; } +.bi-plus-circle-fill::before { content: "\f4f9"; } +.bi-plus-circle::before { content: "\f4fa"; } +.bi-plus-square-dotted::before { content: "\f4fb"; } +.bi-plus-square-fill::before { content: "\f4fc"; } +.bi-plus-square::before { content: "\f4fd"; } +.bi-plus::before { content: "\f4fe"; } +.bi-power::before { content: "\f4ff"; } +.bi-printer-fill::before { content: "\f500"; } +.bi-printer::before { content: "\f501"; } +.bi-puzzle-fill::before { content: "\f502"; } +.bi-puzzle::before { content: "\f503"; } +.bi-question-circle-fill::before { content: "\f504"; } +.bi-question-circle::before { content: "\f505"; } +.bi-question-diamond-fill::before { content: "\f506"; } +.bi-question-diamond::before { content: "\f507"; } +.bi-question-octagon-fill::before { content: "\f508"; } +.bi-question-octagon::before { content: "\f509"; } +.bi-question-square-fill::before { content: "\f50a"; } +.bi-question-square::before { content: "\f50b"; } +.bi-question::before { content: "\f50c"; } +.bi-rainbow::before { content: "\f50d"; } +.bi-receipt-cutoff::before { content: "\f50e"; } +.bi-receipt::before { content: "\f50f"; } +.bi-reception-0::before { content: "\f510"; } +.bi-reception-1::before { content: "\f511"; } +.bi-reception-2::before { content: "\f512"; } +.bi-reception-3::before { content: "\f513"; } +.bi-reception-4::before { content: "\f514"; } +.bi-record-btn-fill::before { content: "\f515"; } +.bi-record-btn::before { content: "\f516"; } +.bi-record-circle-fill::before { content: "\f517"; } +.bi-record-circle::before { content: "\f518"; } +.bi-record-fill::before { content: "\f519"; } +.bi-record::before { content: "\f51a"; } +.bi-record2-fill::before { content: "\f51b"; } +.bi-record2::before { content: "\f51c"; } +.bi-reply-all-fill::before { content: "\f51d"; } +.bi-reply-all::before { content: "\f51e"; } +.bi-reply-fill::before { content: "\f51f"; } +.bi-reply::before { content: "\f520"; } +.bi-rss-fill::before { content: "\f521"; } +.bi-rss::before { content: "\f522"; } +.bi-rulers::before { content: "\f523"; } +.bi-save-fill::before { content: "\f524"; } +.bi-save::before { content: "\f525"; } +.bi-save2-fill::before { content: "\f526"; } +.bi-save2::before { content: "\f527"; } +.bi-scissors::before { content: "\f528"; } +.bi-screwdriver::before { content: "\f529"; } +.bi-search::before { content: "\f52a"; } +.bi-segmented-nav::before { content: "\f52b"; } +.bi-server::before { content: "\f52c"; } +.bi-share-fill::before { content: "\f52d"; } +.bi-share::before { content: "\f52e"; } +.bi-shield-check::before { content: "\f52f"; } +.bi-shield-exclamation::before { content: "\f530"; } +.bi-shield-fill-check::before { content: "\f531"; } +.bi-shield-fill-exclamation::before { content: "\f532"; } +.bi-shield-fill-minus::before { content: "\f533"; } +.bi-shield-fill-plus::before { content: "\f534"; } +.bi-shield-fill-x::before { content: "\f535"; } +.bi-shield-fill::before { content: "\f536"; } +.bi-shield-lock-fill::before { content: "\f537"; } +.bi-shield-lock::before { content: "\f538"; } +.bi-shield-minus::before { content: "\f539"; } +.bi-shield-plus::before { content: "\f53a"; } +.bi-shield-shaded::before { content: "\f53b"; } +.bi-shield-slash-fill::before { content: "\f53c"; } +.bi-shield-slash::before { content: "\f53d"; } +.bi-shield-x::before { content: "\f53e"; } +.bi-shield::before { content: "\f53f"; } +.bi-shift-fill::before { content: "\f540"; } +.bi-shift::before { content: "\f541"; } +.bi-shop-window::before { content: "\f542"; } +.bi-shop::before { content: "\f543"; } +.bi-shuffle::before { content: "\f544"; } +.bi-signpost-2-fill::before { content: "\f545"; } +.bi-signpost-2::before { content: "\f546"; } +.bi-signpost-fill::before { content: "\f547"; } +.bi-signpost-split-fill::before { content: "\f548"; } +.bi-signpost-split::before { content: "\f549"; } +.bi-signpost::before { content: "\f54a"; } +.bi-sim-fill::before { content: "\f54b"; } +.bi-sim::before { content: "\f54c"; } +.bi-skip-backward-btn-fill::before { content: "\f54d"; } +.bi-skip-backward-btn::before { content: "\f54e"; } +.bi-skip-backward-circle-fill::before { content: "\f54f"; } +.bi-skip-backward-circle::before { content: "\f550"; } +.bi-skip-backward-fill::before { content: "\f551"; } +.bi-skip-backward::before { content: "\f552"; } +.bi-skip-end-btn-fill::before { content: "\f553"; } +.bi-skip-end-btn::before { content: "\f554"; } +.bi-skip-end-circle-fill::before { content: "\f555"; } +.bi-skip-end-circle::before { content: "\f556"; } +.bi-skip-end-fill::before { content: "\f557"; } +.bi-skip-end::before { content: "\f558"; } +.bi-skip-forward-btn-fill::before { content: "\f559"; } +.bi-skip-forward-btn::before { content: "\f55a"; } +.bi-skip-forward-circle-fill::before { content: "\f55b"; } +.bi-skip-forward-circle::before { content: "\f55c"; } +.bi-skip-forward-fill::before { content: "\f55d"; } +.bi-skip-forward::before { content: "\f55e"; } +.bi-skip-start-btn-fill::before { content: "\f55f"; } +.bi-skip-start-btn::before { content: "\f560"; } +.bi-skip-start-circle-fill::before { content: "\f561"; } +.bi-skip-start-circle::before { content: "\f562"; } +.bi-skip-start-fill::before { content: "\f563"; } +.bi-skip-start::before { content: "\f564"; } +.bi-slack::before { content: "\f565"; } +.bi-slash-circle-fill::before { content: "\f566"; } +.bi-slash-circle::before { content: "\f567"; } +.bi-slash-square-fill::before { content: "\f568"; } +.bi-slash-square::before { content: "\f569"; } +.bi-slash::before { content: "\f56a"; } +.bi-sliders::before { content: "\f56b"; } +.bi-smartwatch::before { content: "\f56c"; } +.bi-snow::before { content: "\f56d"; } +.bi-snow2::before { content: "\f56e"; } +.bi-snow3::before { content: "\f56f"; } +.bi-sort-alpha-down-alt::before { content: "\f570"; } +.bi-sort-alpha-down::before { content: "\f571"; } +.bi-sort-alpha-up-alt::before { content: "\f572"; } +.bi-sort-alpha-up::before { content: "\f573"; } +.bi-sort-down-alt::before { content: "\f574"; } +.bi-sort-down::before { content: "\f575"; } +.bi-sort-numeric-down-alt::before { content: "\f576"; } +.bi-sort-numeric-down::before { content: "\f577"; } +.bi-sort-numeric-up-alt::before { content: "\f578"; } +.bi-sort-numeric-up::before { content: "\f579"; } +.bi-sort-up-alt::before { content: "\f57a"; } +.bi-sort-up::before { content: "\f57b"; } +.bi-soundwave::before { content: "\f57c"; } +.bi-speaker-fill::before { content: "\f57d"; } +.bi-speaker::before { content: "\f57e"; } +.bi-speedometer::before { content: "\f57f"; } +.bi-speedometer2::before { content: "\f580"; } +.bi-spellcheck::before { content: "\f581"; } +.bi-square-fill::before { content: "\f582"; } +.bi-square-half::before { content: "\f583"; } +.bi-square::before { content: "\f584"; } +.bi-stack::before { content: "\f585"; } +.bi-star-fill::before { content: "\f586"; } +.bi-star-half::before { content: "\f587"; } +.bi-star::before { content: "\f588"; } +.bi-stars::before { content: "\f589"; } +.bi-stickies-fill::before { content: "\f58a"; } +.bi-stickies::before { content: "\f58b"; } +.bi-sticky-fill::before { content: "\f58c"; } +.bi-sticky::before { content: "\f58d"; } +.bi-stop-btn-fill::before { content: "\f58e"; } +.bi-stop-btn::before { content: "\f58f"; } +.bi-stop-circle-fill::before { content: "\f590"; } +.bi-stop-circle::before { content: "\f591"; } +.bi-stop-fill::before { content: "\f592"; } +.bi-stop::before { content: "\f593"; } +.bi-stoplights-fill::before { content: "\f594"; } +.bi-stoplights::before { content: "\f595"; } +.bi-stopwatch-fill::before { content: "\f596"; } +.bi-stopwatch::before { content: "\f597"; } +.bi-subtract::before { content: "\f598"; } +.bi-suit-club-fill::before { content: "\f599"; } +.bi-suit-club::before { content: "\f59a"; } +.bi-suit-diamond-fill::before { content: "\f59b"; } +.bi-suit-diamond::before { content: "\f59c"; } +.bi-suit-heart-fill::before { content: "\f59d"; } +.bi-suit-heart::before { content: "\f59e"; } +.bi-suit-spade-fill::before { content: "\f59f"; } +.bi-suit-spade::before { content: "\f5a0"; } +.bi-sun-fill::before { content: "\f5a1"; } +.bi-sun::before { content: "\f5a2"; } +.bi-sunglasses::before { content: "\f5a3"; } +.bi-sunrise-fill::before { content: "\f5a4"; } +.bi-sunrise::before { content: "\f5a5"; } +.bi-sunset-fill::before { content: "\f5a6"; } +.bi-sunset::before { content: "\f5a7"; } +.bi-symmetry-horizontal::before { content: "\f5a8"; } +.bi-symmetry-vertical::before { content: "\f5a9"; } +.bi-table::before { content: "\f5aa"; } +.bi-tablet-fill::before { content: "\f5ab"; } +.bi-tablet-landscape-fill::before { content: "\f5ac"; } +.bi-tablet-landscape::before { content: "\f5ad"; } +.bi-tablet::before { content: "\f5ae"; } +.bi-tag-fill::before { content: "\f5af"; } +.bi-tag::before { content: "\f5b0"; } +.bi-tags-fill::before { content: "\f5b1"; } +.bi-tags::before { content: "\f5b2"; } +.bi-telegram::before { content: "\f5b3"; } +.bi-telephone-fill::before { content: "\f5b4"; } +.bi-telephone-forward-fill::before { content: "\f5b5"; } +.bi-telephone-forward::before { content: "\f5b6"; } +.bi-telephone-inbound-fill::before { content: "\f5b7"; } +.bi-telephone-inbound::before { content: "\f5b8"; } +.bi-telephone-minus-fill::before { content: "\f5b9"; } +.bi-telephone-minus::before { content: "\f5ba"; } +.bi-telephone-outbound-fill::before { content: "\f5bb"; } +.bi-telephone-outbound::before { content: "\f5bc"; } +.bi-telephone-plus-fill::before { content: "\f5bd"; } +.bi-telephone-plus::before { content: "\f5be"; } +.bi-telephone-x-fill::before { content: "\f5bf"; } +.bi-telephone-x::before { content: "\f5c0"; } +.bi-telephone::before { content: "\f5c1"; } +.bi-terminal-fill::before { content: "\f5c2"; } +.bi-terminal::before { content: "\f5c3"; } +.bi-text-center::before { content: "\f5c4"; } +.bi-text-indent-left::before { content: "\f5c5"; } +.bi-text-indent-right::before { content: "\f5c6"; } +.bi-text-left::before { content: "\f5c7"; } +.bi-text-paragraph::before { content: "\f5c8"; } +.bi-text-right::before { content: "\f5c9"; } +.bi-textarea-resize::before { content: "\f5ca"; } +.bi-textarea-t::before { content: "\f5cb"; } +.bi-textarea::before { content: "\f5cc"; } +.bi-thermometer-half::before { content: "\f5cd"; } +.bi-thermometer-high::before { content: "\f5ce"; } +.bi-thermometer-low::before { content: "\f5cf"; } +.bi-thermometer-snow::before { content: "\f5d0"; } +.bi-thermometer-sun::before { content: "\f5d1"; } +.bi-thermometer::before { content: "\f5d2"; } +.bi-three-dots-vertical::before { content: "\f5d3"; } +.bi-three-dots::before { content: "\f5d4"; } +.bi-toggle-off::before { content: "\f5d5"; } +.bi-toggle-on::before { content: "\f5d6"; } +.bi-toggle2-off::before { content: "\f5d7"; } +.bi-toggle2-on::before { content: "\f5d8"; } +.bi-toggles::before { content: "\f5d9"; } +.bi-toggles2::before { content: "\f5da"; } +.bi-tools::before { content: "\f5db"; } +.bi-tornado::before { content: "\f5dc"; } +.bi-trash-fill::before { content: "\f5dd"; } +.bi-trash::before { content: "\f5de"; } +.bi-trash2-fill::before { content: "\f5df"; } +.bi-trash2::before { content: "\f5e0"; } +.bi-tree-fill::before { content: "\f5e1"; } +.bi-tree::before { content: "\f5e2"; } +.bi-triangle-fill::before { content: "\f5e3"; } +.bi-triangle-half::before { content: "\f5e4"; } +.bi-triangle::before { content: "\f5e5"; } +.bi-trophy-fill::before { content: "\f5e6"; } +.bi-trophy::before { content: "\f5e7"; } +.bi-tropical-storm::before { content: "\f5e8"; } +.bi-truck-flatbed::before { content: "\f5e9"; } +.bi-truck::before { content: "\f5ea"; } +.bi-tsunami::before { content: "\f5eb"; } +.bi-tv-fill::before { content: "\f5ec"; } +.bi-tv::before { content: "\f5ed"; } +.bi-twitch::before { content: "\f5ee"; } +.bi-twitter::before { content: "\f5ef"; } +.bi-type-bold::before { content: "\f5f0"; } +.bi-type-h1::before { content: "\f5f1"; } +.bi-type-h2::before { content: "\f5f2"; } +.bi-type-h3::before { content: "\f5f3"; } +.bi-type-italic::before { content: "\f5f4"; } +.bi-type-strikethrough::before { content: "\f5f5"; } +.bi-type-underline::before { content: "\f5f6"; } +.bi-type::before { content: "\f5f7"; } +.bi-ui-checks-grid::before { content: "\f5f8"; } +.bi-ui-checks::before { content: "\f5f9"; } +.bi-ui-radios-grid::before { content: "\f5fa"; } +.bi-ui-radios::before { content: "\f5fb"; } +.bi-umbrella-fill::before { content: "\f5fc"; } +.bi-umbrella::before { content: "\f5fd"; } +.bi-union::before { content: "\f5fe"; } +.bi-unlock-fill::before { content: "\f5ff"; } +.bi-unlock::before { content: "\f600"; } +.bi-upc-scan::before { content: "\f601"; } +.bi-upc::before { content: "\f602"; } +.bi-upload::before { content: "\f603"; } +.bi-vector-pen::before { content: "\f604"; } +.bi-view-list::before { content: "\f605"; } +.bi-view-stacked::before { content: "\f606"; } +.bi-vinyl-fill::before { content: "\f607"; } +.bi-vinyl::before { content: "\f608"; } +.bi-voicemail::before { content: "\f609"; } +.bi-volume-down-fill::before { content: "\f60a"; } +.bi-volume-down::before { content: "\f60b"; } +.bi-volume-mute-fill::before { content: "\f60c"; } +.bi-volume-mute::before { content: "\f60d"; } +.bi-volume-off-fill::before { content: "\f60e"; } +.bi-volume-off::before { content: "\f60f"; } +.bi-volume-up-fill::before { content: "\f610"; } +.bi-volume-up::before { content: "\f611"; } +.bi-vr::before { content: "\f612"; } +.bi-wallet-fill::before { content: "\f613"; } +.bi-wallet::before { content: "\f614"; } +.bi-wallet2::before { content: "\f615"; } +.bi-watch::before { content: "\f616"; } +.bi-water::before { content: "\f617"; } +.bi-whatsapp::before { content: "\f618"; } +.bi-wifi-1::before { content: "\f619"; } +.bi-wifi-2::before { content: "\f61a"; } +.bi-wifi-off::before { content: "\f61b"; } +.bi-wifi::before { content: "\f61c"; } +.bi-wind::before { content: "\f61d"; } +.bi-window-dock::before { content: "\f61e"; } +.bi-window-sidebar::before { content: "\f61f"; } +.bi-window::before { content: "\f620"; } +.bi-wrench::before { content: "\f621"; } +.bi-x-circle-fill::before { content: "\f622"; } +.bi-x-circle::before { content: "\f623"; } +.bi-x-diamond-fill::before { content: "\f624"; } +.bi-x-diamond::before { content: "\f625"; } +.bi-x-octagon-fill::before { content: "\f626"; } +.bi-x-octagon::before { content: "\f627"; } +.bi-x-square-fill::before { content: "\f628"; } +.bi-x-square::before { content: "\f629"; } +.bi-x::before { content: "\f62a"; } +.bi-youtube::before { content: "\f62b"; } +.bi-zoom-in::before { content: "\f62c"; } +.bi-zoom-out::before { content: "\f62d"; } +.bi-bank::before { content: "\f62e"; } +.bi-bank2::before { content: "\f62f"; } +.bi-bell-slash-fill::before { content: "\f630"; } +.bi-bell-slash::before { content: "\f631"; } +.bi-cash-coin::before { content: "\f632"; } +.bi-check-lg::before { content: "\f633"; } +.bi-coin::before { content: "\f634"; } +.bi-currency-bitcoin::before { content: "\f635"; } +.bi-currency-dollar::before { content: "\f636"; } +.bi-currency-euro::before { content: "\f637"; } +.bi-currency-exchange::before { content: "\f638"; } +.bi-currency-pound::before { content: "\f639"; } +.bi-currency-yen::before { content: "\f63a"; } +.bi-dash-lg::before { content: "\f63b"; } +.bi-exclamation-lg::before { content: "\f63c"; } +.bi-file-earmark-pdf-fill::before { content: "\f63d"; } +.bi-file-earmark-pdf::before { content: "\f63e"; } +.bi-file-pdf-fill::before { content: "\f63f"; } +.bi-file-pdf::before { content: "\f640"; } +.bi-gender-ambiguous::before { content: "\f641"; } +.bi-gender-female::before { content: "\f642"; } +.bi-gender-male::before { content: "\f643"; } +.bi-gender-trans::before { content: "\f644"; } +.bi-headset-vr::before { content: "\f645"; } +.bi-info-lg::before { content: "\f646"; } +.bi-mastodon::before { content: "\f647"; } +.bi-messenger::before { content: "\f648"; } +.bi-piggy-bank-fill::before { content: "\f649"; } +.bi-piggy-bank::before { content: "\f64a"; } +.bi-pin-map-fill::before { content: "\f64b"; } +.bi-pin-map::before { content: "\f64c"; } +.bi-plus-lg::before { content: "\f64d"; } +.bi-question-lg::before { content: "\f64e"; } +.bi-recycle::before { content: "\f64f"; } +.bi-reddit::before { content: "\f650"; } +.bi-safe-fill::before { content: "\f651"; } +.bi-safe2-fill::before { content: "\f652"; } +.bi-safe2::before { content: "\f653"; } +.bi-sd-card-fill::before { content: "\f654"; } +.bi-sd-card::before { content: "\f655"; } +.bi-skype::before { content: "\f656"; } +.bi-slash-lg::before { content: "\f657"; } +.bi-translate::before { content: "\f658"; } +.bi-x-lg::before { content: "\f659"; } +.bi-safe::before { content: "\f65a"; } +.bi-apple::before { content: "\f65b"; } +.bi-microsoft::before { content: "\f65d"; } +.bi-windows::before { content: "\f65e"; } +.bi-behance::before { content: "\f65c"; } +.bi-dribbble::before { content: "\f65f"; } +.bi-line::before { content: "\f660"; } +.bi-medium::before { content: "\f661"; } +.bi-paypal::before { content: "\f662"; } +.bi-pinterest::before { content: "\f663"; } +.bi-signal::before { content: "\f664"; } +.bi-snapchat::before { content: "\f665"; } +.bi-spotify::before { content: "\f666"; } +.bi-stack-overflow::before { content: "\f667"; } +.bi-strava::before { content: "\f668"; } +.bi-wordpress::before { content: "\f669"; } +.bi-vimeo::before { content: "\f66a"; } +.bi-activity::before { content: "\f66b"; } +.bi-easel2-fill::before { content: "\f66c"; } +.bi-easel2::before { content: "\f66d"; } +.bi-easel3-fill::before { content: "\f66e"; } +.bi-easel3::before { content: "\f66f"; } +.bi-fan::before { content: "\f670"; } +.bi-fingerprint::before { content: "\f671"; } +.bi-graph-down-arrow::before { content: "\f672"; } +.bi-graph-up-arrow::before { content: "\f673"; } +.bi-hypnotize::before { content: "\f674"; } +.bi-magic::before { content: "\f675"; } +.bi-person-rolodex::before { content: "\f676"; } +.bi-person-video::before { content: "\f677"; } +.bi-person-video2::before { content: "\f678"; } +.bi-person-video3::before { content: "\f679"; } +.bi-person-workspace::before { content: "\f67a"; } +.bi-radioactive::before { content: "\f67b"; } +.bi-webcam-fill::before { content: "\f67c"; } +.bi-webcam::before { content: "\f67d"; } +.bi-yin-yang::before { content: "\f67e"; } +.bi-bandaid-fill::before { content: "\f680"; } +.bi-bandaid::before { content: "\f681"; } +.bi-bluetooth::before { content: "\f682"; } +.bi-body-text::before { content: "\f683"; } +.bi-boombox::before { content: "\f684"; } +.bi-boxes::before { content: "\f685"; } +.bi-dpad-fill::before { content: "\f686"; } +.bi-dpad::before { content: "\f687"; } +.bi-ear-fill::before { content: "\f688"; } +.bi-ear::before { content: "\f689"; } +.bi-envelope-check-fill::before { content: "\f68b"; } +.bi-envelope-check::before { content: "\f68c"; } +.bi-envelope-dash-fill::before { content: "\f68e"; } +.bi-envelope-dash::before { content: "\f68f"; } +.bi-envelope-exclamation-fill::before { content: "\f691"; } +.bi-envelope-exclamation::before { content: "\f692"; } +.bi-envelope-plus-fill::before { content: "\f693"; } +.bi-envelope-plus::before { content: "\f694"; } +.bi-envelope-slash-fill::before { content: "\f696"; } +.bi-envelope-slash::before { content: "\f697"; } +.bi-envelope-x-fill::before { content: "\f699"; } +.bi-envelope-x::before { content: "\f69a"; } +.bi-explicit-fill::before { content: "\f69b"; } +.bi-explicit::before { content: "\f69c"; } +.bi-git::before { content: "\f69d"; } +.bi-infinity::before { content: "\f69e"; } +.bi-list-columns-reverse::before { content: "\f69f"; } +.bi-list-columns::before { content: "\f6a0"; } +.bi-meta::before { content: "\f6a1"; } +.bi-nintendo-switch::before { content: "\f6a4"; } +.bi-pc-display-horizontal::before { content: "\f6a5"; } +.bi-pc-display::before { content: "\f6a6"; } +.bi-pc-horizontal::before { content: "\f6a7"; } +.bi-pc::before { content: "\f6a8"; } +.bi-playstation::before { content: "\f6a9"; } +.bi-plus-slash-minus::before { content: "\f6aa"; } +.bi-projector-fill::before { content: "\f6ab"; } +.bi-projector::before { content: "\f6ac"; } +.bi-qr-code-scan::before { content: "\f6ad"; } +.bi-qr-code::before { content: "\f6ae"; } +.bi-quora::before { content: "\f6af"; } +.bi-quote::before { content: "\f6b0"; } +.bi-robot::before { content: "\f6b1"; } +.bi-send-check-fill::before { content: "\f6b2"; } +.bi-send-check::before { content: "\f6b3"; } +.bi-send-dash-fill::before { content: "\f6b4"; } +.bi-send-dash::before { content: "\f6b5"; } +.bi-send-exclamation-fill::before { content: "\f6b7"; } +.bi-send-exclamation::before { content: "\f6b8"; } +.bi-send-fill::before { content: "\f6b9"; } +.bi-send-plus-fill::before { content: "\f6ba"; } +.bi-send-plus::before { content: "\f6bb"; } +.bi-send-slash-fill::before { content: "\f6bc"; } +.bi-send-slash::before { content: "\f6bd"; } +.bi-send-x-fill::before { content: "\f6be"; } +.bi-send-x::before { content: "\f6bf"; } +.bi-send::before { content: "\f6c0"; } +.bi-steam::before { content: "\f6c1"; } +.bi-terminal-dash::before { content: "\f6c3"; } +.bi-terminal-plus::before { content: "\f6c4"; } +.bi-terminal-split::before { content: "\f6c5"; } +.bi-ticket-detailed-fill::before { content: "\f6c6"; } +.bi-ticket-detailed::before { content: "\f6c7"; } +.bi-ticket-fill::before { content: "\f6c8"; } +.bi-ticket-perforated-fill::before { content: "\f6c9"; } +.bi-ticket-perforated::before { content: "\f6ca"; } +.bi-ticket::before { content: "\f6cb"; } +.bi-tiktok::before { content: "\f6cc"; } +.bi-window-dash::before { content: "\f6cd"; } +.bi-window-desktop::before { content: "\f6ce"; } +.bi-window-fullscreen::before { content: "\f6cf"; } +.bi-window-plus::before { content: "\f6d0"; } +.bi-window-split::before { content: "\f6d1"; } +.bi-window-stack::before { content: "\f6d2"; } +.bi-window-x::before { content: "\f6d3"; } +.bi-xbox::before { content: "\f6d4"; } +.bi-ethernet::before { content: "\f6d5"; } +.bi-hdmi-fill::before { content: "\f6d6"; } +.bi-hdmi::before { content: "\f6d7"; } +.bi-usb-c-fill::before { content: "\f6d8"; } +.bi-usb-c::before { content: "\f6d9"; } +.bi-usb-fill::before { content: "\f6da"; } +.bi-usb-plug-fill::before { content: "\f6db"; } +.bi-usb-plug::before { content: "\f6dc"; } +.bi-usb-symbol::before { content: "\f6dd"; } +.bi-usb::before { content: "\f6de"; } +.bi-boombox-fill::before { content: "\f6df"; } +.bi-displayport::before { content: "\f6e1"; } +.bi-gpu-card::before { content: "\f6e2"; } +.bi-memory::before { content: "\f6e3"; } +.bi-modem-fill::before { content: "\f6e4"; } +.bi-modem::before { content: "\f6e5"; } +.bi-motherboard-fill::before { content: "\f6e6"; } +.bi-motherboard::before { content: "\f6e7"; } +.bi-optical-audio-fill::before { content: "\f6e8"; } +.bi-optical-audio::before { content: "\f6e9"; } +.bi-pci-card::before { content: "\f6ea"; } +.bi-router-fill::before { content: "\f6eb"; } +.bi-router::before { content: "\f6ec"; } +.bi-thunderbolt-fill::before { content: "\f6ef"; } +.bi-thunderbolt::before { content: "\f6f0"; } +.bi-usb-drive-fill::before { content: "\f6f1"; } +.bi-usb-drive::before { content: "\f6f2"; } +.bi-usb-micro-fill::before { content: "\f6f3"; } +.bi-usb-micro::before { content: "\f6f4"; } +.bi-usb-mini-fill::before { content: "\f6f5"; } +.bi-usb-mini::before { content: "\f6f6"; } +.bi-cloud-haze2::before { content: "\f6f7"; } +.bi-device-hdd-fill::before { content: "\f6f8"; } +.bi-device-hdd::before { content: "\f6f9"; } +.bi-device-ssd-fill::before { content: "\f6fa"; } +.bi-device-ssd::before { content: "\f6fb"; } +.bi-displayport-fill::before { content: "\f6fc"; } +.bi-mortarboard-fill::before { content: "\f6fd"; } +.bi-mortarboard::before { content: "\f6fe"; } +.bi-terminal-x::before { content: "\f6ff"; } +.bi-arrow-through-heart-fill::before { content: "\f700"; } +.bi-arrow-through-heart::before { content: "\f701"; } +.bi-badge-sd-fill::before { content: "\f702"; } +.bi-badge-sd::before { content: "\f703"; } +.bi-bag-heart-fill::before { content: "\f704"; } +.bi-bag-heart::before { content: "\f705"; } +.bi-balloon-fill::before { content: "\f706"; } +.bi-balloon-heart-fill::before { content: "\f707"; } +.bi-balloon-heart::before { content: "\f708"; } +.bi-balloon::before { content: "\f709"; } +.bi-box2-fill::before { content: "\f70a"; } +.bi-box2-heart-fill::before { content: "\f70b"; } +.bi-box2-heart::before { content: "\f70c"; } +.bi-box2::before { content: "\f70d"; } +.bi-braces-asterisk::before { content: "\f70e"; } +.bi-calendar-heart-fill::before { content: "\f70f"; } +.bi-calendar-heart::before { content: "\f710"; } +.bi-calendar2-heart-fill::before { content: "\f711"; } +.bi-calendar2-heart::before { content: "\f712"; } +.bi-chat-heart-fill::before { content: "\f713"; } +.bi-chat-heart::before { content: "\f714"; } +.bi-chat-left-heart-fill::before { content: "\f715"; } +.bi-chat-left-heart::before { content: "\f716"; } +.bi-chat-right-heart-fill::before { content: "\f717"; } +.bi-chat-right-heart::before { content: "\f718"; } +.bi-chat-square-heart-fill::before { content: "\f719"; } +.bi-chat-square-heart::before { content: "\f71a"; } +.bi-clipboard-check-fill::before { content: "\f71b"; } +.bi-clipboard-data-fill::before { content: "\f71c"; } +.bi-clipboard-fill::before { content: "\f71d"; } +.bi-clipboard-heart-fill::before { content: "\f71e"; } +.bi-clipboard-heart::before { content: "\f71f"; } +.bi-clipboard-minus-fill::before { content: "\f720"; } +.bi-clipboard-plus-fill::before { content: "\f721"; } +.bi-clipboard-pulse::before { content: "\f722"; } +.bi-clipboard-x-fill::before { content: "\f723"; } +.bi-clipboard2-check-fill::before { content: "\f724"; } +.bi-clipboard2-check::before { content: "\f725"; } +.bi-clipboard2-data-fill::before { content: "\f726"; } +.bi-clipboard2-data::before { content: "\f727"; } +.bi-clipboard2-fill::before { content: "\f728"; } +.bi-clipboard2-heart-fill::before { content: "\f729"; } +.bi-clipboard2-heart::before { content: "\f72a"; } +.bi-clipboard2-minus-fill::before { content: "\f72b"; } +.bi-clipboard2-minus::before { content: "\f72c"; } +.bi-clipboard2-plus-fill::before { content: "\f72d"; } +.bi-clipboard2-plus::before { content: "\f72e"; } +.bi-clipboard2-pulse-fill::before { content: "\f72f"; } +.bi-clipboard2-pulse::before { content: "\f730"; } +.bi-clipboard2-x-fill::before { content: "\f731"; } +.bi-clipboard2-x::before { content: "\f732"; } +.bi-clipboard2::before { content: "\f733"; } +.bi-emoji-kiss-fill::before { content: "\f734"; } +.bi-emoji-kiss::before { content: "\f735"; } +.bi-envelope-heart-fill::before { content: "\f736"; } +.bi-envelope-heart::before { content: "\f737"; } +.bi-envelope-open-heart-fill::before { content: "\f738"; } +.bi-envelope-open-heart::before { content: "\f739"; } +.bi-envelope-paper-fill::before { content: "\f73a"; } +.bi-envelope-paper-heart-fill::before { content: "\f73b"; } +.bi-envelope-paper-heart::before { content: "\f73c"; } +.bi-envelope-paper::before { content: "\f73d"; } +.bi-filetype-aac::before { content: "\f73e"; } +.bi-filetype-ai::before { content: "\f73f"; } +.bi-filetype-bmp::before { content: "\f740"; } +.bi-filetype-cs::before { content: "\f741"; } +.bi-filetype-css::before { content: "\f742"; } +.bi-filetype-csv::before { content: "\f743"; } +.bi-filetype-doc::before { content: "\f744"; } +.bi-filetype-docx::before { content: "\f745"; } +.bi-filetype-exe::before { content: "\f746"; } +.bi-filetype-gif::before { content: "\f747"; } +.bi-filetype-heic::before { content: "\f748"; } +.bi-filetype-html::before { content: "\f749"; } +.bi-filetype-java::before { content: "\f74a"; } +.bi-filetype-jpg::before { content: "\f74b"; } +.bi-filetype-js::before { content: "\f74c"; } +.bi-filetype-jsx::before { content: "\f74d"; } +.bi-filetype-key::before { content: "\f74e"; } +.bi-filetype-m4p::before { content: "\f74f"; } +.bi-filetype-md::before { content: "\f750"; } +.bi-filetype-mdx::before { content: "\f751"; } +.bi-filetype-mov::before { content: "\f752"; } +.bi-filetype-mp3::before { content: "\f753"; } +.bi-filetype-mp4::before { content: "\f754"; } +.bi-filetype-otf::before { content: "\f755"; } +.bi-filetype-pdf::before { content: "\f756"; } +.bi-filetype-php::before { content: "\f757"; } +.bi-filetype-png::before { content: "\f758"; } +.bi-filetype-ppt::before { content: "\f75a"; } +.bi-filetype-psd::before { content: "\f75b"; } +.bi-filetype-py::before { content: "\f75c"; } +.bi-filetype-raw::before { content: "\f75d"; } +.bi-filetype-rb::before { content: "\f75e"; } +.bi-filetype-sass::before { content: "\f75f"; } +.bi-filetype-scss::before { content: "\f760"; } +.bi-filetype-sh::before { content: "\f761"; } +.bi-filetype-svg::before { content: "\f762"; } +.bi-filetype-tiff::before { content: "\f763"; } +.bi-filetype-tsx::before { content: "\f764"; } +.bi-filetype-ttf::before { content: "\f765"; } +.bi-filetype-txt::before { content: "\f766"; } +.bi-filetype-wav::before { content: "\f767"; } +.bi-filetype-woff::before { content: "\f768"; } +.bi-filetype-xls::before { content: "\f76a"; } +.bi-filetype-xml::before { content: "\f76b"; } +.bi-filetype-yml::before { content: "\f76c"; } +.bi-heart-arrow::before { content: "\f76d"; } +.bi-heart-pulse-fill::before { content: "\f76e"; } +.bi-heart-pulse::before { content: "\f76f"; } +.bi-heartbreak-fill::before { content: "\f770"; } +.bi-heartbreak::before { content: "\f771"; } +.bi-hearts::before { content: "\f772"; } +.bi-hospital-fill::before { content: "\f773"; } +.bi-hospital::before { content: "\f774"; } +.bi-house-heart-fill::before { content: "\f775"; } +.bi-house-heart::before { content: "\f776"; } +.bi-incognito::before { content: "\f777"; } +.bi-magnet-fill::before { content: "\f778"; } +.bi-magnet::before { content: "\f779"; } +.bi-person-heart::before { content: "\f77a"; } +.bi-person-hearts::before { content: "\f77b"; } +.bi-phone-flip::before { content: "\f77c"; } +.bi-plugin::before { content: "\f77d"; } +.bi-postage-fill::before { content: "\f77e"; } +.bi-postage-heart-fill::before { content: "\f77f"; } +.bi-postage-heart::before { content: "\f780"; } +.bi-postage::before { content: "\f781"; } +.bi-postcard-fill::before { content: "\f782"; } +.bi-postcard-heart-fill::before { content: "\f783"; } +.bi-postcard-heart::before { content: "\f784"; } +.bi-postcard::before { content: "\f785"; } +.bi-search-heart-fill::before { content: "\f786"; } +.bi-search-heart::before { content: "\f787"; } +.bi-sliders2-vertical::before { content: "\f788"; } +.bi-sliders2::before { content: "\f789"; } +.bi-trash3-fill::before { content: "\f78a"; } +.bi-trash3::before { content: "\f78b"; } +.bi-valentine::before { content: "\f78c"; } +.bi-valentine2::before { content: "\f78d"; } +.bi-wrench-adjustable-circle-fill::before { content: "\f78e"; } +.bi-wrench-adjustable-circle::before { content: "\f78f"; } +.bi-wrench-adjustable::before { content: "\f790"; } +.bi-filetype-json::before { content: "\f791"; } +.bi-filetype-pptx::before { content: "\f792"; } +.bi-filetype-xlsx::before { content: "\f793"; } +.bi-1-circle-fill::before { content: "\f796"; } +.bi-1-circle::before { content: "\f797"; } +.bi-1-square-fill::before { content: "\f798"; } +.bi-1-square::before { content: "\f799"; } +.bi-2-circle-fill::before { content: "\f79c"; } +.bi-2-circle::before { content: "\f79d"; } +.bi-2-square-fill::before { content: "\f79e"; } +.bi-2-square::before { content: "\f79f"; } +.bi-3-circle-fill::before { content: "\f7a2"; } +.bi-3-circle::before { content: "\f7a3"; } +.bi-3-square-fill::before { content: "\f7a4"; } +.bi-3-square::before { content: "\f7a5"; } +.bi-4-circle-fill::before { content: "\f7a8"; } +.bi-4-circle::before { content: "\f7a9"; } +.bi-4-square-fill::before { content: "\f7aa"; } +.bi-4-square::before { content: "\f7ab"; } +.bi-5-circle-fill::before { content: "\f7ae"; } +.bi-5-circle::before { content: "\f7af"; } +.bi-5-square-fill::before { content: "\f7b0"; } +.bi-5-square::before { content: "\f7b1"; } +.bi-6-circle-fill::before { content: "\f7b4"; } +.bi-6-circle::before { content: "\f7b5"; } +.bi-6-square-fill::before { content: "\f7b6"; } +.bi-6-square::before { content: "\f7b7"; } +.bi-7-circle-fill::before { content: "\f7ba"; } +.bi-7-circle::before { content: "\f7bb"; } +.bi-7-square-fill::before { content: "\f7bc"; } +.bi-7-square::before { content: "\f7bd"; } +.bi-8-circle-fill::before { content: "\f7c0"; } +.bi-8-circle::before { content: "\f7c1"; } +.bi-8-square-fill::before { content: "\f7c2"; } +.bi-8-square::before { content: "\f7c3"; } +.bi-9-circle-fill::before { content: "\f7c6"; } +.bi-9-circle::before { content: "\f7c7"; } +.bi-9-square-fill::before { content: "\f7c8"; } +.bi-9-square::before { content: "\f7c9"; } +.bi-airplane-engines-fill::before { content: "\f7ca"; } +.bi-airplane-engines::before { content: "\f7cb"; } +.bi-airplane-fill::before { content: "\f7cc"; } +.bi-airplane::before { content: "\f7cd"; } +.bi-alexa::before { content: "\f7ce"; } +.bi-alipay::before { content: "\f7cf"; } +.bi-android::before { content: "\f7d0"; } +.bi-android2::before { content: "\f7d1"; } +.bi-box-fill::before { content: "\f7d2"; } +.bi-box-seam-fill::before { content: "\f7d3"; } +.bi-browser-chrome::before { content: "\f7d4"; } +.bi-browser-edge::before { content: "\f7d5"; } +.bi-browser-firefox::before { content: "\f7d6"; } +.bi-browser-safari::before { content: "\f7d7"; } +.bi-c-circle-fill::before { content: "\f7da"; } +.bi-c-circle::before { content: "\f7db"; } +.bi-c-square-fill::before { content: "\f7dc"; } +.bi-c-square::before { content: "\f7dd"; } +.bi-capsule-pill::before { content: "\f7de"; } +.bi-capsule::before { content: "\f7df"; } +.bi-car-front-fill::before { content: "\f7e0"; } +.bi-car-front::before { content: "\f7e1"; } +.bi-cassette-fill::before { content: "\f7e2"; } +.bi-cassette::before { content: "\f7e3"; } +.bi-cc-circle-fill::before { content: "\f7e6"; } +.bi-cc-circle::before { content: "\f7e7"; } +.bi-cc-square-fill::before { content: "\f7e8"; } +.bi-cc-square::before { content: "\f7e9"; } +.bi-cup-hot-fill::before { content: "\f7ea"; } +.bi-cup-hot::before { content: "\f7eb"; } +.bi-currency-rupee::before { content: "\f7ec"; } +.bi-dropbox::before { content: "\f7ed"; } +.bi-escape::before { content: "\f7ee"; } +.bi-fast-forward-btn-fill::before { content: "\f7ef"; } +.bi-fast-forward-btn::before { content: "\f7f0"; } +.bi-fast-forward-circle-fill::before { content: "\f7f1"; } +.bi-fast-forward-circle::before { content: "\f7f2"; } +.bi-fast-forward-fill::before { content: "\f7f3"; } +.bi-fast-forward::before { content: "\f7f4"; } +.bi-filetype-sql::before { content: "\f7f5"; } +.bi-fire::before { content: "\f7f6"; } +.bi-google-play::before { content: "\f7f7"; } +.bi-h-circle-fill::before { content: "\f7fa"; } +.bi-h-circle::before { content: "\f7fb"; } +.bi-h-square-fill::before { content: "\f7fc"; } +.bi-h-square::before { content: "\f7fd"; } +.bi-indent::before { content: "\f7fe"; } +.bi-lungs-fill::before { content: "\f7ff"; } +.bi-lungs::before { content: "\f800"; } +.bi-microsoft-teams::before { content: "\f801"; } +.bi-p-circle-fill::before { content: "\f804"; } +.bi-p-circle::before { content: "\f805"; } +.bi-p-square-fill::before { content: "\f806"; } +.bi-p-square::before { content: "\f807"; } +.bi-pass-fill::before { content: "\f808"; } +.bi-pass::before { content: "\f809"; } +.bi-prescription::before { content: "\f80a"; } +.bi-prescription2::before { content: "\f80b"; } +.bi-r-circle-fill::before { content: "\f80e"; } +.bi-r-circle::before { content: "\f80f"; } +.bi-r-square-fill::before { content: "\f810"; } +.bi-r-square::before { content: "\f811"; } +.bi-repeat-1::before { content: "\f812"; } +.bi-repeat::before { content: "\f813"; } +.bi-rewind-btn-fill::before { content: "\f814"; } +.bi-rewind-btn::before { content: "\f815"; } +.bi-rewind-circle-fill::before { content: "\f816"; } +.bi-rewind-circle::before { content: "\f817"; } +.bi-rewind-fill::before { content: "\f818"; } +.bi-rewind::before { content: "\f819"; } +.bi-train-freight-front-fill::before { content: "\f81a"; } +.bi-train-freight-front::before { content: "\f81b"; } +.bi-train-front-fill::before { content: "\f81c"; } +.bi-train-front::before { content: "\f81d"; } +.bi-train-lightrail-front-fill::before { content: "\f81e"; } +.bi-train-lightrail-front::before { content: "\f81f"; } +.bi-truck-front-fill::before { content: "\f820"; } +.bi-truck-front::before { content: "\f821"; } +.bi-ubuntu::before { content: "\f822"; } +.bi-unindent::before { content: "\f823"; } +.bi-unity::before { content: "\f824"; } +.bi-universal-access-circle::before { content: "\f825"; } +.bi-universal-access::before { content: "\f826"; } +.bi-virus::before { content: "\f827"; } +.bi-virus2::before { content: "\f828"; } +.bi-wechat::before { content: "\f829"; } +.bi-yelp::before { content: "\f82a"; } +.bi-sign-stop-fill::before { content: "\f82b"; } +.bi-sign-stop-lights-fill::before { content: "\f82c"; } +.bi-sign-stop-lights::before { content: "\f82d"; } +.bi-sign-stop::before { content: "\f82e"; } +.bi-sign-turn-left-fill::before { content: "\f82f"; } +.bi-sign-turn-left::before { content: "\f830"; } +.bi-sign-turn-right-fill::before { content: "\f831"; } +.bi-sign-turn-right::before { content: "\f832"; } +.bi-sign-turn-slight-left-fill::before { content: "\f833"; } +.bi-sign-turn-slight-left::before { content: "\f834"; } +.bi-sign-turn-slight-right-fill::before { content: "\f835"; } +.bi-sign-turn-slight-right::before { content: "\f836"; } +.bi-sign-yield-fill::before { content: "\f837"; } +.bi-sign-yield::before { content: "\f838"; } +.bi-ev-station-fill::before { content: "\f839"; } +.bi-ev-station::before { content: "\f83a"; } +.bi-fuel-pump-diesel-fill::before { content: "\f83b"; } +.bi-fuel-pump-diesel::before { content: "\f83c"; } +.bi-fuel-pump-fill::before { content: "\f83d"; } +.bi-fuel-pump::before { content: "\f83e"; } +.bi-0-circle-fill::before { content: "\f83f"; } +.bi-0-circle::before { content: "\f840"; } +.bi-0-square-fill::before { content: "\f841"; } +.bi-0-square::before { content: "\f842"; } +.bi-rocket-fill::before { content: "\f843"; } +.bi-rocket-takeoff-fill::before { content: "\f844"; } +.bi-rocket-takeoff::before { content: "\f845"; } +.bi-rocket::before { content: "\f846"; } +.bi-stripe::before { content: "\f847"; } +.bi-subscript::before { content: "\f848"; } +.bi-superscript::before { content: "\f849"; } +.bi-trello::before { content: "\f84a"; } +.bi-envelope-at-fill::before { content: "\f84b"; } +.bi-envelope-at::before { content: "\f84c"; } +.bi-regex::before { content: "\f84d"; } +.bi-text-wrap::before { content: "\f84e"; } +.bi-sign-dead-end-fill::before { content: "\f84f"; } +.bi-sign-dead-end::before { content: "\f850"; } +.bi-sign-do-not-enter-fill::before { content: "\f851"; } +.bi-sign-do-not-enter::before { content: "\f852"; } +.bi-sign-intersection-fill::before { content: "\f853"; } +.bi-sign-intersection-side-fill::before { content: "\f854"; } +.bi-sign-intersection-side::before { content: "\f855"; } +.bi-sign-intersection-t-fill::before { content: "\f856"; } +.bi-sign-intersection-t::before { content: "\f857"; } +.bi-sign-intersection-y-fill::before { content: "\f858"; } +.bi-sign-intersection-y::before { content: "\f859"; } +.bi-sign-intersection::before { content: "\f85a"; } +.bi-sign-merge-left-fill::before { content: "\f85b"; } +.bi-sign-merge-left::before { content: "\f85c"; } +.bi-sign-merge-right-fill::before { content: "\f85d"; } +.bi-sign-merge-right::before { content: "\f85e"; } +.bi-sign-no-left-turn-fill::before { content: "\f85f"; } +.bi-sign-no-left-turn::before { content: "\f860"; } +.bi-sign-no-parking-fill::before { content: "\f861"; } +.bi-sign-no-parking::before { content: "\f862"; } +.bi-sign-no-right-turn-fill::before { content: "\f863"; } +.bi-sign-no-right-turn::before { content: "\f864"; } +.bi-sign-railroad-fill::before { content: "\f865"; } +.bi-sign-railroad::before { content: "\f866"; } +.bi-building-add::before { content: "\f867"; } +.bi-building-check::before { content: "\f868"; } +.bi-building-dash::before { content: "\f869"; } +.bi-building-down::before { content: "\f86a"; } +.bi-building-exclamation::before { content: "\f86b"; } +.bi-building-fill-add::before { content: "\f86c"; } +.bi-building-fill-check::before { content: "\f86d"; } +.bi-building-fill-dash::before { content: "\f86e"; } +.bi-building-fill-down::before { content: "\f86f"; } +.bi-building-fill-exclamation::before { content: "\f870"; } +.bi-building-fill-gear::before { content: "\f871"; } +.bi-building-fill-lock::before { content: "\f872"; } +.bi-building-fill-slash::before { content: "\f873"; } +.bi-building-fill-up::before { content: "\f874"; } +.bi-building-fill-x::before { content: "\f875"; } +.bi-building-fill::before { content: "\f876"; } +.bi-building-gear::before { content: "\f877"; } +.bi-building-lock::before { content: "\f878"; } +.bi-building-slash::before { content: "\f879"; } +.bi-building-up::before { content: "\f87a"; } +.bi-building-x::before { content: "\f87b"; } +.bi-buildings-fill::before { content: "\f87c"; } +.bi-buildings::before { content: "\f87d"; } +.bi-bus-front-fill::before { content: "\f87e"; } +.bi-bus-front::before { content: "\f87f"; } +.bi-ev-front-fill::before { content: "\f880"; } +.bi-ev-front::before { content: "\f881"; } +.bi-globe-americas::before { content: "\f882"; } +.bi-globe-asia-australia::before { content: "\f883"; } +.bi-globe-central-south-asia::before { content: "\f884"; } +.bi-globe-europe-africa::before { content: "\f885"; } +.bi-house-add-fill::before { content: "\f886"; } +.bi-house-add::before { content: "\f887"; } +.bi-house-check-fill::before { content: "\f888"; } +.bi-house-check::before { content: "\f889"; } +.bi-house-dash-fill::before { content: "\f88a"; } +.bi-house-dash::before { content: "\f88b"; } +.bi-house-down-fill::before { content: "\f88c"; } +.bi-house-down::before { content: "\f88d"; } +.bi-house-exclamation-fill::before { content: "\f88e"; } +.bi-house-exclamation::before { content: "\f88f"; } +.bi-house-gear-fill::before { content: "\f890"; } +.bi-house-gear::before { content: "\f891"; } +.bi-house-lock-fill::before { content: "\f892"; } +.bi-house-lock::before { content: "\f893"; } +.bi-house-slash-fill::before { content: "\f894"; } +.bi-house-slash::before { content: "\f895"; } +.bi-house-up-fill::before { content: "\f896"; } +.bi-house-up::before { content: "\f897"; } +.bi-house-x-fill::before { content: "\f898"; } +.bi-house-x::before { content: "\f899"; } +.bi-person-add::before { content: "\f89a"; } +.bi-person-down::before { content: "\f89b"; } +.bi-person-exclamation::before { content: "\f89c"; } +.bi-person-fill-add::before { content: "\f89d"; } +.bi-person-fill-check::before { content: "\f89e"; } +.bi-person-fill-dash::before { content: "\f89f"; } +.bi-person-fill-down::before { content: "\f8a0"; } +.bi-person-fill-exclamation::before { content: "\f8a1"; } +.bi-person-fill-gear::before { content: "\f8a2"; } +.bi-person-fill-lock::before { content: "\f8a3"; } +.bi-person-fill-slash::before { content: "\f8a4"; } +.bi-person-fill-up::before { content: "\f8a5"; } +.bi-person-fill-x::before { content: "\f8a6"; } +.bi-person-gear::before { content: "\f8a7"; } +.bi-person-lock::before { content: "\f8a8"; } +.bi-person-slash::before { content: "\f8a9"; } +.bi-person-up::before { content: "\f8aa"; } +.bi-scooter::before { content: "\f8ab"; } +.bi-taxi-front-fill::before { content: "\f8ac"; } +.bi-taxi-front::before { content: "\f8ad"; } +.bi-amd::before { content: "\f8ae"; } +.bi-database-add::before { content: "\f8af"; } +.bi-database-check::before { content: "\f8b0"; } +.bi-database-dash::before { content: "\f8b1"; } +.bi-database-down::before { content: "\f8b2"; } +.bi-database-exclamation::before { content: "\f8b3"; } +.bi-database-fill-add::before { content: "\f8b4"; } +.bi-database-fill-check::before { content: "\f8b5"; } +.bi-database-fill-dash::before { content: "\f8b6"; } +.bi-database-fill-down::before { content: "\f8b7"; } +.bi-database-fill-exclamation::before { content: "\f8b8"; } +.bi-database-fill-gear::before { content: "\f8b9"; } +.bi-database-fill-lock::before { content: "\f8ba"; } +.bi-database-fill-slash::before { content: "\f8bb"; } +.bi-database-fill-up::before { content: "\f8bc"; } +.bi-database-fill-x::before { content: "\f8bd"; } +.bi-database-fill::before { content: "\f8be"; } +.bi-database-gear::before { content: "\f8bf"; } +.bi-database-lock::before { content: "\f8c0"; } +.bi-database-slash::before { content: "\f8c1"; } +.bi-database-up::before { content: "\f8c2"; } +.bi-database-x::before { content: "\f8c3"; } +.bi-database::before { content: "\f8c4"; } +.bi-houses-fill::before { content: "\f8c5"; } +.bi-houses::before { content: "\f8c6"; } +.bi-nvidia::before { content: "\f8c7"; } +.bi-person-vcard-fill::before { content: "\f8c8"; } +.bi-person-vcard::before { content: "\f8c9"; } +.bi-sina-weibo::before { content: "\f8ca"; } +.bi-tencent-qq::before { content: "\f8cb"; } +.bi-wikipedia::before { content: "\f8cc"; } +.bi-alphabet-uppercase::before { content: "\f2a5"; } +.bi-alphabet::before { content: "\f68a"; } +.bi-amazon::before { content: "\f68d"; } +.bi-arrows-collapse-vertical::before { content: "\f690"; } +.bi-arrows-expand-vertical::before { content: "\f695"; } +.bi-arrows-vertical::before { content: "\f698"; } +.bi-arrows::before { content: "\f6a2"; } +.bi-ban-fill::before { content: "\f6a3"; } +.bi-ban::before { content: "\f6b6"; } +.bi-bing::before { content: "\f6c2"; } +.bi-cake::before { content: "\f6e0"; } +.bi-cake2::before { content: "\f6ed"; } +.bi-cookie::before { content: "\f6ee"; } +.bi-copy::before { content: "\f759"; } +.bi-crosshair::before { content: "\f769"; } +.bi-crosshair2::before { content: "\f794"; } +.bi-emoji-astonished-fill::before { content: "\f795"; } +.bi-emoji-astonished::before { content: "\f79a"; } +.bi-emoji-grimace-fill::before { content: "\f79b"; } +.bi-emoji-grimace::before { content: "\f7a0"; } +.bi-emoji-grin-fill::before { content: "\f7a1"; } +.bi-emoji-grin::before { content: "\f7a6"; } +.bi-emoji-surprise-fill::before { content: "\f7a7"; } +.bi-emoji-surprise::before { content: "\f7ac"; } +.bi-emoji-tear-fill::before { content: "\f7ad"; } +.bi-emoji-tear::before { content: "\f7b2"; } +.bi-envelope-arrow-down-fill::before { content: "\f7b3"; } +.bi-envelope-arrow-down::before { content: "\f7b8"; } +.bi-envelope-arrow-up-fill::before { content: "\f7b9"; } +.bi-envelope-arrow-up::before { content: "\f7be"; } +.bi-feather::before { content: "\f7bf"; } +.bi-feather2::before { content: "\f7c4"; } +.bi-floppy-fill::before { content: "\f7c5"; } +.bi-floppy::before { content: "\f7d8"; } +.bi-floppy2-fill::before { content: "\f7d9"; } +.bi-floppy2::before { content: "\f7e4"; } +.bi-gitlab::before { content: "\f7e5"; } +.bi-highlighter::before { content: "\f7f8"; } +.bi-marker-tip::before { content: "\f802"; } +.bi-nvme-fill::before { content: "\f803"; } +.bi-nvme::before { content: "\f80c"; } +.bi-opencollective::before { content: "\f80d"; } +.bi-pci-card-network::before { content: "\f8cd"; } +.bi-pci-card-sound::before { content: "\f8ce"; } +.bi-radar::before { content: "\f8cf"; } +.bi-send-arrow-down-fill::before { content: "\f8d0"; } +.bi-send-arrow-down::before { content: "\f8d1"; } +.bi-send-arrow-up-fill::before { content: "\f8d2"; } +.bi-send-arrow-up::before { content: "\f8d3"; } +.bi-sim-slash-fill::before { content: "\f8d4"; } +.bi-sim-slash::before { content: "\f8d5"; } +.bi-sourceforge::before { content: "\f8d6"; } +.bi-substack::before { content: "\f8d7"; } +.bi-threads-fill::before { content: "\f8d8"; } +.bi-threads::before { content: "\f8d9"; } +.bi-transparency::before { content: "\f8da"; } +.bi-twitter-x::before { content: "\f8db"; } +.bi-type-h4::before { content: "\f8dc"; } +.bi-type-h5::before { content: "\f8dd"; } +.bi-type-h6::before { content: "\f8de"; } +.bi-backpack-fill::before { content: "\f8df"; } +.bi-backpack::before { content: "\f8e0"; } +.bi-backpack2-fill::before { content: "\f8e1"; } +.bi-backpack2::before { content: "\f8e2"; } +.bi-backpack3-fill::before { content: "\f8e3"; } +.bi-backpack3::before { content: "\f8e4"; } +.bi-backpack4-fill::before { content: "\f8e5"; } +.bi-backpack4::before { content: "\f8e6"; } +.bi-brilliance::before { content: "\f8e7"; } +.bi-cake-fill::before { content: "\f8e8"; } +.bi-cake2-fill::before { content: "\f8e9"; } +.bi-duffle-fill::before { content: "\f8ea"; } +.bi-duffle::before { content: "\f8eb"; } +.bi-exposure::before { content: "\f8ec"; } +.bi-gender-neuter::before { content: "\f8ed"; } +.bi-highlights::before { content: "\f8ee"; } +.bi-luggage-fill::before { content: "\f8ef"; } +.bi-luggage::before { content: "\f8f0"; } +.bi-mailbox-flag::before { content: "\f8f1"; } +.bi-mailbox2-flag::before { content: "\f8f2"; } +.bi-noise-reduction::before { content: "\f8f3"; } +.bi-passport-fill::before { content: "\f8f4"; } +.bi-passport::before { content: "\f8f5"; } +.bi-person-arms-up::before { content: "\f8f6"; } +.bi-person-raised-hand::before { content: "\f8f7"; } +.bi-person-standing-dress::before { content: "\f8f8"; } +.bi-person-standing::before { content: "\f8f9"; } +.bi-person-walking::before { content: "\f8fa"; } +.bi-person-wheelchair::before { content: "\f8fb"; } +.bi-shadows::before { content: "\f8fc"; } +.bi-suitcase-fill::before { content: "\f8fd"; } +.bi-suitcase-lg-fill::before { content: "\f8fe"; } +.bi-suitcase-lg::before { content: "\f8ff"; } +.bi-suitcase::before { content: "\f900"; } +.bi-suitcase2-fill::before { content: "\f901"; } +.bi-suitcase2::before { content: "\f902"; } +.bi-vignette::before { content: "\f903"; } +.bi-bluesky::before { content: "\f7f9"; } +.bi-tux::before { content: "\f904"; } +.bi-beaker-fill::before { content: "\f905"; } +.bi-beaker::before { content: "\f906"; } +.bi-flask-fill::before { content: "\f907"; } +.bi-flask-florence-fill::before { content: "\f908"; } +.bi-flask-florence::before { content: "\f909"; } +.bi-flask::before { content: "\f90a"; } +.bi-leaf-fill::before { content: "\f90b"; } +.bi-leaf::before { content: "\f90c"; } +.bi-measuring-cup-fill::before { content: "\f90d"; } +.bi-measuring-cup::before { content: "\f90e"; } +.bi-unlock2-fill::before { content: "\f90f"; } +.bi-unlock2::before { content: "\f910"; } +.bi-battery-low::before { content: "\f911"; } +.bi-anthropic::before { content: "\f912"; } +.bi-apple-music::before { content: "\f913"; } +.bi-claude::before { content: "\f914"; } +.bi-openai::before { content: "\f915"; } +.bi-perplexity::before { content: "\f916"; } +.bi-css::before { content: "\f917"; } +.bi-javascript::before { content: "\f918"; } +.bi-typescript::before { content: "\f919"; } +.bi-fork-knife::before { content: "\f91a"; } +.bi-globe-americas-fill::before { content: "\f91b"; } +.bi-globe-asia-australia-fill::before { content: "\f91c"; } +.bi-globe-central-south-asia-fill::before { content: "\f91d"; } +.bi-globe-europe-africa-fill::before { content: "\f91e"; } diff --git a/site_libs/bootstrap/bootstrap-icons.woff b/site_libs/bootstrap/bootstrap-icons.woff new file mode 100644 index 0000000..a4fa4f0 Binary files /dev/null and b/site_libs/bootstrap/bootstrap-icons.woff differ diff --git a/site_libs/bootstrap/bootstrap.min.js b/site_libs/bootstrap/bootstrap.min.js new file mode 100644 index 0000000..e8f21f7 --- /dev/null +++ b/site_libs/bootstrap/bootstrap.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v5.3.1 (https://getbootstrap.com/) + * Copyright 2011-2023 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=I(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return P(s,{delegateTarget:r}),n.oneOff&&N.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return P(n,{delegateTarget:t}),i.oneOff&&N.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function $(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function I(t){return t=t.replace(y,""),T[t]||t}const N={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))$(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(w,"");a&&!e.includes(s)||D(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;D(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=u();let s=null,o=!0,r=!0,a=!1;e!==I(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=P(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function P(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}function M(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function j(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const F={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${j(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${j(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=M(t.dataset[n])}return e},getDataAttribute:(t,e)=>M(t.getAttribute(`data-bs-${j(e)}`))};class H{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=o(e)?F.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...o(e)?F.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[n,s]of Object.entries(e)){const e=t[n],r=o(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(s).test(r))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${r}" but expected type "${s}".`)}var i}}class W extends H{constructor(t,i){super(),(t=r(t))&&(this._element=t,this._config=this._getConfig(i),e.set(this._element,this.constructor.DATA_KEY,this))}dispose(){e.remove(this._element,this.constructor.DATA_KEY),N.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){_(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return e.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.1"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const B=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return n(e)},z={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!l(t)&&a(t)))},getSelectorFromElement(t){const e=B(t);return e&&z.findOne(e)?e:null},getElementFromSelector(t){const e=B(t);return e?z.findOne(e):null},getMultipleElementsFromSelector(t){const e=B(t);return e?z.find(e):[]}},R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;N.on(document,i,`[data-bs-dismiss="${n}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),l(this))return;const s=z.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()}))},q=".bs.alert",V=`close${q}`,K=`closed${q}`;class Q extends W{static get NAME(){return"alert"}close(){if(N.trigger(this._element,V).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),N.trigger(this._element,K),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=Q.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(Q,"close"),m(Q);const X='[data-bs-toggle="button"]';class Y extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=Y.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}N.on(document,"click.bs.button.data-api",X,(t=>{t.preventDefault();const e=t.target.closest(X);Y.getOrCreateInstance(e).toggle()})),m(Y);const U=".bs.swipe",G=`touchstart${U}`,J=`touchmove${U}`,Z=`touchend${U}`,tt=`pointerdown${U}`,et=`pointerup${U}`,it={endCallback:null,leftCallback:null,rightCallback:null},nt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class st extends H{constructor(t,e){super(),this._element=t,t&&st.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return it}static get DefaultType(){return nt}static get NAME(){return"swipe"}dispose(){N.off(this._element,U)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),g(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&g(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(N.on(this._element,tt,(t=>this._start(t))),N.on(this._element,et,(t=>this._end(t))),this._element.classList.add("pointer-event")):(N.on(this._element,G,(t=>this._start(t))),N.on(this._element,J,(t=>this._move(t))),N.on(this._element,Z,(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const ot=".bs.carousel",rt=".data-api",at="next",lt="prev",ct="left",ht="right",dt=`slide${ot}`,ut=`slid${ot}`,ft=`keydown${ot}`,pt=`mouseenter${ot}`,mt=`mouseleave${ot}`,gt=`dragstart${ot}`,_t=`load${ot}${rt}`,bt=`click${ot}${rt}`,vt="carousel",yt="active",wt=".active",At=".carousel-item",Et=wt+At,Tt={ArrowLeft:ht,ArrowRight:ct},Ct={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Ot={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class xt extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=z.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===vt&&this.cycle()}static get Default(){return Ct}static get DefaultType(){return Ot}static get NAME(){return"carousel"}next(){this._slide(at)}nextWhenVisible(){!document.hidden&&a(this._element)&&this.next()}prev(){this._slide(lt)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?N.one(this._element,ut,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void N.one(this._element,ut,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?at:lt;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&N.on(this._element,ft,(t=>this._keydown(t))),"hover"===this._config.pause&&(N.on(this._element,pt,(()=>this.pause())),N.on(this._element,mt,(()=>this._maybeEnableCycle()))),this._config.touch&&st.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of z.find(".carousel-item img",this._element))N.on(t,gt,(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(ct)),rightCallback:()=>this._slide(this._directionToOrder(ht)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new st(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=Tt[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=z.findOne(wt,this._indicatorsElement);e.classList.remove(yt),e.removeAttribute("aria-current");const i=z.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(yt),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===at,s=e||b(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>N.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(dt).defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),d(s),i.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,c),s.classList.add(yt),i.classList.remove(yt,c,l),this._isSliding=!1,r(ut)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return z.findOne(Et,this._element)}_getItems(){return z.find(At,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===ct?lt:at:t===ct?at:lt}_orderToDirection(t){return p()?t===lt?ct:ht:t===lt?ht:ct}static jQueryInterface(t){return this.each((function(){const e=xt.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}N.on(document,bt,"[data-bs-slide], [data-bs-slide-to]",(function(t){const e=z.getElementFromSelector(this);if(!e||!e.classList.contains(vt))return;t.preventDefault();const i=xt.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===F.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),N.on(window,_t,(()=>{const t=z.find('[data-bs-ride="carousel"]');for(const e of t)xt.getOrCreateInstance(e)})),m(xt);const kt=".bs.collapse",Lt=`show${kt}`,St=`shown${kt}`,Dt=`hide${kt}`,$t=`hidden${kt}`,It=`click${kt}.data-api`,Nt="show",Pt="collapse",Mt="collapsing",jt=`:scope .${Pt} .${Pt}`,Ft='[data-bs-toggle="collapse"]',Ht={parent:null,toggle:!0},Wt={parent:"(null|element)",toggle:"boolean"};class Bt extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=z.find(Ft);for(const t of i){const e=z.getSelectorFromElement(t),i=z.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return Ht}static get DefaultType(){return Wt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>Bt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(N.trigger(this._element,Lt).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Pt),this._element.classList.add(Mt),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(Mt),this._element.classList.add(Pt,Nt),this._element.style[e]="",N.trigger(this._element,St)}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(N.trigger(this._element,Dt).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,d(this._element),this._element.classList.add(Mt),this._element.classList.remove(Pt,Nt);for(const t of this._triggerArray){const e=z.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(Mt),this._element.classList.add(Pt),N.trigger(this._element,$t)}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(Nt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=r(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Ft);for(const e of t){const t=z.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=z.find(jt,this._config.parent);return z.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=Bt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}N.on(document,It,Ft,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of z.getMultipleElementsFromSelector(this))Bt.getOrCreateInstance(t,{toggle:!1}).toggle()})),m(Bt);var zt="top",Rt="bottom",qt="right",Vt="left",Kt="auto",Qt=[zt,Rt,qt,Vt],Xt="start",Yt="end",Ut="clippingParents",Gt="viewport",Jt="popper",Zt="reference",te=Qt.reduce((function(t,e){return t.concat([e+"-"+Xt,e+"-"+Yt])}),[]),ee=[].concat(Qt,[Kt]).reduce((function(t,e){return t.concat([e,e+"-"+Xt,e+"-"+Yt])}),[]),ie="beforeRead",ne="read",se="afterRead",oe="beforeMain",re="main",ae="afterMain",le="beforeWrite",ce="write",he="afterWrite",de=[ie,ne,se,oe,re,ae,le,ce,he];function ue(t){return t?(t.nodeName||"").toLowerCase():null}function fe(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function pe(t){return t instanceof fe(t).Element||t instanceof Element}function me(t){return t instanceof fe(t).HTMLElement||t instanceof HTMLElement}function ge(t){return"undefined"!=typeof ShadowRoot&&(t instanceof fe(t).ShadowRoot||t instanceof ShadowRoot)}const _e={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];me(s)&&ue(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});me(n)&&ue(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function be(t){return t.split("-")[0]}var ve=Math.max,ye=Math.min,we=Math.round;function Ae(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function Ee(){return!/^((?!chrome|android).)*safari/i.test(Ae())}function Te(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&me(t)&&(s=t.offsetWidth>0&&we(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&we(n.height)/t.offsetHeight||1);var r=(pe(t)?fe(t):window).visualViewport,a=!Ee()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function Ce(t){var e=Te(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Oe(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&ge(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function xe(t){return fe(t).getComputedStyle(t)}function ke(t){return["table","td","th"].indexOf(ue(t))>=0}function Le(t){return((pe(t)?t.ownerDocument:t.document)||window.document).documentElement}function Se(t){return"html"===ue(t)?t:t.assignedSlot||t.parentNode||(ge(t)?t.host:null)||Le(t)}function De(t){return me(t)&&"fixed"!==xe(t).position?t.offsetParent:null}function $e(t){for(var e=fe(t),i=De(t);i&&ke(i)&&"static"===xe(i).position;)i=De(i);return i&&("html"===ue(i)||"body"===ue(i)&&"static"===xe(i).position)?e:i||function(t){var e=/firefox/i.test(Ae());if(/Trident/i.test(Ae())&&me(t)&&"fixed"===xe(t).position)return null;var i=Se(t);for(ge(i)&&(i=i.host);me(i)&&["html","body"].indexOf(ue(i))<0;){var n=xe(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function Ie(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function Ne(t,e,i){return ve(t,ye(e,i))}function Pe(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function Me(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const je={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=be(i.placement),l=Ie(a),c=[Vt,qt].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return Pe("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:Me(t,Qt))}(s.padding,i),d=Ce(o),u="y"===l?zt:Vt,f="y"===l?Rt:qt,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=$e(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=Ne(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Oe(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function Fe(t){return t.split("-")[1]}var He={top:"auto",right:"auto",bottom:"auto",left:"auto"};function We(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,m=void 0===p?0:p,g="function"==typeof h?h({x:f,y:m}):{x:f,y:m};f=g.x,m=g.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=Vt,y=zt,w=window;if(c){var A=$e(i),E="clientHeight",T="clientWidth";A===fe(i)&&"static"!==xe(A=Le(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===zt||(s===Vt||s===qt)&&o===Yt)&&(y=Rt,m-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,m*=l?1:-1),s!==Vt&&(s!==zt&&s!==Rt||o!==Yt)||(v=qt,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&He),x=!0===h?function(t,e){var i=t.x,n=t.y,s=e.devicePixelRatio||1;return{x:we(i*s)/s||0,y:we(n*s)/s||0}}({x:f,y:m},fe(i)):{x:f,y:m};return f=x.x,m=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?m+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const Be={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:be(e.placement),variation:Fe(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,We(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,We(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var ze={passive:!0};const Re={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=fe(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,ze)})),a&&l.addEventListener("resize",i.update,ze),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,ze)})),a&&l.removeEventListener("resize",i.update,ze)}},data:{}};var qe={left:"right",right:"left",bottom:"top",top:"bottom"};function Ve(t){return t.replace(/left|right|bottom|top/g,(function(t){return qe[t]}))}var Ke={start:"end",end:"start"};function Qe(t){return t.replace(/start|end/g,(function(t){return Ke[t]}))}function Xe(t){var e=fe(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Ye(t){return Te(Le(t)).left+Xe(t).scrollLeft}function Ue(t){var e=xe(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ge(t){return["html","body","#document"].indexOf(ue(t))>=0?t.ownerDocument.body:me(t)&&Ue(t)?t:Ge(Se(t))}function Je(t,e){var i;void 0===e&&(e=[]);var n=Ge(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=fe(n),r=s?[o].concat(o.visualViewport||[],Ue(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Je(Se(r)))}function Ze(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function ti(t,e,i){return e===Gt?Ze(function(t,e){var i=fe(t),n=Le(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Ee();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+Ye(t),y:l}}(t,i)):pe(e)?function(t,e){var i=Te(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):Ze(function(t){var e,i=Le(t),n=Xe(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=ve(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=ve(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+Ye(t),l=-n.scrollTop;return"rtl"===xe(s||i).direction&&(a+=ve(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Le(t)))}function ei(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?be(s):null,r=s?Fe(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case zt:e={x:a,y:i.y-n.height};break;case Rt:e={x:a,y:i.y+i.height};break;case qt:e={x:i.x+i.width,y:l};break;case Vt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?Ie(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case Xt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Yt:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ii(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?Ut:a,c=i.rootBoundary,h=void 0===c?Gt:c,d=i.elementContext,u=void 0===d?Jt:d,f=i.altBoundary,p=void 0!==f&&f,m=i.padding,g=void 0===m?0:m,_=Pe("number"!=typeof g?g:Me(g,Qt)),b=u===Jt?Zt:Jt,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=Je(Se(t)),i=["absolute","fixed"].indexOf(xe(t).position)>=0&&me(t)?$e(t):t;return pe(i)?e.filter((function(t){return pe(t)&&Oe(t,i)&&"body"!==ue(t)})):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce((function(e,i){var s=ti(t,i,n);return e.top=ve(s.top,e.top),e.right=ye(s.right,e.right),e.bottom=ye(s.bottom,e.bottom),e.left=ve(s.left,e.left),e}),ti(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(pe(y)?y:y.contextElement||Le(t.elements.popper),l,h,r),A=Te(t.elements.reference),E=ei({reference:A,element:v,strategy:"absolute",placement:s}),T=Ze(Object.assign({},v,E)),C=u===Jt?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===Jt&&x){var k=x[s];Object.keys(O).forEach((function(t){var e=[qt,Rt].indexOf(t)>=0?1:-1,i=[zt,Rt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function ni(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?ee:l,h=Fe(n),d=h?a?te:te.filter((function(t){return Fe(t)===h})):Qt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ii(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[be(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const si={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=be(g),b=l||(_!==g&&p?function(t){if(be(t)===Kt)return[];var e=Ve(t);return[Qe(t),e,Qe(e)]}(g):[Ve(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(be(i)===Kt?ni(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C=0,S=L?"width":"height",D=ii(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),$=L?k?qt:Vt:k?Rt:zt;y[S]>w[S]&&($=Ve($));var I=Ve($),N=[];if(o&&N.push(D[x]<=0),a&&N.push(D[$]<=0,D[I]<=0),N.every((function(t){return t}))){T=O,E=!1;break}A.set(O,N)}if(E)for(var P=function(t){var e=v.find((function(e){var i=A.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},M=p?3:1;M>0&&"break"!==P(M);M--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function oi(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function ri(t){return[zt,qt,Rt,Vt].some((function(e){return t[e]>=0}))}const ai={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ii(e,{elementContext:"reference"}),a=ii(e,{altBoundary:!0}),l=oi(r,n),c=oi(a,s,o),h=ri(l),d=ri(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},li={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=ee.reduce((function(t,i){return t[i]=function(t,e,i){var n=be(t),s=[Vt,zt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[Vt,qt].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},ci={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=ei({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},hi={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ii(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=be(e.placement),b=Fe(e.placement),v=!b,y=Ie(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,S="y"===y?zt:Vt,D="y"===y?Rt:qt,$="y"===y?"height":"width",I=A[y],N=I+g[S],P=I-g[D],M=f?-T[$]/2:0,j=b===Xt?E[$]:T[$],F=b===Xt?-T[$]:-E[$],H=e.elements.arrow,W=f&&H?Ce(H):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=B[S],R=B[D],q=Ne(0,E[$],W[$]),V=v?E[$]/2-M-q-z-O.mainAxis:j-q-z-O.mainAxis,K=v?-E[$]/2+M+q+R+O.mainAxis:F+q+R+O.mainAxis,Q=e.elements.arrow&&$e(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=I+K-Y,G=Ne(f?ye(N,I+V-Y-X):N,I,f?ve(P,U):P);A[y]=G,k[y]=G-I}if(a){var J,Z="x"===y?zt:Vt,tt="x"===y?Rt:qt,et=A[w],it="y"===w?"height":"width",nt=et+g[Z],st=et-g[tt],ot=-1!==[zt,Vt].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=Ne(t,e,i);return n>i?i:n}(at,et,lt):Ne(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function di(t,e,i){void 0===i&&(i=!1);var n,s,o=me(e),r=me(e)&&function(t){var e=t.getBoundingClientRect(),i=we(e.width)/t.offsetWidth||1,n=we(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=Le(e),l=Te(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==ue(e)||Ue(a))&&(c=(n=e)!==fe(n)&&me(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:Xe(n)),me(e)?((h=Te(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=Ye(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function ui(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var fi={placement:"bottom",modifiers:[],strategy:"absolute"};function pi(){for(var t=arguments.length,e=new Array(t),i=0;iNumber.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(F.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...g(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=z.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>a(t)));i.length&&b(i,e,t===Ti,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=qi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=z.find(Ni);for(const i of e){const e=qi.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Ei,Ti].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Ii)?this:z.prev(this,Ii)[0]||z.next(this,Ii)[0]||z.findOne(Ii,t.delegateTarget.parentNode),o=qi.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}N.on(document,Si,Ii,qi.dataApiKeydownHandler),N.on(document,Si,Pi,qi.dataApiKeydownHandler),N.on(document,Li,qi.clearMenus),N.on(document,Di,qi.clearMenus),N.on(document,Li,Ii,(function(t){t.preventDefault(),qi.getOrCreateInstance(this).toggle()})),m(qi);const Vi="backdrop",Ki="show",Qi=`mousedown.bs.${Vi}`,Xi={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},Yi={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class Ui extends H{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return Xi}static get DefaultType(){return Yi}static get NAME(){return Vi}show(t){if(!this._config.isVisible)return void g(t);this._append();const e=this._getElement();this._config.isAnimated&&d(e),e.classList.add(Ki),this._emulateAnimation((()=>{g(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(Ki),this._emulateAnimation((()=>{this.dispose(),g(t)}))):g(t)}dispose(){this._isAppended&&(N.off(this._element,Qi),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=r(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),N.on(t,Qi,(()=>{g(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){_(t,this._getElement(),this._config.isAnimated)}}const Gi=".bs.focustrap",Ji=`focusin${Gi}`,Zi=`keydown.tab${Gi}`,tn="backward",en={autofocus:!0,trapElement:null},nn={autofocus:"boolean",trapElement:"element"};class sn extends H{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return en}static get DefaultType(){return nn}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),N.off(document,Gi),N.on(document,Ji,(t=>this._handleFocusin(t))),N.on(document,Zi,(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,N.off(document,Gi))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=z.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===tn?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?tn:"forward")}}const on=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",rn=".sticky-top",an="padding-right",ln="margin-right";class cn{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,an,(e=>e+t)),this._setElementAttributes(on,an,(e=>e+t)),this._setElementAttributes(rn,ln,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,an),this._resetElementAttributes(on,an),this._resetElementAttributes(rn,ln)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&F.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=F.getDataAttribute(t,e);null!==i?(F.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(o(t))e(t);else for(const i of z.find(t,this._element))e(i)}}const hn=".bs.modal",dn=`hide${hn}`,un=`hidePrevented${hn}`,fn=`hidden${hn}`,pn=`show${hn}`,mn=`shown${hn}`,gn=`resize${hn}`,_n=`click.dismiss${hn}`,bn=`mousedown.dismiss${hn}`,vn=`keydown.dismiss${hn}`,yn=`click${hn}.data-api`,wn="modal-open",An="show",En="modal-static",Tn={backdrop:!0,focus:!0,keyboard:!0},Cn={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class On extends W{constructor(t,e){super(t,e),this._dialog=z.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new cn,this._addEventListeners()}static get Default(){return Tn}static get DefaultType(){return Cn}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||N.trigger(this._element,pn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(wn),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(N.trigger(this._element,dn).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(An),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){N.off(window,hn),N.off(this._dialog,hn),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new Ui({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=z.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),d(this._element),this._element.classList.add(An),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,N.trigger(this._element,mn,{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){N.on(this._element,vn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),N.on(window,gn,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),N.on(this._element,bn,(t=>{N.one(this._element,_n,(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(wn),this._resetAdjustments(),this._scrollBar.reset(),N.trigger(this._element,fn)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(N.trigger(this._element,un).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(En)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(En),this._queueCallback((()=>{this._element.classList.remove(En),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=On.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}N.on(document,yn,'[data-bs-toggle="modal"]',(function(t){const e=z.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),N.one(e,pn,(t=>{t.defaultPrevented||N.one(e,fn,(()=>{a(this)&&this.focus()}))}));const i=z.findOne(".modal.show");i&&On.getInstance(i).hide(),On.getOrCreateInstance(e).toggle(this)})),R(On),m(On);const xn=".bs.offcanvas",kn=".data-api",Ln=`load${xn}${kn}`,Sn="show",Dn="showing",$n="hiding",In=".offcanvas.show",Nn=`show${xn}`,Pn=`shown${xn}`,Mn=`hide${xn}`,jn=`hidePrevented${xn}`,Fn=`hidden${xn}`,Hn=`resize${xn}`,Wn=`click${xn}${kn}`,Bn=`keydown.dismiss${xn}`,zn={backdrop:!0,keyboard:!0,scroll:!1},Rn={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class qn extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return zn}static get DefaultType(){return Rn}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||N.trigger(this._element,Nn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new cn).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Dn),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Sn),this._element.classList.remove(Dn),N.trigger(this._element,Pn,{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(N.trigger(this._element,Mn).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add($n),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(Sn,$n),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new cn).reset(),N.trigger(this._element,Fn)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new Ui({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():N.trigger(this._element,jn)}:null})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_addEventListeners(){N.on(this._element,Bn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():N.trigger(this._element,jn))}))}static jQueryInterface(t){return this.each((function(){const e=qn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}N.on(document,Wn,'[data-bs-toggle="offcanvas"]',(function(t){const e=z.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this))return;N.one(e,Fn,(()=>{a(this)&&this.focus()}));const i=z.findOne(In);i&&i!==e&&qn.getInstance(i).hide(),qn.getOrCreateInstance(e).toggle(this)})),N.on(window,Ln,(()=>{for(const t of z.find(In))qn.getOrCreateInstance(t).show()})),N.on(window,Hn,(()=>{for(const t of z.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&qn.getOrCreateInstance(t).hide()})),R(qn),m(qn);const Vn={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Kn=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Qn=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Xn=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!Kn.has(i)||Boolean(Qn.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},Yn={allowList:Vn,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Un={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Gn={entry:"(string|element|function|null)",selector:"(string|element)"};class Jn extends H{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Yn}static get DefaultType(){return Un}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},Gn)}_setContent(t,e,i){const n=z.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?o(e)?this._putElementInTemplate(r(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Xn(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return g(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Zn=new Set(["sanitize","allowList","sanitizeFn"]),ts="fade",es="show",is=".modal",ns="hide.bs.modal",ss="hover",os="focus",rs={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},as={allowList:Vn,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},ls={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class cs extends W{constructor(t,e){if(void 0===vi)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return as}static get DefaultType(){return ls}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),N.off(this._element.closest(is),ns,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=N.trigger(this._element,this.constructor.eventName("show")),e=(c(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),N.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.on(t,"mouseover",h);this._queueCallback((()=>{N.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!N.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.off(t,"mouseover",h);this._activeTrigger.click=!1,this._activeTrigger[os]=!1,this._activeTrigger[ss]=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),N.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(ts,es),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(ts),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Jn({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(ts)}_isShown(){return this.tip&&this.tip.classList.contains(es)}_createPopper(t){const e=g(this._config.placement,[this,t,this._element]),i=rs[e.toUpperCase()];return bi(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return g(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...g(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)N.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===ss?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===ss?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");N.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?os:ss]=!0,e._enter()})),N.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?os:ss]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},N.on(this._element.closest(is),ns,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=F.getDataAttributes(this._element);for(const t of Object.keys(e))Zn.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=cs.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(cs);const hs={...cs.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},ds={...cs.DefaultType,content:"(null|string|element|function)"};class us extends cs{static get Default(){return hs}static get DefaultType(){return ds}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=us.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(us);const fs=".bs.scrollspy",ps=`activate${fs}`,ms=`click${fs}`,gs=`load${fs}.data-api`,_s="active",bs="[href]",vs=".nav-link",ys=`${vs}, .nav-item > ${vs}, .list-group-item`,ws={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},As={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Es extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return ws}static get DefaultType(){return As}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=r(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(N.off(this._config.target,ms),N.on(this._config.target,ms,bs,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=z.find(bs,this._config.target);for(const e of t){if(!e.hash||l(e))continue;const t=z.findOne(decodeURI(e.hash),this._element);a(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(_s),this._activateParents(t),N.trigger(this._element,ps,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))z.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(_s);else for(const e of z.parents(t,".nav, .list-group"))for(const t of z.prev(e,ys))t.classList.add(_s)}_clearActiveClass(t){t.classList.remove(_s);const e=z.find(`${bs}.${_s}`,t);for(const t of e)t.classList.remove(_s)}static jQueryInterface(t){return this.each((function(){const e=Es.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(window,gs,(()=>{for(const t of z.find('[data-bs-spy="scroll"]'))Es.getOrCreateInstance(t)})),m(Es);const Ts=".bs.tab",Cs=`hide${Ts}`,Os=`hidden${Ts}`,xs=`show${Ts}`,ks=`shown${Ts}`,Ls=`click${Ts}`,Ss=`keydown${Ts}`,Ds=`load${Ts}`,$s="ArrowLeft",Is="ArrowRight",Ns="ArrowUp",Ps="ArrowDown",Ms="Home",js="End",Fs="active",Hs="fade",Ws="show",Bs=":not(.dropdown-toggle)",zs='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',Rs=`.nav-link${Bs}, .list-group-item${Bs}, [role="tab"]${Bs}, ${zs}`,qs=`.${Fs}[data-bs-toggle="tab"], .${Fs}[data-bs-toggle="pill"], .${Fs}[data-bs-toggle="list"]`;class Vs extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),N.on(this._element,Ss,(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?N.trigger(e,Cs,{relatedTarget:t}):null;N.trigger(t,xs,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(Fs),this._activate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),N.trigger(t,ks,{relatedTarget:e})):t.classList.add(Ws)}),t,t.classList.contains(Hs)))}_deactivate(t,e){t&&(t.classList.remove(Fs),t.blur(),this._deactivate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),N.trigger(t,Os,{relatedTarget:e})):t.classList.remove(Ws)}),t,t.classList.contains(Hs)))}_keydown(t){if(![$s,Is,Ns,Ps,Ms,js].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter((t=>!l(t)));let i;if([Ms,js].includes(t.key))i=e[t.key===Ms?0:e.length-1];else{const n=[Is,Ps].includes(t.key);i=b(e,t.target,n,!0)}i&&(i.focus({preventScroll:!0}),Vs.getOrCreateInstance(i).show())}_getChildren(){return z.find(Rs,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=z.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=z.findOne(t,i);s&&s.classList.toggle(n,e)};n(".dropdown-toggle",Fs),n(".dropdown-menu",Ws),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(Fs)}_getInnerElement(t){return t.matches(Rs)?t:z.findOne(Rs,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=Vs.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(document,Ls,zs,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this)||Vs.getOrCreateInstance(this).show()})),N.on(window,Ds,(()=>{for(const t of z.find(qs))Vs.getOrCreateInstance(t)})),m(Vs);const Ks=".bs.toast",Qs=`mouseover${Ks}`,Xs=`mouseout${Ks}`,Ys=`focusin${Ks}`,Us=`focusout${Ks}`,Gs=`hide${Ks}`,Js=`hidden${Ks}`,Zs=`show${Ks}`,to=`shown${Ks}`,eo="hide",io="show",no="showing",so={animation:"boolean",autohide:"boolean",delay:"number"},oo={animation:!0,autohide:!0,delay:5e3};class ro extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return oo}static get DefaultType(){return so}static get NAME(){return"toast"}show(){N.trigger(this._element,Zs).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(eo),d(this._element),this._element.classList.add(io,no),this._queueCallback((()=>{this._element.classList.remove(no),N.trigger(this._element,to),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(N.trigger(this._element,Gs).defaultPrevented||(this._element.classList.add(no),this._queueCallback((()=>{this._element.classList.add(eo),this._element.classList.remove(no,io),N.trigger(this._element,Js)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(io),super.dispose()}isShown(){return this._element.classList.contains(io)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){N.on(this._element,Qs,(t=>this._onInteraction(t,!0))),N.on(this._element,Xs,(t=>this._onInteraction(t,!1))),N.on(this._element,Ys,(t=>this._onInteraction(t,!0))),N.on(this._element,Us,(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=ro.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(ro),m(ro),{Alert:Q,Button:Y,Carousel:xt,Collapse:Bt,Dropdown:qi,Modal:On,Offcanvas:qn,Popover:us,ScrollSpy:Es,Tab:Vs,Toast:ro,Tooltip:cs}})); +//# sourceMappingURL=bootstrap.bundle.min.js.map \ No newline at end of file diff --git a/site_libs/clipboard/clipboard.min.js b/site_libs/clipboard/clipboard.min.js new file mode 100644 index 0000000..1103f81 --- /dev/null +++ b/site_libs/clipboard/clipboard.min.js @@ -0,0 +1,7 @@ +/*! + * clipboard.js v2.0.11 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return n={686:function(t,e,n){"use strict";n.d(e,{default:function(){return b}});var e=n(279),i=n.n(e),e=n(370),u=n.n(e),e=n(817),r=n.n(e);function c(t){try{return document.execCommand(t)}catch(t){return}}var a=function(t){t=r()(t);return c("cut"),t};function o(t,e){var n,o,t=(n=t,o="rtl"===document.documentElement.getAttribute("dir"),(t=document.createElement("textarea")).style.fontSize="12pt",t.style.border="0",t.style.padding="0",t.style.margin="0",t.style.position="absolute",t.style[o?"right":"left"]="-9999px",o=window.pageYOffset||document.documentElement.scrollTop,t.style.top="".concat(o,"px"),t.setAttribute("readonly",""),t.value=n,t);return e.container.appendChild(t),e=r()(t),c("copy"),t.remove(),e}var f=function(t){var e=11&&void 0!==arguments[1]?arguments[1]:null,i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,n=e[s]=e[s]||[],l={all:n,evt:null,found:null};return t&&i&&P(n)>0&&o(n,(function(e,n){if(e.eventName==t&&e.fn.toString()==i.toString())return l.found=!0,l.evt=n,!1})),l}function a(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=t.onElement,n=t.withCallback,s=t.avoidDuplicate,l=void 0===s||s,a=t.once,h=void 0!==a&&a,d=t.useCapture,c=void 0!==d&&d,u=arguments.length>2?arguments[2]:void 0,g=i||[];function v(e){T(n)&&n.call(u,e,this),h&&v.destroy()}return C(g)&&(g=document.querySelectorAll(g)),v.destroy=function(){o(g,(function(t){var i=r(t,e,v);i.found&&i.all.splice(i.evt,1),t.removeEventListener&&t.removeEventListener(e,v,c)}))},o(g,(function(t){var i=r(t,e,v);(t.addEventListener&&l&&!i.found||!l)&&(t.addEventListener(e,v,c),i.all.push({eventName:e,fn:v}))})),v}function h(e,t){o(t.split(" "),(function(t){return e.classList.add(t)}))}function d(e,t){o(t.split(" "),(function(t){return e.classList.remove(t)}))}function c(e,t){return e.classList.contains(t)}function u(e,t){for(;e!==document.body;){if(!(e=e.parentElement))return!1;if("function"==typeof e.matches?e.matches(t):e.msMatchesSelector(t))return e}}function g(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2&&void 0!==arguments[2]&&arguments[2];if(!e||""===t)return!1;if("none"===t)return T(i)&&i(),!1;var n=x(),s=t.split(" ");o(s,(function(t){h(e,"g"+t)})),a(n,{onElement:e,avoidDuplicate:!1,once:!0,withCallback:function(e,t){o(s,(function(e){d(t,"g"+e)})),T(i)&&i()}})}function v(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";if(""===t)return e.style.webkitTransform="",e.style.MozTransform="",e.style.msTransform="",e.style.OTransform="",e.style.transform="",!1;e.style.webkitTransform=t,e.style.MozTransform=t,e.style.msTransform=t,e.style.OTransform=t,e.style.transform=t}function f(e){e.style.display="block"}function p(e){e.style.display="none"}function m(e){var t=document.createDocumentFragment(),i=document.createElement("div");for(i.innerHTML=e;i.firstChild;)t.appendChild(i.firstChild);return t}function y(){return{width:window.innerWidth||document.documentElement.clientWidth||document.body.clientWidth,height:window.innerHeight||document.documentElement.clientHeight||document.body.clientHeight}}function x(){var e,t=document.createElement("fakeelement"),i={animation:"animationend",OAnimation:"oAnimationEnd",MozAnimation:"animationend",WebkitAnimation:"webkitAnimationEnd"};for(e in i)if(void 0!==t.style[e])return i[e]}function b(e,t,i,n){if(e())t();else{var s;i||(i=100);var l=setInterval((function(){e()&&(clearInterval(l),s&&clearTimeout(s),t())}),i);n&&(s=setTimeout((function(){clearInterval(l)}),n))}}function S(e,t,i){if(I(e))console.error("Inject assets error");else if(T(t)&&(i=t,t=!1),C(t)&&t in window)T(i)&&i();else{var n;if(-1!==e.indexOf(".css")){if((n=document.querySelectorAll('link[href="'+e+'"]'))&&n.length>0)return void(T(i)&&i());var s=document.getElementsByTagName("head")[0],l=s.querySelectorAll('link[rel="stylesheet"]'),o=document.createElement("link");return o.rel="stylesheet",o.type="text/css",o.href=e,o.media="all",l?s.insertBefore(o,l[0]):s.appendChild(o),void(T(i)&&i())}if((n=document.querySelectorAll('script[src="'+e+'"]'))&&n.length>0){if(T(i)){if(C(t))return b((function(){return void 0!==window[t]}),(function(){i()})),!1;i()}}else{var r=document.createElement("script");r.type="text/javascript",r.src=e,r.onload=function(){if(T(i)){if(C(t))return b((function(){return void 0!==window[t]}),(function(){i()})),!1;i()}},document.body.appendChild(r)}}}function w(){return"navigator"in window&&window.navigator.userAgent.match(/(iPad)|(iPhone)|(iPod)|(Android)|(PlayBook)|(BB10)|(BlackBerry)|(Opera Mini)|(IEMobile)|(webOS)|(MeeGo)/i)}function T(e){return"function"==typeof e}function C(e){return"string"==typeof e}function k(e){return!(!e||!e.nodeType||1!=e.nodeType)}function E(e){return Array.isArray(e)}function A(e){return e&&e.length&&isFinite(e.length)}function L(t){return"object"===e(t)&&null!=t&&!T(t)&&!E(t)}function I(e){return null==e}function O(e,t){return null!==e&&hasOwnProperty.call(e,t)}function P(e){if(L(e)){if(e.keys)return e.keys().length;var t=0;for(var i in e)O(e,i)&&t++;return t}return e.length}function M(e){return!isNaN(parseFloat(e))&&isFinite(e)}function z(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:-1,t=document.querySelectorAll(".gbtn[data-taborder]:not(.disabled)");if(!t.length)return!1;if(1==t.length)return t[0];"string"==typeof e&&(e=parseInt(e));var i=[];o(t,(function(e){i.push(e.getAttribute("data-taborder"))}));var n=Math.max.apply(Math,i.map((function(e){return parseInt(e)}))),s=e<0?1:e+1;s>n&&(s="1");var l=i.filter((function(e){return e>=parseInt(s)})),r=l.sort()[0];return document.querySelector('.gbtn[data-taborder="'.concat(r,'"]'))}function X(e){if(e.events.hasOwnProperty("keyboard"))return!1;e.events.keyboard=a("keydown",{onElement:window,withCallback:function(t,i){var n=(t=t||window.event).keyCode;if(9==n){var s=document.querySelector(".gbtn.focused");if(!s){var l=!(!document.activeElement||!document.activeElement.nodeName)&&document.activeElement.nodeName.toLocaleLowerCase();if("input"==l||"textarea"==l||"button"==l)return}t.preventDefault();var o=document.querySelectorAll(".gbtn[data-taborder]");if(!o||o.length<=0)return;if(!s){var r=z();return void(r&&(r.focus(),h(r,"focused")))}var a=z(s.getAttribute("data-taborder"));d(s,"focused"),a&&(a.focus(),h(a,"focused"))}39==n&&e.nextSlide(),37==n&&e.prevSlide(),27==n&&e.close()}})}function Y(e){return Math.sqrt(e.x*e.x+e.y*e.y)}function q(e,t){var i=function(e,t){var i=Y(e)*Y(t);if(0===i)return 0;var n=function(e,t){return e.x*t.x+e.y*t.y}(e,t)/i;return n>1&&(n=1),Math.acos(n)}(e,t);return function(e,t){return e.x*t.y-t.x*e.y}(e,t)>0&&(i*=-1),180*i/Math.PI}var N=function(){function e(i){t(this,e),this.handlers=[],this.el=i}return n(e,[{key:"add",value:function(e){this.handlers.push(e)}},{key:"del",value:function(e){e||(this.handlers=[]);for(var t=this.handlers.length;t>=0;t--)this.handlers[t]===e&&this.handlers.splice(t,1)}},{key:"dispatch",value:function(){for(var e=0,t=this.handlers.length;e=0)console.log("ignore drag for this touched element",e.target.nodeName.toLowerCase());else{this.now=Date.now(),this.x1=e.touches[0].pageX,this.y1=e.touches[0].pageY,this.delta=this.now-(this.last||this.now),this.touchStart.dispatch(e,this.element),null!==this.preTapPosition.x&&(this.isDoubleTap=this.delta>0&&this.delta<=250&&Math.abs(this.preTapPosition.x-this.x1)<30&&Math.abs(this.preTapPosition.y-this.y1)<30,this.isDoubleTap&&clearTimeout(this.singleTapTimeout)),this.preTapPosition.x=this.x1,this.preTapPosition.y=this.y1,this.last=this.now;var t=this.preV;if(e.touches.length>1){this._cancelLongTap(),this._cancelSingleTap();var i={x:e.touches[1].pageX-this.x1,y:e.touches[1].pageY-this.y1};t.x=i.x,t.y=i.y,this.pinchStartLen=Y(t),this.multipointStart.dispatch(e,this.element)}this._preventTap=!1,this.longTapTimeout=setTimeout(function(){this.longTap.dispatch(e,this.element),this._preventTap=!0}.bind(this),750)}}}},{key:"move",value:function(e){if(e.touches){var t=this.preV,i=e.touches.length,n=e.touches[0].pageX,s=e.touches[0].pageY;if(this.isDoubleTap=!1,i>1){var l=e.touches[1].pageX,o=e.touches[1].pageY,r={x:e.touches[1].pageX-n,y:e.touches[1].pageY-s};null!==t.x&&(this.pinchStartLen>0&&(e.zoom=Y(r)/this.pinchStartLen,this.pinch.dispatch(e,this.element)),e.angle=q(r,t),this.rotate.dispatch(e,this.element)),t.x=r.x,t.y=r.y,null!==this.x2&&null!==this.sx2?(e.deltaX=(n-this.x2+l-this.sx2)/2,e.deltaY=(s-this.y2+o-this.sy2)/2):(e.deltaX=0,e.deltaY=0),this.twoFingerPressMove.dispatch(e,this.element),this.sx2=l,this.sy2=o}else{if(null!==this.x2){e.deltaX=n-this.x2,e.deltaY=s-this.y2;var a=Math.abs(this.x1-this.x2),h=Math.abs(this.y1-this.y2);(a>10||h>10)&&(this._preventTap=!0)}else e.deltaX=0,e.deltaY=0;this.pressMove.dispatch(e,this.element)}this.touchMove.dispatch(e,this.element),this._cancelLongTap(),this.x2=n,this.y2=s,i>1&&e.preventDefault()}}},{key:"end",value:function(e){if(e.changedTouches){this._cancelLongTap();var t=this;e.touches.length<2&&(this.multipointEnd.dispatch(e,this.element),this.sx2=this.sy2=null),this.x2&&Math.abs(this.x1-this.x2)>30||this.y2&&Math.abs(this.y1-this.y2)>30?(e.direction=this._swipeDirection(this.x1,this.x2,this.y1,this.y2),this.swipeTimeout=setTimeout((function(){t.swipe.dispatch(e,t.element)}),0)):(this.tapTimeout=setTimeout((function(){t._preventTap||t.tap.dispatch(e,t.element),t.isDoubleTap&&(t.doubleTap.dispatch(e,t.element),t.isDoubleTap=!1)}),0),t.isDoubleTap||(t.singleTapTimeout=setTimeout((function(){t.singleTap.dispatch(e,t.element)}),250))),this.touchEnd.dispatch(e,this.element),this.preV.x=0,this.preV.y=0,this.zoom=1,this.pinchStartLen=null,this.x1=this.x2=this.y1=this.y2=null}}},{key:"cancelAll",value:function(){this._preventTap=!0,clearTimeout(this.singleTapTimeout),clearTimeout(this.tapTimeout),clearTimeout(this.longTapTimeout),clearTimeout(this.swipeTimeout)}},{key:"cancel",value:function(e){this.cancelAll(),this.touchCancel.dispatch(e,this.element)}},{key:"_cancelLongTap",value:function(){clearTimeout(this.longTapTimeout)}},{key:"_cancelSingleTap",value:function(){clearTimeout(this.singleTapTimeout)}},{key:"_swipeDirection",value:function(e,t,i,n){return Math.abs(e-t)>=Math.abs(i-n)?e-t>0?"Left":"Right":i-n>0?"Up":"Down"}},{key:"on",value:function(e,t){this[e]&&this[e].add(t)}},{key:"off",value:function(e,t){this[e]&&this[e].del(t)}},{key:"destroy",value:function(){return this.singleTapTimeout&&clearTimeout(this.singleTapTimeout),this.tapTimeout&&clearTimeout(this.tapTimeout),this.longTapTimeout&&clearTimeout(this.longTapTimeout),this.swipeTimeout&&clearTimeout(this.swipeTimeout),this.element.removeEventListener("touchstart",this.start),this.element.removeEventListener("touchmove",this.move),this.element.removeEventListener("touchend",this.end),this.element.removeEventListener("touchcancel",this.cancel),this.rotate.del(),this.touchStart.del(),this.multipointStart.del(),this.multipointEnd.del(),this.pinch.del(),this.swipe.del(),this.tap.del(),this.doubleTap.del(),this.longTap.del(),this.singleTap.del(),this.pressMove.del(),this.twoFingerPressMove.del(),this.touchMove.del(),this.touchEnd.del(),this.touchCancel.del(),this.preV=this.pinchStartLen=this.zoom=this.isDoubleTap=this.delta=this.last=this.now=this.tapTimeout=this.singleTapTimeout=this.longTapTimeout=this.swipeTimeout=this.x1=this.x2=this.y1=this.y2=this.preTapPosition=this.rotate=this.touchStart=this.multipointStart=this.multipointEnd=this.pinch=this.swipe=this.tap=this.doubleTap=this.longTap=this.singleTap=this.pressMove=this.touchMove=this.touchEnd=this.touchCancel=this.twoFingerPressMove=null,window.removeEventListener("scroll",this._cancelAllHandler),null}}]),e}();function W(e){var t=function(){var e,t=document.createElement("fakeelement"),i={transition:"transitionend",OTransition:"oTransitionEnd",MozTransition:"transitionend",WebkitTransition:"webkitTransitionEnd"};for(e in i)if(void 0!==t.style[e])return i[e]}(),i=window.innerWidth||document.documentElement.clientWidth||document.body.clientWidth,n=c(e,"gslide-media")?e:e.querySelector(".gslide-media"),s=u(n,".ginner-container"),l=e.querySelector(".gslide-description");i>769&&(n=s),h(n,"greset"),v(n,"translate3d(0, 0, 0)"),a(t,{onElement:n,once:!0,withCallback:function(e,t){d(n,"greset")}}),n.style.opacity="",l&&(l.style.opacity="")}function B(e){if(e.events.hasOwnProperty("touch"))return!1;var t,i,n,s=y(),l=s.width,o=s.height,r=!1,a=null,g=null,f=null,p=!1,m=1,x=1,b=!1,S=!1,w=null,T=null,C=null,k=null,E=0,A=0,L=!1,I=!1,O={},P={},M=0,z=0,X=document.getElementById("glightbox-slider"),Y=document.querySelector(".goverlay"),q=new _(X,{touchStart:function(t){if(r=!0,(c(t.targetTouches[0].target,"ginner-container")||u(t.targetTouches[0].target,".gslide-desc")||"a"==t.targetTouches[0].target.nodeName.toLowerCase())&&(r=!1),u(t.targetTouches[0].target,".gslide-inline")&&!c(t.targetTouches[0].target.parentNode,"gslide-inline")&&(r=!1),r){if(P=t.targetTouches[0],O.pageX=t.targetTouches[0].pageX,O.pageY=t.targetTouches[0].pageY,M=t.targetTouches[0].clientX,z=t.targetTouches[0].clientY,a=e.activeSlide,g=a.querySelector(".gslide-media"),n=a.querySelector(".gslide-inline"),f=null,c(g,"gslide-image")&&(f=g.querySelector("img")),(window.innerWidth||document.documentElement.clientWidth||document.body.clientWidth)>769&&(g=a.querySelector(".ginner-container")),d(Y,"greset"),t.pageX>20&&t.pageXo){var a=O.pageX-P.pageX;if(Math.abs(a)<=13)return!1}p=!0;var h,d=s.targetTouches[0].clientX,c=s.targetTouches[0].clientY,u=M-d,m=z-c;if(Math.abs(u)>Math.abs(m)?(L=!1,I=!0):(I=!1,L=!0),t=P.pageX-O.pageX,E=100*t/l,i=P.pageY-O.pageY,A=100*i/o,L&&f&&(h=1-Math.abs(i)/o,Y.style.opacity=h,e.settings.touchFollowAxis&&(E=0)),I&&(h=1-Math.abs(t)/l,g.style.opacity=h,e.settings.touchFollowAxis&&(A=0)),!f)return v(g,"translate3d(".concat(E,"%, 0, 0)"));v(g,"translate3d(".concat(E,"%, ").concat(A,"%, 0)"))}},touchEnd:function(){if(r){if(p=!1,S||b)return C=w,void(k=T);var t=Math.abs(parseInt(A)),i=Math.abs(parseInt(E));if(!(t>29&&f))return t<29&&i<25?(h(Y,"greset"),Y.style.opacity=1,W(g)):void 0;e.close()}},multipointEnd:function(){setTimeout((function(){b=!1}),50)},multipointStart:function(){b=!0,m=x||1},pinch:function(e){if(!f||p)return!1;b=!0,f.scaleX=f.scaleY=m*e.zoom;var t=m*e.zoom;if(S=!0,t<=1)return S=!1,t=1,k=null,C=null,w=null,T=null,void f.setAttribute("style","");t>4.5&&(t=4.5),f.style.transform="scale3d(".concat(t,", ").concat(t,", 1)"),x=t},pressMove:function(e){if(S&&!b){var t=P.pageX-O.pageX,i=P.pageY-O.pageY;C&&(t+=C),k&&(i+=k),w=t,T=i;var n="translate3d(".concat(t,"px, ").concat(i,"px, 0)");x&&(n+=" scale3d(".concat(x,", ").concat(x,", 1)")),v(f,n)}},swipe:function(t){if(!S)if(b)b=!1;else{if("Left"==t.direction){if(e.index==e.elements.length-1)return W(g);e.nextSlide()}if("Right"==t.direction){if(0==e.index)return W(g);e.prevSlide()}}}});e.events.touch=q}var H=function(){function e(i,n){var s=this,l=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;if(t(this,e),this.img=i,this.slide=n,this.onclose=l,this.img.setZoomEvents)return!1;this.active=!1,this.zoomedIn=!1,this.dragging=!1,this.currentX=null,this.currentY=null,this.initialX=null,this.initialY=null,this.xOffset=0,this.yOffset=0,this.img.addEventListener("mousedown",(function(e){return s.dragStart(e)}),!1),this.img.addEventListener("mouseup",(function(e){return s.dragEnd(e)}),!1),this.img.addEventListener("mousemove",(function(e){return s.drag(e)}),!1),this.img.addEventListener("click",(function(e){return s.slide.classList.contains("dragging-nav")?(s.zoomOut(),!1):s.zoomedIn?void(s.zoomedIn&&!s.dragging&&s.zoomOut()):s.zoomIn()}),!1),this.img.setZoomEvents=!0}return n(e,[{key:"zoomIn",value:function(){var e=this.widowWidth();if(!(this.zoomedIn||e<=768)){var t=this.img;if(t.setAttribute("data-style",t.getAttribute("style")),t.style.maxWidth=t.naturalWidth+"px",t.style.maxHeight=t.naturalHeight+"px",t.naturalWidth>e){var i=e/2-t.naturalWidth/2;this.setTranslate(this.img.parentNode,i,0)}this.slide.classList.add("zoomed"),this.zoomedIn=!0}}},{key:"zoomOut",value:function(){this.img.parentNode.setAttribute("style",""),this.img.setAttribute("style",this.img.getAttribute("data-style")),this.slide.classList.remove("zoomed"),this.zoomedIn=!1,this.currentX=null,this.currentY=null,this.initialX=null,this.initialY=null,this.xOffset=0,this.yOffset=0,this.onclose&&"function"==typeof this.onclose&&this.onclose()}},{key:"dragStart",value:function(e){e.preventDefault(),this.zoomedIn?("touchstart"===e.type?(this.initialX=e.touches[0].clientX-this.xOffset,this.initialY=e.touches[0].clientY-this.yOffset):(this.initialX=e.clientX-this.xOffset,this.initialY=e.clientY-this.yOffset),e.target===this.img&&(this.active=!0,this.img.classList.add("dragging"))):this.active=!1}},{key:"dragEnd",value:function(e){var t=this;e.preventDefault(),this.initialX=this.currentX,this.initialY=this.currentY,this.active=!1,setTimeout((function(){t.dragging=!1,t.img.isDragging=!1,t.img.classList.remove("dragging")}),100)}},{key:"drag",value:function(e){this.active&&(e.preventDefault(),"touchmove"===e.type?(this.currentX=e.touches[0].clientX-this.initialX,this.currentY=e.touches[0].clientY-this.initialY):(this.currentX=e.clientX-this.initialX,this.currentY=e.clientY-this.initialY),this.xOffset=this.currentX,this.yOffset=this.currentY,this.img.isDragging=!0,this.dragging=!0,this.setTranslate(this.img,this.currentX,this.currentY))}},{key:"onMove",value:function(e){if(this.zoomedIn){var t=e.clientX-this.img.naturalWidth/2,i=e.clientY-this.img.naturalHeight/2;this.setTranslate(this.img,t,i)}}},{key:"setTranslate",value:function(e,t,i){e.style.transform="translate3d("+t+"px, "+i+"px, 0)"}},{key:"widowWidth",value:function(){return window.innerWidth||document.documentElement.clientWidth||document.body.clientWidth}}]),e}(),V=function(){function e(){var i=this,n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};t(this,e);var s=n.dragEl,l=n.toleranceX,o=void 0===l?40:l,r=n.toleranceY,a=void 0===r?65:r,h=n.slide,d=void 0===h?null:h,c=n.instance,u=void 0===c?null:c;this.el=s,this.active=!1,this.dragging=!1,this.currentX=null,this.currentY=null,this.initialX=null,this.initialY=null,this.xOffset=0,this.yOffset=0,this.direction=null,this.lastDirection=null,this.toleranceX=o,this.toleranceY=a,this.toleranceReached=!1,this.dragContainer=this.el,this.slide=d,this.instance=u,this.el.addEventListener("mousedown",(function(e){return i.dragStart(e)}),!1),this.el.addEventListener("mouseup",(function(e){return i.dragEnd(e)}),!1),this.el.addEventListener("mousemove",(function(e){return i.drag(e)}),!1)}return n(e,[{key:"dragStart",value:function(e){if(this.slide.classList.contains("zoomed"))this.active=!1;else{"touchstart"===e.type?(this.initialX=e.touches[0].clientX-this.xOffset,this.initialY=e.touches[0].clientY-this.yOffset):(this.initialX=e.clientX-this.xOffset,this.initialY=e.clientY-this.yOffset);var t=e.target.nodeName.toLowerCase();e.target.classList.contains("nodrag")||u(e.target,".nodrag")||-1!==["input","select","textarea","button","a"].indexOf(t)?this.active=!1:(e.preventDefault(),(e.target===this.el||"img"!==t&&u(e.target,".gslide-inline"))&&(this.active=!0,this.el.classList.add("dragging"),this.dragContainer=u(e.target,".ginner-container")))}}},{key:"dragEnd",value:function(e){var t=this;e&&e.preventDefault(),this.initialX=0,this.initialY=0,this.currentX=null,this.currentY=null,this.initialX=null,this.initialY=null,this.xOffset=0,this.yOffset=0,this.active=!1,this.doSlideChange&&(this.instance.preventOutsideClick=!0,"right"==this.doSlideChange&&this.instance.prevSlide(),"left"==this.doSlideChange&&this.instance.nextSlide()),this.doSlideClose&&this.instance.close(),this.toleranceReached||this.setTranslate(this.dragContainer,0,0,!0),setTimeout((function(){t.instance.preventOutsideClick=!1,t.toleranceReached=!1,t.lastDirection=null,t.dragging=!1,t.el.isDragging=!1,t.el.classList.remove("dragging"),t.slide.classList.remove("dragging-nav"),t.dragContainer.style.transform="",t.dragContainer.style.transition=""}),100)}},{key:"drag",value:function(e){if(this.active){e.preventDefault(),this.slide.classList.add("dragging-nav"),"touchmove"===e.type?(this.currentX=e.touches[0].clientX-this.initialX,this.currentY=e.touches[0].clientY-this.initialY):(this.currentX=e.clientX-this.initialX,this.currentY=e.clientY-this.initialY),this.xOffset=this.currentX,this.yOffset=this.currentY,this.el.isDragging=!0,this.dragging=!0,this.doSlideChange=!1,this.doSlideClose=!1;var t=Math.abs(this.currentX),i=Math.abs(this.currentY);if(t>0&&t>=Math.abs(this.currentY)&&(!this.lastDirection||"x"==this.lastDirection)){this.yOffset=0,this.lastDirection="x",this.setTranslate(this.dragContainer,this.currentX,0);var n=this.shouldChange();if(!this.instance.settings.dragAutoSnap&&n&&(this.doSlideChange=n),this.instance.settings.dragAutoSnap&&n)return this.instance.preventOutsideClick=!0,this.toleranceReached=!0,this.active=!1,this.instance.preventOutsideClick=!0,this.dragEnd(null),"right"==n&&this.instance.prevSlide(),void("left"==n&&this.instance.nextSlide())}if(this.toleranceY>0&&i>0&&i>=t&&(!this.lastDirection||"y"==this.lastDirection)){this.xOffset=0,this.lastDirection="y",this.setTranslate(this.dragContainer,0,this.currentY);var s=this.shouldClose();return!this.instance.settings.dragAutoSnap&&s&&(this.doSlideClose=!0),void(this.instance.settings.dragAutoSnap&&s&&this.instance.close())}}}},{key:"shouldChange",value:function(){var e=!1;if(Math.abs(this.currentX)>=this.toleranceX){var t=this.currentX>0?"right":"left";("left"==t&&this.slide!==this.slide.parentNode.lastChild||"right"==t&&this.slide!==this.slide.parentNode.firstChild)&&(e=t)}return e}},{key:"shouldClose",value:function(){var e=!1;return Math.abs(this.currentY)>=this.toleranceY&&(e=!0),e}},{key:"setTranslate",value:function(e,t,i){var n=arguments.length>3&&void 0!==arguments[3]&&arguments[3];e.style.transition=n?"all .2s ease":"",e.style.transform="translate3d(".concat(t,"px, ").concat(i,"px, 0)")}}]),e}();function j(e,t,i,n){var s=e.querySelector(".gslide-media"),l=new Image,o="gSlideTitle_"+i,r="gSlideDesc_"+i;l.addEventListener("load",(function(){T(n)&&n()}),!1),l.src=t.href,""!=t.sizes&&""!=t.srcset&&(l.sizes=t.sizes,l.srcset=t.srcset),l.alt="",I(t.alt)||""===t.alt||(l.alt=t.alt),""!==t.title&&l.setAttribute("aria-labelledby",o),""!==t.description&&l.setAttribute("aria-describedby",r),t.hasOwnProperty("_hasCustomWidth")&&t._hasCustomWidth&&(l.style.width=t.width),t.hasOwnProperty("_hasCustomHeight")&&t._hasCustomHeight&&(l.style.height=t.height),s.insertBefore(l,s.firstChild)}function F(e,t,i,n){var s=this,l=e.querySelector(".ginner-container"),o="gvideo"+i,r=e.querySelector(".gslide-media"),a=this.getAllPlayers();h(l,"gvideo-container"),r.insertBefore(m('
'),r.firstChild);var d=e.querySelector(".gvideo-wrapper");S(this.settings.plyr.css,"Plyr");var c=t.href,u=null==t?void 0:t.videoProvider,g=!1;r.style.maxWidth=t.width,S(this.settings.plyr.js,"Plyr",(function(){if(!u&&c.match(/vimeo\.com\/([0-9]*)/)&&(u="vimeo"),!u&&(c.match(/(youtube\.com|youtube-nocookie\.com)\/watch\?v=([a-zA-Z0-9\-_]+)/)||c.match(/youtu\.be\/([a-zA-Z0-9\-_]+)/)||c.match(/(youtube\.com|youtube-nocookie\.com)\/embed\/([a-zA-Z0-9\-_]+)/))&&(u="youtube"),"local"===u||!u){u="local";var l='")}var r=g||m('
'));h(d,"".concat(u,"-video gvideo")),d.appendChild(r),d.setAttribute("data-id",o),d.setAttribute("data-index",i);var v=O(s.settings.plyr,"config")?s.settings.plyr.config:{},f=new Plyr("#"+o,v);f.on("ready",(function(e){a[o]=e.detail.plyr,T(n)&&n()})),b((function(){return e.querySelector("iframe")&&"true"==e.querySelector("iframe").dataset.ready}),(function(){s.resize(e)})),f.on("enterfullscreen",R),f.on("exitfullscreen",R)}))}function R(e){var t=u(e.target,".gslide-media");"enterfullscreen"===e.type&&h(t,"fullscreen"),"exitfullscreen"===e.type&&d(t,"fullscreen")}function G(e,t,i,n){var s,l=this,o=e.querySelector(".gslide-media"),r=!(!O(t,"href")||!t.href)&&t.href.split("#").pop().trim(),d=!(!O(t,"content")||!t.content)&&t.content;if(d&&(C(d)&&(s=m('
'.concat(d,"
"))),k(d))){"none"==d.style.display&&(d.style.display="block");var c=document.createElement("div");c.className="ginlined-content",c.appendChild(d),s=c}if(r){var u=document.getElementById(r);if(!u)return!1;var g=u.cloneNode(!0);g.style.height=t.height,g.style.maxWidth=t.width,h(g,"ginlined-content"),s=g}if(!s)return console.error("Unable to append inline slide content",t),!1;o.style.height=t.height,o.style.width=t.width,o.appendChild(s),this.events["inlineclose"+r]=a("click",{onElement:o.querySelectorAll(".gtrigger-close"),withCallback:function(e){e.preventDefault(),l.close()}}),T(n)&&n()}function Z(e,t,i,n){var s=e.querySelector(".gslide-media"),l=function(e){var t=e.url,i=e.allow,n=e.callback,s=e.appendTo,l=document.createElement("iframe");return l.className="vimeo-video gvideo",l.src=t,l.style.width="100%",l.style.height="100%",i&&l.setAttribute("allow",i),l.onload=function(){l.onload=null,h(l,"node-ready"),T(n)&&n()},s&&s.appendChild(l),l}({url:t.href,callback:n});s.parentNode.style.maxWidth=t.width,s.parentNode.style.height=t.height,s.appendChild(l)}var U=function(){function e(){var i=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};t(this,e),this.defaults={href:"",sizes:"",srcset:"",title:"",type:"",videoProvider:"",description:"",alt:"",descPosition:"bottom",effect:"",width:"",height:"",content:!1,zoomable:!0,draggable:!0},L(i)&&(this.defaults=l(this.defaults,i))}return n(e,[{key:"sourceType",value:function(e){var t=e;if(null!==(e=e.toLowerCase()).match(/\.(jpeg|jpg|jpe|gif|png|apn|webp|avif|svg)/))return"image";if(e.match(/(youtube\.com|youtube-nocookie\.com)\/watch\?v=([a-zA-Z0-9\-_]+)/)||e.match(/youtu\.be\/([a-zA-Z0-9\-_]+)/)||e.match(/(youtube\.com|youtube-nocookie\.com)\/embed\/([a-zA-Z0-9\-_]+)/))return"video";if(e.match(/vimeo\.com\/([0-9]*)/))return"video";if(null!==e.match(/\.(mp4|ogg|webm|mov)/))return"video";if(null!==e.match(/\.(mp3|wav|wma|aac|ogg)/))return"audio";if(e.indexOf("#")>-1&&""!==t.split("#").pop().trim())return"inline";return e.indexOf("goajax=true")>-1?"ajax":"external"}},{key:"parseConfig",value:function(e,t){var i=this,n=l({descPosition:t.descPosition},this.defaults);if(L(e)&&!k(e)){O(e,"type")||(O(e,"content")&&e.content?e.type="inline":O(e,"href")&&(e.type=this.sourceType(e.href)));var s=l(n,e);return this.setSize(s,t),s}var r="",a=e.getAttribute("data-glightbox"),h=e.nodeName.toLowerCase();if("a"===h&&(r=e.href),"img"===h&&(r=e.src,n.alt=e.alt),n.href=r,o(n,(function(s,l){O(t,l)&&"width"!==l&&(n[l]=t[l]);var o=e.dataset[l];I(o)||(n[l]=i.sanitizeValue(o))})),n.content&&(n.type="inline"),!n.type&&r&&(n.type=this.sourceType(r)),I(a)){if(!n.title&&"a"==h){var d=e.title;I(d)||""===d||(n.title=d)}if(!n.title&&"img"==h){var c=e.alt;I(c)||""===c||(n.title=c)}}else{var u=[];o(n,(function(e,t){u.push(";\\s?"+t)})),u=u.join("\\s?:|"),""!==a.trim()&&o(n,(function(e,t){var s=a,l=new RegExp("s?"+t+"s?:s?(.*?)("+u+"s?:|$)"),o=s.match(l);if(o&&o.length&&o[1]){var r=o[1].trim().replace(/;\s*$/,"");n[t]=i.sanitizeValue(r)}}))}if(n.description&&"."===n.description.substring(0,1)){var g;try{g=document.querySelector(n.description).innerHTML}catch(e){if(!(e instanceof DOMException))throw e}g&&(n.description=g)}if(!n.description){var v=e.querySelector(".glightbox-desc");v&&(n.description=v.innerHTML)}return this.setSize(n,t,e),this.slideConfig=n,n}},{key:"setSize",value:function(e,t){var i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,n="video"==e.type?this.checkSize(t.videosWidth):this.checkSize(t.width),s=this.checkSize(t.height);return e.width=O(e,"width")&&""!==e.width?this.checkSize(e.width):n,e.height=O(e,"height")&&""!==e.height?this.checkSize(e.height):s,i&&"image"==e.type&&(e._hasCustomWidth=!!i.dataset.width,e._hasCustomHeight=!!i.dataset.height),e}},{key:"checkSize",value:function(e){return M(e)?"".concat(e,"px"):e}},{key:"sanitizeValue",value:function(e){return"true"!==e&&"false"!==e?e:"true"===e}}]),e}(),$=function(){function e(i,n,s){t(this,e),this.element=i,this.instance=n,this.index=s}return n(e,[{key:"setContent",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null,i=arguments.length>1&&void 0!==arguments[1]&&arguments[1];if(c(t,"loaded"))return!1;var n=this.instance.settings,s=this.slideConfig,l=w();T(n.beforeSlideLoad)&&n.beforeSlideLoad({index:this.index,slide:t,player:!1});var o=s.type,r=s.descPosition,a=t.querySelector(".gslide-media"),d=t.querySelector(".gslide-title"),u=t.querySelector(".gslide-desc"),g=t.querySelector(".gdesc-inner"),v=i,f="gSlideTitle_"+this.index,p="gSlideDesc_"+this.index;if(T(n.afterSlideLoad)&&(v=function(){T(i)&&i(),n.afterSlideLoad({index:e.index,slide:t,player:e.instance.getSlidePlayerInstance(e.index)})}),""==s.title&&""==s.description?g&&g.parentNode.parentNode.removeChild(g.parentNode):(d&&""!==s.title?(d.id=f,d.innerHTML=s.title):d.parentNode.removeChild(d),u&&""!==s.description?(u.id=p,l&&n.moreLength>0?(s.smallDescription=this.slideShortDesc(s.description,n.moreLength,n.moreText),u.innerHTML=s.smallDescription,this.descriptionEvents(u,s)):u.innerHTML=s.description):u.parentNode.removeChild(u),h(a.parentNode,"desc-".concat(r)),h(g.parentNode,"description-".concat(r))),h(a,"gslide-".concat(o)),h(t,"loaded"),"video"!==o){if("external"!==o)return"inline"===o?(G.apply(this.instance,[t,s,this.index,v]),void(s.draggable&&new V({dragEl:t.querySelector(".gslide-inline"),toleranceX:n.dragToleranceX,toleranceY:n.dragToleranceY,slide:t,instance:this.instance}))):void("image"!==o?T(v)&&v():j(t,s,this.index,(function(){var i=t.querySelector("img");s.draggable&&new V({dragEl:i,toleranceX:n.dragToleranceX,toleranceY:n.dragToleranceY,slide:t,instance:e.instance}),s.zoomable&&i.naturalWidth>i.offsetWidth&&(h(i,"zoomable"),new H(i,t,(function(){e.instance.resize()}))),T(v)&&v()})));Z.apply(this,[t,s,this.index,v])}else F.apply(this.instance,[t,s,this.index,v])}},{key:"slideShortDesc",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:50,i=arguments.length>2&&void 0!==arguments[2]&&arguments[2],n=document.createElement("div");n.innerHTML=e;var s=n.innerText,l=i;if((e=s.trim()).length<=t)return e;var o=e.substr(0,t-1);return l?(n=null,o+'... '+i+""):o}},{key:"descriptionEvents",value:function(e,t){var i=this,n=e.querySelector(".desc-more");if(!n)return!1;a("click",{onElement:n,withCallback:function(e,n){e.preventDefault();var s=document.body,l=u(n,".gslide-desc");if(!l)return!1;l.innerHTML=t.description,h(s,"gdesc-open");var o=a("click",{onElement:[s,u(l,".gslide-description")],withCallback:function(e,n){"a"!==e.target.nodeName.toLowerCase()&&(d(s,"gdesc-open"),h(s,"gdesc-closed"),l.innerHTML=t.smallDescription,i.descriptionEvents(l,t),setTimeout((function(){d(s,"gdesc-closed")}),400),o.destroy())}})}})}},{key:"create",value:function(){return m(this.instance.settings.slideHTML)}},{key:"getConfig",value:function(){k(this.element)||this.element.hasOwnProperty("draggable")||(this.element.draggable=this.instance.settings.draggable);var e=new U(this.instance.settings.slideExtraAttributes);return this.slideConfig=e.parseConfig(this.element,this.instance.settings),this.slideConfig}}]),e}(),J=w(),K=null!==w()||void 0!==document.createTouch||"ontouchstart"in window||"onmsgesturechange"in window||navigator.msMaxTouchPoints,Q=document.getElementsByTagName("html")[0],ee={selector:".glightbox",elements:null,skin:"clean",theme:"clean",closeButton:!0,startAt:null,autoplayVideos:!0,autofocusVideos:!0,descPosition:"bottom",width:"900px",height:"506px",videosWidth:"960px",beforeSlideChange:null,afterSlideChange:null,beforeSlideLoad:null,afterSlideLoad:null,slideInserted:null,slideRemoved:null,slideExtraAttributes:null,onOpen:null,onClose:null,loop:!1,zoomable:!0,draggable:!0,dragAutoSnap:!1,dragToleranceX:40,dragToleranceY:65,preload:!0,oneSlidePerOpen:!1,touchNavigation:!0,touchFollowAxis:!0,keyboardNavigation:!0,closeOnOutsideClick:!0,plugins:!1,plyr:{css:"https://cdn.plyr.io/3.6.12/plyr.css",js:"https://cdn.plyr.io/3.6.12/plyr.js",config:{ratio:"16:9",fullscreen:{enabled:!0,iosNative:!0},youtube:{noCookie:!0,rel:0,showinfo:0,iv_load_policy:3},vimeo:{byline:!1,portrait:!1,title:!1,transparent:!1}}},openEffect:"zoom",closeEffect:"zoom",slideEffect:"slide",moreText:"See more",moreLength:60,cssEfects:{fade:{in:"fadeIn",out:"fadeOut"},zoom:{in:"zoomIn",out:"zoomOut"},slide:{in:"slideInRight",out:"slideOutLeft"},slideBack:{in:"slideInLeft",out:"slideOutRight"},none:{in:"none",out:"none"}},svg:{close:'',next:' ',prev:''},slideHTML:'
\n
\n
\n
\n
\n
\n
\n

\n
\n
\n
\n
\n
\n
',lightboxHTML:''},te=function(){function e(){var i=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};t(this,e),this.customOptions=i,this.settings=l(ee,i),this.effectsClasses=this.getAnimationClasses(),this.videoPlayers={},this.apiEvents=[],this.fullElementsList=!1}return n(e,[{key:"init",value:function(){var e=this,t=this.getSelector();t&&(this.baseEvents=a("click",{onElement:t,withCallback:function(t,i){t.preventDefault(),e.open(i)}})),this.elements=this.getElements()}},{key:"open",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null;if(0===this.elements.length)return!1;this.activeSlide=null,this.prevActiveSlideIndex=null,this.prevActiveSlide=null;var i=M(t)?t:this.settings.startAt;if(k(e)){var n=e.getAttribute("data-gallery");n&&(this.fullElementsList=this.elements,this.elements=this.getGalleryElements(this.elements,n)),I(i)&&(i=this.getElementIndex(e))<0&&(i=0)}M(i)||(i=0),this.build(),g(this.overlay,"none"===this.settings.openEffect?"none":this.settings.cssEfects.fade.in);var s=document.body,l=window.innerWidth-document.documentElement.clientWidth;if(l>0){var o=document.createElement("style");o.type="text/css",o.className="gcss-styles",o.innerText=".gscrollbar-fixer {margin-right: ".concat(l,"px}"),document.head.appendChild(o),h(s,"gscrollbar-fixer")}h(s,"glightbox-open"),h(Q,"glightbox-open"),J&&(h(document.body,"glightbox-mobile"),this.settings.slideEffect="slide"),this.showSlide(i,!0),1===this.elements.length?(h(this.prevButton,"glightbox-button-hidden"),h(this.nextButton,"glightbox-button-hidden")):(d(this.prevButton,"glightbox-button-hidden"),d(this.nextButton,"glightbox-button-hidden")),this.lightboxOpen=!0,this.trigger("open"),T(this.settings.onOpen)&&this.settings.onOpen(),K&&this.settings.touchNavigation&&B(this),this.settings.keyboardNavigation&&X(this)}},{key:"openAt",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:0;this.open(null,e)}},{key:"showSlide",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:0,i=arguments.length>1&&void 0!==arguments[1]&&arguments[1];f(this.loader),this.index=parseInt(t);var n=this.slidesContainer.querySelector(".current");n&&d(n,"current"),this.slideAnimateOut();var s=this.slidesContainer.querySelectorAll(".gslide")[t];if(c(s,"loaded"))this.slideAnimateIn(s,i),p(this.loader);else{f(this.loader);var l=this.elements[t],o={index:this.index,slide:s,slideNode:s,slideConfig:l.slideConfig,slideIndex:this.index,trigger:l.node,player:null};this.trigger("slide_before_load",o),l.instance.setContent(s,(function(){p(e.loader),e.resize(),e.slideAnimateIn(s,i),e.trigger("slide_after_load",o)}))}this.slideDescription=s.querySelector(".gslide-description"),this.slideDescriptionContained=this.slideDescription&&c(this.slideDescription.parentNode,"gslide-media"),this.settings.preload&&(this.preloadSlide(t+1),this.preloadSlide(t-1)),this.updateNavigationClasses(),this.activeSlide=s}},{key:"preloadSlide",value:function(e){var t=this;if(e<0||e>this.elements.length-1)return!1;if(I(this.elements[e]))return!1;var i=this.slidesContainer.querySelectorAll(".gslide")[e];if(c(i,"loaded"))return!1;var n=this.elements[e],s=n.type,l={index:e,slide:i,slideNode:i,slideConfig:n.slideConfig,slideIndex:e,trigger:n.node,player:null};this.trigger("slide_before_load",l),"video"===s||"external"===s?setTimeout((function(){n.instance.setContent(i,(function(){t.trigger("slide_after_load",l)}))}),200):n.instance.setContent(i,(function(){t.trigger("slide_after_load",l)}))}},{key:"prevSlide",value:function(){this.goToSlide(this.index-1)}},{key:"nextSlide",value:function(){this.goToSlide(this.index+1)}},{key:"goToSlide",value:function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0];if(this.prevActiveSlide=this.activeSlide,this.prevActiveSlideIndex=this.index,!this.loop()&&(e<0||e>this.elements.length-1))return!1;e<0?e=this.elements.length-1:e>=this.elements.length&&(e=0),this.showSlide(e)}},{key:"insertSlide",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:-1;t<0&&(t=this.elements.length);var i=new $(e,this,t),n=i.getConfig(),s=l({},n),o=i.create(),r=this.elements.length-1;s.index=t,s.node=!1,s.instance=i,s.slideConfig=n,this.elements.splice(t,0,s);var a=null,h=null;if(this.slidesContainer){if(t>r)this.slidesContainer.appendChild(o);else{var d=this.slidesContainer.querySelectorAll(".gslide")[t];this.slidesContainer.insertBefore(o,d)}(this.settings.preload&&0==this.index&&0==t||this.index-1==t||this.index+1==t)&&this.preloadSlide(t),0===this.index&&0===t&&(this.index=1),this.updateNavigationClasses(),a=this.slidesContainer.querySelectorAll(".gslide")[t],h=this.getSlidePlayerInstance(t),s.slideNode=a}this.trigger("slide_inserted",{index:t,slide:a,slideNode:a,slideConfig:n,slideIndex:t,trigger:null,player:h}),T(this.settings.slideInserted)&&this.settings.slideInserted({index:t,slide:a,player:h})}},{key:"removeSlide",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:-1;if(e<0||e>this.elements.length-1)return!1;var t=this.slidesContainer&&this.slidesContainer.querySelectorAll(".gslide")[e];t&&(this.getActiveSlideIndex()==e&&(e==this.elements.length-1?this.prevSlide():this.nextSlide()),t.parentNode.removeChild(t)),this.elements.splice(e,1),this.trigger("slide_removed",e),T(this.settings.slideRemoved)&&this.settings.slideRemoved(e)}},{key:"slideAnimateIn",value:function(e,t){var i=this,n=e.querySelector(".gslide-media"),s=e.querySelector(".gslide-description"),l={index:this.prevActiveSlideIndex,slide:this.prevActiveSlide,slideNode:this.prevActiveSlide,slideIndex:this.prevActiveSlide,slideConfig:I(this.prevActiveSlideIndex)?null:this.elements[this.prevActiveSlideIndex].slideConfig,trigger:I(this.prevActiveSlideIndex)?null:this.elements[this.prevActiveSlideIndex].node,player:this.getSlidePlayerInstance(this.prevActiveSlideIndex)},o={index:this.index,slide:this.activeSlide,slideNode:this.activeSlide,slideConfig:this.elements[this.index].slideConfig,slideIndex:this.index,trigger:this.elements[this.index].node,player:this.getSlidePlayerInstance(this.index)};if(n.offsetWidth>0&&s&&(p(s),s.style.display=""),d(e,this.effectsClasses),t)g(e,this.settings.cssEfects[this.settings.openEffect].in,(function(){i.settings.autoplayVideos&&i.slidePlayerPlay(e),i.trigger("slide_changed",{prev:l,current:o}),T(i.settings.afterSlideChange)&&i.settings.afterSlideChange.apply(i,[l,o])}));else{var r=this.settings.slideEffect,a="none"!==r?this.settings.cssEfects[r].in:r;this.prevActiveSlideIndex>this.index&&"slide"==this.settings.slideEffect&&(a=this.settings.cssEfects.slideBack.in),g(e,a,(function(){i.settings.autoplayVideos&&i.slidePlayerPlay(e),i.trigger("slide_changed",{prev:l,current:o}),T(i.settings.afterSlideChange)&&i.settings.afterSlideChange.apply(i,[l,o])}))}setTimeout((function(){i.resize(e)}),100),h(e,"current")}},{key:"slideAnimateOut",value:function(){if(!this.prevActiveSlide)return!1;var e=this.prevActiveSlide;d(e,this.effectsClasses),h(e,"prev");var t=this.settings.slideEffect,i="none"!==t?this.settings.cssEfects[t].out:t;this.slidePlayerPause(e),this.trigger("slide_before_change",{prev:{index:this.prevActiveSlideIndex,slide:this.prevActiveSlide,slideNode:this.prevActiveSlide,slideIndex:this.prevActiveSlideIndex,slideConfig:I(this.prevActiveSlideIndex)?null:this.elements[this.prevActiveSlideIndex].slideConfig,trigger:I(this.prevActiveSlideIndex)?null:this.elements[this.prevActiveSlideIndex].node,player:this.getSlidePlayerInstance(this.prevActiveSlideIndex)},current:{index:this.index,slide:this.activeSlide,slideNode:this.activeSlide,slideIndex:this.index,slideConfig:this.elements[this.index].slideConfig,trigger:this.elements[this.index].node,player:this.getSlidePlayerInstance(this.index)}}),T(this.settings.beforeSlideChange)&&this.settings.beforeSlideChange.apply(this,[{index:this.prevActiveSlideIndex,slide:this.prevActiveSlide,player:this.getSlidePlayerInstance(this.prevActiveSlideIndex)},{index:this.index,slide:this.activeSlide,player:this.getSlidePlayerInstance(this.index)}]),this.prevActiveSlideIndex>this.index&&"slide"==this.settings.slideEffect&&(i=this.settings.cssEfects.slideBack.out),g(e,i,(function(){var t=e.querySelector(".ginner-container"),i=e.querySelector(".gslide-media"),n=e.querySelector(".gslide-description");t.style.transform="",i.style.transform="",d(i,"greset"),i.style.opacity="",n&&(n.style.opacity=""),d(e,"prev")}))}},{key:"getAllPlayers",value:function(){return this.videoPlayers}},{key:"getSlidePlayerInstance",value:function(e){var t="gvideo"+e,i=this.getAllPlayers();return!(!O(i,t)||!i[t])&&i[t]}},{key:"stopSlideVideo",value:function(e){if(k(e)){var t=e.querySelector(".gvideo-wrapper");t&&(e=t.getAttribute("data-index"))}console.log("stopSlideVideo is deprecated, use slidePlayerPause");var i=this.getSlidePlayerInstance(e);i&&i.playing&&i.pause()}},{key:"slidePlayerPause",value:function(e){if(k(e)){var t=e.querySelector(".gvideo-wrapper");t&&(e=t.getAttribute("data-index"))}var i=this.getSlidePlayerInstance(e);i&&i.playing&&i.pause()}},{key:"playSlideVideo",value:function(e){if(k(e)){var t=e.querySelector(".gvideo-wrapper");t&&(e=t.getAttribute("data-index"))}console.log("playSlideVideo is deprecated, use slidePlayerPlay");var i=this.getSlidePlayerInstance(e);i&&!i.playing&&i.play()}},{key:"slidePlayerPlay",value:function(e){var t;if(!J||null!==(t=this.settings.plyr.config)&&void 0!==t&&t.muted){if(k(e)){var i=e.querySelector(".gvideo-wrapper");i&&(e=i.getAttribute("data-index"))}var n=this.getSlidePlayerInstance(e);n&&!n.playing&&(n.play(),this.settings.autofocusVideos&&n.elements.container.focus())}}},{key:"setElements",value:function(e){var t=this;this.settings.elements=!1;var i=[];e&&e.length&&o(e,(function(e,n){var s=new $(e,t,n),o=s.getConfig(),r=l({},o);r.slideConfig=o,r.instance=s,r.index=n,i.push(r)})),this.elements=i,this.lightboxOpen&&(this.slidesContainer.innerHTML="",this.elements.length&&(o(this.elements,(function(){var e=m(t.settings.slideHTML);t.slidesContainer.appendChild(e)})),this.showSlide(0,!0)))}},{key:"getElementIndex",value:function(e){var t=!1;return o(this.elements,(function(i,n){if(O(i,"node")&&i.node==e)return t=n,!0})),t}},{key:"getElements",value:function(){var e=this,t=[];this.elements=this.elements?this.elements:[],!I(this.settings.elements)&&E(this.settings.elements)&&this.settings.elements.length&&o(this.settings.elements,(function(i,n){var s=new $(i,e,n),o=s.getConfig(),r=l({},o);r.node=!1,r.index=n,r.instance=s,r.slideConfig=o,t.push(r)}));var i=!1;return this.getSelector()&&(i=document.querySelectorAll(this.getSelector())),i?(o(i,(function(i,n){var s=new $(i,e,n),o=s.getConfig(),r=l({},o);r.node=i,r.index=n,r.instance=s,r.slideConfig=o,r.gallery=i.getAttribute("data-gallery"),t.push(r)})),t):t}},{key:"getGalleryElements",value:function(e,t){return e.filter((function(e){return e.gallery==t}))}},{key:"getSelector",value:function(){return!this.settings.elements&&(this.settings.selector&&"data-"==this.settings.selector.substring(0,5)?"*[".concat(this.settings.selector,"]"):this.settings.selector)}},{key:"getActiveSlide",value:function(){return this.slidesContainer.querySelectorAll(".gslide")[this.index]}},{key:"getActiveSlideIndex",value:function(){return this.index}},{key:"getAnimationClasses",value:function(){var e=[];for(var t in this.settings.cssEfects)if(this.settings.cssEfects.hasOwnProperty(t)){var i=this.settings.cssEfects[t];e.push("g".concat(i.in)),e.push("g".concat(i.out))}return e.join(" ")}},{key:"build",value:function(){var e=this;if(this.built)return!1;var t=document.body.childNodes,i=[];o(t,(function(e){e.parentNode==document.body&&"#"!==e.nodeName.charAt(0)&&e.hasAttribute&&!e.hasAttribute("aria-hidden")&&(i.push(e),e.setAttribute("aria-hidden","true"))}));var n=O(this.settings.svg,"next")?this.settings.svg.next:"",s=O(this.settings.svg,"prev")?this.settings.svg.prev:"",l=O(this.settings.svg,"close")?this.settings.svg.close:"",r=this.settings.lightboxHTML;r=m(r=(r=(r=r.replace(/{nextSVG}/g,n)).replace(/{prevSVG}/g,s)).replace(/{closeSVG}/g,l)),document.body.appendChild(r);var d=document.getElementById("glightbox-body");this.modal=d;var g=d.querySelector(".gclose");this.prevButton=d.querySelector(".gprev"),this.nextButton=d.querySelector(".gnext"),this.overlay=d.querySelector(".goverlay"),this.loader=d.querySelector(".gloader"),this.slidesContainer=document.getElementById("glightbox-slider"),this.bodyHiddenChildElms=i,this.events={},h(this.modal,"glightbox-"+this.settings.skin),this.settings.closeButton&&g&&(this.events.close=a("click",{onElement:g,withCallback:function(t,i){t.preventDefault(),e.close()}})),g&&!this.settings.closeButton&&g.parentNode.removeChild(g),this.nextButton&&(this.events.next=a("click",{onElement:this.nextButton,withCallback:function(t,i){t.preventDefault(),e.nextSlide()}})),this.prevButton&&(this.events.prev=a("click",{onElement:this.prevButton,withCallback:function(t,i){t.preventDefault(),e.prevSlide()}})),this.settings.closeOnOutsideClick&&(this.events.outClose=a("click",{onElement:d,withCallback:function(t,i){e.preventOutsideClick||c(document.body,"glightbox-mobile")||u(t.target,".ginner-container")||u(t.target,".gbtn")||c(t.target,"gnext")||c(t.target,"gprev")||e.close()}})),o(this.elements,(function(t,i){e.slidesContainer.appendChild(t.instance.create()),t.slideNode=e.slidesContainer.querySelectorAll(".gslide")[i]})),K&&h(document.body,"glightbox-touch"),this.events.resize=a("resize",{onElement:window,withCallback:function(){e.resize()}}),this.built=!0}},{key:"resize",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null;if((e=e||this.activeSlide)&&!c(e,"zoomed")){var t=y(),i=e.querySelector(".gvideo-wrapper"),n=e.querySelector(".gslide-image"),s=this.slideDescription,l=t.width,o=t.height;if(l<=768?h(document.body,"glightbox-mobile"):d(document.body,"glightbox-mobile"),i||n){var r=!1;if(s&&(c(s,"description-bottom")||c(s,"description-top"))&&!c(s,"gabsolute")&&(r=!0),n)if(l<=768)n.querySelector("img");else if(r){var a=s.offsetHeight,u=n.querySelector("img");u.setAttribute("style","max-height: calc(100vh - ".concat(a,"px)")),s.setAttribute("style","max-width: ".concat(u.offsetWidth,"px;"))}if(i){var g=O(this.settings.plyr.config,"ratio")?this.settings.plyr.config.ratio:"";if(!g){var v=i.clientWidth,f=i.clientHeight,p=v/f;g="".concat(v/p,":").concat(f/p)}var m=g.split(":"),x=this.settings.videosWidth,b=this.settings.videosWidth,S=(b=M(x)||-1!==x.indexOf("px")?parseInt(x):-1!==x.indexOf("vw")?l*parseInt(x)/100:-1!==x.indexOf("vh")?o*parseInt(x)/100:-1!==x.indexOf("%")?l*parseInt(x)/100:parseInt(i.clientWidth))/(parseInt(m[0])/parseInt(m[1]));if(S=Math.floor(S),r&&(o-=s.offsetHeight),b>l||S>o||ob){var w=i.offsetWidth,T=i.offsetHeight,C=o/T,k={width:w*C,height:T*C};i.parentNode.setAttribute("style","max-width: ".concat(k.width,"px")),r&&s.setAttribute("style","max-width: ".concat(k.width,"px;"))}else i.parentNode.style.maxWidth="".concat(x),r&&s.setAttribute("style","max-width: ".concat(x,";"))}}}}},{key:"reload",value:function(){this.init()}},{key:"updateNavigationClasses",value:function(){var e=this.loop();d(this.nextButton,"disabled"),d(this.prevButton,"disabled"),0==this.index&&this.elements.length-1==0?(h(this.prevButton,"disabled"),h(this.nextButton,"disabled")):0!==this.index||e?this.index!==this.elements.length-1||e||h(this.nextButton,"disabled"):h(this.prevButton,"disabled")}},{key:"loop",value:function(){var e=O(this.settings,"loopAtEnd")?this.settings.loopAtEnd:null;return e=O(this.settings,"loop")?this.settings.loop:e,e}},{key:"close",value:function(){var e=this;if(!this.lightboxOpen){if(this.events){for(var t in this.events)this.events.hasOwnProperty(t)&&this.events[t].destroy();this.events=null}return!1}if(this.closing)return!1;this.closing=!0,this.slidePlayerPause(this.activeSlide),this.fullElementsList&&(this.elements=this.fullElementsList),this.bodyHiddenChildElms.length&&o(this.bodyHiddenChildElms,(function(e){e.removeAttribute("aria-hidden")})),h(this.modal,"glightbox-closing"),g(this.overlay,"none"==this.settings.openEffect?"none":this.settings.cssEfects.fade.out),g(this.activeSlide,this.settings.cssEfects[this.settings.closeEffect].out,(function(){if(e.activeSlide=null,e.prevActiveSlideIndex=null,e.prevActiveSlide=null,e.built=!1,e.events){for(var t in e.events)e.events.hasOwnProperty(t)&&e.events[t].destroy();e.events=null}var i=document.body;d(Q,"glightbox-open"),d(i,"glightbox-open touching gdesc-open glightbox-touch glightbox-mobile gscrollbar-fixer"),e.modal.parentNode.removeChild(e.modal),e.trigger("close"),T(e.settings.onClose)&&e.settings.onClose();var n=document.querySelector(".gcss-styles");n&&n.parentNode.removeChild(n),e.lightboxOpen=!1,e.closing=null}))}},{key:"destroy",value:function(){this.close(),this.clearAllEvents(),this.baseEvents&&this.baseEvents.destroy()}},{key:"on",value:function(e,t){var i=arguments.length>2&&void 0!==arguments[2]&&arguments[2];if(!e||!T(t))throw new TypeError("Event name and callback must be defined");this.apiEvents.push({evt:e,once:i,callback:t})}},{key:"once",value:function(e,t){this.on(e,t,!0)}},{key:"trigger",value:function(e){var t=this,i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,n=[];o(this.apiEvents,(function(t,s){var l=t.evt,o=t.once,r=t.callback;l==e&&(r(i),o&&n.push(s))})),n.length&&o(n,(function(e){return t.apiEvents.splice(e,1)}))}},{key:"clearAllEvents",value:function(){this.apiEvents.splice(0,this.apiEvents.length)}},{key:"version",value:function(){return"3.1.0"}}]),e}();return function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=new te(e);return t.init(),t}})); \ No newline at end of file diff --git a/site_libs/quarto-contrib/glightbox/lightbox.css b/site_libs/quarto-contrib/glightbox/lightbox.css new file mode 100644 index 0000000..46432d9 --- /dev/null +++ b/site_libs/quarto-contrib/glightbox/lightbox.css @@ -0,0 +1,26 @@ +body:not(.glightbox-mobile) div.gslide div.gslide-description, +body:not(.glightbox-mobile) div.gslide-description .gslide-title, +body:not(.glightbox-mobile) div.gslide-description .gslide-desc { + color: var(--quarto-body-color); + background-color: var(--quarto-body-bg); +} + +body:not(.glightbox-mobile) div.gslide-media { + background-color: var(--quarto-body-bg); +} + +.goverlay { + background: rgba(0, 0, 0, 0.7); +} + +div.gslide-description .gslide-title { + margin-top: 0.25em; + margin-bottom: 0.25em; + font-weight: 500; + font-family: inherit; +} + +div.gslide-description .gslide-desc { + padding-bottom: 0.5em; + font-family: inherit; +} diff --git a/site_libs/quarto-html/anchor.min.js b/site_libs/quarto-html/anchor.min.js new file mode 100644 index 0000000..5ac814d --- /dev/null +++ b/site_libs/quarto-html/anchor.min.js @@ -0,0 +1,9 @@ +// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt Expat +// +// AnchorJS - v5.0.0 - 2023-01-18 +// https://www.bryanbraun.com/anchorjs/ +// Copyright (c) 2023 Bryan Braun; Licensed MIT +// +// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt Expat +!function(A,e){"use strict";"function"==typeof define&&define.amd?define([],e):"object"==typeof module&&module.exports?module.exports=e():(A.AnchorJS=e(),A.anchors=new A.AnchorJS)}(globalThis,function(){"use strict";return function(A){function u(A){A.icon=Object.prototype.hasOwnProperty.call(A,"icon")?A.icon:"",A.visible=Object.prototype.hasOwnProperty.call(A,"visible")?A.visible:"hover",A.placement=Object.prototype.hasOwnProperty.call(A,"placement")?A.placement:"right",A.ariaLabel=Object.prototype.hasOwnProperty.call(A,"ariaLabel")?A.ariaLabel:"Anchor",A.class=Object.prototype.hasOwnProperty.call(A,"class")?A.class:"",A.base=Object.prototype.hasOwnProperty.call(A,"base")?A.base:"",A.truncate=Object.prototype.hasOwnProperty.call(A,"truncate")?Math.floor(A.truncate):64,A.titleText=Object.prototype.hasOwnProperty.call(A,"titleText")?A.titleText:""}function d(A){var e;if("string"==typeof A||A instanceof String)e=[].slice.call(document.querySelectorAll(A));else{if(!(Array.isArray(A)||A instanceof NodeList))throw new TypeError("The selector provided to AnchorJS was invalid.");e=[].slice.call(A)}return e}this.options=A||{},this.elements=[],u(this.options),this.add=function(A){var e,t,o,i,n,s,a,r,l,c,h,p=[];if(u(this.options),0!==(e=d(A=A||"h2, h3, h4, h5, h6")).length){for(null===document.head.querySelector("style.anchorjs")&&((A=document.createElement("style")).className="anchorjs",A.appendChild(document.createTextNode("")),void 0===(h=document.head.querySelector('[rel="stylesheet"],style'))?document.head.appendChild(A):document.head.insertBefore(A,h),A.sheet.insertRule(".anchorjs-link{opacity:0;text-decoration:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}",A.sheet.cssRules.length),A.sheet.insertRule(":hover>.anchorjs-link,.anchorjs-link:focus{opacity:1}",A.sheet.cssRules.length),A.sheet.insertRule("[data-anchorjs-icon]::after{content:attr(data-anchorjs-icon)}",A.sheet.cssRules.length),A.sheet.insertRule('@font-face{font-family:anchorjs-icons;src:url(data:n/a;base64,AAEAAAALAIAAAwAwT1MvMg8yG2cAAAE4AAAAYGNtYXDp3gC3AAABpAAAAExnYXNwAAAAEAAAA9wAAAAIZ2x5ZlQCcfwAAAH4AAABCGhlYWQHFvHyAAAAvAAAADZoaGVhBnACFwAAAPQAAAAkaG10eASAADEAAAGYAAAADGxvY2EACACEAAAB8AAAAAhtYXhwAAYAVwAAARgAAAAgbmFtZQGOH9cAAAMAAAAAunBvc3QAAwAAAAADvAAAACAAAQAAAAEAAHzE2p9fDzz1AAkEAAAAAADRecUWAAAAANQA6R8AAAAAAoACwAAAAAgAAgAAAAAAAAABAAADwP/AAAACgAAA/9MCrQABAAAAAAAAAAAAAAAAAAAAAwABAAAAAwBVAAIAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAMCQAGQAAUAAAKZAswAAACPApkCzAAAAesAMwEJAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAAAAAAAAAAAAAQAAg//0DwP/AAEADwABAAAAAAQAAAAAAAAAAAAAAIAAAAAAAAAIAAAACgAAxAAAAAwAAAAMAAAAcAAEAAwAAABwAAwABAAAAHAAEADAAAAAIAAgAAgAAACDpy//9//8AAAAg6cv//f///+EWNwADAAEAAAAAAAAAAAAAAAAACACEAAEAAAAAAAAAAAAAAAAxAAACAAQARAKAAsAAKwBUAAABIiYnJjQ3NzY2MzIWFxYUBwcGIicmNDc3NjQnJiYjIgYHBwYUFxYUBwYGIwciJicmNDc3NjIXFhQHBwYUFxYWMzI2Nzc2NCcmNDc2MhcWFAcHBgYjARQGDAUtLXoWOR8fORYtLTgKGwoKCjgaGg0gEhIgDXoaGgkJBQwHdR85Fi0tOAobCgoKOBoaDSASEiANehoaCQkKGwotLXoWOR8BMwUFLYEuehYXFxYugC44CQkKGwo4GkoaDQ0NDXoaShoKGwoFBe8XFi6ALjgJCQobCjgaShoNDQ0NehpKGgobCgoKLYEuehYXAAAADACWAAEAAAAAAAEACAAAAAEAAAAAAAIAAwAIAAEAAAAAAAMACAAAAAEAAAAAAAQACAAAAAEAAAAAAAUAAQALAAEAAAAAAAYACAAAAAMAAQQJAAEAEAAMAAMAAQQJAAIABgAcAAMAAQQJAAMAEAAMAAMAAQQJAAQAEAAMAAMAAQQJAAUAAgAiAAMAAQQJAAYAEAAMYW5jaG9yanM0MDBAAGEAbgBjAGgAbwByAGoAcwA0ADAAMABAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAH//wAP) format("truetype")}',A.sheet.cssRules.length)),h=document.querySelectorAll("[id]"),t=[].map.call(h,function(A){return A.id}),i=0;i\]./()*\\\n\t\b\v\u00A0]/g,"-").replace(/-{2,}/g,"-").substring(0,this.options.truncate).replace(/^-+|-+$/gm,"").toLowerCase()},this.hasAnchorJSLink=function(A){var e=A.firstChild&&-1<(" "+A.firstChild.className+" ").indexOf(" anchorjs-link "),A=A.lastChild&&-1<(" "+A.lastChild.className+" ").indexOf(" anchorjs-link ");return e||A||!1}}}); +// @license-end \ No newline at end of file diff --git a/site_libs/quarto-html/axe/axe-check.js b/site_libs/quarto-html/axe/axe-check.js new file mode 100644 index 0000000..8808085 --- /dev/null +++ b/site_libs/quarto-html/axe/axe-check.js @@ -0,0 +1,145 @@ +class QuartoAxeReporter { + constructor(axeResult, options) { + this.axeResult = axeResult; + this.options = options; + } + + report() { + throw new Error("report() is an abstract method"); + } +} + +class QuartoAxeJsonReporter extends QuartoAxeReporter { + constructor(axeResult, options) { + super(axeResult, options); + } + + report() { + console.log(JSON.stringify(this.axeResult, null, 2)); + } +} + +class QuartoAxeConsoleReporter extends QuartoAxeReporter { + constructor(axeResult, options) { + super(axeResult, options); + } + + report() { + for (const violation of this.axeResult.violations) { + console.log(violation.description); + for (const node of violation.nodes) { + for (const target of node.target) { + console.log(target); + console.log(document.querySelector(target)); + } + } + } + } +} + +class QuartoAxeDocumentReporter extends QuartoAxeReporter { + constructor(axeResult, options) { + super(axeResult, options); + } + + createViolationElement(violation) { + const violationElement = document.createElement("div"); + + const descriptionElement = document.createElement("div"); + descriptionElement.className = "quarto-axe-violation-description"; + descriptionElement.innerText = `${violation.impact.replace(/^[a-z]/, match => match.toLocaleUpperCase())}: ${violation.description}`; + violationElement.appendChild(descriptionElement); + + const helpElement = document.createElement("div"); + helpElement.className = "quarto-axe-violation-help"; + helpElement.innerText = violation.help; + violationElement.appendChild(helpElement); + + const nodesElement = document.createElement("div"); + nodesElement.className = "quarto-axe-violation-nodes"; + violationElement.appendChild(nodesElement); + const nodeElement = document.createElement("div"); + nodeElement.className = "quarto-axe-violation-selector"; + for (const node of violation.nodes) { + for (const target of node.target) { + const targetElement = document.createElement("span"); + targetElement.className = "quarto-axe-violation-target"; + targetElement.innerText = target; + nodeElement.appendChild(targetElement); + nodeElement.addEventListener("mouseenter", () => { + const element = document.querySelector(target); + if (element) { + element.scrollIntoView({ behavior: "smooth", block: "center" }); + element.classList.add("quarto-axe-hover-highlight"); + setTimeout(() => { + element.style.border = ""; + }, 2000); + } + }); + nodeElement.addEventListener("mouseleave", () => { + const element = document.querySelector(target); + if (element) { + element.classList.remove("quarto-axe-hover-highlight"); + } + }); + nodeElement.addEventListener("click", () => { + console.log(document.querySelector(target)); + }); + nodeElement.appendChild(targetElement); + } + nodesElement.appendChild(nodeElement); + } + return violationElement; + } + + report() { + const violations = this.axeResult.violations; + const reportElement = document.createElement("div"); + reportElement.className = "quarto-axe-report"; + if (violations.length === 0) { + const noViolationsElement = document.createElement("div"); + noViolationsElement.className = "quarto-axe-no-violations"; + noViolationsElement.innerText = "No axe-core violations found."; + reportElement.appendChild(noViolationsElement); + } + violations.forEach((violation) => { + reportElement.appendChild(this.createViolationElement(violation)); + }); + document.querySelector("main").appendChild(reportElement); + } +} + +const reporters = { + json: QuartoAxeJsonReporter, + console: QuartoAxeConsoleReporter, + document: QuartoAxeDocumentReporter, +}; + +class QuartoAxeChecker { + constructor(opts) { + this.options = opts; + } + async init() { + const axe = (await import("https://cdn.skypack.dev/pin/axe-core@v4.10.3-aVOFXWsJaCpVrtv89pCa/mode=imports,min/optimized/axe-core.js")).default; + const result = await axe.run({ + exclude: [ + // https://github.com/microsoft/tabster/issues/288 + // MS has claimed they won't fix this, so we need to add an exclusion to + // all tabster elements + "[data-tabster-dummy]" + ], + preload: { assets: ['cssom'], timeout: 50000 } + }); + const reporter = this.options === true ? new QuartoAxeConsoleReporter(result) : new reporters[this.options.output](result, this.options); + reporter.report(); + } +} + +export async function init() { + const opts = document.querySelector("#quarto-axe-checker-options"); + if (opts) { + const jsonOptions = JSON.parse(atob(opts.textContent)); + const checker = new QuartoAxeChecker(jsonOptions); + await checker.init(); + } +} \ No newline at end of file diff --git a/site_libs/quarto-html/popper.min.js b/site_libs/quarto-html/popper.min.js new file mode 100644 index 0000000..e3726d7 --- /dev/null +++ b/site_libs/quarto-html/popper.min.js @@ -0,0 +1,6 @@ +/** + * @popperjs/core v2.11.7 - MIT License + */ + +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Popper={})}(this,(function(e){"use strict";function t(e){if(null==e)return window;if("[object Window]"!==e.toString()){var t=e.ownerDocument;return t&&t.defaultView||window}return e}function n(e){return e instanceof t(e).Element||e instanceof Element}function r(e){return e instanceof t(e).HTMLElement||e instanceof HTMLElement}function o(e){return"undefined"!=typeof ShadowRoot&&(e instanceof t(e).ShadowRoot||e instanceof ShadowRoot)}var i=Math.max,a=Math.min,s=Math.round;function f(){var e=navigator.userAgentData;return null!=e&&e.brands&&Array.isArray(e.brands)?e.brands.map((function(e){return e.brand+"/"+e.version})).join(" "):navigator.userAgent}function c(){return!/^((?!chrome|android).)*safari/i.test(f())}function p(e,o,i){void 0===o&&(o=!1),void 0===i&&(i=!1);var a=e.getBoundingClientRect(),f=1,p=1;o&&r(e)&&(f=e.offsetWidth>0&&s(a.width)/e.offsetWidth||1,p=e.offsetHeight>0&&s(a.height)/e.offsetHeight||1);var u=(n(e)?t(e):window).visualViewport,l=!c()&&i,d=(a.left+(l&&u?u.offsetLeft:0))/f,h=(a.top+(l&&u?u.offsetTop:0))/p,m=a.width/f,v=a.height/p;return{width:m,height:v,top:h,right:d+m,bottom:h+v,left:d,x:d,y:h}}function u(e){var n=t(e);return{scrollLeft:n.pageXOffset,scrollTop:n.pageYOffset}}function l(e){return e?(e.nodeName||"").toLowerCase():null}function d(e){return((n(e)?e.ownerDocument:e.document)||window.document).documentElement}function h(e){return p(d(e)).left+u(e).scrollLeft}function m(e){return t(e).getComputedStyle(e)}function v(e){var t=m(e),n=t.overflow,r=t.overflowX,o=t.overflowY;return/auto|scroll|overlay|hidden/.test(n+o+r)}function y(e,n,o){void 0===o&&(o=!1);var i,a,f=r(n),c=r(n)&&function(e){var t=e.getBoundingClientRect(),n=s(t.width)/e.offsetWidth||1,r=s(t.height)/e.offsetHeight||1;return 1!==n||1!==r}(n),m=d(n),y=p(e,c,o),g={scrollLeft:0,scrollTop:0},b={x:0,y:0};return(f||!f&&!o)&&(("body"!==l(n)||v(m))&&(g=(i=n)!==t(i)&&r(i)?{scrollLeft:(a=i).scrollLeft,scrollTop:a.scrollTop}:u(i)),r(n)?((b=p(n,!0)).x+=n.clientLeft,b.y+=n.clientTop):m&&(b.x=h(m))),{x:y.left+g.scrollLeft-b.x,y:y.top+g.scrollTop-b.y,width:y.width,height:y.height}}function g(e){var t=p(e),n=e.offsetWidth,r=e.offsetHeight;return Math.abs(t.width-n)<=1&&(n=t.width),Math.abs(t.height-r)<=1&&(r=t.height),{x:e.offsetLeft,y:e.offsetTop,width:n,height:r}}function b(e){return"html"===l(e)?e:e.assignedSlot||e.parentNode||(o(e)?e.host:null)||d(e)}function x(e){return["html","body","#document"].indexOf(l(e))>=0?e.ownerDocument.body:r(e)&&v(e)?e:x(b(e))}function w(e,n){var r;void 0===n&&(n=[]);var o=x(e),i=o===(null==(r=e.ownerDocument)?void 0:r.body),a=t(o),s=i?[a].concat(a.visualViewport||[],v(o)?o:[]):o,f=n.concat(s);return i?f:f.concat(w(b(s)))}function O(e){return["table","td","th"].indexOf(l(e))>=0}function j(e){return r(e)&&"fixed"!==m(e).position?e.offsetParent:null}function E(e){for(var n=t(e),i=j(e);i&&O(i)&&"static"===m(i).position;)i=j(i);return i&&("html"===l(i)||"body"===l(i)&&"static"===m(i).position)?n:i||function(e){var t=/firefox/i.test(f());if(/Trident/i.test(f())&&r(e)&&"fixed"===m(e).position)return null;var n=b(e);for(o(n)&&(n=n.host);r(n)&&["html","body"].indexOf(l(n))<0;){var i=m(n);if("none"!==i.transform||"none"!==i.perspective||"paint"===i.contain||-1!==["transform","perspective"].indexOf(i.willChange)||t&&"filter"===i.willChange||t&&i.filter&&"none"!==i.filter)return n;n=n.parentNode}return null}(e)||n}var D="top",A="bottom",L="right",P="left",M="auto",k=[D,A,L,P],W="start",B="end",H="viewport",T="popper",R=k.reduce((function(e,t){return e.concat([t+"-"+W,t+"-"+B])}),[]),S=[].concat(k,[M]).reduce((function(e,t){return e.concat([t,t+"-"+W,t+"-"+B])}),[]),V=["beforeRead","read","afterRead","beforeMain","main","afterMain","beforeWrite","write","afterWrite"];function q(e){var t=new Map,n=new Set,r=[];function o(e){n.add(e.name),[].concat(e.requires||[],e.requiresIfExists||[]).forEach((function(e){if(!n.has(e)){var r=t.get(e);r&&o(r)}})),r.push(e)}return e.forEach((function(e){t.set(e.name,e)})),e.forEach((function(e){n.has(e.name)||o(e)})),r}function C(e){return e.split("-")[0]}function N(e,t){var n=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(n&&o(n)){var r=t;do{if(r&&e.isSameNode(r))return!0;r=r.parentNode||r.host}while(r)}return!1}function I(e){return Object.assign({},e,{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function _(e,r,o){return r===H?I(function(e,n){var r=t(e),o=d(e),i=r.visualViewport,a=o.clientWidth,s=o.clientHeight,f=0,p=0;if(i){a=i.width,s=i.height;var u=c();(u||!u&&"fixed"===n)&&(f=i.offsetLeft,p=i.offsetTop)}return{width:a,height:s,x:f+h(e),y:p}}(e,o)):n(r)?function(e,t){var n=p(e,!1,"fixed"===t);return n.top=n.top+e.clientTop,n.left=n.left+e.clientLeft,n.bottom=n.top+e.clientHeight,n.right=n.left+e.clientWidth,n.width=e.clientWidth,n.height=e.clientHeight,n.x=n.left,n.y=n.top,n}(r,o):I(function(e){var t,n=d(e),r=u(e),o=null==(t=e.ownerDocument)?void 0:t.body,a=i(n.scrollWidth,n.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),s=i(n.scrollHeight,n.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),f=-r.scrollLeft+h(e),c=-r.scrollTop;return"rtl"===m(o||n).direction&&(f+=i(n.clientWidth,o?o.clientWidth:0)-a),{width:a,height:s,x:f,y:c}}(d(e)))}function F(e,t,o,s){var f="clippingParents"===t?function(e){var t=w(b(e)),o=["absolute","fixed"].indexOf(m(e).position)>=0&&r(e)?E(e):e;return n(o)?t.filter((function(e){return n(e)&&N(e,o)&&"body"!==l(e)})):[]}(e):[].concat(t),c=[].concat(f,[o]),p=c[0],u=c.reduce((function(t,n){var r=_(e,n,s);return t.top=i(r.top,t.top),t.right=a(r.right,t.right),t.bottom=a(r.bottom,t.bottom),t.left=i(r.left,t.left),t}),_(e,p,s));return u.width=u.right-u.left,u.height=u.bottom-u.top,u.x=u.left,u.y=u.top,u}function U(e){return e.split("-")[1]}function z(e){return["top","bottom"].indexOf(e)>=0?"x":"y"}function X(e){var t,n=e.reference,r=e.element,o=e.placement,i=o?C(o):null,a=o?U(o):null,s=n.x+n.width/2-r.width/2,f=n.y+n.height/2-r.height/2;switch(i){case D:t={x:s,y:n.y-r.height};break;case A:t={x:s,y:n.y+n.height};break;case L:t={x:n.x+n.width,y:f};break;case P:t={x:n.x-r.width,y:f};break;default:t={x:n.x,y:n.y}}var c=i?z(i):null;if(null!=c){var p="y"===c?"height":"width";switch(a){case W:t[c]=t[c]-(n[p]/2-r[p]/2);break;case B:t[c]=t[c]+(n[p]/2-r[p]/2)}}return t}function Y(e){return Object.assign({},{top:0,right:0,bottom:0,left:0},e)}function G(e,t){return t.reduce((function(t,n){return t[n]=e,t}),{})}function J(e,t){void 0===t&&(t={});var r=t,o=r.placement,i=void 0===o?e.placement:o,a=r.strategy,s=void 0===a?e.strategy:a,f=r.boundary,c=void 0===f?"clippingParents":f,u=r.rootBoundary,l=void 0===u?H:u,h=r.elementContext,m=void 0===h?T:h,v=r.altBoundary,y=void 0!==v&&v,g=r.padding,b=void 0===g?0:g,x=Y("number"!=typeof b?b:G(b,k)),w=m===T?"reference":T,O=e.rects.popper,j=e.elements[y?w:m],E=F(n(j)?j:j.contextElement||d(e.elements.popper),c,l,s),P=p(e.elements.reference),M=X({reference:P,element:O,strategy:"absolute",placement:i}),W=I(Object.assign({},O,M)),B=m===T?W:P,R={top:E.top-B.top+x.top,bottom:B.bottom-E.bottom+x.bottom,left:E.left-B.left+x.left,right:B.right-E.right+x.right},S=e.modifiersData.offset;if(m===T&&S){var V=S[i];Object.keys(R).forEach((function(e){var t=[L,A].indexOf(e)>=0?1:-1,n=[D,A].indexOf(e)>=0?"y":"x";R[e]+=V[n]*t}))}return R}var K={placement:"bottom",modifiers:[],strategy:"absolute"};function Q(){for(var e=arguments.length,t=new Array(e),n=0;n=0?-1:1,i="function"==typeof n?n(Object.assign({},t,{placement:e})):n,a=i[0],s=i[1];return a=a||0,s=(s||0)*o,[P,L].indexOf(r)>=0?{x:s,y:a}:{x:a,y:s}}(n,t.rects,i),e}),{}),s=a[t.placement],f=s.x,c=s.y;null!=t.modifiersData.popperOffsets&&(t.modifiersData.popperOffsets.x+=f,t.modifiersData.popperOffsets.y+=c),t.modifiersData[r]=a}},se={left:"right",right:"left",bottom:"top",top:"bottom"};function fe(e){return e.replace(/left|right|bottom|top/g,(function(e){return se[e]}))}var ce={start:"end",end:"start"};function pe(e){return e.replace(/start|end/g,(function(e){return ce[e]}))}function ue(e,t){void 0===t&&(t={});var n=t,r=n.placement,o=n.boundary,i=n.rootBoundary,a=n.padding,s=n.flipVariations,f=n.allowedAutoPlacements,c=void 0===f?S:f,p=U(r),u=p?s?R:R.filter((function(e){return U(e)===p})):k,l=u.filter((function(e){return c.indexOf(e)>=0}));0===l.length&&(l=u);var d=l.reduce((function(t,n){return t[n]=J(e,{placement:n,boundary:o,rootBoundary:i,padding:a})[C(n)],t}),{});return Object.keys(d).sort((function(e,t){return d[e]-d[t]}))}var le={name:"flip",enabled:!0,phase:"main",fn:function(e){var t=e.state,n=e.options,r=e.name;if(!t.modifiersData[r]._skip){for(var o=n.mainAxis,i=void 0===o||o,a=n.altAxis,s=void 0===a||a,f=n.fallbackPlacements,c=n.padding,p=n.boundary,u=n.rootBoundary,l=n.altBoundary,d=n.flipVariations,h=void 0===d||d,m=n.allowedAutoPlacements,v=t.options.placement,y=C(v),g=f||(y===v||!h?[fe(v)]:function(e){if(C(e)===M)return[];var t=fe(e);return[pe(e),t,pe(t)]}(v)),b=[v].concat(g).reduce((function(e,n){return e.concat(C(n)===M?ue(t,{placement:n,boundary:p,rootBoundary:u,padding:c,flipVariations:h,allowedAutoPlacements:m}):n)}),[]),x=t.rects.reference,w=t.rects.popper,O=new Map,j=!0,E=b[0],k=0;k=0,S=R?"width":"height",V=J(t,{placement:B,boundary:p,rootBoundary:u,altBoundary:l,padding:c}),q=R?T?L:P:T?A:D;x[S]>w[S]&&(q=fe(q));var N=fe(q),I=[];if(i&&I.push(V[H]<=0),s&&I.push(V[q]<=0,V[N]<=0),I.every((function(e){return e}))){E=B,j=!1;break}O.set(B,I)}if(j)for(var _=function(e){var t=b.find((function(t){var n=O.get(t);if(n)return n.slice(0,e).every((function(e){return e}))}));if(t)return E=t,"break"},F=h?3:1;F>0;F--){if("break"===_(F))break}t.placement!==E&&(t.modifiersData[r]._skip=!0,t.placement=E,t.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function de(e,t,n){return i(e,a(t,n))}var he={name:"preventOverflow",enabled:!0,phase:"main",fn:function(e){var t=e.state,n=e.options,r=e.name,o=n.mainAxis,s=void 0===o||o,f=n.altAxis,c=void 0!==f&&f,p=n.boundary,u=n.rootBoundary,l=n.altBoundary,d=n.padding,h=n.tether,m=void 0===h||h,v=n.tetherOffset,y=void 0===v?0:v,b=J(t,{boundary:p,rootBoundary:u,padding:d,altBoundary:l}),x=C(t.placement),w=U(t.placement),O=!w,j=z(x),M="x"===j?"y":"x",k=t.modifiersData.popperOffsets,B=t.rects.reference,H=t.rects.popper,T="function"==typeof y?y(Object.assign({},t.rects,{placement:t.placement})):y,R="number"==typeof T?{mainAxis:T,altAxis:T}:Object.assign({mainAxis:0,altAxis:0},T),S=t.modifiersData.offset?t.modifiersData.offset[t.placement]:null,V={x:0,y:0};if(k){if(s){var q,N="y"===j?D:P,I="y"===j?A:L,_="y"===j?"height":"width",F=k[j],X=F+b[N],Y=F-b[I],G=m?-H[_]/2:0,K=w===W?B[_]:H[_],Q=w===W?-H[_]:-B[_],Z=t.elements.arrow,$=m&&Z?g(Z):{width:0,height:0},ee=t.modifiersData["arrow#persistent"]?t.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},te=ee[N],ne=ee[I],re=de(0,B[_],$[_]),oe=O?B[_]/2-G-re-te-R.mainAxis:K-re-te-R.mainAxis,ie=O?-B[_]/2+G+re+ne+R.mainAxis:Q+re+ne+R.mainAxis,ae=t.elements.arrow&&E(t.elements.arrow),se=ae?"y"===j?ae.clientTop||0:ae.clientLeft||0:0,fe=null!=(q=null==S?void 0:S[j])?q:0,ce=F+ie-fe,pe=de(m?a(X,F+oe-fe-se):X,F,m?i(Y,ce):Y);k[j]=pe,V[j]=pe-F}if(c){var ue,le="x"===j?D:P,he="x"===j?A:L,me=k[M],ve="y"===M?"height":"width",ye=me+b[le],ge=me-b[he],be=-1!==[D,P].indexOf(x),xe=null!=(ue=null==S?void 0:S[M])?ue:0,we=be?ye:me-B[ve]-H[ve]-xe+R.altAxis,Oe=be?me+B[ve]+H[ve]-xe-R.altAxis:ge,je=m&&be?function(e,t,n){var r=de(e,t,n);return r>n?n:r}(we,me,Oe):de(m?we:ye,me,m?Oe:ge);k[M]=je,V[M]=je-me}t.modifiersData[r]=V}},requiresIfExists:["offset"]};var me={name:"arrow",enabled:!0,phase:"main",fn:function(e){var t,n=e.state,r=e.name,o=e.options,i=n.elements.arrow,a=n.modifiersData.popperOffsets,s=C(n.placement),f=z(s),c=[P,L].indexOf(s)>=0?"height":"width";if(i&&a){var p=function(e,t){return Y("number"!=typeof(e="function"==typeof e?e(Object.assign({},t.rects,{placement:t.placement})):e)?e:G(e,k))}(o.padding,n),u=g(i),l="y"===f?D:P,d="y"===f?A:L,h=n.rects.reference[c]+n.rects.reference[f]-a[f]-n.rects.popper[c],m=a[f]-n.rects.reference[f],v=E(i),y=v?"y"===f?v.clientHeight||0:v.clientWidth||0:0,b=h/2-m/2,x=p[l],w=y-u[c]-p[d],O=y/2-u[c]/2+b,j=de(x,O,w),M=f;n.modifiersData[r]=((t={})[M]=j,t.centerOffset=j-O,t)}},effect:function(e){var t=e.state,n=e.options.element,r=void 0===n?"[data-popper-arrow]":n;null!=r&&("string"!=typeof r||(r=t.elements.popper.querySelector(r)))&&N(t.elements.popper,r)&&(t.elements.arrow=r)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function ve(e,t,n){return void 0===n&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function ye(e){return[D,L,A,P].some((function(t){return e[t]>=0}))}var ge={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(e){var t=e.state,n=e.name,r=t.rects.reference,o=t.rects.popper,i=t.modifiersData.preventOverflow,a=J(t,{elementContext:"reference"}),s=J(t,{altBoundary:!0}),f=ve(a,r),c=ve(s,o,i),p=ye(f),u=ye(c);t.modifiersData[n]={referenceClippingOffsets:f,popperEscapeOffsets:c,isReferenceHidden:p,hasPopperEscaped:u},t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-reference-hidden":p,"data-popper-escaped":u})}},be=Z({defaultModifiers:[ee,te,oe,ie]}),xe=[ee,te,oe,ie,ae,le,he,me,ge],we=Z({defaultModifiers:xe});e.applyStyles=ie,e.arrow=me,e.computeStyles=oe,e.createPopper=we,e.createPopperLite=be,e.defaultModifiers=xe,e.detectOverflow=J,e.eventListeners=ee,e.flip=le,e.hide=ge,e.offset=ae,e.popperGenerator=Z,e.popperOffsets=te,e.preventOverflow=he,Object.defineProperty(e,"__esModule",{value:!0})})); + diff --git a/site_libs/quarto-html/quarto-syntax-highlighting-dark-4d9afe2b8d18ee9fa5d0d57b5ed4214d.css b/site_libs/quarto-html/quarto-syntax-highlighting-dark-4d9afe2b8d18ee9fa5d0d57b5ed4214d.css new file mode 100644 index 0000000..9de3c1b --- /dev/null +++ b/site_libs/quarto-html/quarto-syntax-highlighting-dark-4d9afe2b8d18ee9fa5d0d57b5ed4214d.css @@ -0,0 +1,219 @@ +/* quarto syntax highlight colors */ +:root { + --quarto-hl-al-color: #f07178; + --quarto-hl-an-color: #d4d0ab; + --quarto-hl-at-color: #00e0e0; + --quarto-hl-bn-color: #d4d0ab; + --quarto-hl-bu-color: #abe338; + --quarto-hl-ch-color: #abe338; + --quarto-hl-co-color: #f8f8f2; + --quarto-hl-cv-color: #ffd700; + --quarto-hl-cn-color: #ffd700; + --quarto-hl-cf-color: #ffa07a; + --quarto-hl-dt-color: #ffa07a; + --quarto-hl-dv-color: #d4d0ab; + --quarto-hl-do-color: #f8f8f2; + --quarto-hl-er-color: #f07178; + --quarto-hl-ex-color: #00e0e0; + --quarto-hl-fl-color: #d4d0ab; + --quarto-hl-fu-color: #ffa07a; + --quarto-hl-im-color: #abe338; + --quarto-hl-in-color: #d4d0ab; + --quarto-hl-kw-color: #ffa07a; + --quarto-hl-op-color: #ffa07a; + --quarto-hl-ot-color: #00e0e0; + --quarto-hl-pp-color: #dcc6e0; + --quarto-hl-re-color: #00e0e0; + --quarto-hl-sc-color: #abe338; + --quarto-hl-ss-color: #abe338; + --quarto-hl-st-color: #abe338; + --quarto-hl-va-color: #00e0e0; + --quarto-hl-vs-color: #abe338; + --quarto-hl-wa-color: #dcc6e0; +} + +/* other quarto variables */ +:root { + --quarto-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +/* syntax highlight based on Pandoc's rules */ +pre > code.sourceCode > span { + color: #f8f8f2; +} + +code.sourceCode > span { + color: #f8f8f2; +} + +div.sourceCode, +div.sourceCode pre.sourceCode { + color: #f8f8f2; +} + +/* Normal */ +code span { + color: #f8f8f2; +} + +/* Alert */ +code span.al { + color: #f07178; +} + +/* Annotation */ +code span.an { + color: #d4d0ab; +} + +/* Attribute */ +code span.at { + color: #00e0e0; +} + +/* BaseN */ +code span.bn { + color: #d4d0ab; +} + +/* BuiltIn */ +code span.bu { + color: #abe338; +} + +/* ControlFlow */ +code span.cf { + font-weight: bold; + color: #ffa07a; +} + +/* Char */ +code span.ch { + color: #abe338; +} + +/* Constant */ +code span.cn { + color: #ffd700; +} + +/* Comment */ +code span.co { + font-style: italic; + color: #f8f8f2; +} + +/* CommentVar */ +code span.cv { + color: #ffd700; +} + +/* Documentation */ +code span.do { + color: #f8f8f2; +} + +/* DataType */ +code span.dt { + color: #ffa07a; +} + +/* DecVal */ +code span.dv { + color: #d4d0ab; +} + +/* Error */ +code span.er { + color: #f07178; + text-decoration: underline; +} + +/* Extension */ +code span.ex { + font-weight: bold; + color: #00e0e0; +} + +/* Float */ +code span.fl { + color: #d4d0ab; +} + +/* Function */ +code span.fu { + color: #ffa07a; +} + +/* Import */ +code span.im { + color: #abe338; +} + +/* Information */ +code span.in { + color: #d4d0ab; +} + +/* Keyword */ +code span.kw { + font-weight: bold; + color: #ffa07a; +} + +/* Operator */ +code span.op { + color: #ffa07a; +} + +/* Other */ +code span.ot { + color: #00e0e0; +} + +/* Preprocessor */ +code span.pp { + color: #dcc6e0; +} + +/* RegionMarker */ +code span.re { + background-color: #f8f8f2; + color: #00e0e0; +} + +/* SpecialChar */ +code span.sc { + color: #abe338; +} + +/* SpecialString */ +code span.ss { + color: #abe338; +} + +/* String */ +code span.st { + color: #abe338; +} + +/* Variable */ +code span.va { + color: #00e0e0; +} + +/* VerbatimString */ +code span.vs { + color: #abe338; +} + +/* Warning */ +code span.wa { + color: #dcc6e0; +} + +.prevent-inlining { + content: " code.sourceCode > span { + color: #003B4F; +} + +code.sourceCode > span { + color: #003B4F; +} + +div.sourceCode, +div.sourceCode pre.sourceCode { + color: #003B4F; +} + +/* Normal */ +code span { + color: #003B4F; +} + +/* Alert */ +code span.al { + color: #AD0000; + font-style: inherit; +} + +/* Annotation */ +code span.an { + color: #5E5E5E; + font-style: inherit; +} + +/* Attribute */ +code span.at { + color: #657422; + font-style: inherit; +} + +/* BaseN */ +code span.bn { + color: #AD0000; + font-style: inherit; +} + +/* BuiltIn */ +code span.bu { + font-style: inherit; +} + +/* ControlFlow */ +code span.cf { + color: #003B4F; + font-weight: bold; + font-style: inherit; +} + +/* Char */ +code span.ch { + color: #20794D; + font-style: inherit; +} + +/* Constant */ +code span.cn { + color: #8f5902; + font-style: inherit; +} + +/* Comment */ +code span.co { + color: #5E5E5E; + font-style: inherit; +} + +/* CommentVar */ +code span.cv { + color: #5E5E5E; + font-style: italic; +} + +/* Documentation */ +code span.do { + color: #5E5E5E; + font-style: italic; +} + +/* DataType */ +code span.dt { + color: #AD0000; + font-style: inherit; +} + +/* DecVal */ +code span.dv { + color: #AD0000; + font-style: inherit; +} + +/* Error */ +code span.er { + color: #AD0000; + font-style: inherit; +} + +/* Extension */ +code span.ex { + font-style: inherit; +} + +/* Float */ +code span.fl { + color: #AD0000; + font-style: inherit; +} + +/* Function */ +code span.fu { + color: #4758AB; + font-style: inherit; +} + +/* Import */ +code span.im { + color: #00769E; + font-style: inherit; +} + +/* Information */ +code span.in { + color: #5E5E5E; + font-style: inherit; +} + +/* Keyword */ +code span.kw { + color: #003B4F; + font-weight: bold; + font-style: inherit; +} + +/* Operator */ +code span.op { + color: #5E5E5E; + font-style: inherit; +} + +/* Other */ +code span.ot { + color: #003B4F; + font-style: inherit; +} + +/* Preprocessor */ +code span.pp { + color: #AD0000; + font-style: inherit; +} + +/* SpecialChar */ +code span.sc { + color: #5E5E5E; + font-style: inherit; +} + +/* SpecialString */ +code span.ss { + color: #20794D; + font-style: inherit; +} + +/* String */ +code span.st { + color: #20794D; + font-style: inherit; +} + +/* Variable */ +code span.va { + color: #111111; + font-style: inherit; +} + +/* VerbatimString */ +code span.vs { + color: #20794D; + font-style: inherit; +} + +/* Warning */ +code span.wa { + color: #5E5E5E; + font-style: italic; +} + +.prevent-inlining { + content: " { + // Find any conflicting margin elements and add margins to the + // top to prevent overlap + const marginChildren = window.document.querySelectorAll( + ".column-margin.column-container > *, .margin-caption, .aside" + ); + + let lastBottom = 0; + for (const marginChild of marginChildren) { + if (marginChild.offsetParent !== null) { + // clear the top margin so we recompute it + marginChild.style.marginTop = null; + const top = marginChild.getBoundingClientRect().top + window.scrollY; + if (top < lastBottom) { + const marginChildStyle = window.getComputedStyle(marginChild); + const marginBottom = parseFloat(marginChildStyle["marginBottom"]); + const margin = lastBottom - top + marginBottom; + marginChild.style.marginTop = `${margin}px`; + } + const styles = window.getComputedStyle(marginChild); + const marginTop = parseFloat(styles["marginTop"]); + lastBottom = top + marginChild.getBoundingClientRect().height + marginTop; + } + } +}; + +window.document.addEventListener("DOMContentLoaded", function (_event) { + // Recompute the position of margin elements anytime the body size changes + if (window.ResizeObserver) { + const resizeObserver = new window.ResizeObserver( + throttle(() => { + layoutMarginEls(); + if ( + window.document.body.getBoundingClientRect().width < 990 && + isReaderMode() + ) { + quartoToggleReader(); + } + }, 50) + ); + resizeObserver.observe(window.document.body); + } + + const tocEl = window.document.querySelector('nav.toc-active[role="doc-toc"]'); + const sidebarEl = window.document.getElementById("quarto-sidebar"); + const leftTocEl = window.document.getElementById("quarto-sidebar-toc-left"); + const marginSidebarEl = window.document.getElementById( + "quarto-margin-sidebar" + ); + // function to determine whether the element has a previous sibling that is active + const prevSiblingIsActiveLink = (el) => { + const sibling = el.previousElementSibling; + if (sibling && sibling.tagName === "A") { + return sibling.classList.contains("active"); + } else { + return false; + } + }; + + // dispatch for htmlwidgets + // they use slideenter event to trigger resize + function fireSlideEnter() { + const event = window.document.createEvent("Event"); + event.initEvent("slideenter", true, true); + window.document.dispatchEvent(event); + } + + const tabs = window.document.querySelectorAll('a[data-bs-toggle="tab"]'); + tabs.forEach((tab) => { + tab.addEventListener("shown.bs.tab", fireSlideEnter); + }); + + // dispatch for shiny + // they use BS shown and hidden events to trigger rendering + function distpatchShinyEvents(previous, current) { + if (window.jQuery) { + if (previous) { + window.jQuery(previous).trigger("hidden"); + } + if (current) { + window.jQuery(current).trigger("shown"); + } + } + } + + // tabby.js listener: Trigger event for htmlwidget and shiny + document.addEventListener( + "tabby", + function (event) { + fireSlideEnter(); + distpatchShinyEvents(event.detail.previousTab, event.detail.tab); + }, + false + ); + + // Track scrolling and mark TOC links as active + // get table of contents and sidebar (bail if we don't have at least one) + const tocLinks = tocEl + ? [...tocEl.querySelectorAll("a[data-scroll-target]")] + : []; + const makeActive = (link) => tocLinks[link].classList.add("active"); + const removeActive = (link) => tocLinks[link].classList.remove("active"); + const removeAllActive = () => + [...Array(tocLinks.length).keys()].forEach((link) => removeActive(link)); + + // activate the anchor for a section associated with this TOC entry + tocLinks.forEach((link) => { + link.addEventListener("click", () => { + if (link.href.indexOf("#") !== -1) { + const anchor = link.href.split("#")[1]; + const heading = window.document.querySelector( + `[data-anchor-id="${anchor}"]` + ); + if (heading) { + // Add the class + heading.classList.add("reveal-anchorjs-link"); + + // function to show the anchor + const handleMouseout = () => { + heading.classList.remove("reveal-anchorjs-link"); + heading.removeEventListener("mouseout", handleMouseout); + }; + + // add a function to clear the anchor when the user mouses out of it + heading.addEventListener("mouseout", handleMouseout); + } + } + }); + }); + + const sections = tocLinks.map((link) => { + const target = link.getAttribute("data-scroll-target"); + if (target.startsWith("#")) { + return window.document.getElementById(decodeURI(`${target.slice(1)}`)); + } else { + return window.document.querySelector(decodeURI(`${target}`)); + } + }); + + const sectionMargin = 200; + let currentActive = 0; + // track whether we've initialized state the first time + let init = false; + + const updateActiveLink = () => { + // The index from bottom to top (e.g. reversed list) + let sectionIndex = -1; + if ( + window.innerHeight + window.pageYOffset >= + window.document.body.offsetHeight + ) { + // This is the no-scroll case where last section should be the active one + sectionIndex = 0; + } else { + // This finds the last section visible on screen that should be made active + sectionIndex = [...sections].reverse().findIndex((section) => { + if (section) { + return window.pageYOffset >= section.offsetTop - sectionMargin; + } else { + return false; + } + }); + } + if (sectionIndex > -1) { + const current = sections.length - sectionIndex - 1; + if (current !== currentActive) { + removeAllActive(); + currentActive = current; + makeActive(current); + if (init) { + window.dispatchEvent(sectionChanged); + } + init = true; + } + } + }; + + const inHiddenRegion = (top, bottom, hiddenRegions) => { + for (const region of hiddenRegions) { + if (top <= region.bottom && bottom >= region.top) { + return true; + } + } + return false; + }; + + const categorySelector = "header.quarto-title-block .quarto-category"; + const activateCategories = (href) => { + // Find any categories + // Surround them with a link pointing back to: + // #category=Authoring + try { + const categoryEls = window.document.querySelectorAll(categorySelector); + for (const categoryEl of categoryEls) { + const categoryText = categoryEl.textContent; + if (categoryText) { + const link = `${href}#category=${encodeURIComponent(categoryText)}`; + const linkEl = window.document.createElement("a"); + linkEl.setAttribute("href", link); + for (const child of categoryEl.childNodes) { + linkEl.append(child); + } + categoryEl.appendChild(linkEl); + } + } + } catch { + // Ignore errors + } + }; + function hasTitleCategories() { + return window.document.querySelector(categorySelector) !== null; + } + + function offsetRelativeUrl(url) { + const offset = getMeta("quarto:offset"); + return offset ? offset + url : url; + } + + function offsetAbsoluteUrl(url) { + const offset = getMeta("quarto:offset"); + const baseUrl = new URL(offset, window.location); + + const projRelativeUrl = url.replace(baseUrl, ""); + if (projRelativeUrl.startsWith("/")) { + return projRelativeUrl; + } else { + return "/" + projRelativeUrl; + } + } + + // read a meta tag value + function getMeta(metaName) { + const 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 ""; + } + + async function findAndActivateCategories() { + // Categories search with listing only use path without query + const currentPagePath = offsetAbsoluteUrl( + window.location.origin + window.location.pathname + ); + const response = await fetch(offsetRelativeUrl("listings.json")); + if (response.status == 200) { + return response.json().then(function (listingPaths) { + const listingHrefs = []; + for (const listingPath of listingPaths) { + const pathWithoutLeadingSlash = listingPath.listing.substring(1); + for (const item of listingPath.items) { + const encodedItem = encodeURI(item); + if ( + encodedItem === currentPagePath || + encodedItem === currentPagePath + "index.html" + ) { + // Resolve this path against the offset to be sure + // we already are using the correct path to the listing + // (this adjusts the listing urls to be rooted against + // whatever root the page is actually running against) + const relative = offsetRelativeUrl(pathWithoutLeadingSlash); + const baseUrl = window.location; + const resolvedPath = new URL(relative, baseUrl); + listingHrefs.push(resolvedPath.pathname); + break; + } + } + } + + // Look up the tree for a nearby linting and use that if we find one + const nearestListing = findNearestParentListing( + offsetAbsoluteUrl(window.location.pathname), + listingHrefs + ); + if (nearestListing) { + activateCategories(nearestListing); + } else { + // See if the referrer is a listing page for this item + const referredRelativePath = offsetAbsoluteUrl(document.referrer); + const referrerListing = listingHrefs.find((listingHref) => { + const isListingReferrer = + listingHref === referredRelativePath || + listingHref === referredRelativePath + "index.html"; + return isListingReferrer; + }); + + if (referrerListing) { + // Try to use the referrer if possible + activateCategories(referrerListing); + } else if (listingHrefs.length > 0) { + // Otherwise, just fall back to the first listing + activateCategories(listingHrefs[0]); + } + } + }); + } + } + if (hasTitleCategories()) { + findAndActivateCategories(); + } + + const findNearestParentListing = (href, listingHrefs) => { + if (!href || !listingHrefs) { + return undefined; + } + // Look up the tree for a nearby linting and use that if we find one + const relativeParts = href.substring(1).split("/"); + while (relativeParts.length > 0) { + const path = relativeParts.join("/"); + for (const listingHref of listingHrefs) { + if (listingHref.startsWith(path)) { + return listingHref; + } + } + relativeParts.pop(); + } + + return undefined; + }; + + const manageSidebarVisiblity = (el, placeholderDescriptor) => { + let isVisible = true; + let elRect; + + return (hiddenRegions) => { + if (el === null) { + return; + } + + // Find the last element of the TOC + const lastChildEl = el.lastElementChild; + + if (lastChildEl) { + // Converts the sidebar to a menu + const convertToMenu = () => { + for (const child of el.children) { + child.style.opacity = 0; + child.style.overflow = "hidden"; + child.style.pointerEvents = "none"; + } + + nexttick(() => { + const toggleContainer = window.document.createElement("div"); + toggleContainer.style.width = "100%"; + toggleContainer.classList.add("zindex-over-content"); + toggleContainer.classList.add("quarto-sidebar-toggle"); + toggleContainer.classList.add("headroom-target"); // Marks this to be managed by headeroom + toggleContainer.id = placeholderDescriptor.id; + toggleContainer.style.position = "fixed"; + + const toggleIcon = window.document.createElement("i"); + toggleIcon.classList.add("quarto-sidebar-toggle-icon"); + toggleIcon.classList.add("bi"); + toggleIcon.classList.add("bi-caret-down-fill"); + + const toggleTitle = window.document.createElement("div"); + const titleEl = window.document.body.querySelector( + placeholderDescriptor.titleSelector + ); + if (titleEl) { + toggleTitle.append( + titleEl.textContent || titleEl.innerText, + toggleIcon + ); + } + toggleTitle.classList.add("zindex-over-content"); + toggleTitle.classList.add("quarto-sidebar-toggle-title"); + toggleContainer.append(toggleTitle); + + const toggleContents = window.document.createElement("div"); + toggleContents.classList = el.classList; + toggleContents.classList.add("zindex-over-content"); + toggleContents.classList.add("quarto-sidebar-toggle-contents"); + for (const child of el.children) { + if (child.id === "toc-title") { + continue; + } + + const clone = child.cloneNode(true); + clone.style.opacity = 1; + clone.style.pointerEvents = null; + clone.style.display = null; + toggleContents.append(clone); + } + toggleContents.style.height = "0px"; + const positionToggle = () => { + // position the element (top left of parent, same width as parent) + if (!elRect) { + elRect = el.getBoundingClientRect(); + } + toggleContainer.style.left = `${elRect.left}px`; + toggleContainer.style.top = `${elRect.top}px`; + toggleContainer.style.width = `${elRect.width}px`; + }; + positionToggle(); + + toggleContainer.append(toggleContents); + el.parentElement.prepend(toggleContainer); + + // Process clicks + let tocShowing = false; + // Allow the caller to control whether this is dismissed + // when it is clicked (e.g. sidebar navigation supports + // opening and closing the nav tree, so don't dismiss on click) + const clickEl = placeholderDescriptor.dismissOnClick + ? toggleContainer + : toggleTitle; + + const closeToggle = () => { + if (tocShowing) { + toggleContainer.classList.remove("expanded"); + toggleContents.style.height = "0px"; + tocShowing = false; + } + }; + + // Get rid of any expanded toggle if the user scrolls + window.document.addEventListener( + "scroll", + throttle(() => { + closeToggle(); + }, 50) + ); + + // Handle positioning of the toggle + window.addEventListener( + "resize", + throttle(() => { + elRect = undefined; + positionToggle(); + }, 50) + ); + + window.addEventListener("quarto-hrChanged", () => { + elRect = undefined; + }); + + // Process the click + clickEl.onclick = () => { + if (!tocShowing) { + toggleContainer.classList.add("expanded"); + toggleContents.style.height = null; + tocShowing = true; + } else { + closeToggle(); + } + }; + }); + }; + + // Converts a sidebar from a menu back to a sidebar + const convertToSidebar = () => { + for (const child of el.children) { + child.style.opacity = 1; + child.style.overflow = null; + child.style.pointerEvents = null; + } + + const placeholderEl = window.document.getElementById( + placeholderDescriptor.id + ); + if (placeholderEl) { + placeholderEl.remove(); + } + + el.classList.remove("rollup"); + }; + + if (isReaderMode()) { + convertToMenu(); + isVisible = false; + } else { + // Find the top and bottom o the element that is being managed + const elTop = el.offsetTop; + const elBottom = + elTop + lastChildEl.offsetTop + lastChildEl.offsetHeight; + + if (!isVisible) { + // If the element is current not visible reveal if there are + // no conflicts with overlay regions + if (!inHiddenRegion(elTop, elBottom, hiddenRegions)) { + convertToSidebar(); + isVisible = true; + } + } else { + // If the element is visible, hide it if it conflicts with overlay regions + // and insert a placeholder toggle (or if we're in reader mode) + if (inHiddenRegion(elTop, elBottom, hiddenRegions)) { + convertToMenu(); + isVisible = false; + } + } + } + } + }; + }; + + const tabEls = document.querySelectorAll('a[data-bs-toggle="tab"]'); + for (const tabEl of tabEls) { + const id = tabEl.getAttribute("data-bs-target"); + if (id) { + const columnEl = document.querySelector( + `${id} .column-margin, .tabset-margin-content` + ); + if (columnEl) + tabEl.addEventListener("shown.bs.tab", function (event) { + const el = event.srcElement; + if (el) { + const visibleCls = `${el.id}-margin-content`; + // walk up until we find a parent tabset + let panelTabsetEl = el.parentElement; + while (panelTabsetEl) { + if (panelTabsetEl.classList.contains("panel-tabset")) { + break; + } + panelTabsetEl = panelTabsetEl.parentElement; + } + + if (panelTabsetEl) { + const prevSib = panelTabsetEl.previousElementSibling; + if ( + prevSib && + prevSib.classList.contains("tabset-margin-container") + ) { + const childNodes = prevSib.querySelectorAll( + ".tabset-margin-content" + ); + for (const childEl of childNodes) { + if (childEl.classList.contains(visibleCls)) { + childEl.classList.remove("collapse"); + } else { + childEl.classList.add("collapse"); + } + } + } + } + } + + layoutMarginEls(); + }); + } + } + + // Manage the visibility of the toc and the sidebar + const marginScrollVisibility = manageSidebarVisiblity(marginSidebarEl, { + id: "quarto-toc-toggle", + titleSelector: "#toc-title", + dismissOnClick: true, + }); + const sidebarScrollVisiblity = manageSidebarVisiblity(sidebarEl, { + id: "quarto-sidebarnav-toggle", + titleSelector: ".title", + dismissOnClick: false, + }); + let tocLeftScrollVisibility; + if (leftTocEl) { + tocLeftScrollVisibility = manageSidebarVisiblity(leftTocEl, { + id: "quarto-lefttoc-toggle", + titleSelector: "#toc-title", + dismissOnClick: true, + }); + } + + // Find the first element that uses formatting in special columns + const conflictingEls = window.document.body.querySelectorAll( + '[class^="column-"], [class*=" column-"], aside, [class*="margin-caption"], [class*=" margin-caption"], [class*="margin-ref"], [class*=" margin-ref"]' + ); + + // Filter all the possibly conflicting elements into ones + // the do conflict on the left or ride side + const arrConflictingEls = Array.from(conflictingEls); + const leftSideConflictEls = arrConflictingEls.filter((el) => { + if (el.tagName === "ASIDE") { + return false; + } + return Array.from(el.classList).find((className) => { + return ( + className !== "column-body" && + className.startsWith("column-") && + !className.endsWith("right") && + !className.endsWith("container") && + className !== "column-margin" + ); + }); + }); + const rightSideConflictEls = arrConflictingEls.filter((el) => { + if (el.tagName === "ASIDE") { + return true; + } + + const hasMarginCaption = Array.from(el.classList).find((className) => { + return className == "margin-caption"; + }); + if (hasMarginCaption) { + return true; + } + + return Array.from(el.classList).find((className) => { + return ( + className !== "column-body" && + !className.endsWith("container") && + className.startsWith("column-") && + !className.endsWith("left") + ); + }); + }); + + const kOverlapPaddingSize = 10; + function toRegions(els) { + return els.map((el) => { + const boundRect = el.getBoundingClientRect(); + const top = + boundRect.top + + document.documentElement.scrollTop - + kOverlapPaddingSize; + return { + top, + bottom: top + el.scrollHeight + 2 * kOverlapPaddingSize, + }; + }); + } + + let hasObserved = false; + const visibleItemObserver = (els) => { + let visibleElements = [...els]; + const intersectionObserver = new IntersectionObserver( + (entries, _observer) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + if (visibleElements.indexOf(entry.target) === -1) { + visibleElements.push(entry.target); + } + } else { + visibleElements = visibleElements.filter((visibleEntry) => { + return visibleEntry !== entry; + }); + } + }); + + if (!hasObserved) { + hideOverlappedSidebars(); + } + hasObserved = true; + }, + {} + ); + els.forEach((el) => { + intersectionObserver.observe(el); + }); + + return { + getVisibleEntries: () => { + return visibleElements; + }, + }; + }; + + const rightElementObserver = visibleItemObserver(rightSideConflictEls); + const leftElementObserver = visibleItemObserver(leftSideConflictEls); + + const hideOverlappedSidebars = () => { + marginScrollVisibility(toRegions(rightElementObserver.getVisibleEntries())); + sidebarScrollVisiblity(toRegions(leftElementObserver.getVisibleEntries())); + if (tocLeftScrollVisibility) { + tocLeftScrollVisibility( + toRegions(leftElementObserver.getVisibleEntries()) + ); + } + }; + + window.quartoToggleReader = () => { + // Applies a slow class (or removes it) + // to update the transition speed + const slowTransition = (slow) => { + const manageTransition = (id, slow) => { + const el = document.getElementById(id); + if (el) { + if (slow) { + el.classList.add("slow"); + } else { + el.classList.remove("slow"); + } + } + }; + + manageTransition("TOC", slow); + manageTransition("quarto-sidebar", slow); + }; + const readerMode = !isReaderMode(); + setReaderModeValue(readerMode); + + // If we're entering reader mode, slow the transition + if (readerMode) { + slowTransition(readerMode); + } + highlightReaderToggle(readerMode); + hideOverlappedSidebars(); + + // If we're exiting reader mode, restore the non-slow transition + if (!readerMode) { + slowTransition(!readerMode); + } + }; + + const highlightReaderToggle = (readerMode) => { + const els = document.querySelectorAll(".quarto-reader-toggle"); + if (els) { + els.forEach((el) => { + if (readerMode) { + el.classList.add("reader"); + } else { + el.classList.remove("reader"); + } + }); + } + }; + + const setReaderModeValue = (val) => { + if (window.location.protocol !== "file:") { + window.localStorage.setItem("quarto-reader-mode", val); + } else { + localReaderMode = val; + } + }; + + const isReaderMode = () => { + if (window.location.protocol !== "file:") { + return window.localStorage.getItem("quarto-reader-mode") === "true"; + } else { + return localReaderMode; + } + }; + let localReaderMode = null; + + const tocOpenDepthStr = tocEl?.getAttribute("data-toc-expanded"); + const tocOpenDepth = tocOpenDepthStr ? Number(tocOpenDepthStr) : 1; + + // Walk the TOC and collapse/expand nodes + // Nodes are expanded if: + // - they are top level + // - they have children that are 'active' links + // - they are directly below an link that is 'active' + const walk = (el, depth) => { + // Tick depth when we enter a UL + if (el.tagName === "UL") { + depth = depth + 1; + } + + // It this is active link + let isActiveNode = false; + if (el.tagName === "A" && el.classList.contains("active")) { + isActiveNode = true; + } + + // See if there is an active child to this element + let hasActiveChild = false; + for (const child of el.children) { + hasActiveChild = walk(child, depth) || hasActiveChild; + } + + // Process the collapse state if this is an UL + if (el.tagName === "UL") { + if (tocOpenDepth === -1 && depth > 1) { + // toc-expand: false + el.classList.add("collapse"); + } else if ( + depth <= tocOpenDepth || + hasActiveChild || + prevSiblingIsActiveLink(el) + ) { + el.classList.remove("collapse"); + } else { + el.classList.add("collapse"); + } + + // untick depth when we leave a UL + depth = depth - 1; + } + return hasActiveChild || isActiveNode; + }; + + // walk the TOC and expand / collapse any items that should be shown + if (tocEl) { + updateActiveLink(); + walk(tocEl, 0); + } + + // Throttle the scroll event and walk peridiocally + window.document.addEventListener( + "scroll", + throttle(() => { + if (tocEl) { + updateActiveLink(); + walk(tocEl, 0); + } + if (!isReaderMode()) { + hideOverlappedSidebars(); + } + }, 5) + ); + window.addEventListener( + "resize", + throttle(() => { + if (tocEl) { + updateActiveLink(); + walk(tocEl, 0); + } + if (!isReaderMode()) { + hideOverlappedSidebars(); + } + }, 10) + ); + hideOverlappedSidebars(); + highlightReaderToggle(isReaderMode()); +}); + +tabsets.init(); +axe.init(); + +function throttle(func, wait) { + let waiting = false; + return function () { + if (!waiting) { + func.apply(this, arguments); + waiting = true; + setTimeout(function () { + waiting = false; + }, wait); + } + }; +} + +function nexttick(func) { + return setTimeout(func, 0); +} diff --git a/site_libs/quarto-html/tabsets/tabsets.js b/site_libs/quarto-html/tabsets/tabsets.js new file mode 100644 index 0000000..51345d0 --- /dev/null +++ b/site_libs/quarto-html/tabsets/tabsets.js @@ -0,0 +1,95 @@ +// grouped tabsets + +export function init() { + window.addEventListener("pageshow", (_event) => { + function getTabSettings() { + const data = localStorage.getItem("quarto-persistent-tabsets-data"); + if (!data) { + localStorage.setItem("quarto-persistent-tabsets-data", "{}"); + return {}; + } + if (data) { + return JSON.parse(data); + } + } + + function setTabSettings(data) { + localStorage.setItem( + "quarto-persistent-tabsets-data", + JSON.stringify(data) + ); + } + + function setTabState(groupName, groupValue) { + const data = getTabSettings(); + data[groupName] = groupValue; + setTabSettings(data); + } + + function toggleTab(tab, active) { + const tabPanelId = tab.getAttribute("aria-controls"); + const tabPanel = document.getElementById(tabPanelId); + if (active) { + tab.classList.add("active"); + tabPanel.classList.add("active"); + } else { + tab.classList.remove("active"); + tabPanel.classList.remove("active"); + } + } + + function toggleAll(selectedGroup, selectorsToSync) { + for (const [thisGroup, tabs] of Object.entries(selectorsToSync)) { + const active = selectedGroup === thisGroup; + for (const tab of tabs) { + toggleTab(tab, active); + } + } + } + + function findSelectorsToSyncByLanguage() { + const result = {}; + const tabs = Array.from( + document.querySelectorAll(`div[data-group] a[id^='tabset-']`) + ); + for (const item of tabs) { + const div = item.parentElement.parentElement.parentElement; + const group = div.getAttribute("data-group"); + if (!result[group]) { + result[group] = {}; + } + const selectorsToSync = result[group]; + const value = item.innerHTML; + if (!selectorsToSync[value]) { + selectorsToSync[value] = []; + } + selectorsToSync[value].push(item); + } + return result; + } + + function setupSelectorSync() { + const selectorsToSync = findSelectorsToSyncByLanguage(); + Object.entries(selectorsToSync).forEach(([group, tabSetsByValue]) => { + Object.entries(tabSetsByValue).forEach(([value, items]) => { + items.forEach((item) => { + item.addEventListener("click", (_event) => { + setTabState(group, value); + toggleAll(value, selectorsToSync[group]); + }); + }); + }); + }); + return selectorsToSync; + } + + const selectorsToSync = setupSelectorSync(); + for (const [group, selectedName] of Object.entries(getTabSettings())) { + const selectors = selectorsToSync[group]; + // it's possible that stale state gives us empty selections, so we explicitly check here. + if (selectors) { + toggleAll(selectedName, selectors); + } + } + }); +} diff --git a/site_libs/quarto-html/tippy.css b/site_libs/quarto-html/tippy.css new file mode 100644 index 0000000..e6ae635 --- /dev/null +++ b/site_libs/quarto-html/tippy.css @@ -0,0 +1 @@ +.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;white-space:normal;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-7px;left:0;border-width:8px 8px 0;border-top-color:initial;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;left:0;border-width:0 8px 8px;border-bottom-color:initial;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:initial;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:initial;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px;color:#333}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1} \ No newline at end of file diff --git a/site_libs/quarto-html/tippy.umd.min.js b/site_libs/quarto-html/tippy.umd.min.js new file mode 100644 index 0000000..ca292be --- /dev/null +++ b/site_libs/quarto-html/tippy.umd.min.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("@popperjs/core")):"function"==typeof define&&define.amd?define(["@popperjs/core"],t):(e=e||self).tippy=t(e.Popper)}(this,(function(e){"use strict";var t={passive:!0,capture:!0},n=function(){return document.body};function r(e,t,n){if(Array.isArray(e)){var r=e[t];return null==r?Array.isArray(n)?n[t]:n:r}return e}function o(e,t){var n={}.toString.call(e);return 0===n.indexOf("[object")&&n.indexOf(t+"]")>-1}function i(e,t){return"function"==typeof e?e.apply(void 0,t):e}function a(e,t){return 0===t?e:function(r){clearTimeout(n),n=setTimeout((function(){e(r)}),t)};var n}function s(e,t){var n=Object.assign({},e);return t.forEach((function(e){delete n[e]})),n}function u(e){return[].concat(e)}function c(e,t){-1===e.indexOf(t)&&e.push(t)}function p(e){return e.split("-")[0]}function f(e){return[].slice.call(e)}function l(e){return Object.keys(e).reduce((function(t,n){return void 0!==e[n]&&(t[n]=e[n]),t}),{})}function d(){return document.createElement("div")}function v(e){return["Element","Fragment"].some((function(t){return o(e,t)}))}function m(e){return o(e,"MouseEvent")}function g(e){return!(!e||!e._tippy||e._tippy.reference!==e)}function h(e){return v(e)?[e]:function(e){return o(e,"NodeList")}(e)?f(e):Array.isArray(e)?e:f(document.querySelectorAll(e))}function b(e,t){e.forEach((function(e){e&&(e.style.transitionDuration=t+"ms")}))}function y(e,t){e.forEach((function(e){e&&e.setAttribute("data-state",t)}))}function w(e){var t,n=u(e)[0];return null!=n&&null!=(t=n.ownerDocument)&&t.body?n.ownerDocument:document}function E(e,t,n){var r=t+"EventListener";["transitionend","webkitTransitionEnd"].forEach((function(t){e[r](t,n)}))}function O(e,t){for(var n=t;n;){var r;if(e.contains(n))return!0;n=null==n.getRootNode||null==(r=n.getRootNode())?void 0:r.host}return!1}var x={isTouch:!1},C=0;function T(){x.isTouch||(x.isTouch=!0,window.performance&&document.addEventListener("mousemove",A))}function A(){var e=performance.now();e-C<20&&(x.isTouch=!1,document.removeEventListener("mousemove",A)),C=e}function L(){var e=document.activeElement;if(g(e)){var t=e._tippy;e.blur&&!t.state.isVisible&&e.blur()}}var D=!!("undefined"!=typeof window&&"undefined"!=typeof document)&&!!window.msCrypto,R=Object.assign({appendTo:n,aria:{content:"auto",expanded:"auto"},delay:0,duration:[300,250],getReferenceClientRect:null,hideOnClick:!0,ignoreAttributes:!1,interactive:!1,interactiveBorder:2,interactiveDebounce:0,moveTransition:"",offset:[0,10],onAfterUpdate:function(){},onBeforeUpdate:function(){},onCreate:function(){},onDestroy:function(){},onHidden:function(){},onHide:function(){},onMount:function(){},onShow:function(){},onShown:function(){},onTrigger:function(){},onUntrigger:function(){},onClickOutside:function(){},placement:"top",plugins:[],popperOptions:{},render:null,showOnCreate:!1,touch:!0,trigger:"mouseenter focus",triggerTarget:null},{animateFill:!1,followCursor:!1,inlinePositioning:!1,sticky:!1},{allowHTML:!1,animation:"fade",arrow:!0,content:"",inertia:!1,maxWidth:350,role:"tooltip",theme:"",zIndex:9999}),k=Object.keys(R);function P(e){var t=(e.plugins||[]).reduce((function(t,n){var r,o=n.name,i=n.defaultValue;o&&(t[o]=void 0!==e[o]?e[o]:null!=(r=R[o])?r:i);return t}),{});return Object.assign({},e,t)}function j(e,t){var n=Object.assign({},t,{content:i(t.content,[e])},t.ignoreAttributes?{}:function(e,t){return(t?Object.keys(P(Object.assign({},R,{plugins:t}))):k).reduce((function(t,n){var r=(e.getAttribute("data-tippy-"+n)||"").trim();if(!r)return t;if("content"===n)t[n]=r;else try{t[n]=JSON.parse(r)}catch(e){t[n]=r}return t}),{})}(e,t.plugins));return n.aria=Object.assign({},R.aria,n.aria),n.aria={expanded:"auto"===n.aria.expanded?t.interactive:n.aria.expanded,content:"auto"===n.aria.content?t.interactive?null:"describedby":n.aria.content},n}function M(e,t){e.innerHTML=t}function V(e){var t=d();return!0===e?t.className="tippy-arrow":(t.className="tippy-svg-arrow",v(e)?t.appendChild(e):M(t,e)),t}function I(e,t){v(t.content)?(M(e,""),e.appendChild(t.content)):"function"!=typeof t.content&&(t.allowHTML?M(e,t.content):e.textContent=t.content)}function S(e){var t=e.firstElementChild,n=f(t.children);return{box:t,content:n.find((function(e){return e.classList.contains("tippy-content")})),arrow:n.find((function(e){return e.classList.contains("tippy-arrow")||e.classList.contains("tippy-svg-arrow")})),backdrop:n.find((function(e){return e.classList.contains("tippy-backdrop")}))}}function N(e){var t=d(),n=d();n.className="tippy-box",n.setAttribute("data-state","hidden"),n.setAttribute("tabindex","-1");var r=d();function o(n,r){var o=S(t),i=o.box,a=o.content,s=o.arrow;r.theme?i.setAttribute("data-theme",r.theme):i.removeAttribute("data-theme"),"string"==typeof r.animation?i.setAttribute("data-animation",r.animation):i.removeAttribute("data-animation"),r.inertia?i.setAttribute("data-inertia",""):i.removeAttribute("data-inertia"),i.style.maxWidth="number"==typeof r.maxWidth?r.maxWidth+"px":r.maxWidth,r.role?i.setAttribute("role",r.role):i.removeAttribute("role"),n.content===r.content&&n.allowHTML===r.allowHTML||I(a,e.props),r.arrow?s?n.arrow!==r.arrow&&(i.removeChild(s),i.appendChild(V(r.arrow))):i.appendChild(V(r.arrow)):s&&i.removeChild(s)}return r.className="tippy-content",r.setAttribute("data-state","hidden"),I(r,e.props),t.appendChild(n),n.appendChild(r),o(e.props,e.props),{popper:t,onUpdate:o}}N.$$tippy=!0;var B=1,H=[],U=[];function _(o,s){var v,g,h,C,T,A,L,k,M=j(o,Object.assign({},R,P(l(s)))),V=!1,I=!1,N=!1,_=!1,F=[],W=a(we,M.interactiveDebounce),X=B++,Y=(k=M.plugins).filter((function(e,t){return k.indexOf(e)===t})),$={id:X,reference:o,popper:d(),popperInstance:null,props:M,state:{isEnabled:!0,isVisible:!1,isDestroyed:!1,isMounted:!1,isShown:!1},plugins:Y,clearDelayTimeouts:function(){clearTimeout(v),clearTimeout(g),cancelAnimationFrame(h)},setProps:function(e){if($.state.isDestroyed)return;ae("onBeforeUpdate",[$,e]),be();var t=$.props,n=j(o,Object.assign({},t,l(e),{ignoreAttributes:!0}));$.props=n,he(),t.interactiveDebounce!==n.interactiveDebounce&&(ce(),W=a(we,n.interactiveDebounce));t.triggerTarget&&!n.triggerTarget?u(t.triggerTarget).forEach((function(e){e.removeAttribute("aria-expanded")})):n.triggerTarget&&o.removeAttribute("aria-expanded");ue(),ie(),J&&J(t,n);$.popperInstance&&(Ce(),Ae().forEach((function(e){requestAnimationFrame(e._tippy.popperInstance.forceUpdate)})));ae("onAfterUpdate",[$,e])},setContent:function(e){$.setProps({content:e})},show:function(){var e=$.state.isVisible,t=$.state.isDestroyed,o=!$.state.isEnabled,a=x.isTouch&&!$.props.touch,s=r($.props.duration,0,R.duration);if(e||t||o||a)return;if(te().hasAttribute("disabled"))return;if(ae("onShow",[$],!1),!1===$.props.onShow($))return;$.state.isVisible=!0,ee()&&(z.style.visibility="visible");ie(),de(),$.state.isMounted||(z.style.transition="none");if(ee()){var u=re(),p=u.box,f=u.content;b([p,f],0)}A=function(){var e;if($.state.isVisible&&!_){if(_=!0,z.offsetHeight,z.style.transition=$.props.moveTransition,ee()&&$.props.animation){var t=re(),n=t.box,r=t.content;b([n,r],s),y([n,r],"visible")}se(),ue(),c(U,$),null==(e=$.popperInstance)||e.forceUpdate(),ae("onMount",[$]),$.props.animation&&ee()&&function(e,t){me(e,t)}(s,(function(){$.state.isShown=!0,ae("onShown",[$])}))}},function(){var e,t=$.props.appendTo,r=te();e=$.props.interactive&&t===n||"parent"===t?r.parentNode:i(t,[r]);e.contains(z)||e.appendChild(z);$.state.isMounted=!0,Ce()}()},hide:function(){var e=!$.state.isVisible,t=$.state.isDestroyed,n=!$.state.isEnabled,o=r($.props.duration,1,R.duration);if(e||t||n)return;if(ae("onHide",[$],!1),!1===$.props.onHide($))return;$.state.isVisible=!1,$.state.isShown=!1,_=!1,V=!1,ee()&&(z.style.visibility="hidden");if(ce(),ve(),ie(!0),ee()){var i=re(),a=i.box,s=i.content;$.props.animation&&(b([a,s],o),y([a,s],"hidden"))}se(),ue(),$.props.animation?ee()&&function(e,t){me(e,(function(){!$.state.isVisible&&z.parentNode&&z.parentNode.contains(z)&&t()}))}(o,$.unmount):$.unmount()},hideWithInteractivity:function(e){ne().addEventListener("mousemove",W),c(H,W),W(e)},enable:function(){$.state.isEnabled=!0},disable:function(){$.hide(),$.state.isEnabled=!1},unmount:function(){$.state.isVisible&&$.hide();if(!$.state.isMounted)return;Te(),Ae().forEach((function(e){e._tippy.unmount()})),z.parentNode&&z.parentNode.removeChild(z);U=U.filter((function(e){return e!==$})),$.state.isMounted=!1,ae("onHidden",[$])},destroy:function(){if($.state.isDestroyed)return;$.clearDelayTimeouts(),$.unmount(),be(),delete o._tippy,$.state.isDestroyed=!0,ae("onDestroy",[$])}};if(!M.render)return $;var q=M.render($),z=q.popper,J=q.onUpdate;z.setAttribute("data-tippy-root",""),z.id="tippy-"+$.id,$.popper=z,o._tippy=$,z._tippy=$;var G=Y.map((function(e){return e.fn($)})),K=o.hasAttribute("aria-expanded");return he(),ue(),ie(),ae("onCreate",[$]),M.showOnCreate&&Le(),z.addEventListener("mouseenter",(function(){$.props.interactive&&$.state.isVisible&&$.clearDelayTimeouts()})),z.addEventListener("mouseleave",(function(){$.props.interactive&&$.props.trigger.indexOf("mouseenter")>=0&&ne().addEventListener("mousemove",W)})),$;function Q(){var e=$.props.touch;return Array.isArray(e)?e:[e,0]}function Z(){return"hold"===Q()[0]}function ee(){var e;return!(null==(e=$.props.render)||!e.$$tippy)}function te(){return L||o}function ne(){var e=te().parentNode;return e?w(e):document}function re(){return S(z)}function oe(e){return $.state.isMounted&&!$.state.isVisible||x.isTouch||C&&"focus"===C.type?0:r($.props.delay,e?0:1,R.delay)}function ie(e){void 0===e&&(e=!1),z.style.pointerEvents=$.props.interactive&&!e?"":"none",z.style.zIndex=""+$.props.zIndex}function ae(e,t,n){var r;(void 0===n&&(n=!0),G.forEach((function(n){n[e]&&n[e].apply(n,t)})),n)&&(r=$.props)[e].apply(r,t)}function se(){var e=$.props.aria;if(e.content){var t="aria-"+e.content,n=z.id;u($.props.triggerTarget||o).forEach((function(e){var r=e.getAttribute(t);if($.state.isVisible)e.setAttribute(t,r?r+" "+n:n);else{var o=r&&r.replace(n,"").trim();o?e.setAttribute(t,o):e.removeAttribute(t)}}))}}function ue(){!K&&$.props.aria.expanded&&u($.props.triggerTarget||o).forEach((function(e){$.props.interactive?e.setAttribute("aria-expanded",$.state.isVisible&&e===te()?"true":"false"):e.removeAttribute("aria-expanded")}))}function ce(){ne().removeEventListener("mousemove",W),H=H.filter((function(e){return e!==W}))}function pe(e){if(!x.isTouch||!N&&"mousedown"!==e.type){var t=e.composedPath&&e.composedPath()[0]||e.target;if(!$.props.interactive||!O(z,t)){if(u($.props.triggerTarget||o).some((function(e){return O(e,t)}))){if(x.isTouch)return;if($.state.isVisible&&$.props.trigger.indexOf("click")>=0)return}else ae("onClickOutside",[$,e]);!0===$.props.hideOnClick&&($.clearDelayTimeouts(),$.hide(),I=!0,setTimeout((function(){I=!1})),$.state.isMounted||ve())}}}function fe(){N=!0}function le(){N=!1}function de(){var e=ne();e.addEventListener("mousedown",pe,!0),e.addEventListener("touchend",pe,t),e.addEventListener("touchstart",le,t),e.addEventListener("touchmove",fe,t)}function ve(){var e=ne();e.removeEventListener("mousedown",pe,!0),e.removeEventListener("touchend",pe,t),e.removeEventListener("touchstart",le,t),e.removeEventListener("touchmove",fe,t)}function me(e,t){var n=re().box;function r(e){e.target===n&&(E(n,"remove",r),t())}if(0===e)return t();E(n,"remove",T),E(n,"add",r),T=r}function ge(e,t,n){void 0===n&&(n=!1),u($.props.triggerTarget||o).forEach((function(r){r.addEventListener(e,t,n),F.push({node:r,eventType:e,handler:t,options:n})}))}function he(){var e;Z()&&(ge("touchstart",ye,{passive:!0}),ge("touchend",Ee,{passive:!0})),(e=$.props.trigger,e.split(/\s+/).filter(Boolean)).forEach((function(e){if("manual"!==e)switch(ge(e,ye),e){case"mouseenter":ge("mouseleave",Ee);break;case"focus":ge(D?"focusout":"blur",Oe);break;case"focusin":ge("focusout",Oe)}}))}function be(){F.forEach((function(e){var t=e.node,n=e.eventType,r=e.handler,o=e.options;t.removeEventListener(n,r,o)})),F=[]}function ye(e){var t,n=!1;if($.state.isEnabled&&!xe(e)&&!I){var r="focus"===(null==(t=C)?void 0:t.type);C=e,L=e.currentTarget,ue(),!$.state.isVisible&&m(e)&&H.forEach((function(t){return t(e)})),"click"===e.type&&($.props.trigger.indexOf("mouseenter")<0||V)&&!1!==$.props.hideOnClick&&$.state.isVisible?n=!0:Le(e),"click"===e.type&&(V=!n),n&&!r&&De(e)}}function we(e){var t=e.target,n=te().contains(t)||z.contains(t);"mousemove"===e.type&&n||function(e,t){var n=t.clientX,r=t.clientY;return e.every((function(e){var t=e.popperRect,o=e.popperState,i=e.props.interactiveBorder,a=p(o.placement),s=o.modifiersData.offset;if(!s)return!0;var u="bottom"===a?s.top.y:0,c="top"===a?s.bottom.y:0,f="right"===a?s.left.x:0,l="left"===a?s.right.x:0,d=t.top-r+u>i,v=r-t.bottom-c>i,m=t.left-n+f>i,g=n-t.right-l>i;return d||v||m||g}))}(Ae().concat(z).map((function(e){var t,n=null==(t=e._tippy.popperInstance)?void 0:t.state;return n?{popperRect:e.getBoundingClientRect(),popperState:n,props:M}:null})).filter(Boolean),e)&&(ce(),De(e))}function Ee(e){xe(e)||$.props.trigger.indexOf("click")>=0&&V||($.props.interactive?$.hideWithInteractivity(e):De(e))}function Oe(e){$.props.trigger.indexOf("focusin")<0&&e.target!==te()||$.props.interactive&&e.relatedTarget&&z.contains(e.relatedTarget)||De(e)}function xe(e){return!!x.isTouch&&Z()!==e.type.indexOf("touch")>=0}function Ce(){Te();var t=$.props,n=t.popperOptions,r=t.placement,i=t.offset,a=t.getReferenceClientRect,s=t.moveTransition,u=ee()?S(z).arrow:null,c=a?{getBoundingClientRect:a,contextElement:a.contextElement||te()}:o,p=[{name:"offset",options:{offset:i}},{name:"preventOverflow",options:{padding:{top:2,bottom:2,left:5,right:5}}},{name:"flip",options:{padding:5}},{name:"computeStyles",options:{adaptive:!s}},{name:"$$tippy",enabled:!0,phase:"beforeWrite",requires:["computeStyles"],fn:function(e){var t=e.state;if(ee()){var n=re().box;["placement","reference-hidden","escaped"].forEach((function(e){"placement"===e?n.setAttribute("data-placement",t.placement):t.attributes.popper["data-popper-"+e]?n.setAttribute("data-"+e,""):n.removeAttribute("data-"+e)})),t.attributes.popper={}}}}];ee()&&u&&p.push({name:"arrow",options:{element:u,padding:3}}),p.push.apply(p,(null==n?void 0:n.modifiers)||[]),$.popperInstance=e.createPopper(c,z,Object.assign({},n,{placement:r,onFirstUpdate:A,modifiers:p}))}function Te(){$.popperInstance&&($.popperInstance.destroy(),$.popperInstance=null)}function Ae(){return f(z.querySelectorAll("[data-tippy-root]"))}function Le(e){$.clearDelayTimeouts(),e&&ae("onTrigger",[$,e]),de();var t=oe(!0),n=Q(),r=n[0],o=n[1];x.isTouch&&"hold"===r&&o&&(t=o),t?v=setTimeout((function(){$.show()}),t):$.show()}function De(e){if($.clearDelayTimeouts(),ae("onUntrigger",[$,e]),$.state.isVisible){if(!($.props.trigger.indexOf("mouseenter")>=0&&$.props.trigger.indexOf("click")>=0&&["mouseleave","mousemove"].indexOf(e.type)>=0&&V)){var t=oe(!1);t?g=setTimeout((function(){$.state.isVisible&&$.hide()}),t):h=requestAnimationFrame((function(){$.hide()}))}}else ve()}}function F(e,n){void 0===n&&(n={});var r=R.plugins.concat(n.plugins||[]);document.addEventListener("touchstart",T,t),window.addEventListener("blur",L);var o=Object.assign({},n,{plugins:r}),i=h(e).reduce((function(e,t){var n=t&&_(t,o);return n&&e.push(n),e}),[]);return v(e)?i[0]:i}F.defaultProps=R,F.setDefaultProps=function(e){Object.keys(e).forEach((function(t){R[t]=e[t]}))},F.currentInput=x;var W=Object.assign({},e.applyStyles,{effect:function(e){var t=e.state,n={popper:{position:t.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};Object.assign(t.elements.popper.style,n.popper),t.styles=n,t.elements.arrow&&Object.assign(t.elements.arrow.style,n.arrow)}}),X={mouseover:"mouseenter",focusin:"focus",click:"click"};var Y={name:"animateFill",defaultValue:!1,fn:function(e){var t;if(null==(t=e.props.render)||!t.$$tippy)return{};var n=S(e.popper),r=n.box,o=n.content,i=e.props.animateFill?function(){var e=d();return e.className="tippy-backdrop",y([e],"hidden"),e}():null;return{onCreate:function(){i&&(r.insertBefore(i,r.firstElementChild),r.setAttribute("data-animatefill",""),r.style.overflow="hidden",e.setProps({arrow:!1,animation:"shift-away"}))},onMount:function(){if(i){var e=r.style.transitionDuration,t=Number(e.replace("ms",""));o.style.transitionDelay=Math.round(t/10)+"ms",i.style.transitionDuration=e,y([i],"visible")}},onShow:function(){i&&(i.style.transitionDuration="0ms")},onHide:function(){i&&y([i],"hidden")}}}};var $={clientX:0,clientY:0},q=[];function z(e){var t=e.clientX,n=e.clientY;$={clientX:t,clientY:n}}var J={name:"followCursor",defaultValue:!1,fn:function(e){var t=e.reference,n=w(e.props.triggerTarget||t),r=!1,o=!1,i=!0,a=e.props;function s(){return"initial"===e.props.followCursor&&e.state.isVisible}function u(){n.addEventListener("mousemove",f)}function c(){n.removeEventListener("mousemove",f)}function p(){r=!0,e.setProps({getReferenceClientRect:null}),r=!1}function f(n){var r=!n.target||t.contains(n.target),o=e.props.followCursor,i=n.clientX,a=n.clientY,s=t.getBoundingClientRect(),u=i-s.left,c=a-s.top;!r&&e.props.interactive||e.setProps({getReferenceClientRect:function(){var e=t.getBoundingClientRect(),n=i,r=a;"initial"===o&&(n=e.left+u,r=e.top+c);var s="horizontal"===o?e.top:r,p="vertical"===o?e.right:n,f="horizontal"===o?e.bottom:r,l="vertical"===o?e.left:n;return{width:p-l,height:f-s,top:s,right:p,bottom:f,left:l}}})}function l(){e.props.followCursor&&(q.push({instance:e,doc:n}),function(e){e.addEventListener("mousemove",z)}(n))}function d(){0===(q=q.filter((function(t){return t.instance!==e}))).filter((function(e){return e.doc===n})).length&&function(e){e.removeEventListener("mousemove",z)}(n)}return{onCreate:l,onDestroy:d,onBeforeUpdate:function(){a=e.props},onAfterUpdate:function(t,n){var i=n.followCursor;r||void 0!==i&&a.followCursor!==i&&(d(),i?(l(),!e.state.isMounted||o||s()||u()):(c(),p()))},onMount:function(){e.props.followCursor&&!o&&(i&&(f($),i=!1),s()||u())},onTrigger:function(e,t){m(t)&&($={clientX:t.clientX,clientY:t.clientY}),o="focus"===t.type},onHidden:function(){e.props.followCursor&&(p(),c(),i=!0)}}}};var G={name:"inlinePositioning",defaultValue:!1,fn:function(e){var t,n=e.reference;var r=-1,o=!1,i=[],a={name:"tippyInlinePositioning",enabled:!0,phase:"afterWrite",fn:function(o){var a=o.state;e.props.inlinePositioning&&(-1!==i.indexOf(a.placement)&&(i=[]),t!==a.placement&&-1===i.indexOf(a.placement)&&(i.push(a.placement),e.setProps({getReferenceClientRect:function(){return function(e){return function(e,t,n,r){if(n.length<2||null===e)return t;if(2===n.length&&r>=0&&n[0].left>n[1].right)return n[r]||t;switch(e){case"top":case"bottom":var o=n[0],i=n[n.length-1],a="top"===e,s=o.top,u=i.bottom,c=a?o.left:i.left,p=a?o.right:i.right;return{top:s,bottom:u,left:c,right:p,width:p-c,height:u-s};case"left":case"right":var f=Math.min.apply(Math,n.map((function(e){return e.left}))),l=Math.max.apply(Math,n.map((function(e){return e.right}))),d=n.filter((function(t){return"left"===e?t.left===f:t.right===l})),v=d[0].top,m=d[d.length-1].bottom;return{top:v,bottom:m,left:f,right:l,width:l-f,height:m-v};default:return t}}(p(e),n.getBoundingClientRect(),f(n.getClientRects()),r)}(a.placement)}})),t=a.placement)}};function s(){var t;o||(t=function(e,t){var n;return{popperOptions:Object.assign({},e.popperOptions,{modifiers:[].concat(((null==(n=e.popperOptions)?void 0:n.modifiers)||[]).filter((function(e){return e.name!==t.name})),[t])})}}(e.props,a),o=!0,e.setProps(t),o=!1)}return{onCreate:s,onAfterUpdate:s,onTrigger:function(t,n){if(m(n)){var o=f(e.reference.getClientRects()),i=o.find((function(e){return e.left-2<=n.clientX&&e.right+2>=n.clientX&&e.top-2<=n.clientY&&e.bottom+2>=n.clientY})),a=o.indexOf(i);r=a>-1?a:r}},onHidden:function(){r=-1}}}};var K={name:"sticky",defaultValue:!1,fn:function(e){var t=e.reference,n=e.popper;function r(t){return!0===e.props.sticky||e.props.sticky===t}var o=null,i=null;function a(){var s=r("reference")?(e.popperInstance?e.popperInstance.state.elements.reference:t).getBoundingClientRect():null,u=r("popper")?n.getBoundingClientRect():null;(s&&Q(o,s)||u&&Q(i,u))&&e.popperInstance&&e.popperInstance.update(),o=s,i=u,e.state.isMounted&&requestAnimationFrame(a)}return{onMount:function(){e.props.sticky&&a()}}}};function Q(e,t){return!e||!t||(e.top!==t.top||e.right!==t.right||e.bottom!==t.bottom||e.left!==t.left)}return F.setDefaultProps({plugins:[Y,J,G,K],render:N}),F.createSingleton=function(e,t){var n;void 0===t&&(t={});var r,o=e,i=[],a=[],c=t.overrides,p=[],f=!1;function l(){a=o.map((function(e){return u(e.props.triggerTarget||e.reference)})).reduce((function(e,t){return e.concat(t)}),[])}function v(){i=o.map((function(e){return e.reference}))}function m(e){o.forEach((function(t){e?t.enable():t.disable()}))}function g(e){return o.map((function(t){var n=t.setProps;return t.setProps=function(o){n(o),t.reference===r&&e.setProps(o)},function(){t.setProps=n}}))}function h(e,t){var n=a.indexOf(t);if(t!==r){r=t;var s=(c||[]).concat("content").reduce((function(e,t){return e[t]=o[n].props[t],e}),{});e.setProps(Object.assign({},s,{getReferenceClientRect:"function"==typeof s.getReferenceClientRect?s.getReferenceClientRect:function(){var e;return null==(e=i[n])?void 0:e.getBoundingClientRect()}}))}}m(!1),v(),l();var b={fn:function(){return{onDestroy:function(){m(!0)},onHidden:function(){r=null},onClickOutside:function(e){e.props.showOnCreate&&!f&&(f=!0,r=null)},onShow:function(e){e.props.showOnCreate&&!f&&(f=!0,h(e,i[0]))},onTrigger:function(e,t){h(e,t.currentTarget)}}}},y=F(d(),Object.assign({},s(t,["overrides"]),{plugins:[b].concat(t.plugins||[]),triggerTarget:a,popperOptions:Object.assign({},t.popperOptions,{modifiers:[].concat((null==(n=t.popperOptions)?void 0:n.modifiers)||[],[W])})})),w=y.show;y.show=function(e){if(w(),!r&&null==e)return h(y,i[0]);if(!r||null!=e){if("number"==typeof e)return i[e]&&h(y,i[e]);if(o.indexOf(e)>=0){var t=e.reference;return h(y,t)}return i.indexOf(e)>=0?h(y,e):void 0}},y.showNext=function(){var e=i[0];if(!r)return y.show(0);var t=i.indexOf(r);y.show(i[t+1]||e)},y.showPrevious=function(){var e=i[i.length-1];if(!r)return y.show(e);var t=i.indexOf(r),n=i[t-1]||e;y.show(n)};var E=y.setProps;return y.setProps=function(e){c=e.overrides||c,E(e)},y.setInstances=function(e){m(!0),p.forEach((function(e){return e()})),o=e,m(!1),v(),l(),p=g(y),y.setProps({triggerTarget:a})},p=g(y),y},F.delegate=function(e,n){var r=[],o=[],i=!1,a=n.target,c=s(n,["target"]),p=Object.assign({},c,{trigger:"manual",touch:!1}),f=Object.assign({touch:R.touch},c,{showOnCreate:!0}),l=F(e,p);function d(e){if(e.target&&!i){var t=e.target.closest(a);if(t){var r=t.getAttribute("data-tippy-trigger")||n.trigger||R.trigger;if(!t._tippy&&!("touchstart"===e.type&&"boolean"==typeof f.touch||"touchstart"!==e.type&&r.indexOf(X[e.type])<0)){var s=F(t,f);s&&(o=o.concat(s))}}}}function v(e,t,n,o){void 0===o&&(o=!1),e.addEventListener(t,n,o),r.push({node:e,eventType:t,handler:n,options:o})}return u(l).forEach((function(e){var n=e.destroy,a=e.enable,s=e.disable;e.destroy=function(e){void 0===e&&(e=!0),e&&o.forEach((function(e){e.destroy()})),o=[],r.forEach((function(e){var t=e.node,n=e.eventType,r=e.handler,o=e.options;t.removeEventListener(n,r,o)})),r=[],n()},e.enable=function(){a(),o.forEach((function(e){return e.enable()})),i=!1},e.disable=function(){s(),o.forEach((function(e){return e.disable()})),i=!0},function(e){var n=e.reference;v(n,"touchstart",d,t),v(n,"mouseover",d),v(n,"focusin",d),v(n,"click",d)}(e)})),l},F.hideAll=function(e){var t=void 0===e?{}:e,n=t.exclude,r=t.duration;U.forEach((function(e){var t=!1;if(n&&(t=g(n)?e.reference===n:e.popper===n.popper),!t){var o=e.props.duration;e.setProps({duration:r}),e.hide(),e.state.isDestroyed||e.setProps({duration:o})}}))},F.roundArrow='',F})); + diff --git a/site_libs/quarto-listing/list.min.js b/site_libs/quarto-listing/list.min.js new file mode 100644 index 0000000..43dfd15 --- /dev/null +++ b/site_libs/quarto-listing/list.min.js @@ -0,0 +1,2 @@ +var List;List=function(){var t={"./src/add-async.js":function(t){t.exports=function(t){return function e(r,n,s){var i=r.splice(0,50);s=(s=s||[]).concat(t.add(i)),r.length>0?setTimeout((function(){e(r,n,s)}),1):(t.update(),n(s))}}},"./src/filter.js":function(t){t.exports=function(t){return t.handlers.filterStart=t.handlers.filterStart||[],t.handlers.filterComplete=t.handlers.filterComplete||[],function(e){if(t.trigger("filterStart"),t.i=1,t.reset.filter(),void 0===e)t.filtered=!1;else{t.filtered=!0;for(var r=t.items,n=0,s=r.length;nv.page,a=new g(t[s],void 0,n),v.items.push(a),r.push(a)}return v.update(),r}m(t.slice(0),e)}},this.show=function(t,e){return this.i=t,this.page=e,v.update(),v},this.remove=function(t,e,r){for(var n=0,s=0,i=v.items.length;s-1&&r.splice(n,1),v},this.trigger=function(t){for(var e=v.handlers[t].length;e--;)v.handlers[t][e](v);return v},this.reset={filter:function(){for(var t=v.items,e=t.length;e--;)t[e].filtered=!1;return v},search:function(){for(var t=v.items,e=t.length;e--;)t[e].found=!1;return v}},this.update=function(){var t=v.items,e=t.length;v.visibleItems=[],v.matchingItems=[],v.templater.clear();for(var r=0;r=v.i&&v.visibleItems.lengthe},innerWindow:function(t,e,r){return t>=e-r&&t<=e+r},dotted:function(t,e,r,n,s,i,a){return this.dottedLeft(t,e,r,n,s,i)||this.dottedRight(t,e,r,n,s,i,a)},dottedLeft:function(t,e,r,n,s,i){return e==r+1&&!this.innerWindow(e,s,i)&&!this.right(e,n)},dottedRight:function(t,e,r,n,s,i,a){return!t.items[a-1].values().dotted&&(e==n&&!this.innerWindow(e,s,i)&&!this.right(e,n))}};return function(e){var n=new i(t.listContainer.id,{listClass:e.paginationClass||"pagination",item:e.item||"
  • ",valueNames:["page","dotted"],searchClass:"pagination-search-that-is-not-supposed-to-exist",sortClass:"pagination-sort-that-is-not-supposed-to-exist"});s.bind(n.listContainer,"click",(function(e){var r=e.target||e.srcElement,n=t.utils.getAttribute(r,"data-page"),s=t.utils.getAttribute(r,"data-i");s&&t.show((s-1)*n+1,n)})),t.on("updated",(function(){r(n,e)})),r(n,e)}}},"./src/parse.js":function(t,e,r){t.exports=function(t){var e=r("./src/item.js")(t),n=function(r,n){for(var s=0,i=r.length;s0?setTimeout((function(){e(r,s)}),1):(t.update(),t.trigger("parseComplete"))};return t.handlers.parseComplete=t.handlers.parseComplete||[],function(){var e=function(t){for(var e=t.childNodes,r=[],n=0,s=e.length;n]/g.exec(t)){var e=document.createElement("tbody");return e.innerHTML=t,e.firstElementChild}if(-1!==t.indexOf("<")){var r=document.createElement("div");return r.innerHTML=t,r.firstElementChild}}},a=function(e,r,n){var s=void 0,i=function(e){for(var r=0,n=t.valueNames.length;r=1;)t.list.removeChild(t.list.firstChild)},function(){var r;if("function"!=typeof t.item){if(!(r="string"==typeof t.item?-1===t.item.indexOf("<")?document.getElementById(t.item):i(t.item):s()))throw new Error("The list needs to have at least one item on init otherwise you'll have to add a template.");r=n(r,t.valueNames),e=function(){return r.cloneNode(!0)}}else e=function(e){var r=t.item(e);return i(r)}}()};t.exports=function(t){return new e(t)}},"./src/utils/classes.js":function(t,e,r){var n=r("./src/utils/index-of.js"),s=/\s+/;Object.prototype.toString;function i(t){if(!t||!t.nodeType)throw new Error("A DOM element reference is required");this.el=t,this.list=t.classList}t.exports=function(t){return new i(t)},i.prototype.add=function(t){if(this.list)return this.list.add(t),this;var e=this.array();return~n(e,t)||e.push(t),this.el.className=e.join(" "),this},i.prototype.remove=function(t){if(this.list)return this.list.remove(t),this;var e=this.array(),r=n(e,t);return~r&&e.splice(r,1),this.el.className=e.join(" "),this},i.prototype.toggle=function(t,e){return this.list?(void 0!==e?e!==this.list.toggle(t,e)&&this.list.toggle(t):this.list.toggle(t),this):(void 0!==e?e?this.add(t):this.remove(t):this.has(t)?this.remove(t):this.add(t),this)},i.prototype.array=function(){var t=(this.el.getAttribute("class")||"").replace(/^\s+|\s+$/g,"").split(s);return""===t[0]&&t.shift(),t},i.prototype.has=i.prototype.contains=function(t){return this.list?this.list.contains(t):!!~n(this.array(),t)}},"./src/utils/events.js":function(t,e,r){var n=window.addEventListener?"addEventListener":"attachEvent",s=window.removeEventListener?"removeEventListener":"detachEvent",i="addEventListener"!==n?"on":"",a=r("./src/utils/to-array.js");e.bind=function(t,e,r,s){for(var o=0,l=(t=a(t)).length;o32)return!1;var a=n,o=function(){var t,r={};for(t=0;t=p;b--){var j=o[t.charAt(b-1)];if(C[b]=0===m?(C[b+1]<<1|1)&j:(C[b+1]<<1|1)&j|(v[b+1]|v[b])<<1|1|v[b+1],C[b]&d){var x=l(m,b-1);if(x<=u){if(u=x,!((c=b-1)>a))break;p=Math.max(1,2*a-c)}}}if(l(m+1,a)>u)break;v=C}return!(c<0)}},"./src/utils/get-attribute.js":function(t){t.exports=function(t,e){var r=t.getAttribute&&t.getAttribute(e)||null;if(!r)for(var n=t.attributes,s=n.length,i=0;i=48&&t<=57}function i(t,e){for(var i=(t+="").length,a=(e+="").length,o=0,l=0;o=i&&l=a?-1:l>=a&&o=i?1:i-a}i.caseInsensitive=i.i=function(t,e){return i((""+t).toLowerCase(),(""+e).toLowerCase())},Object.defineProperties(i,{alphabet:{get:function(){return e},set:function(t){r=[];var s=0;if(e=t)for(;s { + // category is URI encoded in EJS template for UTF-8 support + category = decodeURIComponent(atob(category)); + if (categoriesLoaded) { + activateCategory(category); + setCategoryHash(category); + } +}; + +window["quarto-listing-loaded"] = () => { + // Process any existing hash + const hash = getHash(); + + if (hash) { + // If there is a category, switch to that + if (hash.category) { + // category hash are URI encoded so we need to decode it before processing + // so that we can match it with the category element processed in JS + activateCategory(decodeURIComponent(hash.category)); + } + // Paginate a specific listing + const listingIds = Object.keys(window["quarto-listings"]); + for (const listingId of listingIds) { + const page = hash[getListingPageKey(listingId)]; + if (page) { + showPage(listingId, page); + } + } + } + + const listingIds = Object.keys(window["quarto-listings"]); + for (const listingId of listingIds) { + // The actual list + const list = window["quarto-listings"][listingId]; + + // Update the handlers for pagination events + refreshPaginationHandlers(listingId); + + // Render any visible items that need it + renderVisibleProgressiveImages(list); + + // Whenever the list is updated, we also need to + // attach handlers to the new pagination elements + // and refresh any newly visible items. + list.on("updated", function () { + renderVisibleProgressiveImages(list); + setTimeout(() => refreshPaginationHandlers(listingId)); + + // Show or hide the no matching message + toggleNoMatchingMessage(list); + }); + } +}; + +window.document.addEventListener("DOMContentLoaded", function (_event) { + // Attach click handlers to categories + const categoryEls = window.document.querySelectorAll( + ".quarto-listing-category .category" + ); + + for (const categoryEl of categoryEls) { + // category needs to support non ASCII characters + const category = decodeURIComponent( + atob(categoryEl.getAttribute("data-category")) + ); + categoryEl.onclick = () => { + activateCategory(category); + setCategoryHash(category); + }; + } + + // Attach a click handler to the category title + // (there should be only one, but since it is a class name, handle N) + const categoryTitleEls = window.document.querySelectorAll( + ".quarto-listing-category-title" + ); + for (const categoryTitleEl of categoryTitleEls) { + categoryTitleEl.onclick = () => { + activateCategory(""); + setCategoryHash(""); + }; + } + + categoriesLoaded = true; +}); + +function toggleNoMatchingMessage(list) { + const selector = `#${list.listContainer.id} .listing-no-matching`; + const noMatchingEl = window.document.querySelector(selector); + if (noMatchingEl) { + if (list.visibleItems.length === 0) { + noMatchingEl.classList.remove("d-none"); + } else { + if (!noMatchingEl.classList.contains("d-none")) { + noMatchingEl.classList.add("d-none"); + } + } + } +} + +function setCategoryHash(category) { + setHash({ category }); +} + +function setPageHash(listingId, page) { + const currentHash = getHash() || {}; + currentHash[getListingPageKey(listingId)] = page; + setHash(currentHash); +} + +function getListingPageKey(listingId) { + return `${listingId}-page`; +} + +function refreshPaginationHandlers(listingId) { + const listingEl = window.document.getElementById(listingId); + const paginationEls = listingEl.querySelectorAll( + ".pagination li.page-item:not(.disabled) .page.page-link" + ); + for (const paginationEl of paginationEls) { + paginationEl.onclick = (sender) => { + setPageHash(listingId, sender.target.getAttribute("data-i")); + showPage(listingId, sender.target.getAttribute("data-i")); + return false; + }; + } +} + +function renderVisibleProgressiveImages(list) { + // Run through the visible items and render any progressive images + for (const item of list.visibleItems) { + const itemEl = item.elm; + if (itemEl) { + const progressiveImgs = itemEl.querySelectorAll( + `img[${kProgressiveAttr}]` + ); + for (const progressiveImg of progressiveImgs) { + const srcValue = progressiveImg.getAttribute(kProgressiveAttr); + if (srcValue) { + progressiveImg.setAttribute("src", srcValue); + } + progressiveImg.removeAttribute(kProgressiveAttr); + } + } + } +} + +function getHash() { + // Hashes are of the form + // #name:value|name1:value1|name2:value2 + const currentUrl = new URL(window.location); + const hashRaw = currentUrl.hash ? currentUrl.hash.slice(1) : undefined; + return parseHash(hashRaw); +} + +const kAnd = "&"; +const kEquals = "="; + +function parseHash(hash) { + if (!hash) { + return undefined; + } + const hasValuesStrs = hash.split(kAnd); + const hashValues = hasValuesStrs + .map((hashValueStr) => { + const vals = hashValueStr.split(kEquals); + if (vals.length === 2) { + return { name: vals[0], value: vals[1] }; + } else { + return undefined; + } + }) + .filter((value) => { + return value !== undefined; + }); + + const hashObj = {}; + hashValues.forEach((hashValue) => { + hashObj[hashValue.name] = decodeURIComponent(hashValue.value); + }); + return hashObj; +} + +function makeHash(obj) { + return Object.keys(obj) + .map((key) => { + return `${key}${kEquals}${obj[key]}`; + }) + .join(kAnd); +} + +function setHash(obj) { + const hash = makeHash(obj); + window.history.pushState(null, null, `#${hash}`); +} + +function showPage(listingId, page) { + const list = window["quarto-listings"][listingId]; + if (list) { + list.show((page - 1) * list.page + 1, list.page); + } +} + +function activateCategory(category) { + // Deactivate existing categories + const activeEls = window.document.querySelectorAll( + ".quarto-listing-category .category.active" + ); + for (const activeEl of activeEls) { + activeEl.classList.remove("active"); + } + + // Activate this category + const categoryEl = window.document.querySelector( + `.quarto-listing-category .category[data-category='${btoa( + encodeURIComponent(category) + )}']` + ); + if (categoryEl) { + categoryEl.classList.add("active"); + } + + // Filter the listings to this category + filterListingCategory(category); +} + +function filterListingCategory(category) { + const listingIds = Object.keys(window["quarto-listings"]); + for (const listingId of listingIds) { + const list = window["quarto-listings"][listingId]; + if (list) { + if (category === "") { + // resets the filter + list.filter(); + } else { + // filter to this category + list.filter(function (item) { + const itemValues = item.values(); + if (itemValues.categories !== null) { + const categories = decodeURIComponent( + atob(itemValues.categories) + ).split(","); + return categories.includes(category); + } else { + return false; + } + }); + } + } + } +} diff --git a/site_libs/quarto-nav/headroom.min.js b/site_libs/quarto-nav/headroom.min.js new file mode 100644 index 0000000..b08f1df --- /dev/null +++ b/site_libs/quarto-nav/headroom.min.js @@ -0,0 +1,7 @@ +/*! + * headroom.js v0.12.0 - Give your page some headroom. Hide your header until you need it + * Copyright (c) 2020 Nick Williams - http://wicky.nillia.ms/headroom.js + * License: MIT + */ + +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t=t||self).Headroom=n()}(this,function(){"use strict";function t(){return"undefined"!=typeof window}function d(t){return function(t){return t&&t.document&&function(t){return 9===t.nodeType}(t.document)}(t)?function(t){var n=t.document,o=n.body,s=n.documentElement;return{scrollHeight:function(){return Math.max(o.scrollHeight,s.scrollHeight,o.offsetHeight,s.offsetHeight,o.clientHeight,s.clientHeight)},height:function(){return t.innerHeight||s.clientHeight||o.clientHeight},scrollY:function(){return void 0!==t.pageYOffset?t.pageYOffset:(s||o.parentNode||o).scrollTop}}}(t):function(t){return{scrollHeight:function(){return Math.max(t.scrollHeight,t.offsetHeight,t.clientHeight)},height:function(){return Math.max(t.offsetHeight,t.clientHeight)},scrollY:function(){return t.scrollTop}}}(t)}function n(t,s,e){var n,o=function(){var n=!1;try{var t={get passive(){n=!0}};window.addEventListener("test",t,t),window.removeEventListener("test",t,t)}catch(t){n=!1}return n}(),i=!1,r=d(t),l=r.scrollY(),a={};function c(){var t=Math.round(r.scrollY()),n=r.height(),o=r.scrollHeight();a.scrollY=t,a.lastScrollY=l,a.direction=ls.tolerance[a.direction],e(a),l=t,i=!1}function h(){i||(i=!0,n=requestAnimationFrame(c))}var u=!!o&&{passive:!0,capture:!1};return t.addEventListener("scroll",h,u),c(),{destroy:function(){cancelAnimationFrame(n),t.removeEventListener("scroll",h,u)}}}function o(t){return t===Object(t)?t:{down:t,up:t}}function s(t,n){n=n||{},Object.assign(this,s.options,n),this.classes=Object.assign({},s.options.classes,n.classes),this.elem=t,this.tolerance=o(this.tolerance),this.offset=o(this.offset),this.initialised=!1,this.frozen=!1}return s.prototype={constructor:s,init:function(){return s.cutsTheMustard&&!this.initialised&&(this.addClass("initial"),this.initialised=!0,setTimeout(function(t){t.scrollTracker=n(t.scroller,{offset:t.offset,tolerance:t.tolerance},t.update.bind(t))},100,this)),this},destroy:function(){this.initialised=!1,Object.keys(this.classes).forEach(this.removeClass,this),this.scrollTracker.destroy()},unpin:function(){!this.hasClass("pinned")&&this.hasClass("unpinned")||(this.addClass("unpinned"),this.removeClass("pinned"),this.onUnpin&&this.onUnpin.call(this))},pin:function(){this.hasClass("unpinned")&&(this.addClass("pinned"),this.removeClass("unpinned"),this.onPin&&this.onPin.call(this))},freeze:function(){this.frozen=!0,this.addClass("frozen")},unfreeze:function(){this.frozen=!1,this.removeClass("frozen")},top:function(){this.hasClass("top")||(this.addClass("top"),this.removeClass("notTop"),this.onTop&&this.onTop.call(this))},notTop:function(){this.hasClass("notTop")||(this.addClass("notTop"),this.removeClass("top"),this.onNotTop&&this.onNotTop.call(this))},bottom:function(){this.hasClass("bottom")||(this.addClass("bottom"),this.removeClass("notBottom"),this.onBottom&&this.onBottom.call(this))},notBottom:function(){this.hasClass("notBottom")||(this.addClass("notBottom"),this.removeClass("bottom"),this.onNotBottom&&this.onNotBottom.call(this))},shouldUnpin:function(t){return"down"===t.direction&&!t.top&&t.toleranceExceeded},shouldPin:function(t){return"up"===t.direction&&t.toleranceExceeded||t.top},addClass:function(t){this.elem.classList.add.apply(this.elem.classList,this.classes[t].split(" "))},removeClass:function(t){this.elem.classList.remove.apply(this.elem.classList,this.classes[t].split(" "))},hasClass:function(t){return this.classes[t].split(" ").every(function(t){return this.classList.contains(t)},this.elem)},update:function(t){t.isOutOfBounds||!0!==this.frozen&&(t.top?this.top():this.notTop(),t.bottom?this.bottom():this.notBottom(),this.shouldUnpin(t)?this.unpin():this.shouldPin(t)&&this.pin())}},s.options={tolerance:{up:0,down:0},offset:0,scroller:t()?window:null,classes:{frozen:"headroom--frozen",pinned:"headroom--pinned",unpinned:"headroom--unpinned",top:"headroom--top",notTop:"headroom--not-top",bottom:"headroom--bottom",notBottom:"headroom--not-bottom",initial:"headroom"}},s.cutsTheMustard=!!(t()&&function(){}.bind&&"classList"in document.documentElement&&Object.assign&&Object.keys&&requestAnimationFrame),s}); diff --git a/site_libs/quarto-nav/quarto-nav.js b/site_libs/quarto-nav/quarto-nav.js new file mode 100644 index 0000000..38cc430 --- /dev/null +++ b/site_libs/quarto-nav/quarto-nav.js @@ -0,0 +1,325 @@ +const headroomChanged = new CustomEvent("quarto-hrChanged", { + detail: {}, + bubbles: true, + cancelable: false, + composed: false, +}); + +const announceDismiss = () => { + const annEl = window.document.getElementById("quarto-announcement"); + if (annEl) { + annEl.remove(); + + const annId = annEl.getAttribute("data-announcement-id"); + window.localStorage.setItem(`quarto-announce-${annId}`, "true"); + } +}; + +const announceRegister = () => { + const annEl = window.document.getElementById("quarto-announcement"); + if (annEl) { + const annId = annEl.getAttribute("data-announcement-id"); + const isDismissed = + window.localStorage.getItem(`quarto-announce-${annId}`) || false; + if (isDismissed) { + announceDismiss(); + return; + } else { + annEl.classList.remove("hidden"); + } + + const actionEl = annEl.querySelector(".quarto-announcement-action"); + if (actionEl) { + actionEl.addEventListener("click", function (e) { + e.preventDefault(); + // Hide the bar immediately + announceDismiss(); + }); + } + } +}; + +window.document.addEventListener("DOMContentLoaded", function () { + let init = false; + + announceRegister(); + + // Manage the back to top button, if one is present. + let lastScrollTop = window.pageYOffset || document.documentElement.scrollTop; + const scrollDownBuffer = 5; + const scrollUpBuffer = 35; + const btn = document.getElementById("quarto-back-to-top"); + const hideBackToTop = () => { + btn.style.display = "none"; + }; + const showBackToTop = () => { + btn.style.display = "inline-block"; + }; + if (btn) { + window.document.addEventListener( + "scroll", + function () { + const currentScrollTop = + window.pageYOffset || document.documentElement.scrollTop; + + // Shows and hides the button 'intelligently' as the user scrolls + if (currentScrollTop - scrollDownBuffer > lastScrollTop) { + hideBackToTop(); + lastScrollTop = currentScrollTop <= 0 ? 0 : currentScrollTop; + } else if (currentScrollTop < lastScrollTop - scrollUpBuffer) { + showBackToTop(); + lastScrollTop = currentScrollTop <= 0 ? 0 : currentScrollTop; + } + + // Show the button at the bottom, hides it at the top + if (currentScrollTop <= 0) { + hideBackToTop(); + } else if ( + window.innerHeight + currentScrollTop >= + document.body.offsetHeight + ) { + showBackToTop(); + } + }, + false + ); + } + + function throttle(func, wait) { + var timeout; + return function () { + const context = this; + const args = arguments; + const later = function () { + clearTimeout(timeout); + timeout = null; + func.apply(context, args); + }; + + if (!timeout) { + timeout = setTimeout(later, wait); + } + }; + } + + function headerOffset() { + // Set an offset if there is are fixed top navbar + const headerEl = window.document.querySelector("header.fixed-top"); + if (headerEl) { + return headerEl.clientHeight; + } else { + return 0; + } + } + + function footerOffset() { + const footerEl = window.document.querySelector("footer.footer"); + if (footerEl) { + return footerEl.clientHeight; + } else { + return 0; + } + } + + function dashboardOffset() { + const dashboardNavEl = window.document.getElementById( + "quarto-dashboard-header" + ); + if (dashboardNavEl !== null) { + return dashboardNavEl.clientHeight; + } else { + return 0; + } + } + + function updateDocumentOffsetWithoutAnimation() { + updateDocumentOffset(false); + } + + function updateDocumentOffset(animated) { + // set body offset + const topOffset = headerOffset(); + const bodyOffset = topOffset + footerOffset() + dashboardOffset(); + const bodyEl = window.document.body; + bodyEl.setAttribute("data-bs-offset", topOffset); + bodyEl.style.paddingTop = topOffset + "px"; + + // deal with sidebar offsets + const sidebars = window.document.querySelectorAll( + ".sidebar, .headroom-target" + ); + sidebars.forEach((sidebar) => { + if (!animated) { + sidebar.classList.add("notransition"); + // Remove the no transition class after the animation has time to complete + setTimeout(function () { + sidebar.classList.remove("notransition"); + }, 201); + } + + if (window.Headroom && sidebar.classList.contains("sidebar-unpinned")) { + sidebar.style.top = "0"; + sidebar.style.maxHeight = "100vh"; + } else { + sidebar.style.top = topOffset + "px"; + sidebar.style.maxHeight = "calc(100vh - " + topOffset + "px)"; + } + }); + + // allow space for footer + const mainContainer = window.document.querySelector(".quarto-container"); + if (mainContainer) { + mainContainer.style.minHeight = "calc(100vh - " + bodyOffset + "px)"; + } + + // link offset + let linkStyle = window.document.querySelector("#quarto-target-style"); + if (!linkStyle) { + linkStyle = window.document.createElement("style"); + linkStyle.setAttribute("id", "quarto-target-style"); + window.document.head.appendChild(linkStyle); + } + while (linkStyle.firstChild) { + linkStyle.removeChild(linkStyle.firstChild); + } + if (topOffset > 0) { + linkStyle.appendChild( + window.document.createTextNode(` + section:target::before { + content: ""; + display: block; + height: ${topOffset}px; + margin: -${topOffset}px 0 0; + }`) + ); + } + if (init) { + window.dispatchEvent(headroomChanged); + } + init = true; + } + + // initialize headroom + var header = window.document.querySelector("#quarto-header"); + if (header && window.Headroom) { + const headroom = new window.Headroom(header, { + tolerance: 5, + onPin: function () { + const sidebars = window.document.querySelectorAll( + ".sidebar, .headroom-target" + ); + sidebars.forEach((sidebar) => { + sidebar.classList.remove("sidebar-unpinned"); + }); + updateDocumentOffset(); + }, + onUnpin: function () { + const sidebars = window.document.querySelectorAll( + ".sidebar, .headroom-target" + ); + sidebars.forEach((sidebar) => { + sidebar.classList.add("sidebar-unpinned"); + }); + updateDocumentOffset(); + }, + }); + headroom.init(); + + let frozen = false; + window.quartoToggleHeadroom = function () { + if (frozen) { + headroom.unfreeze(); + frozen = false; + } else { + headroom.freeze(); + frozen = true; + } + }; + } + + window.addEventListener( + "hashchange", + function (e) { + if ( + getComputedStyle(document.documentElement).scrollBehavior !== "smooth" + ) { + window.scrollTo(0, window.pageYOffset - headerOffset()); + } + }, + false + ); + + // Observe size changed for the header + const headerEl = window.document.querySelector("header.fixed-top"); + if (headerEl && window.ResizeObserver) { + const observer = new window.ResizeObserver(() => { + setTimeout(updateDocumentOffsetWithoutAnimation, 0); + }); + observer.observe(headerEl, { + attributes: true, + childList: true, + characterData: true, + }); + } else { + window.addEventListener( + "resize", + throttle(updateDocumentOffsetWithoutAnimation, 50) + ); + } + setTimeout(updateDocumentOffsetWithoutAnimation, 250); + + // fixup index.html links if we aren't on the filesystem + if (window.location.protocol !== "file:") { + const links = window.document.querySelectorAll("a"); + for (let i = 0; i < links.length; i++) { + if (links[i].href) { + links[i].dataset.originalHref = links[i].href; + links[i].href = links[i].href.replace(/\/index\.html/, "/"); + } + } + + // Fixup any sharing links that require urls + // Append url to any sharing urls + const sharingLinks = window.document.querySelectorAll( + "a.sidebar-tools-main-item, a.quarto-navigation-tool, a.quarto-navbar-tools, a.quarto-navbar-tools-item" + ); + for (let i = 0; i < sharingLinks.length; i++) { + const sharingLink = sharingLinks[i]; + const href = sharingLink.getAttribute("href"); + if (href) { + sharingLink.setAttribute( + "href", + href.replace("|url|", window.location.href) + ); + } + } + + // Scroll the active navigation item into view, if necessary + const navSidebar = window.document.querySelector("nav#quarto-sidebar"); + if (navSidebar) { + // Find the active item + const activeItem = navSidebar.querySelector("li.sidebar-item a.active"); + if (activeItem) { + // Wait for the scroll height and height to resolve by observing size changes on the + // nav element that is scrollable + const resizeObserver = new ResizeObserver((_entries) => { + // The bottom of the element + const elBottom = activeItem.offsetTop; + const viewBottom = navSidebar.scrollTop + navSidebar.clientHeight; + + // The element height and scroll height are the same, then we are still loading + if (viewBottom !== navSidebar.scrollHeight) { + // Determine if the item isn't visible and scroll to it + if (elBottom >= viewBottom) { + navSidebar.scrollTop = elBottom; + } + + // stop observing now since we've completed the scroll + resizeObserver.unobserve(navSidebar); + } + }); + resizeObserver.observe(navSidebar); + } + } + } +}); diff --git a/site_libs/quarto-search/autocomplete.umd.js b/site_libs/quarto-search/autocomplete.umd.js new file mode 100644 index 0000000..6090a55 --- /dev/null +++ b/site_libs/quarto-search/autocomplete.umd.js @@ -0,0 +1,3 @@ +/*! @algolia/autocomplete-js 1.19.1 | MIT License | © Algolia, Inc. and contributors | https://github.com/algolia/autocomplete */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self)["@algolia/autocomplete-js"]={})}(this,(function(e){"use strict";function t(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function n(e){for(var n=1;n=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function a(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var r,o,i,u,a=[],l=!0,c=!1;try{if(i=(n=n.call(e)).next,0===t){if(Object(n)!==n)return;l=!1}else for(;!(l=(r=i.call(n)).done)&&(a.push(r.value),a.length!==t);l=!0);}catch(e){c=!0,o=e}finally{try{if(!l&&null!=n.return&&(u=n.return(),Object(u)!==u))return}finally{if(c)throw o}}return a}}(e,t)||c(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(e){return function(e){if(Array.isArray(e))return s(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||c(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function c(e,t){if(e){if("string"==typeof e)return s(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?s(e,t):void 0}}function s(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);ne.length)&&(t=e.length);for(var n=0,r=new Array(t);ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function x(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function N(e){for(var t=1;t1&&void 0!==arguments[1]?arguments[1]:20,n=[],r=0;r=3||2===n&&r>=4||1===n&&r>=10);function i(t,n,r){if(o&&void 0!==r){var i=r[0].__autocomplete_algoliaCredentials,u={"X-Algolia-Application-Id":i.appId,"X-Algolia-API-Key":i.apiKey};e.apply(void 0,[t].concat(D(n),[{headers:u}]))}else e.apply(void 0,[t].concat(D(n)))}return{init:function(t,n){e("init",{appId:t,apiKey:n})},setAuthenticatedUserToken:function(t){e("setAuthenticatedUserToken",t)},setUserToken:function(t){e("setUserToken",t)},clickedObjectIDsAfterSearch:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&i("clickedObjectIDsAfterSearch",B(t),t[0].items)},clickedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&i("clickedObjectIDs",B(t),t[0].items)},clickedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r0&&e.apply(void 0,["clickedFilters"].concat(n))},convertedObjectIDsAfterSearch:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&i("convertedObjectIDsAfterSearch",B(t),t[0].items)},convertedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&i("convertedObjectIDs",B(t),t[0].items)},convertedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r0&&e.apply(void 0,["convertedFilters"].concat(n))},viewedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&t.reduce((function(e,t){var n=t.items,r=k(t,A);return[].concat(D(e),D(q(N(N({},r),{},{objectIDs:(null==n?void 0:n.map((function(e){return e.objectID})))||r.objectIDs})).map((function(e){return{items:n,payload:e}}))))}),[]).forEach((function(e){var t=e.items;return i("viewedObjectIDs",[e.payload],t)}))},viewedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r0&&e.apply(void 0,["viewedFilters"].concat(n))}}}function L(e){var t=e.items.reduce((function(e,t){var n;return e[t.__autocomplete_indexName]=(null!==(n=e[t.__autocomplete_indexName])&&void 0!==n?n:[]).concat(t),e}),{});return Object.keys(t).map((function(e){return{index:e,items:t[e],algoliaSource:["autocomplete"]}}))}function F(e){return e.objectID&&e.__autocomplete_indexName&&e.__autocomplete_queryID}function U(e){return U="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},U(e)}function M(e){return function(e){if(Array.isArray(e))return H(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(!e)return;if("string"==typeof e)return H(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return H(e,t)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function H(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n0&&z({onItemsChange:o,items:n,insights:c,state:t}))}}),0);return{name:"aa.algoliaInsightsPlugin",subscribe:function(e){var t=e.setContext,n=e.onSelect,r=e.onActive;function o(e){t({algoliaInsightsPlugin:{__algoliaSearchParameters:W(W({},a?{clickAnalytics:!0}:{}),e?{userToken:X(e)}:{}),insights:c}})}l("addAlgoliaAgent","insights-plugin"),o(),l("onUserTokenChange",(function(e){o(e)})),l("getUserToken",null,(function(e,t){o(t)})),n((function(e){var t=e.item,n=e.state,r=e.event,o=e.source;F(t)&&i({state:n,event:r,insights:c,item:t,insightsEvents:[W({eventName:"Item Selected"},j({item:t,items:o.getItems().filter(F)}))]})})),r((function(e){var t=e.item,n=e.source,r=e.state,o=e.event;F(t)&&u({state:r,event:o,insights:c,item:t,insightsEvents:[W({eventName:"Item Active"},j({item:t,items:n.getItems().filter(F)}))]})}))},onStateChange:function(e){var t=e.state;m({state:t})},__autocomplete_pluginOptions:e}}function J(){var e,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],n=arguments.length>1?arguments[1]:void 0;return[].concat(M(t),["autocomplete-internal"],M(null!==(e=n.algoliaInsightsPlugin)&&void 0!==e&&e.__automaticInsights?["autocomplete-automatic"]:[]))}function X(e){return"number"==typeof e?e.toString():e}function Y(e,t){var n=t;return{then:function(t,r){return Y(e.then(ee(t,n,e),ee(r,n,e)),n)},catch:function(t){return Y(e.catch(ee(t,n,e)),n)},finally:function(t){return t&&n.onCancelList.push(t),Y(e.finally(ee(t&&function(){return n.onCancelList=[],t()},n,e)),n)},cancel:function(){n.isCanceled=!0;var e=n.onCancelList;n.onCancelList=[],e.forEach((function(e){e()}))},isCanceled:function(){return!0===n.isCanceled}}}function Z(e){return Y(e,{isCanceled:!1,onCancelList:[]})}function ee(e,t,n){return e?function(n){return t.isCanceled?n:e(n)}:n}var te,ne=!0;function re(e,t,n,r){if(!n)return null;if(e<0&&(null===t||null!==r&&0===t))return n+e;var o=(null===t?-1:t)+e;return o<=-1||o>=n?null===r?null:0:o}function oe(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function ie(e){for(var t=1;t=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,u=!0,a=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return u=e.done,e},e:function(e){a=!0,i=e},f:function(){try{u||null==n.return||n.return()}finally{if(a)throw i}}}}function ce(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n0?t.wait(Math.max.apply(Math,o)):void 0};function fe(e){var t=function(e){var t=e.collections.map((function(e){return e.items.length})).reduce((function(e,t,n){var r=(e[n-1]||0)+t;return e.push(r),e}),[]).reduce((function(t,n){return n<=e.activeItemId?t+1:t}),0);return e.collections[t]}(e);if(!t)return null;var n=t.items[function(e){for(var t=e.state,n=e.collection,r=!1,o=0,i=0;!1===r;){var u=t.collections[o];if(u===n){r=!0;break}i+=u.items.length,o++}return t.activeItemId-i}({state:e,collection:t})],r=t.source;return{item:n,itemInputValue:r.getItemInputValue({item:n,state:e}),itemUrl:r.getItemUrl({item:n,state:e}),source:r}}function pe(e,t,n){return[e,null==n?void 0:n.sourceId,t].filter(Boolean).join("-").replace(/\s/g,"")}var me=/((gt|sm)-|galaxy nexus)|samsung[- ]|samsungbrowser/i;function ve(e){return e.nativeEvent||e}function de(e){return de="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},de(e)}function ye(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function be(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==de(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==de(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===de(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function ge(e,t,n){var r,o=t.initialState;return{getState:function(){return o},dispatch:function(r,i){var u=function(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);n0},reshape:function(e){return e.sources}},e),{},{id:null!==(n=e.id)&&void 0!==n?n:d(),plugins:o,initialState:Ae({activeItemId:null,query:"",completion:null,collections:[],isOpen:!1,status:"idle",context:{}},e.initialState),onStateChange:function(t){var n;null===(n=e.onStateChange)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onStateChange)||void 0===n?void 0:n.call(e,t)}))},onSubmit:function(t){var n;null===(n=e.onSubmit)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onSubmit)||void 0===n?void 0:n.call(e,t)}))},onReset:function(t){var n;null===(n=e.onReset)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onReset)||void 0===n?void 0:n.call(e,t)}))},getSources:function(n){return Promise.all([].concat(Pe(o.map((function(e){return e.getSources}))),[e.getSources]).filter(Boolean).map((function(e){return function(e,t){var n=[];return Promise.resolve(e(t)).then((function(e){return Promise.all(e.filter((function(e){return Boolean(e)})).map((function(e){if(e.sourceId,n.includes(e.sourceId))throw new Error("[Autocomplete] The `sourceId` ".concat(JSON.stringify(e.sourceId)," is not unique."));n.push(e.sourceId);var t={getItemInputValue:function(e){return e.state.query},getItemUrl:function(){},onSelect:function(e){(0,e.setIsOpen)(!1)},onActive:_,onResolve:_};Object.keys(t).forEach((function(e){t[e].__default=!0}));var r=ie(ie({},t),e);return Promise.resolve(r)})))}))}(e,n)}))).then((function(e){return m(e)})).then((function(e){return e.map((function(e){return Ae(Ae({},e),{},{onSelect:function(n){e.onSelect(n),t.forEach((function(e){var t;return null===(t=e.onSelect)||void 0===t?void 0:t.call(e,n)}))},onActive:function(n){e.onActive(n),t.forEach((function(e){var t;return null===(t=e.onActive)||void 0===t?void 0:t.call(e,n)}))},onResolve:function(n){e.onResolve(n),t.forEach((function(e){var t;return null===(t=e.onResolve)||void 0===t?void 0:t.call(e,n)}))}})}))}))},navigator:Ae({navigate:function(e){var t=e.itemUrl;r.location.assign(t)},navigateNewTab:function(e){var t=e.itemUrl,n=r.open(t,"_blank","noopener");null==n||n.focus()},navigateNewWindow:function(e){var t=e.itemUrl;r.open(t,"_blank","noopener")}},e.navigator)})}function Ce(e){return Ce="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Ce(e)}function ke(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function xe(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);n=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}var Je,Xe,Ye,Ze=null,et=(Je=-1,Xe=-1,Ye=void 0,function(e){var t=++Je;return Promise.resolve(e).then((function(e){return Ye&&t=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function lt(e){return lt="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},lt(e)}var ct=["props","refresh","store"],st=["inputElement","formElement","panelElement"],ft=["inputElement"],pt=["inputElement","maxLength"],mt=["source"],vt=["item","source"];function dt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function yt(e){for(var t=1;t=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function ht(e){var t=e.props,n=e.refresh,r=e.store,o=gt(e,ct);return{getEnvironmentProps:function(e){var n=e.inputElement,o=e.formElement,i=e.panelElement;function u(e){!r.getState().isOpen&&r.pendingRequests.isEmpty()||e.target===n||!1===[o,i].some((function(t){return n=t,r=e.target,n===r||n.contains(r);var n,r}))&&(r.dispatch("blur",null),t.debug||r.pendingRequests.cancelAll())}return yt({onTouchStart:u,onMouseDown:u,onTouchMove:function(e){!1!==r.getState().isOpen&&n===t.environment.document.activeElement&&e.target!==n&&n.blur()}},gt(e,st))},getRootProps:function(e){return yt({role:"combobox","aria-expanded":r.getState().isOpen,"aria-haspopup":"listbox","aria-controls":r.getState().isOpen?r.getState().collections.map((function(e){var n=e.source;return pe(t.id,"list",n)})).join(" "):void 0,"aria-labelledby":pe(t.id,"label")},e)},getFormProps:function(e){e.inputElement;var i=gt(e,ft),u=function(i){var u;t.onSubmit(yt({event:i,refresh:n,state:r.getState()},o)),r.dispatch("submit",null),null===(u=e.inputElement)||void 0===u||u.blur()};return yt({action:"",noValidate:!0,role:"search",onSubmit:function(e){e.preventDefault();var n=se(t.plugins,r.pendingRequests);void 0!==n?n.then((function(){return u(e)})):u(e)},onReset:function(i){var u;i.preventDefault(),t.onReset(yt({event:i,refresh:n,state:r.getState()},o)),r.dispatch("reset",null),null===(u=e.inputElement)||void 0===u||u.focus()}},i)},getLabelProps:function(e){return yt({htmlFor:pe(t.id,"input"),id:pe(t.id,"label")},e)},getInputProps:function(e){var i;function u(e){(t.openOnFocus||Boolean(r.getState().query))&&tt(yt({event:e,props:t,query:r.getState().completion||r.getState().query,refresh:n,store:r},o)),r.dispatch("focus",null)}var a=e||{};a.inputElement;var l=a.maxLength,c=void 0===l?512:l,s=gt(a,pt),f=fe(r.getState()),p=function(e){return Boolean(e&&e.match(me))}((null===(i=t.environment.navigator)||void 0===i?void 0:i.userAgent)||""),m=t.enterKeyHint||(null!=f&&f.itemUrl&&!p?"go":"search");return yt({"aria-autocomplete":"both","aria-activedescendant":r.getState().isOpen&&null!==r.getState().activeItemId?pe(t.id,"item-".concat(r.getState().activeItemId),null==f?void 0:f.source):void 0,"aria-controls":r.getState().isOpen?r.getState().collections.filter((function(e){return e.items.length>0})).map((function(e){var n=e.source;return pe(t.id,"list",n)})).join(" "):void 0,"aria-labelledby":pe(t.id,"label"),value:r.getState().completion||r.getState().query,id:pe(t.id,"input"),autoComplete:"off",autoCorrect:"off",autoCapitalize:"off",enterKeyHint:m,spellCheck:"false",autoFocus:t.autoFocus,placeholder:t.placeholder,maxLength:c,type:"search",onChange:function(e){var i=e.currentTarget.value;t.ignoreCompositionEvents&&ve(e).isComposing?o.setQuery(i):tt(yt({event:e,props:t,query:i.slice(0,c),refresh:n,store:r},o))},onCompositionEnd:function(e){tt(yt({event:e,props:t,query:e.currentTarget.value.slice(0,c),refresh:n,store:r},o))},onKeyDown:function(e){ve(e).isComposing||function(e){var t=e.event,n=e.props,r=e.refresh,o=e.store,i=at(e,rt);if("ArrowUp"===t.key||"ArrowDown"===t.key){var u=function(){var e=fe(o.getState()),t=n.environment.document.getElementById(pe(n.id,"item-".concat(o.getState().activeItemId),null==e?void 0:e.source));t&&(t.scrollIntoViewIfNeeded?t.scrollIntoViewIfNeeded(!1):t.scrollIntoView(!1))},a=function(){var e=fe(o.getState());if(null!==o.getState().activeItemId&&e){var n=e.item,u=e.itemInputValue,a=e.itemUrl,l=e.source;l.onActive(it({event:t,item:n,itemInputValue:u,itemUrl:a,refresh:r,source:l,state:o.getState()},i))}};t.preventDefault(),!1===o.getState().isOpen&&(n.openOnFocus||Boolean(o.getState().query))?tt(it({event:t,props:n,query:o.getState().query,refresh:r,store:o},i)).then((function(){o.dispatch(t.key,{nextActiveItemId:n.defaultActiveItemId}),a(),setTimeout(u,0)})):(o.dispatch(t.key,{}),a(),u())}else if("Escape"===t.key)t.preventDefault(),o.dispatch(t.key,null),o.pendingRequests.cancelAll();else if("Tab"===t.key)o.dispatch("blur",null),o.pendingRequests.cancelAll();else if("Enter"===t.key){if(null===o.getState().activeItemId||o.getState().collections.every((function(e){return 0===e.items.length}))){var l=se(n.plugins,o.pendingRequests);return void(void 0!==l?l.then(o.pendingRequests.cancelAll):n.debug||o.pendingRequests.cancelAll())}t.preventDefault();var c=fe(o.getState()),s=c.item,f=c.itemInputValue,p=c.itemUrl,m=c.source;if(t.metaKey||t.ctrlKey)void 0!==p&&(m.onSelect(it({event:t,item:s,itemInputValue:f,itemUrl:p,refresh:r,source:m,state:o.getState()},i)),n.navigator.navigateNewTab({itemUrl:p,item:s,state:o.getState()}));else if(t.shiftKey)void 0!==p&&(m.onSelect(it({event:t,item:s,itemInputValue:f,itemUrl:p,refresh:r,source:m,state:o.getState()},i)),n.navigator.navigateNewWindow({itemUrl:p,item:s,state:o.getState()}));else if(t.altKey);else{if(void 0!==p)return m.onSelect(it({event:t,item:s,itemInputValue:f,itemUrl:p,refresh:r,source:m,state:o.getState()},i)),void n.navigator.navigate({itemUrl:p,item:s,state:o.getState()});tt(it({event:t,nextState:{isOpen:!1},props:n,query:f,refresh:r,store:o},i)).then((function(){m.onSelect(it({event:t,item:s,itemInputValue:f,itemUrl:p,refresh:r,source:m,state:o.getState()},i))}))}}}(yt({event:e,props:t,refresh:n,store:r},o))},onFocus:u,onBlur:_,onClick:function(n){e.inputElement!==t.environment.document.activeElement||r.getState().isOpen||u(n)}},s)},getPanelProps:function(e){return yt({onMouseDown:function(e){e.preventDefault()},onMouseLeave:function(){r.dispatch("mouseleave",null)}},e)},getListProps:function(e){var n=e||{},r=n.source,o=gt(n,mt);return yt({role:"listbox","aria-labelledby":pe(t.id,"label"),id:pe(t.id,"list",r)},o)},getItemProps:function(e){var i=e.item,u=e.source,a=gt(e,vt);return yt({id:pe(t.id,"item-".concat(i.__autocomplete_id),u),role:"option","aria-selected":r.getState().activeItemId===i.__autocomplete_id,onMouseMove:function(e){if(i.__autocomplete_id!==r.getState().activeItemId){r.dispatch("mousemove",i.__autocomplete_id);var t=fe(r.getState());if(null!==r.getState().activeItemId&&t){var u=t.item,a=t.itemInputValue,l=t.itemUrl,c=t.source;c.onActive(yt({event:e,item:u,itemInputValue:a,itemUrl:l,refresh:n,source:c,state:r.getState()},o))}}},onMouseDown:function(e){e.preventDefault()},onClick:function(e){var a=u.getItemInputValue({item:i,state:r.getState()}),l=u.getItemUrl({item:i,state:r.getState()});(l?Promise.resolve():tt(yt({event:e,nextState:{isOpen:!1},props:t,query:a,refresh:n,store:r},o))).then((function(){u.onSelect(yt({event:e,item:i,itemInputValue:a,itemUrl:l,refresh:n,source:u,state:r.getState()},o))}))}},a)}}}function _t(e){return _t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_t(e)}function Ot(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function St(e){for(var t=1;t=5&&((o||!e&&5===r)&&(u.push(r,0,o,n),r=6),e&&(u.push(r,e,0,n),r=6)),o=""},l=0;l"===t?(r=1,o=""):o=t+o[0]:i?t===i?i="":o+=t:'"'===t||"'"===t?i=t:">"===t?(a(),r=1):r&&("="===t?(r=5,n=o,o=""):"/"===t&&(r<5||">"===e[l][c+1])?(a(),3===r&&(u=u[0]),r=u,(u=u[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(a(),r=2):o+=t),3===r&&"!--"===o&&(r=4,u=u[0])}return a(),u}(e)),t),arguments,[])).length>1?t:t[0]}var Ft=function(e){var t=e.environment,n=t.document.createElementNS("http://www.w3.org/2000/svg","svg");n.setAttribute("class","aa-ClearIcon"),n.setAttribute("viewBox","0 0 24 24"),n.setAttribute("width","18"),n.setAttribute("height","18"),n.setAttribute("fill","currentColor");var r=t.document.createElementNS("http://www.w3.org/2000/svg","path");return r.setAttribute("d","M5.293 6.707l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0l5.293-5.293 5.293 5.293c0.391 0.391 1.024 0.391 1.414 0s0.391-1.024 0-1.414l-5.293-5.293 5.293-5.293c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0l-5.293 5.293-5.293-5.293c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414z"),n.appendChild(r),n};function Ut(e,t){if("string"==typeof t){var n=e.document.querySelector(t);return"The element ".concat(JSON.stringify(t)," is not in the document."),n}return t}function Mt(){for(var e=arguments.length,t=new Array(e),n=0;n2&&(u.children=arguments.length>3?on.call(arguments,2):n),"function"==typeof e&&null!=e.defaultProps)for(i in e.defaultProps)void 0===u[i]&&(u[i]=e.defaultProps[i]);return gn(e,u,r,o,null)}function gn(e,t,n,r,o){var i={type:e,props:t,key:n,ref:r,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,__h:null,constructor:void 0,__v:null==o?++an:o};return null==o&&null!=un.vnode&&un.vnode(i),i}function hn(e){return e.children}function _n(e,t){this.props=e,this.context=t}function On(e,t){if(null==t)return e.__?On(e.__,e.__.__k.indexOf(e)+1):null;for(var n;tt&&ln.sort(fn));Pn.__r=0}function wn(e,t,n,r,o,i,u,a,l,c){var s,f,p,m,v,d,y,b=r&&r.__k||mn,g=b.length;for(n.__k=[],s=0;s0?gn(m.type,m.props,m.key,m.ref?m.ref:null,m.__v):m)){if(m.__=n,m.__b=n.__b+1,null===(p=b[s])||p&&m.key==p.key&&m.type===p.type)b[s]=void 0;else for(f=0;f=0;t--)if((n=e.__k[t])&&(r=En(n)))return r;return null}function Dn(e,t,n){"-"===t[0]?e.setProperty(t,null==n?"":n):e[t]=null==n?"":"number"!=typeof n||vn.test(t)?n:n+"px"}function Cn(e,t,n,r,o){var i;e:if("style"===t)if("string"==typeof n)e.style.cssText=n;else{if("string"==typeof r&&(e.style.cssText=r=""),r)for(t in r)n&&t in n||Dn(e.style,t,"");if(n)for(t in n)r&&n[t]===r[t]||Dn(e.style,t,n[t])}else if("o"===t[0]&&"n"===t[1])i=t!==(t=t.replace(/Capture$/,"")),t=t.toLowerCase()in e?t.toLowerCase().slice(2):t.slice(2),e.l||(e.l={}),e.l[t+i]=n,n?r||e.addEventListener(t,i?xn:kn,i):e.removeEventListener(t,i?xn:kn,i);else if("dangerouslySetInnerHTML"!==t){if(o)t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if("width"!==t&&"height"!==t&&"href"!==t&&"list"!==t&&"form"!==t&&"tabIndex"!==t&&"download"!==t&&t in e)try{e[t]=null==n?"":n;break e}catch(e){}"function"==typeof n||(null==n||!1===n&&"-"!==t[4]?e.removeAttribute(t):e.setAttribute(t,n))}}function kn(e){return this.l[e.type+!1](un.event?un.event(e):e)}function xn(e){return this.l[e.type+!0](un.event?un.event(e):e)}function Nn(e,t,n,r,o,i,u,a,l){var c,s,f,p,m,v,d,y,b,g,h,_,O,S,j,P=t.type;if(void 0!==t.constructor)return null;null!=n.__h&&(l=n.__h,a=t.__e=n.__e,t.__h=null,i=[a]),(c=un.__b)&&c(t);try{e:if("function"==typeof P){if(y=t.props,b=(c=P.contextType)&&r[c.__c],g=c?b?b.props.value:c.__:r,n.__c?d=(s=t.__c=n.__c).__=s.__E:("prototype"in P&&P.prototype.render?t.__c=s=new P(y,g):(t.__c=s=new _n(y,g),s.constructor=P,s.render=Ln),b&&b.sub(s),s.props=y,s.state||(s.state={}),s.context=g,s.__n=r,f=s.__d=!0,s.__h=[],s._sb=[]),null==s.__s&&(s.__s=s.state),null!=P.getDerivedStateFromProps&&(s.__s==s.state&&(s.__s=dn({},s.__s)),dn(s.__s,P.getDerivedStateFromProps(y,s.__s))),p=s.props,m=s.state,s.__v=t,f)null==P.getDerivedStateFromProps&&null!=s.componentWillMount&&s.componentWillMount(),null!=s.componentDidMount&&s.__h.push(s.componentDidMount);else{if(null==P.getDerivedStateFromProps&&y!==p&&null!=s.componentWillReceiveProps&&s.componentWillReceiveProps(y,g),!s.__e&&null!=s.shouldComponentUpdate&&!1===s.shouldComponentUpdate(y,s.__s,g)||t.__v===n.__v){for(t.__v!==n.__v&&(s.props=y,s.state=s.__s,s.__d=!1),s.__e=!1,t.__e=n.__e,t.__k=n.__k,t.__k.forEach((function(e){e&&(e.__=t)})),h=0;h0&&void 0!==arguments[0]?arguments[0]:[];return{get:function(){return e},add:function(t){var n=e[e.length-1];(null==n?void 0:n.isHighlighted)===t.isHighlighted?e[e.length-1]={value:n.value+t.value,isHighlighted:n.isHighlighted}:e.push(t)}}}(n?[{value:n,isHighlighted:!1}]:[]);return t.forEach((function(e){var t=e.split(Un);r.add({value:t[0],isHighlighted:!0}),""!==t[1]&&r.add({value:t[1],isHighlighted:!1})})),r.get()}function Hn(e){return function(e){if(Array.isArray(e))return Vn(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(!e)return;if("string"==typeof e)return Vn(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return Vn(e,t)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Vn(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n",""":'"',"'":"'"},Kn=new RegExp(/\w/i),$n=/&(amp|quot|lt|gt|#39);/g,zn=RegExp($n.source);function Gn(e,t){var n,r,o,i=e[t],u=(null===(n=e[t+1])||void 0===n?void 0:n.isHighlighted)||!0,a=(null===(r=e[t-1])||void 0===r?void 0:r.isHighlighted)||!0;return Kn.test((o=i.value)&&zn.test(o)?o.replace($n,(function(e){return Qn[e]})):o)||a!==u?i.isHighlighted:a}function Jn(e){return Jn="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Jn(e)}function Xn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Yn(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);n=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function vr(e){return function(e){if(Array.isArray(e))return dr(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(!e)return;if("string"==typeof e)return dr(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return dr(e,t)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function dr(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n0;if(!_.value.core.openOnFocus&&!t.query)return n;var r=Boolean(y.current||_.value.renderer.renderNoResults);return!n&&r||n},__autocomplete_metadata:{userAgents:wr,options:e}}))})),j=f(n({collections:[],completion:null,context:{},isOpen:!1,query:"",activeItemId:null,status:"idle"},_.value.core.initialState)),P={getEnvironmentProps:_.value.renderer.getEnvironmentProps,getFormProps:_.value.renderer.getFormProps,getInputProps:_.value.renderer.getInputProps,getItemProps:_.value.renderer.getItemProps,getLabelProps:_.value.renderer.getLabelProps,getListProps:_.value.renderer.getListProps,getPanelProps:_.value.renderer.getPanelProps,getRootProps:_.value.renderer.getRootProps},w={setActiveItemId:S.value.setActiveItemId,setQuery:S.value.setQuery,setCollections:S.value.setCollections,setIsOpen:S.value.setIsOpen,setStatus:S.value.setStatus,setContext:S.value.setContext,refresh:S.value.refresh,navigator:S.value.navigator},I=m((function(){return Lt.bind(_.value.renderer.renderer.createElement)})),A=m((function(){return rn({autocomplete:S.value,autocompleteScopeApi:w,classNames:_.value.renderer.classNames,environment:_.value.core.environment,isDetached:O.value,placeholder:_.value.core.placeholder,propGetters:P,setIsModalOpen:k,state:j.current,translations:_.value.renderer.translations})}));function E(){Jt(A.value.panel,{style:O.value?{}:Pr({panelPlacement:_.value.renderer.panelPlacement,container:A.value.root,form:A.value.form,environment:_.value.core.environment})})}function D(e){j.current=e;var t={autocomplete:S.value,autocompleteScopeApi:w,classNames:_.value.renderer.classNames,components:_.value.renderer.components,container:_.value.renderer.container,html:I.value,dom:A.value,panelContainer:O.value?A.value.detachedContainer:_.value.renderer.panelContainer,propGetters:P,state:j.current,renderer:_.value.renderer.renderer},r=!b(e)&&!y.current&&_.value.renderer.renderNoResults||_.value.renderer.render;!function(e){var t=e.autocomplete,r=e.autocompleteScopeApi,o=e.dom,i=e.propGetters,u=e.state;Xt(o.root,i.getRootProps(n({state:u,props:t.getRootProps({})},r))),Xt(o.input,i.getInputProps(n({state:u,props:t.getInputProps({inputElement:o.input}),inputElement:o.input},r))),Jt(o.label,{hidden:"stalled"===u.status}),Jt(o.loadingIndicator,{hidden:"stalled"!==u.status}),Jt(o.clearButton,{hidden:!u.query}),Jt(o.detachedSearchButtonQuery,{textContent:u.query}),Jt(o.detachedSearchButtonPlaceholder,{hidden:Boolean(u.query)})}(t),function(e,t){var r=t.autocomplete,o=t.autocompleteScopeApi,u=t.classNames,a=t.html,l=t.dom,c=t.panelContainer,s=t.propGetters,f=t.state,p=t.components,m=t.renderer;if(f.isOpen){c.contains(l.panel)||"loading"===f.status||c.appendChild(l.panel),l.panel.classList.toggle("aa-Panel--stalled","stalled"===f.status);var v=f.collections.filter((function(e){var t=e.source,n=e.items;return t.templates.noResults||n.length>0})).map((function(e,t){var l=e.source,c=e.items;return m.createElement("section",{key:t,className:u.source,"data-autocomplete-source-id":l.sourceId},l.templates.header&&m.createElement("div",{className:u.sourceHeader},l.templates.header({components:p,createElement:m.createElement,Fragment:m.Fragment,items:c,source:l,state:f,html:a})),l.templates.noResults&&0===c.length?m.createElement("div",{className:u.sourceNoResults},l.templates.noResults({components:p,createElement:m.createElement,Fragment:m.Fragment,source:l,state:f,html:a})):m.createElement("ul",i({className:u.list},s.getListProps(n({state:f,props:r.getListProps({source:l})},o))),c.map((function(e){var t=r.getItemProps({item:e,source:l});return m.createElement("li",i({key:t.id,className:u.item},s.getItemProps(n({state:f,props:t},o))),l.templates.item({components:p,createElement:m.createElement,Fragment:m.Fragment,item:e,state:f,html:a}))}))),l.templates.footer&&m.createElement("div",{className:u.sourceFooter},l.templates.footer({components:p,createElement:m.createElement,Fragment:m.Fragment,items:c,source:l,state:f,html:a})))})),d=m.createElement(m.Fragment,null,m.createElement("div",{className:u.panelLayout},v),m.createElement("div",{className:"aa-GradientBottom"})),y=v.reduce((function(e,t){return e[t.props["data-autocomplete-source-id"]]=t,e}),{});e(n(n({children:d,state:f,sections:v,elements:y},m),{},{components:p,html:a},o),l.panel)}else c.contains(l.panel)&&c.removeChild(l.panel)}(r,t)}function C(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};l();var t=_.value.renderer,n=t.components,r=u(t,Ir);g.current=Vt(r,_.value.core,{components:Wt(n,(function(e){return!e.value.hasOwnProperty("__autocomplete_componentName")})),initialState:j.current},e),v(),c(),S.value.refresh().then((function(){D(j.current)}))}function k(e){e!==_.value.core.environment.document.body.contains(A.value.detachedOverlay)&&(e?(_.value.core.environment.document.body.appendChild(A.value.detachedOverlay),_.value.core.environment.document.body.classList.add("aa-Detached"),A.value.input.focus()):(_.value.core.environment.document.body.removeChild(A.value.detachedOverlay),_.value.core.environment.document.body.classList.remove("aa-Detached")))}return a((function(){var e=S.value.getEnvironmentProps({formElement:A.value.form,panelElement:A.value.panel,inputElement:A.value.input});return Jt(_.value.core.environment,e),function(){Jt(_.value.core.environment,Object.keys(e).reduce((function(e,t){return n(n({},e),{},o({},t,void 0))}),{}))}})),a((function(){var e=O.value?_.value.core.environment.document.body:_.value.renderer.panelContainer,t=O.value?A.value.detachedOverlay:A.value.panel;return O.value&&j.current.isOpen&&k(!0),D(j.current),function(){e.contains(t)&&(e.removeChild(t),e.classList.remove("aa-Detached"))}})),a((function(){var e=_.value.renderer.container;return e.appendChild(A.value.root),function(){e.removeChild(A.value.root)}})),a((function(){var e=p((function(e){D(e.state)}),0);return h.current=function(t){var n=t.state,r=t.prevState;(O.value&&r.isOpen!==n.isOpen&&k(n.isOpen),O.value||!n.isOpen||r.isOpen||E(),n.query!==r.query)&&_.value.core.environment.document.querySelectorAll(".aa-Panel--scrollable").forEach((function(e){0!==e.scrollTop&&(e.scrollTop=0)}));e({state:n})},function(){h.current=void 0}})),a((function(){var e=p((function(){var e=O.value;O.value=_.value.core.environment.matchMedia(_.value.renderer.detachedMediaQuery).matches,e!==O.value?C({}):requestAnimationFrame(E)}),20);return _.value.core.environment.addEventListener("resize",e),function(){_.value.core.environment.removeEventListener("resize",e)}})),a((function(){if(!O.value)return function(){};function e(e){A.value.detachedContainer.classList.toggle("aa-DetachedContainer--modal",e)}function t(t){e(t.matches)}var n=_.value.core.environment.matchMedia(getComputedStyle(_.value.core.environment.document.documentElement).getPropertyValue("--aa-detached-modal-media-query"));e(n.matches);var r=Boolean(n.addEventListener);return r?n.addEventListener("change",t):n.addListener(t),function(){r?n.removeEventListener("change",t):n.removeListener(t)}})),a((function(){return requestAnimationFrame(E),function(){}})),n(n({},w),{},{update:C,destroy:function(){l()}})},e.getAlgoliaFacets=function(e){var t=Ar({transformResponse:function(e){return e.facetHits}}),r=e.queries.map((function(e){return n(n({},e),{},{type:"facet"})}));return t(n(n({},e),{},{queries:r}))},e.getAlgoliaResults=Er,Object.defineProperty(e,"__esModule",{value:!0})})); + diff --git a/site_libs/quarto-search/fuse.min.js b/site_libs/quarto-search/fuse.min.js new file mode 100644 index 0000000..adc2835 --- /dev/null +++ b/site_libs/quarto-search/fuse.min.js @@ -0,0 +1,9 @@ +/** + * Fuse.js v6.6.2 - Lightweight fuzzy-search (http://fusejs.io) + * + * Copyright (c) 2022 Kiro Risk (http://kiro.me) + * All Rights Reserved. Apache Software License 2.0 + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +var e,t;e=this,t=function(){"use strict";function e(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function t(t){for(var n=1;ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n0&&void 0!==arguments[0]?arguments[0]:1,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:3,n=new Map,r=Math.pow(10,t);return{get:function(t){var i=t.match(C).length;if(n.has(i))return n.get(i);var o=1/Math.pow(i,.5*e),c=parseFloat(Math.round(o*r)/r);return n.set(i,c),c},clear:function(){n.clear()}}}var $=function(){function e(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=t.getFn,i=void 0===n?I.getFn:n,o=t.fieldNormWeight,c=void 0===o?I.fieldNormWeight:o;r(this,e),this.norm=E(c,3),this.getFn=i,this.isCreated=!1,this.setIndexRecords()}return o(e,[{key:"setSources",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];this.docs=e}},{key:"setIndexRecords",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];this.records=e}},{key:"setKeys",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];this.keys=t,this._keysMap={},t.forEach((function(t,n){e._keysMap[t.id]=n}))}},{key:"create",value:function(){var e=this;!this.isCreated&&this.docs.length&&(this.isCreated=!0,g(this.docs[0])?this.docs.forEach((function(t,n){e._addString(t,n)})):this.docs.forEach((function(t,n){e._addObject(t,n)})),this.norm.clear())}},{key:"add",value:function(e){var t=this.size();g(e)?this._addString(e,t):this._addObject(e,t)}},{key:"removeAt",value:function(e){this.records.splice(e,1);for(var t=e,n=this.size();t2&&void 0!==arguments[2]?arguments[2]:{},r=n.getFn,i=void 0===r?I.getFn:r,o=n.fieldNormWeight,c=void 0===o?I.fieldNormWeight:o,a=new $({getFn:i,fieldNormWeight:c});return a.setKeys(e.map(_)),a.setSources(t),a.create(),a}function R(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=t.errors,r=void 0===n?0:n,i=t.currentLocation,o=void 0===i?0:i,c=t.expectedLocation,a=void 0===c?0:c,s=t.distance,u=void 0===s?I.distance:s,h=t.ignoreLocation,l=void 0===h?I.ignoreLocation:h,f=r/e.length;if(l)return f;var d=Math.abs(a-o);return u?f+d/u:d?1:f}function N(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:I.minMatchCharLength,n=[],r=-1,i=-1,o=0,c=e.length;o=t&&n.push([r,i]),r=-1)}return e[o-1]&&o-r>=t&&n.push([r,o-1]),n}var P=32;function W(e){for(var t={},n=0,r=e.length;n1&&void 0!==arguments[1]?arguments[1]:{},o=i.location,c=void 0===o?I.location:o,a=i.threshold,s=void 0===a?I.threshold:a,u=i.distance,h=void 0===u?I.distance:u,l=i.includeMatches,f=void 0===l?I.includeMatches:l,d=i.findAllMatches,v=void 0===d?I.findAllMatches:d,g=i.minMatchCharLength,y=void 0===g?I.minMatchCharLength:g,p=i.isCaseSensitive,m=void 0===p?I.isCaseSensitive:p,k=i.ignoreLocation,M=void 0===k?I.ignoreLocation:k;if(r(this,e),this.options={location:c,threshold:s,distance:h,includeMatches:f,findAllMatches:v,minMatchCharLength:y,isCaseSensitive:m,ignoreLocation:M},this.pattern=m?t:t.toLowerCase(),this.chunks=[],this.pattern.length){var b=function(e,t){n.chunks.push({pattern:e,alphabet:W(e),startIndex:t})},x=this.pattern.length;if(x>P){for(var w=0,L=x%P,S=x-L;w3&&void 0!==arguments[3]?arguments[3]:{},i=r.location,o=void 0===i?I.location:i,c=r.distance,a=void 0===c?I.distance:c,s=r.threshold,u=void 0===s?I.threshold:s,h=r.findAllMatches,l=void 0===h?I.findAllMatches:h,f=r.minMatchCharLength,d=void 0===f?I.minMatchCharLength:f,v=r.includeMatches,g=void 0===v?I.includeMatches:v,y=r.ignoreLocation,p=void 0===y?I.ignoreLocation:y;if(t.length>P)throw new Error(w(P));for(var m,k=t.length,M=e.length,b=Math.max(0,Math.min(o,M)),x=u,L=b,S=d>1||g,_=S?Array(M):[];(m=e.indexOf(t,L))>-1;){var O=R(t,{currentLocation:m,expectedLocation:b,distance:a,ignoreLocation:p});if(x=Math.min(O,x),L=m+k,S)for(var j=0;j=z;q-=1){var B=q-1,J=n[e.charAt(B)];if(S&&(_[B]=+!!J),K[q]=(K[q+1]<<1|1)&J,F&&(K[q]|=(A[q+1]|A[q])<<1|1|A[q+1]),K[q]&$&&(C=R(t,{errors:F,currentLocation:B,expectedLocation:b,distance:a,ignoreLocation:p}))<=x){if(x=C,(L=B)<=b)break;z=Math.max(1,2*b-L)}}if(R(t,{errors:F+1,currentLocation:b,expectedLocation:b,distance:a,ignoreLocation:p})>x)break;A=K}var U={isMatch:L>=0,score:Math.max(.001,C)};if(S){var V=N(_,d);V.length?g&&(U.indices=V):U.isMatch=!1}return U}(e,n,i,{location:c+o,distance:a,threshold:s,findAllMatches:u,minMatchCharLength:h,includeMatches:r,ignoreLocation:l}),p=y.isMatch,m=y.score,k=y.indices;p&&(g=!0),v+=m,p&&k&&(d=[].concat(f(d),f(k)))}));var y={isMatch:g,score:g?v/this.chunks.length:1};return g&&r&&(y.indices=d),y}}]),e}(),z=function(){function e(t){r(this,e),this.pattern=t}return o(e,[{key:"search",value:function(){}}],[{key:"isMultiMatch",value:function(e){return D(e,this.multiRegex)}},{key:"isSingleMatch",value:function(e){return D(e,this.singleRegex)}}]),e}();function D(e,t){var n=e.match(t);return n?n[1]:null}var K=function(e){a(n,e);var t=l(n);function n(e){return r(this,n),t.call(this,e)}return o(n,[{key:"search",value:function(e){var t=e===this.pattern;return{isMatch:t,score:t?0:1,indices:[0,this.pattern.length-1]}}}],[{key:"type",get:function(){return"exact"}},{key:"multiRegex",get:function(){return/^="(.*)"$/}},{key:"singleRegex",get:function(){return/^=(.*)$/}}]),n}(z),q=function(e){a(n,e);var t=l(n);function n(e){return r(this,n),t.call(this,e)}return o(n,[{key:"search",value:function(e){var t=-1===e.indexOf(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,e.length-1]}}}],[{key:"type",get:function(){return"inverse-exact"}},{key:"multiRegex",get:function(){return/^!"(.*)"$/}},{key:"singleRegex",get:function(){return/^!(.*)$/}}]),n}(z),B=function(e){a(n,e);var t=l(n);function n(e){return r(this,n),t.call(this,e)}return o(n,[{key:"search",value:function(e){var t=e.startsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,this.pattern.length-1]}}}],[{key:"type",get:function(){return"prefix-exact"}},{key:"multiRegex",get:function(){return/^\^"(.*)"$/}},{key:"singleRegex",get:function(){return/^\^(.*)$/}}]),n}(z),J=function(e){a(n,e);var t=l(n);function n(e){return r(this,n),t.call(this,e)}return o(n,[{key:"search",value:function(e){var t=!e.startsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,e.length-1]}}}],[{key:"type",get:function(){return"inverse-prefix-exact"}},{key:"multiRegex",get:function(){return/^!\^"(.*)"$/}},{key:"singleRegex",get:function(){return/^!\^(.*)$/}}]),n}(z),U=function(e){a(n,e);var t=l(n);function n(e){return r(this,n),t.call(this,e)}return o(n,[{key:"search",value:function(e){var t=e.endsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[e.length-this.pattern.length,e.length-1]}}}],[{key:"type",get:function(){return"suffix-exact"}},{key:"multiRegex",get:function(){return/^"(.*)"\$$/}},{key:"singleRegex",get:function(){return/^(.*)\$$/}}]),n}(z),V=function(e){a(n,e);var t=l(n);function n(e){return r(this,n),t.call(this,e)}return o(n,[{key:"search",value:function(e){var t=!e.endsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,e.length-1]}}}],[{key:"type",get:function(){return"inverse-suffix-exact"}},{key:"multiRegex",get:function(){return/^!"(.*)"\$$/}},{key:"singleRegex",get:function(){return/^!(.*)\$$/}}]),n}(z),G=function(e){a(n,e);var t=l(n);function n(e){var i,o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},c=o.location,a=void 0===c?I.location:c,s=o.threshold,u=void 0===s?I.threshold:s,h=o.distance,l=void 0===h?I.distance:h,f=o.includeMatches,d=void 0===f?I.includeMatches:f,v=o.findAllMatches,g=void 0===v?I.findAllMatches:v,y=o.minMatchCharLength,p=void 0===y?I.minMatchCharLength:y,m=o.isCaseSensitive,k=void 0===m?I.isCaseSensitive:m,M=o.ignoreLocation,b=void 0===M?I.ignoreLocation:M;return r(this,n),(i=t.call(this,e))._bitapSearch=new T(e,{location:a,threshold:u,distance:l,includeMatches:d,findAllMatches:g,minMatchCharLength:p,isCaseSensitive:k,ignoreLocation:b}),i}return o(n,[{key:"search",value:function(e){return this._bitapSearch.searchIn(e)}}],[{key:"type",get:function(){return"fuzzy"}},{key:"multiRegex",get:function(){return/^"(.*)"$/}},{key:"singleRegex",get:function(){return/^(.*)$/}}]),n}(z),H=function(e){a(n,e);var t=l(n);function n(e){return r(this,n),t.call(this,e)}return o(n,[{key:"search",value:function(e){for(var t,n=0,r=[],i=this.pattern.length;(t=e.indexOf(this.pattern,n))>-1;)n=t+i,r.push([t,n-1]);var o=!!r.length;return{isMatch:o,score:o?0:1,indices:r}}}],[{key:"type",get:function(){return"include"}},{key:"multiRegex",get:function(){return/^'"(.*)"$/}},{key:"singleRegex",get:function(){return/^'(.*)$/}}]),n}(z),Q=[K,H,B,J,V,U,q,G],X=Q.length,Y=/ +(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/;function Z(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return e.split("|").map((function(e){for(var n=e.trim().split(Y).filter((function(e){return e&&!!e.trim()})),r=[],i=0,o=n.length;i1&&void 0!==arguments[1]?arguments[1]:{},i=n.isCaseSensitive,o=void 0===i?I.isCaseSensitive:i,c=n.includeMatches,a=void 0===c?I.includeMatches:c,s=n.minMatchCharLength,u=void 0===s?I.minMatchCharLength:s,h=n.ignoreLocation,l=void 0===h?I.ignoreLocation:h,f=n.findAllMatches,d=void 0===f?I.findAllMatches:f,v=n.location,g=void 0===v?I.location:v,y=n.threshold,p=void 0===y?I.threshold:y,m=n.distance,k=void 0===m?I.distance:m;r(this,e),this.query=null,this.options={isCaseSensitive:o,includeMatches:a,minMatchCharLength:u,findAllMatches:d,ignoreLocation:l,location:g,threshold:p,distance:k},this.pattern=o?t:t.toLowerCase(),this.query=Z(this.pattern,this.options)}return o(e,[{key:"searchIn",value:function(e){var t=this.query;if(!t)return{isMatch:!1,score:1};var n=this.options,r=n.includeMatches;e=n.isCaseSensitive?e:e.toLowerCase();for(var i=0,o=[],c=0,a=0,s=t.length;a-1&&(n.refIndex=e.idx),t.matches.push(n)}}))}function ve(e,t){t.score=e.score}function ge(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},r=n.includeMatches,i=void 0===r?I.includeMatches:r,o=n.includeScore,c=void 0===o?I.includeScore:o,a=[];return i&&a.push(de),c&&a.push(ve),e.map((function(e){var n=e.idx,r={item:t[n],refIndex:n};return a.length&&a.forEach((function(t){t(e,r)})),r}))}var ye=function(){function e(n){var i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},o=arguments.length>2?arguments[2]:void 0;r(this,e),this.options=t(t({},I),i),this.options.useExtendedSearch,this._keyStore=new S(this.options.keys),this.setCollection(n,o)}return o(e,[{key:"setCollection",value:function(e,t){if(this._docs=e,t&&!(t instanceof $))throw new Error("Incorrect 'index' type");this._myIndex=t||F(this.options.keys,this._docs,{getFn:this.options.getFn,fieldNormWeight:this.options.fieldNormWeight})}},{key:"add",value:function(e){k(e)&&(this._docs.push(e),this._myIndex.add(e))}},{key:"remove",value:function(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:function(){return!1},t=[],n=0,r=this._docs.length;n1&&void 0!==arguments[1]?arguments[1]:{},n=t.limit,r=void 0===n?-1:n,i=this.options,o=i.includeMatches,c=i.includeScore,a=i.shouldSort,s=i.sortFn,u=i.ignoreFieldNorm,h=g(e)?g(this._docs[0])?this._searchStringList(e):this._searchObjectList(e):this._searchLogical(e);return fe(h,{ignoreFieldNorm:u}),a&&h.sort(s),y(r)&&r>-1&&(h=h.slice(0,r)),ge(h,this._docs,{includeMatches:o,includeScore:c})}},{key:"_searchStringList",value:function(e){var t=re(e,this.options),n=this._myIndex.records,r=[];return n.forEach((function(e){var n=e.v,i=e.i,o=e.n;if(k(n)){var c=t.searchIn(n),a=c.isMatch,s=c.score,u=c.indices;a&&r.push({item:n,idx:i,matches:[{score:s,value:n,norm:o,indices:u}]})}})),r}},{key:"_searchLogical",value:function(e){var t=this,n=function(e,t){var n=(arguments.length>2&&void 0!==arguments[2]?arguments[2]:{}).auto,r=void 0===n||n,i=function e(n){var i=Object.keys(n),o=ue(n);if(!o&&i.length>1&&!se(n))return e(le(n));if(he(n)){var c=o?n[ce]:i[0],a=o?n[ae]:n[c];if(!g(a))throw new Error(x(c));var s={keyId:j(c),pattern:a};return r&&(s.searcher=re(a,t)),s}var u={children:[],operator:i[0]};return i.forEach((function(t){var r=n[t];v(r)&&r.forEach((function(t){u.children.push(e(t))}))})),u};return se(e)||(e=le(e)),i(e)}(e,this.options),r=function e(n,r,i){if(!n.children){var o=n.keyId,c=n.searcher,a=t._findMatches({key:t._keyStore.get(o),value:t._myIndex.getValueForItemAtKeyId(r,o),searcher:c});return a&&a.length?[{idx:i,item:r,matches:a}]:[]}for(var s=[],u=0,h=n.children.length;u1&&void 0!==arguments[1]?arguments[1]:{},n=t.getFn,r=void 0===n?I.getFn:n,i=t.fieldNormWeight,o=void 0===i?I.fieldNormWeight:i,c=e.keys,a=e.records,s=new $({getFn:r,fieldNormWeight:o});return s.setKeys(c),s.setIndexRecords(a),s},ye.config=I,function(){ne.push.apply(ne,arguments)}(te),ye},"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).Fuse=t(); \ No newline at end of file diff --git a/site_libs/quarto-search/quarto-search.js b/site_libs/quarto-search/quarto-search.js new file mode 100644 index 0000000..d788a95 --- /dev/null +++ b/site_libs/quarto-search/quarto-search.js @@ -0,0 +1,1290 @@ +const kQueryArg = "q"; +const kResultsArg = "show-results"; + +// If items don't provide a URL, then both the navigator and the onSelect +// function aren't called (and therefore, the default implementation is used) +// +// We're using this sentinel URL to signal to those handlers that this +// item is a more item (along with the type) and can be handled appropriately +const kItemTypeMoreHref = "0767FDFD-0422-4E5A-BC8A-3BE11E5BBA05"; + +window.document.addEventListener("DOMContentLoaded", function (_event) { + // Ensure that search is available on this page. If it isn't, + // should return early and not do anything + var searchEl = window.document.getElementById("quarto-search"); + if (!searchEl) return; + + const { autocomplete } = window["@algolia/autocomplete-js"]; + + let quartoSearchOptions = {}; + let language = {}; + const searchOptionEl = window.document.getElementById( + "quarto-search-options" + ); + if (searchOptionEl) { + const jsonStr = searchOptionEl.textContent; + quartoSearchOptions = JSON.parse(jsonStr); + language = quartoSearchOptions.language; + } + + // note the search mode + if (quartoSearchOptions.type === "overlay") { + searchEl.classList.add("type-overlay"); + } else { + searchEl.classList.add("type-textbox"); + } + + // Used to determine highlighting behavior for this page + // A `q` query param is expected when the user follows a search + // to this page + const currentUrl = new URL(window.location); + const query = currentUrl.searchParams.get(kQueryArg); + const showSearchResults = currentUrl.searchParams.get(kResultsArg); + const mainEl = window.document.querySelector("main"); + + // highlight matches on the page + if (query && mainEl) { + // perform any highlighting + highlight(escapeRegExp(query), mainEl); + + // fix up the URL to remove the q query param + const replacementUrl = new URL(window.location); + replacementUrl.searchParams.delete(kQueryArg); + window.history.replaceState({}, "", replacementUrl); + } + + // function to clear highlighting on the page when the search query changes + // (e.g. if the user edits the query or clears it) + let highlighting = true; + const resetHighlighting = (searchTerm) => { + if (mainEl && highlighting && query && searchTerm !== query) { + clearHighlight(query, mainEl); + highlighting = false; + } + }; + + // Clear search highlighting when the user scrolls sufficiently + const resetFn = () => { + resetHighlighting(""); + window.removeEventListener("quarto-hrChanged", resetFn); + window.removeEventListener("quarto-sectionChanged", resetFn); + }; + + // Register this event after the initial scrolling and settling of events + // on the page + window.addEventListener("quarto-hrChanged", resetFn); + window.addEventListener("quarto-sectionChanged", resetFn); + + // Responsively switch to overlay mode if the search is present on the navbar + // Note that switching the sidebar to overlay mode requires more coordinate (not just + // the media query since we generate different HTML for sidebar overlays than we do + // for sidebar input UI) + const detachedMediaQuery = + quartoSearchOptions.type === "overlay" ? "all" : "(max-width: 991px)"; + + // If configured, include the analytics client to send insights + const plugins = configurePlugins(quartoSearchOptions); + + let lastState = null; + const { setIsOpen, setQuery, setCollections } = autocomplete({ + container: searchEl, + detachedMediaQuery: detachedMediaQuery, + defaultActiveItemId: 0, + panelContainer: "#quarto-search-results", + panelPlacement: quartoSearchOptions["panel-placement"], + debug: false, + openOnFocus: true, + plugins, + classNames: { + form: "d-flex", + }, + placeholder: language["search-text-placeholder"], + translations: { + clearButtonTitle: language["search-clear-button-title"], + detachedCancelButtonText: language["search-detached-cancel-button-title"], + submitButtonTitle: language["search-submit-button-title"], + }, + initialState: { + query, + }, + getItemUrl({ item }) { + return item.href; + }, + onStateChange({ state }) { + // If this is a file URL, note that + + // Perhaps reset highlighting + resetHighlighting(state.query); + + // If the panel just opened, ensure the panel is positioned properly + if (state.isOpen) { + if (lastState && !lastState.isOpen) { + setTimeout(() => { + positionPanel(quartoSearchOptions["panel-placement"]); + }, 150); + } + } + + // Perhaps show the copy link + showCopyLink(state.query, quartoSearchOptions); + + lastState = state; + }, + reshape({ sources, state }) { + return sources.map((source) => { + try { + const items = source.getItems(); + + // Validate the items + validateItems(items); + + // group the items by document + const groupedItems = new Map(); + items.forEach((item) => { + const hrefParts = item.href.split("#"); + const baseHref = hrefParts[0]; + const isDocumentItem = hrefParts.length === 1; + + const items = groupedItems.get(baseHref); + if (!items) { + groupedItems.set(baseHref, [item]); + } else { + // If the href for this item matches the document + // exactly, place this item first as it is the item that represents + // the document itself + if (isDocumentItem) { + items.unshift(item); + } else { + items.push(item); + } + groupedItems.set(baseHref, items); + } + }); + + const reshapedItems = []; + let count = 1; + for (const [_key, value] of groupedItems) { + const firstItem = value[0]; + reshapedItems.push({ + ...firstItem, + type: kItemTypeDoc, + }); + + const collapseMatches = quartoSearchOptions["collapse-after"]; + const collapseCount = + typeof collapseMatches === "number" ? collapseMatches : 1; + + if (value.length > 1) { + const target = `search-more-${count}`; + const isExpanded = + state.context.expanded && + state.context.expanded.includes(target); + + const remainingCount = value.length - collapseCount; + + for (let i = 1; i < value.length; i++) { + if (collapseMatches && i === collapseCount) { + reshapedItems.push({ + target, + title: isExpanded + ? language["search-hide-matches-text"] + : remainingCount === 1 + ? `${remainingCount} ${language["search-more-match-text"]}` + : `${remainingCount} ${language["search-more-matches-text"]}`, + type: kItemTypeMore, + href: kItemTypeMoreHref, + }); + } + + if (isExpanded || !collapseMatches || i < collapseCount) { + reshapedItems.push({ + ...value[i], + type: kItemTypeItem, + target, + }); + } + } + } + count += 1; + } + + return { + ...source, + getItems() { + return reshapedItems; + }, + }; + } catch (error) { + // Some form of error occurred + return { + ...source, + getItems() { + return [ + { + title: error.name || "An Error Occurred While Searching", + text: + error.message || + "An unknown error occurred while attempting to perform the requested search.", + type: kItemTypeError, + }, + ]; + }, + }; + } + }); + }, + navigator: { + navigate({ itemUrl }) { + if (itemUrl !== offsetURL(kItemTypeMoreHref)) { + window.location.assign(itemUrl); + } + }, + navigateNewTab({ itemUrl }) { + if (itemUrl !== offsetURL(kItemTypeMoreHref)) { + const windowReference = window.open(itemUrl, "_blank", "noopener"); + if (windowReference) { + windowReference.focus(); + } + } + }, + navigateNewWindow({ itemUrl }) { + if (itemUrl !== offsetURL(kItemTypeMoreHref)) { + window.open(itemUrl, "_blank", "noopener"); + } + }, + }, + getSources({ state, setContext, setActiveItemId, refresh }) { + return [ + { + sourceId: "documents", + getItemUrl({ item }) { + if (item.href) { + return offsetURL(item.href); + } else { + return undefined; + } + }, + onSelect({ + item, + state, + setContext, + setIsOpen, + setActiveItemId, + refresh, + }) { + if (item.type === kItemTypeMore) { + toggleExpanded(item, state, setContext, setActiveItemId, refresh); + + // Toggle more + setIsOpen(true); + } + }, + getItems({ query }) { + if (query === null || query === "") { + return []; + } + + const limit = quartoSearchOptions.limit; + if (quartoSearchOptions.algolia) { + return algoliaSearch(query, limit, quartoSearchOptions.algolia); + } else { + // Fuse search options + const fuseSearchOptions = { + isCaseSensitive: false, + shouldSort: true, + minMatchCharLength: 2, + limit: limit, + }; + + return readSearchData().then(function (fuse) { + return fuseSearch(query, fuse, fuseSearchOptions); + }); + } + }, + templates: { + noResults({ createElement }) { + const hasQuery = lastState.query; + + return createElement( + "div", + { + class: `quarto-search-no-results${ + hasQuery ? "" : " no-query" + }`, + }, + language["search-no-results-text"] + ); + }, + header({ items, createElement }) { + // count the documents + const count = items.filter((item) => { + return item.type === kItemTypeDoc; + }).length; + + if (count > 0) { + return createElement( + "div", + { class: "search-result-header" }, + `${count} ${language["search-matching-documents-text"]}` + ); + } else { + return createElement( + "div", + { class: "search-result-header-no-results" }, + `` + ); + } + }, + footer({ _items, createElement }) { + if ( + quartoSearchOptions.algolia && + quartoSearchOptions.algolia["show-logo"] + ) { + const libDir = quartoSearchOptions.algolia["libDir"]; + const logo = createElement("img", { + src: offsetURL( + `${libDir}/quarto-search/search-by-algolia.svg` + ), + class: "algolia-search-logo", + }); + return createElement( + "a", + { href: "http://www.algolia.com/" }, + logo + ); + } + }, + + item({ item, createElement }) { + return renderItem( + item, + createElement, + state, + setActiveItemId, + setContext, + refresh, + quartoSearchOptions + ); + }, + }, + }, + ]; + }, + }); + + window.quartoOpenSearch = () => { + setIsOpen(false); + setIsOpen(true); + focusSearchInput(); + }; + + document.addEventListener("keyup", (event) => { + const { key } = event; + const kbds = quartoSearchOptions["keyboard-shortcut"]; + const focusedEl = document.activeElement; + + const isFormElFocused = [ + "input", + "select", + "textarea", + "button", + "option", + ].find((tag) => { + return focusedEl.tagName.toLowerCase() === tag; + }); + + if ( + kbds && + kbds.includes(key) && + !isFormElFocused && + !document.activeElement.isContentEditable + ) { + event.preventDefault(); + window.quartoOpenSearch(); + } + }); + + // Remove the labeleledby attribute since it is pointing + // to a non-existent label + if (quartoSearchOptions.type === "overlay") { + const inputEl = window.document.querySelector( + "#quarto-search .aa-Autocomplete" + ); + if (inputEl) { + inputEl.removeAttribute("aria-labelledby"); + } + } + + function throttle(func, wait) { + let waiting = false; + return function () { + if (!waiting) { + func.apply(this, arguments); + waiting = true; + setTimeout(function () { + waiting = false; + }, wait); + } + }; + } + + // If the main document scrolls dismiss the search results + // (otherwise, since they're floating in the document they can scroll with the document) + window.document.body.onscroll = throttle(() => { + // Only do this if we're not detached + // Bug #7117 + // This will happen when the keyboard is shown on ios (resulting in a scroll) + // which then closed the search UI + if (!window.matchMedia(detachedMediaQuery).matches) { + setIsOpen(false); + } + }, 50); + + if (showSearchResults) { + setIsOpen(true); + focusSearchInput(); + } +}); + +function configurePlugins(quartoSearchOptions) { + const autocompletePlugins = []; + const algoliaOptions = quartoSearchOptions.algolia; + if ( + algoliaOptions && + algoliaOptions["analytics-events"] && + algoliaOptions["search-only-api-key"] && + algoliaOptions["application-id"] + ) { + const apiKey = algoliaOptions["search-only-api-key"]; + const appId = algoliaOptions["application-id"]; + + // Aloglia insights may not be loaded because they require cookie consent + // Use deferred loading so events will start being recorded when/if consent + // is granted. + const algoliaInsightsDeferredPlugin = deferredLoadPlugin(() => { + if ( + window.aa && + window["@algolia/autocomplete-plugin-algolia-insights"] + ) { + window.aa("init", { + appId, + apiKey, + useCookie: true, + }); + + const { createAlgoliaInsightsPlugin } = + window["@algolia/autocomplete-plugin-algolia-insights"]; + // Register the insights client + const algoliaInsightsPlugin = createAlgoliaInsightsPlugin({ + insightsClient: window.aa, + onItemsChange({ insights, insightsEvents }) { + const events = insightsEvents.flatMap((event) => { + // This API limits the number of items per event to 20 + const chunkSize = 20; + const itemChunks = []; + const eventItems = event.items; + for (let i = 0; i < eventItems.length; i += chunkSize) { + itemChunks.push(eventItems.slice(i, i + chunkSize)); + } + // Split the items into multiple events that can be sent + const events = itemChunks.map((items) => { + return { + ...event, + items, + }; + }); + return events; + }); + + for (const event of events) { + insights.viewedObjectIDs(event); + } + }, + }); + return algoliaInsightsPlugin; + } + }); + + // Add the plugin + autocompletePlugins.push(algoliaInsightsDeferredPlugin); + return autocompletePlugins; + } +} + +// For plugins that may not load immediately, create a wrapper +// plugin and forward events and plugin data once the plugin +// is initialized. This is useful for cases like cookie consent +// which may prevent the analytics insights event plugin from initializing +// immediately. +function deferredLoadPlugin(createPlugin) { + let plugin = undefined; + let subscribeObj = undefined; + const wrappedPlugin = () => { + if (!plugin && subscribeObj) { + plugin = createPlugin(); + if (plugin && plugin.subscribe) { + plugin.subscribe(subscribeObj); + } + } + return plugin; + }; + + return { + subscribe: (obj) => { + subscribeObj = obj; + }, + onStateChange: (obj) => { + const plugin = wrappedPlugin(); + if (plugin && plugin.onStateChange) { + plugin.onStateChange(obj); + } + }, + onSubmit: (obj) => { + const plugin = wrappedPlugin(); + if (plugin && plugin.onSubmit) { + plugin.onSubmit(obj); + } + }, + onReset: (obj) => { + const plugin = wrappedPlugin(); + if (plugin && plugin.onReset) { + plugin.onReset(obj); + } + }, + getSources: (obj) => { + const plugin = wrappedPlugin(); + if (plugin && plugin.getSources) { + return plugin.getSources(obj); + } else { + return Promise.resolve([]); + } + }, + data: (obj) => { + const plugin = wrappedPlugin(); + if (plugin && plugin.data) { + plugin.data(obj); + } + }, + }; +} + +function validateItems(items) { + // Validate the first item + if (items.length > 0) { + const item = items[0]; + const missingFields = []; + if (item.href == undefined) { + missingFields.push("href"); + } + if (!item.title == undefined) { + missingFields.push("title"); + } + if (!item.text == undefined) { + missingFields.push("text"); + } + + if (missingFields.length === 1) { + throw { + name: `Error: Search index is missing the ${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 @@ + + + + https://aefarrell.github.io/about.html + 2026-01-05T05:19:30.586Z + + + https://aefarrell.github.io/index.html + 2025-06-27T14:15:20.064Z + + + https://aefarrell.github.io/posts/adiabatic-compressible-flow/index.html + 2025-05-30T17:00:56.086Z + + + https://aefarrell.github.io/posts/intpuff2_successive_approximations/index.html + 2025-05-30T17:00:55.998Z + + + https://aefarrell.github.io/posts/hydrogen_release_modeling/index.html + 2025-05-30T17:00:55.984Z + + + https://aefarrell.github.io/posts/gaussian_explosive_mass/index.html + 2026-02-25T01:26:03.158Z + + + https://aefarrell.github.io/posts/vapour_cloud_explosion_example/index.html + 2025-05-30T17:00:56.092Z + + + https://aefarrell.github.io/posts/integrated_puff/index.html + 2025-12-29T18:16:21.364Z + + + https://aefarrell.github.io/posts/pollen_dispersion/index.html + 2025-12-31T02:33:30.220Z + + + https://aefarrell.github.io/posts/relief_valve_sizing/index.html + 2025-12-31T01:53:37.859Z + + + https://aefarrell.github.io/posts/worst_case_weather/index.html + 2025-05-30T17:00:56.091Z + + + https://aefarrell.github.io/posts/engineering_a_cup_of_coffee_part-2/index.html + 2025-12-31T01:18:51.034Z + + + https://aefarrell.github.io/posts/sizing_a_gooseneck_example/index.html + 2025-05-30T17:00:55.992Z + + + https://aefarrell.github.io/posts/vessel_blowdown_dispersion/index.html + 2025-12-27T18:28:09.872Z + + + https://aefarrell.github.io/posts/impossible_bowling/index.html + 2026-04-03T15:05:24.012Z + + + https://aefarrell.github.io/posts/turbulent_jet_example/index.html + 2025-05-30T17:00:56.087Z + + + https://aefarrell.github.io/posts/turbulent_jet_notes_part_2/index.html + 2025-12-29T18:10:57.697Z + + + https://aefarrell.github.io/posts/building_infiltration_2/index.html + 2025-05-30T17:00:56.013Z + + + https://aefarrell.github.io/posts/atmotube_data_logging/index.html + 2026-01-05T05:07:44.029Z + + + https://aefarrell.github.io/archive.html + 2025-06-01T00:39:07.326Z + + + https://aefarrell.github.io/projects/pymotube/index.html + 2026-01-21T02:22:23.472Z + + + https://aefarrell.github.io/projects/gas_dispersion_jl/index.html + 2025-12-31T02:42:57.008Z + + + https://aefarrell.github.io/projects/unitfulcorrelations_jl/index.html + 2025-06-27T14:16:10.238Z + + + https://aefarrell.github.io/projects/picocalc/index.html + 2025-11-29T18:52:07.261Z + + + https://aefarrell.github.io/posts/vessel_blowdown_real_gases/index.html + 2025-05-30T17:00:56.001Z + + + https://aefarrell.github.io/posts/dynamic_mode_decomposition/index.html + 2025-05-30T17:00:56.039Z + + + https://aefarrell.github.io/posts/butane_leak_example/index.html + 2026-02-11T02:14:32.957Z + + + https://aefarrell.github.io/posts/indoor_air_quality/index.html + 2025-05-30T17:00:55.999Z + + + https://aefarrell.github.io/posts/building_infiltration_example/index.html + 2025-12-29T17:32:45.155Z + + + https://aefarrell.github.io/posts/engineering_a_cup_of_coffee/index.html + 2025-05-30T17:00:56.009Z + + + https://aefarrell.github.io/posts/vessel_blowdown_ideal_gases/index.html + 2025-11-10T16:57:35.206Z + + + https://aefarrell.github.io/posts/hydrogen_blending/index.html + 2025-12-29T18:24:28.707Z + + + https://aefarrell.github.io/posts/federal_election/index.html + 2025-05-30T17:00:56.016Z + + + https://aefarrell.github.io/posts/ooms_plume_model/index.html + 2025-11-15T19:49:48.152Z + + + https://aefarrell.github.io/posts/hydrogen_compression/index.html + 2026-05-08T04:26:12.475Z + + + https://aefarrell.github.io/posts/dispersion_parameter_sensitivity/index.html + 2025-12-31T00:57:55.013Z + + + https://aefarrell.github.io/posts/Britter-McQuaid/index.html + 2025-05-30T17:00:56.085Z + + + https://aefarrell.github.io/posts/smoke_days/index.html + 2025-05-30T17:00:56.015Z + + + https://aefarrell.github.io/posts/turbulent_jet_notes/index.html + 2025-05-30T17:00:56.084Z + + + https://aefarrell.github.io/posts/fugitive-hydrogen/index.html + 2025-12-31T01:00:41.510Z + + + https://aefarrell.github.io/posts/gaussian_dispersion_example/index.html + 2025-12-29T18:07:58.206Z + + + https://aefarrell.github.io/posts/plastics-recycling-microplastics/index.html + 2025-05-30T17:00:56.088Z + + + https://aefarrell.github.io/projects.html + 2025-06-27T14:15:33.411Z + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..e1063fa --- /dev/null +++ b/styles.css @@ -0,0 +1,22 @@ +/* css styles */ +code.sourceCode.r::before{ + content:url('data:image/svg+xml; utf8, '); + float:right; + width: 20px; + filter: opacity(0.25); + margin-right: 20px; +} +code.sourceCode.python::before{ + content:url('data:image/svg+xml; utf8, '); + float:right; + width: 20px; + filter: opacity(0.25); + margin-right: 20px; +} +code.sourceCode.julia::before{ + content:url('data:image/svg+xml; utf8, '); + float:right; + width: 20px; + filter: opacity(0.25); + margin-right: 20px; +}