Skip to main content

Schema reference

The OTN schema is forty-four kinds across eight files in schemas/. Eight of them are generics and thirty-six are concrete nodes.

Four of those files define the equipment layer: the generics, the port kinds, the device kinds, and the site. They are the subject here. The plant, container, carrier and service layers have their own pages: optical plant concepts, client mapping and link budget.

What follows is what the YAML cannot state for itself: why the generic layer is shaped the way it is, and why every physical quantity is an integer.

The files​

FileHolds
schemas/otn_base.ymlThe five OTN generics
schemas/otn_ports.ymlThe port kinds, two generics and twelve nodes
schemas/otn_devices.ymlThe eight device kinds
schemas/location.ymlLocationGeneric, vendored, OtnSite and OtnFacility

Infrahub loads every file in the directory into one schema, so the split has no runtime meaning. It is how the source reads and diffs.

Two generics, not one chain​

A device and an optical element are different things, and the schema keeps them apart on purpose.

  • OtnGenericDevice is anything racked at a site. It has a name, a status, a role, a site, and ports.
  • OtnOpticalElement is anything light passes through and loses power in. It has an insertion loss, a vendor, a model, and an element class.

Eight device kinds inherit OtnGenericDevice. Seven of those eight also inherit OtnOpticalElement, and one kind inherits OtnOpticalElement alone. There is no edge between the two generics:

A single inheritance chain, "optical element is a kind of device", would look tidier and would be wrong in both directions.

It would be wrong about the router. Light terminates at a router. A router has no insertion loss, so a query for everything that attenuates light must return seven kinds, not eight. Put the router under a combined chain and the optical budget starts counting a loss that does not exist.

It would be wrong about the fiber span. OtnFiberSpan contributes the largest loss in the network and is not a device. It is not racked, it has no ports, and it has no site, so it inherits OtnOpticalElement alone. A chain that made every optical element a device would force the span to grow a rack position and a port list it can never have.

So the two questions get one query each:

  • "everything racked here" is a query against OtnGenericDevice.
  • "everything the budget must sum" is a query against OtnOpticalElement.

OtnOpticalElement has no relationships and no identity keys, for the same reason. The span inherits it on its own.

The generic layer​

None of the five OTN generics inherits from another. Infrahub's generic schema has no inherit_from key, so a generic taxonomy cannot be stacked. It is composed instead: a concrete node lists the generics it needs.

GenericContributes
OtnGenericPortname, role, enabled, admin_state, oper_state, the parent device, and connected_to
OtnGenericDevicename, status, role, the site, and the ports list
OtnOpticalElementinsertion_loss_mdb, vendor, model, element_class
OtnOpticalPortcenter_frequency_mhz, tx_power_mdbm, rx_sensitivity_mdbm, connector_type
OtnCopperPortspeed_kbps, impedance_ohm, connector_type

Composition is the whole design. OtnLinePort is OtnGenericPort plus OtnOpticalPort. OtnTributaryPort is OtnGenericPort plus OtnCopperPort. OtnRouter is OtnGenericDevice and nothing else.

One inherited attribute is restated, and only to default it. element_class is determined by the kind: an amplifier is an amplifier, a span is a fiber_span. Each of the seven concrete kinds that inherit OtnOpticalElement declares it with the matching default_value, so no object file writes it.

That restatement has to repeat the ten choices, because overriding an inherited Dropdown requires the full list, and the server only half enforces that. Omitting the choices key is rejected at load with The property 'choices' is required for kind=Dropdown. A choices key holding nine of the ten loads in silence, and the divergence appears only when an object tries to write the value the override left out. tests/unit/test_schema_contract.py asserts every override matches the generic's list, and it is the only thing that does. The tenth choice, odu_switch, cost nine blocks, and the test is what tells you one was missed.

Nothing else is restated. Restating an attribute would let it drift from the generic, and the drift would break the guarantee that one query against OtnGenericPort returns every port with the same shape.

Ports​

Seven kinds, six optical and one copper.

KindComposesWhat it is
OtnRouterPortgeneric + opticalGrey optics on an IP router
OtnClientPortgeneric + opticalTransponder client side
OtnLinePortgeneric + opticalTransponder DWDM line side
OtnRoadmAddDropPortgeneric + opticalLocal add and drop on a ROADM
OtnRoadmDegreePortgeneric + opticalLine-facing degree, one per direction
OtnAmplifierPortgeneric + opticalAmplifier input or output
OtnTributaryPortgeneric + copperE1 or T1 G.703 electrical tributary

