file_io: Python OO → Rust FP 移行プラン

現状 ScanDataPy/model/file_io.py の OO 構造と、Rust で関数型プログラミング (FP) として再設計した姿の比較 · 作成 2026-05-17 · 最終更新 2026-05-18 (全面リライト)
目次
  1. 概要と目的
  2. 現状の Python OO 構造と問題点
  3. Rust FP 版の核アイデア (3 原則)
  4. 共通出力型 ScanRecord
  5. 公開 API の階層
  6. データ型の全体像
  7. モジュール構造とクレート配置
  8. データフロー: TSM
  9. データフロー: DA
  10. 高レベル / 低レベルの責務分離
  11. enum と trait の使い分け
  12. OO vs FP 対比表
  13. 移行ステップ
  14. 将来: Python (AI) との橋渡し

1. 概要と目的

現在 ScanDataPy/model/file_io.py には TsmFileIo / TbnFileIo / DaFileIo / HekaFileIO の 4 クラスがあり、いずれも コンストラクタ内でファイル I/O を実行し、結果を多数のインスタンス変数に 書き込む典型的な OO スタイルで書かれています。

本ドキュメントは、これを Rust の関数型プログラミング (FP) 強寄りのスタイルに 書き換えるためのプランを、両方の構造を図示しながら整理します。最終的には Python 側で AI による画像解析を行う構想があるため、境界 (PyO3) を意識した分離を 最初から設計に組み込みます。

方針の要約

※ Rust 言語仕様の詳細は別資料を参照: pub キーワード / derive マクロ

2. 現状の Python OO 構造と問題点

4 つのクラスが並び、TsmFileIo は内部で TbnFileIo をインスタンス化する 凝集度の高い構造になっています。

TsmFileIo - filename, file_path, full_filename - header: bytes - full_frames: np.array - dark_frame: np.array - ch_frames: np.array - num_fluo_ch, num_full_frames - full_frame_interval, ch_frame_interval - data_pixel, full_3D_size, ch_3D_size - elec_data_obj: TbnFileIo + __init__(filename_obj, num_fluo_ch=2) + read_infor() ← I/O + read_data() ← I/O + get_3d() / get_1d() / get_header() ... TbnFileIo - filename, full_filename - full_frame_interval (from TSM) - num_full_frames (from TSM) - elec_header, elec_trace - bnc_ratio, num_elec_ch - elec_interval, num_elec_data + __init__(filename_obj, ...) + read_data() ← I/O + get_data(), get_infor() creates & owns DaFileIo (NeuroPlex) - imaging 関連 + elec 関連 + dark frame - 全部のフィールドが 1 クラスに同居 + __init__(filename_obj, num_fluo_ch=2) + read_data() ← imaging + elec + dark を 1 関数で I/O + get_3d() / get_1d() / get_header() ... ※ TsmFileIo と機能重複 HekaFileIO ⚠ 壊れている - bundle: Bundle (heka_reader) - trace, data + __init__() ← NameError (group_ind 未定義) + get_infor(), get_1d() ※ 現状 import 時に try/except でスキップ 凡例 クラス (OO) 所有/合成関係 ⚠ 副作用 = 多
図 1. 現状の OO クラス図。コンストラクタが副作用を持ち、TsmFileIo は TbnFileIo を内部で生成する。

典型的な使われ方

# コンストラクタを呼んだ瞬間にファイル I/O が走り、
# 30 個近いインスタンス変数が mutate される
tsm = TsmFileIo(filename_obj, num_fluo_ch=2)
full, ch0, ch1 = tsm.get_3d()
elec = tsm.get_1d()  # 内部で elec_data_obj.get_data() を呼ぶ

問題点の整理

#問題影響
P1 コンストラクタが副作用を持つ (__init__ 内で open(), np.fromfile, 例外) テスト時にファイルなしでオブジェクトを作れない。失敗が constructor で起きる
P2 インスタンス変数の遅延初期化 (self.dark_frame = np.array([0,]) 後で上書き) 「初期化済みなのか未読込なのか」が型から読み取れない
P3 TsmFileIo が内部で TbnFileIo をインスタンス化 TBN だけ独立テストできない。TSM なしでは TBN を読めない設計
P4 print() をログ代わりに使用 ログレベル制御不能、テスト出力が汚れる
P5 TsmFileIo と DaFileIo で split_frames, dark frame 減算が重複 仕様変更時に 2 箇所修正が必要
P6 例外を握りつぶして文字列 print → 再 raise 呼び出し側がエラー種別で分岐できない
P7 HekaFileIO が NameError そもそも動作しない (現状 try/except で import 時にスキップ)
P8 getter が大量 (get_3d, get_1d, get_header, ...) 不変なデータなのに「メソッド経由でしか取れない」OO 形式に縛られる
P9 TSM+TBN ペアと DA 単一ファイルで API がバラバラ get_1d() の戻り値や電気生理データの取得経路が形式ごとに違う

