Tutoriel : matrice de monodromie et déplacement orbital sur un cycle selle de type X analytique#

Ce tutoriel démontre le workflow central de monodromie pyna sur un cycle selle construit analytiquement (point X d’une carte de Poincaré) intégré dans une géométrie toroïdale 3D.

Ce que vous apprendrez :

  1. Comment définir un champ analytique avec un cycle X m=3, n=1 connu en coordonnées cylindriques (R, Z, φ)

  2. Comment evolve_DPm_along_cycle intègre l’équation variationnelle dDX_pol/dφ = A · DX_pol pour obtenir la matrice de monodromie DPm

  3. Pourquoi l’évolution de DPm (équation de commutateur) nécessite DPm(φ₀) = DX_pol(φ_end), et non l’identité, et ce que cela signifie géométriquement

  4. Comment calculer le déplacement orbital linéaire sous un champ de perturbation avec orbit_shift_under_perturbation

  5. Comment comparer la formule analytique avec le résultat de l’API pyna

Géométrie de référence :
Un tokamak modèle de grand rayon R₀=1 m, \(B_φ^{ax}=2.5\) T, cycle X elliptique de demi-axes (R_ell=0.3, Z_ell=0.5) et transformée rotationnelle ι = n/m = 1/3.

1. Construction analytique du champ#

Nous construisons un champ dont le cycle X m=3 exact est connu analytiquement.

1.1 Le cycle et ses directions propres#

Le cycle vit sur l’ellipse

\[\begin{split}X_c(\varphi) = \begin{pmatrix} R_{\rm ax} + R_{\rm ell}\cos(\iota\varphi + \varphi_0) \\ Z_{\rm ax} + Z_{\rm ell}\sin(\iota\varphi + \varphi_0) \end{pmatrix}\end{split}\]

avec \(\iota = 1/3\), donc l’orbite se ferme après \(m=3\) tours toroïdaux.

La carte de Poincaré au point X a des valeurs propres \(\lambda_u = e^{+1/5}\) (instable) et \(\lambda_s = e^{-1/5}\) (stable). Les directions propres tournent le long de l’orbite selon un angle prescrit \(\theta(\varphi)\).

[1]:
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp

# ── Cycle parameters ────────────────────────────────────────────────────────
Rax, Zax = 1.0, 0.0
Rell, Zell = 0.3, 0.5
phi0_cycle = 0.0
BPhiax = 2.5
m = 3       # toroidal period (orbit closes after m tours)
n = 1
iota = n / m  # rotational transform

# Valeurs propres of the m-turn Poincaré map
lam_u = np.e ** (1/5)   # unstable multiplier
lam_s = np.e ** (-1/5)  # stable  multiplier
print(f"λ_u = {lam_u:.6f},  λ_s = {lam_s:.6f},  produit = {lam_u * lam_s:.10f}  (devrait être 1)")

# Rotating eigendirections along the orbit
def theta_u(phi):
    """Angle of unstable eigenvector at toroidal position phi."""
    return phi/3 + np.pi/2 + np.pi/9

def theta_s(phi):
    """Angle of stable eigenvector at toroidal position phi."""
    return phi/3 + np.pi/2 - np.pi/9

dtheta_dphi = 1.0/3  # same rotation rate as the orbit

# ── The cycle position ──────────────────────────────────────────────────────
def cycle_RZ(phi):
    """Return (R, Z) of the X-cycle at toroidal angle phi."""
    return np.array([
        Rell * np.cos(iota * phi + phi0_cycle) + Rax,
        Zell * np.sin(iota * phi + phi0_cycle) + Zax,
    ])

# ── Toroidal field (axisymmetric model: R·B_φ = const) ─────────────────────
def BPhi(phi, RZ):
    """Toroidal field B_φ(R) = B_φ^ax · R_ax / R  (free-force-balance model)."""
    return BPhiax * Rax / RZ[0]

print("Cycle à phi=0 :", cycle_RZ(0.0))
print("Cycle à phi=2π :", cycle_RZ(2*np.pi))
print("Cycle à phi=6π (ferme) :", cycle_RZ(6*np.pi))
λ_u = 1.221403,  λ_s = 0.818731,  produit = 1.0000000000  (devrait être 1)
Cycle à phi=0 : [1.3 0. ]
Cycle à phi=2π : [0.85      0.4330127]
Cycle à phi=6π (ferme) : [ 1.3000000e+00 -1.2246468e-16]
[2]:
# ── Poloidal field on the cycle ──────────────────────────────────────────────
#
# The poloidal field on the exact cycle is just B_pol = (dR_c/dl, dZ_c/dl)
# re-parameterised by phi.  We compute the φ-derivatives:
#   dR_c/dφ = -ι R_ell sin(ι φ + φ₀)
#   dZ_c/dφ = +ι Z_ell cos(ι φ + φ₀)

