Skip to content

Commit e7b4e11

Browse files
Nush395Torax team
authored andcommitted
Simplify safety factor statistics calculation.
Replace the error prone quadratic fitting routine with simpler logic. For q min we simply return the min value. For the intercepts we do linear interpolation of each interval and look for potential intercepts in each interval. This fits better with making fewer assumptions on the underlying profile and fixes the buggy values returned by quadratic fits. PiperOrigin-RevId: 949037347
1 parent ead9218 commit e7b4e11

56 files changed

Lines changed: 65 additions & 206 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

torax/_src/output_tools/safety_factor_fit.py

Lines changed: 41 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@
1313
# limitations under the License.
1414
"""Helper for finding safety factor outputs."""
1515
import dataclasses
16-
import functools
1716

18-
import chex
1917
import jax
2018
from jax import numpy as jnp
2119
from torax._src import array_typing
@@ -24,7 +22,7 @@
2422
@jax.tree_util.register_dataclass
2523
@dataclasses.dataclass(frozen=True)
2624
class SafetyFactorFit:
27-
"""Collection of outputs calculated after each simulation step.
25+
"""Collection of statistics of safety factor calculated each simulation step.
2826
2927
Attributes:
3028
rho_q_min: rho_norm at the minimum q.
@@ -53,115 +51,41 @@ class SafetyFactorFit:
5351
rho_q_3_1_second: array_typing.FloatScalar
5452

5553

56-
def _sliding_window_of_three(flat_array: jax.Array) -> jax.Array:
57-
"""Sliding window equivalent to numpy.lib.stride_tricks.sliding_window."""
58-
window_size = 3
59-
starts = jnp.arange(len(flat_array) - window_size + 1)
60-
return jax.vmap(
61-
lambda start: jax.lax.dynamic_slice(flat_array, (start,), (window_size,))
62-
)(starts)
63-
54+
def _linear_intercepts(
55+
rho_norm: jax.Array, q_face: jax.Array, q_target: float
56+
) -> jax.Array:
57+
"""Finds intercepts of q_face with q_target using linear interpolation.
6458
65-
def _fit_polynomial_to_intervals_of_three(
66-
rho_norm: jax.Array, q_face: jax.Array
67-
) -> tuple[jax.Array, jax.Array, jax.Array]:
68-
"""Fit polynomial to every set of three points in given arrays."""
69-
q_face_intervals = _sliding_window_of_three(
70-
q_face,
71-
)
72-
rho_norm_intervals = _sliding_window_of_three(
73-
rho_norm,
74-
)
59+
Args:
60+
rho_norm: Array of rho_norm values on face grid.
61+
q_face: Array of q values on face grid.
62+
q_target: q value to find intercepts for.
7563
76-
@jax.vmap
77-
def batch_polyfit(
78-
q_face_interval: jax.Array, rho_norm_interval: jax.Array
79-
) -> jax.Array:
80-
"""Solve Ax=b to get coefficients for a quadratic polynomial."""
81-
chex.assert_shape(q_face_interval, (3,))
82-
chex.assert_shape(rho_norm_interval, (3,))
83-
rho_norm_squared = rho_norm_interval**2
84-
A = jnp.array([ # pylint: disable=invalid-name
85-
[rho_norm_squared[0], rho_norm_interval[0], 1],
86-
[rho_norm_squared[1], rho_norm_interval[1], 1],
87-
[rho_norm_squared[2], rho_norm_interval[2], 1],
88-
])
89-
b = jnp.array([q_face_interval[0], q_face_interval[1], q_face_interval[2]])
90-
coeffs = jnp.linalg.solve(A, b)
91-
return coeffs
92-
93-
return (
94-
batch_polyfit(q_face_intervals, rho_norm_intervals),
95-
rho_norm_intervals,
96-
q_face_intervals,
97-
)
64+
Returns:
65+
Array of rho_norm values of (potential) intercepts in each interval. If no
66+
intercept is found in an interval, -jnp.inf is placed in the array.
67+
"""
68+
q_diff = q_face[1:] - q_face[:-1]
69+
safe_q_diff = jnp.where(q_diff == 0.0, 1.0, q_diff)
70+
t = (q_target - q_face[:-1]) / safe_q_diff
9871