There is no management or console port kind. This model covers the transmission path, and a management port is not on it.

Two behaviours matter before you load data.

connected_to is one edge, not two. It is declared once on OtnGenericPort, peers OtnGenericPort, and pins identifier: otn_port__connected_to. Set it from A to B and B reports A with no second write. The explicit identifier is what stops Infrahub deriving a different string per side and splitting the link into two one-way halves. The identifier is immutable once loaded.

Port names are unique per device across all seven kinds. The constraint ["device", "name__value"] is declared on the generic, and Infrahub enforces it over every kind that inherits it. Creating OtnRouterPort dup/1 and then OtnTributaryPort dup/1 on the same device fails on the second write.

A line port names the wavelength it terminates​

OtnLinePort.carrier peers OtnOpticalCarrier, cardinality one and optional. OtnOpticalCarrier.line_ports peers back, cardinality many and optional. Both sides pin identifier: otn_carrier__line_ports by hand, for the same reason connected_to pins its own: the string is frozen the moment a load succeeds, and changing it afterwards costs a remove-and-re-add on both peers. Many on the carrier side, because a wavelength is terminated at each of its two ends by two ports on two devices at two sites. Optional on the port side, because a line port with no colour on it is a legal state: 38 of the 118 in the dataset are exactly that.

Before this edge existed, the only thing tying a transponder to a wavelength was that both names carried the same site code. That is a naming convention doing a relationship's job, and it cannot say which of a site's transponders carries which of its wavelengths.

Both sides are kind: Attribute with on_delete: no-action, written out rather than left to the default.

Deleting a transponder does not delete the wavelengths it carried. It does delete its ports: OtnGenericDevice.ports is kind: Component with on_delete: cascade, because a port has no meaning apart from the device it sits in. The deletion stops at the port. A wavelength has two ends, so no single port owns it, and cascading from Milan would delete the wavelength out from under the Frankfurt transponder that is still installed and still patched to its ROADM. A carrier also legally exists with no line ports at all, which is what a freshly provisioned one is, and a peer whose existence does not depend on the relationship cannot be cascaded by it. The other direction is plainer still: cascading from the carrier would mean retiring a service deletes hardware from the inventory.

What that leaves behind is the point. A lit wavelength nothing terminates is a real fault, and no-action leaves it sitting there, visible and open to a query, rather than making it vanish along with the transponder whose deletion caused it.

The objection, because it gets asked every time: light has direction, so should the edge? No, and each half of the answer is separate. A line port is a transceiver, carrying the transmit and the receive side of the same wavelength, so one edge already covers both. A cardinality-one cascade would fire from either end, which gives a wavelength two owners rather than one. And OtnOpticalCarrier holds no direction at all: direction lives on the plant, in the per-direction amplifier lists on a section, and in the path traversal that walks them. The model already has a way to say the light stopped, which is oper_state on the port and status on the carrier, not deletion.

Amplification is per direction​

Light crosses an optical multiplex section both ways, and an amplifier restores power in one of them. The section says which, by holding two lists:

RelationshipKindWhat it holds
amplifiers_a2bMany, optionalThe chain running towards the section's own roadm_b
amplifiers_b2aMany, optionalThe chain running towards its roadm_a

An amplifier has no direction attribute. Which chain it is in is the relationship holding it, and an attribute restating that would be a second copy that can disagree with the first. Read from the amplifier's own page, the same fact is oms_a2b or oms_b2a: exactly one of the two is set, and which one it is answers the question.

That costs a relationship. One relationship cannot be the inverse of two identifiers, so the amplifier needs both. The alternative was giving the amplifier no section relationship at all and reaching the section by a server-side filter. That was rejected: an amplifier page is a page an operator lands on, and "which section and which way" is what it is for.

Both lists stay optional. A section is creatable before its amplifiers exist, and that reason has not changed.

AttributeKindWhat it holds
oms_sequenceNumber, mandatory, 1 to 51Position in this amplifier's own chain, counting along the direction it amplifies

