← Aung Kaung Myat/Walkthroughsプロジェクト解説
ROS 2 · infrastructure · repoインフラ · リポジトリ fleet_monitoring_ws

Watching a Robot Fleet

Three simulated robots stream position through Kafka to two independent consumers: one stores every reading in QuestDB, the other opens an alert in PostgreSQL when a robot stops moving (position variance < 0.001 m²). The whole fleet is one YAML file, and CI proves the chain with 4 of 4 assertions on every push.

シミュレーション上の3台のロボットの位置がKafkaを経て2つの独立したconsumerに届きます。1つは全データをQuestDBに保存し、もう1つはロボットの停止(位置の分散 < 0.001 m²)を検知してPostgreSQLにアラートを開きます。フリート全体は1つのYAMLで定義され、pushのたびにCIが4つのassertionでパイプラインを検証します。

ROS 2 HumbleGazebo HarmonicApache Kafka (KRaft) QuestDBPostgreSQLGrafana Docker ComposeGitHub Actions
Robotsロボット
3, defined in one file台(1ファイルで定義)
Databasesデータベース
2, different jobsつ(役割が別)
Stuck rule停止判定
variance分散 < 0.001 m²
CI assertionsCI assertion
4 of 4 pass合格
Smoke runSmoke test
369 rows in 25 s25 sで369行
Python
≈1 330 lines約1 330行
00

At a glance要点

The data path, the numbers CI produced, and the boundary of what is proved — before any detail.データの流れ、CIが出した数値、そして証明できている範囲。詳細はその後で。

Architecture — one position reading, robot to alertアーキテクチャ — 1件の位置データがロボットからアラートになるまで
Highlighted box = the one rule that opens and closes alerts. The bracket marks the path CI exercises; the robots themselves are not in it.強調したboxがアラートを開閉する唯一のルール。下の線はCIが検証する経路で、ロボット自体は含まれません。
tb1 — drive: linear 0.15, angular 0.3 tb1 drives a circle 円を走行 tb2 — drive: null, the alert fixture tb2 never moves 動かない(停止) tb3 — drive: linear 0.1, angular −0.5 tb3 reverse circle 逆向きに円走行 3 robots, one YAML 3台、1つのYAML not in CI CI対象外 Apache Kafka (KRaft): topics robot_odom + robot_odom_dlq; storage and detector are two consumer groups on the same topic Kafka robot_odom · robot_odom_dlq 2 consumer groups read the same topic independently 2つのconsumer groupが同じtopicを独立に読む ROS 2 odometry re-published as one JSON message per reading ROS 2 odometry re-published as one JSON message per reading ROS 2 odometry re-published as one JSON message per reading kafka_producer.py /tbN/odom → JSON kafka_consumer.py (104 lines): Kafka → QuestDB line protocol, dead-letter path, registry heartbeat at most once per 5 s Storage consumer 保存用consumer kafka_consumer.py every valid reading → QuestDB 有効データを全件 → QuestDB heartbeat to Postgres ≤ 1 / 5 s heartbeatをPostgresへ、最大5秒に1回 malformed → robot_odom_dlq 不正メッセージ → robot_odom_dlq anomaly_detector.py (124 lines): window 10, min 5 samples, var(x)+var(y) < 0.001 m² → stuck; edge-triggered, one alert per event Detector 検知器 anomaly_detector.py window 10 · min 5 samples window 10 · 最小5サンプル var(x)+var(y) < 0.001 m² → stuck edge-triggered · 1 alert / event edge-triggered · イベントごとに1件 consumer group: storage consumer group: detector QuestDB: 369 rows stored in the 25 s smoke run (375 expected at 3 robots × 5 Hz); stuck/moving events recorded as they happened QuestDB append-only: readings + stuck events 追記のみ:位置データ + 停止イベント 369 rows / 25 s 25 sで369行 line protocol 9009 · read on 8812 line protocol 9009 · 読み出し 8812 PostgreSQL: robots registry + alerts, status CHECK (open, acknowledged, resolved); one unresolved alert per robot per type PostgreSQL robots registry + alerts ロボット台帳 + アラート open → acknowledged → resolved human closes · auto-resolve on move 人がクローズ/再移動で自動resolve line protocol, TCP 9009; reconnect + one retry on a dead socket psycopg2, port 5432; INSERT … WHERE NOT EXISTS unresolved CI on every push · synthetic fleet · 25 s · 4 of 4 assertions pass pushごとのCI · 合成フリート · 25 s · assertion 4/4 合格
  • 01One YAML defines the whole fleet. 3 robots in config/fleet.yaml generate the Gazebo world (420 lines) and the RViz layout (127 lines); CI fails the build if the generated files drift from the config.1つのYAMLがフリート全体を定義。config/fleet.yamlの3台からGazeboのworld(420行)とRVizレイアウト(127行)を生成。生成ファイルが設定からずれるとCIがビルドを失敗させます。
  • 02Two independent consumers, two databases. One Kafka topic feeds a storage consumer (QuestDB, append-only) and a detector (PostgreSQL alerts, open → acknowledged → resolved); malformed messages go to a dead-letter topic, every service reconnects instead of dying.独立したconsumerが2つ、データベースも2つ。1つのKafka topicを保存用consumer(QuestDB、追記のみ)と検知器(PostgreSQLのアラート、open → acknowledged → resolved)が読みます。不正なメッセージはdead-letter topicへ、全サービスは落ちずに再接続します。
  • 03Stuck rule: position variance < 0.001 m². Over a 10-sample window, edge-triggered — one alert per event, closed by a human or automatically when the robot moves again.停止判定:位置の分散 < 0.001 m²。直近10サンプルのwindowに対してedge-triggeredで判定。アラートはイベントごとに1件で、人がクローズするか、ロボットが再び動けば自動でクローズされます。
  • 04CI proves the pipeline: 4 of 4 assertions. Real container stack on every push, synthetic fleet for 25 s → 369 rows stored, ≥ 1 stuck event, ≥ 1 open alert, 3 of 3 robots registered. ≈1 330 lines of Python.CIがパイプラインを証明:assertion 4/4合格。pushのたびに実コンテナ構成を起動し、合成フリートを25 s動かして369行の保存、停止イベント≥ 1、openのアラート≥ 1、3台中3台の登録を確認。Pythonは約1 330行。
  • 05Proved: the data pipeline. Not proved: the robots. Gazebo, the bridge and the ROS 2 producer are outside CI and were never watched running; no latency or throughput figure; 3 simulated robots, no hardware.証明済みはデータパイプライン、ロボットは未証明。Gazebo、bridge、ROS 2 producerはCIの対象外で、動作を目視したこともありません。latencyやthroughputの数値はなく、シミュレーション上の3台のみで実機なし。
