Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions docs/src/basics/InputOutput.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,77 @@ See [Symbolic Metadata](@ref symbolic_metadata). Metadata specified when creatin

See [Linearization](@ref linearization).

## Real-time Input Handling During Simulation

ModelingToolkit supports setting input values during simulation for variables marked with the `[input=true]` metadata. This is useful for real-time simulations, hardware-in-the-loop testing, interactive simulations, or any scenario where input values need to be determined during integration rather than specified beforehand.

To use this functionality, variables must be marked as inputs using the `[input=true]` metadata and specified in the `inputs` keyword argument of `@mtkcompile`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

variables must be marked as inputs using the [input=true] metadata

is this actually required? The inputs are specified

in the inputs keyword argument of @mtkcompile

anyway. No other input-using functionality, like linearization or generation of input-dependent functions, rely on the input metadata


There are two approaches to handling inputs during simulation:

### Determinate Form: Using `Input` Objects

When all input values are known beforehand, you can use the [`Input`](@ref) type to specify input values at specific time points. The solver will automatically apply these values using discrete callbacks.

```@example inputs
using ModelingToolkit
using ModelingToolkit: t_nounits as t, D_nounits as D
using OrdinaryDiffEq
using Plots

# Define system with an input variable
@variables x(t) [input=true]
@variables y(t) = 0

eqs = [D(y) ~ x]

# Compile with inputs specified
@mtkcompile sys=System(eqs, t, [x, y], []) inputs=[x]

prob = ODEProblem(sys, [], (0, 4))

# Create an Input object with predetermined values
input = Input(sys.x, [1, 2, 3, 4], [0, 1, 2, 3])

# Solve with the input - solver handles callbacks automatically
sol = solve(prob, [input], Tsit5())

plot(sol; idxs = [x, y])
```

Multiple `Input` objects can be passed in a vector to handle multiple input variables simultaneously.

### Indeterminate Form: Manual Input Setting with `set_input!`

When input values need to be computed on-the-fly or depend on external data sources, you can manually set inputs during integration using [`set_input!`](@ref). This approach requires explicit control of the integration loop.

```@example inputs
# Initialize the integrator
integrator = init(prob, Tsit5())

# Manually set inputs and step through time
set_input!(integrator, sys.x, 1.0)
step!(integrator, 1.0, true)

set_input!(integrator, sys.x, 2.0)
step!(integrator, 1.0, true)

set_input!(integrator, sys.x, 3.0)
step!(integrator, 1.0, true)

set_input!(integrator, sys.x, 4.0)
step!(integrator, 1.0, true)

# IMPORTANT: Must call finalize! to save all input callbacks
finalize!(integrator)

plot(sol; idxs = [x, y])
```

!!! warning "Always call `finalize!`"

When using `set_input!`, you must call [`finalize!`](@ref) after integration is complete. This ensures that all discrete callbacks associated with input variables are properly saved in the solution. Without this call, input values may not be correctly recorded when querying the solution.

## Docstrings

