Dynamical Similarity and Equivariant Symmetry

Geometric Controls
Exposition
Published

December 8, 2025

Introduction

We have started our investigation of geometric controls by investigating conserved quantities derived from Noether’s (first) theorem. Here we look at a slight extension, where the Noether charge is not conserved for the system itself, but across classes of systems.

Here, we will look at a particular case of this phenomenon: dynamical similarity.

Note: Much of this material was briefly included in the last post prior to a refactor (for cleaner conceptual organization).

AI disclosure: I had ChatGPT draft the one bridge section required to complete the refactor (the “Modified Noether” section).

Background

Given \(g \in G\) for some group \(G\), there is a group action \(q \mapsto \Phi_g(q) = g \cdot q\) (and associated tangent maps) that the Lagrangian is invariant to:

\[ L(\Phi_g(q), T\Phi_g(\dot q)) = L(q, \dot q) \]

But the Euler-Lagrange equations are homogeneous in \(L\). That is, multiplying \(L\) by a nonzero constant \(\alpha\) doesn’t change the equations of motion1.

\[ \frac{d}{dt} \left( \frac{\partial (\alpha L)}{\partial \dot{q}} \right) - \frac{\partial (\alpha L)}{\partial q} = \alpha\bigg(\frac{d}{dt} \left( \frac{\partial L}{\partial \dot{q}} \right) - \frac{\partial L}{\partial q}\bigg) \]

This suggests a looser restraint on the Lagrangian:

\[ L(\Phi_g(q), T\Phi_g(\dot q)) = \chi(g) \cdot L(q, \dot q) \]

where \(\chi: G \to \mathbb{R}_{>0}\) is a group homomorphism2, called the “character”. Note that if \(\forall g\in G\), \(\chi(g) = 1\), we have the original invariance condition.

In this case, the Lagrangian is not invariant but is instead “equivariant”.

When the symmetry is only equivariant, the usual Noether quantity is no longer conserved. Instead, it drifts predictably, as determined by the scaling factor. The combination (with the integral correction) is the piece that remains constant across similar systems.

Modified Noether

Given this, how is the Noether charge modified?

Assume we have a transformation depending on a small parameter \(\epsilon\) and the Lagrangian transforms by a scalar factor

\[ L(q_\epsilon, \dot q_\epsilon) = \chi(\epsilon)\, L(q,\dot q) \]

where \(\chi(0)=1\) and \(\chi\) is smooth.

For the symmetries we care about, we can write

\[ \chi(\epsilon)=e^{k\epsilon} \]

for some constant \(k\).

Differentiate both sides at \(\epsilon = 0\).

Left-hand side:

\[ \left.\frac{d}{d\epsilon} [L(q_\epsilon, \dot q_\epsilon)]\right|_{\epsilon=0} = \frac{d}{dt}\big( p \cdot \omega_Q(q) \big) \]

(plus any time-related terms. I omitted those here but they pass through as you’d expect.)

Right-hand side:

\[ \left.\frac{d}{d\epsilon} \big(\chi(\epsilon) L(q,\dot q)\big)\right|_{\epsilon=0} = \chi'(0)\,L(q,\dot q) = k\,L(q,\dot q) \]

Equating both expressions gives the equivariant Noether identity:

\[ \boxed{ \frac{d}{dt}\big( p \cdot \omega_Q(q) \big) = k\,L(q,\dot q) } \]

This replaces the conservation law of the invariant case.

Integrating in time, the combination

\[ \boxed{ J(t) = p \cdot \omega_Q(q) \;-\; k \!\int_{t_0}^{t} L(q(t'),\dot q(t'))\, dt' } \]

is constant across all trajectories related by the symmetry.

For \(k=0\) (i.e. \(\chi(\epsilon)=1\)), we recover the usual Noether charge.

Reintroducing Time

When we impose an equivariant symmetry on the Lagrangian,

\[ L(\Phi_g(q),\,T\Phi_g(\dot q)) = \chi(g)\,L(q,\dot q), \]

the usual continuous Noether statement produces the modified identity

\[ \frac{d}{dt}(p\cdot\delta q) = k\,L \] \[ \chi(e^\epsilon)=e^{k\epsilon} \]

and the conserved quantitu

\[ J = p\cdot\delta q \;-\; k\!\int_{t_0}^t L\,dt'. \]

At first glance, this seems to require an additional term in the computation of the Noether charge.

What happens if we reintroduce time?

We have extended Lagrangian:

\[ \tilde L(\tilde q,\dot{\tilde q}) = \dot t\,L\!\left(q,\frac{\dot q}{\dot t}\right). \]

Recall

\[ \tilde J = \frac{\partial \tilde L}{\partial \dot t} \left.\frac{d t_\epsilon}{d\epsilon}\right|_{\epsilon=0} \;+\; p\cdot \left.\frac{d q_\epsilon}{d\epsilon}\right|_{\epsilon=0} \]

with no additional terms.

For the scaling symmetry

\[ (t,q) \mapsto (\lambda^{\alpha} t,\; \lambda q) \] \[ \lambda = e^{\epsilon} \]

we have

\[ \left.\frac{d t_\epsilon}{d\epsilon}\right|_{\epsilon=0} = \alpha t \] \[ \left.\frac{d q_\epsilon}{d\epsilon}\right|_{\epsilon=0} = q \]

so

\[ J = -\,\alpha H t + p q, \]

which is exactly the form we derived in the example section.

Since we know

\[ \frac{d}{dt}(p\cdot\delta q) = k\,L \]

and the extended-time identity

\[ \frac{d}{dt}(-\alpha H t + p q) = 0 \]

hold at the same time, we can subtract:

\[ \alpha\,\frac{d}{dt}(H t) = k\,L \]

Integrating from \(t_0\) to \(t\),

\[ \alpha H t - \alpha H(t_0)t_0 = k\!\int_{t_0}^{t} L\,dt'. \]

So (up to a constant)

\[ -\,k\!\int L\,dt = -\,\alpha H t \]

So (assuming \(L\) is homogeneous, has a Hamiltonian and the symmetry rescales time) we actually don’t have to compute that integral! This entire formulation is equivalent to our extended Noether framework!

Examples

Homogeneous Potentials

Let’s look at dynamical similarity.

Suppose we have:

\[ L(q, \dot q) = \frac{1}{2}m|\dot q|^2 - V(q) \]

i.e. movement under some potential \(V(q)\). Let’s assume the potential \(V(q)\) is homogeneous of degree \(k\):

\[ V(\lambda q) = \lambda^{k}V(q) \]

and we have symmetry of form:

\[ (t, q) \mapsto (\lambda^{\alpha}t, \lambda q) \]

With \(\lambda = e^{\epsilon}\) (so it’s infinitesimal) and \(\alpha = 1-k/2\) (which causes all of \(q, \dot q, V, K\) to scale homogeneously: \(q\) scales as \(\lambda\), \(\dot q\) as \(\lambda^{1-\alpha}\), \(K\) as \(\lambda^{2(1-\alpha)}\), \(V\) as \(\lambda^k\)).

That is:

\[ L(\lambda q, \lambda^{1-\alpha} \dot q) = \frac{1}{2}m|\lambda^{1-\alpha}\dot q|^2 - V(\lambda q) = \lambda^k(\frac{1}{2}m|\dot q|^2 - V(q)) = \lambda^{k}L(q, \dot q) \]

(since \(2-2\alpha = k\)).

Let’s compute the Noether charge:

\[ J = \frac{\partial \tilde L}{\partial \dot t}\frac{\partial t}{\partial \epsilon} \biggr|_{\epsilon=0} + \ p\cdot \omega_Q(q) \] \[ J = \frac{\partial \tilde L}{\partial \dot t}\frac{d}{d\epsilon}[e^{\alpha\epsilon}t] \biggr|_{\epsilon=0} + \ p \frac{d}{d\epsilon}[e^{\epsilon}q]|_{\epsilon=0} \] \[ J = \frac{\partial \tilde L}{\partial \dot t}\alpha t + \ p q \]

And from a previous example we know

\[ \frac{\partial \tilde L}{\partial \dot t} = -H \]

so

\[ J = -\alpha Ht + pq \]

This specializes to some known cases:

Free Fall

\(q=h\) in this case. \(p = m|\dot q|\). If we double the initial height, we need to “stretch time” by dividing \(t\) by \(\sqrt 2\) to remain on a valid solution.

Here potential scales with height \(V(h) = mgh\), so \(V(q) = \lambda q\) and \(k=1\). \(\alpha=1/2\).

\(J = -\frac{1}{2}Ht + m|\dot q|h\)

Kepler’s Third Law

\(q=r\) in this case. \(p=m|\dot q|\). So doubling the radius \(r\) impies you must “stretch time” (like the time to complete one orbit) by dividing by \(2^{3/2}\) to stay on a valid solution.

Here, \(V \propto \frac{1}{q}\), so \(k = -1\). \(\alpha = 3/2\), as in Kepler’s third law.

\[ J = -\frac{3}{2}Ht + m|\dot q|r \]

Code

We don’t need to adjust the code at all. It should already work!

Examples

Kepler’s Third Law

We define

class Kepler(VariationalSystem):
    def control_plane(self):
        return {
            "r": Rn(2)
        }
        
    def params(self):
        return ["mass", "mu"]

    def lagrangian(self, ctrl, dctrl):
        r = ctrl.r
        rdot = dctrl.r
        m = self.params.mass
        mu = self.params.mu
            
        r_norm = torch.sqrt((r * r).sum() + 1e-10)
        T = 0.5 * m * (rdot * rdot).sum()
        V = -mu / r_norm
        return T - V

We run it with

if __name__ == "__main__":
    kepler = Kepler({
        "mass": 1.0,
        "mu": 1.0
    })

    h = 0.01
    recorder = StepRecorder()
    integrator = VariationalIntegrator(kepler, step_size=h, on_step=recorder.on_step)

    # Energy (time translation): (eps, t) -> t + eps
    energy_sym = Symmetry(
        space_transform=lambda eps, t, q: q,
        time_transform=lambda eps, t, q: t + eps
    )
    integrator.register_noether_charge("energy", energy_sym)

    r_slice = kepler.model.layout["r"][1]
    def rotate_r(eps, t, q, sl=r_slice):
        qn = q.clone()
        x, y = q[sl]
        c, s = math.cos(eps), math.sin(eps)
        qn[sl] = torch.tensor([c*x - s*y, s*x + c*y], dtype=q.dtype)
        return qn

    angular_sym = Symmetry(space_transform=rotate_r)
    integrator.register_noether_charge("angular_momentum", angular_sym)

    alpha = 1.5

    def scale_r(eps, t, q, sl=r_slice):
        qn = q.clone()
        qn[sl] = math.exp(eps) * q[sl]
        return qn

    dyn_sim = Symmetry(
        space_transform=scale_r,
        time_transform=lambda eps, t, q: math.exp(alpha * eps) * t
    )

    integrator.register_noether_charge("dynamical_similarity", dyn_sim)

    # Initial conditions for elliptical orbit
    r0 = torch.tensor([1.0, 0.0], dtype=torch.float64)
    v0 = torch.tensor([0.0, 0.8], dtype=torch.float64)
    t0 = torch.tensor([0.0], dtype=torch.float64)

    ctrl0 = AttrObject({"r": r0, "t": t0})
    q0 = kepler.model.pack(ctrl0)

    steps = 500

    ctrl1 = AttrObject({"r": r0 + h * v0, "t": t0 + h})
    q1 = kepler.model.pack(ctrl1)

    qs = [q0.clone(), q1.clone()]
    q_prev, q_curr = q0, q1

    for _ in tqdm.tqdm(range(steps - 2)):
        q_next, ok = integrator.step(q_prev, q_curr)
        qs.append(q_next.clone())
        q_prev, q_curr = q_curr, q_next

    qs = torch.stack(qs, dim=0)

    print("\nKepler Problem:")
    energies = [float(rec["noether_charges"]["energy"]) for rec in recorder.records]
    angular = [float(rec["noether_charges"]["angular_momentum"]) for rec in recorder.records]
    similarity = [float(rec["noether_charges"]["dynamical_similarity"]) for rec in recorder.records]
    
    print("Energy (should be constant):")
    print("  min:", min(energies), "max:", max(energies), "drift:", energies[-1] - energies[0])
    print("Angular momentum (should be constant):")
    print("  min:", min(angular), "max:", max(angular), "drift:", angular[-1] - angular[0])
    
    J_values = [float(rec["noether_charges"]["dynamical_similarity"]) for rec in recorder.records]

    # Second Kepler run, scaled initial conditions
    lam = 2.0
    kepler2 = Kepler({
        "mass": 1.0,
        "mu": 1.0
    })
    recorder2 = StepRecorder()
    h2 = h*(lam**alpha)
    integrator2 = VariationalIntegrator(kepler2, step_size=h2, on_step=recorder2.on_step)

    r0_sc = lam * r0
    v0_sc = (lam ** (1.0 - alpha)) * v0
    t0_sc = torch.tensor([0.0], dtype=torch.float64)

    ctrl0_sc = AttrObject({"r": r0_sc, "t": t0_sc})
    q0_sc = kepler2.model.pack(ctrl0_sc)

    ctrl1_sc = AttrObject({"r": r0_sc + h2 * v0_sc, "t": t0_sc + h2})
    q1_sc = kepler2.model.pack(ctrl1_sc)

    qs2 = [q0_sc.clone(), q1_sc.clone()]
    q_prev, q_curr = q0_sc, q1_sc

    for _ in tqdm.tqdm(range(steps - 2)):
        q_next, ok = integrator2.step(q_prev, q_curr)
        qs2.append(q_next.clone())
        q_prev, q_curr = q_curr, q_next

    qs2 = torch.stack(qs2, dim=0)

    base_t, base_J = compute_J_from_records(kepler, recorder, alpha)
    sc_t, sc_J = compute_J_from_records(kepler2, recorder2, alpha)

    lam_t = lam ** alpha
    lam_J = lam ** (2.0 - alpha)

    errors = []
    for tb, Jb in zip(base_t, base_J):
        target_t = lam_t * tb
        idx = min(range(len(sc_t)), key=lambda k: abs(sc_t[k] - target_t))
        J_scaled_rescaled = sc_J[idx] / lam_J
        errors.append(J_scaled_rescaled - Jb)

    print(f"\nDynamical similarity comparison (lambda={lam}):")
    print("  J_scaled/lam^{2-alpha} - J_base stats:")
    print("    min:", min(errors))
    print("    max:", max(errors))
    print("    mean:", sum(errors) / len(errors))

This is the previous example, but we run it twice, at two different scales.

We get:

Kepler Problem:
Energy (should be constant):
  min: 0.6735297151115243 max: 0.6868012832352087 drift: -0.0026780225061522334
Angular momentum (should be constant):
  min: 0.8000193606114198 max: 0.8000206253911845 drift: -1.9376809246018922e-07
100%|████████████████████████████████████████████████████████████████████████████████| 498/498 [00:01<00:00, 361.06it/s]
Dynamical similarity comparison (lambda=2.0):
  J_scaled/lam^{2-alpha} - J_base stats:
    min: -3.547062643605159e-11
    max: 4.892536153988658e-09
    mean: 9.990145743197486e-10

Which looks good. So we have the same \(J\) for similar curves.

Conclusion

We showed how equivariance results in Noether charges across similar systems, rather than within a single system. In the next post in this series, I plan to dig into some more interesting examples.

Footnotes

  1. There’s also another way (gauges) to transform the Lagrangian while preserving the physics that I’ll explore in a later post.↩︎

  2. Ignoring the cases where \(\chi(g)\) is less than zero, as that flips the minima and maxima.↩︎