DEV Community

Cover image for Building RailPulse AI: Deploying an Open-Source Railway Dispatch Agent with Gemma and Google Cloud Run
Robina Mirbahar for Google Developer Experts

Posted on Edited on Originally published at github.com

Building RailPulse AI: Deploying an Open-Source Railway Dispatch Agent with Gemma and Google Cloud Run

Debuted live at DevFest Baku 2026

Author: Robina Mirbahar

AI/ML Google Developer Expert (GDE) | Google Cloud Champion Innovator

GitHub Blueprint: RobinaMirbahar/railpulse-gemma-cloudrun

YouTube Walkthrough: @RobinaCodesAI

RailPulse AI Banner

Robina Mirbahar presenting RailPulse AI, Gemma 4, and Cloud Run at DevFest Baku 2026, ADA University

Robina Mirbahar presenting RailPulse AI, Gemma 4, and Cloud Run at DevFest Baku 2026, ADA University.


Most artificial intelligence tutorials show you how to build chatbots that write poems, summarize PDFs, or generate boilerplate code.

Those demos are fun, but they do not reflect the harsh reality of engineering in mission-critical industries.

When you deploy AI to manage physical operations, like freight trains moving across thousands of kilometers, a generic chatbot is not just useless; it can be dangerous. A hallucinated speed limit or a delayed signal alert can lead to catastrophic hardware failure or derailment.

At DevFest Baku 2026 at ADA University, I wanted to show developers how to build an enterprise-grade AI system that solves a real operational problem. We built and deployed RailPulse AI, an autonomous, safety-critical railway dispatch and live-tracking agent powered by Google's open-weights Gemma models, the Google Agent Development Kit (ADK), and Google Cloud Run.

In this article, I will walk you through the complete architecture, how we used function calling with deterministic safety interlocks, and how you can run open-weights agents serverlessly in production.


The Real-World Engineering Problem

Railway corridors run on tight margins. Dispatchers must continuously monitor:

  • Real-Time Telemetry: GPS coordinates, axle bearing temperatures, and locomotive velocity.
  • Corridor Weather Extremes: Sudden crosswinds along coastal or mountain tracks.
  • Schedule Recovery: Balancing delays against passenger connection deadlines and single-track passing sidings.

When an emergency happens, dispatchers do not have time to sift through fragmented SCADA dashboards and thousand-line logs. They need an intelligent system that aggregates live telemetry, cross-references signaling rules, and synthesizes clear, actionable dispatch advisories.

Crucially, the system cannot hallucinate. It must combine the natural reasoning of an LLM with the deterministic predictability of hard-coded safety rules.


The RailPulse AI Architecture

Here is the high-level system design we implemented in RobinaMirbahar/railpulse-gemma-cloudrun:

┌─────────────────────────────────────────────────────────────┐
│                 Streamlit Operator Cockpit                  │
│       Interactive SVG Gauges • Multi-Language (EN/AZ/RU)    │
└──────────────────────────────┬──────────────────────────────┘
                               │ HTTPS / JSON
                               ▼
┌─────────────────────────────────────────────────────────────┐
│                 Google Cloud Run Service                    │
│                                                             │
│   ┌─────────────────────────────────────────────────────┐   │
│   │             Google ADK Agent Orchestrator           │   │
│   │                                                     │   │
│   │  ┌─────────────────┐       ┌─────────────────────┐  │   │
│   │  │   Gemma Model   │ ◄───► │  Tool Declarations  │  │   │
│   │  │  (Open-Weights) │       │   Function Calling  │  │   │
│   │  └─────────────────┘       └──────────┬──────────┘  │   │
│   └───────────────────────────────────────┼─────────────┘   │
│                                           ▼                 │
│   ┌─────────────────────────────────────────────────────┐   │
│   │       Deterministic Safety Interlocks (Python)      │   │
│   │   • Hard Wind Speed Limits (Wind > 65 km/h -> 80)   │   │
│   │   • Absolute Signal Overrides (Red Signal = STOP)   │   │
│   │   └───────────────────────────────────┬─────────────┘   │
│                                           ▼                 │
│   ┌─────────────────────────────────────────────────────┐   │
│   │         Pydantic Structured Advisory Schema         │   │
│   │           RailDispatchAdvisory Validation               │   │
│   └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

