Skip to content

Commit 81686ae

Browse files
lxmotaclaude
andcommitted
Symbolic differentiation for juliac-safe dynamic Dirichlet BCs
The `DirichletBCs{F}` juliac-safe constructor previously hard-coded `func_dot` and `func_dot_dot` to a literal `"0.0"` expression and documented "for now will only work for static" — silently zeroing the prescribed velocity and acceleration for any time-dependent BC under `juliac --trim`. The default Julia path uses ForwardDiff, which doesn't survive juliac. Pulling in Symbolics.jl just to round-trip a derivative also doesn't. This change adds a hand-rolled recursive tree-rewrite differentiator over FEC's own closed expression grammar (10 unary + 5 binary operators), plus minor parser fixes: - `src/Expressions.jl`: - `_differentiate(::Node{T, D}, var_idx)` — pure tree rewrite producing the symbolic ∂/∂x_{var_idx}. Trivial constant-folding (`0+x=x`, `0·x=0`, `1·x=x`) keeps derivative trees proportional in size. - `differentiate(::ScalarExpressionFunction, var_name)` — public API returning another ScalarExpressionFunction. - Second inner constructor on ScalarExpressionFunction accepting a prebuilt Expression (used to wrap the result of differentiate). - Parser fix: lower unary-minus right-binding-power from 100 to 25. Today `-t^2` parses as `(-t)^2`; standard math precedence is `-(t^2)`. The Gaussian-pulse form `exp(-t^2/(2τ^2))` doesn't work correctly otherwise. - `src/bcs/DirichletBCs.jl`: replace the hard-coded `zero_func` with `Expressions.differentiate(bc.func, "t")` for the first derivative and one more call on that for the second. Drops the broken 2D-only `["x", "y", "t"]` var_names hardcoding (the user's expression already carries its own var_names through metadata, so the constructor no longer needs to invent one). The Julia closure path (ForwardDiff) stays unchanged. - `test/TestExpressions.jl`: 5 new test items exercising the parser precedence fix, each unary/binary op's derivative, Gaussian-pulse first and second time derivatives, spatial derivatives for traveling-wave ICs, and error on unknown variable. - `test/TestBCs.jl`: 2 new test items exercising `DirichletBCs{ScalarExpressionFunction}` with `2 t²` and a Gaussian pulse, validating that vals_dot and vals_dot_dot match the analytical derivatives at multiple time samples. Full suite: 18183/18183 (+98 new assertions). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4eb7192 commit 81686ae

4 files changed

Lines changed: 405 additions & 8 deletions

File tree

src/Expressions.jl

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,10 @@ function _nud(p::Parser, t::Token, ::Type{T}) where T <: Number
328328
return Node{T}(; val = t.value)
329329
elseif t.id == OPERATOR
330330
if t.op == BINARY_MINUS
331-
val = _parse_statement(p, 100)
331+
# Unary minus. Right-binding-power 25 sits between `*`/`/` (20)
332+
# and `^` (30) — matches standard math precedence so `-t^2` parses
333+
# as `-(t^2)` rather than `(-t)^2`.
334+
val = _parse_statement(p, 25)
332335
return Node{T}(; op = UNARY_MINUS, l = val)
333336
else
334337
error("Unexpected operator in _nud. Found operator $(t.op)")
@@ -386,6 +389,20 @@ struct ScalarExpressionFunction{T <: Number} <: AbstractExpressionFunction{T, No
386389
expr = Expression(ast; operators, var_names)
387390
new{T}(expr, length(var_names))
388391
end
392+
393+
"""
394+
$(TYPEDSIGNATURES)
395+
396+
Build a `ScalarExpressionFunction` directly from a prebuilt
397+
`DynamicExpressions.Expression` — used by [`differentiate`](@ref) to wrap
398+
the result of a tree rewrite without round-tripping through the parser.
399+
"""
400+
function ScalarExpressionFunction{T}(
401+
expr::Expression{T, Node{T, DEFAULT_MAX_DEGREE}, ntuple_type},
402+
num_vars::Int
403+
) where T <: Number
404+
new{T}(expr, num_vars)
405+
end
389406
end
390407

391408
Base.eltype(::ScalarExpressionFunction{T}) where T <: Number = T
@@ -453,4 +470,178 @@ function (f::VectorExpressionFunction)(X::SVector{ND, T}, t::T) where {ND, T <:
453470
return map(func -> func(X, t), f.exprs)
454471
end
455472

473+
########################################################
474+
# Symbolic differentiation on Node{T, D} trees.
475+
#
476+
# The grammar is finite (10 unary + 5 binary operators), so the chain rule
477+
# can be applied by tree rewriting in ~80 lines of pure Julia. This avoids
478+
# pulling in ForwardDiff/Zygote/Symbolics, so the result survives
479+
# `juliac --trim`. All helpers operate on values; no closures are formed.
480+
########################################################
481+
482+
@inline _is_const(n::Node) = n.degree == 0 && n.constant
483+
@inline _is_zero(n::Node) = _is_const(n) && iszero(n.val)
484+
@inline _is_one(n::Node) = _is_const(n) && isone(n.val)
485+
486+
# Smart constructors that fold trivial constants so derivative trees stay
487+
# proportional in size to the input. Each returns a fresh `Node{T, D}`.
488+
function _add(a::Node{T, D}, b::Node{T, D}) where {T, D}
489+
_is_zero(a) && return b
490+
_is_zero(b) && return a
491+
return Node{T, D}(; op = BINARY_PLUS, l = a, r = b)
492+
end
493+
494+
function _sub(a::Node{T, D}, b::Node{T, D}) where {T, D}
495+
_is_zero(b) && return a
496+
_is_zero(a) && return _neg(b)
497+
return Node{T, D}(; op = BINARY_MINUS, l = a, r = b)
498+
end
499+
500+
function _mul(a::Node{T, D}, b::Node{T, D}) where {T, D}
501+
(_is_zero(a) || _is_zero(b)) && return Node{T, D}(; val = zero(T))
502+
_is_one(a) && return b
503+
_is_one(b) && return a
504+
return Node{T, D}(; op = BINARY_MULTIPLY, l = a, r = b)
505+
end
506+
507+
function _div(a::Node{T, D}, b::Node{T, D}) where {T, D}
508+
_is_zero(a) && return Node{T, D}(; val = zero(T))
509+
_is_one(b) && return a
510+
return Node{T, D}(; op = BINARY_DIVIDE, l = a, r = b)
511+
end
512+
513+
function _neg(a::Node{T, D}) where {T, D}
514+
_is_zero(a) && return a
515+
return Node{T, D}(; op = UNARY_MINUS, l = a)
516+
end
517+
518+
function _pow_int(a::Node{T, D}, k::Int) where {T, D}
519+
# Build a^k for a small positive integer constant k (>= 1).
520+
k == 1 && return a
521+
return Node{T, D}(; op = BINARY_POWER, l = a, r = Node{T, D}(; val = T(k)))
522+
end
523+
524+
"""
525+
$(TYPEDSIGNATURES)
526+
527+
Pure tree-rewrite differentiator. Recurses over `node` returning a new
528+
`Node{T, D}` representing `∂ node / ∂ x_{var_idx}`, where `var_idx` is the
529+
1-based feature index of the variable to differentiate with respect to.
530+
"""
531+
function _differentiate(node::Node{T, D}, var_idx::Int) where {T, D}
532+
if node.degree == 0
533+
if node.constant
534+
return Node{T, D}(; val = zero(T))
535+
else
536+
return Node{T, D}(;
537+
val = node.feature == var_idx ? one(T) : zero(T)
538+
)
539+
end
540+
elseif node.degree == 1
541+
u = node.l
542+
du = _differentiate(u, var_idx)
543+
op = node.op
544+
if op == UNARY_MINUS
545+
return _neg(du)
546+
elseif op == FUNC_COS
547+
sin_u = Node{T, D}(; op = FUNC_SIN, l = u)
548+
return _mul(_neg(sin_u), du)
549+
elseif op == FUNC_COSH
550+
sinh_u = Node{T, D}(; op = FUNC_SINH, l = u)
551+
return _mul(sinh_u, du)
552+
elseif op == FUNC_EXP
553+
return _mul(node, du)
554+
elseif op == FUNC_LOG
555+
return _div(du, u)
556+
elseif op == FUNC_SIN
557+
cos_u = Node{T, D}(; op = FUNC_COS, l = u)
558+
return _mul(cos_u, du)
559+
elseif op == FUNC_SINH
560+
cosh_u = Node{T, D}(; op = FUNC_COSH, l = u)
561+
return _mul(cosh_u, du)
562+
elseif op == FUNC_SQRT
563+
two_sqrt = _mul(Node{T, D}(; val = T(2)), node)
564+
return _div(du, two_sqrt)
565+
elseif op == FUNC_TAN
566+
cos_u = Node{T, D}(; op = FUNC_COS, l = u)
567+
return _div(du, _pow_int(cos_u, 2))
568+
elseif op == FUNC_TANH
569+
cosh_u = Node{T, D}(; op = FUNC_COSH, l = u)
570+
return _div(du, _pow_int(cosh_u, 2))
571+
end
572+
error("differentiate: unhandled unary op $op")
573+
elseif node.degree == 2
574+
u, v = node.l, node.r
575+
op = node.op
576+
if op == BINARY_PLUS
577+
return _add(_differentiate(u, var_idx), _differentiate(v, var_idx))
578+
elseif op == BINARY_MINUS
579+
return _sub(_differentiate(u, var_idx), _differentiate(v, var_idx))
580+
elseif op == BINARY_MULTIPLY
581+
du, dv = _differentiate(u, var_idx), _differentiate(v, var_idx)
582+
return _add(_mul(du, v), _mul(u, dv))
583+
elseif op == BINARY_DIVIDE
584+
du, dv = _differentiate(u, var_idx), _differentiate(v, var_idx)
585+
num = _sub(_mul(du, v), _mul(u, dv))
586+
return _div(num, _pow_int(v, 2))
587+
elseif op == BINARY_POWER
588+
# Common cases: constant exponent or constant base get clean
589+
# derivatives. General case uses u^v * (v' log u + v u' / u).
590+
if _is_const(v)
591+
# d/dx(u^c) = c · u^(c-1) · u'
592+
du = _differentiate(u, var_idx)
593+
c = v
594+
c_minus_1 = Node{T, D}(; val = c.val - one(T))
595+
u_pow = Node{T, D}(; op = BINARY_POWER, l = u, r = c_minus_1)
596+
return _mul(_mul(c, u_pow), du)
597+
elseif _is_const(u)
598+
# d/dx(c^v) = c^v · log(c) · v'
599+
dv = _differentiate(v, var_idx)
600+
log_c = Node{T, D}(; val = log(u.val))
601+
return _mul(_mul(node, log_c), dv)
602+
else
603+
# general: d/dx(u^v) = u^v · (v' log u + v u'/u)
604+
du = _differentiate(u, var_idx)
605+
dv = _differentiate(v, var_idx)
606+
log_u = Node{T, D}(; op = FUNC_LOG, l = u)
607+
term1 = _mul(dv, log_u)
608+
term2 = _div(_mul(v, du), u)
609+
return _mul(node, _add(term1, term2))
610+
end
611+
end
612+
error("differentiate: unhandled binary op $op")
613+
end
614+
error("differentiate: unhandled degree $(node.degree)")
615+
end
616+
617+
"""
618+
$(TYPEDSIGNATURES)
619+
620+
Return the symbolic derivative of `f` with respect to variable `var_name`.
621+
622+
The returned function is a `ScalarExpressionFunction` over the same variable
623+
list as `f` — only the underlying expression tree changes. Differentiation
624+
is implemented as a recursive tree rewrite over FEC's closed grammar (10
625+
unary + 5 binary operators), so there is no dependency on ForwardDiff,
626+
Zygote, or Symbolics; the result survives `juliac --trim`.
627+
628+
Supported operators: cos, cosh, exp, log, sin, sinh, sqrt, tan, tanh, unary
629+
minus, +, -, *, /, ^.
630+
631+
```julia
632+
f = ScalarExpressionFunction{Float64}("a * exp(-(t - tc)^2 / (2 * τ^2))",
633+
["a", "tc", "τ", "t"])
634+
f_dot = differentiate(f, "t")
635+
f_dot_dot = differentiate(f_dot, "t")
636+
```
637+
"""
638+
function differentiate(f::ScalarExpressionFunction{T}, var_name::String) where T
639+
var_names = f.expr.metadata.var_names
640+
idx = findfirst(==(var_name), var_names)
641+
@assert idx !== nothing "variable \"$var_name\" not in $(var_names)"
642+
deriv_tree = _differentiate(f.expr.tree, idx)
643+
deriv_expr = Expression(deriv_tree; operators, var_names)
644+
return ScalarExpressionFunction{T}(deriv_expr, f.num_vars)
645+
end
646+
456647
end # module

src/bcs/DirichletBCs.jl

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,11 @@ struct DirichletBCs{
263263
)
264264
end
265265

266-
# juliac safe, for now will only work for static
267-
# need to change bc input to have bindings
268-
# for user provided first and/or second derivatives
266+
# juliac-safe path: derives `func_dot` and `func_dot_dot` symbolically via
267+
# [`differentiate`](@ref) on the user's expression tree, so dynamic
268+
# Dirichlet BCs work under `juliac --trim` without requiring ForwardDiff,
269+
# Zygote, Symbolics, or user-supplied derivatives. F is expected to be
270+
# `Expressions.ScalarExpressionFunction{T}`.
269271
function DirichletBCs{F}(mesh::AbstractMesh, dof, bcs_input) where {F <: Function}
270272
bc_funcs = DirichletBCFunction{F, F, F}[]
271273
if length(bcs_input) == 0
@@ -275,11 +277,11 @@ struct DirichletBCs{
275277
return new{typeof(bc_funcs), IV, RV}(bc_cache, bc_funcs)
276278
end
277279

278-
# TODO change me, will fail if F is not an ExpressionFunction
279-
# and not a 2d func time-dependent
280-
zero_func = F("0.0", ["x", "y", "t"])
281280
for bc in bcs_input
282-
push!(bc_funcs, DirichletBCFunction{F, F, F}(bc.func, zero_func, zero_func))
281+
func_dot = Expressions.differentiate(bc.func, "t")
282+
func_dot_dot = Expressions.differentiate(func_dot, "t")
283+
push!(bc_funcs,
284+
DirichletBCFunction{F, F, F}(bc.func, func_dot, func_dot_dot))
283285
end
284286

285287
bc_caches = DirichletBCContainer.((mesh,), (dof,), bcs_input)

test/TestBCs.jl

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,67 @@ end
104104
@test all(A[dof.dirichlet_dofs] .≈ 4.0)
105105
end
106106

107+
@testitem "BCs - juliac-safe dynamic DirichletBCs (symbolic derivatives)" setup=[BCHelper] begin
108+
# Mirrors `test_dirichlet_update_bc_values!` but goes through the
109+
# `DirichletBCs{F}` juliac-safe constructor, which computes the BC's first
110+
# and second time derivatives symbolically via Expressions.differentiate.
111+
# No ForwardDiff, no user-supplied derivatives, no Symbolics.
112+
import FiniteElementContainers: update_bc_values!
113+
import FiniteElementContainers.Expressions: ScalarExpressionFunction
114+
115+
u = VectorFunction(fspace, "displ")
116+
dof = DofManager(u)
117+
118+
# g(t) = 2 t^2 → g'(t) = 4 t → g''(t) = 4
119+
F = ScalarExpressionFunction{Float64}
120+
bc_func = F("2 * t^2", ["x", "y", "t"])
121+
bc_in = DirichletBC("displ_x", bc_func; sideset_name = "sset_1")
122+
bcs = DirichletBCs{F}(mesh, dof, DirichletBC[bc_in])
123+
124+
X = mesh.nodal_coords
125+
t = 3.0
126+
update_bc_values!(bcs, X, t)
127+
@test all(bcs.bc_cache.vals .≈ 18.0)
128+
@test all(bcs.bc_cache.vals_dot .≈ 12.0)
129+
@test all(bcs.bc_cache.vals_dot_dot .≈ 4.0)
130+
131+
U = create_field(dof); V = create_field(dof); A = create_field(dof)
132+
update_field_dirichlet_bcs!(U, V, A, bcs)
133+
@test all(U[dof.dirichlet_dofs] .≈ 18.0)
134+
@test all(V[dof.dirichlet_dofs] .≈ 12.0)
135+
@test all(A[dof.dirichlet_dofs] .≈ 4.0)
136+
end
137+
138+
@testitem "BCs - juliac-safe DirichletBCs Gaussian pulse" setup=[BCHelper] begin
139+
# Exercises a realistic Gaussian-pulse BC of the form used in the
140+
# Norma-ported clamped-bar test: g(t) = a · exp(-(t-tc)^2 / (2 τ^2)).
141+
# All three of g, g', g'' are produced by symbolic differentiation alone.
142+
import FiniteElementContainers: update_bc_values!
143+
import FiniteElementContainers.Expressions: ScalarExpressionFunction
144+
145+
u = VectorFunction(fspace, "displ")
146+
dof = DofManager(u)
147+
148+
a, tc, τ = 1.0e-3, 2.5e-4, 5.0e-5
149+
F = ScalarExpressionFunction{Float64}
150+
bc_func = F("1.0e-3 * exp(-(t - 2.5e-4)^2 / (2 * (5.0e-5)^2))",
151+
["x", "y", "t"])
152+
bc_in = DirichletBC("displ_x", bc_func; sideset_name = "sset_1")
153+
bcs = DirichletBCs{F}(mesh, dof, DirichletBC[bc_in])
154+
155+
X = mesh.nodal_coords
156+
for t in (1.5e-4, 2.5e-4, 3.5e-4)
157+
update_bc_values!(bcs, X, t)
158+
η = t - tc
159+
g_ = a * exp(-η^2 / (2 * τ^2))
160+
gp_ = -/ τ^2) * g_
161+
gpp_=^2 / τ^4 - 1 / τ^2) * g_
162+
@test all(bcs.bc_cache.vals .≈ g_ )
163+
@test all(bcs.bc_cache.vals_dot .≈ gp_ )
164+
@test all(bcs.bc_cache.vals_dot_dot .≈ gpp_)
165+
end
166+
end
167+
107168
@testitem "BCs - test_neumann_bc_input" setup=[BCHelper] begin
108169
bc = NeumannBC("my_var", dummy_func_1, "my_sset")
109170
@test bc.var_name == "my_var"

0 commit comments

Comments
 (0)