Best practices for modeling systems that evolve over time — mutability, patterns, and performance?

What you can´t (for performance reasons) is to use abstract types for the fields like this. To keep generality you have to parameterize them, i. e.

mutable struct Spacecraft{S<:AbstractSpace} <: AbstractPoint
    state::S
    ...

With that an instance of a space craft will be concrete, but the definition of the type is generic enough to support different types of spaces.

2 Likes

Ah, I see! This saved me a lot of time tinkering. Thank you!

I need to handle defaults flexibly, and not require setting defaults on an struct that will encapsulate a lot of complexity. I think a Parameterized struct, with keyword arg out constructor solves these problems. Am I missing anything subtle here to be warned of:

struct Spacecraft{S<:AbstractState, T<:Time}
state::S
time::T
end

Defaults

default_state() = CartesianState([7000.0, 0.0, 0.0, 0.0, 7.5, 0.0])
default_time() = Time(“2015-09-21T12:23:12”, :tai, :isot)

Keyword-based outer constructor

function Spacecraft(;
state::AbstractState = default_state(),
time::Time = default_time()
)
Spacecraft(state, time)
end

all defaults

sc1 = Spacecraft()

no defaults

sc4 = Spacecraft(
time = Time(“2025-09-21T12:23:12”, :tt, :isot),
state = CartesianState([7100.0, 0.0, 500.0, 0.0, 7.6, 0.0]),
)

Hello Orestis, I was watching a few videos from last summer’s session at JuliaCon 2024 a few days ago. Do you know if a similar session is planned for 2025?

On the topic of units, for some purposes DynamicQuantities.jl may also be worth considering. The short version is that where Unitful puts all of the units in the type domain, so they are handled at compile time, DynamicQuantities (unless otherwise specified) puts everything into base SI marks and does any unit handling at runtime. The upside of this is that you can have a concretely-typed array of quantities with disparate units, which isn’t really possible for Unitful.
Both still have a little friction with SciML solvers, last I checked, but DynamicQuantities is a more natural fit I believe because of the array typing.

1 Like

Another aspect of this topic I’m not seeing mentioned are packages like Accessors.jl which “make the immutable mutable”, or in other words provide easy ways to create a copy of an arbitrary (nested) struct with a particular value (or values) changed. I use this quite heavily, and if you need to take it a step further and programmatically change some subset of fields then setproperties from ConstructionsBase.jl will help.

2 Likes

That seems ok. Just to add that in Julia you have @kwdef to help you with that:

julia> @kwdef struct A{T1,T2}
           x::T1 = 1
           y::T2 = 1.0
       end

julia> A()
A{Int64, Float64}(1, 1.0)

julia> A(;x=1, y=1)
A{Int64, Int64}(1, 1)

julia> A(;x=1.0, y=1.0)
A{Float64, Float64}(1.0, 1.0)


1 Like

I have a question related to this, so I am posting to this topic again in the hopes I can get some guidance before making some design changes that would permeate what I am working on.

As I have continued to develop, I am addressing more complex use cases, and, I need to be able to change the type on a struct’s field 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. And, reconstructing the struct causes me problems because it breaks references to the struct used in the sim elsewhere.

The solution I am considering is to use concrete “wrapper structs” and parameterize the state and time 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.

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.

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

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, would changer other types like state/coords too, keeping it simple for example
sc.time = TDB(4.5)  

# Propagate to Moon happens here