1. Grounding the Agent: Real-World Tools via Function Calling

Rather than letting Gemma guess railway conditions, we gave the agent access to dedicated telemetry tools using Google ADK function calling:

def get_live_train_telemetry(train_id: str) -> dict:
    """Fetch live GPS, speed, bearing temperatures, and locomotive health."""
    pass

def check_corridor_signals_and_wind(corridor_id: str) -> dict:
    """Query wayside signal states (Green/Yellow/Red) and track anemometers."""
    pass

def calculate_schedule_recovery(train_id: str, current_delay_min: int) -> dict:
    """Calculate speed profiles needed to recover schedule without exceeding corridor limits."""
    pass
Enter fullscreen mode Exit fullscreen mode

When an operator asks:

"Train 102 is approaching the Absheron corridor 14 minutes behind schedule. Can we accelerate to recover time?"

Gemma does not guess. It autonomously calls check_corridor_signals_and_wind(corridor="Absheron") and get_live_train_telemetry(train_id="102") to gather factual state before generating a response.


2. Deterministic Safety Interlocks: Why the LLM Does Not Make the Final Safety Call

In physical systems, you never give an LLM sole authority over life-safety decisions.

If the model experiences a temporary context confusion, it might suggest maintaining 140 km/h in a 90 km/h windstorm. To prevent this, we built a two-tier architecture:

  1. Tier 1 (Cognitive Reasoning): Gemma analyzes the situation, extracts the context, and proposes a schedule recovery plan.
  2. Tier 2 (Deterministic Guardrails): A hard-coded Python interlock inspects the output against physical invariants before delivering the advisory:
class SafetyInterlock:
    MAX_HIGH_WIND_SPEED_KMH = 80.0
    HIGH_WIND_THRESHOLD_KMH = 65.0

    @classmethod
    def enforce_guardrails(cls, advisory: RailDispatchAdvisory, wind_speed: float, signal_state: str) -> RailDispatchAdvisory:
        # Rule 1: High Wind Speed Clamp
        if wind_speed >= cls.HIGH_WIND_THRESHOLD_KMH:
            if advisory.recommended_speed_kmh > cls.MAX_HIGH_WIND_SPEED_KMH:
                advisory.recommended_speed_kmh = cls.MAX_HIGH_WIND_SPEED_KMH
                advisory.safety_flags.append(
                    f"GUARDRAIL ENFORCED: Wind speed {wind_speed} km/h exceeds threshold. Speed clamped to 80 km/h."
                )

        # Rule 2: Absolute Red Signal Override
        if signal_state == "RED":
            advisory.recommended_speed_kmh = 0.0
            advisory.action_required = "EMERGENCY_STOP"
            advisory.safety_flags.append("CRITICAL: Red signal detected. Immediate stop enforced.")

        return advisory
Enter fullscreen mode Exit fullscreen mode

This guarantees that even if an operator or a prompt edge case attempts to push the model beyond safe boundaries, the mathematical and physical guardrails cannot be bypassed.


3. Strict Output Contracts with Pydantic

To ensure the downstream operator dashboard never crashes due to unstructured freeform text, all agent advisories are validated against a strict schema:

from pydantic import BaseModel, Field
from typing import List, Optional

class RailDispatchAdvisory(BaseModel):
    train_id: str
    recommended_speed_kmh: float = Field(..., ge=0.0, le=160.0)
    delay_delta_minutes: int
    signal_status: str
    action_required: str
    advisory_summary: str
    safety_flags: List[str] = []
    confidence_score: float = Field(..., ge=0.0, le=1.0)
Enter fullscreen mode Exit fullscreen mode

If the agent’s response does not conform to this structure, the ADK pipeline rejects it before it ever reaches the dispatcher cockpit.


4. Packaging and Deploying on Google Cloud Run

To make RailPulse AI truly production-ready, we containerized the application with Docker and deployed it to Google Cloud Run.

Why Cloud Run for AI Agents?

  • Serverless Economics: Rail telematics spikes during train arrivals and yard movements, with quiet periods in between. Cloud Run scales dynamically from zero to handle spikes and scales back down when traffic subsides.
  • Air-Gapped and Sovereign Edge Ready: Because Gemma is an open-weights model, you can run inference entirely inside your own private VPC without sending sensitive operational telemetry to third-party proprietary APIs.
  • One-Command Deployment:
