Skip to content

Commit 154c641

Browse files
authored
Use Enzyme in ipopt rather than FiniteDiff (#20)
* working Enzyme # Conflicts: # Project.toml # examples/budgerigar.jl # src/endotherm/thermoregulation/ipopt.jl # src/endotherm/thermoregulation/rulebased.jl * Track HeatExchange API renames and add smoothing kwarg to IPOPTControl Caller-side updates for upstream renames: generated_heat_flow → metabolic_heat_flow, per-side insulation_depth/conductivity moved under .dorsal/.ventral, shape_b → axis_ratio_b, respiration_mass → respiration_mass_flow. Adds `smoothing` field on IPOPTControl (default SmoothBound(1e-5)) threaded into nlp_pack so AD sees differentiable kinks. * cleanup and deps * more cleanup * Apply suggestion from @rafaqz
1 parent 87c792a commit 154c641

6 files changed

Lines changed: 88 additions & 41 deletions

File tree

Project.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ authors = ["Michael Kearney and Rafael Schouten"]
66
[deps]
77
BiophysicalGeometry = "06c61620-4f66-4020-a018-07befe374221"
88
ConstructionBase = "187b0558-2788-49d3-abe0-74a17ed4e7c9"
9-
FiniteDiff = "6a86dc24-6348-571c-b903-95158fe2bd41"
9+
Enzyme = "7da242da-08ed-463a-9acd-ee780be4f1d9"
1010
Flatten = "4c728ea3-d9ee-5c9a-9642-b6f7d7dc04fa"
1111
FluidProperties = "d16f1b92-71d3-42f3-aafa-43e5bb0efda8"
1212
HeatExchange = "720bd838-cdd6-4aad-926b-452b962dce21"
@@ -21,7 +21,7 @@ Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d"
2121
UnitfulMoles = "999f2bd7-36bf-5ba7-9bc1-c9473aa75374"
2222

2323
[sources]
24-
BiophysicalGeometry = {rev = "characteristic-dimension-options", url = "https://github.com/BiophysicalEcology/BiophysicalGeometry.jl"}
24+
BiophysicalGeometry = {rev = "main", url = "https://github.com/BiophysicalEcology/BiophysicalGeometry.jl"}
2525
FluidProperties = {rev = "main", url = "https://github.com/BiophysicalEcology/FluidProperties.jl"}
2626
HeatExchange = {rev = "main", url = "https://github.com/BiophysicalEcology/HeatExchange.jl"}
2727

@@ -31,7 +31,7 @@ BiophysicalGeometry = "0.1"
3131
CSV = "0.10"
3232
ConstructionBase = "1.6"
3333
DataFrames = "1"
34-
FiniteDiff = "2.30.0"
34+
Enzyme = "0.13.144"
3535
Flatten = "0.4.3"
3636
FluidProperties = "0.1"
3737
HeatExchange = "0.1"

docs/ipopt_endotherm_thermoregulation.md

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,9 @@ performance. The exact Hessian requires O(n²) = 81 finite-difference evaluation
208208
iteration; L-BFGS constructs a low-rank approximation from gradient differences instead,
209209
reducing the cost substantially.
210210

211-
**Why finite differences?** `Unitful.jl` units propagate through `HeatExchange.heat_balance`,
212-
making the residuals and objective incompatible with Julia automatic differentiation packages
213-
(Zygote, ForwardDiff). All gradients, Jacobians, and Hessians are computed via `FiniteDiff.jl`.
214-
The `hess` and `cons_h` callbacks are registered (required by `IpoptOptimizer`) but never
215-
called at runtime when L-BFGS is active.
211+
**Differentiation strategy.** Gradients and constraint Jacobians are computed via
212+
[`Enzyme.jl`](https://github.com/EnzymeAD/Enzyme.jl): reverse-mode for the scalar objective
213+
gradient and forward-mode for the constraint Jacobian.
216214

217215
---
218216

@@ -234,7 +232,7 @@ for (air_temperature, ...) in zip(air_temperatures, ...)
234232
out = thermoregulate(Endotherm(), IPOPTControl(), organism, environment,
235233
generated_heat_flow_ipopt, skin_temperature_ipopt, insulation_temperature_ipopt)
236234

237-
generated_heat_flow_ipopt = out.energy_flows.generated_heat_flow
235+
generated_heat_flow_ipopt = out.energy_flows.metabolic_heat_flow
238236
skin_temperature_ipopt = out.thermoregulation.skin_temperature
239237
insulation_temperature_ipopt = out.thermoregulation.insulation_temperature
240238
end
@@ -311,7 +309,7 @@ insulation_temperature_ipopt = air_temperatures[1]
311309
for (air_temperature, rh, q10) in zip(air_temperatures, ...)
312310
out = thermoregulate(Endotherm(), IPOPTControl(), organism, environment,
313311
generated_heat_flow_ipopt, skin_temperature_ipopt, insulation_temperature_ipopt)
314-
generated_heat_flow_ipopt = out.energy_flows.generated_heat_flow
312+
generated_heat_flow_ipopt = out.energy_flows.metabolic_heat_flow
315313
skin_temperature_ipopt = out.thermoregulation.skin_temperature
316314
insulation_temperature_ipopt = out.thermoregulation.insulation_temperature
317315
push!(ipopt_results, ...)
@@ -331,6 +329,6 @@ Typical tuning guidance for penalty weights:
331329

332330
## Limitations and Future Work
333331

334-
- **Automatic differentiation:** `Unitful.jl` units propagate through `HeatExchange.heat_balance`, making it incompatible with `ForwardDiff` or `Zygote`. All derivatives are computed by finite differences via `FiniteDiff.jl`. Stripping units from the inner-loop heat balance computation would allow exact derivatives and potentially faster convergence.
332+
- **Automatic differentiation:** derivatives are computed via `Enzyme.jl` (reverse-mode for the objective gradient, forward-mode for the constraint Jacobian). The Hessian and constraint Hessian callbacks are stubs because IPOPT runs with the L-BFGS Hessian approximation; supplying exact second-order information may improve convergence further.
335333
- **Dorsal/ventral symmetry:** the mean-weighted body approximation merges dorsal and ventral sides. The full `solve_metabolic_rate` computes them separately. In strongly asymmetric conditions (e.g. high solar loading on dorsal surface) this may introduce small errors.
336334
- **Global optimality:** IPOPT finds a local optimum of the NLP. For well-posed problems the objective is approximately convex and the local optimum is unique, but unusual initial conditions or extreme parameter combinations may yield suboptimal solutions.

examples/budgerigar.jl

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,7 @@ skin_temperature_ipopt = metabolism_pars_init.core_temperature - 3.0u"K
516516
insulation_temperature_ipopt = air_temperatures[1]
517517

518518
@time for (air_temperature, relative_humidity, q10) in zip(air_temperatures, experimental_relative_humdities, q10s)
519+
global generated_heat_flow_ipopt, skin_temperature_ipopt, insulation_temperature_ipopt
519520

520521
environment_vars_loop = example_environment_vars(;
521522
air_temperature,
@@ -563,7 +564,7 @@ insulation_temperature_ipopt = air_temperatures[1]
563564
insulation_temperature_dorsal = tr.dorsal.insulation_temperature,
564565
insulation_temperature_ventral = tr.ventral.insulation_temperature,
565566
insulation_depth_dorsal = tr.dorsal.insulation_depth,
566-
axis_ratio_b = tr.aspect_ratio,
567+
axis_ratio_b = tr.axis_ratio_b,
567568
flesh_conductivity = tr.flesh_conductivity,
568569
pant = tr.pant,
569570
skin_wetness = tr.skin_wetness,
@@ -701,6 +702,9 @@ plot(pc5, pc6, pc7, layout = (1, 3), size = (1200, 350))
701702
# layout = (7, 1),
702703
# size = (700, 1600))
703704

704-
plot(pc1, pc2, pc3, pc4, pc5, pc6, pc7,
705+
final_plot = plot(pc1, pc2, pc3, pc4, pc5, pc6, pc7,
705706
layout = (4, 2),
706707
size = (700, 1000))
708+
display(final_plot)
709+
println("\nPlot window open. Press Enter to exit.")
710+
readline()

src/BiophysicalBehaviour.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ using UnitfulMoles
1111

1212
using BiophysicalGeometry: AbstractBody, shape
1313

14-
using FiniteDiff
14+
using Enzyme
1515
using Optimization
1616
using OptimizationIpopt
1717
import SciMLBase

src/endotherm/thermoregulation/ipopt.jl

Lines changed: 67 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -145,22 +145,48 @@ function _run_ipopt(
145145
setpoint_temperature = setpoint_temperature_K,
146146
)
147147

148-
obj_fn(x, _) = _objective_value_weighted(x, opt_pars)
148+
obj_fn(x, _) = _objective_value_weighted(x, opt_pars)
149149
res_fn!(r, x, _) = _heat_balance_residuals_weighted!(r, x, nlp_pars)
150-
grad_fn!(g, x, _) = FiniteDiff.finite_difference_gradient!(g, e -> _objective_value_weighted(e, opt_pars), x)
151-
hess_fn!(H, x, _) = (H .= FiniteDiff.finite_difference_hessian(e -> _objective_value_weighted(e, opt_pars), x))
152-
cons_j_fn!(J, x, _) = (J .= FiniteDiff.finite_difference_jacobian(
153-
e -> (r = zeros(eltype(e), 4); _heat_balance_residuals_weighted!(r, e, nlp_pars); r),
154-
x,
155-
))
156-
function cons_h_fn!(res, x, _)
157-
for i in eachindex(res)
158-
res[i] .= FiniteDiff.finite_difference_hessian(
159-
e -> (r = zeros(4); _heat_balance_residuals_weighted!(r, e, nlp_pars); r[i]),
160-
x,
161-
)
150+
151+
# Enzyme reverse-mode AD. Bypass DifferentiationInterface — the params
152+
# NamedTuples are already captured in the closures, and DI's vector packing
153+
# of `p` doesn't compose with Unitful values.
154+
# hess! and cons_h! are registered (required by IpoptOptimizer) but not
155+
# called at runtime when hessian_approximation="limited-memory" is set.
156+
function grad_fn!(g, x, _)
157+
fill!(g, 0)
158+
Enzyme.autodiff(Enzyme.Reverse, _objective_value_weighted,
159+
Enzyme.Active,
160+
Enzyme.Duplicated(x, g),
161+
Enzyme.Const(opt_pars))
162+
return nothing
163+
end
164+
function cons_j_fn!(J, x, _)
165+
# Forward mode: 9 passes (one per input) instead of reverse's 4.
166+
# Reverse mode currently produces silent NaN for cols 2 (skin_T)
167+
# and 3 (ins_T) in this call chain — even though every called
168+
# function is type-stable per JET. Forward mode gives correct
169+
# finite values matching finite differences. The reverse-mode
170+
# NaN is an Enzyme limitation unrelated to type stability; revisit
171+
# when the Enzyme issue is resolved upstream.
172+
m = size(J, 1)
173+
n = length(x)
174+
r = zeros(m)
175+
dr = zeros(m)
176+
dx = zeros(n)
177+
for j in 1:n
178+
fill!(r, 0); fill!(dr, 0); fill!(dx, 0); dx[j] = 1.0
179+
Enzyme.autodiff(Enzyme.Forward, _heat_balance_residuals_weighted!,
180+
Enzyme.Const,
181+
Enzyme.Duplicated(r, dr),
182+
Enzyme.Duplicated(x, dx),
183+
Enzyme.Const(nlp_pars))
184+
@views J[:, j] .= dr
162185
end
186+
return nothing
163187
end
188+
hess_fn!(H, x, _) = (fill!(H, 0); nothing)
189+
cons_h_fn!(res, x, _) = (foreach(r -> fill!(r, 0), res); nothing)
164190

165191
optimization_func = OptimizationFunction(obj_fn, SciMLBase.NoAD();
166192
cons = res_fn!,
@@ -343,20 +369,34 @@ function _run_ipopt(
343369

344370
obj_fn(x, _) = _objective_value_multisided(x, opt_pars)
345371
res_fn!(r, x, _) = _heat_balance_residuals_multisided!(r, x, nlp_pars)
346-
grad_fn!(g, x, _) = FiniteDiff.finite_difference_gradient!(g, e -> _objective_value_multisided(e, opt_pars), x)
347-
hess_fn!(H, x, _) = (H .= FiniteDiff.finite_difference_hessian(e -> _objective_value_multisided(e, opt_pars), x))
348-
cons_j_fn!(J, x, _) = (J .= FiniteDiff.finite_difference_jacobian(
349-
e -> (r = zeros(eltype(e), 6); _heat_balance_residuals_multisided!(r, e, nlp_pars); r),
350-
x,
351-
))
352-
function cons_h_fn!(res, x, _)
353-
for i in eachindex(res)
354-
res[i] .= FiniteDiff.finite_difference_hessian(
355-
e -> (r = zeros(6); _heat_balance_residuals_multisided!(r, e, nlp_pars); r[i]),
356-
x,
357-
)
372+
373+
function grad_fn!(g, x, _)
374+
fill!(g, 0)
375+
Enzyme.autodiff(Enzyme.Reverse, _objective_value_multisided,
376+
Enzyme.Active,
377+
Enzyme.Duplicated(x, g),
378+
Enzyme.Const(opt_pars))
379+
return nothing
380+
end
381+
function cons_j_fn!(J, x, _)
382+
m = size(J, 1)
383+
n = length(x)
384+
r = zeros(m)
385+
dr = zeros(m)
386+
dx = zeros(n)
387+
for j in 1:n
388+
fill!(r, 0); fill!(dr, 0); fill!(dx, 0); dx[j] = 1.0
389+
Enzyme.autodiff(Enzyme.Forward, _heat_balance_residuals_multisided!,
390+
Enzyme.Const,
391+
Enzyme.Duplicated(r, dr),
392+
Enzyme.Duplicated(x, dx),
393+
Enzyme.Const(nlp_pars))
394+
@views J[:, j] .= dr
358395
end
396+
return nothing
359397
end
398+
hess_fn!(H, x, _) = (fill!(H, 0); nothing)
399+
cons_h_fn!(res, x, _) = (foreach(r -> fill!(r, 0), res); nothing)
360400

361401
optimization_func = OptimizationFunction(obj_fn, SciMLBase.NoAD();
362402
cons = res_fn!,
@@ -455,7 +495,8 @@ function thermoregulate(
455495
evap_pars = evaporation_pars(organism)
456496

457497
nlp_packed = HeatExchange.nlp_pack(control.nlp_strategy, organism, environment,
458-
skin_temperature_init, insulation_temperature_init)
498+
skin_temperature_init, insulation_temperature_init;
499+
smoothing = control.smoothing)
459500

460501
_run_ipopt(nlp_packed, organism, environment, limits, metab_pars, int_cond, evap_pars,
461502
metabolic_heat_flow_init, skin_temperature_init, insulation_temperature_init;

src/organism.jl

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,15 @@ pant, skin_wetness) as continuous decision variables.
8989
- `nlp_strategy`: NLP formulation — `WeightedMeanNLP()` (default, dorsal/ventral
9090
weighted-mean single body, 9 variables, 4 constraints) or `MultiSidedNLP()`
9191
(explicit per-side heat balance, 11 variables, 7 constraints).
92+
- `smoothing`: smoothing policy passed to the heat-balance physics so AD sees
93+
differentiable kinks. Defaults to `SmoothBound(1.0e-5)`; pass `HardBound()` to
94+
match the rule-based path's exact `abs`/`max`/`step` behaviour.
9295
9396
Requires `Optimization.jl` and `OptimizationIpopt.jl`.
9497
"""
95-
Base.@kwdef struct IPOPTControl <: AbstractControlStrategy
98+
Base.@kwdef struct IPOPTControl{S<:HeatExchange.SmoothingStrategy} <: AbstractControlStrategy
9699
nlp_strategy::HeatExchange.NLPStrategy = HeatExchange.WeightedMeanNLP()
100+
smoothing::S = HeatExchange.SmoothBound(1.0e-5)
97101
end
98102

99103
# =============================================================================

0 commit comments

Comments
 (0)