01

In plain words概要

What the project is, and what problem it exists to solve.

このプロジェクトが何であり、どんな問題を解くために存在するのか。

One robot is easy to watch. You look at it, or you open a terminal and read its messages. Ten robots in a building are not: nobody is looking at any individual one, and the interesting question stops being what is this robot doing and becomes which of these robots needs a human right now.

ロボットが1台なら監視は簡単です。直接見るか、ターミナルを開いてメッセージを読めば済みます。しかし建物内に10台あるとそうはいきません。 誰も個々のロボットを見ていない状況では、問うべきことは「このロボットは何をしているか」ではなく、 「今、人の対応が必要なのはどのロボットか」に変わります。

This project builds the plumbing that answers the second question. Robots publish their position continuously. That stream is collected, stored, and watched by a rule. When a robot has not moved for a while, the system files an alert with the robot's identifier and its last known position. The alert stays open until either a person closes it or the robot starts moving again, at which point it closes itself.

このプロジェクトは、後者の問いに答えるための基盤を構築します。ロボットは自己位置を常時publishし、そのストリームが収集・保存され、 ルールによって監視されます。ロボットが一定時間動かないと、システムはロボットのIDと最後の既知位置を付けてアラートを発行します。 アラートは、人がクローズするか、ロボットが再び動き出すまでopenのままで、後者の場合は自動的にクローズされます。

The thing that makes it more than a demoデモで終わらない理由

Nothing here is hand-wired. The fleet is described once, in config/fleet.yaml. The simulation world, the visualiser layout, the launch file, the message producers, the driving patterns and the tests all read that same file. Adding a fourth robot is five lines of configuration and one regeneration command — no code changes anywhere. A continuous-integration job refuses the change if the generated files and the configuration have drifted apart.

手作業で配線した部分はありません。フリートはconfig/fleet.yamlに一度だけ記述されます。シミュレーションのworld、 可視化ツールのレイアウト、launchファイル、メッセージのproducer、走行パターン、テストはすべて同じファイルを読み込みます。 4台目のロボットを追加するには、設定5行と再生成コマンド1回だけで済み、コードの変更は一切不要です。 生成ファイルと設定の内容がずれていれば、CIのジョブがその変更を拒否します。

Why it sits alongside the learning projects学習系プロジェクトと並べている理由

The other projects on this site are about making a model that works. This one is about the layer underneath that: the part that has to keep running while nobody is watching it, notice something is wrong, and still be inspectable afterwards. It is deliberately the infrastructure slot rather than the main line of work, and it is built the way that layer has to be built — every service restartable, every failure visible, every claim checked by a test.

このサイトの他のプロジェクトは、動くモデルを作ることがテーマです。本プロジェクトはその下の層を扱います。 誰も見ていない間も動き続け、異常に気づき、後から調査できなければならない部分です。 あえて主軸ではなくインフラ枠として位置づけていますが、その層に求められる作り方で構築しています。 すべてのサービスは再起動可能で、すべての障害は可視化され、すべての主張はテストで確認されています。

Terms used on this pageこのページの用語

