0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザ完結ETLの「泥仕事」全記録 — サイレントクラッシュを防ぐ3ガード節と、`mo.ui.data_editor` に浮動小数点を流してはいけない理由

0
Posted at

この記事について
The Automation Initiative 第12弾「SmartOrder(在庫発注点シミュレーター)」の開発で得た、ブラウザ完結型ETL処理の実装パターンと、UIコンポーネントへのデータ受け渡しにおける型設計の話です。
marimo・WASM・Altairの導入経緯やアンチパターンは過去記事で解説済みのため、本稿では 今回直面した2つの泥臭いUX・アーキテクチャ問題 に絞って書きます。

はじめに:「在庫管理CSV」は、きれいじゃない

在庫管理や発注業務で日常的に使われるCSVデータは、往々にして「構造」を持っていません。

販売管理システムから出力されたCSVには、こんなものが混入しています。

  • 多段ヘッダー(「商品名\n商品コード」が1セルに結合されている)
  • 小計行(「合計」「小計」という文字列がデータ行に混在)
  • 幽霊行(空白文字のみのセルが NaN ではなく "" として読み込まれる)
  • 型の汚染(数値のはずの列が "1,200" のように文字列として入っている)

これらを手動でExcelの「検索と置換」で消していくのが現場の実態です。
本プロジェクトでは、これを ブラウザ内で全自動化 することを目指しました。

問題1: UI Error Binding — クセのあるCSVによるクラッシュを無力化する

設計方針:「入力は信頼しない」原則と Stealth Block の回避

WASM(Pyodide)環境では、ユーザーがアップロードするCSVの品質は完全に未知数です。
不正なデータを引いた際に、安易に mo.stop() を使って処理を物理遮断してしまうと、下流のレイアウトまで芋づる式に消滅する「ステルスクラッシュ (The Stealth Block Trap)」を引き起こしてしまいます。
そこで、パイプラインのクラッシュを避けつつエラー状態を正常に下流へ流通させる 3層のガード節(UI Error Binding) で構成しました。

Sample_code-1
@app.cell
def __(cleanse_data, io, mo, pd, smart_order_inputs):
    # --- Cell 3: ETL Phase (UI Error Binding & Data Cleansing) ---
    _files = smart_order_inputs.value["file_upload"]
    
    cleaned_df = None
    etl_error_ui = None

    # ガード節 1: ファイル未アップロード時の防衛
    if not _files:
        etl_error_ui = mo.md("⚠️ 過去の販売データ(CSV)をアップロードしてください。").callout(kind="warn")
    else:
        try:
            _raw_bytes = _files[0].contents
            _raw_df = pd.read_csv(io.BytesIO(_raw_bytes), dtype=str).fillna("")
            
            # バックエンド関数へ委譲(ベクトル演算によるETL)
            cleaned_df = cleanse_data(_raw_df)
            
            # ガード節 3: クレンジング後データが0件の場合の防衛
            if cleaned_df.empty:
                etl_error_ui = mo.md("⚠️ 有効なデータが0件です。フォーマットを確認してください。").callout(kind="warn")
                cleaned_df = None
                
        except Exception as _e:
            # ガード節 2: パース・クレンジング失敗時の防衛
            etl_error_ui = mo.md(f"🚨 パースに失敗しました。エラー詳細: {_e}").callout(kind="danger")
            cleaned_df = None
            
    # DAGに正常流通させるための必須エクスポート
    return cleaned_df, etl_error_ui

3層の意図を整理すると...

ガード節 検出する状態 UIへのフィードバック
ガード節1 ファイル未アップロード(Noneまたは空リスト) warn Callout で誘導
ガード節2 CSV自体がパース不能(文字コード不一致、壊れたファイル等) danger Callout でエラー詳細を表示
ガード節3 パース成功したが有効行が0件(全行が小計・空白だった) warn Callout で再アップロードを促す

クレンジング本体:幽霊行と小計行の駆逐

バックエンド関数 cleanse_data() の核心部分です。
動的ループを使わず、完全な Pandas のベクトル演算で処理します。

Sample_code-2
def cleanse_data(raw_df: pd.DataFrame) -> pd.DataFrame:
    df = raw_df.copy()
    
    # Step1: 列名の正規化(日本語 → 英語)
    col_map = {'日付': 'Date', '商品コード': 'ItemCode', '売上数': 'SalesQty'}
    df = df.rename(columns=col_map)
    
    # Step2: 幽霊行の除去
    # pd.read_csv(..., dtype=str).fillna("") で読み込んだ場合、
    # 空白文字のみのセルが NaN ではなく "" や " " として残る。
    # regex=True でホワイトスペースのみの文字列を NaN に変換してから dropna する。
    df = df.replace(r'^\s*$', np.nan, regex=True)
    df = df.dropna(how='all')
    
    # Step3: 小計行のパージ
    # isin(['小計']) はセルの値全体が '小計' と一致する行を対象とする。
    # '合計' が混在する場合はリストに追加すればよい。
    mask = df.isin(['小計']).any(axis=1)
    df = df[~mask]
    
    # Step4: 必須列のNaN除去と数値変換
    essential_cols = [c for c in ['ItemCode', 'Date', 'SalesQty'] if c in df.columns]
    if essential_cols:
        df = df.dropna(subset=essential_cols)
        
    if 'SalesQty' in df.columns:
        df['SalesQty'] = pd.to_numeric(df['SalesQty'], errors='coerce')
        df = df.dropna(subset=['SalesQty'])
        
    return df

