# GeneratorServer A self-contained OPC UA server implementing the **Generators companion specification** (`http://opcfoundation.org/UA/Generators/`) end to end: N simulated generating sets, a datasheet-driven simulation, DI and Machinery integration, and a per-set OpenUSD twin. It is the sibling of [`PumpDeviceIntegrationServer`](../PumpDeviceIntegrationServer), and the two are composed into one scene by [`SiteCompositionServer`](../SiteCompositionServer). ``` dotnet run --project samples/GeneratorServer -- \ --host localhost --port 62543 --generators 4 ``` | Argument | Default | Meaning | |---|---|---| | `--generators N` | 2 | How many sets to simulate (1…100) | | `--faults` | `true` | Let the first set develop faults on a slow rotation | | `--port` | 62543 | Endpoint port | | `--host` | `0.0.0.0` | Bind host | ## The machine A fictitious **SimGen Systems GenX-500** — 400 kW prime / 440 kW standby, 400/230 V, 50 Hz, four-pole, on a 12.5 L turbocharged inline-six. Full parameters, curves and trip points are in [`DATASHEET.md`](DATASHEET.md), and every one of them is a constant in [`GeneratorDatasheet.cs`](GeneratorDatasheet.cs), so the document, the address space and the simulation cannot drift apart. ## The model ```mermaid graph TD GS["GeneratorSet_n : GeneratorSetType
(DI DeviceType)"] GS --> ID["Identification
(Machinery)"] GS --> BB[MachineryBuildingBlocks] GS --> ST["OperatingState
GeneratorStateMachineType"] GS --> ENG["Engine
speed, oil, coolant, exhaust, fuel, hours"] GS --> ALT[Alternator] ALT --> L1[L1] ALT --> L2[L2] ALT --> L3[L3] GS --> CTL[Controller] GS --> FUEL[FuelSystem] GS --> COOL[CoolingSystem] GS --> LUBE[LubricationSystem] GS --> BATT[StartingSystem] ``` `GeneratorSetType` derives from the DI `DeviceType`, so — unlike the pump sample, whose `PumpType` derives from the Machinery `MachineType` — sets are created through `CreateDeviceAsync` and get DI registration and topology wiring for free. Most telemetry in the specification is **optional**, so the type fits both a bare air-cooled residential set and a fully instrumented industrial one. This sample models the instrumented case and opts in explicitly, including the `L2` and `L3` phases (optional because single-phase sets populate only `L1`). ## The simulation Load fraction is the **only** independent variable. Everything else follows from the curves in the datasheet, so the published values reconcile by construction rather than by coincidence: ``` V̇_f(x) = 3.67 + 100·x [L/h] η(x) = P(x) / (V̇_f(x) · ρ · LHV) S = P / PF I = S / (√3 · V_LL) f = N · p / 120 ``` Verified live against a running server at 87.17 % load: | Published | Value | Check | |---|---|---| | Real power | 348.69 kW | `0.8717 × 400 kW` | | Voltage / current / PF | 400 V / 629.11 A / 0.8 | `√3·V·I·PF = 348.69 kW` | | Fuel rate | 90.84 L/h | `3.67 + 100×0.8717` | | Efficiency | 38.9 % | `P / (V̇·ρ·LHV)` | | Frequency | 49.97 Hz | `1498.95 min⁻¹ × 4 / 120`, with governor droop | Each set carries its own state and a phase-shifted duty point, so no two sets in a plant report the same numbers — a four-set run showed 81.9 / 66.3 / 55.1 / 56.6 %. ## Control: state machine, protections, methods The three surfaces the specification defines for controlling a set are all wired, and all three are driven from **one** decider — the simulation's operating state. A state machine driven independently of the physics eventually reports a machine as running while the simulation says it is stopped, and nothing at run time notices. ### `OperatingState` `GeneratorStateMachineType` is mandatory on the type, so it already exists; the sample attaches behaviour to it in lifecycle mode rather than defining states: ``` Off ──▶ Ready ──▶ Starting ──▶ Warmup ──▶ Running ──┬──▶ Loaded ──▶ Cooldown ──▶ Stopping ──▶ Off │ └──▶ Synchronizing ──▶ Paralleled ──▶ Loaded ▼ Fault ──▶ Off any running state ──▶ Fault / EmergencyStopped ──▶ Off ``` Twelve states, twenty-two declared transitions. The first set energises a dead bus and closes straight onto it; every other set has to match voltage, frequency and phase to a bus that is already live, which is what puts `Synchronizing` and `Paralleled` on the path. Without that they would be states the model declares, the diagram draws and a client can never observe — so a test drives the simulation and asserts every declared state is actually entered. `CurrentState` carries the readable name **and** its `Id` property, because a client that gets a name it cannot resolve to a state node is no better off than one that got nothing. The states and transition numbers live in [`GeneratorStateMap.cs`](GeneratorStateMap.cs), apart from the node manager, because they are a statement about the model rather than about how this server publishes it — and because a test then holds them against `GeneratorSimulation.IsLegalTransition` in both directions. ### Protection alarms Four `GeneratorProtectionAlarmType` instances per set — **one per protection function**, not one instance whose `ProtectionFunction` changes. A set can trip on low oil pressure and overspeed in the same moment and an operator needs to see both, which is also how a real control panel annunciates. | Alarm | Supervises | Trips | Class | |---|---|---|---| | `LowOilPressureAlarm` | `LubricationSystem/OilPressure` | < 1.7 bar | shutdown | | `HighCoolantTemperatureAlarm` | `Engine/CoolantTemperature` | > 98 °C | shutdown | | `OverspeedAlarm` | `Engine/Speed` | > 1725 min⁻¹ | shutdown | | `OverloadAlarm` | `Alternator/LoadPercent` | > 110 % | warning | `OffNormalAlarmType` takes *healthy* as normal, so each instance carries `InputNode` pointing at the variable it actually supervises — otherwise a client can see that something tripped but not what was being watched. **A healthy set cannot trip.** The datasheet curves are bounded well inside every trip point — that is what a datasheet means — so a plant running to spec would leave all four alarms, the shutdown class, `ResetFaults` and the whole `Fault` branch of the state machine as code that never runs. The first set therefore develops a fault on a slow rotation (`CoolingFailure` → `OilPressureLoss` → `Overload` → `GovernorFailure`), trips, annunciates, shuts down and recovers. It is the first set rather than the last because that is the machine nearest the hero camera in the 3D twin — an alarm nobody can see demonstrates nothing. A fault deviates the *measurement* from the curve, which is what a real fault does, so the datasheet identities keep holding for every healthy set. Pass `--faults false` for a purely healthy plant. > **The trap that cost the most here.** `ProtectionFunction` is *mandatory* on the > type, so the generated factory materialises it. `IsShutdown` and `SubsystemName` > are *optional* and it does not. Calling `CreateOrReplaceIsShutdown(...).Value = x` > on its own **appears to work** — the child exists, it is returned by > `GetChildren`, it holds the value — but it has no `ReferenceTypeId`, so there is > no reference for a browse to follow and the property is simply absent from every > client's view. Optional members must be opted into with `AddXxx(context)` first. > This was found by reading the running server with a client, not by reading the > code, because the code looks correct. `GeneratorProtectionAlarmNodeTests` now > asserts that every published member carries a reference a client can follow. A shutdown trip is applied **after** every protection has been evaluated, not inside the loop. Stopping the set mid-loop makes its remaining conditions read healthy, so a set that lost oil pressure and overspeed in the same moment would annunciate only whichever came first in the table — exactly the case that one-alarm-per-function exists to show. **A shutdown-class alarm latches; a warning-class one follows its condition.** The trip is what removes the condition — oil pressure and coolant temperature are only supervised while the engine turns — so an alarm that simply tracked its input would go active for a single tick and clear, leaving an operator with a stopped machine and no indication of why. It stays annunciated until the set leaves the shutdown state, whether through `ResetFaults` or the automatic recovery. This was found by watching a running server, not by a test: every unit test that checked the *condition* passed. Both raising and clearing report an event. A client learns of condition state changes only through events, so clearing the node silently — which `ResetFaults` used to do — leaves an alarm-list client showing the condition as active and retained until it happens to issue a `ConditionRefresh`. Low oil pressure is **bypassed while cranking**. Oil pressure has not built during a start, so supervising it there trips every set the moment it tries to run; a real set bypasses the trip for exactly this reason. This was a live defect here before the bypass was added, and a test now pins it. ### Methods | Method | Effect | Refused when | |---|---|---| | `Start` | `Off` → `Ready` → `Starting`, or `Ready` → `Starting` | already running | | `Stop` | → `Cooldown` | not running | | `EmergencyStop` | → `EmergencyStopped`, breaker open | see below | | `ResetFaults` | clears latched alarms; `Fault`/`EmergencyStopped` → `Off` | never | | `StartTest` | starts the set and publishes `Test` mode | already running | | `SetOperatingMode` | writes `OperatingMode` | mode is not declared | A refused request answers **`BadInvalidState`** rather than quietly doing nothing: a method that silently succeeds without acting is the worst of the three outcomes, because a client cannot tell it from a real success. Legality is decided in exactly one place — `GeneratorSimulation.RequestState` — so no caller can drive a machine from `Off` straight to `Loaded` by picking the right method. Resetting a healthy set is a **no-op success**. An operator pressing reset on a running machine has not done anything wrong, and answering `Bad` would only train them to ignore the result. > **A note on the emergency stop.** The model declares an emergency stop only out > of `Running`, `Loaded` and `Paralleled`, so this sample refuses it from `Starting` > and `Warmup`. A real panel stops from anywhere. That is the specification's shape, > not this sample's choice, and it is left visible — with a test that pins it — > rather than papered over with an undeclared transition. The method semantics live in [`GeneratorCommands.cs`](GeneratorCommands.cs), expressed against the simulation rather than against address-space nodes, so they can be held to account by a test without standing up a server. ## The OpenUSD twin Each set owns a `GeneratorTwin` with its own prim path, signal Variables and bindings, so `--generators N` renders N machines that move independently. Sets lay out along +Y at 6 m pitch. Sixteen live bindings drive the bay position, radiator fan, load and coolant gauge needles, exhaust and radiator colour, fuel-tank surface, a red alarm ring, overheat and low-oil halos at the subsystem each fault belongs to, a run lamp, and frequency / power / engine-hours / load / operating-state readouts. Five more make the machine visibly alive rather than merely correct: both exhaust manifolds glow with exhaust temperature — those are the parts of a running engine that actually glow — the alternator carries a heat band driven by load, and both turbochargers turn. The manifolds and the stack share **one** source Variable, so a client asking why they are glowing reads the single value behind all three. > **The fan is shown slower than it turns, deliberately.** A fan on a 1500 rpm > engine sweeps 9000°/s. Sampled at the tick interval that is several revolutions > per sample, so the published angle jumps by a near-arbitrary amount and the > blades either strobe or sit still — neither of which says "this machine is > running", which is the only thing the signal exists to say. The display rate is > scaled so the per-tick step stays well under the blade pitch. Fault indicators sit clear of the machine. Both halos were originally tucked into the engine centre, which was fine against a plain block and invisible once there was a crankcase, an aftercooler and a sump in the way — and an indicator you cannot see is worse than none, because it reads as *no fault*. `AlarmRing` moved for the same reason: as a decal on the floor it was hidden by the skid and by the neighbouring machines from any operator-height camera, so it is now a beacon ring *above* the set, at the one height nothing else occupies. > **Colour and visibility changes are both live viewport signals.** Thermal > bindings publish `primvars:displayColor` (`color3f[]`) for the radiator, > exhaust, manifold glow and alternator heat band, and current OpenUSD packages > animate those authored colours in the shipped viewer. The **visibility** > bindings — run lamp, alarm beacon, fault halos — remain useful for binary > state that must be unmistakable at a glance. The operating-state readout is what makes an idle machine legible: without it the 3D view shows *that* a set is not turning but not *why* — `Cooldown` and `EmergencyStopped` look identical from the outside. ### The machine you see The geometry is an open-frame ~400 kW V16 genset, modelled to be recognisable rather than schematic: - **Skid** — channel-section side rails (web plus top and bottom flanges), five cross members and lifting lugs, carrying the base fuel tank - **Engine** — 60° V16: crankcase, sump, gear case and flywheel housing, two tilted heads with eight rocker covers each - **Exhaust side** — a manifold log per bank with eight risers and an elbow into each turbocharger, in scaled copper-oxide brown - **Turbochargers** — turbine housing, cartridge, compressor housing and inlet - **Charge air** — riser / crossover / drop pipes arcing over each bank into the aftercooler in the vee - **Service side** — a bank of spin-on fuel filters, oil filters, starter, charging alternator, water pump and coolant hoses - **Alternator** — drum with a ventilation slot band, both end bells, terminal box and mounting feet - **Radiator** — core plus a fin pack, bolted guard uprights, header tanks, filler cap, fan shroud and a nine-blade fan - **Control panel** — cabinet with a dark instrument fascia, display, mimic plate, keypad, emergency-stop mushroom and warning label strip Detail is worth the bytes here: a viewport is the only place some of this sample's behaviour is legible at all, and a machine that reads as a box makes a fan that turns and a radiator that changes colour hard to interpret. ### The authoring rule that matters A prim a connector positions **must** declare `xformOp:transform` in its `xformOpOrder`. A connector folds Translation, Rotation and Scale into a single matrix op, and `xformOpOrder` is `uniform` — it cannot be rewritten from the stronger layer the connector edits. Naming `xformOp:translate` there instead makes USD discard every value **in silence**, and every set renders on the origin. Indicator geometry defaults to `invisible`, the plant aggregation composes with `Reference` (not `Instance`, which would make descendants a shared prototype that cannot carry per-set colour or rotation), and it is not declared `dynamic` because the set of machines is fixed at start-up. The geometry is generated — edit [`Assets/generate_generator_assets.py`](Assets/generate_generator_assets.py) and re-run it; never hand-edit `generator.usda` or `Powerhouse.usda`. Prim names in that script are load-bearing. `OpenUsdBindings.cs` drives `Radiator/Fan`, `Radiator/Core`, `Exhaust/Stack`, the two gauge needles, `ControlPanel/RunLamp`, `FuelTank/Surface`, `AlarmRing` and the two engine halos **by path**, so renaming any of them silently unbinds it — `GeneratorAssetContractTests` asserts every one still resolves. ## The model files `Model/` vendors the Generators nodeset plus a reduced Machinery nodeset. The reduction is not cosmetic: the full official Machinery nodeset does not survive the model source generator, and it drags in the IA namespace through a single optional `Stacklight` member that a generator set does not have. [`prepare_machinery_nodeset.py`](Model/prepare_machinery_nodeset.py) derives the reduced set from the official one by whitelist, so the provenance stays checkable and the whitelist is the only thing to edit when more types are needed. ## Rendering it ``` dotnet run --project tools/Opc.Ua.OpenUsd.Connector -- \ --server opc.tcp://localhost:62543/GeneratorServer \ --insecure --view ``` ## See also - [`DATASHEET.md`](DATASHEET.md) — the product datasheet the server is aligned to - [`Generators.md`](Generators.md) — the model, the simulation design and cross-server composition - [OpenUSD bindings](../../docs/OpenUsd.md) - [Device integration](../../docs/DeviceIntegration.md) - [`SiteCompositionServer`](../SiteCompositionServer) — composes this server with the pump server