3. Rust FP 版の核アイデア (3 原則)

本リファクタの全体方針は次の 3 つの原則に集約されます。これらは独立ではなく、互いに支え合う関係にあります。

原則 1: 不変な struct データは生成後 mutate しない #[derive(Debug, Clone)] pub struct TsmHeader { pub num_x: usize, pub num_y: usize, ... } 原則 2: 純粋な自由関数 操作はメソッドではなく関数 pub fn parse_tsm_header( bytes: &[u8; 2880] ) -> Result<TsmHeader, _> 入力 → 出力のみ self への mutate なし 原則 3: 副作用は最外層 I/O は open_* 関数 1 箇所 pub fn open_tsm(path) { let bytes = fs::read(path)?; ↑ 副作用ここだけ parse_header(...)? reshape(...)? } 結果: テスト可能・予測可能・部品化された設計 原則 1: 不変なので状態に起因するバグが起きない 原則 2: 関数は入力→出力なので単独テストできる (ファイル不要) 原則 3: 副作用が 1 箇所なので、そこだけモック / 統合テストする 3 原則は互いに支え合い、テスト可能性・予測可能性・再利用性を同時に高める
図 2. Rust FP 版を貫く 3 原則と、それがもたらす結果。

原則 1: 不変な struct でデータを表す

OO 版では self.full_frames = ... のように、生成後に値が書き換わるフィールドが多数ありました。 Rust FP 版では struct のフィールドは生成後に mutate しないことを徹底します。

#[derive(Debug, Clone)]
pub struct TsmHeader {
    pub num_x:       usize,
    pub num_y:       usize,
    pub num_frames:  u32,
    pub frame_interval_ms: f64,
}

全フィールドを pub にすれば getter メソッドを書く必要がなく、不変なので外部から読まれても問題ありません。 #[derive] マクロで Debug / Clone を自動実装するのが定石です。

原則 2: 操作は純粋な自由関数

OO 版の tsm.read_infor() のように self を mutate するメソッドではなく、 入力を取って出力を返すだけの関数として書きます。

// バイト列を渡すと、ヘッダ構造体を返す。それだけ。
// self も io も触らない、再現性 100%。
pub fn parse_tsm_header(bytes: &[u8; 2880]) -> Result<TsmHeader, ScanIoError> {
    // FITS 形式ヘッダから NAXIS1, NAXIS2, NAXIS3, EXPOSURE を抽出
    ...
}

同じ入力に対して常に同じ出力を返すため、ファイルなしでテストできるのが大きな利点です:

// テスト例: 実ファイルなしで小さなバイト列を渡して検証
#[test]
fn parses_minimal_header() {
    let bytes = make_test_header(64, 64, 100, 0.005);
    let header = parse_tsm_header(&bytes).unwrap();
    assert_eq!(header.num_x, 64);
}

原則 3: 副作用は最外層 1 関数に集約

ファイル I/O は open_tsm / open_tsm_pair / open_da 等の Adapter 層関数 1 つにだけ閉じ込めます。それ以外の関数は全て純粋。

pub fn open_tsm(path: impl AsRef<Path>) -> Result<ScanRecord, ScanIoError> {
    // ① 副作用: ファイル読み込み (この 1 行だけが I/O)
    let bytes = std::fs::read(path.as_ref())?;

    // ② 以下は全て純粋関数の合成
    let header = parse_tsm_header(&bytes[..2880].try_into()?)?;
    let raw    = raw_i16_from_bytes(&bytes[2880..], &header)?;
    let arr    = reshape_fortran_3d(raw, header.shape_with_dark())?;
    let arr    = axis_transform(arr);
    let (full, dark) = split_dark(arr);
    let full   = subtract_dark_frame(full.view(), dark.view());

    Ok(ScanRecord {
        source: SourceFormat::Tsm,
        fluo:   FluoData { full_frames: full, dark_frame: dark, num_fluo_ch: 2, ... },
        elec:   None,
    })
}
3 原則の相互作用:

4. 共通出力型 ScanRecord

実際の研究データは 3 種類のファイル形式があり、それぞれ「画像データ」と「電気生理データ」の持ち方が違います。 この非対称性を低レベルで露呈させると、呼び出し側に形式ごとの分岐が漏れて API が汚れます。

形式画像電気生理Dark frameファイル数
.tsm + .tbn (Redshirt).tsm 内.tbn 内 (別ファイル).tsm の最終フレーム2 ファイル でペア
.da (NeuroPlex).da 内.da 内 (8 ch 固定).da 内 (末尾)1 ファイル に全部
.heka (HEKA) ※対象外.heka 内1 ファイル

解決策は、高レベル API が返す型を 1 つに統一することです。形式の違いは Adapter 層で吸収し、呼び出し側からは見えなくします。

.tsm + .tbn (ペア) 2 ファイル .da (単一ファイル) 1 ファイル内に全データ open_tsm_pair(tsm_path) 内部で隣の .tbn も読む → Result<ScanRecord, _> open_da(da_path) 1 ファイルを 3 section に分割 → Result<ScanRecord, _> open(path) ← 拡張子で振り分け → Result<ScanRecord, _> ScanRecord (共通出力型) pub struct ScanRecord { pub source: SourceFormat, pub fluo: FluoData, pub elec: Option<ElecData>, } pub struct FluoData { pub full_frames: Array3<i32>, pub dark_frame: Array2<i16>, pub num_fluo_ch: usize, pub frame_interval_ms: f64, ... } 呼び出し側は ScanRecord 1 型で受け取れるため、形式の違いを意識せず処理を書ける
図 3. 共通出力型 ScanRecord による API の統一。3 つの高レベル関数が全て同じ型を返す。
得られる効果:

5. 公開 API の階層

scandata-io が公開する関数は明確な役割分担を持ちます。利用者は通常、高レベル API だけを使えば済みます。

関数レベル用途戻り値
open_tsm(path) 高レベル TSM 単体を読む (TBN なし)。電気生理データなしの想定 Result<ScanRecord, _> (elec: None)
open_tsm_pair(tsm_path) 高レベル TSM + 隣の TBN を一括で読む (実用上はこちらが基本) Result<ScanRecord, _> (elec: Some)
open_da(path) 高レベル DA 単一ファイルを読む Result<ScanRecord, _> (elec: Some)
open(path) (将来) 高レベル 拡張子で振り分けて上の 3 つにディスパッチ Result<ScanRecord, _>
parse_tsm_header(bytes) 低レベル (純粋) FITS ヘッダ解析 Result<TsmHeader, _>
parse_tbn_header(bytes) 低レベル (純粋) TBN ヘッダ解析 (num_ch, bnc_ratio) Result<TbnHeader, _>
parse_tbn_elec(bytes, hint) 低レベル (純粋) TBN 電気生理トレース解析 + ゲイン適用 Result<ElecData, _>
parse_da_header(bytes) 低レベル (純粋) DA 5120 byte ヘッダ解析 Result<DaHeader, _>
parse_da_imaging_section 低レベル (純粋) DA imaging section の int16 → Array3 変換 Result<Array3<i16>, _>
parse_da_elec_section 低レベル (純粋) DA elec section (8 ch 固定) の AtoD 変換 Result<Array2<f64>, _>
open_tbn という単独関数は公開しない: TBN は単独で読んでも意味がない (TSM のフレーム数や frame_interval が必要なため)。 TBN を扱いたい場合は open_tsm_pair を使うか、低レベルの parse_tbn_* 純粋関数を直接呼びます。

6. データ型の全体像

公開される struct と enum の定義を types.rs に集約します。 全て不変 (生成後 mutate なし) で、フィールドは pub 公開します。

// ===== 出力型 (公開) =====

#[derive(Debug, Clone)]
pub enum SourceFormat {
    Tsm,   // .tsm + .tbn ペア
    Da,    // .da 単一ファイル
}

#[derive(Debug, Clone)]
pub struct ScanRecord {
    pub source: SourceFormat,
    pub fluo:   FluoData,            // 画像は必ずある
    pub elec:   Option<ElecData>,   // 電気は無い場合もある
}