A section with N spans holds N+1 amplifiers per chain. oms_sequence does two jobs and both are its own. It orders a chain the relationship has already identified, so a chain sorted on it is in traversal order and nothing ever reverses one. And it fixes which member of that chain is which: position 1 is the booster, position N+1 is the pre-amplifier, and amplifier k feeds span k of its own walk. A span's oms_sequence does the other thing: a span has no direction, so it is numbered from the A end and stays there.

oms_sequence deliberately has no default. A default of 1 would let a new amplifier take a silent position at the head of its chain. It would collide with the real first amplifier and sort stably into a plausible wrong answer instead of raising.

It is not unique and cannot be. A uniqueness constraint cannot reference an optional relationship, and both section relationships have to stay optional. Rejecting duplicate positions is a test's job.

Nothing on the server enforces how many amplifiers a section holds in either direction. A query that selects one list and forgets the other gets an empty chain rather than an error. The budget engine is what refuses it: its rule that a section of N spans has N+1 amplifiers each way fails for that direction and names the section. Without that rule the split would have traded a crash for a plausible wrong number.

Amplifier names encode nothing a query needs​

An amplifier is amp-<a>-<b>-<NN>, where the two slugs are the section's own sites and NN is a position. A section of N spans has N+1 amplifier huts counting from the A end, each holding two amplifiers, and hut k takes ordinals 2k+1 and 2k+2. Frankfurt to Milan is nine spans, so amp-fra-mil-01 is the booster at Frankfurt and amp-fra-mil-20 is the booster at Milan.

The name is an identifier: unique, stable, and encoding nothing a query needs. Everything it used to say is readable from the schema. Which chain an amplifier is in is the relationship holding it. Where it sits in that chain is oms_sequence. Whether it is a booster, an inline or a pre-amplifier is the role on its own IN and OUT ports, which reads booster, line and preamp.

Any deterministic naming rule over an ordered set can be inverted. A reader who learns that ordinals run two per hut can work out which of amp-fra-mil-01 and amp-fra-mil-02 serves which chain. That is unavoidable and it is not the point. The point is that nothing reads it, so nobody has to know the rule to ask a question.

Raman pumps​

OtnRamanPump is a pump laser that injects Raman gain into one fiber span. It inherits OtnGenericDevice and OtnOpticalElement, like every other optical device kind, and adds four attributes of its own:

AttributeKindWhat it holds
on_off_gain_mdbNumber, 0 to 15000Received signal power with the pump on, less the same with it off
on_off_gain_displayText, read-onlyThe same figure in dB
injection_endDropdown, mandatorysite_a or site_b, which end of its span the pump is spliced in at
propagationDropdown, mandatorycounter if the laser fires against the signal, co if it fires with it

span points at the OtnFiberSpan the pump serves and is mandatory. Its inverse, raman_pumps, is declared on the span under the same identifier, and it is load-bearing twice. A budget walks a section, reaches its spans, and reads what is on them. Without that side of the edge nothing on the walk can see a pump. Every pumped span would sum to zero gain, and the check would pass while reporting a loss the network does not have. The inverse is also what makes the kind reachable in the interface, since the sidebar names no device kind and a kind nothing points at cannot be opened at all.

The ceiling on on_off_gain_mdb is 15.0 dB rather than 20.0 dB. The smallest fiber loss on any shipped span is 16.167 dB, so one pump can never reduce a span to zero loss. It takes two on one span to reach the floor.

The direction a pump amplifies is worked out, not stored​

A pump holds two facts about the hardware: which end of the span it is spliced in at, and whether its laser fires with the signal or against it. There is no third attribute naming the direction of travel it amplifies. That is the answer, and the two facts above compute it:

amplifies A to B when (injection_end == site_a) == (propagation == co)

Read physically, a counter-propagating pump fires back up the fibre from the far end. One at the B end therefore amplifies the A to B signal. A co-propagating pump fires along with the signal from the near end, so one at the A end amplifies A to B too. Those are two ways of reaching the same answer. There are four cases in all.

injection_endpropagationAmplifies
site_bcounterA to B
site_acoA to B
site_acounterB to A
site_bcoB to A

Every pump in this network is counter-propagating, so only the first and third rows occur here. The table shows all four anyway, because a reader who sees only the shipped cases will assume counter-propagating is the only kind there is.

Storing the conclusion beside its two premises would be a third value that can contradict them. A counter-propagating pump recorded at the A end and marked a_to_b is a self-contradictory object, and a schema that accepts one will eventually hold one.