gcloud run deploy railpulse-agent \
    --source . \
    --platform managed \
    --region europe-west1 \
    --allow-unauthenticated \
    --memory 4Gi \
    --cpu 2
Enter fullscreen mode Exit fullscreen mode

5. Localized Operator Experience: Multi-Language Dispatch (EN / AZ / RU)

In international logistics corridors like the Trans-Caspian International Transport Route, operators speak multiple languages.

Because Gemma possesses strong multilingual representations, RailPulse AI seamlessly provides telemetry readouts, safety flags, and advisory summaries in English, Azerbaijani (Azərbaycan dili), and Russian (Русский), allowing local dispatchers to interact with the system in their native language while maintaining uniform safety semantics under the hood.


Key Lessons from Building RailPulse AI

Building this project and presenting it at DevFest Baku reinforced three fundamental principles for building agentic AI:

  1. Agents are More Than Prompts: A production agent is 20% prompt engineering and 80% systems engineering: tool definitions, schema validation, network resilience, and error handling.
  2. Deterministic Guardrails are Mandatory: In physical, industrial, or medical applications, never let an LLM have unchecked authority. Pair cognitive reasoning with deterministic code interlocks.
  3. Open-Weights Models are Production Ready: With models like Gemma and serverless platforms like Google Cloud Run, individual developers and enterprise teams can build custom, private, and fully auditable AI systems without paying thousands of dollars in proprietary API costs.

Explore the Code and Video Walkthrough

The complete codebase, Streamlit operator dashboard, Dockerfile, and Google ADK implementation are open-source and ready to clone:

Special thanks to the GDG Baku team, ADA University, and all the incredible participants at DevFest Baku 2026 for making this hands-on workshop such a rewarding experience!

Top comments (23)

Collapse
 
pengeszikra profile image
Peter Vivo •

👏👏👏
Congratulation!
Good to see someone who is care about real problem!

mathematical and physical guardrails cannot be bypassed.

I hope many of us try to found a right solution for a real problem.
👏👏👏

Collapse
 
robinamirbahar profile image
Robina Mirbahar Google Developer Experts •

Thank you, @pengeszikra Really appreciate you reading and highlighting that point. In physical systems like railways, you simply cannot let an LLM have unchecked authority over safety-critical thresholds. Combining cognitive reasoning with deterministic code interlocks has to become standard practice if we want autonomous systems trusted in the real world. Thanks for the support!

Collapse
 
robinamirbahar profile image
Robina Mirbahar Google Developer Experts •

by the way how did you got this subscription badge @pengeszikra

Collapse
 
pengeszikra profile image
Peter Vivo •

I was won a copilot vibe contest

Thread Thread
 
robinamirbahar profile image
Robina Mirbahar Google Developer Experts •

Cool .I wanted to win

Thread Thread
 
pengeszikra profile image
Peter Vivo •

Start many of them, for example on hacktober I just miss 2 weeks, but even that weeks also created something. Now we live in the golden ages of AI before singularity, so we have very strong tools to make our dreams comes to true.

Collapse
 
dafili profile image
Renhua Wei •

Two holes worth closing before this sits in front of a dispatcher: enforce_guardrails receives wind_speed and signal_state as arguments, so it only guards the values it was handed, and the bare wind_speed >= 65 has no unit binding attached to it -- an anemometer reporting m/s clears that threshold only at about 234 km/h. Bind the observation to the advisory (corridor id, source, timestamp) and re-check freshness inside the interlock; if the read is older than your poll interval or the tool call raised, return STOP instead of falling through to the model's number. The proof is two assertions: speed 140 with wind 70.6 must come out 80, and a stale observation timestamp must come out 0.

Collapse
 
robinamirbahar profile image
Robina Mirbahar Google Developer Experts •

