本文へ移動
vast-cowのブログ
前のページへ戻る

ターミナル上でストリームデータを直接プロットする

この記事を編集

このツールは、ライブの数値データをターミナル内に直接プロットするための小さなコマンドラインプログラムです。標準入力から値を読み取り、それらを gnuplot に渡し、新しいデータが到着するたびに更新されるシンプルなテキストベースのグラフを表示します。

目的

このツールの目的は、別のグラフィカルウィンドウを開くことなく、ストリームデータを簡単に観察できるようにすることです。

センサーの測定値、ログ出力、ベンチマーク結果、または他のコマンドラインプログラムが生成する値など、リアルタイムで変化するデータを監視したい場合に役立ちます。

グラフはターミナル内に描画されるため、シンプルなシェルワークフローや、グラフィカルデスクトップ環境が利用できないリモート環境でも使用できます。

動作の仕組み

プログラムは標準入力から行単位でデータを読み取ります。各行には数値データが含まれていることを想定しています。

有効な数値を受け取るたびに、プログラムは起動からの経過時間を記録し、その値を経過時間に対してプロットします。新しいデータを受信すると、グラフは自動的に更新されます。

保持されるデータポイント数には上限があります。デフォルトでは最大 200 点まで保存されるため、表示は直近の変化に集中できます。

入力形式

このツールはカンマ区切りまたはタブ区切りの入力を受け付けます。

例えば、以下の入力をプロットできます。

1.2
1.5
1.8
2.1

また、CSV 形式のようなデータから最初の列を読み取ることもできます。

1.2,ok
1.5,ok
1.8,ok
2.1,ok

行に有効な数値が含まれていない場合、その行は無視されます。

基本的な使い方

スクリプトをファイルとして保存します。例えば次のようにします。

stream-plot.py

実行可能にします。

chmod +x stream-plot.py

その後、数値データをパイプで渡します。

python3 generate_values.py | ./stream-plot.py

ターミナル内にシンプルなライブプロットが表示されます。

使用例

シェルのループを使って簡単にテストできます。

while true; do
  echo $RANDOM
  sleep 1
done | ./stream-plot.py

この例では 1 秒ごとにランダムな値が生成されます。新しい値が入力されるたびに、ターミナル上のプロットが更新されます。

ターミナルサイズの変更

このツールはターミナルのサイズを検出し、プロット領域を自動調整します。ターミナルウィンドウのサイズが変更された場合でも、グラフが利用可能な表示領域に収まるようにプロットサイズが更新されます。

ツールの終了

ツールは以下で停止できます。

Ctrl+C

終了時には gnuplot プロセスを適切にクリーンアップして閉じます。

必要環境

このツールには以下が必要です。

gnuplot

また、Python 3 と、asyncio、csv、signal などの標準 Python ライブラリを使用します。

まとめ

このターミナル向けストリームプロットツールは、コマンドラインからライブの数値データを監視するためのシンプルな方法を提供します。軽量で標準入力を利用でき、フル機能のプロットアプリケーションが不要な簡易モニタリング用途に適しています。

import asyncio
import csv
import io
import shutil
import signal
import sys
import time
import uuid
from collections import deque


def get_plot_size():
    size = shutil.get_terminal_size(fallback=(120, 30))

    width = max(20, size.columns)
    height = max(10, size.lines - 1)

    return width, height


