一覧へ

リアルタイムの機器ダッシュボード:WebSocketを使用してインキュベーターの状態をストリーミング

インキュベーター、PCR装置、フローサイトメーターなどの機器のデータをリアルタイムに表示するダッシュボード。PythonのWebSocketサーバーとReactクライアントを使用し、リングバッファーで最近のデータを保持します。

中級
|
100
|
検証済み (2026-07)
リアルタイムダッシュボード保育器温度、二酸化炭素濃度、pHWebSocketイベントストリーミング実験の監視時系列バッファ
進捗0/19 (0%)

リアルタイム機器ダッシュボード — WebSocketでインキュベーターの状態をストリーミング

このトピックを終えると

教科書で学んだ WebSocketReact state時系列バッファー を組み合わせて、細胞培養インキュベーターが毎秒出力する温度、CO2、pH の値をブラウザでリアルタイムに表示するダッシュボードを自分で作成できます。HTTP リクエスト/レスポンスしか知らなかった皆さんが、サーバーが最初にデータをプッシュする 処理の世界に足を踏み入れます。

この記事は 教育用一般例 です。細胞培養モニタリングは実験室でよく行われる管理業務なので、題材として選びました。


「次の日の朝になって初めて気づいた」―ポーリングの限界

あなたが、細胞培養インキュベーターに貴重な細胞株を置いたとしましょう。インキュベーターのCO2濃度は**5%**に維持される必要があります。ところが、ある晩、誰かがドアを少し開けたままにしてしまい、CO2濃度が急激に低下します。次の日の朝、あなたは細胞がすべて死んでしまったことに気づきます。

このような事故を防ぐためには、リアルタイムモニタリングが必要です。「値が毎秒更新され、異常があればすぐに通知する」システムです。

最初の試みは、以下のようなものになるかもしれません。

javascript
// 素朴なポーリングによるアプローチ
setInterval(async () => {
  const res = await fetch("/api/incubator");
  const data = await res.json();
  updateChart(data);
}, 1000);

1秒ごとにサーバーに問い合わせます。 この方法は機能しますが、大きな問題があります。

  • ネットワーク負荷: 1秒ごとにHTTPリクエストとレスポンスヘッダーのやり取りが発生します。ほとんどのリクエストは「新しいデータなし」という結果に終わり、無駄になります。
  • 遅延: インキュベーターが0.1秒前にCO2の急激な低下を検知したとしても、次のポーリング時点(最大1秒後)までは、画面には正常な値が表示されます。
  • 複数の機器への拡張が困難: 5台のインキュベーターを監視する場合、1秒間に5回リクエストを送信します。スケールを拡大すると、サーバーがポーリングの負荷で崩壊します。

これらの問題を解決するのがWebSocketです。リクエスト/レスポンスのやり取りなしに、サーバーがデータが生成されたときにクライアントにプッシュする通信です。この記事では、その仕組みを基礎から作成します。


まずは完成品を見てみましょう(ブラックボックスを先に動かす)

私たちが作成するシステムは、以下のようになります。

text
┌────────────────┐          WebSocket           ┌──────────────────┐
│  Pythonサーバー    │  ═══════════════════════════  │  Reactブラウザ   │
│  (模擬インキュベーター) │  {temp, co2, pH, timestamp}  │  (ダッシュボード)       │
│                │  ← 1秒に数回プッシュする       │                  │
└────────────────┘                              └──────────────────┘

サーバーは、1秒に数回、以下のようなメッセージをプッシュします。

json
{"temp": 37.02, "co2": 4.98, "pH": 7.35, "timestamp": 1720000000.5}
{"temp": 37.03, "co2": 4.97, "pH": 7.35, "timestamp": 1720000000.7}
{"temp": 37.01, "co2": 4.98, "pH": 7.36, "timestamp": 1720000000.9}

ブラウザは、これらの値を受け取り、直近の60秒間のデータを保持し、リアルタイムで3つのライングラフを描画します。CO2が危険範囲(4.5〜5.5)を超えると、画面上部に警告バナーが表示されます。

ポーリングなしで、遅延なしで、サーバーがデータを生成すると同時に画面が反応します。