```@index
Expand All @@ -96,4 +167,7 @@ Pages = ["InputOutput.md"]
```@docs; canonical=false
ModelingToolkit.generate_control_function
ModelingToolkit.build_explicit_observed_function
ModelingToolkit.Input
ModelingToolkit.set_input!
ModelingToolkit.finalize!
```
2 changes: 2 additions & 0 deletions src/ModelingToolkit.jl
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ include("constants.jl")

include("utils.jl")

export set_input!, finalize!, Input
include("systems/index_cache.jl")
include("systems/parameter_buffer.jl")
include("systems/abstractsystem.jl")
Expand All @@ -175,6 +176,7 @@ include("systems/state_machines.jl")
include("systems/analysis_points.jl")
include("systems/imperative_affect.jl")
include("systems/callbacks.jl")
include("systems/inputs.jl")
include("systems/system.jl")
include("systems/codegen_utils.jl")
include("problems/docs.jl")
Expand Down
3 changes: 2 additions & 1 deletion src/systems/abstractsystem.jl
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,8 @@ const SYS_PROPS = [:eqs
:index_cache
:isscheduled
:costs
:consolidate]
:consolidate
:input_functions]

for prop in SYS_PROPS
fname_get = Symbol(:get_, prop)
Expand Down
201 changes: 201 additions & 0 deletions src/systems/inputs.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
using SymbolicIndexingInterface
using Setfield
using StaticArrays
using OrdinaryDiffEqCore
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not top level

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


"""
Input(var, data::Vector{<:Real}, time::Vector{<:Real})
Create an `Input` object that specifies predetermined input values for a variable at specific time points.
# Arguments
- `var`: The symbolic variable (marked with `[input=true]` metadata) to be used as an input.
- `data`: A vector of real values that the input variable should take at the corresponding time points.
- `time`: A vector of time points at which the input values should be applied. Must be the same length as `data`.
# Description
The `Input` struct is used with the extended `solve` method to provide time-varying inputs to a system
during simulation. When passed to `solve(prob, [input1, input2, ...], alg)`, the solver will automatically
set the input variable to the specified values at the specified times using discrete callbacks.
This provides a "determinate form" of input handling where all input values are known a priori,
as opposed to setting inputs manually during integration with [`set_input!`](@ref).
See also [`set_input!`](@ref), [`finalize!`](@ref)
"""
struct Input
var::Num
data::SVector
time::SVector
Comment on lines +23 to +24
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why specifically SVector? Not only is this not concrete, I don't think it doesn't offer any performance gain given how it is used.

end

function Input(var, data::Vector{<:Real}, time::Vector{<:Real})
n = length(data)
return Input(var, SVector{n}(data), SVector{n}(time))
Comment on lines +28 to +29
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is type-unstable. n isn't inferrable.

end

struct InputFunctions
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do the fields of this struct need to be wrapped in Tuple{...}?

events::Tuple{SymbolicDiscreteCallback}
vars::Tuple{SymbolicUtils.BasicSymbolic{Real}}
Comment on lines +33 to +34
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not concrete

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not fixed. Tuples are only concrete if you also choose a size for them

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fairly minor, but vars is a bit of a misnomer for a field only containing one variable.

setters::Tuple{SymbolicIndexingInterface.ParameterHookWrapper}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SII.ParameterHookWrapper is not public API.

end

function InputFunctions(events::Vector, vars::Vector, setters::Vector)
InputFunctions(Tuple(events), Tuple(vars), Tuple(setters))
end

"""
set_input!(integrator, var, value::Real)
Set the value of an input variable during integration.
# Arguments
- `integrator`: An ODE integrator object (from `init(prob, alg)` or available in callbacks).
- `var`: The symbolic input variable to set (must be marked with `[input=true]` metadata and included in the `inputs` keyword of `@mtkcompile`).
- `value`: The new real-valued input to assign to the variable.
- `input_funs` (optional): The `InputFunctions` object associated with the system. If not provided, it will be retrieved from `integrator.f.sys`.
# Description
This function allows you to manually set input values during integration, providing an "indeterminate form"
of input handling where inputs can be computed on-the-fly. This is useful when input values depend on
runtime conditions, external data sources, or interactive user input.
After setting input values with `set_input!`, you must call [`finalize!`](@ref) at the end of integration
to ensure all discrete callbacks are properly saved.
# Example
```julia
@variables x(t) [input=true]
@variables y(t) = 0
eqs = [D(y) ~ x]
@mtkcompile sys = System(eqs, t, [x, y], []) inputs=[x]
prob = ODEProblem(sys, [], (0, 4))
integrator = init(prob, Tsit5())
# Set input and step forward
set_input!(integrator, sys.x, 1.0)
step!(integrator, 1.0, true)
set_input!(integrator, sys.x, 2.0)
step!(integrator, 1.0, true)
# Must call finalize! at the end
finalize!(integrator)
```
See also [`finalize!`](@ref), [`Input`](@ref)
"""
function set_input!(input_funs::InputFunctions, integrator::OrdinaryDiffEqCore.ODEIntegrator, var, value::Real)
i = findfirst(isequal(var), input_funs.vars)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't quite make sense. Why findfirst for a field that only contains a tuple of one variable? Either the first one matches, or it doesn't. I imagine you wanted InputFunctions to contain a list of variables along with their corresponding callbacks and setters. In such a case, it is better to store a Dict than repeatedly perform findfirst.

setter = input_funs.setters[i]
event = input_funs.events[i]

setter(integrator, value)
save_callback_discretes!(integrator, event)
u_modified!(integrator, true)
return nothing
end
function set_input!(integrator, var, value::Real)
set_input!(get_input_functions(integrator.f.sys), integrator, var, value)
Copy link
Member

@AayushSabharwal AayushSabharwal Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessing sys during a solve is discouraged. The solve should not involve symbolic quantities. This is a lot of unnecessary, type-unstable overhead. Solves should also work without the system - anything possible symbolically should be possible without the symbolic infrastructure.

end

"""
finalize!(integrator)
Finalize all input callbacks at the end of integration.
# Arguments
- `integrator`: An ODE integrator object (from `init(prob, alg)` or available in callbacks).
- `input_funs` (optional): The `InputFunctions` object associated with the system. If not provided, it will be retrieved from `integrator.f.sys`.
# Description
This function must be called after using [`set_input!`](@ref) to manually set input values during integration.
It ensures that all discrete callbacks associated with input variables are properly saved in the solution,
making the input values accessible when querying the solution at specific time points.
Without calling `finalize!`, input values set with `set_input!` may not be correctly recorded in the
final solution object, leading to incorrect results when indexing the solution.
See also [`set_input!`](@ref), [`Input`](@ref)
"""
function finalize!(input_funs::InputFunctions, integrator)
for i in eachindex(input_funs.vars)
save_callback_discretes!(integrator, input_funs.events[i])
end

return nothing
end
finalize!(integrator) = finalize!(get_input_functions(integrator.f.sys), integrator)

function (input_funs::InputFunctions)(integrator, var, value::Real)
set_input!(input_funs, integrator, var, value)
end
(input_funs::InputFunctions)(integrator) = finalize!(input_funs, integrator)

function build_input_functions(sys, inputs)

# Here we ensure the inputs have metadata marking the discrete variables as parameters. In some
# cases the inputs can be fed to this function before they are converted to parameters by mtkcompile.
vars = SymbolicUtils.BasicSymbolic[isparameter(x) ? x : toparam(x)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think build_input_functions should be called as a model transformation after mtkcompile, and verify that the list of inputs it gets are inputs (and part of the list of parameters). This is more robust than trusting that the user passed values around correctly and trusting that there won't be bugs in the future.

for x in unwrap.(inputs)]
setters = []
events = SymbolicDiscreteCallback[]
defaults = get_defaults(sys)
if !isempty(vars)
for x in vars
affect = ImperativeAffect((m, o, c, i)->m, modified = (; x))
sdc = SymbolicDiscreteCallback(Inf, affect)

push!(events, sdc)

# ensure that the ODEProblem does not complain about missing parameter map
if !haskey(defaults, x)
push!(defaults, x => 0.0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mutating the defaults stored in sys. Model transformations should ideally be out of place.

end
end

@set! sys.discrete_events = events
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would overwrite any pre-existing discrete events in the system.

@set! sys.index_cache = ModelingToolkit.IndexCache(sys)
@set! sys.defaults = defaults

setters = [SymbolicIndexingInterface.setsym(sys, x) for x in vars]

@set! sys.input_functions = InputFunctions(events, vars, setters)
end

return sys
end

function DiffEqBase.solve(prob::SciMLBase.AbstractDEProblem, inputs::Vector{Input}, args...; kwargs...)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this violates the contract, and its not DiffEqBase

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

tstops = Float64[]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not guaranteed that the time is Float64.

callbacks = DiscreteCallback[]

# set_input!
for input::Input in inputs
tstops = union(tstops, input.time)
condition = (u, t, integrator) -> any(t .== input.time)
affect! = function (integrator)
@inbounds begin
i = findfirst(integrator.t .== input.time)
set_input!(integrator, input.var, input.data[i])
end
end
push!(callbacks, DiscreteCallback(condition, affect!))

# DiscreteCallback doesn't hit on t==0, workaround...
if input.time[1] == 0
prob.ps[input.var] = input.data[1]
end
end
Comment on lines +171 to +186
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be done here. This should instead be done by registering discontinuities for the interpolation function if it is a discontinuous one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no interpolation here, this is explicitly a discrete input. The point is to not have interpolation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is ConstantInterpolation


# finalize!
t_end = prob.tspan[2]
condition = (u, t, integrator) -> (t == t_end)
affect! = (integrator) -> finalize!(integrator)
push!(callbacks, DiscreteCallback(condition, affect!))
push!(tstops, t_end)

return solve(prob, args...; tstops, callback = CallbackSet(callbacks...), kwargs...)
end
9 changes: 7 additions & 2 deletions src/systems/system.jl
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,11 @@ struct System <: IntermediateDeprecationSystem
The `Schedule` containing additional information about the simplified system.
"""
schedule::Union{Schedule, Nothing}
"""
$INTERNAL_FIELD_WARNING
Functions used to set input variables with `set_input!` and `finalize!` functions
"""
input_functions::Union{InputFunctions, Nothing}

function System(
tag, eqs, noise_eqs, jumps, constraints, costs, consolidate, unknowns, ps,
Expand All @@ -271,7 +276,7 @@ struct System <: IntermediateDeprecationSystem
complete = false, index_cache = nothing, ignored_connections = nothing,
preface = nothing, parent = nothing, initializesystem = nothing,
is_initializesystem = false, is_discrete = false, isscheduled = false,
schedule = nothing; checks::Union{Bool, Int} = true)
schedule = nothing, input_functions = nothing; checks::Union{Bool, Int} = true)
if is_initializesystem && iv !== nothing
throw(ArgumentError("""
Expected initialization system to be time-independent. Found independent
Expand Down Expand Up @@ -310,7 +315,7 @@ struct System <: IntermediateDeprecationSystem
tstops, inputs, outputs, tearing_state, namespacing,
complete, index_cache, ignored_connections,
preface, parent, initializesystem, is_initializesystem, is_discrete,
isscheduled, schedule)
isscheduled, schedule, input_functions)
end
end

Expand Down
5 changes: 5 additions & 0 deletions src/systems/systems.jl
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ function mtkcompile(
@set! newsys.parent = complete(sys; split = false, flatten = false)
end
newsys = complete(newsys; split)

if !isempty(inputs)
newsys = build_input_functions(newsys, inputs)
end

if newsys′ isa Tuple
idxs = [parameter_index(newsys, i) for i in io[1]]
return newsys, idxs
Expand Down
Loading
Loading