odometryA robot's own running estimate of where it is and how fast it is going, computed from its wheels and sensors.ロボット自身が車輪やセンサーから逐次計算する、自己位置と速度の推定値。
message busA service that takes messages from producers and hands them to any number of independent consumers. Here, Apache Kafka.producerからメッセージを受け取り、任意の数の独立したconsumerに渡すサービス。本プロジェクトではApache Kafka。
topicA named channel on the bus. Producers write to it, consumers read from it, and neither knows the other exists.バス上の名前付きチャネル。producerが書き込み、consumerが読み出しますが、互いの存在は知りません。
time-series databaseA database built for append-only data stamped with a time. Fast to write, fast to query over a window, bad at changing rows after the fact.時刻付きの追記専用データ向けのデータベース(時系列DB)。書き込みと期間指定の検索は速い一方、後から行を変更するのは苦手です。
dead-letter queueA separate topic where messages that could not be processed are parked, with the reason, instead of being dropped or crashing the reader.処理できなかったメッセージを、破棄したり読み手をクラッシュさせたりせず、理由とともに退避しておく別のtopic。
sliding windowThe last N samples, with the oldest falling off as each new one arrives. The detector's memory.直近N個のサンプル。新しいサンプルが届くたびに最も古いものが外れます。検知器の記憶にあたります。
edge-triggeredActing on the moment a condition changes, not on every sample while it holds. One alert per event, not one per message.条件が成立している間の毎サンプルではなく、条件が変化した瞬間にだけ動作する方式。アラートはメッセージごとではなく、事象ごとに1件です。
healthcheckA command a container runs on itself so the orchestrator can tell "started" apart from "actually ready to serve".コンテナが自身に対して実行するコマンド。オーケストレーターが「起動しただけ」と「実際に処理可能」を区別できるようにします。
TF frameA named coordinate system in ROS 2. Two robots publishing the same frame name in one scene collide; prefixes keep them apart.ROS 2における名前付きの座標系。同じシーンで2台が同名のframeをpublishすると衝突するため、prefixで区別します。
02

The data pathデータの流れ

One position reading, from the simulated robot to the alert on a dashboard.

1件の位置データが、シミュレーション上のロボットからダッシュボードのアラートに届くまで。

stage 1段階 1Gazebo Harmonic Three TurtleBot3 models drive in the generated world. ros_gz_bridge carries odometry, velocity commands, joint states, transforms and the clock across to ROS 2. 生成されたworld内で3台のTurtleBot3が走行します。ros_gz_bridgeがodometry、速度指令、関節状態、座標変換、クロックをROS 2側へ中継します。 /tb1/odom · /tb2/odom · /tb3/odom
stage 2段階 2Producer nodeProducer(ROS 2 node) A ROS 2 node subscribes to every robot's odometry topic and re-publishes each reading as one JSON message. ROS 2 nodeが全ロボットのodometry topicをsubscribeし、各データを1件のJSONメッセージとして再送信します。 kafka_producer.py · 79 lines79行
stage 3段階 3Kafka The bus. Two topics: the odometry stream, and a dead-letter topic for anything unreadable. メッセージバスです。topicは2つで、odometryのストリームと、読み取れないメッセージ用のdead-letter topicです。 robot_odom · robot_odom_dlq
stage 4a段階 4aStorage consumer保存用consumer Reads the stream, writes each valid reading into QuestDB over the line protocol, and updates the robot's heartbeat in Postgres at most once every five seconds. ストリームを読み、有効なデータをline protocolでQuestDBに書き込みます。またPostgres上のロボットのheartbeatを最大5秒に1回更新します。 kafka_consumer.py · 104 lines104行
stage 4b段階 4bDetector検知器 Reads the same stream independently, keeps a sliding window of positions per robot, and opens or resolves alerts on transitions. 同じストリームを独立に読み、ロボットごとに位置のsliding windowを保持し、状態が遷移した時点でアラートをopenまたはresolveします。 anomaly_detector.py · 124 lines124行
stage 5段階 5Grafana + CLI Dashboards load themselves from the repository. A command-line tool lists robots and alerts and closes them. ダッシュボードはリポジトリから自動で読み込まれます。コマンドラインツールでロボットとアラートを一覧表示し、アラートをクローズできます。 localhost:3000 · fleet_cli.py
Why two independent readers読み手を2つに分けた理由

Storage and detection are separate Kafka consumer groups reading the same topic. Neither blocks the other, either can be restarted alone, and the detector can be changed without any risk to the data being recorded. That separation is the entire reason a bus is in the design rather than a direct database write from the robot.

保存と検知は、同じtopicを読む別々のKafka consumer groupです。互いにブロックせず、どちらも単独で再起動でき、 記録中のデータに影響を与えることなく検知器を変更できます。ロボットからデータベースへ直接書き込むのではなく、 設計にバスを入れている理由はこの分離にほかなりません。

03

Every file全ファイル

The whole repository, with line counts. Generated files are marked.

リポジトリ全体と各ファイルの行数。自動生成ファイルには印を付けています。

