pamoja

An SDK for IoT, robotics, and drones

pamojaRev 0.2.0MIT licensepamoja.molex.cloud

Rules#

A profile decides at the node that reads. A rule decides between nodes: when one node's reading crosses a line, it switches an output on another node or publishes a message. A rule file is data like a profile, so a fleet retunes a rule by shipping a file:

JSON
{ "rules": [
  { "name": "water-when-dry",
    "when": { "topic": "garden/bed-1/moisture", "below": 30.0, "hysteresis": 5.0 },
    "then": [ { "drive": "bed-valve", "on": true } ],
    "otherwise": [ { "drive": "bed-valve", "on": false } ] }
] }

The valve opens when the bed dries below 30 and closes once it is wetter than 35. The hysteresis is the release band that stops a reading hovering at 30 from firing the rule on every sample. Each rule fires only as its condition sets or clears, and nothing in between.

ToUse
Run a rule file off a linkRuleEngine: it subscribes to each watched topic, reads each message, switches the outputs you hand it by the names the file uses, and publishes over the same link.
Decide in a program that moves its own messagesRuleEvaluator, the engine's deciding half: hand it each reading with its topic, and it says which rules set or cleared and what each calls for.

Both are in every language, and they decide alike, because the engine runs on an evaluator.

What the example does#

It runs a file of two rules on one raised bed: the watering rule above, which also publishes the valve's state, and an alarm when the bed is soaked past 60. A probe sends six readings, the engine holds the valve, and a watcher hears what the rules publish. Then it shows what goes wrong: a reading that is not a number, a topic no rule watches, two files no engine could run, and a rule with no release band.

It proves:

  • The same file loads in every language and says which topics to subscribe to and which outputs to hold.
  • A rule fires once on the way down, at 28, and the readings in between change nothing: 31 is above the line, and 33 is inside the release band.
  • One reading can fire two rules, in the order the file lists them: at 65 the watering rule clears and the alarm sets.
  • Each rule that fires carries its actions in the file's order, and a rule with nothing under otherwise clears with nothing to do.
  • The watcher hears every message the rules published, in order, and the valve switched exactly twice.
  • A reading that is not a number is refused on a watched topic, where it would otherwise leave every rule as it was without a word, and a topic no rule watches is not judged at all.
  • A file that watches a filter, or gives two rules one name, is refused with the rule and the reason.
  • With no release band, four readings hovering at the line fire the rule four times; with a band of 5, once.

Run it#

The example below is a program CI runs on every change, in each language, from a clone of the repository:

Rust

cargo run -p pamoja-examples --example rules

TypeScript

npm --prefix bindings/node run guides -- rules

Python

python bindings/python/guides/rules.py

C#

dotnet run --project bindings/dotnet/samples/Pamoja.Guides -- rules

Rust#

In Rust, Rules::from_json reads the file and Rules::check says what no engine could run. A RuleEngine runs the file off any link that can receive: connect checks the rules, refuses a rule that drives an actuator it was not given, and subscribes to every watched topic, and each step handles one message and returns what fired, Fired values with the rule, the edge, the reading, and the actions it carried out. A RuleEvaluator is the same decisions for a loop of your own.

From examples/guides/rules.rs:

Rust
    use pamoja_codec::JsonCodec;
    use pamoja_core::{Receive, Transport};
    use pamoja_kit::Edge;
    use pamoja_loopback::{LoopbackBroker, LoopbackTransport};
    use pamoja_profile::{Action, RuleEngine, Rules};

    // A rule is a file: the topic it watches, the line a reading crosses, the release
    // band that stops it firing over and over, and what to do on the way down and on the
    // way back. Two rules watch one bed here: one waters it when it dries past 30 and
    // stops once it is wetter than 35, and one raises an alarm when it is soaked past 60.
    let file = r#"{ "rules": [
  { "name": "water-when-dry",
    "when": { "topic": "garden/bed-1/moisture", "below": 30.0, "hysteresis": 5.0 },
    "then": [ { "drive": "bed-valve", "on": true },
              { "publish": "garden/bed-1/valve", "payload": "open" } ],
    "otherwise": [ { "drive": "bed-valve", "on": false },
                   { "publish": "garden/bed-1/valve", "payload": "closed" } ] },
  { "name": "flood-alarm",
    "when": { "topic": "garden/bed-1/moisture", "above": 60.0, "hysteresis": 5.0 },
    "then": [ { "publish": "garden/alarm", "payload": "waterlogged" } ] }
] }"#;

    // Three parties on one broker: the node that reads the bed, the engine that holds the
    // valve, and a watcher on the topics the rules publish to.
    let broker = LoopbackBroker::new();
    let mut probe = LoopbackTransport::new(broker.clone());
    let mut watcher = LoopbackTransport::new(broker.clone());
    probe.connect().await?;
    watcher.connect().await?;
    watcher.subscribe("garden/bed-1/valve").await?;
    watcher.subscribe("garden/alarm").await?;

    let valve = Valve::default();
    let mut engine = RuleEngine::new(
        Rules::from_json(file)?,
        LoopbackTransport::new(broker),
        JsonCodec,
    )
    .with_actuator("bed-valve", valve.clone());
    engine.connect().await?;
    println!(
        "watches   {}, and drives {}",
        engine.topics().join(", "),
        engine.actuators().join(", ")
    );

    // The bed dries out, is watered, and floods. A rule fires only as its condition sets
    // or clears, and the readings in between change nothing. At 65 two rules fire on one
    // reading, in the order the file lists them.
    for reading in [42.0f32, 31.0, 28.0, 33.0, 65.0, 50.0] {
        probe
            .send_text("garden/bed-1/moisture", &reading.to_string())
            .await?;
        let fired = engine.step().await?.expect("the link is up");
        if fired.is_empty() {
            println!("{reading:<10}nothing fired");
        }
        for one in &fired {
            let edge = match one.edge {
                Edge::Set => "set",
                Edge::Cleared => "cleared",
            };
            let actions: Vec<String> = one
                .actions
                .iter()
                .map(|action| match action {
                    Action::Drive { actuator, on } => {
                        format!("drive {actuator} {}", if *on { "on" } else { "off" })
                    }
                    Action::Publish { topic, payload } => format!("publish {payload} to {topic}"),
                })
                .collect();
            if actions.is_empty() {
                println!("{reading:<10}{} {edge}, with nothing to do", one.rule);
            } else {
                println!("{reading:<10}{} {edge}: {}", one.rule, actions.join(", "));
            }
        }
    }

    // The watcher heard every message the rules published, in the order they went out.
    let mut heard = Vec::new();
    for _ in 0..3 {
        let message = watcher.recv().await?.expect("a message");
        heard.push(message.text().expect("words").to_owned());
    }
    println!("heard     {}", heard.join(", "));
    let switches = valve.switches.lock().expect("valve lock").clone();
    let state = if switches.last() == Some(&true) {
        "on"
    } else {
        "off"
    };
    println!(
        "valve     switched {} times, and it is {state}",
        switches.len()
    );

What goes wrong, continuing from above:

From examples/guides/rules.rs:

Rust
use pamoja_profile::RuleEvaluator;

// A program that moves its own messages hands each reading to an evaluator, the
// engine's deciding half on its own, and carries out what it says.
let mut evaluator = RuleEvaluator::new(Rules::from_json(file)?)?;

// A reading that is not a number, such as the NaN a failed probe reports, is refused
// on a watched topic rather than leaving every rule as it was with nothing to say why.
match evaluator.evaluate("garden/bed-1/moisture", f32::NAN) {
    Ok(_) => println!("a reading of NaN was judged, which should never happen"),
    Err(error) => println!("refused   {error}"),
}

// A topic no rule watches is not judged at all, so even a NaN there says nothing.
if evaluator
    .evaluate("garden/bed-2/moisture", f32::NAN)?
    .is_empty()
{
    println!(
        "ignored   no rule watches garden/bed-2/moisture, so even a NaN there is not judged"
    );
}

// A file no engine could run is refused as it loads, with the rule and the reason.
for edited in [
    file.replace("garden/bed-1/moisture", "garden/+/moisture"),
    file.replace("\"flood-alarm\"", "\"water-when-dry\""),
] {
    match Rules::from_json(&edited).and_then(RuleEvaluator::new) {
        Ok(_) => println!("a file no engine could run was accepted, which should never happen"),
        Err(error) => println!("refused   {error}"),
    }
}

// With no release band, readings that hover at the line set and clear the rule on
// every sample, and each edge switches the valve. The band of 5 holds it through them.
let fires = |text: &str| -> std::result::Result<usize, Box<dyn Error>> {
    let mut judge = RuleEvaluator::new(Rules::from_json(text)?)?;
    let mut count = 0;
    for reading in [29.9, 30.1, 29.8, 30.2] {
        count += judge.evaluate("garden/bed-1/moisture", reading)?.len();
    }
    Ok(count)
};
let bare = fires(&file.replace("\"hysteresis\": 5.0", "\"hysteresis\": 0.0"))?;
let banded = fires(file)?;
println!(
    "chatter   4 readings hovering at 30 fire the rule {bare} times with no release band, {banded} with a band of 5"
);

TypeScript#

In TypeScript, @pamoja/profile loads the file into a RuleEngine, which throws an Error saying why for a file no engine could run. The engine takes any link with send, subscribe, and recv, and its outputs as functions under the names the file uses; listen refuses one it was not given and subscribes, and step resolves to what fired: each with the rule, the edge as 'set' or 'cleared', the reading, and the actions it carried out, each with a kind of RuleActionKind.Drive or RuleActionKind.Publish. run repeats step until its AbortSignal fires, and RuleEvaluator is the deciding half on its own.

From bindings/node/guides/rules.ts:

TypeScript
import { LoopbackBroker } from '@pamoja/loopback'
import { type RuleAction, RuleActionKind, RuleEngine } from '@pamoja/profile'

// A rule is a file: the topic it watches, the line a reading crosses, the release band
// that stops it firing over and over, and what to do on the way down and on the way back.
// Two rules watch one bed here: one waters it when it dries past 30 and stops once it is
// wetter than 35, and one raises an alarm when it is soaked past 60.
const file = `{ "rules": [
  { "name": "water-when-dry",
    "when": { "topic": "garden/bed-1/moisture", "below": 30.0, "hysteresis": 5.0 },
    "then": [ { "drive": "bed-valve", "on": true },
              { "publish": "garden/bed-1/valve", "payload": "open" } ],
    "otherwise": [ { "drive": "bed-valve", "on": false },
                   { "publish": "garden/bed-1/valve", "payload": "closed" } ] },
  { "name": "flood-alarm",
    "when": { "topic": "garden/bed-1/moisture", "above": 60.0, "hysteresis": 5.0 },
    "then": [ { "publish": "garden/alarm", "payload": "waterlogged" } ] }
] }`

// Says what one action does, in the words the output uses.
const described = (action: RuleAction) =>
  action.kind === RuleActionKind.Drive
    ? `drive ${action.actuator} ${action.on ? 'on' : 'off'}`
    : `publish ${action.payload} to ${action.topic}`

async function main() {
  // Three parties on one broker: the node that reads the bed, the engine that holds the
  // valve, and a watcher on the topics the rules publish to.
  const broker = new LoopbackBroker()
  const probe = broker.link()
  const watcher = broker.link()
  const link = broker.link()
  await probe.connect()
  await watcher.connect()
  await link.connect()
  await watcher.subscribe('garden/bed-1/valve')
  await watcher.subscribe('garden/alarm')

  // The engine runs the file off its link: it listens on every topic a rule watches,
  // switches the outputs it was given by the names the file uses, and publishes over the
  // same link. Here the valve is a list of the settings it was given.
  const valve: boolean[] = []
  const engine = new RuleEngine(file, link, { actuators: { 'bed-valve': (on) => valve.push(on) } })
  await engine.listen()
  console.log(`watches   ${engine.topics.join(', ')}, and drives ${engine.actuators.join(', ')}`)

  // The bed dries out, is watered, and floods. A rule fires only as its condition sets or
  // clears, and the readings in between change nothing. At 65 two rules fire on one
  // reading, in the order the file lists them.
  for (const reading of [42, 31, 28, 33, 65, 50]) {
    await probe.send('garden/bed-1/moisture', String(reading))
    const fired = (await engine.step())!
    const at = String(reading).padEnd(10)
    if (fired.length === 0) {
      console.log(`${at}nothing fired`)
    }
    for (const one of fired) {
      if (one.actions.length === 0) {
        console.log(`${at}${one.rule} ${one.edge}, with nothing to do`)
      } else {
        console.log(`${at}${one.rule} ${one.edge}: ${one.actions.map(described).join(', ')}`)
      }
    }
  }

  // The watcher heard every message the rules published, in the order they went out.
  const heard: string[] = []
  for (let i = 0; i < 3; i += 1) {
    heard.push((await watcher.recv())!.text!)
  }
  console.log(`heard     ${heard.join(', ')}`)
  console.log(`valve     switched ${valve.length} times, and it is ${valve[valve.length - 1] ? 'on' : 'off'}`)
  return { heard, valve, engine }
}

main()

What goes wrong, continuing from above:

From bindings/node/guides/rules.ts:

TypeScript
import { RuleEvaluator } from '@pamoja/profile'

function wrong(): void {
  // A program that moves its own messages hands each reading to an evaluator, the engine's
  // deciding half on its own, and carries out what it says.
  const judge = RuleEvaluator.fromJson(file)

  // A reading that is not a number, such as the NaN a failed probe reports, is refused on a
  // watched topic rather than leaving every rule as it was with nothing to say why.
  try {
    judge.evaluate('garden/bed-1/moisture', Number.NaN)
    console.log('a reading of NaN was judged, which should never happen')
  } catch (error) {
    console.log(`refused   ${(error as Error).message}`)
  }

  // A topic no rule watches is not judged at all, so even a NaN there says nothing.
  if (judge.evaluate('garden/bed-2/moisture', Number.NaN).length === 0) {
    console.log('ignored   no rule watches garden/bed-2/moisture, so even a NaN there is not judged')
  }

  // A file no engine could run is refused as it loads, with the rule and the reason.
  for (const edited of [
    file.split('garden/bed-1/moisture').join('garden/+/moisture'),
    file.replace('"flood-alarm"', '"water-when-dry"'),
  ]) {
    try {
      RuleEvaluator.fromJson(edited)
      console.log('a file no engine could run was accepted, which should never happen')
    } catch (error) {
      console.log(`refused   ${(error as Error).message}`)
    }
  }

  // With no release band, readings that hover at the line set and clear the rule on every
  // sample, and each edge switches the valve. The band of 5 holds it through them.
  const fires = (text: string) => {
    const rules = RuleEvaluator.fromJson(text)
    let count = 0
    for (const reading of [29.9, 30.1, 29.8, 30.2]) {
      count += rules.evaluate('garden/bed-1/moisture', reading).length
    }
    return count
  }
  const bare = fires(file.split('"hysteresis": 5.0').join('"hysteresis": 0.0'))
  const banded = fires(file)
  console.log(
    `chatter   4 readings hovering at 30 fire the rule ${bare} times with no release band, ${banded} with a band of 5`,
  )
}

Python#

In Python, pamoja.profile loads the file into a RuleEngine, which raises PamojaError for a file no engine could run, as a step does for a reading that is not a number on a watched topic. The engine takes any link with send, subscribe, and recv, and its outputs as callables, plain or async, under the names the file uses; listen refuses one it was not given and subscribes, step returns a list of RuleFired, whose edge compares equal to a pamoja.kit.Edge member and whose actions are RuleAction values with a kind equal to a RuleActionKind member, and run repeats it until its task is cancelled.

From bindings/python/guides/rules.py:

Python
import asyncio

from pamoja.loopback import LoopbackBroker
from pamoja.profile import RuleActionKind, RuleEngine

# A rule is a file: the topic it watches, the line a reading crosses, the release band that
# stops it firing over and over, and what to do on the way down and on the way back. Two
# rules watch one bed here: one waters it when it dries past 30 and stops once it is wetter
# than 35, and one raises an alarm when it is soaked past 60.
file = """{ "rules": [
  { "name": "water-when-dry",
    "when": { "topic": "garden/bed-1/moisture", "below": 30.0, "hysteresis": 5.0 },
    "then": [ { "drive": "bed-valve", "on": true },
              { "publish": "garden/bed-1/valve", "payload": "open" } ],
    "otherwise": [ { "drive": "bed-valve", "on": false },
                   { "publish": "garden/bed-1/valve", "payload": "closed" } ] },
  { "name": "flood-alarm",
    "when": { "topic": "garden/bed-1/moisture", "above": 60.0, "hysteresis": 5.0 },
    "then": [ { "publish": "garden/alarm", "payload": "waterlogged" } ] }
] }"""

def described(action) -> str:
    """Says what one action does, in the words the output uses."""
    if action.kind == RuleActionKind.DRIVE:
        return f"drive {action.actuator} {'on' if action.on else 'off'}"
    return f"publish {action.payload} to {action.topic}"