このツールはどのような部品で構成されているか(部品分解図)

text
リアルタイム設備ダッシュボード(フルスタック)
   ┌──────────────────────────────────────────────────────┐
   │                                                        │
   │   [サーバーサイド]                     [クライアントサイド]    │
   │                                                        │
   │   ┌─────────────────┐              ┌────────────────┐  │
   │   │ インキュベーターモック値    │              │ WebSocket接続  │  │
   │   │ (初期フルデータ)   │              │ (受信 → React) │  │
   │   └─────────┬───────┘              └────────┬───────┘  │
   │             │                                │          │
   │             ▼                                ▼          │
   │   ┌─────────────────┐              ┌────────────────┐  │
   │   │ WebSocketサーバー   │ ────────►    │ 時系列バッファー      │  │
   │   │ 部品: websocket   │              │ (直近60秒)      │  │
   │   └─────────────────┘              └────────┬───────┘  │
   │       ★ 自分で作成する                          │          │
   │                                              ▼          │
   │                                    ┌────────────────┐  │
   │                                    │ Reactグラフ    │  │
   │                                    │ + 警告バナー     │  │
   │                                    └────────────────┘  │
   │                                        ★ 自分で作成する     │
   └──────────────────────────────────────────────────────┘
部品どこで学んだかこのツールで何をするか
HTML/CSS/React基礎html-css-react-basicsページ構造とコンポーネントのレンダリング
WebSocketwebsocket-basicsサーバー→クライアントへのプッシュ通信
React statereact-stateプッシュされたデータを画面に接続
時系列バッファーtime-series-buffer直近のデータのみ保持し、古いデータを破棄

📌 これらの概念を初めて見る場合は(上部のリンクを参照)

自分で作成する新しい概念は WebSocketサーバー、React state、リング型の時系列バッファーの3つ。 HTML/CSSの基本的な構造はツールとして完成品として提供します。 たったの3つ — 認知の限界内に収まっています。


1단계 — モックインキュベーターサーバー ★ (WebSocketサーバー)

✍️ 自分で記述する部分。 部品 = WebSocketサーバー (Python)。モックインキュベーターは、1秒間に複数回、状態データをプッシュします。

Pythonの websockets ライブラリを使用します。ローカルで実行できる完全なサーバーです。

python
# server.py
import asyncio
import json
import time
import random
import websockets # pip install websockets
class MockIncubator:
"""モックインキュベーター — 温度/CO2/pHをノイズとともにシミュレーションします。"""
def __init__(self, target_temp=37.0, target_co2=5.0, target_ph=7.35):
self.target_temp = target_temp
self.target_co2 = target_co2
self.target_ph = target_ph
# 少しドアが開いている状態をシミュレート
self.door_open = False
def sample(self) -> dict:
# 通常の状態:目標値 ± 小さなノイズ
temp_noise = 0.15
co2_noise = 0.10 if not self.door_open else 0.8
ph_noise = 0.02
return {
"temp": round(self.target_temp + random.gauss(0, temp_noise), 3),
"co2": round(self.target_co2 + random.gauss(0, co2_noise), 3),
"pH": round(self.target_ph + random.gauss(0, ph_noise), 3),
"timestamp": round(time.time(), 3),
"door_open": self.door_open,
}
incubator = MockIncubator()
async def stream_handler(websocket):
"""新しいクライアント接続ごとにこの関数が呼び出されます。"""
print(f"[+] クライアントが接続されました: {websocket.remote_address}")
try:
while True:
data = incubator.sample()
await websocket.send(json.dumps(data))
await asyncio.sleep(0.5) # 1秒あたり2回プッシュ
except websockets.ConnectionClosed:
print("[-] クライアント接続が閉じられました")
async def main():
async with websockets.serve(stream_handler, "localhost", 8765):
print("サーバーを開始しました: ws://localhost:8765")
await asyncio.Future() # 永遠に実行
if __name__ == "__main__":
asyncio.run(main())

このコードにおける重要なポイントは、while True 内の sendsleep の組み合わせです。各繰り返しで新しいサンプルを作成し、クライアントにプッシュします。HTTPのようにリクエストを待つことはありません。

