Skip to content

MinimumSearch.kt

src/jvmMain/kotlin/afield/simulation/physics/pressure/MinimumSearch.kt · 112 lines

package afield.simulation.physics.pressure

import org.apache.commons.math4.legacy.linear.EigenDecomposition
import java.util.Random
import java.util.concurrent.CancellationException
import kotlin.math.*

A sampled check of whether a solved membrane sits at an energy minimum: MinimumSearch estimates the lowest curvature with a finite-difference Lanczos iteration and tries displacements along it and along eight smoothed random directions.

The result is a MinimumAudit, which also folds in a ShapeAudit of the deformed skin. A positive Ritz value is not a stability certificate; the audit reports whether a lower-energy direction was found in the directions it sampled.

data class MinimumAudit(val energy: Double,val curvature: Double,val curvatureAtDoubleStep: Double,

The outcome of MinimumSearch: sampled curvature, the best energy drop found, and the shape checks.

data class MinimumAudit(val energy: Double,val curvature: Double,val curvatureAtDoubleStep: Double,
                        val ritzResidual: Double,val directions: Int,val testedDisplacements: Int,
                        val bestEnergyDrop: Double,val escape: DoubleArray?,val shape: ShapeAudit,
                        val rows: List<String> = emptyList()) {

val negativeCurvature get()=curvature < -1e-2 && curvatureAtDoubleStep < -1e-2

val title get()

    val title get()=when {
        shape.intersections.isNotEmpty()->"Intersecting skin — rejected"
        negativeCurvature || bestEnergyDrop>max(1e-4,abs(energy)*1e-10)->"Lower-energy direction remains"
        shape.foldedEdges>0->"Sharp folds remain — unresolved"
        else->"No descent found in sampled directions"
    }
}

object MinimumSearch

Finite-difference Hessian/Lanczos audit. A positive Ritz value is NOT a stability certificate.

data class Ritz(val value: Double,val vector: DoubleArray,val residual: Double,val steps: Int)

fun lowest(n: Int,apply: (DoubleArray)->DoubleArray,steps: Int

    fun lowest(n: Int,apply: (DoubleArray)->DoubleArray,steps: Int=96,seed: Long=47): Ritz {
        val random=Random(seed);val basis=ArrayList<DoubleArray>();val alpha=ArrayList<Double>();val beta=ArrayList<Double>()
        var q=DoubleArray(n) { random.nextGaussian() };normalise(q)
        for(k in 0 until min(n,steps)) {
            basis.add(q);val v=apply(q);val a=dot(q,v);alpha.add(a)
            for(i in 0 until n) {v[i]-=a*q[i];if(k>0) v[i]-=beta[k-1]*basis[k-1][i]}
            // Full reorthogonalisation avoids spurious duplicate Ritz values in stiff meshes.
            repeat(2) {for(b in basis) {val projection=dot(b,v);for(i in 0 until n) v[i]-=projection*b[i]}}
            val length=sqrt(dot(v,v));if(length<1e-12 || k+1==min(n,steps)) break
            beta.add(length);q=DoubleArray(n) { v[it]/length }
        }
        val eigen=EigenDecomposition(alpha.toDoubleArray(),beta.toDoubleArray())
        val index=alpha.indices.minBy { eigen.getRealEigenvalue(it) };val coefficients=eigen.getEigenvector(index).toArray()
        val vector=DoubleArray(n) { i->basis.indices.sumOf { coefficients[it]*basis[it][i] } };normalise(vector)
        val value=dot(vector,apply(vector));val av=apply(vector)
        return Ritz(value,vector,sqrt(av.indices.sumOf { (av[it]-value*vector[it]).pow(2) }),basis.size)
    }
    private fun dot(a: DoubleArray,b: DoubleArray)=a.indices.sumOf { a[it]*b[it] }
    private fun normalise(x: DoubleArray) {val n=sqrt(dot(x,x));if(n>0) for(i in x.indices) x[i]/=n}

internal fun diagonal(m: Envelope): DoubleArray

    internal fun diagonal(m: Envelope): DoubleArray {
        val d=DoubleArray(m.initial.size) { 1.0 }
        m.triangles.forEach { f->for(j in 0..2) for(k in 0..2) d[3*f.nodes()[j]+k]+=f.film.young*f.film.thickness*f.area*(f.du[j].pow(2)+f.dv[j].pow(2)) }
        m.tapes.forEach { t->for(p in listOf(t.startPoint(),t.endPoint())) for(j in p.nodes.indices) for(k in 0..2) d[3*p.nodes[j]+k]+=t.ea/t.rest*p.weights[j].pow(2) }
        m.tethers.forEach { t->for(j in t.attachment.nodes.indices) for(k in 0..2) d[3*t.attachment.nodes[j]+k]+=t.ea/t.rest*t.attachment.weights[j].pow(2) }
        return d
    }

fun audit(s: Solution,cancelled: ()->Boolean

    fun audit(s: Solution,cancelled: ()->Boolean={false},steps: Int=96): MinimumAudit {
        require(s.converged) {"Minimum audit requires full-pressure force balance"}
        val m=s.model;val x=s.positions;val mechanics=Mechanics(m,s.load);val base=mechanics.evaluate(x,details=false)
        val fixed=m.anchors.filter { it.stiffness==null }.map { it.node }.toSet();val free=x.indices.filter { it/3 !in fixed }
        val diag=diagonal(m);val inv=DoubleArray(free.size) { 1/sqrt(diag[free[it]]) }
        fun full(v: DoubleArray)=DoubleArray(x.size).also { out->free.indices.forEach { out[free[it]]=v[it]*inv[it] } }
        fun hv(v: DoubleArray,h: Double): DoubleArray {
            if(cancelled()) throw CancellationException()
            val scale=max(1e-20,v.maxOf { abs(it) });val step=h/scale
            val plus=mechanics.evaluate(DoubleArray(x.size) { x[it]+step*v[it] },details=false).gradient
            val minus=mechanics.evaluate(DoubleArray(x.size) { x[it]-step*v[it] },details=false).gradient
            return DoubleArray(x.size) { (plus[it]-minus[it])/(2*step) }
        }
        val ritz=lowest(free.size,{v->val h=hv(full(v),1e-5);DoubleArray(free.size) { h[free[it]]*inv[it] }},steps)
        val direction=full(ritz.vector);normalise(direction)
        val curvature=dot(direction,hv(direction,1e-5));val curvature2=dot(direction,hv(direction,2e-5))
        val directions=ArrayList<DoubleArray>();directions.add(direction)
        val neighbours=Array(m.nodes) { HashSet<Int>() };m.triangles.forEach { f->for(a in f.nodes()) for(b in f.nodes()) if(a!=b) neighbours[a].add(b) }
        val random=Random(7129)
        repeat(8) {
            var v=DoubleArray(x.size) { random.nextGaussian() }
            repeat(6) { v=DoubleArray(x.size) { i->(v[i]+neighbours[i/3].sumOf { v[3*it+i%3] })/(neighbours[i/3].size+1) } }
            fixed.forEach { put(v,it,P3(0.0,0.0,0.0)) };normalise(v);directions.add(v)
        }
        var best=base.energy;var escape: DoubleArray?=null;var tested=0
        for(v in directions) {val maximum=v.maxOf { abs(it) };if(maximum<1e-20) continue
            for(size in listOf(.0001,.001,.005,.02)) for(sign in listOf(-1.0,1.0)) {
                if(cancelled()) throw CancellationException()
                val trial=DoubleArray(x.size) { x[it]+sign*size*v[it]/maximum }
                val energy=runCatching { mechanics.evaluate(trial,details=false).energy }.getOrDefault(Double.POSITIVE_INFINITY);tested++
                if(energy<best-max(1e-4,abs(base.energy)*1e-10)) {best=energy;escape=trial}
            }
        }
        return MinimumAudit(base.energy,curvature,curvature2,ritz.residual,directions.size,tested,base.energy-best,escape,ShapeAudits.inspect(m,x))
    }

fun improve(input: Solution,cancelled: ()->Boolean

    fun improve(input: Solution,cancelled: ()->Boolean={false},progress: (String)->Unit={}): Solution {
        if(!input.converged) return input
        var best=input;val rows=ArrayList<String>();val start=System.nanoTime()
        var audit=audit(best,cancelled)
        rows.add("initial potential ${best.evaluation.energy} J; ${audit.title}; tested drop ${audit.bestEnergyDrop} J")
        for(round in 0 until 3) {
            val seed=audit.escape ?: break
            progress("Minimum search ${round+1}/3 · testing a descent of ${audit.bestEnergyDrop} J")
            val trial=InflationSolver.solve(best.model,best.load,SolveOptions(pressureSteps=4,iterationsPerStep=48000,relativeTolerance=2e-6,absoluteTolerance=.005),cancelled=cancelled,initialPositions=seed)
            val gain=best.evaluation.energy-trial.evaluation.energy
            rows.add("restart ${round+1}: potential ${trial.evaluation.energy} J; decrease $gain J; force balance ${trial.converged}")
            if(!trial.converged || gain<=max(1e-4,abs(best.evaluation.energy)*1e-10)) break
            best=trial;audit=audit(best,cancelled)
        }
        return best.copy(elapsedMs=input.elapsedMs+(System.nanoTime()-start)/1e6,minimum=audit.copy(rows=rows))
    }
}