#[derive(Debug, Clone)]
pub struct FluoData {
    pub full_frames:       Array3<i32>,  // dark 減算済み (Y, X, N)
    pub dark_frame:        Array2<i16>,  // (Y, X)
    pub num_fluo_ch:       usize,         // チャネル数 (通常 2)
    pub frame_interval_ms: f64,
}
// 注: channels (チャネル別 4D 配列) は保持しない。
//     チャネル分離は domain 層 (builder) で split_channels() を呼び出して行う。

#[derive(Debug, Clone)]
pub struct ElecData {
    pub trace:        Array2<f64>,  // (sample, ch)
    pub num_ch:       usize,
    pub interval_ms:  f64,
    pub gain_table:   Vec<f64>,    // チャネル別ゲイン
}

// ===== 内部ヘッダ型 (公開だが主に内部使用) =====

#[derive(Debug, Clone)]
pub struct TsmHeader {
    pub num_x:             usize,
    pub num_y:             usize,
    pub num_full_frames:   u32,
    pub frame_interval_ms: f64,
}

#[derive(Debug, Clone)]
pub struct TbnHeader {
    pub num_elec_ch: usize,
    pub bnc_ratio:   f64,
}

#[derive(Debug, Clone)]
pub struct DaHeader {
    pub num_x:        usize,
    pub num_y:        usize,
    pub num_frames:   u32,
    pub bnc_ratio:    f64,
    pub interval_ms:  f64,
}

// ===== エラー型 (sum type) =====