injection_end is mandatory and has a default of site_b. The default is a loading device, because a mandatory attribute needs one for the schema to load at all, and it is not a statement about this network. Every pump in the dataset writes the value explicitly, and a test says so.

What the pump does to a budget, and where that treatment stops being predictive, is on the link budget page.

Regenerators and ODU cross-connects​

OtnOduSwitch is the eighth device kind. It receives one wavelength, reframes the payload and originates the next, which is what lets a circuit cross a route too long for any single wavelength. Two flat generics composed on the concrete node, the same shape OtnMuxDemux uses.

AttributeWhat it says
switching_moderegenerator carries the whole payload across without looking inside it. cross_connect demultiplexes to containers and regroups them, which is what lets the two segments carry different clients.
framing_latency_nsWhat the junction charges for reframing. Nanoseconds as an integer, the unit fec_latency_ns and latency_ns already use.
carriersThe wavelengths patched to this shelf. Many, optional. Attachment, not termination: a regenerator does terminate the two it joins and a cross-connect grooms containers behind a transponder and terminates nothing, so one edge cannot mean both. OtnLinePort.carrier is what answers "what terminates this wavelength".

It inherits OtnOpticalElement because OtnPathHop.element peers that generic, and a segment's route has to be able to name the device it terminates on.

A regenerator has line-side optics, and they are where termination is read from. Two OtnLinePort components on the shelf, one facing each segment it joins, each naming its wavelength through OtnLinePort.carrier. A regenerated circuit then answers the same question a direct one does: every wavelength has a port at each of its two ends, a transponder on the outside and the regenerator on the inside. checks/carrier_termination.py reads those ports and nothing else, which is why the answer is the same relationship in both cases.

A cross-connect has none, and the difference is the reason the two modes are separate values rather than a flag. It sits at the electrical layer behind a transponder that has already taken the light off the fibre, so it has no line-side optics to give. oxc-mil-01 is patched to 37 wavelengths that all terminate on Milan transponders; reading that edge as termination would make every one of them terminated three times.

Its insertion loss belongs to one side only. insertion_loss_mdb arrives with the generic and applies to the incoming segment. The device terminates the light rather than passing it through, so the outgoing segment starts at a transmitter and not at an attenuated signal. That asymmetry is why the loss cannot be added into one total spanning both segments. It is the schema-level reason a regenerated circuit has a margin per segment rather than one figure.

carriers is not what finds the chain, and that was measured. The relationship says which wavelengths a device terminates, and src/infrahub_demo_otn/chains.py reads it to evaluate a junction. The two wavelengths must meet at a site, that site must host the device, and the device must terminate both. A device-to-device traversal over this edge alone returns zero paths, because a ROADM has no edge to a carrier. Widening the filter until one is reachable brings the intended chain back among 65 paths. Of those, 48 are two carriers meeting on a shared section with no device between them at all, and removing this edge from the filter changes none of that. The graph does not constrain the junction, so the predicate is explicit in Python and the cover is unit tested against a pinned expectation.

A device with an empty carriers contributes no junction. That is the correct reading of a racked but unpatched device, and it needs no special case.

The three the dataset ships​

DeviceSiteModeFraming delay
oeo-fra-01Frankfurtregenerator3000 ns
oxc-fra-01Frankfurtcross_connect5000 ns
oxc-mil-01Milancross_connect5000 ns

The two sites were measured rather than chosen. Every one of the 40 shipped wavelengths crosses oms-fra-mil, so 37 of them terminate at Milan and 25 at Frankfurt, and no third site reaches double figures. The regenerator is at Frankfurt because that is the only split of Madrid to Warsaw that closes, which the link budget page has in full.

Each of the three names at least one wavelength. An inert device would load cleanly, never be a junction, and read as a capability the demo does not have.

element_class gained a tenth choice, odu_switch, when the first object forced the decision. None of the original nine names an O-E-O device. The alternative was defaulting to transponder or roadm, which puts a false value on the one attribute that says what a device is. Nothing in the repository reads element_class, so a wrong label costs nothing mechanically and is only untrue in the data, which is the whole argument for paying for the tenth choice. The choice cost nine blocks: the generic, the seven overrides, and the pinned list in tests/unit/test_schema_contract.py. A block missed is a dropdown that offers different options depending on the kind you look at, and the server accepts that in silence.

