Solver.kt
src/jvmMain/kotlin/afield/simulation/physics/pressure/Solver.kt · 155 lines
package afield.simulation.physics.pressure
import afield.math.autodiff.ADGradient
import afield.math.autodiff.OptimizeMethod
import afield.math.autodiff.OptimizeOptions
import afield.math.autodiff.minimizeWithGradient
import java.util.concurrent.CancellationException
import kotlin.math.*
InflationSolver relaxes an Envelope to equilibrium by raising pressure in steps and
minimising the Mechanics energy at each one with Field's L-BFGS.
A step is accepted on free-force residuals in newtons, not on optimiser step size, and the
Solution reports anchor reactions, force and moment balance and the step Solution.history.
A failed continuation returns unconverged at the load fraction it reached. SolveOptions
sets the step count, tolerances and the optional Newton polish.
data class SolveOptions(val pressureSteps: Int = 8, val iterationsPerStep: Int = 6000,
Continuation and stopping settings for InflationSolver.solve.
data class SolveOptions(val pressureSteps: Int = 8, val iterationsPerStep: Int = 6000,
val relativeTolerance: Double = 2e-5, val absoluteTolerance: Double = .02,
val minimumStep: Double = 1.0/128, val precondition: Boolean = false,
val physicalStopping: Boolean = true, val newtonPolish: Boolean = true, val fastKernel: Boolean = true) {
init
init { require(pressureSteps>0 && iterationsPerStep>0 && relativeTolerance>0 && absoluteTolerance>0 && minimumStep>0 && minimumStep<=1.0 && listOf(relativeTolerance,absoluteTolerance,minimumStep).all {it.isFinite()}) }
}
data class InflationStep(val fraction: Double,val pressure: Double,val volume: Double,
data class InflationStep(val fraction: Double,val pressure: Double,val volume: Double,
val residualN: Double,val relativeResidual: Double,val iterations: Int,
val accepted: Boolean,val reason: String,val positions: DoubleArray)
data class AnchorReaction(val anchor: Anchor,val force: P3)
data class Solution(val model: Envelope,val load: LoadCase,val positions: DoubleArray,
data class Solution(val model: Envelope,val load: LoadCase,val positions: DoubleArray,
val evaluation: Evaluation,val history: List<InflationStep>,
val converged: Boolean,val reason: String,val elapsedMs: Double,
val reactions: List<AnchorReaction>,val balanceN: Double,val momentNm: Double,
val residualN: Double,val relativeResidual: Double,val minimum: MinimumAudit?=null,
val trace: SolveTrace = SolveTrace()) {
val maximumTapeForce get() = evaluation.tapes.maxOfOrNull { it.tension } ?: 0.0
val maximumFilmForce get() = evaluation.films.maxOf { it.major }
val maximumStrain get() = evaluation.films.maxOf { it.strain }
data class SolveProgress(val iteration:Int,val fraction:Double,val residualN:Double,val relativeResidual:Double,val evaluations:Int,val method:String)
Uses Field's existing L-BFGS; only physical residuals can declare an equilibrium accepted.
data class SolveProgress(val iteration:Int,val fraction:Double,val residualN:Double,val relativeResidual:Double,val evaluations:Int,val method:String)
class SolveTrace
var evaluations
var evaluations=0;var iterations=0;var backtracks=0;var skippedCurvature=0;var newtonIterations=0
val rows=ArrayList<String>()
override fun toString()
override fun toString()="evaluations=$evaluations iterations=$iterations backtracks=$backtracks skippedCurvature=$skippedCurvature newton=$newtonIterations"
}
object InflationSolver
object InflationSolver {
fun solve(model: Envelope, load: LoadCase = LoadCase(), options: SolveOptions = SolveOptions(),
Relax a supplied closed membrane by pressure continuation. initialPositions enables a full-load warm start.
Acceptance uses free-force residuals in N, not optimizer step size. Failed continuation returns an
unconverged result with its actual load fraction in Solution.history. Cancellation propagates.
A converged force balance is not proof of a minimum; MinimumSearch offers a sampled diagnostic.
body — 100 lines, from line 55
fun solve(model: Envelope, load: LoadCase = LoadCase(), options: SolveOptions = SolveOptions(),
cancelled: () -> Boolean = { false }, initialPositions: DoubleArray? = null, onProgress:(SolveProgress)->Unit={}, onStep: (InflationStep) -> Unit = {}): Solution {
require(load.gravity.norm()==0.0 || model.anchors.isNotEmpty() || model.tethers.isNotEmpty() || load.groundY!=null) {
"Gravity needs supports/contact; an unsupported body has no static equilibrium" }
val start=System.nanoTime(); val mechanics=Mechanics(model,load)
val trace=SolveTrace()
val fast=if(options.fastKernel) FastMechanics(model,load) else null
fun evaluate(x:DoubleArray,fraction:Double):Evaluation {
if(cancelled()) throw CancellationException("Inflation cancelled")
trace.evaluations++
return fast?.evaluate(x,fraction) ?: mechanics.evaluate(x,fraction,false)
}
val fixed=model.anchors.filter { it.stiffness==null }.associateBy { it.node }
val free=model.initial.indices.filter { it/3 !in fixed }.toIntArray()
require(free.isNotEmpty())
// Change optimization coordinates only; residual acceptance remains in newtons.
val diagonal=if(options.precondition) MinimumSearch.diagonal(model) else DoubleArray(model.initial.size) {1.0}
val typical=free.map {diagonal[it]}.sorted()[free.size/2]
val coordinateScale=DoubleArray(free.size) {sqrt(typical/diagonal[free[it]])}
fun full(q: DoubleArray): DoubleArray = model.initial.copyOf().also { x ->
free.forEachIndexed { i,k -> x[k]=q[i]*coordinateScale[i] }; fixed.values.forEach { put(x,it.node,it.target) } }
require(initialPositions==null || initialPositions.size==model.initial.size && initialPositions.all { it.isFinite() })
val initial=(initialPositions ?: model.initial).copyOf().also { x->fixed.values.forEach { put(x,it.node,it.target) } }
var q=DoubleArray(free.size) { initial[free[it]]/coordinateScale[it] }
val scale=max(1.0,model.triangles.sumOf { it.area*it.film.young*it.film.thickness })
val history=ArrayList<InflationStep>()
var fraction=0.0; var increment=if(initialPositions==null) 1.0/options.pressureSteps else 1.0
var target=if(load.pressure==0.0) 1.0 else increment
var lastPositions=initial; var reason="not started"; var converged=false
var lastResidual=Double.POSITIVE_INFINITY; var lastRelative=Double.POSITIVE_INFINITY
while(true) {
if(cancelled()) throw CancellationException("Inflation cancelled")
var latest:Evaluation?=null
val run=minimizeWithGradient(q,OptimizeOptions(method=OptimizeMethod.LBFGS,
iterations=options.iterationsPerStep,memory=16,maxLineSearchSteps=48,
gradientTolerance=options.absoluteTolerance/(scale*sqrt(free.size.toDouble())),stepTolerance=1e-15,showProgress=false,
recordTrajectory=false), observer={ p ->
val e=latest!!
val r=sqrt(free.sumOf { e.gradient[it]*e.gradient[it] })
val relative=r/max(1.0,sqrt(free.sumOf { e.pressureForces[it]*e.pressureForces[it] }))
trace.backtracks+=p.lineSearchReductions
if(p.curvatureAccepted==false) trace.skippedCurvature++
if(p.iteration%25==0) {
trace.rows.add("lbfgs,$target,${p.iteration},${p.evaluations},$r,$relative,${p.value},${p.stepNorm},${p.lineSearchReductions},${p.curvatureAccepted}")
onProgress(SolveProgress(p.iteration,target,r,relative,trace.evaluations,"L-BFGS"))
}
options.physicalStopping && (r<=options.absoluteTolerance || target>0 && relative<=options.relativeTolerance)
}) { u ->
if(cancelled()) throw CancellationException("Inflation cancelled")
try {
val e=evaluate(full(u),target);latest=e
ADGradient(e.energy/scale,DoubleArray(free.size) { e.gradient[free[it]]*coordinateScale[it]/scale })
} catch(e: IllegalArgumentException) { ADGradient(1e100,DoubleArray(free.size)) }
}
trace.iterations+=run.iterations
trace.rows.add("stop,$target,${run.iterations},${run.evaluations},${run.reason}")
var positions=full(run.x)
var evaluation=evaluate(positions,target)
val before=sqrt(free.sumOf {evaluation.gradient[it].pow(2)})
val gate=max(options.absoluteTolerance,options.relativeTolerance*sqrt(free.sumOf {evaluation.pressureForces[it].pow(2)}))
if(options.newtonPolish && before>gate && before<max(100.0,gate*100)) {
positions=NewtonPolish.improve(model,positions,target,options,{evaluate(it,target)},cancelled,trace,onProgress)
evaluation=evaluate(positions,target)
}
if(fast!=null) evaluation=mechanics.evaluate(positions,target,false)
val residual=sqrt(free.sumOf { evaluation.gradient[it].pow(2) })
val pressureNorm=sqrt(free.sumOf { evaluation.pressureForces[it].pow(2) })
val relative=residual/max(1.0,pressureNorm)
val accepted=residual<=options.absoluteTolerance || target>0 && relative<=options.relativeTolerance
val step=InflationStep(target,evaluation.pressure,evaluation.volume,residual,relative,
run.iterations,accepted,run.reason,positions.copyOf())
history.add(step); onStep(step)
if(accepted) {
q=DoubleArray(free.size) {positions[free[it]]/coordinateScale[it]}; lastPositions=positions; fraction=target
lastResidual=residual; lastRelative=relative
if(target>=1.0) { converged=true; reason="force residual satisfied at full pressure"; break }
target=min(1.0,fraction+increment)
} else if(initialPositions!=null || target==0.0 || increment<=options.minimumStep) {
lastPositions=positions; lastResidual=residual; lastRelative=relative
reason="Unconverged at inflation fraction $target (${run.reason})"; break
} else {
increment*=.5; target=min(1.0,fraction+increment)
}
}
val evaluation=mechanics.evaluate(lastPositions,target,true)
val reactions=model.anchors.map { a -> AnchorReaction(a,if(a.stiffness==null) point(evaluation.gradient,a.node)
else (a.target-point(lastPositions,a.node))*a.stiffness) }
var force=P3(0.0,0.0,0.0); var moment=force
// Resultant of pressure + gravity + ground + anchor forces, about model centroid.
val origin=(0 until model.nodes).fold(P3(0.0,0.0,0.0)) { s,n->s+point(lastPositions,n) }*(1.0/model.nodes)
for(n in 0 until model.nodes) {
val f=point(evaluation.pressureForces,n)+load.gravity*model.mass[n]+point(evaluation.contact,n)
force+=f; moment+=(point(lastPositions,n)-origin).cross(f)
}
reactions.forEach { force+=it.force; moment+=(point(lastPositions,it.anchor.node)-origin).cross(it.force) }
model.tethers.forEach { val f=it.force(lastPositions); force+=f; moment+=(it.attachment.at(lastPositions)-origin).cross(f) }
return Solution(model,load,lastPositions,evaluation,history,converged,reason,
(System.nanoTime()-start)/1e6,reactions,force.norm(),moment.norm(),lastResidual,lastRelative,trace=trace)
}
}