"""Ostrogradsky reduction for higher-derivative field equations.
Converts 4th-order-in-time equations to 2nd-order by introducing
auxiliary fields. For ``d⁴h/dt⁴ = RHS``, defines ``w = d²h/dt²``
and rewrites as two coupled 2nd-order equations:
d²h/dt² = w (kinematic)
d²w/dt² = RHS' (dynamics, with substituted operators)
This is the standard Ostrogradsky reduction (Ostrogradsky 1850,
"Mémoires sur les équations différentielles"). Modern review:
Woodard (2015, arXiv:1506.02210).
**Physics warning**: Non-degenerate 4th-order Lagrangians generically
have Ostrogradsky ghosts (unbounded Hamiltonian). Ghost-free parameter
windows exist for PGT (Barker 2024, arXiv:2406.12826). Full ghost
detection is tracked in issue #164.
The reduction is applied in-memory during JSON loading, transparent
to all solvers. The JSON on disk is unchanged.
"""
from __future__ import annotations
import logging
import re
import warnings
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from tidal.symbolic.json_loader import (
ComponentEquation,
EquationSystem,
OperatorTerm,
)
logger = logging.getLogger(__name__)
#: Maximum time-derivative order that solvers support natively.
#: Equations with higher order are reduced via Ostrogradsky.
MAX_NATIVE_TIME_ORDER = 2
#: Time-derivative order constants used in Ostrogradsky reduction logic.
_T_ORDER_3 = 3 # 3rd time derivative: d³/dt³
_T_ORDER_4 = 4 # 4th time derivative: Ostrogradsky threshold
#: Spatial derivative orders for operator name mapping.
_S_ORDER_GRADIENT = 1 # 1st spatial derivative → gradient_*
_S_ORDER_LAPLACIAN = 2 # 2nd spatial derivative → laplacian_*
# --- Operator substitution for d⁴ → d² reduction ---
# Maps (operator_on_original_field) → (new_operator, use_auxiliary_field)
# "auxiliary" means the term references the auxiliary field w instead of h.
_OPERATOR_REDUCTION_MAP: dict[str, tuple[str, bool]] = {
# Pure time derivatives → auxiliary field
"d2_t": ("identity", True), # d²_t(h) = w → identity(w)
"d3_t": ("first_derivative_t", True), # d³_t(h) = d_t(w) → v_w
# d4_t: handled specially (cross-field substitution)
# Mixed time-space → spatial on auxiliary
"mixed_T2_S1x": ("gradient_x", True), # ∂²_t ∂_x(h) = ∂_x(w)
"mixed_T2_S2x": ("laplacian_x", True), # ∂²_t ∂²_x(h) = ∂²_x(w)
"mixed_T2_S1y": ("gradient_y", True),
"mixed_T2_S2y": ("laplacian_y", True),
"mixed_T2_S1z": ("gradient_z", True),
"mixed_T2_S2z": ("laplacian_z", True),
}
# Pattern for mixed_T[n]_S[m][axis] operators
_MIXED_RE = re.compile(r"^mixed_T(\d+)_(.+)$")
[docs]
@dataclass(frozen=True)
class AuxiliaryField:
"""Metadata for an Ostrogradsky auxiliary field.
Attributes
----------
original_field : str
Name of the higher-order field (e.g., "h_3").
auxiliary_name : str
Name of the auxiliary field (e.g., "w_h_3").
original_time_order : int
Time derivative order of the original equation (4, 6, ...).
"""
original_field: str
auxiliary_name: str
original_time_order: int
[docs]
def apply_ostrogradsky_reduction( # noqa: C901, PLR0914
spec: EquationSystem,
) -> EquationSystem:
"""Apply Ostrogradsky reduction to higher-derivative equations.
For each equation with ``time_derivative_order > 2``, introduces
auxiliary fields to reduce to 2nd-order. The reduction is applied
in-memory — the JSON on disk is unchanged.
Parameters
----------
spec : EquationSystem
Original equation system, possibly with higher-order equations.
Returns
-------
EquationSystem
Modified system where all equations have ``time_order <= 2``.
Auxiliary fields are added to the component list.
Raises
------
ValueError
If equations have odd time-derivative order (not physically
meaningful for Euler-Lagrange equations from a Lagrangian).
References
----------
- Ostrogradsky M.V. (1850), Mem. Acad. St. Petersbourg VI 4, 385.
- Woodard R.P. (2015), arXiv:1506.02210.
- Barker W.E.V. et al. (2024), arXiv:2406.12826.
"""
from tidal.symbolic.json_loader import ( # noqa: PLC0415
ComponentEquation,
OperatorTerm,
)
# Identify higher-order equations
fourth_order = [
eq for eq in spec.equations if eq.time_derivative_order > MAX_NATIVE_TIME_ORDER
]
if not fourth_order:
return spec # Nothing to reduce
# Validate: only even orders (from E-L equations)
for eq in fourth_order:
if eq.time_derivative_order % 2 != 0:
msg = (
f"Field {eq.field_name}: odd time-derivative order "
f"{eq.time_derivative_order} not supported by Ostrogradsky "
f"reduction (E-L equations should have even order)"
)
raise ValueError(msg)
# Build auxiliary field mapping
aux_map: dict[str, str] = {} # original_name → auxiliary_name
aux_fields: list[AuxiliaryField] = []
for eq in fourth_order:
aux_name = f"w_{eq.field_name}"
aux_map[eq.field_name] = aux_name
aux_fields.append(
AuxiliaryField(
original_field=eq.field_name,
auxiliary_name=aux_name,
original_time_order=eq.time_derivative_order,
)
)
logger.info(
"Ostrogradsky reduction: %d fields with order > 2 → %d auxiliary fields",
len(fourth_order),
len(aux_fields),
)
warnings.warn(
f"Higher-derivative theory: {len(fourth_order)} equation(s) with "
f"time order > 2 reduced via Ostrogradsky. Ghost instability may "
f"be present (see issue #164 for propagator analysis).",
stacklevel=2,
)
# Build time-order lookup for ALL fields (needed for mixed operator resolution)
{eq.field_name: eq.time_derivative_order for eq in spec.equations}
# Pre-process: substitute d4_t cross-references
# If d4_t(h_5) appears in h_3's RHS, and h_5 is also 4th-order,
# then d4_t(h_5) = RHS_h5. Substitute RHS_h5 into h_3's equation.
rhs_lookup: dict[str, tuple[OperatorTerm, ...]] = {
eq.field_name: eq.rhs_terms for eq in fourth_order
}
# Build new equation list
new_equations: list[ComponentEquation] = []
next_idx = max(eq.field_index for eq in spec.equations) + 1
for eq in spec.equations:
if eq.time_derivative_order <= MAX_NATIVE_TIME_ORDER:
# Non-4th-order: pass through, but substitute any d2_t/d3_t
# references to 4th-order fields with auxiliary references
new_terms = _substitute_cross_field_refs(eq.rhs_terms, aux_map, rhs_lookup)
new_equations.append(replace(eq, rhs_terms=new_terms))
continue
# 4th-order equation: split into two 2nd-order equations
aux_name = aux_map[eq.field_name]
# Equation 1: d²h/dt² = w (kinematic)
eq_kinematic = ComponentEquation(
field_name=eq.field_name,
field_index=eq.field_index,
time_derivative_order=2,
rhs_terms=(
OperatorTerm(
operator="identity",
field=aux_name,
coefficient=1.0,
coefficient_symbolic="1",
),
),
constraint_solver=eq.constraint_solver,
)
new_equations.append(eq_kinematic)
# Equation 2: d²w/dt² = RHS' (dynamics)
# Substitute operators: d2_t(h) → identity(w), etc.
new_rhs = _reduce_rhs_terms(eq.rhs_terms, eq.field_name, aux_map, rhs_lookup)
eq_dynamics = ComponentEquation(
field_name=aux_name,
field_index=next_idx,
time_derivative_order=2,
rhs_terms=new_rhs,
constraint_solver=eq.constraint_solver,
)
new_equations.append(eq_dynamics)
next_idx += 1
# Build new component names and field list
new_names = list(spec.component_names)
new_names.extend(af.auxiliary_name for af in aux_fields)
# Expand mass/coupling matrices to include auxiliary fields (zero rows/cols)
import numpy as np # noqa: PLC0415
n_new = len(new_names)
n_old = spec.n_components
n_aux = n_new - n_old
def _expand_matrix(
mat: tuple[tuple[float, ...], ...] | np.ndarray | None,
n_old: int,
n_aux: int,
) -> tuple[tuple[float, ...], ...] | np.ndarray | None:
if mat is None:
return None
arr = np.array(mat, dtype=float)
expanded = np.zeros((n_old + n_aux, n_old + n_aux))
expanded[:n_old, :n_old] = arr
# Return as tuple-of-tuples to match original format
return tuple(tuple(row) for row in expanded)
new_mass = _expand_matrix(spec.mass_matrix, n_old, n_aux)
new_coupling = _expand_matrix(spec.coupling_matrix, n_old, n_aux)
# Post-reduction: eliminate time-derivative operators on constraint fields.
# For constraint fields (time_order=0), ∂²_t(field) = 0 in the linearized
# theory. Any d2_t, d3_t, d4_t, or mixed_T* operator on a constraint
# field produces zero → drop the term.
constraint_fields = {
eq.field_name for eq in spec.equations if eq.time_derivative_order == 0
}
time_ops = {"d2_t", "d3_t", "d4_t", "first_derivative_t"}
cleaned_equations: list[ComponentEquation] = []
for eq in new_equations:
cleaned_terms = tuple(
t
for t in eq.rhs_terms
if not (
t.field in constraint_fields
and (t.operator in time_ops or _MIXED_RE.match(t.operator))
)
)
if cleaned_terms != eq.rhs_terms:
n_dropped = len(eq.rhs_terms) - len(cleaned_terms)
logger.info(
"Ostrogradsky: dropped %d time-derivative terms on constraint "
"fields in %s equation",
n_dropped,
eq.field_name,
)
cleaned_equations.append(replace(eq, rhs_terms=cleaned_terms))
# Reconstruct EquationSystem with reduced equations
return replace(
spec,
n_components=n_new,
component_names=tuple(new_names),
equations=tuple(cleaned_equations),
mass_matrix=new_mass,
coupling_matrix=new_coupling,
)
def _reduce_rhs_terms(
terms: tuple[OperatorTerm, ...],
field_name: str,
aux_map: dict[str, str],
rhs_lookup: dict[str, tuple[OperatorTerm, ...]],
) -> tuple[OperatorTerm, ...]:
"""Reduce RHS terms for a 4th-order equation's auxiliary dynamics.
Substitutes time-derivative operators with auxiliary field references.
"""
result: list[OperatorTerm] = []
for term in terms:
new_term = _reduce_single_term(term, field_name, aux_map, rhs_lookup)
if new_term is not None:
if isinstance(new_term, list):
result.extend(new_term)
else:
result.append(new_term)
return tuple(result)
def _reduce_single_term( # noqa: C901, PLR0911
term: OperatorTerm,
own_field: str,
aux_map: dict[str, str],
_rhs_lookup: dict[str, tuple[OperatorTerm, ...]],
) -> OperatorTerm | list[OperatorTerm] | None:
"""Reduce a single RHS term via Ostrogradsky substitution.
Raises
------
ValueError
If ``d4_t`` appears on a field that is not a 4th-order field.
"""
from tidal.symbolic.json_loader import OperatorTerm # noqa: PLC0415
op = term.operator
field = term.field
# Check if the field is a 4th-order field with auxiliary
field_has_aux = field in aux_map
aux_name = aux_map.get(field, "")
# d4_t cross-reference: substitute with RHS of target equation
if op == "d4_t" and field_has_aux:
# d4_t(field) = d²_t(w_field) = the RHS of w_field's equation
# This is circular for own field (d4_t(h) in h's own equation)
# For cross-field: d4_t(h_5) in h_3's equation = RHS_h5
if field == own_field:
msg = (
f"d4_t({field}) in own equation — circular reference. "
f"This shouldn't happen in correctly derived E-L equations."
)
logger.warning(msg)
return None
# Substitute with the target field's RHS (scaled by coefficient)
# d4_t(h_5) = d²_t(w_h_5), which becomes the RHS of w_h_5
# But we can just keep it as d2_t(w_h_5) — the dynamics will handle it
return OperatorTerm(
operator="d2_t",
field=aux_name,
coefficient=term.coefficient,
coefficient_symbolic=term.coefficient_symbolic,
)
# d4_t on non-4th-order field: error
if op == "d4_t" and not field_has_aux:
msg = f"d4_t({field}) but {field} is not 4th-order"
raise ValueError(msg)
# Check lookup table for direct reduction
if op in _OPERATOR_REDUCTION_MAP and field_has_aux:
new_op, use_aux = _OPERATOR_REDUCTION_MAP[op]
target = aux_name if use_aux else field
return OperatorTerm(
operator=new_op,
field=target,
coefficient=term.coefficient,
coefficient_symbolic=term.coefficient_symbolic,
)
# Check d2_t/d3_t on non-auxiliary fields (2nd-order fields)
if op == "d2_t" and not field_has_aux:
# d2_t on a 2nd-order field = the LHS of that field's equation
# This is an implicit coupling — keep as-is for now
# The solver's RHS evaluator handles d2_t via acceleration
return term
if op == "d3_t" and not field_has_aux:
# d3_t on a 2nd-order field — needs velocity of acceleration
# For now, keep as-is (solver needs special handling)
return term
# Handle mixed_T[n]_S[m] operators
m = _MIXED_RE.match(op)
if m and field_has_aux:
t_order = int(m.group(1))
s_part = m.group(2)
if t_order == MAX_NATIVE_TIME_ORDER:
# mixed_T2_Sx(h) = ∂_x(∂²_t h) = ∂_x(w) → spatial_op(w)
spatial_op = _spatial_part_to_operator(s_part)
return OperatorTerm(
operator=spatial_op,
field=aux_name,
coefficient=term.coefficient,
coefficient_symbolic=term.coefficient_symbolic,
)
if t_order == _T_ORDER_3:
# mixed_T3_Sx(h) = ∂_x(∂³_t h) = ∂_x(∂_t w)
# → mixed_T1_Sx(w) (one less time order)
new_op = f"mixed_T{t_order - MAX_NATIVE_TIME_ORDER}_{s_part}"
return OperatorTerm(
operator=new_op,
field=aux_name,
coefficient=term.coefficient,
coefficient_symbolic=term.coefficient_symbolic,
)
if t_order >= _T_ORDER_4:
# Higher-order mixed: reduce by 2
new_op = f"mixed_T{t_order - MAX_NATIVE_TIME_ORDER}_{s_part}"
return OperatorTerm(
operator=new_op,
field=aux_name,
coefficient=term.coefficient,
coefficient_symbolic=term.coefficient_symbolic,
)
# Mixed operators on non-4th-order fields: check if resolvable.
# For constraint fields (time_order=0), ∂²_t(field) = 0 in the
# linearized theory → mixed_T2_S*(field) = ∂_x(∂²_t field) = 0.
# For 2nd-order dynamical fields, mixed_T2_S1x(field) = ∂_x(d²_t field)
# — this is a genuine RHS term that needs solver support.
if m and not field_has_aux:
return term
# Pure spatial operators: pass through unchanged
return term
def _substitute_cross_field_refs(
terms: tuple[OperatorTerm, ...],
aux_map: dict[str, str],
rhs_lookup: dict[str, tuple[OperatorTerm, ...]],
) -> tuple[OperatorTerm, ...]:
"""Substitute d2_t/d3_t references to 4th-order fields in non-4th-order equations."""
from tidal.symbolic.json_loader import OperatorTerm # noqa: PLC0415
result: list[OperatorTerm] = []
for term in terms:
if term.field in aux_map:
# This term references a 4th-order field
if term.operator == "d2_t":
# d2_t(4th_order_field) → identity(w_field)
result.append(
OperatorTerm(
operator="identity",
field=aux_map[term.field],
coefficient=term.coefficient,
coefficient_symbolic=term.coefficient_symbolic,
)
)
elif term.operator == "d3_t":
# d3_t(4th_order_field) → first_derivative_t(w_field)
result.append(
OperatorTerm(
operator="first_derivative_t",
field=aux_map[term.field],
coefficient=term.coefficient,
coefficient_symbolic=term.coefficient_symbolic,
)
)
elif term.operator == "d4_t":
# d4_t(4th_order_field) → d2_t(w_field)
result.append(
OperatorTerm(
operator="d2_t",
field=aux_map[term.field],
coefficient=term.coefficient,
coefficient_symbolic=term.coefficient_symbolic,
)
)
elif _MIXED_RE.match(term.operator):
# Mixed operator on 4th-order field — reduce time order
reduced = _reduce_single_term(term, "", aux_map, rhs_lookup)
if reduced is not None:
if isinstance(reduced, list):
result.extend(reduced)
else:
result.append(reduced)
else:
# Spatial-only operator on 4th-order field: pass through
result.append(term)
else:
# Non-4th-order field: pass through
result.append(term)
return tuple(result)
def _spatial_part_to_operator(s_part: str) -> str:
"""Convert spatial part code to standard operator name.
Examples: "S1x" → "gradient_x", "S2x" → "laplacian_x"
Raises
------
ValueError
If ``s_part`` does not match the expected ``S[order][axis]`` pattern.
"""
# Parse S[order][axis] pattern
m = re.match(r"S(\d+)(\w+)", s_part)
if not m:
msg = f"Cannot parse spatial part '{s_part}'"
raise ValueError(msg)
order = int(m.group(1))
axis = m.group(2)
if order == _S_ORDER_GRADIENT:
return f"gradient_{axis}"
if order == _S_ORDER_LAPLACIAN:
return f"laplacian_{axis}"
# Higher-order spatial: use generic name
return f"derivative_{order}_{axis}"