ローカルテストで検証:

python
# test_server.py — サーバーが正しくデータをプッシュしているかどうかを、クライアントの模倣で検証
import asyncio
import json
import websockets
async def sanity_check():
async with websockets.connect("ws://localhost:8765") as ws:
# 5つのメッセージを受信
messages = []
for _ in range(5):
raw = await ws.recv()
messages.append(json.loads(raw))
# 検証
assert len(messages) == 5
for m in messages:
assert set(m.keys()) >= {"temp", "co2", "pH", "timestamp"}
assert 35 < m["temp"] < 39 # 通常の範囲
assert 3 < m["co2"] < 7
assert 6.8 < m["pH"] < 8.0
# タイムスタンプが時間順に増加
times = [m["timestamp"] for m in messages]
assert all(times[i] <= times[i+1] for i in range(len(times)-1))
# asyncio.run(sanity_check()) # サーバーが起動しているときに実行

🔎 WebSocketはポーリングとどう違うのか(図解 — websocket-basics) HTTPは、リクエスト → レスポンス → 接続の終了 を毎回繰り返します。WebSocketは、一度接続すると、継続的に維持される双方向のチャネル です。サーバーは、必要に応じていつでもデータをプッシュでき、クライアントもいつでも送信できます。チャット、ゲーム、リアルタイムモニタリングの標準です。

🤔 自己説明プロンプト while True 内に await asyncio.sleep(0.5) をわざわざ入れました。これを削除するとどうなるでしょうか?サーバーのCPU使用率は?クライアントが1秒あたり何個のメッセージを受信しますか?(ヒント:sleepがない場合、CPUは100%使用 + 1ミリ秒あたり数千個のメッセージ)


ステップ2:時系列バッファー ★ (time-series-buffer)

✍️ 手動で埋めるセクション。 コンポーネント = 循環時系列バッファー。最新のN個のポイントのみを保持し、古い値は自動的に破棄します。

リアルタイムダッシュボードは、すべての過去データを保存する必要はありません。最新の60秒のウィンドウを見るだけで十分です。データが1秒あたり2つずつ来ると、60秒のウィンドウは最大120個のポイントになります。

最も単純なアプローチは、JavaScript配列に継続的にpushし、時々shiftで先頭を削除することです。

javascript
// シンプルなバッファー - 問題あり
let buffer = [];
function push(point) {
  buffer.push(point);
  if (buffer.length > 120) buffer.shift();
}

この方法は機能しますが、問題があります。Array.shift() は O(n) です。配列の先頭を削除すると、後続のすべての要素が1つずつシフトされます。データが長時間経過すると、pushごとに O(n) のシフトコストが累積されます。

循環バッファー を使用して、O(1) の push/pop を実現します。

🔎 循環バッファーとは (図 — time-series-buffer) 固定サイズの配列を円のように使用します。書き込みインデックス を1つ使用して、次の値をどこに書き込むかを追跡します。配列の末尾に到達すると、先頭に戻って上書きします。この構造により、push/pop の両方が O(1) になります。オーディオ処理、ログリング、リアルタイムグラフに頻繁に使用されます。

typescript
// buffer.ts
export class TimeSeriesBuffer<T> {
  private data: (T | null)[];
  private writeIdx = 0;
  private size = 0;

  constructor(public capacity: number) {
    this.data = new Array(capacity).fill(null);
  }

  push(value: T): void {
    this.data[this.writeIdx] = value;
    this.writeIdx = (this.writeIdx + 1) % this.capacity;
    this.size = Math.min(this.size + 1, this.capacity);
  }

  toArray(): T[] {
    // 時間順に並べられた配列を返す
    if (this.size < this.capacity) {
      // まだ満たされていない — 先頭から size 個
      return this.data.slice(0, this.size) as T[];
    }
    // 満たされている — writeIdx 以降からラップして返す
    return [
      ...this.data.slice(this.writeIdx),
      ...this.data.slice(0, this.writeIdx),
    ] as T[];
  }
}

検証:

typescript
// buffer.test.ts
import { TimeSeriesBuffer } from "./buffer";

const buf = new TimeSeriesBuffer<number>(5);
[1, 2, 3].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[1,2,3]");

[4, 5].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[1,2,3,4,5]");

// 容量超過 — 古いものを上書き
[6, 7].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[3,4,5,6,7]");

O(n) の shift() を O(1) の writeIdx のインクリメント に置き換えました。データが数時間経過しても、push コストは一定です。

🤔 自己説明プロンプト toArray() が O(n) である理由は? push は O(1) なのに、なぜ返却は O(n) なのか? リアルタイムグラフのレンダリングで、これは問題になるか? (ヒント:60Hz でレンダリングするため、毎フレーム O(120) は意味がない。)


3段階で作る — React stateフック ★ (react-state)

✍️ 自分で埋める部分。 部品 = React state + useEffect。WebSocketからプッシュされた値をバッファーに格納し、画面に反映する。

Reactでリアルタイムデータを扱う際の重要な原理は、useState + useEffect の組み合わせです。

  • useState: 画面に表示する「現在のウィンドウ」のスナップショット
  • useEffect: WebSocketの接続・切断ライフサイクルを管理
typescript
// useIncubatorStream.ts
import { useEffect, useState, useRef } from "react";
import { TimeSeriesBuffer } from "./buffer";

interface Sample {
  temp: number;
  co2: number;
  pH: number;
  timestamp: number;
  door_open?: boolean;
}

export function useIncubatorStream(wsUrl: string, windowSize: number = 120) {
  const [samples, setSamples] = useState<Sample[]>([]);
  const [connected, setConnected] = useState(false);
  const bufferRef = useRef(new TimeSeriesBuffer<Sample>(windowSize));

  useEffect(() => {
    const ws = new WebSocket(wsUrl);

    ws.onopen = () => {
      console.log("WebSocketに接続");
      setConnected(true);
    };

    ws.onmessage = (event) => {
      const data: Sample = JSON.parse(event.data);
      bufferRef.current.push(data);
      // React stateに新しいスナップショットを渡す
      setSamples(bufferRef.current.toArray());
    };

    ws.onclose = () => {
      console.log("WebSocketを切断");
      setConnected(false);
    };

    // クリーンアップ: コンポーネントがアンマウントされたら接続を閉じる
    return () => {
      ws.close();
    };
  }, [wsUrl, windowSize]);

  return { samples, connected };
}

このフックの決定的な特徴は次のとおりです。

  • bufferRef (useRef): 循環バッファーは、コンポーネントのリレンダリングとは無関係に維持される必要があります。useRefでラップします。
  • setSamples: メッセージごとに新しい配列を返し、Reactにリレンダリングさせます。配列参照が変更されることで、Reactが変更を検知します。
  • クリーンアップ関数: コンポーネントが削除されたら、必ずws.close()を実行します。そうしないと、ゾンビ接続が積み重なります。

🔎 useRefとuseStateは、いつどちらを使うか (ドロー — react-state) 画面に影響を与える場合はuseState (変更時にリレンダリング)。内部計算や参照のみが必要な場合はuseRef (変更してもリレンダリングしない)。循環バッファー自体はuseRefで、グラフのレンダリング対象であるスナップショットはuseStateで — この分離がパフォーマンスの鍵です。

🤔 自己説明プロンプト bufferRef を単に let buffer = new TimeSeriesBuffer(...) としてコンポーネント内に配置した場合、どうなるでしょうか? (ヒント: 各レンダリングごとに新しいバッファーが作成され、過去のデータがすべて失われます。)


4단계:ダッシュボードUIの組み立て ★

いよいよ最後の段階です。フックから取得したサンプルデータを、実際のグラフと警告バナーとして表示します。

typescript
// IncubatorDashboard.tsx
import { useIncubatorStream } from "./useIncubatorStream";

function isCritical(latest: any): string | null {
  if (!latest) return null;
  if (latest.co2 < 4.5 || latest.co2 > 5.5) return `CO2異常:${latest.co2}%`;
  if (latest.temp < 36.5 || latest.temp > 37.5) return `温度異常:${latest.temp}°C`;
  if (latest.pH < 7.2 || latest.pH > 7.5) return `pH異常:${latest.pH}`;
  return null;
}