FileファイルLines行数What it does役割
config/fleet.yaml46The single source of truth: robots, spawn poses, colours, drive patterns, endpoints, detector thresholds.唯一の情報源(single source of truth)。ロボット、spawn位置・姿勢、色、走行パターン、接続先、検知器の閾値。
scripts/generate_fleet.py284Reads the yaml, writes the simulation world and the visualiser layout.yamlを読み、シミュレーションのworldと可視化ツールのレイアウトを書き出します。
worlds/fleet.sdf420Generated. The Gazebo world — ground plane, lighting, one model per robot at its spawn pose.自動生成。Gazeboのworld。地面、照明、各ロボットのspawn位置に置かれたモデル1つずつ。
config/fleet.rviz127Generated. RViz layout with one display group per robot.自動生成。ロボットごとに表示グループを1つ持つRVizレイアウト。
launch/multi_robot.launch.py104Starts the simulator, the bridge, one state publisher per robot, the static transforms, and optionally RViz.シミュレーター、bridge、ロボットごとのstate publisher、静的な座標変換、必要に応じてRVizを起動します。
pipeline/common.py181Shared plumbing: config loading, endpoint resolution, retrying connections, the QuestDB writer, the Postgres store.共通基盤。設定の読み込み、接続先の解決、リトライ付きの接続、QuestDB writer、Postgres store。
pipeline/kafka_producer.py79ROS 2 node. Every robot's odometry topic to one Kafka topic, as JSON.ROS 2 node。全ロボットのodometry topicを、JSONとして1つのKafka topicに送ります。
pipeline/kafka_consumer.py104Kafka to QuestDB, with the dead-letter path and the registry heartbeat.KafkaからQuestDBへの書き込み。dead-letterの経路と、ロボット台帳のheartbeat更新を含みます。
pipeline/anomaly_detector.py124Sliding-window stuck detection and the full alert lifecycle.sliding windowによる停止検知と、アラートのライフサイクル全体。
pipeline/mock_producer.py77Synthetic fleet emitting the identical message schema, so the pipeline runs with no simulator at all.同一のメッセージスキーマを出力する合成フリート。シミュレーターなしでパイプラインを動かせます。
scripts/smoke_test.py96Starts the three services, runs them for 25 seconds, then asserts four things about the databases.3つのサービスを起動して25秒間動かし、データベースについて4項目をassertします。
scripts/fleet_cli.py91List robots, list alerts, acknowledge one, resolve one.ロボット一覧、アラート一覧、アラートのacknowledgeとresolve。
scripts/drive_fleet.py56Publishes each robot's configured drive pattern to its velocity topic.各ロボットに設定された走行パターンを、その速度topicにpublishします。
dashboard/monitor.py138Terminal dashboard, querying QuestDB over the Postgres wire protocol.ターミナル用ダッシュボード。Postgres wire protocol経由でQuestDBに問い合わせます。
docker-compose.yml106Kafka, QuestDB, Postgres and Grafana, each with a healthcheck.Kafka、QuestDB、Postgres、Grafana。それぞれhealthcheck付き。
docker/postgres/init.sql27Two tables: the robot registry and the alert lifecycle, with their indexes.2つのテーブル(ロボット台帳とアラートのライフサイクル)と、そのインデックス。
docker/grafana/166Datasources and the Fleet Overview dashboard, provisioned from the repository.データソースとFleet Overviewダッシュボード。リポジトリから自動でプロビジョニングされます。
.github/workflows/ci.yml45Lint, a drift check on the generated files, then the smoke test against the real container stack.lint、生成ファイルのずれ検査、そして実際のコンテナ構成に対するsmoke test。
Makefile51The short commands: up, sim, rviz, pipeline, mock, drive, test, down, clean.短縮コマンド:up、sim、rviz、pipeline、mock、drive、test、down、clean。

Roughly 1 330 lines of Python across the pipeline, scripts, dashboard and launch file. The two highlighted service files are where all the interesting behaviour is.

パイプライン、スクリプト、ダッシュボード、launchファイルを合わせて、Pythonは約1 330行です。 重要な挙動はすべて、ハイライトした2つのサービスファイルにあります。

04

One file defines the fleet1つのファイルでフリートを定義

Why a configuration file generates the world instead of a human editing XML.

人がXMLを編集するのではなく、設定ファイルからworldを生成する理由。

A robot in this system exists in six places at once: as a model in the simulation world, as a display group in the visualiser, as a state publisher and a static transform in the launch file, as a topic subscription in the producer, as an entry in the synthetic fleet, and as a target in the driver. Keeping six hand-written places in agreement is the kind of job that quietly stops being done.

このシステムでは、1台のロボットが同時に6か所に存在します。シミュレーションworld内のモデル、可視化ツールの表示グループ、 launchファイル内のstate publisherと静的座標変換、producerのtopic subscription、合成フリートのエントリ、そして走行スクリプトの対象です。 手書きの6か所を常に一致させておく作業は、いつの間にか誰もやらなくなる類の仕事です。

So one file holds it, and the two file formats nobody wants to hand-edit are generated from it:

そこで情報を1つのファイルにまとめ、誰も手で編集したくない2種類のファイル形式はそこから生成します。

robots:
  - id: tb1
    model: turtlebot3_burger
    color: "0.9 0.5 0.0 1"
    spawn: {x: 0.0, y: 1.0, yaw: 0.0}
    drive: {linear: 0.15, angular: 0.3}    # drives a circle
  - id: tb2
    spawn: {x: 0.0, y: -1.0, yaw: 0.0}
    drive: null                            # never moves — the alert case
  - id: tb3
    spawn: {x: 0.0, y: 3.0, yaw: 1.57}
    drive: {linear: 0.1, angular: -0.5}    # circles the other way
generate_fleet.py
Emits worlds/fleet.sdf (420 lines) and config/fleet.rviz (127 lines). Both carry a "do not edit" header.
worlds/fleet.sdf(420行)とconfig/fleet.rviz(127行)を出力します。どちらにも「編集禁止」のヘッダーが付きます。
launch, pipeline, driverlaunch・パイプライン・走行スクリプト
Read the same yaml directly at start-up. No generation step, no duplication.
起動時に同じyamlを直接読み込みます。生成ステップも重複もありません。
detector thresholds検知器の閾値
Window size, variance threshold and minimum sample count live in the same file, under detector:.
window size、分散の閾値、最小サンプル数も同じファイルのdetector:以下に置かれています。
endpoints接続先
Kafka, QuestDB and Postgres addresses resolve environment variable first, yaml second — so a container overrides them without a code change.
Kafka、QuestDB、Postgresのアドレスは環境変数を優先し、次にyamlを参照します。そのためコンテナ側からコード変更なしで上書きできます。
The check that makes it hold一貫性を保証する検査

Generated files drift the moment somebody edits the yaml and forgets to regenerate. So CI regenerates them and runs git diff --exit-code on the results. If the committed world does not match the committed configuration, the build fails with the command needed to fix it. The guarantee is not "we remembered" — it is "the build will not pass if we did not".

誰かがyamlを編集して再生成を忘れた瞬間に、生成ファイルはずれ始めます。そこでCIがファイルを再生成し、 その結果にgit diff --exit-codeを実行します。コミット済みのworldがコミット済みの設定と一致しなければ、 修正に必要なコマンドを表示してビルドが失敗します。保証の根拠は「忘れなかったこと」ではなく、「忘れたらビルドが通らないこと」です。

One robot in the fleet has drive: null. That is not an oversight — it is the test fixture. A robot that never moves is exactly the condition the detector exists to catch, so the default configuration always contains one, and the smoke test refuses to run if none is present.

フリート内の1台はdrive: nullになっています。これは見落としではなく、テスト用のfixtureです。 まったく動かないロボットこそ検知器が捉えるべき状態なので、デフォルト設定には必ず1台含まれており、 該当するロボットがいなければsmoke testは実行を拒否します。

05

Two databases, on purposeあえて2つのデータベース

Telemetry and state are different problems, so they get different stores.

テレメトリと状態は別の問題なので、保存先も分けています。

QuestDBPostgreSQL
Holds保存内容Every position reading, and every alert event as it happenedすべての位置データと、発生したすべてのアラートイベントThe robot registry and the current state of each alertロボット台帳と、各アラートの現在の状態
Shapeデータの性質Append-only, time-stamped, never updated追記のみ、時刻付き、更新なしRows that change:変化する行: open → acknowledged → resolved
Written by書き込みLine protocol over a raw TCP socket, port 9009生のTCP socket上のline protocol(port 9009)psycopg2, port 5432
Read by読み出しGrafana and the terminal dashboard, over the Postgres wire protocol on 8812Grafanaとターミナル用ダッシュボード(port 8812のPostgres wire protocol経由)Grafana andGrafanaと fleet_cli.py

The split is the point. A time-series database is built to accept a very high rate of immutable rows; asking it to update one row from open to resolved is using it against its design. A relational database handles that update trivially but is the wrong shape for tens of readings per second per robot, forever.

この分割こそが要点です。時系列データベースは、変更されない行を非常に高いレートで受け付けるために作られています。 1行をopenからresolvedに更新させるのは、その設計に逆らった使い方です。一方リレーショナルデータベースは、 その更新は簡単にこなせますが、ロボット1台あたり毎秒数十件のデータを際限なく受け続ける用途には向きません。

So the alert exists twice, deliberately, in two different senses. QuestDB records that a stuck event happened at a particular instant, and that record never changes. Postgres records that an alert is currently open, and that row is the one a human acts on.

そのため、アラートは意図的に2か所に、異なる意味で存在します。QuestDBは停止イベントがある時点で発生したことを記録し、 その記録は変わりません。Postgresはアラートが現在openであることを記録し、人が対応するのはこちらの行です。

CREATE TABLE alerts (
    id              BIGSERIAL PRIMARY KEY,
    robot_id        TEXT NOT NULL REFERENCES robots (robot_id),
    alert_type      TEXT NOT NULL,
    status          TEXT NOT NULL DEFAULT 'open'
                    CHECK (status IN ('open', 'acknowledged', 'resolved')),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    acknowledged_at TIMESTAMPTZ,
    resolved_at     TIMESTAMPTZ
);

The CHECK constraint is doing real work: the set of legal states is enforced by the database, not by the Python that writes to it. A future service with a bug cannot put an alert into a state the rest of the system does not understand.

CHECK制約は実際に役割を果たしています。許される状態の集合を強制しているのは、書き込む側のPythonではなくデータベースです。 将来バグのあるサービスが加わっても、システムの他の部分が理解できない状態にアラートを置くことはできません。

Grafana reads QuestDB through its Postgres wire protocol on port 8812, which means one datasource type covers both stores and no plugin is needed. Datasources and the dashboard are provisioned from files in the repository, so bringing the stack up produces working dashboards with nothing clicked.

GrafanaはQuestDBをport 8812のPostgres wire protocol経由で読むため、1種類のデータソースで両方のDBをカバーでき、プラグインも不要です。 データソースとダッシュボードはリポジトリ内のファイルからプロビジョニングされるので、スタックを立ち上げるだけで、 一切クリックせずに動作するダッシュボードが揃います。

06

The stuck detector停止検知器

One rule, one state machine, and the two database writes it triggers.

1つのルール、1つのstate machine、そしてそれが引き起こす2つのデータベース書き込み。

The rule is deliberately simple, and simple is the right choice here: a robot that is not moving has a position that is not changing, and "not changing" is measurable as variance.

ルールは意図的に単純にしており、ここではそれが正しい選択です。動いていないロボットは位置が変化せず、 「変化していない」ことは分散として計測できます。

