Skip to content

Commit 38a0886

Browse files
feat: [AI] add isolate_subsystem
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 49b8d47 commit 38a0886

3 files changed

Lines changed: 517 additions & 0 deletions

File tree

lib/ModelingToolkitBase/test/analysis_points.jl

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,312 @@ if @isdefined(ModelingToolkit)
779779
]
780780
@test isapprox(fr, reference_fr)
781781
end
782+
783+
@testset "isolate_subsystem" begin
784+
@testset "basic plant isolation" begin
785+
@named P = FirstOrder(k = 1, T = 1)
786+
@named C = Blocks.Gain(k = -1)
787+
788+
eqs = [connect(C.output, :u, P.input), connect(P.output, :y, C.input)]
789+
sys = System(eqs, t, systems = [P, C], name = :cl)
790+
791+
isolated, input_vars, output_vars = isolate_subsystem(sys, :u, :y)
792+
793+
@test length(get_systems(isolated)) == 1
794+
@test nameof(only(get_systems(isolated))) == :P
795+
@test isempty(get_eqs(isolated))
796+
@test isequal(only(input_vars), P.input.u)
797+
@test isequal(only(output_vars), P.output.u)
798+
end
799+
800+
@testset "external components are removed" begin
801+
@named P = FirstOrder(k = 1, T = 1)
802+
@named C = Blocks.Gain(k = 1)
803+
@named add = Blocks.Add(k2 = -1)
804+
@named ref = Step()
805+
806+
eqs = [
807+
connect(ref.output, add.input1)
808+
connect(P.output, :y, add.input2)
809+
connect(add.output, C.input)
810+
connect(C.output, :u, P.input)
811+
]
812+
sys = System(eqs, t, systems = [P, C, add, ref], name = :cl)
813+
814+
isolated, input_vars, output_vars = isolate_subsystem(sys, :u, :y)
815+
816+
@test Set(nameof.(get_systems(isolated))) == Set([:P])
817+
@test isempty(get_eqs(isolated))
818+
@test isequal(only(input_vars), P.input.u)
819+
@test isequal(only(output_vars), P.output.u)
820+
end
821+
822+
@testset "internal connections are preserved" begin
823+
@named P1 = FirstOrder(k = 1, T = 1)
824+
@named P2 = FirstOrder(k = 1, T = 1)
825+
@named C = Blocks.Gain(k = -1)
826+
827+
eqs = [
828+
connect(C.output, :u, P1.input)
829+
connect(P1.output, P2.input)
830+
connect(P2.output, :y, C.input)
831+
]
832+
sys = System(eqs, t, systems = [P1, P2, C], name = :cl)
833+
834+
isolated, input_vars, output_vars = isolate_subsystem(sys, :u, :y)
835+
836+
@test Set(nameof.(get_systems(isolated))) == Set([:P1, :P2])
837+
@test length(get_eqs(isolated)) == 1
838+
@test value(only(get_eqs(isolated)).rhs) isa Connection
839+
@test isequal(only(input_vars), P1.input.u)
840+
@test isequal(only(output_vars), P2.output.u)
841+
end
842+
843+
@testset "reachability finds intermediate inside components" begin
844+
@named P1 = FirstOrder(k = 1, T = 1)
845+
@named P_mid = FirstOrder(k = 1, T = 1)
846+
@named P2 = FirstOrder(k = 1, T = 1)
847+
@named C = Blocks.Gain(k = -1)
848+
849+
eqs = [
850+
connect(C.output, :u, P1.input)
851+
connect(P1.output, P_mid.input)
852+
connect(P_mid.output, P2.input)
853+
connect(P2.output, :y, C.input)
854+
]
855+
sys = System(eqs, t, systems = [P1, P_mid, P2, C], name = :cl)
856+
857+
isolated, input_vars, output_vars = isolate_subsystem(sys, :u, :y)
858+
859+
@test Set(nameof.(get_systems(isolated))) == Set([:P1, :P_mid, :P2])
860+
@test length(get_eqs(isolated)) == 2
861+
@test isequal(only(input_vars), P1.input.u)
862+
@test isequal(only(output_vars), P2.output.u)
863+
end
864+
865+
@testset "AnalysisPoint object API" begin
866+
@named P = FirstOrder(k = 1, T = 1)
867+
@named C = Blocks.Gain(k = -1)
868+
869+
eqs = [connect(C.output, :u, P.input), connect(P.output, :y, C.input)]
870+
sys = System(eqs, t, systems = [P, C], name = :cl)
871+
872+
isolated, input_vars, output_vars = isolate_subsystem(sys, sys.u, sys.y)
873+
874+
@test Set(nameof.(get_systems(isolated))) == Set([:P])
875+
@test isequal(only(input_vars), P.input.u)
876+
@test isequal(only(output_vars), P.output.u)
877+
end
878+
879+
@testset "causal variable connectors" begin
880+
@named P = FirstOrder(k = 1, T = 1)
881+
@named C = Blocks.Gain(k = -1)
882+
883+
eqs = [
884+
connect(C.output.u, :u, P.input.u)
885+
connect(P.output.u, :y, C.input.u)
886+
]
887+
sys = System(eqs, t, systems = [P, C], name = :cl)
888+
889+
isolated, input_vars, output_vars = isolate_subsystem(sys, :u, :y)
890+
891+
@test Set(nameof.(get_systems(isolated))) == Set([:P])
892+
@test isempty(get_eqs(isolated))
893+
@test isequal(only(input_vars), P.input.u)
894+
@test isequal(only(output_vars), P.output.u)
895+
end
896+
897+
@testset "vector of symbol API" begin
898+
@named P = FirstOrder(k = 1, T = 1)
899+
@named C = Blocks.Gain(k = -1)
900+
901+
eqs = [connect(C.output, :u, P.input), connect(P.output, :y, C.input)]
902+
sys = System(eqs, t, systems = [P, C], name = :cl)
903+
904+
isolated, input_vars, output_vars = isolate_subsystem(sys, [:u], [:y])
905+
906+
@test Set(nameof.(get_systems(isolated))) == Set([:P])
907+
@test isequal(only(input_vars), P.input.u)
908+
@test isequal(only(output_vars), P.output.u)
909+
end
910+
911+
@testset "nested analysis points" begin
912+
@named P = FirstOrder(k = 1, T = 1)
913+
@named C = Blocks.Gain(k = -1)
914+
915+
# APs live inside `inner`, not at the root level
916+
inner_eqs = [connect(C.output, :u, P.input), connect(P.output, :y, C.input)]
917+
@named inner = System(inner_eqs, t, systems = [P, C])
918+
@named root = System(Equation[], t, systems = [inner])
919+
920+
# Access APs through the nested hierarchy using AnalysisPoint objects
921+
isolated, input_vars, output_vars = isolate_subsystem(
922+
root, root.inner.u, root.inner.y
923+
)
924+
925+
# root is returned; its only direct child is a trimmed inner containing only P
926+
@test Set(nameof.(get_systems(isolated))) == Set([:inner])
927+
inner_isolated = only(get_systems(isolated))
928+
@test Set(nameof.(get_systems(inner_isolated))) == Set([:P])
929+
@test isempty(get_eqs(isolated))
930+
@test isempty(get_eqs(inner_isolated))
931+
@test isequal(only(input_vars), P.input.u)
932+
@test isequal(only(output_vars), P.output.u)
933+
end
934+
935+
@testset "nested analysis points - symbol API" begin
936+
@named P = FirstOrder(k = 1, T = 1)
937+
@named C = Blocks.Gain(k = -1)
938+
939+
inner_eqs = [connect(C.output, :u, P.input), connect(P.output, :y, C.input)]
940+
@named inner = System(inner_eqs, t, systems = [P, C])
941+
@named root = System(Equation[], t, systems = [inner])
942+
943+
# Access APs by their full namespaced symbol
944+
isolated, input_vars, output_vars = isolate_subsystem(
945+
root, nameof(root.inner.u), nameof(root.inner.y)
946+
)
947+
948+
@test Set(nameof.(get_systems(isolated))) == Set([:inner])
949+
inner_isolated = only(get_systems(isolated))
950+
@test Set(nameof.(get_systems(inner_isolated))) == Set([:P])
951+
@test isequal(only(input_vars), P.input.u)
952+
@test isequal(only(output_vars), P.output.u)
953+
end
954+
955+
@testset "nested with external components at outer level" begin
956+
@named P = FirstOrder(k = 1, T = 1)
957+
@named C = Blocks.Gain(k = -1)
958+
@named ref = Step()
959+
960+
# The APs bounding the plant live inside `inner`
961+
inner_eqs = [connect(C.output, :u, P.input), connect(P.output, :y, C.input)]
962+
@named inner = System(inner_eqs, t, systems = [P, C])
963+
964+
# `ref` exists at the outer level — it must not bleed into the isolated result
965+
outer_eqs = [connect(ref.output, inner.C.input)]
966+
@named root = System(outer_eqs, t, systems = [inner, ref])
967+
968+
isolated, input_vars, output_vars = isolate_subsystem(
969+
root, root.inner.u, root.inner.y
970+
)
971+
972+
# root is returned; ref is stripped, inner is trimmed to only P
973+
@test Set(nameof.(get_systems(isolated))) == Set([:inner])
974+
inner_isolated = only(get_systems(isolated))
975+
@test Set(nameof.(get_systems(inner_isolated))) == Set([:P])
976+
@test isempty(get_eqs(isolated))
977+
@test isempty(get_eqs(inner_isolated))
978+
@test isequal(only(input_vars), P.input.u)
979+
@test isequal(only(output_vars), P.output.u)
980+
end
981+
982+
@testset "mixed nesting levels" begin
983+
@named P = FirstOrder(k = 1, T = 1)
984+
@named C = Blocks.Gain(k = -1)
985+
@named A = Step()
986+
987+
# AP :y lives inside `inner`; AP :u lives at root level
988+
inner_eqs = [connect(P.output, :y, C.input)]
989+
@named inner = System(inner_eqs, t, systems = [P, C])
990+
991+
# A drives P.input through AP :u at the root level
992+
outer_eqs = [connect(A.output, :u, inner.P.input)]
993+
@named root = System(outer_eqs, t, systems = [A, inner])
994+
995+
# :u is at root level, inner.y is nested — different nesting levels
996+
isolated, input_vars, output_vars = isolate_subsystem(
997+
root, :u, root.inner.y
998+
)
999+
1000+
# root is returned; A is stripped, inner is trimmed to only P (C removed)
1001+
@test Set(nameof.(get_systems(isolated))) == Set([:inner])
1002+
inner_isolated = only(get_systems(isolated))
1003+
@test Set(nameof.(get_systems(inner_isolated))) == Set([:P])
1004+
@test isempty(get_eqs(isolated))
1005+
@test isempty(get_eqs(inner_isolated))
1006+
# input_var: from root-level AP :u, connector is inner.P.input (root-namespaced)
1007+
@test isequal(only(input_vars), inner.P.input.u)
1008+
# output_var: from inner-level AP :y, connector is P.output (inner-namespaced)
1009+
@test isequal(only(output_vars), P.output.u)
1010+
end
1011+
1012+
@testset "deep nesting — isolate middle two of four" begin
1013+
@named A = Blocks.Gain(k = 1)
1014+
@named B = FirstOrder(k = 1, T = 1)
1015+
@named C = FirstOrder(k = 1, T = 2)
1016+
@named D = Blocks.Gain(k = 1)
1017+
1018+
# Four components in series inside `inner`; APs bound B and C (the middle two)
1019+
inner_eqs = [
1020+
connect(A.output, :ap_in, B.input),
1021+
connect(B.output, :bc, C.input),
1022+
connect(C.output, :ap_out, D.input),
1023+
]
1024+
@named inner = System(inner_eqs, t, systems = [A, B, C, D])
1025+
@named root = System(Equation[], t, systems = [inner])
1026+
1027+
isolated, input_vars, output_vars = isolate_subsystem(
1028+
root, root.inner.ap_in, root.inner.ap_out
1029+
)
1030+
1031+
# root is returned; inner is trimmed to contain only B and C
1032+
@test Set(nameof.(get_systems(isolated))) == Set([:inner])
1033+
inner_isolated = only(get_systems(isolated))
1034+
@test Set(nameof.(get_systems(inner_isolated))) == Set([:B, :C])
1035+
# The B→C connection equation is preserved; A and D boundary APs are removed
1036+
@test length(get_eqs(inner_isolated)) == 1
1037+
@test isequal(only(input_vars), B.input.u)
1038+
@test isequal(only(output_vars), C.output.u)
1039+
# Container levels have no own variables/parameters/observed/defaults
1040+
@test isempty(get_unknowns(isolated))
1041+
@test isempty(get_ps(isolated))
1042+
@test isempty(get_unknowns(inner_isolated))
1043+
@test isempty(get_ps(inner_isolated))
1044+
end
1045+
1046+
@testset "container-level variables, parameters, and equations are stripped" begin
1047+
@named P = FirstOrder(k = 1, T = 1)
1048+
@named Q = FirstOrder(k = 1, T = 2)
1049+
@named R = FirstOrder(k = 1, T = 3)
1050+
@named S = Blocks.Gain(k = 1)
1051+
1052+
# Declare an extra variable and parameter at the inner (container) level
1053+
@variables extra_state(t) = 0.0
1054+
@parameters extra_gain = 2.0
1055+
1056+
inner_eqs = [
1057+
connect(P.output, :ap_in, Q.input),
1058+
connect(Q.output, :qr, R.input),
1059+
connect(R.output, :ap_out, S.input),
1060+
# Plain algebraic equation declared at the container level — must be removed
1061+
extra_state ~ extra_gain * Q.output.u,
1062+
]
1063+
# inner has its own unknowns, ps, defaults, and a non-connection equation
1064+
inner = System(
1065+
inner_eqs, t, [extra_state], [extra_gain];
1066+
name = :inner, systems = [P, Q, R, S]
1067+
)
1068+
@named root = System(Equation[], t, systems = [inner])
1069+
1070+
isolated, input_vars, output_vars = isolate_subsystem(
1071+
root, root.inner.ap_in, root.inner.ap_out
1072+
)
1073+
1074+
inner_isolated = only(get_systems(isolated))
1075+
@test Set(nameof.(get_systems(inner_isolated))) == Set([:Q, :R])
1076+
# The Q→R connection AP is kept; extra_state equation and boundary APs are removed
1077+
@test length(get_eqs(inner_isolated)) == 1
1078+
# extra_state, extra_gain, their defaults, and the algebraic equation are stripped
1079+
@test isempty(get_unknowns(inner_isolated))
1080+
@test isempty(get_ps(inner_isolated))
1081+
@test isempty(get_observed(inner_isolated))
1082+
@test isempty(get_defaults(inner_isolated))
1083+
# root is also clean
1084+
@test isempty(get_unknowns(isolated))
1085+
@test isempty(get_ps(isolated))
1086+
end
1087+
end
7821088
end
7831089

7841090
using DynamicQuantities

src/ModelingToolkit.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export TearingState
163163
export Clock, SolverStepClock, TimeDomain
164164
export get_sensitivity_function, get_comp_sensitivity_function,
165165
get_looptransfer_function, get_sensitivity, get_comp_sensitivity, get_looptransfer
166+
export isolate_subsystem
166167

167168
function FMIComponent end
168169

0 commit comments

Comments
 (0)