class StreamGnuplot:
    def __init__(
        self,
        *,
        max_points=200,
        width=120,
        height=30,
        value_column=0,
        # title="stdin stream",
        # ylabel="value",
    ):
        self.max_points = max_points
        self.width = width
        self.height = height
        self.value_column = value_column
        # self.title = title
        # self.ylabel = ylabel

        self.start_time = time.monotonic()
        self.points = deque(maxlen=max_points)

        self.proc = None
        self._lock = asyncio.Lock()

        self._first_draw = True
        self._resize_requested = False

    async def start(self):
        self.proc = await asyncio.create_subprocess_exec(
            "gnuplot",
            stdin=asyncio.subprocess.PIPE,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )

        await self._write(self._gnuplot_setup_script())

    def request_resize(self):
        self._resize_requested = True

    def _gnuplot_setup_script(self):

        # set title "{self.title}"
        # set ylabel "{self.ylabel}"
        return f"""
set terminal dumb {self.width} {self.height}
set print "-"
set xlabel "time since start [s]"
set grid
set key off
"""

    async def _apply_resize_if_needed(self):
        if not self._resize_requested:
            return

        self._resize_requested = False

        new_width, new_height = get_plot_size()

        if new_width == self.width and new_height == self.height:
            return

        self.width = new_width
        self.height = new_height

        await self._write(f"set terminal dumb {self.width} {self.height}\n")

        # ここでは _first_draw を戻さない。
        # 「1回目だけ \033[H しない」という条件を維持する。

    async def stop(self):
        if self.proc is None:
            return

        try:
            await self._write("exit\n")
        except Exception:
            pass

        try:
            await asyncio.wait_for(self.proc.wait(), timeout=1.0)
        except asyncio.TimeoutError:
            self.proc.terminate()
            await self.proc.wait()

    async def _write(self, text):
        if self.proc is None or self.proc.stdin is None:
            raise RuntimeError("gnuplot is not running")

        self.proc.stdin.write(text.encode("utf-8"))
        await self.proc.stdin.drain()

    async def add_value(self, value):
        elapsed = time.monotonic() - self.start_time
        self.points.append((elapsed, value))
        await self.replot()

    async def replot(self):
        if not self.points:
            return

        async with self._lock:
            await self._apply_resize_if_needed()

            marker = f"__GPLOT_DONE_{uuid.uuid4().hex}__"

            data = "\n".join(
                f"{x:.6f} {y:.12g}"
                for x, y in self.points
            )

            script = f"""
plot "-" using 1:2 with lines
{data}
e
print "{marker}"
"""

            await self._write(script)

            plot_text = await self._read_until_marker(marker)
            self._draw_to_terminal(plot_text)

    async def _read_until_marker(self, marker):
        if self.proc is None or self.proc.stdout is None:
            raise RuntimeError("gnuplot stdout is not available")

        lines = []

        while True:
            raw = await self.proc.stdout.readline()

            if raw == b"":
                raise RuntimeError("gnuplot stdout closed unexpectedly")

            line = raw.decode("utf-8", errors="replace")

            if line.rstrip("\r\n") == marker:
                break

            lines.append(line)

        return "".join(lines)

    def _draw_to_terminal(self, plot_text):
        """
        初回は cursor home せず、その場にプロットを出力する。
        2回目以降は左上へ戻ってプロットテキストを上書きする。

        プロットテキスト最後の改行だけを出力しない。
        sys.stdout.write("\\033[J") は使わない。
        """
        if self._first_draw:
            self._first_draw = False
        else:
            sys.stdout.write("\033[H")

        if plot_text.endswith("\r\n"):
            plot_text = plot_text[:-2]
        elif plot_text.endswith("\n") or plot_text.endswith("\r"):
            plot_text = plot_text[:-1]

        sys.stdout.write(plot_text)
        sys.stdout.flush()


def detect_delimiter(line):
    if "\t" in line:
        return "\t"
    return ","


def parse_numeric_value(line, value_column):
    line = line.strip()

    if not line:
        return None

    delimiter = detect_delimiter(line)

    reader = csv.reader(io.StringIO(line), delimiter=delimiter)
    row = next(reader, None)

    if not row:
        return None

    try:
        return float(row[value_column])
    except (ValueError, IndexError):
        return None


async def read_stdin_stream(plotter):
    loop = asyncio.get_running_loop()

    while True:
        line = await loop.run_in_executor(None, sys.stdin.readline)

        if line == "":
            break

        value = parse_numeric_value(line, plotter.value_column)

        if value is None:
            continue

        await plotter.add_value(value)


async def drain_stderr(proc):
    if proc.stderr is None:
        return

    while True:
        raw = await proc.stderr.readline()

        if raw == b"":
            break

        # sys.stderr.write(raw.decode("utf-8", errors="replace"))
        # sys.stderr.flush()


async def main():
    width, height = get_plot_size()

    plotter = StreamGnuplot(
        max_points=200,
        width=width,
        height=height,
        value_column=0,
        # title="stdin stream",
        # ylabel="value",
    )

    stop_event = asyncio.Event()

    def request_stop():
        stop_event.set()

    loop = asyncio.get_running_loop()

    for sig in (signal.SIGINT, signal.SIGTERM):
        try:
            loop.add_signal_handler(sig, request_stop)
        except NotImplementedError:
            pass

    try:
        loop.add_signal_handler(signal.SIGWINCH, plotter.request_resize)
    except (AttributeError, NotImplementedError):
        pass

    await plotter.start()

    stderr_task = asyncio.create_task(drain_stderr(plotter.proc))
    reader_task = asyncio.create_task(read_stdin_stream(plotter))
    stop_task = asyncio.create_task(stop_event.wait())

    done, pending = await asyncio.wait(
        {reader_task, stop_task},
        return_when=asyncio.FIRST_COMPLETED,
    )

    for task in pending:
        task.cancel()

    stderr_task.cancel()

    await plotter.stop()


if __name__ == "__main__":
    asyncio.run(main())

この記事を編集
この記事を共有:

コメント


前の記事
画像ファイル用にレガシー版 Windows フォト ビューアーを登録する
次の記事
Weston 内で Waydroid を実行する