def BR0_BZ0_on_cycle(phi):
    """Return (B_R, B_Z) of the unperturbed field on the X-cycle."""
    Rc = cycle_RZ(phi)[0]
    Bp = BPhi(phi, cycle_RZ(phi))
    dRc = -iota * Rell * np.sin(iota * phi + phi0_cycle)
    dZc =  iota * Zell * np.cos(iota * phi + phi0_cycle)
    return np.array([dRc / Rc * Bp, dZc / Rc * Bp])


# ── Full field B(R, Z, φ) with exact X-cycle ────────────────────────────────
#
# We construct B_pol(X, φ) so that:
#  1. B_pol(X_c(φ), φ) = B_pol^0(φ)  (matches cycle tangent)
#  2. The Jacobian A(φ) of g = R·B_pol/B_φ along X_c has the prescribed
#     eigenstructure (λ_u, λ_s with rotating eigenvectors θ_u, θ_s).
#
# Construction: let V(φ) = [e_u | e_s] be the eigenvector matrix and
# Λ = diag(log|λ_u|, log|λ_s|) / (2mπ)  (per-φ growth rates).
# Then  A(φ) = V Λ V^{-1} + dV/dφ · V^{-1}  (accounts for rotating frame).
# The field is B_pol(X, φ) = B_pol^0(φ) + R·A(φ)·(X - X_c(φ)) / R_c

def _eigvec_matrix(phi):
    """2x2 matrix of eigenvectors [e_u | e_s] at phi."""
    return np.array([
        [np.cos(theta_u(phi)), np.cos(theta_s(phi))],
        [np.sin(theta_u(phi)), np.sin(theta_s(phi))],
    ])

def _A_matrix(phi):
    """A(φ) = Jacobian of g = R·B_pol/B_φ w.r.t. (R,Z) along cycle."""
    V = _eigvec_matrix(phi)
    # per-radian growth rates
    mu_u = np.log(np.abs(lam_u)) / (2 * m * np.pi)
    mu_s = np.log(np.abs(lam_s)) / (2 * m * np.pi)
    Lam = np.diag([mu_u, mu_s])
    # rotating-frame correction: dV/dφ · V^{-1}
    dV = np.array([
        [-dtheta_dphi * np.sin(theta_u(phi)), -dtheta_dphi * np.sin(theta_s(phi))],
        [ dtheta_dphi * np.cos(theta_u(phi)),  dtheta_dphi * np.cos(theta_s(phi))],
    ])
    return V @ Lam @ np.linalg.inv(V) + dV @ np.linalg.inv(V)

def field_g_pol(phi, X_pol):
    """φ-parameterised field direction g = R·B_pol/B_φ  (units: m)."""
    Xc = cycle_RZ(phi)
    g0 = Xc  # Xc gives the cycle tangent when dividing by R·B_phi/R_B_phi^ax
    # Actually g = dX_c/dphi + A·(X - X_c)
    dXc = np.array([
        -iota * Rell * np.sin(iota * phi + phi0_cycle),
         iota * Zell * np.cos(iota * phi + phi0_cycle),
    ])
    A = _A_matrix(phi)
    return dXc + A @ (X_pol - Xc)

# Verify: g on the cycle equals dX_c/dphi
phi_test = 0.7
Xc_test = cycle_RZ(phi_test)
g_on_cycle = field_g_pol(phi_test, Xc_test)
dXc_expected = np.array([
    -iota * Rell * np.sin(iota * phi_test + phi0_cycle),
     iota * Zell * np.cos(iota * phi_test + phi0_cycle),
])
print("g sur le cycle  :", g_on_cycle)
print("dX_c/dφ     :", dXc_expected)
print("Correspondance :", np.allclose(g_on_cycle, dXc_expected))
g sur le cycle  : [-0.02312218  0.16215018]
dX_c/dφ     : [-0.02312218  0.16215018]
Correspondance : True

2. Encapsulation comme fonction de champ pyna#

pyna attend des fonctions de champ de signature field_func(rzphi) -> (dR/dl, dZ/dl, dφ/dl), c’est-à-dire paramétrées par la longueur d’arc. Nous convertissons depuis la paramétrisation en φ avec \(d\varphi/dl = B_\varphi / (R |B|)\).

[3]:
def pyna_field(rzphi):
    """pyna-compatible field function: rzphi=(R,Z,φ) ?(dR/dl, dZ/dl, dφ/dl)."""
    R, Z, phi = rzphi[0], rzphi[1], rzphi[2]
    X_pol = np.array([R, Z])
    g = field_g_pol(phi, X_pol)          # (dR/dφ, dZ/dφ)
    Bp = BPhi(phi, X_pol)                # B_φ at (R, Z)
    # dφ/dl = B_φ / (R · |B|),  |B|² = B_R² + B_Z² + B_φ²
    # B_R = g[0] · B_φ / R,  B_Z = g[1] · B_φ / R
    # So  |B|² = B_φ² · (g²/R² + 1)  ? dphi/dl = 1/sqrt(R² + |g|²)
    dphi_dl = 1.0 / np.sqrt(R**2 + np.dot(g, g))
    dR_dl = g[0] * dphi_dl
    dZ_dl = g[1] * dphi_dl
    return np.array([dR_dl, dZ_dl, dphi_dl])

# Quick sanity: integrate the cycle once and check it closes
def rhs_phi(phi, XZ):
    f = pyna_field(np.array([XZ[0], XZ[1], phi]))
    dphi_dl = f[2]
    return np.array([f[0] / dphi_dl, f[1] / dphi_dl])  # dX/dphi

X0 = cycle_RZ(0.0)
sol = solve_ivp(rhs_phi, [0, 2 * m * np.pi], X0, rtol=1e-10, atol=1e-12, max_step=0.01)
X_end = sol.y[:, -1]
print(f"Début du cycle : {X0}")
print(f"Fin du cycle  : {X_end}")
print(f"Erreur de fermeture : {np.linalg.norm(X_end - X0):.2e}  (devrait être < 1e-6)")
Début du cycle : [1.3 0. ]
Fin du cycle  : [1.30000000e+00 3.20923843e-17]
Erreur de fermeture : 4.45e-16  (devrait être < 1e-6)

3. Calcul de la matrice de monodromie avec pyna.topo.evolve_DPm_along_cycle#

La fonction evolve_DPm_along_cycle intègre deux équations couplées le long de l’orbite :

Phase 1 - équation variationnelle pour DX_pol :

\[\frac{d\,\mathtt{DX\_pol}}{d\varphi} = A(\varphi)\cdot \mathtt{DX\_pol}, \quad \mathtt{DX\_pol}(\varphi_0) = I\]

Après une période complète (\(\varphi_0 \to \varphi_0 + 2m\pi\)), nous obtenons \(\mathtt{DPm} = \mathtt{DX\_pol}(\varphi_{\rm end})\) : la matrice de monodromie.

Phase 2 - équation de commutateur pour l’évolution de DPm :

\[\frac{d\,\mathtt{DPm}}{d\varphi} = A\,\mathtt{DPm} - \mathtt{DPm}\,A, \quad \mathtt{DPm}(\varphi_0) = \mathtt{DX\_pol}(\varphi_{\rm end})\]

La condition initiale n’est pas l’identité : c’est la matrice de monodromie calculée en Phase 1. Cela garantit que DPm(φ) suit l’apparence qu’aurait le jacobien de la carte sur période complète si la période commençait à φ plutôt qu’à φ₀.

[4]:
from pyna.topo.monodromy import evolve_DPm_along_cycle
from pyna.topo.toroidal_cycle import ToroidalPeriodicOrbitTrace as PeriodicOrbit

# Build a PeriodicOrbit object that pyna needs
rzphi0 = np.array([cycle_RZ(0.0)[0], cycle_RZ(0.0)[1], 0.0])
orbit = PeriodicOrbit(
    rzphi0=rzphi0,
    period_m=m,
    trajectory=np.zeros((1, 3)),  # placeholder; evolve_DPm_along_cycle re-integrates
    DPm=np.eye(2),                # placeholder
)

# Run monodromy computation
mono = evolve_DPm_along_cycle(
    field_func=pyna_field,
    orbit=orbit,
    dt_output=2 * np.pi / 200,  # ~200 output points per turn
    rtol=1e-10, atol=1e-12,
)

print("DPm (monodromy matrix):")
print(mono.DPm)
print()
print("Valeurs propres :", mono.eigenvalues)
print("Indice de stabilité (Tr/2) :", mono.stability_index)
print("Résidu de Greene         :", mono.Greene_residue)
DPm (monodromy matrix):
[[ 1.02006674 -0.07328026]
 [-0.55316615  1.02006674]]

Valeurs propres : [0.81873081 1.22140267]
Indice de stabilité (Tr/2) : 1.0200667408183737
Résidu de Greene         : -0.010033370409186837
[5]:
# ── Analytic verification ────────────────────────────────────────────────────
#
# The analytic DPm at phi=0 devrait être V(2mπ) · diag(λ_u, λ_s) · V^{-1}(0)
# where V(φ) is the eigenvector matrix.

V_end = _eigvec_matrix(2 * m * np.pi)
V_start = _eigvec_matrix(0.0)
DPm_analytic = V_end @ np.diag([lam_u, lam_s]) @ np.linalg.inv(V_start)

print("DPm analytique :")
print(DPm_analytic)
print()
print("DPm depuis pyna :")
print(mono.DPm)
print()
print("Erreur maximale:", np.max(np.abs(mono.DPm - DPm_analytic)))
DPm analytique :
[[ 1.02006676 -0.07328031]
 [-0.55316612  1.02006676]]

DPm depuis pyna :
[[ 1.02006674 -0.07328026]
 [-0.55316615  1.02006674]]

Erreur maximale: 5.4677180783002655e-08

4. DX_pol le long de l’orbite et condition initiale de DPm#

Ici, nous visualisons DX_pol(φ), la matrice de transition d’état à chaque φ, et expliquons pourquoi l’équation DPm de Phase 2 part de DX_pol(φ_end) plutôt que de I.

Sens géométrique : DPm(φ) est la monodromie de l’orbite “décalée” qui commence à φ au lieu de φ₀. Comme la carte sur période complète change lorsque l’on décale la section de Poincaré, DPm(φ) != DX_pol(φ_end) en général : elle évolue via l’équation de commutateur, et sa valeur initiale doit être égale à DX_pol(φ_end) pour rester cohérente avec la définition.

[6]:
phi_arr = mono.phi_arr
fig, axes = plt.subplots(2, 2, figsize=(12, 8), sharex=True)
fig.suptitle("DX_pol(φ) composantes le long du cycle X m=3", fontsize=14)
labels = [["DX_pol[0,0]", "DX_pol[0,1]"], ["DX_pol[1,0]", "DX_pol[1,1]"]]

for i in range(2):
    for j in range(2):
        axes[i, j].plot(phi_arr / np.pi, mono.DX_pol_arr[:, i, j], color='steelblue')
        axes[i, j].set_ylabel(labels[i][j])
        axes[i, j].axvline(0, color='k', lw=0.5)
        axes[i, j].axvline(2 * m, color='gray', lw=0.5, linestyle='--', label='φ_end')
        # Mark the initial value of DPm (= DX_pol at phi_end)
        axes[i, j].scatter([2 * m], [mono.DX_pol_arr[-1, i, j]],
                            color='red', zorder=5, label='CI DPm')

axes[0, 0].legend(fontsize=9)
for ax in axes[1]:
    ax.set_xlabel("φ / π")
plt.tight_layout()
plt.savefig("DX_pol_components.png", dpi=120)
plt.show()

print("DX_pol(φ_end) = DPm_init (CI Phase 2) :")
print(mono.DX_pol_arr[-1])
print()
print("DPm (résultat Phase 1 = monodromie) :")
print(mono.DPm)
print()
print("Elles sont identiques :", np.allclose(mono.DX_pol_arr[-1], mono.DPm))
DX_pol(φ_end) = DPm_init (CI Phase 2) :
[[ 1.02006674 -0.07328026]
 [-0.55316615  1.02006674]]

DPm (résultat Phase 1 = monodromie) :
[[ 1.02006674 -0.07328026]
 [-0.55316615  1.02006674]]

Elles sont identiques : True

5. Déplacement orbital linéaire sous un champ de perturbation#

Nous appliquons maintenant une petite perturbation δB et calculons le déplacement linéaire du cycle X avec orbit_shift_under_perturbation.

Le déplacement δX(φ) satisfait :

\[\frac{d\,\delta X}{d\varphi} = A(\varphi)\,\delta X + \delta b_{\rm pol}(\varphi)\]

avec la condition aux limites périodique δX(φ₀ + 2mπ) = δX(φ₀), ce qui donne :

\[\delta X(\varphi_0) = (\mathtt{DPm} - I)^{-1}\,(-\delta X_{\rm particular}(\varphi_{\rm end}))\]

Nous utilisons une perturbation analytique simple : un déplacement vertical uniforme δB_Z = ε.

[7]:
from pyna.topo.monodromy import orbit_shift_under_perturbation

epsilon = 0.01  # perturbation amplitude

def delta_field(rzphi):
    """Perturbation: uniform δB_Z = ε (arc-length parameterised)."""
    R, Z, phi = rzphi[0], rzphi[1], rzphi[2]
    X_pol = np.array([R, Z])
    Bp = BPhi(phi, X_pol)
    # dφ/dl from unperturbed field
    g = field_g_pol(phi, X_pol)
    dphi_dl = 1.0 / np.sqrt(R**2 + np.dot(g, g))
    # δB_Z = ε ?δ(dZ/dl) = ε · |B| / |B| = ε / |B| · |B| = ε dphi_dl · R / Bp · Bp = ...
    # Simple: δdZ/dl = ε · dphi_dl / dphi_dl = ε (unit B)
    return np.array([0.0, epsilon * dphi_dl, 0.0])

delta_X = orbit_shift_under_perturbation(
    field_func=pyna_field,
    delta_field_func=delta_field,
    orbit=orbit,
    monodromy_analysis=mono,
)

print(f"Déplacement orbital à φ=0 : δR={delta_X[0, 0]:.6f} m,  δZ={delta_X[0, 1]:.6f} m")
print(f"Contrôle de périodicité : |δX(end) - δX(start)| = "
      f"{np.linalg.norm(delta_X[-1] - delta_X[0]):.2e}  (devrait être < 1e-6)")
Déplacement orbital à φ=0 : δR=-0.029622 m,  δZ=0.000000 m
Contrôle de périodicité : |δX(end) - δX(start)| = 1.36e-15  (devrait être < 1e-6)
[8]:
# ── Visualise the orbit shift in 3D ─────────────────────────────────────────
fig = plt.figure(figsize=(10, 7))
ax = fig.add_subplot(111, projection='3d')

Rc = mono.trajectory[:, 0]
Zc = mono.trajectory[:, 1]
phi_plot = mono.phi_arr

# Unperturbed cycle (xyz)
ax.plot(Rc * np.cos(phi_plot), Rc * np.sin(phi_plot), Zc,
        color='black', linewidth=2, label='Cycle X non perturbé')

# Cycle décalé
R_shifted = Rc + delta_X[:, 0]
Z_shifted = Zc + delta_X[:, 1]
ax.plot(R_shifted * np.cos(phi_plot), R_shifted * np.sin(phi_plot), Z_shifted,
        color='tomato', linewidth=2, linestyle='--', label=f'Cycle décalé (ε={epsilon})')

ax.set_xlabel('x [m]')
ax.set_ylabel('y [m]')
ax.set_zlabel('Z [m]')
ax.legend()
ax.set_title('Déplacement orbital linéaire sous perturbation uniforme δB_Z')
plt.tight_layout()
plt.savefig("orbit_shift_3d.png", dpi=120)
plt.show()

6. Points clés#

Concept

Symbole

API pyna

Matrice variationnelle le long de l’orbite

DX_pol(φ)

mono.DX_pol_arr, mono.DX_pol_at(φ)

Jacobien de la carte de Poincaré sur période complète

DPm

mono.DPm

Condition initiale de Phase 2

DPm(φ₀) = DX_pol(φ_end)

automatique dans evolve_DPm_along_cycle

Valeurs propres de DPm

λ_u, λ_s

mono.eigenvalues

Indice de stabilité

Tr(DPm)/2

mono.stability_index

Résidu de Greene

(2 - Tr(DPm))/4

mono.Greene_residue

Déplacement orbital linéaire

δX(φ)

orbit_shift_under_perturbation(...)

Pourquoi ``DX_pol`` et non ``J`` ?
Le suffixe _pol indique clairement que cette matrice vit dans le plan poloïdal cylindrique (R, Z), et non dans l’espace cartésien. Le nom de variable porte immédiatement le sens physique.
Pourquoi ``DPm`` et non ``M`` ?
DPm = Derivative of Poincaré map (période à m tours). Une seule lettre M ne donne aucun indice sur cette structure riche.