TECH NOTE / 002

Stormworks HTTPデータロガーの仕組み

この仕組みの役割はグラフを作ることではなく、Stormworks内にある数値をゲームの外へ取り出し、一般的なCSVとして残すことです。

01 / OVERVIEW

ゲーム内の数値を、普通のファイルへ運ぶ

Stormworksのマイコンは、機体の速度、RPS、姿勢、操作入力などを数値として扱えます。しかし、そのままでは長時間の変化をゲーム外の道具で調べにくい。そこでLuaからPC上の小さなHTTPサーバーへ数値を送り、Pythonが1回の通信をCSVの1行へ変換します。

  1. 01Stormworks

    8つの数値入力と記録スイッチをLuaが読む。

  2. 02HTTP GET

    Tickと各チャンネルをURLのクエリへ並べて送る。

  3. 03Python

    localhostの8080番ポートでリクエストを受け取る。

  4. 04CSV

    PC時刻、Tick、8チャンネルを1行として追記する。

CSVになった後は、Excelやスプレッドシートなどで自由にグラフ化できます。データの採取と分析を分けられることが、この構成の利点です。

02 / LUA

Lua側:値をHTTPリクエストへ詰める

次は、note記事と同じ通信形式を保ちながら、処理のまとまりが見えるように整理した解説用コードです。Bool入力1が記録スイッチ、Number入力1〜8が記録対象です。

microcontroller.luaStormworks
local PORT = 8080
local SAMPLE_INTERVAL = 1
local CHANNEL_COUNT = 8
local tick = 0

function onTick()
  tick = tick + 1

  if not input.getBool(1) then
    return
  end

  if tick % SAMPLE_INTERVAL ~= 0 then
    return
  end

  local query = {"/log?tick=", tostring(tick)}

  for channel = 1, CHANNEL_COUNT do
    local value = input.getNumber(channel)
    query[#query + 1] = string.format(
      "&c%d=%.6f",
      channel,
      value
    )
  end

  async.httpGet(PORT, table.concat(query))
end

function httpReply(port, request, reply)
  -- 最小構成では応答本文を使わない
end
01

Tickは常に数える

tickonTick()が呼ばれるたびに増えます。記録スイッチがOFFの間も増えるため、設定した送信間隔より大きく飛んでいれば、その区間は送信されていないと判断できます。

02

記録する瞬間だけ先へ進む

Bool入力1がOFFなら、そのTickの処理をそこで終えます。さらにSAMPLE_INTERVALで送信間隔を間引きます。記事の設定では、1で毎Tick、6で約10Hz、60で約1Hzです。

03

8つの値をクエリへ変換する

送信先は/logです。Tickはtick、数値入力はc1c8という名前で並べます。小数は6桁へそろえ、毎回同じ形式にします。

04

非同期で送る

async.httpGet()は、Lua側のTick処理を待たせずHTTPリクエストを開始します。最小構成ではhttpReply()を空にしているため、受信や保存の成功確認までは行いません。

03 / HTTP

HTTP側:URLが1サンプルを表す

Luaが組み立てるリクエストは、概念的には次の形です。

GET /log?tick=720&c1=12.500000&c2=3.250000&...&c8=0.000000
/log
Python側がデータ記録用として受け付けるパス。
tick=720
Stormworks側で数えた時間順序。PC時刻とは別の基準です。
c1c8
8つのNumber入力。チャンネルの意味は機体側の配線で決まります。
8080
Luaの送信先とPythonの待受先を一致させるポート番号。

値はHTTPの本文ではなくURLのクエリとして送られます。8チャンネル程度なら構造が単純で、受信側も標準ライブラリだけで処理できます。

04 / PYTHON

Python側:リクエストをCSVの1行へ変える

次の解説用コードは、note記事の基本構成に、出力先の明示、空ファイルの判定、同時書込みを避けるロック、終了処理を加えたものです。外部ライブラリは使いません。

stormworks_logger.pyPython 3
from csv import writer
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from threading import Lock
from urllib.parse import parse_qs, urlparse

PORT = 8080
CHANNELS = range(1, 9)
OUTPUT = Path(__file__).with_name("stormworks_log.csv")
WRITE_LOCK = Lock()


class LogHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        request = urlparse(self.path)

        if request.path != "/log":
            self.send_error(404)
            return

        query = parse_qs(request.query)
        row = [
            datetime.now().isoformat(timespec="milliseconds"),
            query.get("tick", [""])[0],
            *[
                query.get(f"c{channel}", [""])[0]
                for channel in CHANNELS
            ],
        ]

        with WRITE_LOCK:
            needs_header = (
                not OUTPUT.exists()
                or OUTPUT.stat().st_size == 0
            )

            with OUTPUT.open(
                "a",
                newline="",
                encoding="utf-8",
            ) as stream:
                csv_writer = writer(stream)

                if needs_header:
                    csv_writer.writerow([
                        "PC_Time",
                        "Tick",
                        *[
                            f"Channel{channel}"
                            for channel in CHANNELS
                        ],
                    ])

                csv_writer.writerow(row)

        self.send_response(204)
        self.end_headers()

    def log_message(self, format, *args):
        return


if __name__ == "__main__":
    server = ThreadingHTTPServer(
        ("localhost", PORT),
        LogHandler,
    )
    print(f"Stormworks logger: http://localhost:{PORT}")

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()
01

localhostだけで待ち受ける

ThreadingHTTPServerlocalhost:8080へ結びます。同じPCからの通信だけを受ける構成で、LANやインターネットへ公開する必要はありません。

02

URLをパスと値に分解する

urlparse()/logとクエリを分け、parse_qs()tickc1を辞書として取り出します。別のパスは404で拒否します。

03

到着時刻とゲーム内時刻を両方残す

PC_TimeはPythonが受信したPC側の時刻、TickはLuaが付けたゲーム側の順序です。通信の遅れを含む時刻と、シミュレーション内の並びを分けて確認できます。

04

ヘッダーは最初の1回だけ書く

CSVが存在しないか空なら列名を書き、その後にデータ行を追記します。サーバーが複数のリクエストを並行処理しても書込みが重ならないよう、ファイル操作をロックで囲んでいます。

05

204で受信完了を返す

保存後は本文なしのHTTP 204を返します。Lua側は返信内容を使いませんが、HTTP通信を完了させるために応答自体は必要です。

05 / CSV

保存される列

作る側意味
PC_TimePythonリクエストを受け取ったPC側の時刻
TickLuaStormworks側で数えたTick番号
Channel1Channel8LuaNumber入力1〜8の値

チャンネル名は汎用のまま保存されます。どの列が速度、RPS、操作入力などに対応するかは、機体ごとに別途メモするか、分析用ファイル側で見出しを付け替えると扱いやすくなります。

06 / LIMITS

記録するときに意識すること

  • 送信間隔

    変化の速い制御値は細かく、燃料量など緩やかな値は粗く記録できます。必要以上に毎Tick送るとCSVが急速に大きくなります。

  • Tickの欠け

    この最小構成には再送処理がありません。記録スイッチを切った区間や通信できなかったサンプルは、設定した送信間隔を超えるTickの飛びとして現れます。

  • セッションの区切り

    Pythonは既存CSVへ追記します。試験ごとにファイル名を変えるか、実行前に以前のCSVを別の場所へ移すと混ざりません。

  • 公開範囲

    サーバーはlocalhostへだけ結びます。外部PCから受ける必要がない限り、待受先を0.0.0.0へ広げません。