72+
# If q_diff is 0 and q_face[:-1] == q_target, we force t = 1.0 (intercept at
73+
# rho_norm[i+1])
74+
t = jnp.where((q_diff == 0.0) & (q_face[:-1] == q_target), 1.0, t)
9975

100-
@jax.vmap
101-
def _minimum_location_value_in_interval(
102-
coeffs: jax.Array, rho_norm_interval: jax.Array, q_interval: jax.Array
103-
) -> tuple[jax.Array, jax.Array]:
104-
"""Returns the minimum value and location of the fit quadratic in the interval."""
105-
min_interval, max_interval = rho_norm_interval[0], rho_norm_interval[1]
106-
q_min_interval, q_max_interval = (
107-
q_interval[0],
108-
q_interval[1],
109-
)
110-
a, b = coeffs[0], coeffs[1]
111-
extremum_location = -b / (2 * a)
112-
extremum_in_interval = jnp.greater(
113-
extremum_location, min_interval
114-
) & jnp.less(extremum_location, max_interval)
115-
extremum_value = jax.lax.cond(
116-
extremum_in_interval,
117-
lambda x: jnp.polyval(coeffs, x),
118-
lambda x: jnp.inf,
119-
extremum_location,
120-
)
76+
is_intercept = (t > 0.0) & (t <= 1.0)
77+
# Mask out cases where q_diff == 0 and q_face[:-1] != q_target
78+
is_intercept = is_intercept & ~((q_diff == 0.0) & (q_face[:-1] != q_target))
12179

122-
interval_minimum_location, interval_minimum_value = jax.lax.cond(
123-
jnp.less(q_min_interval, q_max_interval),
124-
lambda: (min_interval, q_min_interval),
125-
lambda: (max_interval, q_max_interval),
126-
)
127-
overall_minimum_location, overall_minimum_value = jax.lax.cond(
128-
jnp.less(interval_minimum_value, extremum_value),
129-
lambda: (interval_minimum_location, interval_minimum_value),
130-
lambda: (extremum_location, extremum_value),
131-
)
132-
return overall_minimum_location, overall_minimum_value
133-
134-
135-
def _find_roots_quadratic(coeffs: jax.Array) -> jax.Array:
136-
"""Finds the roots of a quadratic equation."""
137-
a, b, c = coeffs[0], coeffs[1], coeffs[2]
138-
determinant = b**2 - 4.0 * a * c
139-
roots_exist = jnp.greater(determinant, 0)
140-
plus_root = jax.lax.cond(
141-
roots_exist,
142-
lambda: (-b + jnp.sqrt(determinant)) / (2.0 * a),
143-
lambda: -jnp.inf,
144-
)
145-
minus_root = jax.lax.cond(
146-
roots_exist,
147-
lambda: (-b - jnp.sqrt(determinant)) / (2.0 * a),
148-
lambda: -jnp.inf,
149-
)
150-
return jnp.array([plus_root, minus_root])
80+
intercepts = rho_norm[:-1] + t * (rho_norm[1:] - rho_norm[:-1])
15181

82+
all_intercepts = jnp.where(is_intercept, intercepts, -jnp.inf)
15283

