Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ Manifest.toml
.vscode/

# End of https://www.toptal.com/developers/gitignore/api/julia

.ipynb_checkpoints
4 changes: 3 additions & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "StructuralIdentifiability"
uuid = "220ca800-aa68-49bb-acd8-6037fa93a544"
authors = ["Alexander Demin, Ruiwen Dong, Christian Goodbrake, Heather Harrington, Gleb Pogudin <gleb.pogudin@polytechnique.edu>"]
version = "0.5.17"
authors = ["Alexander Demin, Ruiwen Dong, Christian Goodbrake, Heather Harrington, Gleb Pogudin <gleb.pogudin@polytechnique.edu>"]

[deps]
AbstractAlgebra = "c3fe647b-3220-5bb0-a1ea-a7954cac585d"
Expand All @@ -17,6 +17,7 @@ Nemo = "2edaba10-b0f1-5616-af89-8c11ac63239a"
ParamPunPam = "3e851597-e36f-45a9-af0a-b7781937992f"
PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a"
Primes = "27ebfcd6-29c5-5fa9-bf4b-fb8fc14df3ae"
REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
RationalFunctionFields = "73480bc8-48a2-41cc-880f-208b490ccf65"
TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f"
Expand Down Expand Up @@ -47,6 +48,7 @@ ParamPunPam = "0.5.5"
Pkg = "1.10, 1.11"
PrecompileTools = "1.2"
Primes = "0.5"
REPL = "1.10, 1.11"
Random = "1.10, 1.11"
RationalFunctionFields = "0.2.2"
SpecialFunctions = "2"
Expand Down
3 changes: 2 additions & 1 deletion src/StructuralIdentifiability.jl
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ using MacroTools
using Primes
using Random
using TimerOutputs
using REPL, REPL.TerminalMenus

# Algebra packages
using AbstractAlgebra
Expand Down Expand Up @@ -60,7 +61,7 @@ export PBRepresentation, diffreduce, io_switch!, pseudodivision
export find_submodels

# finding identifiabile reparametrizations
export reparametrize_global
export reparametrize_global, reparametrize_interactive

ExtendedFraction{P} = Union{P, Generic.FracFieldElem{P}}

