lockkernel 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lockkernel/measured.py ADDED
@@ -0,0 +1,271 @@
1
+ """Fit a MEASURED synchronization branch: threshold, exponent, kernel.
2
+
3
+ The theory half of this package computes the branch exactly from a
4
+ known line shape and a known locking kernel. This module is the lab
5
+ half: given measured (coupling, order parameter) points near an onset
6
+ -- from an experiment or from a simulation whose kernel is NOT known
7
+ -- it fits the near-threshold law
8
+
9
+ R = A (chiN / chiN_c - 1)^beta, chiN > chiN_c,
10
+
11
+ for the threshold chiN_c, the exponent beta and the amplitude A
12
+ together, with error bars, and translates beta back into the locking
13
+ kernel's tail exponent through the exact dictionary of
14
+ `lockkernel.parametric`:
15
+
16
+ beta = 1/(s-1) for 1 < s < 3, beta = 1/2 for s >= 3,
17
+
18
+ so s = 1 + 1/beta wherever beta > 1/2, while beta consistent with 1/2
19
+ identifies only the CLASS s >= 3 (compact support or fast decay) --
20
+ `kernel_tail_from_beta` refuses to name a single s there, because the
21
+ data cannot single one out.
22
+
23
+ House rules, as everywhere in this organization: measured data carry
24
+ a mandatory `reference` (instrument or simulation provenance), the
25
+ fit REFUSES -- with the reason -- rather than returning numbers it
26
+ cannot defend (too few points, couplings not increasing, less than a
27
+ decade of reduced coupling, a threshold estimate that collides with
28
+ the data, a fit that does not converge), and every closed form in
29
+ this module is held in the tests against the exact parametric branch
30
+ of `lockkernel.parametric` and against seeded Monte Carlo -- the
31
+ theory half checks the lab half.
32
+
33
+ `points_for_beta` plans the measurement before it is taken: the
34
+ closed-form error bar of a log-log slope over a given range of
35
+ reduced coupling, inverted exactly for the number of points a target
36
+ error bar costs.
37
+
38
+ Honest limits: the power law is the LEADING near-threshold behavior;
39
+ data taken far above threshold bend away from it and bias beta -- fit
40
+ what is near the onset (the eps_max refusal guards the worst of it,
41
+ and the residual diagnostics are returned so the bend is visible).
42
+ Error bars are the standard asymptotic (J^T J)^-1 covariance of the
43
+ weighted fit: honest when the model holds and the noise estimate is
44
+ right, and stated as such.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ from dataclasses import dataclass
49
+
50
+ import numpy as np
51
+ from scipy.optimize import least_squares
52
+
53
+ __all__ = ["BranchFit", "fit_branch", "kernel_tail_from_beta",
54
+ "beta_relative_sigma", "points_for_beta"]
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class BranchFit:
59
+ """The fitted near-threshold law, with provenance."""
60
+ chi_c: float
61
+ beta: float
62
+ amplitude: float
63
+ sigma_chi_c: float
64
+ sigma_beta: float
65
+ sigma_amplitude: float
66
+ n_points: int
67
+ eps_range: tuple
68
+ residual_rms_log: float
69
+ reference: str
70
+
71
+
72
+ def _check_reference(reference):
73
+ if not isinstance(reference, str) or len(reference.strip()) < 8:
74
+ raise ValueError("measured branch data require a real "
75
+ "reference (instrument or simulation "
76
+ "provenance, >= 8 characters); this package "
77
+ "does not fit uncited data")
78
+
79
+
80
+ def fit_branch(chi, r, reference, sigma_r=None, min_decade=1.0):
81
+ """Fit R = A (chi/chi_c - 1)^beta to measured points.
82
+
83
+ chi : couplings chiN, strictly increasing, all above threshold.
84
+ r : measured order parameters at those couplings, all > 0.
85
+ reference : where the data come from (mandatory, >= 8 chars).
86
+ sigma_r : absolute 1-sigma errors of r (same shape); when given,
87
+ the covariance is exact for that noise; when omitted, the
88
+ residual scatter sets the scale and the error bars say so by
89
+ construction (residual-scaled).
90
+ min_decade : the minimum decades of reduced coupling
91
+ eps = chi/chi_c - 1 the fitted points must span (default one
92
+ decade); a narrower fit cannot separate the exponent from the
93
+ amplitude and is refused.
94
+
95
+ Returns a `BranchFit`. Refusals: fewer than 6 points, couplings
96
+ not strictly increasing, non-positive R, a converged threshold
97
+ indistinguishable from the smallest coupling (the data do not
98
+ reach below the bend), a span of eps under `min_decade` decades,
99
+ or a fit that does not converge.
100
+ """
101
+ _check_reference(reference)
102
+ x = np.asarray(chi, dtype=float)
103
+ y = np.asarray(r, dtype=float)
104
+ if x.size < 6 or x.size != y.size:
105
+ raise ValueError("need >= 6 (chi, R) points of equal length: "
106
+ "three parameters and error bars cannot come "
107
+ "from fewer")
108
+ if np.any(~np.isfinite(x)) or np.any(np.diff(x) <= 0.0):
109
+ raise ValueError("couplings must be finite and strictly "
110
+ "increasing")
111
+ if np.any(~np.isfinite(y)) or np.any(y <= 0.0):
112
+ raise ValueError("order parameters must be finite and > 0: "
113
+ "points below threshold (R = 0) carry no "
114
+ "exponent information in this law; drop them")
115
+ if sigma_r is not None:
116
+ sy = np.asarray(sigma_r, dtype=float)
117
+ if sy.shape != y.shape or np.any(sy <= 0.0):
118
+ raise ValueError("sigma_r must be positive on the same "
119
+ "grid as r")
120
+ w_log = sy / y # d(log R) = dR / R
121
+ else:
122
+ w_log = np.ones_like(y)
123
+
124
+ ly = np.log(y)
125
+ chi_min = x[0]
126
+
127
+ def resid(p):
128
+ chi_c, beta, la = p
129
+ eps = x / chi_c - 1.0
130
+ return (la + beta * np.log(eps) - ly) / w_log
131
+
132
+ # start: threshold a little under the first point, slope from the
133
+ # outermost pair
134
+ eps0 = x / (0.95 * chi_min) - 1.0
135
+ b0 = (ly[-1] - ly[0]) / np.log(eps0[-1] / eps0[0])
136
+ b0 = float(np.clip(b0, 0.1, 10.0))
137
+ p0 = np.array([0.95 * chi_min, b0,
138
+ ly[-1] - b0 * np.log(eps0[-1])])
139
+ sol = least_squares(resid, p0,
140
+ bounds=([1e-300, 0.05, -np.inf],
141
+ [chi_min * (1 - 1e-9), 20.0, np.inf]))
142
+ if not sol.success:
143
+ raise ValueError(f"the branch fit did not converge: "
144
+ f"{sol.message}")
145
+ chi_c, beta, la = sol.x
146
+ if chi_c >= chi_min * (1.0 - 1e-8):
147
+ raise ValueError(
148
+ "the fitted threshold is indistinguishable from the "
149
+ "smallest measured coupling: the data do not resolve the "
150
+ "onset. Measure closer to threshold (smaller R), or "
151
+ "supply points at lower coupling")
152
+ eps = x / chi_c - 1.0
153
+ decades = np.log10(eps[-1] / eps[0])
154
+ if decades < float(min_decade):
155
+ raise ValueError(
156
+ f"the fitted points span only {decades:.2f} decades of "
157
+ f"reduced coupling eps (minimum {float(min_decade):.2f}): "
158
+ "exponent and amplitude are not separable on so short a "
159
+ "lever arm. Extend the coupling range")
160
+
161
+ # asymptotic covariance of the weighted fit
162
+ jac = sol.jac
163
+ jtj = jac.T @ jac
164
+ try:
165
+ cov = np.linalg.inv(jtj)
166
+ except np.linalg.LinAlgError:
167
+ raise ValueError("the fit covariance is singular: the data "
168
+ "do not constrain all three parameters "
169
+ "independently")
170
+ dof = x.size - 3
171
+ res = sol.fun
172
+ if sigma_r is None and dof > 0:
173
+ cov = cov * float(res @ res) / dof # residual-scaled
174
+ sig = np.sqrt(np.diag(cov))
175
+ return BranchFit(
176
+ chi_c=float(chi_c), beta=float(beta),
177
+ amplitude=float(np.exp(la)),
178
+ sigma_chi_c=float(sig[0]), sigma_beta=float(sig[1]),
179
+ sigma_amplitude=float(np.exp(la) * sig[2]),
180
+ n_points=int(x.size),
181
+ eps_range=(float(eps[0]), float(eps[-1])),
182
+ residual_rms_log=float(np.sqrt(np.mean((res * w_log) ** 2))),
183
+ reference=reference)
184
+
185
+
186
+ def kernel_tail_from_beta(beta, sigma_beta=0.0, n_sigma=2.0):
187
+ """The kernel tail exponent s from a measured beta: s = 1 + 1/beta.
188
+
189
+ Error propagation: sigma_s = sigma_beta / beta^2.
190
+
191
+ Refuses to name a single s when beta is consistent with 1/2
192
+ within `n_sigma` error bars: every kernel with s >= 3 (compact
193
+ support, Gaussian, any decay faster than |u|^-3) produces
194
+ beta = 1/2, so the measurement identifies only the class. Also
195
+ refuses beta significantly BELOW 1/2, which no locking kernel in
196
+ this dictionary produces -- that is a sign the fitted range left
197
+ the near-threshold regime.
198
+ """
199
+ b = float(beta)
200
+ sb = abs(float(sigma_beta))
201
+ if not (np.isfinite(b) and b > 0.0):
202
+ raise ValueError("beta must be finite and positive")
203
+ if b + n_sigma * sb < 0.5:
204
+ raise ValueError(
205
+ f"beta = {b:.4g} +- {sb:.4g} lies significantly below "
206
+ "1/2, which no locking kernel produces in the dictionary "
207
+ "beta = 1/(s-1) (1 < s < 3), 1/2 (s >= 3). The fitted "
208
+ "range has likely left the near-threshold regime; refit "
209
+ "closer to the onset")
210
+ if b - n_sigma * sb <= 0.5:
211
+ raise ValueError(
212
+ f"beta = {b:.4g} +- {sb:.4g} is consistent with 1/2, "
213
+ "which identifies only the CLASS s >= 3 (compact support "
214
+ "or decay faster than |u|^-3); no single tail exponent "
215
+ "can be named from it")
216
+ s = 1.0 + 1.0 / b
217
+ if s <= 1.0 or s >= 3.0:
218
+ raise ValueError(f"implied s = {s:.4g} outside the algebraic "
219
+ "window 1 < s < 3")
220
+ return s, sb / b ** 2
221
+
222
+
223
+ def beta_relative_sigma(n_points, decades, sigma_log):
224
+ """Closed-form 1-sigma error of a log-log slope.
225
+
226
+ n_points : measured couplings, log-uniform over the eps range.
227
+ decades : decades of reduced coupling spanned.
228
+ sigma_log : per-point scatter of log R (for small relative noise,
229
+ sigma_log ~ sigma_R / R).
230
+
231
+ For n equally spaced abscissas spanning L = decades * ln 10,
232
+
233
+ sigma_beta = sigma_log / sqrt( L^2 n (n+1) / (12 (n-1)) ),
234
+
235
+ the exact variance of the least-squares slope; the tests hold it
236
+ against seeded simulation. This is the KNOWN-threshold error --
237
+ fitting chi_c together with beta (as `fit_branch` does) is
238
+ strictly harder, so plan with margin.
239
+ """
240
+ n = int(n_points)
241
+ if n < 3:
242
+ raise ValueError("need >= 3 points for a slope with an error "
243
+ "bar")
244
+ d = float(decades)
245
+ s = float(sigma_log)
246
+ if not (d > 0.0 and s > 0.0):
247
+ raise ValueError("decades and sigma_log must be positive")
248
+ ell = d * np.log(10.0)
249
+ sxx = ell ** 2 * n * (n + 1) / (12.0 * (n - 1))
250
+ return s / np.sqrt(sxx)
251
+
252
+
253
+ def points_for_beta(target_sigma_beta, decades, sigma_log):
254
+ """Points needed for a target error bar on beta.
255
+
256
+ Exact inversion of `beta_relative_sigma`: returns (n, achieved)
257
+ with achieved <= target and n-1 failing it (asserted two-sided in
258
+ the tests).
259
+ """
260
+ tgt = float(target_sigma_beta)
261
+ if not (np.isfinite(tgt) and tgt > 0.0):
262
+ raise ValueError("target_sigma_beta must be positive")
263
+ n = 3
264
+ while beta_relative_sigma(n, decades, sigma_log) > tgt:
265
+ n += 1
266
+ if n > 10 ** 7:
267
+ raise ValueError(
268
+ "over 10^7 points would be needed: the target is out "
269
+ "of reach at this noise and range; widen the range "
270
+ "or reduce the noise")
271
+ return n, beta_relative_sigma(n, decades, sigma_log)
@@ -0,0 +1,266 @@
1
+ """The exact parametric solution of the locking self consistency.
2
+
3
+ THE SELF CONSISTENCY. Write R for the order parameter, chiN for the coupling
4
+ per spin times the spin number, and Omega = chiN R for the locking bandwidth,
5
+ the width in detuning over which an oscillator follows the mean field. With a
6
+ locking kernel W the stationary condition is
7
+
8
+ R = int p(delta) W(delta / Omega) d delta.
9
+
10
+ THE RECASTING. Substituting delta = Omega u turns the right hand side into a
11
+ function of Omega alone,
12
+
13
+ H(Omega) = Omega int p(Omega u) W(u) du,
14
+
15
+ so that R = H(Omega), and since R = Omega / chiN,
16
+
17
+ chiN(Omega) = Omega / H(Omega), R(Omega) = H(Omega).
18
+
19
+ This is an EXACT PARAMETRIC SOLUTION of the transition for any line shape and
20
+ any kernel. Sweeping Omega upward from zero traces the branch, with no root
21
+ finding and no cancellation near threshold, which is what makes high precision
22
+ exponent extraction possible. Letting Omega go to zero gives the threshold
23
+
24
+ chiN_c = 1 / (p(0) m), m = int W(u) du,
25
+
26
+ for every kernel: the kernel enters the threshold only through its mass.
27
+
28
+ THE MECHANISM. Everything about the onset is contained in how H leaves its
29
+ linear behaviour. Define
30
+
31
+ G(Omega) = H(Omega) / Omega = int p(Omega u) W(u) du,
32
+
33
+ so G(0) = p(0) m. If the kernel has an algebraic tail W ~ |u|^-s with
34
+ 1 < s < 3, the integral samples the whole line and
35
+
36
+ G(0) - G(Omega) ~ const * Omega^(s-1),
37
+
38
+ because the tail of the kernel meets the tail of the line. If instead the
39
+ kernel has compact support, or decays faster than any power, the leading
40
+ correction comes from the local curvature of the line and is quadratic,
41
+
42
+ G(0) - G(Omega) = -(1/2) p''(0) M_2 Omega^2 + O(Omega^4),
43
+ M_2 = int u^2 W(u) du.
44
+
45
+ Since eps = chiN/chiN_c - 1 = G(0)/G(Omega) - 1 and R = Omega G(Omega), the
46
+ order parameter follows eps to the power beta with
47
+
48
+ beta = 1/(s-1) for 1 < s < 3, beta = 1/2 for s >= 3.
49
+
50
+ The crossover at s = 3 is exactly where the second moment M_2 of the kernel
51
+ stops converging, so that the tail contribution and the curvature contribution
52
+ exchange dominance.
53
+
54
+ THE CONSERVATIVE CASE. For conservative spins W(u) = 1/(1+u^2), whence
55
+ s = 2, m = pi and beta = 1. Then H is a Lorentzian smoothing of the line,
56
+
57
+ H(Omega) = pi Omega (p * L_Omega)(0),
58
+
59
+ with L_Omega the normalised Lorentzian of half width Omega, and the linear
60
+ coefficient can be written in closed form,
61
+
62
+ G(Omega) = pi [p(0) - c Omega + o(Omega)],
63
+ c = -(1/pi) int [p(delta) - p(0)] / delta^2 d delta,
64
+
65
+ giving R = A eps with A = pi p(0)^2 / c. The SIGN of c decides the order of
66
+ the transition: c > 0 gives a monotonic branch and a continuous onset, c < 0
67
+ gives a fold in the branch and therefore a hysteretic, first order onset.
68
+ """
69
+ from __future__ import annotations
70
+
71
+ import mpmath as mp
72
+
73
+ from .kernels import Kernel, conservative
74
+ from .lineshapes import LineShape
75
+
76
+ __all__ = ["G_of_Omega", "H_of_Omega", "branch_point", "threshold",
77
+ "c_coefficient", "amplitude", "sweep", "extract_beta",
78
+ "fold_interval"]
79
+
80
+ INF = mp.inf
81
+
82
+
83
+ def _breakpoints(line: LineShape, kernel: Kernel, Omega):
84
+ """Quadrature break points in u for int p(Omega u) W(u) du.
85
+
86
+ The integrand has structure on two scales: the kernel varies on u ~ 1 and
87
+ the line varies on u ~ scale/Omega. Both are given to the quadrature.
88
+ """
89
+ s = mp.mpf(line.scale) / Omega
90
+ if kernel.support is not None:
91
+ cut = mp.mpf(kernel.support)
92
+ pts = sorted({mp.mpf(0), min(s, cut), cut})
93
+ return [-p for p in reversed(pts)] + list(pts)[1:]
94
+ pts = sorted({mp.mpf(0), mp.mpf(1), s, 10 * s, mp.mpf(10)})
95
+ return [-INF] + [-p for p in reversed(pts) if p > 0] + list(pts) + [INF]
96
+
97
+
98
+ def G_of_Omega(line: LineShape, kernel: Kernel, Omega) -> mp.mpf:
99
+ """G(Omega) = int p(Omega u) W(u) du. Finite and positive at Omega = 0."""
100
+ Omega = mp.mpf(Omega)
101
+ if Omega == 0:
102
+ return line.p0() * kernel.mass()
103
+ f = lambda u: line.pdf(Omega * u) * kernel.W(u)
104
+ return mp.quad(f, _breakpoints(line, kernel, Omega))
105
+
106
+
107
+ def H_of_Omega(line: LineShape, kernel: Kernel, Omega) -> mp.mpf:
108
+ """H(Omega) = Omega G(Omega). Equals the order parameter on the branch."""
109
+ return mp.mpf(Omega) * G_of_Omega(line, kernel, Omega)
110
+
111
+
112
+ def threshold(line: LineShape, kernel: Kernel) -> mp.mpf:
113
+ """chiN_c = 1 / (p(0) m). The kernel enters only through its mass."""
114
+ return 1 / (line.p0() * kernel.mass())
115
+
116
+
117
+ def branch_point(line: LineShape, kernel: Kernel, Omega):
118
+ """One point of the branch: (chiN, R, eps) at locking bandwidth Omega."""
119
+ Omega = mp.mpf(Omega)
120
+ G = G_of_Omega(line, kernel, Omega)
121
+ chiN = 1 / G
122
+ R = Omega * G
123
+ eps = chiN / threshold(line, kernel) - 1
124
+ return chiN, R, eps
125
+
126
+
127
+ def sweep(line: LineShape, kernel: Kernel, exponents):
128
+ """The branch at Omega = 10^e for each e in `exponents`."""
129
+ return [branch_point(line, kernel, mp.mpf(10) ** mp.mpf(e)) for e in exponents]
130
+
131
+
132
+ def extract_beta(line: LineShape, kernel: Kernel, exponents):
133
+ """Successive local slopes d log R / d log eps along the branch.
134
+
135
+ Returned as a list with one fewer entry than `exponents`. Convergence of
136
+ the list is the evidence that the exponent has been reached; the last
137
+ entry is quoted as the measured exponent.
138
+ """
139
+ pts = sweep(line, kernel, exponents)
140
+ out = []
141
+ for i in range(len(pts) - 1):
142
+ e0, r0 = pts[i][2], pts[i][1]
143
+ e1, r1 = pts[i + 1][2], pts[i + 1][1]
144
+ out.append(mp.log(r1 / r0) / mp.log(e1 / e0))
145
+ return out
146
+
147
+
148
+ def c_coefficient(line: LineShape) -> mp.mpf:
149
+ """c = -(1/pi) int [p(delta) - p(0)] / delta^2 d delta.
150
+
151
+ Defined for the conservative kernel only, where it is both the linear
152
+ coefficient of the smoothed peak and the criterion for the order of the
153
+ transition. The integrand has a REMOVABLE singularity at the origin, its
154
+ limit being p''(0)/2, so the quadrature must not evaluate it there; the
155
+ range is split away from zero and the integrand is even.
156
+ """
157
+ p0 = line.p0()
158
+ sc = mp.mpf(line.scale)
159
+
160
+ def f(d):
161
+ if d == 0:
162
+ return mp.diff(line.pdf, mp.mpf(0), 2) / 2
163
+ return (line.pdf(d) - p0) / d ** 2
164
+
165
+ val = 2 * mp.quad(f, [mp.mpf(10) ** -20, sc * mp.mpf("1e-3"), sc,
166
+ 10 * sc, 100 * sc, INF])
167
+ return -val / mp.pi
168
+
169
+
170
+ def amplitude(line: LineShape) -> mp.mpf:
171
+ """A = pi p(0)^2 / c, the slope of R against eps for conservative spins."""
172
+ return mp.pi * line.p0() ** 2 / c_coefficient(line)
173
+
174
+
175
+ def tail_integral(line: LineShape, s) -> mp.mpf:
176
+ """I_s = int [p(0) - p(delta)] |delta|^-s d delta.
177
+
178
+ This is the object that carries the whole onset for a kernel of tail
179
+ exponent s, and the regime 1 < s < 3 of the exponent formula is exactly
180
+ the regime in which it converges: at s = 3 it diverges at the origin, so
181
+ the local curvature takes over and the exponent returns to 1/2, and at
182
+ s = 1 it diverges at infinity, together with the mass of the kernel, so
183
+ the threshold collapses. At s = 2 it equals pi times the coefficient c.
184
+
185
+ Integrating by parts,
186
+
187
+ I_s = (2 / (s-1)) int_0^inf delta^(1-s) [-p'(delta)] d delta,
188
+
189
+ which is the form used here: it removes the subtraction of two nearly
190
+ equal numbers near the origin, where p(0) - p(delta) is a cancellation of
191
+ order delta^2 that costs as many digits as the quadrature comes close.
192
+ """
193
+ s = mp.mpf(s)
194
+ if s <= 1 or s >= 3:
195
+ raise ValueError("I_s converges only for 1 < s < 3")
196
+ sc = mp.mpf(line.scale)
197
+ f = lambda d: d ** (1 - s) * (-mp.diff(line.pdf, d))
198
+ return (2 / (s - 1)) * (mp.quad(f, [0, sc])
199
+ + mp.quad(f, [sc, 10 * sc, 100 * sc, INF]))
200
+
201
+
202
+ def amplitude_general(line: LineShape, kernel: Kernel, C=None) -> mp.mpf:
203
+ """A_s in R = A_s eps^(1/(s-1)), for a kernel of tail W ~ C |u|^-s.
204
+
205
+ A_s = p(0) m [ p(0) m / (C I_s) ]^(1/(s-1)),
206
+
207
+ with m the kernel mass and I_s the integral above. It reduces to
208
+ pi p(0)^2 / c at s = 2, where the exponent is one and the branch is
209
+ linear. `C` defaults to the tail amplitude measured from the kernel
210
+ itself, which is one for the family 1/(1+|u|^s).
211
+ """
212
+ s = mp.mpf(kernel.tail)
213
+ m = kernel.mass()
214
+ if C is None:
215
+ v = mp.mpf(10) ** 6
216
+ C = kernel.W(v) * v ** s
217
+ p0m = line.p0() * m
218
+ return p0m * (p0m / (mp.mpf(C) * tail_integral(line, s))) ** (1 / (s - 1))
219
+
220
+
221
+ def fold_interval(line: LineShape, kernel: Kernel = None, n: int = 400,
222
+ lo: float = -4.0, hi: float = 1.5):
223
+ """Locate a fold in the branch, if there is one.
224
+
225
+ The branch chiN(Omega) is sampled on a logarithmic grid. Where it is not
226
+ monotonic there are two turning points, and between the couplings they
227
+ define the stationary condition has three solutions, so the onset is
228
+ hysteretic. Each turning point is refined by solving d chiN / d Omega = 0
229
+ rather than being read off the grid, and the size of the jump is measured
230
+ at the upper turning point, where the low branch ceases to exist and the
231
+ order parameter must move to the highest solution at the same coupling.
232
+
233
+ Returns None when the branch is monotonic.
234
+ """
235
+ kernel = kernel or conservative()
236
+ Om = [mp.mpf(10) ** (lo + (hi - lo) * i / (n - 1)) for i in range(n)]
237
+ chi = [branch_point(line, kernel, o)[0] for o in Om]
238
+ down = [i for i in range(len(chi) - 1) if chi[i + 1] < chi[i]]
239
+ if not down:
240
+ return None
241
+ i0, i1 = down[0], down[-1] + 1
242
+
243
+ dchi = lambda o: mp.diff(lambda x: branch_point(line, kernel, x)[0], o)
244
+ def refine(ia, ib):
245
+ try:
246
+ return mp.findroot(dchi, (Om[ia], Om[ib]), solver="bisect",
247
+ tol=mp.mpf("1e-16"))
248
+ except Exception:
249
+ return Om[ia]
250
+
251
+ O_hi = refine(max(i0 - 1, 0), min(i0 + 1, n - 1)) # end of the low branch
252
+ O_lo = refine(max(i1 - 1, 0), min(i1 + 1, n - 1)) # end of the high branch
253
+ chiN_hi, R_low, _ = branch_point(line, kernel, O_hi)
254
+ chiN_lo, R_high_at_lo, _ = branch_point(line, kernel, O_lo)
255
+
256
+ # the state the system jumps to: the largest Omega with chiN(Omega) = chiN_hi
257
+ f = lambda o: branch_point(line, kernel, o)[0] - chiN_hi
258
+ O_jump = None
259
+ for k in range(i1, n - 1):
260
+ if f(Om[k]) * f(Om[k + 1]) <= 0:
261
+ O_jump = mp.findroot(f, (Om[k], Om[k + 1]), solver="bisect",
262
+ tol=mp.mpf("1e-14"))
263
+ R_jump = branch_point(line, kernel, O_jump)[1] if O_jump else None
264
+ return dict(chiN_lo=chiN_lo, chiN_hi=chiN_hi,
265
+ R_low=R_low, R_jump=R_jump, R_high_at_lo=R_high_at_lo,
266
+ Omega_lo=O_lo, Omega_hi=O_hi)