Changing struct fields to a different sub-type during run time

Hello everyone, I am working on an aerospace system and as I am developing more complex use cases, I need to change fields on structs to a new type during a simulation. For example, when a spacecraft leaves the Earth system, we use a different time system to model time. We also use different coordinate systems and state representations as a mission evolves. So, immutable concrete types in fields are problematic. I don’t need to do this often (relatively speaking to say an integration step size), but it does need to happen at certain points in a simulation. Reconstructing the whole spacecraft struct causes me problems because it breaks references to the struct used in the sim elsewhere.

I know using abstract types here causes major performance problems so I have avoided that. The solution I am considering is to use concrete “wrapper structs” and parameterize the state and time type inside the wrappers for performance and type flexibility. If I need to change the state or time type, I reconstruct the wrapper which is concrete and since the wrapper internals are parameterized, it’s fields are also concrete.

To hide this from users (who are aerospace engineers, not Julia experts), I would overload get/set property so users only work with the concrete time and state types and the nested structure pattern is hidden behind the API.

Here is working code. Is there a better way to do this? Feedback, thoughts?

Thanks in advance for any help here!!

# Abstract interfaces
abstract type AbstractState end
abstract type AbstractTime end

# Concrete types (there would be many more, these are examples)
struct CartesianState <: AbstractState
    rv::Vector
end

struct UTC <: AbstractTime
    seconds::Float64
end

struct TDB <: AbstractTime
    seconds::Float64
end

# Parametric wrappers for time and state
struct OrbitState{T<:AbstractState}
    state::T
end

struct SpacecraftTime{T<:AbstractTime}
    time::T
end

# Spacecraft stuct
mutable struct Spacecraft
    state::OrbitState
    time::SpacecraftTime
end

# Outer constructor
function Spacecraft(state::AbstractState, time::AbstractTime)
    Spacecraft(OrbitState(state), SpacecraftTime(time))
end

# Override get/set property for Spacecraft so wrappers and type management are hidden
function Base.getproperty(s::Spacecraft, sym::Symbol)
    if sym === :state
        return getfield(s, :state).state
    elseif sym === :time
        return getfield(s, :time).time
    end
end

function Base.setproperty!(s::Spacecraft, sym::Symbol, val)
    if sym === :time && val isa AbstractTime
        return setfield!(s, :time, SpacecraftTime(val))
    elseif sym === :state && val isa AbstractState
        return setfield!(s, :state, OrbitState(val))
    else
        return setfield!(s, sym, val)
    end
end

# EXAMPLE USAGE HERE

sc = Spacecraft(CartesianState([10.0e6,0.0,0,0,0,0,7.8,15.0,0]), UTC(123.456))

# Propagate to Earth Sphere of influence happens here

# Change time to TDB, this would happen in the internals not at user level, just showing what needs to happen
sc.time = TDB(4.5)  # Time was UTC type, now it is TDB type

# Propagate to Moon happens here

These fields are still declared with abstract types, and that’s what the compiler sees given the concrete Spacecraft. For example, the compiler can only see getfield(s, :state)::OrbitState in getproperty(s::Spacecraft, :state), so it can infer the return value as ::AbstractState at best, not the likely concrete state::T. Note that @code_warntype sc.state would give you a more pessimistic report that doesn’t consider how the literal :state is specially propagated for getproperty to narrow down the branch. At the moment, it’s not clear how this layer of indirection has any advantage over:

mutable struct SpacecraftDirect
    state::AbstractState
    time::AbstractTime
end

getproperty and setproperty returns instances, which will always have concrete types at runtime i.e. neither typeof(sc.state) nor typeof(sc_direct.state) would ever be abstract. The abstract declared types OrbitState or AbstractState are only exposed to the users if they directly reference them or use reflection functions like fieldtypes(Spacecraft) or fieldtype(SpacecraftDirect, :state).

Without any indication of how these instances are passed into and used in methods, it’s impossible to tell where this compromises performance and how you may limit that. I’d also suggest you vastly change the tags of this question if it will only concern the base type system; there would be no reason to specially categorize this topic for aerospace simulations or imply readers need any such experience to interact.

As Benny remarks, there are still type instability due to abstract fields. I think you are better off with either LightSumTypes.jl or SumTypes.jl. It will look something like this. (I have very limited experience with these packages, but I think this is what you need).

using LightSumTypes: @sumtype

struct UTC
    seconds::Float64
end

struct TDB
    seconds::Float64
end

@sumtype TimeType(UTC, TDB)

mutable struct Spacecraft
    time::TimeType
end

It’s type stable:

function doubletime(sc::Spacecraft)
    2sc.time.seconds
end

julia> sc = Spacecraft(TimeType(UTC(42.17)))
Spacecraft(TimeType(UTC(42.17)))

julia> @code_warntype doubletime(sc)
MethodInstance for doubletime(::Spacecraft)
  from doubletime(sc::Spacecraft) @ Main ~/scratch/foo.jl:17
Arguments
  #self#::Core.Const(Main.doubletime)
  sc::Spacecraft
Body::Float64
1 ─ %1 = Main.:*::Core.Const(*)
│   %2 = Base.getproperty(sc, :time)::TimeType
│   %3 = Base.getproperty(%2, :seconds)::Float64
│   %4 = (%1)(2, %3)::Float64
└──      return %4


julia> sc.time = TimeType(TDB(12.73))
TimeType(TDB(12.73))

julia> @code_warntype doubletime(sc)
MethodInstance for doubletime(::Spacecraft)
  from doubletime(sc::Spacecraft) @ Main ~/scratch/foo.jl:17
Arguments
  #self#::Core.Const(Main.doubletime)
  sc::Spacecraft
Body::Float64
1 ─ %1 = Main.:*::Core.Const(*)
│   %2 = Base.getproperty(sc, :time)::TimeType
│   %3 = Base.getproperty(%2, :seconds)::Float64
│   %4 = (%1)(2, %3)::Float64
└──      return %4

Are you sure that scales to more types? 2sc.time.seconds doesn’t look like an expression that restores type stability. It might just be leveraging Union-splitting for small sets.

julia> mutable struct Spacecraft2
           time::Union{UTC,TDB}
       end

julia> function doubletime(sc::Spacecraft2)
           2sc.time.seconds
       end
doubletime (generic function with 2 methods)

julia> @code_warntype doubletime(Spacecraft2(UTC(42.17)))
MethodInstance for doubletime(::Spacecraft2)
  from doubletime(sc::Spacecraft2) @ Main REPL[8]:1
Arguments
  #self#::Core.Const(Main.doubletime)
  sc::Spacecraft2
Body::Float64
1 ─ %1 = Main.:*::Core.Const(*)
│   %2 = Base.getproperty(sc, :time)::Union{TDB, UTC}
│   %3 = Base.getproperty(%2, :seconds)::Float64
│   %4 = (%1)(2, %3)::Float64
└──      return %4