Expand Down
5 changes: 5 additions & 0 deletions src/identifiable_functions.jl
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function find_identifiable_functions(
simplify = :standard,
rational_interpolator = :VanDerHoevenLecerf,
loglevel = Logging.Info,
return_all = false,
) where {T <: MPolyRingElem{QQFieldElem}}
restart_logging(loglevel = loglevel)
reset_timings()
Expand All @@ -67,6 +68,7 @@ function find_identifiable_functions(
with_states = with_states,
simplify = simplify,
rational_interpolator = rational_interpolator,
return_all = return_all,
)
else
id_funcs = _find_identifiable_functions_kic(
Expand All @@ -76,6 +78,7 @@ function find_identifiable_functions(
seed = seed,
simplify = simplify,
rational_interpolator = rational_interpolator,
return_all = return_all,
)
# renaming variables from `x(t)` to `x(0)`
return replace_with_ic(ode, id_funcs)
Expand All @@ -90,6 +93,7 @@ function _find_identifiable_functions(
with_states = false,
simplify = :standard,
rational_interpolator = :VanDerHoevenLecerf,
return_all = false,
) where {T <: MPolyRingElem{QQFieldElem}}
Random.seed!(seed)
@assert simplify in (:standard, :weak, :strong, :absent)
Expand Down Expand Up @@ -124,6 +128,7 @@ function _find_identifiable_functions(
simplify = simplify,
rational_interpolator = rational_interpolator,
priority_variables = [parent_ring_change(p, bring) for p in ode.parameters],
return_all = return_all,
)
else
id_funcs_fracs = dennums_to_fractions(id_funcs)
Expand Down
8 changes: 6 additions & 2 deletions src/logging.jl
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ const _runtime_logger = Dict(
const _si_logger =
Ref{Logging.ConsoleLogger}(Logging.ConsoleLogger(Logging.Info, show_limited = false))

function restart_logging(; loglevel = Logging.Info)
function restart_logging(; loglevel = Logging.Info, stream = nothing)
@assert loglevel isa Base.CoreLogging.LogLevel
_si_logger[] = Logging.ConsoleLogger(loglevel, show_limited = false)
if stream !== nothing
_si_logger[] = Logging.ConsoleLogger(stream, loglevel, show_limited = false)
else
_si_logger[] = Logging.ConsoleLogger(loglevel, show_limited = false)
end
for r in _runtime_rubrics
_runtime_logger[r] = 0
end
Expand Down
239 changes: 231 additions & 8 deletions src/parametrizations.jl
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ function vector_field_along(derivation::Dict{T, U}, directions::AbstractVector)
return new_vector_field
end

function default_variable_names(new_states, new_params)
(
states = map(i -> "X$i", 1:length(new_states)),
params = map(i -> "a$i", 1:length(new_params)),
)
end

"""
reparametrize_with_respect_to(ode, new_states, new_params)

Expand All @@ -27,7 +34,12 @@ Reparametrizes the `ode` using the given fractional states and parameters.
- `new_states`: a vector of new states as fractions in `parent(ode)`.
- `new_params`: a vector of new parameters as fractions in `parent(ode)`.
"""
function reparametrize_with_respect_to(ode, new_states, new_params)
function reparametrize_with_respect_to(
ode::ODE{P},
new_states,
new_params;
new_variable_names = default_variable_names(new_states, new_params),
) where {P}
@assert length(new_states) > 0
poly_ring = base_ring(parent(first(new_states)))
# Compute the new dynamics in terms of the original variables.
Expand All @@ -52,7 +64,7 @@ function reparametrize_with_respect_to(ode, new_states, new_params)
gen_tag_names(length(ode.u_vars), "Input"),
gen_tag_names(length(ode.y_vars), "Output"),
)
@info """
@debug """
Tag names:
$tag_names
Generating functions:
Expand Down Expand Up @@ -81,7 +93,7 @@ function reparametrize_with_respect_to(ode, new_states, new_params)
if !isempty(ode.u_vars)
new_inputs = tag_inputs
end
@info """
@debug """
New state dynamics:
$new_dynamics_states
New output dynamics:
Expand All @@ -94,14 +106,14 @@ function reparametrize_with_respect_to(ode, new_states, new_params)
state = tags[i]
new_vars_vector_field[state] = new_dynamics_states[i]
end
@info "Converting variable names to human-readable ones"
internal_variable_names = map(i -> "X$i(t)", 1:length(new_states))
parameter_variable_names = map(i -> "a$i", 1:length(new_params))
@debug "Converting variable names to human-readable ones"
@assert length(new_variable_names.states) == length(new_states)
@assert length(new_variable_names.params) == length(new_params)
input_variable_names = map(i -> "u$i(t)", 1:length(tag_inputs))
output_variable_names = map(i -> "y$i(t)", 1:length(tag_outputs))
all_variable_names = vcat(
internal_variable_names,
parameter_variable_names,
new_variable_names.states,
new_variable_names.params,
input_variable_names,
output_variable_names,
)
Expand Down Expand Up @@ -237,3 +249,214 @@ function _reparametrize_global(ode::ODE{P}; prob_threshold = 0.99, seed = 42) wh
new_ode = ODE{P}(new_vector_field, new_outputs, new_inputs)
return (new_ode = new_ode, new_vars = new_vars, implicit_relations = implicit_relations)
end

function reparametrize_interactive(
ode::ODE{P};
prob_threshold = 0.99,
seed = 42,
loglevel = Logging.Info,
input::IO = stdin,
output::IO = stdout,
) where {P}
restart_logging(loglevel = loglevel, stream = output)
with_logger(_si_logger[]) do
return _reparametrize_interactive(ode, prob_threshold, seed, input, output)
end
end

function _reparametrize_interactive(
ode::ODE{P},
prob_threshold,
seed,
input,
output,
) where {P}
Random.seed!(seed)
id_funcs = find_identifiable_functions(
ode,
with_states = true,
simplify = :strong,
prob_threshold = prob_threshold,
return_all = true,
loglevel = Logging.Warn,
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

Inconsistent indentation: line 281 uses tabs while the rest of the file uses spaces. This should be changed to spaces for consistency.

Suggested change
loglevel = Logging.Warn,
loglevel = Logging.Warn,

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

Inconsistent indentation: line 288 uses tabs while the rest of the file uses spaces. This should be changed to spaces for consistency.

Copilot uses AI. Check for mistakes.
)
id_funcs_simple = find_identifiable_functions(
ode,
with_states = true,
simplify = :strong,
prob_threshold = prob_threshold,
loglevel = Logging.Warn,
)
state = Dict(
:counter => 0,
:id_funcs => id_funcs,
:chosen_funcs => (states = empty(id_funcs), params = empty(id_funcs)),
:variable_names => (states = Vector{String}(), params = Vector{String}()),
)
contains_states(poly::MPolyRingElem) = any(x -> degree(poly, x) > 0, ode.x_vars)
contains_states(func) =
contains_states(numerator(func)) || contains_states(denominator(func))
function print_header(state)
println(output, "\n$(state[:counter]). Info.")
println(output, " Original states: $(join(string.(ode.x_vars), ", "))")
println(output, " Original parameters: $(join(string.(ode.parameters), ", "))")
println(output, " Identifiable functions: $(join(string.(id_funcs_simple), ", "))")
end
function print_state(state)
counter, chosen_funcs, variable_names =
state[:counter], state[:chosen_funcs], state[:variable_names]
if isempty(chosen_funcs.states) && isempty(chosen_funcs.params)
return
end
println(output, "\n$counter. Current selection:")
for (names, funcs) in [
(variable_names.states, chosen_funcs.states),
(variable_names.params, chosen_funcs.params),
]
for (name, func) in zip(names, funcs)
println(output, " ", name, " := ", func)
end
end
end
function make_choice(state)
counter, id_funcs = state[:counter], state[:id_funcs]
terminal =
REPL.TerminalMenus.default_terminal(in = input, out = output, err = output)
menu = MultiSelectMenu(vcat("Enter a custom function", map(string, id_funcs)))
choice = request(
terminal,
"\n$counter. Select identifiable function(s) for reparametrization.",
menu,
)
if 1 in choice # a custom function
funcs = empty(id_funcs)
while true
varnames = map(
f -> chopsuffix(f, "(t)"),
string.(vcat(ode.x_vars, ode.parameters)),
)
res = Base.prompt(
input,
output,
"\n$counter. Enter a rational function in the variables: $(join(varnames, ", "))\n",
)
func = nothing
try
func = myeval(
Meta.parse(res),
Dict(Symbol.(varnames) .=> vcat(ode.x_vars, ode.parameters)),
)
catch e
@info "" e
printstyled(
output,
"\n ==> Error when parsing $res. Trying again..\n",
bold = true,
)
continue
end
ffring = fraction_field(parent(ode))
funcs = [ffring(func)]
if all(
field_contains(
RationalFunctionField(id_funcs_simple),
funcs,
prob_threshold,
),
)
break
else
printstyled(
output,
"\n ==> The given function $(funcs[1]) is not identifiable. Trying again..\n",
bold = true,
)
continue
end
end
else
funcs = id_funcs[sort(collect(choice)) .- 1]
end
funcs
end
function query_names(state, funcs)
counter, chosen_funcs, variable_names =
state[:counter], state[:chosen_funcs], state[:variable_names]
new_states = filter(contains_states, funcs)
new_params = setdiff(funcs, new_states)
idx_states, idx_params = length(chosen_funcs.states), length(chosen_funcs.params)
append!(chosen_funcs.states, new_states)
append!(chosen_funcs.params, new_params)
default_names = default_variable_names(chosen_funcs.states, chosen_funcs.params)
for (kind, vars, defaults, new_vars, idx) in [
("state", new_states, default_names.states, variable_names.states, idx_states),
(
"parameter",
new_params,
default_names.params,
variable_names.params,
idx_params,
),
]
for (i, var) in enumerate(vars)
default = defaults[idx + i]
res = Base.prompt(
input,
output,
"\n$counter. Enter a name for the new $kind: $var. Leave empty for default: $default.\n",
)
if isnothing(res) || (res isa String && isempty(strip(res)))
res = default
end
push!(new_vars, res)
printstyled(output, " ==> New variable: $res := $var\n", bold = true)
end
end
end
function try_to_reparametrize(state)
counter, chosen_funcs, variable_names, id_funcs =
state[:counter], state[:chosen_funcs], state[:variable_names], state[:id_funcs]
rff = RationalFunctionField(vcat(chosen_funcs.states, chosen_funcs.params))
state[:id_funcs] = id_funcs[.! field_contains(rff, id_funcs, prob_threshold)]
if isempty(chosen_funcs.states)
printstyled(
output,
"\n ==> Please select at least one new state in order to reparametrize.\n",
bold = true,
)
return nothing
end
try
new_vector_field, new_inputs, new_outputs, new_vars, implicit_relations =
reparametrize_with_respect_to(
ode,
chosen_funcs.states,
chosen_funcs.params,
new_variable_names = variable_names,
)
new_ode = ODE{P}(new_vector_field, new_outputs, new_inputs)
return (
new_ode = new_ode,
new_vars = new_vars,
implicit_relations = implicit_relations,
)
catch e
printstyled(
output,
"\n ==> Selected functions is not enough to reparametrize. Please select more.\n",
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

Grammatical error in error message: 'Selected functions is' should be 'Selected functions are' (plural subject requires plural verb).

Suggested change
"\n ==> Selected functions is not enough to reparametrize. Please select more.\n",
"\n ==> Selected functions are not enough to reparametrize. Please select more.\n",

Copilot uses AI. Check for mistakes.
bold = true,
)
end
return nothing
end

print_header(state)
while true
state[:counter] += 1
print_state(state)
funcs = make_choice(state)
query_names(state, funcs)
result = try_to_reparametrize(state)
!(result == nothing) && return result
end
end
Loading
Loading