Guide

Calculated agent

Derives new measurements from ones other agents already publish — totals, fallbacks, unit changes, state counts — and raises alarms on them. No field protocol, no commands.

Every other agent bridges a field protocol. This one doesn't talk to equipment at all: it subscribes to measurements already on the broker, evaluates an expression per point, and publishes the result as a new measurement — stored, queried, and alarmed like any other. It has no commands and no interlocks.

What it can read follows where it's connected. Associate it with a site and it sees that site's measurements; connect it straight to the Hub and it sees every measurement the Hub has, including those exported from sites. Measurements, alarms, and everything else shared with other agents are in Agent configuration.

01
The calc document

type is calc, with no protocol. The body takes only measurements and alarms — commands or interlocks are rejected. Measurements keep the usual name, equipment, type, store, and tags; alarms are written exactly as for any other agent, digital and discrete delay included:

config
{
  "plant-calc": {
    "type": "calc",
    "measurements": [
      {
        "name": "total_flow",
        "equipment": "plant",
        "type": "analogical",
        "store": true,
        "driver": {
          "type": "analogical",
          "inputs": { "a": "pump1::flow", "b": "pump2::flow" },
          "expr": { "add": ["a", "b"] }
        }
      },
      {
        "name": "pumps_running",
        "equipment": "plant",
        "type": "discrete",
        "driver": {
          "type": "discrete",
          "inputs": { "a": "pump1::flow", "b": "pump2::flow" },
          "expr": { "add": [{ "gt": ["a", 0.5] }, { "gt": ["b", 0.5] }] }
        }
      }
    ],
    "alarms": [
      {
        "name": "total_flow_high",
        "area": "plant",
        "type": "analog",
        "meas": "plant::total_flow",
        "conditions": {
          "high": { "value": 8, "severity": "medium", "deadband": 0.5 }
        }
      }
    ]
  }
}
Driver

Next to the signal type (and an optional conversion, as everywhere), the driver holds three fields:

  • inputs — local names mapped to the equipment::name of each measurement read. The expression only ever uses the local names.
  • expr — the value expression.
  • quality — optional quality expression, covered below.

An expression is a JSON tree: a bare number is a constant, a bare string is the current value of an input, and an object with one key is an operator applied to its children — {"add": ["a", "b"]}. Every value is a number while it's calculated; the result is converted to the measurement's type at the end, so a discrete point like pumps_running above simply sums two comparisons. The published schema checks each operator name and how many children it takes:

  • Arithmetic — add and mul (two or more children), sub (one child negates, two subtract), div, rem, pow, neg, abs.
  • Functions — sqrt, exp, ln, log10, floor, ceil, round (half away from zero), sign, and clamp — [x, low, high].
  • Aggregates — min, max, sum, avg — one or more children.
  • Comparison — gt, ge, lt, le — exact, [a, b]. eq and ne take a third child, an absolute precision: [a, b, 0] compares exactly (right for discrete values), [a, b, 0.01] treats analog values within that band as equal.
  • Logic — and, or, not, and if with required cond, then, and else.
  • Input metadata — {"quality": "a"} — 1 when input a is good, 0 when bad. {"timestamp": "a"} — its sample time in Unix seconds.

A condition is true when its value is finite and non-zero; comparisons and logic return 1 or 0. if, and, and or only evaluate the children they need.

Quality

Without a quality expression, the output is bad whenever any input is bad. Set one to choose for yourself: the output is good when that expression comes out finite and non-zero. Together with quality inside expr, that gives a value that survives one bad input — the sum while both pumps are good, whichever is good otherwise, and bad only when both are:

measurement
{
  "name": "flow_fallback",
  "equipment": "plant",
  "type": "analogical",
  "driver": {
    "type": "analogical",
    "inputs": { "a": "pump1::flow", "b": "pump2::flow" },
    "expr": {
      "if": {
        "cond": { "and": [{ "quality": "a" }, { "quality": "b" }] },
        "then": { "add": ["a", "b"] },
        "else": {
          "if": { "cond": { "quality": "a" }, "then": "a", "else": "b" }
        }
      }
    },
    "quality": { "or": [{ "quality": "a" }, { "quality": "b" }] }
  }
}

A failed evaluation — division by zero, a non-finite result, or a domain error such as sqrt of a negative number or ln of zero — publishes the point bad, with its last good value (or 0 if it never had one). The quality expression isn't consulted then.

Chaining points

A point can read another point of the same agent. The intermediate is published like any other measurement, and the agent evaluates them in dependency order in the same pass. A point that reads itself, or a cycle between points, is rejected:

measurement
{
  "name": "total_flow_m3h",
  "equipment": "plant",
  "type": "analogical",
  "driver": {
    "type": "analogical",
    "inputs": { "t": "plant::total_flow" },
    "expr": { "mul": ["t", 3.6] }
  }
}
02
Docker image

Image: khorde/calc-agent:latest. It doesn't serve an API — the only thing on 9090 is a liveness check, GET /health — same compose shape as every agent (see Agent configuration). Save the agent's service account key next to it, renamed to service-user.json. With no field to talk to, its own config.yaml is just the connection to Khorde:

config.yaml
agent:
  broker:
    ip: host.docker.internal
    port: "4222"
    tls: false
  credentials:
    url: http://auth.localhost:9310
    service_account_path: service-user.json
  input_coalescing:
    quiet_window: 25ms
    max_window: 250ms

input_coalescing is optional; the values above are the defaults. A general interrogation delivers inputs one at a time, so publishing on every update would emit a total made of one new value and one stale one. Instead, each new result waits for quiet_window with no further update before it's published, and max_window publishes anyway while updates keep arriving. Alarms are raised from the published value.

Limitations today

  • A point publishes only once every one of its inputs has been seen — there's no stored history to start from.
  • Expressions are stateless: no rate of change, integral, moving average, previous value, or timer.
  • The quiet window is a heuristic. A gap longer than quiet_window inside an interrogation publishes what has arrived so far.
Next

Agent configuration

Measurements and alarms in full. Agent configuration.

Where inputs come from

REST, IEC 60870-5-104, IEC 61850, and TwinCAT ADS agents.

Taking this past your laptop?

Talk to us