This is gold @dafili, thank you for such a sharp systems-engineering review! Both of those points hit right at the heart of real-world safety engineering:

  1. Unit-bound types (km/h vs m/s): Passing raw floats is how Mars Climate Orbiter-style bugs happen. Wrapping telemetry fields in explicit unit-validated Pydantic types (or pint quantities) at the ingestion boundary so wind_speed_kmh rejects or converts raw m/s anemometer reads before enforce_guardrails ever runs is a must.
  2. Binding the signed observation directly to the interlock: Instead of trusting parameters passed back through the agent loop, enforce_guardrails should pull the immutable, timestamped sensor struct (corridor_id, source_id, observed_at_utc) directly from the telemetry store and verify freshness inside the guardrail itself.

I love those two exact unit test assertions (speed=140, wind=70.6 -> 80 and stale_timestamp -> 0). I am going to add both of those directly into the repo's test suite!

Collapse
 
dafili profile image
Renhua Wei •

Two traps when you write those assertions. Test the guardrail function, not the endpoint — if the assertion goes through the agent loop you are testing the model's mood, not the interlock:

def test_wind_clamp_from_ms_reading():
    obs = WindObservation(raw=17.5, unit="m/s")                     # 63 km/h, under the 65 clamp
    assert enforce_guardrails(speed_kmh=140, wind=obs).speed_kmh == 140
    with pytest.raises(ValidationError):
        enforce_guardrails(speed_kmh=140, wind=70.6)                # bare float must not be accepted

def test_stale_telemetry_boundary():
    assert enforce_guardrails(140, wind_ok, age_s=15).speed_kmh == 140
    assert enforce_guardrails(140, wind_ok, age_s=15.001).speed_kmh == 0
Enter fullscreen mode Exit fullscreen mode

Second: pin the freshness boundary, not just "a stale timestamp" — exactly poll_window and poll_window + 1ms must land on opposite sides, or the comparison flips silently the day someone rewrites > as >=. And assert on the returned JSON, never on the summary text: the failure that mattered was a model that named the crosswind in its explanation and still emitted 120 km/h, which any text assertion passes happily.

Thread Thread
 
robinamirbahar profile image
Robina Mirbahar Google Developer Experts •

100% agreed @dafili, especially on pinning both sides of the exact 15.000s vs 15.001s poll-window boundary (> vs >= off-by-one regressions are classic silent killers during refactors) and testing enforce_guardrails() as a pure, isolated function rather than routing unit tests through the stochastic LLM loop.

One additional detail I like pairing with your age_s boundary test in production telemetry is using a monotonic clock delta or rejecting negative clock skew (age_s < -max_clock_skew_s -> speed_kmh == 0). If a wayside sensor's NTP clock drifts ahead of the central dispatch container, a naive now - observed_at check produces a negative age_s that silently passes age_s <= 15 even if the sensor is stuck repeating a future-stamped packet!

So the full boundary contract on the structured output becomes:

  • age_s == 15.000 -> 140 (fresh boundary inclusive)
  • age_s == 15.001 -> 0 (stale boundary exclusive)
  • age_s < -0.500 -> 0 (clock-skew / invalid future timestamp clamp)
  • wind=19.61 m/s (70.6 km/h converted via WindObservation) -> 80

Really appreciate you sharing those exact pytest snippets. This thread is basically a mini-masterclass on why safety interlocks and LLM reasoning have to be tested with completely different harnesses!

Thread Thread
 
dafili profile image
Renhua Wei •

Your skew case is the one that actually survives review, with one refinement: a monotonic delta on the consumer side does not fix a producer whose clock runs ahead, because the two sides share no origin. Stamp received_at from the consumer's own time.monotonic() at ingest and derive age_s from that; keep the producer's observed_at only as telemetry (producer_offset_s), never as input to the freshness decision. Then a future-stamped packet fails on arrival instead of on age.

The other half is making the clamp observable. age_s < -0.5 -> 0 is the right safety output, but it also hides a wayside box with permanently broken NTP, and the dispatcher just sees a train held at restrictive with no cause. So the structured output needs stale_reason="clock_skew" next to the speed, and you alarm on that counter being nonzero — a clamp without a reason field is how this class of bug stays silent for a month.

The edge I would pin, with the reason asserted separately:

@pytest.mark.parametrize("age,expect", [(15.000, 140), (15.001, 0), (-0.500, 140), (-0.501, 0)])
def test_wind_age(age, expect):
    assert enforce_guardrails(wind_at(age)).speed_kmh == expect
Enter fullscreen mode Exit fullscreen mode

