Skip to content

Two convention bugs in Result.mueller_matrix and Experiment.set_vector (v0.22.6) #284

@antorbus

Description

@antorbus

Two convention bugs in Result.mueller_matrix and Experiment.set_vector (v0.22.6)

Both verified on pyElli 0.22.6 / Python 3.14 / NumPy. Both have one-line fixes.
Neither affects pyElli's psi, delta, R, T, or jones_matrix_* outputs (those are derived from rho directly), but each silently corrupts downstream code that uses the affected functions as transformations.


Bug 1: Result.mueller_matrix returns the wrong Kronecker order in elli/result.py:228.

s_kron_s_star = np.einsum(
    "aij,akl->aikjl", np.conjugate(self.rho_matrix), self.rho_matrix
).reshape([self.rho_matrix.shape[0], 4, 4])

This is kron(conj(J), J) = J* ⊗ J (the column-major Kronecker), but the surrounding reshape is C-order (row-major). The conventions don't match. The defining property of the Mueller matrix, S_out = M @ S_in, fails.

The standard derivation (Goldstein 2003 Eq. 4.40; Tompkins–Irene 2005 §1.5; Wikipedia "Mueller calculus"; Cloude 1986; Aiello–Woerdman arXiv:1906.11198) uses the row-major vec–Kronecker identity vec_row(ABC) = (A ⊗ Cᵀ) vec_row(B), applied to Φ_out = J Φ Jᴴ with (Jᴴ)ᵀ = J*, giving vec_row(Φ_out) = (J ⊗ J*) vec_row(Φ). The correct Kronecker is kron(J, conj(J)), operands swapped relative to the current code.

Reproduce (pure NumPy, no pyElli sample needed):

import numpy as np

A = np.array([[1, 0, 0, 1], [1, 0, 0, -1], [0, 1, 1, 0], [0, 1j, -1j, 0]], dtype=complex)
J = np.array([[0.5, 0.1-0.2j], [0.05+0.3j, 0.3+0.2j]])

# input LCP per pyElli's S_3 = -2 Im(E_x E_y*)  ->  Jones = (1, +i)/sqrt(2)
E_in = np.array([1, 1j]) / np.sqrt(2)
S_in = np.array([1.0, 0.0, 0.0, 1.0])

def stokes(e):  # pyElli's forward formula, experiment.py:92-107
    return np.array([abs(e[0])**2 + abs(e[1])**2,
                     abs(e[0])**2 - abs(e[1])**2,
                     2 * np.real(e[0] * np.conj(e[1])),
                     -2 * np.imag(e[0] * np.conj(e[1]))])

S_direct  = stokes(J @ E_in)
M_pyelli  = (A @ np.kron(np.conj(J), J) @ np.linalg.inv(A)).real     # current code
M_correct = (A @ np.kron(J, np.conj(J)) @ np.linalg.inv(A)).real     # standard

print("S_direct      =", S_direct)
print("M_pyelli @ S  =", M_pyelli @ S_in)        # MISMATCH
print("M_correct @ S =", M_correct @ S_in)       # matches direct to ~1e-15

Output:

S_direct      = [0.44125  0.05875 -0.045    0.435  ]
M_pyelli @ S  = [0.08125  0.01875  0.075   -0.025  ]   # off by 0.36 on S_0
M_correct @ S = [0.44125  0.05875 -0.045    0.435  ]

100/100 random complex Jones matrices fail with the current code at tol 1e-10; 0/100 fail with the corrected order.

For an isotropic sample (diagonal J = diag(r_p, r_s)), the discrepancy collapses to a sign flip on M[2,3] and M[3,2] — equivalently, the returned Mueller matches Azzam–Bashara if and only if the user later flips the sign of Δ.

Fix (one line, elli/result.py:228):

# was:
s_kron_s_star = np.einsum("aij,akl->aikjl", np.conjugate(self.rho_matrix), self.rho_matrix).reshape(...)
# fix:
s_kron_s_star = np.einsum("aij,akl->aikjl", self.rho_matrix, np.conjugate(self.rho_matrix)).reshape(...)

After the fix, Result.mueller_matrix satisfies M @ S_in == Stokes(J @ Jones(S_in)) to machine precision and matches the textbook isotropic-block form with the standard Δ sign.


Bug 2: Experiment.set_vector(Stokes → Jones) inverts S_3 on round-trip in elli/experiment.py:129.

b = U / (2 * a) - 1j * V / (2 * a)

The minus sign on V is wrong under pyElli's own forward Stokes convention (experiment.py:92-107, S_3 = -2 Im(E_x E_y*)). Round-tripping any fully-polarised input with V ≠ 0 flips S_3.

Reproduce.

import numpy as np
import elli

exp = elli.Experiment(
    elli.Structure(front=elli.AIR, layers=[], back=elli.AIR),
    [550.0], 0.0,
    vector=np.array([1.0, 0.0, 0.0, 1.0]),    # LCP under pyElli's S_3 convention
)
print("input    .stokes_vector  =", exp.stokes_vector)     # [1. 0. 0. 1.]
print("from     .jones_vector   =", exp.jones_vector)      # (0.7071, -0.7071j)

j = exp.jones_vector
S_recovered = np.array([abs(j[0])**2 + abs(j[1])**2,
                        abs(j[0])**2 - abs(j[1])**2,
                        2 * np.real(j[0] * np.conj(j[1])),
                        -2 * np.imag(j[0] * np.conj(j[1]))])
print("re-stokes from jones    =", S_recovered)            # [1. 0. 0. -1.]   <-- S_3 flipped

Linear states (V = 0) round-trip cleanly because the buggy -1j V / (2a) term is zero.

Fix (one character, elli/experiment.py:129):

# was:
b = U / (2 * a) - 1j * V / (2 * a)
# fix:
b = U / (2 * a) + 1j * V / (2 * a)

After the fix, the round-trip identity Stokes(jones_from_set_vector(S)) == S holds exactly for every fully-polarised S.


Notes

  • We discovered both while building a torch port of Solver4x4 and applying Result.mueller_matrix as a Stokes-domain transformation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions