System Architecture
One X-Edge AI box runs a self-contained three-service stack. Everything — configuration, model versions, history, logs — lives on the box; no cloud dependency exists at runtime.
flowchart LR
subgraph Box["X-Edge AI Box (Docker)"]
FE["Frontend\nnginx + React"] -->|REST + SignalR| BE["Backend\nASP.NET orchestration"]
BE -->|gRPC :50051| INF["Inference runtime\nPython + ONNX Runtime"]
BE --- DB[("SQLite\nconfig · history · logs")]
PLUG["Datasource plugins\nOPC UA · MQTT · CSV"] --- BE
end
SENSORS["PLCs · brokers · devices"] --> PLUG
PLUG -->|predictions written back| SENSORS
USER["Operator browser"] --> FE
| Service | Role | Port |
|---|---|---|
| Frontend | Dashboard UI, served by nginx | 80 (443 with TLS) |
| Backend | Orchestration: datasources, models, inference lifecycle, auth, persistence | 5000 |
| Inference | ONNX model execution (CUDA on the Jetson TX2, CPU fallback) | 50051 (gRPC) |
Datasource plugins
Section titled “Datasource plugins”Industrial protocols are implemented as plugins loaded by the backend at startup. Each plugin describes its connection settings as a typed schema, which the UI renders as a dynamic form — adding a protocol never requires a new UI build.
- Inputs (read path): OPC UA (polled every tick), MQTT (push with staleness guard), and CSV replay for testing.
- Outputs (write path): OPC UA, MQTT, and rotating CSV files.
An output tag’s address is used verbatim (an MQTT topic, an OPC UA NodeId, a
CSV column), and each value is scaled as
value × scale + offset.
The two paths fail differently on purpose: a dead input tag halts inference visibly (feeding a model stale data is the worst failure mode for anomaly detection), while a failing output sink never stalls inference — it raises a coalesced Output write failed notification instead.
Inference pipeline
Section titled “Inference pipeline”A flow binds one input datasource, one model version and zero or more output datasources. The flow is what you enable, and at most one flow is enabled at a time. Several flows can share an input, so you can keep variants ready and switch between them. See Flows.
flowchart LR IN["Input datasource\nOPC UA · MQTT · CSV"] --> M["Model version\n(enabled flow)"] M --> OUT["Output datasources\n0..N"] M --> DASH["Dashboard · history"]
The enabled flow’s input feeds its model:
- The backend samples mapped tags at the datasource’s sampling rate and streams them to the inference runtime over gRPC.
- The runtime maintains a sliding window of
window size × tag countsamples; once full, every new sample produces a prediction (health_score,failure_probability,rul_normalized). - Predictions flow back and fan out to the dashboard (SignalR), the history store, the flow’s output sinks, and the on-box SQLite audit trail.
Throughput therefore scales with the input’s sampling rate, not with the publisher’s rate — a 100 Hz sensor sampled at 10 Hz yields 10 predictions/s.
Backpressure is per-consumer: slow dashboards are never allowed to throttle inference (their events drop first), output sinks get a bounded lossless queue that slows the pipeline end-to-end when a sink can’t keep up, and non-finite predictions (NaN/Inf) are filtered and counted. All of these are visible as counters — see Monitoring.
Model lifecycle
Section titled “Model lifecycle”Models are authored off-box with modelctl (convert → validate → quantize → package), then uploaded through the UI. Upload validates the ONNX contract via the inference runtime (shapes + SHA-256) without loading it; the model only enters the engine when a flow that uses it is enabled. Loading is a zero-downtime hot swap, and versions are immutable — re-uploading a changed file under the same version is rejected. Rollback re-activates the previous valid version.
Real-time eventing
Section titled “Real-time eventing”Live dashboard updates (sensor batches, predictions, health, state changes, notifications) travel over a SignalR WebSocket hub. This stream is fire-and-forget and in-memory — it is never the source of truth. If a browser disconnects, missed events are not replayed; authoritative state always comes from the REST API and the on-box database.
Logging — three bounded layers
Section titled “Logging — three bounded layers”| Layer | Purpose | Bound |
|---|---|---|
Container stdout (docker logs) | Live debugging | 10 MB × 3 files per service |
| Rolling file on a persistent volume (Warning+) | Post-mortem forensics — survives container recreation and database corruption | 10 MB × 7 (backend) / × 6 (inference) |
| SQLite log store | The LogViewer page and notification alerts | 10,000 entries, swept every 60 s |
Per-tick pipeline lines are logged at Debug level; the default Info stream carries lifecycle events, heartbeats, and warnings only.
Configuration import / export
Section titled “Configuration import / export”Admins can export the full inference configuration — input and output datasources with their tag mappings, and the flows binding them to models — as one versioned JSON envelope, and re-import it on another box. Flows reference their datasources and model by name, so a file carries no database Ids.
Secrets are redacted on export and must be re-entered on the target. Imports run in a single transaction: any per-item failure rolls the whole file back and returns the complete error list, while an item skipped (most often a flow whose model is not on this box) is reported and the rest commits.
An import that would rewrite a datasource the enabled flow is using is refused before anything is written, naming the flow.