|
2 | 2 |
|
3 | 3 | Keeps math helpers small and rigorous. Does not duplicate rqm-core; this |
4 | 4 | module only contains the minimum needed to support circuit-level optimization. |
| 5 | +
|
| 6 | +Quaternion convention |
| 7 | +--------------------- |
| 8 | +A unit quaternion is represented as a length-4 NumPy array ``q = [w, x, y, z]`` |
| 9 | +where *w* is the scalar part and *x, y, z* are the pure-imaginary components. |
| 10 | +
|
| 11 | +The exact isomorphism between unit quaternions and SU(2) matrices used here is:: |
| 12 | +
|
| 13 | + q = (w, x, y, z) <-> U = [[ w - iz, -(y + ix)], |
| 14 | + [ y - ix, w + iz ]] |
| 15 | +
|
| 16 | +This convention is consistent with the standard quantum rotation gates: |
| 17 | +
|
| 18 | +* ``Rx(theta)`` -> ``(cos(theta/2), sin(theta/2), 0, 0 )`` |
| 19 | +* ``Ry(theta)`` -> ``(cos(theta/2), 0, sin(theta/2), 0 )`` |
| 20 | +* ``Rz(theta)`` -> ``(cos(theta/2), 0, 0, sin(theta/2))`` |
| 21 | +
|
| 22 | +Quaternion multiplication ``quaternion_multiply(q2, q1)`` represents applying |
| 23 | +*q1* first, then *q2*, matching matrix composition ``U2 @ U1``. |
| 24 | +
|
| 25 | +For canonicalization the representative with non-negative scalar part (``w >= 0``) |
| 26 | +is preferred, keeping the quaternion on the shortest geodesic arc on S³. |
5 | 27 | """ |
6 | 28 |
|
7 | 29 | from __future__ import annotations |
@@ -69,3 +91,196 @@ def matrices_close( |
69 | 91 | n = a.shape[0] |
70 | 92 | inner = np.trace(a.conj().T @ b) |
71 | 93 | return bool(abs(abs(inner) - n) < atol * n) |
| 94 | + |
| 95 | + |
| 96 | +# --------------------------------------------------------------------------- |
| 97 | +# Quaternion helpers |
| 98 | +# --------------------------------------------------------------------------- |
| 99 | + |
| 100 | +def su2_to_quaternion(matrix: NDArray[np.complexfloating]) -> NDArray[np.floating]: |
| 101 | + """Return the unit quaternion ``[w, x, y, z]`` for an SU(2) matrix. |
| 102 | +
|
| 103 | + Uses the exact algebraic isomorphism:: |
| 104 | +
|
| 105 | + U = [[ w - iz, -(y + ix)], |
| 106 | + [ y - ix, w + iz ]] <-> q = (w, x, y, z) |
| 107 | +
|
| 108 | + The result is normalized and has ``w >= 0`` (canonical shortest-path |
| 109 | + representative on S³). |
| 110 | +
|
| 111 | + Args: |
| 112 | + matrix: 2×2 complex SU(2) array (determinant +1, unitary). |
| 113 | +
|
| 114 | + Returns: |
| 115 | + Length-4 float array ``[w, x, y, z]`` representing the unit quaternion. |
| 116 | + """ |
| 117 | + # Extract components from matrix entries: |
| 118 | + # U[0,0] = w - iz => w = Re(U[0,0]), z = -Im(U[0,0]) |
| 119 | + # U[0,1] = -(y+ix) => y = -Re(U[0,1]), x = -Im(U[0,1]) |
| 120 | + w = float(matrix[0, 0].real) |
| 121 | + x = float(-matrix[0, 1].imag) |
| 122 | + y = float(-matrix[0, 1].real) |
| 123 | + z = float(-matrix[0, 0].imag) |
| 124 | + q = np.array([w, x, y, z], dtype=float) |
| 125 | + return quaternion_canonicalize(q) |
| 126 | + |
| 127 | + |
| 128 | +def quaternion_to_su2(q: NDArray[np.floating]) -> NDArray[np.complexfloating]: |
| 129 | + """Return the SU(2) matrix for a unit quaternion ``[w, x, y, z]``. |
| 130 | +
|
| 131 | + Inverse of :func:`su2_to_quaternion`. Uses the isomorphism:: |
| 132 | +
|
| 133 | + q = (w, x, y, z) <-> U = [[ w - iz, -(y + ix)], |
| 134 | + [ y - ix, w + iz ]] |
| 135 | +
|
| 136 | + Args: |
| 137 | + q: Length-4 float array ``[w, x, y, z]``. |
| 138 | +
|
| 139 | + Returns: |
| 140 | + 2×2 complex SU(2) array. |
| 141 | + """ |
| 142 | + w, x, y, z = float(q[0]), float(q[1]), float(q[2]), float(q[3]) |
| 143 | + return np.array( |
| 144 | + [ |
| 145 | + [complex(w, -z), complex(-y, -x)], |
| 146 | + [complex(y, -x), complex(w, z)], |
| 147 | + ], |
| 148 | + dtype=complex, |
| 149 | + ) |
| 150 | + |
| 151 | + |
| 152 | +def quaternion_multiply( |
| 153 | + q2: NDArray[np.floating], |
| 154 | + q1: NDArray[np.floating], |
| 155 | +) -> NDArray[np.floating]: |
| 156 | + """Return the quaternion product ``q2 * q1``. |
| 157 | +
|
| 158 | + Represents applying gate *q1* first, then gate *q2*, matching matrix |
| 159 | + composition ``U2 @ U1``. |
| 160 | +
|
| 161 | + Uses the standard Hamilton product formula:: |
| 162 | +
|
| 163 | + (w2, x2, y2, z2) * (w1, x1, y1, z1) = ( |
| 164 | + w2*w1 - x2*x1 - y2*y1 - z2*z1, |
| 165 | + w2*x1 + x2*w1 + y2*z1 - z2*y1, |
| 166 | + w2*y1 - x2*z1 + y2*w1 + z2*x1, |
| 167 | + w2*z1 + x2*y1 - y2*x1 + z2*w1, |
| 168 | + ) |
| 169 | +
|
| 170 | + Args: |
| 171 | + q2: Unit quaternion for the second (outer) gate. |
| 172 | + q1: Unit quaternion for the first (inner) gate. |
| 173 | +
|
| 174 | + Returns: |
| 175 | + Product quaternion (not yet canonicalized or re-normalized). |
| 176 | + """ |
| 177 | + w2, x2, y2, z2 = float(q2[0]), float(q2[1]), float(q2[2]), float(q2[3]) |
| 178 | + w1, x1, y1, z1 = float(q1[0]), float(q1[1]), float(q1[2]), float(q1[3]) |
| 179 | + return np.array( |
| 180 | + [ |
| 181 | + w2 * w1 - x2 * x1 - y2 * y1 - z2 * z1, |
| 182 | + w2 * x1 + x2 * w1 + y2 * z1 - z2 * y1, |
| 183 | + w2 * y1 - x2 * z1 + y2 * w1 + z2 * x1, |
| 184 | + w2 * z1 + x2 * y1 - y2 * x1 + z2 * w1, |
| 185 | + ], |
| 186 | + dtype=float, |
| 187 | + ) |
| 188 | + |
| 189 | + |
| 190 | +def quaternion_canonicalize(q: NDArray[np.floating]) -> NDArray[np.floating]: |
| 191 | + """Return the canonical unit quaternion with non-negative scalar part. |
| 192 | +
|
| 193 | + Both *q* and *-q* represent the same physical SU(2) rotation. This |
| 194 | + function normalizes the quaternion and selects the representative with |
| 195 | + ``w >= 0``, keeping it on the shortest geodesic arc (canonical |
| 196 | + representative) on S³. |
| 197 | +
|
| 198 | + Args: |
| 199 | + q: Length-4 float array ``[w, x, y, z]``. |
| 200 | +
|
| 201 | + Returns: |
| 202 | + Normalized quaternion with ``w >= 0``. |
| 203 | + """ |
| 204 | + norm = float(np.linalg.norm(q)) |
| 205 | + if norm < _ATOL: |
| 206 | + return np.array([1.0, 0.0, 0.0, 0.0], dtype=float) |
| 207 | + q = q / norm |
| 208 | + if q[0] < 0.0: |
| 209 | + q = -q |
| 210 | + return q |
| 211 | + |
| 212 | + |
| 213 | +def quaternion_to_axis_angle( |
| 214 | + q: NDArray[np.floating], |
| 215 | +) -> tuple[NDArray[np.floating], float]: |
| 216 | + """Extract the rotation axis and angle from a unit quaternion. |
| 217 | +
|
| 218 | + The physical Bloch-sphere rotation angle is ``theta = 2 * arccos(w)`` |
| 219 | + (the factor of 2 arises from the half-angle spinor convention). |
| 220 | +
|
| 221 | + Args: |
| 222 | + q: Length-4 float array ``[w, x, y, z]``, assumed to be a unit |
| 223 | + quaternion. Canonicalization is applied internally. |
| 224 | +
|
| 225 | + Returns: |
| 226 | + A tuple ``(axis, theta)`` where: |
| 227 | +
|
| 228 | + * ``axis`` is a length-3 float array giving the unit rotation axis |
| 229 | + ``[nx, ny, nz]``. When the rotation is near-identity (``theta`` |
| 230 | + close to 0), ``axis`` defaults to ``[0, 0, 1]``. |
| 231 | + * ``theta`` is the rotation angle in radians (in ``[0, pi]`` after |
| 232 | + canonicalization). |
| 233 | + """ |
| 234 | + q = quaternion_canonicalize(q) |
| 235 | + w = float(np.clip(q[0], -1.0, 1.0)) |
| 236 | + theta = 2.0 * float(np.arccos(w)) |
| 237 | + sin_half = float(np.sqrt(max(0.0, 1.0 - w * w))) |
| 238 | + if sin_half < _ATOL: |
| 239 | + axis = np.array([0.0, 0.0, 1.0], dtype=float) |
| 240 | + else: |
| 241 | + axis = np.array(q[1:], dtype=float) / sin_half |
| 242 | + return axis, theta |
| 243 | + |
| 244 | + |
| 245 | +# Reference unit vectors for the three Cartesian rotation axes. |
| 246 | +_CARTESIAN_AXES: tuple[tuple[str, NDArray[np.floating]], ...] = ( |
| 247 | + ("x", np.array([1.0, 0.0, 0.0])), |
| 248 | + ("y", np.array([0.0, 1.0, 0.0])), |
| 249 | + ("z", np.array([0.0, 0.0, 1.0])), |
| 250 | +) |
| 251 | + |
| 252 | +# Default tolerance for axis-alignment checks. |
| 253 | +_AXIS_ATOL = 1e-6 |
| 254 | + |
| 255 | + |
| 256 | +def axis_aligned_rotation( |
| 257 | + q: NDArray[np.floating], |
| 258 | + atol: float = _AXIS_ATOL, |
| 259 | +) -> tuple[str, float] | None: |
| 260 | + """Return ``(axis_name, theta)`` if *q* is a rotation about x, y, or z. |
| 261 | +
|
| 262 | + Checks whether the rotation axis of *q* lies within *atol* of the x, y, or |
| 263 | + z unit vector. This is the geometric test that drives the axis-aware |
| 264 | + compression pass: when the result is non-``None``, the caller can emit a |
| 265 | + single named rotation gate (``rx``, ``ry``, or ``rz``) instead of a |
| 266 | + generic ``U`` gate. |
| 267 | +
|
| 268 | + Near-identity rotations (``|theta| < atol``) return ``None`` because the |
| 269 | + axis is numerically undefined at that scale. |
| 270 | +
|
| 271 | + Args: |
| 272 | + q: Length-4 float array ``[w, x, y, z]``, a unit quaternion. |
| 273 | + atol: Absolute tolerance for axis alignment and near-identity detection. |
| 274 | +
|
| 275 | + Returns: |
| 276 | + ``("x"/"y"/"z", theta)`` when the axis is Cartesian-aligned, or |
| 277 | + ``None`` when the rotation is generic or near-identity. |
| 278 | + """ |
| 279 | + q = quaternion_canonicalize(q) |
| 280 | + axis, theta = quaternion_to_axis_angle(q) |
| 281 | + if abs(theta) < atol: |
| 282 | + return None |
| 283 | + for name, ref in _CARTESIAN_AXES: |
| 284 | + if np.allclose(axis, ref, atol=atol): |
| 285 | + return name, theta |
| 286 | + return None |
0 commit comments