An int-only assertion passes even when a refactor routes clock skew down the ordinary stale path, which is exactly the failure you want a test to catch and not excuse.

Collapse
 
eye_java_420f1faa10ae8b86 profile image
Nan •

The 65 km/h clamp guards the decision, but what guards the input to the decision? A wind reading that is ten minutes stale is a number that passes every range check while describing weather that no longer exists. Three things make this boring instead of scary: every sensor value arrives with its age attached, the interlock checks age against a per-sensor TTL rather than checking the value against a threshold alone, and a read older than its TTL doesn't get clamped — it drives the interlock to its most restrictive state until fresh data lands. The dispatcher then sees "stale, holding at restrictive" instead of a confident number. It's a small contract change: the guardrail stops trusting values and starts trusting (value, age) pairs. Do you set the TTL per sensor from the telemetry's own publish rate, or is a single global staleness budget simpler to reason about in front of a dispatcher?

Collapse
 
robinamirbahar profile image
Robina Mirbahar Google Developer Experts •

I love how you phrased that @eye_java_420f1faa10ae8b86: "the guardrail stops trusting values and starts trusting (value, age) pairs."

In practice, a two-layer hybrid works best: per-sensor TTL in the backend interlock mapped to a single unified freshness state on the dispatcher UI.

At the ingestion and guardrail layer, a single global TTL breaks down quickly because different railway subsystems publish on very different cadences:

  • Wayside interlocking & track-circuit signal state: event-driven / sub-second heartbeat (TTL ~ 2 to 3s)
  • Locomotive GPS & axle-bearing thermal sensors: high-frequency telemetry (TTL ~ 10 to 15s, usually 2x to 3x the publish interval)
  • Corridor weather / anemometer stations: slower polling (TTL ~ 60 to 120s)

If you used a 60s global TTL, a 10-second-old signal state would be dangerously stale; if you used a 3s global TTL, the wind sensor would constantly trip false-stale holds. So each sensor type carries its own max_age_s = k * publish_interval_s in the Python interlock, but if any safety-critical input exceeds its own TTL, the operator cockpit surfaces one clear, unambiguous banner (STALE TELEMETRY [WIND_SENSOR]: HOLDING AT RESTRICTIVE) so the dispatcher never has to do mental math across different sensor clocks during an emergency.

Collapse
 
alexshev profile image
Alex Shev •

Rail dispatch is a good test case because an optimistic but wrong schedule has a physical cost. I would make every recommendation carry a confidence band plus an operator-facing explanation of the constrained resources and counterfactual: which conflict it resolves, and what it displaces. That makes the model useful for triage without turning a black-box score into automatic authority.

Collapse
 
robinamirbahar profile image
Robina Mirbahar Google Developer Experts •

Spot on @alexshev! Showing the counterfactual ("which conflict this resolves, and which downstream train or siding window it displaces") is what separates a real decision-support cockpit from a black-box oracle.

In single-track or bottlenecked rail corridors, every recovery action is a trade-off: holding a freight consist at Siding B clears the block for an oncoming express, but pushes the freight crew closer to their statutory hours-of-service limit. Extending the RailDispatchAdvisory Pydantic schema with explicit resolved_conflicts, displaced_resources (for example, secondary delay minutes added to opposing traffic), and an uncertainty/confidence band gives the human dispatcher immediate situational awareness to accept or override the plan in seconds. Love that framing of using the model for triage rather than unchecked authority!

Collapse
 
paullovelace profile image
Paul Lovelace •

The 65 km/h wind clamp is the detail that makes this real for me — a hard number in code, not a suggestion in a prompt. The part I'd want to see next is the one aifrontierpost already asked about: telemetry includes axle bearing temperatures, so where does the hot-bearing check live? If that's in the interlock too, this is the cleanest safety-critical agent pattern I've seen written up.

Collapse
 
robinamirbahar profile image
Robina Mirbahar Google Developer Experts •

Really appreciate that @paullovelace Yes, hot-box (axle bearing temperature) detection belongs 100% inside the deterministic Python interlock right alongside the wind clamp and signal state, never left up to prompt instructions alone.

