lp_trajectories.kt
src/jvmMain/kotlin/afield/geometry/spatial/lp_trajectories.kt · 981 lines
@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.geometry.spatial
import afield.geometry.path.FLine
import afield.graphics.render.color
import afield.graphics.render.thickness
import afield.math.linalg.Vec2
import afield.math.linalg.Vec4
import afield.math.linalg.vec
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.hypot
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
Linear-programming algorithms on a convex polygon, used as curve generators.
A 2-D linear program is "maximise c·x over a convex polygon". Every classical method for it walks
from somewhere to the same optimal vertex, and each walks differently: the barrier method's central
path is a smooth arc from the analytic centre, the primal-dual method takes a few long Newton
strides, PDHG spirals in, the ellipsoid method shrinks a sequence of ellipses, the simplex method
hops along the boundary and cutting planes carve the region away. Here those walks are the product.
Every method returns its whole trajectory with per-step metadata (LpSample), and each result type
has a toFLine that draws it, with toFLines for a whole fan of them.
The pure math is independent of Box and of FLine: an LpPolygon is a list of half-planes and the methods are plain functions on it. Doubles throughout; LpSample.position is the Vec2 for drawing. The visual reference for all of this is https://lpviz.net.
class LpPolygon(points: List<Vec2>)
A strictly convex polygon, held both as its vertices and as the half-planes nᵢ·x ≤ bᵢ of its
edges, ready for the trajectory methods (centralPath, centralFan, primalDual, pdhg,
ellipsoid, simplexWalk, cuttingPlanes).
The constructor accepts the vertices in either winding and normalises them to counter-clockwise.
A closing vertex that repeats the first, and consecutive duplicates, are dropped. It rejects,
with a message saying which, fewer than three distinct vertices, zero area, a reflex corner and
three collinear vertices: each would make an edge redundant or the interior empty, and the
barrier methods need a strictly feasible interior. Use LpPolygon.of to read the vertices of
an FLine, LpPolygon.regular for a regular polygon, and LpPolygon.hull / LpPolygon.hullOf
for the convex hull of any point set or drawn line.
Edge i runs from vertices[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) to vertices[i + 1]; its unit outward normal is
(normalX[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240), normalY[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240)) and its bound bound[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240).
class LpPolygon(points: List<Vec2>) {
val vertices: List<Vec2>
The vertices, counter-clockwise, without a closing repeat.
val normalX: DoubleArray
x component of each edge's unit outward normal.
val normalY: DoubleArray
y component of each edge's unit outward normal.
val bound: DoubleArray
Each edge's bound: the polygon is normalX[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240)·x + normalY[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240)·y ≤ bound[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) for every i.
val center: Vec2
The mean of the vertices, strictly inside a strictly convex polygon; the default start.
init
body — 43 lines, from line 66
init {
val distinct = ArrayList<Vec2>()
for (p in points) {
require(p.x.isFinite() && p.y.isFinite()) { "LpPolygon: vertex $p is not finite" }
if (distinct.isEmpty() || distinct.last().distance(p) > 1e-6f) distinct.add(vec(p.x, p.y))
}
if (distinct.size > 1 && distinct.first().distance(distinct.last()) <= 1e-6f) distinct.removeAt(distinct.size - 1)
require(distinct.size >= 3) { "LpPolygon needs at least 3 distinct vertices, got ${distinct.size}" }
var area2 = 0.0
for (i in distinct.indices) {
val a = distinct[i]
val b = distinct[(i + 1) % distinct.size]
area2 += a.x.toDouble() * b.y - a.y.toDouble() * b.x
}
val scale = distinct.maxOf { max(abs(it.x), abs(it.y)).toDouble() }.coerceAtLeast(1.0)
require(abs(area2) > 1e-9 * scale * scale) { "LpPolygon: the vertices enclose no area" }
val ccw = if (area2 > 0) distinct else distinct.reversed()
val n = ccw.size
for (i in 0 until n) {
val a = ccw[i]
val b = ccw[(i + 1) % n]
val c = ccw[(i + 2) % n]
val cross = (b.x.toDouble() - a.x) * (c.y.toDouble() - b.y) - (b.y.toDouble() - a.y) * (c.x.toDouble() - b.x)
val norm = hypot(b.x.toDouble() - a.x, b.y.toDouble() - a.y) * hypot(c.x.toDouble() - b.x, c.y.toDouble() - b.y)
require(cross > -1e-9 * norm) { "LpPolygon: not convex — vertex ${(i + 1) % n} at ${ccw[(i + 1) % n]} is a reflex corner" }
require(cross > 1e-9 * norm) { "LpPolygon: vertices ${i}, ${(i + 1) % n}, ${(i + 2) % n} are collinear; drop the middle one" }
}
vertices = ccw
normalX = DoubleArray(n)
normalY = DoubleArray(n)
bound = DoubleArray(n)
for (i in 0 until n) {
val a = ccw[i]
val b = ccw[(i + 1) % n]
val ex = b.x.toDouble() - a.x
val ey = b.y.toDouble() - a.y
val len = hypot(ex, ey)
normalX[i] = ey / len
normalY[i] = -ex / len
bound[i] = normalX[i] * a.x + normalY[i] * a.y
}
center = vec(ccw.sumOf { it.x.toDouble() } / n, ccw.sumOf { it.y.toDouble() } / n)
}
val edgeCount: Int get() = vertices.size
The number of edges, which is also the number of constraints and of vertices.
fun slack(i: Int, x: Double, y: Double): Double = bound[i] - normalX[i] * x - normalY[i] * y
How far (x, y) is inside edge i: positive inside, zero on the edge, negative outside.
fun violation(x: Double, y: Double): Double
The largest constraint violation at (x, y), zero for any point inside or on the polygon.
fun violation(x: Double, y: Double): Double {
var v = 0.0
for (i in 0 until edgeCount) v = max(v, -slack(i, x, y))
return v
}
fun containsStrictly(x: Double, y: Double): Boolean
True when (x, y) is strictly inside every edge.
fun containsStrictly(x: Double, y: Double): Boolean = (0 until edgeCount).all { slack(it, x, y) > 0.0 }
fun optimalVertex(direction: Vec2): Vec2
The vertex that maximises direction · x: the answer every method here walks toward.
fun optimalVertex(direction: Vec2): Vec2 = vertices.maxBy { it.x.toDouble() * direction.x + it.y.toDouble() * direction.y }
fun toFLine(color: Vec4 = vec(0.62, 0.70, 0.80, 0.9), thickness: Double = 1.4): FLine
The polygon's outline as a closed FLine.
fun toFLine(color: Vec4 = vec(0.62, 0.70, 0.80, 0.9), thickness: Double = 1.4): FLine =
closedPolyline(vertices, color, thickness)
companion object
companion object {
fun of(line: FLine): LpPolygon = LpPolygon(line.nodes.map { vec(it.to.x, it.to.y) })
The polygon whose vertices are the node positions of line — what you get from a drawn
or fline { }-built closed outline. Only the end point of each segment is read, so a
curved segment contributes its end vertex; flatten curves first if you want them.
fun hull(points: List<Vec2>, mergeDistance: Double = 1e-6): LpPolygon
The convex hull of points, as a polygon: any point set, in any order, with repeats,
interior points and collinear runs. Interior points are dropped, points on a hull edge
between two corners are dropped (they would be collinear vertices), and the hull is
counter-clockwise. Points closer than mergeDistance count as one.
Andrew's monotone chain in doubles. It fails, with a message, when fewer than three distinct points remain or every point lies on one line. Use LpPolygon's own constructor when the vertices are meant to be convex already and a reflex corner should be an error rather than silently cut off.
fun hull(points: List<Vec2>, mergeDistance: Double = 1e-6): LpPolygon =
LpPolygon(convexHull2(points, mergeDistance))
fun hullOf(line: FLine, mergeDistance: Double = 1e-6): LpPolygon
The convex hull of line's node positions — a hand-drawn stroke, a non-convex outline
or a scatter drawn with moveTos all give a polygon. As with of, only the end point of
each segment is read, so flatten curved segments first if their bulge should count.
fun hullOf(line: FLine, mergeDistance: Double = 1e-6): LpPolygon =
hull(line.nodes.indices.map { val p = line.endPointOf(it); vec(p.x, p.y) }, mergeDistance)
fun regular(sides: Int, radius: Double, cx: Double = 0.0, cy: Double = 0.0, rotation: Double = 0.0): LpPolygon
A regular polygon with sides vertices on a circle of radius around (cx, cy).
fun regular(sides: Int, radius: Double, cx: Double = 0.0, cy: Double = 0.0, rotation: Double = 0.0): LpPolygon {
require(sides >= 3) { "LpPolygon.regular needs at least 3 sides, got $sides" }
require(radius > 0.0) { "LpPolygon.regular needs a positive radius, got $radius" }
return LpPolygon(List(sides) { k ->
val a = rotation + 2.0 * PI * k / sides
vec(cx + radius * cos(a), cy + radius * sin(a))
})
}
}
}
data class LpSample
One step of a trajectory.
data class LpSample(
/** The iterate's x. */
val x: Double,
/** The iterate's y. */
val y: Double,
/** The step number, starting at 0. */
val iteration: Int,
/** `c·x` with `c` the normalised objective direction. */
val objective: Double,
/** The largest constraint violation, zero for a feasible iterate. */
val violation: Double,
/**
* The method's own measure at this step: the barrier weight μ for [centralPath], the duality
* measure μ for [primalDual], the dual norm for [pdhg], `√det` of the ellipse for [ellipsoid],
* the remaining area for [cuttingPlanes], and `NaN` for [simplexWalk].
*/
val scale: Double,
/**
* What kind of step this is: the method's name, or for [ellipsoid] which cut was taken —
* `feasibility-cut`, `objective-cut`, or `final` at the last step.
*/
val tag: String,
) {
val position: Vec2 get() = vec(x, y)
The iterate as a Vec2, for drawing.
class LpTrajectory(val method: String, val samples: List<LpSample>, val solverSteps: Int = 0)
A method's whole walk, in order. method names the method that produced it.
solverSteps counts the inner solver's work where there is one: the damped Newton steps taken
over the whole of centralPath. It is how a warm start (LpContinuation) shows what it saved;
methods without an inner solver leave it at 0.
class LpTrajectory(val method: String, val samples: List<LpSample>, val solverSteps: Int = 0) {
val last: LpSample get() = samples.last()
The final iterate.
val positions: List<Vec2> get() = samples.map { it.position }
The iterates as Vec2s.
fun toFLine(color: Vec4 = vec(1.0, 0.72, 0.32, 0.95), thickness: Double = 1.6): FLine
The walk as an open polyline.
fun toFLine(color: Vec4 = vec(1.0, 0.72, 0.32, 0.95), thickness: Double = 1.6): FLine =
FLine().also { f ->
samples.forEachIndexed { i, s -> if (i == 0) f.moveTo(s.x, s.y) else f.lineTo(s.x, s.y) }
f.color = Vec4(color)
f.thickness = thickness
}
}
data class LpEllipse(val cx: Double, val cy: Double, val pxx: Double, val pxy: Double, val pyy: Double)
The ellipse { z : (z − c)ᵀ P⁻¹ (z − c) ≤ 1 } with centre (cx, cy) and symmetric positive
definite shape matrix P = [[pxx, pxy], [pxy, pyy]], as kept by ellipsoid.
data class LpEllipse(val cx: Double, val cy: Double, val pxx: Double, val pxy: Double, val pyy: Double) {
val determinant: Double get() = pxx * pyy - pxy * pxy
det P; the ellipse's area is π √det.
val isPositiveDefinite: Boolean get() = pxx > 0.0 && determinant > 0.0
True when the shape matrix is symmetric positive definite, i.e. this is a real ellipse.
fun toFLine(segments: Int = 72, color: Vec4 = vec(0.55, 0.80, 1.0, 0.5), thickness: Double = 1.0): FLine
The boundary as a closed FLine of segments chords, through the Cholesky factor of P.
fun toFLine(segments: Int = 72, color: Vec4 = vec(0.55, 0.80, 1.0, 0.5), thickness: Double = 1.0): FLine {
val l11 = sqrt(max(pxx, 0.0))
val l21 = if (l11 > 0.0) pxy / l11 else 0.0
val l22 = sqrt(max(pyy - l21 * l21, 0.0))
return closedPolyline(List(segments) { k ->
val a = 2.0 * PI * k / segments
vec(cx + l11 * cos(a), cy + l21 * cos(a) + l22 * sin(a))
}, color, thickness)
}
}
class LpEllipsoidTrace(val trajectory: LpTrajectory, val ellipses: List<LpEllipse>)
ellipsoid's result: the centres, and the ellipse kept at every step (same length).
class LpPdhgTrace(val trajectory: LpTrajectory, val duals: List<DoubleArray>)
pdhg's result: the primal walk, and the dual vector (one entry per edge) at every step.
class LpCuttingPlaneTrace(val trajectory: LpTrajectory, val regions: List<List<Vec2>>)
cuttingPlanes's result: the centres, and the region still in play at every step.
enum class LpCenter
The centres cuttingPlanes can cut through.
enum class LpCenter {
The analytic centre: the maximiser of Σ ln slackᵢ over the region's constraints, cuts included.
ANALYTIC,
The centroid (centre of gravity) of the region that remains.
CENTROID,
The Chebyshev centre: the centre of the largest disc inside the region. Only the constraints the disc touches decide it, so a cut that misses the disc does not move it.
CHEBYSHEV,
Vaidya's volumetric centre: the minimiser of ½ ln det ∇²(−Σ ln slackᵢ) over the region's
constraints, cuts included. It weighs each constraint by its leverage, so a crowd of nearly
parallel cuts counts for less than it does at the analytic centre.
VOLUMETRIC,
}
private fun unit(direction: Vec2): DoubleArray {
val len = hypot(direction.x.toDouble(), direction.y.toDouble())
require(len > 0.0 && len.isFinite()) { "the objective direction must be a nonzero finite vector, got $direction" }
return doubleArrayOf(direction.x / len, direction.y / len)
}
private fun LpPolygon.sample(c: DoubleArray, x: Double, y: Double, k: Int, scale: Double, tag: String) =
LpSample(x, y, k, c[0] * x + c[1] * y, violation(x, y), scale, tag)
internal fun convexHull2(points: List<Vec2>, mergeDistance: Double): List<Vec2>
Andrew's monotone chain. Sort by (x, y), drop near-duplicates, build the lower and upper chains, popping every point that is not a strict left turn: collinear and interior points go. The turn test is relative to the two edge lengths, a little looser than LpPolygon's own collinearity check, so whatever survives here is accepted there.
body — 44 lines, from line 294
internal fun convexHull2(points: List<Vec2>, mergeDistance: Double): List<Vec2> {
require(mergeDistance >= 0.0) { "mergeDistance must be non-negative, got $mergeDistance" }
val sorted = points.map {
require(it.x.isFinite() && it.y.isFinite()) { "LpPolygon.hull: point $it is not finite" }
doubleArrayOf(it.x.toDouble(), it.y.toDouble())
}.sortedWith(compareBy<DoubleArray>({ it[0] }, { it[1] }))
val distinct = ArrayList<DoubleArray>()
for (p in sorted) {
// Sorted by x, so only the points within mergeDistance in x can be repeats of p.
var j = distinct.size - 1
var repeat = false
while (j >= 0 && distinct[j][0] >= p[0] - mergeDistance) {
if (hypot(distinct[j][0] - p[0], distinct[j][1] - p[1]) <= mergeDistance) { repeat = true; break }
j--
}
if (!repeat) distinct.add(p)
}
require(distinct.size >= 3) { "LpPolygon.hull needs at least 3 distinct points, got ${distinct.size}" }
fun leftTurn(o: DoubleArray, a: DoubleArray, b: DoubleArray): Boolean {
val cross = (a[0] - o[0]) * (b[1] - a[1]) - (a[1] - o[1]) * (b[0] - a[0])
val norm = hypot(a[0] - o[0], a[1] - o[1]) * hypot(b[0] - a[0], b[1] - a[1])
return cross > 1e-8 * norm
}
fun chain(ps: List<DoubleArray>): List<DoubleArray> {
val out = ArrayList<DoubleArray>()
for (p in ps) {
while (out.size >= 2 && !leftTurn(out[out.size - 2], out[out.size - 1], p)) out.removeAt(out.size - 1)
out.add(p)
}
return out
}
val lower = chain(distinct)
val upper = chain(distinct.reversed())
val hull = lower.dropLast(1) + upper.dropLast(1)
require(hull.size >= 3) { "LpPolygon.hull: all ${distinct.size} distinct points lie on one line, so their hull has no area" }
return hull.map { vec(it[0], it[1]) }
}
private fun closedPolyline(points: List<Vec2>, color: Vec4, thickness: Double): FLine = FLine().also { f ->
points.forEachIndexed { i, p -> if (i == 0) f.moveTo(p) else f.lineTo(p) }
f.lineTo(points[0])
f.color = Vec4(color)
f.thickness = thickness
}
internal fun barrierNewton
Damped Newton on f(x) = c·x + mu Σ wᵢ ln(bᵢ − nᵢ·x) over half-planes given as arrays. The Hessian is 2×2 and negative definite inside, so the step is solved in closed form. Returns the maximiser, starting from a strictly feasible (x0, y0), as (x, y, Newton steps taken).
body — 57 lines, from line 344
internal fun barrierNewton(
nx: DoubleArray, ny: DoubleArray, b: DoubleArray, w: DoubleArray,
cx: Double, cy: Double, mu: Double, x0: Double, y0: Double, maxIterations: Int = 100,
): DoubleArray {
var x = x0
var y = y0
var steps = 0
fun done() = doubleArrayOf(x, y, steps.toDouble())
fun value(px: Double, py: Double): Double {
var v = cx * px + cy * py
for (i in nx.indices) {
val s = b[i] - nx[i] * px - ny[i] * py
if (s <= 0.0) return Double.NEGATIVE_INFINITY
v += mu * w[i] * ln(s)
}
return v
}
repeat(maxIterations) {
var gx = cx
var gy = cy
var hxx = 0.0
var hxy = 0.0
var hyy = 0.0
for (i in nx.indices) {
val s = b[i] - nx[i] * x - ny[i] * y
val z = mu * w[i] / s
gx -= z * nx[i]
gy -= z * ny[i]
val q = z / s
hxx += q * nx[i] * nx[i]
hxy += q * nx[i] * ny[i]
hyy += q * ny[i] * ny[i]
}
val det = hxx * hyy - hxy * hxy
check(det > 0.0 && det.isFinite()) { "degenerate barrier Hessian (det = $det)" }
val dx = (hyy * gx - hxy * gy) / det
val dy = (hxx * gy - hxy * gx) / det
val decrement = gx * dx + gy * dy
if (decrement < 1e-18) return done()
steps++
val before = value(x, y)
var alpha = 1.0
var accepted = false
for (tries in 0 until 80) {
val after = value(x + alpha * dx, y + alpha * dy)
if (after.isFinite() && after >= before + 1e-4 * alpha * decrement) {
x += alpha * dx
y += alpha * dy
accepted = true
break
}
alpha *= 0.5
}
if (!accepted) return done()
}
return done()
}
internal fun barrierDecrement
The Newton decrement gᵀH⁻¹g of the barrier objective at (x, y): how far, in Newton's own measure, the point is from the maximiser for this μ. Infinite outside. Used to pick the better of two starts.
internal fun barrierDecrement(
nx: DoubleArray, ny: DoubleArray, b: DoubleArray, w: DoubleArray,
cx: Double, cy: Double, mu: Double, x: Double, y: Double,
): Double {
var gx = cx
var gy = cy
var hxx = 0.0
var hxy = 0.0
var hyy = 0.0
for (i in nx.indices) {
val s = b[i] - nx[i] * x - ny[i] * y
if (s <= 0.0) return Double.POSITIVE_INFINITY
val z = mu * w[i] / s
gx -= z * nx[i]
gy -= z * ny[i]
val q = z / s
hxx += q * nx[i] * nx[i]
hxy += q * nx[i] * ny[i]
hyy += q * ny[i] * ny[i]
}
val det = hxx * hyy - hxy * hxy
if (!(det > 0.0) || !det.isFinite()) return Double.POSITIVE_INFINITY
return (gx * (hyy * gx - hxy * gy) + gy * (hxx * gy - hxy * gx)) / det
}
internal fun LpPolygon.checkedWeights(weights: DoubleArray?): DoubleArray
internal fun LpPolygon.checkedWeights(weights: DoubleArray?): DoubleArray {
val w = weights ?: DoubleArray(edgeCount) { 1.0 }
require(w.size == edgeCount) { "need one weight per edge: ${w.size} weights for $edgeCount edges" }
require(w.all { it.isFinite() && it > 0.0 }) { "edge weights must be positive and finite: ${w.toList()}" }
return w
}
fun LpPolygon.centralPath
The log-barrier central path: for μ falling geometrically from muHigh to muLow, the
maximiser of c·x + μ Σ wᵢ ln(slackᵢ), found by damped Newton warm-started from the previous
sample. At large μ it sits at the weighted analytic centre; as μ falls it bends toward the
optimal vertex for direction, which it approaches as μ → 0.
weights gives each edge's barrier weight (default all 1). Weights are an artistic control: a
heavier edge pushes the path away from itself, and weight k is exactly the same as repeating
that constraint k times, which is how redundant constraints change the path although they do
not change the polygon. Every sample is strictly feasible. LpSample.scale is μ.
@param samples how many values of μ, at least 2.
@param start where Newton starts for the first μ; must be strictly inside.
@param warm a previous central path with the same samples (typically for a slightly different
polygon, direction or weights). Newton for each μ then starts from that path's sample at the
same μ instead of from this path's previous sample, whenever its Newton decrement says it is
the closer of the two. The answer is the same — each sample is the unique barrier maximiser — but it takes
fewer Newton steps when the change is small (LpTrajectory.solverSteps). LpContinuation
threads this through an animation for you.
fun LpPolygon.centralPath(
direction: Vec2,
weights: DoubleArray? = null,
samples: Int = 160,
muHigh: Double = 1000.0,
muLow: Double = 1e-5,
start: Vec2 = center,
warm: LpTrajectory? = null,
): LpTrajectory {
require(samples >= 2) { "centralPath needs at least 2 samples, got $samples" }
require(muHigh > muLow && muLow > 0.0) { "centralPath needs muHigh > muLow > 0, got $muHigh and $muLow" }
require(containsStrictly(start.x.toDouble(), start.y.toDouble())) { "centralPath must start strictly inside the polygon; $start is not" }
val c = unit(direction)
val w = checkedWeights(weights)
val guide = warm?.samples?.takeIf { it.size == samples }
var p = doubleArrayOf(start.x.toDouble(), start.y.toDouble())
var steps = 0
val out = List(samples) { k ->
val mu = exp(ln(muHigh) + (ln(muLow) - ln(muHigh)) * k / (samples - 1))
// Start from whichever of the previous path's sample and this path's last one Newton
// judges closer, so a warm start is never worse than following the path itself.
val hint = guide?.get(k)?.takeIf {
barrierDecrement(normalX, normalY, bound, w, c[0], c[1], mu, it.x, it.y) <
barrierDecrement(normalX, normalY, bound, w, c[0], c[1], mu, p[0], p[1])
}
p = if (hint != null) barrierNewton(normalX, normalY, bound, w, c[0], c[1], mu, hint.x, hint.y)
else barrierNewton(normalX, normalY, bound, w, c[0], c[1], mu, p[0], p[1])
steps += p[2].toInt()
sample(c, p[0], p[1], k, mu, "central-path")
}
return LpTrajectory("central-path", out, steps)
}
class LpContinuation
A warm-started centralPath for a polygon, direction or weights that move smoothly between
calls — an animation's frames. From the third call on it extrapolates each sample linearly from
the previous two paths (2·last − before, a secant predictor) and passes that as
centralPath's warm; on the second call it passes the previous path itself. Newton then starts
next to the answer and needs fewer steps than following the path cold, for the same path.
last is the latest path and lastSteps its Newton step count; reset forgets the history,
which a jump should do. A prediction that does not fit (a different sample count, or a sample
Newton judges further off than the path's own previous sample) is ignored, so a jump costs a
cold start and nothing else.
class LpContinuation {
var last: LpTrajectory?
The path the previous call returned, or null before the first call and after reset.
var last: LpTrajectory? = null
private set
private var before: LpTrajectory? = null
val lastSteps: Int get() = last?.solverSteps ?: 0
Newton steps the previous call took.
fun centralPath
polygon's central path for direction, warm-started from the previous calls'.
fun centralPath(
polygon: LpPolygon,
direction: Vec2,
weights: DoubleArray? = null,
samples: Int = 160,
muHigh: Double = 1000.0,
muLow: Double = 1e-5,
): LpTrajectory {
val a = last
val b = before
val guess = if (a != null && b != null && a.samples.size == b.samples.size)
LpTrajectory("prediction", a.samples.indices.map { k ->
val s = a.samples[k]
s.copy(x = 2.0 * s.x - b.samples[k].x, y = 2.0 * s.y - b.samples[k].y)
})
else a
val path = polygon.centralPath(direction, weights, samples, muHigh, muLow, warm = guess)
before = a
last = path
return path
}
fun reset()
Forget the previous paths, so the next call starts cold.
fun reset() {
last = null
before = null
}
}
fun LpPolygon.centralFan
A fan of central paths: the objective direction turned through a full circle in angles equal
steps, starting at startAngle radians, one centralPath each. Every path leaves the same
weighted analytic centre and ends at the vertex that is optimal for its direction, so the fan
is a sampling of the surface x(θ, μ).
fun LpPolygon.centralFan(
angles: Int = 48,
samples: Int = 120,
weights: DoubleArray? = null,
startAngle: Double = 0.0,
muHigh: Double = 1000.0,
muLow: Double = 1e-5,
): List<LpTrajectory> {
require(angles > 0) { "centralFan needs at least one angle, got $angles" }
return List(angles) { i ->
val theta = startAngle + 2.0 * PI * i / angles
centralPath(vec(cos(theta), sin(theta)), weights, samples, muHigh, muLow)
}
}
fun LpPolygon.primalDual
Mehrotra's predictor–corrector primal-dual interior-point method, recording every Newton
iterate. Primal x starts at start with slacks s = b − Ax, so it stays strictly feasible;
the duals start at 1 and the dual residual Aᵀy − c is driven to zero alongside the duality
measure μ = sᵀy / m. Each step is the 2×2 normal-equation solve, with separate primal and dual
step lengths at fraction stepFraction of the distance to the boundary. Stops when μ and the
dual residual are below tolerance, or after maxIterations. LpSample.scale is μ.
Expect a handful of long strides rather than a smooth arc: this is the same central path, followed coarsely.
body — 81 lines, from line 574
fun LpPolygon.primalDual(
direction: Vec2,
maxIterations: Int = 60,
tolerance: Double = 1e-9,
stepFraction: Double = 0.95,
start: Vec2 = center,
): LpTrajectory {
require(maxIterations >= 0) { "maxIterations must be non-negative, got $maxIterations" }
require(stepFraction > 0.0 && stepFraction < 1.0) { "stepFraction must be in (0, 1), got $stepFraction" }
require(containsStrictly(start.x.toDouble(), start.y.toDouble())) { "primalDual must start strictly inside the polygon; $start is not" }
val c = unit(direction)
val m = edgeCount
var x = start.x.toDouble()
var y = start.y.toDouble()
val s = DoubleArray(m) { slack(it, x, y) }
val z = DoubleArray(m) { 1.0 }
val out = ArrayList<LpSample>()
fun mu() = (0 until m).sumOf { s[it] * z[it] } / m
out.add(sample(c, x, y, 0, mu(), "primal-dual"))
val ds = DoubleArray(m)
val dz = DoubleArray(m)
val rc = DoubleArray(m)
// Solves the Newton system for complementarity right-hand side rc; rp is zero by construction.
fun direction(rd0: Double, rd1: Double): DoubleArray {
var hxx = 0.0
var hxy = 0.0
var hyy = 0.0
var r0 = -rd0
var r1 = -rd1
for (i in 0 until m) {
val q = z[i] / s[i]
hxx += q * normalX[i] * normalX[i]
hxy += q * normalX[i] * normalY[i]
hyy += q * normalY[i] * normalY[i]
r0 -= normalX[i] * rc[i] / s[i]
r1 -= normalY[i] * rc[i] / s[i]
}
val det = hxx * hyy - hxy * hxy
check(det > 0.0 && det.isFinite()) { "degenerate primal-dual normal matrix (det = $det)" }
val dx = (hyy * r0 - hxy * r1) / det
val dy = (hxx * r1 - hxy * r0) / det
for (i in 0 until m) {
ds[i] = -(normalX[i] * dx + normalY[i] * dy)
dz[i] = (rc[i] - z[i] * ds[i]) / s[i]
}
return doubleArrayOf(dx, dy)
}
fun maxStep(v: DoubleArray, dv: DoubleArray): Double {
var a = 1.0
for (i in v.indices) if (dv[i] < 0.0) a = min(a, -v[i] / dv[i])
return a
}
for (k in 1..maxIterations) {
val rd0 = (0 until m).sumOf { normalX[it] * z[it] } - c[0]
val rd1 = (0 until m).sumOf { normalY[it] * z[it] } - c[1]
val muNow = mu()
if (muNow < tolerance && hypot(rd0, rd1) < tolerance) break
// Predictor: the affine-scaling direction, aiming straight at complementarity.
for (i in 0 until m) rc[i] = -s[i] * z[i]
direction(rd0, rd1)
val ap = maxStep(s, ds)
val ad = maxStep(z, dz)
val muAff = (0 until m).sumOf { (s[it] + ap * ds[it]) * (z[it] + ad * dz[it]) } / m
val sigma = (muAff / muNow).let { it * it * it }
// Corrector: recentre by sigma·mu and cancel the predictor's second-order term.
for (i in 0 until m) rc[i] = -s[i] * z[i] - ds[i] * dz[i] + sigma * muNow
val d = direction(rd0, rd1)
val alphaP = min(1.0, stepFraction * maxStep(s, ds))
val alphaD = min(1.0, stepFraction * maxStep(z, dz))
x += alphaP * d[0]
y += alphaP * d[1]
for (i in 0 until m) {
s[i] = slack(i, x, y)
z[i] += alphaD * dz[i]
}
out.add(sample(c, x, y, k, mu(), "primal-dual"))
}
return LpTrajectory("primal-dual", out)
}
fun LpPolygon.pdhg
Primal-dual hybrid gradient (Chambolle–Pock) on the saddle problem
max_x min_{y ≥ 0} c·x − yᵀ(Ax − b), keeping every primal and dual state, infeasible ones
included. Each step projects the dual y ← max(0, y + σ(A x̄ − b)), moves the primal
x ← x + τ(c − Aᵀy), and extrapolates x̄ = x + θ(x − x_prev).
The primal walk is the interesting curve: it overshoots the polygon, is pulled back by the edge
duals and spirals into the optimal vertex. Larger tau and sigma give wider spirals; with
τσ‖A‖² ≥ 1 it may oscillate or diverge, which is kept, not hidden. A non-finite iterate ends
the trace. LpSample.scale is the dual vector's Euclidean norm.
fun LpPolygon.pdhg(
direction: Vec2,
iterations: Int = 1000,
tau: Double = 0.15,
sigma: Double = 0.15,
theta: Double = 1.0,
start: Vec2 = center,
): LpPdhgTrace {
require(iterations >= 0) { "iterations must be non-negative, got $iterations" }
require(tau > 0.0 && sigma > 0.0 && theta >= 0.0) { "pdhg needs tau > 0, sigma > 0, theta >= 0; got $tau, $sigma, $theta" }
val c = unit(direction)
val m = edgeCount
val dual = DoubleArray(m)
var x = start.x.toDouble()
var y = start.y.toDouble()
var bx = x
var by = y
fun dualNorm() = sqrt(dual.sumOf { it * it })
val samples = arrayListOf(sample(c, x, y, 0, 0.0, "pdhg"))
val duals = arrayListOf(dual.copyOf())
for (k in 1..iterations) {
for (i in 0 until m) dual[i] = max(0.0, dual[i] - sigma * slack(i, bx, by))
var gx = c[0]
var gy = c[1]
for (i in 0 until m) {
gx -= normalX[i] * dual[i]
gy -= normalY[i] * dual[i]
}
val nx = x + tau * gx
val ny = y + tau * gy
if (!nx.isFinite() || !ny.isFinite()) break
bx = nx + theta * (nx - x)
by = ny + theta * (ny - y)
x = nx
y = ny
samples.add(sample(c, x, y, k, dualNorm(), "pdhg"))
duals.add(dual.copyOf())
}
return LpPdhgTrace(LpTrajectory("pdhg", samples), duals)
}
fun LpPolygon.ellipsoid
The central-cut ellipsoid method, returning every centre and every ellipse. It starts from a
disc of initialRadius (default: the polygon's diameter) around start. At an infeasible
centre it cuts on the most violated edge; at a feasible one it cuts on the objective, keeping the
half where c·x is at least its current value. Each central cut in two dimensions moves the
centre by −Pg / (3√(gᵀPg)) and replaces P by 4/3 · (P − 2/3 · (Pg)(Pg)ᵀ / gᵀPg), which
multiplies the area by √(16/27) ≈ 0.77 every step. LpSample.tag is "feasibility-cut" or
"objective-cut" for the cut taken at that centre ("final" for the last), and
LpSample.scale is √det P.
This is an exploratory trajectory, not a high-accuracy solver: its centres are often outside the polygon, and the best feasible centre, not the last, is the method's answer.
body — 48 lines, from line 721
fun LpPolygon.ellipsoid(
direction: Vec2,
iterations: Int = 80,
initialRadius: Double? = null,
start: Vec2 = center,
): LpEllipsoidTrace {
require(iterations >= 0) { "iterations must be non-negative, got $iterations" }
val c = unit(direction)
val radius = initialRadius ?: vertices.maxOf { a -> vertices.maxOf { b -> a.distance(b).toDouble() } }
require(radius > 0.0 && radius.isFinite()) { "initialRadius must be positive, got $radius" }
var x = start.x.toDouble()
var y = start.y.toDouble()
var pxx = radius * radius
var pxy = 0.0
var pyy = radius * radius
val samples = ArrayList<LpSample>()
val ellipses = ArrayList<LpEllipse>()
for (k in 0..iterations) {
val det = pxx * pyy - pxy * pxy
ellipses.add(LpEllipse(x, y, pxx, pxy, pyy))
var worst = -1
var worstViolation = 0.0
for (i in 0 until edgeCount) {
val v = -slack(i, x, y)
if (v > worstViolation) {
worstViolation = v
worst = i
}
}
val tag = if (k == iterations) "final" else if (worst >= 0) "feasibility-cut" else "objective-cut"
samples.add(sample(c, x, y, k, sqrt(max(det, 0.0)), tag))
if (k == iterations) break
val gx = if (worst >= 0) normalX[worst] else -c[0]
val gy = if (worst >= 0) normalY[worst] else -c[1]
val ux = pxx * gx + pxy * gy
val uy = pxy * gx + pyy * gy
val gpg = gx * ux + gy * uy
if (gpg <= 1e-280 || !gpg.isFinite()) break
val norm = sqrt(gpg)
x -= ux / (3.0 * norm)
y -= uy / (3.0 * norm)
val factor = 4.0 / 3.0
pxx = factor * (pxx - (2.0 / 3.0) * ux * ux / gpg)
pxy = factor * (pxy - (2.0 / 3.0) * ux * uy / gpg)
pyy = factor * (pyy - (2.0 / 3.0) * uy * uy / gpg)
}
return LpEllipsoidTrace(LpTrajectory("ellipsoid", samples), ellipses)
}
fun LpPolygon.simplexWalk(direction: Vec2, startVertex: Int = 0): LpTrajectory
The simplex method in two dimensions: a walk along the boundary from startVertex, moving to
whichever neighbouring vertex improves c·x more, until neither does. It ends at the optimal
vertex, having visited at most half the polygon. LpSample.scale is NaN.
fun LpPolygon.simplexWalk(direction: Vec2, startVertex: Int = 0): LpTrajectory {
require(startVertex in vertices.indices) { "startVertex must be in 0 until $edgeCount, got $startVertex" }
val c = unit(direction)
val n = edgeCount
fun value(i: Int) = c[0] * vertices[i].x + c[1] * vertices[i].y
val out = ArrayList<LpSample>()
var i = startVertex
for (step in 0..n) {
out.add(sample(c, vertices[i].x.toDouble(), vertices[i].y.toDouble(), step, Double.NaN, "simplex"))
val next = listOf((i + n - 1) % n, (i + 1) % n).maxBy { value(it) }
if (value(next) > value(i) + 1e-12) i = next else break
}
return LpTrajectory("simplex", out)
}
The part of convex region with nx·x + ny·y ≤ b (one Sutherland–Hodgman pass).
private fun clipConvex(region: List<DoubleArray>, nx: Double, ny: Double, b: Double): List<DoubleArray> {
val out = ArrayList<DoubleArray>()
for (i in region.indices) {
val p = region[i]
val q = region[(i + 1) % region.size]
val sp = b - nx * p[0] - ny * p[1]
val sq = b - nx * q[0] - ny * q[1]
if (sp >= 0.0) out.add(p)
if ((sp >= 0.0) != (sq >= 0.0)) {
val t = sp / (sp - sq)
out.add(doubleArrayOf(p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1])))
}
}
return out
}
internal fun chebyshevCenter(nx: DoubleArray, ny: DoubleArray, b: DoubleArray): DoubleArray
The Chebyshev centre of { x : nᵢ·x ≤ bᵢ } with unit normals: maximise r subject to nᵢ·x + r ≤ bᵢ. That is a three-variable LP whose bounded optimum has three tight constraints, so for the few dozen constraints a cutting-plane run accumulates it is enough to solve each triple's 3×3 system (Cramer's rule) and keep the feasible solution with the largest r. Returns (x, y, r).
internal fun chebyshevCenter(nx: DoubleArray, ny: DoubleArray, b: DoubleArray): DoubleArray {
val m = nx.size
var best: DoubleArray? = null
for (i in 0 until m) for (j in i + 1 until m) for (k in j + 1 until m) {
val det = nx[i] * (ny[j] - ny[k]) - ny[i] * (nx[j] - nx[k]) + (nx[j] * ny[k] - nx[k] * ny[j])
if (abs(det) < 1e-12) continue
val dx = b[i] * (ny[j] - ny[k]) - ny[i] * (b[j] - b[k]) + (b[j] * ny[k] - b[k] * ny[j])
val dy = nx[i] * (b[j] - b[k]) - b[i] * (nx[j] - nx[k]) + (nx[j] * b[k] - nx[k] * b[j])
val dr = nx[i] * (ny[j] * b[k] - ny[k] * b[j]) - ny[i] * (nx[j] * b[k] - nx[k] * b[j]) + b[i] * (nx[j] * ny[k] - nx[k] * ny[j])
val x = dx / det
val y = dy / det
val r = dr / det
if (r <= 0.0 || (best != null && r <= best[2])) continue
val tolerance = 1e-9 * (1.0 + abs(r))
if ((0 until m).all { nx[it] * x + ny[it] * y + r <= b[it] + tolerance }) best = doubleArrayOf(x, y, r)
}
return checkNotNull(best) { "chebyshevCenter: the region has no interior" }
}
internal fun volumetricCenter(nx: DoubleArray, ny: DoubleArray, b: DoubleArray, x0: Double, y0: Double, maxIterations: Int = 100): DoubleArray
Vaidya's volumetric centre: minimise V(x) = ½ ln det H(x), H = Σ aᵢaᵢᵀ / sᵢ², over the interior. ∇V = Σ σᵢ aᵢ / sᵢ with leverages σᵢ = aᵢᵀH⁻¹aᵢ / sᵢ²; the Newton step uses Vaidya's approximation Q = Σ σᵢ aᵢaᵢᵀ / sᵢ² of the Hessian (Q ≼ ∇²V ≼ 5Q), with backtracking on V, from a strictly feasible start (the analytic centre).
body — 78 lines, from line 838
internal fun volumetricCenter(nx: DoubleArray, ny: DoubleArray, b: DoubleArray, x0: Double, y0: Double, maxIterations: Int = 100): DoubleArray {
var x = x0
var y = y0
fun hessian(px: Double, py: Double): DoubleArray? {
var hxx = 0.0
var hxy = 0.0
var hyy = 0.0
for (i in nx.indices) {
val s = b[i] - nx[i] * px - ny[i] * py
if (s <= 0.0) return null
val q = 1.0 / (s * s)
hxx += q * nx[i] * nx[i]
hxy += q * nx[i] * ny[i]
hyy += q * ny[i] * ny[i]
}
return doubleArrayOf(hxx, hxy, hyy)
}
fun value(px: Double, py: Double): Double {
val h = hessian(px, py) ?: return Double.POSITIVE_INFINITY
return 0.5 * ln(h[0] * h[2] - h[1] * h[1])
}
repeat(maxIterations) {
val h = hessian(x, y) ?: return doubleArrayOf(x, y)
val det = h[0] * h[2] - h[1] * h[1]
var gx = 0.0
var gy = 0.0
var qxx = 0.0
var qxy = 0.0
var qyy = 0.0
for (i in nx.indices) {
val s = b[i] - nx[i] * x - ny[i] * y
val leverage = (h[2] * nx[i] * nx[i] - 2.0 * h[1] * nx[i] * ny[i] + h[0] * ny[i] * ny[i]) / det / (s * s)
gx += leverage * nx[i] / s
gy += leverage * ny[i] / s
val q = leverage / (s * s)
qxx += q * nx[i] * nx[i]
qxy += q * nx[i] * ny[i]
qyy += q * ny[i] * ny[i]
}
val qdet = qxx * qyy - qxy * qxy
if (!(qdet > 0.0) || !qdet.isFinite()) return doubleArrayOf(x, y)
val dx = -(qyy * gx - qxy * gy) / qdet
val dy = -(qxx * gy - qxy * gx) / qdet
val decrement = -(gx * dx + gy * dy)
if (decrement < 1e-18) return doubleArrayOf(x, y)
val before = value(x, y)
var accepted = false
var alpha = 1.0
for (tries in 0 until 80) {
val after = value(x + alpha * dx, y + alpha * dy)
if (after.isFinite() && after <= before - 1e-4 * alpha * decrement) {
x += alpha * dx
y += alpha * dy
accepted = true
break
}
alpha *= 0.5
}
if (!accepted) return doubleArrayOf(x, y)
}
return doubleArrayOf(x, y)
}
private fun areaAndCentroid(region: List<DoubleArray>): DoubleArray {
var a2 = 0.0
var cx = 0.0
var cy = 0.0
for (i in region.indices) {
val p = region[i]
val q = region[(i + 1) % region.size]
val cr = p[0] * q[1] - q[0] * p[1]
a2 += cr
cx += (p[0] + q[0]) * cr
cy += (p[1] + q[1]) * cr
}
if (abs(a2) < 1e-300) return doubleArrayOf(0.0, region.sumOf { it[0] } / region.size, region.sumOf { it[1] } / region.size)
return doubleArrayOf(a2 / 2.0, cx / (3.0 * a2), cy / (3.0 * a2))
}
fun LpPolygon.cuttingPlanes
A cutting-plane walk: find a centre of the region still in play, cut the region with the
objective (c·x ≥ c·centre), and repeat. With LpCenter.ANALYTIC the centre is the analytic
centre of every constraint so far, cuts included, so earlier cuts keep pushing on the path just
as extra constraints push a central path; with LpCenter.CENTROID it is the centre of gravity,
which removes at least 1 − (2/3)² of the area each step; with LpCenter.CHEBYSHEV it is the
centre of the largest inscribed disc, and with LpCenter.VOLUMETRIC Vaidya's volumetric centre
of every constraint so far. The trajectory approaches the optimal
vertex from inside; it stops when the remaining area falls below minArea or after
iterations. LpSample.scale is the remaining area, and LpCuttingPlaneTrace.regions holds
the region at every step.
body — 45 lines, from line 929
fun LpPolygon.cuttingPlanes(
direction: Vec2,
iterations: Int = 40,
center: LpCenter = LpCenter.ANALYTIC,
minArea: Double = 1e-10,
): LpCuttingPlaneTrace {
require(iterations >= 0) { "iterations must be non-negative, got $iterations" }
val c = unit(direction)
var region = vertices.map { doubleArrayOf(it.x.toDouble(), it.y.toDouble()) }
val nx = normalX.toMutableList()
val ny = normalY.toMutableList()
val b = bound.toMutableList()
val samples = ArrayList<LpSample>()
val regions = ArrayList<List<Vec2>>()
for (k in 0..iterations) {
val ac = areaAndCentroid(region)
val area = abs(ac[0])
val p = when (center) {
LpCenter.CENTROID -> doubleArrayOf(ac[1], ac[2])
LpCenter.ANALYTIC, LpCenter.VOLUMETRIC -> {
// The region's vertex mean is strictly inside it, so Newton can start there.
val sx = region.sumOf { it[0] } / region.size
val sy = region.sumOf { it[1] } / region.size
val ax = nx.toDoubleArray()
val ay = ny.toDoubleArray()
val bounds = b.toDoubleArray()
val analytic = barrierNewton(ax, ay, bounds, DoubleArray(ax.size) { 1.0 }, 0.0, 0.0, 1.0, sx, sy)
if (center == LpCenter.ANALYTIC) analytic else volumetricCenter(ax, ay, bounds, analytic[0], analytic[1])
}
LpCenter.CHEBYSHEV -> chebyshevCenter(nx.toDoubleArray(), ny.toDoubleArray(), b.toDoubleArray())
}
samples.add(sample(c, p[0], p[1], k, area, "cutting-plane"))
regions.add(region.map { vec(it[0], it[1]) })
if (k == iterations || area < minArea) break
// Keep c·x ≥ c·p, i.e. (−c)·x ≤ −c·p.
val cut = -(c[0] * p[0] + c[1] * p[1])
val next = clipConvex(region, -c[0], -c[1], cut)
if (next.size < 3) break
region = next
nx.add(-c[0])
ny.add(-c[1])
b.add(cut)
}
return LpCuttingPlaneTrace(LpTrajectory("cutting-plane-${center.name.lowercase()}", samples), regions)
}
fun List<LpTrajectory>.toFLines(alpha: Double = 0.8, thickness: Double = 1.1): List<FLine>
Every path of a fan as an FLine, coloured round a hue circle so neighbouring angles read apart.
fun List<LpTrajectory>.toFLines(alpha: Double = 0.8, thickness: Double = 1.1): List<FLine> =
mapIndexed { i, t ->
val h = 2.0 * PI * i / size
t.toFLine(vec(0.6 + 0.4 * cos(h), 0.6 + 0.4 * cos(h - 2.1), 0.6 + 0.4 * cos(h + 2.1), alpha), thickness)
}