Particle advection
All SpeedyWeather.jl models support particle advection. Particles are objects without mass or volume at a location
This equation applies in 2D, i.e.
Discretization of particle advection
The particle advection equation has to be discretized to be numerically solved. While the particle location can generally be anywhere on the sphere, the velocity
Meaning we have used the velocity field at both departure time
We can write down a more accurate scheme at the cost of a second interpolation step. The Heun method, also called predictor-corrector is 2nd order accurate and uses an average of the velocity at departure time
Because we don't have
Denoted here as the last line with the left-hand side becoming the last term of the first line in the next time step
We use for horizontal coordinates degrees, such that we need to scale the time step
Technically, a particle moved with a given velocity follows a great circle in spherical coordinates. This means that
becomes a bad approximation when the time step and or the velocity are large. However, for simplicity and to avoid the calculation of the great circle we currently do use this to move particles with a given velocity. We essentially assume a local cartesian coordinate system instead of the geodesics in spherical coordinates. However, for typical time steps of 1 hour and velocities not exceeding 100 m/s the error is not catastrophic and can be reduced with a shorter time step. We may switch to great circle calculations in future versions.
Create a particle
So much about the theory
A Particle at location 10˚E and 30˚N (and
using SpeedyWeather
p = Particle(lon=10, lat=30, σ=0)
p = Particle(lon=10, lat=30)
p = Particle(10, 30, 0)
p = Particle(10, 30)Particle{Float32}( active, 10.00˚E, 30.00˚N, σ = 0.00)All of the above are equivalent. Unless a keyword argument is used, longitude is the first argument, followed by latitude (necessary), followed by mod(::Particle) function, which is similar to the modulo operator but with the second argument hardcoded to the coordinate ranges from above, e.g.
mod(Particle(lon=-30, lat=0))Particle{Float32}( active, 330.00˚E, 0.00˚N, σ = 0.00)which also takes into account pole crossings which adds 180˚ in longitude
mod(Particle(lon=0, lat=100))Particle{Float32}( active, 180.00˚E, 80.00˚N, σ = 0.00)as if the particle has moved across the pole. That way all real values for longitude and latitude are wrapped into the reference range [0, 360˚E] and [-90˚N, 90˚N].
Particles are immutable
Particles are implemented as immutable struct, meaning you cannot change their position by particle.lon = value. You have to think of them as integers or floats instead. If you have a particle p and you want to change its position to the Equator for example you need to create a new one new_particle = Particle(p.lon, 0, p.σ).
By default Float32 is used, but providing coordinates in Float64 will promote the type accordingly. Also by default, particles are active which is indicated by the field active of Particle, a boolean. Active particles are moved following the equation above, but inactive particles are not. You can activate or deactivate a particle like so
deactivate(p)Particle{Float32}(inactive, 10.00˚E, 30.00˚N, σ = 0.00)and so
activate(p)Particle{Float32}( active, 10.00˚E, 30.00˚N, σ = 0.00)or check its activity by isactive(::Particle) returning true or false. The zero-element of the Particle type is
zero(Particle)Particle{Float32}( active, 0.00˚E, 0.00˚N, σ = 0.00)and you can also create a random particle which uses a raised cosine distribution in latitude for an equal area-weighted uniform distribution over the sphere
rand(Particle{Float32}) # specify number format
rand(Particle) # or not (defaults used instead)Particle{Float32}( active, 262.17˚E, -69.51˚N, σ = 0.10)Advecting particles
The Particle type can be used inside vectors, e.g.
zeros(Particle{Float32}, 3)
rand(Particle{Float64}, 5)5-element Vector{Particle{Float64}}:
Particle{Float64}( active, 20.86˚E, 31.12˚N, σ = 0.24)
Particle{Float64}( active, 272.03˚E, -50.69˚N, σ = 0.67)
Particle{Float64}( active, 201.44˚E, 14.84˚N, σ = 0.80)
Particle{Float64}( active, 350.09˚E, 24.51˚N, σ = 0.20)
Particle{Float64}( active, 305.92˚E, 35.39˚N, σ = 0.24)which is how particles are represented inside a SpeedyWeather Simulation. Note that the default initial state of particles is active. In SpeedyWeather all particles can be activated or deactivated at any time using the activate and deactivate functions.
First, you create a SpectralGrid with the nparticles keyword
spectral_grid = SpectralGrid(nparticles = 3)SpectralGrid{CPU, Spectrum{...}, OctahedralGaussianGrid{...}}
├ Number format: Float32
├ Spectral: T31 LowerTriangularMatrix, 560 harmonics
├ Grid: 48-ring OctahedralGaussianGrid, 3168 grid points
├ Resolution: 3.61°, 401km (at 6371km radius)
├ Particles: 3
├ Vertical: 8-layer atmosphere
└ Architecture: CPU using ArrayThen the particles live as AbstractVector{Particle} inside the prognostic variables
model = BarotropicModel(spectral_grid)
simulation = initialize!(model)
simulation.prognostic_variables.particles3-element Vector{Particle{Float32}}:
Particle{Float32}( active, 0.00˚E, 0.00˚N, σ = 0.00)
Particle{Float32}( active, 0.00˚E, 0.00˚N, σ = 0.00)
Particle{Float32}( active, 0.00˚E, 0.00˚N, σ = 0.00)Which are placed in random locations (using rand) initially. In order to change these (e.g. to set the initial conditions) you do
simulation.prognostic_variables.particles[1] = Particle(lon=-120, lat=45)
simulation.prognostic_variables.particles3-element Vector{Particle{Float32}}:
Particle{Float32}( active, -120.00˚E, 45.00˚N, σ = 0.00)
Particle{Float32}( active, 0.00˚E, 0.00˚N, σ = 0.00)
Particle{Float32}( active, 0.00˚E, 0.00˚N, σ = 0.00)which sets the first particle (you can think of the index as the particle identification) to some specified location, or you could deactivate a particle with
first_particle = simulation.prognostic_variables.particles[1]
simulation.prognostic_variables.particles[1] = deactivate(first_particle)
simulation.prognostic_variables.particles3-element Vector{Particle{Float32}}:
Particle{Float32}(inactive, -120.00˚E, 45.00˚N, σ = 0.00)
Particle{Float32}( active, 0.00˚E, 0.00˚N, σ = 0.00)
Particle{Float32}( active, 0.00˚E, 0.00˚N, σ = 0.00)To actually advect these particles inside a SpeedyWeather simulation we have to create a ParticalAdvection2D instance that lets you control the time step used for particle advection and which vertical layer to use in the 3D models.
particle_advection = ParticleAdvection2D(spectral_grid, layer = 1)ParticleAdvection2D{Float32, GridGeometry{OctahedralGaussianGrid{CPU{KernelAbstractions.CPU}, Vector{UnitRange{Int64}}, Vector{Int64}}, Vector{Float32}, Vector{Int64}, Vector{UnitRange{Int64}}}} <: SpeedyWeather.AbstractParticleAdvection
├ every_n_timesteps::Int64 = 6
├ layer::Int64 = 1
├ Δt::Base.RefValue{Float32} = Base.RefValue{Float32}(0.0f0)
└ geometry::GridGeometry{OctahedralGaussianGrid{CPU{KernelAbstractions.CPU}, Vector{UnitRange{Int64}}, Vector{Int64}}, Vector{Float32}, Vector{Int64}, Vector{UnitRange{Int64}}} = GridGeometry for 48-ring OctahedralGaussianGrid
├ nlat_half=24 (3168 points, ~3.6˚, reduced)
└ architecture: CPUwe choose the first (=top-most) layer although this is the default anyway. Now we can advect our three particles we have defined above
model = BarotropicModel(spectral_grid; particle_advection)
simulation = initialize!(model)
simulation.prognostic_variables.particles3-element Vector{Particle{Float32}}:
Particle{Float32}( active, 130.19˚E, 30.76˚N, σ = 0.38)
Particle{Float32}( active, 208.81˚E, 21.72˚N, σ = 0.49)
Particle{Float32}( active, 288.04˚E, -37.27˚N, σ = 0.07)Which are the initial conditions for our three particles. After 10 days of simulation they have changed
run!(simulation, period=Day(10))
simulation.prognostic_variables.particles3-element Vector{Particle{Float32}}:
Particle{Float32}( active, 17.44˚E, 50.08˚N, σ = 0.06)
Particle{Float32}( active, 350.28˚E, 15.65˚N, σ = 0.06)
Particle{Float32}( active, 290.84˚E, -46.78˚N, σ = 0.06)Woohoo! We just advected some particles. This is probably not as exciting as actually tracking the particles over the globe and being able to visualise their trajectory which we will do in the next section
Tracking particles
A ParticleTracker is implemented as a callback, see Callbacks, outputting the particle locations via netCDF. We can create it like
using SpeedyWeather
spectral_grid = SpectralGrid(nparticles = 100, nlayers=1)
particle_tracker = ParticleTracker(spectral_grid, schedule=Schedule(every=Hour(3)))ParticleTracker{Float32} <: AbstractCallback
├ schedule::Schedule = Schedule <: SpeedyWeather.AbstractSchedule
├ every::Second = 10800 seconds
├ steps::Int64 = 0
├ counter::Int64 = 0
└── arrays: times, schedule
├ filename::String = particles.nc
├ path::String =
├ compression_level::Int64 = 1
├ shuffle::Bool = false
├ keepbits::Int64 = 15
├ nparticles::Int64 = 100
├ netcdf_file::Nothing = nothing
└── arrays: lon, lat, σwhich would output every 3 hours (the default). This output frequency might be slightly adjusted depending on the time step of the dynamics to output every n time steps (an @info is thrown if that is the case), see Schedules. Further options on compression are available as keyword arguments ParticleTracker(spectral_grid, keepbits=15) for example. The callback is then added after the model is created
particle_advection = ParticleAdvection2D(spectral_grid)
model = ShallowWaterModel(spectral_grid; particle_advection)
add!(model.callbacks, particle_tracker)[ Info: ParticleTracker{Float32} callback added with key callback_xNqMwhich will give it a random key too in case you need to remove it again (more on this in Callbacks). If you now run the simulation the particle tracker is called on particle_tracker.every_n_timesteps and it continuously writes into particle_tracker.netcdf_file which is placed in the run folder similar to other NetCDF output. For example, the run folder can be obtained after the simulation by model.output.run_folder.
simulation = initialize!(model)
run!(simulation, period=Day(10))
model.output.run_folder""so that you can read the netCDF file with
using NCDatasets
run_folder = model.output.run_folder # normally the run_???? string with run number
path = joinpath(run_folder, particle_tracker.filename) # by default "run_????/particles.nc"
ds = NCDataset(path)
ds["lon"]
ds["lat"]lat (100 × 91)
Datatype: Float32 (Float32)
Dimensions: particle × time
Attributes:
units = degrees_east
long_name = latitudewhere the last two lines are lazy loading a matrix with each row a particle and each column a time step. You may do ds["lon"][:,:] to obtain the full Matrix. We had specified spectral_grid.nparticles above and we will have time steps in this file depending on the period the simulation ran for and the particle_tracker.Δt output frequency. We can visualise the particles' trajectories with
lon = ds["lon"][:,:]
lat = ds["lat"][:,:]
nparticles = size(lon,1)
using CairoMakie
fig = lines(lon[1, :], lat[1, :]) # first particle only
[lines!(fig.axis, lon[i,:], lat[i,:]) for i in 2:nparticles] # add lines for other particles
# display updated figure
fig
Instead of providing a polished example with a nice projection we decided to keep it simple here because this is probably how you will first look at your data too. As you can see, some particles in the Northern Hemisphere have been advected with a zonal jet and perform some wavy motions as the jet does too. However, there are also some horizontal lines which are automatically plotted when a particles travels across the prime meridian 0˚E = 360˚E. Ideally you would want to use a more advanced projection and plot the particle trajectories as geodetics.
With GeoMakie.jl you can do
using GeoMakie, CairoMakie
fig = Figure()
ga = GeoAxis(fig[1, 1]; dest = "+proj=ortho +lon_0=45 +lat_0=45")
[lines!(ga, lon[i,:], lat[i,:]) for i in 1:nparticles]
fig