Special Relativity: Minkowski Spacetime#
This tutorial works through special relativity in coordinax, using a real measurement as the thread: why cosmic-ray muons reach the ground when, classically, they should not. You will learn how to:
Represent events on
MinkowskiManifoldwith theMinkowskiCTchartMeasure with
intervalinstead ofseparation, and why that swap is forcedClassify pairs of events as timelike, null, or spacelike
Change inertial frame with
LorentzBoostSee which quantities are frame-dependent and which are invariant
Prerequisites: Working With Charts and Working With Manifolds.
>>> import coordinax.charts as cxc
>>> import coordinax.manifolds as cxm
>>> import coordinax.representations as cxr
>>> import coordinax.transforms as cxfm
>>> import unxt as u
>>> import quaxed.numpy as jnp
The Scenario#
Muons are created about 15 km up, when cosmic rays strike the upper atmosphere. A muon at rest decays with a mean lifetime of about \(2.2\;\mu\text{s}\), and the ones we care about move at \(\beta = v/c \approx 0.998\).
Multiply those together the naive way and a muon covers
so essentially none should reach sea level. Detectors at sea level find plenty of them. Special relativity is what closes the gap, and we can watch it close.
Step 1: The Chart#
Minkowski spacetime is minkowski4d, and its canonical chart is minkowskict, with components \((ct, x, y, z)\).
>>> M = cxm.minkowski4d
>>> M
MinkowskiManifold(ndim=4)
>>> cxc.minkowskict.components
('ct', 'x', 'y', 'z')
Note that all four components carry length units, not just the spatial ones. The time coordinate is \(ct\) — a time multiplied by the speed of light. This is not a quirk of the implementation; it is what makes the metric dimensionless and lets a boost be an ordinary dimensionless matrix. It also means “one second of time” is written as one light-second of \(ct\).
The metric carries the signature that does all the work:
>>> M.metric.signature
(-1, 1, 1, 1)
That single minus sign is the entire difference between this tutorial and Euclidean geometry.
Step 2: Why separation Is Not the Tool#
Let us set up the muon’s birth and death as two events. We work first in the muon’s own rest frame, where it does not move: it is born at the origin and decays \(2.2\;\mu\text{s}\) later at the same place.
>>> c = u.Q(299792458.0, "m/s")
>>> tau0 = u.Q(2.2, "us")
>>> ct0 = (c * tau0).uconvert("m")
>>> round(float(ct0.ustrip("m")), 1)
659.5
>>> def event(ct, x, y=0.0, z=0.0):
... return {"ct": u.Q(ct, "m"), "x": u.Q(x, "m"), "y": u.Q(y, "m"), "z": u.Q(z, "m")}
...
>>> birth = event(0.0, 0.0)
>>> death = event(float(ct0.ustrip("m")), 0.0)
The reflex from Euclidean geometry is to ask for the separation between them. That does not work here, and coordinax says so rather than handing back a number:
>>> try:
... cxm.separation(cxc.minkowskict, birth, death)
... except NotImplementedError as e:
... print(str(e)[:50])
...
separation() supports only positive-definite metri
The refusal is not squeamishness. separation is \(\sqrt{\Delta x^\top G\, \Delta x}\), and with a \((-1,1,1,1)\) signature that quadratic form goes negative whenever the time difference dominates. The square root of a negative number is nan, which would be a wrong answer wearing the costume of a right one.
Step 3: The Interval#
The quantity that is defined is the same form without the square root — the invariant interval:
>>> ds2 = cxm.interval(cxc.minkowskict, birth, death)
>>> round(float(ds2.ustrip("m2")))
-434998
Negative, as promised. Its sign is physically meaningful, and causal_character reads it off.
Note the namespace: interval lives directly on coordinax.manifolds because the signed quadratic form is defined for every metric, but the verbs that read its sign need a timelike direction, so they live in cxm.lorentzian. (Named for the signature rather than “spacetime” because galileanct is a spacetime too, and is not Lorentzian.)
>>> cxm.lorentzian.causal_character(cxc.minkowskict, birth, death)
'timelike'
Timelike means a slower-than-light observer can be present at both events — here, obviously, the muon itself. The three cases:
\(\Delta s^2\) |
name |
meaning |
|---|---|---|
\(< 0\) |
timelike |
one observer can attend both events |
\(= 0\) |
null |
only a light ray connects them |
\(> 0\) |
spacelike |
no observer attends both; neither causes the other |
>>> cxm.lorentzian.causal_character(cxc.minkowskict, birth, event(1.0, 5.0))
'spacelike'
>>> cxm.lorentzian.causal_character(cxc.minkowskict, birth, event(3.0, 3.0))
'null'
For a timelike pair, the magnitude is the elapsed proper time — what a wristwatch carried between the two events would read. Since we built these events from the muon’s lifetime, we should get it back:
>>> tau = cxm.lorentzian.proper_time(cxc.minkowskict, birth, death)
>>> round(float(tau.uconvert("us").ustrip("us")), 3)
2.2
For a spacelike pair the magnitude is a proper distance instead, and asking for the wrong one is an error rather than a silent nan:
>>> sigma = cxm.lorentzian.proper_distance(cxc.minkowskict, birth, event(3.0, 5.0))
>>> round(float(sigma.ustrip("m")), 3)
4.0
>>> try:
... cxm.lorentzian.proper_time(cxc.minkowskict, birth, event(1.0, 5.0))
... except ValueError as e:
... print(str(e)[:52])
...
proper_time() is defined only for timelike-separated
Step 4: Changing Frames with a Boost#
So far everything is in the muon’s rest frame. The atmosphere is 15 km thick in our frame, so to compare we must change frames — which is what a LorentzBoost does.
The boost parameter is the dimensionless \(\boldsymbol\beta = \mathbf{v}/c\). That is the chart-native choice: since the chart already measures time in length units, no speed-of-light constant is needed here at all.
>>> boost = cxfm.LorentzBoost([0.998, 0.0, 0.0])
>>> round(float(boost.gamma), 2)
15.82
\(\gamma \approx 15.8\) is the whole story in one number. There is also rapidity, the parameter that adds under composition where velocities do not:
>>> round(float(boost.rapidity), 3)
3.453
Now transform the muon’s death event into the frame where the muon is moving. The None in the second slot is the time parameter \(\tau\); a boost does not use one, for reasons the aside after this section explains.
>>> death_lab = cxfm.act(boost, None, death, cxc.minkowskict, cxr.point)
>>> round(float(death_lab["ct"].ustrip("m")))
10434
>>> round(float(death_lab["x"].uconvert("km").ustrip("km")), 2)
10.41
Two things changed. In this frame the muon travelled 10.4 km, not zero — of course, it is moving here. And the elapsed coordinate time grew:
>>> elapsed = (death_lab["ct"] / c).uconvert("us")
>>> round(float(elapsed.ustrip("us")), 1)
34.8
\(34.8\;\mu\text{s}\), against the \(2.2\;\mu\text{s}\) we started with — a factor of \(\gamma = 15.82\). This is time dilation, and it is what saves the muon.
It is worth finishing the arithmetic, because 10.4 km is still short of 15 km. The \(2.2\;\mu\text{s}\) is a mean lifetime: decay is exponential, so what matters is not whether one muon survives a fixed distance but what fraction of them do.
>>> import math
>>> classical = 0.998 * float(c.ustrip("m/s")) * float(tau0.uconvert("s").ustrip("s"))
>>> relativistic = float(death_lab["x"].ustrip("m"))
>>> f"{math.exp(-15000 / classical):.1e}"
'1.3e-10'
>>> round(math.exp(-15000 / relativistic), 3)
0.237
Roughly a quarter of them arrive, against one in eight billion classically. That ratio — not the single distance — is the thing sea-level detectors measure.
An aside: why a boost is not “time-dependent”#
coordinax marks transforms whose point action varies with the evolution parameter \(\tau\). A Lorentz boost is not one of them, which surprises people who know that its Galilean cousin is:
>>> boost.is_time_dependent
False
>>> galilean = cxfm.Boost(
... {"x": jnp.asarray(1.0), "y": jnp.asarray(0.0), "z": jnp.asarray(0.0)},
... chart=cxc.cart3d,
... )
>>> galilean.is_time_dependent
True
The difference is where time lives. For Boost, time is a parameter outside the manifold and the action \(x \mapsto x + \Delta v\,\tau\) genuinely depends on it. For LorentzBoost, \(ct\) is a coordinate of the manifold — time is already inside the vector being transformed — so \(\Lambda\) is just a constant matrix. That is why act above takes None for \(\tau\).
An accelerating frame, where the rapidity itself grows with \(\tau\), is built by wrapping a builder in TimeDep:
>>> import equinox as eqx
>>> class UniformlyAccelerating(eqx.Module):
... rate: jnp.ndarray
... def __call__(self, tau):
... return cxfm.LorentzBoost(self.rate * tau)
...
>>> accelerating = cxfm.TimeDep(UniformlyAccelerating(jnp.asarray([0.1, 0.0, 0.0])))
>>> accelerating.is_time_dependent
True
Step 5: What Did Not Change#
The coordinates moved, the elapsed time moved, the distance travelled moved. It is easy to come away thinking everything is relative. The interval is not:
>>> birth_lab = cxfm.act(boost, None, birth, cxc.minkowskict, cxr.point)
>>> ds2_lab = cxm.interval(cxc.minkowskict, birth_lab, death_lab)
>>> round(float(ds2_lab.ustrip("m2")))
-434998
The same \(-434998\;\text{m}^2\) we computed in the rest frame. And therefore so is the proper time:
>>> tau_lab = cxm.lorentzian.proper_time(cxc.minkowskict, birth_lab, death_lab)
>>> round(float(tau_lab.uconvert("us").ustrip("us")), 3)
2.2
The muon still ages 2.2 μs. It has to: that is a reading on a physical clock, and no choice of coordinates can change what a clock says. What changed is how much of our coordinate time that corresponds to.
The causal character is likewise absolute — no boost can turn a timelike pair spacelike, which is why relativity does not let you reorder cause and effect:
>>> cxm.lorentzian.causal_character(cxc.minkowskict, birth_lab, death_lab)
'timelike'
This invariance is the defining property of a Lorentz transformation, \(\Lambda^\top \eta \Lambda = \eta\), and you can check it directly on the matrix.
>>> eta = jnp.diag(jnp.array([-1.0, 1.0, 1.0, 1.0]))
>>> lam = boost.matrix
>>> bool(jnp.allclose(lam.T @ eta @ lam, eta, atol=1e-5))
True
Step 6: Relativity of Simultaneity#
One more frame-dependent thing, and the least intuitive. Take two events that are simultaneous in one frame but happen in different places:
>>> here = event(0.0, 0.0)
>>> there = event(0.0, 1.0)
Both at \(ct = 0\): simultaneous. After a boost they are not — the two clocks now differ by \(\gamma\beta\,\Delta x\):
>>> here_b = cxfm.act(boost, None, here, cxc.minkowskict, cxr.point)
>>> there_b = cxfm.act(boost, None, there, cxc.minkowskict, cxr.point)
>>> round(float((there_b["ct"] - here_b["ct"]).ustrip("m")), 3)
15.788
“At the same time” is not a property of a pair of events; it is a property of a pair of events and a frame. Note this is only possible because the pair is spacelike separated — for a timelike pair the ordering is fixed, so causality survives:
>>> cxm.lorentzian.causal_character(cxc.minkowskict, here, there)
'spacelike'
Step 7: Velocities Do Not Add#
A last piece of bookkeeping worth seeing. Boost by \(0.6c\), then by \(0.6c\) again. The answer is not \(1.2c\):
>>> b1 = cxfm.LorentzBoost([0.6, 0.0, 0.0])
>>> combined = b1.matrix @ b1.matrix
>>> round(float(combined[0, 1] / combined[0, 0]), 4)
0.8824
\(0.882c\) — the relativistic velocity-addition formula \((\beta_1 + \beta_2)/(1 + \beta_1\beta_2)\). You can never reach \(c\) by composing subluminal boosts.
Rapidity is the parameterisation that does add, which is why it exists:
>>> r1 = cxfm.LorentzBoost.from_rapidity(0.3)
>>> r2 = cxfm.LorentzBoost.from_rapidity(0.5)
>>> r3 = cxfm.LorentzBoost.from_rapidity(0.8)
>>> bool(jnp.allclose(r2.matrix @ r1.matrix, r3.matrix, atol=1e-5))
True
Summary#
quantity |
frame-dependent? |
|---|---|
coordinates \((ct, x, y, z)\) |
✅ yes |
elapsed coordinate time |
✅ yes |
distance travelled |
✅ yes |
simultaneity of spacelike events |
✅ yes |
interval \(\Delta s^2\) |
❌ invariant |
proper time |
❌ invariant |
causal character |
❌ invariant |
The practical rule: reach for interval and the cxm.lorentzian verbs when you want a statement about physics, and read coordinates only when you genuinely want a statement about a particular frame. separation and norm belong to the Riemannian world and will refuse Minkowski input rather than let you mix the two up.
See Also#
Working With Manifolds — metrics and signatures
Working With Transforms — the operator machinery
LorentzBoostis built onWorking With Frames — the transformation-group taxonomy, including the Lorentz and Poincaré groups