10window sizeThe last ten positions per robot, oldest dropped as each arrives.ロボットごとの直近10個の位置。新しい値が届くたびに最も古い値を捨てます。
5minimum samples最小サンプル数Below this, the detector returns nothing — a robot that just appeared is not yet stuck.これ未満では検知器は何も返しません。現れたばかりのロボットは、まだ停止とは判定されません。
0.001variance threshold, m²分散の閾値(m²)Population variance of x plus variance of y. Below it, the robot is considered stuck.xの母分散とyの母分散の和。これを下回るとロボットは停止とみなされます。
xs, ys      = positions in the window
variance    = ( Σ(x − x̄)² + Σ(y − ȳ)² ) / n
is_stuck    = variance < 0.001

moving → stuck      : QuestDB event + open an alert row in Postgres
stuck  → moving     : QuestDB event + resolve that alert row
otherwise           : nothing at all

Edge-triggered, not level-triggeredlevel-triggeredではなくedge-triggered

The detector keeps a set of robot identifiers it currently believes are stuck. An alert fires on the transition into that set and a resolution fires on the transition out of it. A robot that stays stuck for an hour produces exactly one alert, not one per message. This is the difference between a system somebody watches and a system somebody mutes.

検知器は、現在停止中と判断しているロボットIDの集合を保持します。その集合に入る遷移でアラートが発行され、 集合から出る遷移でresolveされます。1時間停止し続けたロボットでも、アラートはメッセージごとではなくちょうど1件です。 これが「人が見続けるシステム」と「人がミュートしてしまうシステム」の差です。

Two layers of duplicate protection二重の重複防止

The in-memory set survives only as long as the process. If the detector restarts while a robot is still stuck, it will see the transition again and try to open a second alert. So the database refuses:

メモリ上の集合はプロセスが生きている間しか残りません。ロボットが停止したまま検知器が再起動すると、 同じ遷移を再び検出して2件目のアラートを開こうとします。そこでデータベース側が拒否します。

INSERT INTO alerts (robot_id, alert_type, severity, pos_x, pos_y)
SELECT %s, %s, %s, %s, %s
WHERE NOT EXISTS (
    SELECT 1 FROM alerts
    WHERE robot_id = %s AND alert_type = %s AND status <> 'resolved');

One unresolved alert per robot per alert type, enforced in the write itself rather than by a read-then-write that two processes could interleave. Resolution is the mirror image — a single UPDATE over every unresolved alert for that robot, returning the number of rows it actually changed, which is what gets logged.

未解決のアラートはロボット1台・アラート種別1つにつき1件までです。これを、2つのプロセスが割り込み合う可能性のある 「読んでから書く」処理ではなく、書き込みそのものの中で強制しています。resolveはその鏡像で、そのロボットの未解決アラートすべてに対して 1回のUPDATEを実行し、実際に変更された行数を返します。ログに記録されるのはその値です。

A detail that shows in the codeコードから読み取れる細部

The detector skips malformed messages with a bare continue and a comment saying the consumer service dead-letters them. That is correct while both services run — but it does mean that running the detector alone, without the storage consumer, silently discards bad messages instead of parking them. The division of responsibility is real, and it is a coupling worth knowing about.

検知器は不正なメッセージを素のcontinueで読み飛ばし、「consumerサービスがdead-letterに回す」というコメントを付けています。 両サービスが動いている間はこれで正しいのですが、保存用consumerなしで検知器だけを動かすと、不正なメッセージは退避されずに 黙って破棄されることになります。責任の分担は実在するもので、知っておくべき結合です。

07

When something breaks障害時の挙動

Four failure modes that were designed for rather than discovered.

後から見つかったのではなく、設計段階で想定した4つの障害モード。

01

A malformed message arrives不正なメッセージの到着

Parsing is a single function that raises on anything unexpected. The caller catches it, forwards the raw bytes plus the error text to robot_odom_dlq, logs a running count, and carries on. The poison message is inspectable afterwards; the consumer does not die.

パースは1つの関数で行い、想定外の入力にはすべて例外を投げます。呼び出し側がそれを捕捉し、生のバイト列とエラー文を robot_odom_dlqに転送し、累計件数をログに出して処理を続けます。問題のメッセージは後から調査でき、consumerは停止しません。

This one is not hypothetical. The synthetic producer once emitted different key names for velocity and timestamp, and it crashed the consumer. The dead-letter path is the fix that came out of it.

これは仮定の話ではありません。以前、合成producerが速度とタイムスタンプに異なるキー名を出力し、 consumerがクラッシュしました。dead-letterの経路は、そこから生まれた修正です。

02

A database or the bus is not up yetデータベースやバスがまだ起動していない

Every connection helper retries instead of failing: Kafka thirty times at two-second intervals, QuestDB and Postgres twenty times at three. Services can be started in any order, and a slow container start is not a crash.

接続用のヘルパーはすべて、失敗せずにリトライします。Kafkaは2秒間隔で30回、QuestDBとPostgresは3秒間隔で20回です。 サービスはどの順番で起動してもよく、コンテナの起動が遅くてもクラッシュにはなりません。

03

The QuestDB socket dies mid-run実行中にQuestDBのsocketが切れる