async def main() -> tuple[list[str], list[bool], RuleEngine]:
    # Three parties on one broker: the node that reads the bed, the engine that holds the
    # valve, and a watcher on the topics the rules publish to.
    broker = LoopbackBroker()
    probe = broker.link()
    watcher = broker.link()
    link = broker.link()
    await probe.connect()
    await watcher.connect()
    await link.connect()
    await watcher.subscribe("garden/bed-1/valve")
    await watcher.subscribe("garden/alarm")

    # The engine runs the file off its link: it listens on every topic a rule watches,
    # switches the outputs it was given by the names the file uses, and publishes over the
    # same link. Here the valve is a list of the settings it was given.
    valve: list[bool] = []
    engine = RuleEngine(file, link, actuators={"bed-valve": valve.append})
    await engine.listen()
    print(f"watches   {', '.join(engine.topics)}, and drives {', '.join(engine.actuators)}")

    # The bed dries out, is watered, and floods. A rule fires only as its condition sets or
    # clears, and the readings in between change nothing. At 65 two rules fire on one
    # reading, in the order the file lists them.
    for reading in [42, 31, 28, 33, 65, 50]:
        await probe.send("garden/bed-1/moisture", str(reading))
        fired = await engine.step()
        at = f"{reading:<10}"
        if not fired:
            print(f"{at}nothing fired")
        for one in fired:
            if not one.actions:
                print(f"{at}{one.rule} {one.edge}, with nothing to do")
            else:
                print(f"{at}{one.rule} {one.edge}: {', '.join(described(a) for a in one.actions)}")

    # The watcher heard every message the rules published, in the order they went out.
    heard = [(await watcher.recv()).text for _ in range(3)]
    print(f"heard     {', '.join(heard)}")
    print(f"valve     switched {len(valve)} times, and it is {'on' if valve[-1] else 'off'}")
    return heard, valve, engine


heard, valve, engine = asyncio.run(main())

What goes wrong, continuing from above:

From bindings/python/guides/rules.py:

Python
from pamoja.core import PamojaError
from pamoja.profile import RuleEvaluator

# A program that moves its own messages hands each reading to an evaluator, the engine's
# deciding half on its own, and carries out what it says.
judge = RuleEvaluator.from_json(file)

# A reading that is not a number, such as the NaN a failed probe reports, is refused on a
# watched topic rather than leaving every rule as it was with nothing to say why.
try:
    judge.evaluate("garden/bed-1/moisture", float("nan"))
    print("a reading of NaN was judged, which should never happen")
except PamojaError as error:
    print(f"refused   {error}")

# A topic no rule watches is not judged at all, so even a NaN there says nothing.
if not judge.evaluate("garden/bed-2/moisture", float("nan")):
    print("ignored   no rule watches garden/bed-2/moisture, so even a NaN there is not judged")

# A file no engine could run is refused as it loads, with the rule and the reason.
for edited in [
    file.replace("garden/bed-1/moisture", "garden/+/moisture"),
    file.replace('"flood-alarm"', '"water-when-dry"'),
]:
    try:
        RuleEvaluator.from_json(edited)
        print("a file no engine could run was accepted, which should never happen")
    except PamojaError as error:
        print(f"refused   {error}")


def fires(text: str) -> int:
    """Counts how often the rules fire as four readings hover at the line."""
    rules = RuleEvaluator.from_json(text)
    return sum(len(rules.evaluate("garden/bed-1/moisture", r)) for r in [29.9, 30.1, 29.8, 30.2])


# With no release band, readings that hover at the line set and clear the rule on every
# sample, and each edge switches the valve. The band of 5 holds it through them.
bare = fires(file.replace('"hysteresis": 5.0', '"hysteresis": 0.0'))
banded = fires(file)
print(
    f"chatter   4 readings hovering at 30 fire the rule {bare} times with no release band, "
    f"{banded} with a band of 5"
)

C##

In C#, a RuleEngine loads the file and throws PamojaException for one no engine could run; it holds native state and is disposed with using. It takes any ILink and its outputs as functions under the names the file uses; ListenAsync refuses one it was not given and subscribes, and StepAsync returns RuleFired records, whose Edge is the kit's Edge and whose Actions are RuleAction records with a Kind of RuleActionKind.Drive or RuleActionKind.Publish. RunAsync repeats it until its token is cancelled, and RuleEvaluator is the deciding half on its own.

From bindings/dotnet/samples/Pamoja.Guides/RulesGuide.cs:

C#
// A rule is a file: the topic it watches, the line a reading crosses, the release
// band that stops it firing over and over, and what to do on the way down and on
// the way back. Two rules watch one bed here: one waters it when it dries past 30
// and stops once it is wetter than 35, and one raises an alarm when it is soaked
// past 60.
const string file = """
    { "rules": [
      { "name": "water-when-dry",
        "when": { "topic": "garden/bed-1/moisture", "below": 30.0, "hysteresis": 5.0 },
        "then": [ { "drive": "bed-valve", "on": true },
                  { "publish": "garden/bed-1/valve", "payload": "open" } ],
        "otherwise": [ { "drive": "bed-valve", "on": false },
                       { "publish": "garden/bed-1/valve", "payload": "closed" } ] },
      { "name": "flood-alarm",
        "when": { "topic": "garden/bed-1/moisture", "above": 60.0, "hysteresis": 5.0 },
        "then": [ { "publish": "garden/alarm", "payload": "waterlogged" } ] }
    ] }
    """;

// Three parties on one broker: the node that reads the bed, the engine that holds
// the valve, and a watcher on the topics the rules publish to.
using var broker = new LoopbackBroker();
using LoopbackTransport probe = broker.Link();
using LoopbackTransport watcher = broker.Link();
using LoopbackTransport link = broker.Link();
await probe.ConnectAsync();
await watcher.ConnectAsync();
await link.ConnectAsync();
await watcher.SubscribeAsync("garden/bed-1/valve");
await watcher.SubscribeAsync("garden/alarm");

// The engine runs the file off its link: it listens on every topic a rule watches,
// switches the outputs it was given by the names the file uses, and publishes over
// the same link. Here the valve is a list of the settings it was given.
var valve = new List<bool>();
using var engine = new RuleEngine(file, link, new Dictionary<string, Func<bool, ValueTask>>
{
    ["bed-valve"] = on =>
    {
        valve.Add(on);
        return ValueTask.CompletedTask;
    },
});
await engine.ListenAsync();
Console.WriteLine(
    $"watches   {string.Join(", ", engine.Topics)}, and drives {string.Join(", ", engine.Actuators)}");

static string Described(RuleAction action) => action.Kind == RuleActionKind.Drive
    ? $"drive {action.Actuator} {(action.On == true ? "on" : "off")}"
    : $"publish {action.Payload} to {action.Topic}";

// The bed dries out, is watered, and floods. A rule fires only as its condition
// sets or clears, and the readings in between change nothing. At 65 two rules fire
// on one reading, in the order the file lists them.
foreach (int reading in new[] { 42, 31, 28, 33, 65, 50 })
{
    await probe.SendAsync("garden/bed-1/moisture", reading.ToString(CultureInfo.InvariantCulture));
    IReadOnlyList<RuleFired> fired = (await engine.StepAsync(TimeSpan.FromSeconds(5)))!;
    string at = $"{reading,-10}";
    if (fired.Count == 0)
    {
        Console.WriteLine($"{at}nothing fired");
    }

    foreach (RuleFired one in fired)
    {
        string edge = one.Edge == Pamoja.Kit.Edge.Set ? "set" : "cleared";
        Console.WriteLine(one.Actions.Count == 0
            ? $"{at}{one.Rule} {edge}, with nothing to do"
            : $"{at}{one.Rule} {edge}: {string.Join(", ", one.Actions.Select(Described))}");
    }
}

// The watcher heard every message the rules published, in the order they went out.
var heard = new List<string>();
for (int i = 0; i < 3; i++)
{
    heard.Add((await watcher.ReceiveAsync())!.Text);
}

Console.WriteLine($"heard     {string.Join(", ", heard)}");
Console.WriteLine($"valve     switched {valve.Count} times, and it is {(valve[^1] ? "on" : "off")}");

What goes wrong, continuing from above:

From bindings/dotnet/samples/Pamoja.Guides/RulesGuide.cs:

C#
// A program that moves its own messages hands each reading to an evaluator, the
// engine's deciding half on its own, and carries out what it says.
using var judge = RuleEvaluator.FromJson(file);

// A reading that is not a number, such as the NaN a failed probe reports, is
// refused on a watched topic rather than leaving every rule as it was with nothing
// to say why.
try
{
    judge.Evaluate("garden/bed-1/moisture", float.NaN);
    Console.WriteLine("a reading of NaN was judged, which should never happen");
}
catch (PamojaException error)
{
    Console.WriteLine($"refused   {error.Message}");
}

// A topic no rule watches is not judged at all, so even a NaN there says nothing.
if (judge.Evaluate("garden/bed-2/moisture", float.NaN).Count == 0)
{
    Console.WriteLine("ignored   no rule watches garden/bed-2/moisture, so even a NaN there is not judged");
}

// A file no engine could run is refused as it loads, with the rule and the reason.
foreach (string edited in new[]
{
    file.Replace("garden/bed-1/moisture", "garden/+/moisture"),
    file.Replace("\"flood-alarm\"", "\"water-when-dry\""),
})
{
    try
    {
        using var accepted = RuleEvaluator.FromJson(edited);
        Console.WriteLine("a file no engine could run was accepted, which should never happen");
    }
    catch (PamojaException error)
    {
        Console.WriteLine($"refused   {error.Message}");
    }
}

// With no release band, readings that hover at the line set and clear the rule on
// every sample, and each edge switches the valve. The band of 5 holds it through
// them.
static int Fires(string text)
{
    using var rules = RuleEvaluator.FromJson(text);
    return new[] { 29.9f, 30.1f, 29.8f, 30.2f }
        .Sum(reading => rules.Evaluate("garden/bed-1/moisture", reading).Count);
}

int bare = Fires(file.Replace("\"hysteresis\": 5.0", "\"hysteresis\": 0.0"));
int banded = Fires(file);
Console.WriteLine(
    $"chatter   4 readings hovering at 30 fire the rule {bare} times with no release band, {banded} with a band of 5");

Values at a glance#

A rule reads as a sentence: when garden/bed-1/moisture is below 30, drive bed-valve on, and otherwise drive it off. Every field of the file, with what it holds and its default, is under Every field below, generated from the published schema an editor checks the file with.

When a condition moves. Every condition starts cleared:

The lineSets when a reading isClears when a reading isAny other reading
belowbelow the lineabove the line plus the hysteresisleaves it as it was
aboveabove the linebelow the line less the hysteresisleaves it as it was

A reading exactly on the threshold does not set the condition, and one exactly on the far edge of the band does not clear it. The example's watering rule sets below 30 and clears above 35, and its alarm sets above 60 and clears below 55.

What fired:

FieldHolds
rulethe rule's name
edgeset when the condition became true, cleared when it stopped holding
readingthe reading that moved it
actionsthe rule's then actions when it set and its otherwise actions when it cleared, in the file's order

Rules that fire on one reading come back in the order the file lists them.

What a file is refused for, with the rule named in the reason:

The fileThe reason says
a field the format does not have, such as a misspelled hysterisisunknown field `hysterisis`, and the field it was probably meant to be
a $schema for another formatwritten in rules format 2, or that it is not a pamoja rules schema
a condition with both above and below, or neithernames both, or needs a line to cross
a drive with no on, a publish with no payload, or an action that does bothneeds, and the field it lacks, or not both
a rule with no namea rule needs a name
two rules with one nametwo rules share the name
a rule with no topicneeds a topic to watch
a rule watching a topic with + or #a filter; a rule watches one topic exactly
a threshold that is not a finite numberneeds a threshold that is a finite number
a hysteresis below zero, or not a finite numberneeds a hysteresis that is a finite number of zero or more
a rule with nothing under then or otherwisehas nothing to do
a drive with no actuator nameddrives an actuator with no name
a publish with no topic, or to a filterpublishes to no topic, or a filter rather than a topic

A reading is refused, as an error from evaluate or from the engine's step, when a rule watches its topic and it is not a finite number. The engine also refuses a payload its codec cannot decode, and, as it connects, a rule that drives an actuator it was not given.

The calls in each language:

Rust#

ToCall
load and check a fileRules::from_json(text), check(), to_json()
build one in codeRules::new().with(Rule::new(name, Condition::below(topic, line).with_hysteresis(band)).then(action).otherwise(action))
run it off a linkRuleEngine::new(rules, link, codec), with_actuator(name, output), connect(), step()
judge readings yourselfRuleEvaluator::new(rules), evaluate(topic, reading)
see what it needstopics(), actuators(), watches(topic), is_set(rule)

TypeScript#

ToCall
run it off a linknew RuleEngine(text, link, { actuators }), listen(), step(timeoutMs), run({ signal, onFired, onError })
load a fileRuleEvaluator.fromJson(text), toJson()
judge a readingevaluate(topic, reading), then each fired rule, edge, reading, actions
read an actionkind of RuleActionKind.Drive with actuator and on, or RuleActionKind.Publish with topic and payload
see what it needstopics, actuators, watches(topic), isSet(rule)

Python#

ToCall
run it off a linkRuleEngine(text, link, actuators=...), listen(), step(timeout), run(on_fired=..., on_error=...)
load a fileRuleEvaluator.from_json(text), to_json()
judge a readingevaluate(topic, reading), then each fired rule, edge, reading, actions
read an actionkind of RuleActionKind.DRIVE with actuator and on, or RuleActionKind.PUBLISH with topic and payload
see what it needstopics, actuators, watches(topic), is_set(rule)

C##

ToCall
run it off a linknew RuleEngine(text, link, actuators), ListenAsync(), StepAsync(timeout), RunAsync(onFired, onError, cancellationToken: token)
load a fileRuleEvaluator.FromJson(text), ToJson()
judge a readingEvaluate(topic, reading), then each fired Rule, Edge, Reading, Actions
read an actionKind of RuleActionKind.Drive with Actuator and On, or RuleActionKind.Publish with Topic and Payload
see what it needsTopics, Actuators, Watches(topic), IsSet(rule)

Every field#

These tables are generated from the published JSON Schema, rules-1.json, the same file an editor checks a rule file against. Name it as the file's $schema and an editor such as VS Code completes the fields and marks a wrong one as it is typed. The schema cannot say that two rules have different names, so the library's own check is the last word on that.

The rule file#

Rules between nodes: when one topic's reading crosses a line, drive an output or publish a message, and undo it once the reading comes back.

FieldValueRequiredWhat it does
$schematextnoThe format the file is written in: this schema's address, or a copy of it by the same file name. An editor reads it to check the file as it is typed.
ruleslist of objects, see rulesyesThe rules, checked against each reading in the order they are listed.

rules#

One rule: a line through one topic's readings, and what to do as a reading crosses it and comes back.

FieldValueRequiredWhat it does
nametextyesA name no other rule in the file has, such as water-when-dry. The engine reports it when the rule fires.
whenobject, see whenyesThe line a topic's readings cross. It names one side, above or below.
thenlist of drive and publish actionsnoWhat to do once, the moment the condition becomes true.
otherwiselist of drive and publish actionsnoWhat to do once, the moment the condition stops holding.

when#

The line a topic's readings cross. It names one side, above or below.

FieldValueRequiredWhat it does
topictextyesThe topic whose readings are watched, exactly as the node publishes to it. One topic, so no + or # wildcards.
abovenumbernoThe condition holds while readings are above this line.
belownumbernoThe condition holds while readings are below this line.
hysteresisnumber, at least 0no, 0How far back past the line a reading must come for the condition to clear, so readings hovering at the line do not fire it over and over.

then and otherwise, drive#

Switches an output the program running the rules holds under this name.

FieldValueRequiredWhat it does
drivetextyesThe output's name, such as bed-valve.
ontrue or falseyestrue to switch it on, false to switch it off.

then and otherwise, publish#

Publishes a message over the link the rules run on.

FieldValueRequiredWhat it does
publishtextyesThe topic to publish to. One topic, so no + or # wildcards.
payloadtextyesThe text to publish.

When it goes wrong#

A file no engine could run is refused as it loads. What gets past that shows up as a rule that never fires, fires too often, or leaves an output in the wrong state. The ones that cost an afternoon:

  • A rule never fires. A rule compares each message's topic with its own exactly, so garden/bed-1/moisture misses garden/Bed-1/moisture and garden/bed-1/moisture/. Check the topic the node really publishes to. A reading exactly on the threshold does not set the condition either.
  • The valve switches on every reading. A rule with no release band, which is what leaving out hysteresis gives, sets and clears each time a noisy reading crosses the line. Set the band wider than the sensor's noise.
  • A failed probe goes unnoticed, or stops the loop. A reading that is not a number on a watched topic comes back as an error, so the failure is seen. A loop that stops at the first error then stops every rule with it: log the error and go on to the next message.
  • The valve stays open after a restart. Conditions live in memory and start cleared, and a rule only clears from the set state. A node restarted with the valve open and the bed at 33 never closes it, because nothing sets the rule again until the bed dries past 30. Put every output in a known state as the program starts.
  • Two rules fight over one output. Rules act independently, so two rules that drive one actuator leave it wherever the last edge put it. Give each output one rule.
  • The engine will not start. A rule drives an output the engine was not given, and the reason names it. Give it under the name the file uses: with_actuator in Rust, actuators in the other languages.
  • Every reading fails to decode. The engine reads each payload with the codec the nodes publish in, so a node sending text to an engine built for CBOR fails every reading. Build the engine with the codec the nodes use.

Where next#

  • Device profiles: Named, ready-to-run device profiles from plain data or a JSON manifest, run as a node in every language.
  • Event bus: An in-memory typed publish and subscribe event bus, with publishers that never wait and subscribers that count what they miss.
  • Helpers: Plain-language helper math.
  • Also in Profiles and robotics: Robot motion, ROS 2 rules, Zenoh keys.

Reference#

Edit this page on GitHub