Handing Pairwise Interactions

Handing Pairwise Interactions

We want users to be able to define their own vortex types, as well as arbitrarily group and nest different vortex elements together. For example, suppose the user has defined a new element, MyVortexType, then they should be able to do something like

myvortices = MyVortexType.(...)
points = Vortex.Point.(...)
sheet = Vortex.Sheet(...)

system = (myvortices, (points, sheet))
velocities = allocate_velocity(system)

self_induce_velocity!(velocities, system)

But how would myvortices know how to induce velocities on points, sheet, or the tuple (points, sheet)? It would be asking a lot for the user to have to define all possible pairwise interactions between their new vortex type and all other built-in types. Instead, the user should only have to define induce_velocity between MyVortexType and a complex point, leaving library to simply just apply induce_velocity recursively to all ther targets. But how would the library know if a vortex element is actually a collection of more primitive types that should be recursed over? For example, while it is obvious that Vector{Vortex.Point} should be treated as a collection of point vortices, it has no prior knowledge of how MyVortexType is defined. It might be something like Vortex.Blob, which cannot recursed into, or it can be more like Vortex.Sheet, which is just a wrapper around Vector{Vortex.Blob}. The following section describes how the library handles this problem using Tim Holy's trait trick.

Traits: Singleton vs. Group

Let's trace through how the library currently handles a call like

induce_velocity(target::V1, source::V2)

If the user has explicitly defined induce_velocity between the vortex types V1 and V2, then Julia will call that method. Otherwise, this call will be turned into

induce_velocity(unwrap_targ(target), unwrap_src(source),
                kind(unwrap_targ(target)), kind(unwrap_src(source)))

There are two different things going on here:

There are four possible (target,source) trait combinations:

induce_velocity(uw_target, uw_source, ::Type{Singleton}, ::Type{Singleton})
induce_velocity(uw_target, uw_source, ::Type{Group}, ::Type{Singleton})
induce_velocity(uw_target, uw_source, ::Type{Singleton}, ::Type{Group})
induce_velocity(uw_target, uw_source, ::Type{Group}, ::Type{Group})

but we only have to handle three cases:

Ultimately, this whole setup is just a way to allow induce_velocity to be called recursively on arbitrary groupings of vortex elements. However, velocity is not the only property that can be computed this way. Other quantities, such as acceleration, circulation, etcs., can all be be computed using the same framework. Instead of writing essentially the same code for all of them, we can use the @property macro

The @property macro

All the induce_velocity methods listed above (and their in-place version, induce_velocity!) can be generated with

@property begin
    signature = induce_velocity(targ::Target, src::Source)
    preallocator = allocate_velocity
    stype = ComplexF64
end

where

Note

You can see the actual functions generated with

julia> import VortexModel.Vortex: @property

julia> @macroexpand(@property begin
           signature = induce_velocity(targ::Target, src::Source)
           preallocator = allocate_velocity
           stype = ComplexF64
       end)

Suppose we want a function to also get the acceleration of the vortex elements. To find the acceleration, we need to know the current velocity, so we can have something like

@property begin
    signature = induce_acceleration(targ::Target, targvel::Target, src::Source, srcvel::Source)
    preallocator = allocate_acceleration
    stype = ComplexF64
end

By annotating targvel as a Target, we are saying that whenever you iterate through targ, we should pass in the corresponding element in targvel, and likewise for srcvel. Arguments that are not annotated will be treated as parameters to be passed in at each iteration without indexing.

There are also properties that do not require a target, but are properties of the source itself. For example, we have

@property begin
    signature = circulation(src::Source)
    stype = Float64
    reduce = (+)
end

The reduce operation means that it is a property that can be aggregate over a collection of vortex elements. In this particular case, it means that the circulation of a group of vortex elements is just the sum of the circulation of each element. Another example of this can be seen in the definition of rate_of_impulse.

Defining a new property

We'll go through an example of how to define new properties using the @property marco. Suppose we want to check if a system of elements have branch cuts in their streamfunction, we can simply define the following:

import PotentialFlow.Properties: @property

@property begin
	signature = continuous_streamfunction(src::Source)
	stype = Bool
	reduce = (&, true)
end

continuous_streamfunction(::Vortex.Point) = true
continuous_streamfunction(::Vortex.Blob) = true
continuous_streamfunction(::Source.Point) = false
continuous_streamfunction(::Source.Blob) = false

vortices = (Vortex.Point.(rand(10), rand(10)), 
	        Vortex.Blob.(rand(10), rand(10), rand())
		   )

sources = (Source.Point.(rand(10), rand(10)),
           Source.Blob.(rand(10), rand(10), rand())
		  )

mixed = (vortices, sources)

continuous_streamfunction.((vortices, sources, mixed))

# output

(true, false, false)

Here, the reduce operation is a tuple that takes in a binary operation and an initial value. When continuous_streamfunction is called on a group source, such as an array of elements, it will recursively call continuous_streamfunction on each member of the group, and use & to combine the results. Without the true initial value, the @property macro will use zero(stype), which in this case, would have been false. If we did not want the values to be aggregated, but instead wanted to preserve the organization structure of our source elements, we can simply leave out the reduce field. For instance, if we wanted to know whether the element is a desingularized element or not, it does not make sense to reduce the results.

import PotentialFlow.Properties: @property
import PotentialFlow: Points, Blobs

@property begin
	signature = is_desingularized(src::Source)
	stype = Bool
end

is_desingularized(::Points.Point) = false
is_desingularized(::Blobs.Blob) = true

vortices = (Vortex.Point.(rand(2), rand(2)), 
	        Vortex.Blob.(rand(2), rand(2), rand())
		   )

sources = (Source.Point.(rand(2), rand(2)),
           Source.Blob.(rand(2), rand(2), rand())
		  )

is_desingularized.((vortices, sources))

# output

((Bool[false, false], Bool[true, true]), (Bool[false, false], Bool[true, true]))