def register_noether_charge(self, name:str, symmetry: Symmetry):
def _new_charge(qk, qk1):
p = self.D2_Ld(qk, qk1)
qk1_eps = symmetry.log(symmetry.exp(self.tol * symmetry.generator) * symmetry.exp(qk1))
omega_qk1 = (qk1_eps - qk1)/self.tol
return (p*omega_qk1).sum()
self.noether_charges[name] = _new_charge Introduction
Let’s extend the discrete controls framework and Noether’s theorems to include time-invariance. We’ll start with extending the continuous version, then discretize.
Edit: I refactored this post on December 8th 2025, moving the discussion of homogeneous potentials and dynamical similarity to the subsequent post. Some symmetries (such as dynamical similarity) do not make the physical Lagrangian strictly invariant, but become invariant only after lifting to the extended space \(\tilde Q\) and parametrizing by \(s\). In such cases, the associated Noether quantity is conserved with respect to \(s\) but not generally with respect to the physical time \(t\).
Background
Lagrangian
Before we defined our Lagrangian as
\[ L : TQ \to \mathbb{R} \]
where the action was
\[ S[q] = \int_{t_0}^{t_N} L(q(t), \dot{q}(t)) \, dt \]
Let us now alter our definitions to explicitly include time. We want:
\[ L': \mathbb{R} \times TQ \to \mathbb{R} \]
where
\[ S[q] = \int_{t_0}^{t_N} L'(t, q(t), \dot{q}(t)) \, dt \]
Let’s start by defining a new manifold:
\(\tilde Q := \mathbb{R} \times Q\)
Where \(\tilde q \in \tilde Q\) looks like \((t, q)\).
In terms of \(\tilde Q\), the Lagrangian \(\tilde L\) is
\[ \tilde L: T\tilde Q \to \mathbb{R} \]
where \(\tilde L\) takes as data \((\tilde q, \dot {\tilde q})\) and returns a number.
However, there’s a problem. Since \(t\) is now part of the state, we need to introduce a new variable to play to role of \(t\) in the adjusted formulation. Thus, we introduce a dummy parameter, “virtual time”, denoted \(s\)1.
A curve through \(\tilde Q\) is thus \(\tilde q(s) := (t(s), \ q(t(s)))\), and the action is
\[ S[q] = \int_{s_0}^{s_N} \tilde L(\tilde q(s), \dot{\tilde q}(s)) \, ds \]
Unpacking this further, the \(q\)’s don’t actually depend on \(s\) directly. They only depend through \(t(s)\), so our “dot” operator is now with respect to \(s\). What does this mean for our derivation?
Let’s try casting this back into physical time.
By definitions of \(\tilde q\)
\[ S[\tilde q] = \int_{s_0}^{s_N}\tilde L((t, q(t(s))), (\dot t, \frac{d}{ds}[q(t(s))])) \, ds \]
Consider: \(\frac{d}{ds}[q(t(s))] = \frac{dq}{dt}(t(s)) \cdot \frac{dt}{ds} = \frac{dq}{dt}(t(s)) \cdot \dot t\)
So
\[ S[\tilde q] = \int_{s_0}^{s_N} \tilde L((t, q(t(s))), (\dot t, \frac{dq}{dt}(t(s)) \cdot \dot t)) \, ds \]
The action is preserved, so:
\[ \int_{s_0}^{s_N} \tilde L((t, q(t(s))), (\dot t, \frac{dq}{dt}(t(s)) \cdot \dot t)) \, ds = \int_{t_0(s_0)}^{t_N(s_N)} L'(t, q(t), \dot{q}(t)) \, dt \]
Or, by adjusting the RHS
\[ \int_{s_0}^{s_N} \tilde L((t, q(t(s))), (\dot t, \frac{dq}{dt}(t(s)) \cdot \dot t)) \, ds = \int_{s_0}^{s_N} L'(t, q(t), \frac{dq}{dt}) \ \dot t\, ds \]
Let’s define some temp variables: \(A = t,\ B = q(t(s)),\ C = \dot t,\ D = \frac{dq}{dt}(t(s)) \cdot \dot t\).
By \(C\) and \(D\):
\(\frac{D}{C} = \frac{dq}{dt}(t(s))\)
Therefore:
\[ L((A, B), (C, D)) = CL'(A, B, \frac{D}{C}) \]
Which implies
\[ \int_{s_0}^{s_N} \tilde L((t, q(t(s))), (\dot t, \frac{dq}{dt}(t(s)) \cdot \dot t)) \, ds = \int_{s_0}^{s_N} \dot t L'(t, q(t), \frac{dq}{dt}) ds \]
Seen another way, the curve we are integrating over in \(\tilde Q\) is defined by \(\gamma(s):=(t(s), q(t(s)))\). Its derivative is \((\dot t, \frac{dq}{dt} \cdot \dot t)\). If \(\dot t = 1\), then \(t = s\) (up to a constant), and the curve is \(\gamma(t):=(t, q(t))\) with the derivative is \((1, \frac{dq}{dt})\).
So the \(\dot t\) factor is just a linear reparametrization of our curve. Glossing over a few steps, we “normalize” by \(\dot t\) and match arguments to get the following:
\[ S[q] = \int_{s_0}^{s_N} L'(t, q, \frac{\dot q}{\dot t}) \ \dot t \, ds \]
(Note \(\frac{dq}{dt} = \frac{dq/ds}{dt/ds}\))
The upshot is that if the symmetry affects \(t\), then \(\dot q\) needs to be adjusted by “dividing out” the change in the time variable with respect to virtual time2.
Also notice, if \(t = t(s)\), we have \(dt = \dot t ds\).
So
\[ S[q] = \int_{t(s_0)}^{t(s_N)} L'(t, q, \frac{\dot q}{\dot t}) \, dt \]
In particular, if \(t(s) = s\), we have
\[ S[q] = \int_{t_0}^{t_N} L'(t, q, \dot q) \, dt \]
This implies that any Lagrangian \(L(q, \dot q)\) can be extended “for free” into \(L'(t, q, \dot q)\) under the trivial reparametrization \(t=s\)3.
G-Invariance and Noether Charge
By analogy with the time-free case, if \(\tilde q \in \tilde Q\), we define a map \(\Phi'_g\) such that
\[ \tilde \Phi_g : G \times \tilde Q \to \tilde Q \]
\[ \tilde \Phi_g(\tilde q) := \tilde \Phi_g(t, q) = (t', q') \]
Where
\[ \quad t' := \text{proj}_1(\tilde\Phi_g(t,q)) \] and \[ \quad q' := \text{proj}_2(\tilde\Phi_g(t,q)) \]
The \(\proj_i\) are just operators that “unpack” the tuple and return the \(i\)-th argument.
We also construct the map
\[ T\tilde\Phi_g: T\tilde Q \to T\tilde Q \] \[ T\tilde\Phi_g : T_q\tilde Q \to T_{\tilde\Phi_g(\tilde q)}\tilde Q \]
Note that \(T \tilde Q \cong \mathbb{R} \times Q \times \mathbb{R} \times TQ\).
Once again, this matches our previous derivation, but on \(\tilde Q\) instead of \(Q\).
We can now state our updated \(G\)-invariance principle. We say that \(\tilde L\) is \(G\)-invariant if:
\(\forall g \in G, q\in Q\), \[ \tilde L(\tilde \Phi_g(\tilde q), T\tilde\Phi_g(\dot {\tilde q})) = \tilde L(\tilde q, \dot {\tilde q}) \]
At this point, we have reduced the problem back to the original Noether’s theorem proof.
We need to make one change. In the original proof we started here
\[ \left.\frac{d}{d\epsilon}[ L\bigl(q_\epsilon(t),\, \dot q_\epsilon(t))]\right|_{\epsilon=0} = \frac{\partial L}{\partial q}\cdot \frac{dq_{\epsilon}}{d{\epsilon}}\biggr|_{\epsilon=0} + \frac{\partial L}{\partial \dot q} \cdot \frac{d\dot q_{\epsilon}}{d\epsilon}\biggr|_{\epsilon=0} \]
\(\tilde L\) has two tuples as arguments
\[ \left.\frac{d}{d\epsilon}[ \tilde L\bigl((t, q_\epsilon(t)),\,(\dot t, \dot q_\epsilon(t)))]\right|_{\epsilon=0} = \frac{\partial \tilde L}{\partial t} \frac{\partial t}{\partial \epsilon}\biggr|_{\epsilon=0} + \frac{\partial \tilde L}{\partial q}\cdot \frac{dq_{\epsilon}}{d{\epsilon}}\biggr|_{\epsilon=0} + \newline \frac{\partial \tilde L}{\partial \dot t} \frac{\partial \dot t}{\partial \epsilon}\biggr|_{\epsilon=0} + \frac{\partial \tilde L}{\partial \dot q} \cdot \frac{d\dot q_{\epsilon}}{d\epsilon}\biggr|_{\epsilon=0} \]
Our Noether charge ends up being:
\[ J = \frac{\partial \tilde L}{\partial \dot t}\frac{\partial t}{\partial \epsilon} \biggr|_{\epsilon=0} + \ p\cdot \omega_Q(q) \]
Lie Groups
What changes when \(Q = G\), where \(G\) is a Lie group4?
\(\mathbb{R}\) is a Lie group, and Lie groups are closed under product. So the product of \(\mathbb{R}\) and \(G\) is also a Lie group.
We can then use the same reparametrization trick. Call \(\tilde G = \mathbb{R} \times Q\), with \(\tilde g \in \tilde G\) and \(g \in G\).
If you recall, for left-invariant Lie groups we actually are interested in the reduced lagrangian for a Lie Group5:
\[ L(\text{id}_G, \omega) = \ell(\omega) \]
where
\[ \omega = g^{-1}(t)\dot g(t) \in \mathfrak{g} \]
(this is the tangent along some path \(g(t)\) starting from the origin).
Now, we instead seek the reduced Lagrangian dependent on time
\[ \ell(t, \omega) \]
with action
\[ S[\omega] = \int_{t_0}^{t_n} \ell(t, \omega)dt \]
For our extended problem, we get “for free”
\[ S[\tilde \omega] = \int_{s_0}^{s_n} \tilde \ell(\tilde \omega)ds \]
We want to put this in terms of \(t\). First of all, since, we can unpack \(\tilde \omega\) into constituent parts6
\[ \tilde \omega = (t^{-1}\ \dot t,\ g^{-1}\ \dot g) \in \mathbb{R} \times \mathfrak{g} \]
We need to work out \(\dot g\), since the dot operator is now with respect to \(s\). By the chain rule:
\[ \frac{d}{ds}[g(t(s))] = \frac{dg}{dt}\cdot \dot t \]
Thus7
\[ S[\tilde \omega] = \int_{s_0}^{s_n} \tilde \ell((t^{-1} \cdot \dot t, g^{-1}\frac{dg}{dt}\cdot \dot t ))ds \]
Call \(\omega := g^{-1}\frac{dg}{dt}\).
The action is preserved, so
\[ \int_{s_0}^{s_N} \tilde \ell((t^{-1} \cdot \dot t, \omega \cdot \dot t))ds = \int_{t(s_0)}^{t(s_N)} \ell'(t, \omega) dt \]
We need \(\ell'\) (in terms of \(\ell\)) that makes this equation true.
\[ \int_{s_0}^{s_N} \tilde \ell((t^{-1} \cdot \dot t, \omega \cdot \dot t))ds = \int_{s_0}^{s_N} \ell'(t, \omega) \dot t \ ds \]
Mapping back to the original reduced lagrangian via matching argument (same as in the original derivation; omitted), we therefore have
\[ S[\omega] = \int_{s_0}^{s_N} t (t^{-1} \dot t) \ \ell(t, \frac{\omega \dot t}{t (t^{-1} \dot t)}) \ ds = \int_{s_0}^{s_N} \dot t \ \ell(t, \omega) \ ds \]
Interestingly, if you squint you can see a lot of “conjugation actions” that might pop up for a general extension by an arbitrary non-abelian “time group \(T\)”, rather than \(\mathbb{R}\) specifically8.
As a last aside, notice if we change variables back to \(t\):
\[ S[\omega] = \int_{s_0}^{s_N} \dot t \ \ell(t, \omega) \ ds = \int_{t(s_0)}^{t(s_N)} \ell(t, \omega) \ ds \]
So we successfully added a \(t\) to \(\ell\), and can in fact do this to any arbitrary \(\ell\) “without penalty”. So our Lie groups are already normalized by time, “naturally” (it’s included in \(\omega\)).
Examples
Let’s look at some examples.
Energy
Let’s say we have \[ L(t, q, \dot q) \]
preserved under symmetry
\[ (t, q) \mapsto (t_{\epsilon}, \ q_{\epsilon}) = (t + \epsilon, \ q) \]
This implies that
\[ \omega_{Q}(q) = \frac{d}{d\epsilon}[q_{\epsilon}(t)]\bigg|_{\epsilon = 0} = \frac{d}{d\epsilon}[q(t + \epsilon)]\bigg|_{\epsilon = 0} = 0 \]
We have that
\[ \frac{dt}{d\epsilon}\biggr|_{\epsilon=0} = 1 \]
So we need
\[ \frac{\partial \tilde L}{\partial \dot t} = \frac{\partial }{\partial \dot t}[\dot t L'(t, q, \frac{\dot q}{\dot t})] = L' - \frac{\partial L'}{\partial \dot q} \frac{\dot q}{\dot t} \]
In the usual parametrization (\(\dot t = 1\)) the Noether charge is:
\[ L' - p \dot q \]
In our Hamiltonian exposition, we defined
\[ H(t, v, p) = \sup_v (p(v) - L'(t, q, v)) \]
If we evaluate this at the unique \(v(q,p)\) such that \(p = \frac{\partial L'}{\partial \dot q}\), then
\[ H(t, q, p) = p \dot q - L'(t, q, \dot q) \]
So the (negative) Hamiltonian is conserved (the total energy).
Linear Momentum
Consider the following transformation:
\[ (t, q) \mapsto (t, q + c\epsilon) \]
\(t\) is static so we only need to worry about \(p\cdot w_Q(q)\).
\[ w_Q(q) = \frac{d}{d\epsilon}[q_{\epsilon}(t)]\bigg|_{\epsilon = 0} = \frac{d}{d\epsilon}[q + c\epsilon]\bigg|_{\epsilon = 0} = c \]
So \(pc\) is conserved. Setting \(c = 1\) gives conservation of \(p\). If we view \(c\) as vector valued we can view this as conservation of momentum along each dimension.
Galilean Boost
Omitted.
ChatGPT suggested that you might be able to derive Kinetic energy via symmetry of the “free Lagrangian” under \(\mathbb{R}^n \rtimes \text{SO}_n\).
Discrete Noether with Time
As we saw above, for Lie groups the reduced Lagrangian is unchanged for Lie groups. The only thing we really need to do is update the computation of the Noether charge (if time is included).
Code
Claude Opus 4.5 + ChatGPT (with some coaxing through several major issues) was able to modify the code.
In the last post, we had this function
For time, we need to add the time-dependent term:
To handle time-dependent symmetries, we update both Symmetry and register_noether_charge to work with an infinitesimal parameter \(\epsilon\) acting on both space and time:
class Symmetry:
def __init__(
self,
space_transform: Callable[..., torch.Tensor],
time_transform: Optional[Callable[..., float]] = None,
):
self._space_transform = space_transform
self._time_transform = time_transform
def apply_space(self, eps: float, t: float, q: torch.Tensor) -> torch.Tensor:
try:
return self._space_transform(eps, t, q)
except TypeError:
# Allow simpler signatures like f(eps, q)
return self._space_transform(eps, q)
def apply_time(self, eps: float, t: float, q: torch.Tensor) -> float:
if self._time_transform is None:
return t
try:
return self._time_transform(eps, t, q)
except TypeError:
# Allow simpler signatures like f(eps, t)
return self._time_transform(eps, t) def register_noether_charge(self, name: str, symmetry: Symmetry):
def _charge(qk, qk1, t_k, t_k1):
p = self.D2_Ld(qk, qk1)
eps = self.tol
q_eps = symmetry.apply_space(eps, t_k1, qk1)
omega = (q_eps - qk1) / eps
t_eps = symmetry.apply_time(eps, t_k1, qk1)
tau = (t_eps - t_k1) / eps
charge = (p * omega).sum()
if tau != 0.0:
E = self.discrete_energy(qk, qk1)
charge = charge - E * tau
return charge
self.noether_charges[name] = _chargeExample
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 - VWe 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])Ignore the “dynamical similarity” material for now.
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
As expected.
Conclusion
I originally thought this would be a super short post but the tale grew in the telling, plus led me to several additional rabbit holes that I may pursue in future posts (algebraic geometry! self-similarity!). We are still doing “physics” but I will eventually reach “controls”. Thanks for reading.
Changelog
12/8/2025 - Refactored post to move part of Kepler example.
Footnotes
Why not just assume the Lagrangian is constant under translation by time? My intent here is to try and preserve the option of “dynamical similarity”, where time is scaled simultaneously with some other variable, or by some transformation other than \(t \mapsto t + \epsilon\). Another thought: could you have some kind of degenerate dynamical similarity without some notion of virtual time?↩︎
One quick note: \(\dot t = 0\) would correspond to some kind of degenerate symmetry, where \(t \to \text{constant}\). So it shouldn’t happen.↩︎
In code terms, this means we can just add a dummy argument \(t\) to any existing Lagrangian function \(L(q, \dot q)\) and discard it when doing calculations.↩︎
Slight notation discrepancy - this is a different \(G\) than in the previous section.↩︎
Slight notation discrepancy - this is a different \(G\) than in the previous section.↩︎
\(\mathbb{R}\) is it’s own Lie Algebra.↩︎
Please note that \(t^{-1}\) doesn’t imply anything about the group structure of the time dimension. It is simply the group inverse.↩︎
I’m not 100% sure this makes sense due to the way the parametrizations work, but I think there may be some general algorithm “extending” a Lagrangian by appending new Lie groups to the manifold.↩︎