In rail operations, bearing temperature usually has two hard thresholds: an elevated warning band (for example, above 85°C where you clamp speed and route the train to the next siding for inspection) and a critical thermal threshold (above 105°C or a rapid delta spike compared to adjacent axles on the same bogie, which forces an immediate 0 km/h emergency stop to prevent axle seizure). Having Gemma summarize why the bearing is heating up alongside track gradient and load weight is super helpful for the human dispatcher, but the actual speed clamp has to live in deterministic code.

Collapse
 
onelifevibe profile image
OneLifeVibe •

The deterministic safety interlock is the part that stuck with me — Gemma proposes a schedule recovery plan, but the hard-coded Python check against the 65 km/h wind threshold is what actually decides whether 145 km/h stands. That split between reasoning and enforcement is a clean answer to the 'never give an LLM sole authority over life-safety decisions' problem. The Pydantic output contract is a nice second layer too: the operator dashboard never has to parse freeform text. Did the interlock ever catch the model mid-recovery in testing, or did the wind-clamp scenario mostly surface in the DevFest demo?

Collapse
 
robinamirbahar profile image
Robina Mirbahar Google Developer Experts •

Great question @onelifevibe ! It actually caught the model multiple times during our stress testing before the DevFest Baku demo, and in a very revealing way.

Whenever we gave the scenario a severe schedule delay (for example, a passenger train running 25 minutes late with a tight single-track siding window ahead) and borderline high crosswinds (around 68 to 72 km/h), smaller quantized models would occasionally prioritize "making up schedule time" in their reasoning chain and propose 110 to 125 km/h while still acknowledging the wind in the text summary! That was our biggest "aha" moment during testing: an LLM can literally mention a hazard in its explanation while still outputting an unsafe numeric parameter in the JSON tool call. Watching the Python interlock catch that 120 km/h proposal and slam it down to 80 km/h in real time proved why you always need code enforcement sitting after the model.

Collapse
 
iictp profile image
IICTP •

This is a great example of an agent built around a real operational constraint rather than a demo. I especially liked the deterministic SafetyInterlock — capping speed when wind crosses the 65 km/h threshold instead of letting Gemma make the final safety call — and the Pydantic RailDispatchAdvisory contract that keeps the dashboard from breaking on freeform text. How did you handle a case where the telemetry feed itself goes stale mid-dispatch? Does the interlock fall back to a conservative speed, or hold the last advisory until fresh data arrives?

Collapse
 
robinamirbahar profile image
Robina Mirbahar Google Developer Experts •

Thank you @iictp! That is a great catch because in real SCADA and railway telemetry, dropped packets or stale sensor reads happen all the time, especially through mountain tunnels.

Holding the last advisory is actually dangerous if a train is entering a high-wind zone or approaching a signal change. In a production deployment, the interlock should treat stale telemetry as a degraded safety state rather than trusting the last cached number. Specifically, every telemetry payload carries a UTC timestamp, and if the delta between the current clock and the last sensor heartbeat exceeds the poll window (for example, 15 seconds), the interlock bypasses the LLM recommendation entirely, steps the speed target down to a conservative restricted-sight speed (or triggers a full hold depending on the block state), and flags a TELEMETRY_STALE warning on the operator cockpit. Never let the agent guess when the sensors go blind!

Collapse
 
devkursat profile image
Kürşat Yılmaz •

The obsession with wrapping remote APIs into multi-layered agent abstractions is creating massive hidden latency and failure cascades in production.

If you profile real-world workloads, bare-metal local inference (via direct C++ / llama.cpp or vLLM bindings) consistently beats fragile cloud wrappers on both predictability and cost. Tooling should eliminate abstraction penalties, not compound them.

Collapse
 
robinamirbahar profile image
Robina Mirbahar Google Developer Experts •

Spot on @devkursat , and I actually agree with you on the latency and abstraction tax! That is one of the main reasons we chose open-weight Gemma models for this architecture rather than locking into a proprietary cloud-only API.

When you own the weights, you can run the exact same Gemma model on bare-metal edge hardware at the wayside or locomotive level via vLLM or llama.cpp for deterministic sub-second latency, or inside a self-contained GPU container on Cloud Run when you want elastic central dispatch without managing Kubernetes clusters. The agent layer here is kept intentionally thin (just structured tool bindings + hard Python safety interlocks) so there are no multi-hop prompt chains adding unpredictable tail latency.