Devices and sites​

OtnGenericDevice.site points at OtnSite and is optional. It is an Attribute relationship rather than a Parent one, because a Parent relationship is mandatory and a device has to be creatable before its site record exists.

OtnSite inherits LocationGeneric, vendored from the published opsmill/schema-library location file. Only the generic is taken. The upstream file also defines a hosting node that peers a competing device hierarchy, which this repository does not use. OtnSite adds latitude_microdeg and longitude_microdeg.

The inverse devices relationship is declared directly on the Site node in schemas/location.yml, peering OtnGenericDevice under the identifier otn_site__devices. The location layer therefore names the OTN device layer, and the two files load as one payload for that reason.

OtnFacility is an edge, and used to be a prefix on a tag name​

OtnFacility records a supercomputing facility hosted at a PoP. Six of the fourteen PoPs host one; the other eight host none, which is why both sides of otn_site__facility are optional. Cardinality is one on both sides, because no site in the modelled network hosts two. Widening that later is a migration a reader can see coming; narrowing it is not.

Until it existed, the only record that a site hosted a facility was the text after eurohpc- in a BuiltinTag name, sliced back out by a helper duplicated in transforms/network_map.py and transforms/odu_map.py. That defence for it was half right: it read a tag, not a device, and nothing in this repository recovers an amplifier, a ROADM or a router from a name.

The half that was wrong is what moved it. The failure was silent in both directions, and both were measured on a throwaway branch rather than argued. Renaming eurohpc-vega to hpc-vega dropped Vega from both maps and raised nothing anywhere. Attaching a mistyped eurohpc_deucalion to Geneva created a facility that no map draws and no check reports.

name is the tag suffix and has to stay it: mapchrome.py upper-cases the value for the caption on the node disc, and the committed reference render holds the upper-cased suffix. So marenostrum-5, not MareNostrum 5. The readable form lives in description, which is the thing a tag could never carry.

The six eurohpc- tags are still on their sites. They are data an operator wrote, and nothing reads them back.

site_type keeps "the PoPs" a filter​

OtnSite.site_type is a mandatory dropdown with two values, pop and customer, defaulting to pop. The loaded network has fifteen sites: fourteen PoPs and one customer campus, Amsterdam Science Park, reached over an 18.4 km coarse tail.

The attribute exists so that "the PoPs" is a query rather than a number in a paragraph. Before it, every statement about the core said fourteen, and the first site that was not a PoP made all of them wrong at once. A report that means the core now filters on site_type: pop and stays correct through the next customer endpoint.

It also does one job nothing else can. The guard in tests/unit/test_geant_dataset.py reads customer as its discriminator and refuses an oms on any span touching such a site. That refusal is what keeps a coarse tail out of the optical budget engine, which would price it at a 1550 nm attenuation coefficient. See optical plant concepts for the size of the error.

OtnCwdmChannel and cwdm_channels​

OtnCwdmChannel is the ITU-T G.694.2 coarse wavelength plan: eighteen entries at 20 nm spacing from 1271 to 1611 nm, with two attributes and no more. The standard defines a coarse channel by its nominal central wavelength and gives it nothing else, so there is no name, no channel number and no frequency to store. Its human_friendly_id is the wavelength, and the display label renders 1471 nm.

band is stored rather than computed, for the same reason a port's centre frequency is stored. "Which coarse wavelengths could an erbium amplifier reach" is then a GraphQL filter instead of a Python loop over eighteen rows. The redundancy is covered by a guard test that recomputes all eighteen wavelengths and all eighteen bands from units.py, so the file cannot drift from the code.

OtnCwdmChannel inherits nothing. Light does not pass through a plan entry, it passes through the multiplexer that selects one, so the multiplexer is the optical element and the plan entry is reference data.

OtnMuxDemux.cwdm_channels is the edge between them, optional and many. It says which coarse wavelengths a multiplexer lights, which is a property of the device rather than of the plan. The network loads sixteen multiplexers and only the two on the coarse tail have a non-empty list. The relationship is also what makes OtnCwdmChannel reachable from another kind, which the sidebar reachability test in tests/unit/test_menu.py requires.

One rule for the one-sided relationships​

Several relationships in the model point one way. One rule decides whether each gets an inverse, written once in the header of schemas/otn_plant.yml:

Declare the inverse when the peer is a page an operator lands on and the reverse list is what that page is for. Otherwise the reverse direction is a server-side filter on the forward side, which costs nothing and does not duplicate the fact.

Six qualify and have one, each declared on the node it belongs to. A site reads the fiber spans terminating on it; a ROADM reads the sections it terminates; a channel reads the carriers holding it and a mode reads the carriers using it. OtnService.endpoint_a and endpoint_z do not, and the reason is structural. Their peer is a generic, so the inverse would have to sit on OtnGenericDevice in the base file and make the base layer depend on the service layer.

Every inverse reuses the identifier its forward side already declares. That is what makes it read the edges already in the graph instead of an empty list, with no migration. An inverse under a new identifier reads nothing and looks broken.

Every quantity is a scaled integer​

Infrahub has no Float attribute kind. Number is an integer, and JSON is not filterable, not sortable, and not usable in a computed attribute. So every physical quantity is stored as an integer scaled by a fixed factor, with the unit in the attribute name.

SuffixUnit storedScaleExample valueMeans
_mdbmillidecibel1 dB = 10002500.25 dB
_mdbmmilli-dBm1 dBm = 1000-28000-28.0 dBm
_mhzmegahertz1 THz = 1000000193700000193.7 THz
_kbpskilobit per second1 Mbps = 10002048E1, 2.048 Mbps
_ohmohmnone, already whole120120 ohms
_microdegmillionth of a degree1 degree = 10000005237918952.379189 degrees

The rate suffix is _kbps and not _mbps for a reason. E1 is 2.048 Mbps and T1 is 1.544 Mbps. Rounded to whole megabits both become 2, and the two signals the copper generic exists to carry stop being distinguishable.

Every scaled attribute declares both a minimum and a maximum. A value outside the range is rejected at write time with an HTTP 422, so a bad number never reaches the budget engine:

99000 is higher than the maximum allowed value 30000 at tx_power_mdbm
185000000 is lower than the minimum allowed value 191350000 at center_frequency_mhz

src/infrahub_demo_otn/units.py holds every scale factor and every conversion. No scale factor may be written inline anywhere else.

Display attributes​

A scaled attribute that appears in the user interface has a paired read-only _display attribute that renders engineering units. Four of them are on the generics, so every kind composing those generics gets them.

AttributeDisplay attributeRenders
insertion_loss_mdbinsertion_loss_display0.25 dB
center_frequency_mhzcenter_frequency_display193.7 THz
tx_power_mdbmtx_power_display1.0 dBm
rx_sensitivity_mdbmrx_sensitivity_display-28.0 dBm

Three more sit on the device kinds that need them: noise_figure_display and gain_display on OtnAmplifier, and on_off_gain_display on OtnRamanPump.

Read-only here is stronger than greyed out. A _display attribute is absent from the generated ...UpdateInput type. Writing to one is a GraphQL schema error rather than a permission error:

Field 'tx_power_display' is not defined by type 'OtnLinePortUpdateInput'

Scaled attributes that do not appear in the interface get no display attribute.

The one inline divisor​

Each display attribute is a Jinja2 template, and the template engine cannot import Python. A template therefore has to include its own divisor:

{% if tx_power_mdbm__value is not none %}{{ tx_power_mdbm__value / 1000 }} dBm{% endif %}

That is the single exception to the "no inline scale factors" rule, and it is machine-checked. tests/unit/test_schema_contract.py extracts the divisor from every display template and asserts it equals the matching constant in units.py. A template that drifts from the module fails the unit suite, which is what bounds the exception.

Monitors​

A monitor is the interface a device reports its own last-known readings on. There are five kinds, one per family of equipment, and each declares the readings its hardware can take:

KindReadings
OtnAmplifierMonitorinput_power_mdbm, output_power_mdbm, measured_gain_mdb, tilt_mdb
OtnRoadmDegreeMonitortotal_power_mdbm, channel_count
OtnMuxDemuxMonitortotal_power_mdbm, channel_count
OtnRamanMonitorpump_power_mdbm, measured_gain_mdb, back_reflection_mdb
OtnReceiverMonitorrx_power_mdbm, measured_osnr_mdb, pre_fec_ber_ppb, q_factor_mdb, cd_fs_per_nm, dgd_fs

Five kinds rather than one, because the readings barely overlap. Of the fourteen readings in the model, eleven appear on exactly one family. The names suggest more sharing than there is: input_power_mdbm, total_power_mdbm, pump_power_mdbm and rx_power_mdbm are four different measurements that happen to have "power" in four different names. Three readings are shared, and only in two pairs: measured_gain_mdb between an amplifier and a Raman pump, and total_power_mdbm and channel_count between a ROADM degree and a multiplexer.

At that separation the schema can say the whole thing itself. Every reading is mandatory, so the server refuses a monitor that is missing one. And a kind has no field at all for a reading its hardware cannot produce. The server refuses that too:

measured_osnr_mdb is not a valid attribute or relationship for OtnAmplifierMonitor
tilt_mdb is mandatory

Both halves, on every write path, rather than inside a proposed change. A coherent receiver's signal processor computes OSNR; an amplifier measures power. An amplifier monitor reporting OSNR would be a number the device cannot take, and now there is nowhere to put it.

Two shared generics​

OtnMonitor declares measured_at and nothing else. It is the one fact every monitor shares, and it is mandatory, because a reading nobody can age is not evidence. This is a last known value, not a telemetry feed.

OtnChannelMonitor declares total_power_mdbm and channel_count. A ROADM degree and a multiplexer report exactly those two, so they are declared once rather than twice. The kinds stay separate, because a degree and a multiplexer are different equipment and "show me the multiplexer monitors" should stay a question about a kind. It also leaves room for the two to diverge later, which is what a real degree monitor and a real multiplexer monitor eventually do, without a migration on loaded data.

Neither generic inherits the other. Generics do not inherit generics in Infrahub, so both are flat and each concrete kind composes them alongside OtnGenericPort, which is where identity, state and the parent device come from. OtnAmplifierPort already uses that shape for two generics; the channel monitors use it for three.

A bit error rate is held in parts per billion so it stays an integer: 2.1e-3 is 2100000. That follows the same rule as every other quantity here.

What the schema still cannot say​

Nothing stops a receiver monitor from being attached to an amplifier. OtnGenericPort.device peers OtnGenericDevice, and a concrete kind cannot narrow the peer of a relationship it inherits. Infrahub 1.11.0 rejects the attempt from either end, before any data is loaded:

ProbeAmpPort's relationship device inherited from ProbeGenericPort
must have the same peer (ProbeGenericDevice != ProbeAmp)

The remaining option would be a Python check, which is the layer these five kinds exist to leave. So the gap is accepted and stated rather than hidden. Nothing in the shipped network creates a mismatched pair, and the generator that writes the monitors is the only thing that ever creates one.

The six reason codes and the signature beside them​

A refused service carries its verdict in three attributes on OtnService, and none of them is a parsed string. rejection_code is a Dropdown, rejection_detail is Text capped at 512 characters, and refusal_accepted is a Boolean. Together they replace one free-text field that held "{code}: {detail}" and that two transforms had to split apart, transforms/service_trace.py and transforms/ai_latency.py.

CodeLabelColourWhat it means
no-routeNo routegrey #9e9e9eNo path exists between the two endpoints.
no-modeNo modegrey #757575No transponder mode meets the requested rate.
budgetOptical budgetred #f44336A route exists and the OSNR margin is negative on all of them.
latencyLatency budgetred #b71c1cA route exists and every one of them is slower than the service allows.
capacityNo spectrumamber #ff9800A route exists and no channel on it is free.
no-slotsNo tributary slotsamber #ffb74dA wavelength was found and its containers have no room for the client.

The colour grouping is information, not decoration. The two greys mean the request was unanswerable, the two reds mean the physics refused a route that exists, and the two amber codes mean the network is full. An operator reading a list of refusals learns from the colour alone whether to re-plan the request, to regenerate the circuit, or to build more network. A string field carried none of that.

Negative result: only two of the six appear in practice today. Every refusal the demo produces is no-slots or budget. The other four are reachable in principle, and no shipped scenario reaches them. capacity needs a route whose spectrum is full while its containers are not, and the saturated corridor closes both layers at once. no-route and no-mode need a request the topology and the mode catalog cannot answer at all, and both are answerable for every pair of sites in the dataset. They are in the schema because the generator writes them, not because a demo scenario shows them.

The choices are the Python constants, and a test says so. All six live in routing.py, including no-slots, whose only writer is generators/optical_service.py. tests/unit/test_schema_contract.py asserts the six schema names equal the six constants, because a code added to one and not the other fails at write time on a live branch and nowhere earlier. Two neighbouring constants in routing.py, CHANNEL_NO_SPECTRUM and CHANNEL_NO_BLOCK, look like reason codes and are detail text. They are deliberately not choices.

refusal_accepted is optional: false with default_value: false, and that pairing is what made it safe to add to a kind that already had rows. Mandatory with no default fails validation against every existing service and blocks the whole schema update. The default lands false on all of them, which is the correct reading of every service written before the flag existed.

Only a person ever sets it to true. The generator may clear it, and only on the path where it provisions the service. See the gate and the escape hatch for why both halves of that rule are load-bearing.

The free-text rejection_reason these three replaced was deleted from the YAML outright rather than retired with state: absent. The dataset here is generated and reloads in one command, so the graph catches up on uv run invoke init and a retired declaration would be pure reading cost. state: absent is the right answer for a graph that has to survive the change, and this one does not.

So an instance loaded before this change needs uv run invoke init re-run, and that is not optional. A deleted declaration is not a migration. The attribute stays on every service already in the graph until the schema is loaded again, and until then the three attributes above do not exist there at all. A check reading status and a transform reading rejection_code are both querying fields the running instance has never heard of. Reloading is the whole migration, and it is one command because the dataset is generated.

Two deliberate absences​

Both are load-bearing.

A router contributes no insertion loss. OtnRouter inherits OtnGenericDevice only. It has no insertion_loss_mdb, and it is not returned by a query against OtnOpticalElement. Light terminates at the router, so there is nothing to attenuate. Adding the generic to make the device list look uniform would break the query the optical budget depends on.

A grey router port has no centre frequency. center_frequency_mhz is optional because a grey port is not on the C-band grid at all. When it is unset, center_frequency_display renders an empty string. It does not raise inside the template engine, and it does not block the save. An empty frequency on a router port is the correct reading, not missing data.

menus/otn.yml defines the sidebar. Five top-level entries and thirteen leaves:

EntryWhat is under it
OTN SitesThe fourteen PoPs and the customer campus. A leaf, because it is where you start
OTN ServicesServices, optical carriers
OTN PlantDevices, fiber spans, optical multiplex sections, conduits
OTN CatalogFrequency grid, CWDM plan, optical modes, fiber types, client signals
OTN ReportsService traces

Both wavelength plans are in the catalog. The dense grid and the coarse plan are two entries side by side, so an engineer opening the catalog finds both. The alternative is reaching a wavelength plan by first opening a multiplexer, which is a route nobody guesses.

Devices opens on the generic kind, so one click gives all eight device kinds.

The sidebar names no port kind and no individual device kind, because the object pages already reach them. A site page lists the devices at the site; a device page lists its ports; an optical path lists its hops; a carrier lists its containers. An entry for "Patch panels" is a second route to a place you were already going to arrive at.

A site page lists the fiber terminating on it in two lists, one for each end: "Fiber spans (A end)" and "Fiber spans (B end)". Frankfurt reads 16 and 24, and the 40 spans that touch it are the union. site_a and site_b are two relationships under two identifiers, so their inverses are two lists; merging them would lose the end each span terminates on.

Every generic and node in this schema sets include_in_menu: false. Custom menus and the auto-generated per-kind sidebar are additive and do not deduplicate, so without it each kind would appear twice.

A menu load is additive in the same way. uv run invoke load-menu deletes the existing OTN menu items before loading, so the file is the sidebar rather than an addition to it. Loading with infrahubctl directly gives a server the union of the file and whatever it had, which makes a shorter file produce a longer sidebar.

Loading and checking the schema​

uv run invoke schema-check

The offline gate. It checks schema formatting and needs no running Infrahub. There is no schema-validate subcommand.

uv run invoke load-schema --branch otn-schema

Loads schemas/ onto a branch. Into a deployment that already holds objects, load onto a branch rather than onto main, so the change arrives through a proposed change with a diff to read.

uv run invoke test-unit

Runs the contract tests that guard the rules a formatter cannot see: no Float, no JSON, and both bounds on every scaled number. It also guards a unit suffix on every quantity, the four display attributes present and read-only, and no generic inheriting from another generic.