Skip to content

Commit f3e9b6c

Browse files
authored
Fix RCC with negative height (#45)
1 parent be15a20 commit f3e9b6c

3 files changed

Lines changed: 127 additions & 33 deletions

File tree

src/openmc_mcnp_adapter/openmc_conversion.py

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# SPDX-License-Identifier: MIT
33

44
import argparse
5-
from math import pi
5+
from math import pi, isclose
66
import os
77
import re
88
import tempfile
@@ -72,26 +72,39 @@ def rotation_matrix(v1, v2):
7272
3x3 rotation matrix
7373
7474
"""
75-
# Normalize vectors
75+
# Normalize vectors and compute cosine
7676
u1 = v1 / np.linalg.norm(v1)
7777
u2 = v2 / np.linalg.norm(v2)
78-
79-
# Calculate axis of rotation
80-
axis = np.cross(u1, u2)
81-
axis /= np.linalg.norm(axis)
78+
cos_angle = float(np.clip(np.dot(u1, u2), -1.0, 1.0))
8279

8380
I = np.identity(3)
8481

8582
# Handle special case where vectors are parallel or anti-parallel
86-
if abs(np.dot(u2, axis) - 1.0) < 1e-8:
87-
return I
88-
elif abs(np.dot(u2, axis) + 1.0) < 1e-8:
89-
return -I
83+
if isclose(abs(cos_angle), 1.0, rel_tol=1e-8):
84+
if cos_angle > 0.0:
85+
return I
86+
else:
87+
# Proper 180° rotation: rotate about any axis that is orthogonal
88+
# with u1. Because |k| = 1 and cos(180°) = -1, the rotation matrix
89+
# is simply K = I + 2 * (k k^T - I) = 2 k k^T - I
90+
91+
# Choose reference vector not parallel to u1
92+
ref = np.array([1.0, 0.0, 0.0]) if abs(u1[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
93+
94+
# Create orthogonal unit vector
95+
k = np.cross(u1, ref)
96+
k /= np.linalg.norm(k)
97+
98+
# Create rotation matrix
99+
return 2.0 * np.outer(k, k) - I
90100
else:
91101
# Calculate rotation angle
92-
cos_angle = np.dot(u1, u2)
93102
sin_angle = np.sqrt(1 - cos_angle*cos_angle)
94103

104+
# Calculate axis of rotation
105+
axis = np.cross(u1, u2)
106+
axis /= np.linalg.norm(axis)
107+
95108
# Create cross-product matrix K
96109
kx, ky, kz = axis
97110
K = np.array([
@@ -326,20 +339,11 @@ def flip_sense(surf):
326339
raise NotImplementedError(f"{s['mnemonic']} surface with {len(coeffs)} parameters")
327340
elif s['mnemonic'] == 'rcc':
328341
vx, vy, vz, hx, hy, hz, r = coeffs
329-
if hx == 0.0 and hy == 0.0:
330-
if hz < 0.0:
331-
vz += hz
332-
hz = -hz
342+
if hx == 0.0 and hy == 0.0 and hz > 0.0:
333343
surf = RCC((vx, vy, vz), hz, r, axis='z')
334-
elif hy == 0.0 and hz == 0.0:
335-
if hx < 0.0:
336-
vx += hx
337-
hx = -hx
344+
elif hy == 0.0 and hz == 0.0 and hx > 0.0:
338345
surf = RCC((vx, vy, vz), hx, r, axis='x')
339-
elif hx == 0.0 and hz == 0.0:
340-
if hy < 0.0:
341-
vy += hy
342-
hy = -hy
346+
elif hx == 0.0 and hz == 0.0 and hy > 0.0:
343347
surf = RCC((vx, vy, vz), hy, r, axis='y')
344348
else:
345349
# Create vectors for Z-axis and cylinder orientation

tests/test_rotation_matrix.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import numpy as np
2+
import pytest
3+
4+
from openmc_mcnp_adapter import rotation_matrix
5+
6+
7+
def is_orthogonal(R: np.ndarray, atol: float = 1e-12) -> bool:
8+
"""Check if the matrix R is orthogonal"""
9+
I = np.identity(3)
10+
return np.allclose(R.T @ R, I, atol=atol) and np.allclose(R @ R.T, I, atol=atol)
11+
12+
13+
@pytest.mark.parametrize(
14+
"v1, v2",
15+
[
16+
# Same direction, different magnitudes
17+
(np.array([1.0, 2.0, 3.0]), np.array([2.0, 4.0, 6.0])),
18+
(np.array([0.0, 0.0, 1.0]), np.array([0.0, 0.0, 10.0])),
19+
],
20+
)
21+
def test_rotation_parallel(v1, v2):
22+
"""Test rotation_matrix for parallel vectors"""
23+
R = rotation_matrix(v1, v2)
24+
# Should be exactly identity from the parallel branch
25+
assert np.allclose(R, np.identity(3))
26+
assert is_orthogonal(R)
27+
assert np.isclose(np.linalg.det(R), 1.0)
28+
29+
30+
@pytest.mark.parametrize(
31+
"v1, v2",
32+
[
33+
(np.array([0.0, 0.0, 1.0]), np.array([0.0, 0.0, -5.0])), # z -> -z
34+
(np.array([1.0, 0.0, 0.0]), np.array([-2.0, 0.0, 0.0])), # x -> -x
35+
],
36+
)
37+
def test_rotation_antiparallel(v1, v2):
38+
"""Test rotation_matrix for anti-parallel vectors"""
39+
R = rotation_matrix(v1, v2)
40+
41+
# Maps v1 direction to v2 direction
42+
v2_hat = v2 / np.linalg.norm(v2)
43+
mapped = R @ (v1 / np.linalg.norm(v1))
44+
assert np.allclose(mapped, v2_hat, atol=1e-12)
45+
46+
# No NaNs, orthogonal and det +1
47+
assert not np.isnan(R).any()
48+
assert is_orthogonal(R)
49+
assert np.isclose(np.linalg.det(R), 1.0, atol=1e-12)
50+
# 180-degree rotation has trace -1
51+
assert np.isclose(np.trace(R), -1.0, atol=1e-12)
52+
53+
54+
@pytest.mark.parametrize(
55+
"v1, v2",
56+
[
57+
(np.array([0.0, 0.0, 1.0]), np.array([1.0, 2.0, 3.0])),
58+
(np.array([0.0, 1.0, 0.0]), np.array([3.0, -1.0, 2.0])),
59+
],
60+
)
61+
def test_rotation_general(v1, v2):
62+
"""Test rotation_matrix for general vectors"""
63+
R = rotation_matrix(v1, v2)
64+
65+
# Maps v1 to v2 direction
66+
v2_hat = v2 / np.linalg.norm(v2)
67+
mapped = R @ (v1 / np.linalg.norm(v1))
68+
assert np.allclose(mapped, v2_hat, atol=1e-12)
69+
70+
# Orthogonal and proper rotation
71+
assert is_orthogonal(R)
72+
assert np.isclose(np.linalg.det(R), 1.0, atol=1e-12)
73+
74+
# A vector perpendicular to v1 remains perpendicular to R v1 (i.e., to v2_hat)
75+
# Use a simple perpendicular vector: pick the least-aligned basis axis and project out
76+
basis = [
77+
np.array([1.0, 0.0, 0.0]),
78+
np.array([0.0, 1.0, 0.0]),
79+
np.array([0.0, 0.0, 1.0])
80+
]
81+
u1_hat = v1 / np.linalg.norm(v1)
82+
e = min(basis, key=lambda b: abs(np.dot(b, u1_hat)))
83+
x = e - np.dot(e, u1_hat) * u1_hat
84+
x /= np.linalg.norm(x)
85+
86+
# Should be perpendicular to v2_hat
87+
assert np.isclose(np.dot(R @ x, v2_hat), 0.0, atol=1e-12)

tests/test_surfaces.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from openmc.model.surface_composite import OrthogonalBox, \
66
RectangularParallelepiped, RightCircularCylinder, ConicalFrustum
77
from openmc_mcnp_adapter import mcnp_str_to_model, get_openmc_surfaces
8-
import pytest
98
from pytest import approx, mark
109

1110

@@ -239,21 +238,25 @@ def test_rpp_macrobody():
239238

240239

241240
@mark.parametrize(
242-
"coeffs, expected_bottom_z, expected_top_z, r",
241+
"coeffs, expected_bottom, expected_top, r, coeff",
243242
[
244-
# Base at (0,0,0), height +5 along z
245-
((0.0, 0.0, 0.0, 0.0, 0.0, 5.0, 1.5), 0.0, 5.0, 1.5),
246-
# Negative height vector should be flipped internally (known failing behavior)
247-
pytest.param((0.0, 0.0, 0.0, 0.0, 0.0, -5.0, 1.0), 0.0, -5.0, 1.0,
248-
marks=pytest.mark.xfail(reason="Negative height handling currently broken", strict=False)),
243+
# Base at (0,0,0), positive/negative height along x
244+
((0.0, 0.0, 0.0, 5.0, 0.0, 0.0, 1.5), 0.0, 5.0, 1.5, 'a'),
245+
((0.0, 0.0, 0.0, -5.0, 0.0, 0.0, 1.0), 0.0, -5.0, 1.0, 'a'),
246+
# Base at (0,0,0), positive/negative height along y
247+
((0.0, 0.0, 0.0, 0.0, 5.0, 0.0, 1.5), 0.0, 5.0, 1.5, 'b'),
248+
((0.0, 0.0, 0.0, 0.0, -5.0, 0.0, 1.0), 0.0, -5.0, 1.0, 'b'),
249+
# Base at (0,0,0), positive/negative height along z
250+
((0.0, 0.0, 0.0, 0.0, 0.0, 5.0, 1.5), 0.0, 5.0, 1.5, 'c'),
251+
((0.0, 0.0, 0.0, 0.0, 0.0, -5.0, 1.0), 0.0, -5.0, 1.0, 'c'),
249252
],
250253
)
251-
def test_rcc_macrobody(coeffs, expected_bottom_z, expected_top_z, r):
254+
def test_rcc_macrobody(coeffs, expected_bottom, expected_top, r, coeff):
252255
surf = convert_surface("rcc", coeffs)
253256
assert isinstance(surf, RightCircularCylinder)
254257
assert surf.cyl.r == approx(r)
255-
assert surf.bottom.d / surf.bottom.c == approx(expected_bottom_z)
256-
assert surf.top.d / surf.top.c == approx(expected_top_z)
258+
assert surf.bottom.d / getattr(surf.bottom, coeff) == approx(expected_bottom)
259+
assert surf.top.d / getattr(surf.top, coeff) == approx(expected_top)
257260

258261

259262
def test_trc_macrobody():

0 commit comments

Comments
 (0)