Skip to content

Free tool

schedule-scan — how a schedule decides its value and its next change

A control schedule looks like the simplest object in a station. schedule-scan reads schedule-rt.jar with javap and prints the three properties worth knowing before a site asks why an output did not change: the horizon beyond which there is no next change at all, the calendar every boundary resolves through, and the single serial queue every schedule on the station shares.

  • Niagara schedules
  • scanLimit
  • Reads the shipped jars
  • No station needed
  • Read-only
  • MIT

Install and run

One file, standard library only, and nothing on the network: it reads a jar on disk. It needs a Niagara installation and a javap on the path, which the JDK that ships with Niagara already provides. Point it at the installation or set NIAGARA_HOME.

# download it next to wherever you are working
curl -O https://plantroomlabs.com/tools/schedule-scan.py

# read the schedule driver out of an installation
python3 schedule-scan.py /opt/Niagara/Niagara-4.15.5.22

What it printed here

This is the whole of one run against the installation on the machine that built this page, pasted by the script that publishes it rather than retyped. Four of the findings are worth reading before anything is driven off a schedule.

Ninety days is a horizon, not a performance setting. scanLimit defaults to 90 days and it is the window nextCov searches, so a change further out than that is reported as no next change at all — a null time, not a far-off one. Anything a graphic or a logic block drives off “when does this next change” inherits that horizon.

nextCov returns the next change of value, not the next boundary. It compares each candidate's output with equivalent() against the value already in effect and steps over any boundary whose value matches, so two back-to-back periods resolving to the same value are not a change.

The clock is a calendar. Chronometer extends java.util.GregorianCalendar, so every boundary resolves through that calendar's wall-clock, daylight-saving and timezone rules: a 06:00 edge is 06:00 local on both sides of a DST change, not a fixed number of hours apart. And clockChanged re-runs execute() on any clock step, so an NTP correction re-evaluates the output immediately rather than waiting for the next boundary.

Every control schedule on the station shares one thread. There is a single static ExecutionQueue named Schedule:Execution, built with maxThreads=1. Its maxQueueSize is 0, and enqueue() reads a non-positive maxQueueSize as no cap — so the shared queue is unbounded and strictly serial. Every schedule-driven write on the station runs one at a time on that one thread.

$ python3 schedule-scan.py $NIAGARA_HOME
How a control schedule picks its value and its next change
======================================================================

read from /opt/Niagara/Niagara-4.15.5.22
javap:    1.8.0_504
source:   modules/schedule-rt.jar

The schedule clock is a calendar, not a UTC counter
----------------------------------------------------------
  com.tridium.schedule.Chronometer extends java.util.GregorianCalendar.
  Every boundary is resolved through that calendar's own wall-clock,
  daylight-saving and timezone rules - a 06:00 edge is 06:00 local on
  both sides of a DST change, not a fixed number of hours apart.

Two forward-search horizons are defined, exact to the millisecond
----------------------------------------------------------
  _90_DAYS   =    7776000000 ms  =  90 days
  _365_DAYS  =   31536000000 ms  = 365 days

How far ahead it looks: scanLimit, default 90 days
----------------------------------------------------------
  BControlSchedule.scanLimit defaults to Chronometer._90_DAYS (the
  90-day horizon above). It is the window nextCov searches for the
  next change of output; a change further out than scanLimit is
  reported as no next change at all (a null time).

nextCov returns the next change of VALUE, not the next boundary
----------------------------------------------------------
  nextCov(now) reads getOutput(now), walks nextEvent forward, and
  compares each candidate's getOutput with equivalent() against the
  value already in effect - returning the first boundary whose value
  actually differs. Two back-to-back periods that resolve to the same
  value are not a change of value; nextCov steps over them. It stops
  at now.add(getScanLimit()) - the 90-day window above.

A clock step re-evaluates the output at once
----------------------------------------------------------
  clockChanged(delta) calls super.clockChanged then execute() straight
  away, so an NTP correction or a DST shift re-runs the schedule and
  re-posts the output immediately, rather than waiting for the next
  scheduled boundary to come round.

Every control schedule shares one serial, uncapped execution queue
----------------------------------------------------------
  BControlSchedule holds a single static ExecutionQueue, named
  "Schedule:Execution", built with maxThreads=1 (one worker) and
  maxQueueSize=0. enqueue() tests maxQueueSize and, when it is <= 0,
  skips the QueueFull cap entirely - so the shared queue is unbounded
  and strictly serial. Every schedule-driven write on the station,
  from every control schedule, runs one at a time on that one thread.

======================================================================
Measured, not guessed: every number and name above is read from
schedule-rt.jar by this script and re-read identically on each run.

What it reads, and what that does not cover

It reads schedule-rt.jar with javap and nothing else. No station is contacted and nothing is written — which is also the limit of what the output is worth. It is a reading of compiled code, so it says what the framework is built to do rather than what a schedule did over a bank holiday. The millisecond constants it prints are exact; what a given site does with them is a question for that site.

The numbers above came from one installation, 4.15.5.22, and the output names it. The controller this work usually targets runs 4.14.0.162. A different Niagara version is a different answer, so run it against yours rather than trusting this page.

Niagara schedules: special events and master copies is the configuration side of the same object — which copy wins, and what a special event does to the week.

The repository

The same file, MIT licensed, at github.com/UsamaIqbal0304/schedule-scan. Its README carries the findings and a run of the output, so the repository is checkable without downloading anything. Issues and pull requests are read.

The file you are downloading

Published here so the download is checkable rather than trusted. Both figures are read off the file served at /tools/schedule-scan.py when this page is built, so they cannot disagree with it.

PropertyValue
Fileschedule-scan.py
Size11,585 bytes
SHA-256 39b5f0128feaf525285ab988b99adea873971f8dc3fe9cf5344fd5b8ac863f99
Licence MIT — LICENSE.txt
Source github.com/UsamaIqbal0304/schedule-scan

To check it, on Linux sha256sum schedule-scan.py, on macOS shasum -a 256 schedule-scan.py, on Windows certutil -hashfile schedule-scan.py SHA256. A different digest means a different file — not necessarily a hostile one, but not this one.

The repository holds the same file, byte for byte, together with everything needed to re-run the checks this page's claims rest on — so they can be run rather than read about. Issues and pull requests there are read.

Also free

The others

Same idea, a different protocol or a different file. Every free tool.

Next step

Send the schedule that did not change the output.

The horizon, the value comparison and the shared queue are three different reasons for the same symptom, and the station holds enough to say which. That read costs nothing either way.