The writer holds a raw TCP socket for the line protocol. A write that raises BrokenPipeError or OSError triggers a reconnect and one retry of that same line, so a database restart costs at most a reading rather than the process.

writerはline protocol用に生のTCP socketを保持しています。書き込みでBrokenPipeErrorまたは OSErrorが発生すると、再接続して同じ行を1回だけ再送します。そのため、データベースが再起動しても失うのは 最大でデータ1件で、プロセスは落ちません。

04

The stack is "started" but not ready「起動した」が、まだ準備できていない

Every container declares a healthcheck and the stack is brought up with --wait, which blocks until they pass. Nothing in the scripts or CI sleeps for an arbitrary number of seconds and hopes.

すべてのコンテナがhealthcheckを宣言しており、スタックは--wait付きで起動されるため、healthcheckが通るまで待機します。 スクリプトにもCIにも、適当な秒数だけsleepして上手くいくことを祈るような処理はありません。

Also worth naming補足

Both consumers use auto_offset_reset="latest". That is the right choice for live monitoring — a service that restarts should show what is happening now, not replay an hour of history — but it does mean a consumer started after a producer misses whatever went past in between. The smoke test starts its consumers before its producer for exactly this reason.

両consumerともauto_offset_reset="latest"を使っています。ライブ監視ではこれが正しい選択です。 再起動したサービスが見せるべきは1時間分の履歴の再生ではなく、今起きていることだからです。ただしその結果、producerより後に起動した consumerは、その間に流れたメッセージを取りこぼします。smoke testがproducerより先にconsumerを起動しているのは、まさにこのためです。

08

What CI actually provesCIが実際に証明していること

The four assertions, the numbers they produced, and what they leave untested.

4つのassertion、その結果の数値、そしてテストされずに残る部分。

Every push brings up the real container stack — Kafka, QuestDB, Postgres, Grafana — runs the synthetic fleet for 25 seconds at five messages per second per robot, then queries both databases and asserts four things.

pushのたびに実際のコンテナ構成(Kafka、QuestDB、Postgres、Grafana)を立ち上げ、合成フリートをロボット1台あたり毎秒5メッセージで 25秒間動かしたあと、両方のデータベースに問い合わせて4項目をassertします。

AssertionQueryクエリResult結果
Odometry was storedodometryが保存されたcount() FROM robot_odom369 rows369行
A stuck event was recorded停止イベントが記録されたcount() FROM robot_alerts WHERE event = 'stuck'≥ 1
An open alert exists for the idle robot停止中のロボットにopenのアラートがあるalerts WHERE status = 'open'≥ 1
Every robot registered itself全ロボットが自身を登録したcount(*) FROM robots3 of 3

Three robots at five hertz for 25 seconds is 375 expected readings; 369 landed. The shortfall is the start-up and shutdown edges of a timed run, and it is the reason the assertion is greater than zero rather than an exact count — a test that demands 375 would fail on a slow runner for no useful reason.

3台×5 Hz×25秒で期待値は375件、実際に届いたのは369件です。不足分は時間指定の実行における起動時と終了時の端数で、 assertionを厳密な件数ではなく0より大きいとしているのもそのためです。375件を要求するテストは、遅いrunner上では 意味もなく失敗してしまいます。

The job also runs ruff over every Python directory, regenerates the world and RViz files and fails on any diff, dumps the last hundred lines of every service log if anything fails, and tears the stack down with its volumes whatever the outcome. Fifteen-minute timeout.

このジョブはほかにも、すべてのPythonディレクトリにruffをかけ、worldとRVizのファイルを再生成して差分があれば失敗させ、 何かが失敗したら各サービスログの末尾100行を出力し、結果にかかわらずvolumeごとスタックを破棄します。タイムアウトは15分です。

What it does not prove証明していないこと

CI exercises the Gazebo-free path only: synthetic producer to Kafka to both databases. The simulator, the bridge, the real ROS 2 producer node and the visualiser are not in CI and are not covered by any assertion. The end-to-end claim this project can make is about the data pipeline, not about the robots.

CIが検証するのはGazeboを使わない経路だけです。合成producerからKafkaを経て両データベースまでです。シミュレーター、bridge、 実際のROS 2 producer node、可視化ツールはCIに含まれず、どのassertionでもカバーされていません。 このプロジェクトがend-to-endで主張できるのはデータパイプラインについてであり、ロボットについてではありません。

09

What this does not doこのプロジェクトでできないこと

The honest boundary of the project, stated before anyone finds it themselves.

誰かに指摘される前に明記しておく、このプロジェクトの正直な範囲。

01

The simulator has never been watched runningシミュレーターの動作を目視確認したことがない

The launch file has been validated by inspection only — the three-robot fleet has not been brought up in Gazebo and RViz and observed. Every claim on this page about the data pipeline is backed by a test; the claims about the simulation layer are backed by code review and nothing else.

launchファイルはコードを読んで確認しただけで、3台のフリートをGazeboとRVizで起動して観察したことはありません。 このページのデータパイプラインに関する主張はすべてテストで裏付けられていますが、シミュレーション層に関する主張の根拠は コードレビューだけです。

02

Nothing is measured for speed速度は何も計測していない

There is no latency figure from robot to database, no throughput ceiling, and no answer to how many robots this handles. The Kafka topic is single-partition with one consumer per group; partitioning by robot identifier is designed but not built, and the "50+ robots" question is open.