export function IncubatorDashboard() {
  const { samples, connected } = useIncubatorStream("ws://localhost:8765");
  const latest = samples[samples.length - 1];
  const warning = isCritical(latest);

  return (
    <div className="dashboard">
      <header>
        <h1>インキュベーターA</h1>
        <span className={connected ? "connected" : "disconnected"}>
          {connected ? "リアルタイム接続中" : "接続切れ"}
        </span>
      </header>

      {warning && (
        <div className="warning-banner">
          ⚠️ 警告:{warning}
        </div>
      )}

      <section className="metrics">
        <MetricCard label="温度" value={latest?.temp} unit="°C" target={37.0} />
        <MetricCard label="CO2" value={latest?.co2} unit="%" target={5.0} />
        <MetricCard label="pH" value={latest?.pH} unit="" target={7.35} />
      </section>

      <section className="charts">
        <LineChart data={samples} field="temp" color="#ff6b6b" />
        <LineChart data={samples} field="co2" color="#4ecdc4" />
        <LineChart data={samples} field="pH" color="#95e1d3" />
      </section>
    </div>
  );
}

MetricCardLineChartは標準コンポーネントなので、詳細な実装は省略します。重要な点は、上記の1つのフックが、ダッシュボード全体のデータソースであるということです。

検証(統合テストの概要):

typescript
// integration.test.ts(Playwright / Cypress統合テストの概念)
async function test_dashboard_flow() {
  // 1. サーバー起動(テストフィクスチャ)
  // 2. ブラウザでダッシュボードをロード
  // 3. 5秒以内に、グラフに少なくとも5つのポイントが表示されること
  await waitFor(() => expect(chart.pointCount()).toBeGreaterThan(5));
  // 4. WebSocket接続が有効であること
  expect(document.querySelector(".connected")).toBeInTheDocument();
  // 5. door_open=trueに人為的に変更すると、警告バナーが表示されること
  await triggerDoorOpen(server);
  await waitFor(() => expect(document.querySelector(".warning-banner")).toBeInTheDocument());
}

部品を統合 — 完成したダッシュボード構造

全体のシステムをまとめると、以下のようになります。

text
サーバーサイド(Python):
  MockIncubator → sample()を繰り返し実行 → WebSocket.send()
  ファイル:server.py

クライアントサイド(React/TypeScript):
  useIncubatorStream(wsUrl)
    ├── WebSocket接続(useEffect)
    ├── TimeSeriesBuffer(useRef、O(1) push)
    └── samples状態(useState、レンダートリガー)

  IncubatorDashboard
    ├── 接続状態の表示
    ├── 警告バナー(isCritical関数)
    ├── MetricCard × 3(現在の値)
    └── LineChart × 3(60秒ウィンドウ)

  ファイル:useIncubatorStream.ts、buffer.ts、IncubatorDashboard.tsx

このツールは、今日、産業用SCADA(Supervisory Control and Data Acquisition)、実験室LIMS(Laboratory Information Management System)などで見られるリアルタイムダッシュボードの縮小版です。実務で使用するツールには、これに加えて**認証、複数の機器へのルーティング、時系列DBへの保存(InfluxDB)、アラートのルーティング(Slack/メール)**などが追加されます。


パフォーマンスの詳細分析 — なぜこのアーキテクチャなのか

text
ポーリング方式(1秒ごとにfetch):
  - サーバーの負荷:クライアントN個 × 1秒あたりのリクエスト1回 = Nリクエスト/秒
  - 遅延:平均0.5秒(ポーリング間隔の半分)
  - 帯域幅:リクエストごとにHTTPヘッダーのオーバーヘッド(〜500バイト)

WebSocket方式(継続的なプッシュ):
  - サーバーの負荷:接続ごとに常時維持(ソケット1つ、TCP維持)
  - 遅延:〜ミリ秒(ネットワークの往復のみ)
  - 帯域幅:ヘッダーなし、ペイロードのみ(〜80バイト)