#[derive(Debug, thiserror::Error)]
pub enum ScanIoError {
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    #[error("missing header key: {0}")]
    MissingHeaderKey(&'static str),

    #[error("invalid header value for {key}: {value}")]
    InvalidHeaderValue { key: &'static str, value: String },

    #[error("file too small: expected {expected} bytes, got {actual}")]
    FileTooSmall { expected: usize, actual: usize },
}

7. モジュール構造とクレート配置

クレートは scandata-io として ScanDataNet プロジェクトの workspace に置きます。 Adapter 層 1 段 + Domain 層 1 段の構造。

Adapter 層 (副作用あり) — io.rs pub fn open_tsm(path) -> Result<ScanRecord, _> ⚡ fs::read × 1 (elec: None) pub fn open_tsm_pair(path) -> Result<ScanRecord, _> ⚡ fs::read × 2 (tsm + tbn) pub fn open_da(path) -> Result<ScanRecord, _> ⚡ fs::read × 1 (3 section) open_heka() / open() (Stage 4+) 仕様確定後 / dispatch Domain 層 (純粋関数のみ) header.rs parse_tsm_header(&[u8; 2880]) parse_tbn_header(&[u8; 4]) parse_da_header(&[u8; 5120]) ✓ 純粋: バイト列 → ヘッダ構造体 frames.rs reshape_fortran_3d(...) axis_transform(...) subtract_dark_frame(...) ✓ 純粋: ndarray 変換 (TSM/DA 共有) elec.rs / da_sections.rs parse_tbn_elec(bytes, hint) parse_da_imaging_section(...) parse_da_elec_section(...) ✓ 純粋: 電気/section の int16/f64 化 types.rs (データ定義) pub struct ScanRecord { source, fluo, elec } pub struct FluoData { full_frames, dark_frame, ... } pub struct ElecData { trace, num_ch, gain_table } pub enum SourceFormat { Tsm, Da } ✓ 全フィールド不変 / pub readonly error.rs (sum type) pub enum ScanIoError { Io(std::io::Error), MissingHeaderKey(&'static str), InvalidHeaderValue { key, value }, ... } ✓ thiserror + #[from] でエラー伝播 公開 API — lib.rs pub use io::{open_tsm, open_tsm_pair, open_da}; pub use types::{ScanRecord, FluoData, ElecData, SourceFormat}; pub use error::ScanIoError; pub enum FileFormat { Tsm, Da } pub fn detect_format(path) -> FileFormat (Stage 4+) ※ trait は Stage 4+ に必要に応じて導入
図 4. Rust FP 版のモジュール構造。Adapter 層 (黄) と Domain 層 (青) を完全分離。Adapter 層の 3 関数すべてが ScanRecord を返す。

クレート構造 (実ファイル配置)

ScanDataNet/server/crates/scandata-io/
├── Cargo.toml
├── src/
│   ├── lib.rs          // 公開 API の再エクスポート
│   ├── io.rs           // 副作用層: open_tsm / open_tsm_pair / open_da
│   ├── header.rs       // 純粋: 3 形式のヘッダ解析
│   ├── frames.rs       // 純粋: reshape / axis_transform / subtract_dark
│   ├── elec.rs         // 純粋: TBN 電気生理解析
│   ├── da_sections.rs  // 純粋: DA の 3 section 分割
│   ├── types.rs        // データ struct / enum
│   └── error.rs        // ScanIoError
├── tests/
│   └── compat.rs       // .npy との bit-perfect 一致テスト
└── examples/
    └── tsm_info.rs     // CLI

8. データフロー: TSM

open_tsm() は「ファイルから生バイトを読む」副作用 1 ステップと、その後の純粋関数の合成として表現できます。 最終出力は ScanRecord

① fs::read path → Vec<u8> ⚡ 副作用 ② parse_tsm_header &[u8;2880] → TsmHeader ③ raw_i16_from_bytes &[u8] + header → Vec<i16> ④ reshape_fortran_3d Vec<i16> + shape → Array3<i16> ⑤ axis_transform rot90+fliplr → Array3<i16> ⑥ split_dark (Array3<i16>) → (full, dark) ⑦ subtract_dark_frame i16-i16 → i32 → Array3<i32> ⑧ ScanRecord 組み立て source: Tsm fluo: FluoData {..} elec: None return Ok(ScanRecord) Result で エラー伝播 (?) 黄=副作用 / 青=純粋 / ピンク=出力。? 演算子で Result<_, ScanIoError> が一直線に伝播
図 5. open_tsm() のデータフロー。最終出力は ScanRecord (elec: None)。中間はすべて純粋関数の合成。

コード例: open_tsm / open_tsm_pair の実装

pub fn open_tsm(path: impl AsRef<Path>) -> Result<ScanRecord, ScanIoError> {
    let bytes = std::fs::read(path.as_ref())?;
    let fluo = build_fluo_from_tsm_bytes(&bytes)?;

    Ok(ScanRecord {
        source: SourceFormat::Tsm,
        fluo,
        elec: None,
    })
}

fn build_fluo_from_tsm_bytes(bytes: &[u8]) -> Result<FluoData, ScanIoError> {
    let header_bytes: &[u8; 2880] = bytes.get(..2880)
        .ok_or(ScanIoError::FileTooSmall { expected: 2880, actual: bytes.len() })?
        .try_into()?;
    let header = parse_tsm_header(header_bytes)?;
    let raw    = raw_i16_from_bytes(&bytes[2880..], &header)?;
    let arr    = reshape_fortran_3d(raw, header.shape_with_dark())?;
    let arr    = axis_transform(arr);
    let (full, dark) = split_dark(arr);
    let full   = subtract_dark_frame(full.view(), dark.view());

    Ok(FluoData {
        full_frames:       full,
        dark_frame:        dark,
        num_fluo_ch:       2,
        frame_interval_ms: header.frame_interval_ms,
    })
}

// open_tsm_pair は同じ関数を再利用しつつ TBN も読む
pub fn open_tsm_pair(tsm_path: impl AsRef<Path>) -> Result<ScanRecord, ScanIoError> {
    let tsm_path = tsm_path.as_ref();
    let tbn_path = tsm_path.with_extension("tbn");

    let tsm_bytes = std::fs::read(tsm_path)?;
    let tbn_bytes = std::fs::read(&tbn_path)?;

    let fluo = build_fluo_from_tsm_bytes(&tsm_bytes)?;
    let elec = build_elec_from_tbn_bytes(&tbn_bytes, fluo.frame_interval_ms)?;

    Ok(ScanRecord {
        source: SourceFormat::Tsm,
        fluo,
        elec: Some(elec),
    })
}
ポイント:

9. データフロー: DA

DA は .tsm + .tbn と違い、imaging / elec / dark frame が 1 つのバイト列の中で連続配置されています。 これを副作用 1 ステップ + 純粋関数による section 分割で処理します。

バイト列 (.da 単一ファイル) — fs::read で 1 回読む header (5120) imaging (X·Y·N·2 byte) elec (8ch × N·bnc_ratio·2 byte) dark (X·Y·2) ① parse_da_header &[u8; 5120] → DaHeader offset / shape / bnc_ratio ② section_offsets DaHeader → SectionRanges { img, elec, dark } 各 section の (start, end) を純関数で算出 ③ parse_da_imaging_section &bytes[img.start..img.end] → Array3<i16> (C order) ④ parse_da_elec_section &bytes[elec.start..elec.end] + DaHeader → Array2<f64> (AtoD → mV 変換済み) ⑤ parse_da_dark_section &bytes[dark.start..dark.end] → Array2<i16> (Y, X) ⑥ subtract_dark_frame (共通) imaging + dark → Array3<i32> (TSM と同じ純粋関数) ⑦ ScanRecord { source: Da, fluo, elec: Some(.) } elec は 8ch 固定で必ず Some
図 6. open_da() のデータフロー。3 section を切る純粋関数 (③④⑤) を経て TSM と共通の ⑥ で合流し、ScanRecord に組み立てられる。

コード例: open_da の実装

pub fn open_da(path: impl AsRef<Path>) -> Result<ScanRecord, ScanIoError> {
    let bytes = std::fs::read(path.as_ref())?;

    let header_bytes: &[u8; 5120] = bytes.get(..5120)
        .ok_or(ScanIoError::FileTooSmall { expected: 5120, actual: bytes.len() })?
        .try_into()?;
    let header = parse_da_header(header_bytes)?;
    let ranges = section_offsets(&header);

    let img  = parse_da_imaging_section(&bytes[ranges.img.clone()],  &header)?;
    let elec = parse_da_elec_section   (&bytes[ranges.elec.clone()], &header)?;
    let dark = parse_da_dark_section   (&bytes[ranges.dark.clone()], &header)?;

    let full = subtract_dark_frame(img.view(), dark.view());

    Ok(ScanRecord {
        source: SourceFormat::Da,
        fluo: FluoData {
            full_frames:       full,
            dark_frame:        dark,
            num_fluo_ch:       2,
            frame_interval_ms: header.interval_ms,
        },
        elec: Some(ElecData {
            trace:        elec,
            num_ch:       8,
            interval_ms:  header.interval_ms / header.bnc_ratio,
            gain_table:   header.gain_table(),
        }),
    })
}

10. 高レベル / 低レベルの責務分離

関数性質役割
高レベル API (副作用) open_tsm(path) 副作用 .tsm 単体を読んで ScanRecord (elec: None) を返す
open_tsm_pair(tsm_path) 副作用 .tsm + .tbn を読んで ScanRecord (elec: Some) を返す
open_da(path) 副作用 .da 1 ファイルから 3 section を読んで ScanRecord を返す
低レベル (TSM 系) parse_tsm_header(&[u8; 2880])純粋FITS ヘッダ解析
parse_tbn_header(&[u8; 4])純粋TBN ヘッダ (num_ch, bnc_ratio)
parse_tbn_elec(&[u8], hint)純粋float64 トレース解析 + ゲイン適用 (hint = TsmHeader 由来)
低レベル (DA 系) parse_da_header(&[u8; 5120])純粋5120 byte ヘッダ解析
parse_da_imaging_section(...)純粋imaging section の int16 → Array3
parse_da_elec_section(...)純粋elec section (8 ch 固定) の int16 → Array2 + AtoD
低レベル (共通) frames::subtract_dark_frame 純粋 形式に依存しないので TSM/DA で共有
domain 層 (builder で使用) split_channels(full_frames, num_ch) 純粋 scandata-app/usecase の builder が必要時に呼び出す。file_io 層は持たない
チャネル分離は scandata-io ではなく domain 層で行う: Python OO 版の builder.py は、TSM/DA から読んだ後で各チャネル (FluoFramesCh0, FluoFramesCh1, ...) を 対等な FramesData ValueObject として Repository に登録していました。 Rust 版もこの哲学を踏襲し、scandata-io は素材 (full_frames, dark_frame) だけ返し、 チャネル分離は builder が split_channels を呼んで行います。これにより file_io 層に派生データを持たせず、 メモリ消費も最小になります (channels 524 MB 分の冗長保持が不要)。

11. enum と trait の使い分け

用途選択Stage理由
エラーenum ScanIoErrorStage 1「これらのうち一つが起きる」という閉じた集合
データ保持struct ScanRecordStage 1単なるデータ束。enum でも trait でもない
ソース形式判別enum SourceFormatStage 1Tsm / Da の閉じた集合。match record.source { ... }
ファイル種別判定 (path → format)enum FileFormat + detect_format(path)Stage 4+拡張子から振り分ける用途。Stage 1 では不要
複数フォーマット抽象化trait FileReaderStage 4+TBN/DA を実装してから共通点を見て切り出す (premature abstraction を避ける)
Modifier chain (将来)trait ModifierStage 4+BlComp / DfOverF 等を後から増やせる方が嬉しい

12. OO vs FP 対比表

観点現状 (Python OO)提案 (Rust FP)
データ作成 TsmFileIo(filename) がコンストラクタ内で I/O open_tsm_pair(path) -> Result<ScanRecord, _> 自由関数
状態 30 個近いインスタンス変数を mutate 不変な struct を組み立てて 1 回返すだけ
エラー raise Exception + print で文字列 enum ScanIoError + thiserror + ? 演算子
テスト容易性 ファイルがないとオブジェクトを作れない 各純粋関数を小さなバイト列で単独テストできる
TBN の扱い TsmFileIo の中で TbnFileIo() をインスタンス化 open_tsm_pair が 2 ファイル読み込みを 1 関数に集約。下層は parse_tbn_* として独立純粋関数
ファイル形式の非対称性 TSM+TBN ペアと DA で API がバラバラ (get_3d()/get_1d() の戻り値が違う、電気生理データの取得経路が形式ごとに別) 共通出力型 ScanRecord で吸収open_tsm_pairopen_da も同じ型を返す。呼び出し側は record.fluo.full_frames / record.elec.as_ref() で形式に依存しない
DA 単一ファイルの section 分割 1 メソッド (約 100 行の DaFileIo.read_data) で imaging / elec / dark を全部 inline 処理 3 個の純粋関数 (parse_da_imaging_section / parse_da_elec_section / parse_da_dark_section) に分割。各 section 単独でテスト可能
ロジック重複 Tsm / Da で split_frames と dark 減算が重複 subtract_dark_frame は TSM/DA で共有。split_channels は builder が呼び出す純粋関数として 1 箇所に集約
派生データの保持 ch_frames (4D 配列、約 524 MB) を full_frames と並行して持つ file_io 層は持たない。builder が必要時に split_channels を呼んで Ch ごとの FluoFrames を生成
ログ print() 連発 tracing または呼び出し側で制御
HEKA HekaFileIO が NameError Stage 4+ で仕様確定後に open_heka を実装
不変性 後から self.full_frames = ... で上書き可能 struct のフィールドは生成後 mutate しない
API 形式 getter メソッド (get_3d, get_1d, ...) pub フィールドを直接読む

13. 移行ステップ

既存 PHASE_L1_TSM_PARSER.md のステップを本 FP 設計に整合させ、Stage 1〜3 で実装する順序です。

#ステップ純粋?学習対象
S1workspace + クレート scandata-io 初期化Cargo workspace
S2error.rs: enum ScanIoError 定義thiserror, #[from]
S3types.rs: ScanRecord, FluoData, ElecData, SourceFormat 定義イミュータブル struct, Option, 列挙型
S4header.rs: parse_tsm_header(&[u8; 2880])純粋関数, 文字列解析
S5frames.rs: raw_i16_from_bytes, reshape_fortran_3dbyteorder, ndarray Fortran order
S6frames.rs: axis_transform, split_dark, subtract_dark_framendarray view, 型昇格
S7channels.rs: split_channels 純粋関数iterator chain, step_by
S8io.rs: open_tsm 実装 (TSM 単独、ScanRecord { elec: None })✗(I/O)? 演算子, Result 合成
S9テスト: 既存 Python が出力した .npy と bit-perfect 一致ndarray-npy, 検証戦略
S10CLI examples/tsm_info.rsanyhow (bin 側)
S11Stage 2: parse_tbn_header / parse_tbn_elec (純粋関数)パターン再利用
S12Stage 2: open_tsm_pairopen_tsm + TBN 統合 (elec: Some(.))✗(I/O)2 ファイル読み + 副作用集約
S13Stage 3: parse_da_header + section_offsets (純粋関数)section 分割の数学
S14Stage 3: parse_da_imaging_section / parse_da_elec_section / parse_da_dark_section巨大関数の分解
S15Stage 3: open_da 実装 (subtract_dark_frame を再利用して ScanRecord 組み立て)✗(I/O)共通関数の再利用
S16(Stage 4+) detect_format(path) + open(path) ディスパッチ✓ (pure)拡張子→FileFormat enum
S4〜S7, S11, S13, S14 は全て純粋関数なので、ファイルなしで cargo test が高速に回ります。 S8 / S12 / S15 で副作用が登場し、S9 で実ファイル相手の統合テストが入る、という二段構えです。

実装プランとの差分 (付録)

本ドキュメントは既存の実装プラン (ScanDataNet/PHASE_L1_TSM_PARSER.md および ScanDataNet/LEARNING_WEB_PROJECT_PLAN.md) を以下のように拡張・更新します:

観点実装プランでの状態本ドキュメントでの提案
TSM / TBN / DA の Stage 順序 ✓ Stage 1 / 2 / 3 と明記済み (PHASE_L1) 変更なし
エラー型名 ✓ PHASE_L1 §5 で TsmError として雛形あり ScanIoError に統一 (TBN/DA を扱うため)
共通出力型 ScanRecord ✗ 言及なし。PHASE_L1 は TsmFile 単体を返す前提 本ドキュメントで新規提案 (§4)
TSM+TBN ペアの統合 API (open_tsm_pair) ✗ 未設計 本ドキュメントで新規提案 (§5, S12)
DA の 3 section 分割 ✗ 未設計 本ドキュメントで新規提案 (§9, S13〜S15)
PyO3 / numpy 連携 (将来 AI 解析用) △ LEARNING_WEB_PROJECT_PLAN §6 で「移行期の保険」と一言のみ 本ドキュメント §14 で「ScanRecord ベースなら数行でラップ可能」と明示
実装着手前のアクション: 本ドキュメントの提案を採用する場合、ScanDataNet/PHASE_L1_TSM_PARSER.md の §5 公開 API 草案を更新するのが望ましいです。 PHASE_L1 の現行案では TsmFile という型を返すことになっているため、 Stage 2 で TBN を追加するときに型を変えるか別関数を生やすかで迷うことになります。 ScanRecord を最初から想定しておけば、Stage 1 完了時点で型が安定します。

14. 将来: Python (AI) との橋渡し

最終的に Python 側で AI による画像解析を行う構想があるため、Rust と Python の境界を 最初から見据えて 設計します。 ただし Stage 1〜3 では PyO3 連携は導入せず、純粋 Rust で完結させます。

Python 側 (AI 解析、可視化) AI 画像解析 (PyTorch, JAX, ...) 既存 ScanDataPy (PyQt6 GUI) numpy.ndarray ← この型で受け取りたい 境界: PyO3 + maturin (Stage 4 以降) #[pyfunction] fn open_tsm_pair_py(path: &str) -> PyResult<PyScanRecord> numpy crate で Array3<i32> ↔ numpy.ndarray を zero-copy 変換 Rust 側 (scandata-io) open_tsm_pair() 純粋関数の合成 open_da() / open_tsm() 同じパターン ScanRecord { source, fluo, elec } ← 共通出力型 代替経路: WebSocket (ScanDataNet のメイン経路) Rust → MessagePack (rmp-serde) → WebSocket → TypeScript フロントエンド この経路では Python は介在しない。AI 解析が必要になった時のみ PyO3 経路を追加 ※ Stage 1〜3 はこちらを優先
図 7. Rust と Python の橋渡し戦略。PyO3 経路と WebSocket 経路の 2 つを後から選べる設計。

なぜ「最初は PyO3 を入れない」のか

PyO3 連携を入れる時の最小ステップ (将来用メモ)

ScanDataNet/server/crates/
├── scandata-io/         // 純粋 Rust (現在のターゲット)
└── scandata-io-py/      // 将来追加: scandata-io をラップする PyO3 クレート
    └── src/lib.rs:
        use pyo3::prelude::*;
        use numpy::{IntoPyArray, PyArray3};

        // scandata-io の open_tsm_pair を Python から呼べる薄いラッパ
        #[pyfunction]
        fn open_tsm_pair_py(py: Python, path: &str) -> PyResult<Py<PyArray3<i32>>> {
            let record = scandata_io::open_tsm_pair(path)?;
            Ok(record.fluo.full_frames.into_pyarray(py).to_owned())
        }
設計の妙: 現状のプランで open_tsm_pair純粋関数の合成として書いておけば、 PyO3 ラッパは scandata_io::open_tsm_pair(path)? を呼ぶだけの数行で済む。 共通出力型 ScanRecord のおかげで Python 側も「形式を問わず同じ型」で受け取れる。

作成 2026-05-17 · 最終更新 2026-05-18 (全面リライト) · ScanDataPy → ScanDataNet 移行計画の一部