ポイント:dtype=str で全列を文字列として読み込む理由

pd.read_csv() のデフォルト挙動は、数値に見えるセルを自動的に float に変換します。
しかし「商品コード」が "001" のようにゼロ埋めされた数値文字列の場合、dtype=str を省略すると 1 に変換されてしまい、マスターデータとの突合が失敗します。
入力の多様性を吸収するためには、 全列を文字列として読み込み、変換が必要な列だけ pd.to_numeric(errors='coerce') で個別に型変換する のが安全です。

問題2: mo.ui.data_editor に浮動小数点を垂れ流してはいけない

現場の認知負荷とは何か

ROP(発注点)の計算式は次の通りです。

ROP = (1日あたり平均消費量 × リードタイム) + 安全在庫

例えば、ある商品の過去実績から算出した平均消費量が 2.666... 個/日で、リードタイムが3日、安全在庫が5個だった場合、

ROP = 2.666... × 3 + 5 = 12.999...

この 12.999... という数値を mo.ui.data_editor にそのまま表示すると、現場の担当者は困惑します。

「13個でいいの? 12個でいいの? この小数点以下は何?」

発注点は整数でなければ実運用では使えません。
浮動小数点をデータエディタに垂れ流すことは、UI/UXとして失格 です。

解決策:Pandas側で型を確定してからUIに渡す

Sample_code-3
def calculate_rop(df: pd.DataFrame, lead_time: int, safety_stock: int) -> pd.DataFrame:
    if df.empty:
        return pd.DataFrame(columns=['ItemCode', 'ROP'])
        
    avg_sales = df.groupby('ItemCode')['SalesQty'].mean().reset_index()
    avg_sales = avg_sales.rename(columns={'SalesQty': 'AvgDailySales'})
    
    result_df = avg_sales.copy()
    result_df['ROP'] = (result_df['AvgDailySales'] * lead_time) + safety_stock
    
    # 【UX設計の核心】端数処理と型キャストをUIに渡す前に完結させる
    # np.ceil() で「切り上げ(安全側)」を選択し、.astype(int) で整数型を確定。
    # 発注点は在庫切れを防ぐための指標なので、切り捨てより切り上げが適切。
    result_df['AvgDailySales'] = np.ceil(result_df['AvgDailySales']).astype(int)
    result_df['ROP'] = np.ceil(result_df['ROP']).astype(int)
    
    return result_df

mo.ui.data_editor はDataFrameをそのまま受け取ります。
つまり、 DataFrameの型がそのままUIの表示型になります
Pandas側で astype(int) を通しておくことで、data_editor上の表示は整数になり、担当者は迷わず発注数を確認・修正できます。

なぜ round() ではなく np.ceil() なのか

丸め方法 2.1 2.5 2.9 用途の適合性
round() 2 2(banker's rounding) 3 統計的精度重視
np.floor() 2 2 2 常に切り捨て
np.ceil() 3 3 3 常に切り上げ(在庫管理向き)

発注点は「欠品を防ぐための最低ライン」です。
切り捨てると欠品リスクが上がります。
np.ceil() による切り上げは、 在庫管理の文脈では安全側への倒し方として理にかなっています

まとめ:ブラウザ完結ETLのUXは「データの型」から始まる

今回のSmartOrder開発で得た教訓を2点まとめます。

教訓1: 入力は3層で防衛せよ(UI Error Binding)

WASM環境では、ユーザーがアップロードするデータの品質を制御できません。
「ファイル未選択」「パース失敗」「クレンジング後0件」を捕捉した際、安易な mo.stop() による通信切断を避け、エラー状態であっても正常なUIオブジェクトと None を下流にストリームさせることで、画面全体の消滅(ステルスクラッシュ)を防ぎます。

教訓2: UIに渡す前、Pandas側で型を確定させよ

UIコンポーネントの「見た目の問題」を、UIレイヤーで解決しようとする誘惑に抗いましょう。
データ変換は常にバックエンド(Pandas/NumPy)側で完結させることが、認知負荷の低いインターフェース設計の基本です。

浮動小数点を殺すのは、astype(int) の一行です。
しかしその一行を意識的に設計するかどうかで、現場での使われ方が劇的に変わります。

付録:今回の構成(アーキテクチャ概要)

[UI Layer]  mo.ui.file() → mo.ui.number() → mo.ui.data_editor()
                ↓                ↓                   ↑
[ETL Layer] UI Error Binding × 3 → cleanse_data() [dtype=str → to_numeric]
                                        ↓
[Sim Layer] calculate_rop() [np.ceil + astype(int)] → Altair Chart
                                        ↓
[Export]    mo.download() → order_plan.csv

すべての処理はブラウザ内(Pyodide / WASM)で完結し、データはサーバーに送信されません。
NDA制約のある職場での利用を想定した、セキュリティファーストな設計です。

プロダクト一覧

開発してきたすべてのアプリはこちらからご覧いだけます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?