数値で比較すると:

シナリオポーリング(1秒間隔)WebSocket
クライアント100台・1秒あたりのリクエスト100リクエスト/秒0リクエスト/秒
異常検知の遅延(平均)500ms〜10ms
ペイロードの帯域幅(1秒あたり)100 × 500B = 50KB/s100 × 80B × 2 = 16KB/s

検知の遅延が50倍、帯域幅が1/3。 複数の機器を拡張するほど、その差は大きくなります。


別の道もある(マルチパスによる考察)

  • Server-Sent Events(SSE): WebSocketよりも単純な代替手段。サーバーからクライアントへの一方通行のみをサポートするが、HTTP上に構築されているため、プロキシやファイアウォールを通過しやすい。クライアントがサーバーにコマンドを送信する必要がない場合(単純なモニタリング)であれば、SSEで十分。
  • MQTTブローカー: IoT標準プロトコル。複数のデバイスをブローカーに接続し、ダッシュボードもブローカーにサブスクライブする。デバイスの数が数十〜数百の場合、こちらの方が適している。Mosquitto、HiveMQなどのオープンソースがある。
  • 時系列DBとの併用: リアルタイムダッシュボードだけでは、過去のログがない。InfluxDB、TimescaleDB、Prometheusなどに同時に記録することで、リアルタイムビューと過去の分析を同時に行うことができる。
  • 再接続ロジック: 弊社のフックは、サーバーが切断された場合に再接続しない。実務では、指数バックオフを使用して再接続するロジックが必須。ws.onclose内に再試行スケジュールを記述する。
  • 警告伝達の拡張: 画面バナーだけでは、夜間の事故対応は不可能。Slack webhook、SMS、PagerDutyなどの外部チャネルに警告を送信することで、より実用的なシステムを構築できる。

核心: 「サーバーがプッシュし、バッファーがウィンドウを維持し、stateが画面を繋ぐ」。この3層がそれぞれの役割を理解していれば、リアルタイムシステムのアーキテクチャが見えてくる。皆さんが今作成したものが、そのアーキテクチャの実体である。

次のステップへ(下部のリンク)

実際にやってみよう(独立した課題)

  1. 再接続ロジック: WebSocketが切断された場合、1秒、2秒、4秒、8秒(指数関数的バックオフ)で再試行するロジックをフックに追加してください。
  2. 複数デバイス: 5台のインキュベーターを監視するダッシュボードに拡張してください。各デバイスに対して個別のWebSocket接続を確立します。パフォーマンスのボトルネックがどこにあるかを観察してください。
  3. ダウンサンプリング: 1秒あたり20個のメッセージが送信される場合、画面のレンダリングを1秒あたり5回に制限したいと考えています。フックにdebounce/throttleを追加してください。
  4. 挑戦 — SSEへの移植: 同じシステムをWebSocketの代わりにServer-Sent Eventsを使用して再実装してください。コードの複雑さはどのように変化しますか?

まとめ

「機器がリアルタイムで出力するデータを、すぐに画面に反映する」という問題を、以下の3つの要素に分解して解決しました。

  • WebSocket により、サーバーがデータをプッシュする通信チャンネルが開かれました(ポーリングは不要)。
  • リング型の時系列バッファー が、O(1) でのプッシュにより、最新のデータを保持しました。
  • React state (+ useRef) が、データフローとレンダリングを正確に結びつける役割を果たしました。

インキュベーターが毎秒出力する値が、ミリ秒単位の遅延で画面に表示されるようになりました。翌朝になって初めて異常を発見する時代は終わりです。ドアが開いてから5秒以内に、画面に警告が表示されます。

この記事は、一般的な教育用サンプル です。実際の実験室ダッシュボードには、認証、複数機器へのルーティング、時系列データベース、通知システムなどが追加されます。より詳細なバージョンは、この基本構造にさらに機能を追加するか、実績のあるSCADA/LIMSツールを使用することで実現できます。

💬 質問・コメント

0件のコメント

ログインせずに投稿できます。ゲスト投稿は投稿者自身で編集・削除できません。

0/2000

読み込み中...