From cff4716113fe19830874056460bb01f0759721af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 14 Oct 2026 19:20:24 +0200 Subject: [PATCH 01/18] Use SetProg's templates --- CLAUDE.md | 9 + Project.toml | 10 +- README.md | 12 +- bench/athanasopoulos2019.jl | 3 +- docs/Project.toml | 6 +- docs/make.jl | 1 + docs/src/bibliography.md | 4 +- docs/src/developers/conventions.md | 56 ++--- docs/src/developers/examples.md | 3 +- docs/src/examples/comparing.jl | 13 +- docs/src/examples/refinement.jl | 3 +- docs/src/examples/set_prog.jl | 52 +---- docs/src/examples/stability.jl | 21 +- docs/src/examples/templates_and_graphs.jl | 28 ++- docs/src/index.md | 10 +- docs/src/manual/comparing.md | 4 +- docs/src/manual/support.md | 68 ++---- docs/src/manual/templates.md | 190 +++++++--------- docs/src/reference/templates.md | 59 +---- ext/PathCompleteCertificatesSetProgExt.jl | 206 ----------------- ...PathCompleteCertificatesSumOfSquaresExt.jl | 65 ------ src/PathCompleteCertificates.jl | 8 +- src/lifts/compare.jl | 4 +- src/problems/catalogue/stability.jl | 58 +++-- src/problems/certificate.jl | 23 +- src/problems/sets.jl | 126 +++++++++++ src/refinements/refine.jl | 4 +- src/templates/abstract.jl | 5 +- src/templates/catalogue/conic_polyhedral.jl | 207 ------------------ src/templates/catalogue/dual_copositive.jl | 88 -------- src/templates/catalogue/linear_copositive.jl | 81 ------- .../catalogue/parallel_faces_polyhedral.jl | 158 ------------- src/templates/catalogue/quadratic.jl | 10 +- src/templates/catalogue/set_prog.jl | 47 ---- src/templates/catalogue/sum_of_squares.jl | 50 ----- test/lifts/compare.jl | 7 +- test/lifts/composition.jl | 21 +- test/lifts/identity.jl | 7 +- test/lifts/min.jl | 3 +- test/lifts/product.jl | 5 +- test/lifts/split.jl | 11 +- test/problems/certificate.jl | 3 +- test/problems/certify.jl | 22 +- test/problems/common.jl | 17 +- test/problems/dual.jl | 52 ++--- test/problems/optimal_control.jl | 3 +- .../set_prog.jl => problems/sets.jl} | 186 ++++++++++------ test/problems/stability.jl | 54 +++-- test/problems/tight_edges.jl | 37 ++-- test/refinements/refine.jl | 5 +- test/runtests.jl | 6 +- test/templates/conic_polyhedral.jl | 128 ----------- test/templates/dual_copositive.jl | 50 ----- test/templates/parallel_faces_polyhedral.jl | 141 ------------ test/templates/sum_of_squares.jl | 183 ---------------- 55 files changed, 664 insertions(+), 1969 deletions(-) delete mode 100644 ext/PathCompleteCertificatesSetProgExt.jl delete mode 100644 ext/PathCompleteCertificatesSumOfSquaresExt.jl create mode 100644 src/problems/sets.jl delete mode 100644 src/templates/catalogue/conic_polyhedral.jl delete mode 100644 src/templates/catalogue/dual_copositive.jl delete mode 100644 src/templates/catalogue/linear_copositive.jl delete mode 100644 src/templates/catalogue/parallel_faces_polyhedral.jl delete mode 100644 src/templates/catalogue/set_prog.jl delete mode 100644 src/templates/catalogue/sum_of_squares.jl rename test/{templates/set_prog.jl => problems/sets.jl} (50%) delete mode 100644 test/templates/conic_polyhedral.jl delete mode 100644 test/templates/dual_copositive.jl delete mode 100644 test/templates/parallel_faces_polyhedral.jl delete mode 100644 test/templates/sum_of_squares.jl diff --git a/CLAUDE.md b/CLAUDE.md index f16baca..ded0d14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,12 @@ +## Stability migration + +Stability uses SetProg families directly, or vectors with one family per node. +SetProg is a direct dependency; polynomial support is its optional SumOfSquares +extension. The legacy quadratic catalogue remains only for safety and optimal +control, whose migration is deferred. Stability gauge primitives belong to +SetProg; applicability, graph closures, and reindexing live in `problems/sets.jl`. +The legacy template architecture described below applies to deferred problems. + # CLAUDE.md Operating guide for AI coding agents working in **PathCompleteCertificates.jl**. The developer diff --git a/Project.toml b/Project.toml index f961b37..cfd7eaa 100644 --- a/Project.toml +++ b/Project.toml @@ -9,6 +9,7 @@ JuMP = "4076af6c-e467-56ae-b986-b466b2749572" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" MathematicalSystems = "d14a8603-c872-5ed3-9ece-53e0e82e39da" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +SetProg = "39881422-4b75-5582-a5c7-0feb14562a65" [compat] HybridSystems = "0.4" @@ -16,19 +17,12 @@ JuMP = "1.20" LinearAlgebra = "1" MathematicalSystems = "0.14" Random = "1" -MultivariatePolynomials = "0.5" RecipesBase = "1" -SetProg = "0.4.4" -SumOfSquares = "0.7, 0.8" +SetProg = "0.4.5" julia = "1.10" [weakdeps] -MultivariatePolynomials = "102ac46a-7ee4-5c85-9060-abc95bfdeaa3" RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01" -SetProg = "39881422-4b75-5582-a5c7-0feb14562a65" -SumOfSquares = "4b9e565b-77fc-50a5-a571-1244f986bda1" [extensions] PathCompleteCertificatesRecipesBaseExt = "RecipesBase" -PathCompleteCertificatesSetProgExt = ["SetProg", "SumOfSquares", "MultivariatePolynomials"] -PathCompleteCertificatesSumOfSquaresExt = ["SumOfSquares", "MultivariatePolynomials"] diff --git a/README.md b/README.md index 869263c..6ba7496 100644 --- a/README.md +++ b/README.md @@ -78,12 +78,12 @@ Contributions are welcome. Please open an [issue](https://github.com/dionysos-dev/PathCompleteCertificates.jl/issues) to report a bug or discuss a feature, and see the Developer Docs for the setup, conventions and Git workflow. -One thing to read before writing code: the package is organised along **two independent axes**, -*template* (what the node functions are) and *problem* (what the edge inequality says), with the -graph cutting across both. A new template, problem, lift or refinement algorithm is one file in the -`catalogue/` of its directory under `src/`. The -[conventions](https://dionysos-dev.github.io/PathCompleteCertificates.jl/dev/developers/conventions/) -page explains why, and what goes wrong when the structure is ignored. +Stability certificates use [SetProg](https://github.com/blegat/SetProg.jl) +set families directly. SetProg owns their variables, inclusions and fitted gauges; +this package owns the systems, graphs, lifts and refinement algorithms. +Safety and optimal control retain their quadratic templates until their migration. +See the [conventions](https://dionysos-dev.github.io/PathCompleteCertificates.jl/dev/developers/conventions/) +for extending the package. ## References diff --git a/bench/athanasopoulos2019.jl b/bench/athanasopoulos2019.jl index 93122eb..5259316 100644 --- a/bench/athanasopoulos2019.jl +++ b/bench/athanasopoulos2019.jl @@ -13,6 +13,7 @@ # step count is not a defect; the rate falling below one is the claim. import PathCompleteCertificates as PCC +import SetProg import Clarabel const A1 = [0.095 -0.2375 0.2375; -1.9 0.0 0.0; 0.0 0.0 0.475] @@ -21,7 +22,7 @@ const A2 = [0.4753 0.0 0.0; 0.0 0.0 0.4753; 0.0 -1.9012 0.0] depth_max = isempty(ARGS) ? 15 : parse(Int, ARGS[1]) problem = PCC.StabilityProblem(PCC.switched_system([A1, A2])) -template = PCC.ParallelFacesPolyhedralTemplate(1, 3) +template = SetProg.Parallelotope(; dimension = 3) elapsed = @elapsed trace = PCC.refine( template, diff --git a/docs/Project.toml b/docs/Project.toml index 5a59971..3bdc193 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,7 +1,7 @@ # The examples are executed as part of the docs build, so this environment needs # a solver, a plotting backend and the polynomial and set-programming stacks on -# top of the documentation tooling. None of them is a dependency of the package, -# nor of the environment CI instantiates for the tests. +# top of the documentation tooling. SetProg is a direct package dependency; +# SumOfSquares is optional, and solvers and plotting tools are for examples. [deps] Clarabel = "61c947e1-3e6d-4ee4-985a-eec8c727bd6e" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" @@ -21,7 +21,7 @@ SumOfSquares = "4b9e565b-77fc-50a5-a571-1244f986bda1" Documenter = "1" DocumenterCitations = "1" Literate = "2" -SetProg = "0.4.4" +SetProg = "0.4.5" SumOfSquares = "0.7, 0.8" [sources] diff --git a/docs/make.jl b/docs/make.jl index 7db1959..60f78a4 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,3 +1,4 @@ +import SetProg using PathCompleteCertificates using Documenter, Literate import DocumenterCitations diff --git a/docs/src/bibliography.md b/docs/src/bibliography.md index 0aaadb7..e1650d5 100644 --- a/docs/src/bibliography.md +++ b/docs/src/bibliography.md @@ -23,8 +23,8 @@ number quoted in the manual was read in the paper it is attributed to. | [parrilo2008approximation](@cite) | [`SumOfSquaresTemplate`](@ref) | | [debauche2024thesis](@cite) | [`DualCopositiveTemplate`](@ref), Def. 2.30 | -[`QuadraticTemplate`](@ref) and [`LinearCopositiveTemplate`](@ref) are -standard. [`ConicPolyhedralTemplate`](@ref) is under submission and has no +`SetProg.Ellipsoid` and `SetProg.PositiveSimplex` are +standard. `SetProg.ConicPolytope` is under submission and has no entry yet. ## Safety and optimal control diff --git a/docs/src/developers/conventions.md b/docs/src/developers/conventions.md index dc5fa0a..d47ad2a 100644 --- a/docs/src/developers/conventions.md +++ b/docs/src/developers/conventions.md @@ -16,55 +16,31 @@ between problems: | Safety | ``B_d(A_i x) \le B_s(x)`` | | Optimal control | ``x^\top Q x + u^\top R u + V_d(A_i x + B_i u) \le V_s(x)`` | -Templates and problems are therefore two independent type hierarchies. The -**template**, `src/templates/`, says what the node functions are, and the -**problem**, `src/problems/`, says what the edge inequality is. A template -supplies primitives, a problem composes them, and neither names the other. +Stability set families live in SetProg. The problem builds SetProg variables, +normalizes their gauges, and models each edge by set inclusion. The fitted +node values are callable SetProg sets. Extend SetProg's homogeneous gauge +primitives to add a stability family: ```julia -# Template axis: one file per template, answering all of it. -add_function_variables!(model, template, dim, node) # the node function V_s, well defined -add_decrease!(model, template, V_src, V_dst, map; scale = 1) - # scale·V_src(x) − V_dst(map·x) ≥ 0 -decrease_slack(template, V_src, V_dst, map; scale = 1) # the same, read off a solution; 0 when tight -add_normalization!(model, template, V) # excludes V ≡ 0 -homogeneity_degree(template) # p: V(cx) = cᵖ V(x) -solution_value(template, V) # variables -> a callable node function -is_applicable(template, system) # default true; does the template apply at all? -is_closed_under(template, ::Operation, system) # default false; which lifts are valid -reindex(template, origins) # per-node data follows a refined graph -has_dual(template), dual(template), dual(template, V) # the dual norms, when they exist - -# Problem axis: one file per problem, composing the above. -node_value(problem, V, x) # V(x), or V([x; 1]) for safety -optimization_model(template, graph, problem; optimizer, ...) # build, do not solve -certify(template, graph, problem; optimizer, ...) # build, solve, extract -best_certificate, objective, optimality_gap, edge_slacks # what refine reads - -# The common function: each node value from the problem (node_value), their combination -# from the graph alone (graphs/observer.jl); the public method is in problems/certificate.jl. -common(graph, problem, Vs, x) +SetProg.add_function_variables!(model, family, dimension, node) +SetProg.add_normalization!(model, family, variable) +SetProg.add_decrease!(model, family, source, destination, A; scale) +SetProg.solution_value(family, variable) +SetProg.homogeneity_degree(family) +SetProg.decrease_slack(family, source, destination, A; scale) ``` -Every primitive is public, since a user implements it. Problems express -their edge inequality through `add_decrease!`: quantifying an inequality over -every ``x`` needs a cone that depends only on the template, so stability's edge -condition is one call with `scale = γᵖ`. Optimal control is the exception. Its -inequality is convex only after the substitution ``S = P^{-1}``, ``Y = KS``, -so it cannot be written against `V_src` and `V_dst`; it works on the quadratic -template's variables directly, and a comment in the code says so. Each problem -writes its edge condition once, in a private `_edge_decrease` that both builds -the model and measures the slacks. - -A template is an **instance**, never a type: `ParallelFacesPolyhedralTemplate` carries a -matrix per node, and `::Type{T}` has nowhere to put it. An `AbstractTemplate` -is the family; the fitted node function is what `solution_value` returns. +PCC owns the system and graph metadata in `src/problems/sets.jl`: +`is_applicable`, `is_closed_under`, `reindex`, `has_dual`, and `dual`. +Families are instances; a vector associates a family with each node. +Safety and optimal control retain the legacy quadratic template and its +primitives until their separate migration. Each problem has its own certificate type. The shared fields live once in `CertificateData`; what the problem certifies is a typed field of its own, `rate`, `margin` or `gains`. -**A new template, problem, lift or refinement algorithm is one file in the +**A new problem, lift or refinement algorithm is one file in the `catalogue/` of its directory, and touches nothing else.** Every directory with an interface has that shape: the interface at the top, one file per inhabitant under `catalogue/`, named after the type without the directory's suffix. diff --git a/docs/src/developers/examples.md b/docs/src/developers/examples.md index aea5ef0..15fad27 100644 --- a/docs/src/developers/examples.md +++ b/docs/src/developers/examples.md @@ -13,6 +13,7 @@ docs build, so a broken example fails the build. import PathCompleteCertificates as PCC import Clarabel +import SetProg # Prose is a `# ` comment between code blocks. @@ -22,7 +23,7 @@ problem = PCC.StabilityProblem(PCC.switched_system(A)) # The last expression of a block is what the page shows, so end a block with # the value worth seeing rather than printing it. -PCC.jsr_bound(PCC.QuadraticTemplate(), PCC.de_bruijn(1, 1), problem; optimizer = Clarabel.Optimizer) +PCC.jsr_bound(SetProg.Ellipsoid(symmetric = true), PCC.de_bruijn(1, 1), problem; optimizer = Clarabel.Optimizer) ``` - The `# # Title` line is the sidebar entry; `docs/make.jl` reads it from the diff --git a/docs/src/examples/comparing.jl b/docs/src/examples/comparing.jl index f1276ba..9e13e08 100644 --- a/docs/src/examples/comparing.jl +++ b/docs/src/examples/comparing.jl @@ -8,6 +8,8 @@ # that justify each answer, and compares them with certified rates. import PathCompleteCertificates as PCC +import SetProg +import LinearAlgebra import Clarabel using HybridSystems using Plots @@ -120,9 +122,9 @@ A = [[1.5519 0.44737; 7.6412 7.4716], [0.47501 9.1755; 1.8955 0.18502]] system = PCC.switched_system(A) problem = PCC.StabilityProblem(system) -QUADRATIC = PCC.QuadraticTemplate() -COPOSITIVE = PCC.LinearCopositiveTemplate() -DUAL_COPOSITIVE = PCC.DualCopositiveTemplate() +QUADRATIC = SetProg.Ellipsoid(; symmetric = true) +COPOSITIVE = SetProg.PositiveSimplex() +DUAL_COPOSITIVE = SetProg.PositiveBox() TEMPLATES = (QUADRATIC, COPOSITIVE, DUAL_COPOSITIVE) c12 = PCC.compare(G1, G2; templates = TEMPLATES, system, optimizer = OPTIMIZER) @@ -179,8 +181,9 @@ bound(template, g) = PCC.jsr_bound( on_G1 = bound(QUADRATIC, G1) V = PCC.functions(on_G1) W = [ - PCC.QuadraticFunction(sum(C[h, g] * Matrix(V[g]) for g in eachindex(V))) for - h in 1:PCC.n_nodes(G2) + SetProg.Sets.Ellipsoid( + LinearAlgebra.Symmetric(sum(C[h, g] * Matrix(V[g]) for g in eachindex(V))), + ) for h in 1:PCC.n_nodes(G2) ] scale = on_G1.rate^PCC.homogeneity_degree(QUADRATIC) diff --git a/docs/src/examples/refinement.jl b/docs/src/examples/refinement.jl index f2fe70f..c6615ca 100644 --- a/docs/src/examples/refinement.jl +++ b/docs/src/examples/refinement.jl @@ -7,11 +7,12 @@ # Bruijn graph of the same size. import PathCompleteCertificates as PCC +import SetProg import Clarabel using Plots const OPTIMIZER = Clarabel.Optimizer -const TEMPLATE = PCC.QuadraticTemplate() +const TEMPLATE = SetProg.Ellipsoid(; symmetric = true) # A rotation and a shear. No single quadratic function attains the joint # spectral radius of this pair, so the bound on the one-node graph is not diff --git a/docs/src/examples/set_prog.jl b/docs/src/examples/set_prog.jl index 2be4b4b..d28541b 100644 --- a/docs/src/examples/set_prog.jl +++ b/docs/src/examples/set_prog.jl @@ -1,15 +1,8 @@ # # Piecewise semi-ellipsoids: a template from SetProg # -# [SetProg](https://github.com/blegat/SetProg.jl) writes optimisation problems -# over sets: ellipsoids, sublevel sets of polynomials, polytopes, and sets that -# are one of these on each cone of a partition. [`SetProgTemplate`](@ref) -# makes such a set the node function of a node, the function whose unit -# sublevel set it is, and the stability inequality -# ``V_d(A_i x) \le \gamma^p V_s(x)`` becomes the inclusion -# ``(A_i / \gamma)\, S_s \subseteq S_d``, which SetProg imposes. Three of its -# sets give templates this package already has, and the example checks that -# they certify the same rates; the fourth, the piecewise semi-ellipsoid, does -# not. +# SetProg families are accepted directly by PCC. Their stability inequality +# is modeled as the inclusion `(A / rate) * S_source ⊆ S_destination`. +# This example compares polynomial and piecewise set families. import PathCompleteCertificates as PCC import Clarabel, HiGHS, JuMP @@ -43,38 +36,16 @@ polygon(normals) = polyhedron(hrep([HalfSpace(a, norm(a)) for a in normals]), LI octagon = polygon([[2, 1], [1, 2], [-1, 2], [-2, 1], [-2, -1], [-1, -2], [1, -2], [2, -1]]) -# ## Three sets that are templates already -# -# An ellipsoid is a quadratic form, the sublevel set of a polynomial is a sum -# of squares, and a polytope that is linear on each cone of a partition is the -# free-facet polyhedral template on the same cones; the cones over the facets -# of this octagon are those of [`planar_conic_partition`](@ref)`(1)`. Each -# pair certifies the same rate. +# ## Three homogeneous set families +using SumOfSquares @polyvar x[1:2] - -pairs = [ - ( - "quadratic", - PCC.QuadraticTemplate(), - PCC.SetProgTemplate(Ellipsoid(; symmetric = true)), - ), - ( - "sum of squares, degree 4", - PCC.SumOfSquaresTemplate(2, x), - PCC.SetProgTemplate(PolySet(; symmetric = true, degree = 4, variables = x)), - ), - ( - "free facets, 4 cones", - PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(1)]), - PCC.SetProgTemplate(Polytope(; symmetric = true, piecewise = octagon)), - ), -] - -[ - (name, round(bound(ours).rate; digits = 4), round(bound(theirs).rate; digits = 4)) for - (name, ours, theirs) in pairs +families = [ + ("quadratic", Ellipsoid(; symmetric = true)), + ("degree 4", PolySet(; symmetric = true, degree = 4, variables = x)), + ("polyhedral", Polytope(; symmetric = true, piecewise = octagon)), ] +[(name, round(bound(family).rate; digits = 4)) for (name, family) in families] # ## The piecewise semi-ellipsoid # @@ -106,8 +77,7 @@ sixteen = polygon([ partitions = [("square", square), ("octagon", octagon), ("16-gon", sixteen)] piecewise = [ - (name, bound(PCC.SetProgTemplate(Ellipsoid(; symmetric = true, piecewise = P)))) for - (name, P) in partitions + (name, bound(Ellipsoid(; symmetric = true, piecewise = P))) for (name, P) in partitions ] [ diff --git a/docs/src/examples/stability.jl b/docs/src/examples/stability.jl index f9648a2..6a19b57 100644 --- a/docs/src/examples/stability.jl +++ b/docs/src/examples/stability.jl @@ -4,6 +4,7 @@ # joint spectral radius, with the node functions that prove it. import PathCompleteCertificates as PCC +import SetProg import Clarabel const OPTIMIZER = Clarabel.Optimizer @@ -26,10 +27,20 @@ problem = PCC.StabilityProblem(PCC.switched_system(A)) # [`is_feasible`](@ref) says whether the problem was feasible: here at `0.6` # and not at `0.4`, which is below the spectral radius. -at_six_tenths = - PCC.certify(PCC.QuadraticTemplate(), graph, problem; optimizer = OPTIMIZER, rate = 0.6) -at_four_tenths = - PCC.certify(PCC.QuadraticTemplate(), graph, problem; optimizer = OPTIMIZER, rate = 0.4) +at_six_tenths = PCC.certify( + SetProg.Ellipsoid(; symmetric = true), + graph, + problem; + optimizer = OPTIMIZER, + rate = 0.6, +) +at_four_tenths = PCC.certify( + SetProg.Ellipsoid(; symmetric = true), + graph, + problem; + optimizer = OPTIMIZER, + rate = 0.4, +) PCC.is_feasible(at_six_tenths), PCC.is_feasible(at_four_tenths) @@ -40,7 +51,7 @@ PCC.is_feasible(at_six_tenths), PCC.is_feasible(at_four_tenths) # on this graph. result = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(; symmetric = true), graph, problem; optimizer = OPTIMIZER, diff --git a/docs/src/examples/templates_and_graphs.jl b/docs/src/examples/templates_and_graphs.jl index a4dc128..e8fefd3 100644 --- a/docs/src/examples/templates_and_graphs.jl +++ b/docs/src/examples/templates_and_graphs.jl @@ -8,6 +8,7 @@ # function ``V``, evaluated by calling the certificate on ``x``. import PathCompleteCertificates as PCC +import SetProg import Clarabel using HybridSystems using SumOfSquares, DynamicPolynomials @@ -48,11 +49,11 @@ rotation_graph = PCC.de_bruijn(1, 1) rotation_problem = PCC.StabilityProblem(PCC.switched_system(ROTATION)) templates = [ - ("quadratic", PCC.QuadraticTemplate()), - ("polyhedral, fixed facets", PCC.ParallelFacesPolyhedralTemplate(1, 2)), + ("quadratic", SetProg.Ellipsoid(; symmetric = true)), + ("polyhedral, fixed facets", SetProg.Parallelotope(; dimension = 2)), ( "polyhedral, free facets", - PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(3)]), + SetProg.ConicPolytope.([SetProg.planar_conic_partition(3)]), ), ] @@ -109,7 +110,7 @@ right = plot(; for (name, graph) in graphs certificate = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(; symmetric = true), graph, mixed_problem; optimizer = OPTIMIZER, @@ -134,7 +135,7 @@ plot(left, right; layout = (1, 2), size = (1100, 620)) # ## Sums of squares # -# [`SumOfSquaresTemplate`](@ref) takes polynomial node functions of higher +# `SetProg.PolySet` takes polynomial node functions of higher # degree ([parrilo2008approximation](@cite)). It is defined in a package # extension loaded with SumOfSquares, and the polynomial variables are created # once and shared by every node. Degree 2 is the quadratic template. The table @@ -149,8 +150,17 @@ one_node = PCC.common_graph(mixed_problem.system) two_nodes = PCC.de_bruijn(1, 2) [ - ("quadratic, 1 node", bound(PCC.QuadraticTemplate(), one_node)), - ("degree 4, 1 node", bound(PCC.SumOfSquaresTemplate(2, x), one_node)), - ("quadratic, 2 nodes", bound(PCC.QuadraticTemplate(), two_nodes)), - ("degree 4, 2 nodes", bound(PCC.SumOfSquaresTemplate(2, x), two_nodes)), + ("quadratic, 1 node", bound(SetProg.Ellipsoid(; symmetric = true), one_node)), + ( + "degree 4, 1 node", + bound(SetProg.PolySet(; symmetric = true, degree = 2 * 2, variables = x), one_node), + ), + ("quadratic, 2 nodes", bound(SetProg.Ellipsoid(; symmetric = true), two_nodes)), + ( + "degree 4, 2 nodes", + bound( + SetProg.PolySet(; symmetric = true, degree = 2 * 2, variables = x), + two_nodes, + ), + ), ] diff --git a/docs/src/index.md b/docs/src/index.md index 8a166a0..b179f96 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -65,14 +65,14 @@ In reading order: Safety and optimal control have one example each, [Safety: a barrier certificate](@ref) and -[Optimal control: a feedback per node](@ref), and so does the SetProg -template, [Piecewise semi-ellipsoids: a template from SetProg](@ref). +[Optimal control: a feedback per node](@ref), and so do SetProg +piecewise families, [Piecewise semi-ellipsoids: a template from SetProg](@ref). ## Scope -Stability is implemented for every template, safety and optimal control for -the quadratic one. [`compare`](@ref) is stated for stability, as in the -literature, and so is [`refine`](@ref); +Stability uses SetProg families directly. Safety and optimal control retain +the legacy quadratic template until their migration. [`compare`](@ref) is stated for stability, as in the +literature, and [`refine`](@ref) runs on stability; [What works with what](@ref) gives the full table. A bound is always an upper bound, and a negative answer means that no certificate was found in this template on this graph, not that the property fails. diff --git a/docs/src/manual/comparing.md b/docs/src/manual/comparing.md index 3a68f5e..035ee9c 100644 --- a/docs/src/manual/comparing.md +++ b/docs/src/manual/comparing.md @@ -62,7 +62,7 @@ finds the order. The products give graphs with words on their edges, which ¹ The linear copositive norms are not closed under `min`, yet the min lift is valid for them on positive systems ([debauche2024thesis](@cite), Thm. 7.43): -[`is_valid`](@ref) reads it through their dual, [`DualCopositiveTemplate`](@ref), +[`is_valid`](@ref) reads it through their dual, `SetProg.PositiveBox`, closed under `max`. ² Sufficient only: [jongeneel2025ordering](@cite), Ex. IV.3, exhibits two @@ -78,7 +78,7 @@ both ([Duality](@ref)). ## `compare` ```julia -c = compare(G, H; templates = (QuadraticTemplate(),), system, optimizer) +c = compare(G, H; templates = (SetProg.Ellipsoid(symmetric = true),), system, optimizer) lifts(c) # the lifts tried, in order simulations(c) # lift => simulation, for each lift whose lifted G simulates H templates(c) # the templates given for which H is proved no worse than G diff --git a/docs/src/manual/support.md b/docs/src/manual/support.md index 99f1bee..53ae81c 100644 --- a/docs/src/manual/support.md +++ b/docs/src/manual/support.md @@ -4,54 +4,20 @@ CurrentModule = PathCompleteCertificates # What works with what -Stability is implemented for all seven templates, safety and optimal control -for the quadratic one. - -| | [`StabilityProblem`](@ref) | [`SafetyProblem`](@ref) | [`OptimalControlProblem`](@ref) | -| :----------------------------- | :------------------------: | :---------------------: | :-----------------------------: | -| [`QuadraticTemplate`](@ref) | ✓ | ✓ | ✓ | -| [`LinearCopositiveTemplate`](@ref) | ✓ ¹ | ✗ | ✗ | -| [`DualCopositiveTemplate`](@ref) | ✓ ¹ | ✗ | ✗ | -| [`ParallelFacesPolyhedralTemplate`](@ref) | ✓ | ✗ | ✗ | -| [`ConicPolyhedralTemplate`](@ref) | ✓ | ✗ | ✗ | -| [`SumOfSquaresTemplate`](@ref) | ✓ ² | ✗ | ✗ | -| [`SetProgTemplate`](@ref) | ✓ ² | ✗ | ✗ | - -¹ Positive systems only: entrywise nonnegative mode matrices. - -² Behind a package extension: `using SumOfSquares` first, or `using SetProg, -SumOfSquares` for [`SetProgTemplate`](@ref). - -A ✗ throws an `ArgumentError` before any model is built. Optimal control needs -a **complete** graph, not merely a path-complete one. Stability accepts a -[`WordGraph`](@ref); safety and optimal control need one mode per edge. - -## Comparing and refining - -[`compare`](@ref) is stated for stability, as in the literature; which of its -results hold for a template depends on the template's closure properties. -[`refine`](@ref) runs on stability only, as in the literature, for every template that -implements [`decrease_slack`](@ref), from which the edge slacks are computed: - -| | [`refine`](@ref) | lifts valid for [`compare`](@ref) | -| :-- | :-: | :-- | -| [`QuadraticTemplate`](@ref) | ✓ | identity, sum; composition if invertible | -| [`LinearCopositiveTemplate`](@ref) | ✓ ¹ | identity, sum, min without closure; composition if nonnegative with no zero column | -| [`DualCopositiveTemplate`](@ref) | ✓ ¹ | identity, max; sum through its dual | -| [`ParallelFacesPolyhedralTemplate`](@ref) | ✓ | identity; max when the node matrices coincide | -| [`ConicPolyhedralTemplate`](@ref) | ✓ | identity | -| [`SumOfSquaresTemplate`](@ref) | ✗ ³ | identity, sum; composition if invertible | -| [`SetProgTemplate`](@ref) | ✗ ³ | identity; sum when the nodes share the set variable | - -³ No `decrease_slack` yet, so the loop cannot read where these templates are -tight. - -## The gaps - -Safety with the polyhedral templates is possible by the same construction in -homogeneous coordinates; it is not implemented. Optimal -control is tied to the quadratic template by the mathematics, the substitution -``S = P^{-1}``, ``Y = KS`` that makes it convex ([ninite2026path](@cite)). A -new template that implements the template primitives works with stability; -safety and optimal control need further primitives; see the -[developer conventions](@ref "Conventions"). +Stability accepts SetProg families directly. Safety and optimal control retain +[`QuadraticTemplate`](@ref); their SetProg migration is deferred. + +| Family | Stability | Refinement | +| :-- | :-: | :-: | +| symmetric ellipsoid | ✓ | ✓ | +| positive simplex or box | ✓ on positive systems | ✓ | +| parallelotope or conic polytope | ✓ | ✓ | +| polynomial set | ✓ with `using SumOfSquares` | | +| piecewise ellipsoid or polytope | ✓ | | + +Refinement requires [`decrease_slack`](@ref). Fitted polynomial and general +piecewise slacks need a separate optimization and are not implemented. +See [Templates](@ref) for closures governing [`compare`](@ref). + +Stability accepts [`WordGraph`](@ref). Safety and optimal control require one +mode per edge; optimal control requires a complete graph. diff --git a/docs/src/manual/templates.md b/docs/src/manual/templates.md index 54b271c..fa644bc 100644 --- a/docs/src/manual/templates.md +++ b/docs/src/manual/templates.md @@ -4,121 +4,85 @@ CurrentModule = PathCompleteCertificates # Templates -A template defines the search space from which the node functions of a -certificate are chosen. Its properties depend on the problem: for stability, -the template consists of candidate Lyapunov functions, continuous, positive -definite and homogeneous; for safety, of candidate barrier functions, which -need not be positive. Once a certificate has been computed, -[`functions`](@ref) returns the fitted node functions, one per node, each -callable. - -## The seven templates - -| Template | ``V_s(x)`` | Homogeneity degree | Solved for | Source | -| :-- | :-- | :-: | :-- | :-- | -| [`QuadraticTemplate`](@ref) | ``x^\top P_s x`` | 2 | ``P_s \succ 0`` | standard | -| [`LinearCopositiveTemplate`](@ref) | ``c_s^\top x`` on the nonnegative orthant | 1 | ``c_s > 0`` | standard | -| [`DualCopositiveTemplate`](@ref) | ``\max_i x_i / v_{s,i}`` on the nonnegative orthant | 1 | ``v_s > 0`` | [debauche2024thesis](@cite), Def. 2.30 | -| [`ParallelFacesPolyhedralTemplate`](@ref) | ``\max_k \lvert (G_s x)_k \rvert / w_{s,k}`` | 1 | weights ``w_s``, facets ``G_s`` fixed | [athanasopoulos2019polyhedral](@cite) | -| [`ConicPolyhedralTemplate`](@ref) | ``\max_i \lvert p_{s,i}^\top x \rvert`` | 1 | facets ``p_{s,i}`` | unpublished ¹ | -| [`SumOfSquaresTemplate`](@ref) | ``z(x)^\top Q_s z(x)``, ``z`` the monomials of degree ``d`` | ``2d`` | ``Q_s \succeq 0`` | [parrilo2008approximation](@cite) ² | -| [`SetProgTemplate`](@ref) | the gauge of a [SetProg](https://github.com/blegat/SetProg.jl) set | the set's | the set | ³ | - -¹ The free-facet construction is under submission. - -² Behind a package extension: load SumOfSquares first. Raising ``d`` tightens -the bound monotonically, and ``d = 1`` is exactly [`QuadraticTemplate`](@ref). - -³ Behind a package extension: load SetProg first. Its ellipsoids, polynomial -sets and piecewise polytopes give the quadratic, sum-of-squares and free-facet -templates. Its piecewise semi-ellipsoids, quadratic on each cone of a -partition, improve on the quadratic template as the partition is refined; -[Piecewise semi-ellipsoids: a template from SetProg](@ref) shows all four. - -The **homogeneity degree**, [`homogeneity_degree`](@ref), is the ``p`` in -``V(cx) = c^p V(x)``. Stability scales its edge inequality by ``\gamma^p``, so -the rate bounds the joint spectral radius in every template, and rates from -different templates can be compared. For safety the quadratic template is used in homogeneous -coordinates, ``[x; 1]^\top P_s [x; 1]``, without positive definiteness. - -**The two polyhedral templates.** Both search symmetric polytopes with a -linear program. [`ParallelFacesPolyhedralTemplate`](@ref) fixes the face -normals, the rows of ``G_s``, and solves for the distances ``w_s`` of the -faces, ``n`` variables per node. [`ConicPolyhedralTemplate`](@ref) fixes the -vertex directions, the rays of a conic partition, and solves for the faces, -one row of ``n`` variables per cone. - -**The two copositive templates** are each other's duals, the dual norm of -``c^\top x`` on the orthant being ``\max_i x_i / c_i``. Both are defined on -the nonnegative orthant, so they apply to positive systems only: on any other, -[`is_applicable`](@ref) is `false` and stability throws. - -## Classes and solvers - -Several templates search subsets of one class of functions. The class sets the -kind of program, and so the solver to pass as `optimizer`. - -| Class | Templates | Program | Solvers | -| :-- | :-- | :-- | :-- | -| quadratic | [`QuadraticTemplate`](@ref); [`SetProgTemplate`](@ref) with an ellipsoid | semidefinite | Clarabel, SCS, MOSEK | -| sum of squares | [`SumOfSquaresTemplate`](@ref); [`SetProgTemplate`](@ref) with a polynomial set | semidefinite | Clarabel, SCS, MOSEK | -| piecewise quadratic | [`SetProgTemplate`](@ref) with a piecewise ellipsoid | semidefinite | Clarabel, SCS, MOSEK | -| polyhedral | [`ParallelFacesPolyhedralTemplate`](@ref), [`ConicPolyhedralTemplate`](@ref); [`SetProgTemplate`](@ref) with a piecewise polytope | linear | HiGHS | -| copositive | [`LinearCopositiveTemplate`](@ref), [`DualCopositiveTemplate`](@ref) | linear | HiGHS | - -A semidefinite solver such as Clarabel also solves the linear programs. - -[What works with what](@ref) says which problems accept which template. - -## Closure properties - -A lift is **valid** for a template, guaranteed not to worsen the bound, when -the template is closed under the operation the lift combines node functions -with ([debauche2021comparison](@cite), Defs. 6 and 7): the sum lift needs -closure under addition, the min and max lifts under pointwise minimum and -maximum, the composition lifts under composition with the dynamics. A result of -[`compare`](@ref) holds for a template when the lift that gave it is valid for -the template. [`is_closed_under`](@ref) returns the closures of a template: - -| Template | `+` | `max` | `min` | `∘ A` | -| :-- | :-: | :-: | :-: | :-: | -| [`QuadraticTemplate`](@ref) | ✓ | | | ✓ if invertible | -| [`SumOfSquaresTemplate`](@ref) | ✓ | | | ✓ if invertible | -| [`LinearCopositiveTemplate`](@ref) | ✓ | | valid on positive systems ¹ | ✓ if `A ≥ 0` with no zero column | -| [`DualCopositiveTemplate`](@ref) | | ✓ | | | -| [`ParallelFacesPolyhedralTemplate`](@ref) | | ✓ if the node matrices coincide | | | -| [`ConicPolyhedralTemplate`](@ref) | | | | | -| [`SetProgTemplate`](@ref) | ✓ if the nodes share the set variable | | | | - -¹ Not closed as a set of functions, yet the min lift is valid for it on -positive linear systems ([debauche2024thesis](@cite), Thm. 7.43): -[`is_valid`](@ref) reads it through the dual template, -[`DualCopositiveTemplate`](@ref), closed under `max`. - -Closure is a property of the template as implemented. Polyhedral norms are -closed under maximum, but the maximum of two members of the parallel-faces -template has the union of their facets, so that template is closed only when -every node has the same facet matrix. The free-facet template has a fixed -number of facets and declares no closure. - -## Dual templates - -[`has_dual`](@ref) tests whether a template has a dual, [`dual`](@ref)`(template)` -returns it, and `dual(template, V)` returns the dual norm of a fitted function -`V`. Quadratic forms are self-dual and the -copositive templates are each other's dual; the polyhedral, sum-of-squares and -SetProg templates have none here. A closure of one template of a dual pair is -used for both; see [Duality](@ref). +For stability, a template is a SetProg set family. Pass it directly to +[`certify`](@ref), [`jsr_bound`](@ref), [`compare`](@ref), or [`refine`](@ref). +[`functions`](@ref) returns fitted SetProg sets, each callable as `V(x)`. +The homogeneous sublevel function is the node's Lyapunov function. + +```julia +using SetProg +family = Ellipsoid(symmetric = true) +certificate = PCC.jsr_bound(family, graph, problem; optimizer) +``` + +## Families + +| SetProg family | Node function | Degree | Program | +| :-- | :-- | :-: | :-- | +| `Ellipsoid(symmetric = true)` | `x'Qx` | 2 | semidefinite | +| `PositiveSimplex()` | `c'x` on the nonnegative orthant | 1 | linear | +| `PositiveBox()` | `maximum(x ./ v)` on the nonnegative orthant | 1 | linear | +| `Parallelotope(G)` | `maximum(abs.(G*x) ./ w)` | 1 | linear | +| `ConicPolytope(cones)` | `maximum(abs.(P*x))` | 1 | linear | +| `PolySet(symmetric = true, degree = 2d)` | sum-of-squares polynomial | 2d | semidefinite | +| `Ellipsoid(symmetric = true, piecewise = partition)` | quadratic on each partition cone | 2 | semidefinite | +| `Polytope(symmetric = true, piecewise = partition)` | linear on each partition cone | 1 | linear | + +Load `SumOfSquares` before constructing a `PolySet`; otherwise SetProg gives +an error explaining the missing extension. Polynomial variables may be +supplied with `variables = x`, or inferred in a shared coordinate system. +SetProg is a direct dependency of PCC; its polynomial dependencies are optional. + +`Parallelotope()` uses the identity for `G`, with dimension inferred from the +system. `ConicPolytope` fixes the rays of a conic partition and solves for its +facet normals. `SetProg.planar_conic_partition(order)` supplies a symmetric +planar partition (the returned cones cover the upper half-plane). +`PositiveSimplex` and `PositiveBox` require nonnegative mode matrices. +Their positive coefficient floors, and those of the polyhedral families, +are configurable with `min_scale` or `min_weight`. + +The degree `p`, returned by [`homogeneity_degree`](@ref), satisfies +`V(c*x) = c^p * V(x)`. Stability uses `rate^p` in each edge inequality, +so every family's rate bounds the joint spectral radius. +Ellipsoids and polynomial sets are normalized against the Euclidean unit gauge. +A finite `conditioning_bound` keyword in `certify` or `jsr_bound` also bounds +an ellipsoid's matrix by `conditioning_bound * I`; the default is `Inf`. + +Clarabel solves semidefinite and linear models; HiGHS solves linear models. + +## Closure and duality + +Graph operations remain in PCC. [`is_closed_under`](@ref) declares a family's +closures; [`is_valid`](@ref) checks a lift, also using the dual family when available. + +| Family | Addition | Maximum | Composition with invertible dynamics | +| :-- | :-: | :-: | :-- | +| symmetric ellipsoid | ✓ | | ✓ | +| polynomial set | ✓ | | ✓ | +| positive simplex | ✓ | | if nonnegative with no zero column | +| positive box | | ✓ | | +| parallelotope | | ✓ when all node matrices coincide | | +| conic polytope | | | | +| piecewise ellipsoid or polytope | ✓ when the nodes share a partition | | | + +Symmetric nonpiecewise ellipsoids are self-dual. Positive simplices and boxes +are each other's blocking duals on the nonnegative orthant. Other families +have no fitted dual implementation here. See [Duality](@ref). ## Per-node data -The facet matrices of [`ParallelFacesPolyhedralTemplate`](@ref) and the set variables of -[`SetProgTemplate`](@ref) are attached to the nodes. When a refinement copies a -node, [`reindex`](@ref)`(template, origins)` gives the copy the data of the -node it came from. +A single family is shared by all nodes. A vector supplies one family per node: + +```julia +families = [Parallelotope(G1), Parallelotope(G2)] +certificate = PCC.jsr_bound(families, graph, problem; optimizer) +``` + +[`reindex`](@ref) follows node origins through lifts. Copies retain their +origin's family, and merging nodes requires equal families. +All nodes must use gauges of the same degree. -## Writing your own +## Deferred problems -A new template implements the primitives listed in the -[developer conventions](@ref "Conventions"): its variables, the edge -inequality, and how a solution is read back. The problems need no change. +Safety and optimal control still use [`QuadraticTemplate`](@ref) and their +existing fitted quadratic functions. Their migration to SetProg is deferred. diff --git a/docs/src/reference/templates.md b/docs/src/reference/templates.md index 5b41866..5c32139 100644 --- a/docs/src/reference/templates.md +++ b/docs/src/reference/templates.md @@ -4,67 +4,26 @@ CurrentModule = PathCompleteCertificates # Template reference -A template is the family the node functions are drawn from. See -[Templates](@ref) for the concepts, and [What works with what](@ref) for which problems accept each one. +Stability takes SetProg families directly. See [Templates](@ref) for their +parameters and [What works with what](@ref) for support by problem. -## The interface +## SetProg families ```@autodocs -Modules = [PathCompleteCertificates] -Pages = ["templates/abstract.jl"] -``` - -## Quadratic - -```@autodocs -Modules = [PathCompleteCertificates] -Pages = ["templates/catalogue/quadratic.jl"] -``` - -## Linear copositive - -```@autodocs -Modules = [PathCompleteCertificates] -Pages = ["templates/catalogue/linear_copositive.jl"] -``` - -## Dual copositive - -```@autodocs -Modules = [PathCompleteCertificates] -Pages = ["templates/catalogue/dual_copositive.jl"] -``` - -## Polyhedral, fixed facets - -```@autodocs -Modules = [PathCompleteCertificates] -Pages = ["templates/catalogue/parallel_faces_polyhedral.jl"] +Modules = [SetProg] +Pages = ["gauges.jl"] ``` -## Polyhedral, free facets +## Graph operations on families ```@autodocs Modules = [PathCompleteCertificates] -Pages = ["templates/catalogue/conic_polyhedral.jl"] +Pages = ["problems/sets.jl", "templates/abstract.jl"] ``` -## Sum of squares - -The primitives are defined in a package extension, so `using SumOfSquares` is -required before this template can be solved with. - -```@autodocs -Modules = [PathCompleteCertificates] -Pages = ["templates/catalogue/sum_of_squares.jl"] -``` - -## SetProg sets - -The primitives are defined in a package extension, so `using SetProg, -SumOfSquares` is required before this template can be solved with. +## Legacy quadratic template for safety and optimal control ```@autodocs Modules = [PathCompleteCertificates] -Pages = ["templates/catalogue/set_prog.jl"] +Pages = ["templates/catalogue/quadratic.jl"] ``` diff --git a/ext/PathCompleteCertificatesSetProgExt.jl b/ext/PathCompleteCertificatesSetProgExt.jl deleted file mode 100644 index 50ddc95..0000000 --- a/ext/PathCompleteCertificatesSetProgExt.jl +++ /dev/null @@ -1,206 +0,0 @@ -# The methods of `SetProgTemplate`. Every primitive is a set inclusion, modeled -# in SetProg's primal space, the only one where the normalization `S ⊆ unit` -# can be written. -module PathCompleteCertificatesSetProgExt - -import JuMP -import LinearAlgebra -import MultivariatePolynomials as MP -import SetProg -import SumOfSquares -import PathCompleteCertificates as PCC - -const Sets = SetProg.Sets - -_variable(template::PCC.SetProgTemplate, node::Integer) = - template.shared ? only(template.variables) : - PCC._node_datum(template.variables, node, "set variables") - -_degree(::SetProg.Ellipsoid) = 2 -_degree(variable::SetProg.PolySet) = variable.degree -_degree(::SetProg.Polytope) = 1 - -function PCC.homogeneity_degree(template::PCC.SetProgTemplate) - degrees = unique(_degree.(template.variables)) - - # One rate gamma^p for the whole graph needs one degree p. - length(degrees) == 1 || - throw(ArgumentError("the set variables of every node must have the same degree")) - - return only(degrees) -end - -# The variable with its dimension filled in, for `_unit` to read. -function _with_dimension(variable::SetProg.Ellipsoid, dimension) - variable.point === nothing && variable.superset === nothing || - throw(ArgumentError("`point` and `superset` are not supported by SetProgTemplate")) - variable.symmetric || - throw(ArgumentError("SetProgTemplate needs `Ellipsoid(symmetric = true)`")) - _check_dimension(variable.piecewise, dimension) - - return SetProg.Ellipsoid(; - symmetric = true, - dimension = dimension, - piecewise = variable.piecewise, - ) -end - -function _with_dimension(variable::SetProg.Polytope, dimension) - variable.symmetric && variable.piecewise !== nothing || throw( - ArgumentError( - "SetProgTemplate needs `Polytope(symmetric = true, piecewise = P)`: " * - "without a partition the function is linear, not a gauge", - ), - ) - _check_dimension(variable.piecewise, dimension) - - return SetProg.Polytope(; - symmetric = true, - dimension = dimension, - piecewise = variable.piecewise, - ) -end - -function _with_dimension(variable::SetProg.PolySet, dimension) - variable.point === nothing && variable.superset === nothing || - throw(ArgumentError("`point` and `superset` are not supported by SetProgTemplate")) - variable.symmetric || - throw(ArgumentError("SetProgTemplate needs `PolySet(symmetric = true)`")) - - # The unit set must be written in the same polynomial variables. - variable.variables === nothing && throw( - ArgumentError( - "SetProgTemplate needs the polynomial variables, " * - "use `PolySet(variables = x, ...)`", - ), - ) - length(variable.variables) == dimension || throw( - ArgumentError( - "the set variable carries $(length(variable.variables)) polynomial " * - "variables but the system has dimension $dimension", - ), - ) - - return SetProg.PolySet(; - symmetric = true, - degree = variable.degree, - dimension = dimension, - convex = variable.convex, - variables = variable.variables, - basis = variable.basis, - ) -end - -_check_dimension(::Nothing, _) = nothing - -function _check_dimension(partition, dimension) - SetProg.Polyhedra.fulldim(partition) == dimension || throw( - ArgumentError( - "the partition lives in R^$(SetProg.Polyhedra.fulldim(partition)) " * - "but the system is $dimension-dimensional", - ), - ) - - return nothing -end - -function PCC.add_function_variables!( - model::JuMP.Model, - template::PCC.SetProgTemplate, - dimension::Integer, - node::Integer, -) - @assert dimension > 0 - variable = _with_dimension(_variable(template, node), dimension) - - return JuMP.@variable(model, variable_type = variable, base_name = "S_$(node)") -end - -_identity(dimension) = - LinearAlgebra.Symmetric(Matrix(1.0LinearAlgebra.I, dimension, dimension)) - -# The 1-sublevel set of `‖x‖₂^p`, or of the gauge of the partition for a -# polytope, in the representation of the set variable. -function _unit(variable::SetProg.Ellipsoid) - ball = Sets.Ellipsoid(_identity(variable.dimension)) - variable.piecewise === nothing && return ball - - partition = variable.piecewise - - return Sets.Piecewise( - [ball for _ in SetProg.Polyhedra.halfspaces(partition)], - partition, - ) -end - -function _unit(variable::SetProg.Polytope) - partition = variable.piecewise - facets = [Sets.PolarPoint(h.a / h.β) for h in SetProg.Polyhedra.halfspaces(partition)] - - return Sets.Piecewise(facets, partition) -end - -function _unit(variable::SetProg.PolySet) - half_degree = div(variable.degree, 2) - monomials = MP.monomials(variable.variables, half_degree) - - # (x₁² + ⋯ + xₙ²)ᵈ = Σ_{|α| = d} d! / α! x^(2α), a diagonal Gram matrix. - coefficients = [ - factorial(half_degree) / prod(factorial, MP.exponents(monomial)) for - monomial in monomials - ] - gram = SumOfSquares.GramMatrix(Matrix(LinearAlgebra.Diagonal(coefficients)), monomials) - - return Sets.PolySet(variable.degree, gram) -end - -function PCC.add_normalization!( - model::JuMP.Model, - ::PCC.SetProgTemplate, - S::SetProg.SetVariableRef, -) - # `S ⊆ unit` is `V ≥ ‖x‖₂^p`, which only fixes the scale. - JuMP.@constraint(model, S ⊆ _unit(S.set)) - - return nothing -end - -function PCC.add_decrease!( - model::JuMP.Model, - template::PCC.SetProgTemplate, - S_src::SetProg.SetVariableRef, - S_dst::SetProg.SetVariableRef, - map::AbstractMatrix; - scale::Real = 1, -) - # `V_dst(A x) ≤ γᵖ V_src(x)` is `(A / γ) S_src ⊆ S_dst`. - scale > 0 || throw(ArgumentError("the scale must be positive, got $scale")) - - gamma = scale^(1 / PCC.homogeneity_degree(template)) - - JuMP.@constraint(model, (map ./ gamma) * S_src ⊆ S_dst) - - return nothing -end - -PCC.solution_value(::PCC.SetProgTemplate, S::SetProg.SetVariableRef) = - PCC.SetProgFunction(JuMP.value(S)) - -(V::PCC.SetProgFunction)(x::AbstractVector{<:Real}) = _evaluate(V.set, x) - -_evaluate(set::Sets.Ellipsoid, x) = LinearAlgebra.dot(x, set.Q * x) - -_evaluate(set::Union{Sets.PolySet, Sets.ConvexPolySet}, x) = - MP.polynomial(set.p)(Sets.space_variables(set) => collect(x)) - -_evaluate(set::Sets.PolarPoint, x) = LinearAlgebra.dot(set.a, x) - -function _evaluate(set::Sets.Piecewise, x) - # Continuous, so a point on a boundary may use either piece. - piece = findfirst(piece -> x in piece, set.pieces) - piece === nothing && throw(ArgumentError("$x is in none of the pieces")) - - return _evaluate(set.sets[piece], x) -end - -end diff --git a/ext/PathCompleteCertificatesSumOfSquaresExt.jl b/ext/PathCompleteCertificatesSumOfSquaresExt.jl deleted file mode 100644 index 3e8b427..0000000 --- a/ext/PathCompleteCertificatesSumOfSquaresExt.jl +++ /dev/null @@ -1,65 +0,0 @@ -# The methods of `SumOfSquaresTemplate`. -module PathCompleteCertificatesSumOfSquaresExt - -import JuMP -import SumOfSquares -import MultivariatePolynomials -import PathCompleteCertificates as PCC - -using SumOfSquares: SOSPoly, SOSCone -using MultivariatePolynomials: monomials, subs - -function PCC.add_function_variables!( - model::JuMP.Model, - template::PCC.SumOfSquaresTemplate, - dimension::Integer, - node::Integer, -) - dimension == length(template.variables) || throw( - ArgumentError( - "the template carries $(length(template.variables)) polynomial " * - "variables but the system has dimension $dimension", - ), - ) - - # Turns the plain JuMP model into a `SOSModel`. - SumOfSquares.PolyJuMP.setpolymodule!(model, SumOfSquares) - - return JuMP.@variable( - model, - variable_type = SOSPoly(monomials(template.variables, template.degree)) - ) -end - -# V(x) >= min_scale * ||x||^(2 degree). -function PCC.add_normalization!(model::JuMP.Model, template::PCC.SumOfSquaresTemplate, V) - x = template.variables - bound = template.min_scale * sum(x .^ 2)^template.degree - - JuMP.@constraint(model, V - bound in SOSCone()) - - return nothing -end - -function PCC.add_decrease!( - model::JuMP.Model, - template::PCC.SumOfSquaresTemplate, - V_src, - V_dst, - map::AbstractMatrix; - scale::Real = 1, -) - x = template.variables - - # With scale = gamma^(2d), V_src(x) - V_dst((A/gamma) x) >= 0 is better - # conditioned than scale * V_src(x) - V_dst(A x) >= 0. - gamma = scale^(1 / PCC.homogeneity_degree(template)) - JuMP.@constraint(model, V_src - subs(V_dst, x => (map ./ gamma) * x) in SOSCone()) - - return nothing -end - -PCC.solution_value(template::PCC.SumOfSquaresTemplate, V) = - PCC.SumOfSquaresFunction(JuMP.value(V), template.variables) - -end diff --git a/src/PathCompleteCertificates.jl b/src/PathCompleteCertificates.jl index 27aa95e..fdc1753 100644 --- a/src/PathCompleteCertificates.jl +++ b/src/PathCompleteCertificates.jl @@ -11,6 +11,7 @@ import JuMP import LinearAlgebra import MathematicalSystems import Random +import SetProg const _HS = HybridSystems const _MS = MathematicalSystems @@ -46,16 +47,11 @@ include("systems/simulate.jl") include("templates/abstract.jl") include("problems/abstract.jl") +include("problems/sets.jl") include("problems/certificate.jl") include("problems/tight_edges.jl") -include("templates/catalogue/linear_copositive.jl") -include("templates/catalogue/dual_copositive.jl") include("templates/catalogue/quadratic.jl") -include("templates/catalogue/parallel_faces_polyhedral.jl") -include("templates/catalogue/conic_polyhedral.jl") -include("templates/catalogue/sum_of_squares.jl") -include("templates/catalogue/set_prog.jl") include("problems/catalogue/stability.jl") include("problems/catalogue/safety.jl") diff --git a/src/lifts/compare.jl b/src/lifts/compare.jl index 7916215..0082e9c 100644 --- a/src/lifts/compare.jl +++ b/src/lifts/compare.jl @@ -7,7 +7,7 @@ What [`compare`](@ref) returns: the [`lifts`](@ref) tried, the struct Comparison lifts::Vector{AbstractLift} simulations::Vector{Pair{AbstractLift, Any}} - templates::Vector{AbstractTemplate} + templates::Vector{Union{AbstractTemplate, _SetFamily}} end """ @@ -80,7 +80,7 @@ function compare( L isa IdentityLift && break end - proven = AbstractTemplate[ + proven = Union{AbstractTemplate, _SetFamily}[ template for template in templates if any(is_valid(L, template, system) for (L, _) in found) ] diff --git a/src/problems/catalogue/stability.jl b/src/problems/catalogue/stability.jl index 94be680..7ffd78f 100644 --- a/src/problems/catalogue/stability.jl +++ b/src/problems/catalogue/stability.jl @@ -44,19 +44,21 @@ function _edge_decrease( end """ - optimization_model(template, graph, problem; optimizer, rate = 1, - check_path_complete = true) + optimization_model(family, graph, problem; optimizer, rate = 1, + conditioning_bound = Inf) -The feasibility problem `V_b(A_w x) <= rate^(p |w|) * V_a(x)` for every edge +A SetProg model, whose set variables are expanded when optimized, for +the feasibility problem `V_b(A_w x) <= rate^(p |w|) * V_a(x)` for every edge `(a, b, w)`, with `p` the template's degree and `|w|` the length of the word. """ function optimization_model( - template::AbstractTemplate, + template::_SetFamily, graph::CertificateGraph, problem::StabilityProblem; optimizer, rate::Real = 1, check_path_complete::Bool = true, + conditioning_bound::Real = Inf, ) rate >= 0 || throw(ArgumentError("the rate must be nonnegative")) @@ -73,15 +75,26 @@ function optimization_model( model = JuMP.Model(optimizer) JuMP.set_silent(model) - Vs = [add_function_variables!(model, template, dimension, a) for a in nodes(graph)] + families = _prepared_families(template, dimension, n_nodes(graph)) + Vs = [ + SetProg.add_function_variables!(model, families[a], dimension, a) for + a in nodes(graph) + ] for V in Vs - add_normalization!(model, template, V) + SetProg.add_normalization!(model, V.set, V; conditioning_bound) end for edge in edges(graph) dynamics, scale = _edge_decrease(problem, graph, edge, A; rate, degree) - add_decrease!(model, template, Vs[source(edge)], Vs[dest(edge)], dynamics; scale) + SetProg.add_decrease!( + model, + Vs[source(edge)].set, + Vs[source(edge)], + Vs[dest(edge)], + dynamics; + scale, + ) end model[:stability_V] = Vs @@ -97,7 +110,7 @@ The [`StabilityCertificate`](@ref) of the smallest feasible rate, to relative tolerance `rtol`. `initial_upper` is a first guess of a feasible rate. """ function jsr_bound( - template::AbstractTemplate, + template::_SetFamily, graph::CertificateGraph, problem::StabilityProblem; optimizer, @@ -105,6 +118,7 @@ function jsr_bound( max_iterations::Integer = 100, initial_upper::Real = 1.0, check_path_complete::Bool = true, + conditioning_bound::Real = Inf, ) rtol > 0 || throw(ArgumentError("rtol must be positive")) @@ -128,6 +142,7 @@ function jsr_bound( optimizer, rate, check_path_complete = false, + conditioning_bound = conditioning_bound, ) JuMP.optimize!(model) return JuMP.is_solved_and_feasible(model; allow_almost = true) @@ -159,9 +174,10 @@ function jsr_bound( template, graph, problem; - optimizer = optimizer, + optimizer, rate = upper, check_path_complete = false, + conditioning_bound, ) is_feasible(certificate) || @@ -174,18 +190,28 @@ end certify(template, graph, problem::StabilityProblem; optimizer, rate = 1, check_path_complete = true) -Solve the stability problem at the fixed `rate`. +Solve the stability problem at the fixed `rate`, using a SetProg family or +a vector of families, one per node. A finite `conditioning_bound` bounds +ellipsoid gauge matrices between `I` and `conditioning_bound * I`. """ function certify( - template::AbstractTemplate, + template::_SetFamily, graph::CertificateGraph, problem::StabilityProblem; optimizer, rate::Real = 1, check_path_complete::Bool = true, + conditioning_bound::Real = Inf, ) - model = - optimization_model(template, graph, problem; optimizer, rate, check_path_complete) + model = optimization_model( + template, + graph, + problem; + optimizer, + rate, + check_path_complete, + conditioning_bound, + ) JuMP.optimize!(model) termination = JuMP.termination_status(model) @@ -193,7 +219,7 @@ function certify( JuMP.is_solved_and_feasible(model; allow_almost = true) || return StabilityCertificate(_failed(problem, template, graph, termination), rate) - V = [solution_value(template, v) for v in model[:stability_V]] + V = [SetProg.solution_value(v.set, v) for v in model[:stability_V]] return StabilityCertificate( CertificateData(problem, template, graph, V, termination, true), @@ -234,7 +260,7 @@ function dual(certificate::StabilityCertificate) end function _check_stability_data( - template::AbstractTemplate, + template::_SetFamily, graph::CertificateGraph, problem::StabilityProblem; check_path_complete::Bool = true, @@ -262,7 +288,7 @@ The certificate of smallest rate on `graph`, by [`jsr_bound`](@ref) to relative tolerance `rtol`, where [`certify`](@ref) checks one given rate. """ function best_certificate( - template::AbstractTemplate, + template::_SetFamily, graph::CertificateGraph, problem::StabilityProblem; optimizer, diff --git a/src/problems/certificate.jl b/src/problems/certificate.jl index ac03e85..684f560 100644 --- a/src/problems/certificate.jl +++ b/src/problems/certificate.jl @@ -23,7 +23,13 @@ The fields shared by every certificate: - `feasible`, whether the solve produced a certificate, read by [`is_feasible`](@ref). """ -struct CertificateData{P <: AbstractProblem, T <: AbstractTemplate, G, F, S} +struct CertificateData{ + P <: AbstractProblem, + T <: Union{AbstractTemplate, _SetFamily}, + G, + F, + S, +} problem::P template::T graph::G @@ -33,7 +39,12 @@ struct CertificateData{P <: AbstractProblem, T <: AbstractTemplate, G, F, S} end # The CertificateData of a failed solve. -function _failed(problem::AbstractProblem, template::AbstractTemplate, graph, status) +function _failed( + problem::AbstractProblem, + template::Union{AbstractTemplate, _SetFamily}, + graph, + status, +) return CertificateData(problem, template, graph, Any[], status, false) end @@ -167,6 +178,12 @@ function _edge_slacks(certificate::AbstractCertificate; kwargs...) return map(edges(graph_)) do edge dynamics, scale = _edge_decrease(problem_, graph_, edge, A; kwargs...) - return decrease_slack(template_, V[source(edge)], V[dest(edge)], dynamics; scale) + return decrease_slack( + template_ isa AbstractVector ? template_[source(edge)] : template_, + V[source(edge)], + V[dest(edge)], + dynamics; + scale, + ) end end diff --git a/src/problems/sets.jl b/src/problems/sets.jl new file mode 100644 index 0000000..2fbf05b --- /dev/null +++ b/src/problems/sets.jl @@ -0,0 +1,126 @@ +const _SetFamily = + Union{SetProg.AbstractVariable, AbstractVector{<:SetProg.AbstractVariable}} + +""" + is_applicable(family, system) + +Whether the SetProg family applies to the system. Orthant gauges require +nonnegative dynamics. +""" +is_applicable(::SetProg.AbstractVariable, system) = true +is_applicable(::Union{SetProg.PositiveSimplex, SetProg.PositiveBox}, system) = + is_positive(system) +is_applicable(families::AbstractVector{<:SetProg.AbstractVariable}, system) = + all(f -> is_applicable(f, system), families) + +""" + is_closed_under(family, operation, system) + +Whether a SetProg gauge family admits the operation required by a graph lift. +""" +is_closed_under(::SetProg.AbstractVariable, ::Operation, system) = false +is_closed_under( + ::Union{SetProg.Ellipsoid, SetProg.PolySet, SetProg.PositiveSimplex}, + ::Addition, + system, +) = true +is_closed_under(f::SetProg.Ellipsoid, ::Composition, system) = + f.piecewise === nothing && is_invertible(system) +is_closed_under(::SetProg.PolySet, ::Composition, system) = is_invertible(system) +is_closed_under(f::SetProg.Ellipsoid, ::InverseComposition, system) = + f.piecewise === nothing && is_invertible(system) +is_closed_under(::SetProg.PolySet, ::InverseComposition, system) = is_invertible(system) +is_closed_under(::SetProg.Polytope, ::Addition, system) = true +is_closed_under(::Union{SetProg.PositiveBox, SetProg.Parallelotope}, ::Maximum, system) = + true +function is_closed_under(::SetProg.PositiveSimplex, ::Composition, system) + return all(A -> all(>=(0), A) && all(>(0), sum(A; dims = 1)), mode_matrices(system)) +end +function is_closed_under(::SetProg.PositiveSimplex, ::InverseComposition, system) + return is_invertible(system) && all(A -> all(>=(0), inv(A)), mode_matrices(system)) +end +function is_closed_under( + families::AbstractVector{<:SetProg.AbstractVariable}, + operation::Operation, + system, +) + if operation isa Maximum && all(f -> f isa SetProg.Parallelotope, families) + return allequal(getproperty.(families, :G)) + end + return allequal(families) && is_closed_under(first(families), operation, system) +end + +""" + reindex(family, origins) + +Follow node origins when lifting a graph. A shared family is unchanged; a +vector supplies one SetProg family per node and cannot merge unequal families. +""" +reindex(family::SetProg.AbstractVariable, ::AbstractVector) = family +reindex(families::AbstractVector{<:SetProg.AbstractVariable}, origins::AbstractVector) = + _reindexed_data(families, origins, "set families") + +""" + has_dual(family) + +Whether a SetProg gauge family has an implemented dual. +""" +has_dual(family::SetProg.AbstractVariable) = SetProg.supports_dual(family) +has_dual(families::AbstractVector{<:SetProg.AbstractVariable}) = all(has_dual, families) + +dual(family::SetProg.AbstractVariable) = + has_dual(family) ? SetProg.dual_family(family) : + throw(ArgumentError("no dual gauge family is implemented for $(typeof(family))")) +dual(families::AbstractVector{<:SetProg.AbstractVariable}) = dual.(families) + +""" + dual(family, set) + +The fitted dual homogeneous gauge as a SetProg set. +""" +dual(family::SetProg.AbstractVariable, set) = + has_dual(family) ? SetProg.dual_value(set) : + throw(ArgumentError("no dual gauge is implemented for $(typeof(family))")) +dual(families::AbstractVector{<:SetProg.AbstractVariable}, set) = SetProg.dual_value(set) + +function _prepared_families(family::_SetFamily, dimension, count) + families = family isa AbstractVector ? family : fill(family, count) + length(families) == count || throw(ArgumentError("one set family is required per node")) + polynomials = filter(f -> f isa SetProg.PolySet, families) + x = nothing + if !isempty(polynomials) + explicit = [f.variables for f in polynomials if f.variables !== nothing] + x = isempty(explicit) ? SetProg.new_polyvars(dimension) : first(explicit) + all(==(x), explicit) || + throw(ArgumentError("polynomial sets must share their variables")) + end + return [SetProg.with_dimension(f, dimension; variables = x) for f in families] +end + +function homogeneity_degree(family::_SetFamily) + return SetProg.homogeneity_degree(family) +end +function add_function_variables!(model, family::SetProg.AbstractVariable, dimension, node) + return SetProg.add_function_variables!(model, family, dimension, node) +end +function add_normalization!(model, family::SetProg.AbstractVariable, V; kwargs...) + return SetProg.add_normalization!(model, family, V; kwargs...) +end +function add_decrease!( + model, + family::SetProg.AbstractVariable, + source, + destination, + A; + kwargs..., +) + return SetProg.add_decrease!(model, family, source, destination, A; kwargs...) +end +function solution_value(family::SetProg.AbstractVariable, V) + return SetProg.solution_value(family, V) +end +function decrease_slack(family::SetProg.AbstractVariable, source, destination, A; kwargs...) + return SetProg.decrease_slack(family, source, destination, A; kwargs...) +end + +node_value(::_SetFamily, ::AbstractProblem, V, x::AbstractVector{<:Real}) = V(x) diff --git a/src/refinements/refine.jl b/src/refinements/refine.jl index 4ead6da..067ecf3 100644 --- a/src/refinements/refine.jl +++ b/src/refinements/refine.jl @@ -114,7 +114,7 @@ gives the next graph, a lift of the current one. Other keywords go to [`best_certificate`](@ref). """ function refine( - template::AbstractTemplate, + template::Union{AbstractTemplate, _SetFamily}, problem::AbstractProblem; optimizer, algorithm::AbstractRefinementAlgorithm = TightestNodeSplitting(), @@ -125,7 +125,7 @@ function refine( end function refine( - template::AbstractTemplate, + template::Union{AbstractTemplate, _SetFamily}, start::CertificateGraph, problem::AbstractProblem; optimizer, diff --git a/src/templates/abstract.jl b/src/templates/abstract.jl index 8af07bf..9bb978e 100644 --- a/src/templates/abstract.jl +++ b/src/templates/abstract.jl @@ -1,8 +1,9 @@ """ AbstractTemplate -A family of candidate node functions, passed as an instance such as -`QuadraticTemplate()`. +A legacy family of candidate node functions for safety and optimal control, +passed as an instance such as `QuadraticTemplate()`. Stability accepts SetProg +set families directly. """ abstract type AbstractTemplate end diff --git a/src/templates/catalogue/conic_polyhedral.jl b/src/templates/catalogue/conic_polyhedral.jl deleted file mode 100644 index f2df078..0000000 --- a/src/templates/catalogue/conic_polyhedral.jl +++ /dev/null @@ -1,207 +0,0 @@ -""" - ConicPolyhedralTemplate(cones; min_scale = 1) - -The general polyhedral template: at node ``s``, - -```math -V_s(x) = \\max_i |p_{s,i}^\\top x|, -``` - -with the rows of ``P_s`` as variables. `cones[s]` partitions the state space -into cones, one per row of ``P_s``, each a matrix whose columns are its extreme -rays; row ``i`` is constrained to attain the maximum on cone ``i``. `min_scale` -is the lower bound on ``c_s`` in ``V_s(x) \\ge c_s \\|x\\|_\\infty``. -The program is homogeneous, so `min_scale` only fixes its scale; a much -smaller one lets the solver's absolute tolerance show in the rate. -""" -struct ConicPolyhedralTemplate{T <: Real} <: AbstractTemplate - cones::Vector{Vector{Matrix{T}}} - min_scale::T - - function ConicPolyhedralTemplate( - cones::AbstractVector{<:AbstractVector{<:AbstractMatrix{T}}}; - min_scale::Real = 1, - ) where {T <: Real} - isempty(cones) && throw(ArgumentError("at least one node partition is required")) - min_scale > 0 || throw(ArgumentError("min_scale must be positive")) - - for (node, node_cones) in enumerate(cones) - isempty(node_cones) && - throw(ArgumentError("node $node must have at least one cone")) - end - - dimension = size(first(first(cones)), 1) - - for (node, node_cones) in enumerate(cones) - for (i, cone) in enumerate(node_cones) - size(cone, 1) == dimension || throw( - ArgumentError( - "cone $i of node $node lives in R^$(size(cone, 1)); " * - "expected R^$dimension", - ), - ) - - size(cone, 2) >= 1 || - throw(ArgumentError("cone $i of node $node has no extreme ray")) - end - end - - # Floating point, so that integer rays take the default bound. - F = float(promote_type(T, typeof(min_scale))) - - return new{F}( - [collect(Matrix{F}, node_cones) for node_cones in cones], - convert(F, min_scale), - ) - end -end - -""" - ConicPolyhedralFunction(cones, P, c) - -A node function `V(x) = max_i |(P x)_i|` of a [`ConicPolyhedralTemplate`](@ref), -with `V(x) ≥ c ‖x‖_∞`. -""" -struct ConicPolyhedralFunction{C, M, S} - cones::C - P::M - c::S -end - -(V::ConicPolyhedralFunction)(x::AbstractVector{<:Real}) = maximum(abs.(V.P * x)) - -function add_function_variables!( - model::JuMP.Model, - template::ConicPolyhedralTemplate, - dimension::Integer, - node::Integer, -) - @assert dimension > 0 - - cones = _node_datum(template.cones, node, "node partitions") - - size(first(cones), 1) == dimension || throw( - ArgumentError( - "the partition of node $node lives in R^$(size(first(cones), 1)) but " * - "the system is $dimension-dimensional", - ), - ) - - P = JuMP.@variable(model, [1:length(cones), 1:dimension], base_name = "P_$(node)") - c = JuMP.@variable(model, base_name = "c_$(node)") - - # Row i attains the maximum on cone i, which makes V linear on each cone. - for (i, cone) in enumerate(cones), ray in eachcol(cone) - values = P * ray - for k in eachindex(cones) - k == i && continue - JuMP.@constraint(model, values[i] + values[k] >= 0) - JuMP.@constraint(model, values[i] - values[k] >= 0) - end - end - - return ConicPolyhedralFunction(cones, P, c) -end - -solution_value(::ConicPolyhedralTemplate, V::ConicPolyhedralFunction) = - ConicPolyhedralFunction(V.cones, JuMP.value.(V.P), JuMP.value(V.c)) - -""" - planar_conic_partition(order) - -A partition of the upper half-plane into `4 * 2^(order - 1)` cones, each a -`2 x 2` matrix whose columns are its extreme rays; enough since the functions -of [`ConicPolyhedralTemplate`](@ref) are symmetric. -""" -function planar_conic_partition(order::Integer) - order >= 1 || throw(ArgumentError("The order must be positive.")) - - rays = ([1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [-1.0, 1.0], [-1.0, 0.0]) - cones = [hcat(rays[i], rays[i + 1]) for i in 1:(length(rays) - 1)] - - for _ in 2:order - refined = Matrix{Float64}[] - - for cone in cones - first_ray = vec(cone[:, 1]) - second_ray = vec(cone[:, 2]) - middle = first_ray + second_ray - - push!(refined, hcat(first_ray, middle)) - push!(refined, hcat(middle, second_ray)) - end - - cones = refined - end - - return cones -end - -homogeneity_degree(::ConicPolyhedralTemplate) = 1 - -# V(x) ≥ c ‖x‖_∞ with c ≥ min_scale: on cone i, row i is at least c |x_j|. -function add_normalization!( - model::JuMP.Model, - template::ConicPolyhedralTemplate, - V::ConicPolyhedralFunction, -) - JuMP.@constraint(model, V.c >= template.min_scale) - - for (i, cone) in enumerate(V.cones), ray in eachcol(cone) - value = (V.P * ray)[i] - for coordinate in ray - JuMP.@constraint(model, value + V.c * coordinate >= 0) - JuMP.@constraint(model, value - V.c * coordinate >= 0) - end - end - - return nothing -end - -function add_decrease!( - model::JuMP.Model, - ::ConicPolyhedralTemplate, - V_src::ConicPolyhedralFunction, - V_dst::ConicPolyhedralFunction, - map::AbstractMatrix; - scale = 1, -) - # On cone i, V_src is row i, so the condition is linear; imposing it on the - # extreme rays covers the cone by homogeneity and convexity. - for (i, cone) in enumerate(V_src.cones), ray in eachcol(cone) - source_value = (V_src.P * ray)[i] - destination_values = V_dst.P * (map * ray) - - JuMP.@constraint(model, scale * source_value .+ destination_values .>= 0) - JuMP.@constraint(model, scale * source_value .- destination_values .>= 0) - end - - return nothing -end - -function decrease_slack( - ::ConicPolyhedralTemplate, - V_src::ConicPolyhedralFunction, - V_dst::ConicPolyhedralFunction, - map::AbstractMatrix; - scale = 1, -) - worst = Inf - - for (i, cone) in enumerate(V_src.cones) - for ray in eachcol(cone) - source_value = (V_src.P * ray)[i] - destination_values = V_dst.P * (map * ray) - - worst = min(worst, minimum(scale * source_value .- abs.(destination_values))) - end - end - - return worst -end - -reindex(template::ConicPolyhedralTemplate, origins::AbstractVector) = - ConicPolyhedralTemplate( - _reindexed_data(template.cones, origins, "partitions"); - min_scale = template.min_scale, - ) diff --git a/src/templates/catalogue/dual_copositive.jl b/src/templates/catalogue/dual_copositive.jl deleted file mode 100644 index 012aa28..0000000 --- a/src/templates/catalogue/dual_copositive.jl +++ /dev/null @@ -1,88 +0,0 @@ -""" - DualCopositiveTemplate(; min_weight = 1) - -The template ``V(x) = \\max_i x_i / v_i`` on the nonnegative orthant, with -every ``v_i`` at least `min_weight`: the dual of -[`LinearCopositiveTemplate`](@ref). For positive systems only. The program is -homogeneous, so `min_weight` only fixes its scale; a much smaller one lets the -solver's absolute tolerance show in the rate. -""" -struct DualCopositiveTemplate{T <: Real} <: AbstractTemplate - min_weight::T - - function DualCopositiveTemplate(; min_weight::Real = 1) - min_weight > 0 || throw(ArgumentError("min_weight must be positive")) - return new{typeof(min_weight)}(min_weight) - end -end - -""" - DualCopositiveFunction(v) - -A node function `V(x) = max_i x_i / v_i` of a [`DualCopositiveTemplate`](@ref). -""" -struct DualCopositiveFunction{W <: AbstractVector} - v::W -end - -(V::DualCopositiveFunction)(x::AbstractVector{<:Real}) = maximum(x ./ V.v) - -# The weights are a denominator, so they are bounded away from zero. -function add_function_variables!( - model::JuMP.Model, - template::DualCopositiveTemplate, - dimension::Integer, - node::Integer, -) - @assert dimension > 0 - v = JuMP.@variable( - model, - [1:dimension], - lower_bound = template.min_weight, - base_name = "v_$(node)", - ) - return DualCopositiveFunction(v) -end - -solution_value(::DualCopositiveTemplate, V::DualCopositiveFunction) = - DualCopositiveFunction(JuMP.value.(V.v)) - -homogeneity_degree(::DualCopositiveTemplate) = 1 - -# The bounded weights already make V positive on the orthant. -add_normalization!(::JuMP.Model, ::DualCopositiveTemplate, ::DualCopositiveFunction) = - nothing - -function add_decrease!( - model::JuMP.Model, - ::DualCopositiveTemplate, - V_src::DualCopositiveFunction, - V_dst::DualCopositiveFunction, - map::AbstractMatrix; - scale = 1, -) - # V_dst(A x) ≤ scale · V_src(x) for every x ≥ 0 is A v_src ≤ scale · v_dst. - JuMP.@constraint(model, map * V_src.v .<= scale * V_dst.v) - - return nothing -end - -# On the orthant only: a positive system never leaves it. -is_applicable(::DualCopositiveTemplate, system) = _is_positive(system) - -function decrease_slack( - ::DualCopositiveTemplate, - V_src::DualCopositiveFunction, - V_dst::DualCopositiveFunction, - map::AbstractMatrix; - scale = 1, -) - return minimum(scale * V_dst.v - map * V_src.v) -end - -# max(V_v, V_w) = V_min(v, w) [debauche2021comparison, Prop. 5]. -is_closed_under(::DualCopositiveTemplate, ::Maximum, system) = true - -has_dual(::DualCopositiveTemplate) = true -dual(::DualCopositiveTemplate) = LinearCopositiveTemplate() -dual(::DualCopositiveTemplate, V::DualCopositiveFunction) = LinearCopositiveFunction(V.v) diff --git a/src/templates/catalogue/linear_copositive.jl b/src/templates/catalogue/linear_copositive.jl deleted file mode 100644 index 0a61cb8..0000000 --- a/src/templates/catalogue/linear_copositive.jl +++ /dev/null @@ -1,81 +0,0 @@ -""" - LinearCopositiveTemplate() - -The template ``V(x) = c^\\top x`` on the nonnegative orthant, for positive -systems only. -""" -struct LinearCopositiveTemplate <: AbstractTemplate end - -""" - LinearCopositiveFunction(c) - -A node function `V(x) = c'x` of a [`LinearCopositiveTemplate`](@ref). -""" -struct LinearCopositiveFunction{C <: AbstractVector} - c::C -end - -(V::LinearCopositiveFunction)(x::AbstractVector{<:Real}) = LinearAlgebra.dot(V.c, x) - -function add_function_variables!( - model::JuMP.Model, - ::LinearCopositiveTemplate, - dimension::Integer, - node::Integer, -) - @assert dimension > 0 - return JuMP.@variable(model, [1:dimension], base_name = "c_$(node)") -end - -solution_value(::LinearCopositiveTemplate, c) = LinearCopositiveFunction(JuMP.value.(c)) - -homogeneity_degree(::LinearCopositiveTemplate) = 1 - -function add_normalization!(model::JuMP.Model, ::LinearCopositiveTemplate, c) - JuMP.@constraint(model, c .>= 1) - - return nothing -end - -function add_decrease!( - model::JuMP.Model, - ::LinearCopositiveTemplate, - c_src, - c_dst, - map::AbstractMatrix; - scale = 1, -) - JuMP.@constraint(model, transpose(map) * c_dst .<= scale * c_src) - - return nothing -end - -# On the orthant only: a positive system never leaves it. -is_applicable(::LinearCopositiveTemplate, system) = _is_positive(system) - -function decrease_slack( - ::LinearCopositiveTemplate, - V_src::LinearCopositiveFunction, - V_dst::LinearCopositiveFunction, - map::AbstractMatrix; - scale = 1, -) - return minimum(scale * V_src.c - transpose(map) * V_dst.c) -end - -# Under composition, (Aᵀc)ᵀx stays positive when A ≥ 0 has no zero column; -# under the inverse, when A⁻¹ ≥ 0. -is_closed_under(::LinearCopositiveTemplate, ::Addition, system) = true - -function is_closed_under(::LinearCopositiveTemplate, ::Composition, system) - return all(A -> all(>=(0), A) && all(>(0), sum(A; dims = 1)), state_matrices(system)) -end - -function is_closed_under(::LinearCopositiveTemplate, ::InverseComposition, system) - _is_invertible(system) || return false - return all(A -> all(>=(0), inv(A)), state_matrices(system)) -end - -has_dual(::LinearCopositiveTemplate) = true -dual(::LinearCopositiveTemplate) = DualCopositiveTemplate() -dual(::LinearCopositiveTemplate, V::LinearCopositiveFunction) = DualCopositiveFunction(V.c) diff --git a/src/templates/catalogue/parallel_faces_polyhedral.jl b/src/templates/catalogue/parallel_faces_polyhedral.jl deleted file mode 100644 index d8433a2..0000000 --- a/src/templates/catalogue/parallel_faces_polyhedral.jl +++ /dev/null @@ -1,158 +0,0 @@ -""" - ParallelFacesPolyhedralTemplate(G; min_weight = 1) - ParallelFacesPolyhedralTemplate(n_nodes, dimension; min_weight = 1) - -The polytopes with pairs of parallel faces of -[athanasopoulos2019polyhedral](@citet), eq. (10): at node ``s``, - -```math -V_s(x) = \\max_k \\frac{|(G_s x)_k|}{w_{s,k}}, -``` - -where `G` holds one fixed invertible matrix per node (the identity by default) -and the weights ``w_{s,k}``, at least `min_weight`, are the variables. The -program is homogeneous, so `min_weight` only fixes its scale; a much smaller -one lets the solver's absolute tolerance show in the rate. -""" -struct ParallelFacesPolyhedralTemplate{T <: Real} <: AbstractTemplate - G::Vector{Matrix{T}} - min_weight::T - - function ParallelFacesPolyhedralTemplate( - G::AbstractVector{<:AbstractMatrix{T}}; - min_weight::Real = 1, - ) where {T <: Real} - isempty(G) && throw(ArgumentError("at least one node matrix is required")) - min_weight > 0 || throw(ArgumentError("min_weight must be positive")) - - dimension = size(first(G), 1) - - for (node, G_node) in enumerate(G) - size(G_node) == (dimension, dimension) || throw( - ArgumentError( - "G[$node] has size $(size(G_node)); expected " * - "($dimension, $dimension)", - ), - ) - - # The edge condition needs G_src^-1. - LinearAlgebra.rank(G_node) == dimension || - throw(ArgumentError("G[$node] is singular; it must have full rank")) - end - - # Floating point, so that integer node matrices take the default bound. - F = float(promote_type(T, typeof(min_weight))) - - return new{F}(collect(Matrix{F}, G), convert(F, min_weight)) - end -end - -function ParallelFacesPolyhedralTemplate( - n_nodes::Integer, - dimension::Integer; - min_weight::Real = 1, -) - n_nodes > 0 || throw(ArgumentError("n_nodes must be positive")) - dimension > 0 || throw(ArgumentError("dimension must be positive")) - - identity = Matrix{Float64}(LinearAlgebra.I, dimension, dimension) - - return ParallelFacesPolyhedralTemplate( - [copy(identity) for _ in 1:n_nodes]; - min_weight = min_weight, - ) -end - -""" - ParallelFacesPolyhedralFunction(G, w) - -A node function `V(x) = max_k |(Gx)_k| / w_k` of a [`ParallelFacesPolyhedralTemplate`](@ref). -""" -struct ParallelFacesPolyhedralFunction{M <: AbstractMatrix, W <: AbstractVector} - G::M - w::W -end - -(V::ParallelFacesPolyhedralFunction)(x::AbstractVector{<:Real}) = - maximum(abs.(V.G * x) ./ V.w) - -function add_function_variables!( - model::JuMP.Model, - template::ParallelFacesPolyhedralTemplate, - dimension::Integer, - node::Integer, -) - @assert dimension > 0 - - G = _node_datum(template.G, node, "node matrices") - - size(G, 1) == dimension || throw( - ArgumentError( - "G[$node] is $(size(G, 1))-dimensional but the system is " * - "$dimension-dimensional", - ), - ) - - # The weights are a denominator, so they are bounded away from zero. - w = JuMP.@variable( - model, - [1:dimension], - lower_bound = template.min_weight, - base_name = "w_$(node)", - ) - - return ParallelFacesPolyhedralFunction(G, w) -end - -solution_value(::ParallelFacesPolyhedralTemplate, V::ParallelFacesPolyhedralFunction) = - ParallelFacesPolyhedralFunction(V.G, JuMP.value.(V.w)) - -homogeneity_degree(::ParallelFacesPolyhedralTemplate) = 1 - -function add_normalization!( - model::JuMP.Model, - ::ParallelFacesPolyhedralTemplate, - ::ParallelFacesPolyhedralFunction, -) - # The bounded weights and the invertible G already make V positive. - return nothing -end - -function add_decrease!( - model::JuMP.Model, - ::ParallelFacesPolyhedralTemplate, - V_src::ParallelFacesPolyhedralFunction, - V_dst::ParallelFacesPolyhedralFunction, - map::AbstractMatrix; - scale = 1, -) - # The induced infinity-norm bound |G_dst map G_src^-1| w_src <= scale * w_dst; - # w_dst carries the scale since w is a denominator. - M = abs.(V_dst.G * map * inv(V_src.G)) - - JuMP.@constraint(model, M * V_src.w .<= scale * V_dst.w) - - return nothing -end - -function decrease_slack( - ::ParallelFacesPolyhedralTemplate, - V_src::ParallelFacesPolyhedralFunction, - V_dst::ParallelFacesPolyhedralFunction, - map::AbstractMatrix; - scale = 1, -) - M = abs.(V_dst.G * map * inv(V_src.G)) - - return minimum(scale * V_dst.w - M * V_src.w) -end - -# The max of two such norms with the same G is one, with the smaller weights. -is_closed_under(template::ParallelFacesPolyhedralTemplate, ::Maximum, system) = - allequal(template.G) - -reindex(template::ParallelFacesPolyhedralTemplate, origins::AbstractVector) = - ParallelFacesPolyhedralTemplate( - _reindexed_data(template.G, origins, "node matrices"); - min_weight = template.min_weight, - ) diff --git a/src/templates/catalogue/quadratic.jl b/src/templates/catalogue/quadratic.jl index bfdab8c..376bdd0 100644 --- a/src/templates/catalogue/quadratic.jl +++ b/src/templates/catalogue/quadratic.jl @@ -1,11 +1,13 @@ """ QuadraticTemplate(; conditioning_bound = Inf) -The template ``V(x) = x^\\top P x``, with ``P`` positive definite. +Legacy quadratic node functions for safety and optimal control. Stability +uses `SetProg.Ellipsoid(symmetric = true)` directly instead. +Safety evaluates the quadratic in homogeneous coordinates and permits an +indefinite coefficient matrix; optimal control uses positive quadratic forms. +The `conditioning_bound` parameter is retained for the legacy normalization +primitive. -A finite `conditioning_bound` also imposes -``P_s \\preceq \\text{conditioning\\_bound} \\cdot I``, which can exclude every -certificate of a stable system. """ struct QuadraticTemplate{T <: Real} <: AbstractTemplate conditioning_bound::T diff --git a/src/templates/catalogue/set_prog.jl b/src/templates/catalogue/set_prog.jl deleted file mode 100644 index dfc4550..0000000 --- a/src/templates/catalogue/set_prog.jl +++ /dev/null @@ -1,47 +0,0 @@ -""" - SetProgTemplate(variable) - SetProgTemplate(variables::AbstractVector) - -The template whose node function ``V_s`` has as 1-sublevel set a -[SetProg](https://github.com/blegat/SetProg.jl) set ``S_s``; the edge inequality -is the inclusion ``(A / \\gamma) S_{src} \\subseteq S_{dst}``. `variable` is one of - -| `variable` | ``V_s(x)`` | Degree | -| :-- | :-- | :-: | -| `Ellipsoid(symmetric = true)` | ``x^\\top Q_s x`` | 2 | -| `PolySet(symmetric = true, degree = 2d, variables = x)` | a polynomial ``p_s(x)`` | ``2d`` | -| `Ellipsoid(symmetric = true, piecewise = P)` | ``x^\\top Q_{s,i} x`` on the ``i``th cone | 2 | -| `Polytope(symmetric = true, piecewise = P)` | ``a_{s,i}^\\top x`` on the ``i``th cone | 1 | - -where the cones are over the facets of the polytope `P`, which must contain -the origin in its interior. A vector gives one variable per node. - -Requires `using SetProg, SumOfSquares`. Round the facet normals of `P`, since rounding errors -can make an almost optimal solution violate the edge inequalities. -[`decrease_slack`](@ref), hence [`edge_slacks`](@ref), is not implemented. -""" -struct SetProgTemplate{V <: AbstractVector} <: AbstractTemplate - variables::V - shared::Bool - - SetProgTemplate(variables::AbstractVector) = new{typeof(variables)}(variables, false) - SetProgTemplate(variable) = new{Vector{typeof(variable)}}([variable], true) -end - -""" - SetProgFunction(set) - -A fitted node function of a [`SetProgTemplate`](@ref), whose 1-sublevel set -is `set`. -""" -struct SetProgFunction{S} - set::S -end - -reindex(template::SetProgTemplate, origins::AbstractVector) = - template.shared ? template : - SetProgTemplate(_reindexed_data(template.variables, origins, "set variables")) - -# Closed under addition when all nodes share one family and one partition. -is_closed_under(template::SetProgTemplate, ::Addition, system) = - template.shared || allequal(template.variables) diff --git a/src/templates/catalogue/sum_of_squares.jl b/src/templates/catalogue/sum_of_squares.jl deleted file mode 100644 index 9ce8353..0000000 --- a/src/templates/catalogue/sum_of_squares.jl +++ /dev/null @@ -1,50 +0,0 @@ -""" - SumOfSquaresTemplate(degree, variables; min_scale = 1) - -The template of polynomials ``V(x) = z(x)^\\top Q z(x)`` with ``Q \\succeq 0``, -where ``z`` runs over the monomials of degree `degree` in the polynomial -`variables`, shared by every node. `min_scale` is the lower bound on ``c`` in -``V(x) \\ge c \\|x\\|^{2\\,\\text{degree}}``. Requires `using SumOfSquares`. -The program is homogeneous, so `min_scale` only fixes its scale; a much -smaller one lets the solver's absolute tolerance show in the rate. -[`decrease_slack`](@ref), hence [`edge_slacks`](@ref), is not implemented, so -[`refine`](@ref) does not run on it. -""" -struct SumOfSquaresTemplate{V <: AbstractVector, T <: Real} <: AbstractTemplate - degree::Int - variables::V - min_scale::T - - function SumOfSquaresTemplate( - degree::Integer, - variables::V; - min_scale::Real = 1, - ) where {V <: AbstractVector} - degree >= 1 || throw(ArgumentError("degree must be at least 1, got $degree")) - isempty(variables) && throw(ArgumentError("at least one variable is required")) - min_scale > 0 || throw(ArgumentError("min_scale must be positive")) - - return new{V, typeof(min_scale)}(Int(degree), variables, min_scale) - end -end - -""" - SumOfSquaresFunction(polynomial, variables) - -A fitted node function of a [`SumOfSquaresTemplate`](@ref): a polynomial in -`variables`. -""" -struct SumOfSquaresFunction{P, V <: AbstractVector} - polynomial::P - variables::V -end - -(V::SumOfSquaresFunction)(x::AbstractVector{<:Real}) = - V.polynomial(V.variables => collect(x)) - -homogeneity_degree(template::SumOfSquaresTemplate) = 2 * template.degree - -is_closed_under(::SumOfSquaresTemplate, ::Addition, system) = true -is_closed_under(::SumOfSquaresTemplate, ::Composition, system) = _is_invertible(system) -is_closed_under(::SumOfSquaresTemplate, ::InverseComposition, system) = - _is_invertible(system) diff --git a/test/lifts/compare.jl b/test/lifts/compare.jl index 707e2f4..ff00eea 100644 --- a/test/lifts/compare.jl +++ b/test/lifts/compare.jl @@ -4,6 +4,7 @@ module TestCompare # HSCC 2023 (Code Ocean 0480330): five graphs on two modes, one positive # system, and the rates its main script prints. +import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -85,9 +86,9 @@ const A_STAR = [[1.5519 0.44737; 7.6412 7.4716], [0.47501 9.1755; 1.8955 0.18502 const SYSTEM = PCC.switched_system(A_STAR) const PROBLEM = PCC.StabilityProblem(SYSTEM) -const QUADRATIC = PCC.QuadraticTemplate() -const COPOSITIVE = PCC.LinearCopositiveTemplate() -const DUAL_COPOSITIVE = PCC.DualCopositiveTemplate() +const QUADRATIC = SetProg.Ellipsoid(; symmetric = true) +const COPOSITIVE = SetProg.PositiveSimplex() +const DUAL_COPOSITIVE = SetProg.PositiveBox() const TEMPLATES = (QUADRATIC, COPOSITIVE, DUAL_COPOSITIVE) found(comparison) = Dict(PCC.simulations(comparison)) diff --git a/test/lifts/composition.jl b/test/lifts/composition.jl index 99d63e7..2feeeca 100644 --- a/test/lifts/composition.jl +++ b/test/lifts/composition.jl @@ -1,5 +1,6 @@ module TestLiftComposition +import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -50,12 +51,24 @@ end @test PCC._is_invertible(invertible) @test !PCC._is_invertible(singular) - @test PCC.is_valid(PCC.CompositionLift(1), PCC.QuadraticTemplate(), invertible) - @test !PCC.is_valid(PCC.CompositionLift(1), PCC.QuadraticTemplate(), singular) - @test PCC.is_valid(PCC.BackwardCompositionLift(1), PCC.QuadraticTemplate(), invertible) + @test PCC.is_valid( + PCC.CompositionLift(1), + SetProg.Ellipsoid(symmetric = true), + invertible, + ) + @test !PCC.is_valid( + PCC.CompositionLift(1), + SetProg.Ellipsoid(symmetric = true), + singular, + ) + @test PCC.is_valid( + PCC.BackwardCompositionLift(1), + SetProg.Ellipsoid(symmetric = true), + invertible, + ) @test !PCC.is_valid( PCC.CompositionLift(1), - PCC.ParallelFacesPolyhedralTemplate(1, 2), + SetProg.Parallelotope(dimension = 2), invertible, ) end diff --git a/test/lifts/identity.jl b/test/lifts/identity.jl index 97aa1a4..e775f7d 100644 --- a/test/lifts/identity.jl +++ b/test/lifts/identity.jl @@ -1,5 +1,6 @@ module TestLiftIdentity +import SetProg using Test import PathCompleteCertificates as PCC @@ -19,9 +20,9 @@ import PathCompleteCertificates as PCC system = PCC.switched_system([[0.5 0.0; 0.0 0.5], [0.0 0.5; 0.5 0.0]]) for template in ( - PCC.QuadraticTemplate(), - PCC.ParallelFacesPolyhedralTemplate(1, 2), - PCC.LinearCopositiveTemplate(), + SetProg.Ellipsoid(symmetric = true), + SetProg.Parallelotope(dimension = 2), + SetProg.PositiveSimplex(), ) @test PCC.is_valid(PCC.IdentityLift(), template, system) end diff --git a/test/lifts/min.jl b/test/lifts/min.jl index ed27266..086c5b2 100644 --- a/test/lifts/min.jl +++ b/test/lifts/min.jl @@ -1,5 +1,6 @@ module TestLiftMin +import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -144,7 +145,7 @@ end end @testset "the min lift is valid on the linear copositive template for positive systems only" begin - template = PCC.LinearCopositiveTemplate() + template = SetProg.PositiveSimplex() positive = PCC.switched_system([[0.5 0.25; 0.0 0.5]]) signed = PCC.switched_system([[0.5 -0.25; 0.0 0.5]]) diff --git a/test/lifts/product.jl b/test/lifts/product.jl index bb8108f..5ebb590 100644 --- a/test/lifts/product.jl +++ b/test/lifts/product.jl @@ -1,5 +1,6 @@ module TestLiftProduct +import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -118,14 +119,14 @@ end expanded = PCC.expanded_form(w) on_words = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), w, problem; optimizer = Clarabel.Optimizer, rtol = 1e-5, ) on_letters = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), expanded, problem; optimizer = Clarabel.Optimizer, diff --git a/test/lifts/split.jl b/test/lifts/split.jl index 67e6beb..df92a12 100644 --- a/test/lifts/split.jl +++ b/test/lifts/split.jl @@ -1,5 +1,6 @@ module TestLiftSplit +import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -319,16 +320,16 @@ end @testset "a template with per-node data follows the split" begin graph = PCC.de_bruijn(1, 2) - template = PCC.ParallelFacesPolyhedralTemplate([[1.0 0.0; 0.0 1.0], [1.0 1.0; 0.0 1.0]]) + template = SetProg.Parallelotope.([[1.0 0.0; 0.0 1.0], [1.0 1.0; 0.0 1.0]]) lifted = PCC.lift(PCC.OutSplitLift(), graph, PCC.outgoing_edges(graph, 1)) moved = PCC.reindex(template, PCC.origins(lifted)) - @test length(moved.G) == PCC.n_nodes(PCC.graph(lifted)) - @test moved.G == [template.G[o] for o in PCC.origins(lifted)] + @test length(moved) == PCC.n_nodes(PCC.graph(lifted)) + @test getproperty.(moved, :G) == [template[o].G for o in PCC.origins(lifted)] # A data-free template is its own reindexing. - @test PCC.reindex(PCC.QuadraticTemplate(), PCC.origins(lifted)) == - PCC.QuadraticTemplate() + @test PCC.reindex(SetProg.Ellipsoid(symmetric = true), PCC.origins(lifted)) == + SetProg.Ellipsoid(symmetric = true) end end # module diff --git a/test/problems/certificate.jl b/test/problems/certificate.jl index 9b9886e..96ac682 100644 --- a/test/problems/certificate.jl +++ b/test/problems/certificate.jl @@ -4,6 +4,7 @@ module TestCertificate # accessed through methods and stored once in `CertificateData`; what a problem # certifies is a typed field on its own certificate type. +import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -21,7 +22,7 @@ const SYSTEM = PCC.switched_system([[0.5 0.0; 0.0 0.25]]) const PROBLEM = PCC.StabilityProblem(SYSTEM) const STABILITY = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(; symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, diff --git a/test/problems/certify.jl b/test/problems/certify.jl index 3150ece..fe10593 100644 --- a/test/problems/certify.jl +++ b/test/problems/certify.jl @@ -3,6 +3,7 @@ module TestCertify # The seam itself: one `certify` per problem, one `optimization_model` per # problem, and the failure path they share. +import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -18,7 +19,7 @@ const GRAPH = PCC.de_bruijn(1, 1) @testset "certify at a fixed rate" begin loose = PCC.certify( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -33,7 +34,7 @@ end @testset "an infeasible rate yields a certificate, not an error" begin tight = PCC.certify( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -50,17 +51,17 @@ end @testset "a failed solve names no function type" begin # `functions` is empty, so there is no fitted function to take a type from. - template = PCC.ParallelFacesPolyhedralTemplate(PCC.n_nodes(GRAPH), 2) + template = SetProg.Parallelotope(dimension = 2) tight = PCC.certify(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rate = 0.01) @test !PCC.is_feasible(tight) @test isempty(PCC.functions(tight)) - @test !any(f -> f isa PCC.QuadraticFunction, PCC.functions(tight)) + @test !any(f -> f isa SetProg.Sets.Ellipsoid, PCC.functions(tight)) end @testset "jsr_bound agrees with certify at the rate it returns" begin bound = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -72,7 +73,7 @@ end # The bisection ends in exactly this solve. direct = PCC.certify( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -83,7 +84,7 @@ end @testset "optimization_model builds without solving" begin model = PCC.optimization_model( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; rate = 0.9, @@ -92,7 +93,8 @@ end @test model isa JuMP.Model @test JuMP.termination_status(model) == JuMP.MOI.OPTIMIZE_NOT_CALLED - @test JuMP.num_variables(model) > 0 + @test length(model[:stability_V]) == PCC.n_nodes(GRAPH) + @test all(V -> V isa SetProg.SetVariableRef, model[:stability_V]) end @testset "optimal control has a model of its own now" begin @@ -119,7 +121,7 @@ end # The common graph is accepted ... model = PCC.optimization_model( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), PCC.common_graph(problem.system), problem; rate = 1.0, @@ -133,7 +135,7 @@ end add_transition!(partial, 1, 1, mode) end @test_throws ArgumentError PCC.optimization_model( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), partial, problem; rate = 1.0, diff --git a/test/problems/common.jl b/test/problems/common.jl index ce2e7cb..4e87317 100644 --- a/test/problems/common.jl +++ b/test/problems/common.jl @@ -1,5 +1,6 @@ module TestCommon +import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -9,8 +10,8 @@ const A = [[0.5 0.0; 0.0 0.25], [0.25 0.0; 0.0 0.5]] const PROBLEM = PCC.StabilityProblem(PCC.switched_system(A)) const X = [1.0, 2.0] -const P1 = PCC.QuadraticFunction(LinearAlgebra.Symmetric([1.0 0.0; 0.0 1.0])) -const P2 = PCC.QuadraticFunction(LinearAlgebra.Symmetric([3.0 0.0; 0.0 3.0])) +const P1 = SetProg.Sets.Ellipsoid(LinearAlgebra.Symmetric([1.0 0.0; 0.0 1.0])) +const P2 = SetProg.Sets.Ellipsoid(LinearAlgebra.Symmetric([3.0 0.0; 0.0 3.0])) const VS = [P1, P2] value(V) = V(X) @@ -51,19 +52,13 @@ end @testset "the common function is generic in the template" begin graph = PCC.de_bruijn(1, 2) - linear = PCC.LinearCopositiveFunction.([[1.0, 1.0], [2.0, 2.0]]) + linear = SetProg.Sets.PositiveSimplex.([[1.0, 1.0], [2.0, 2.0]]) @test PCC.common(graph, PROBLEM, linear, X) ≈ minimum(V(X) for V in linear) weights = [ - PCC.ParallelFacesPolyhedralFunction( - Matrix{Float64}(LinearAlgebra.I, 2, 2), - [1.0, 1.0], - ), - PCC.ParallelFacesPolyhedralFunction( - Matrix{Float64}(LinearAlgebra.I, 2, 2), - [4.0, 4.0], - ), + SetProg.Sets.Parallelotope(Matrix{Float64}(LinearAlgebra.I, 2, 2), [1.0, 1.0]), + SetProg.Sets.Parallelotope(Matrix{Float64}(LinearAlgebra.I, 2, 2), [4.0, 4.0]), ] @test PCC.common(graph, PROBLEM, weights, X) ≈ minimum(V(X) for V in weights) diff --git a/test/problems/dual.jl b/test/problems/dual.jl index f3ccfe4..0acd0e0 100644 --- a/test/problems/dual.jl +++ b/test/problems/dual.jl @@ -7,6 +7,7 @@ module TestDual # for a template exactly when its dual is valid for the dual template -- and, # wherever a lift is valid, that the rate it certifies is no worse. +import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -20,9 +21,9 @@ edgeset(g) = Set( ) # The numbers a fitted node function is made of, whatever the template. -coefficients(V::PCC.QuadraticFunction) = Matrix(V) -coefficients(V::PCC.DualCopositiveFunction) = V.v -coefficients(V::PCC.LinearCopositiveFunction) = V.c +coefficients(V::SetProg.Sets.Ellipsoid) = Matrix(V) +coefficients(V::SetProg.Sets.PositiveBox) = V.v +coefficients(V::SetProg.Sets.PositiveSimplex) = V.c # A positive system for the copositive pair, a general one for the quadratic # template; both invertible, so the composition lifts are valid somewhere. @@ -33,9 +34,9 @@ const POSITIVE = PCC.switched_system([ const GENERAL = PCC.switched_system([[0.8 0.3; -0.2 0.5], [0.4 -0.6; 0.5 0.3]]) const CASES = [ - (PCC.QuadraticTemplate(), GENERAL), - (PCC.LinearCopositiveTemplate(), POSITIVE), - (PCC.DualCopositiveTemplate(), POSITIVE), + (SetProg.Ellipsoid(; symmetric = true), GENERAL), + (SetProg.PositiveSimplex(), POSITIVE), + (SetProg.PositiveBox(), POSITIVE), ] const ONE = PCC.common_graph(2) @@ -134,31 +135,24 @@ end # What crossing over adds: the sum lift is valid for the dual copositive # norms because their primal is closed under addition, which they are not. - @test !PCC.is_closed_under(PCC.DualCopositiveTemplate(), PCC.Addition(), POSITIVE) - @test PCC.is_valid(PCC.SumLift(2), PCC.DualCopositiveTemplate(), POSITIVE) + @test !PCC.is_closed_under(SetProg.PositiveBox(), PCC.Addition(), POSITIVE) + @test PCC.is_valid(PCC.SumLift(2), SetProg.PositiveBox(), POSITIVE) # And Thm. 7.43, stated on the primal, is read on the dual through closure. - @test PCC.is_valid(PCC.MinLift(), PCC.LinearCopositiveTemplate(), POSITIVE) - @test PCC.is_valid(PCC.MaxLift(), PCC.DualCopositiveTemplate(), POSITIVE) - @test !PCC.is_valid(PCC.MaxLift(), PCC.LinearCopositiveTemplate(), POSITIVE) - @test !PCC.is_valid(PCC.MinLift(), PCC.DualCopositiveTemplate(), POSITIVE) + @test PCC.is_valid(PCC.MinLift(), SetProg.PositiveSimplex(), POSITIVE) + @test PCC.is_valid(PCC.MaxLift(), SetProg.PositiveBox(), POSITIVE) + @test !PCC.is_valid(PCC.MaxLift(), SetProg.PositiveSimplex(), POSITIVE) + @test !PCC.is_valid(PCC.MinLift(), SetProg.PositiveBox(), POSITIVE) # A template without a dual is decided by its own closures only. - @test PCC.is_valid( - PCC.SumLift(2), - PCC.ParallelFacesPolyhedralTemplate(1, 2), - GENERAL, - ) == PCC.is_closed_under( - PCC.ParallelFacesPolyhedralTemplate(1, 2), - PCC.Addition(), - GENERAL, - ) + @test PCC.is_valid(PCC.SumLift(2), SetProg.Parallelotope(dimension = 2), GENERAL) == + PCC.is_closed_under(SetProg.Parallelotope(dimension = 2), PCC.Addition(), GENERAL) end @testset "an infeasible certificate dualises to an infeasible one" begin problem = PCC.StabilityProblem(POSITIVE) none = PCC.certify( - PCC.LinearCopositiveTemplate(), + SetProg.PositiveSimplex(), ONE, problem; optimizer = OPTIMIZER, @@ -171,19 +165,19 @@ end @test PCC.status(dualised) == PCC.status(none) @test isempty(PCC.functions(dualised)) @test dualised.rate == none.rate - @test PCC.template(dualised) == PCC.DualCopositiveTemplate() + @test PCC.template(dualised) == SetProg.PositiveBox() end @testset "a template without a dual says so" begin - @test PCC.has_dual(PCC.QuadraticTemplate()) - @test PCC.has_dual(PCC.LinearCopositiveTemplate()) - @test PCC.has_dual(PCC.DualCopositiveTemplate()) - @test !PCC.has_dual(PCC.ParallelFacesPolyhedralTemplate(1, 2)) + @test PCC.has_dual(SetProg.Ellipsoid(symmetric = true)) + @test PCC.has_dual(SetProg.PositiveSimplex()) + @test PCC.has_dual(SetProg.PositiveBox()) + @test !PCC.has_dual(SetProg.Parallelotope(dimension = 2)) - @test_throws ArgumentError PCC.dual(PCC.ParallelFacesPolyhedralTemplate(1, 2)) + @test_throws ArgumentError PCC.dual(SetProg.Parallelotope(dimension = 2)) polyhedral = - bound(PCC.ParallelFacesPolyhedralTemplate(1, 2), ONE, PCC.StabilityProblem(GENERAL)) + bound(SetProg.Parallelotope(dimension = 2), ONE, PCC.StabilityProblem(GENERAL)) @test PCC.is_feasible(polyhedral) @test_throws ArgumentError PCC.dual(polyhedral) end diff --git a/test/problems/optimal_control.jl b/test/problems/optimal_control.jl index 623e575..d41d65f 100644 --- a/test/problems/optimal_control.jl +++ b/test/problems/optimal_control.jl @@ -3,6 +3,7 @@ module TestOptimalControl using Test using HybridSystems import PathCompleteCertificates as PCC +struct UnsupportedTemplate <: PCC.AbstractTemplate end import Clarabel import JuMP import LinearAlgebra @@ -99,7 +100,7 @@ end @test_throws ArgumentError PCC.OptimalControlProblem(SYSTEM, Q[1:1, 1:1], R) @test_throws ArgumentError PCC.certify( - PCC.LinearCopositiveTemplate(), + UnsupportedTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, diff --git a/test/templates/set_prog.jl b/test/problems/sets.jl similarity index 50% rename from test/templates/set_prog.jl rename to test/problems/sets.jl index 3f68793..81c199b 100644 --- a/test/templates/set_prog.jl +++ b/test/problems/sets.jl @@ -1,13 +1,6 @@ -module TestSetProg +module TestSetFamilies -# The SetProg template lives in a package extension, so this file is the only -# one that loads SetProg. -# -# The anchors are the templates it overlaps with: an ellipsoid is -# `QuadraticTemplate`, a polynomial set is `SumOfSquaresTemplate`, and a -# polytope that is linear on the cones over the facets of a partition is -# `ConicPolyhedralTemplate` on the same cones. What it adds, the piecewise -# semi-ellipsoids, is checked against these and against the edge inequality. +# Native SetProg families for stability. using Test using LinearAlgebra @@ -85,55 +78,48 @@ function test_edges(certificate, template, graph) end end -@testset "the extension is what supplies the methods" begin - @test Base.get_extension(PCC, :PathCompleteCertificatesSetProgExt) !== nothing +@testset "SetProg is a direct dependency" begin + @test PCC.SetProg === SetProg + @test Base.get_extension(PCC, :PathCompleteCertificatesSetProgExt) === nothing end @testset "the degree is the one of the set variable" begin - @test PCC.homogeneity_degree(PCC.SetProgTemplate(Ellipsoid(symmetric = true))) == 2 - @test PCC.homogeneity_degree( - PCC.SetProgTemplate(PolySet(symmetric = true, degree = 6, variables = x)), - ) == 6 - @test PCC.homogeneity_degree( - PCC.SetProgTemplate(Polytope(symmetric = true, piecewise = SQUARE)), - ) == 1 - mixed = PCC.SetProgTemplate([ - Ellipsoid(symmetric = true), - PolySet(symmetric = true, degree = 4, variables = x), - ]) + @test PCC.homogeneity_degree(Ellipsoid(symmetric = true)) == 2 + @test PCC.homogeneity_degree(PolySet(symmetric = true, degree = 6, variables = x)) == 6 + @test PCC.homogeneity_degree(Polytope(symmetric = true, piecewise = SQUARE)) == 1 + mixed = + [Ellipsoid(symmetric = true), PolySet(symmetric = true, degree = 4, variables = x)] @test_throws ArgumentError PCC.homogeneity_degree(mixed) end # The one-node ellipsoid bound, shared by the tests below. -const ELLIPSOID = rate(PCC.SetProgTemplate(Ellipsoid(; symmetric = true))) - -@testset "an ellipsoid reproduces the quadratic template" begin - quadratic = rate(PCC.QuadraticTemplate()) +const ELLIPSOID = rate(Ellipsoid(; symmetric = true)) +@testset "an ellipsoid fits a normalized quadratic gauge" begin @test PCC.is_feasible(ELLIPSOID) - @test abs(ELLIPSOID.rate - quadratic.rate) <= AGREE + @test ELLIPSOID.rate >= LOWER_BOUND - 1e-4 V = only(PCC.functions(ELLIPSOID)) - @test V isa PCC.SetProgFunction + @test V isa SetProg.Sets.Ellipsoid # V(cx) = c² V(x), and the normalization `V ≥ ‖x‖²` @test V([2.0, 3.0]) ≈ 4 * V([1.0, 1.5]) rtol = 1e-6 @test V([1.0, 1.0]) >= 2 - 1e-6 - test_edges(ELLIPSOID, PCC.SetProgTemplate(Ellipsoid(symmetric = true)), MEMORYLESS) + test_edges(ELLIPSOID, Ellipsoid(symmetric = true), MEMORYLESS) end -@testset "a polynomial set reproduces the sum-of-squares template" begin - sos = rate(PCC.SumOfSquaresTemplate(2, x)) - template = PCC.SetProgTemplate(PolySet(symmetric = true, degree = 4, variables = x)) +@testset "a polynomial set fits a sum-of-squares gauge" begin + template = PolySet(symmetric = true, degree = 4, variables = x) polyset = rate(template) @test PCC.is_feasible(polyset) - @test abs(polyset.rate - sos.rate) <= AGREE + @test polyset.rate >= LOWER_BOUND - 1e-4 + @test polyset.rate <= ELLIPSOID.rate + AGREE test_edges(polyset, template, MEMORYLESS) end @testset "a piecewise polytope reproduces the conic polyhedral template" begin - conic = rate(PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(1)])) - template = PCC.SetProgTemplate(Polytope(symmetric = true, piecewise = OCTAGON)) + conic = rate(SetProg.ConicPolytope.([SetProg.planar_conic_partition(1)])) + template = Polytope(symmetric = true, piecewise = OCTAGON) polytope = rate(template) @test PCC.is_feasible(polytope) @@ -143,13 +129,12 @@ end @testset "piecewise semi-ellipsoids" begin piecewise = Dict( - name => rate( - PCC.SetProgTemplate(Ellipsoid(symmetric = true, piecewise = partition)), - ) for (name, partition) in [("square", SQUARE), ("octagon", OCTAGON)] + name => rate(Ellipsoid(symmetric = true, piecewise = partition)) for + (name, partition) in [("square", SQUARE), ("octagon", OCTAGON)] ) @testset "$name" for (name, partition) in [("square", SQUARE), ("octagon", OCTAGON)] - template = PCC.SetProgTemplate(Ellipsoid(symmetric = true, piecewise = partition)) + template = Ellipsoid(symmetric = true, piecewise = partition) certificate = piecewise[name] @test PCC.is_feasible(certificate) @@ -158,7 +143,7 @@ end @test certificate.rate >= LOWER_BOUND - 1e-4 V = only(PCC.functions(certificate)) - @test V.set isa SetProg.Sets.Piecewise + @test V isa SetProg.Sets.Piecewise test_edges(certificate, template, MEMORYLESS) end @@ -168,10 +153,10 @@ end @testset "one partition per node" begin graph = PCC.de_bruijn(1, 2) - template = PCC.SetProgTemplate([ + template = [ Ellipsoid(symmetric = true, piecewise = SQUARE), Ellipsoid(symmetric = true, piecewise = OCTAGON), - ]) + ] certificate = rate(template, graph) @test PCC.is_feasible(certificate) @@ -181,63 +166,126 @@ end end @testset "the template follows a lift" begin - shared = PCC.SetProgTemplate(Ellipsoid(symmetric = true)) + shared = Ellipsoid(symmetric = true) @test PCC.reindex(shared, [1, 1, 1]) === shared square = Ellipsoid(symmetric = true, piecewise = SQUARE) octagon = Ellipsoid(symmetric = true, piecewise = OCTAGON) - per_node = PCC.SetProgTemplate([square, octagon]) + per_node = [square, octagon] # Each new node gets the variable of its origin. lifted = PCC.reindex(per_node, [2, 1, 2]) - @test lifted.variables[1] === octagon - @test lifted.variables[2] === square - @test lifted.variables[3] === octagon - @test !lifted.shared + @test lifted[1] === octagon + @test lifted[2] === square + @test lifted[3] === octagon # Merging nodes is fine when they carry the same variable, not otherwise. - @test only(PCC.reindex(PCC.SetProgTemplate([square, square]), [[1, 2]]).variables) === - square + @test only(PCC.reindex([square, square], [[1, 2]])) === square @test_throws ArgumentError PCC.reindex(per_node, [[1, 2]]) end @testset "closed under addition when the nodes share the variable" begin system = PROBLEM.system - shared = PCC.SetProgTemplate(Ellipsoid(symmetric = true)) + shared = Ellipsoid(symmetric = true) @test PCC.is_closed_under(shared, PCC.Addition(), system) @test PCC.is_valid(PCC.SumLift(2), shared, system) octagon = Ellipsoid(symmetric = true, piecewise = OCTAGON) - same = PCC.SetProgTemplate([octagon, octagon]) + same = [octagon, octagon] @test PCC.is_closed_under(same, PCC.Addition(), system) - mixed = PCC.SetProgTemplate([Ellipsoid(symmetric = true, piecewise = SQUARE), octagon]) + mixed = [Ellipsoid(symmetric = true, piecewise = SQUARE), octagon] @test !PCC.is_closed_under(mixed, PCC.Addition(), system) @test !PCC.is_valid(PCC.SumLift(2), mixed, system) @test !PCC.is_closed_under(shared, PCC.Maximum(), system) end -@testset "what the template does not support" begin - @test_throws ArgumentError rate(PCC.SetProgTemplate(Ellipsoid(dimension = 2))) - @test_throws ArgumentError rate( - PCC.SetProgTemplate(PolySet(symmetric = true, degree = 4)), - ) - @test_throws ArgumentError rate( - PCC.SetProgTemplate(Polytope(symmetric = true, dimension = 2)), - ) +@testset "family validation and zero rate" begin + @test_throws ArgumentError rate(Ellipsoid(dimension = 2)) + @test_throws ArgumentError rate(Polytope(symmetric = true, dimension = 2)) @polyvar y[1:3] - @test_throws ArgumentError rate( - PCC.SetProgTemplate(PolySet(symmetric = true, degree = 4, variables = y)), - ) - @test_throws ArgumentError rate( - PCC.SetProgTemplate([Ellipsoid(symmetric = true)]), - PCC.de_bruijn(1, 2), + @test_throws DimensionMismatch rate( + PolySet(symmetric = true, degree = 4, variables = y), ) + @test_throws ArgumentError rate([Ellipsoid(symmetric = true)], PCC.de_bruijn(1, 2)) model = JuMP.Model() - template = PCC.SetProgTemplate(Ellipsoid(symmetric = true)) + template = Ellipsoid(symmetric = true) S = PCC.add_function_variables!(model, template, 2, 1) - @test_throws ArgumentError PCC.add_decrease!(model, template, S, S, A[1]; scale = 0) + PCC.add_normalization!(model, S.set, S) + PCC.add_decrease!(model, template, S, S, A[1]; scale = 0) + @test JuMP.num_constraints(model; count_variable_in_set_constraints = false) > 0 +end + +@testset "inferred polynomial coordinates are shared by the nodes" begin + diagonal = PCC.StabilityProblem(PCC.switched_system([[0.5 0.0; 0.0 0.25]])) + graph = PCC.de_bruijn(1, 1) + family = PolySet(symmetric = true, degree = 4) + certificate = PCC.certify(family, graph, diagonal; optimizer = OPTIMIZER, rate = 0.6) + @test PCC.is_feasible(certificate) + @test only(PCC.functions(certificate))([1.0, 1.0]) >= 4 - 1e-5 + @polyvar y[1:2] + different = [PolySet(symmetric = true, degree = 4, variables = z) for z in (x, y)] + @test_throws ArgumentError PCC.optimization_model( + different, + PCC.de_bruijn(1, 2), + PROBLEM; + optimizer = OPTIMIZER, + ) + models = PCC.optimization_model( + [family, family], + PCC.de_bruijn(1, 2), + PROBLEM; + optimizer = OPTIMIZER, + ) + @test models[:stability_V][1].set.variables == models[:stability_V][2].set.variables +end + +@testset "native per-node polyhedral families and duals" begin + system = PCC.switched_system([[0.5 0.0; 0.0 0.25], [0.25 0.0; 0.0 0.5]]) + problem = PCC.StabilityProblem(system) + graph = PCC.de_bruijn(1, 2) + families = [Parallelotope([1.0 0.0; 0.0 1.0]), Parallelotope([2.0 0.0; 0.0 1.0])] + certificate = + PCC.certify(families, graph, problem; optimizer = HiGHS.Optimizer, rate = 0.8) + @test PCC.is_feasible(certificate) + @test all(>=(-1e-6), PCC.edge_slacks(certificate)) + @test !PCC.is_closed_under(families, PCC.Maximum(), system) + @test PCC.is_closed_under([families[1], families[1]], PCC.Maximum(), system) + boxes = [PositiveBox(min_weight = 2), PositiveBox(min_weight = 3)] + certificate = + PCC.certify(boxes, graph, problem; optimizer = HiGHS.Optimizer, rate = 0.8) + @test PCC.has_dual(boxes) + @test PCC.dual(PCC.dual(boxes)) == boxes + @test all(>=(-1e-6), PCC.edge_slacks(PCC.dual(certificate))) + @test_throws ArgumentError PCC.dual(families) + @test !PCC.is_closed_under( + Ellipsoid(symmetric = true, piecewise = SQUARE), + PCC.Composition(), + PROBLEM.system, + ) + @test !PCC.is_closed_under( + Ellipsoid(symmetric = true, piecewise = SQUARE), + PCC.InverseComposition(), + PROBLEM.system, + ) +end + +@testset "the native gauge primitives also work directly" begin + family = PositiveBox(min_weight = 2) + model = JuMP.Model(HiGHS.Optimizer) + JuMP.set_silent(model) + S = PCC.add_function_variables!(model, family, 2, 1) + PCC.add_normalization!(model, S.set, S) + A = [0.5 0.0; 0.0 0.25] + PCC.add_decrease!(model, family, S, S, A; scale = 0.6) + JuMP.optimize!(model) + fitted = PCC.solution_value(family, S) + @test fitted([2.0, 0.0]) <= 1 + 1e-6 + @test fitted(A * [2.0, 1.0]) <= 0.6 * fitted([2.0, 1.0]) + 1e-6 + system = PCC.switched_system([A]) + @test PCC.is_closed_under(PositiveSimplex(), PCC.Composition(), system) + @test PCC.is_closed_under(PositiveSimplex(), PCC.InverseComposition(), system) end end diff --git a/test/problems/stability.jl b/test/problems/stability.jl index fd273ef..ab8aadc 100644 --- a/test/problems/stability.jl +++ b/test/problems/stability.jl @@ -1,6 +1,7 @@ module TestStability +import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -29,7 +30,7 @@ const OPTIMIZER = Clarabel.Optimizer @test_throws ArgumentError PCC.is_feasible( PCC.certify( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), mode_1_only, problem; optimizer = OPTIMIZER, @@ -38,7 +39,7 @@ const OPTIMIZER = Clarabel.Optimizer ) @test_throws ArgumentError PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), mode_1_only, problem; optimizer = OPTIMIZER, @@ -60,7 +61,7 @@ end @test_throws ArgumentError PCC.is_feasible( PCC.certify( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), mode_1_only, problem; optimizer = OPTIMIZER, @@ -72,7 +73,7 @@ end # which is exactly the risk the caller has taken on. @test PCC.is_feasible( PCC.certify( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), mode_1_only, problem; optimizer = OPTIMIZER, @@ -100,7 +101,7 @@ end @test PCC.is_path_complete(graph, 1:2) result = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), graph, problem; optimizer = OPTIMIZER, @@ -114,7 +115,7 @@ end @testset "quadratic stability" begin @test PCC.is_feasible( PCC.certify( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -124,7 +125,7 @@ end @test !PCC.is_feasible( PCC.certify( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -133,7 +134,7 @@ end ) result = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -152,7 +153,7 @@ end @test PCC.is_feasible( PCC.certify( - PCC.LinearCopositiveTemplate(), + SetProg.PositiveSimplex(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -162,7 +163,7 @@ end @test !PCC.is_feasible( PCC.certify( - PCC.LinearCopositiveTemplate(), + SetProg.PositiveSimplex(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -171,7 +172,7 @@ end ) result = PCC.jsr_bound( - PCC.LinearCopositiveTemplate(), + SetProg.PositiveSimplex(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -184,7 +185,7 @@ end # Both copositive templates need a positive system, and say so up front. rotation = PCC.StabilityProblem(PCC.switched_system([[0.0 -0.5; 0.5 0.0]])) - for template in (PCC.LinearCopositiveTemplate(), PCC.DualCopositiveTemplate()) + for template in (SetProg.PositiveSimplex(), SetProg.PositiveBox()) @test_throws ArgumentError PCC.certify( template, GRAPH, @@ -198,14 +199,14 @@ end @testset "every template agrees on the rate" begin # The point of `homogeneity_degree`: the same system and graph, three templates # of two different degrees, one answer. - @test PCC.homogeneity_degree(PCC.QuadraticTemplate()) == 2 - @test PCC.homogeneity_degree(PCC.LinearCopositiveTemplate()) == 1 - @test PCC.homogeneity_degree(PCC.ParallelFacesPolyhedralTemplate(2, 2)) == 1 + @test PCC.homogeneity_degree(SetProg.Ellipsoid(symmetric = true)) == 2 + @test PCC.homogeneity_degree(SetProg.PositiveSimplex()) == 1 + @test PCC.homogeneity_degree(SetProg.Parallelotope(dimension = 2)) == 1 bounds = map(( - PCC.QuadraticTemplate(), - PCC.LinearCopositiveTemplate(), - PCC.ParallelFacesPolyhedralTemplate(PCC.n_nodes(GRAPH), 2), + SetProg.Ellipsoid(symmetric = true), + SetProg.PositiveSimplex(), + SetProg.Parallelotope(dimension = 2), )) do template PCC.jsr_bound(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rtol = 1e-3).rate end @@ -229,7 +230,7 @@ end problem = PCC.StabilityProblem(PCC.switched_system([stretched])) default = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), graph, problem; optimizer = OPTIMIZER, @@ -239,7 +240,7 @@ end @test abs(default.rate - 0.9) <= 0.01 @test PCC.is_feasible( PCC.certify( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), graph, problem; optimizer = OPTIMIZER, @@ -250,17 +251,24 @@ end # The cap still available, and still conservative -- which is the point of # making the caller ask for it. capped = PCC.jsr_bound( - PCC.QuadraticTemplate(; conditioning_bound = 100), + SetProg.Ellipsoid(symmetric = true), graph, problem; optimizer = OPTIMIZER, + conditioning_bound = 100, rtol = 1e-3, ) @test capped.rate > default.rate + 0.1 @test capped.rate >= 0.9 # still a sound upper bound - @test_throws ArgumentError PCC.QuadraticTemplate(; conditioning_bound = 0.5) + @test_throws ArgumentError PCC.certify( + SetProg.Ellipsoid(symmetric = true), + graph, + problem; + optimizer = OPTIMIZER, + conditioning_bound = 0.5, + ) end @testset "input validation" begin @@ -268,7 +276,7 @@ end add_transition!(invalid_label_graph, 1, 1, 2) @test_throws ArgumentError PCC.optimization_model( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), invalid_label_graph, PROBLEM; rate = 1.0, diff --git a/test/problems/tight_edges.jl b/test/problems/tight_edges.jl index 205a4d9..b512e30 100644 --- a/test/problems/tight_edges.jl +++ b/test/problems/tight_edges.jl @@ -3,6 +3,7 @@ module TestTightEdges # Slack is the quantity a refinement step reads: zero means the edge holds the # bound up, and a node with two tight outgoing edges is the one to split. +import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -19,7 +20,7 @@ const GRAPH = PCC.de_bruijn(1, 1) @testset "slack is nonnegative where the certificate holds" begin loose = PCC.certify( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -38,7 +39,7 @@ end @testset "the bound is attained exactly where an edge goes tight" begin bound = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -62,7 +63,7 @@ end @testset "slack agrees with the inequality it measures" begin certificate = PCC.certify( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -70,14 +71,14 @@ end ) V = PCC.functions(certificate) - scale = 0.8^PCC.homogeneity_degree(PCC.QuadraticTemplate()) + scale = 0.8^PCC.homogeneity_degree(SetProg.Ellipsoid(symmetric = true)) for (edge, slack) in zip(PCC.edges(GRAPH), PCC.edge_slacks(certificate)) src = V[PCC.source(edge)] dst = V[PCC.dest(edge)] map = A[PCC.label(GRAPH, edge)] - residual = scale * src.P - transpose(map) * dst.P * map + residual = scale * src.Q - transpose(map) * dst.Q * map @test slack ≈ minimum(LinearAlgebra.eigvals(LinearAlgebra.Symmetric(residual))) # Slack ≥ 0 is exactly the edge inequality holding, sampled. @@ -89,7 +90,7 @@ end @testset "every template answers decrease_slack" begin quadratic = PCC.certify( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -98,7 +99,7 @@ end @test PCC.edge_slacks(quadratic) isa Vector polyhedral = PCC.certify( - PCC.ParallelFacesPolyhedralTemplate(PCC.n_nodes(GRAPH), 2), + SetProg.Parallelotope(dimension = 2), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -107,7 +108,7 @@ end @test all(>=(-1e-7), PCC.edge_slacks(polyhedral)) conic = PCC.certify( - PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(2)]), + SetProg.ConicPolytope.([SetProg.planar_conic_partition(2)]), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -117,7 +118,7 @@ end nonneg = PCC.StabilityProblem(PCC.switched_system([[0.4 0.1; 0.2 0.3]])) copositive = PCC.certify( - PCC.LinearCopositiveTemplate(), + SetProg.PositiveSimplex(), PCC.de_bruijn(1, 1), nonneg; optimizer = OPTIMIZER, @@ -128,7 +129,7 @@ end @testset "an infeasible certificate has nothing to measure" begin tight = PCC.certify( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -156,7 +157,7 @@ const TWO_NODES = PCC.de_bruijn(1, 2) @testset "the tight subgraph keeps the nodes and drops the slack edges" begin certificate = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -177,7 +178,7 @@ end @testset "a cycle bounds the JSR from below, whatever atol was" begin certificate = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), TWO_NODES, ROTATION_PROBLEM; optimizer = OPTIMIZER, @@ -201,7 +202,7 @@ end @testset "Theorem 4 is sufficient, not necessary" begin exact = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), TWO_NODES, ROTATION_PROBLEM; optimizer = OPTIMIZER, @@ -216,7 +217,7 @@ end # The one-mode system has a single tight self-loop, so the condition holds. single = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -230,7 +231,7 @@ end @testset "the optimality gap is zero when proved, the bracket otherwise" begin # Theorem 4 holds on the one-mode system: the gap is exactly zero. single = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -241,7 +242,7 @@ end # On the rotations it does not, and the gap is the cycle bracket. rotations = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), TWO_NODES, ROTATION_PROBLEM; optimizer = OPTIMIZER, @@ -260,7 +261,7 @@ end PCC.switched_system([0.69 .* [0.0 1.0; -1.0 0.0], 0.69 .* [1.0 1.0; 0.0 1.0]]), ) loose = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), PCC.common_graph(2), sheared; optimizer = OPTIMIZER, @@ -272,7 +273,7 @@ end # An exact certificate read with too small a tolerance: no tight cycle, so no claim. single = PCC.jsr_bound( - PCC.QuadraticTemplate(), + SetProg.Ellipsoid(symmetric = true), GRAPH, PROBLEM; optimizer = OPTIMIZER, diff --git a/test/refinements/refine.jl b/test/refinements/refine.jl index 7019872..7fc4863 100644 --- a/test/refinements/refine.jl +++ b/test/refinements/refine.jl @@ -1,5 +1,6 @@ module TestRefine +import SetProg using Test using HybridSystems using Random @@ -7,7 +8,7 @@ import PathCompleteCertificates as PCC import Clarabel const OPTIMIZER = Clarabel.Optimizer -const TEMPLATE = PCC.QuadraticTemplate() +const TEMPLATE = SetProg.Ellipsoid(; symmetric = true) # A pure rotation scaled by 0.9: since rotations preserve the Euclidean norm # exactly, P = I is already the exact quadratic certificate, at exactly the @@ -360,7 +361,7 @@ end # at every tight edge, each candidate solved, the best kept. The products # write words on the edges, so the graph gains edges rather than nodes. problem = SHEARED - template = PCC.ParallelFacesPolyhedralTemplate(1, 2) + template = SetProg.Parallelotope(dimension = 2) trace = PCC.refine( template, diff --git a/test/runtests.jl b/test/runtests.jl index 9755afc..159865b 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -26,11 +26,7 @@ const TEST_FILES = [ ("./systems/switched.jl",), ("./systems/trajectory.jl",), ("./systems/simulate.jl",), - ("./templates/dual_copositive.jl",), - ("./templates/parallel_faces_polyhedral.jl", :slow), - ("./templates/conic_polyhedral.jl", :slow), - ("./templates/sum_of_squares.jl", :slow), - ("./templates/set_prog.jl", :slow), + ("./problems/sets.jl", :slow), ("./problems/certify.jl", :slow), ("./problems/tight_edges.jl", :slow), ("./problems/stability.jl", :slow), diff --git a/test/templates/conic_polyhedral.jl b/test/templates/conic_polyhedral.jl deleted file mode 100644 index 3d36ec6..0000000 --- a/test/templates/conic_polyhedral.jl +++ /dev/null @@ -1,128 +0,0 @@ -module TestConicPolyhedral - -# The conic polyhedral template: the facets are variables, and each node -# carries a conic partition on which the max is linear. -using Test -using HybridSystems -import PathCompleteCertificates as PCC -import Clarabel -import LinearAlgebra -using LinearAlgebra: normalize - -const OPTIMIZER = Clarabel.Optimizer - -# rho(A) = 0.5, and the identity template certifies it exactly: the weighted -# infinity norm with w = (1, 1) is already a Lyapunov function here. -const A = [[0.5 0.0; 0.0 0.25]] -const SYSTEM = PCC.switched_system(A) -const PROBLEM = PCC.StabilityProblem(SYSTEM) - -const GRAPH = GraphAutomaton(2) -add_transition!(GRAPH, 1, 2, 1) -add_transition!(GRAPH, 2, 1, 1) - -@testset "planar conic partitions" begin - for order in 1:4 - cones = PCC.planar_conic_partition(order) - - @test length(cones) == 4 * 2^(order - 1) - @test all(size(cone) == (2, 2) for cone in cones) - - # Consecutive cones share a ray, and together they sweep the upper - # half-plane from (1, 0) to (-1, 0). - @test cones[1][:, 1] == [1.0, 0.0] - @test normalize(cones[end][:, 2]) ≈ [-1.0, 0.0] - - for i in 1:(length(cones) - 1) - @test normalize(cones[i][:, 2]) ≈ normalize(cones[i + 1][:, 1]) - end - end - - @test_throws ArgumentError PCC.planar_conic_partition(0) -end - -@testset "the conic template solves for its facets" begin - cones = PCC.planar_conic_partition(2) - template = PCC.ConicPolyhedralTemplate([cones, cones]) - - @test PCC.homogeneity_degree(template) == 1 - - result = PCC.jsr_bound(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rtol = 1e-3) - - @test PCC.is_feasible(result) - @test abs(result.rate - 0.5) <= 0.01 - - # One facet row per cone, and the bound `c` that keeps V away from zero. - @test size(PCC.functions(result)[1].P) == (length(cones), 2) - @test PCC.functions(result)[1].c >= 1 - 1e-9 - - # The edge condition is imposed only on the cones' extreme rays; check it - # actually holds everywhere, which is the point of the dominance - # constraints. - for edge in PCC.edges(GRAPH) - src = PCC.source(edge) - dst = PCC.dest(edge) - mode = PCC.label(GRAPH, edge) - - for theta in range(0, 2pi; length = 400) - x = [cos(theta), sin(theta)] - @test PCC.functions(result)[dst](A[mode] * x) <= - result.rate * PCC.functions(result)[src](x) + 1e-6 - end - end -end - -@testset "free facets beat fixed ones, and refine toward the true rate" begin - # A rotation scaled by 0.9. Its JSR is 0.9, but no weighted infinity norm - # in the standard coordinates gets near it -- the 2n-face template cannot - # even certify contraction, while refining the partition drives the conic - # template down to the true rate. - theta = pi / 3 - rotated = 0.9 * [cos(theta) -sin(theta); sin(theta) cos(theta)] - - graph = GraphAutomaton(1) - add_transition!(graph, 1, 1, 1) - problem = PCC.StabilityProblem(PCC.switched_system([rotated])) - - fixed = PCC.jsr_bound( - PCC.ParallelFacesPolyhedralTemplate(1, 2), - graph, - problem; - optimizer = OPTIMIZER, - rtol = 1e-4, - ).rate - - @test fixed > 1.0 # certifies nothing about contraction - - bounds = map(1:4) do order - template = PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(order)]) - PCC.jsr_bound(template, graph, problem; optimizer = OPTIMIZER, rtol = 1e-4).rate - end - - @test all(>=(0.9 - 1e-4), bounds) # never below the true JSR - @test all(<(fixed), bounds) # all better than fixed facets - @test issorted(bounds; rev = true) # refining never hurts - @test bounds[end] <= 0.91 # and converges to 0.9 -end - -@testset "the conic template validates its partition" begin - cones = PCC.planar_conic_partition(1) - - @test_throws ArgumentError PCC.ConicPolyhedralTemplate(Vector{Matrix{Float64}}[]) - @test_throws ArgumentError PCC.ConicPolyhedralTemplate([Matrix{Float64}[]]) - @test_throws ArgumentError PCC.ConicPolyhedralTemplate([cones]; min_scale = 0.0) - - # Cones of mismatched dimension. - @test_throws ArgumentError PCC.ConicPolyhedralTemplate([[cones[1], ones(3, 2)]]) - - # A partition for fewer nodes than the graph has. - single = PCC.ConicPolyhedralTemplate([cones]) - @test_throws ArgumentError PCC.jsr_bound(single, GRAPH, PROBLEM; optimizer = OPTIMIZER) - - # Integer rays take the default bound in floating point. - integer = PCC.ConicPolyhedralTemplate([[[1 0; 0 1]]]) - @test integer.min_scale > 0 - @test only(only(integer.cones)) == [1 0; 0 1] -end - -end diff --git a/test/templates/dual_copositive.jl b/test/templates/dual_copositive.jl deleted file mode 100644 index 6421aa9..0000000 --- a/test/templates/dual_copositive.jl +++ /dev/null @@ -1,50 +0,0 @@ -module TestDualCopositive - -using Test -using HybridSystems -import PathCompleteCertificates as PCC -import HiGHS - -# G_4 of the HSCC 2023 capsule. The one-node graph simulates every graph, so no -# graph certifies a larger rate than it does. -const G4 = GraphAutomaton(5) -for (a, b, mode) in [ - (1, 2, 1), - (2, 4, 1), - (3, 2, 1), - (4, 3, 1), - (4, 5, 1), - (5, 1, 1), - (1, 4, 2), - (1, 3, 2), - (2, 1, 2), - (3, 2, 2), - (4, 5, 2), - (5, 1, 2), -] - add_transition!(G4, a, b, mode) -end - -# A positive system on which, with the weights floored at 1e-3, HiGHS's -# absolute tolerance let the one-node graph certify a smaller rate than G_4. -const SYSTEM = PCC.switched_system([[9.007 0.274; 2.069 8.272], [0.014 2.458; 8.874 1.281]]) - -@testset "the weight floor keeps the solver's tolerance out of the rate" begin - template = PCC.DualCopositiveTemplate() - @test template.min_weight == 1 - - problem = PCC.StabilityProblem(SYSTEM) - rate(graph) = - PCC.jsr_bound(template, graph, problem; optimizer = HiGHS.Optimizer, rtol = 1e-9).rate - - @test rate(PCC.common_graph(2)) >= rate(G4) * (1 - 1e-7) -end - -@testset "the template applies to positive systems only" begin - template = PCC.DualCopositiveTemplate() - @test PCC.is_applicable(template, SYSTEM) - @test !PCC.is_applicable(template, PCC.switched_system([[0.5 -0.1; 0.0 0.5]])) - @test_throws ArgumentError PCC.DualCopositiveTemplate(; min_weight = 0) -end - -end # module diff --git a/test/templates/parallel_faces_polyhedral.jl b/test/templates/parallel_faces_polyhedral.jl deleted file mode 100644 index 75cb86e..0000000 --- a/test/templates/parallel_faces_polyhedral.jl +++ /dev/null @@ -1,141 +0,0 @@ -module TestParallelFacesPolyhedral - -# The parallel-faces polyhedral template: one fixed matrix per node, functions -# homogeneous of degree 1. - -using Test -using HybridSystems -import PathCompleteCertificates as PCC -import Clarabel -import LinearAlgebra -using LinearAlgebra: normalize - -const OPTIMIZER = Clarabel.Optimizer - -# rho(A) = 0.5, and the identity template certifies it exactly: the weighted -# infinity norm with w = (1, 1) is already a Lyapunov function here. -const A = [[0.5 0.0; 0.0 0.25]] -const SYSTEM = PCC.switched_system(A) -const PROBLEM = PCC.StabilityProblem(SYSTEM) - -const GRAPH = GraphAutomaton(2) -add_transition!(GRAPH, 1, 2, 1) -add_transition!(GRAPH, 2, 1, 1) - -@testset "the template carries per-node data" begin - template = PCC.ParallelFacesPolyhedralTemplate(2, 2) - - @test length(template.G) == 2 - @test all(G == LinearAlgebra.I for G in template.G) - @test template.min_weight > 0 - - rotation = [0.0 -1.0; 1.0 0.0] - rotated = PCC.ParallelFacesPolyhedralTemplate([ - rotation, - Matrix{Float64}(LinearAlgebra.I, 2, 2), - ]) - @test rotated.G[1] == rotation - - @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(Matrix{Float64}[]) - @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate([zeros(2, 2)]) # singular - @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate([ones(2, 3)]) # not square - @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(2, 2; min_weight = 0.0) - - # Integer face normals take the default floor in floating point. - integer = PCC.ParallelFacesPolyhedralTemplate([[1 0; 0 1]]) - @test integer.min_weight > 0 - @test only(integer.G) == LinearAlgebra.I -end - -@testset "the node function is a weighted infinity norm" begin - G = [2.0 0.0; 0.0 1.0] - V = PCC.ParallelFacesPolyhedralFunction(G, [1.0, 4.0]) - - # max(|2*3|/1, |1*2|/4) = max(6, 0.5) = 6 - @test V([3.0, 2.0]) ≈ 6.0 - @test V([0.0, 0.0]) ≈ 0.0 - - # Degree 1, unlike the quadratic template. - @test V([6.0, 4.0]) ≈ 2 * V([3.0, 2.0]) -end - -@testset "synthesis recovers the rate" begin - template = PCC.ParallelFacesPolyhedralTemplate(PCC.n_nodes(GRAPH), 2) - - @test PCC.is_feasible( - PCC.certify(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rate = 0.6), - ) - @test !PCC.is_feasible( - PCC.certify(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rate = 0.4), - ) - - result = PCC.jsr_bound(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rtol = 1e-3) - - @test PCC.is_feasible(result) - @test abs(result.rate - 0.5) <= 0.01 - @test length(PCC.functions(result)) == 2 -end - -@testset "the certificate satisfies the edge condition it claims" begin - # Not the solver's status: check V_dst(A x) <= gamma * V_src(x) directly, - # on the fitted weights, at sampled states. - template = PCC.ParallelFacesPolyhedralTemplate(PCC.n_nodes(GRAPH), 2) - result = PCC.jsr_bound(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rtol = 1e-3) - - fitted = PCC.functions(result) - - for edge in PCC.edges(GRAPH) - src = PCC.source(edge) - dst = PCC.dest(edge) - mode = PCC.label(GRAPH, edge) - - for x in ([1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [-2.0, 3.0], [0.7, -1.3]) - @test fitted[dst](A[mode] * x) <= result.rate * fitted[src](x) + 1e-6 - end - end -end - -@testset "a rotated template still certifies" begin - # G is fixed data rather than a decision variable, so a different G is a - # different -- and generally more conservative -- template on the same - # graph. It must still produce a sound bound. - theta = pi / 6 - rotation = [cos(theta) -sin(theta); sin(theta) cos(theta)] - template = PCC.ParallelFacesPolyhedralTemplate([copy(rotation), copy(rotation)]) - - result = PCC.jsr_bound(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rtol = 1e-3) - - @test PCC.is_feasible(result) - @test result.rate >= 0.5 - 1e-6 # never below the true rate - @test result.rate <= 1.0 # and still certifies contraction -end - -@testset "the common function works for this template too" begin - template = PCC.ParallelFacesPolyhedralTemplate(PCC.n_nodes(GRAPH), 2) - result = PCC.jsr_bound(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rtol = 1e-3) - - fitted = PCC.functions(result) - - x = [1.0, 2.0] - value = PCC.common(GRAPH, PROBLEM, fitted, x) - - # GRAPH uses mode 1 only, and the system has one mode, so it is complete - # and the common function is the minimum over nodes. - @test PCC.is_complete(GRAPH, 1:1) - @test value ≈ minimum(V(x) for V in fitted) -end - -@testset "the template's weights are floored, not merely nonnegative" begin - # `w` sits in a denominator. The LinearCopositive method would impose - # `w >= 0` and leave V undefined; dispatching on the template is what stops - # that, so check the floor is actually in force. - template = PCC.ParallelFacesPolyhedralTemplate(PCC.n_nodes(GRAPH), 2; min_weight = 2.5) - result = PCC.jsr_bound(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rtol = 1e-3) - - @test PCC.is_feasible(result) - for V in PCC.functions(result) - @test all(>=(2.5 - 1e-6), V.w) - end -end - -end diff --git a/test/templates/sum_of_squares.jl b/test/templates/sum_of_squares.jl deleted file mode 100644 index 3ee5c3d..0000000 --- a/test/templates/sum_of_squares.jl +++ /dev/null @@ -1,183 +0,0 @@ -module TestSumOfSquares - -# The sum-of-squares template lives in a package extension, so this file is the -# only one that loads the polynomial stack. -# -# The anchor is that degree 1 reproduces `QuadraticTemplate` exactly: a -# sum-of-squares polynomial parametrised by degree-1 monomials *is* x'Px with -# P psd, so the two must agree to solver tolerance. Anything else means the -# scaling or the substitution is wrong. - -using Test -using HybridSystems -using SumOfSquares, DynamicPolynomials -import PathCompleteCertificates as PCC -import Clarabel - -const OPTIMIZER = Clarabel.Optimizer - -@polyvar x[1:2] - -# A rotation and a shear: no quadratic attains the joint spectral radius. -const A = [0.69 * [0.0 1.0; -1.0 0.0], 0.69 * [1.0 1.0; 0.0 1.0]] -const PROBLEM = PCC.StabilityProblem(PCC.switched_system(A)) - -const MEMORYLESS = GraphAutomaton(1) -add_transition!(MEMORYLESS, 1, 1, 1) -add_transition!(MEMORYLESS, 1, 1, 2) - -@testset "the extension is what supplies the methods" begin - @test Base.get_extension(PCC, :PathCompleteCertificatesSumOfSquaresExt) !== nothing -end - -@testset "the template validates its own data" begin - @test_throws ArgumentError PCC.SumOfSquaresTemplate(0, x) - @test_throws ArgumentError PCC.SumOfSquaresTemplate(2, typeof(x[1])[]) - - @test PCC.homogeneity_degree(PCC.SumOfSquaresTemplate(1, x)) == 2 - @test PCC.homogeneity_degree(PCC.SumOfSquaresTemplate(3, x)) == 6 -end - -@testset "degree 1 reproduces the quadratic template" begin - quadratic = PCC.jsr_bound( - PCC.QuadraticTemplate(), - MEMORYLESS, - PROBLEM; - optimizer = OPTIMIZER, - rtol = 1e-4, - ) - sos = PCC.jsr_bound( - PCC.SumOfSquaresTemplate(1, x), - MEMORYLESS, - PROBLEM; - optimizer = OPTIMIZER, - rtol = 1e-4, - ) - - @test PCC.is_feasible(quadratic) - @test PCC.is_feasible(sos) - @test abs(sos.rate - quadratic.rate) <= 1e-3 -end - -@testset "raising the degree tightens the bound" begin - rates = map((1, 2, 3)) do degree - PCC.jsr_bound( - PCC.SumOfSquaresTemplate(degree, x), - MEMORYLESS, - PROBLEM; - optimizer = OPTIMIZER, - rtol = 1e-4, - ).rate - end - - # Parrilo and Jadbabaie: the bound is non-increasing in the degree. - @test rates[2] <= rates[1] + 1e-6 - @test rates[3] <= rates[2] + 1e-6 - - # And strictly better here, since no quadratic attains this JSR. - @test rates[2] < rates[1] - 1e-2 -end - -@testset "the fitted function is callable and homogeneous" begin - degree = 2 - certificate = PCC.jsr_bound( - PCC.SumOfSquaresTemplate(degree, x), - MEMORYLESS, - PROBLEM; - optimizer = OPTIMIZER, - rtol = 1e-3, - ) - - V = only(PCC.functions(certificate)) - - @test V isa PCC.SumOfSquaresFunction - @test V([1.0, 1.0]) > 0 - @test V([0.0, 0.0]) ≈ 0 atol = 1e-8 - - # V(cx) = c^(2 degree) V(x). - @test V([2.0, 3.0]) ≈ 2.0^(2degree) * V([1.0, 1.5]) rtol = 1e-6 - - # `certificate(x)` is the common function over one node, so the same number. - @test certificate([1.0, 1.0]) ≈ V([1.0, 1.0]) rtol = 1e-8 -end - -@testset "the template and the system must agree on the dimension" begin - @polyvar y[1:3] - - @test_throws ArgumentError PCC.jsr_bound( - PCC.SumOfSquaresTemplate(1, y), - MEMORYLESS, - PROBLEM; - optimizer = OPTIMIZER, - ) -end - -@testset "the edge inequality holds where it was imposed" begin - certificate = PCC.jsr_bound( - PCC.SumOfSquaresTemplate(2, x), - MEMORYLESS, - PROBLEM; - optimizer = OPTIMIZER, - rtol = 1e-3, - ) - - V = only(PCC.functions(certificate)) - scale = certificate.rate^PCC.homogeneity_degree(PCC.SumOfSquaresTemplate(2, x)) - - # The constraint is built with the map divided rather than the inequality - # scaled; this checks the two are the same inequality. - for point in ([1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, -2.0], [-0.5, 3.0]) - for map in A - @test scale * V(point) - V(map * point) >= -1e-6 - end - end -end - -@testset "a genuine path-complete graph, with two distinct node functions" begin - # Everything above runs on one node, where V_src and V_dst are the same - # object: the substitution is never exercised on two different functions, - # and a swapped source/destination cannot be detected. This is that test. - graph = PCC.de_bruijn(1, 2) - template = PCC.SumOfSquaresTemplate(2, x) - - @test PCC.n_nodes(graph) == 2 - @test PCC.n_edges(graph) == 4 - - certificate = - PCC.jsr_bound(template, graph, PROBLEM; optimizer = OPTIMIZER, rtol = 1e-4) - @test PCC.is_feasible(certificate) - - V = PCC.functions(certificate) - @test length(V) == 2 - - # Memory buys something here, so the two-node bound beats the one-node one. - memoryless = - PCC.jsr_bound(template, MEMORYLESS, PROBLEM; optimizer = OPTIMIZER, rtol = 1e-4) - @test certificate.rate <= memoryless.rate + 1e-6 - - # The nodes really do carry different functions. - probe = [1.0, 0.7] - @test !(V[1](probe) ≈ V[2](probe)) - - # The edge inequality, per edge, with the pairing the graph dictates. A - # source/destination swap fails here and nowhere else. - scale = certificate.rate^PCC.homogeneity_degree(template) - - for edge in PCC.edges(graph) - src = V[PCC.source(edge)] - dst = V[PCC.dest(edge)] - map = A[PCC.label(graph, edge)] - - for point in ([1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, -2.0], [-0.5, 3.0]) - @test scale * src(point) - dst(map * point) >= -1e-6 - end - end - - # `common` over a complete graph is the minimum over the nodes, and the - # certificate is callable through it. - for point in ([1.0, 1.0], [2.0, -1.0], [0.3, 0.8]) - @test certificate(point) ≈ min(V[1](point), V[2](point)) rtol = 1e-6 - end -end - -end From 09db883bd70a3e16cb9eccf18cd0a9854e1736f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Thu, 15 Oct 2026 13:27:05 +0200 Subject: [PATCH 02/18] Move templates back to PCC --- bench/athanasopoulos2019.jl | 2 +- docs/src/bibliography.md | 10 +- docs/src/examples/comparing.jl | 4 +- docs/src/examples/templates_and_graphs.jl | 7 +- docs/src/manual/comparing.md | 2 +- docs/src/manual/support.md | 7 +- docs/src/manual/templates.md | 32 +++-- docs/src/reference/templates.md | 12 +- src/PathCompleteCertificates.jl | 1 + src/problems/polytope_compat.jl | 137 ++++++++++++++++++++++ src/problems/sets.jl | 32 ++--- test/lifts/compare.jl | 4 +- test/lifts/composition.jl | 2 +- test/lifts/identity.jl | 4 +- test/lifts/min.jl | 2 +- test/lifts/split.jl | 5 +- test/problems/certify.jl | 2 +- test/problems/common.jl | 12 +- test/problems/dual.jl | 47 ++++---- test/problems/sets.jl | 32 +++-- test/problems/stability.jl | 16 +-- test/problems/tight_edges.jl | 6 +- test/refinements/refine.jl | 2 +- 23 files changed, 269 insertions(+), 111 deletions(-) create mode 100644 src/problems/polytope_compat.jl diff --git a/bench/athanasopoulos2019.jl b/bench/athanasopoulos2019.jl index 5259316..20b03d7 100644 --- a/bench/athanasopoulos2019.jl +++ b/bench/athanasopoulos2019.jl @@ -22,7 +22,7 @@ const A2 = [0.4753 0.0 0.0; 0.0 0.0 0.4753; 0.0 -1.9012 0.0] depth_max = isempty(ARGS) ? 15 : parse(Int, ARGS[1]) problem = PCC.StabilityProblem(PCC.switched_system([A1, A2])) -template = SetProg.Parallelotope(; dimension = 3) +template = PCC.Parallelotope(; dimension = 3) elapsed = @elapsed trace = PCC.refine( template, diff --git a/docs/src/bibliography.md b/docs/src/bibliography.md index e1650d5..c5393d2 100644 --- a/docs/src/bibliography.md +++ b/docs/src/bibliography.md @@ -19,12 +19,12 @@ number quoted in the manual was read in the paper it is attributed to. | | | | :-- | :-- | -| [athanasopoulos2019polyhedral](@cite) | [`ParallelFacesPolyhedralTemplate`](@ref), fixed facets and solved-for weights, and the four partial lifts that are [`OutSplitLift`](@ref), [`InSplitLift`](@ref), [`ForwardProductLift`](@ref) and [`BackwardProductLift`](@ref) | -| [parrilo2008approximation](@cite) | [`SumOfSquaresTemplate`](@ref) | -| [debauche2024thesis](@cite) | [`DualCopositiveTemplate`](@ref), Def. 2.30 | +| [athanasopoulos2019polyhedral](@cite) | `PCC.Parallelotope`, fixed facets and solved-for weights, and the four partial lifts that are [`OutSplitLift`](@ref), [`InSplitLift`](@ref), [`ForwardProductLift`](@ref) and [`BackwardProductLift`](@ref) | +| [parrilo2008approximation](@cite) | `SetProg.PolySet` | +| [debauche2024thesis](@cite) | `PCC.PositiveBox`, Def. 2.30 | -`SetProg.Ellipsoid` and `SetProg.PositiveSimplex` are -standard. `SetProg.ConicPolytope` is under submission and has no +`SetProg.Ellipsoid` and `PCC.PositiveSimplex` are +standard. `PCC.ConicPolytope` is under submission and has no entry yet. ## Safety and optimal control diff --git a/docs/src/examples/comparing.jl b/docs/src/examples/comparing.jl index 9e13e08..429707c 100644 --- a/docs/src/examples/comparing.jl +++ b/docs/src/examples/comparing.jl @@ -123,8 +123,8 @@ system = PCC.switched_system(A) problem = PCC.StabilityProblem(system) QUADRATIC = SetProg.Ellipsoid(; symmetric = true) -COPOSITIVE = SetProg.PositiveSimplex() -DUAL_COPOSITIVE = SetProg.PositiveBox() +COPOSITIVE = PCC.PositiveSimplex() +DUAL_COPOSITIVE = PCC.PositiveBox() TEMPLATES = (QUADRATIC, COPOSITIVE, DUAL_COPOSITIVE) c12 = PCC.compare(G1, G2; templates = TEMPLATES, system, optimizer = OPTIMIZER) diff --git a/docs/src/examples/templates_and_graphs.jl b/docs/src/examples/templates_and_graphs.jl index e8fefd3..d46a886 100644 --- a/docs/src/examples/templates_and_graphs.jl +++ b/docs/src/examples/templates_and_graphs.jl @@ -50,11 +50,8 @@ rotation_problem = PCC.StabilityProblem(PCC.switched_system(ROTATION)) templates = [ ("quadratic", SetProg.Ellipsoid(; symmetric = true)), - ("polyhedral, fixed facets", SetProg.Parallelotope(; dimension = 2)), - ( - "polyhedral, free facets", - SetProg.ConicPolytope.([SetProg.planar_conic_partition(3)]), - ), + ("polyhedral, fixed facets", PCC.Parallelotope(; dimension = 2)), + ("polyhedral, free facets", PCC.ConicPolytope.([PCC.planar_conic_partition(3)])), ] left = plot(; diff --git a/docs/src/manual/comparing.md b/docs/src/manual/comparing.md index 035ee9c..42f2ff3 100644 --- a/docs/src/manual/comparing.md +++ b/docs/src/manual/comparing.md @@ -62,7 +62,7 @@ finds the order. The products give graphs with words on their edges, which ¹ The linear copositive norms are not closed under `min`, yet the min lift is valid for them on positive systems ([debauche2024thesis](@cite), Thm. 7.43): -[`is_valid`](@ref) reads it through their dual, `SetProg.PositiveBox`, +[`is_valid`](@ref) reads it through their dual, `PCC.PositiveBox`, closed under `max`. ² Sufficient only: [jongeneel2025ordering](@cite), Ex. IV.3, exhibits two diff --git a/docs/src/manual/support.md b/docs/src/manual/support.md index 53ae81c..fd2f307 100644 --- a/docs/src/manual/support.md +++ b/docs/src/manual/support.md @@ -13,10 +13,11 @@ Stability accepts SetProg families directly. Safety and optimal control retain | positive simplex or box | ✓ on positive systems | ✓ | | parallelotope or conic polytope | ✓ | ✓ | | polynomial set | ✓ with `using SumOfSquares` | | -| piecewise ellipsoid or polytope | ✓ | | +| piecewise ellipsoid | ✓ | | +| piecewise polytope | ✓ | ✓ | -Refinement requires [`decrease_slack`](@ref). Fitted polynomial and general -piecewise slacks need a separate optimization and are not implemented. +Refinement requires [`decrease_slack`](@ref). Fitted polynomial and piecewise-quadratic +slacks need a separate optimization and are not implemented. See [Templates](@ref) for closures governing [`compare`](@ref). Stability accepts [`WordGraph`](@ref). Safety and optimal control require one diff --git a/docs/src/manual/templates.md b/docs/src/manual/templates.md index fa644bc..794939b 100644 --- a/docs/src/manual/templates.md +++ b/docs/src/manual/templates.md @@ -20,10 +20,10 @@ certificate = PCC.jsr_bound(family, graph, problem; optimizer) | SetProg family | Node function | Degree | Program | | :-- | :-- | :-: | :-- | | `Ellipsoid(symmetric = true)` | `x'Qx` | 2 | semidefinite | -| `PositiveSimplex()` | `c'x` on the nonnegative orthant | 1 | linear | -| `PositiveBox()` | `maximum(x ./ v)` on the nonnegative orthant | 1 | linear | -| `Parallelotope(G)` | `maximum(abs.(G*x) ./ w)` | 1 | linear | -| `ConicPolytope(cones)` | `maximum(abs.(P*x))` | 1 | linear | +| `Polytope(piecewise = :orthant)` | `c'x` on the nonnegative orthant | 1 | linear | +| `Polytope(piecewise = :orthant, polar = true)` | `maximum(x ./ v)` on the nonnegative orthant | 1 | linear | +| `Polytope(piecewise = :orthants, transform = G', polar = true)` | `maximum(abs.(G*x) ./ w)` | 1 | linear | +| `Polytope(symmetric = true, piecewise = cones)` | `maximum(abs.(P*x))` | 1 | linear | | `PolySet(symmetric = true, degree = 2d)` | sum-of-squares polynomial | 2d | semidefinite | | `Ellipsoid(symmetric = true, piecewise = partition)` | quadratic on each partition cone | 2 | semidefinite | | `Polytope(symmetric = true, piecewise = partition)` | linear on each partition cone | 1 | linear | @@ -33,13 +33,21 @@ an error explaining the missing extension. Polynomial variables may be supplied with `variables = x`, or inferred in a shared coordinate system. SetProg is a direct dependency of PCC; its polynomial dependencies are optional. -`Parallelotope()` uses the identity for `G`, with dimension inferred from the -system. `ConicPolytope` fixes the rays of a conic partition and solves for its -facet normals. `SetProg.planar_conic_partition(order)` supplies a symmetric -planar partition (the returned cones cover the upper half-plane). -`PositiveSimplex` and `PositiveBox` require nonnegative mode matrices. -Their positive coefficient floors, and those of the polyhedral families, -are configurable with `min_scale` or `min_weight`. +The four polyhedral families use the same piecewise-linear representation. +The `:orthant` partition has one linear piece, restricted to the nonnegative +orthant; `:orthants` is a weighted absolute-value sum. Taking the polar gives +a box or parallelotope. Explicit cone matrices prescribe the linear pieces' +regions and solve for their coefficients. `polar` specifies the family; +SetProg chooses its modeling space from the constraints. + +PCC retains `PositiveSimplex`, `PositiveBox`, `Parallelotope`, and +`ConicPolytope` as compatibility constructors returning these configurations. +The original `LinearCopositiveTemplate`, `DualCopositiveTemplate`, +`ParallelFacesPolyhedralTemplate`, and `ConicPolyhedralTemplate` constructors +also remain available. Positive-orthant families require nonnegative dynamics. +`PCC.planar_conic_partition(order)` supplies a planar conic partition. +Coefficient floors are configurable through `min_scale` on `Polytope`, or +through the compatibility constructors' original keyword arguments. The degree `p`, returned by [`homogeneity_degree`](@ref), satisfies `V(c*x) = c^p * V(x)`. Stability uses `rate^p` in each edge inequality, @@ -74,7 +82,7 @@ have no fitted dual implementation here. See [Duality](@ref). A single family is shared by all nodes. A vector supplies one family per node: ```julia -families = [Parallelotope(G1), Parallelotope(G2)] +families = [PCC.Parallelotope(G1), PCC.Parallelotope(G2)] certificate = PCC.jsr_bound(families, graph, problem; optimizer) ``` diff --git a/docs/src/reference/templates.md b/docs/src/reference/templates.md index 5c32139..c8c0170 100644 --- a/docs/src/reference/templates.md +++ b/docs/src/reference/templates.md @@ -11,7 +11,7 @@ parameters and [What works with what](@ref) for support by problem. ```@autodocs Modules = [SetProg] -Pages = ["gauges.jl"] +Pages = ["variables.jl", "gauges.jl"] ``` ## Graph operations on families @@ -27,3 +27,13 @@ Pages = ["problems/sets.jl", "templates/abstract.jl"] Modules = [PathCompleteCertificates] Pages = ["templates/catalogue/quadratic.jl"] ``` + +## Compatibility constructors + +The former polyhedral templates are constructors returning native SetProg +families. Their names remain in PCC; there are no separate SetProg template types. + +```@autodocs +Modules = [PathCompleteCertificates] +Pages = ["problems/polytope_compat.jl"] +``` diff --git a/src/PathCompleteCertificates.jl b/src/PathCompleteCertificates.jl index fdc1753..4e6a82f 100644 --- a/src/PathCompleteCertificates.jl +++ b/src/PathCompleteCertificates.jl @@ -48,6 +48,7 @@ include("systems/simulate.jl") include("templates/abstract.jl") include("problems/abstract.jl") include("problems/sets.jl") +include("problems/polytope_compat.jl") include("problems/certificate.jl") include("problems/tight_edges.jl") diff --git a/src/problems/polytope_compat.jl b/src/problems/polytope_compat.jl new file mode 100644 index 0000000..7eddbcc --- /dev/null +++ b/src/problems/polytope_compat.jl @@ -0,0 +1,137 @@ +""" + PositiveSimplex(; dimension = nothing, min_scale = 1) + +Compatibility constructor for a linear gauge on the nonnegative orthant. +""" +PositiveSimplex(; dimension = nothing, min_scale = 1) = + SetProg.Polytope(; piecewise = :orthant, dimension = dimension, min_scale = min_scale) + +""" + PositiveBox(; dimension = nothing, min_weight = 1) + +Compatibility constructor for the blocking polar of a positive simplex. +""" +PositiveBox(; dimension = nothing, min_weight = 1) = SetProg.Polytope(; + piecewise = :orthant, + dimension = dimension, + polar = true, + min_scale = min_weight, +) + +""" + Parallelotope(G; min_weight = 1) + Parallelotope(; dimension = nothing, min_weight = 1) + +Compatibility constructor for the polar of a weighted absolute-value sum. +""" +Parallelotope(G::AbstractMatrix; min_weight = 1) = SetProg.Polytope(; + symmetric = true, + piecewise = :orthants, + transform = Matrix(G'), + polar = true, + min_scale = min_weight, +) +Parallelotope(; dimension = nothing, min_weight = 1) = SetProg.Polytope(; + symmetric = true, + piecewise = :orthants, + dimension = dimension, + polar = true, + min_scale = min_weight, +) + +""" + ConicPolytope(cones; min_scale = 1) + +Compatibility constructor for a symmetric polytope with prescribed cones. +""" +ConicPolytope(cones::AbstractVector{<:AbstractMatrix}; min_scale = 1) = + SetProg.Polytope(; symmetric = true, piecewise = cones, min_scale = min_scale) + +""" + planar_conic_partition(order) + +Compatibility helper: subdivide the upper half-plane into partition cones. +""" +function planar_conic_partition(order::Integer) + order >= 1 || throw(ArgumentError("the order must be positive")) + rays = ([1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [-1.0, 1.0], [-1.0, 0.0]) + cones = [hcat(rays[i], rays[i + 1]) for i in 1:4] + for _ in 2:order + cones = [ + C for cone in cones for C in ( + hcat(cone[:, 1], sum(cone; dims = 2)), + hcat(sum(cone; dims = 2), cone[:, 2]), + ) + ] + end + return cones +end + +function PositiveSimplex(c::AbstractVector) + cone = Matrix{Float64}(LinearAlgebra.I, length(c), length(c)) + return SetProg.Sets.Intersection( + SetProg.Sets.Piecewise([collect(c)], [cone]), + SetProg.polyhedron( + SetProg.vrep( + [zeros(length(c))], + SetProg.Line{Float64, Vector{Float64}}[], + [SetProg.Ray(collect(ray)) for ray in eachcol(cone)], + ), + ), + ) +end +PositiveBox(v::AbstractVector) = SetProg.dual_value(PositiveSimplex(v)) +function Parallelotope(G::AbstractMatrix, w::AbstractVector) + n = LinearAlgebra.checksquare(G) + signs = vec([Float64[s...] for s in Iterators.product(ntuple(_ -> (-1, 1), n)...)]) + return SetProg.polar( + SetProg.Sets.Piecewise( + [inv(G) * (sign .* w) for sign in signs], + [G' * LinearAlgebra.Diagonal(sign) for sign in signs], + ), + ) +end + +""" + LinearCopositiveTemplate() + +Backward-compatible constructor for `Polytope(piecewise = :orthant)`. +""" +LinearCopositiveTemplate() = PositiveSimplex() + +""" + DualCopositiveTemplate(; min_weight = 1) + +Backward-compatible constructor for a positive box polytope configuration. +""" +DualCopositiveTemplate(; min_weight = 1) = PositiveBox(; min_weight) + +""" + ParallelFacesPolyhedralTemplate(n_nodes, dimension; min_weight = 1) + ParallelFacesPolyhedralTemplate(matrices; min_weight = 1) + +Backward-compatible constructor for native polytopes, one per graph node. +""" +function ParallelFacesPolyhedralTemplate( + n_nodes::Integer, + dimension::Integer; + min_weight = 1, +) + n_nodes > 0 || throw(ArgumentError("at least one node is required")) + return fill(Parallelotope(; dimension, min_weight), n_nodes) +end +function ParallelFacesPolyhedralTemplate( + matrices::AbstractVector{<:AbstractMatrix}; + min_weight = 1, +) + isempty(matrices) && throw(ArgumentError("at least one matrix is required")) + return [Parallelotope(G; min_weight) for G in matrices] +end + +""" + ConicPolyhedralTemplate(node_cones; min_scale = 1) + +Backward-compatible constructor for one native conic polytope per graph node. +""" +ConicPolyhedralTemplate(node_cones; min_scale = 1) = + [ConicPolytope(cones; min_scale) for cones in node_cones] diff --git a/src/problems/sets.jl b/src/problems/sets.jl index 2fbf05b..01c23fc 100644 --- a/src/problems/sets.jl +++ b/src/problems/sets.jl @@ -8,8 +8,7 @@ Whether the SetProg family applies to the system. Orthant gauges require nonnegative dynamics. """ is_applicable(::SetProg.AbstractVariable, system) = true -is_applicable(::Union{SetProg.PositiveSimplex, SetProg.PositiveBox}, system) = - is_positive(system) +is_applicable(f::SetProg.Polytope, system) = f.piecewise !== :orthant || is_positive(system) is_applicable(families::AbstractVector{<:SetProg.AbstractVariable}, system) = all(f -> is_applicable(f, system), families) @@ -19,33 +18,34 @@ is_applicable(families::AbstractVector{<:SetProg.AbstractVariable}, system) = Whether a SetProg gauge family admits the operation required by a graph lift. """ is_closed_under(::SetProg.AbstractVariable, ::Operation, system) = false -is_closed_under( - ::Union{SetProg.Ellipsoid, SetProg.PolySet, SetProg.PositiveSimplex}, - ::Addition, - system, -) = true +is_closed_under(::Union{SetProg.Ellipsoid, SetProg.PolySet}, ::Addition, system) = true is_closed_under(f::SetProg.Ellipsoid, ::Composition, system) = f.piecewise === nothing && is_invertible(system) is_closed_under(::SetProg.PolySet, ::Composition, system) = is_invertible(system) is_closed_under(f::SetProg.Ellipsoid, ::InverseComposition, system) = f.piecewise === nothing && is_invertible(system) is_closed_under(::SetProg.PolySet, ::InverseComposition, system) = is_invertible(system) -is_closed_under(::SetProg.Polytope, ::Addition, system) = true -is_closed_under(::Union{SetProg.PositiveBox, SetProg.Parallelotope}, ::Maximum, system) = - true -function is_closed_under(::SetProg.PositiveSimplex, ::Composition, system) - return all(A -> all(>=(0), A) && all(>(0), sum(A; dims = 1)), mode_matrices(system)) +is_closed_under(f::SetProg.Polytope, ::Addition, system) = !f.polar +is_closed_under(f::SetProg.Polytope, ::Maximum, system) = f.polar && f.piecewise isa Symbol +function is_closed_under(f::SetProg.Polytope, ::Composition, system) + return !f.polar && + f.piecewise === :orthant && + all(A -> all(>=(0), A) && all(>(0), sum(A; dims = 1)), mode_matrices(system)) end -function is_closed_under(::SetProg.PositiveSimplex, ::InverseComposition, system) - return is_invertible(system) && all(A -> all(>=(0), inv(A)), mode_matrices(system)) +function is_closed_under(f::SetProg.Polytope, ::InverseComposition, system) + return !f.polar && + f.piecewise === :orthant && + is_invertible(system) && + all(A -> all(>=(0), inv(A)), mode_matrices(system)) end function is_closed_under( families::AbstractVector{<:SetProg.AbstractVariable}, operation::Operation, system, ) - if operation isa Maximum && all(f -> f isa SetProg.Parallelotope, families) - return allequal(getproperty.(families, :G)) + if operation isa Maximum && + all(f -> f isa SetProg.Polytope && f.polar && f.piecewise === :orthants, families) + return allequal(getproperty.(families, :transform)) end return allequal(families) && is_closed_under(first(families), operation, system) end diff --git a/test/lifts/compare.jl b/test/lifts/compare.jl index ff00eea..d798c82 100644 --- a/test/lifts/compare.jl +++ b/test/lifts/compare.jl @@ -87,8 +87,8 @@ const SYSTEM = PCC.switched_system(A_STAR) const PROBLEM = PCC.StabilityProblem(SYSTEM) const QUADRATIC = SetProg.Ellipsoid(; symmetric = true) -const COPOSITIVE = SetProg.PositiveSimplex() -const DUAL_COPOSITIVE = SetProg.PositiveBox() +const COPOSITIVE = PCC.PositiveSimplex() +const DUAL_COPOSITIVE = PCC.PositiveBox() const TEMPLATES = (QUADRATIC, COPOSITIVE, DUAL_COPOSITIVE) found(comparison) = Dict(PCC.simulations(comparison)) diff --git a/test/lifts/composition.jl b/test/lifts/composition.jl index 2feeeca..030adf0 100644 --- a/test/lifts/composition.jl +++ b/test/lifts/composition.jl @@ -68,7 +68,7 @@ end ) @test !PCC.is_valid( PCC.CompositionLift(1), - SetProg.Parallelotope(dimension = 2), + PCC.Parallelotope(dimension = 2), invertible, ) end diff --git a/test/lifts/identity.jl b/test/lifts/identity.jl index e775f7d..795b1ea 100644 --- a/test/lifts/identity.jl +++ b/test/lifts/identity.jl @@ -21,8 +21,8 @@ import PathCompleteCertificates as PCC system = PCC.switched_system([[0.5 0.0; 0.0 0.5], [0.0 0.5; 0.5 0.0]]) for template in ( SetProg.Ellipsoid(symmetric = true), - SetProg.Parallelotope(dimension = 2), - SetProg.PositiveSimplex(), + PCC.Parallelotope(dimension = 2), + PCC.PositiveSimplex(), ) @test PCC.is_valid(PCC.IdentityLift(), template, system) end diff --git a/test/lifts/min.jl b/test/lifts/min.jl index 086c5b2..7273a3a 100644 --- a/test/lifts/min.jl +++ b/test/lifts/min.jl @@ -145,7 +145,7 @@ end end @testset "the min lift is valid on the linear copositive template for positive systems only" begin - template = SetProg.PositiveSimplex() + template = PCC.PositiveSimplex() positive = PCC.switched_system([[0.5 0.25; 0.0 0.5]]) signed = PCC.switched_system([[0.5 -0.25; 0.0 0.5]]) diff --git a/test/lifts/split.jl b/test/lifts/split.jl index df92a12..2f178b0 100644 --- a/test/lifts/split.jl +++ b/test/lifts/split.jl @@ -320,12 +320,13 @@ end @testset "a template with per-node data follows the split" begin graph = PCC.de_bruijn(1, 2) - template = SetProg.Parallelotope.([[1.0 0.0; 0.0 1.0], [1.0 1.0; 0.0 1.0]]) + template = PCC.Parallelotope.([[1.0 0.0; 0.0 1.0], [1.0 1.0; 0.0 1.0]]) lifted = PCC.lift(PCC.OutSplitLift(), graph, PCC.outgoing_edges(graph, 1)) moved = PCC.reindex(template, PCC.origins(lifted)) @test length(moved) == PCC.n_nodes(PCC.graph(lifted)) - @test getproperty.(moved, :G) == [template[o].G for o in PCC.origins(lifted)] + @test getproperty.(moved, :transform) == + [template[o].transform for o in PCC.origins(lifted)] # A data-free template is its own reindexing. @test PCC.reindex(SetProg.Ellipsoid(symmetric = true), PCC.origins(lifted)) == diff --git a/test/problems/certify.jl b/test/problems/certify.jl index fe10593..658ca43 100644 --- a/test/problems/certify.jl +++ b/test/problems/certify.jl @@ -51,7 +51,7 @@ end @testset "a failed solve names no function type" begin # `functions` is empty, so there is no fitted function to take a type from. - template = SetProg.Parallelotope(dimension = 2) + template = PCC.Parallelotope(dimension = 2) tight = PCC.certify(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rate = 0.01) @test !PCC.is_feasible(tight) diff --git a/test/problems/common.jl b/test/problems/common.jl index 4e87317..78a7b5f 100644 --- a/test/problems/common.jl +++ b/test/problems/common.jl @@ -52,16 +52,6 @@ end @testset "the common function is generic in the template" begin graph = PCC.de_bruijn(1, 2) - linear = SetProg.Sets.PositiveSimplex.([[1.0, 1.0], [2.0, 2.0]]) + linear = PCC.PositiveSimplex.([[1.0, 1.0], [2.0, 2.0]]) @test PCC.common(graph, PROBLEM, linear, X) ≈ minimum(V(X) for V in linear) - - weights = [ - SetProg.Sets.Parallelotope(Matrix{Float64}(LinearAlgebra.I, 2, 2), [1.0, 1.0]), - SetProg.Sets.Parallelotope(Matrix{Float64}(LinearAlgebra.I, 2, 2), [4.0, 4.0]), - ] - - @test PCC.common(graph, PROBLEM, weights, X) ≈ minimum(V(X) for V in weights) -end - -end diff --git a/test/problems/dual.jl b/test/problems/dual.jl index 0acd0e0..06b703b 100644 --- a/test/problems/dual.jl +++ b/test/problems/dual.jl @@ -22,8 +22,9 @@ edgeset(g) = Set( # The numbers a fitted node function is made of, whatever the template. coefficients(V::SetProg.Sets.Ellipsoid) = Matrix(V) -coefficients(V::SetProg.Sets.PositiveBox) = V.v -coefficients(V::SetProg.Sets.PositiveSimplex) = V.c +coefficients(V::SetProg.Sets.Intersection) = coefficients(V.set) +coefficients(V::SetProg.Sets.Polar) = coefficients(SetProg.polar(V)) +coefficients(V::SetProg.Sets.Piecewise) = reduce(vcat, [s.a for s in V.sets]) # A positive system for the copositive pair, a general one for the quadratic # template; both invertible, so the composition lifts are valid somewhere. @@ -35,8 +36,8 @@ const GENERAL = PCC.switched_system([[0.8 0.3; -0.2 0.5], [0.4 -0.6; 0.5 0.3]]) const CASES = [ (SetProg.Ellipsoid(; symmetric = true), GENERAL), - (SetProg.PositiveSimplex(), POSITIVE), - (SetProg.PositiveBox(), POSITIVE), + (PCC.PositiveSimplex(), POSITIVE), + (PCC.PositiveBox(), POSITIVE), ] const ONE = PCC.common_graph(2) @@ -135,29 +136,24 @@ end # What crossing over adds: the sum lift is valid for the dual copositive # norms because their primal is closed under addition, which they are not. - @test !PCC.is_closed_under(SetProg.PositiveBox(), PCC.Addition(), POSITIVE) - @test PCC.is_valid(PCC.SumLift(2), SetProg.PositiveBox(), POSITIVE) + @test !PCC.is_closed_under(PCC.PositiveBox(), PCC.Addition(), POSITIVE) + @test PCC.is_valid(PCC.SumLift(2), PCC.PositiveBox(), POSITIVE) # And Thm. 7.43, stated on the primal, is read on the dual through closure. - @test PCC.is_valid(PCC.MinLift(), SetProg.PositiveSimplex(), POSITIVE) - @test PCC.is_valid(PCC.MaxLift(), SetProg.PositiveBox(), POSITIVE) - @test !PCC.is_valid(PCC.MaxLift(), SetProg.PositiveSimplex(), POSITIVE) - @test !PCC.is_valid(PCC.MinLift(), SetProg.PositiveBox(), POSITIVE) + @test PCC.is_valid(PCC.MinLift(), PCC.PositiveSimplex(), POSITIVE) + @test PCC.is_valid(PCC.MaxLift(), PCC.PositiveBox(), POSITIVE) + @test !PCC.is_valid(PCC.MaxLift(), PCC.PositiveSimplex(), POSITIVE) + @test !PCC.is_valid(PCC.MinLift(), PCC.PositiveBox(), POSITIVE) # A template without a dual is decided by its own closures only. - @test PCC.is_valid(PCC.SumLift(2), SetProg.Parallelotope(dimension = 2), GENERAL) == - PCC.is_closed_under(SetProg.Parallelotope(dimension = 2), PCC.Addition(), GENERAL) + @test PCC.is_valid(PCC.SumLift(2), PCC.Parallelotope(dimension = 2), GENERAL) == + PCC.is_closed_under(PCC.Parallelotope(dimension = 2), PCC.Addition(), GENERAL) end @testset "an infeasible certificate dualises to an infeasible one" begin problem = PCC.StabilityProblem(POSITIVE) - none = PCC.certify( - SetProg.PositiveSimplex(), - ONE, - problem; - optimizer = OPTIMIZER, - rate = 0.01, - ) + none = + PCC.certify(PCC.PositiveSimplex(), ONE, problem; optimizer = OPTIMIZER, rate = 0.01) @test !PCC.is_feasible(none) dualised = PCC.dual(none) @@ -165,19 +161,18 @@ end @test PCC.status(dualised) == PCC.status(none) @test isempty(PCC.functions(dualised)) @test dualised.rate == none.rate - @test PCC.template(dualised) == SetProg.PositiveBox() + @test PCC.template(dualised) == PCC.PositiveBox() end @testset "a template without a dual says so" begin @test PCC.has_dual(SetProg.Ellipsoid(symmetric = true)) - @test PCC.has_dual(SetProg.PositiveSimplex()) - @test PCC.has_dual(SetProg.PositiveBox()) - @test !PCC.has_dual(SetProg.Parallelotope(dimension = 2)) + @test PCC.has_dual(PCC.PositiveSimplex()) + @test PCC.has_dual(PCC.PositiveBox()) + @test !PCC.has_dual(PCC.Parallelotope(dimension = 2)) - @test_throws ArgumentError PCC.dual(SetProg.Parallelotope(dimension = 2)) + @test_throws ArgumentError PCC.dual(PCC.Parallelotope(dimension = 2)) - polyhedral = - bound(SetProg.Parallelotope(dimension = 2), ONE, PCC.StabilityProblem(GENERAL)) + polyhedral = bound(PCC.Parallelotope(dimension = 2), ONE, PCC.StabilityProblem(GENERAL)) @test PCC.is_feasible(polyhedral) @test_throws ArgumentError PCC.dual(polyhedral) end diff --git a/test/problems/sets.jl b/test/problems/sets.jl index 81c199b..6623f42 100644 --- a/test/problems/sets.jl +++ b/test/problems/sets.jl @@ -31,7 +31,7 @@ const LIB = Polyhedra.DefaultLibrary{Float64}( ) # Partitions with exact facets, see the warning in the docstring. The cones over -# the facets of the octagon are the cones of `planar_conic_partition(1)`: its +# the facets of the octagon are the cones of `PCC.planar_conic_partition(1)`: its # vertices are on the rays ±(1, 0), ±(1, 1), ±(0, 1) and ±(-1, 1). const SQUARE = polyhedron( hrep([ @@ -118,7 +118,7 @@ end end @testset "a piecewise polytope reproduces the conic polyhedral template" begin - conic = rate(SetProg.ConicPolytope.([SetProg.planar_conic_partition(1)])) + conic = rate(PCC.ConicPolytope.([PCC.planar_conic_partition(1)])) template = Polytope(symmetric = true, piecewise = OCTAGON) polytope = rate(template) @@ -245,14 +245,15 @@ end system = PCC.switched_system([[0.5 0.0; 0.0 0.25], [0.25 0.0; 0.0 0.5]]) problem = PCC.StabilityProblem(system) graph = PCC.de_bruijn(1, 2) - families = [Parallelotope([1.0 0.0; 0.0 1.0]), Parallelotope([2.0 0.0; 0.0 1.0])] + families = + [PCC.Parallelotope([1.0 0.0; 0.0 1.0]), PCC.Parallelotope([2.0 0.0; 0.0 1.0])] certificate = PCC.certify(families, graph, problem; optimizer = HiGHS.Optimizer, rate = 0.8) @test PCC.is_feasible(certificate) @test all(>=(-1e-6), PCC.edge_slacks(certificate)) @test !PCC.is_closed_under(families, PCC.Maximum(), system) @test PCC.is_closed_under([families[1], families[1]], PCC.Maximum(), system) - boxes = [PositiveBox(min_weight = 2), PositiveBox(min_weight = 3)] + boxes = [PCC.PositiveBox(min_weight = 2), PCC.PositiveBox(min_weight = 3)] certificate = PCC.certify(boxes, graph, problem; optimizer = HiGHS.Optimizer, rate = 0.8) @test PCC.has_dual(boxes) @@ -272,7 +273,7 @@ end end @testset "the native gauge primitives also work directly" begin - family = PositiveBox(min_weight = 2) + family = PCC.PositiveBox(min_weight = 2) model = JuMP.Model(HiGHS.Optimizer) JuMP.set_silent(model) S = PCC.add_function_variables!(model, family, 2, 1) @@ -284,8 +285,25 @@ end @test fitted([2.0, 0.0]) <= 1 + 1e-6 @test fitted(A * [2.0, 1.0]) <= 0.6 * fitted([2.0, 1.0]) + 1e-6 system = PCC.switched_system([A]) - @test PCC.is_closed_under(PositiveSimplex(), PCC.Composition(), system) - @test PCC.is_closed_under(PositiveSimplex(), PCC.InverseComposition(), system) + @test PCC.is_closed_under(PCC.PositiveSimplex(), PCC.Composition(), system) + @test PCC.is_closed_under(PCC.PositiveSimplex(), PCC.InverseComposition(), system) +end + +@testset "compatibility constructors return native families" begin + @test PCC.PositiveBox() isa SetProg.Polytope + @test PCC.PositiveSimplex() isa SetProg.Polytope + @test PCC.Parallelotope() isa SetProg.Polytope + @test PCC.ConicPolytope(PCC.planar_conic_partition(1)) isa SetProg.Polytope + @test PCC.LinearCopositiveTemplate() == PCC.PositiveSimplex() + @test PCC.DualCopositiveTemplate() == PCC.PositiveBox() + @test length(PCC.ParallelFacesPolyhedralTemplate(2, 2)) == 2 + @test length(PCC.ParallelFacesPolyhedralTemplate([[1.0 0.0; 0.0 1.0]])) == 1 + @test length(PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(1)])) == 1 + @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(0, 2) + @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(Matrix{Float64}[]) + @test_throws ArgumentError PCC.planar_conic_partition(0) + @test PCC.PositiveBox([2.0, 3.0])([1.0, 1.0]) ≈ 0.5 + @test PCC.PositiveSimplex([2.0, 3.0])([1.0, 1.0]) ≈ 5 end end diff --git a/test/problems/stability.jl b/test/problems/stability.jl index ab8aadc..461c9b6 100644 --- a/test/problems/stability.jl +++ b/test/problems/stability.jl @@ -153,7 +153,7 @@ end @test PCC.is_feasible( PCC.certify( - SetProg.PositiveSimplex(), + PCC.PositiveSimplex(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -163,7 +163,7 @@ end @test !PCC.is_feasible( PCC.certify( - SetProg.PositiveSimplex(), + PCC.PositiveSimplex(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -172,7 +172,7 @@ end ) result = PCC.jsr_bound( - SetProg.PositiveSimplex(), + PCC.PositiveSimplex(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -185,7 +185,7 @@ end # Both copositive templates need a positive system, and say so up front. rotation = PCC.StabilityProblem(PCC.switched_system([[0.0 -0.5; 0.5 0.0]])) - for template in (SetProg.PositiveSimplex(), SetProg.PositiveBox()) + for template in (PCC.PositiveSimplex(), PCC.PositiveBox()) @test_throws ArgumentError PCC.certify( template, GRAPH, @@ -200,13 +200,13 @@ end # The point of `homogeneity_degree`: the same system and graph, three templates # of two different degrees, one answer. @test PCC.homogeneity_degree(SetProg.Ellipsoid(symmetric = true)) == 2 - @test PCC.homogeneity_degree(SetProg.PositiveSimplex()) == 1 - @test PCC.homogeneity_degree(SetProg.Parallelotope(dimension = 2)) == 1 + @test PCC.homogeneity_degree(PCC.PositiveSimplex()) == 1 + @test PCC.homogeneity_degree(PCC.Parallelotope(dimension = 2)) == 1 bounds = map(( SetProg.Ellipsoid(symmetric = true), - SetProg.PositiveSimplex(), - SetProg.Parallelotope(dimension = 2), + PCC.PositiveSimplex(), + PCC.Parallelotope(dimension = 2), )) do template PCC.jsr_bound(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rtol = 1e-3).rate end diff --git a/test/problems/tight_edges.jl b/test/problems/tight_edges.jl index b512e30..06740e5 100644 --- a/test/problems/tight_edges.jl +++ b/test/problems/tight_edges.jl @@ -99,7 +99,7 @@ end @test PCC.edge_slacks(quadratic) isa Vector polyhedral = PCC.certify( - SetProg.Parallelotope(dimension = 2), + PCC.Parallelotope(dimension = 2), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -108,7 +108,7 @@ end @test all(>=(-1e-7), PCC.edge_slacks(polyhedral)) conic = PCC.certify( - SetProg.ConicPolytope.([SetProg.planar_conic_partition(2)]), + PCC.ConicPolytope.([PCC.planar_conic_partition(2)]), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -118,7 +118,7 @@ end nonneg = PCC.StabilityProblem(PCC.switched_system([[0.4 0.1; 0.2 0.3]])) copositive = PCC.certify( - SetProg.PositiveSimplex(), + PCC.PositiveSimplex(), PCC.de_bruijn(1, 1), nonneg; optimizer = OPTIMIZER, diff --git a/test/refinements/refine.jl b/test/refinements/refine.jl index 7fc4863..908f336 100644 --- a/test/refinements/refine.jl +++ b/test/refinements/refine.jl @@ -361,7 +361,7 @@ end # at every tight edge, each candidate solved, the best kept. The products # write words on the edges, so the graph gains edges rather than nodes. problem = SHEARED - template = SetProg.Parallelotope(dimension = 2) + template = PCC.Parallelotope(dimension = 2) trace = PCC.refine( template, From 0d2b54f15d3ecdccac88c7ef6971bb3abd1e7413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Thu, 15 Oct 2026 14:16:08 +0200 Subject: [PATCH 03/18] Decrease diff --- CLAUDE.md | 5 +- Project.toml | 3 + bench/athanasopoulos2019.jl | 3 +- docs/src/bibliography.md | 8 +- docs/src/developers/conventions.md | 16 +- docs/src/examples/comparing.jl | 4 +- docs/src/examples/templates_and_graphs.jl | 7 +- docs/src/manual/comparing.md | 2 +- docs/src/manual/templates.md | 6 +- docs/src/reference/templates.md | 4 +- ...PathCompleteCertificatesSumOfSquaresExt.jl | 17 ++ src/PathCompleteCertificates.jl | 1 + src/problems/catalogue/stability.jl | 11 +- src/problems/polytope_compat.jl | 147 ++++++++---------- src/problems/sets.jl | 36 +---- test/lifts/compare.jl | 4 +- test/lifts/composition.jl | 2 +- test/lifts/identity.jl | 4 +- test/lifts/min.jl | 2 +- test/lifts/split.jl | 2 +- test/problems/certify.jl | 2 +- test/problems/common.jl | 19 ++- test/problems/dual.jl | 49 +++--- test/problems/sets.jl | 35 +++-- test/problems/stability.jl | 16 +- test/problems/tight_edges.jl | 6 +- test/refinements/refine.jl | 2 +- test/runtests.jl | 1 + 28 files changed, 215 insertions(+), 199 deletions(-) create mode 100644 ext/PathCompleteCertificatesSumOfSquaresExt.jl diff --git a/CLAUDE.md b/CLAUDE.md index ded0d14..3430e32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,8 +3,9 @@ Stability uses SetProg families directly, or vectors with one family per node. SetProg is a direct dependency; polynomial support is its optional SumOfSquares extension. The legacy quadratic catalogue remains only for safety and optimal -control, whose migration is deferred. Stability gauge primitives belong to -SetProg; applicability, graph closures, and reindexing live in `problems/sets.jl`. +control, whose migration is deferred. SetProg implements set variables and +inclusion constraints. Certificate primitives live in `problems/gauges.jl`; +applicability, graph closures, and reindexing live in `problems/sets.jl`. The legacy template architecture described below applies to deferred problems. # CLAUDE.md diff --git a/Project.toml b/Project.toml index cfd7eaa..fc3956e 100644 --- a/Project.toml +++ b/Project.toml @@ -19,10 +19,13 @@ MathematicalSystems = "0.14" Random = "1" RecipesBase = "1" SetProg = "0.4.5" +SumOfSquares = "0.8" julia = "1.10" [weakdeps] RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01" +SumOfSquares = "4b9e565b-77fc-50a5-a571-1244f986bda1" [extensions] +PathCompleteCertificatesSumOfSquaresExt = "SumOfSquares" PathCompleteCertificatesRecipesBaseExt = "RecipesBase" diff --git a/bench/athanasopoulos2019.jl b/bench/athanasopoulos2019.jl index 20b03d7..93122eb 100644 --- a/bench/athanasopoulos2019.jl +++ b/bench/athanasopoulos2019.jl @@ -13,7 +13,6 @@ # step count is not a defect; the rate falling below one is the claim. import PathCompleteCertificates as PCC -import SetProg import Clarabel const A1 = [0.095 -0.2375 0.2375; -1.9 0.0 0.0; 0.0 0.0 0.475] @@ -22,7 +21,7 @@ const A2 = [0.4753 0.0 0.0; 0.0 0.0 0.4753; 0.0 -1.9012 0.0] depth_max = isempty(ARGS) ? 15 : parse(Int, ARGS[1]) problem = PCC.StabilityProblem(PCC.switched_system([A1, A2])) -template = PCC.Parallelotope(; dimension = 3) +template = PCC.ParallelFacesPolyhedralTemplate(1, 3) elapsed = @elapsed trace = PCC.refine( template, diff --git a/docs/src/bibliography.md b/docs/src/bibliography.md index c5393d2..2942ea2 100644 --- a/docs/src/bibliography.md +++ b/docs/src/bibliography.md @@ -19,12 +19,12 @@ number quoted in the manual was read in the paper it is attributed to. | | | | :-- | :-- | -| [athanasopoulos2019polyhedral](@cite) | `PCC.Parallelotope`, fixed facets and solved-for weights, and the four partial lifts that are [`OutSplitLift`](@ref), [`InSplitLift`](@ref), [`ForwardProductLift`](@ref) and [`BackwardProductLift`](@ref) | +| [athanasopoulos2019polyhedral](@cite) | `ParallelFacesPolyhedralTemplate`, fixed facets and solved-for weights, and the four partial lifts that are [`OutSplitLift`](@ref), [`InSplitLift`](@ref), [`ForwardProductLift`](@ref) and [`BackwardProductLift`](@ref) | | [parrilo2008approximation](@cite) | `SetProg.PolySet` | -| [debauche2024thesis](@cite) | `PCC.PositiveBox`, Def. 2.30 | +| [debauche2024thesis](@cite) | `DualCopositiveTemplate`, Def. 2.30 | -`SetProg.Ellipsoid` and `PCC.PositiveSimplex` are -standard. `PCC.ConicPolytope` is under submission and has no +`SetProg.Ellipsoid` and `LinearCopositiveTemplate` are +standard. `ConicPolyhedralTemplate` is under submission and has no entry yet. ## Safety and optimal control diff --git a/docs/src/developers/conventions.md b/docs/src/developers/conventions.md index d47ad2a..2682f52 100644 --- a/docs/src/developers/conventions.md +++ b/docs/src/developers/conventions.md @@ -18,16 +18,16 @@ between problems: Stability set families live in SetProg. The problem builds SetProg variables, normalizes their gauges, and models each edge by set inclusion. The fitted -node values are callable SetProg sets. Extend SetProg's homogeneous gauge -primitives to add a stability family: +node values are callable SetProg sets. PCC implements the certificate +primitives using SetProg's JuMP syntax: ```julia -SetProg.add_function_variables!(model, family, dimension, node) -SetProg.add_normalization!(model, family, variable) -SetProg.add_decrease!(model, family, source, destination, A; scale) -SetProg.solution_value(family, variable) -SetProg.homogeneity_degree(family) -SetProg.decrease_slack(family, source, destination, A; scale) +add_function_variables!(model, family, dimension, node) +add_normalization!(model, family, variable) +add_decrease!(model, family, source, destination, A; scale) +solution_value(family, variable) +homogeneity_degree(family) +decrease_slack(family, source, destination, A; scale) ``` PCC owns the system and graph metadata in `src/problems/sets.jl`: diff --git a/docs/src/examples/comparing.jl b/docs/src/examples/comparing.jl index 429707c..9f88590 100644 --- a/docs/src/examples/comparing.jl +++ b/docs/src/examples/comparing.jl @@ -123,8 +123,8 @@ system = PCC.switched_system(A) problem = PCC.StabilityProblem(system) QUADRATIC = SetProg.Ellipsoid(; symmetric = true) -COPOSITIVE = PCC.PositiveSimplex() -DUAL_COPOSITIVE = PCC.PositiveBox() +COPOSITIVE = PCC.LinearCopositiveTemplate() +DUAL_COPOSITIVE = PCC.DualCopositiveTemplate() TEMPLATES = (QUADRATIC, COPOSITIVE, DUAL_COPOSITIVE) c12 = PCC.compare(G1, G2; templates = TEMPLATES, system, optimizer = OPTIMIZER) diff --git a/docs/src/examples/templates_and_graphs.jl b/docs/src/examples/templates_and_graphs.jl index d46a886..ff366f6 100644 --- a/docs/src/examples/templates_and_graphs.jl +++ b/docs/src/examples/templates_and_graphs.jl @@ -50,8 +50,11 @@ rotation_problem = PCC.StabilityProblem(PCC.switched_system(ROTATION)) templates = [ ("quadratic", SetProg.Ellipsoid(; symmetric = true)), - ("polyhedral, fixed facets", PCC.Parallelotope(; dimension = 2)), - ("polyhedral, free facets", PCC.ConicPolytope.([PCC.planar_conic_partition(3)])), + ("polyhedral, fixed facets", PCC.ParallelFacesPolyhedralTemplate(1, 2)), + ( + "polyhedral, free facets", + PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(3)]), + ), ] left = plot(; diff --git a/docs/src/manual/comparing.md b/docs/src/manual/comparing.md index 42f2ff3..8615a8c 100644 --- a/docs/src/manual/comparing.md +++ b/docs/src/manual/comparing.md @@ -62,7 +62,7 @@ finds the order. The products give graphs with words on their edges, which ¹ The linear copositive norms are not closed under `min`, yet the min lift is valid for them on positive systems ([debauche2024thesis](@cite), Thm. 7.43): -[`is_valid`](@ref) reads it through their dual, `PCC.PositiveBox`, +[`is_valid`](@ref) reads it through their dual, `DualCopositiveTemplate`, closed under `max`. ² Sufficient only: [jongeneel2025ordering](@cite), Ex. IV.3, exhibits two diff --git a/docs/src/manual/templates.md b/docs/src/manual/templates.md index 794939b..a29647b 100644 --- a/docs/src/manual/templates.md +++ b/docs/src/manual/templates.md @@ -40,9 +40,7 @@ a box or parallelotope. Explicit cone matrices prescribe the linear pieces' regions and solve for their coefficients. `polar` specifies the family; SetProg chooses its modeling space from the constraints. -PCC retains `PositiveSimplex`, `PositiveBox`, `Parallelotope`, and -`ConicPolytope` as compatibility constructors returning these configurations. -The original `LinearCopositiveTemplate`, `DualCopositiveTemplate`, +The existing `LinearCopositiveTemplate`, `DualCopositiveTemplate`, `ParallelFacesPolyhedralTemplate`, and `ConicPolyhedralTemplate` constructors also remain available. Positive-orthant families require nonnegative dynamics. `PCC.planar_conic_partition(order)` supplies a planar conic partition. @@ -82,7 +80,7 @@ have no fitted dual implementation here. See [Duality](@ref). A single family is shared by all nodes. A vector supplies one family per node: ```julia -families = [PCC.Parallelotope(G1), PCC.Parallelotope(G2)] +families = PCC.ParallelFacesPolyhedralTemplate([G1, G2]) certificate = PCC.jsr_bound(families, graph, problem; optimizer) ``` diff --git a/docs/src/reference/templates.md b/docs/src/reference/templates.md index c8c0170..cdbab91 100644 --- a/docs/src/reference/templates.md +++ b/docs/src/reference/templates.md @@ -11,14 +11,14 @@ parameters and [What works with what](@ref) for support by problem. ```@autodocs Modules = [SetProg] -Pages = ["variables.jl", "gauges.jl"] +Pages = ["variables.jl"] ``` ## Graph operations on families ```@autodocs Modules = [PathCompleteCertificates] -Pages = ["problems/sets.jl", "templates/abstract.jl"] +Pages = ["problems/sets.jl", "problems/gauges.jl", "templates/abstract.jl"] ``` ## Legacy quadratic template for safety and optimal control diff --git a/ext/PathCompleteCertificatesSumOfSquaresExt.jl b/ext/PathCompleteCertificatesSumOfSquaresExt.jl new file mode 100644 index 0000000..b5e9234 --- /dev/null +++ b/ext/PathCompleteCertificatesSumOfSquaresExt.jl @@ -0,0 +1,17 @@ +module PathCompleteCertificatesSumOfSquaresExt + +import PathCompleteCertificates as PCC +import SetProg +import SumOfSquares +import LinearAlgebra + +function PCC._unit_set(family::SetProg.PolySet) + d = div(family.degree, 2) + monos = SumOfSquares.MP.monomials(family.variables, d) + coefficients = + [factorial(d) / prod(factorial, SumOfSquares.MP.exponents(mono)) for mono in monos] + gram = SumOfSquares.GramMatrix(Matrix(LinearAlgebra.Diagonal(coefficients)), monos) + return SetProg.Sets.PolySet(family.degree, gram) +end + +end diff --git a/src/PathCompleteCertificates.jl b/src/PathCompleteCertificates.jl index 4e6a82f..d7de321 100644 --- a/src/PathCompleteCertificates.jl +++ b/src/PathCompleteCertificates.jl @@ -47,6 +47,7 @@ include("systems/simulate.jl") include("templates/abstract.jl") include("problems/abstract.jl") +include("problems/gauges.jl") include("problems/sets.jl") include("problems/polytope_compat.jl") include("problems/certificate.jl") diff --git a/src/problems/catalogue/stability.jl b/src/problems/catalogue/stability.jl index 7ffd78f..235ed76 100644 --- a/src/problems/catalogue/stability.jl +++ b/src/problems/catalogue/stability.jl @@ -76,18 +76,15 @@ function optimization_model( JuMP.set_silent(model) families = _prepared_families(template, dimension, n_nodes(graph)) - Vs = [ - SetProg.add_function_variables!(model, families[a], dimension, a) for - a in nodes(graph) - ] + Vs = [add_function_variables!(model, families[a], dimension, a) for a in nodes(graph)] for V in Vs - SetProg.add_normalization!(model, V.set, V; conditioning_bound) + add_normalization!(model, V.set, V; conditioning_bound) end for edge in edges(graph) dynamics, scale = _edge_decrease(problem, graph, edge, A; rate, degree) - SetProg.add_decrease!( + add_decrease!( model, Vs[source(edge)].set, Vs[source(edge)], @@ -219,7 +216,7 @@ function certify( JuMP.is_solved_and_feasible(model; allow_almost = true) || return StabilityCertificate(_failed(problem, template, graph, termination), rate) - V = [SetProg.solution_value(v.set, v) for v in model[:stability_V]] + V = [solution_value(v.set, v) for v in model[:stability_V]] return StabilityCertificate( CertificateData(problem, template, graph, V, termination, true), diff --git a/src/problems/polytope_compat.jl b/src/problems/polytope_compat.jl index 7eddbcc..31695c8 100644 --- a/src/problems/polytope_compat.jl +++ b/src/problems/polytope_compat.jl @@ -1,51 +1,69 @@ """ - PositiveSimplex(; dimension = nothing, min_scale = 1) + LinearCopositiveTemplate() -Compatibility constructor for a linear gauge on the nonnegative orthant. +Linear gauges on the nonnegative orthant, represented by a SetProg polytope. """ -PositiveSimplex(; dimension = nothing, min_scale = 1) = - SetProg.Polytope(; piecewise = :orthant, dimension = dimension, min_scale = min_scale) +LinearCopositiveTemplate() = SetProg.Polytope(; piecewise = :orthant, min_scale = 1) """ - PositiveBox(; dimension = nothing, min_weight = 1) + DualCopositiveTemplate(; min_weight = 1) -Compatibility constructor for the blocking polar of a positive simplex. +Blocking polars of linear copositive gauges, represented by a SetProg polytope. """ -PositiveBox(; dimension = nothing, min_weight = 1) = SetProg.Polytope(; - piecewise = :orthant, - dimension = dimension, - polar = true, - min_scale = min_weight, -) +DualCopositiveTemplate(; min_weight = 1) = + SetProg.Polytope(; piecewise = :orthant, polar = true, min_scale = min_weight) """ - Parallelotope(G; min_weight = 1) - Parallelotope(; dimension = nothing, min_weight = 1) + ParallelFacesPolyhedralTemplate(n_nodes, dimension; min_weight = 1) + ParallelFacesPolyhedralTemplate(matrices; min_weight = 1) -Compatibility constructor for the polar of a weighted absolute-value sum. -""" -Parallelotope(G::AbstractMatrix; min_weight = 1) = SetProg.Polytope(; - symmetric = true, - piecewise = :orthants, - transform = Matrix(G'), - polar = true, - min_scale = min_weight, +One SetProg polytope per node, with prescribed pairs of parallel faces. +""" +function ParallelFacesPolyhedralTemplate( + n_nodes::Integer, + dimension::Integer; + min_weight = 1, ) -Parallelotope(; dimension = nothing, min_weight = 1) = SetProg.Polytope(; - symmetric = true, - piecewise = :orthants, - dimension = dimension, - polar = true, - min_scale = min_weight, + n_nodes > 0 || throw(ArgumentError("at least one node is required")) + family = SetProg.Polytope(; + symmetric = true, + piecewise = :orthants, + dimension = dimension, + polar = true, + min_scale = min_weight, + ) + return fill(family, n_nodes) +end +function ParallelFacesPolyhedralTemplate( + matrices::AbstractVector{<:AbstractMatrix}; + min_weight = 1, ) + isempty(matrices) && throw(ArgumentError("at least one matrix is required")) + n = size(first(matrices), 1) + all(G -> size(G) == (n, n), matrices) || throw(DimensionMismatch()) + return [ + SetProg.Polytope(; + symmetric = true, + piecewise = :orthants, + transform = Matrix(G'), + polar = true, + min_scale = min_weight, + ) for G in matrices + ] +end """ - ConicPolytope(cones; min_scale = 1) + ConicPolyhedralTemplate(node_cones; min_scale = 1) -Compatibility constructor for a symmetric polytope with prescribed cones. +One SetProg polytope per node, with linear pieces on the prescribed cones. """ -ConicPolytope(cones::AbstractVector{<:AbstractMatrix}; min_scale = 1) = - SetProg.Polytope(; symmetric = true, piecewise = cones, min_scale = min_scale) +function ConicPolyhedralTemplate(node_cones; min_scale = 1) + isempty(node_cones) && throw(ArgumentError("at least one node is required")) + return [ + SetProg.Polytope(; symmetric = true, piecewise = cones, min_scale = min_scale) for + cones in node_cones + ] +end """ planar_conic_partition(order) @@ -67,7 +85,12 @@ function planar_conic_partition(order::Integer) return cones end -function PositiveSimplex(c::AbstractVector) +""" + LinearCopositiveFunction(c) + +A fitted linear copositive function represented by a SetProg set. +""" +function LinearCopositiveFunction(c::AbstractVector) cone = Matrix{Float64}(LinearAlgebra.I, length(c), length(c)) return SetProg.Sets.Intersection( SetProg.Sets.Piecewise([collect(c)], [cone]), @@ -80,8 +103,18 @@ function PositiveSimplex(c::AbstractVector) ), ) end -PositiveBox(v::AbstractVector) = SetProg.dual_value(PositiveSimplex(v)) -function Parallelotope(G::AbstractMatrix, w::AbstractVector) +""" + DualCopositiveFunction(v) + +A fitted dual copositive function represented by a SetProg set. +""" +DualCopositiveFunction(v::AbstractVector) = _dual_value(LinearCopositiveFunction(v)) +""" + ParallelFacesPolyhedralFunction(G, w) + +A fitted parallel-faces polyhedral function represented by a SetProg set. +""" +function ParallelFacesPolyhedralFunction(G::AbstractMatrix, w::AbstractVector) n = LinearAlgebra.checksquare(G) signs = vec([Float64[s...] for s in Iterators.product(ntuple(_ -> (-1, 1), n)...)]) return SetProg.polar( @@ -91,47 +124,3 @@ function Parallelotope(G::AbstractMatrix, w::AbstractVector) ), ) end - -""" - LinearCopositiveTemplate() - -Backward-compatible constructor for `Polytope(piecewise = :orthant)`. -""" -LinearCopositiveTemplate() = PositiveSimplex() - -""" - DualCopositiveTemplate(; min_weight = 1) - -Backward-compatible constructor for a positive box polytope configuration. -""" -DualCopositiveTemplate(; min_weight = 1) = PositiveBox(; min_weight) - -""" - ParallelFacesPolyhedralTemplate(n_nodes, dimension; min_weight = 1) - ParallelFacesPolyhedralTemplate(matrices; min_weight = 1) - -Backward-compatible constructor for native polytopes, one per graph node. -""" -function ParallelFacesPolyhedralTemplate( - n_nodes::Integer, - dimension::Integer; - min_weight = 1, -) - n_nodes > 0 || throw(ArgumentError("at least one node is required")) - return fill(Parallelotope(; dimension, min_weight), n_nodes) -end -function ParallelFacesPolyhedralTemplate( - matrices::AbstractVector{<:AbstractMatrix}; - min_weight = 1, -) - isempty(matrices) && throw(ArgumentError("at least one matrix is required")) - return [Parallelotope(G; min_weight) for G in matrices] -end - -""" - ConicPolyhedralTemplate(node_cones; min_scale = 1) - -Backward-compatible constructor for one native conic polytope per graph node. -""" -ConicPolyhedralTemplate(node_cones; min_scale = 1) = - [ConicPolytope(cones; min_scale) for cones in node_cones] diff --git a/src/problems/sets.jl b/src/problems/sets.jl index 01c23fc..3592901 100644 --- a/src/problems/sets.jl +++ b/src/problems/sets.jl @@ -65,11 +65,11 @@ reindex(families::AbstractVector{<:SetProg.AbstractVariable}, origins::AbstractV Whether a SetProg gauge family has an implemented dual. """ -has_dual(family::SetProg.AbstractVariable) = SetProg.supports_dual(family) +has_dual(family::SetProg.AbstractVariable) = _supports_dual(family) has_dual(families::AbstractVector{<:SetProg.AbstractVariable}) = all(has_dual, families) dual(family::SetProg.AbstractVariable) = - has_dual(family) ? SetProg.dual_family(family) : + has_dual(family) ? _dual_family(family) : throw(ArgumentError("no dual gauge family is implemented for $(typeof(family))")) dual(families::AbstractVector{<:SetProg.AbstractVariable}) = dual.(families) @@ -79,9 +79,9 @@ dual(families::AbstractVector{<:SetProg.AbstractVariable}) = dual.(families) The fitted dual homogeneous gauge as a SetProg set. """ dual(family::SetProg.AbstractVariable, set) = - has_dual(family) ? SetProg.dual_value(set) : + has_dual(family) ? _dual_value(set) : throw(ArgumentError("no dual gauge is implemented for $(typeof(family))")) -dual(families::AbstractVector{<:SetProg.AbstractVariable}, set) = SetProg.dual_value(set) +dual(families::AbstractVector{<:SetProg.AbstractVariable}, set) = _dual_value(set) function _prepared_families(family::_SetFamily, dimension, count) families = family isa AbstractVector ? family : fill(family, count) @@ -94,33 +94,7 @@ function _prepared_families(family::_SetFamily, dimension, count) all(==(x), explicit) || throw(ArgumentError("polynomial sets must share their variables")) end - return [SetProg.with_dimension(f, dimension; variables = x) for f in families] -end - -function homogeneity_degree(family::_SetFamily) - return SetProg.homogeneity_degree(family) -end -function add_function_variables!(model, family::SetProg.AbstractVariable, dimension, node) - return SetProg.add_function_variables!(model, family, dimension, node) -end -function add_normalization!(model, family::SetProg.AbstractVariable, V; kwargs...) - return SetProg.add_normalization!(model, family, V; kwargs...) -end -function add_decrease!( - model, - family::SetProg.AbstractVariable, - source, - destination, - A; - kwargs..., -) - return SetProg.add_decrease!(model, family, source, destination, A; kwargs...) -end -function solution_value(family::SetProg.AbstractVariable, V) - return SetProg.solution_value(family, V) -end -function decrease_slack(family::SetProg.AbstractVariable, source, destination, A; kwargs...) - return SetProg.decrease_slack(family, source, destination, A; kwargs...) + return [_with_dimension(f, dimension; variables = x) for f in families] end node_value(::_SetFamily, ::AbstractProblem, V, x::AbstractVector{<:Real}) = V(x) diff --git a/test/lifts/compare.jl b/test/lifts/compare.jl index d798c82..4d387d7 100644 --- a/test/lifts/compare.jl +++ b/test/lifts/compare.jl @@ -87,8 +87,8 @@ const SYSTEM = PCC.switched_system(A_STAR) const PROBLEM = PCC.StabilityProblem(SYSTEM) const QUADRATIC = SetProg.Ellipsoid(; symmetric = true) -const COPOSITIVE = PCC.PositiveSimplex() -const DUAL_COPOSITIVE = PCC.PositiveBox() +const COPOSITIVE = PCC.LinearCopositiveTemplate() +const DUAL_COPOSITIVE = PCC.DualCopositiveTemplate() const TEMPLATES = (QUADRATIC, COPOSITIVE, DUAL_COPOSITIVE) found(comparison) = Dict(PCC.simulations(comparison)) diff --git a/test/lifts/composition.jl b/test/lifts/composition.jl index 030adf0..2c4996f 100644 --- a/test/lifts/composition.jl +++ b/test/lifts/composition.jl @@ -68,7 +68,7 @@ end ) @test !PCC.is_valid( PCC.CompositionLift(1), - PCC.Parallelotope(dimension = 2), + PCC.ParallelFacesPolyhedralTemplate(1, 2), invertible, ) end diff --git a/test/lifts/identity.jl b/test/lifts/identity.jl index 795b1ea..6b9481a 100644 --- a/test/lifts/identity.jl +++ b/test/lifts/identity.jl @@ -21,8 +21,8 @@ import PathCompleteCertificates as PCC system = PCC.switched_system([[0.5 0.0; 0.0 0.5], [0.0 0.5; 0.5 0.0]]) for template in ( SetProg.Ellipsoid(symmetric = true), - PCC.Parallelotope(dimension = 2), - PCC.PositiveSimplex(), + PCC.ParallelFacesPolyhedralTemplate(1, 2), + PCC.LinearCopositiveTemplate(), ) @test PCC.is_valid(PCC.IdentityLift(), template, system) end diff --git a/test/lifts/min.jl b/test/lifts/min.jl index 7273a3a..2b68671 100644 --- a/test/lifts/min.jl +++ b/test/lifts/min.jl @@ -145,7 +145,7 @@ end end @testset "the min lift is valid on the linear copositive template for positive systems only" begin - template = PCC.PositiveSimplex() + template = PCC.LinearCopositiveTemplate() positive = PCC.switched_system([[0.5 0.25; 0.0 0.5]]) signed = PCC.switched_system([[0.5 -0.25; 0.0 0.5]]) diff --git a/test/lifts/split.jl b/test/lifts/split.jl index 2f178b0..fd93dc6 100644 --- a/test/lifts/split.jl +++ b/test/lifts/split.jl @@ -320,7 +320,7 @@ end @testset "a template with per-node data follows the split" begin graph = PCC.de_bruijn(1, 2) - template = PCC.Parallelotope.([[1.0 0.0; 0.0 1.0], [1.0 1.0; 0.0 1.0]]) + template = PCC.ParallelFacesPolyhedralTemplate([[1.0 0.0; 0.0 1.0], [1.0 1.0; 0.0 1.0]]) lifted = PCC.lift(PCC.OutSplitLift(), graph, PCC.outgoing_edges(graph, 1)) moved = PCC.reindex(template, PCC.origins(lifted)) diff --git a/test/problems/certify.jl b/test/problems/certify.jl index 658ca43..1ac03ac 100644 --- a/test/problems/certify.jl +++ b/test/problems/certify.jl @@ -51,7 +51,7 @@ end @testset "a failed solve names no function type" begin # `functions` is empty, so there is no fitted function to take a type from. - template = PCC.Parallelotope(dimension = 2) + template = PCC.ParallelFacesPolyhedralTemplate(PCC.n_nodes(GRAPH), 2) tight = PCC.certify(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rate = 0.01) @test !PCC.is_feasible(tight) diff --git a/test/problems/common.jl b/test/problems/common.jl index 78a7b5f..828285b 100644 --- a/test/problems/common.jl +++ b/test/problems/common.jl @@ -52,6 +52,21 @@ end @testset "the common function is generic in the template" begin graph = PCC.de_bruijn(1, 2) - linear = PCC.PositiveSimplex.([[1.0, 1.0], [2.0, 2.0]]) + linear = PCC.LinearCopositiveFunction.([[1.0, 1.0], [2.0, 2.0]]) - @test PCC.common(graph, PROBLEM, linear, X) ≈ minimum(V(X) for V in linear) + @test PCC.common(graph, PROBLEM, linear, X) ≈ + minimum(V(X) for V in linear) + + weights = [ + PCC.ParallelFacesPolyhedralFunction(Matrix{Float64}(LinearAlgebra.I, 2, 2), w) + for w in ([1.0, 1.0], [4.0, 4.0]) + ] + + @test PCC.common(graph, + PROBLEM, + weights, + X, + ) ≈ minimum(V(X) for V in weights) +end + +end diff --git a/test/problems/dual.jl b/test/problems/dual.jl index 06b703b..429da9d 100644 --- a/test/problems/dual.jl +++ b/test/problems/dual.jl @@ -36,8 +36,8 @@ const GENERAL = PCC.switched_system([[0.8 0.3; -0.2 0.5], [0.4 -0.6; 0.5 0.3]]) const CASES = [ (SetProg.Ellipsoid(; symmetric = true), GENERAL), - (PCC.PositiveSimplex(), POSITIVE), - (PCC.PositiveBox(), POSITIVE), + (PCC.LinearCopositiveTemplate(), POSITIVE), + (PCC.DualCopositiveTemplate(), POSITIVE), ] const ONE = PCC.common_graph(2) @@ -136,24 +136,36 @@ end # What crossing over adds: the sum lift is valid for the dual copositive # norms because their primal is closed under addition, which they are not. - @test !PCC.is_closed_under(PCC.PositiveBox(), PCC.Addition(), POSITIVE) - @test PCC.is_valid(PCC.SumLift(2), PCC.PositiveBox(), POSITIVE) + @test !PCC.is_closed_under(PCC.DualCopositiveTemplate(), PCC.Addition(), POSITIVE) + @test PCC.is_valid(PCC.SumLift(2), PCC.DualCopositiveTemplate(), POSITIVE) # And Thm. 7.43, stated on the primal, is read on the dual through closure. - @test PCC.is_valid(PCC.MinLift(), PCC.PositiveSimplex(), POSITIVE) - @test PCC.is_valid(PCC.MaxLift(), PCC.PositiveBox(), POSITIVE) - @test !PCC.is_valid(PCC.MaxLift(), PCC.PositiveSimplex(), POSITIVE) - @test !PCC.is_valid(PCC.MinLift(), PCC.PositiveBox(), POSITIVE) + @test PCC.is_valid(PCC.MinLift(), PCC.LinearCopositiveTemplate(), POSITIVE) + @test PCC.is_valid(PCC.MaxLift(), PCC.DualCopositiveTemplate(), POSITIVE) + @test !PCC.is_valid(PCC.MaxLift(), PCC.LinearCopositiveTemplate(), POSITIVE) + @test !PCC.is_valid(PCC.MinLift(), PCC.DualCopositiveTemplate(), POSITIVE) # A template without a dual is decided by its own closures only. - @test PCC.is_valid(PCC.SumLift(2), PCC.Parallelotope(dimension = 2), GENERAL) == - PCC.is_closed_under(PCC.Parallelotope(dimension = 2), PCC.Addition(), GENERAL) + @test PCC.is_valid( + PCC.SumLift(2), + PCC.ParallelFacesPolyhedralTemplate(1, 2), + GENERAL, + ) == PCC.is_closed_under( + PCC.ParallelFacesPolyhedralTemplate(1, 2), + PCC.Addition(), + GENERAL, + ) end @testset "an infeasible certificate dualises to an infeasible one" begin problem = PCC.StabilityProblem(POSITIVE) - none = - PCC.certify(PCC.PositiveSimplex(), ONE, problem; optimizer = OPTIMIZER, rate = 0.01) + none = PCC.certify( + PCC.LinearCopositiveTemplate(), + ONE, + problem; + optimizer = OPTIMIZER, + rate = 0.01, + ) @test !PCC.is_feasible(none) dualised = PCC.dual(none) @@ -161,18 +173,19 @@ end @test PCC.status(dualised) == PCC.status(none) @test isempty(PCC.functions(dualised)) @test dualised.rate == none.rate - @test PCC.template(dualised) == PCC.PositiveBox() + @test PCC.template(dualised) == PCC.DualCopositiveTemplate() end @testset "a template without a dual says so" begin @test PCC.has_dual(SetProg.Ellipsoid(symmetric = true)) - @test PCC.has_dual(PCC.PositiveSimplex()) - @test PCC.has_dual(PCC.PositiveBox()) - @test !PCC.has_dual(PCC.Parallelotope(dimension = 2)) + @test PCC.has_dual(PCC.LinearCopositiveTemplate()) + @test PCC.has_dual(PCC.DualCopositiveTemplate()) + @test !PCC.has_dual(PCC.ParallelFacesPolyhedralTemplate(1, 2)) - @test_throws ArgumentError PCC.dual(PCC.Parallelotope(dimension = 2)) + @test_throws ArgumentError PCC.dual(PCC.ParallelFacesPolyhedralTemplate(1, 2)) - polyhedral = bound(PCC.Parallelotope(dimension = 2), ONE, PCC.StabilityProblem(GENERAL)) + polyhedral = + bound(PCC.ParallelFacesPolyhedralTemplate(1, 2), ONE, PCC.StabilityProblem(GENERAL)) @test PCC.is_feasible(polyhedral) @test_throws ArgumentError PCC.dual(polyhedral) end diff --git a/test/problems/sets.jl b/test/problems/sets.jl index 6623f42..162fd2e 100644 --- a/test/problems/sets.jl +++ b/test/problems/sets.jl @@ -118,7 +118,7 @@ end end @testset "a piecewise polytope reproduces the conic polyhedral template" begin - conic = rate(PCC.ConicPolytope.([PCC.planar_conic_partition(1)])) + conic = rate(PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(1)])) template = Polytope(symmetric = true, piecewise = OCTAGON) polytope = rate(template) @@ -245,15 +245,17 @@ end system = PCC.switched_system([[0.5 0.0; 0.0 0.25], [0.25 0.0; 0.0 0.5]]) problem = PCC.StabilityProblem(system) graph = PCC.de_bruijn(1, 2) - families = - [PCC.Parallelotope([1.0 0.0; 0.0 1.0]), PCC.Parallelotope([2.0 0.0; 0.0 1.0])] + families = PCC.ParallelFacesPolyhedralTemplate([[1.0 0.0; 0.0 1.0], [2.0 0.0; 0.0 1.0]]) certificate = PCC.certify(families, graph, problem; optimizer = HiGHS.Optimizer, rate = 0.8) @test PCC.is_feasible(certificate) @test all(>=(-1e-6), PCC.edge_slacks(certificate)) @test !PCC.is_closed_under(families, PCC.Maximum(), system) @test PCC.is_closed_under([families[1], families[1]], PCC.Maximum(), system) - boxes = [PCC.PositiveBox(min_weight = 2), PCC.PositiveBox(min_weight = 3)] + boxes = [ + PCC.DualCopositiveTemplate(min_weight = 2), + PCC.DualCopositiveTemplate(min_weight = 3), + ] certificate = PCC.certify(boxes, graph, problem; optimizer = HiGHS.Optimizer, rate = 0.8) @test PCC.has_dual(boxes) @@ -273,7 +275,7 @@ end end @testset "the native gauge primitives also work directly" begin - family = PCC.PositiveBox(min_weight = 2) + family = PCC.DualCopositiveTemplate(min_weight = 2) model = JuMP.Model(HiGHS.Optimizer) JuMP.set_silent(model) S = PCC.add_function_variables!(model, family, 2, 1) @@ -285,25 +287,28 @@ end @test fitted([2.0, 0.0]) <= 1 + 1e-6 @test fitted(A * [2.0, 1.0]) <= 0.6 * fitted([2.0, 1.0]) + 1e-6 system = PCC.switched_system([A]) - @test PCC.is_closed_under(PCC.PositiveSimplex(), PCC.Composition(), system) - @test PCC.is_closed_under(PCC.PositiveSimplex(), PCC.InverseComposition(), system) + @test PCC.is_closed_under(PCC.LinearCopositiveTemplate(), PCC.Composition(), system) + @test PCC.is_closed_under( + PCC.LinearCopositiveTemplate(), + PCC.InverseComposition(), + system, + ) end @testset "compatibility constructors return native families" begin - @test PCC.PositiveBox() isa SetProg.Polytope - @test PCC.PositiveSimplex() isa SetProg.Polytope - @test PCC.Parallelotope() isa SetProg.Polytope - @test PCC.ConicPolytope(PCC.planar_conic_partition(1)) isa SetProg.Polytope - @test PCC.LinearCopositiveTemplate() == PCC.PositiveSimplex() - @test PCC.DualCopositiveTemplate() == PCC.PositiveBox() + @test PCC.DualCopositiveTemplate() isa SetProg.Polytope + @test PCC.LinearCopositiveTemplate() isa SetProg.Polytope + @test only(PCC.ParallelFacesPolyhedralTemplate(1, 2)) isa SetProg.Polytope + @test only(PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(1)])) isa + SetProg.Polytope @test length(PCC.ParallelFacesPolyhedralTemplate(2, 2)) == 2 @test length(PCC.ParallelFacesPolyhedralTemplate([[1.0 0.0; 0.0 1.0]])) == 1 @test length(PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(1)])) == 1 @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(0, 2) @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(Matrix{Float64}[]) @test_throws ArgumentError PCC.planar_conic_partition(0) - @test PCC.PositiveBox([2.0, 3.0])([1.0, 1.0]) ≈ 0.5 - @test PCC.PositiveSimplex([2.0, 3.0])([1.0, 1.0]) ≈ 5 + @test PCC.DualCopositiveFunction([2.0, 3.0])([1.0, 1.0]) ≈ 0.5 + @test PCC.LinearCopositiveFunction([2.0, 3.0])([1.0, 1.0]) ≈ 5 end end diff --git a/test/problems/stability.jl b/test/problems/stability.jl index 461c9b6..020e965 100644 --- a/test/problems/stability.jl +++ b/test/problems/stability.jl @@ -153,7 +153,7 @@ end @test PCC.is_feasible( PCC.certify( - PCC.PositiveSimplex(), + PCC.LinearCopositiveTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -163,7 +163,7 @@ end @test !PCC.is_feasible( PCC.certify( - PCC.PositiveSimplex(), + PCC.LinearCopositiveTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -172,7 +172,7 @@ end ) result = PCC.jsr_bound( - PCC.PositiveSimplex(), + PCC.LinearCopositiveTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -185,7 +185,7 @@ end # Both copositive templates need a positive system, and say so up front. rotation = PCC.StabilityProblem(PCC.switched_system([[0.0 -0.5; 0.5 0.0]])) - for template in (PCC.PositiveSimplex(), PCC.PositiveBox()) + for template in (PCC.LinearCopositiveTemplate(), PCC.DualCopositiveTemplate()) @test_throws ArgumentError PCC.certify( template, GRAPH, @@ -200,13 +200,13 @@ end # The point of `homogeneity_degree`: the same system and graph, three templates # of two different degrees, one answer. @test PCC.homogeneity_degree(SetProg.Ellipsoid(symmetric = true)) == 2 - @test PCC.homogeneity_degree(PCC.PositiveSimplex()) == 1 - @test PCC.homogeneity_degree(PCC.Parallelotope(dimension = 2)) == 1 + @test PCC.homogeneity_degree(PCC.LinearCopositiveTemplate()) == 1 + @test PCC.homogeneity_degree(PCC.ParallelFacesPolyhedralTemplate(2, 2)) == 1 bounds = map(( SetProg.Ellipsoid(symmetric = true), - PCC.PositiveSimplex(), - PCC.Parallelotope(dimension = 2), + PCC.LinearCopositiveTemplate(), + PCC.ParallelFacesPolyhedralTemplate(PCC.n_nodes(GRAPH), 2), )) do template PCC.jsr_bound(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rtol = 1e-3).rate end diff --git a/test/problems/tight_edges.jl b/test/problems/tight_edges.jl index 06740e5..dff1d27 100644 --- a/test/problems/tight_edges.jl +++ b/test/problems/tight_edges.jl @@ -99,7 +99,7 @@ end @test PCC.edge_slacks(quadratic) isa Vector polyhedral = PCC.certify( - PCC.Parallelotope(dimension = 2), + PCC.ParallelFacesPolyhedralTemplate(PCC.n_nodes(GRAPH), 2), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -108,7 +108,7 @@ end @test all(>=(-1e-7), PCC.edge_slacks(polyhedral)) conic = PCC.certify( - PCC.ConicPolytope.([PCC.planar_conic_partition(2)]), + PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(2)]), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -118,7 +118,7 @@ end nonneg = PCC.StabilityProblem(PCC.switched_system([[0.4 0.1; 0.2 0.3]])) copositive = PCC.certify( - PCC.PositiveSimplex(), + PCC.LinearCopositiveTemplate(), PCC.de_bruijn(1, 1), nonneg; optimizer = OPTIMIZER, diff --git a/test/refinements/refine.jl b/test/refinements/refine.jl index 908f336..80bc287 100644 --- a/test/refinements/refine.jl +++ b/test/refinements/refine.jl @@ -361,7 +361,7 @@ end # at every tight edge, each candidate solved, the best kept. The products # write words on the edges, so the graph gains edges rather than nodes. problem = SHEARED - template = PCC.Parallelotope(dimension = 2) + template = PCC.ParallelFacesPolyhedralTemplate(1, 2) trace = PCC.refine( template, diff --git a/test/runtests.jl b/test/runtests.jl index 159865b..75b50cc 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -27,6 +27,7 @@ const TEST_FILES = [ ("./systems/trajectory.jl",), ("./systems/simulate.jl",), ("./problems/sets.jl", :slow), + ("./problems/gauges.jl", :slow), ("./problems/certify.jl", :slow), ("./problems/tight_edges.jl", :slow), ("./problems/stability.jl", :slow), From bd261d498ea6ad9cdd5c7dd6330512c7f5d0bd72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Thu, 15 Oct 2026 21:38:46 +0200 Subject: [PATCH 04/18] Use SetProg's graph representation of polytopes --- docs/src/manual/templates.md | 15 ++++++---- src/problems/polytope_compat.jl | 53 +++++++++++++++++++++------------ src/problems/sets.jl | 15 +++++----- test/runtests.jl | 1 + 4 files changed, 52 insertions(+), 32 deletions(-) diff --git a/docs/src/manual/templates.md b/docs/src/manual/templates.md index a29647b..eeaa05a 100644 --- a/docs/src/manual/templates.md +++ b/docs/src/manual/templates.md @@ -23,7 +23,6 @@ certificate = PCC.jsr_bound(family, graph, problem; optimizer) | `Polytope(piecewise = :orthant)` | `c'x` on the nonnegative orthant | 1 | linear | | `Polytope(piecewise = :orthant, polar = true)` | `maximum(x ./ v)` on the nonnegative orthant | 1 | linear | | `Polytope(piecewise = :orthants, transform = G', polar = true)` | `maximum(abs.(G*x) ./ w)` | 1 | linear | -| `Polytope(symmetric = true, piecewise = cones)` | `maximum(abs.(P*x))` | 1 | linear | | `PolySet(symmetric = true, degree = 2d)` | sum-of-squares polynomial | 2d | semidefinite | | `Ellipsoid(symmetric = true, piecewise = partition)` | quadratic on each partition cone | 2 | semidefinite | | `Polytope(symmetric = true, piecewise = partition)` | linear on each partition cone | 1 | linear | @@ -36,16 +35,20 @@ SetProg is a direct dependency of PCC; its polynomial dependencies are optional. The four polyhedral families use the same piecewise-linear representation. The `:orthant` partition has one linear piece, restricted to the nonnegative orthant; `:orthants` is a weighted absolute-value sum. Taking the polar gives -a box or parallelotope. Explicit cone matrices prescribe the linear pieces' -regions and solve for their coefficients. `polar` specifies the family; +a box or parallelotope. Partition polytopes describe the linear pieces' regions +through the cones over their facets, allowing SetProg to use facet adjacency. +`polar` specifies the family; SetProg chooses its modeling space from the constraints. The existing `LinearCopositiveTemplate`, `DualCopositiveTemplate`, `ParallelFacesPolyhedralTemplate`, and `ConicPolyhedralTemplate` constructors also remain available. Positive-orthant families require nonnegative dynamics. -`PCC.planar_conic_partition(order)` supplies a planar conic partition. -Coefficient floors are configurable through `min_scale` on `Polytope`, or -through the compatibility constructors' original keyword arguments. +`PCC.planar_conic_partition(order; library)` supplies a partition polytope. +Pass one polytope per node to `ConicPolyhedralTemplate`; separate cone matrices +are no longer accepted. Its `min_scale` keyword scales the partition gauge +used for normalization. +The other polyhedral compatibility constructors retain their coefficient-floor +keyword arguments. The degree `p`, returned by [`homogeneity_degree`](@ref), satisfies `V(c*x) = c^p * V(x)`. Stability uses `rate^p` in each edge inequality, diff --git a/src/problems/polytope_compat.jl b/src/problems/polytope_compat.jl index 31695c8..89021ad 100644 --- a/src/problems/polytope_compat.jl +++ b/src/problems/polytope_compat.jl @@ -53,36 +53,51 @@ function ParallelFacesPolyhedralTemplate( end """ - ConicPolyhedralTemplate(node_cones; min_scale = 1) + ConicPolyhedralTemplate(node_partitions; min_scale = 1) -One SetProg polytope per node, with linear pieces on the prescribed cones. -""" -function ConicPolyhedralTemplate(node_cones; min_scale = 1) - isempty(node_cones) && throw(ArgumentError("at least one node is required")) +One SetProg polytope per node. Each partition polytope describes the cones over +its facets and must contain the origin in its interior. `min_scale` scales the +partition's gauge used for normalization. +""" +function ConicPolyhedralTemplate(node_partitions; min_scale = 1) + isempty(node_partitions) && throw(ArgumentError("at least one node is required")) + isfinite(min_scale) && min_scale > 0 || + throw(ArgumentError("min_scale must be finite and positive")) + all(p -> p isa SetProg.Polyhedra.Polyhedron, node_partitions) || + throw(ArgumentError("supply partition polytopes instead of separate cones")) return [ - SetProg.Polytope(; symmetric = true, piecewise = cones, min_scale = min_scale) for - cones in node_cones + SetProg.Polytope(; + symmetric = true, + piecewise = min_scale == 1 ? partition : + SetProg.polyhedron( + inv(min_scale) * SetProg.vrep(partition), + SetProg.Polyhedra.library(partition), + ), + ) for partition in node_partitions ] end """ - planar_conic_partition(order) + planar_conic_partition(order; library = nothing) -Compatibility helper: subdivide the upper half-plane into partition cones. +Return a centrally symmetric partition polytope with `8 * 2^(order - 1)` facets. +The cones over its facets have the same boundary rays as the planar conic +partition. `library` can supply a Polyhedra library with an LP solver. """ -function planar_conic_partition(order::Integer) +function planar_conic_partition(order::Integer; library = nothing) order >= 1 || throw(ArgumentError("the order must be positive")) - rays = ([1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [-1.0, 1.0], [-1.0, 0.0]) - cones = [hcat(rays[i], rays[i + 1]) for i in 1:4] + rays = [[1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [-1.0, 1.0], [-1.0, 0.0]] for _ in 2:order - cones = [ - C for cone in cones for C in ( - hcat(cone[:, 1], sum(cone; dims = 2)), - hcat(sum(cone; dims = 2), cone[:, 2]), - ) - ] + rays = vcat( + [r for i in 1:(length(rays) - 1) for r in (rays[i], rays[i] + rays[i + 1])], + [last(rays)], + ) end - return cones + # Put every ray on a strictly convex curve so each becomes a vertex. + vertices = [r / LinearAlgebra.norm(r) for r in rays[1:(end - 1)]] + vertices = vcat(vertices, [-v for v in vertices]) + rep = SetProg.vrep(vertices) + return library === nothing ? SetProg.polyhedron(rep) : SetProg.polyhedron(rep, library) end """ diff --git a/src/problems/sets.jl b/src/problems/sets.jl index 3592901..f01fc0c 100644 --- a/src/problems/sets.jl +++ b/src/problems/sets.jl @@ -25,16 +25,17 @@ is_closed_under(::SetProg.PolySet, ::Composition, system) = is_invertible(system is_closed_under(f::SetProg.Ellipsoid, ::InverseComposition, system) = f.piecewise === nothing && is_invertible(system) is_closed_under(::SetProg.PolySet, ::InverseComposition, system) = is_invertible(system) -is_closed_under(f::SetProg.Polytope, ::Addition, system) = !f.polar -is_closed_under(f::SetProg.Polytope, ::Maximum, system) = f.polar && f.piecewise isa Symbol +is_closed_under(f::SetProg.Polytope, ::Addition, system) = + f.piecewise isa SetProg.Polyhedra.Rep || !f.polar +is_closed_under(f::SetProg.Polytope, ::Maximum, system) = f.piecewise isa Symbol && f.polar function is_closed_under(f::SetProg.Polytope, ::Composition, system) - return !f.polar && - f.piecewise === :orthant && + return f.piecewise === :orthant && + !f.polar && all(A -> all(>=(0), A) && all(>(0), sum(A; dims = 1)), mode_matrices(system)) end function is_closed_under(f::SetProg.Polytope, ::InverseComposition, system) - return !f.polar && - f.piecewise === :orthant && + return f.piecewise === :orthant && + !f.polar && is_invertible(system) && all(A -> all(>=(0), inv(A)), mode_matrices(system)) end @@ -44,7 +45,7 @@ function is_closed_under( system, ) if operation isa Maximum && - all(f -> f isa SetProg.Polytope && f.polar && f.piecewise === :orthants, families) + all(f -> f isa SetProg.Polytope && f.piecewise === :orthants && f.polar, families) return allequal(getproperty.(families, :transform)) end return allequal(families) && is_closed_under(first(families), operation, system) diff --git a/test/runtests.jl b/test/runtests.jl index 75b50cc..384f41d 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -27,6 +27,7 @@ const TEST_FILES = [ ("./systems/trajectory.jl",), ("./systems/simulate.jl",), ("./problems/sets.jl", :slow), + ("./problems/conic_polyhedral.jl", :slow), ("./problems/gauges.jl", :slow), ("./problems/certify.jl", :slow), ("./problems/tight_edges.jl", :slow), From ff8d00d2048f6141e38b6f1d4585c2c497ff1132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Fri, 16 Oct 2026 15:13:50 +0200 Subject: [PATCH 05/18] Migrate stability templates to native SetProg families Preserve the PCC compatibility constructors while using Domain and partition polytopes. Keep normalization policy in PCC: fixed reference inclusions select SetProg modeling spaces automatically and retain minimum weights; parallel-face families also enforce central symmetry. Evaluate native fitted gauges in PCC, including optional polynomial evaluation, and implement blocking duality for copositive certificates. Replace references to the unmerged symbolic partitions and explicit-cone constructors. Validated 2682 assertions across the focused copositive, conic, gauge, native-family, common-function, and certificate-duality suites against SetProg e6899a6. Ran JuliaFormatter over the repository. --- docs/src/index.md | 4 +- docs/src/manual/templates.md | 33 +- ...PathCompleteCertificatesSumOfSquaresExt.jl | 5 + src/problems/catalogue/stability.jl | 3 + src/problems/gauges.jl | 410 ++++++++++++++++++ src/problems/polytope_compat.jl | 83 ++-- src/problems/sets.jl | 50 ++- test/lifts/split.jl | 3 +- test/problems/common.jl | 21 +- test/problems/conic_polyhedral.jl | 117 +++++ test/problems/copositive.jl | 206 +++++++++ test/problems/dual.jl | 3 +- test/problems/gauges.jl | 157 +++++++ test/problems/sets.jl | 36 +- test/runtests.jl | 1 + 15 files changed, 1023 insertions(+), 109 deletions(-) create mode 100644 src/problems/gauges.jl create mode 100644 test/problems/conic_polyhedral.jl create mode 100644 test/problems/copositive.jl create mode 100644 test/problems/gauges.jl diff --git a/docs/src/index.md b/docs/src/index.md index b179f96..b5a1165 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -71,8 +71,8 @@ piecewise families, [Piecewise semi-ellipsoids: a template from SetProg](@ref). ## Scope Stability uses SetProg families directly. Safety and optimal control retain -the legacy quadratic template until their migration. [`compare`](@ref) is stated for stability, as in the -literature, and [`refine`](@ref) runs on stability; +the legacy quadratic template until their migration. [`compare`](@ref) is stated +for stability, as in the literature, and [`refine`](@ref) runs on stability; [What works with what](@ref) gives the full table. A bound is always an upper bound, and a negative answer means that no certificate was found in this template on this graph, not that the property fails. diff --git a/docs/src/manual/templates.md b/docs/src/manual/templates.md index eeaa05a..d213513 100644 --- a/docs/src/manual/templates.md +++ b/docs/src/manual/templates.md @@ -6,8 +6,9 @@ CurrentModule = PathCompleteCertificates For stability, a template is a SetProg set family. Pass it directly to [`certify`](@ref), [`jsr_bound`](@ref), [`compare`](@ref), or [`refine`](@ref). -[`functions`](@ref) returns fitted SetProg sets, each callable as `V(x)`. -The homogeneous sublevel function is the node's Lyapunov function. +[`functions`](@ref) returns fitted SetProg sets. Evaluate a node with +`node_value(problem, V, x)`, or the common Lyapunov function with +`certificate(x)`. ```julia using SetProg @@ -20,9 +21,9 @@ certificate = PCC.jsr_bound(family, graph, problem; optimizer) | SetProg family | Node function | Degree | Program | | :-- | :-- | :-: | :-- | | `Ellipsoid(symmetric = true)` | `x'Qx` | 2 | semidefinite | -| `Polytope(piecewise = :orthant)` | `c'x` on the nonnegative orthant | 1 | linear | -| `Polytope(piecewise = :orthant, polar = true)` | `maximum(x ./ v)` on the nonnegative orthant | 1 | linear | -| `Polytope(piecewise = :orthants, transform = G', polar = true)` | `maximum(abs.(G*x) ./ w)` | 1 | linear | +| `Domain(Polytope(symmetric = true), MOI.Nonnegatives)` | `c'x` on the nonnegative orthant | 1 | linear | +| `DualCopositiveTemplate()` | `maximum(x ./ v)` on the nonnegative orthant | 1 | linear | +| `ParallelFacesPolyhedralTemplate([G])` | `maximum(abs.(G*x) ./ w)` | 1 | linear | | `PolySet(symmetric = true, degree = 2d)` | sum-of-squares polynomial | 2d | semidefinite | | `Ellipsoid(symmetric = true, piecewise = partition)` | quadratic on each partition cone | 2 | semidefinite | | `Polytope(symmetric = true, piecewise = partition)` | linear on each partition cone | 1 | linear | @@ -32,13 +33,13 @@ an error explaining the missing extension. Polynomial variables may be supplied with `variables = x`, or inferred in a shared coordinate system. SetProg is a direct dependency of PCC; its polynomial dependencies are optional. -The four polyhedral families use the same piecewise-linear representation. -The `:orthant` partition has one linear piece, restricted to the nonnegative -orthant; `:orthants` is a weighted absolute-value sum. Taking the polar gives -a box or parallelotope. Partition polytopes describe the linear pieces' regions -through the cones over their facets, allowing SetProg to use facet adjacency. -`polar` specifies the family; -SetProg chooses its modeling space from the constraints. +`Domain(Polytope(symmetric = true), MOI.Nonnegatives)` has one linear piece +restricted to the nonnegative orthant, with its dimension inferred from the +model. PCC normalizes its coefficients to be at least one using a fixed +unit-simplex inclusion. Partition polytopes describe the linear pieces' +regions through the cones over their facets, retaining facet adjacency. +A cross-polytope supplies the orthant partition for parallel-face families. +SetProg chooses the modeling space from the normalization constraints. The existing `LinearCopositiveTemplate`, `DualCopositiveTemplate`, `ParallelFacesPolyhedralTemplate`, and `ConicPolyhedralTemplate` constructors @@ -50,6 +51,14 @@ used for normalization. The other polyhedral compatibility constructors retain their coefficient-floor keyword arguments. +`DualCopositiveTemplate` and `ParallelFacesPolyhedralTemplate` retain their +compatibility constructors. A PCC normalization adapter stores the native +SetProg family and a fixed reference set. Requiring that reference to lie in +the variable selects dual modeling through SetProg's inclusion rules; +`min_weight` remains a lower bound on the box widths. Parallel-face families +also impose central symmetry using `-S ⊆ S`. No variable polarity option is +used, and normalization and gauge evaluation stay in PCC. + The degree `p`, returned by [`homogeneity_degree`](@ref), satisfies `V(c*x) = c^p * V(x)`. Stability uses `rate^p` in each edge inequality, so every family's rate bounds the joint spectral radius. diff --git a/ext/PathCompleteCertificatesSumOfSquaresExt.jl b/ext/PathCompleteCertificatesSumOfSquaresExt.jl index b5e9234..4e6c749 100644 --- a/ext/PathCompleteCertificatesSumOfSquaresExt.jl +++ b/ext/PathCompleteCertificatesSumOfSquaresExt.jl @@ -14,4 +14,9 @@ function PCC._unit_set(family::SetProg.PolySet) return SetProg.Sets.PolySet(family.degree, gram) end +function PCC._gauge(V::Union{SetProg.Sets.PolySet, SetProg.Sets.ConvexPolySet}, x) + p = SumOfSquares.MP.polynomial(V.p) + return p(SumOfSquares.MP.variables(p) => x) +end + end diff --git a/src/problems/catalogue/stability.jl b/src/problems/catalogue/stability.jl index 235ed76..65bdb1d 100644 --- a/src/problems/catalogue/stability.jl +++ b/src/problems/catalogue/stability.jl @@ -18,6 +18,9 @@ end dual(problem::StabilityProblem) = StabilityProblem(dual(problem.system)) +node_value(::StabilityProblem, V::SetProg.Sets.AbstractSet, x::AbstractVector{<:Real}) = + _gauge(V, x) + """ StabilityCertificate diff --git a/src/problems/gauges.jl b/src/problems/gauges.jl new file mode 100644 index 0000000..ef1248c --- /dev/null +++ b/src/problems/gauges.jl @@ -0,0 +1,410 @@ +const _LinearPolytope = SetProg.Sets.Piecewise{<:Any, <:SetProg.Sets.PolarPoint} +const _LinearCopositiveSet = SetProg.Sets.Intersection{<:Any, <:SetProg.Sets.PolarPoint} + +_is_linear_copositive(::SetProg.AbstractVariable) = false +_is_linear_copositive(f::SetProg.Domain{<:SetProg.Polytope}) = + f.set.piecewise === nothing && + (f.domain === SetProg.MOI.Nonnegatives || f.domain isa SetProg.MOI.Nonnegatives) + +# PCC normalization policy; construction is delegated to the native family. +struct _NormalizedFamily{S <: SetProg.AbstractVariable, R} <: SetProg.AbstractVariable + set::S + reference::R + contains_reference::Bool + symmetric::Bool + dimension::Union{Int, Nothing} +end +_NormalizedFamily(set, reference; contains_reference = true, symmetric = false) = + _NormalizedFamily(set, reference, contains_reference, symmetric, set.dimension) +Base.:(==)(a::_NormalizedFamily, b::_NormalizedFamily) = + a.set == b.set && + a.reference == b.reference && + a.contains_reference == b.contains_reference && + a.symmetric == b.symmetric +Base.hash(f::_NormalizedFamily, h::UInt) = + hash((f.set, f.reference, f.contains_reference, f.symmetric), h) +SetProg.Sets.space_variables(f::_NormalizedFamily) = SetProg.Sets.space_variables(f.set) +SetProg.variable_polyvars( + f::_NormalizedFamily, + spaces::SetProg.Spaces, + index::SetProg.SpaceIndex, +) = SetProg.variable_polyvars(f.set, spaces, index) +function SetProg.variable_set( + model::JuMP.AbstractModel, + f::_NormalizedFamily, + space::SetProg.Space, + n, + x, +) + return SetProg.variable_set(model, f.set, space, n, x) +end + +_is_parallelotope(f) = f isa _NormalizedFamily && f.symmetric && f.set isa SetProg.Polytope + +""" + homogeneity_degree(family) + +The degree of the homogeneous gauge used to represent a set family. +""" +homogeneity_degree(::Union{SetProg.Ellipsoid, SetProg.Sets.Ellipsoid}) = 2 +homogeneity_degree(f::_NormalizedFamily) = homogeneity_degree(f.set) +homogeneity_degree( + ::Union{ + SetProg.Polytope, + SetProg.Sets.PolarPoint, + SetProg.Sets.Piecewise{<:Any, <:SetProg.Sets.PolarPoint}, + }, +) = 1 +homogeneity_degree( + family::Union{SetProg.PolySet, SetProg.Sets.PolySet, SetProg.Sets.ConvexPolySet}, +) = family.degree +homogeneity_degree( + set::Union{SetProg.Domain, SetProg.Sets.Polar, SetProg.Sets.Intersection}, +) = homogeneity_degree(set.set) +function homogeneity_degree(families::AbstractVector{<:SetProg.AbstractVariable}) + isempty(families) && throw(ArgumentError("at least one set family is required")) + degrees = unique(homogeneity_degree.(families)) + length(degrees) == 1 || + throw(ArgumentError("every node must have the same gauge degree")) + return only(degrees) +end + +""" + _with_dimension(family, dimension; variables = nothing) + +Fill the dimension of a homogeneous gauge family, validating explicit data. +`variables` supplies a shared polynomial coordinate system when needed. +""" +function _with_dimension(family::SetProg.AbstractVariable, n::Integer; variables = nothing) + n > 0 || throw(ArgumentError("the dimension must be positive")) + family.dimension === nothing || family.dimension == n || throw(DimensionMismatch()) + return _with_dimension(family, Int(n), variables) +end +function _with_dimension(family::SetProg.Ellipsoid, n, vars) + family.symmetric || throw(ArgumentError("a homogeneous ellipsoid must be symmetric")) + family.point === nothing && family.superset === nothing || + throw(ArgumentError("point and superset are not supported for homogeneous gauges")) + return SetProg.Ellipsoid(; + symmetric = true, + dimension = n, + piecewise = family.piecewise, + ) +end +function _with_dimension(family::SetProg.Domain, n, vars) + set = if _is_linear_copositive(family) + SetProg.Polytope(; symmetric = family.set.symmetric, dimension = n) + else + _with_dimension(family.set, n, vars) + end + return SetProg.Domain(set, family.domain) +end +function _with_dimension(family::SetProg.Polytope, n, vars) + family.piecewise !== nothing || + throw(ArgumentError("a polytope gauge requires a partition")) + return SetProg.Polytope(; + symmetric = family.symmetric, + dimension = n, + piecewise = family.piecewise, + ) +end +function _with_dimension(family::_NormalizedFamily, n, vars) + return _NormalizedFamily( + _with_dimension(family.set, n, vars), + family.reference; + contains_reference = family.contains_reference, + symmetric = family.symmetric, + ) +end +function _with_dimension(family::SetProg.PolySet, n, vars) + family.symmetric || + throw(ArgumentError("a homogeneous polynomial set must be symmetric")) + family.point === nothing && family.superset === nothing || + throw(ArgumentError("point and superset are not supported for homogeneous gauges")) + x = + family.variables === nothing ? (vars === nothing ? SetProg.new_polyvars(n) : vars) : + family.variables + length(x) == n || throw(DimensionMismatch()) + return SetProg.PolySet(; + symmetric = true, + degree = family.degree, + dimension = n, + convex = family.convex, + variables = x, + basis = family.basis, + ) +end +""" + _unit_set(family) + +The unit gauge used to normalize a set variable in a homogeneous certificate. +""" +function _unit_set(f::SetProg.Ellipsoid) + ball = SetProg.Sets.Ellipsoid( + LinearAlgebra.Symmetric(Matrix{Float64}(LinearAlgebra.I, f.dimension, f.dimension)), + ) + f.piecewise === nothing && return ball + return SetProg.Sets.Piecewise( + [ball for _ in SetProg.halfspaces(f.piecewise)], + f.piecewise, + ) +end +_unit_set(f::SetProg.Domain{<:SetProg.Polytope}) = + LinearCopositiveFunction(ones(f.dimension)) +function _unit_set(f::SetProg.Polytope) + return SetProg.Sets.Piecewise( + [SetProg.Sets.PolarPoint(h.a / h.β) for h in SetProg.halfspaces(f.piecewise)], + f.piecewise, + ) +end + +function _unit_set(f::_NormalizedFamily) + if f.reference isa Real + unit = LinearCopositiveFunction(fill(f.reference, f.dimension)) + return f.contains_reference ? SetProg.polar(unit) : unit + end + return f.reference +end + +""" + add_function_variables!(model, family, dimension, node) + +Create a homogeneous set variable, named for `node`, for a certificate model. +""" +function add_function_variables!( + model::JuMP.Model, + family::SetProg.AbstractVariable, + n::Integer, + node::Integer, +) + variable = _with_dimension(family, n) + return JuMP.@variable(model, variable_type = variable, base_name = "S_$node") +end + +""" + add_normalization!(model, family, S; conditioning_bound = Inf) + +Exclude the zero gauge by inclusion with a fixed reference set. The inclusion +direction and representation let SetProg select the modeling space. +A finite ellipsoid conditioning bound also requires +`Q ≤ conditioning_bound * LinearAlgebra.I`. +""" +function add_normalization!( + model::JuMP.Model, + family::SetProg.AbstractVariable, + S::SetProg.SetVariableRef; + conditioning_bound::Real = Inf, +) + conditioning_bound >= 1 || throw(ArgumentError("conditioning_bound must be at least 1")) + unit = _unit_set(S.set) + if family isa _NormalizedFamily && family.contains_reference + JuMP.@constraint(model, unit ⊆ S) + else + JuMP.@constraint(model, S ⊆ unit) + end + if family isa _NormalizedFamily && family.symmetric + n = family.dimension + JuMP.@constraint(model, (-Matrix{Float64}(LinearAlgebra.I, n, n)) * S ⊆ S) + end + if isfinite(conditioning_bound) + S.set isa SetProg.Ellipsoid && S.set.piecewise === nothing || + throw(ArgumentError("conditioning bounds require an ellipsoid")) + bound = SetProg.Sets.Ellipsoid( + LinearAlgebra.Symmetric( + conditioning_bound * + Matrix{Float64}(LinearAlgebra.I, S.set.dimension, S.set.dimension), + ), + ) + JuMP.@constraint(model, bound ⊆ S) + end + return nothing +end + +""" + add_decrease!(model, family, source, destination, A; scale = 1) + +Require `V_destination(A * x) ≤ scale * V_source(x)`, using set inclusion. +""" +function add_decrease!( + model::JuMP.Model, + family::SetProg.AbstractVariable, + source::SetProg.SetVariableRef, + destination::SetProg.SetVariableRef, + A::AbstractMatrix; + scale::Real = 1, +) + scale >= 0 || throw(ArgumentError("the scale must be nonnegative")) + if iszero(scale) + iszero(A) || JuMP.@constraint(model, zero(JuMP.AffExpr) >= 1) + return nothing + end + γ = scale^(1 / homogeneity_degree(family)) + JuMP.@constraint(model, (A / γ) * source ⊆ destination) + return nothing +end + +""" + solution_value(family, variable) + +The fitted set with solver values substituted for its variables. +""" +solution_value(::SetProg.AbstractVariable, variable::SetProg.SetVariableRef) = + JuMP.value(variable) + +""" + decrease_slack(family, source, destination, A; scale = 1) + +Measure the fitted gauge's decrease condition. A nonnegative slack certifies +that the modeled edge inequality holds. Polynomial and piecewise-quadratic +slacks require a separate optimization and are not implemented. +""" +function decrease_slack( + ::SetProg.Ellipsoid, + source::SetProg.Sets.Ellipsoid, + destination::SetProg.Sets.Ellipsoid, + A; + scale = 1, +) + return minimum( + LinearAlgebra.eigvals( + LinearAlgebra.Symmetric(Matrix(scale * source.Q - A' * destination.Q * A)), + ), + ) +end +function decrease_slack(family::SetProg.AbstractVariable, source, destination, A; scale = 1) + return throw(ArgumentError("decrease slacks are not implemented for $(typeof(family))")) +end + +""" + _supports_dual(family) + +Whether the fitted homogeneous gauge has an implemented dual gauge family. +""" +_supports_dual(::SetProg.AbstractVariable) = false +_supports_dual(f::SetProg.Ellipsoid) = f.symmetric && f.piecewise === nothing +_supports_dual(f::SetProg.Domain) = _is_linear_copositive(f) +_supports_dual(f::_NormalizedFamily) = _supports_dual(f.set) + +""" + _dual_family(family) + +The family of dual homogeneous gauges. On the positive orthant this uses +blocking duality between positive simplices and boxes. +""" +_dual_family(f::SetProg.Ellipsoid) = + _supports_dual(f) ? f : + throw(ArgumentError("piecewise ellipsoid duals are not implemented")) +_dual_family(f::SetProg.Domain) = _NormalizedFamily(f, 1) +function _dual_family(f::_NormalizedFamily) + if f.contains_reference && f.reference == 1 + return f.set + end + return _NormalizedFamily( + f.set, + f.reference; + contains_reference = !f.contains_reference, + symmetric = f.symmetric, + ) +end + +""" + _dual_value(set) + +The fitted dual gauge, represented as a set in the primal space. +""" +_dual_value(set::SetProg.Sets.Ellipsoid) = + SetProg.Sets.Ellipsoid(LinearAlgebra.Symmetric(inv(set.Q))) + +_dual_value( + set::Union{ + _LinearCopositiveSet, + SetProg.Sets.PolarOf{<:_LinearCopositiveSet}, + _LinearPolytope, + SetProg.Sets.PolarOf{<:_LinearPolytope}, + }, +) = SetProg.Polyhedra.polar(set) + +# A convex piecewise-linear gauge is the maximum of its linear pieces. +_polytope_value(set::_LinearPolytope, x) = + maximum(piece -> LinearAlgebra.dot(piece.a, x), set.sets) + +_gauge(V::_LinearPolytope, x) = _polytope_value(V, x) + +function _polytope_slack(source::_LinearPolytope, destination::_LinearPolytope, A, scale) + return minimum( + scale * LinearAlgebra.dot(s.a, SetProg.Polyhedra.coord(r)) - + _polytope_value(destination, A * SetProg.Polyhedra.coord(r)) for + (s, cone) in zip(source.sets, source.pieces) for r in SetProg.rays(cone) + ) +end +function decrease_slack( + ::SetProg.Polytope, + source::_LinearPolytope, + destination::_LinearPolytope, + A; + scale = 1, +) + return _polytope_slack(source, destination, A, scale) +end +function decrease_slack( + ::SetProg.Polytope, + source::SetProg.Sets.PolarOf{<:_LinearPolytope}, + destination::SetProg.Sets.PolarOf{<:_LinearPolytope}, + A; + scale = 1, +) + return _polytope_slack( + SetProg.Polyhedra.polar(destination), + SetProg.Polyhedra.polar(source), + A', + scale, + ) +end +function decrease_slack( + ::SetProg.Domain{<:SetProg.Polytope}, + source::_LinearCopositiveSet, + destination::_LinearCopositiveSet, + A; + scale = 1, +) + # A negative matrix entry sends an orthant direction outside the domain. + all(>=(0), A) || return -Inf + return minimum(scale * source.set.a - A' * destination.set.a) +end + +function decrease_slack(f::_NormalizedFamily, source, destination, A; scale = 1) + return decrease_slack(f.set, source, destination, A; scale) +end +function decrease_slack( + ::SetProg.Domain{<:SetProg.Polytope}, + source::SetProg.Sets.PolarOf{<:_LinearCopositiveSet}, + destination::SetProg.Sets.PolarOf{<:_LinearCopositiveSet}, + A; + scale = 1, +) + all(>=(0), A) || return -Inf + return minimum( + scale * SetProg.polar(destination).set.a - A * SetProg.polar(source).set.a, + ) +end + +_gauge(V::SetProg.Sets.Ellipsoid, x) = LinearAlgebra.dot(x, V.Q * x) +_gauge(V::SetProg.Sets.PolarPoint, x) = LinearAlgebra.dot(V.a, x) +function _gauge(V::SetProg.Sets.Intersection, x) + return x in V.domain ? _gauge(V.set, x) : Inf +end +function _gauge(V::SetProg.Sets.Piecewise, x) + i = findfirst(cone -> x in cone, V.pieces) + return _gauge(V.sets[i], x) +end +function _gauge(V::SetProg.Sets.PolarOf{<:_LinearCopositiveSet}, x) + inner = SetProg.polar(V) + return x in inner.domain ? maximum(x ./ inner.set.a) : Inf +end +function _gauge(V::SetProg.Sets.PolarOf{<:_LinearPolytope}, x) + inner = SetProg.polar(V) + return maximum( + LinearAlgebra.dot(x, SetProg.Polyhedra.coord(r)) / + LinearAlgebra.dot(s.a, SetProg.Polyhedra.coord(r)) for + (s, cone) in zip(inner.sets, inner.pieces) for r in SetProg.rays(cone) + ) +end diff --git a/src/problems/polytope_compat.jl b/src/problems/polytope_compat.jl index 89021ad..b5a54b8 100644 --- a/src/problems/polytope_compat.jl +++ b/src/problems/polytope_compat.jl @@ -3,15 +3,19 @@ Linear gauges on the nonnegative orthant, represented by a SetProg polytope. """ -LinearCopositiveTemplate() = SetProg.Polytope(; piecewise = :orthant, min_scale = 1) +LinearCopositiveTemplate() = + SetProg.Domain(SetProg.Polytope(; symmetric = true), SetProg.MOI.Nonnegatives) """ DualCopositiveTemplate(; min_weight = 1) Blocking polars of linear copositive gauges, represented by a SetProg polytope. """ -DualCopositiveTemplate(; min_weight = 1) = - SetProg.Polytope(; piecewise = :orthant, polar = true, min_scale = min_weight) +function DualCopositiveTemplate(; min_weight = 1) + isfinite(min_weight) && min_weight > 0 || + throw(ArgumentError("min_weight must be finite and positive")) + return _NormalizedFamily(LinearCopositiveTemplate(), min_weight) +end """ ParallelFacesPolyhedralTemplate(n_nodes, dimension; min_weight = 1) @@ -25,31 +29,32 @@ function ParallelFacesPolyhedralTemplate( min_weight = 1, ) n_nodes > 0 || throw(ArgumentError("at least one node is required")) - family = SetProg.Polytope(; - symmetric = true, - piecewise = :orthants, - dimension = dimension, - polar = true, - min_scale = min_weight, - ) - return fill(family, n_nodes) + dimension > 0 || throw(ArgumentError("the dimension must be positive")) + identity = Matrix{Float64}(LinearAlgebra.I, dimension, dimension) + return ParallelFacesPolyhedralTemplate(fill(identity, n_nodes); min_weight) end function ParallelFacesPolyhedralTemplate( matrices::AbstractVector{<:AbstractMatrix}; min_weight = 1, ) isempty(matrices) && throw(ArgumentError("at least one matrix is required")) - n = size(first(matrices), 1) + isfinite(min_weight) && min_weight > 0 || + throw(ArgumentError("min_weight must be finite and positive")) + n = LinearAlgebra.checksquare(first(matrices)) + n > 0 || throw(ArgumentError("the dimension must be positive")) all(G -> size(G) == (n, n), matrices) || throw(DimensionMismatch()) - return [ - SetProg.Polytope(; - symmetric = true, - piecewise = :orthants, - transform = Matrix(G'), - polar = true, - min_scale = min_weight, - ) for G in matrices - ] + return map(matrices) do G + LinearAlgebra.rank(G) == n || + throw(ArgumentError("node matrices must be invertible")) + partition = _parallel_faces_partition(G) + family = SetProg.Polytope(; symmetric = true, piecewise = partition) + reference = ParallelFacesPolyhedralFunction(G, fill(min_weight, n); partition) + return _NormalizedFamily(family, reference; symmetric = true) + end +end +function _parallel_faces_partition(G) + vertices = [collect(v) for v in eachcol(Matrix(G'))] + return SetProg.polyhedron(SetProg.vrep(vcat(vertices, [-v for v in vertices]))) end """ @@ -106,18 +111,12 @@ end A fitted linear copositive function represented by a SetProg set. """ function LinearCopositiveFunction(c::AbstractVector) - cone = Matrix{Float64}(LinearAlgebra.I, length(c), length(c)) - return SetProg.Sets.Intersection( - SetProg.Sets.Piecewise([collect(c)], [cone]), - SetProg.polyhedron( - SetProg.vrep( - [zeros(length(c))], - SetProg.Line{Float64, Vector{Float64}}[], - [SetProg.Ray(collect(ray)) for ray in eachcol(cone)], - ), - ), - ) + n = length(c) + domain = + SetProg.polyhedron(SetProg.hrep(-Matrix{Float64}(LinearAlgebra.I, n, n), zeros(n))) + return SetProg.Sets.Intersection(SetProg.Sets.PolarPoint(collect(c)), domain) end + """ DualCopositiveFunction(v) @@ -129,13 +128,17 @@ DualCopositiveFunction(v::AbstractVector) = _dual_value(LinearCopositiveFunction A fitted parallel-faces polyhedral function represented by a SetProg set. """ -function ParallelFacesPolyhedralFunction(G::AbstractMatrix, w::AbstractVector) +function ParallelFacesPolyhedralFunction( + G::AbstractMatrix, + w::AbstractVector; + partition = _parallel_faces_partition(G), +) n = LinearAlgebra.checksquare(G) - signs = vec([Float64[s...] for s in Iterators.product(ntuple(_ -> (-1, 1), n)...)]) - return SetProg.polar( - SetProg.Sets.Piecewise( - [inv(G) * (sign .* w) for sign in signs], - [G' * LinearAlgebra.Diagonal(sign) for sign in signs], - ), - ) + length(w) == n || throw(DimensionMismatch()) + all(>(0), w) || throw(ArgumentError("weights must be positive")) + pieces = [ + SetProg.Sets.PolarPoint(G \ (w .* (G * (h.a / h.β)))) for + h in SetProg.halfspaces(partition) + ] + return SetProg.polar(SetProg.Sets.Piecewise(pieces, partition)) end diff --git a/src/problems/sets.jl b/src/problems/sets.jl index f01fc0c..9682c5a 100644 --- a/src/problems/sets.jl +++ b/src/problems/sets.jl @@ -8,7 +8,8 @@ Whether the SetProg family applies to the system. Orthant gauges require nonnegative dynamics. """ is_applicable(::SetProg.AbstractVariable, system) = true -is_applicable(f::SetProg.Polytope, system) = f.piecewise !== :orthant || is_positive(system) +is_applicable(f::SetProg.Domain, system) = !_is_linear_copositive(f) || _is_positive(system) +is_applicable(f::_NormalizedFamily, system) = is_applicable(f.set, system) is_applicable(families::AbstractVector{<:SetProg.AbstractVariable}, system) = all(f -> is_applicable(f, system), families) @@ -20,33 +21,37 @@ Whether a SetProg gauge family admits the operation required by a graph lift. is_closed_under(::SetProg.AbstractVariable, ::Operation, system) = false is_closed_under(::Union{SetProg.Ellipsoid, SetProg.PolySet}, ::Addition, system) = true is_closed_under(f::SetProg.Ellipsoid, ::Composition, system) = - f.piecewise === nothing && is_invertible(system) -is_closed_under(::SetProg.PolySet, ::Composition, system) = is_invertible(system) + f.piecewise === nothing && _is_invertible(system) +is_closed_under(::SetProg.PolySet, ::Composition, system) = _is_invertible(system) is_closed_under(f::SetProg.Ellipsoid, ::InverseComposition, system) = - f.piecewise === nothing && is_invertible(system) -is_closed_under(::SetProg.PolySet, ::InverseComposition, system) = is_invertible(system) -is_closed_under(f::SetProg.Polytope, ::Addition, system) = - f.piecewise isa SetProg.Polyhedra.Rep || !f.polar -is_closed_under(f::SetProg.Polytope, ::Maximum, system) = f.piecewise isa Symbol && f.polar -function is_closed_under(f::SetProg.Polytope, ::Composition, system) - return f.piecewise === :orthant && - !f.polar && - all(A -> all(>=(0), A) && all(>(0), sum(A; dims = 1)), mode_matrices(system)) -end -function is_closed_under(f::SetProg.Polytope, ::InverseComposition, system) - return f.piecewise === :orthant && - !f.polar && - is_invertible(system) && - all(A -> all(>=(0), inv(A)), mode_matrices(system)) -end + f.piecewise === nothing && _is_invertible(system) +is_closed_under(::SetProg.PolySet, ::InverseComposition, system) = _is_invertible(system) +is_closed_under(::SetProg.Polytope, ::Addition, system) = true +is_closed_under(f::_NormalizedFamily, ::Addition, system) = + !f.contains_reference && is_closed_under(f.set, Addition(), system) +is_closed_under(f::_NormalizedFamily, ::Maximum, system) = f.contains_reference +is_closed_under(f::_NormalizedFamily, ::Composition, system) = + !f.contains_reference && is_closed_under(f.set, Composition(), system) +is_closed_under(f::_NormalizedFamily, ::InverseComposition, system) = + !f.contains_reference && is_closed_under(f.set, InverseComposition(), system) +is_closed_under(f::SetProg.Domain, ::Addition, system) = _is_linear_copositive(f) +is_closed_under(f::SetProg.Domain, ::Composition, system) = + _is_linear_copositive(f) && + all(A -> all(>=(0), A) && all(>(0), sum(A; dims = 1)), state_matrices(system)) +is_closed_under(f::SetProg.Domain, ::InverseComposition, system) = + _is_linear_copositive(f) && + _is_invertible(system) && + all(A -> all(>=(0), inv(A)), state_matrices(system)) function is_closed_under( families::AbstractVector{<:SetProg.AbstractVariable}, operation::Operation, system, ) - if operation isa Maximum && - all(f -> f isa SetProg.Polytope && f.piecewise === :orthants && f.polar, families) - return allequal(getproperty.(families, :transform)) + if operation isa Maximum && all(_is_parallelotope, families) + return allequal([ + Set(Tuple(SetProg.Polyhedra.coord(p)) for p in SetProg.points(f.set.piecewise)) + for f in families + ]) end return allequal(families) && is_closed_under(first(families), operation, system) end @@ -98,4 +103,3 @@ function _prepared_families(family::_SetFamily, dimension, count) return [_with_dimension(f, dimension; variables = x) for f in families] end -node_value(::_SetFamily, ::AbstractProblem, V, x::AbstractVector{<:Real}) = V(x) diff --git a/test/lifts/split.jl b/test/lifts/split.jl index fd93dc6..835e1f2 100644 --- a/test/lifts/split.jl +++ b/test/lifts/split.jl @@ -325,8 +325,7 @@ end moved = PCC.reindex(template, PCC.origins(lifted)) @test length(moved) == PCC.n_nodes(PCC.graph(lifted)) - @test getproperty.(moved, :transform) == - [template[o].transform for o in PCC.origins(lifted)] + @test moved == [template[o] for o in PCC.origins(lifted)] # A data-free template is its own reindexing. @test PCC.reindex(SetProg.Ellipsoid(symmetric = true), PCC.origins(lifted)) == diff --git a/test/problems/common.jl b/test/problems/common.jl index 828285b..e60cef8 100644 --- a/test/problems/common.jl +++ b/test/problems/common.jl @@ -14,7 +14,7 @@ const P1 = SetProg.Sets.Ellipsoid(LinearAlgebra.Symmetric([1.0 0.0; 0.0 1.0])) const P2 = SetProg.Sets.Ellipsoid(LinearAlgebra.Symmetric([3.0 0.0; 0.0 3.0])) const VS = [P1, P2] -value(V) = V(X) +value(V) = PCC.node_value(PROBLEM, V, X) @testset "a complete graph combines with a minimum" begin graph = PCC.de_bruijn(1, 2) @@ -54,19 +54,6 @@ end graph = PCC.de_bruijn(1, 2) linear = PCC.LinearCopositiveFunction.([[1.0, 1.0], [2.0, 2.0]]) - @test PCC.common(graph, PROBLEM, linear, X) ≈ - minimum(V(X) for V in linear) - - weights = [ - PCC.ParallelFacesPolyhedralFunction(Matrix{Float64}(LinearAlgebra.I, 2, 2), w) - for w in ([1.0, 1.0], [4.0, 4.0]) - ] - - @test PCC.common(graph, - PROBLEM, - weights, - X, - ) ≈ minimum(V(X) for V in weights) -end - -end + @test PCC.common(graph, PROBLEM, linear, X) ≈ minimum( + PCC.node_value(PROBLEM, V, X) for V in linear + ) diff --git a/test/problems/conic_polyhedral.jl b/test/problems/conic_polyhedral.jl new file mode 100644 index 0000000..749849f --- /dev/null +++ b/test/problems/conic_polyhedral.jl @@ -0,0 +1,117 @@ +module TestConicPolyhedral + +using Test +using LinearAlgebra +using SetProg +import PathCompleteCertificates as PCC +import Clarabel +import HiGHS + +const LIB = Polyhedra.DefaultLibrary{Float64}( + optimizer_with_attributes(HiGHS.Optimizer, MOI.Silent() => true), +) + +@testset "Planar partition polytopes" begin + for order in 1:3 + partition = PCC.planar_conic_partition(order; library = LIB) + @test partition isa Polyhedra.Polyhedron + @test fulldim(partition) == 2 + @test npoints(partition) == nhalfspaces(partition) == 8 * 2^(order - 1) + @test all(h -> h.β > 0, halfspaces(partition)) + @test all(v -> -v in partition, points(partition)) + pieces = [SetProg.Sets.PolarPoint(h.a / h.β) for h in halfspaces(partition)] + set = SetProg.Sets.Piecewise(pieces, partition) + @test all(neighbors -> length(neighbors) == 2, set.graph) + # Refinement preserves the original axes and diagonal boundary rays. + for r in ([1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [-1.0, 1.0]) + @test any(v -> v ≈ r / norm(r), points(partition)) + end + end + @test PCC.planar_conic_partition(1) isa Polyhedra.Polyhedron + @test_throws ArgumentError PCC.planar_conic_partition(0) +end + +@testset "Conic template compatibility constructor" begin + partition = PCC.planar_conic_partition(1; library = LIB) + families = PCC.ConicPolyhedralTemplate([partition, partition]) + @test length(families) == 2 + @test all(f -> f isa Polytope && f.piecewise === partition, families) + @test all(f -> f.symmetric && f.dimension == 2, families) + system = PCC.switched_system([[0.5 0.0; 0.0 0.25]]) + @test PCC.is_closed_under(first(families), PCC.Addition(), system) + @test !PCC.is_closed_under(first(families), PCC.Maximum(), system) + @test !PCC.is_closed_under(first(families), PCC.Composition(), system) + @test !PCC.is_closed_under(first(families), PCC.InverseComposition(), system) + @test !PCC.is_closed_under(families, PCC.Maximum(), system) + scaled = only(PCC.ConicPolyhedralTemplate([partition]; min_scale = 2)) + @test all(v -> 2v in partition, points(scaled.piecewise)) + @test_throws ArgumentError PCC.ConicPolyhedralTemplate([]) + @test_throws ArgumentError PCC.ConicPolyhedralTemplate([[Matrix{Float64}(I, 2, 2)]]) + @test_throws ArgumentError PCC.ConicPolyhedralTemplate([partition]; min_scale = 0) + @test_throws ArgumentError PCC.ConicPolyhedralTemplate([partition]; min_scale = Inf) +end + +@testset "Conic certificates use the native piecewise polytope" begin + A = [0.69 * [0.0 1.0; -1.0 0.0], 0.69 * [1.0 1.0; 0.0 1.0]] + problem = PCC.StabilityProblem(PCC.switched_system(A)) + graph = PCC.common_graph(2) + partition = PCC.planar_conic_partition(1; library = LIB) + families = PCC.ConicPolyhedralTemplate([partition]) + # The original octagon has the same facet cones, with different vertex radii. + octagon = polyhedron( + hrep([ + HalfSpace(a, 6.0) for a in ( + [2.0, 1.0], + [1.0, 2.0], + [-1.0, 2.0], + [-2.0, 1.0], + [-2.0, -1.0], + [-1.0, -2.0], + [1.0, -2.0], + [2.0, -1.0], + ) + ]), + LIB, + ) + native = Polytope(symmetric = true, piecewise = octagon) + conic = + PCC.jsr_bound(families, graph, problem; optimizer = Clarabel.Optimizer, rtol = 1e-3) + baseline = + PCC.jsr_bound(native, graph, problem; optimizer = Clarabel.Optimizer, rtol = 1e-3) + @test PCC.is_feasible(conic) + @test PCC.is_feasible(baseline) + @test abs(conic.rate - baseline.rate) <= 2e-3 + @test all(>=(-1e-6), PCC.edge_slacks(conic)) + V = only(PCC.functions(conic)) + @test V isa SetProg.Sets.Piecewise + @test V.polytope === partition + @test conic([1.0, 1.0]) > 0 + for map in A, θ in range(0, 2π; length = 97) + x = [cos(θ), sin(θ)] + value = PCC.node_value(problem, V, x) + @test value > 0 + @test conic.rate * value - PCC.node_value(problem, V, map * x) >= -1e-5 + end + # Higher-order partitions also return meaningful edge slacks. + diagonal = PCC.StabilityProblem(PCC.switched_system([[0.5 0.0; 0.0 0.25]])) + refined = PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(2; library = LIB)]) + certificate = PCC.certify( + refined, + PCC.de_bruijn(1, 1), + diagonal; + optimizer = Clarabel.Optimizer, + rate = 0.9, + ) + @test PCC.is_feasible(certificate) + @test all(>=(-1e-6), PCC.edge_slacks(certificate)) + infeasible = PCC.certify( + refined, + PCC.de_bruijn(1, 1), + diagonal; + optimizer = Clarabel.Optimizer, + rate = 0.4, + ) + @test !PCC.is_feasible(infeasible) +end + +end diff --git a/test/problems/copositive.jl b/test/problems/copositive.jl new file mode 100644 index 0000000..1cc9404 --- /dev/null +++ b/test/problems/copositive.jl @@ -0,0 +1,206 @@ +module TestLinearCopositive + +using Test +using LinearAlgebra +using SetProg +import PathCompleteCertificates as PCC +import HiGHS + +const OPTIMIZER = optimizer_with_attributes(HiGHS.Optimizer, MOI.Silent() => true) +const A = [[0.5 0.1; 0.0 0.25], [0.3 0.0; 0.1 0.4]] +const SYSTEM = PCC.switched_system(A) +const PROBLEM = PCC.StabilityProblem(SYSTEM) +const GRAPH = PCC.common_graph(2) +const FAMILY = PCC.LinearCopositiveTemplate() + +@testset "Copositive compatibility constructors" begin + @test FAMILY isa SetProg.Domain{<:SetProg.Polytope} + @test FAMILY.domain === MOI.Nonnegatives + @test FAMILY.dimension === nothing + @test PCC.homogeneity_degree(FAMILY) == 1 + @test PCC._with_dimension(FAMILY, 2).dimension == 2 + @test_throws DimensionMismatch PCC._with_dimension( + Domain(Polytope(symmetric = true), MOI.Nonnegatives(3)), + 2, + ) + fitted = PCC.LinearCopositiveFunction([2.0, 3.0]) + @test fitted isa SetProg.Sets.Intersection + @test fitted.set isa SetProg.Sets.PolarPoint + @test fitted.set.a == [2.0, 3.0] + @test PCC.node_value(PROBLEM, fitted, [1.0, 1.0]) == 5 + @test PCC.node_value(PROBLEM, fitted, [2.0, 2.0]) == 10 + @test PCC.node_value(PROBLEM, fitted, [-1.0, 1.0]) == Inf + @test PCC.is_applicable(FAMILY, SYSTEM) + negative = PCC.switched_system([[-0.1 0.0; 0.0 0.25]]) + @test !PCC.is_applicable(FAMILY, negative) + @test PCC.is_closed_under(FAMILY, PCC.Addition(), SYSTEM) + @test !PCC.is_closed_under(FAMILY, PCC.Maximum(), SYSTEM) + @test PCC.is_closed_under(FAMILY, PCC.Composition(), SYSTEM) + @test !PCC.is_closed_under(FAMILY, PCC.InverseComposition(), SYSTEM) + diagonal = PCC.switched_system([[0.5 0.0; 0.0 0.25]]) + @test PCC.is_closed_under(FAMILY, PCC.InverseComposition(), diagonal) + @test_throws ArgumentError PCC.certify( + FAMILY, + PCC.common_graph(1), + PCC.StabilityProblem(negative); + optimizer = OPTIMIZER, + rate = 0.6, + ) +end + +@testset "Copositive normalization and edge inequalities" begin + for (rate, feasible) in ((0.6, true), (0.4, false)) + certificate = PCC.certify(FAMILY, GRAPH, PROBLEM; optimizer = OPTIMIZER, rate) + @test PCC.is_feasible(certificate) == feasible + feasible || continue + V = only(PCC.functions(certificate)) + @test all(>=(1 - 1e-7), V.set.a) + @test certificate([1.0, 1.0]) >= 2 - 1e-7 + @test certificate([-1.0, 1.0]) == Inf + @test all(>=(-1e-7), PCC.edge_slacks(certificate)) + for map in A, x in ([1.0, 0.0], [0.0, 1.0], [0.5, 2.0]) + @test rate * certificate(x) - certificate(map * x) >= -1e-7 + end + for (edge, slack) in zip(PCC.edges(GRAPH), PCC.edge_slacks(certificate)) + map = A[only(PCC.letters(PCC.label(GRAPH, edge)))] + @test slack ≈ minimum(rate * V.set.a - map' * V.set.a) + end + @test PCC.decrease_slack(FAMILY, V, V, [-0.5 0.0; 0.0 0.25]) == -Inf + end + bound = PCC.jsr_bound(FAMILY, GRAPH, PROBLEM; optimizer = OPTIMIZER, rtol = 1e-3) + @test PCC.is_feasible(bound) + @test abs(bound.rate - 0.5) <= 1e-3 + @test all(>=(-1e-6), PCC.edge_slacks(bound)) + graph = PCC.de_bruijn(1, 2) + per_node = fill(FAMILY, PCC.n_nodes(graph)) + certificate = PCC.certify(per_node, graph, PROBLEM; optimizer = OPTIMIZER, rate = 0.6) + @test PCC.is_feasible(certificate) + @test length(PCC.functions(certificate)) == 2 + @test all(>=(-1e-7), PCC.edge_slacks(certificate)) + @test certificate([1.0, 1.0]) >= 2 - 1e-7 +end + +@testset "Copositive native variable helpers" begin + for (map, feasible) in ((zeros(2, 2), true), (Matrix{Float64}(I, 2, 2), false)) + model = Model(OPTIMIZER) + S = PCC.add_function_variables!(model, FAMILY, 2, 1) + @test S isa SetProg.SetVariableRef + PCC.add_normalization!(model, S.set, S) + PCC.add_decrease!(model, S.set, S, S, map; scale = 0) + optimize!(model) + @test termination_status(model) == (feasible ? MOI.OPTIMAL : MOI.INFEASIBLE) + if feasible + @test PCC.solution_value(FAMILY, S) isa SetProg.Sets.Intersection + end + end + model = Model(OPTIMIZER) + S = PCC.add_function_variables!(model, FAMILY, 2, 1) + @test_throws ArgumentError PCC.add_normalization!( + model, + S.set, + S; + conditioning_bound = 10, + ) + @test_throws ArgumentError PCC.add_decrease!(model, S.set, S, S, A[1]; scale = -1) +end + +@testset "Dual copositive normalization selects dual modeling" begin + for min_weight in (1.0, 3.0), rate in (0.6, 0.4) + family = PCC.DualCopositiveTemplate(; min_weight) + model = PCC.optimization_model(family, GRAPH, PROBLEM; optimizer = OPTIMIZER, rate) + optimize!(model) + @test termination_status(model) == (rate == 0.6 ? MOI.OPTIMAL : MOI.INFEASIBLE) + @test SetProg.data(model).space == SetProg.DualSpace + @test PCC.has_dual(family) + @test PCC.dual(PCC.dual(family)) == family + if rate == 0.6 + V = value(only(model[:stability_V])) + @test V isa SetProg.Sets.PolarOf{<:PCC._LinearCopositiveSet} + @test all(>=(min_weight - 1e-7), polar(V).set.a) + @test PCC.node_value(PROBLEM, V, [1.0, 1.0]) > 0 + @test PCC.node_value(PROBLEM, V, [2.0, 2.0]) ≈ + 2 * PCC.node_value(PROBLEM, V, [1.0, 1.0]) + @test PCC.decrease_slack(family, V, V, A[1]; scale = rate) >= -1e-7 + dual_family = PCC.dual(family) + dual_model = PCC.optimization_model( + dual_family, + PCC.dual(GRAPH), + PCC.dual(PROBLEM); + optimizer = OPTIMIZER, + rate, + ) + optimize!(dual_model) + @test termination_status(dual_model) == MOI.OPTIMAL + @test SetProg.data(dual_model).space == SetProg.PrimalSpace + @test all(>=(min_weight - 1e-7), value(only(dual_model[:stability_V])).set.a) + end + end + @test hash(PCC.DualCopositiveTemplate(min_weight = 1)) == + hash(PCC.DualCopositiveTemplate(min_weight = 1.0)) + @test_throws ArgumentError PCC.DualCopositiveTemplate(min_weight = 0) + @test_throws ArgumentError PCC.DualCopositiveTemplate(min_weight = Inf) + families = [PCC.DualCopositiveTemplate(min_weight = w) for w in (2, 3)] + graph = PCC.de_bruijn(1, 2) + c = PCC.certify(families, graph, PROBLEM; optimizer = OPTIMIZER, rate = 0.8) + @test PCC.is_feasible(c) + @test all(>=(-1e-7), PCC.edge_slacks(c)) + @test all(>=(-1e-7), PCC.edge_slacks(PCC.dual(c))) + @test PCC.template(PCC.dual(PCC.dual(c))) == families + @test PCC.dual(PCC.LinearCopositiveTemplate()) == PCC.DualCopositiveTemplate() + for family in (PCC.LinearCopositiveTemplate(), PCC.DualCopositiveTemplate()) + c = PCC.certify(family, GRAPH, PROBLEM; optimizer = OPTIMIZER, rate = 0.8) + @test PCC.is_feasible(c) + dual = PCC.dual(c) + @test all(>=(-1e-7), PCC.edge_slacks(dual)) + back = PCC.dual(dual) + @test PCC.template(back) == family + @test back([1.0, 1.0]) ≈ c([1.0, 1.0]) + end +end + +@testset "Parallel faces retain orientations, symmetry, and minimum weights" begin + for n in (2, 3) + G = Matrix{Float64}(I, n, n) + G[1, 2] = 0.5 + family = only(PCC.ParallelFacesPolyhedralTemplate([G]; min_weight = 2)) + problem = + PCC.StabilityProblem(PCC.switched_system([0.5 * Matrix{Float64}(I, n, n)])) + graph = PCC.common_graph(1) + @test family.set isa SetProg.Polytope + @test family.set.piecewise isa Polyhedra.Polyhedron + @test !PCC.has_dual(family) + for (rate, feasible) in ((0.6, true), (0.4, false)) + c = PCC.certify(family, graph, problem; optimizer = OPTIMIZER, rate) + @test PCC.is_feasible(c) == feasible + feasible || continue + V = only(PCC.functions(c)) + @test V isa SetProg.Sets.PolarOf{<:PCC._LinearPolytope} + inner = polar(V) + w = [PCC._polytope_value(inner, G[i, :]) for i in 1:n] + @test all(>=(2 - 1e-7), w) + for x in (ones(n), collect(1.0:n), -ones(n)) + v = PCC.node_value(problem, V, x) + @test v ≈ maximum(abs.(G * x) ./ w) + @test v ≈ PCC.node_value(problem, V, -x) + end + @test all(>=(-1e-7), PCC.edge_slacks(c)) + end + end + matrices = [[1.0 0.5; 0.0 1.0], [2.0 0.0; 0.0 1.0]] + families = PCC.ParallelFacesPolyhedralTemplate(matrices) + graph = PCC.de_bruijn(1, 2) + c = PCC.certify(families, graph, PROBLEM; optimizer = OPTIMIZER, rate = 0.9) + @test PCC.is_feasible(c) + @test all(>=(-1e-7), PCC.edge_slacks(c)) + @test !PCC.is_closed_under(families, PCC.Maximum(), SYSTEM) + @test PCC.is_closed_under(fill(first(families), 2), PCC.Maximum(), SYSTEM) + @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate([zeros(2, 2)]) + @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(1, 0) + @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(1, 2; min_weight = 0) + @test_throws DimensionMismatch PCC.ParallelFacesPolyhedralTemplate([ + ones(2, 2), + ones(3, 3), + ]) +end + +end diff --git a/test/problems/dual.jl b/test/problems/dual.jl index 429da9d..d42e2e2 100644 --- a/test/problems/dual.jl +++ b/test/problems/dual.jl @@ -21,7 +21,8 @@ edgeset(g) = Set( ) # The numbers a fitted node function is made of, whatever the template. -coefficients(V::SetProg.Sets.Ellipsoid) = Matrix(V) +coefficients(V::SetProg.Sets.Ellipsoid) = V.Q +coefficients(V::SetProg.Sets.PolarPoint) = V.a coefficients(V::SetProg.Sets.Intersection) = coefficients(V.set) coefficients(V::SetProg.Sets.Polar) = coefficients(SetProg.polar(V)) coefficients(V::SetProg.Sets.Piecewise) = reduce(vcat, [s.a for s in V.sets]) diff --git a/test/problems/gauges.jl b/test/problems/gauges.jl new file mode 100644 index 0000000..5e669f5 --- /dev/null +++ b/test/problems/gauges.jl @@ -0,0 +1,157 @@ +module TestGauges +using Test, LinearAlgebra, SetProg +using SumOfSquares, DynamicPolynomials +import PathCompleteCertificates as PCC +import Clarabel, HiGHS + +@testset "Homogeneous gauges" begin + A = [0.5 0.0; 0.0 0.25] + families = ( + Ellipsoid(symmetric = true), + PCC.LinearCopositiveTemplate(), + PCC.DualCopositiveTemplate(), + only(PCC.ParallelFacesPolyhedralTemplate(1, 2)), + only(PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(1)])), + ) + @polyvar x[1:2] + families = ( + families..., + PolySet(symmetric = true, degree = 4, variables = x), + PolySet(symmetric = true, degree = 2, variables = x, convex = true), + ) + problem = PCC.StabilityProblem(PCC.switched_system([A])) + for family in families, rate in (0.6, 0.4) + optimizer = + family isa Union{Ellipsoid, PolySet} ? Clarabel.Optimizer : HiGHS.Optimizer + model = Model(optimizer_with_attributes(optimizer, MOI.Silent() => true)) + S = PCC.add_function_variables!(model, family, 2, 1) + PCC.add_normalization!(model, S.set, S) + PCC.add_decrease!( + model, + family, + S, + S, + A; + scale = rate^PCC.homogeneity_degree(family), + ) + optimize!(model) + if rate > 0.5 + @test termination_status(model) in (MOI.OPTIMAL, MOI.ALMOST_OPTIMAL) + fitted = PCC.solution_value(family, S) + @test PCC.node_value(problem, fitted, [1.0, 1.0]) > 0 + @test PCC.node_value(problem, fitted, [2.0, 2.0]) ≈ + 2^PCC.homogeneity_degree(family) * + PCC.node_value(problem, fitted, [1.0, 1.0]) + if family isa PolySet + @test_throws ArgumentError PCC.decrease_slack(family, fitted, fitted, A) + else + @test PCC.decrease_slack( + family, + fitted, + fitted, + A; + scale = rate^PCC.homogeneity_degree(family), + ) >= -1e-6 + end + else + @test termination_status(model) in (MOI.INFEASIBLE, MOI.ALMOST_INFEASIBLE) + end + end +end + +@testset "Gauge data and duality" begin + @test_throws ArgumentError PCC.DualCopositiveTemplate(min_weight = 0) + @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate([zeros(2, 2)]) + @test_throws ArgumentError PCC.ConicPolyhedralTemplate([]) + @test_throws DimensionMismatch PCC._with_dimension( + only(PCC.ParallelFacesPolyhedralTemplate(1, 2)), + 3, + ) + @test PCC._supports_dual(Ellipsoid(symmetric = true)) + @test PCC._dual_family(PCC.LinearCopositiveTemplate()) == PCC.DualCopositiveTemplate() + @test PCC._dual_family(PCC.DualCopositiveTemplate()) == PCC.LinearCopositiveTemplate() + family = PCC.DualCopositiveTemplate(min_weight = 2) + @test PCC._dual_family(PCC._dual_family(family)) == family + @test PCC.homogeneity_degree([ + PCC.DualCopositiveTemplate(), + PCC.LinearCopositiveTemplate(), + ]) == 1 + ell = SetProg.Sets.Ellipsoid(Symmetric([2.0 0.0; 0.0 3.0])) + @test PCC._dual_value(PCC._dual_value(ell)).Q ≈ ell.Q + simplex = PCC.LinearCopositiveFunction([2.0, 3.0]) + @test PCC._dual_value(PCC._dual_value(simplex)).set.a == simplex.set.a +end + +@testset "Gauge normalization and edge directions" begin + @polyvar x[1:2] + family = PCC._with_dimension(PolySet(symmetric = true, degree = 6), 2; variables = x) + @test family.variables == x + @test PCC._gauge(PCC._unit_set(family), [2.0, 3.0]) ≈ 13^3 + @test_throws DimensionMismatch PCC._with_dimension( + PolySet(symmetric = true, degree = 4, variables = x), + 3, + ) + @test_throws ArgumentError PCC._with_dimension(Ellipsoid(), 2) + @test_throws ArgumentError PCC.homogeneity_degree(SetProg.AbstractVariable[]) + @test_throws ArgumentError PCC.homogeneity_degree([ + Ellipsoid(symmetric = true), + PCC.DualCopositiveTemplate(), + ]) + @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(0, 2) + @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(1, 2; min_weight = 0) + model = Model(optimizer_with_attributes(Clarabel.Optimizer, MOI.Silent() => true)) + S = PCC.add_function_variables!(model, Ellipsoid(symmetric = true), 2, 1) + PCC.add_normalization!(model, S.set, S; conditioning_bound = 10) + PCC.add_decrease!(model, S.set, S, S, zeros(2, 2); scale = 0) + optimize!(model) + @test termination_status(model) in (MOI.OPTIMAL, MOI.ALMOST_OPTIMAL) + Q = value(S).Q + @test minimum(eigvals(Q)) >= 1 - 1e-6 + @test maximum(eigvals(Q)) <= 10 + 1e-6 + @test_throws ArgumentError PCC.add_normalization!( + model, + S.set, + S; + conditioning_bound = 0.5, + ) + @test_throws ArgumentError PCC.add_decrease!( + model, + S.set, + S, + S, + zeros(2, 2); + scale = -1, + ) + model = Model(optimizer_with_attributes(HiGHS.Optimizer, MOI.Silent() => true)) + B = PCC.add_function_variables!(model, PCC.DualCopositiveTemplate(), 2, 1) + @test_throws ArgumentError PCC.add_normalization!( + model, + B.set, + B; + conditioning_bound = 10, + ) + PCC.add_normalization!(model, B.set, B) + PCC.add_decrease!(model, B.set, B, B, Matrix{Float64}(I, 2, 2); scale = 0) + optimize!(model) + @test termination_status(model) == MOI.INFEASIBLE + simplex = PCC.LinearCopositiveFunction([2.0, 3.0]) + box = PCC.DualCopositiveFunction([4.0, 5.0]) + @test PCC.decrease_slack( + PCC.LinearCopositiveTemplate(), + simplex, + PCC.LinearCopositiveFunction([1.0, 2.0]), + [0.5 0.2; 0.0 0.5], + ) ≈ 1.5 + @test PCC.decrease_slack( + PCC.DualCopositiveTemplate(), + box, + PCC.DualCopositiveFunction([3.0, 4.0]), + [0.5 0.2; 0.0 0.5], + ) ≈ 0 + G = [2.0 0.0; 0.0 1.0] + parallelotope = PCC.ParallelFacesPolyhedralFunction(G, [1.0, 4.0]) + @test PCC._gauge(parallelotope, [3.0, 2.0]) ≈ 6 + @test !PCC._supports_dual(only(PCC.ParallelFacesPolyhedralTemplate([G]))) +end + +end diff --git a/test/problems/sets.jl b/test/problems/sets.jl index 162fd2e..d887cff 100644 --- a/test/problems/sets.jl +++ b/test/problems/sets.jl @@ -74,7 +74,9 @@ function test_edges(certificate, template, graph) point = [cos(θ), sin(θ)] src = V[PCC.source(edge)] dst = V[PCC.dest(edge)] - @test scale * src(point) - dst(A[PCC.label(graph, edge)] * point) >= -1e-5 + @test scale * PCC.node_value(PROBLEM, src, point) - + PCC.node_value(PROBLEM, dst, A[PCC.label(graph, edge)] * point) >= + -1e-5 end end @@ -102,8 +104,10 @@ const ELLIPSOID = rate(Ellipsoid(; symmetric = true)) V = only(PCC.functions(ELLIPSOID)) @test V isa SetProg.Sets.Ellipsoid # V(cx) = c² V(x), and the normalization `V ≥ ‖x‖²` - @test V([2.0, 3.0]) ≈ 4 * V([1.0, 1.5]) rtol = 1e-6 - @test V([1.0, 1.0]) >= 2 - 1e-6 + @test PCC.node_value(PROBLEM, V, [2.0, 3.0]) ≈ + 4 * PCC.node_value(PROBLEM, V, [1.0, 1.5]) rtol = + 1e-6 + @test PCC.node_value(PROBLEM, V, [1.0, 1.0]) >= 2 - 1e-6 test_edges(ELLIPSOID, Ellipsoid(symmetric = true), MEMORYLESS) end @@ -223,7 +227,8 @@ end family = PolySet(symmetric = true, degree = 4) certificate = PCC.certify(family, graph, diagonal; optimizer = OPTIMIZER, rate = 0.6) @test PCC.is_feasible(certificate) - @test only(PCC.functions(certificate))([1.0, 1.0]) >= 4 - 1e-5 + @test PCC.node_value(diagonal, only(PCC.functions(certificate)), [1.0, 1.0]) >= + 4 - 1e-5 @polyvar y[1:2] different = [PolySet(symmetric = true, degree = 4, variables = z) for z in (x, y)] @test_throws ArgumentError PCC.optimization_model( @@ -284,8 +289,9 @@ end PCC.add_decrease!(model, family, S, S, A; scale = 0.6) JuMP.optimize!(model) fitted = PCC.solution_value(family, S) - @test fitted([2.0, 0.0]) <= 1 + 1e-6 - @test fitted(A * [2.0, 1.0]) <= 0.6 * fitted([2.0, 1.0]) + 1e-6 + @test PCC.node_value(PROBLEM, fitted, [2.0, 0.0]) <= 1 + 1e-6 + @test PCC.node_value(PROBLEM, fitted, A * [2.0, 1.0]) <= + 0.6 * PCC.node_value(PROBLEM, fitted, [2.0, 1.0]) + 1e-6 system = PCC.switched_system([A]) @test PCC.is_closed_under(PCC.LinearCopositiveTemplate(), PCC.Composition(), system) @test PCC.is_closed_under( @@ -295,10 +301,10 @@ end ) end -@testset "compatibility constructors return native families" begin - @test PCC.DualCopositiveTemplate() isa SetProg.Polytope - @test PCC.LinearCopositiveTemplate() isa SetProg.Polytope - @test only(PCC.ParallelFacesPolyhedralTemplate(1, 2)) isa SetProg.Polytope +@testset "compatibility constructors use native families" begin + @test PCC.DualCopositiveTemplate().set isa SetProg.Domain + @test PCC.LinearCopositiveTemplate() isa SetProg.Domain + @test only(PCC.ParallelFacesPolyhedralTemplate(1, 2)).set isa SetProg.Polytope @test only(PCC.ConicPolyhedralTemplate([PCC.planar_conic_partition(1)])) isa SetProg.Polytope @test length(PCC.ParallelFacesPolyhedralTemplate(2, 2)) == 2 @@ -307,8 +313,14 @@ end @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(0, 2) @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(Matrix{Float64}[]) @test_throws ArgumentError PCC.planar_conic_partition(0) - @test PCC.DualCopositiveFunction([2.0, 3.0])([1.0, 1.0]) ≈ 0.5 - @test PCC.LinearCopositiveFunction([2.0, 3.0])([1.0, 1.0]) ≈ 5 + @test PCC.node_value(PROBLEM, + PCC.DualCopositiveFunction([2.0, 3.0]), + [1.0, 1.0], + ) ≈ 0.5 + @test PCC.node_value(PROBLEM, + PCC.LinearCopositiveFunction([2.0, 3.0]), + [1.0, 1.0], + ) ≈ 5 end end diff --git a/test/runtests.jl b/test/runtests.jl index 384f41d..901114a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -28,6 +28,7 @@ const TEST_FILES = [ ("./systems/simulate.jl",), ("./problems/sets.jl", :slow), ("./problems/conic_polyhedral.jl", :slow), + ("./problems/copositive.jl", :slow), ("./problems/gauges.jl", :slow), ("./problems/certify.jl", :slow), ("./problems/tight_edges.jl", :slow), From 0611ae59b50a1fb843b1689a191080d3a44955db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Fri, 16 Oct 2026 15:51:37 +0200 Subject: [PATCH 06/18] SetProg#main --- Project.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Project.toml b/Project.toml index fc3956e..dc39779 100644 --- a/Project.toml +++ b/Project.toml @@ -29,3 +29,6 @@ SumOfSquares = "4b9e565b-77fc-50a5-a571-1244f986bda1" [extensions] PathCompleteCertificatesSumOfSquaresExt = "SumOfSquares" PathCompleteCertificatesRecipesBaseExt = "RecipesBase" + +[sources] +SetProg = {url = "https://github.com/blegat/SetProg.jl", rev = "main"} From 8fd5264cf9cc503a8f35d5238d6eef194d9bde9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Mon, 19 Oct 2026 11:12:49 +0200 Subject: [PATCH 07/18] FIX meta: honor SetProg main in CI Align the compatibility bound with the 0.4.4 version currently declared by SetProg main. Install the declared source explicitly on Julia 1.10, which does not support Project.toml source overrides. Verified dependency resolution and Pkg.build in fresh isolated environments on Julia 1.10.12 and 1.13.2; both track SetProg main at version 0.4.4. --- .github/workflows/ci.yml | 8 ++++++++ Project.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec30ecf..85adbb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,14 @@ jobs: version: ${{ matrix.version }} arch: ${{ matrix.arch }} - uses: julia-actions/cache@v3 + # Julia 1.10 does not read Project.toml's source overrides. + - name: Install SetProg source on Julia 1.10 + if: matrix.version == '1.10' + shell: julia --project=. {0} + run: | + using Pkg, TOML + source = TOML.parsefile("Project.toml")["sources"]["SetProg"] + Pkg.add(PackageSpec(; name = "SetProg", url = source["url"], rev = source["rev"])) - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 with: diff --git a/Project.toml b/Project.toml index dc39779..d7d55ed 100644 --- a/Project.toml +++ b/Project.toml @@ -18,7 +18,7 @@ LinearAlgebra = "1" MathematicalSystems = "0.14" Random = "1" RecipesBase = "1" -SetProg = "0.4.5" +SetProg = "0.4.4" SumOfSquares = "0.8" julia = "1.10" From af3c26f9e3b9eea2500e3ac7b24f6597588499fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Mon, 19 Oct 2026 11:38:34 +0200 Subject: [PATCH 08/18] FIX test: require Clarabel 0.11 --- test/Project.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/Project.toml b/test/Project.toml index edf0683..17f5cdb 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -13,5 +13,8 @@ SetProg = "39881422-4b75-5582-a5c7-0feb14562a65" SumOfSquares = "4b9e565b-77fc-50a5-a571-1244f986bda1" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +[compat] +Clarabel = "0.11" + [sources] PathCompleteCertificates = {path = ".."} From e8c2aab7823290a6ee33adc4895bcda1868d3be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Mon, 19 Oct 2026 13:38:32 +0200 Subject: [PATCH 09/18] FIX test: evaluate native sets in tight edge checks --- test/problems/tight_edges.jl | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/test/problems/tight_edges.jl b/test/problems/tight_edges.jl index dff1d27..44772c5 100644 --- a/test/problems/tight_edges.jl +++ b/test/problems/tight_edges.jl @@ -10,6 +10,8 @@ import PathCompleteCertificates as PCC import Clarabel import LinearAlgebra +# Keep bisection above the solver tolerance when testing tightness at 1e-4. +const BISECTION_RTOL = 1e-6 const OPTIMIZER = Clarabel.Optimizer # rho(A) = 0.5 exactly, and the quadratic template attains it, so the single @@ -62,16 +64,11 @@ end end @testset "slack agrees with the inequality it measures" begin - certificate = PCC.certify( - SetProg.Ellipsoid(symmetric = true), - GRAPH, - PROBLEM; - optimizer = OPTIMIZER, - rate = 0.8, - ) + template = SetProg.Ellipsoid(symmetric = true) + certificate = PCC.certify(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rate = 0.8) V = PCC.functions(certificate) - scale = 0.8^PCC.homogeneity_degree(SetProg.Ellipsoid(symmetric = true)) + scale = 0.8^PCC.homogeneity_degree(template) for (edge, slack) in zip(PCC.edges(GRAPH), PCC.edge_slacks(certificate)) src = V[PCC.source(edge)] @@ -83,7 +80,8 @@ end # Slack ≥ 0 is exactly the edge inequality holding, sampled. for x in ([1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, -2.0]) - @test scale * src(x) - dst(map * x) >= -1e-7 + @test scale * PCC.node_value(template, PROBLEM, src, x) - + PCC.node_value(template, PROBLEM, dst, map * x) >= -1e-7 end end end @@ -161,7 +159,7 @@ const TWO_NODES = PCC.de_bruijn(1, 2) GRAPH, PROBLEM; optimizer = OPTIMIZER, - rtol = 1e-8, + rtol = BISECTION_RTOL, ) subgraph = PCC.tight_subgraph(certificate; atol = 1e-4) @@ -182,7 +180,7 @@ end TWO_NODES, ROTATION_PROBLEM; optimizer = OPTIMIZER, - rtol = 1e-8, + rtol = BISECTION_RTOL, ) # Both modes are rotations of norm 0.9, so every cycle gives exactly 0.9 and @@ -206,7 +204,7 @@ end TWO_NODES, ROTATION_PROBLEM; optimizer = OPTIMIZER, - rtol = 1e-8, + rtol = BISECTION_RTOL, ) # Every edge is tight, so every node has two tight outgoing edges and the @@ -221,7 +219,7 @@ end GRAPH, PROBLEM; optimizer = OPTIMIZER, - rtol = 1e-8, + rtol = BISECTION_RTOL, ) @test PCC.is_jsr_exact(single; atol = 1e-4) @@ -235,7 +233,7 @@ end GRAPH, PROBLEM; optimizer = OPTIMIZER, - rtol = 1e-8, + rtol = BISECTION_RTOL, ) @test PCC.is_jsr_exact(single; atol = 1e-4) @test PCC.optimality_gap(single; atol = 1e-4) == 0 @@ -246,7 +244,7 @@ end TWO_NODES, ROTATION_PROBLEM; optimizer = OPTIMIZER, - rtol = 1e-8, + rtol = BISECTION_RTOL, ) gap = PCC.optimality_gap(rotations; atol = 1e-4) @test gap == rotations.rate - PCC.jsr_lower_bound(rotations; atol = 1e-4) @@ -277,7 +275,7 @@ end GRAPH, PROBLEM; optimizer = OPTIMIZER, - rtol = 1e-8, + rtol = BISECTION_RTOL, ) @test !PCC.is_jsr_exact(single; atol = -1.0) end From aeb3c9c9f2c1fad2db0826951ebf2dc462a14286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Mon, 19 Oct 2026 15:35:58 +0200 Subject: [PATCH 10/18] FIX problems: reuse SetProg normalization remainders --- src/problems/gauges.jl | 34 +++++++++++++++++++++++++++++++--- test/problems/gauges.jl | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/problems/gauges.jl b/src/problems/gauges.jl index ef1248c..2828b87 100644 --- a/src/problems/gauges.jl +++ b/src/problems/gauges.jl @@ -165,6 +165,30 @@ function _unit_set(f::_NormalizedFamily) return f.reference end +# Use SetProg's PSD/SOS remainder to enforce normalization at construction. +_with_superset(::SetProg.AbstractVariable, unit) = nothing +function _with_superset(f::SetProg.Ellipsoid, unit) + # A bound on a cone need not hold globally for its quadratic piece. + (f.piecewise !== nothing || f.superset !== nothing) && return nothing + return SetProg.Ellipsoid(; + symmetric = f.symmetric, + dimension = f.dimension, + superset = unit, + ) +end +function _with_superset(f::SetProg.PolySet, unit) + # SetProg does not support a superset for convex polynomial families. + (f.convex || f.superset !== nothing) && return nothing + return SetProg.PolySet(; + symmetric = f.symmetric, + degree = f.degree, + dimension = f.dimension, + variables = f.variables, + basis = f.basis, + superset = unit, + ) +end + """ add_function_variables!(model, family, dimension, node) @@ -183,8 +207,9 @@ end """ add_normalization!(model, family, S; conditioning_bound = Inf) -Exclude the zero gauge by inclusion with a fixed reference set. The inclusion -direction and representation let SetProg select the modeling space. +Exclude the zero gauge with a fixed reference set. Ellipsoid and polynomial +families use SetProg's `superset` construction when supported; other families +use an inclusion constraint. A finite ellipsoid conditioning bound also requires `Q ≤ conditioning_bound * LinearAlgebra.I`. """ @@ -196,7 +221,10 @@ function add_normalization!( ) conditioning_bound >= 1 || throw(ArgumentError("conditioning_bound must be at least 1")) unit = _unit_set(S.set) - if family isa _NormalizedFamily && family.contains_reference + normalized = _with_superset(S.set, unit) + if normalized !== nothing + S.set = normalized + elseif family isa _NormalizedFamily && family.contains_reference JuMP.@constraint(model, unit ⊆ S) else JuMP.@constraint(model, S ⊆ unit) diff --git a/test/problems/gauges.jl b/test/problems/gauges.jl index 5e669f5..0e2f40e 100644 --- a/test/problems/gauges.jl +++ b/test/problems/gauges.jl @@ -59,6 +59,42 @@ import Clarabel, HiGHS end end +@testset "Normalization uses a native PSD or SOS remainder" begin + @polyvar x[1:2] + for (family, dimension) in ( + (Ellipsoid(symmetric = true), 3), + (PolySet(symmetric = true, degree = 4, variables = x), 2), + ) + model = Model(optimizer_with_attributes(Clarabel.Optimizer, MOI.Silent() => true)) + S = PCC.add_function_variables!(model, family, dimension, 1) + original = S.set + PCC.add_normalization!(model, original, S) + @test S.set.superset !== nothing + @test original.superset === nothing + @test isempty(SetProg.data(model).constraints) + optimize!(model) + @test termination_status(model) in (MOI.OPTIMAL, MOI.ALMOST_OPTIMAL) + # Only the remainder is constrained; there is no second PSD/SOS cone. + @test num_constraints(model; count_variable_in_set_constraints = true) == 1 + point = ones(dimension) + @test PCC._gauge(value(S), point) >= + PCC._gauge(PCC._unit_set(original), point) - 1e-6 + end + # Keep normalization over each cone for piecewise ellipsoids; convex + # polynomial families do not support the superset construction. + partition = PCC.planar_conic_partition(1) + for family in ( + Ellipsoid(symmetric = true, piecewise = partition), + PolySet(symmetric = true, degree = 4, convex = true, variables = x), + ) + model = Model() + S = PCC.add_function_variables!(model, family, 2, 1) + PCC.add_normalization!(model, S.set, S) + @test S.set.superset === nothing + @test length(SetProg.data(model).constraints) == 1 + end +end + @testset "Gauge data and duality" begin @test_throws ArgumentError PCC.DualCopositiveTemplate(min_weight = 0) @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate([zeros(2, 2)]) From 26ba6ce9576c8d344535e75627e53ed07ab52aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Tue, 20 Oct 2026 14:41:13 +0200 Subject: [PATCH 11/18] Remvoe ci checkout --- .github/workflows/ci.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85adbb2..ec30ecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,14 +35,6 @@ jobs: version: ${{ matrix.version }} arch: ${{ matrix.arch }} - uses: julia-actions/cache@v3 - # Julia 1.10 does not read Project.toml's source overrides. - - name: Install SetProg source on Julia 1.10 - if: matrix.version == '1.10' - shell: julia --project=. {0} - run: | - using Pkg, TOML - source = TOML.parsefile("Project.toml")["sources"]["SetProg"] - Pkg.add(PackageSpec(; name = "SetProg", url = source["url"], rev = source["rev"])) - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 with: From e108024a2d0fc4c6ef1eb90885c60795c8c08db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Tue, 20 Oct 2026 14:57:09 +0200 Subject: [PATCH 12/18] REF problems: preserve the quadratic compatibility API --- CLAUDE.md | 7 +- docs/src/developers/examples.md | 3 +- docs/src/examples/comparing.jl | 9 +-- docs/src/examples/refinement.jl | 3 +- docs/src/examples/stability.jl | 21 ++---- docs/src/examples/templates_and_graphs.jl | 8 +-- docs/src/manual/comparing.md | 2 +- docs/src/reference/templates.md | 6 +- src/problems/catalogue/stability.jl | 77 ++++++++++++++++++++++ src/templates/abstract.jl | 5 +- src/templates/catalogue/quadratic.jl | 8 +-- test/lifts/compare.jl | 3 +- test/lifts/composition.jl | 19 +----- test/lifts/identity.jl | 3 +- test/lifts/min.jl | 1 - test/lifts/product.jl | 5 +- test/lifts/split.jl | 5 +- test/problems/certificate.jl | 79 +---------------------- test/problems/certify.jl | 16 ++--- test/problems/common.jl | 63 ++++++------------ test/problems/dual.jl | 6 +- test/problems/stability.jl | 38 +++++------ test/problems/tight_edges.jl | 41 ++++++------ test/refinements/refine.jl | 3 +- 24 files changed, 189 insertions(+), 242 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3430e32..f381d18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,10 @@ Stability uses SetProg families directly, or vectors with one family per node. SetProg is a direct dependency; polynomial support is its optional SumOfSquares -extension. The legacy quadratic catalogue remains only for safety and optimal -control, whose migration is deferred. SetProg implements set variables and -inclusion constraints. Certificate primitives live in `problems/gauges.jl`; +extension. `QuadraticTemplate` forwards stability modeling to SetProg and keeps +the fitted quadratic functions for compatibility. The legacy quadratic models +remain for safety and optimal control, whose migration is deferred. SetProg +implements set variables and inclusion constraints. Certificate primitives live in `problems/gauges.jl`; applicability, graph closures, and reindexing live in `problems/sets.jl`. The legacy template architecture described below applies to deferred problems. diff --git a/docs/src/developers/examples.md b/docs/src/developers/examples.md index 15fad27..aea5ef0 100644 --- a/docs/src/developers/examples.md +++ b/docs/src/developers/examples.md @@ -13,7 +13,6 @@ docs build, so a broken example fails the build. import PathCompleteCertificates as PCC import Clarabel -import SetProg # Prose is a `# ` comment between code blocks. @@ -23,7 +22,7 @@ problem = PCC.StabilityProblem(PCC.switched_system(A)) # The last expression of a block is what the page shows, so end a block with # the value worth seeing rather than printing it. -PCC.jsr_bound(SetProg.Ellipsoid(symmetric = true), PCC.de_bruijn(1, 1), problem; optimizer = Clarabel.Optimizer) +PCC.jsr_bound(PCC.QuadraticTemplate(), PCC.de_bruijn(1, 1), problem; optimizer = Clarabel.Optimizer) ``` - The `# # Title` line is the sidebar entry; `docs/make.jl` reads it from the diff --git a/docs/src/examples/comparing.jl b/docs/src/examples/comparing.jl index 9f88590..f1276ba 100644 --- a/docs/src/examples/comparing.jl +++ b/docs/src/examples/comparing.jl @@ -8,8 +8,6 @@ # that justify each answer, and compares them with certified rates. import PathCompleteCertificates as PCC -import SetProg -import LinearAlgebra import Clarabel using HybridSystems using Plots @@ -122,7 +120,7 @@ A = [[1.5519 0.44737; 7.6412 7.4716], [0.47501 9.1755; 1.8955 0.18502]] system = PCC.switched_system(A) problem = PCC.StabilityProblem(system) -QUADRATIC = SetProg.Ellipsoid(; symmetric = true) +QUADRATIC = PCC.QuadraticTemplate() COPOSITIVE = PCC.LinearCopositiveTemplate() DUAL_COPOSITIVE = PCC.DualCopositiveTemplate() TEMPLATES = (QUADRATIC, COPOSITIVE, DUAL_COPOSITIVE) @@ -181,9 +179,8 @@ bound(template, g) = PCC.jsr_bound( on_G1 = bound(QUADRATIC, G1) V = PCC.functions(on_G1) W = [ - SetProg.Sets.Ellipsoid( - LinearAlgebra.Symmetric(sum(C[h, g] * Matrix(V[g]) for g in eachindex(V))), - ) for h in 1:PCC.n_nodes(G2) + PCC.QuadraticFunction(sum(C[h, g] * Matrix(V[g]) for g in eachindex(V))) for + h in 1:PCC.n_nodes(G2) ] scale = on_G1.rate^PCC.homogeneity_degree(QUADRATIC) diff --git a/docs/src/examples/refinement.jl b/docs/src/examples/refinement.jl index c6615ca..f2fe70f 100644 --- a/docs/src/examples/refinement.jl +++ b/docs/src/examples/refinement.jl @@ -7,12 +7,11 @@ # Bruijn graph of the same size. import PathCompleteCertificates as PCC -import SetProg import Clarabel using Plots const OPTIMIZER = Clarabel.Optimizer -const TEMPLATE = SetProg.Ellipsoid(; symmetric = true) +const TEMPLATE = PCC.QuadraticTemplate() # A rotation and a shear. No single quadratic function attains the joint # spectral radius of this pair, so the bound on the one-node graph is not diff --git a/docs/src/examples/stability.jl b/docs/src/examples/stability.jl index 6a19b57..f9648a2 100644 --- a/docs/src/examples/stability.jl +++ b/docs/src/examples/stability.jl @@ -4,7 +4,6 @@ # joint spectral radius, with the node functions that prove it. import PathCompleteCertificates as PCC -import SetProg import Clarabel const OPTIMIZER = Clarabel.Optimizer @@ -27,20 +26,10 @@ problem = PCC.StabilityProblem(PCC.switched_system(A)) # [`is_feasible`](@ref) says whether the problem was feasible: here at `0.6` # and not at `0.4`, which is below the spectral radius. -at_six_tenths = PCC.certify( - SetProg.Ellipsoid(; symmetric = true), - graph, - problem; - optimizer = OPTIMIZER, - rate = 0.6, -) -at_four_tenths = PCC.certify( - SetProg.Ellipsoid(; symmetric = true), - graph, - problem; - optimizer = OPTIMIZER, - rate = 0.4, -) +at_six_tenths = + PCC.certify(PCC.QuadraticTemplate(), graph, problem; optimizer = OPTIMIZER, rate = 0.6) +at_four_tenths = + PCC.certify(PCC.QuadraticTemplate(), graph, problem; optimizer = OPTIMIZER, rate = 0.4) PCC.is_feasible(at_six_tenths), PCC.is_feasible(at_four_tenths) @@ -51,7 +40,7 @@ PCC.is_feasible(at_six_tenths), PCC.is_feasible(at_four_tenths) # on this graph. result = PCC.jsr_bound( - SetProg.Ellipsoid(; symmetric = true), + PCC.QuadraticTemplate(), graph, problem; optimizer = OPTIMIZER, diff --git a/docs/src/examples/templates_and_graphs.jl b/docs/src/examples/templates_and_graphs.jl index ff366f6..b45d20e 100644 --- a/docs/src/examples/templates_and_graphs.jl +++ b/docs/src/examples/templates_and_graphs.jl @@ -49,7 +49,7 @@ rotation_graph = PCC.de_bruijn(1, 1) rotation_problem = PCC.StabilityProblem(PCC.switched_system(ROTATION)) templates = [ - ("quadratic", SetProg.Ellipsoid(; symmetric = true)), + ("quadratic", PCC.QuadraticTemplate()), ("polyhedral, fixed facets", PCC.ParallelFacesPolyhedralTemplate(1, 2)), ( "polyhedral, free facets", @@ -110,7 +110,7 @@ right = plot(; for (name, graph) in graphs certificate = PCC.jsr_bound( - SetProg.Ellipsoid(; symmetric = true), + PCC.QuadraticTemplate(), graph, mixed_problem; optimizer = OPTIMIZER, @@ -150,12 +150,12 @@ one_node = PCC.common_graph(mixed_problem.system) two_nodes = PCC.de_bruijn(1, 2) [ - ("quadratic, 1 node", bound(SetProg.Ellipsoid(; symmetric = true), one_node)), + ("quadratic, 1 node", bound(PCC.QuadraticTemplate(), one_node)), ( "degree 4, 1 node", bound(SetProg.PolySet(; symmetric = true, degree = 2 * 2, variables = x), one_node), ), - ("quadratic, 2 nodes", bound(SetProg.Ellipsoid(; symmetric = true), two_nodes)), + ("quadratic, 2 nodes", bound(PCC.QuadraticTemplate(), two_nodes)), ( "degree 4, 2 nodes", bound( diff --git a/docs/src/manual/comparing.md b/docs/src/manual/comparing.md index 8615a8c..c8df93d 100644 --- a/docs/src/manual/comparing.md +++ b/docs/src/manual/comparing.md @@ -78,7 +78,7 @@ both ([Duality](@ref)). ## `compare` ```julia -c = compare(G, H; templates = (SetProg.Ellipsoid(symmetric = true),), system, optimizer) +c = compare(G, H; templates = (QuadraticTemplate(),), system, optimizer) lifts(c) # the lifts tried, in order simulations(c) # lift => simulation, for each lift whose lifted G simulates H templates(c) # the templates given for which H is proved no worse than G diff --git a/docs/src/reference/templates.md b/docs/src/reference/templates.md index cdbab91..616158b 100644 --- a/docs/src/reference/templates.md +++ b/docs/src/reference/templates.md @@ -21,7 +21,11 @@ Modules = [PathCompleteCertificates] Pages = ["problems/sets.jl", "problems/gauges.jl", "templates/abstract.jl"] ``` -## Legacy quadratic template for safety and optimal control +## Quadratic compatibility template + +`QuadraticTemplate()` delegates stability modeling to SetProg while preserving +the existing fitted quadratic functions. Safety and optimal control retain +their implementations. ```@autodocs Modules = [PathCompleteCertificates] diff --git a/src/problems/catalogue/stability.jl b/src/problems/catalogue/stability.jl index 65bdb1d..5e3e792 100644 --- a/src/problems/catalogue/stability.jl +++ b/src/problems/catalogue/stability.jl @@ -376,3 +376,80 @@ function jsr_lower_bound( return best end + +# Compatibility with the quadratic catalogue used by safety and optimal control. +# Stability models are built entirely by the native SetProg family. +function optimization_model( + template::QuadraticTemplate, + graph::CertificateGraph, + problem::StabilityProblem; + conditioning_bound = template.conditioning_bound, + kwargs..., +) + return optimization_model( + SetProg.Ellipsoid(; symmetric = true), + graph, + problem; + conditioning_bound, + kwargs..., + ) +end + +function _quadratic_certificate( + template::QuadraticTemplate, + certificate::StabilityCertificate, +) + data = CertificateData( + problem(certificate), + template, + graph(certificate), + [QuadraticFunction(V.Q) for V in functions(certificate)], + status(certificate), + is_feasible(certificate), + ) + return StabilityCertificate(data, certificate.rate) +end + +function certify( + template::QuadraticTemplate, + graph::CertificateGraph, + problem::StabilityProblem; + conditioning_bound = template.conditioning_bound, + kwargs..., +) + certificate = certify( + SetProg.Ellipsoid(; symmetric = true), + graph, + problem; + conditioning_bound, + kwargs..., + ) + return _quadratic_certificate(template, certificate) +end + +function jsr_bound( + template::QuadraticTemplate, + graph::CertificateGraph, + problem::StabilityProblem; + conditioning_bound = template.conditioning_bound, + kwargs..., +) + certificate = jsr_bound( + SetProg.Ellipsoid(; symmetric = true), + graph, + problem; + conditioning_bound, + kwargs..., + ) + return _quadratic_certificate(template, certificate) +end + +function best_certificate( + template::QuadraticTemplate, + graph::CertificateGraph, + problem::StabilityProblem; + rtol::Real = 1e-6, + kwargs..., +) + return jsr_bound(template, graph, problem; rtol, kwargs...) +end diff --git a/src/templates/abstract.jl b/src/templates/abstract.jl index 9bb978e..a88f849 100644 --- a/src/templates/abstract.jl +++ b/src/templates/abstract.jl @@ -1,9 +1,8 @@ """ AbstractTemplate -A legacy family of candidate node functions for safety and optimal control, -passed as an instance such as `QuadraticTemplate()`. Stability accepts SetProg -set families directly. +A family of candidate node functions, passed as an instance such as +`QuadraticTemplate()`. Stability also accepts SetProg set families directly. """ abstract type AbstractTemplate end diff --git a/src/templates/catalogue/quadratic.jl b/src/templates/catalogue/quadratic.jl index 376bdd0..177cb24 100644 --- a/src/templates/catalogue/quadratic.jl +++ b/src/templates/catalogue/quadratic.jl @@ -1,12 +1,12 @@ """ QuadraticTemplate(; conditioning_bound = Inf) -Legacy quadratic node functions for safety and optimal control. Stability -uses `SetProg.Ellipsoid(symmetric = true)` directly instead. +Quadratic node functions for stability, safety and optimal control. Stability +uses SetProg's native ellipsoid model and returns the legacy fitted functions. Safety evaluates the quadratic in homogeneous coordinates and permits an indefinite coefficient matrix; optimal control uses positive quadratic forms. -The `conditioning_bound` parameter is retained for the legacy normalization -primitive. +A finite `conditioning_bound` also imposes `P ≤ conditioning_bound * I` in +stability, which can exclude every certificate of a stable system. """ struct QuadraticTemplate{T <: Real} <: AbstractTemplate diff --git a/test/lifts/compare.jl b/test/lifts/compare.jl index 4d387d7..707e2f4 100644 --- a/test/lifts/compare.jl +++ b/test/lifts/compare.jl @@ -4,7 +4,6 @@ module TestCompare # HSCC 2023 (Code Ocean 0480330): five graphs on two modes, one positive # system, and the rates its main script prints. -import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -86,7 +85,7 @@ const A_STAR = [[1.5519 0.44737; 7.6412 7.4716], [0.47501 9.1755; 1.8955 0.18502 const SYSTEM = PCC.switched_system(A_STAR) const PROBLEM = PCC.StabilityProblem(SYSTEM) -const QUADRATIC = SetProg.Ellipsoid(; symmetric = true) +const QUADRATIC = PCC.QuadraticTemplate() const COPOSITIVE = PCC.LinearCopositiveTemplate() const DUAL_COPOSITIVE = PCC.DualCopositiveTemplate() const TEMPLATES = (QUADRATIC, COPOSITIVE, DUAL_COPOSITIVE) diff --git a/test/lifts/composition.jl b/test/lifts/composition.jl index 2c4996f..99d63e7 100644 --- a/test/lifts/composition.jl +++ b/test/lifts/composition.jl @@ -1,6 +1,5 @@ module TestLiftComposition -import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -51,21 +50,9 @@ end @test PCC._is_invertible(invertible) @test !PCC._is_invertible(singular) - @test PCC.is_valid( - PCC.CompositionLift(1), - SetProg.Ellipsoid(symmetric = true), - invertible, - ) - @test !PCC.is_valid( - PCC.CompositionLift(1), - SetProg.Ellipsoid(symmetric = true), - singular, - ) - @test PCC.is_valid( - PCC.BackwardCompositionLift(1), - SetProg.Ellipsoid(symmetric = true), - invertible, - ) + @test PCC.is_valid(PCC.CompositionLift(1), PCC.QuadraticTemplate(), invertible) + @test !PCC.is_valid(PCC.CompositionLift(1), PCC.QuadraticTemplate(), singular) + @test PCC.is_valid(PCC.BackwardCompositionLift(1), PCC.QuadraticTemplate(), invertible) @test !PCC.is_valid( PCC.CompositionLift(1), PCC.ParallelFacesPolyhedralTemplate(1, 2), diff --git a/test/lifts/identity.jl b/test/lifts/identity.jl index 6b9481a..97aa1a4 100644 --- a/test/lifts/identity.jl +++ b/test/lifts/identity.jl @@ -1,6 +1,5 @@ module TestLiftIdentity -import SetProg using Test import PathCompleteCertificates as PCC @@ -20,7 +19,7 @@ import PathCompleteCertificates as PCC system = PCC.switched_system([[0.5 0.0; 0.0 0.5], [0.0 0.5; 0.5 0.0]]) for template in ( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), PCC.ParallelFacesPolyhedralTemplate(1, 2), PCC.LinearCopositiveTemplate(), ) diff --git a/test/lifts/min.jl b/test/lifts/min.jl index 2b68671..ed27266 100644 --- a/test/lifts/min.jl +++ b/test/lifts/min.jl @@ -1,6 +1,5 @@ module TestLiftMin -import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC diff --git a/test/lifts/product.jl b/test/lifts/product.jl index 5ebb590..bb8108f 100644 --- a/test/lifts/product.jl +++ b/test/lifts/product.jl @@ -1,6 +1,5 @@ module TestLiftProduct -import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -119,14 +118,14 @@ end expanded = PCC.expanded_form(w) on_words = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), w, problem; optimizer = Clarabel.Optimizer, rtol = 1e-5, ) on_letters = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), expanded, problem; optimizer = Clarabel.Optimizer, diff --git a/test/lifts/split.jl b/test/lifts/split.jl index 835e1f2..60444d5 100644 --- a/test/lifts/split.jl +++ b/test/lifts/split.jl @@ -1,6 +1,5 @@ module TestLiftSplit -import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -328,8 +327,8 @@ end @test moved == [template[o] for o in PCC.origins(lifted)] # A data-free template is its own reindexing. - @test PCC.reindex(SetProg.Ellipsoid(symmetric = true), PCC.origins(lifted)) == - SetProg.Ellipsoid(symmetric = true) + @test PCC.reindex(PCC.QuadraticTemplate(), PCC.origins(lifted)) == + PCC.QuadraticTemplate() end end # module diff --git a/test/problems/certificate.jl b/test/problems/certificate.jl index 96ac682..520ed21 100644 --- a/test/problems/certificate.jl +++ b/test/problems/certificate.jl @@ -4,7 +4,6 @@ module TestCertificate # accessed through methods and stored once in `CertificateData`; what a problem # certifies is a typed field on its own certificate type. -import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -22,7 +21,7 @@ const SYSTEM = PCC.switched_system([[0.5 0.0; 0.0 0.25]]) const PROBLEM = PCC.StabilityProblem(SYSTEM) const STABILITY = PCC.jsr_bound( - SetProg.Ellipsoid(; symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -43,77 +42,5 @@ end @testset "a certificate is callable, and is the common function" begin x = [1.0, 2.0] - @test STABILITY(x) ≈ PCC.common(GRAPH, PROBLEM, PCC.functions(STABILITY), x) - - # Positive away from the origin, zero at it -- it is a Lyapunov function. - @test STABILITY(x) > 0 - @test STABILITY([0.0, 0.0]) ≈ 0 atol = 1e-8 -end - -@testset "every problem returns the same shape" begin - S0 = [-1.0 0.0 0.0; 0.0 -1.0 0.0; 0.0 0.0 16.0] - Su = [1.0 0.0 0.0; 0.0 1.0 0.0; 0.0 0.0 -36.0] - modes = [[0.7 0.77; -0.49 0.84], [0.7 0.77; -0.49 0.56]] - - safety = PCC.certify( - PCC.QuadraticTemplate(), - PCC.de_bruijn(1, 2), - PCC.SafetyProblem(PCC.switched_system(modes), S0, Su); - optimizer = OPTIMIZER, - ) - - control = PCC.certify( - PCC.QuadraticTemplate(), - PCC.de_bruijn(1, 2), - PCC.OptimalControlProblem( - PCC.switched_system( - [[0.0 1.0; -1.0 0.0], [-0.1 0.0; 0.0 -0.95]], - [[1.0; 0.0;;], [1.0; 0.0;;]], - ), - [1.0 0.0; 0.0 1.0], - [1.0;;], - ); - optimizer = JuMP.optimizer_with_attributes( - Clarabel.Optimizer, - "chordal_decomposition_enable" => false, - ), - ) - - for certificate in (STABILITY, safety, control) - @test certificate isa PCC.AbstractCertificate - @test PCC.is_feasible(certificate) - @test !isempty(PCC.functions(certificate)) - @test PCC.status(certificate) isa JuMP.MOI.TerminationStatusCode - @test certificate([1.0, 1.0]) isa Real - end - - # What each problem certifies is a typed field on its own type, not an - # entry in a shared bag -- so it is discoverable and documented. - @test STABILITY isa PCC.StabilityCertificate - @test safety isa PCC.SafetyCertificate - @test control isa PCC.OptimalControlCertificate - - @test STABILITY.rate > 0 - @test safety.margin > 0 - @test length(control.gains) == PCC.n_nodes(PCC.graph(control)) -end - -@testset "an infeasible solve still returns a certificate" begin - # Rather than a differently-shaped tuple with `nothing` fields. - overlapping = [1.0 0.0 0.0; 0.0 1.0 0.0; 0.0 0.0 -1.0] - S0 = [-1.0 0.0 0.0; 0.0 -1.0 0.0; 0.0 0.0 16.0] - modes = [[0.7 0.77; -0.49 0.84], [0.7 0.77; -0.49 0.56]] - - res = PCC.certify( - PCC.QuadraticTemplate(), - PCC.de_bruijn(1, 2), - PCC.SafetyProblem(PCC.switched_system(modes), S0, overlapping); - optimizer = OPTIMIZER, - ) - - @test res isa PCC.SafetyCertificate - @test !PCC.is_feasible(res) - @test_throws ArgumentError res([1.0, 2.0]) -end - -end + @test STABILITY(x) ≈ + PCC.common(GRAPH, PROBLEM, PCC.functions(STABILITY), x) diff --git a/test/problems/certify.jl b/test/problems/certify.jl index 1ac03ac..390c07a 100644 --- a/test/problems/certify.jl +++ b/test/problems/certify.jl @@ -19,7 +19,7 @@ const GRAPH = PCC.de_bruijn(1, 1) @testset "certify at a fixed rate" begin loose = PCC.certify( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -34,7 +34,7 @@ end @testset "an infeasible rate yields a certificate, not an error" begin tight = PCC.certify( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -56,12 +56,12 @@ end @test !PCC.is_feasible(tight) @test isempty(PCC.functions(tight)) - @test !any(f -> f isa SetProg.Sets.Ellipsoid, PCC.functions(tight)) + @test !any(f -> f isa PCC.QuadraticFunction, PCC.functions(tight)) end @testset "jsr_bound agrees with certify at the rate it returns" begin bound = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -73,7 +73,7 @@ end # The bisection ends in exactly this solve. direct = PCC.certify( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -84,7 +84,7 @@ end @testset "optimization_model builds without solving" begin model = PCC.optimization_model( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; rate = 0.9, @@ -121,7 +121,7 @@ end # The common graph is accepted ... model = PCC.optimization_model( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), PCC.common_graph(problem.system), problem; rate = 1.0, @@ -135,7 +135,7 @@ end add_transition!(partial, 1, 1, mode) end @test_throws ArgumentError PCC.optimization_model( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), partial, problem; rate = 1.0, diff --git a/test/problems/common.jl b/test/problems/common.jl index e60cef8..d2f812c 100644 --- a/test/problems/common.jl +++ b/test/problems/common.jl @@ -1,6 +1,5 @@ module TestCommon -import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -10,50 +9,30 @@ const A = [[0.5 0.0; 0.0 0.25], [0.25 0.0; 0.0 0.5]] const PROBLEM = PCC.StabilityProblem(PCC.switched_system(A)) const X = [1.0, 2.0] -const P1 = SetProg.Sets.Ellipsoid(LinearAlgebra.Symmetric([1.0 0.0; 0.0 1.0])) -const P2 = SetProg.Sets.Ellipsoid(LinearAlgebra.Symmetric([3.0 0.0; 0.0 3.0])) +const P1 = PCC.QuadraticFunction(LinearAlgebra.Symmetric([1.0 0.0; 0.0 1.0])) +const P2 = PCC.QuadraticFunction(LinearAlgebra.Symmetric([3.0 0.0; 0.0 3.0])) const VS = [P1, P2] -value(V) = PCC.node_value(PROBLEM, V, X) -@testset "a complete graph combines with a minimum" begin - graph = PCC.de_bruijn(1, 2) - @test PCC.is_complete(graph) - - @test PCC.common(graph, PROBLEM, VS, X) ≈ minimum(value(P) for P in VS) -end - -@testset "a co-complete graph combines with a maximum" begin - graph = PCC.dual(PCC.de_bruijn(1, 2)) - @test PCC.is_co_complete(graph) - - @test PCC.common(graph, PROBLEM, VS, X) ≈ maximum(value(P) for P in VS) + weights = [ + PCC.ParallelFacesPolyhedralFunction( + Matrix{Float64}(LinearAlgebra.I, 2, 2), + [1.0, 1.0], + ), + PCC.ParallelFacesPolyhedralFunction( + Matrix{Float64}(LinearAlgebra.I, 2, 2), + [4.0, 4.0], + ), + ] + + @test PCC.common(graph, + PROBLEM, + weights, + X, + ) ≈ minimum( + PCC.node_value(PROBLEM, V, X) for + V in weights + ) end -@testset "any other graph combines over the observer construction" begin - # Node 2 has no label-2 edge, so this graph is neither complete nor - # co-complete and `common` must fall back to the observer construction -- - # a max within each observer state and a min across them. - graph = GraphAutomaton(2) - add_transition!(graph, 1, 1, 1) - add_transition!(graph, 1, 2, 2) - add_transition!(graph, 2, 1, 1) - - @test !PCC.is_complete(graph) - @test !PCC.is_co_complete(graph) - - _, states = PCC.observer_graph(graph) - @test states == [Set([1, 2]), Set([1]), Set([2])] - - expected = minimum(maximum(value(VS[node]) for node in state) for state in states) - - @test PCC.common(graph, PROBLEM, VS, X) ≈ expected end - -@testset "the common function is generic in the template" begin - graph = PCC.de_bruijn(1, 2) - linear = PCC.LinearCopositiveFunction.([[1.0, 1.0], [2.0, 2.0]]) - - @test PCC.common(graph, PROBLEM, linear, X) ≈ minimum( - PCC.node_value(PROBLEM, V, X) for V in linear - ) diff --git a/test/problems/dual.jl b/test/problems/dual.jl index d42e2e2..be634b2 100644 --- a/test/problems/dual.jl +++ b/test/problems/dual.jl @@ -21,7 +21,7 @@ edgeset(g) = Set( ) # The numbers a fitted node function is made of, whatever the template. -coefficients(V::SetProg.Sets.Ellipsoid) = V.Q +coefficients(V::PCC.QuadraticFunction) = Matrix(V) coefficients(V::SetProg.Sets.PolarPoint) = V.a coefficients(V::SetProg.Sets.Intersection) = coefficients(V.set) coefficients(V::SetProg.Sets.Polar) = coefficients(SetProg.polar(V)) @@ -36,7 +36,7 @@ const POSITIVE = PCC.switched_system([ const GENERAL = PCC.switched_system([[0.8 0.3; -0.2 0.5], [0.4 -0.6; 0.5 0.3]]) const CASES = [ - (SetProg.Ellipsoid(; symmetric = true), GENERAL), + (PCC.QuadraticTemplate(), GENERAL), (PCC.LinearCopositiveTemplate(), POSITIVE), (PCC.DualCopositiveTemplate(), POSITIVE), ] @@ -178,7 +178,7 @@ end end @testset "a template without a dual says so" begin - @test PCC.has_dual(SetProg.Ellipsoid(symmetric = true)) + @test PCC.has_dual(PCC.QuadraticTemplate()) @test PCC.has_dual(PCC.LinearCopositiveTemplate()) @test PCC.has_dual(PCC.DualCopositiveTemplate()) @test !PCC.has_dual(PCC.ParallelFacesPolyhedralTemplate(1, 2)) diff --git a/test/problems/stability.jl b/test/problems/stability.jl index 020e965..fd273ef 100644 --- a/test/problems/stability.jl +++ b/test/problems/stability.jl @@ -1,7 +1,6 @@ module TestStability -import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -30,7 +29,7 @@ const OPTIMIZER = Clarabel.Optimizer @test_throws ArgumentError PCC.is_feasible( PCC.certify( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), mode_1_only, problem; optimizer = OPTIMIZER, @@ -39,7 +38,7 @@ const OPTIMIZER = Clarabel.Optimizer ) @test_throws ArgumentError PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), mode_1_only, problem; optimizer = OPTIMIZER, @@ -61,7 +60,7 @@ end @test_throws ArgumentError PCC.is_feasible( PCC.certify( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), mode_1_only, problem; optimizer = OPTIMIZER, @@ -73,7 +72,7 @@ end # which is exactly the risk the caller has taken on. @test PCC.is_feasible( PCC.certify( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), mode_1_only, problem; optimizer = OPTIMIZER, @@ -101,7 +100,7 @@ end @test PCC.is_path_complete(graph, 1:2) result = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), graph, problem; optimizer = OPTIMIZER, @@ -115,7 +114,7 @@ end @testset "quadratic stability" begin @test PCC.is_feasible( PCC.certify( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -125,7 +124,7 @@ end @test !PCC.is_feasible( PCC.certify( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -134,7 +133,7 @@ end ) result = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -199,12 +198,12 @@ end @testset "every template agrees on the rate" begin # The point of `homogeneity_degree`: the same system and graph, three templates # of two different degrees, one answer. - @test PCC.homogeneity_degree(SetProg.Ellipsoid(symmetric = true)) == 2 + @test PCC.homogeneity_degree(PCC.QuadraticTemplate()) == 2 @test PCC.homogeneity_degree(PCC.LinearCopositiveTemplate()) == 1 @test PCC.homogeneity_degree(PCC.ParallelFacesPolyhedralTemplate(2, 2)) == 1 bounds = map(( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), PCC.LinearCopositiveTemplate(), PCC.ParallelFacesPolyhedralTemplate(PCC.n_nodes(GRAPH), 2), )) do template @@ -230,7 +229,7 @@ end problem = PCC.StabilityProblem(PCC.switched_system([stretched])) default = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), graph, problem; optimizer = OPTIMIZER, @@ -240,7 +239,7 @@ end @test abs(default.rate - 0.9) <= 0.01 @test PCC.is_feasible( PCC.certify( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), graph, problem; optimizer = OPTIMIZER, @@ -251,24 +250,17 @@ end # The cap still available, and still conservative -- which is the point of # making the caller ask for it. capped = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(; conditioning_bound = 100), graph, problem; optimizer = OPTIMIZER, - conditioning_bound = 100, rtol = 1e-3, ) @test capped.rate > default.rate + 0.1 @test capped.rate >= 0.9 # still a sound upper bound - @test_throws ArgumentError PCC.certify( - SetProg.Ellipsoid(symmetric = true), - graph, - problem; - optimizer = OPTIMIZER, - conditioning_bound = 0.5, - ) + @test_throws ArgumentError PCC.QuadraticTemplate(; conditioning_bound = 0.5) end @testset "input validation" begin @@ -276,7 +268,7 @@ end add_transition!(invalid_label_graph, 1, 1, 2) @test_throws ArgumentError PCC.optimization_model( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), invalid_label_graph, PROBLEM; rate = 1.0, diff --git a/test/problems/tight_edges.jl b/test/problems/tight_edges.jl index 44772c5..73f271d 100644 --- a/test/problems/tight_edges.jl +++ b/test/problems/tight_edges.jl @@ -3,7 +3,6 @@ module TestTightEdges # Slack is the quantity a refinement step reads: zero means the edge holds the # bound up, and a node with two tight outgoing edges is the one to split. -import SetProg using Test using HybridSystems import PathCompleteCertificates as PCC @@ -22,7 +21,7 @@ const GRAPH = PCC.de_bruijn(1, 1) @testset "slack is nonnegative where the certificate holds" begin loose = PCC.certify( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -41,7 +40,7 @@ end @testset "the bound is attained exactly where an edge goes tight" begin bound = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -64,31 +63,35 @@ end end @testset "slack agrees with the inequality it measures" begin - template = SetProg.Ellipsoid(symmetric = true) - certificate = PCC.certify(template, GRAPH, PROBLEM; optimizer = OPTIMIZER, rate = 0.8) + certificate = PCC.certify( + PCC.QuadraticTemplate(), + GRAPH, + PROBLEM; + optimizer = OPTIMIZER, + rate = 0.8, + ) V = PCC.functions(certificate) - scale = 0.8^PCC.homogeneity_degree(template) + scale = 0.8^PCC.homogeneity_degree(PCC.QuadraticTemplate()) for (edge, slack) in zip(PCC.edges(GRAPH), PCC.edge_slacks(certificate)) src = V[PCC.source(edge)] dst = V[PCC.dest(edge)] map = A[PCC.label(GRAPH, edge)] - residual = scale * src.Q - transpose(map) * dst.Q * map + residual = scale * src.P - transpose(map) * dst.P * map @test slack ≈ minimum(LinearAlgebra.eigvals(LinearAlgebra.Symmetric(residual))) # Slack ≥ 0 is exactly the edge inequality holding, sampled. for x in ([1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, -2.0]) - @test scale * PCC.node_value(template, PROBLEM, src, x) - - PCC.node_value(template, PROBLEM, dst, map * x) >= -1e-7 + @test scale * src(x) - dst(map * x) >= -1e-7 end end end @testset "every template answers decrease_slack" begin quadratic = PCC.certify( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -127,7 +130,7 @@ end @testset "an infeasible certificate has nothing to measure" begin tight = PCC.certify( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -155,7 +158,7 @@ const TWO_NODES = PCC.de_bruijn(1, 2) @testset "the tight subgraph keeps the nodes and drops the slack edges" begin certificate = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -176,7 +179,7 @@ end @testset "a cycle bounds the JSR from below, whatever atol was" begin certificate = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), TWO_NODES, ROTATION_PROBLEM; optimizer = OPTIMIZER, @@ -200,7 +203,7 @@ end @testset "Theorem 4 is sufficient, not necessary" begin exact = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), TWO_NODES, ROTATION_PROBLEM; optimizer = OPTIMIZER, @@ -215,7 +218,7 @@ end # The one-mode system has a single tight self-loop, so the condition holds. single = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -229,7 +232,7 @@ end @testset "the optimality gap is zero when proved, the bracket otherwise" begin # Theorem 4 holds on the one-mode system: the gap is exactly zero. single = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, @@ -240,7 +243,7 @@ end # On the rotations it does not, and the gap is the cycle bracket. rotations = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), TWO_NODES, ROTATION_PROBLEM; optimizer = OPTIMIZER, @@ -259,7 +262,7 @@ end PCC.switched_system([0.69 .* [0.0 1.0; -1.0 0.0], 0.69 .* [1.0 1.0; 0.0 1.0]]), ) loose = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), PCC.common_graph(2), sheared; optimizer = OPTIMIZER, @@ -271,7 +274,7 @@ end # An exact certificate read with too small a tolerance: no tight cycle, so no claim. single = PCC.jsr_bound( - SetProg.Ellipsoid(symmetric = true), + PCC.QuadraticTemplate(), GRAPH, PROBLEM; optimizer = OPTIMIZER, diff --git a/test/refinements/refine.jl b/test/refinements/refine.jl index 80bc287..7019872 100644 --- a/test/refinements/refine.jl +++ b/test/refinements/refine.jl @@ -1,6 +1,5 @@ module TestRefine -import SetProg using Test using HybridSystems using Random @@ -8,7 +7,7 @@ import PathCompleteCertificates as PCC import Clarabel const OPTIMIZER = Clarabel.Optimizer -const TEMPLATE = SetProg.Ellipsoid(; symmetric = true) +const TEMPLATE = PCC.QuadraticTemplate() # A pure rotation scaled by 0.9: since rotations preserve the Euclidean norm # exactly, P = I is already the exact quadratic certificate, at exactly the From 48fc55f0ca12d905c0c589710f11daaeec0d0f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 21 Oct 2026 14:29:26 +0200 Subject: [PATCH 13/18] FIX docs: use the quadratic compatibility constructor --- docs/src/examples/set_prog.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/examples/set_prog.jl b/docs/src/examples/set_prog.jl index d28541b..1db6861 100644 --- a/docs/src/examples/set_prog.jl +++ b/docs/src/examples/set_prog.jl @@ -41,7 +41,7 @@ octagon = polygon([[2, 1], [1, 2], [-1, 2], [-2, 1], [-2, -1], [-1, -2], [1, -2] using SumOfSquares @polyvar x[1:2] families = [ - ("quadratic", Ellipsoid(; symmetric = true)), + ("quadratic", PCC.QuadraticTemplate()), ("degree 4", PolySet(; symmetric = true, degree = 4, variables = x)), ("polyhedral", Polytope(; symmetric = true, piecewise = octagon)), ] From ef648322344d49b3e1a60b42a0806544ea2a7b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 21 Oct 2026 14:31:07 +0200 Subject: [PATCH 14/18] Remove unused --- src/templates/abstract.jl | 12 ------------ src/templates/catalogue/quadratic.jl | 14 -------------- 2 files changed, 26 deletions(-) diff --git a/src/templates/abstract.jl b/src/templates/abstract.jl index a88f849..84b0fbd 100644 --- a/src/templates/abstract.jl +++ b/src/templates/abstract.jl @@ -53,18 +53,6 @@ problem refuses a template that does not, and no lift is valid for it. """ is_applicable(::AbstractTemplate, system) = true -# The node datum of `data` at `node`, or an error naming `what` when the -# template carries fewer than the graph has nodes. -function _node_datum(data::AbstractVector, node::Integer, what::AbstractString) - node <= length(data) || throw( - ArgumentError( - "the template carries $(length(data)) $what but the graph has at least " * - "$node nodes", - ), - ) - return data[node] -end - """ decrease_slack(template, V_src, V_dst, map; scale = 1) diff --git a/src/templates/catalogue/quadratic.jl b/src/templates/catalogue/quadratic.jl index 177cb24..f47f41a 100644 --- a/src/templates/catalogue/quadratic.jl +++ b/src/templates/catalogue/quadratic.jl @@ -57,20 +57,6 @@ solution_value(::QuadraticTemplate, P) = QuadraticFunction(JuMP.value.(P)) homogeneity_degree(::QuadraticTemplate) = 2 -function add_normalization!(model::JuMP.Model, template::QuadraticTemplate, P) - # The edge conditions are homogeneous, so this only fixes the scale. - JuMP.@constraint(model, P - LinearAlgebra.I in JuMP.PSDCone()) - - if isfinite(template.conditioning_bound) - JuMP.@constraint( - model, - template.conditioning_bound * LinearAlgebra.I - P in JuMP.PSDCone() - ) - end - - return nothing -end - function add_decrease!( model::JuMP.Model, ::QuadraticTemplate, From 6404e5a268cab156e01fe7ad4002e52e29df4b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 21 Oct 2026 14:33:19 +0200 Subject: [PATCH 15/18] FIX docs: finish restoring quadratic constructor examples --- docs/src/manual/templates.md | 103 +---------------------------------- 1 file changed, 3 insertions(+), 100 deletions(-) diff --git a/docs/src/manual/templates.md b/docs/src/manual/templates.md index d213513..c154bde 100644 --- a/docs/src/manual/templates.md +++ b/docs/src/manual/templates.md @@ -4,103 +4,6 @@ CurrentModule = PathCompleteCertificates # Templates -For stability, a template is a SetProg set family. Pass it directly to -[`certify`](@ref), [`jsr_bound`](@ref), [`compare`](@ref), or [`refine`](@ref). -[`functions`](@ref) returns fitted SetProg sets. Evaluate a node with -`node_value(problem, V, x)`, or the common Lyapunov function with -`certificate(x)`. - -```julia -using SetProg -family = Ellipsoid(symmetric = true) -certificate = PCC.jsr_bound(family, graph, problem; optimizer) -``` - -## Families - -| SetProg family | Node function | Degree | Program | -| :-- | :-- | :-: | :-- | -| `Ellipsoid(symmetric = true)` | `x'Qx` | 2 | semidefinite | -| `Domain(Polytope(symmetric = true), MOI.Nonnegatives)` | `c'x` on the nonnegative orthant | 1 | linear | -| `DualCopositiveTemplate()` | `maximum(x ./ v)` on the nonnegative orthant | 1 | linear | -| `ParallelFacesPolyhedralTemplate([G])` | `maximum(abs.(G*x) ./ w)` | 1 | linear | -| `PolySet(symmetric = true, degree = 2d)` | sum-of-squares polynomial | 2d | semidefinite | -| `Ellipsoid(symmetric = true, piecewise = partition)` | quadratic on each partition cone | 2 | semidefinite | -| `Polytope(symmetric = true, piecewise = partition)` | linear on each partition cone | 1 | linear | - -Load `SumOfSquares` before constructing a `PolySet`; otherwise SetProg gives -an error explaining the missing extension. Polynomial variables may be -supplied with `variables = x`, or inferred in a shared coordinate system. -SetProg is a direct dependency of PCC; its polynomial dependencies are optional. - -`Domain(Polytope(symmetric = true), MOI.Nonnegatives)` has one linear piece -restricted to the nonnegative orthant, with its dimension inferred from the -model. PCC normalizes its coefficients to be at least one using a fixed -unit-simplex inclusion. Partition polytopes describe the linear pieces' -regions through the cones over their facets, retaining facet adjacency. -A cross-polytope supplies the orthant partition for parallel-face families. -SetProg chooses the modeling space from the normalization constraints. - -The existing `LinearCopositiveTemplate`, `DualCopositiveTemplate`, -`ParallelFacesPolyhedralTemplate`, and `ConicPolyhedralTemplate` constructors -also remain available. Positive-orthant families require nonnegative dynamics. -`PCC.planar_conic_partition(order; library)` supplies a partition polytope. -Pass one polytope per node to `ConicPolyhedralTemplate`; separate cone matrices -are no longer accepted. Its `min_scale` keyword scales the partition gauge -used for normalization. -The other polyhedral compatibility constructors retain their coefficient-floor -keyword arguments. - -`DualCopositiveTemplate` and `ParallelFacesPolyhedralTemplate` retain their -compatibility constructors. A PCC normalization adapter stores the native -SetProg family and a fixed reference set. Requiring that reference to lie in -the variable selects dual modeling through SetProg's inclusion rules; -`min_weight` remains a lower bound on the box widths. Parallel-face families -also impose central symmetry using `-S ⊆ S`. No variable polarity option is -used, and normalization and gauge evaluation stay in PCC. - -The degree `p`, returned by [`homogeneity_degree`](@ref), satisfies -`V(c*x) = c^p * V(x)`. Stability uses `rate^p` in each edge inequality, -so every family's rate bounds the joint spectral radius. -Ellipsoids and polynomial sets are normalized against the Euclidean unit gauge. -A finite `conditioning_bound` keyword in `certify` or `jsr_bound` also bounds -an ellipsoid's matrix by `conditioning_bound * I`; the default is `Inf`. - -Clarabel solves semidefinite and linear models; HiGHS solves linear models. - -## Closure and duality - -Graph operations remain in PCC. [`is_closed_under`](@ref) declares a family's -closures; [`is_valid`](@ref) checks a lift, also using the dual family when available. - -| Family | Addition | Maximum | Composition with invertible dynamics | -| :-- | :-: | :-: | :-- | -| symmetric ellipsoid | ✓ | | ✓ | -| polynomial set | ✓ | | ✓ | -| positive simplex | ✓ | | if nonnegative with no zero column | -| positive box | | ✓ | | -| parallelotope | | ✓ when all node matrices coincide | | -| conic polytope | | | | -| piecewise ellipsoid or polytope | ✓ when the nodes share a partition | | | - -Symmetric nonpiecewise ellipsoids are self-dual. Positive simplices and boxes -are each other's blocking duals on the nonnegative orthant. Other families -have no fitted dual implementation here. See [Duality](@ref). - -## Per-node data - -A single family is shared by all nodes. A vector supplies one family per node: - -```julia -families = PCC.ParallelFacesPolyhedralTemplate([G1, G2]) -certificate = PCC.jsr_bound(families, graph, problem; optimizer) -``` - -[`reindex`](@ref) follows node origins through lifts. Copies retain their -origin's family, and merging nodes requires equal families. -All nodes must use gauges of the same degree. - -## Deferred problems - -Safety and optimal control still use [`QuadraticTemplate`](@ref) and their -existing fitted quadratic functions. Their migration to SetProg is deferred. +For stability, use PCC's compatibility constructors or a SetProg set family. +Pass the template to [`certify`](@ref), [`jsr_bound`](@ref), [`compare`](@ref), +or [`refine`](@ref). For example: From 25fdb89ad0c67664c4907c3e7eb815746693023d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 21 Oct 2026 15:10:52 +0200 Subject: [PATCH 16/18] ADD templates: restore SumOfSquaresTemplate shortcut --- docs/src/examples/set_prog.jl | 2 +- docs/src/examples/templates_and_graphs.jl | 22 ++-- docs/src/manual/templates.md | 102 ++++++++++++++++++ docs/src/reference/templates.md | 4 +- ...PathCompleteCertificatesSumOfSquaresExt.jl | 8 +- src/PathCompleteCertificates.jl | 1 + src/problems/gauges.jl | 8 +- src/templates/catalogue/sum_of_squares.jl | 20 ++++ test/problems/certificate.jl | 72 +++++++++++++ test/problems/common.jl | 45 ++++++++ test/problems/gauges.jl | 33 ++++++ 11 files changed, 292 insertions(+), 25 deletions(-) create mode 100644 src/templates/catalogue/sum_of_squares.jl diff --git a/docs/src/examples/set_prog.jl b/docs/src/examples/set_prog.jl index 1db6861..ba7f13f 100644 --- a/docs/src/examples/set_prog.jl +++ b/docs/src/examples/set_prog.jl @@ -42,7 +42,7 @@ using SumOfSquares @polyvar x[1:2] families = [ ("quadratic", PCC.QuadraticTemplate()), - ("degree 4", PolySet(; symmetric = true, degree = 4, variables = x)), + ("degree 4", PCC.SumOfSquaresTemplate(2, x)), ("polyhedral", Polytope(; symmetric = true, piecewise = octagon)), ] [(name, round(bound(family).rate; digits = 4)) for (name, family) in families] diff --git a/docs/src/examples/templates_and_graphs.jl b/docs/src/examples/templates_and_graphs.jl index b45d20e..4fa23b3 100644 --- a/docs/src/examples/templates_and_graphs.jl +++ b/docs/src/examples/templates_and_graphs.jl @@ -8,7 +8,6 @@ # function ``V``, evaluated by calling the certificate on ``x``. import PathCompleteCertificates as PCC -import SetProg import Clarabel using HybridSystems using SumOfSquares, DynamicPolynomials @@ -135,10 +134,10 @@ plot(left, right; layout = (1, 2), size = (1100, 620)) # ## Sums of squares # -# `SetProg.PolySet` takes polynomial node functions of higher -# degree ([parrilo2008approximation](@cite)). It is defined in a package -# extension loaded with SumOfSquares, and the polynomial variables are created -# once and shared by every node. Degree 2 is the quadratic template. The table +# [`SumOfSquaresTemplate`](@ref) takes polynomial node functions of higher +# degree ([parrilo2008approximation](@cite)). Load SumOfSquares before +# constructing it. The polynomial variables are created once and shared by +# every node. Degree 2 is the quadratic template. The table # gives the rates of degrees 2 and 4 on one node and on `de_bruijn(1, 2)`. @polyvar x[1:2] @@ -151,16 +150,7 @@ two_nodes = PCC.de_bruijn(1, 2) [ ("quadratic, 1 node", bound(PCC.QuadraticTemplate(), one_node)), - ( - "degree 4, 1 node", - bound(SetProg.PolySet(; symmetric = true, degree = 2 * 2, variables = x), one_node), - ), + ("degree 4, 1 node", bound(PCC.SumOfSquaresTemplate(2, x), one_node)), ("quadratic, 2 nodes", bound(PCC.QuadraticTemplate(), two_nodes)), - ( - "degree 4, 2 nodes", - bound( - SetProg.PolySet(; symmetric = true, degree = 2 * 2, variables = x), - two_nodes, - ), - ), + ("degree 4, 2 nodes", bound(PCC.SumOfSquaresTemplate(2, x), two_nodes)), ] diff --git a/docs/src/manual/templates.md b/docs/src/manual/templates.md index c154bde..e3dabe0 100644 --- a/docs/src/manual/templates.md +++ b/docs/src/manual/templates.md @@ -7,3 +7,105 @@ CurrentModule = PathCompleteCertificates For stability, use PCC's compatibility constructors or a SetProg set family. Pass the template to [`certify`](@ref), [`jsr_bound`](@ref), [`compare`](@ref), or [`refine`](@ref). For example: + +```julia +family = PCC.QuadraticTemplate() +certificate = PCC.jsr_bound(family, graph, problem; optimizer) +``` + +`QuadraticTemplate()` uses SetProg's ellipsoid model and returns fitted +`QuadraticFunction`s. Passing a native SetProg family instead returns fitted +SetProg sets. Evaluate either with `node_value(problem, V, x)`, or the +common Lyapunov function with `certificate(x)`. + +## Families + +| Template or SetProg family | Node function | Degree | Program | +| :-- | :-- | :-: | :-- | +| `QuadraticTemplate()` | `x'Qx` | 2 | semidefinite | +| `Domain(Polytope(symmetric = true), MOI.Nonnegatives)` | `c'x` on the nonnegative orthant | 1 | linear | +| `DualCopositiveTemplate()` | `maximum(x ./ v)` on the nonnegative orthant | 1 | linear | +| `ParallelFacesPolyhedralTemplate([G])` | `maximum(abs.(G*x) ./ w)` | 1 | linear | +| `SumOfSquaresTemplate(d, x)` | sum-of-squares polynomial | 2d | semidefinite | +| `Ellipsoid(symmetric = true, piecewise = partition)` | quadratic on each partition cone | 2 | semidefinite | +| `Polytope(symmetric = true, piecewise = partition)` | linear on each partition cone | 1 | linear | + +`SumOfSquaresTemplate(d, x; min_scale = 1)` returns a native +`PolySet(symmetric = true, degree = 2d, variables = x)` with its normalization +superset. `min_scale` preserves the lower bound on the Euclidean gauge. +Load `SumOfSquares` before constructing it or a `PolySet`; otherwise SetProg gives +an error explaining the missing extension. Polynomial variables may be +supplied with `variables = x`, or inferred in a shared coordinate system. +SetProg is a direct dependency of PCC; its polynomial dependencies are optional. + +`Domain(Polytope(symmetric = true), MOI.Nonnegatives)` has one linear piece +restricted to the nonnegative orthant, with its dimension inferred from the +model. PCC normalizes its coefficients to be at least one using a fixed +unit-simplex inclusion. Partition polytopes describe the linear pieces' +regions through the cones over their facets, retaining facet adjacency. +A cross-polytope supplies the orthant partition for parallel-face families. +SetProg chooses the modeling space from the normalization constraints. + +The existing `LinearCopositiveTemplate`, `DualCopositiveTemplate`, +`ParallelFacesPolyhedralTemplate`, and `ConicPolyhedralTemplate` constructors +also remain available. Positive-orthant families require nonnegative dynamics. +`PCC.planar_conic_partition(order; library)` supplies a partition polytope. +Pass one polytope per node to `ConicPolyhedralTemplate`; separate cone matrices +are no longer accepted. Its `min_scale` keyword scales the partition gauge +used for normalization. +The other polyhedral compatibility constructors retain their coefficient-floor +keyword arguments. + +`DualCopositiveTemplate` and `ParallelFacesPolyhedralTemplate` retain their +compatibility constructors. A PCC normalization adapter stores the native +SetProg family and a fixed reference set. Requiring that reference to lie in +the variable selects dual modeling through SetProg's inclusion rules; +`min_weight` remains a lower bound on the box widths. Parallel-face families +also impose central symmetry using `-S ⊆ S`. No variable polarity option is +used, and normalization and gauge evaluation stay in PCC. + +The degree `p`, returned by [`homogeneity_degree`](@ref), satisfies +`V(c*x) = c^p * V(x)`. Stability uses `rate^p` in each edge inequality, +so every family's rate bounds the joint spectral radius. +Ellipsoids and polynomial sets are normalized against the Euclidean unit gauge. +A finite `conditioning_bound` keyword in `certify` or `jsr_bound` also bounds +an ellipsoid's matrix by `conditioning_bound * I`; the default is `Inf`. + +Clarabel solves semidefinite and linear models; HiGHS solves linear models. + +## Closure and duality + +Graph operations remain in PCC. [`is_closed_under`](@ref) declares a family's +closures; [`is_valid`](@ref) checks a lift, also using the dual family when available. + +| Family | Addition | Maximum | Composition with invertible dynamics | +| :-- | :-: | :-: | :-- | +| symmetric ellipsoid | ✓ | | ✓ | +| polynomial set | ✓ | | ✓ | +| positive simplex | ✓ | | if nonnegative with no zero column | +| positive box | | ✓ | | +| parallelotope | | ✓ when all node matrices coincide | | +| conic polytope | | | | +| piecewise ellipsoid or polytope | ✓ when the nodes share a partition | | | + +Symmetric nonpiecewise ellipsoids are self-dual. Positive simplices and boxes +are each other's blocking duals on the nonnegative orthant. Other families +have no fitted dual implementation here. See [Duality](@ref). + +## Per-node data + +A single family is shared by all nodes. A vector supplies one family per node: + +```julia +families = PCC.ParallelFacesPolyhedralTemplate([G1, G2]) +certificate = PCC.jsr_bound(families, graph, problem; optimizer) +``` + +[`reindex`](@ref) follows node origins through lifts. Copies retain their +origin's family, and merging nodes requires equal families. +All nodes must use gauges of the same degree. + +## Deferred problems + +Safety and optimal control still use [`QuadraticTemplate`](@ref) and their +existing fitted quadratic functions. Their migration to SetProg is deferred. diff --git a/docs/src/reference/templates.md b/docs/src/reference/templates.md index 616158b..1cd9070 100644 --- a/docs/src/reference/templates.md +++ b/docs/src/reference/templates.md @@ -34,10 +34,10 @@ Pages = ["templates/catalogue/quadratic.jl"] ## Compatibility constructors -The former polyhedral templates are constructors returning native SetProg +`SumOfSquaresTemplate` and the former polyhedral templates return native SetProg families. Their names remain in PCC; there are no separate SetProg template types. ```@autodocs Modules = [PathCompleteCertificates] -Pages = ["problems/polytope_compat.jl"] +Pages = ["problems/polytope_compat.jl", "templates/catalogue/sum_of_squares.jl"] ``` diff --git a/ext/PathCompleteCertificatesSumOfSquaresExt.jl b/ext/PathCompleteCertificatesSumOfSquaresExt.jl index 4e6c749..7811003 100644 --- a/ext/PathCompleteCertificatesSumOfSquaresExt.jl +++ b/ext/PathCompleteCertificatesSumOfSquaresExt.jl @@ -5,11 +5,13 @@ import SetProg import SumOfSquares import LinearAlgebra -function PCC._unit_set(family::SetProg.PolySet) +function PCC._unit_set(family::SetProg.PolySet; scale = 1) d = div(family.degree, 2) monos = SumOfSquares.MP.monomials(family.variables, d) - coefficients = - [factorial(d) / prod(factorial, SumOfSquares.MP.exponents(mono)) for mono in monos] + coefficients = [ + scale * factorial(d) / prod(factorial, SumOfSquares.MP.exponents(mono)) for + mono in monos + ] gram = SumOfSquares.GramMatrix(Matrix(LinearAlgebra.Diagonal(coefficients)), monos) return SetProg.Sets.PolySet(family.degree, gram) end diff --git a/src/PathCompleteCertificates.jl b/src/PathCompleteCertificates.jl index d7de321..d8a3ee0 100644 --- a/src/PathCompleteCertificates.jl +++ b/src/PathCompleteCertificates.jl @@ -54,6 +54,7 @@ include("problems/certificate.jl") include("problems/tight_edges.jl") include("templates/catalogue/quadratic.jl") +include("templates/catalogue/sum_of_squares.jl") include("problems/catalogue/stability.jl") include("problems/catalogue/safety.jl") diff --git a/src/problems/gauges.jl b/src/problems/gauges.jl index 2828b87..9cb3f4c 100644 --- a/src/problems/gauges.jl +++ b/src/problems/gauges.jl @@ -118,8 +118,8 @@ end function _with_dimension(family::SetProg.PolySet, n, vars) family.symmetric || throw(ArgumentError("a homogeneous polynomial set must be symmetric")) - family.point === nothing && family.superset === nothing || - throw(ArgumentError("point and superset are not supported for homogeneous gauges")) + family.point === nothing || + throw(ArgumentError("point is not supported for homogeneous gauges")) x = family.variables === nothing ? (vars === nothing ? SetProg.new_polyvars(n) : vars) : family.variables @@ -131,6 +131,7 @@ function _with_dimension(family::SetProg.PolySet, n, vars) convex = family.convex, variables = x, basis = family.basis, + superset = family.superset, ) end """ @@ -178,7 +179,8 @@ function _with_superset(f::SetProg.Ellipsoid, unit) end function _with_superset(f::SetProg.PolySet, unit) # SetProg does not support a superset for convex polynomial families. - (f.convex || f.superset !== nothing) && return nothing + f.superset !== nothing && return f + f.convex && return nothing return SetProg.PolySet(; symmetric = f.symmetric, degree = f.degree, diff --git a/src/templates/catalogue/sum_of_squares.jl b/src/templates/catalogue/sum_of_squares.jl new file mode 100644 index 0000000..f5c720c --- /dev/null +++ b/src/templates/catalogue/sum_of_squares.jl @@ -0,0 +1,20 @@ +""" + SumOfSquaresTemplate(degree, variables; min_scale = 1) + +Construct a native SetProg `PolySet` of homogeneous SOS polynomials of degree +`2degree`, using the shared polynomial `variables`. Its gauge is +`min_scale * sum(variables .^ 2)^degree` plus an SOS remainder, so `min_scale` +fixes the normalization. Requires `using SumOfSquares`. +Decrease slacks and refinement are not implemented for polynomial sets. +""" +function SumOfSquaresTemplate( + degree::Integer, + variables::AbstractVector; + min_scale::Real = 1, +) + degree >= 1 || throw(ArgumentError("degree must be at least 1, got $degree")) + isempty(variables) && throw(ArgumentError("at least one variable is required")) + min_scale > 0 || throw(ArgumentError("min_scale must be positive")) + family = SetProg.PolySet(; symmetric = true, degree = 2 * Int(degree), variables) + return _with_superset(family, _unit_set(family; scale = min_scale)) +end diff --git a/test/problems/certificate.jl b/test/problems/certificate.jl index 520ed21..f4c1dd9 100644 --- a/test/problems/certificate.jl +++ b/test/problems/certificate.jl @@ -44,3 +44,75 @@ end @test STABILITY(x) ≈ PCC.common(GRAPH, PROBLEM, PCC.functions(STABILITY), x) + + # Positive away from the origin, zero at it -- it is a Lyapunov function. + @test STABILITY(x) > 0 + @test STABILITY([0.0, 0.0]) ≈ 0 atol = 1e-8 +end + +@testset "every problem returns the same shape" begin + S0 = [-1.0 0.0 0.0; 0.0 -1.0 0.0; 0.0 0.0 16.0] + Su = [1.0 0.0 0.0; 0.0 1.0 0.0; 0.0 0.0 -36.0] + modes = [[0.7 0.77; -0.49 0.84], [0.7 0.77; -0.49 0.56]] + + safety = PCC.certify( + PCC.QuadraticTemplate(), + PCC.de_bruijn(1, 2), + PCC.SafetyProblem(PCC.switched_system(modes), S0, Su); + optimizer = OPTIMIZER, + ) + + control = PCC.certify( + PCC.QuadraticTemplate(), + PCC.de_bruijn(1, 2), + PCC.OptimalControlProblem( + PCC.switched_system( + [[0.0 1.0; -1.0 0.0], [-0.1 0.0; 0.0 -0.95]], + [[1.0; 0.0;;], [1.0; 0.0;;]], + ), + [1.0 0.0; 0.0 1.0], + [1.0;;], + ); + optimizer = JuMP.optimizer_with_attributes( + Clarabel.Optimizer, + "chordal_decomposition_enable" => false, + ), + ) + + for certificate in (STABILITY, safety, control) + @test certificate isa PCC.AbstractCertificate + @test PCC.is_feasible(certificate) + @test !isempty(PCC.functions(certificate)) + @test PCC.status(certificate) isa JuMP.MOI.TerminationStatusCode + @test certificate([1.0, 1.0]) isa Real + end + + # What each problem certifies is a typed field on its own type, not an + # entry in a shared bag -- so it is discoverable and documented. + @test STABILITY isa PCC.StabilityCertificate + @test safety isa PCC.SafetyCertificate + @test control isa PCC.OptimalControlCertificate + + @test STABILITY.rate > 0 + @test safety.margin > 0 + @test length(control.gains) == PCC.n_nodes(PCC.graph(control)) +end + +@testset "an infeasible solve still returns a certificate" begin + # Rather than a differently-shaped tuple with `nothing` fields. + overlapping = [1.0 0.0 0.0; 0.0 1.0 0.0; 0.0 0.0 -1.0] + S0 = [-1.0 0.0 0.0; 0.0 -1.0 0.0; 0.0 0.0 16.0] + modes = [[0.7 0.77; -0.49 0.84], [0.7 0.77; -0.49 0.56]] + + res = PCC.certify( + PCC.QuadraticTemplate(), + PCC.de_bruijn(1, 2), + PCC.SafetyProblem(PCC.switched_system(modes), S0, overlapping); + optimizer = OPTIMIZER, + ) + + @test res isa PCC.SafetyCertificate + @test !PCC.is_feasible(res) +end + +end diff --git a/test/problems/common.jl b/test/problems/common.jl index d2f812c..9487e40 100644 --- a/test/problems/common.jl +++ b/test/problems/common.jl @@ -13,6 +13,51 @@ const P1 = PCC.QuadraticFunction(LinearAlgebra.Symmetric([1.0 0.0; 0.0 1.0])) const P2 = PCC.QuadraticFunction(LinearAlgebra.Symmetric([3.0 0.0; 0.0 3.0])) const VS = [P1, P2] +value(V) = V(X) + +@testset "a complete graph combines with a minimum" begin + graph = PCC.de_bruijn(1, 2) + @test PCC.is_complete(graph) + + @test PCC.common(graph, PROBLEM, VS, X) ≈ + minimum(value(P) for P in VS) +end + +@testset "a co-complete graph combines with a maximum" begin + graph = PCC.dual(PCC.de_bruijn(1, 2)) + @test PCC.is_co_complete(graph) + + @test PCC.common(graph, PROBLEM, VS, X) ≈ + maximum(value(P) for P in VS) +end + +@testset "any other graph combines over the observer construction" begin + # Node 2 has no label-2 edge, so this graph is neither complete nor + # co-complete and `common` must fall back to the observer construction -- + # a max within each observer state and a min across them. + graph = GraphAutomaton(2) + add_transition!(graph, 1, 1, 1) + add_transition!(graph, 1, 2, 2) + add_transition!(graph, 2, 1, 1) + + @test !PCC.is_complete(graph) + @test !PCC.is_co_complete(graph) + + _, states = PCC.observer_graph(graph) + @test states == [Set([1, 2]), Set([1]), Set([2])] + + expected = minimum(maximum(value(VS[node]) for node in state) for state in states) + + @test PCC.common(graph, PROBLEM, VS, X) ≈ expected +end + +@testset "the common function is generic in the template" begin + graph = PCC.de_bruijn(1, 2) + linear = PCC.LinearCopositiveFunction.([[1.0, 1.0], [2.0, 2.0]]) + + @test PCC.common(graph, PROBLEM, linear, X) ≈ minimum( + PCC.node_value(PROBLEM, V, X) for V in linear + ) weights = [ PCC.ParallelFacesPolyhedralFunction( diff --git a/test/problems/gauges.jl b/test/problems/gauges.jl index 0e2f40e..ecb0814 100644 --- a/test/problems/gauges.jl +++ b/test/problems/gauges.jl @@ -95,6 +95,39 @@ end end end +@testset "SumOfSquaresTemplate is a native PolySet shortcut" begin + @polyvar x[1:2] + for degree in 1:3, min_scale in (0.25, 1.0, 2.0) + family = PCC.SumOfSquaresTemplate(degree, x; min_scale) + @test family isa SetProg.PolySet + @test family.degree == 2degree + @test family.variables == x + @test PCC.homogeneity_degree(family) == 2degree + @test PCC._gauge(family.superset, [2.0, 3.0]) ≈ min_scale * 13^degree + end + @test_throws ArgumentError PCC.SumOfSquaresTemplate(0, x) + @test_throws ArgumentError PCC.SumOfSquaresTemplate(1, x[1:0]) + @test_throws ArgumentError PCC.SumOfSquaresTemplate(1, x; min_scale = 0) + problem = PCC.StabilityProblem(PCC.switched_system([[0.5 0.0; 0.0 0.25]])) + graph = PCC.de_bruijn(1, 1) + for min_scale in (0.25, 2.0) + family = PCC.SumOfSquaresTemplate(2, x; min_scale) + model = Model(optimizer_with_attributes(Clarabel.Optimizer, MOI.Silent() => true)) + S = PCC.add_function_variables!(model, family, 2, 1) + PCC.add_normalization!(model, S.set, S) + optimize!(model) + @test termination_status(model) in (MOI.OPTIMAL, MOI.ALMOST_OPTIMAL) + @test isempty(SetProg.data(model).constraints) + @test num_constraints(model; count_variable_in_set_constraints = true) == 1 + # The custom reference survives dimension filling and normalization. + @test SetProg.JuMP.constant.(S.variable.p.Q) ≈ family.superset.p.Q + certificate = + PCC.certify(family, graph, problem; optimizer = Clarabel.Optimizer, rate = 0.6) + @test PCC.is_feasible(certificate) + @test certificate([1.0, 1.0]) >= min_scale * 4 - 1e-6 + end +end + @testset "Gauge data and duality" begin @test_throws ArgumentError PCC.DualCopositiveTemplate(min_weight = 0) @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate([zeros(2, 2)]) From ffc29a8d207ba6e555ae64a6f70ae714f7a3ce99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 21 Oct 2026 15:44:17 +0200 Subject: [PATCH 17/18] released SetProg --- Project.toml | 5 +---- src/problems/sets.jl | 1 - test/problems/certificate.jl | 3 +-- test/problems/common.jl | 21 ++++++--------------- test/problems/sets.jl | 19 +++++-------------- 5 files changed, 13 insertions(+), 36 deletions(-) diff --git a/Project.toml b/Project.toml index d7d55ed..fc3956e 100644 --- a/Project.toml +++ b/Project.toml @@ -18,7 +18,7 @@ LinearAlgebra = "1" MathematicalSystems = "0.14" Random = "1" RecipesBase = "1" -SetProg = "0.4.4" +SetProg = "0.4.5" SumOfSquares = "0.8" julia = "1.10" @@ -29,6 +29,3 @@ SumOfSquares = "4b9e565b-77fc-50a5-a571-1244f986bda1" [extensions] PathCompleteCertificatesSumOfSquaresExt = "SumOfSquares" PathCompleteCertificatesRecipesBaseExt = "RecipesBase" - -[sources] -SetProg = {url = "https://github.com/blegat/SetProg.jl", rev = "main"} diff --git a/src/problems/sets.jl b/src/problems/sets.jl index 9682c5a..d452a6f 100644 --- a/src/problems/sets.jl +++ b/src/problems/sets.jl @@ -102,4 +102,3 @@ function _prepared_families(family::_SetFamily, dimension, count) end return [_with_dimension(f, dimension; variables = x) for f in families] end - diff --git a/test/problems/certificate.jl b/test/problems/certificate.jl index f4c1dd9..fc68404 100644 --- a/test/problems/certificate.jl +++ b/test/problems/certificate.jl @@ -42,8 +42,7 @@ end @testset "a certificate is callable, and is the common function" begin x = [1.0, 2.0] - @test STABILITY(x) ≈ - PCC.common(GRAPH, PROBLEM, PCC.functions(STABILITY), x) + @test STABILITY(x) ≈ PCC.common(GRAPH, PROBLEM, PCC.functions(STABILITY), x) # Positive away from the origin, zero at it -- it is a Lyapunov function. @test STABILITY(x) > 0 diff --git a/test/problems/common.jl b/test/problems/common.jl index 9487e40..76c7a19 100644 --- a/test/problems/common.jl +++ b/test/problems/common.jl @@ -19,16 +19,14 @@ value(V) = V(X) graph = PCC.de_bruijn(1, 2) @test PCC.is_complete(graph) - @test PCC.common(graph, PROBLEM, VS, X) ≈ - minimum(value(P) for P in VS) + @test PCC.common(graph, PROBLEM, VS, X) ≈ minimum(value(P) for P in VS) end @testset "a co-complete graph combines with a maximum" begin graph = PCC.dual(PCC.de_bruijn(1, 2)) @test PCC.is_co_complete(graph) - @test PCC.common(graph, PROBLEM, VS, X) ≈ - maximum(value(P) for P in VS) + @test PCC.common(graph, PROBLEM, VS, X) ≈ maximum(value(P) for P in VS) end @testset "any other graph combines over the observer construction" begin @@ -55,9 +53,8 @@ end graph = PCC.de_bruijn(1, 2) linear = PCC.LinearCopositiveFunction.([[1.0, 1.0], [2.0, 2.0]]) - @test PCC.common(graph, PROBLEM, linear, X) ≈ minimum( - PCC.node_value(PROBLEM, V, X) for V in linear - ) + @test PCC.common(graph, PROBLEM, linear, X) ≈ + minimum(PCC.node_value(PROBLEM, V, X) for V in linear) weights = [ PCC.ParallelFacesPolyhedralFunction( @@ -70,14 +67,8 @@ end ), ] - @test PCC.common(graph, - PROBLEM, - weights, - X, - ) ≈ minimum( - PCC.node_value(PROBLEM, V, X) for - V in weights - ) + @test PCC.common(graph, PROBLEM, weights, X) ≈ + minimum(PCC.node_value(PROBLEM, V, X) for V in weights) end end diff --git a/test/problems/sets.jl b/test/problems/sets.jl index d887cff..a09e3af 100644 --- a/test/problems/sets.jl +++ b/test/problems/sets.jl @@ -75,8 +75,7 @@ function test_edges(certificate, template, graph) src = V[PCC.source(edge)] dst = V[PCC.dest(edge)] @test scale * PCC.node_value(PROBLEM, src, point) - - PCC.node_value(PROBLEM, dst, A[PCC.label(graph, edge)] * point) >= - -1e-5 + PCC.node_value(PROBLEM, dst, A[PCC.label(graph, edge)] * point) >= -1e-5 end end @@ -105,8 +104,7 @@ const ELLIPSOID = rate(Ellipsoid(; symmetric = true)) @test V isa SetProg.Sets.Ellipsoid # V(cx) = c² V(x), and the normalization `V ≥ ‖x‖²` @test PCC.node_value(PROBLEM, V, [2.0, 3.0]) ≈ - 4 * PCC.node_value(PROBLEM, V, [1.0, 1.5]) rtol = - 1e-6 + 4 * PCC.node_value(PROBLEM, V, [1.0, 1.5]) rtol = 1e-6 @test PCC.node_value(PROBLEM, V, [1.0, 1.0]) >= 2 - 1e-6 test_edges(ELLIPSOID, Ellipsoid(symmetric = true), MEMORYLESS) end @@ -227,8 +225,7 @@ end family = PolySet(symmetric = true, degree = 4) certificate = PCC.certify(family, graph, diagonal; optimizer = OPTIMIZER, rate = 0.6) @test PCC.is_feasible(certificate) - @test PCC.node_value(diagonal, only(PCC.functions(certificate)), [1.0, 1.0]) >= - 4 - 1e-5 + @test PCC.node_value(diagonal, only(PCC.functions(certificate)), [1.0, 1.0]) >= 4 - 1e-5 @polyvar y[1:2] different = [PolySet(symmetric = true, degree = 4, variables = z) for z in (x, y)] @test_throws ArgumentError PCC.optimization_model( @@ -313,14 +310,8 @@ end @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(0, 2) @test_throws ArgumentError PCC.ParallelFacesPolyhedralTemplate(Matrix{Float64}[]) @test_throws ArgumentError PCC.planar_conic_partition(0) - @test PCC.node_value(PROBLEM, - PCC.DualCopositiveFunction([2.0, 3.0]), - [1.0, 1.0], - ) ≈ 0.5 - @test PCC.node_value(PROBLEM, - PCC.LinearCopositiveFunction([2.0, 3.0]), - [1.0, 1.0], - ) ≈ 5 + @test PCC.node_value(PROBLEM, PCC.DualCopositiveFunction([2.0, 3.0]), [1.0, 1.0]) ≈ 0.5 + @test PCC.node_value(PROBLEM, PCC.LinearCopositiveFunction([2.0, 3.0]), [1.0, 1.0]) ≈ 5 end end From 736e06ee1810bca34aef67ae799f24039feac954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Fri, 23 Oct 2026 20:06:05 +0200 Subject: [PATCH 18/18] FIX problems: record coverage of family capabilities Test native SetProg defaults, homogeneity degrees, duality, and polynomial closure for invertible and singular dynamics. Use explicit bodies for constant-return methods so Julia records their execution instead of eliding their line coverage. --- src/problems/gauges.jl | 18 +++++++++++++----- src/problems/sets.jl | 16 ++++++++++++---- test/problems/gauges.jl | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/problems/gauges.jl b/src/problems/gauges.jl index 9cb3f4c..39a033c 100644 --- a/src/problems/gauges.jl +++ b/src/problems/gauges.jl @@ -1,7 +1,9 @@ const _LinearPolytope = SetProg.Sets.Piecewise{<:Any, <:SetProg.Sets.PolarPoint} const _LinearCopositiveSet = SetProg.Sets.Intersection{<:Any, <:SetProg.Sets.PolarPoint} -_is_linear_copositive(::SetProg.AbstractVariable) = false +function _is_linear_copositive(::SetProg.AbstractVariable) + return false +end _is_linear_copositive(f::SetProg.Domain{<:SetProg.Polytope}) = f.set.piecewise === nothing && (f.domain === SetProg.MOI.Nonnegatives || f.domain isa SetProg.MOI.Nonnegatives) @@ -46,15 +48,19 @@ _is_parallelotope(f) = f isa _NormalizedFamily && f.symmetric && f.set isa SetPr The degree of the homogeneous gauge used to represent a set family. """ -homogeneity_degree(::Union{SetProg.Ellipsoid, SetProg.Sets.Ellipsoid}) = 2 +function homogeneity_degree(::Union{SetProg.Ellipsoid, SetProg.Sets.Ellipsoid}) + return 2 +end homogeneity_degree(f::_NormalizedFamily) = homogeneity_degree(f.set) -homogeneity_degree( +function homogeneity_degree( ::Union{ SetProg.Polytope, SetProg.Sets.PolarPoint, SetProg.Sets.Piecewise{<:Any, <:SetProg.Sets.PolarPoint}, }, -) = 1 +) + return 1 +end homogeneity_degree( family::Union{SetProg.PolySet, SetProg.Sets.PolySet, SetProg.Sets.ConvexPolySet}, ) = family.degree @@ -309,7 +315,9 @@ end Whether the fitted homogeneous gauge has an implemented dual gauge family. """ -_supports_dual(::SetProg.AbstractVariable) = false +function _supports_dual(::SetProg.AbstractVariable) + return false +end _supports_dual(f::SetProg.Ellipsoid) = f.symmetric && f.piecewise === nothing _supports_dual(f::SetProg.Domain) = _is_linear_copositive(f) _supports_dual(f::_NormalizedFamily) = _supports_dual(f.set) diff --git a/src/problems/sets.jl b/src/problems/sets.jl index d452a6f..97c6e77 100644 --- a/src/problems/sets.jl +++ b/src/problems/sets.jl @@ -7,7 +7,9 @@ const _SetFamily = Whether the SetProg family applies to the system. Orthant gauges require nonnegative dynamics. """ -is_applicable(::SetProg.AbstractVariable, system) = true +function is_applicable(::SetProg.AbstractVariable, system) + return true +end is_applicable(f::SetProg.Domain, system) = !_is_linear_copositive(f) || _is_positive(system) is_applicable(f::_NormalizedFamily, system) = is_applicable(f.set, system) is_applicable(families::AbstractVector{<:SetProg.AbstractVariable}, system) = @@ -18,15 +20,21 @@ is_applicable(families::AbstractVector{<:SetProg.AbstractVariable}, system) = Whether a SetProg gauge family admits the operation required by a graph lift. """ -is_closed_under(::SetProg.AbstractVariable, ::Operation, system) = false -is_closed_under(::Union{SetProg.Ellipsoid, SetProg.PolySet}, ::Addition, system) = true +function is_closed_under(::SetProg.AbstractVariable, ::Operation, system) + return false +end +function is_closed_under(::Union{SetProg.Ellipsoid, SetProg.PolySet}, ::Addition, system) + return true +end is_closed_under(f::SetProg.Ellipsoid, ::Composition, system) = f.piecewise === nothing && _is_invertible(system) is_closed_under(::SetProg.PolySet, ::Composition, system) = _is_invertible(system) is_closed_under(f::SetProg.Ellipsoid, ::InverseComposition, system) = f.piecewise === nothing && _is_invertible(system) is_closed_under(::SetProg.PolySet, ::InverseComposition, system) = _is_invertible(system) -is_closed_under(::SetProg.Polytope, ::Addition, system) = true +function is_closed_under(::SetProg.Polytope, ::Addition, system) + return true +end is_closed_under(f::_NormalizedFamily, ::Addition, system) = !f.contains_reference && is_closed_under(f.set, Addition(), system) is_closed_under(f::_NormalizedFamily, ::Maximum, system) = f.contains_reference diff --git a/test/problems/gauges.jl b/test/problems/gauges.jl index ecb0814..239316a 100644 --- a/test/problems/gauges.jl +++ b/test/problems/gauges.jl @@ -4,6 +4,41 @@ using SumOfSquares, DynamicPolynomials import PathCompleteCertificates as PCC import Clarabel, HiGHS +# A user-defined family inherits the documented default capabilities. +struct UnknownSetFamily <: SetProg.AbstractVariable end + +@testset "Native family capabilities" begin + invertible = PCC.switched_system([[0.5 -0.1; 0.2 0.4]]) + singular = PCC.switched_system([[0.5 0.0; 0.0 0.0]]) + unknown = UnknownSetFamily() + @test PCC.is_applicable(unknown, invertible) + @test !PCC.is_closed_under(unknown, PCC.Addition(), invertible) + @test !PCC.has_dual(unknown) + @test_throws ArgumentError PCC.dual(unknown) + + ellipsoid = Ellipsoid(symmetric = true) + # A domain on an ellipsoid is not a linear copositive family. + domain = SetProg.Domain(ellipsoid, MOI.Nonnegatives) + @test PCC.is_applicable(domain, invertible) + + @polyvar x[1:2] + polynomial = PolySet(symmetric = true, degree = 4, variables = x) + polytope = Polytope(symmetric = true, dimension = 2) + for (family, degree) in ((ellipsoid, 2), (polynomial, 4), (polytope, 1)) + @test PCC.is_applicable(family, invertible) + @test PCC.homogeneity_degree(family) == degree + @test PCC.is_closed_under(family, PCC.Addition(), invertible) + @test !PCC.is_closed_under(family, PCC.Maximum(), invertible) + end + @test PCC.homogeneity_degree(SetProg.Sets.Ellipsoid(Symmetric([2.0 0.0; 0.0 3.0]))) == 2 + @test PCC.homogeneity_degree(SetProg.Sets.PolarPoint([1.0, 2.0])) == 1 + for operation in (PCC.Composition(), PCC.InverseComposition()) + @test PCC.is_closed_under(polynomial, operation, invertible) + @test !PCC.is_closed_under(polynomial, operation, singular) + end + @test PCC.dual(ellipsoid) === ellipsoid +end + @testset "Homogeneous gauges" begin A = [0.5 0.0; 0.0 0.25] families = (