Building a Model from the CLI
Folders, metrics, measures, chains, and bulk creates — the order to build in and the rules that bite.
Building a model by script is where the CLI earns its place. A four-hundred-measure model is tedious to click and straightforward to loop, and the result is reviewable and repeatable.
Build in dependency order#
Everything references something created earlier, so order matters:
- The instance —
--create - Foundations — time schemes and shifts, enumerations, limits, custom functions, model attributes
- Folders — the tree
- Templates, if you are using them — authored, then released
- Metrics
- Measures — retrieved first, then aggregations upward, then calculations
- Event schemes — triggers, then attributes
- Limits attached to measures
- Charts and dashboards
- Deployment
Retrieved before aggregated before calculated is not a style preference — an aggregation needs its source measure's ID, and a calculation needs its dependencies' IDs.
Capture IDs as you go#
Almost every verb returns or reports the ID of what it created, and the next step needs it. A build script is largely an exercise in keeping track of identifiers.
$fRoot = New-Folder "Riverside Plant" $null
$fUtils = New-Folder "Utilities" $fRoot
$mSteam = New-Metric "Boiler 1 Steam" $fUtils
$tag = New-TagMeasure $mSteam "Steam Flow" "010-FT-001.PV" "kg/s"
$hourly = New-Aggregation $mSteam "Steam Flow Hourly" $tag "hourly" "Average" "kg/s"
$daily = New-Aggregation $mSteam "Steam Flow Daily" $hourly "daily" "Average" "kg/s"
Where a name is more convenient than an ID, the listing verbs let you resolve one — useful after an instantiation, where you did not create the entities individually and do not know their IDs.
--create seeds two things#
A new instance is not empty. It gets:
- A root metric folder named after the instance
- A time scheme called Production
Recover the seeded folder with --list-metric-folders rather than creating a second root, and reuse the seeded time scheme if your calendar is going to be called Production.
A created instance also carries a platform and a data engine. If your script adds its own, the template verbs can no longer guess which to use and refuse until you pass --engine-id and --platform-id; see Templates and Federation from the CLI.
Folders have placement rules#
--create-metric-folder and --create-template-folder check the parent's type, not only that it exists, and refuse the shapes the GUI refuses:
| Folder type | May sit |
|---|---|
| Model metric folder | At root, or inside another model metric folder |
Template folder (--create-template-folder) |
At root, or inside another template folder |
Template metric folder (--create-metric-folder --view-type template) |
Inside a template folder or another template metric folder — never at root |
So a template tree starts with --create-template-folder, and the template metric folders go inside it with --parent-id. --move-folder applies the same rule.
Template-ness is inherited#
This rule catches people, and it changed for the better in 7.2.0.
Creating a measure under a template parent — a template metric, a template event scheme, or a template parent measure — produces a template measure, and bumps the parent's version. This now happens even when you do not pass --view-type.
The reasoning: --view-type defaults to model, so an explicit model is indistinguishable from the default, and silent inheritance from the parent is the only correct reading.
This applies to --create-measure, --create-tag-transform including its --parent-id-only form, and both bulk create verbs. --list-measures reads with the same rule, so a create is visible to the list you use to verify it.
On older builds, bulk creates inside a template produced model measures. Before this was fixed, a bulk create inside a template minted every row as an ordinary model measure — invisible to instantiation, with no version bump to announce it. If you maintain build scripts that ran against an earlier build, the templates they produced may be incomplete. Verify with --list-measures that template measures are actually template measures.
DisplayOrder appends#
Created measures take max(sibling) + 1 rather than 0, so a script builds a model whose members are ordered in the sequence you created them.
That is worth exploiting: create the chain in interval order — tag, hourly, daily, monthly — and the metric reads correctly in Flow Config and in chart pickers without any reordering.
Bulk creates#
For repetitive structures, --bulk-create-retrieved and --bulk-create-aggregation create many measures in one invocation. Substantially faster than one call each for a large model.
They follow the same template-inheritance and DisplayOrder rules as single creates. As with any bulk operation, assert the count afterwards — a bulk create that produced fewer rows than asked exits 0.
Calculations#
A calculated measure needs its expression and its dependencies. Expressions reference measures as Measure_<id>, so the IDs must be known:
$expr = "Measure_$daily.Value == 0 ? 0 : Measure_$water.Value / Measure_$daily.Value"
New-Calculation $mEff "Water per Tonne Steam" "daily" "l/t" $expr @($daily, $water)
Two practical rules for scripted expressions:
Keep them numeric. No formatting inside the expression — that is the measure's format string. And avoid double quotes in expressions where the surrounding script would have to escape them.
Create the function first if the expression calls one. A SafeDivide used by every ratio in the model should be created in the foundations step.
For moving windows, both the expression and the dependency reference the window:
Measure_1043[-7, 0].Values.Average()
Model attributes in tags and queries#
The attribute token contains real double quotes, which needs care in a shell:
$tok = '[ModelAttribute("Unit Number")]'
Single-quoted in PowerShell so nothing interpolates, and the double quotes reach the CLI intact. Build the tag as "$tok-FT-001.PV".
Event schemes#
Order within an event scheme matters too:
- Create the scheme on its folder
- Add triggers — start and end, each on a tag or a query
- Add attributes — manual ones binding to enumerations or enumeration groups, retrieved ones reading a source, calculated ones with expressions
- Create event-aggregated measures for the anchors you need
Calculated event attributes can reference the event's own duration — Period.Duration.TotalMinutes — and other attributes, which means inter-attribute dependencies need declaring in the same way calculation dependencies do.
--add-event-trigger --source-tag-id creates a trigger that fires on a tag. A trigger can also take a calendar or another event scheme as its source, and these are separate verbs because an event trigger is a shell whose source lives in its own row:
# start on a shift boundary
--attach-event-trigger-time-scheme --event-trigger-id <id> --time-scheme-id <id> --interval-type Shiftly
# start when another event scheme's event starts
--attach-event-trigger-event-scheme --event-trigger-id <id> --event-scheme-id <id> --trigger-type-id 1
Each refuses what the engine could not resolve: a second source on one trigger, a scheme triggering itself, or an attribute outside the source scheme. --trigger-type-id is required rather than defaulted.
--list-event-attributes --event-scheme-id <id> reports attribute IDs, which --attach-event-trigger-event-scheme --event-attribute-id and the chart verbs need. Add --measure-id to show only the attributes in that measure's event-attribute context.
Attaching context to measures#
A measure's calendars, event schemes, consumers and limits are attached after it exists, and each attach verb now writes everything the GUI's drag-and-drop would:
| Verb | What it writes, and what it refuses |
|---|---|
--attach-measure-time-scheme --measure-id --time-scheme-id |
The calendar link and its refresh. On a measure that already has an event scheme, the event scheme sets for the new calendar as well, so attach order does not matter |
--attach-measure-event-scheme --measure-id --event-scheme-id |
The link, the event scheme set on every calendar refresh, the TimeSliceAggregationType property, and a clear of the measure's time slices so the engine rebuilds them. Add --copy-attributes to carry every attribute into the measure's context. Re-running it on a measure that already has the scheme adds no second link; it re-writes the sets. Refused on a measure that aggregates a different event scheme — such a measure may only take the event it aggregates as context |
--attach-measure-consumer --measure-id --consumer-id |
The consumer binding, stamped with the default Integration Engine (the one named in system settings, else the first by name). Refused when the instance has no Integration Engine — create one with --create-integration-engine first. Lands undeployed; add --active to deploy it |
--attach-measure-limit --measure-id --limit-id |
The measure-limit link under one calendar or event scheme — --time-scheme-id or --event-scheme-id, defaulted from the measure's single active calendar when it has exactly one. Optional --comparator-type, --high, --low |
--attach-measure-time-scheme-refresh-trigger --measure-id --time-scheme-id --connection-id |
A refresh trigger on that calendar, so the measure re-reads when the source changes. One per calendar; the connection's plugin must support checking for modified data |
A consumer on a non-tag measure hangs under one of its calendars. A consumer on a tag (Timestamp) measure attaches to the measure itself. On any other measure the Integration Engine joins the binding on a calendar or event scheme, so the verb attaches it under one of the measure's own. With one calendar or event scheme it uses that one. With more than one it refuses and lists the choices — pass --time-scheme-id or --event-scheme-id to say which. Passing either on a tag measure is refused too.
Only tag measures require a realtime-capable consumer. A CSV, SQL or MQTT consumer attaches to an hourly or daily measure as normal, and is refused only on a tag.
Templates#
The sequence is unforgiving:
# author the template metric in template view
# then release it — this is mandatory
Release-Template "Metric" $tplMetric
# then instantiate
Instantiate $tplMetric $destFolder "Unit {0}" 3 'Unit Number=0{0}0'
where the helper runs --instantiate --source-entity-type Metric --source-entity-id <id> --destination-entity-type MetricFolder --destination-entity-id <folder> --naming-pattern "Unit {0}" --instance-count 3 --model-attribute-patterns 'Unit Number=0{0}0'.
Release before instantiate. Instantiating an unreleased template is refused with exit 1 and a message naming the --release command to run. An instantiation that creates zero entities — because matching instances already exist in the destination — still exits 0 with a warning, so assert the instance count afterwards regardless. {0} is the sequence number, starting at --naming-start (default 1), usable in both the name pattern and the attribute value patterns.
Afterwards, resolve the created entities by name to get their IDs. See Templates and Federation from the CLI.
Limits#
Create the limit, attach it to a measure with its ranges, then bind it onto a chart:
$lim = New-Limit "Steam Flow Band"
$ml = Attach-Limit $daily $lim $timeScheme $high $low
Bind-ChartLimit $chartItem $ml
--attach-measure-limit reports the MeasureLimit ID — the ID of the measure-to-limit link, not of the limit — and that is what a chart binds. If you did not capture it, --list-measure-limits --measure-id <id> lists the links on a measure (or --limit-id <id> the measures using a limit), with the calendar each sits under and whether a chart item already binds it.
A limit slot only accepts a limit belonging to the measure on its parent chart item, so the chart must already carry that measure as a series. Add the series first, then bind with --update-chart-item-limit --chart-item-id <limit item> --measure-limit-id <ml>.
Deleting#
--delete-entity --entity-type folder|metric|eventscheme|measure --entity-id <id> removes an entity with the same cascade the GUI uses: calculations and their dependencies, aggregations, limits, consumers, connections, calendars and their triggers, event contexts and their attributes, model attributes, event triggers and their refresh triggers. An entity with children is refused unless you pass --force, which deletes the whole subtree in one transaction. --dry-run prints what would go.
Earlier 7.2.0 builds stopped on the first child table without a database cascade, so a folder holding an event scheme with refresh triggers, or a measure some calculation depends on, could not be deleted from the CLI at all.
Moving an event scheme past a data gap#
When an event scheme's source stopped delivering for a period and you want processing to resume at the present rather than crawl through the gap:
--gap-skip-to --entity-type eventscheme --entity-id <id> --gap-skip-to-date "2026-09-30 06:00"
This is the Event Scheme form's Skip To button. The date is UTC, must be after the scheme's current processing position and not in the future, and the verb applies to model event schemes only. The Data Engine acts on it at the start of its next cycle: open periods are closed at the current position and the scheme's position moves to the date. --dry-run shows the current position without setting anything.
What to skip during a build#
Two things worth deliberately not doing while building:
Do not deploy. Build the whole model, review it, then deploy as a separate step. Deploying mid-build starts engines against half-finished configuration.
Do not start engines. For the same reason. Infohub's own demo-model scripts build entirely without deploying and without starting anything.
Verify before you deploy#
At the end of a build, before deployment:
--list-measures --output-format json # count, units, intervals
--list-chart-tree --chart-id <id> # charts actually have series
--deployment-status # what will change when you deploy
Things worth asserting: every measure has a unit; every calculated measure's dependencies resolved; every totaliser chain has exactly one Counter; the measure count matches expectation; every chart has content.
The last is the most commonly missed. A chart created but never given a measure exits 0 and renders empty.
Diagnosing#
| Symptom | Cause |
|---|---|
| Exit 1 before anything ran | Parse error — check option spelling; it is --user, not --username |
| Exit 1 with a message | The verb refused — a missing ID, a precondition, or a placement the GUI would refuse too |
| Exit 10 | Database-level failure — connectivity, credentials, permissions |
| Instantiate refused as "not a released template" | Release the source first — the message names the command |
| Instantiation exited 0 and created nothing | Matching instances already exist in the destination |
| Instantiate refused for lack of a default engine or platform | The instance has more than one; pass --engine-id and --platform-id |
| Consumer attach refused | No Integration Engine on the instance, or the measure is on several calendars — name one |
| Event scheme attach refused | The measure aggregates a different event scheme |
| Template metric folder refused at root | Create a template folder first and nest it with --parent-id |
| Measures inside a template are model measures | An older build; verify with --list-measures |
| Expression will not compile | Dependency not declared, or a wrong measure ID |
| Members in the wrong order | Created out of interval order; DisplayOrder appends |
| Charts empty | Created but no measure dropped |
| Bulk create produced fewer rows than asked | Assert the count — it exits 0 regardless |
| Delete refused "has children" | Pass --force to delete the subtree |