ロボットからデータベースまでのlatencyの数値も、throughputの上限も、何台まで扱えるかの答えもありません。 Kafka topicは単一partitionで、consumerはgroupごとに1つです。ロボットIDによるpartition分割は設計済みですが未実装で、 「50台以上」に対応できるかは未解決のままです。

03

One detector, one rule検知器は1つ、ルールも1つ

Position variance catches a robot that has stopped. It does not catch a robot moving wrongly, a velocity spike, or a robot that has gone silent — dropout detection needs a timer over absence of messages, which is a different structure and is not written.

位置の分散で検知できるのは、停止したロボットです。誤った動きをするロボット、速度の急変、通信が途絶えたロボットは検知できません。 途絶の検知にはメッセージが来ないことを監視するタイマーが必要で、構造が異なるため実装していません。

04

Three simulated robots, no hardwareシミュレーション上の3台のみ、実機なし

No physical robot has ever connected to this pipeline. The message schema is what a ROS 2 odometry topic provides, so a real robot would fit — but that has not been demonstrated here.

このパイプラインに実機のロボットを接続したことは一度もありません。メッセージスキーマはROS 2のodometry topicが提供するものなので、 実機でも適合するはずですが、ここでは実証していません。

05

The producer is PythonproducerがPython製

The odometry bridge is a Python ROS 2 node. For a node in the hot path of every robot's sensor stream, C++ with librdkafka is the correct language, and rewriting it with a latency comparison against the Python version is the first item on the project's own list.

odometryの中継部分はPythonのROS 2 nodeです。全ロボットのセンサーストリームのhot pathにあるnodeには、librdkafkaを使うC++が適切です。 C++で書き直し、Python版とのlatencyを比較することが、このプロジェクト自身のTODOリストの最優先項目です。

10

Running it実行方法

Two paths: the full simulator, or the pipeline alone on any machine.

2通りの方法:シミュレーターを含む全体構成か、任意のマシンでパイプラインのみを動かすか。

Full stack全体構成

./scripts/start_all.sh          # everything

# or step by step
make up                         # Kafka, QuestDB, Postgres, Grafana — waits for healthchecks
make rviz                       # Gazebo Harmonic + RViz with the generated fleet
make pipeline                   # producer, consumer, detector
make drive                      # drive the robots per their configured patterns

No simulator neededシミュレーター不要の構成

make up
make mock                                 # synthetic fleet into Kafka
python3 pipeline/kafka_consumer.py        # separate terminal
python3 pipeline/anomaly_detector.py      # separate terminal

This second path is the one CI uses and the one that runs anywhere Docker does. It needs no ROS 2 installation and no simulator.

2つ目の方法はCIが使っているもので、Dockerが動く環境ならどこでも実行できます。ROS 2のインストールもシミュレーターも不要です。

Watching and closing alertsアラートの確認とクローズ

python3 scripts/fleet_cli.py robots              # registry and online status
python3 scripts/fleet_cli.py alerts --status open
python3 scripts/fleet_cli.py ack 3
python3 scripts/fleet_cli.py resolve 3
Grafana
localhost:3000 — the Fleet Overview dashboard loads itself.
localhost:3000 — Fleet Overviewダッシュボードが自動で読み込まれます。
QuestDB consoleQuestDBコンソール
localhost:9000 — raw SQL over the telemetry.
localhost:9000 — テレメトリに対して直接SQLを実行できます。
make test
The same four assertions CI runs, locally.
CIと同じ4つのassertionをローカルで実行します。
make clean
Stops everything and deletes the volumes for a full reset.
すべてを停止し、volumeを削除して完全に初期化します。
11

The short versionまとめ

What to take away in four sentences.

要点を4文で。

Three simulated robots publish their position; a message bus carries it to two independent readers, one that stores every reading and one that watches for a robot that has stopped, and an alert opened by the second can be acknowledged by a human or closed automatically when the robot moves again.

シミュレーション上の3台のロボットが自己位置をpublishし、メッセージバスがそれを2つの独立した読み手に届けます。1つはすべてのデータを保存し、 もう1つは停止したロボットを監視します。後者が開いたアラートは、人がacknowledgeするか、ロボットが再び動けば自動的にクローズされます。

The fleet is described in one configuration file that generates the simulation world and the visualiser layout, and continuous integration fails the build if the generated files and the configuration have drifted apart.

フリートは1つの設定ファイルで定義され、そこからシミュレーションのworldと可視化ツールのレイアウトを生成します。 生成ファイルと設定がずれていれば、CIがビルドを失敗させます。

Telemetry and state live in different databases because they are different problems, malformed messages are parked in a dead-letter topic rather than dropped, and every service reconnects rather than dying when something it depends on is not ready.

テレメトリと状態は別の問題なので別々のデータベースに置き、不正なメッセージは破棄せずdead-letter topicに退避し、 依存先の準備ができていないときもすべてのサービスは停止せずに再接続します。

What is proved is the data pipeline, by four assertions against the real container stack on every push. What is not proved is the simulator layer, the latency, or anything about scale — and those are stated on the page because a reviewer would find them anyway.

証明されているのはデータパイプラインで、pushのたびに実際のコンテナ構成に対する4つのassertionで確認しています。 シミュレーション層、latency、スケールに関することは証明されていません。レビューする人はいずれ気づくので、このページに明記しています。