Poincaré Sections¶
Surface-of-section diagnostic for field-line topology. Each seed is traced with the adaptive Dormand-Prince integrator; every accepted-step segment that crosses a transverse plane \(\Sigma\) contributes one puncture, located via linear interpolation. The resulting 2D scatter pattern makes topology visually obvious:
- closed curves ⇒ magnetic islands / O-points
- finite point sets ⇒ rational flux surfaces
- densely-filled 1D fills ⇒ KAM surfaces (good confinement)
- 2D blobs ⇒ chaotic / stochastic regions
A classical dynamical-systems diagnostic; in 3D plasma the same construction underpins fusion poloidal sections, X-line separatrix mapping in magnetotail reconnection, mirror-mode magnetic-hole topology, and stellarator divertor footprint analysis 1.
Tokamak poloidal section (closed orbits)¶
import numpy as np
from pypic import PoincareSurface, poincare_section, open_simulation
from pypic.plotting import plot_poincare_section
sim = open_simulation("/path/to/run")
ds = sim.read(sim.steps[-1])
# Φ = 0 cut: poloidal plane normal is the toroidal direction (ŷ here)
surf = PoincareSurface.from_axis("y", 0.0, name="φ = 0 poloidal")
# Seed a radial fan across the minor radius
seeds = np.array([[r, 0.0, 0.0] for r in np.linspace(0.2, 0.9, 12)])
section = poincare_section(
ds, seeds, surf,
max_steps=20_000, # ~100 poloidal transits at this resolution
direction="forward",
)
fig, ax = plot_poincare_section(section, color_by_seed=True)
ax.set_xlabel(r"$Z$ [m]")
ax.set_ylabel(r"$R$ [m]")
fig.savefig("poincare_phi_0.png", dpi=200)
Inside an island chain, several seeds will produce the same nested closed-curve pattern (same-color punctures forming concentric ovals). Seeds on KAM surfaces give 1D filled curves. Seeds in the ergodic boundary layer fill 2D regions.
Magnetotail X-line geometry¶
import numpy as np
from pypic import PoincareSurface, poincare_section, open_simulation
ds = open_simulation("/path/to/mhd_run").read(step=120)
# x-z plane in GSM (the standard reconnection-geometry view)
surf = PoincareSurface.from_axis("y", 0.0, name="meridional (GSM)")
# Seed a fan straddling the expected X-line
seeds = np.array([
[-15.0, 0.0, z] for z in np.linspace(-3.0, 3.0, 21)
])
section = poincare_section(
ds, seeds, surf,
max_steps=10_000,
direction="both", # capture both inflow regions
)
The separatrix appears as the boundary between qualitatively different puncture patterns: seeds on closed plasmoid loops trace ovals, inflow-region seeds give open punctures that exit the domain on the opposite side.
Re-puncturing without re-integrating¶
Tracing is the cost; puncturing is essentially free. Keep the
underlying FieldLine traces in the returned section and re-puncture
against a different surface:
from pypic.traces import plane_crossings
section_phi0 = poincare_section(ds, seeds, PoincareSurface.from_axis("y", 0.0))
# Re-puncture on a different toroidal angle without retracing
surf_phi_pi_2 = PoincareSurface.from_axis("x", 0.0, name="φ = π/2")
new_punctures = [
plane_crossings(fl.points, surf_phi_pi_2.normal, surf_phi_pi_2.offset)
for fl in section_phi0.field_lines
]
Arbitrary plane normals¶
Tilted current sheets, oblique X-lines, and stellarator Boozer-angle cuts don't align with the world axes:
# Plane through the origin with normal (1, 1, 0)/√2
surf = PoincareSurface(normal=(1.0, 1.0, 0.0), point=(0.0, 0.0, 0.0))
The Gram--Schmidt basis is built against the world axis least parallel to \(\hat{\mathbf{n}}\) for numerical stability — the standard oblique-section convention shared by FLARE 1 and most field-mapping tools.
Implementation notes¶
poincare_section forces loop_tol=None on the adaptive tracer.
The default auto closed-loop detector would otherwise terminate the
very orbits of interest after their first revolution, leaving a
single puncture per seed instead of an entire ring.
Punctures are extracted post-hoc via
plane_crossings — linear
interpolation between accepted trace points. The interpolation is
\(O(h^2/r)\) accurate, where \(h\) is the local step size and \(r\) is the
orbit radius. For sharp topology rendering, set
max_step <= circumference / 50.
Poincaré surface-of-section diagnostic for field-line topology.
Given an adaptive trace of a 3D vector field, project each line onto a fixed transverse surface \(\Sigma\) and record the puncture points. The resulting 2D scatter pattern makes topology visually obvious — closed curves are islands / O-points, dense 1D fills are KAM surfaces, blobs are chaotic regions. Standard tool for fusion poloidal sections and magnetotail X-line geometry — the FLARE 3D boundary code uses the same construction for stellarator / divertor footprint analysis [@Frerichs2024].
This module is a thin orchestrator on top of two existing primitives:
pypic.traces.trace_field_lines_adaptive
(integration) and
pypic.traces.plane_crossings (sign-change + linear
interp on
the plane). Punctures are extracted post-hoc from full traces;
loop_tol=None is forced so closed orbits don't self-terminate
before they can be sampled.
PoincareSurface
dataclass
¶
Transverse plane \(\Sigma\) for a Poincaré section.
\(\Sigma = \{\mathbf{x} : \hat{\mathbf{n}} \cdot
(\mathbf{x} - \mathbf{p}) = 0\}\), parameterized by an outward
normal \(\mathbf{n}\) and an in-plane reference point \(\mathbf{p}\).
The 2D plane coordinates \((u, v)\) produced by project are
measured relative to \(\mathbf{p}\) in an orthonormal basis
basis_2d spanning \(\Sigma\).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
normal
|
Vector3
|
Plane normal. Normalized internally; need not be unit length. |
required |
point
|
Vector3
|
A point on the plane (origin of the 2D \((u, v)\) frame). |
required |
name
|
str | None
|
Optional label propagated into output metadata and plots. |
None
|
Examples:
>>> surf = PoincareSurface.from_axis("y", 0.0, name="meridional")
>>> surf.offset
0.0
>>> import numpy as np
>>> u, v = surf.basis_2d
>>> float(np.dot(u, v))
0.0
Source code in src/pypic/traces/_poincare.py
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | |
offset
cached
property
¶
Signed scalar \(d = \hat{\mathbf{n}} \cdot \mathbf{p}\).
The form consumed by
pypic.traces.plane_crossings.
basis_2d
cached
property
¶
Orthonormal \((\hat{\mathbf{u}}, \hat{\mathbf{v}})\) spanning \(\Sigma\).
Constructed via Gram--Schmidt against the world axis least aligned with \(\hat{\mathbf{n}}\) for numerical stability — the standard oblique-section basis shared by FLARE [@Frerichs2024] and most field-mapping tools.
from_axis(axis, value, *, name=None)
classmethod
¶
Build an axis-aligned surface \(x_i = \text{value}\).
Covers tokamak poloidal sections (from_axis("y", 0.0) for a
\(\phi = 0\) cut, with the toroidal direction along \(\hat y\)) and
the standard magnetotail meridional plane
(from_axis("y", 0.0) in GSM).
Examples:
>>> surf = PoincareSurface.from_axis("z", 1.5)
>>> surf.normal
(0.0, 0.0, 1.0)
>>> surf.point
(0.0, 0.0, 1.5)
Source code in src/pypic/traces/_poincare.py
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 | |
project(points_3d)
¶
Project 3D points onto plane coordinates \((u, v)\).
For each input point \(\mathbf{x}\): \(u = \hat{\mathbf{u}} \cdot (\mathbf{x} - \mathbf{p})\), \(v = \hat{\mathbf{v}} \cdot (\mathbf{x} - \mathbf{p})\).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
points_3d
|
FloatArray
|
Input points, shape |
required |
Returns:
| Type | Description |
|---|---|
FloatArray
|
Plane coordinates, shape |
Examples:
Plane coordinates are oriented by the Gram--Schmidt basis; for the z-axis surface the basis is \(\hat{\mathbf{u}} = (0, 1, 0)\), \(\hat{\mathbf{v}} = (-1, 0, 0)\) (right-handed about \(\hat{\mathbf{n}}\)):
>>> import numpy as np
>>> surf = PoincareSurface.from_axis("z", 0.0)
>>> pts = np.array([[1.0, 2.0, 0.0], [-1.0, 3.0, 0.0]])
>>> surf.project(pts)
array([[ 2., -1.],
[ 3., 1.]])
Source code in src/pypic/traces/_poincare.py
174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | |
PoincareSection
dataclass
¶
Result of poincare_section: per-seed punctures and provenance.
Holds both the 3D crossing positions (for re-projection onto a
different surface) and the projected 2D coordinates (for plotting).
The underlying FieldLine traces are retained so the same
trajectories can be re-punctured against a different surface without
re-integrating.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
surface
|
PoincareSurface
|
The plane the punctures were extracted on. |
required |
seeds
|
FloatArray
|
Original seed positions, shape |
required |
direction
|
str
|
|
required |
punctures_3d
|
tuple[FloatArray, ...]
|
Per-seed crossings in 3D, each shape |
required |
punctures_2d
|
tuple[FloatArray, ...]
|
Per-seed crossings in plane coordinates, each |
required |
field_lines
|
tuple[FieldLine, ...]
|
Underlying trace per seed. |
required |
metadata
|
dict[str, Any]
|
Provenance: |
dict()
|
Examples:
>>> import numpy as np
>>> from pypic.units import Normalization
>>> from pypic.dataset import FieldDataset
>>> from pypic.grid import GridInfo
>>> grid = GridInfo(dimensions=(16, 16, 16), spacing=(0.2, 0.2, 0.2),
... origin=(-1.5, -1.5, -1.5))
>>> x = np.linspace(-1.5, 1.5, 16)
>>> y = np.linspace(-1.5, 1.5, 16)
>>> z = np.linspace(-1.5, 1.5, 16)
>>> X, Y, _ = np.meshgrid(x, y, z, indexing="ij")
>>> data = FieldDataset.from_arrays(
... {"B_1": -Y, "B_2": X, "B_3": 0.1 * np.ones_like(X)},
... grid, Normalization.identity(),
... )
>>> surf = PoincareSurface.from_axis("y", 0.0)
>>> section = poincare_section(
... data, np.array([[0.6, 0.0, 0.0]]), surf, max_steps=2000,
... direction="forward",
... )
>>> bool(section.metadata["n_crossings_per_seed"][0] > 1)
True
Source code in src/pypic/traces/_poincare.py
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 | |
poincare_section(data, seeds, surface, *, direction='forward', max_steps=50000, field_components=('B_1', 'B_2', 'B_3'), atol=1e-06, rtol=0.0001, step_size_init=0.5, min_step=1e-08, max_step=2.0, null_threshold=1e-12, interpolator=None)
¶
Build a Poincaré section by tracing seeds and puncturing on \(\Sigma\).
Each seed is integrated forward (and/or backward) with the adaptive Dormand-Prince tracer; every accepted-step segment that crosses \(\Sigma\) contributes one puncture, located via linear interpolation on the signed-distance field \(\mathbf{n}\cdot\mathbf{r} - d\).
Internally forces loop_tol=None on the tracer — the auto
closed-loop detector would otherwise terminate the very orbits we
want to sample on the second puncture.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
FieldDataset
|
Gridded vector field data. |
required |
seeds
|
FloatArray or Sequence[Vector3]
|
Seed positions, shape |
required |
surface
|
PoincareSurface
|
Transverse plane to puncture on. |
required |
direction
|
``"forward"`` | ``"backward"`` | ``"both"``
|
Trace direction. Forward only is typical for tokamak/stellarator
cuts; |
'forward'
|
max_steps
|
int
|
Per-direction step budget for the adaptive tracer. Memory cost
scales as |
50000
|
field_components
|
tuple[str, str, str]
|
Field component names. Default |
('B_1', 'B_2', 'B_3')
|
atol
|
float
|
Adaptive error tolerances. |
1e-06
|
rtol
|
float
|
Adaptive error tolerances. |
1e-06
|
step_size_init
|
float
|
Step-size controller knobs. |
0.5
|
min_step
|
float
|
Step-size controller knobs. |
0.5
|
max_step
|
float
|
Step-size controller knobs. |
0.5
|
null_threshold
|
float
|
Field-magnitude threshold below which a point is a null. |
1e-12
|
interpolator
|
VectorFieldInterpolator | None
|
Pre-built interpolator; constructed internally if |
None
|
Returns:
| Type | Description |
|---|---|
PoincareSection
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
Propagated from |
Notes
For fusion poloidal sections, set direction="forward" and pick
max_steps to span ~\(N\) poloidal transits per seed; N = 100
is typical for a quick island survey. For magnetotail X-line
geometry, direction="both" captures the separatrix from both
inflow regions.
Examples:
See PoincareSection for a closed-circle example.
Source code in src/pypic/traces/_poincare.py
300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 | |
-
H. Frerichs and others. FLARE: field line analysis and reconstruction for 3D boundary plasma modeling. Nuclear Fusion, 64:106041, 2024. doi:10.1088/1741-4326/ad7303. ↩↩