Skip to content

Instantly share code, notes, and snippets.

@philipturner
Last active November 11, 2025 23:41
Show Gist options
  • Select an option

  • Save philipturner/cc15677a76178521176eb64b362b8b34 to your computer and use it in GitHub Desktop.

Select an option

Save philipturner/cc15677a76178521176eb64b362b8b34 to your computer and use it in GitHub Desktop.
GitHub Gist for the MM4 energy minimization test
//
// FIRE.swift
// MolecularRendererApp
//
// Created by Philip Turner on 5/31/24.
//
import HDL
import Numerics
struct FIREMinimizationDescriptor {
/// Optional. Indices of atoms to keep positionally constrained.
var anchors: Set<UInt32>?
/// Required. The tolerance for maximum force among the atoms.
///
/// The default value is 10 pN. This value is sufficient for accurate
/// structures. For accurate energies (when comparing two different
/// structures), choose a value of 0.3 pN.
var forceTolerance: Float = 10
/// Required. The mass of each atom (in yoctograms).
var masses: [Float]?
/// Required. The position of each atom's nucleus.
var positions: [SIMD3<Float>]?
/// Required. The minimum timestep.
var ΔtMin: Float = 0.25e-3
/// Required. The initial timestep.
var ΔtStart: Float = 0.001
/// Required. The maximum timestep.
var ΔtMax: Float = 0.010
}
/// A data structure for integration during an energy minimization.
///
/// This is not a full molecular dynamics engine. The current prototype is only
/// scoped to something capable of ONIOM simulation.
struct FIREMinimization {
// Integrator settings.
let anchors: Set<UInt32>
let masses: [Float]
let ΔtMin: Float
let ΔtMax: Float
let forceTolerance: Float
// FIRE timestep trackers.
private(set) var Δt: Float
private(set) var NP0: Int
// Dynamical variables.
private(set) var time: Double
var positions: [SIMD3<Float>] // must be mutable to enforce constraints
var velocities: [SIMD3<Float>] // must be mutable to enforce constraints
// Cached state.
private(set) var oldTime: Double
private(set) var oldPositions: [SIMD3<Float>]
init(descriptor: FIREMinimizationDescriptor) {
guard let masses = descriptor.masses,
let positions = descriptor.positions else {
fatalError("Descriptor was incomplete.")
}
guard masses.count == positions.count else {
fatalError("Size of masses did not match size of positions.")
}
// Initialize the constraints.
if let anchors = descriptor.anchors {
self.anchors = anchors
} else {
self.anchors = []
}
self.masses = masses
ΔtMin = descriptor.ΔtMin
ΔtMax = descriptor.ΔtMax
forceTolerance = descriptor.forceTolerance
// Initialize the timestep trackers.
Δt = descriptor.ΔtStart
NP0 = 0
// Initialize the dynamical variables.
time = 0
self.positions = positions
velocities = [SIMD3<Float>](repeating: .zero, count: positions.count)
// Initialize the cached state.
oldTime = 0
oldPositions = positions
}
// If the minimization has converged, return `true`. Otherwise, integrate for
// one timestep.
mutating func step(forces: [SIMD3<Float>]) -> Bool {
guard forces.count == positions.count else {
fatalError("Size of forces did not match size of positions.")
}
// Find the power (P) and maximum force.
var P: Double = .zero
var maxForce: Float = .zero
for atomID in positions.indices {
if anchors.contains(UInt32(atomID)) {
continue
}
let force = forces[atomID]
let velocity = velocities[atomID]
P += Double((force * velocity).sum())
let forceMagnitude = (force * force).sum().squareRoot()
maxForce = max(maxForce, forceMagnitude)
}
// Return early if converged.
if maxForce < forceTolerance {
return true
}
// Prepare for integration.
updateTimestep(P: P)
// Integrate.
integrate(forces: forces)
oldTime = time + Double(0.5 * Δt)
time = time + Double(Δt)
// Return that you haven't converged.
return false
}
// Adjust the timestep, according to the value of P.
private mutating func updateTimestep(P: Double) {
if time > 0, P < 0 {
time = oldTime
positions = oldPositions
velocities = Array(repeating: .zero, count: positions.count)
NP0 = 0
Δt = max(0.5 * Δt, ΔtMin)
} else {
NP0 += 1
if NP0 > 5 {
Δt = min(1.1 * Δt, ΔtMax)
}
}
}
// Compute the force scale for the entire system.
//
// WARNING: This must be done after the velocities are reset when P < 0.
private func createForceScale(forces: [SIMD3<Float>]) -> Float {
var vAccumulator: Float = .zero
var fAccumulator: Float = .zero
for atomID in positions.indices {
if anchors.contains(UInt32(atomID)) {
continue
}
let velocity = velocities[atomID]
let force = forces[atomID]
vAccumulator += (velocity * velocity).sum()
fAccumulator += (force * force).sum()
}
let vNorm = vAccumulator.squareRoot()
let fNorm = fAccumulator.squareRoot()
var forceScale = vNorm / fNorm
if forceScale.isNaN || forceScale.isInfinite {
forceScale = .zero
}
return forceScale
}
// Perform an integration step.
private mutating func integrate(forces: [SIMD3<Float>]) {
// Find the force scale for redirecting atom velocities.
let forceScale = createForceScale(forces: forces)
// Iterate over the atoms.
for atomID in positions.indices {
var halfwayPosition: SIMD3<Float>
var finalPosition: SIMD3<Float>
var finalVelocity: SIMD3<Float>
defer {
oldPositions[atomID] = halfwayPosition
positions[atomID] = finalPosition
velocities[atomID] = finalVelocity
}
let initialPosition = positions[atomID]
let initialVelocity = velocities[atomID]
if anchors.contains(UInt32(atomID)) {
// Prevent the anchors from moving.
halfwayPosition = initialPosition
finalPosition = initialPosition
finalVelocity = .zero
} else {
let force = forces[atomID]
let mass = masses[atomID]
var velocity = initialVelocity
// Semi-implicit Euler integration.
// - This could be considered a valid form of molecular dynamics, just
// with a forcing term that accelerates the descent into the global
// minimum.
let α: Float = 0.25
velocity += Δt * force / mass
velocity = (1 - α) * velocity + α * force * forceScale
// Accelerated bias correction.
// - This should be applied whenever the system's velocities are
// suddenly reset to zero. Ideally, such a situation will never
// happen in a molecular dynamics simulation.
// - The correction can shrink the number of iterations by a factor of
// 2x.
if NP0 > 0 {
var biasCorrection = 1 - α
biasCorrection = Float.pow(biasCorrection, Float(NP0))
biasCorrection = 1 / (1 - biasCorrection)
velocity *= biasCorrection
}
// Clamp the velocity to 4000 m/s.
let speed = (velocity * velocity).sum().squareRoot()
if speed > 4.0 {
velocity *= 4.0 / speed
}
finalVelocity = velocity
// Integrate the position.
halfwayPosition = initialPosition + 0.5 * Δt * velocity
finalPosition = initialPosition + Δt * velocity
}
}
}
}
import HDL
import MM4
import MolecularRenderer
// MARK: - Compile Structure
func passivate(topology: inout Topology) {
func createHydrogen(
atomID: UInt32,
orbital: SIMD3<Float>
) -> Atom {
let atom = topology.atoms[Int(atomID)]
var bondLength = atom.element.covalentRadius
bondLength += Element.hydrogen.covalentRadius
let position = atom.position + bondLength * orbital
return Atom(position: position, element: .hydrogen)
}
let orbitalLists = topology.nonbondingOrbitals()
var insertedAtoms: [Atom] = []
var insertedBonds: [SIMD2<UInt32>] = []
for atomID in topology.atoms.indices {
let orbitalList = orbitalLists[atomID]
for orbital in orbitalList {
let hydrogen = createHydrogen(
atomID: UInt32(atomID),
orbital: orbital)
let hydrogenID = topology.atoms.count + insertedAtoms.count
insertedAtoms.append(hydrogen)
let bond = SIMD2(
UInt32(atomID),
UInt32(hydrogenID))
insertedBonds.append(bond)
}
}
topology.atoms += insertedAtoms
topology.bonds += insertedBonds
}
func createTopology() -> Topology {
let lattice = Lattice<Cubic> { h, k, l in
Bounds { 10 * (h + k + l) }
Material { .checkerboard(.carbon, .silicon) }
}
var reconstruction = Reconstruction()
reconstruction.atoms = lattice.atoms
reconstruction.material = .checkerboard(.silicon, .carbon)
var topology = reconstruction.compile()
passivate(topology: &topology)
return topology
}
let topology = createTopology()
// MARK: - Run Minimization
// Utility for logging quantities to the console.
struct Format {
static func pad(_ x: String, to size: Int) -> String {
var output = x
while output.count < size {
output = " " + output
}
return output
}
static func time<T: BinaryFloatingPoint>(_ x: T) -> String {
let xInFs = Float(x) * 1e3
var repr = String(format: "%.2f", xInFs) + " fs"
repr = pad(repr, to: 9)
return repr
}
static func energy(_ x: Double) -> String {
var repr = String(format: "%.2f", x / 160.218) + " eV"
repr = pad(repr, to: 13)
return repr
}
static func force(_ x: Float) -> String {
var repr = String(format: "%.2f", x) + " pN"
repr = pad(repr, to: 13)
return repr
}
static func distance(_ x: Float) -> String {
var repr = String(format: "%.2f", x) + " nm"
repr = pad(repr, to: 9)
return repr
}
}
var paramsDesc = MM4ParametersDescriptor()
paramsDesc.atomicNumbers = topology.atoms.map(\.atomicNumber)
paramsDesc.bonds = topology.bonds
let parameters = try! MM4Parameters(descriptor: paramsDesc)
var forceFieldDesc = MM4ForceFieldDescriptor()
forceFieldDesc.parameters = parameters
let forceField = try! MM4ForceField(descriptor: forceFieldDesc)
var minimizationDesc = FIREMinimizationDescriptor()
minimizationDesc.masses = parameters.atoms.masses
minimizationDesc.positions = topology.atoms.map(\.position)
var minimization = FIREMinimization(descriptor: minimizationDesc)
var frames: [[SIMD4<Float>]] = []
@MainActor
func createFrame() -> [Atom] {
var output: [SIMD4<Float>] = []
for atomID in topology.atoms.indices {
var atom = topology.atoms[atomID]
atom.position = minimization.positions[atomID]
output.append(atom)
}
return output
}
let maxIterationCount: Int = 500
for trialID in 0..<maxIterationCount {
frames.append(createFrame())
forceField.positions = minimization.positions
let forces = forceField.forces
var maximumForce: Float = .zero
for atomID in topology.atoms.indices {
let force = forces[atomID]
let forceMagnitude = (force * force).sum().squareRoot()
maximumForce = max(maximumForce, forceMagnitude)
}
let energy = forceField.energy.potential
print("time: \(Format.time(minimization.time))", terminator: " | ")
print("energy: \(Format.energy(energy))", terminator: " | ")
print("max force: \(Format.force(maximumForce))", terminator: " | ")
let converged = minimization.step(forces: forces)
if !converged {
print("Δt: \(Format.time(minimization.Δt))", terminator: " | ")
}
print()
if converged {
print("converged at trial \(trialID)")
frames.append(createFrame())
break
} else if trialID == maxIterationCount - 1 {
print("failed to converge!")
}
}
// MARK: - Launch Application
// Input: time in seconds
// Output: atoms
@MainActor
func interpolate(
frames: [[Atom]],
time: Float
) -> [Atom] {
let multiple25Hz = time * 25
var lowFrame = Int(multiple25Hz.rounded(.down))
var highFrame = lowFrame + 1
var lowInterpolationFactor = Float(highFrame) - multiple25Hz
var highInterpolationFactor = multiple25Hz - Float(lowFrame)
if lowFrame < -1 {
fatalError("This should never happen.")
}
if lowFrame >= frames.count - 1 {
lowFrame = frames.count - 1
highFrame = frames.count - 1
lowInterpolationFactor = 1
highInterpolationFactor = 0
}
var output: [Atom] = []
for atomID in topology.atoms.indices {
let lowAtom = frames[lowFrame][atomID]
let highAtom = frames[highFrame][atomID]
var position: SIMD3<Float> = .zero
position += lowAtom.position * lowInterpolationFactor
position += highAtom.position * highInterpolationFactor
let element = topology.atoms[atomID].element
let atom = Atom(position: position, element: element)
output.append(atom)
}
return output
}
@MainActor
func createApplication() -> Application {
// Set up the device.
var deviceDesc = DeviceDescriptor()
deviceDesc.deviceID = Device.fastestDeviceID
let device = Device(descriptor: deviceDesc)
// Set up the display.
var displayDesc = DisplayDescriptor()
displayDesc.device = device
displayDesc.frameBufferSize = SIMD2<Int>(1440, 1440)
displayDesc.monitorID = device.fastestMonitorID
let display = Display(descriptor: displayDesc)
// Set up the application.
var applicationDesc = ApplicationDescriptor()
applicationDesc.device = device
applicationDesc.display = display
applicationDesc.upscaleFactor = 3
applicationDesc.addressSpaceSize = 4_000_000
applicationDesc.voxelAllocationSize = 500_000_000
applicationDesc.worldDimension = 64
let application = Application(descriptor: applicationDesc)
return application
}
let application = createApplication()
@MainActor
func createTime() -> Float {
let elapsedFrames = application.clock.frames
let frameRate = application.display.frameRate
let seconds = Float(elapsedFrames) / Float(frameRate)
return seconds
}
@MainActor
func modifyAtoms() {
let time = createTime()
let delayTime: Float = 1
let animationEndTime: Float = delayTime + Float(frames.count) / 25
if time < delayTime {
let atoms = topology.atoms
for atomID in atoms.indices {
let atom = atoms[atomID]
application.atoms[atomID] = atom
}
} else if time < animationEndTime {
let atoms = interpolate(
frames: frames,
time: time - delayTime)
for atomID in atoms.indices {
let atom = atoms[atomID]
application.atoms[atomID] = atom
}
} else {
let atoms = frames.last!
for atomID in atoms.indices {
let atom = atoms[atomID]
application.atoms[atomID] = atom
}
}
}
@MainActor
func modifyCamera() {
let basisX = SIMD3<Float>(1, 0, 0) / Float(1).squareRoot()
let basisY = SIMD3<Float>(0, 1, -1) / Float(2).squareRoot()
let basisZ = SIMD3<Float>(0, 1, 1) / Float(2).squareRoot()
application.camera.basis = (basisX, basisY, basisZ)
let latticeConstant = Constant(.square) {
.checkerboard(.silicon, .carbon)
}
let halfSize = latticeConstant * 5
application.camera.position = SIMD3<Float>(
halfSize,
3.5 * halfSize,
3.5 * halfSize)
}
// Enter the run loop.
application.run {
modifyAtoms()
modifyCamera()
var image = application.render()
image = application.upscale(image: image)
application.present(image: image)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment