alphastell 0.1.0

Rust CAD generator for stellarator fusion reactors: VMEC equilibria to STEP geometry for in-vessel layers and modular coils.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
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
209
210
211
212
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
298
299
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
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
//! VMEC ファイル (wout_*.nc) を読み込んで磁束面の (R, Z) を評価するモジュール。
//!
//! # このモジュールが扱う世界
//!
//! **ステラレータ** は核融合炉の一種で、ねじれたドーナツ (トーラス) 型の容器の中に
//! 強力な磁場で**プラズマ**(電離した高温ガス)を閉じ込めます。核融合反応を起こすには
//! プラズマを温度 1 億度、圧力十数気圧まで持ち上げる必要があり、物質の壁で閉じ込める
//! ことはできないので、**磁場の籠** で宙に浮かせておきます。
//!
//! プラズマの形を知らないと炉の設計 (ブランケットの厚み、コイルの位置、etc) が
//! できません。そこで **VMEC** というシミュレータが登場します。VMEC は
//! 「この形のコイルを配置したら、プラズマはこうなります」という**平衡計算**を行い、
//! 結果を `wout_*.nc` という netCDF ファイルに書き出します。
//!
//! # プラズマ座標 (s, θ, φ) とは?
//!
//! ドーナツ型のプラズマ内の点を指定するのに、直交座標 (x, y, z) ではなく
//! **磁束座標** (s, θ, φ) を使います。
//!
//! - `s` : 「プラズマの中心 (磁気軸) からどれくらい外側か」を 0〜1 で表す値。
//!         s=0 が中心、s=1 が **LCFS** (Last Closed Flux Surface = プラズマの最外縁)。
//!         水に浮かんだ玉ねぎを想像して、皮の層番号を 0 (芯) 〜 1 (外皮) で表すのに似ている。
//! - `θ` (theta): 玉ねぎの**断面**をぐるっと一周する角度 (0〜2π)。「上下左右のどこ」か。
//! - `φ` (phi): トーラスの**周**をぐるっと一周する角度 (0〜2π)。「ドーナツを上から見て
//!               時計の何時の位置か」に対応。
//!
//! 本 repo の `wout_vmec.nc` は **4 周期対称** (nfp=4) なステラレータで、
//! φ を 90° ずつ進めると同じ形が出てきます。
//!
//! # なぜ Fourier 級数?
//!
//! プラズマの形 `R(θ, φ)`, `Z(θ, φ)` は複雑な 2 次元の波です。VMEC はこれを
//! **サイン・コサインの足し算** (= Fourier 級数) で表現してファイルに保存します。
//! 高校で習う三角関数の足し算だけで、複雑な曲面がコンパクトに書ける、というのが
//! Fourier 級数の強みです。
//!
//! ```text
//!   R(θ, φ) = Σ rmnc[k] × cos(m[k] × θ − n[k] × φ)
//!   Z(θ, φ) = Σ zmns[k] × sin(m[k] × θ − n[k] × φ)
//! ```
//!
//! 係数 `rmnc`, `zmns` と波の周期を決める整数 `m`, `n` を netCDF から読むのが
//! このモジュールの仕事です。
//!
//! # このモジュールの API (全部 VmecData のメソッド)
//!
//! 1. [`VmecData::load`] — netCDF ファイルを開いて [`VmecData`] を作る
//! 2. [`VmecData::index_rz`] — s グリッド上の離散点 `s_grid[index_s]` で (R, Z) を計算
//! 3. [`VmecData::interpolate_rz`] — 任意の s で (R, Z) を計算 (Fourier 係数を s 方向にスプライン)
//!
//! vessel / first_wall は `interpolate_rz(s, θ, φ)` を (θ, φ) 走査しながら呼べばよい。
//! 内部ヘルパーとして `eval_rz(r_coeff, z_coeff, θ, φ)` (private) が Fourier 和だけを担当する。

use crate::Result;
use netcdf3::FileReader;
use std::f64::consts::TAU;
use std::path::Path;
use std::sync::OnceLock;

// ================================================================
// VmecData — 必要な変数だけ抽出したプラズマデータ
// ================================================================

/// VMEC が出した `wout_*.nc` ファイルから、プラズマ形状を評価するのに必要な
/// **最小セット**だけを抜き出して保持する構造体。
///
/// # フィールドの意味
///
/// - `s_grid`:  規格化磁束座標 s の離散グリッド (0, 1/(ns-1), 2/(ns-1), ..., 1.0)。
///              長さ `ns` (例: 201 点)。
/// - `rmnc`:    R の Fourier 係数。`rmnc[i][k]` = 「s_grid[i] の磁束面における
///              k 番目のモードの振幅」。外側の Vec の長さは ns、内側は mnmax。
/// - `zmns`:    Z の Fourier 係数。構造は rmnc と同じ。
/// - `mode_poloidal`: 各モードの **poloidal モード数 m** (断面方向の波が何周期か)。
///                    VMEC ファイル内の名前は `xm`。
/// - `mode_toroidal`: 各モードの **toroidal モード数 n** (周方向の波が何周期か)。
///                    VMEC ファイル内の名前は `xn`。
///                    `(mode_poloidal, mode_toroidal)` の組 = `(m, n)` で 1 つの波。
///
/// 例: `mode_poloidal=[0, 1, 0, 1, ...]`, `mode_toroidal=[0, 0, 4, 4, ...]`, `mnmax=179` 個。
pub struct VmecData {
	/// 規格化磁束座標 s の配列 (長さ ns)
	pub s_grid: Vec<f64>,
	/// R の Fourier 係数 (rmnc[s 軸 index][mode 番号])
	pub rmnc: Vec<Vec<f64>>,
	/// Z の Fourier 係数 (zmns[s 軸 index][mode 番号])
	pub zmns: Vec<Vec<f64>>,
	/// poloidal モード数 m (長さ mnmax)。ファイル上の名前は `xm`。
	pub mode_poloidal: Vec<f64>,
	/// toroidal モード数 n (長さ mnmax)。ファイル上の名前は `xn`。
	pub mode_toroidal: Vec<f64>,
	/// `interpolate_rz` が初回に構築して以降使い回すスプライン群。(r_splines, z_splines) で
	/// 各 Vec の長さは mnmax。[`OnceLock`] により再計算されない (計算結果は VmecData に
	/// 紐づく遅延フィールド)。
	splines: OnceLock<(Vec<CubicSpline>, Vec<CubicSpline>)>,
}

pub struct RZ {
	pub r: f64,
	pub z: f64,
	pub dr_dtheta: f64,
	pub dr_dphi: f64,
	pub dz_dtheta: f64,
	pub dz_dphi: f64,
}

/// [`VmecData::mesh`] の `offset != 0` 時に使う法線の定義。
#[derive(Clone, Copy, Debug)]
#[allow(dead_code)] // バリアント列挙、呼び出し側は後続 PR で追加予定
pub enum NormalKind {
	/// **parastell 互換**。constant-φ 断面内の 2D 法線。
	/// (R, Z) 断面内で θ 接線 `(dR/dθ, dZ/dθ)` を 90° 回した `(dZ/dθ, -dR/dθ)` を、
	/// φ で Z 軸まわりに回転して 3D に埋め込む。φ 方向の形状変化は無視される。
	Planar,
	/// 真の 3D 曲面法線 `∂p/∂φ × ∂p/∂θ` を外向きに正規化したもの。
	/// φ 方向の形状変化も反映するため、ヘリカル成分が強い領域で Planar と向きがずれる。
	Surface,
}

impl VmecData {
	// --------------------------------------------------------------
	// load — netCDF から VmecData を作る (コンストラクタ)
	// --------------------------------------------------------------

	/// netCDF ファイル (wout_*.nc) を開いて、このモジュールで使う変数だけ読み出す。
	///
	/// netCDF は **科学技術計算でよく使われるバイナリ形式**で、中身は「変数名と
	/// 多次元配列のペア」の集まりです。HDF5 という別規格の上に乗っているので、
	/// 読むには libnetcdf と libhdf5 の両方が必要で、Rust 側では `netcdf` クレート
	/// がその FFI をしてくれます。
	///
	/// VMEC の wout ファイルには何十もの変数が入っていますが、今回プラズマ表面を
	/// 描くのに必要なのは `rmnc`, `zmns`, `xm`, `xn` の 4 つだけです
	/// (Rust 側の名前はそれぞれ `rmnc`, `zmns`, `mode_poloidal`, `mode_toroidal`)。
	pub fn load(path: &Path) -> Result<Self> {
		// netCDF-3 (Classic / 64-bit offset) ファイルを pure-Rust で読む。
		// VMEC の wout は `CDF\x02` (64-bit offset) なので HDF5 は不要。
		//
		// netcdf3 の ReadError は内部に Rc を持つので !Send。エラーメッセージを
		// 文字列化してから Box<dyn Error> に載せる。
		let mut file = FileReader::open(path)
			.map_err(|e| format!("open {}: {:?}", path.display(), e))?;

		// rmnc の shape を DataSet から取る (ns × mnmax)
		let shape: Vec<usize> = file
			.data_set()
			.get_var("rmnc")
			.ok_or("missing rmnc")?
			.get_dims()
			.iter()
			.map(|d| d.size())
			.collect();
		let ns = shape[0]; // 放射方向 (s 軸) のグリッド点数
		let mnmax = shape[1]; // Fourier mode の個数

		// 値を実際に読む。read_var は DataVector を返すので f64 スライスを取り出す。
		let read_f64 = |f: &mut FileReader, name: &str| -> Result<Vec<f64>> {
			f.read_var(name)
				.map_err(|e| format!("read {}: {:?}", name, e))?
				.get_f64_into()
				.map_err(|_| format!("{} is not f64", name).into())
		};
		let rmnc_flat = read_f64(&mut file, "rmnc")?;
		let zmns_flat = read_f64(&mut file, "zmns")?;
		let mode_poloidal = read_f64(&mut file, "xm")?;
		let mode_toroidal = read_f64(&mut file, "xn")?;

		// netCDF から来たのは 1 次元に潰れた配列 (長さ ns*mnmax)。
		// これを `[ns][mnmax]` の入れ子 Vec に作り直す方が後段のコードで扱いやすい。
		let rmnc: Vec<Vec<f64>> = (0..ns)
			.map(|i| rmnc_flat[i * mnmax..(i + 1) * mnmax].to_vec())
			.collect();
		let zmns: Vec<Vec<f64>> = (0..ns)
			.map(|i| zmns_flat[i * mnmax..(i + 1) * mnmax].to_vec())
			.collect();

		// VMEC の慣例: s の離散点は 0, 1/(ns-1), 2/(ns-1), ..., 1.0 の一様分布。
		// ファイルに書いてないので自分で構築する。
		let s_grid: Vec<f64> = (0..ns).map(|i| i as f64 / (ns - 1) as f64).collect();

		Ok(Self {
			s_grid,
			rmnc,
			zmns,
			mode_poloidal,
			mode_toroidal,
			splines: OnceLock::new(),
		})
	}

	// --------------------------------------------------------------
	// eval_rz — s グリッド点ちょうどで Fourier 級数の和を計算
	// --------------------------------------------------------------

	/// s グリッド上の離散点 `s_grid[index_s]` における (R, Z) を Fourier 級数の和で計算する。
	///
	/// ```text
	///   R(θ, φ) = Σ_k  rmnc[index_s][k] · cos(mode_poloidal[k] · θ − mode_toroidal[k] · φ)
	///   Z(θ, φ) = Σ_k  zmns[index_s][k] · sin(mode_poloidal[k] · θ − mode_toroidal[k] · φ)
	/// ```
	///
	/// **スプライン不要**。任意 s で評価したい場合は [`Self::spline_rz`] を使うこと。
	///
	/// # なぜ R は cos で Z は sin ?
	///
	/// VMEC の wout ファイルは「**ステラレータ対称**」な平衡を前提にしていて
	/// (`lasym=0`)、上下 (Z の向き) を反転すると元と同じ形になる。この対称性のもと
	/// では R は偶関数、Z は奇関数に分解でき、それぞれ cos 成分 (rmnc)、sin 成分
	/// (zmns) だけで書ける。左右非対称なプラズマを扱う場合は rmns, zmnc が足されるが、
	/// 本 example の wout は対称なので不要。
	///
	/// # なぜ `m·θ − n·φ` という 1 つの角度にまとめるのか
	///
	/// ステラレータの磁力線はトーラスの周りをぐるぐる**らせん状**に巻く。θ と φ を
	/// 独立に変えるのではなく「m 回 θ 方向に進み、n 回 φ 方向に進む」という**らせん
	/// 角** `m·θ − n·φ` を考えると、個々のモードがそのまま磁力線の 1 種類の
	/// ヘリカル構造に対応するので自然。
	///
	/// # 各モードの物理的意味 (本 repo の `wout_vmec.nc` で実測した LCFS 例)
	///
	/// ```text
	/// (m, n)    振幅[m]    意味
	/// (0, 0)   +11.06      ★ R の定常成分 = トーラスの major radius そのもの
	///                        (Z 軸からトーラス中心軸まわりの「平均距離」)
	/// (1, 0)    +1.89      断面が θ 方向に楕円化 (上下方向に引き伸ばす形状補正)
	/// (0, 4)    +1.53      φ 方向に nfp=4 回のうねり
	///                        (断面が φ に応じて膨らんだり引っ込んだり)
	/// (1, -4)   -1.39      ヘリカルな捻じり (θ と φ のカップリング、
	///                        ステラレータらしさの本体)
	/// (1, +4)   +0.58      副次ヘリカル
	/// ```
	///
	/// - **(m=0, n=0) モード**は定数 cos(0) = 1 なので、`rmnc[(0,0)]` がそのまま R の
	///   平均値、つまり **トーラスの major radius** を意味する。本 wout では約 **11 m**。
	/// - 残りのモードは「円形から歪ませる形状補正」で、ステラレータ特有の bean 型
	///   断面と nfp=4 のヘリカル構造をこの数モードだけで再現できる。
	///
	/// # Shafranov シフト (補足)
	///
	/// 磁気軸 (s=0) と LCFS (s=1) で (0,0) モードの値が微妙に違う:
	///
	/// ```text
	/// s=0 (磁気軸) :  rmnc[(0,0)] = 11.28 m
	/// s=1 (LCFS)   :  rmnc[(0,0)] = 11.06 m
	///      差       :  磁気軸が LCFS 中心より 22 cm **外側 (大 R 側)** にずれている
	/// ```
	///
	/// これは **Shafranov シフト**と呼ばれる現象で、プラズマ圧力によって磁気軸が
	/// 低磁場側 (外側) に押し出される効果。この wout が高 β な定常状態を表している
	/// ことを数値的に確認できる。
	///
	/// # Z 側には定常成分は実質無い
	///
	/// `zmns` は sin 展開なので (m=0, n=0) モードは `sin(0) = 0` で寄与ゼロ。
	/// 上下対称なプラズマなら Z のオフセットは自動的に 0 になる。
	fn eval_rz(&self, r_coeff: &[f64], z_coeff: &[f64], theta: f64, phi: f64) -> RZ {
		let mnmax = self.mode_poloidal.len();
		let mut res = RZ {
			r: 0.0,
			z: 0.0,
			dr_dtheta: 0.0,
			dr_dphi: 0.0,
			dz_dtheta: 0.0,
			dz_dphi: 0.0,
		};
		for k in 0..mnmax {
			// m·θ − n·φ は「その点でのらせん位相」
			let angle = self.mode_poloidal[k] * theta - self.mode_toroidal[k] * phi;
			res.r += r_coeff[k] * angle.cos();
			res.z += z_coeff[k] * angle.sin();
			// ∂(cos α)/∂x = (-sin α)·(∂α/∂x), ∂(sin α)/∂x = (cos α)·(∂α/∂x)
			let dangle_dtheta = self.mode_poloidal[k];
			let dangle_dphi = -self.mode_toroidal[k];
			res.dr_dtheta += r_coeff[k] * (-angle.sin()) * dangle_dtheta;
			res.dr_dphi += r_coeff[k] * (-angle.sin()) * dangle_dphi;
			res.dz_dtheta += z_coeff[k] * angle.cos() * dangle_dtheta;
			res.dz_dphi += z_coeff[k] * angle.cos() * dangle_dphi;
		}
		res
	}

	#[allow(dead_code)] // API として公開; 現在は tests からのみ使用
	pub fn index_rz(&self, index_s: usize, theta: f64, phi: f64) -> RZ {
		self.eval_rz(&self.rmnc[index_s], &self.zmns[index_s], theta, phi)
	}

	/// (θ, φ) を等分した格子で磁束面 (または `offset` だけ離れた平行面) の 3D 点を返す。
	///
	/// - 角度範囲は `[0, 2π)` の半開区間。θ=0 と θ=2π は同一点なので終点は含めない。
	/// - `offset` の単位は VMEC ネイティブの **m**。スケール変換は呼び出し側で行う。
	/// - 戻り値は `result[phi_idx][theta_idx]` で `div_phi × div_theta` の行列。
	/// - `offset == 0.0` なら法線計算はスキップする。
	#[allow(dead_code)] // API として公開; 呼び出し側は後続 PR で導入予定
	pub fn mesh(
		&self,
		div_theta: usize,
		div_phi: usize,
		s: f64,
		offset: f64,
		normal: NormalKind,
	) -> Vec<Vec<[f64; 3]>> {
		fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
			[
				a[1] * b[2] - a[2] * b[1],
				a[2] * b[0] - a[0] * b[2],
				a[0] * b[1] - a[1] * b[0],
			]
		}

		let mut grid: Vec<Vec<[f64; 3]>> = Vec::with_capacity(div_phi);
		for i in 0..div_phi {
			let phi = TAU * (i as f64) / (div_phi as f64);
			let (sp, cp) = phi.sin_cos();
			let mut row: Vec<[f64; 3]> = Vec::with_capacity(div_theta);
			for j in 0..div_theta {
				let theta = TAU * (j as f64) / (div_theta as f64);
				let rz = self.interpolate_rz(s, theta, phi);
				// まず φ=0 の断面で点と法線を組み立てる (x=R, y=0, z=Z の 2D ライクな座標系)。
				// ∂R/∂φ, ∂Z/∂φ はこの座標系では「隣の断面がどう変わるか」の成分として残る。
				let mut p = [rz.r, 0.0, rz.z];
				if offset != 0.0 {
					let n = {
						let [t_theta, t_phi] = match normal {
							NormalKind::Planar => {
								// θ 接線 (Surface と共通) と、constant-φ 面の法線 (φ=0 で y_hat)。
								// cross(y_hat, t_θ) が parastell `_normals()` と同じ断面内 2D 外向き法線。
								[
									[rz.dr_dtheta, 0.0, rz.dz_dtheta],
									[0.0, 1.0, 0.0],
								]
							}
							NormalKind::Surface => {
								// φ=0 での接線: ∂p/∂θ = (dR/dθ, 0, dZ/dθ), ∂p/∂φ = (dR/dφ, R, dZ/dφ)
								// cross(t_φ, t_θ) が外向き 3D 曲面法線 (t_θ × t_φ は内向きになる)。
								[
									[rz.dr_dtheta, 0.0, rz.dz_dtheta],
									[rz.dr_dphi, rz.r, rz.dz_dphi],
								]
							}
						};
						cross(t_phi, t_theta)
					};
					let inv_len = 1.0 / (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
					p[0] += offset * n[0] * inv_len;
					p[1] += offset * n[1] * inv_len;
					p[2] += offset * n[2] * inv_len;
				}
				// 最後に Z 軸まわり φ 回転で実際の (x, y, z) に持ち上げる。
				// (x, y, z) → (x cosφ − y sinφ, x sinφ + y cosφ, z)
				row.push([p[0] * cp - p[1] * sp, p[0] * sp + p[1] * cp, p[2]]);
			}
			grid.push(row);
		}
		grid
	}

	pub fn interpolate_rz(&self, s: f64, theta: f64, phi: f64) -> RZ {
		// 各モードごとの s 軸方向スプラインは (s, θ, φ) に依存しないので、VmecData の
		// ライフタイムで 1 回だけ構築してメモ化する。初回呼び出しで lazy 初期化。
		let (r_splines, z_splines) = self.splines.get_or_init(|| {
			let mnmax = self.mode_poloidal.len();
			let mut r_splines = Vec::with_capacity(mnmax);
			let mut z_splines = Vec::with_capacity(mnmax);
			for k in 0..mnmax {
				let r_col: Vec<f64> = self.rmnc.iter().map(|row| row[k]).collect();
				let z_col: Vec<f64> = self.zmns.iter().map(|row| row[k]).collect();
				// デフォルトは Natural。NotAKnot (scipy 互換) も実装済みだが、現状の
				// cadrum `Solid::shell` (3D 表面 offset) と組み合わせると s=1.08 の
				// 外挿でアグレッシブに延びた波 (max ΔR ≈ 17cm) が shell 操作で
				// 増幅されて first_wall 体積が parastell 比 +81% に膨らむ。Natural は
				// 外挿が線形に近く、現 shell 実装との相性がよい。
				// (cadrum を 2D poloidal offset に切り替える日が来たら NotAKnot 推奨)
				r_splines.push(CubicSpline::new(&self.s_grid, &r_col, BoundaryCondition::Natural));
				z_splines.push(CubicSpline::new(&self.s_grid, &z_col, BoundaryCondition::Natural));
			}
			(r_splines, z_splines)
		});
		// 以降は eval だけでよい (スプライン構築コストがかからない)
		let mnmax = self.mode_poloidal.len();
		let r_at_s: Vec<f64> = (0..mnmax).map(|k| r_splines[k].eval(s)).collect();
		let z_at_s: Vec<f64> = (0..mnmax).map(|k| z_splines[k].eval(s)).collect();
		self.eval_rz(&r_at_s, &z_at_s, theta, phi)
	}
}

// ================================================================
// CubicSpline — 3 次スプライン (境界条件を選べる内部 helper)
// ================================================================

/// 境界条件 (両端で何を固定するか) の指定。
///
/// ## Natural (自由端)
///
/// 両端で **2 階微分 = 0**。「両端で曲がりが最小」になるように繋ぐ。端点のふるまい
/// がおだやかで外挿が暴れにくい一方、元データに対応する物理的根拠は弱い。
///
/// ## NotAKnot (not-a-knot / ノットなし)
///
/// **scipy `CubicSpline` のデフォルト**。最初の 2 区間と最後の 2 区間で **3 階微分**
/// が連続、すなわち「最初の 2 区間を 1 本の 3 次式でつなぐ、末尾も同様」という条件。
/// 内側にも端点にも余計な制約をかけない分、元データに素直に追従する。
/// parastell (scipy 依存) との一致を取りたいときはこちら。
///
/// # 両者の違いが出る場所
///
/// - データ範囲内 (補間) はどちらもほぼ一致 (10⁻⁴ オーダ)
/// - データ範囲外 (外挿) で差が出る:
///   - Natural: 端で曲率 0 に引き込まれるので直線的に延びる
///   - NotAKnot: 最終区間の 3 次式をそのまま延長する
///
/// VMEC の s=1.08 のような**外挿**を使うなら NotAKnot の方が scipy と一致する。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoundaryCondition {
	Natural,
	NotAKnot,
}

/// スプライン補間のための内部構造体。**このモジュール外には公開しない**。
///
/// # スプライン補間って何?
///
/// 離散的な点 (x₁, y₁), (x₂, y₂), ..., (xₙ, yₙ) が手元にあって、点と点の
/// **間の値** を滑らかに埋めたいときに使う手法。1 次関数で繋ぐと折れ線になって
/// しまうので、区間ごとに **3 次多項式** で繋いでなめらか (2 階微分まで連続) に
/// する、というのが「3 次スプライン」。
///
/// ## どう滑らかにするか
///
/// 区間 [xᵢ, xᵢ₊₁] の多項式を `y(x) = aᵢ + bᵢ(x-xᵢ) + cᵢ(x-xᵢ)² + dᵢ(x-xᵢ)³`
/// と置くと、各区間に 4 つの係数で合計 4(n-1) 個の未知数。
/// 繋ぎ目での値・1 階微分・2 階微分の連続性と、両端での境界条件で方程式を立て、
/// Thomas algorithm (三重対角連立方程式専用の高速解法) で O(n) で解く。
///
/// 境界条件は [`BoundaryCondition`] で切り替え。
///
/// ## なぜこれを使うのか
///
/// VMEC の Fourier 係数は s 軸上に 201 点だけ離散的に格納されている。プラズマ境界
/// (s=1.0) の少し外 (s=1.08 など) を評価したいとき、離散データの間を補間するために
/// スプラインが必要。
struct CubicSpline {
	/// x 軸上のサンプル点 (昇順)
	xs: Vec<f64>,
	/// 各区間の 3 次多項式係数 (y = a + b·dx + c·dx² + d·dx³)
	a: Vec<f64>,
	b: Vec<f64>,
	c: Vec<f64>,
	d: Vec<f64>,
}

impl CubicSpline {
	/// (xs, ys) のデータと境界条件からスプラインを構築する。
	///
	/// 手順:
	/// 1. 各区間の幅 h[i] を計算
	/// 2. 2 階微分 M[i] を解く三重対角連立方程式を立てる (境界条件で第 1・最終行が変化)
	/// 3. Thomas algorithm で前進消去 → 後退代入
	/// 4. NotAKnot の場合は M[0] と M[n-1] を境界条件式から復元
	/// 5. 解いた M[i] と h[i], y[i] から各区間の 3 次多項式係数 a, b, c, d を作る
	fn new(xs: &[f64], ys: &[f64], bc: BoundaryCondition) -> Self {
		let n = xs.len();
		assert_eq!(ys.len(), n);
		assert!(n >= 2, "スプライン構築には最低 2 点必要");

		// h[i] = xs[i+1] - xs[i]  (各区間の幅)
		let h: Vec<f64> = (0..n - 1).map(|i| xs[i + 1] - xs[i]).collect();

		// M[i] = 2 階微分 (= 2·c[i]) を全ノードで持つ配列
		let mut m = vec![0.0; n];

		// 点が 2 点だけの場合は直線、3 点の場合は NotAKnot も事実上 Natural と同じ扱い
		// になる (BC1 と BC2 が同じ条件に縮退するため)。安全のため n<4 では Natural に
		// フォールバック。
		let effective_bc = if n < 4 {
			BoundaryCondition::Natural
		} else {
			bc
		};

		if n >= 3 {
			// 内部の n-2 個の M (= M[1], M[2], ..., M[n-2]) を解く三重対角系。
			// 内部方程式 (i = 1..n-2, ここでは row index = i-1 = 0..n-3):
			//   h[i-1] * M[i-1] + 2(h[i-1]+h[i]) * M[i] + h[i] * M[i+1]
			//   = 6 * ((y[i+1]-y[i])/h[i] - (y[i]-y[i-1])/h[i-1])
			//
			// 境界条件で行 0 と行 n-3 の係数が書き換えられる。明示的に lower/diag/upper
			// の 3 本の Vec を持ち、Thomas アルゴリズムはこれらを使って前進消去する。
			let k = n - 2; // 内部方程式の本数 = 内部 M の個数
			let mut lower = vec![0.0; k]; // 下三角 (row i の M[i-1] 相当の列)
			let mut diag = vec![0.0; k]; // 対角
			let mut upper = vec![0.0; k]; // 上三角
			let mut rhs = vec![0.0; k];

			// まず内部行の係数を組む (row index r = 0..k-1 ↔ 内部 M index i = r+1)
			for r in 0..k {
				let i = r + 1;
				lower[r] = h[i - 1];
				diag[r] = 2.0 * (h[i - 1] + h[i]);
				upper[r] = h[i];
				rhs[r] = 6.0 * ((ys[i + 1] - ys[i]) / h[i] - (ys[i] - ys[i - 1]) / h[i - 1]);
			}

			match effective_bc {
				BoundaryCondition::Natural => {
					// M[0] = 0 と M[n-1] = 0 を代入するので、lower[0] と upper[k-1] の
					// 項は消える。元々 Thomas の先頭・末尾では使わない値なので実質無操作。
					lower[0] = 0.0;
					upper[k - 1] = 0.0;
				}
				BoundaryCondition::NotAKnot => {
					// BC1: h[1]·M[0] - (h[0]+h[1])·M[1] + h[0]·M[2] = 0
					//      ↔ M[0] = ((h[0]+h[1])·M[1] - h[0]·M[2]) / h[1]
					// これを row 0 (M[1] の内部式) に代入すると:
					//   diag[0] += h[0]·(h[0]+h[1])/h[1]  →  (h[0]+h[1])·(h[0]+2h[1])/h[1]
					//   upper[0] -= h[0]²/h[1]            →  (h[1]² - h[0]²)/h[1]
					//   lower[0] は消える (M[0] を吸収)
					//   rhs[0] は変わらず
					let h0 = h[0];
					let h1 = h[1];
					diag[0] = (h0 + h1) * (h0 + 2.0 * h1) / h1;
					upper[0] = (h1 * h1 - h0 * h0) / h1;
					lower[0] = 0.0;

					// BC2 (末尾側、対称):
					//   M[n-1] = ((h[n-3]+h[n-2])·M[n-2] - h[n-2]·M[n-3]) / h[n-3]
					// これを row k-1 (M[n-2] の内部式) に代入:
					//   lower[k-1] -= h[n-2]²/h[n-3]     →  (h[n-3]² - h[n-2]²)/h[n-3]
					//   diag[k-1]  += h[n-2]·(h[n-3]+h[n-2])/h[n-3]
					//                                   →  (h[n-3]+h[n-2])·(h[n-2]+2h[n-3])/h[n-3]
					//   upper[k-1] は消える (M[n-1] を吸収)
					let ha = h[n - 3];
					let hb = h[n - 2];
					lower[k - 1] = (ha * ha - hb * hb) / ha;
					diag[k - 1] = (ha + hb) * (hb + 2.0 * ha) / ha;
					upper[k - 1] = 0.0;
				}
			}

			// Thomas 前進消去
			for r in 1..k {
				let w = lower[r] / diag[r - 1];
				diag[r] -= w * upper[r - 1];
				rhs[r] -= w * rhs[r - 1];
			}

			// Thomas 後退代入
			let mut m_inner = vec![0.0; k];
			m_inner[k - 1] = rhs[k - 1] / diag[k - 1];
			for r in (0..k - 1).rev() {
				m_inner[r] = (rhs[r] - upper[r] * m_inner[r + 1]) / diag[r];
			}

			// 内部 M を全体配列に反映 (M[1..n-1] = m_inner)
			for r in 0..k {
				m[r + 1] = m_inner[r];
			}

			// 境界の M[0] と M[n-1] を復元 (Natural は 0 のまま、NotAKnot は BC から逆算)
			if effective_bc == BoundaryCondition::NotAKnot {
				m[0] = ((h[0] + h[1]) * m[1] - h[0] * m[2]) / h[1];
				m[n - 1] =
					((h[n - 3] + h[n - 2]) * m[n - 2] - h[n - 2] * m[n - 3]) / h[n - 3];
			}
		}

		// 各区間の 3 次多項式係数を生成
		//   y = a + b·(x - xᵢ) + c·(x - xᵢ)² + d·(x - xᵢ)³
		//   a = yᵢ
		//   b = (yᵢ₊₁ - yᵢ)/hᵢ - hᵢ·(2Mᵢ + Mᵢ₊₁)/6
		//   c = Mᵢ / 2
		//   d = (Mᵢ₊₁ - Mᵢ) / (6·hᵢ)
		let mut a = Vec::with_capacity(n - 1);
		let mut b = Vec::with_capacity(n - 1);
		let mut c = Vec::with_capacity(n - 1);
		let mut d = Vec::with_capacity(n - 1);
		for i in 0..n - 1 {
			let hi = h[i];
			a.push(ys[i]);
			b.push((ys[i + 1] - ys[i]) / hi - hi * (2.0 * m[i] + m[i + 1]) / 6.0);
			c.push(m[i] / 2.0);
			d.push((m[i + 1] - m[i]) / (6.0 * hi));
		}

		CubicSpline {
			xs: xs.to_vec(),
			a,
			b,
			c,
			d,
		}
	}

	/// 指定の x での y 値を計算する。
	///
	/// 範囲外の x が来た場合は、最初または最後の区間の多項式をそのまま延長して
	/// **外挿**する (= extrapolate)。VMEC の wall_s = 1.08 など、LCFS (s=1)
	/// の少し外でも値が欲しい場合に使う。
	fn eval(&self, x: f64) -> f64 {
		let n = self.xs.len();
		// どの区間の多項式を使うか決める
		let idx = if x <= self.xs[0] {
			// x が左端より小さい: 最初の区間の多項式で外挿
			0
		} else if x >= self.xs[n - 1] {
			// x が右端より大きい: 最後の区間の多項式で外挿
			n - 2
		} else {
			// 範囲内: 二分探索で適切な区間を見つける
			match self.xs.binary_search_by(|v| v.partial_cmp(&x).unwrap()) {
				Ok(i) => i.min(n - 2),  // 完全一致
				Err(i) => i - 1,          // 挿入位置 - 1 が含む区間
			}
		};
		// y = a + b·(x-xᵢ) + c·(x-xᵢ)² + d·(x-xᵢ)³
		let dx = x - self.xs[idx];
		self.a[idx] + self.b[idx] * dx + self.c[idx] * dx.powi(2) + self.d[idx] * dx.powi(3)
	}
}

// ================================================================
// ベンチマーク用のテスト
// ================================================================

#[cfg(test)]
mod tests {
	use super::*;
	use std::time::Instant;

	/// phi=0 と phi=2π で (R, Z) および全偏導関数が厳密一致することを確認する。
	///
	/// - 値の一致 → **C⁰ 連続** (seam で飛びがない)
	/// - ∂R/∂θ, ∂Z/∂θ, ∂R/∂φ, ∂Z/∂φ の一致 → **C¹ 微分可能**
	///   (φ 方向の tangent vector 連続性 + θ 方向の tangent が seam 両側で一致)
	///
	/// 前提: xm, xn が整数 (そうでないと cos/sin 位相が 2π で一致しない)。
	/// この 2 条件が満たされていれば B-spline 側が seam を乱す場合の原因は
	/// 入力データではなく cadrum / OCCT の surface construction or subtract 側にある。
	#[test]
	fn interpolate_rz_periodic_and_differentiable_at_phi_seam() {
		let path = Path::new("parastell/examples/wout_vmec.nc");
		if !path.exists() {
			eprintln!("skip: {} not found", path.display());
			return;
		}
		let vmec = VmecData::load(path).expect("load vmec");

		// --- 前提: xm, xn が整数であること ---
		let xn_nonint = vmec
			.mode_toroidal
			.iter()
			.enumerate()
			.find(|&(_, &n)| (n - n.round()).abs() > 1e-10);
		let xm_nonint = vmec
			.mode_poloidal
			.iter()
			.enumerate()
			.find(|&(_, &m)| (m - m.round()).abs() > 1e-10);
		assert!(xn_nonint.is_none(), "xn non-integer at {:?}", xn_nonint);
		assert!(xm_nonint.is_none(), "xm non-integer at {:?}", xm_nonint);

		// --- 値と全偏導関数を (s, θ) の広い範囲で phi=0 vs phi=TAU で比較 ---
		let tol = 1e-9;
		let mut max_diff_r = 0.0f64;
		let mut max_diff_z = 0.0f64;
		let mut max_diff_dr_dtheta = 0.0f64;
		let mut max_diff_dz_dtheta = 0.0f64;
		let mut max_diff_dr_dphi = 0.0f64;
		let mut max_diff_dz_dphi = 0.0f64;
		for &s in &[0.25, 0.5, 1.0, 1.08] {
			// θ は 64 点で sweep (mesh と同じ粒度)
			for j in 0..64 {
				let theta = std::f64::consts::TAU * (j as f64) / 64.0;
				let a = vmec.interpolate_rz(s, theta, 0.0);
				let b = vmec.interpolate_rz(s, theta, std::f64::consts::TAU);
				let d_r = (a.r - b.r).abs();
				let d_z = (a.z - b.z).abs();
				let d_dr_dt = (a.dr_dtheta - b.dr_dtheta).abs();
				let d_dz_dt = (a.dz_dtheta - b.dz_dtheta).abs();
				let d_dr_dp = (a.dr_dphi - b.dr_dphi).abs();
				let d_dz_dp = (a.dz_dphi - b.dz_dphi).abs();
				assert!(d_r < tol, "s={s} θ={theta}: R mismatch {} vs {}", a.r, b.r);
				assert!(d_z < tol, "s={s} θ={theta}: Z mismatch {} vs {}", a.z, b.z);
				assert!(d_dr_dt < tol, "s={s} θ={theta}: ∂R/∂θ mismatch");
				assert!(d_dz_dt < tol, "s={s} θ={theta}: ∂Z/∂θ mismatch");
				assert!(d_dr_dp < tol, "s={s} θ={theta}: ∂R/∂φ mismatch");
				assert!(d_dz_dp < tol, "s={s} θ={theta}: ∂Z/∂φ mismatch");
				max_diff_r = max_diff_r.max(d_r);
				max_diff_z = max_diff_z.max(d_z);
				max_diff_dr_dtheta = max_diff_dr_dtheta.max(d_dr_dt);
				max_diff_dz_dtheta = max_diff_dz_dtheta.max(d_dz_dt);
				max_diff_dr_dphi = max_diff_dr_dphi.max(d_dr_dp);
				max_diff_dz_dphi = max_diff_dz_dphi.max(d_dz_dp);
			}
		}
		eprintln!(
			"seam @ phi=0 vs phi=TAU (across 4×64 = 256 sample points):\n  \
			 max|ΔR|       = {max_diff_r:.3e}\n  \
			 max|ΔZ|       = {max_diff_z:.3e}\n  \
			 max|Δ∂R/∂θ|   = {max_diff_dr_dtheta:.3e}\n  \
			 max|Δ∂Z/∂θ|   = {max_diff_dz_dtheta:.3e}\n  \
			 max|Δ∂R/∂φ|   = {max_diff_dr_dphi:.3e}\n  \
			 max|Δ∂Z/∂φ|   = {max_diff_dz_dphi:.3e}"
		);
	}

	/// mesh() の最終行 (i = div_phi-1) と、もし i = div_phi で計算した場合に期待される
	/// 複製行 (= row 0) との差が、cadrum の phi-direction internal augment が成立する
	/// だけの精度 (Precision::Confusion() ~ 1e-7) を満たすか確認する。
	#[test]
	fn mesh_phi_seam_matches_row0() {
		let path = Path::new("parastell/examples/wout_vmec.nc");
		if !path.exists() {
			eprintln!("skip: {} not found", path.display());
			return;
		}
		let vmec = VmecData::load(path).expect("load vmec");
		let div_theta = 64;
		let div_phi = 240;
		let s = 1.08;
		for (offset, kind) in [(0.0, NormalKind::Planar), (0.05, NormalKind::Planar), (0.05, NormalKind::Surface)] {
			let grid = vmec.mesh(div_theta, div_phi, s, offset, kind);
			// 行 0 (phi=0)
			let row0 = &grid[0];
			// 手計算で phi=TAU の虚構行を再構築 (mesh と同じロジックを phi=TAU で)
			let phi = std::f64::consts::TAU;
			let (sp, cp) = phi.sin_cos();
			let mut virt: Vec<[f64; 3]> = Vec::with_capacity(div_theta);
			for j in 0..div_theta {
				let theta = std::f64::consts::TAU * (j as f64) / (div_theta as f64);
				let rz = vmec.interpolate_rz(s, theta, phi);
				let mut p = [rz.r, 0.0, rz.z];
				if offset != 0.0 {
					let (a, b) = match kind {
						NormalKind::Planar => (
							[rz.dr_dtheta, 0.0, rz.dz_dtheta],
							[0.0_f64, 1.0, 0.0],
						),
						NormalKind::Surface => (
							[rz.dr_dtheta, 0.0, rz.dz_dtheta],
							[rz.dr_dphi, rz.r, rz.dz_dphi],
						),
					};
					let n = [
						b[1] * a[2] - b[2] * a[1],
						b[2] * a[0] - b[0] * a[2],
						b[0] * a[1] - b[1] * a[0],
					];
					let inv_len = 1.0 / (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
					p[0] += offset * n[0] * inv_len;
					p[1] += offset * n[1] * inv_len;
					p[2] += offset * n[2] * inv_len;
				}
				virt.push([p[0] * cp - p[1] * sp, p[0] * sp + p[1] * cp, p[2]]);
			}
			// row0 と virt (phi=2π) の差を集計
			let mut max_diff = 0.0f64;
			for j in 0..div_theta {
				let dx = row0[j][0] - virt[j][0];
				let dy = row0[j][1] - virt[j][1];
				let dz = row0[j][2] - virt[j][2];
				max_diff = max_diff.max((dx * dx + dy * dy + dz * dz).sqrt());
			}
			eprintln!(
				"offset={offset:.3} kind={:?}: max |row0 - virt(phi=TAU)| = {max_diff:.3e}",
				kind
			);
			assert!(max_diff < 1e-6, "seam too large");
		}
	}

	/// 1000 点を interpolate_rz で計算したときの実時間を測って、vessel の現実的なコスト感を掴む。
	/// `cargo test --release -- --nocapture bench_interpolate_rz_1000pts` で測定推奨。
	#[test]
	fn bench_interpolate_rz_1000pts() {
		let path = Path::new("parastell/examples/wout_vmec.nc");
		if !path.exists() {
			eprintln!("skip: {} not found", path.display());
			return;
		}
		let vmec = VmecData::load(path).expect("load vmec");

		const N: usize = 1000;
		let mut checksum = 0.0f64;
		let start = Instant::now();
		for i in 0..N {
			let t = i as f64 / N as f64;
			// s は LCFS 内外を織り交ぜる (0.2〜1.08)、θ・φ は非自明な数列にして
			// コンパイラに定数最適化されないようにする。
			let s = 0.2 + 0.88 * t;
			let theta = 0.037 * i as f64;
			let phi = 0.041 * i as f64;
			let rz = vmec.interpolate_rz(s, theta, phi);
			checksum += rz.r + rz.z;
		}
		let elapsed = start.elapsed();
		eprintln!(
			"interpolate_rz × {}pts: {:?} ({:.2} us/pt), checksum={:.6}",
			N,
			elapsed,
			elapsed.as_secs_f64() * 1e6 / N as f64,
			checksum
		);
	}

	/// グリッド点 `s_grid[i]` における index_rz と interpolate_rz の返値が一致することを確認する。
	/// スプライン補間がノード上で元データを通ることを検証する健全性チェックでもある。
	#[test]
	fn index_rz_matches_interpolate_rz_on_grid() {
		let path = Path::new("parastell/examples/wout_vmec.nc");
		if !path.exists() {
			eprintln!("skip: {} not found", path.display());
			return;
		}
		let vmec = VmecData::load(path).expect("load vmec");
		// LCFS (s=1.0) と磁気軸寄り (s=0.5) と中央 (s=0.5 付近の index) を抜き打ち確認。
		for &i in &[0, vmec.s_grid.len() / 2, vmec.s_grid.len() - 1] {
			let s = vmec.s_grid[i];
			for (theta, phi) in [(0.0, 0.0), (0.37, 1.29), (1.0, 0.5)] {
				let idx = vmec.index_rz(i, theta, phi);
				let int = vmec.interpolate_rz(s, theta, phi);
				let tol = 1e-9;
				assert!(
					(idx.r - int.r).abs() < tol,
					"R mismatch at i={i}, θ={theta}, φ={phi}: idx={}, int={}",
					idx.r, int.r
				);
				assert!(
					(idx.z - int.z).abs() < tol,
					"Z mismatch at i={i}, θ={theta}, φ={phi}: idx={}, int={}",
					idx.z, int.z
				);
			}
		}
	}

	/// どの境界条件でもスプラインはグリッド点を**正確に通る** (補間性) ことを確認する。
	#[test]
	fn cubic_spline_passes_through_data_points() {
		let xs = [0.0, 0.1, 0.3, 0.6, 1.0, 1.5, 2.1];
		let ys = [0.0, 0.5, -0.2, 0.8, 0.3, -0.1, 1.2];
		for bc in [BoundaryCondition::Natural, BoundaryCondition::NotAKnot] {
			let sp = CubicSpline::new(&xs, &ys, bc);
			for (i, &x) in xs.iter().enumerate() {
				let y = sp.eval(x);
				assert!(
					(y - ys[i]).abs() < 1e-10,
					"bc={bc:?} i={i}: eval({x}) = {y}, expected {}",
					ys[i]
				);
			}
		}
	}

	/// x² (純粋な 2 次関数) は not-a-knot 3 次スプラインで**厳密に再現**される
	/// (3 次係数 d=0、M_i=2 が正解)。計算結果と比較して実装が正しいことを確認する。
	#[test]
	fn not_a_knot_reproduces_quadratic_exactly() {
		let xs: Vec<f64> = (0..5).map(|i| i as f64).collect();
		let ys: Vec<f64> = xs.iter().map(|&x| x * x).collect();
		let sp = CubicSpline::new(&xs, &ys, BoundaryCondition::NotAKnot);
		// 内挿点と外挿点 (x=6) の両方で y = x² を厳密に再現することを確認
		for &x in &[0.5, 1.5, 2.5, 3.5, 4.5, 6.0, -1.0] {
			let y = sp.eval(x);
			let expected = x * x;
			assert!(
				(y - expected).abs() < 1e-9,
				"not-a-knot should reproduce x²: eval({x}) = {y}, expected {expected}"
			);
		}
	}

	/// VMEC の s=1.08 外挿で natural vs not-a-knot の (R, Z) 差をサンプルして目視確認する。
	/// 実装バグの診断用。`cargo test -- --nocapture vmec_s108_natural_vs_not_a_knot`
	#[test]
	fn vmec_s108_natural_vs_not_a_knot() {
		let path = Path::new("parastell/examples/wout_vmec.nc");
		if !path.exists() {
			eprintln!("skip: {} not found", path.display());
			return;
		}
		let vmec = VmecData::load(path).expect("load vmec");
		let mnmax = vmec.mode_poloidal.len();
		let s = 1.08;

		// 手動で natural と not-a-knot それぞれの係数補間をやって、dominant mode (k=0)
		// の rmnc 補間値を比較してみる。
		let r_col: Vec<f64> = vmec.rmnc.iter().map(|row| row[0]).collect();
		let sp_nat = CubicSpline::new(&vmec.s_grid, &r_col, BoundaryCondition::Natural);
		let sp_nak = CubicSpline::new(&vmec.s_grid, &r_col, BoundaryCondition::NotAKnot);
		let v_nat = sp_nat.eval(s);
		let v_nak = sp_nak.eval(s);
		let v_grid_last = r_col[r_col.len() - 1];
		eprintln!(
			"mode 0 (rmnc) at s=1.08: natural={v_nat:.6}, not-a-knot={v_nak:.6}, at s=1.0={v_grid_last:.6}"
		);

		// (θ, φ) = (0, 0) での (R, Z) を 3 通りで比較。
		// (現在の interpolate_rz は not-a-knot を使用)
		let int = vmec.interpolate_rz(s, 0.0, 0.0);
		// 末端グリッド点での値 (s=1.0)
		let grid = vmec.index_rz(vmec.s_grid.len() - 1, 0.0, 0.0);
		eprintln!(
			"(θ=0, φ=0): at s=1.0 (R={:.4}, Z={:.4}), at s=1.08 not-a-knot (R={:.4}, Z={:.4})",
			grid.r, grid.z, int.r, int.z
		);

		// mode 0..5 の (s=1.0, s=1.08-natural, s=1.08-notaknot) を対照表示
		for k in 0..5.min(mnmax) {
			let col: Vec<f64> = vmec.rmnc.iter().map(|row| row[k]).collect();
			let a = CubicSpline::new(&vmec.s_grid, &col, BoundaryCondition::Natural);
			let b = CubicSpline::new(&vmec.s_grid, &col, BoundaryCondition::NotAKnot);
			eprintln!(
				"k={k} (m={}, n={}): rmnc at s=1.0={:.4}, s=1.08 natural={:.4}, not-a-knot={:.4}",
				vmec.mode_poloidal[k],
				vmec.mode_toroidal[k],
				col[col.len() - 1],
				a.eval(s),
				b.eval(s),
			);
		}
	}

	/// VMEC を s=1.08 で natural と not-a-knot それぞれ使って、(θ, φ) 全面を走査して
	/// R, Z の最大ズレを測る。実装が妥当なら差は数 cm 以内のはず (parastell と合わない
	/// 原因を切り分けるための診断)。
	#[test]
	fn vmec_s108_surface_drift() {
		let path = Path::new("parastell/examples/wout_vmec.nc");
		if !path.exists() {
			eprintln!("skip: {} not found", path.display());
			return;
		}
		let vmec = VmecData::load(path).expect("load vmec");
		let mnmax = vmec.mode_poloidal.len();

		// 各モードの rmnc, zmns について natural / not-a-knot 2 通りのスプラインを
		// 事前構築する (interpolate_rz は not-a-knot 固定なので、ここでは自前で計算する)。
		let mut r_nat = Vec::with_capacity(mnmax);
		let mut r_nak = Vec::with_capacity(mnmax);
		let mut z_nat = Vec::with_capacity(mnmax);
		let mut z_nak = Vec::with_capacity(mnmax);
		for k in 0..mnmax {
			let rc: Vec<f64> = vmec.rmnc.iter().map(|row| row[k]).collect();
			let zc: Vec<f64> = vmec.zmns.iter().map(|row| row[k]).collect();
			r_nat.push(CubicSpline::new(&vmec.s_grid, &rc, BoundaryCondition::Natural));
			r_nak.push(CubicSpline::new(&vmec.s_grid, &rc, BoundaryCondition::NotAKnot));
			z_nat.push(CubicSpline::new(&vmec.s_grid, &zc, BoundaryCondition::Natural));
			z_nak.push(CubicSpline::new(&vmec.s_grid, &zc, BoundaryCondition::NotAKnot));
		}
		let s = 1.08;
		let r_coef_nat: Vec<f64> = r_nat.iter().map(|sp| sp.eval(s)).collect();
		let r_coef_nak: Vec<f64> = r_nak.iter().map(|sp| sp.eval(s)).collect();
		let z_coef_nat: Vec<f64> = z_nat.iter().map(|sp| sp.eval(s)).collect();
		let z_coef_nak: Vec<f64> = z_nak.iter().map(|sp| sp.eval(s)).collect();

		let mut max_dr = 0.0f64;
		let mut max_dz = 0.0f64;
		let mut sum_sq_dr = 0.0f64;
		let mut sum_sq_dz = 0.0f64;
		let n_theta = 64;
		let n_phi = 60;
		for i in 0..n_phi {
			let phi = std::f64::consts::TAU * (i as f64) / (n_phi as f64);
			for j in 0..n_theta {
				let theta = std::f64::consts::TAU * (j as f64) / (n_theta as f64);
				let n = vmec.eval_rz(&r_coef_nat, &z_coef_nat, theta, phi);
				let k = vmec.eval_rz(&r_coef_nak, &z_coef_nak, theta, phi);
				let dr = (n.r - k.r).abs();
				let dz = (n.z - k.z).abs();
				max_dr = max_dr.max(dr);
				max_dz = max_dz.max(dz);
				sum_sq_dr += dr * dr;
				sum_sq_dz += dz * dz;
			}
		}
		let n_pts = (n_phi * n_theta) as f64;
		eprintln!(
			"s=1.08 surface drift: max(|ΔR|)={max_dr:.4}, max(|ΔZ|)={max_dz:.4}, \
			 rms(ΔR)={:.4}, rms(ΔZ)={:.4}",
			(sum_sq_dr / n_pts).sqrt(),
			(sum_sq_dz / n_pts).sqrt(),
		);
	}

	/// Natural と NotAKnot で、データ範囲内ではほぼ一致し、範囲外 (外挿) で差が出る
	/// ことを確認する。parastell との差異の起源 (境界条件) を再現する。
	#[test]
	fn natural_vs_not_a_knot_extrapolation_differs() {
		// VMEC の s_grid に似せた 201 点等間隔で、非自明な関数 y = sin(3πx) + 0.2 x³ を
		// サンプルする。scipy の CubicSpline と同様に、内挿領域ではどちらも真値に近く、
		// 外挿領域で差が出る。
		let n = 201;
		let xs: Vec<f64> = (0..n).map(|i| i as f64 / (n - 1) as f64).collect();
		let ys: Vec<f64> =
			xs.iter().map(|x| (3.0 * std::f64::consts::PI * x).sin() + 0.2 * x.powi(3)).collect();
		let natural = CubicSpline::new(&xs, &ys, BoundaryCondition::Natural);
		let not_a_knot = CubicSpline::new(&xs, &ys, BoundaryCondition::NotAKnot);

		// 内挿領域 (x=0.5) ではほぼ一致
		let y_nat_mid = natural.eval(0.5);
		let y_nak_mid = not_a_knot.eval(0.5);
		assert!(
			(y_nat_mid - y_nak_mid).abs() < 1e-6,
			"natural ({y_nat_mid}) と not-a-knot ({y_nak_mid}) が内挿で大きく違う"
		);

		// 外挿領域 (x=1.08) では差が出るはず。差が「ゼロじゃない」ことを確認。
		let y_nat_ext = natural.eval(1.08);
		let y_nak_ext = not_a_knot.eval(1.08);
		let diff = (y_nat_ext - y_nak_ext).abs();
		assert!(
			diff > 1e-3,
			"外挿で差が出ていない: natural={y_nat_ext}, not-a-knot={y_nak_ext}, diff={diff}"
		);
		eprintln!(
			"x=1.08 extrapolation: natural={y_nat_ext:.6}, not-a-knot={y_nak_ext:.6}, diff={diff:.6}"
		);
	}
}