Skip to content
Fondazione ITS INCOM

Examples · Internet of Things

A greenhouse that waters itself

Soil moisture for four zones and a small weather forecast go in; the model decides through function calling which valves to open and for how long. Your code holds the guardrails, refuses every unsafe call, and keeps the log you can trust.

Intermediate · 40 min

The idea

The school garden in Legnano has four zones with a drip line and a valve each: tomatoes and lettuce in the greenhouse, herbs and strawberries outside. Every morning at 06:00 someone has to decide which valves to open, and for how long.

A model can make that decision from the soil readings and the forecast. With function calling it does not write text for a person: it asks your program to open_valve(zone, minutes) or to skip(zone, reason). The model only asks. Your code decides whether to do it, with three rules a model must never break:

The rules are in the system message too. Here you will see how often that is enough.

What you need

A key and the openai library, as in Your first API call in Python. If function calling is new to you, read Let the model call your functions first.

The data

The soil sensors, as moisture.json. Moisture and target are in percent:

{
  "time": "2026-10-09 06:00",
  "zones": {
    "tomatoes": {"where": "greenhouse", "moisture": 14, "target": 35},
    "lettuce": {"where": "greenhouse", "moisture": 38, "target": 40},
    "herbs": {"where": "outside", "moisture": 19, "target": 30},
    "strawberries": {"where": "outside", "moisture": 24, "target": 35}
  }
}

The forecast, as forecast.json. Look at it closely: there is little rain today, and more tonight.

{
  "place": "Legnano",
  "periods": [
    {"from": "2026-10-09 06:00", "to": "2026-10-09 12:00", "rain_mm": 0.0, "temp_max": 16},
    {"from": "2026-10-09 12:00", "to": "2026-10-09 18:00", "rain_mm": 0.5, "temp_max": 19},
    {"from": "2026-10-09 18:00", "to": "2026-10-10 00:00", "rain_mm": 3.0, "temp_max": 15},
    {"from": "2026-10-10 00:00", "to": "2026-10-10 06:00", "rain_mm": 3.0, "temp_max": 12}
  ]
}

The program

Save it as water.py, next to the two files. The valves are simulated: the program keeps a clock and prints what it would do.

import json
import os
import sys
from datetime import datetime, timedelta

from openai import OpenAI

client = OpenAI(
    base_url="https://api.ai.itsincom.org/v1",
    api_key=os.environ["API_KEY"],
)
MODEL = sys.argv[1] if len(sys.argv) > 1 else "qwen3.8-27b"

MAX_MINUTES = 15  # per valve, per morning
RAIN_LIMIT = 5.0  # mm in the next 24 hours: above this, no watering outside

with open("moisture.json") as f:
    soil = json.load(f)
with open("forecast.json") as f:
    forecast = json.load(f)
zones = soil["zones"]
rain = sum(period["rain_mm"] for period in forecast["periods"])  # the code adds up the rain, not the model

tools = [
    {"type": "function", "function": {
        "name": "open_valve",
        "description": "Open the valve of a zone now, for some minutes. Only one valve can be open at a time.",
        "parameters": {
            "type": "object",
            "properties": {
                "zone": {"type": "string", "enum": list(zones)},
                "minutes": {"type": "integer"},
            },
            "required": ["zone", "minutes"],
        },
    }},
    {"type": "function", "function": {
        "name": "skip",
        "description": "Do not water a zone this morning.",
        "parameters": {
            "type": "object",
            "properties": {
                "zone": {"type": "string", "enum": list(zones)},
                "reason": {"type": "string"},
            },
            "required": ["zone", "reason"],
        },
    }},
]

clock = datetime.fromisoformat(soil["time"])
open_zone, closes_at = None, None
done = {}  # zone -> what happened to it


def refuse(name, args):
    """The guardrails: the reason to refuse a call, or None when it is safe."""
    zone = args.get("zone")
    if zone not in zones:
        return f"there is no zone {zone!r}"
    if zone in done:
        return f"{zone} was already handled this morning"
    if name == "skip":
        return None
    minutes = args.get("minutes")
    if not isinstance(minutes, int) or not 1 <= minutes <= MAX_MINUTES:
        return f"minutes must be a whole number from 1 to {MAX_MINUTES}"
    if zones[zone]["where"] == "outside" and rain > RAIN_LIMIT:
        return f"{rain} mm of rain are forecast in the next 24 hours: no watering outside"
    if open_zone:
        return f"the valve of {open_zone} is open until {closes_at:%H:%M}: one valve at a time"
    return None


messages = [
    {"role": "system", "content": "You control the watering of the school garden in Legnano. For every zone, "
                                  "call open_valve or skip, once. Water a zone when its moisture is below its "
                                  "target: one minute of watering raises the moisture by about 1 point. "
                                  f"Rules: at most {MAX_MINUTES} minutes per valve; only one valve open at a "
                                  f"time; no watering outside if more than {RAIN_LIMIT} mm of rain are forecast "
                                  "in the next 24 hours. When you are done, write one short line per zone."},
    {"role": "user", "content": f"Soil now:\n{json.dumps(soil)}\n\nForecast:\n{json.dumps(forecast)}"},
]

for turn in range(8):
    answer = client.chat.completions.create(model=MODEL, messages=messages, tools=tools, temperature=0)
    message = answer.choices[0].message
    if not message.tool_calls:
        print("The model:", (message.content or "").strip())
        break
    messages.append(message.model_dump(exclude_none=True))
    for call in message.tool_calls:
        args = json.loads(call.function.arguments)
        reason = refuse(call.function.name, args)
        if reason:
            result = f"REFUSED: {reason}"
        elif call.function.name == "open_valve":
            open_zone, closes_at = args["zone"], clock + timedelta(minutes=args["minutes"])
            result = f"valve of {open_zone} open from {clock:%H:%M} to {closes_at:%H:%M}"
            done[args["zone"]] = result
        else:
            result = f"{args['zone']} skipped"
            done[args["zone"]] = result
        print(f"{clock:%H:%M}  {call.function.name}({json.dumps(args)})\n       -> {result}")
        messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
    if open_zone:  # the valve runs, closes, and only then the model may open the next one
        clock, open_zone = closes_at, None

print(f"\nRain forecast in the next 24 hours: {rain} mm")
for zone, z in zones.items():
    print(f"  {zone:13} {z['where']:10} moisture {z['moisture']:2} / {z['target']}  "
          f"{done.get(zone, 'NOT HANDLED')}")

Run it with python3 water.py, or give another model: python3 water.py gemma-4-26b. A morning takes two to four calls. In our runs it took from 3 seconds (Gemma) to over 30 (the Qwens, on a busy afternoon).

All the calls in one answer of the model happen at the same moment, the moment the answer arrives. Between two answers, the open valve runs and closes. So a model that asks for two valves in one answer is asking for two valves at once.

What you should see

06:00  open_valve({"zone": "tomatoes", "minutes": 15})
       -> valve of tomatoes open from 06:00 to 06:15
06:00  open_valve({"zone": "lettuce", "minutes": 2})
       -> REFUSED: the valve of tomatoes is open until 06:15: one valve at a time
06:00  skip({"zone": "herbs", "reason": "Rain forecast 6.5 mm in next 24h exceeds 5.0 mm threshold; no outdoor watering allowed."})
       -> herbs skipped
06:00  skip({"zone": "strawberries", "reason": "Rain forecast 6.5 mm in next 24h exceeds 5.0 mm threshold; no outdoor watering allowed."})
       -> strawberries skipped
06:15  open_valve({"zone": "lettuce", "minutes": 2})
       -> valve of lettuce open from 06:15 to 06:17
The model: - **Tomatoes (greenhouse):** Watered 15 min (max allowed). Moisture 14 → ~29; still below target 35, will need more later.
- **Lettuce (greenhouse):** Watered 2 min. Moisture 38 → ~40, at target.
- **Herbs (outside):** Skipped — 6.5 mm rain forecast in next 24 h exceeds the 5.0 mm threshold.
- **Strawberries (outside):** Skipped — 6.5 mm rain forecast in next 24 h exceeds the 5.0 mm threshold.

Rain forecast in the next 24 hours: 6.5 mm
  tomatoes      greenhouse moisture 14 / 35  valve of tomatoes open from 06:00 to 06:15
  lettuce       greenhouse moisture 38 / 40  valve of lettuce open from 06:15 to 06:17
  herbs         outside    moisture 19 / 30  herbs skipped
  strawberries  outside    moisture 24 / 35  strawberries skipped

Qwen3.8 knew the rule about one valve: it is in the system message and in the description of open_valve. It still asked for the lettuce while the tomatoes were watering. The code refused, the model read the refusal, and it asked again at 06:15. The plan was good; only the timing was unsafe, and the code caught it.

What the guardrails caught

We ran the program 16 times with four models. In 14 runs the code had to refuse at least one call:

Model Runs One valve at a time Rain Max 15 minutes
qwen3.8-27b 5 refused in 3 runs never broken never broken
qwen3.6-35b 4 refused in 4 runs never broken never broken
gemma-4-26b 4 never broken refused in 4 runs: herbs, 11 minutes never broken
ministral-3-14b 3 see below refused in 3 runs: herbs, 11 minutes refused in 2 runs: tomatoes, 21 minutes

The zone check and the “already handled” check never fired in our runs. They cost two lines each, and they make a wrong call harmless.

The model's summary is not the log

Two decisions were wrong, and no guardrail could catch them, because they were not unsafe:

And after its refusals, Ministral never called skip for the herbs. Its summary says “herbs: Skipped (rain forecast > 5.0 mm)”; the table printed by your code says NOT HANDLED. The table comes from what your code did, and it is the one to trust. Print the real moisture next to every decision, as here, and a person sees “38 / 40 lettuce skipped” at a glance.

Good to know

Exercise

All examples