153-
@functools.partial(jax.vmap, in_axes=(0, 0, None))
154-
def _root_in_interval(
155-
coeffs: jax.Array, interval: jax.Array, q_surface: float
156-
) -> jax.Array:
157-
"""Finds roots of quadratic(coeffs)=q_surface in the interval."""
158-
intercept_coeffs = coeffs - jnp.array([0.0, 0.0, q_surface])
159-
min_interval, max_interval = interval[0], interval[1]
160-
root_values = _find_roots_quadratic(intercept_coeffs)
161-
in_interval = jnp.greater(root_values, min_interval) & jnp.less(
162-
root_values, max_interval
84+
first_point_intercept = jnp.where(
85+
q_face[0] == q_target, rho_norm[0], -jnp.inf
16386
)
164-
return jnp.where(in_interval, root_values, -jnp.inf)
87+
88+
return jnp.concat([jnp.array([first_point_intercept]), all_intercepts])
16589

16690

16791
@jax.jit
@@ -170,33 +94,16 @@ def find_min_q_and_q_surface_intercepts(
17094
) -> SafetyFactorFit:
17195
"""Finds the minimum q and the q surface intercepts.
17296
173-
This method divides the input arrays into groups of three points, fits a
174-
quadratic to each interval containing three points. It then uses the fit
175-
quadratics to find the minimum q value as well as any intercepts of the q=3/2,
176-
q=2/1, and q=3/1 planes.
177-
178-
As the quadratic fits could have overlapping definitions where the groups
179-
overlap, we choose the quadratic for the domain [rho_norm[i-1], rho_norm[i]]
180-
defined using the points (i, i-1, i-2).
181-
(There is one exception which is the first group for which the domain for
182-
points in [rho_norm[0], rho_norm[2]] are defined using points (0, 1, 2))
97+
This method uses the input arrays to find the minimum q value and its
98+
corresponding rho_norm. It then uses linear interpolation between adjacent
99+
points to find any intercepts of the q=3/2, q=2/1, and q=3/1 planes.
183100
184101
Args:
185102
rho_norm: Array of rho_norm values on face grid.
186103
q_face: Array of q values on face grid.
187104
188105
Returns:
189-
rho_q_min: rho_norm at the minimum q.
190-
q_min: minimum q value.
191-
outermost_rho_q_3_2: Array of two outermost rho_norm values that intercept
192-
the q=3/2 plane. If fewer than two intercepts are found, entries will be set
193-
to -inf.
194-
outermost_rho_q_2_1: Array of 2 outermost rho_norm values that intercept the
195-
q=2/1 plane. If fewer than two intercepts are found, entries will be set to
196-
-inf.
197-
outermost_rho_q_3_1: Array of 2 outermost rho_norm values that intercept the
198-
q=3/1 plane. If fewer than two intercepts are found, entries will be set to
199-
-inf.
106+
Safety factor statistics.
200107
"""
201108
if len(q_face) != len(rho_norm):
202109
raise ValueError(
@@ -209,36 +116,21 @@ def find_min_q_and_q_surface_intercepts(
209116
sorted_indices = jnp.argsort(rho_norm)
210117
rho_norm = rho_norm[sorted_indices]
211118
q_face = q_face[sorted_indices]
212-
# Fit polynomial to every set of three points in given arrays.
213-
poly_coeffs, rho_norm_3, q_face_3 = _fit_polynomial_to_intervals_of_three(
214-
rho_norm, q_face
215-
)
216-
# To bridge across the entire span of rho_norm, and avoid overlapping
217-
# interval domains, we define the first group from points (0, 2), and all
218-
# subsequent groups from (i+1, i+2), from i=1 until i=len(rho_norm)-1.
219-
first_rho_norm = jnp.expand_dims(
220-
jnp.array([rho_norm[0], rho_norm[2]]), axis=0
221-
)
222-
first_q_face = jnp.expand_dims(jnp.array([q_face[0], q_face[2]]), axis=0)
223-
rho_norms = jnp.concat([first_rho_norm, rho_norm_3[1:, 1:]], axis=0)
224-
q_faces = jnp.concat([first_q_face, q_face_3[1:, 1:]], axis=0)
225119

226-
# Find the minimum q value and its location for each interval.
227-
rho_q_min_intervals, q_min_intervals = _minimum_location_value_in_interval(
228-
poly_coeffs, rho_norms, q_faces
229-
)
230-
# Find the minimum q value and its location out of all intervals.
231-
arg_q_min = jnp.argmin(q_min_intervals)
232-
rho_q_min = rho_q_min_intervals[arg_q_min]
233-
q_min = q_min_intervals[arg_q_min]
120+
# Find the minimum q value and its location.
121+
idx_min = jnp.argmin(q_face)
122+
q_min = q_face[idx_min]
123+
rho_q_min = rho_norm[idx_min]
234124

235125
# Find the outermost rho_norm values that intercept the q=3/2, q=2/1, and
236126
# q=3/1 planes. If none are found, fill from the left with -jnp.inf.
237-
rho_q_3_2 = _root_in_interval(poly_coeffs, rho_norms, 1.5).flatten()
127+
rho_q_3_2 = _linear_intercepts(rho_norm, q_face, 1.5)
238128
outermost_rho_q_3_2 = rho_q_3_2[jnp.argsort(rho_q_3_2)[-2:]]
239-
rho_q_2_1 = _root_in_interval(poly_coeffs, rho_norms, 2.0).flatten()
129+
130+
rho_q_2_1 = _linear_intercepts(rho_norm, q_face, 2.0)
240131
outermost_rho_q_2_1 = rho_q_2_1[jnp.argsort(rho_q_2_1)[-2:]]
241-
rho_q_3_1 = _root_in_interval(poly_coeffs, rho_norms, 3.0).flatten()
132+
133+
rho_q_3_1 = _linear_intercepts(rho_norm, q_face, 3.0)
242134
outermost_rho_q_3_1 = rho_q_3_1[jnp.argsort(rho_q_3_1)[-2:]]
243135

244136
return SafetyFactorFit(

torax/_src/output_tools/tests/safety_factor_fit_test.py

Lines changed: 24 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
# limitations under the License.
1414
from absl.testing import absltest
1515
from absl.testing import parameterized
16-
import chex
1716
import jax
1817
from jax import numpy as jnp
1918
import numpy as np
@@ -72,7 +71,7 @@ def tearDown(self):
7271
def test_find_min_q_and_q_surface_intercepts_finds_minimum_q(
7372
self, transform, expected_q_min, expected_rho_q_min
7473
):
75-
rho_norm_face = jnp.linspace(0.0, 1.0, 1000)
74+
rho_norm_face = jnp.linspace(0.0, 1.0, 1001)
7675
q_face = transform(rho_norm_face)
7776
outputs = safety_factor_fit.find_min_q_and_q_surface_intercepts(
7877
rho_norm_face, q_face
@@ -90,26 +89,26 @@ def test_find_min_q_and_q_surface_intercepts_finds_minimum_q(
9089
dict(
9190
testcase_name="3x^2+0.5",
9291
transform=lambda x: 3 * x**2 + 0.5,
93-
expected_q_3_2=[-np.inf, 1 / np.sqrt(3)],
94-
expected_q_2_1=[-np.inf, 1 / np.sqrt(2)],
95-
expected_q_3_1=[-np.inf, np.sqrt(5 / 6)],
92+
expected_q_3_2=[-np.inf, 0.5773500721500722],
93+
expected_q_2_1=[-np.inf, 0.7071067137809187],
94+
expected_q_3_1=[-np.inf, 0.91287086758],
9695
),
9796
dict(
9897
testcase_name="2x^2+1",
9998
transform=lambda x: 2 * x**2 + 1,
10099
expected_q_3_2=[-np.inf, 0.5],
101-
expected_q_2_1=[-np.inf, 1 / np.sqrt(2)],
100+
expected_q_2_1=[-np.inf, 0.7071067137809187],
102101
expected_q_3_1=[
103102
-np.inf,
104-
-np.inf,
105-
], # We don't find the root on the border.
103+
1.0,
104+
],
106105
),
107106
dict(
108107
testcase_name="20*(x-0.5)^2",
109108
transform=lambda x: 20 * (x - 0.5) ** 2,
110-
expected_q_3_2=[(10 - np.sqrt(30)) / 20, (10 + np.sqrt(30)) / 20],
111-
expected_q_2_1=[0.5 - 1 / np.sqrt(10), 0.5 + 1 / np.sqrt(10)],
112-
expected_q_3_1=[(5 - np.sqrt(15)) / 10, (5 + np.sqrt(15)) / 10],
109+
expected_q_3_2=[0.2261389396709324, 0.7738610603290677],
110+
expected_q_2_1=[0.18377251184834123, 0.8162274881516588],
111+
expected_q_3_1=[0.11270193548387099, 0.887298064516129],
113112
),
114113
dict(
115114
testcase_name="-3x^2-0.5",
@@ -136,7 +135,7 @@ def test_find_min_q_and_q_surface_intercepts_finds_minimum_q(
136135
def test_find_min_q_and_q_surface_intercepts_finds_q_surface_intercepts(
137136
self, transform, expected_q_3_2, expected_q_2_1, expected_q_3_1
138137
):
139-
rho_norm_face = jnp.linspace(0.0, 1.0, 1000)
138+
rho_norm_face = jnp.linspace(0.0, 1.0, 1001)
140139
q_face = transform(rho_norm_face)
141140
outputs = safety_factor_fit.find_min_q_and_q_surface_intercepts(
142141
rho_norm_face, q_face
@@ -151,53 +150,21 @@ def test_find_min_q_and_q_surface_intercepts_finds_q_surface_intercepts(
151150
with self.subTest("q_3_1"):
152151
np.testing.assert_allclose(q_3_1, np.array(expected_q_3_1))
153152

154-
def test_polynomial_fit_to_threes_on_exact_quadratic(self):
155-
"""Test to give us an idea of the error on the quadratic fit."""
156-
rho_norm = jnp.array([0.0, 0.5, 1.0], dtype=jnp.float64)
157-
random_coeffs = jax.random.normal(
158-
key=jax.random.PRNGKey(0), shape=(3,), dtype=jnp.float64
159-
)
160-
q_face = (
161-
random_coeffs[0] * rho_norm**2
162-
+ random_coeffs[1] * rho_norm
163-
+ random_coeffs[2]
164-
)
165-
polyfit, rho_norm_3, q_face_3 = (
166-
safety_factor_fit._fit_polynomial_to_intervals_of_three(
167-
rho_norm, q_face
168-
)
169-
)
170-
chex.assert_shape(polyfit, (1, 3))
171-
chex.assert_shape(rho_norm_3, (1, 3))
172-
chex.assert_shape(q_face_3, (1, 3))
173-
np.testing.assert_allclose(polyfit[0], random_coeffs)
153+
def test_linear_intercepts(self):
154+
rho_norm = jnp.array([0.0, 0.5, 1.0])
155+
q_face = jnp.array([1.0, 2.0, 3.0])
174156

175-
def test_root_in_interval_finds_correct_roots(self):
176-
"""Test correct roots found in each interval.
157+
with self.subTest("target_1_5"):
158+
intercepts = safety_factor_fit._linear_intercepts(rho_norm, q_face, 1.5)
159+
np.testing.assert_allclose(intercepts, np.array([-np.inf, 0.25, -np.inf]))
177160

178-
y=4 *
179-
|
180-
|
181-
y=1 * *
182-
|
183-
*--1---2---3----> x
184-
"""
185-
# Given two polynomials that fit the above points.
186-
a_1, b_1, c_1 = 1.0, 0.0, 0.0
187-
a_2, b_2, c_2 = -3.0, 12.0, -8.0
188-
coeffs = np.array([[a_1, b_1, c_1], [a_2, b_2, c_2]])
189-
rho_norm = np.array([[0.0, 2.0], [2.0, 3.0]])
190-
# When we calculate roots for intercepts of the q=3/2 plane.
191-
intercept = 1.5
192-
intercepts = safety_factor_fit._root_in_interval(
193-
coeffs, rho_norm, intercept
194-
)
195-
# Then expect only one root to be found in first interval.
196-
np.testing.assert_allclose(
197-
intercepts[0], np.array([np.sqrt(intercept), -np.inf])
198-
)
199-
# And expect only one root in second interval (as only over [2.0, 3.0]]).
200-
np.testing.assert_allclose(intercepts[1], np.array([-np.inf, 2.912871]))
161+
with self.subTest("target_2_0"):
162+
intercepts = safety_factor_fit._linear_intercepts(rho_norm, q_face, 2.0)
163+
np.testing.assert_allclose(intercepts, np.array([-np.inf, 0.5, -np.inf]))
164+
165+
with self.subTest("target_1_0"):
166+
intercepts = safety_factor_fit._linear_intercepts(rho_norm, q_face, 1.0)
167+
np.testing.assert_allclose(intercepts, np.array([0.0, -np.inf, -np.inf]))
201168

202169

203170
if __name__ == "__main__":
-250 Bytes
Binary file not shown.
986 Bytes
Binary file not shown.
1.05 KB
Binary file not shown.
-1.43 KB
Binary file not shown.
729 Bytes
Binary file not shown.
-1.53 KB
Binary file not shown.
-53 Bytes
Binary file not shown.
164 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)