diff --git a/CHANGELOG.md b/CHANGELOG.md index 3592cf3..186bbcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,22 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline * `DslAccess.qualify_result` now transitions to `:feasibilityChecked` — restores correct TMF form following [diffo#168](https://github.com/diffo-dev/diffo/pull/168) broadening the Assigner lifecycle gate to include `:feasibilityChecked` * `Util.summarise_characteristics/2` no longer called from tests — typed characteristic + pool records now surface in TMF JSON by default ([diffo#169](https://github.com/diffo-dev/diffo/pull/169)). The projection function is retained in [lib/diffo_example/util.ex](lib/diffo_example/util.ex) for future projection demonstrations. Expected JSON strings updated across `cable`, `card`, `cable`, `path`, `shelf`, `dsl_access`, `nbn_ethernet` tests to reflect the real typed/pool surfacing +### Features: +* NBN — AVC, CVC, NniGroup characteristic inheritance and metrics (issue #49): + * AVC inherits the upstream CVC's `cvc` characteristic via the `:cvlan` assignment (single-hop), and the NniGroup's `nni_group` characteristic transitively via `[:cvlan, :svlan]` (two-hop). Both singular. + * CVC inherits the upstream NniGroup's `nni_group` characteristic via the `:svlan` assignment (singular). + * NniGroup brings up the typed value of every comprised NNI as `nnis[]` via the `:contains` relationship. + * New `cvc_metrics` characteristic on CVC carries `avcs_count` and `avcs_total_bandwidth` aggregated live over assigned AVCs. + * New `nni_group_metrics` characteristic on NniGroup carries `cvcs_count`/`cvcs_total_bandwidth` (demand), `nnis_count`/`nnis_total_bandwidth` (capacity), and `utilization = cvcs_total_bandwidth / nnis_total_bandwidth`. +* NBN — NTD brings up assigned UNIs as `unis[]` via the `:port` assignment (issue #49 part 2). +* NBN — NbnEthernet (PRI) brings up four characteristics surfacing the full delivery chain (issue #49 part 3): `avc` single-hop via the `:circuit` owns relationship, `uni` single-hop via the `:port` owns relationship, `cvc` two-hop via `:circuit` then `:cvc`, and `ntd` two-hop via `:port` then `:ntd`. All singular. +* NBN and Access — consumer-side aliases on assignments and relationships now name the **upstream related resource** the consumer is part of (its domain role), not the slot/thing being received. NBN: AVC sets `:cvc` on its cvlan assignment, CVC sets `:nni_group` on its svlan assignment, UNI sets `:ntd` on its port assignment; PRI's two `:owns` relationships are aliased `:circuit` (AVC) and `:port` (UNI). Access: Card sets `:shelf` on its slot assignment, Path sets `:card` on its port assignment, and `Shelf.cards` filters on `alias: :shelf`. Inheritance walks use these consumer-aliases. Pool/metric aggregations are unaffected — they still filter by `thing`. +* `BandwidthProfile.downstream/1` — atom-to-Mbps mapping used by the metrics aggregation. CVCs are treated as symmetric capacity in this model (satellite asymmetry ignored). + +### Refactors (continued): +* `DiffoExample.Calculations.InheritedCharacteristic` renamed to `InheritedCharacteristicViaAssignment`; new sibling `InheritedCharacteristicViaRelationship` traverses `Provider.Relationship` edges (forward source → target). Both calcs accept `singular?:` to unwrap to a single value where graph identity guarantees ≤1 result. `InheritedCharacteristicViaRelationship` also accepts a `then_via:` list of assignment aliases to continue the walk via `AssignmentRelationship` after the relationship hop — covers mixed paths like PRI's `cvc` (relationship + assignment). +* `ReverseInheritedCharacteristic` extended with a `thing:` filter option, complementing the existing `alias:` filter. Source-side aggregations should prefer `thing:` since it's always set from the pool DSL — see the assignment-direction-asymmetry rationale. + ## [v0.2.2](https://github.com/diffo-dev/diffo/compare/v0.2.1..v0.2.2) (2026-05-21) ### Maintenance: diff --git a/lib/access/calculations/shelf_total_ports.ex b/lib/access/calculations/shelf_total_ports.ex index 08b12a6..106805d 100644 --- a/lib/access/calculations/shelf_total_ports.ex +++ b/lib/access/calculations/shelf_total_ports.ex @@ -7,9 +7,10 @@ defmodule DiffoExample.Access.Calculations.ShelfTotalPorts do Sums the `:ports` pool capacity across every card a shelf has assigned a slot to. - For each outgoing slot-assignment (alias `:slot`, source = shelf), looks - up the assigned card's `AssignableCharacteristic` for the `:ports` pool - and sums `(last - first + 1)` across all of them. + For each outgoing slot-assignment (cards consumer-alias their upstream + Shelf relationship as `:shelf`), looks up the assigned card's + `AssignableCharacteristic` for the `:ports` pool and sums + `(last - first + 1)` across all of them. Local-to-this-repo for now. Could in time become a more general diffo-side primitive (`SumPoolCapacityOfAssignees` or similar) once the @@ -26,7 +27,7 @@ defmodule DiffoExample.Access.Calculations.ShelfTotalPorts do Enum.map(records, fn shelf -> assignments = Diffo.Provider.AssignmentRelationship - |> Ash.Query.filter_input(source_id: shelf.id, alias: :slot) + |> Ash.Query.filter_input(source_id: shelf.id, alias: :shelf) |> Ash.read!(domain: Diffo.Provider) Enum.reduce(assignments, 0, fn assignment, acc -> diff --git a/lib/access/resources/card.ex b/lib/access/resources/card.ex index 396014b..4172981 100644 --- a/lib/access/resources/card.ex +++ b/lib/access/resources/card.ex @@ -89,20 +89,21 @@ defmodule DiffoExample.Access.Card do end calculations do - # The shelf characteristic value brought up from the shelf this card is - # in — derived live via the :slot assignment. + # The shelf characteristic value brought up from the shelf this card + # is part of — Card's :shelf consumer-alias on its slot assignment + # from the Shelf. calculate :shelf, {:array, :map}, - {DiffoExample.Calculations.InheritedCharacteristic, - [via: [:slot], characteristic_module: DiffoExample.Access.ShelfCharacteristic]} do + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, + [via: [:shelf], characteristic_module: DiffoExample.Access.ShelfCharacteristic]} do public? true end - # The slot number this card occupies on its shelf — the value of the - # shelf's :slots-pool assignment to this card. + # The slot number this card occupies on its shelf — the :value of + # the assignment Card aliases :shelf (its upstream Shelf). calculate :slot, {:array, :integer}, - {Diffo.Provider.Calculations.FieldFromAssignment, [alias: :slot, field: :value]} do + {Diffo.Provider.Calculations.FieldFromAssignment, [alias: :shelf, field: :value]} do public? true end end diff --git a/lib/access/resources/path.ex b/lib/access/resources/path.ex index c56e2f2..28642fe 100644 --- a/lib/access/resources/path.ex +++ b/lib/access/resources/path.ex @@ -77,29 +77,31 @@ defmodule DiffoExample.Access.Path do calculations do # The card characteristic value brought up from the card this path is - # assigned a port on — via the :port assignment. + # part of — Path's :card consumer-alias on its port assignment from + # the Card. calculate :card, {:array, :map}, - {DiffoExample.Calculations.InheritedCharacteristic, - [via: [:port], characteristic_module: DiffoExample.Access.CardCharacteristic]} do + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, + [via: [:card], characteristic_module: DiffoExample.Access.CardCharacteristic]} do public? true end - # The port number this path occupies on its card — the value of the - # card's :ports-pool assignment to this path. + # The port number this path occupies on its card — the :value of the + # assignment Path aliases :card (its upstream Card). calculate :port, {:array, :integer}, - {Diffo.Provider.Calculations.FieldFromAssignment, [alias: :port, field: :value]} do + {Diffo.Provider.Calculations.FieldFromAssignment, [alias: :card, field: :value]} do public? true end - # The shelf characteristic value brought up transitively — port to the - # card, then the card's slot to its shelf. Two-hop via [:port, :slot]. + # The shelf characteristic value brought up transitively — Path's + # :card alias to the Card, then the Card's :shelf alias to its Shelf. + # Two-hop via [:card, :shelf]. calculate :shelf, {:array, :map}, - {DiffoExample.Calculations.InheritedCharacteristic, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, [ - via: [:port, :slot], + via: [:card, :shelf], characteristic_module: DiffoExample.Access.ShelfCharacteristic ]} do public? true diff --git a/lib/access/resources/shelf.ex b/lib/access/resources/shelf.ex index 43b8a50..5f06a45 100644 --- a/lib/access/resources/shelf.ex +++ b/lib/access/resources/shelf.ex @@ -96,7 +96,7 @@ defmodule DiffoExample.Access.Shelf do calculate :cards, {:array, :map}, {DiffoExample.Calculations.ReverseInheritedCharacteristic, - [alias: :slot, characteristic_module: DiffoExample.Access.CardCharacteristic]} do + [alias: :shelf, characteristic_module: DiffoExample.Access.CardCharacteristic]} do public? true end diff --git a/lib/diffo_example/calculations/inherited_characteristic.ex b/lib/diffo_example/calculations/inherited_characteristic_via_assignment.ex similarity index 52% rename from lib/diffo_example/calculations/inherited_characteristic.ex rename to lib/diffo_example/calculations/inherited_characteristic_via_assignment.ex index 1495028..22768d4 100644 --- a/lib/diffo_example/calculations/inherited_characteristic.ex +++ b/lib/diffo_example/calculations/inherited_characteristic_via_assignment.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule DiffoExample.Calculations.InheritedCharacteristic do +defmodule DiffoExample.Calculations.InheritedCharacteristicViaAssignment do @moduledoc """ Brings up a typed characteristic value from an assignment-source instance. @@ -11,10 +11,15 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do `AssignmentRelationship` by alias to reach source instances, then queries the typed characteristic resource on each source and returns its `.value`. - Local-to-this-repo for now. Worth yarning upstream as a diffo-side - `inherited_characteristic` DSL declaration backed by a - `Diffo.Provider.Calculations.InheritedCharacteristic` calc, sitting - alongside the existing inherited-place and inherited-party machinery. + Sibling to `InheritedCharacteristicViaRelationship`, which performs the + analogous traversal over `DefinedSimpleRelationship` edges (forward, + source → target). Pick the right calc by the kind of edge being + traversed — assignment vs. relationship. + + Local-to-this-repo for now. Worth yarning upstream as a pair of + diffo-side DSL declarations backed by analogous calcs in the provider + extension, sitting alongside the existing inherited-place and + inherited-party machinery. ## Options @@ -22,24 +27,39 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do from this instance back to the source whose characteristic we want. Each step filters `AssignmentRelationship` by `target_id` and `alias`, then follows `source_id` to the next set of instances. The aliases are - the assignee's slot names, supplied when the assignment is made. + the **consumer's name for the upstream related resource** at each hop + (e.g. AVC names its CVC slot `:cvc`, CVC names its NniGroup slot + `:nni_group`) — set when the assignment is made. - `characteristic_module:` *(required)* — the typed characteristic Ash resource on the final source (e.g. `ShelfCharacteristic`). The calc queries this resource by `instance_id` and returns the `.value`. + - `singular?:` *(optional, default `false`)* — when `true`, unwraps the + result to a single value (or `nil`) rather than a list. Safe whenever + every hop in `via:` traverses an `AssignmentRelationship` with identity + `[:target_id, :alias]` — that guarantees ≤1 source per step, so the + walk yields at most one value. Declare the calc's return type as `:map` + (rather than `{:array, :map}`) when using this option. ## Example # Card brings up its shelf's typed characteristic via the slot # assignment the shelf made to it (alias :slot on the incoming # AssignmentRelationship). - calculate :shelf, :map, - {DiffoExample.Calculations.InheritedCharacteristic, + calculate :shelf, {:array, :map}, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, [via: [:slot], characteristic_module: ShelfCharacteristic]} # Path brings up the same via a two-hop chain — port-then-slot. - calculate :shelf, :map, - {DiffoExample.Calculations.InheritedCharacteristic, + calculate :shelf, {:array, :map}, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, [via: [:port, :slot], characteristic_module: ShelfCharacteristic]} + + # AVC brings up its singular CVC via its :cvc consumer-alias on the + # cvlan assignment from the CVC. AssignmentRelationship identity + # guarantees ≤1 source, so we declare :map and ask the calc to unwrap. + calculate :cvc, :map, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, + [via: [:cvc], characteristic_module: CvcCharacteristic, singular?: true]} """ use Ash.Resource.Calculation @@ -50,6 +70,7 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do def calculate(records, opts, _context) do via = Keyword.fetch!(opts, :via) characteristic_module = Keyword.fetch!(opts, :characteristic_module) + singular? = Keyword.get(opts, :singular?, false) Enum.map(records, fn record -> final_ids = @@ -62,13 +83,16 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do end) end) - Enum.flat_map(final_ids, fn id -> - characteristic_module - |> Ash.Query.filter_input(instance_id: id) - |> Ash.Query.load(:value) - |> Ash.read!() - |> Enum.map(& &1.value) - end) + values = + Enum.flat_map(final_ids, fn id -> + characteristic_module + |> Ash.Query.filter_input(instance_id: id) + |> Ash.Query.load(:value) + |> Ash.read!() + |> Enum.map(& &1.value) + end) + + if singular?, do: List.first(values), else: values end) end end diff --git a/lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex b/lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex new file mode 100644 index 0000000..9cf438e --- /dev/null +++ b/lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex @@ -0,0 +1,130 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do + @moduledoc """ + Brings up typed characteristic values from target instances reached via + forward `Diffo.Provider.Relationship` edges (source → target), optionally + filtered by `type:` and/or `alias:`. + + Sibling to `InheritedCharacteristicViaAssignment`, which performs the + analogous traversal over `AssignmentRelationship` edges. Pick the right + calc by the kind of edge being traversed — relationship vs. assignment. + + Use this when the edge between the consuming instance and the target was + created by a `:relate` action (a `Provider.Relationship` record). Use + `InheritedCharacteristicViaAssignment` when the edge was created by the + Assigner (an `AssignmentRelationship` record). + + Local-to-this-repo for now. Worth yarning upstream alongside the + assignment variant as a pair of provider-side calcs. + + ## Options + + - `characteristic_module:` *(required)* — the typed characteristic Ash + resource on the final source (e.g. `NniCharacteristic`). The calc + queries this resource by `instance_id` and returns the `.value`. + - `type:` *(optional)* — filter relationships by type atom (e.g. `:contains`). + - `alias:` *(optional)* — filter relationships by alias atom (e.g. `:avc`). + - `then_via:` *(optional)* — list of consumer-alias atoms to walk back + via `AssignmentRelationship` **after** the relationship hop. Each step + walks back through the target's incoming assignments (`target_id + + alias` identity, so each step has cardinality ≤1). Aliases name the + upstream related resource each consumer is part of. Use this for mixed + paths — one relationship hop followed by one or more assignment hops + — e.g. PRI's `:cvc` bring-up: `:circuit` owns relationship, then `:cvc` + assignment back to the CVC. + - `singular?:` *(optional, default `false`)* — unwrap to a single value + when the consumer expects a 1-cardinality result (e.g. PRI's `:avc` or + `:uni` aliased owns-relationship). Declare the calc's return type as + `:map` (rather than `{:array, :map}`) when using this option. + + ## Examples + + # NniGroup brings up the typed characteristic of every NNI it + # comprises — forward traversal of :contains relationships, returns + # a list of NniCharacteristic values. + calculate :nnis, {:array, :map}, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [type: :contains, characteristic_module: NniCharacteristic]} + + # PRI brings up the singular AVC it owns — PRI calls this related + # resource :circuit (its domain role), set as the alias on PRI's + # owns relationship. + calculate :avc, :map, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [alias: :circuit, characteristic_module: AvcCharacteristic, singular?: true]} + + # PRI brings up the singular CVC two-hop — :circuit owns relationship + # from PRI to AVC, then back via the AVC's :cvc consumer-alias + # assignment from CVC. + calculate :cvc, :map, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [alias: :circuit, then_via: [:cvc], + characteristic_module: CvcCharacteristic, singular?: true]} + """ + use Ash.Resource.Calculation + require Ash.Query + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, opts, _context) do + characteristic_module = Keyword.fetch!(opts, :characteristic_module) + type_filter = Keyword.get(opts, :type) + alias_filter = Keyword.get(opts, :alias) + then_via = Keyword.get(opts, :then_via, []) + singular? = Keyword.get(opts, :singular?, false) + + Enum.map(records, fn record -> + target_ids = + Diffo.Provider.Relationship + |> filter_relationships(record.id, type_filter, alias_filter) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.target_id) + + final_ids = walk_assignments(target_ids, then_via) + + values = + Enum.flat_map(final_ids, fn id -> + characteristic_module + |> Ash.Query.filter_input(instance_id: id) + |> Ash.Query.load(:value) + |> Ash.read!() + |> Enum.map(& &1.value) + end) + + if singular?, do: List.first(values), else: values + end) + end + + # Walks back through incoming `AssignmentRelationship` records for each + # id, following `target_id + alias` (identity, ≤1 source per step). + defp walk_assignments(ids, []), do: ids + + defp walk_assignments(ids, [alias_step | rest]) do + next_ids = + Enum.flat_map(ids, fn id -> + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(target_id: id, alias: alias_step) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.source_id) + end) + + walk_assignments(next_ids, rest) + end + + defp filter_relationships(query, source_id, nil, nil), + do: Ash.Query.filter_input(query, source_id: source_id) + + defp filter_relationships(query, source_id, type, nil), + do: Ash.Query.filter_input(query, source_id: source_id, type: type) + + defp filter_relationships(query, source_id, nil, alias_name), + do: Ash.Query.filter_input(query, source_id: source_id, alias: alias_name) + + defp filter_relationships(query, source_id, type, alias_name), + do: Ash.Query.filter_input(query, source_id: source_id, type: type, alias: alias_name) +end diff --git a/lib/diffo_example/calculations/reverse_inherited_characteristic.ex b/lib/diffo_example/calculations/reverse_inherited_characteristic.ex index af146f9..66629d9 100644 --- a/lib/diffo_example/calculations/reverse_inherited_characteristic.ex +++ b/lib/diffo_example/calculations/reverse_inherited_characteristic.ex @@ -26,19 +26,34 @@ defmodule DiffoExample.Calculations.ReverseInheritedCharacteristic do ## Options - - `alias:` *(optional)* — filter outgoing assignments by alias (the - assignee's slot name). When omitted, all outgoing assignments are - included. - `characteristic_module:` *(required)* — the typed characteristic Ash resource on each assignee (e.g. `CardCharacteristic`). + - `alias:` *(optional)* — filter outgoing assignments by alias (the + assignee's slot name). Use when every consumer follows the same + aliasing convention. + - `thing:` *(optional)* — filter outgoing assignments by `thing` (the + pool's thing name, e.g. `:slot`, `:port`). Always set from the pool + DSL declaration, so this is more robust than `alias:` when consumers + don't reliably name their slots. See `[[assignment-direction-asymmetry]]`. - ## Example + Specify either `alias:` or `thing:` (or both); omitting both includes + every outgoing assignment from this instance. + + ## Examples # Shelf brings up the card characteristic from every card it's - # assigned a slot to, ordered by slot number. + # assigned a slot to, ordered by slot number. Cards-as-assignees + # name their slot :slot when requesting. calculate :cards, {:array, :map}, {DiffoExample.Calculations.ReverseInheritedCharacteristic, [alias: :slot, characteristic_module: CardCharacteristic]} + + # NTD brings up the UNI characteristic from every UNI it's assigned + # a port to. The UNIs may not have set an alias on their request, + # so filter by `thing` from the :ports pool declaration. + calculate :unis, {:array, :map}, + {DiffoExample.Calculations.ReverseInheritedCharacteristic, + [thing: :port, characteristic_module: UniCharacteristic]} """ use Ash.Resource.Calculation @@ -48,12 +63,13 @@ defmodule DiffoExample.Calculations.ReverseInheritedCharacteristic do @impl true def calculate(records, opts, _context) do alias_filter = Keyword.get(opts, :alias) + thing_filter = Keyword.get(opts, :thing) characteristic_module = Keyword.fetch!(opts, :characteristic_module) Enum.map(records, fn record -> assignments = Diffo.Provider.AssignmentRelationship - |> filter_outgoing(record.id, alias_filter) + |> filter_outgoing(record.id, alias_filter, thing_filter) |> Ash.Query.sort(value: :asc) |> Ash.read!(domain: Diffo.Provider) @@ -67,9 +83,20 @@ defmodule DiffoExample.Calculations.ReverseInheritedCharacteristic do end) end - defp filter_outgoing(query, source_id, nil), - do: query |> Ash.Query.filter_input(source_id: source_id) + defp filter_outgoing(query, source_id, nil, nil), + do: Ash.Query.filter_input(query, source_id: source_id) + + defp filter_outgoing(query, source_id, alias_filter, nil), + do: Ash.Query.filter_input(query, source_id: source_id, alias: alias_filter) + + defp filter_outgoing(query, source_id, nil, thing_filter), + do: Ash.Query.filter_input(query, source_id: source_id, thing: thing_filter) - defp filter_outgoing(query, source_id, alias_filter), - do: query |> Ash.Query.filter_input(source_id: source_id, alias: alias_filter) + defp filter_outgoing(query, source_id, alias_filter, thing_filter), + do: + Ash.Query.filter_input(query, + source_id: source_id, + alias: alias_filter, + thing: thing_filter + ) end diff --git a/lib/nbn/nbn.ex b/lib/nbn/nbn.ex index b21cafc..dda9e09 100644 --- a/lib/nbn/nbn.ex +++ b/lib/nbn/nbn.ex @@ -27,7 +27,9 @@ defmodule DiffoExample.Nbn do alias DiffoExample.Nbn.Rsp alias DiffoExample.Nbn.AvcCharacteristic alias DiffoExample.Nbn.CvcCharacteristic + alias DiffoExample.Nbn.CvcMetrics alias DiffoExample.Nbn.NniGroupCharacteristic + alias DiffoExample.Nbn.NniGroupMetrics alias DiffoExample.Nbn.NniCharacteristic alias DiffoExample.Nbn.NtdCharacteristic alias DiffoExample.Nbn.UniCharacteristic @@ -220,7 +222,9 @@ defmodule DiffoExample.Nbn do resource AvcCharacteristic resource CvcCharacteristic + resource CvcMetrics resource NniGroupCharacteristic + resource NniGroupMetrics resource NniCharacteristic resource NtdCharacteristic resource UniCharacteristic diff --git a/lib/nbn/resources/avc.ex b/lib/nbn/resources/avc.ex index 8be922d..0108c7b 100644 --- a/lib/nbn/resources/avc.ex +++ b/lib/nbn/resources/avc.ex @@ -96,6 +96,36 @@ defmodule DiffoExample.Nbn.Avc do end end + calculations do + # The CVC characteristic value brought up from the singular CVC this + # AVC is part of — single-hop via the AVC's :cvc consumer-alias on its + # cvlan assignment from the CVC. + calculate :cvc, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, + [ + via: [:cvc], + characteristic_module: DiffoExample.Nbn.CvcCharacteristic, + singular?: true + ]} do + public? true + end + + # The singular NniGroup characteristic value brought up transitively — + # AVC's :cvc alias to the CVC, then the CVC's :nni_group alias to its + # NniGroup. Two-hop via [:cvc, :nni_group]. + calculate :nni_group, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, + [ + via: [:cvc, :nni_group], + characteristic_module: DiffoExample.Nbn.NniGroupCharacteristic, + singular?: true + ]} do + public? true + end + end + def identifier() do DiffoExample.Nbn.Util.identifier("AVC") end diff --git a/lib/nbn/resources/characteristic_values/cvc_metrics.ex b/lib/nbn/resources/characteristic_values/cvc_metrics.ex new file mode 100644 index 0000000..7f6f5f1 --- /dev/null +++ b/lib/nbn/resources/characteristic_values/cvc_metrics.ex @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Nbn.CvcMetrics do + @moduledoc """ + Local metrics characteristic for a CVC — `avcs_count` and + `avcs_total_bandwidth` aggregated live across the AVCs the CVC has + assigned a cvlan to. Not inheritable. + """ + use Ash.Resource, + fragments: [Diffo.Provider.BaseCharacteristic], + domain: DiffoExample.Nbn + + resource do + description "Live metrics for a CVC — count and total downstream bandwidth across its assigned AVCs" + plural_name :cvc_metrics + end + + calculations do + calculate :value, + Diffo.Type.CharacteristicValue, + DiffoExample.Nbn.CvcMetrics.ValueCalculation do + public? true + end + end + + preparations do + prepare build(load: [:value]) + end + + jason do + pick [:name, :value] + compact true + end +end + +defmodule DiffoExample.Nbn.CvcMetrics.Value do + @moduledoc false + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + + jason do + pick [:avcs_count, :avcs_total_bandwidth] + compact true + rename avcs_count: "avcsCount", avcs_total_bandwidth: "avcsTotalBandwidth" + end + + typed_struct do + field :avcs_count, :integer + field :avcs_total_bandwidth, :integer + end +end + +defmodule DiffoExample.Nbn.CvcMetrics.ValueCalculation do + @moduledoc false + use Ash.Resource.Calculation + + require Ash.Query + + alias DiffoExample.Nbn.AvcCharacteristic + alias DiffoExample.Nbn.BandwidthProfile + alias DiffoExample.Nbn.CvcMetrics + + @impl true + def load(_, _, _), do: [] + + @impl true + def calculate(records, _, _) do + Enum.map(records, fn r -> + # AVCs the CVC has assigned a cvlan to live on the target side of + # outgoing AssignmentRelationships sourced from this CVC's :cvlan + # pool. Filter by `thing` (the pool's thing name) rather than `alias` + # — alias is the consumer's slot name and may be unset. + avc_ids = + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(source_id: r.instance_id, thing: :cvlan) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.target_id) + + avcs = + Enum.flat_map(avc_ids, fn id -> + AvcCharacteristic + |> Ash.Query.filter_input(instance_id: id) + |> Ash.read!() + end) + + %CvcMetrics.Value{ + avcs_count: length(avcs), + avcs_total_bandwidth: + Enum.reduce(avcs, 0, fn avc, acc -> + acc + BandwidthProfile.downstream(avc.bandwidth_profile) + end) + } + end) + end +end diff --git a/lib/nbn/resources/characteristic_values/nni_group_metrics.ex b/lib/nbn/resources/characteristic_values/nni_group_metrics.ex new file mode 100644 index 0000000..c1b8c87 --- /dev/null +++ b/lib/nbn/resources/characteristic_values/nni_group_metrics.ex @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Nbn.NniGroupMetrics do + @moduledoc """ + Local metrics characteristic for an NNI Group — demand-side aggregates + across assigned CVCs (`cvcs_count`, `cvcs_total_bandwidth`), capacity-side + aggregates across comprised NNIs (`nnis_count`, `nnis_total_bandwidth`), + and the derived `utilization = cvcs_total_bandwidth / nnis_total_bandwidth`. + + Expected `utilization` range 0–1 under normal provisioning; >1 indicates + deliberate oversubscription. Not inheritable. + """ + use Ash.Resource, + fragments: [Diffo.Provider.BaseCharacteristic], + domain: DiffoExample.Nbn + + resource do + description "Live metrics for an NNI Group — demand from CVCs, capacity from NNIs, and utilization" + plural_name :nni_group_metrics + end + + calculations do + calculate :value, + Diffo.Type.CharacteristicValue, + DiffoExample.Nbn.NniGroupMetrics.ValueCalculation do + public? true + end + end + + preparations do + prepare build(load: [:value]) + end + + jason do + pick [:name, :value] + compact true + end +end + +defmodule DiffoExample.Nbn.NniGroupMetrics.Value do + @moduledoc false + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + + jason do + pick [ + :cvcs_count, + :cvcs_total_bandwidth, + :nnis_count, + :nnis_total_bandwidth, + :utilization + ] + + compact true + + rename cvcs_count: "cvcsCount", + cvcs_total_bandwidth: "cvcsTotalBandwidth", + nnis_count: "nnisCount", + nnis_total_bandwidth: "nnisTotalBandwidth" + end + + typed_struct do + field :cvcs_count, :integer + field :cvcs_total_bandwidth, :integer + field :nnis_count, :integer + field :nnis_total_bandwidth, :integer + field :utilization, :float + end +end + +defmodule DiffoExample.Nbn.NniGroupMetrics.ValueCalculation do + @moduledoc false + use Ash.Resource.Calculation + + require Ash.Query + + alias DiffoExample.Nbn.CvcCharacteristic + alias DiffoExample.Nbn.NniCharacteristic + alias DiffoExample.Nbn.NniGroupMetrics + + @impl true + def load(_, _, _), do: [] + + @impl true + def calculate(records, _, _) do + Enum.map(records, fn r -> + {cvcs_count, cvcs_total_bandwidth} = cvc_aggregates(r.instance_id) + {nnis_count, nnis_total_bandwidth} = nni_aggregates(r.instance_id) + + %NniGroupMetrics.Value{ + cvcs_count: cvcs_count, + cvcs_total_bandwidth: cvcs_total_bandwidth, + nnis_count: nnis_count, + nnis_total_bandwidth: nnis_total_bandwidth, + utilization: utilization(cvcs_total_bandwidth, nnis_total_bandwidth) + } + end) + end + + # Demand: CVCs the NNI Group has assigned an svlan to live on the target + # side of outgoing AssignmentRelationships sourced from the group's :svlan + # pool. Filter by `thing` (the pool's thing name) rather than `alias` — + # alias is the consumer's slot name and may be unset. + defp cvc_aggregates(nni_group_id) do + cvc_ids = + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(source_id: nni_group_id, thing: :svlan) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.target_id) + + cvcs = + Enum.flat_map(cvc_ids, fn id -> + CvcCharacteristic + |> Ash.Query.filter_input(instance_id: id) + |> Ash.read!() + end) + + {length(cvcs), Enum.reduce(cvcs, 0, fn cvc, acc -> acc + (cvc.bandwidth || 0) end)} + end + + # Capacity: NNIs the NNI Group comprises live on the target side of + # outgoing :contains Relationships sourced from the group. Relationships + # (not DefinedSimpleRelationships) carry the relate-action's TMF + # source/target/type/alias surface. + defp nni_aggregates(nni_group_id) do + nni_ids = + Diffo.Provider.Relationship + |> Ash.Query.filter_input(source_id: nni_group_id, type: :contains) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.target_id) + + nnis = + Enum.flat_map(nni_ids, fn id -> + NniCharacteristic + |> Ash.Query.filter_input(instance_id: id) + |> Ash.read!() + end) + + {length(nnis), Enum.reduce(nnis, 0, fn nni, acc -> acc + (nni.capacity || 0) end)} + end + + defp utilization(_demand, capacity) when capacity in [nil, 0], do: 0.0 + defp utilization(demand, capacity), do: demand / capacity +end diff --git a/lib/nbn/resources/cvc.ex b/lib/nbn/resources/cvc.ex index 90d38b6..e1bfc52 100644 --- a/lib/nbn/resources/cvc.ex +++ b/lib/nbn/resources/cvc.ex @@ -45,6 +45,7 @@ defmodule DiffoExample.Nbn.Cvc do characteristics do characteristic :cvc, DiffoExample.Nbn.CvcCharacteristic + characteristic :metrics, DiffoExample.Nbn.CvcMetrics end pools do @@ -109,6 +110,22 @@ defmodule DiffoExample.Nbn.Cvc do end end + calculations do + # The NniGroup characteristic value brought up from the singular + # NniGroup this CVC is part of — single-hop via the CVC's :nni_group + # consumer-alias on its svlan assignment from the NniGroup. + calculate :nni_group, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, + [ + via: [:nni_group], + characteristic_module: DiffoExample.Nbn.NniGroupCharacteristic, + singular?: true + ]} do + public? true + end + end + def identifier() do DiffoExample.Nbn.Util.identifier("CVC") end diff --git a/lib/nbn/resources/nbn_ethernet.ex b/lib/nbn/resources/nbn_ethernet.ex index 7dae763..8a53da9 100644 --- a/lib/nbn/resources/nbn_ethernet.ex +++ b/lib/nbn/resources/nbn_ethernet.ex @@ -94,6 +94,67 @@ defmodule DiffoExample.Nbn.NbnEthernet do end end + calculations do + # PRI names its two owns relationships by the domain role each plays — + # `:circuit` for the AVC (Access Virtual Circuit) and `:port` for the + # UNI (the customer's port). Both are consumer-aliases on PRI's owns + # relationships, set at relate time. + + # The singular AVC this access owns — single-hop via the :circuit owns relationship. + calculate :avc, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [ + alias: :circuit, + characteristic_module: DiffoExample.Nbn.AvcCharacteristic, + singular?: true + ]} do + public? true + end + + # The singular UNI this access owns — single-hop via the :port owns relationship. + calculate :uni, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [ + alias: :port, + characteristic_module: DiffoExample.Nbn.UniCharacteristic, + singular?: true + ]} do + public? true + end + + # The singular CVC backing this access's circuit — two-hop via the + # :circuit owns relationship, then back via the AVC's :cvc consumer-alias + # assignment to its CVC. + calculate :cvc, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [ + alias: :circuit, + then_via: [:cvc], + characteristic_module: DiffoExample.Nbn.CvcCharacteristic, + singular?: true + ]} do + public? true + end + + # The singular NTD this access's port plugs into — two-hop via the + # :port owns relationship, then back via the UNI's :ntd consumer-alias + # assignment to its NTD. + calculate :ntd, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [ + alias: :port, + then_via: [:ntd], + characteristic_module: DiffoExample.Nbn.NtdCharacteristic, + singular?: true + ]} do + public? true + end + end + def identifier() do DiffoExample.Nbn.Util.identifier("PRI") end diff --git a/lib/nbn/resources/nni_group.ex b/lib/nbn/resources/nni_group.ex index baf8897..1bccebd 100644 --- a/lib/nbn/resources/nni_group.ex +++ b/lib/nbn/resources/nni_group.ex @@ -44,6 +44,7 @@ defmodule DiffoExample.Nbn.NniGroup do characteristics do characteristic :nni_group, DiffoExample.Nbn.NniGroupCharacteristic + characteristic :metrics, DiffoExample.Nbn.NniGroupMetrics end pools do @@ -107,5 +108,16 @@ defmodule DiffoExample.Nbn.NniGroup do end end + calculations do + # The NNI characteristic value of every NNI this NniGroup comprises — + # forward traversal of :contains Relationships (low cardinality). + calculate :nnis, + {:array, :map}, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [type: :contains, characteristic_module: DiffoExample.Nbn.NniCharacteristic]} do + public? true + end + end + use DiffoExample.Nbn.RspOwnership end diff --git a/lib/nbn/resources/ntd.ex b/lib/nbn/resources/ntd.ex index 424a386..629d0f0 100644 --- a/lib/nbn/resources/ntd.ex +++ b/lib/nbn/resources/ntd.ex @@ -115,4 +115,18 @@ defmodule DiffoExample.Nbn.Ntd do change Diffo.Provider.Changes.Relate end end + + calculations do + # The UNI characteristic value of every UNI this NTD has assigned a + # port to — reverse traversal of outgoing :port AssignmentRelationships + # sourced from this NTD. Low cardinality (typical NTD has a handful of + # ports). Filter by `thing` (the pool's thing name) rather than `alias` + # — see assignment-direction-asymmetry memory. + calculate :unis, + {:array, :map}, + {DiffoExample.Calculations.ReverseInheritedCharacteristic, + [thing: :port, characteristic_module: DiffoExample.Nbn.UniCharacteristic]} do + public? true + end + end end diff --git a/lib/nbn/resources/types/bandwidth_profile.ex b/lib/nbn/resources/types/bandwidth_profile.ex index 0f30dea..d7ac57a 100644 --- a/lib/nbn/resources/types/bandwidth_profile.ex +++ b/lib/nbn/resources/types/bandwidth_profile.ex @@ -28,4 +28,36 @@ defmodule DiffoExample.Nbn.BandwidthProfile do ] def default, do: :home_fast + + @doc """ + Returns the representative downstream rate (Mbps) for a bandwidth profile. + + CVCs are sold as symmetric capacity in this model — we ignore the asymmetry + of satellite/wireless tiers and use the headline downstream figure as the + bandwidth contribution when aggregating. Used by `CvcMetrics` to compute + `avcs_total_bandwidth` across assigned AVCs. + + ## Examples + + iex> DiffoExample.Nbn.BandwidthProfile.downstream(:D100_U40) + 100 + iex> DiffoExample.Nbn.BandwidthProfile.downstream(:home_fast) + 500 + """ + def downstream(:D12_U1), do: 12 + def downstream(:D25_U5), do: 25 + def downstream(:D25_U10), do: 25 + def downstream(:D50_U20), do: 50 + def downstream(:D100_U40), do: 100 + def downstream(:D250_U100), do: 250 + def downstream(:D500_U200), do: 500 + def downstream(:D1000_U400), do: 1000 + def downstream(:wireless_plus), do: 100 + def downstream(:wireless_fast), do: 250 + def downstream(:wireless_superfast), do: 400 + def downstream(:home_fast), do: 500 + def downstream(:home_superfast), do: 750 + def downstream(:home_ultrafast), do: 1000 + def downstream(:home_hyperfast), do: 2000 + def downstream(nil), do: 0 end diff --git a/test/access/path_test.exs b/test/access/path_test.exs index c929ffa..94debff 100644 --- a/test/access/path_test.exs +++ b/test/access/path_test.exs @@ -104,11 +104,12 @@ defmodule DiffoExample.Access.PathTest do # now assign a port from a line card [_dslam, line_card] = create_dslam_with_line_card("QDONC-0001", tl(places), parties) - # path-as-assignee names its slot :port when requesting the port-assignment - # from the line card. This alias lets the InheritedCharacteristic calc - # traverse path → port → card (and transitively card → slot → shelf). + # path-as-assignee names its upstream Card relationship :card when + # requesting the port-assignment. The consumer-alias names the + # related resource, letting the inheritance calc traverse path → :card + # → card (and transitively card → :shelf → shelf). Access.assign_port!(line_card, %{ - assignment: %Assignment{assignee_id: path.id, alias: :port, operation: :auto_assign} + assignment: %Assignment{assignee_id: path.id, alias: :card, operation: :auto_assign} }) # 5 cables each assigned a pair to the path, plus 1 line card assigned a port @@ -245,10 +246,11 @@ defmodule DiffoExample.Access.PathTest do ] }) - # card-as-assignee names its slot :slot when requesting; alias lets - # downstream calculations traverse the assignment by name. + # card-as-assignee names its upstream Shelf relationship :shelf when + # requesting the slot-assignment; consumer-aliases let downstream calcs + # traverse by relationship. Access.assign_slot!(shelf, %{ - assignment: %Assignment{assignee_id: card.id, alias: :slot, operation: :auto_assign} + assignment: %Assignment{assignee_id: card.id, alias: :shelf, operation: :auto_assign} }) [shelf, card] diff --git a/test/access/shelf_test.exs b/test/access/shelf_test.exs index 1211a92..82a5e9a 100644 --- a/test/access/shelf_test.exs +++ b/test/access/shelf_test.exs @@ -180,15 +180,16 @@ defmodule DiffoExample.Access.ShelfTest do ] }) - # Each card-as-assignee names its slot :slot when requesting. + # Each card-as-assignee names its upstream Shelf relationship :shelf + # when requesting. {:ok, _shelf} = Access.assign_slot(shelf, %{ - assignment: %Assignment{assignee_id: card_a.id, alias: :slot, operation: :auto_assign} + assignment: %Assignment{assignee_id: card_a.id, alias: :shelf, operation: :auto_assign} }) {:ok, shelf} = Access.assign_slot(shelf, %{ - assignment: %Assignment{assignee_id: card_b.id, alias: :slot, operation: :auto_assign} + assignment: %Assignment{assignee_id: card_b.id, alias: :shelf, operation: :auto_assign} }) # Shelf brings up its cards (in slot order) and aggregates total ports. diff --git a/test/nbn/nbn_ethernet_test.exs b/test/nbn/nbn_ethernet_test.exs index 640b4e0..a0aa2d7 100644 --- a/test/nbn/nbn_ethernet_test.exs +++ b/test/nbn/nbn_ethernet_test.exs @@ -145,6 +145,91 @@ defmodule DiffoExample.Nbn.NbnEthernetTest do assert encoding == ~s({"id":"#{access.id}","href":"resourceInventoryManagement/v4/resource/#{access.id}","category":"Network Resource","description":"An NBN Ethernet access comprising a dedicated UNI and AVC","name":"#{access.name}","resourceSpecification":{"id":"f2a4c6e8-1b3d-4f5a-8c7e-9d0b2e4f6a8c","href":"resourceCatalogManagement/v4/resourceSpecification/f2a4c6e8-1b3d-4f5a-8c7e-9d0b2e4f6a8c","name":"nbnEthernet","version":"v1.0.0"},"resourceRelationship":[{"alias":"avc","type":"owns","resource":{"id":"#{avc.id}","href":"resourceInventoryManagement/v4/resource/#{avc.id}"}},{"alias":"uni","type":"owns","resource":{"id\":"#{uni.id}","href":"resourceInventoryManagement/v4/resource/#{uni.id}"}}],"supportingResource":[{"id":"avc","href":"resourceInventoryManagement/v4/resource/#{avc.id}"},{"id\":"uni","href":"resourceInventoryManagement/v4/resource/#{uni.id}"}],"resourceCharacteristic":[{"name":"pri","value":{}}]}) end + + test "pri brings up avc, uni, cvc, ntd via relationship + assignment chains" do + # CVC with cvlan pool, assigned to an AVC + {:ok, cvc} = Nbn.build_cvc(%{}) + + {:ok, cvc} = + Nbn.define_cvc(cvc, %{ + characteristic_value_updates: [ + cvc: [svlan: 1, bandwidth: 1000], + cvlans: [first: 1, last: 100, assignable_type: "cvlan"] + ] + }) + + # NTD with port pool, assigned to a UNI + {:ok, ntd} = Nbn.build_ntd(%{}) + + {:ok, ntd} = + Nbn.define_ntd(ntd, %{ + characteristic_value_updates: [ + ntd: [model: "Sercomm CG4000A", technology: :FTTP], + ports: [first: 1, last: 4, assignable_type: "port"] + ] + }) + + # AVC + UNI + {:ok, avc} = Nbn.build_avc(%{}) + + {:ok, _} = + Nbn.define_avc(avc, %{ + characteristic_value_updates: [avc: [bandwidth_profile: :home_fast]] + }) + + {:ok, uni} = Nbn.build_uni(%{}) + + {:ok, _} = + Nbn.define_uni(uni, %{ + characteristic_value_updates: [ + uni: [port: 1, encapsulation: "DSCP Mapped", technology: :FTTP] + ] + }) + + # AVC takes a cvlan from CVC; UNI takes a port from NTD. Consumer + # aliases name the upstream resource each is part of, so the + # inheritance walks (target_id + alias identity) resolve cleanly. + {:ok, _} = + Nbn.assign_cvlan(cvc, %{ + assignment: %Assignment{ + assignee_id: avc.id, + alias: :cvc, + operation: :auto_assign + } + }) + + {:ok, _} = + Nbn.assign_port(ntd, %{ + assignment: %Assignment{ + assignee_id: uni.id, + alias: :ntd, + operation: :auto_assign + } + }) + + # PRI owns the AVC and UNI. Aliases name the role each related + # resource plays from PRI's perspective — the AVC is the :circuit, + # the UNI is the :port. + {:ok, pri} = Nbn.build_nbn_ethernet(%{}) + + {:ok, _} = + Nbn.relate_nbn_ethernet(pri, %{ + relationships: [ + %Relationship{id: avc.id, direction: :forward, type: :owns, alias: :circuit}, + %Relationship{id: uni.id, direction: :forward, type: :owns, alias: :port} + ] + }) + + {:ok, pri} = Nbn.get_nbn_ethernet_by_id(pri.id, load: [:avc, :uni, :cvc, :ntd]) + + # Single-hop via :owns relationship + assert %{bandwidth_profile: :home_fast} = pri.avc + assert %{port: 1, encapsulation: "DSCP Mapped", technology: :FTTP} = pri.uni + + # Two-hop: :owns relationship then :cvlan / :port assignment + assert %{svlan: 1, bandwidth: 1000} = pri.cvc + assert %{model: "Sercomm CG4000A", technology: :FTTP} = pri.ntd + end end describe "build uni" do @@ -201,6 +286,61 @@ defmodule DiffoExample.Nbn.NbnEthernetTest do avc ) end + + test "avc inherits cvc (single-hop) and nni_group (two-hop) via assignment chain" do + # NniGroup with svlan pool, then a CVC that takes an svlan from it, + # then an AVC that takes a cvlan from the CVC. AVC's inherited calcs + # should bring up cvc and nni_group characteristics. + {:ok, nni_group} = Nbn.build_nni_group(%{}) + + {:ok, nni_group} = + Nbn.define_nni_group(nni_group, %{ + characteristic_value_updates: [ + nni_group: [group_name: "SYD-POI-01", location: "Sydney"], + svlans: [first: 1, last: 4000, assignable_type: "svlan"] + ] + }) + + {:ok, cvc} = Nbn.build_cvc(%{}) + + {:ok, cvc} = + Nbn.define_cvc(cvc, %{ + characteristic_value_updates: [ + cvc: [bandwidth: 1000], + cvlans: [first: 1, last: 4000, assignable_type: "cvlan"] + ] + }) + + {:ok, _nni_group} = + Nbn.assign_svlan(nni_group, %{ + assignment: %Assignment{ + assignee_id: cvc.id, + alias: :nni_group, + operation: :auto_assign + } + }) + + {:ok, avc} = Nbn.build_avc(%{}) + + {:ok, _avc} = + Nbn.define_avc(avc, %{ + characteristic_value_updates: [avc: [bandwidth_profile: :home_fast]] + }) + + {:ok, _cvc} = + Nbn.assign_cvlan(cvc, %{ + assignment: %Assignment{ + assignee_id: avc.id, + alias: :cvc, + operation: :auto_assign + } + }) + + {:ok, avc} = Nbn.get_avc_by_id(avc.id, load: [:cvc, :nni_group]) + + assert %{bandwidth: 1000} = avc.cvc + assert %{group_name: "SYD-POI-01", location: "Sydney"} = avc.nni_group + end end describe "build ntd" do @@ -254,6 +394,43 @@ defmodule DiffoExample.Nbn.NbnEthernetTest do ) end) end + + test "ntd brings up assigned UNIs as unis[] via :port assignment" do + {:ok, ntd} = Nbn.build_ntd(%{}) + + {:ok, ntd} = + Nbn.define_ntd(ntd, %{ + characteristic_value_updates: [ + ntd: [model: "Sercomm CG4000A", technology: :FTTP], + ports: [first: 1, last: 4, assignable_type: "port"] + ] + }) + + # Two UNIs defined and assigned ports from the NTD + for {port_num, encap} <- [{1, "DSCP Mapped"}, {2, "untagged"}] do + {:ok, uni} = Nbn.build_uni(%{}) + + {:ok, _} = + Nbn.define_uni(uni, %{ + characteristic_value_updates: [ + uni: [port: port_num, encapsulation: encap, technology: :FTTP] + ] + }) + + {:ok, _} = + Nbn.assign_port(ntd, %{ + assignment: %Assignment{assignee_id: uni.id, operation: :auto_assign} + }) + end + + {:ok, ntd} = Nbn.get_ntd_by_id(ntd.id, load: [:unis]) + + assert is_list(ntd.unis) + assert length(ntd.unis) == 2 + + ports = Enum.map(ntd.unis, & &1.port) |> Enum.sort() + assert ports == [1, 2] + end end describe "build cvc" do @@ -308,6 +485,43 @@ defmodule DiffoExample.Nbn.NbnEthernetTest do ) end) end + + test "cvc metrics aggregates avcs_count and avcs_total_bandwidth across assigned avcs" do + {:ok, cvc} = Nbn.build_cvc(%{}) + + {:ok, cvc} = + Nbn.define_cvc(cvc, %{ + characteristic_value_updates: [ + cvc: [svlan: 1, bandwidth: 10000], + cvlans: [first: 1, last: 4000, assignable_type: "cvlan"] + ] + }) + + # Two AVCs with distinct bandwidth_profiles — :home_fast (500 Mbps + # downstream) and :D100_U40 (100 Mbps downstream). + for profile <- [:home_fast, :D100_U40] do + {:ok, avc} = Nbn.build_avc(%{}) + + {:ok, _} = + Nbn.define_avc(avc, %{ + characteristic_value_updates: [avc: [bandwidth_profile: profile]] + }) + + {:ok, _} = + Nbn.assign_cvlan(cvc, %{ + assignment: %Assignment{assignee_id: avc.id, operation: :auto_assign} + }) + end + + metrics = + DiffoExample.Nbn.CvcMetrics + |> Ash.Query.filter_input(instance_id: cvc.id) + |> Ash.Query.load(:value) + |> Ash.read_one!() + + assert metrics.value.avcs_count == 2 + assert metrics.value.avcs_total_bandwidth == 600 + end end describe "build nni_group" do @@ -361,6 +575,78 @@ defmodule DiffoExample.Nbn.NbnEthernetTest do ) end) end + + test "nni_group metrics — cvcs and nnis aggregates plus utilization" do + {:ok, nni_group} = Nbn.build_nni_group(%{}) + + {:ok, nni_group} = + Nbn.define_nni_group(nni_group, %{ + characteristic_value_updates: [ + nni_group: [group_name: "SYD-POI-01", location: "Sydney"], + svlans: [first: 1, last: 4000, assignable_type: "svlan"] + ] + }) + + # Demand side: two CVCs assigned svlans from this NniGroup. + for bandwidth <- [400, 600] do + {:ok, cvc} = Nbn.build_cvc(%{}) + + {:ok, _} = + Nbn.define_cvc(cvc, %{ + characteristic_value_updates: [cvc: [bandwidth: bandwidth]] + }) + + {:ok, _} = + Nbn.assign_svlan(nni_group, %{ + assignment: %Assignment{assignee_id: cvc.id, operation: :auto_assign} + }) + end + + # Capacity side: two NNIs comprised by this NniGroup, related via + # DefinedSimpleRelationship type :contains. + nni_ids = + for capacity <- [10, 10] do + {:ok, nni} = Nbn.build_nni(%{}) + + {:ok, _} = + Nbn.define_nni(nni, %{ + characteristic_value_updates: [ + nni: [port_id: "SYD-01-ETH-#{capacity}", capacity: capacity] + ] + }) + + nni.id + end + + {:ok, _nni_group} = + Nbn.relate_nni_group(nni_group, %{ + relationships: + Enum.map(nni_ids, fn nni_id -> + %Relationship{id: nni_id, direction: :forward, type: :contains} + end) + }) + + metrics = + DiffoExample.Nbn.NniGroupMetrics + |> Ash.Query.filter_input(instance_id: nni_group.id) + |> Ash.Query.load(:value) + |> Ash.read_one!() + + assert metrics.value.cvcs_count == 2 + assert metrics.value.cvcs_total_bandwidth == 1000 + assert metrics.value.nnis_count == 2 + assert metrics.value.nnis_total_bandwidth == 20 + assert_in_delta metrics.value.utilization, 50.0, 0.001 + + # nnis[] brings up the NNI characteristic of every comprised NNI via + # the same :contains relationships. + {:ok, nni_group} = Nbn.get_nni_group_by_id(nni_group.id, load: [:nnis]) + + assert is_list(nni_group.nnis) + assert length(nni_group.nnis) == 2 + + assert Enum.all?(nni_group.nnis, &match?(%{capacity: 10}, &1)) + end end describe "build nni" do diff --git a/test/nbn/show_neo4j_test.exs b/test/nbn/show_neo4j_test.exs new file mode 100644 index 0000000..77a8dc5 --- /dev/null +++ b/test/nbn/show_neo4j_test.exs @@ -0,0 +1,315 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Nbn.ShowNeo4jTest do + @moduledoc """ + Tests tagged `:show_neo4j` build a coherent NBN graph in the **real** + Neo4j database (no sandbox, no rollback) so the result can be inspected + via the Neo4j browser afterwards. + + Each test prints the Ash-side records and TMF JSON to stdout while it + runs, and the persisted nodes/relationships remain in Neo4j for further + exploration. The two tests together build the picture — NniGroup → CVCs + → AVCs chain (with metrics), and an NTD with assigned UNIs. + + Run with: + + mix test --only show_neo4j + + Excluded from default test runs. + """ + use ExUnit.Case, async: false + + alias Diffo.Provider.Assignment + alias Diffo.Provider.Instance.Relationship + alias DiffoExample.Nbn + + @moduletag :show_neo4j + + test "NniGroup with NNIs, CVCs and an AVC — inheritance + metrics" do + {:ok, nni_group} = Nbn.build_nni_group(%{}) + + {:ok, nni_group} = + Nbn.define_nni_group(nni_group, %{ + characteristic_value_updates: [ + nni_group: [group_name: "SYD-POI-01", location: "Sydney Olympic Park"], + svlans: [first: 1, last: 4000, assignable_type: "svlan"] + ] + }) + + # Two CVCs assigned svlans from this NniGroup + cvc_ids = + for bandwidth <- [400, 600] do + {:ok, cvc} = Nbn.build_cvc(%{}) + + {:ok, _} = + Nbn.define_cvc(cvc, %{ + characteristic_value_updates: [ + cvc: [bandwidth: bandwidth], + cvlans: [first: 1, last: 4000, assignable_type: "cvlan"] + ] + }) + + {:ok, _} = + Nbn.assign_svlan(nni_group, %{ + assignment: %Assignment{ + assignee_id: cvc.id, + alias: :nni_group, + operation: :auto_assign + } + }) + + cvc.id + end + + # Two NNIs comprised by this NniGroup — realistic capacities so + # utilization comes out in the 0–1 range + nni_ids = + for {port_id, capacity} <- [{"SYD-01-ETH-1", 10000}, {"SYD-01-ETH-2", 10000}] do + {:ok, nni} = Nbn.build_nni(%{}) + + {:ok, _} = + Nbn.define_nni(nni, %{ + characteristic_value_updates: [ + nni: [port_id: port_id, capacity: capacity] + ] + }) + + nni.id + end + + {:ok, _} = + Nbn.relate_nni_group(nni_group, %{ + relationships: + Enum.map(nni_ids, fn id -> + %Relationship{id: id, direction: :forward, type: :contains} + end) + }) + + # One AVC assigned a cvlan from the first CVC + cvc1_id = hd(cvc_ids) + {:ok, cvc1} = Nbn.get_cvc_by_id(cvc1_id) + + {:ok, avc} = Nbn.build_avc(%{}) + + {:ok, _} = + Nbn.define_avc(avc, %{ + characteristic_value_updates: [avc: [bandwidth_profile: :home_fast]] + }) + + {:ok, _} = + Nbn.assign_cvlan(cvc1, %{ + assignment: %Assignment{ + assignee_id: avc.id, + alias: :cvc, + operation: :auto_assign + } + }) + + # Reload all three with their inheritance calcs / metrics loaded + {:ok, avc} = Nbn.get_avc_by_id(avc.id, load: [:cvc, :nni_group]) + {:ok, cvc} = Nbn.get_cvc_by_id(cvc1_id, load: [:nni_group]) + {:ok, nni_group} = Nbn.get_nni_group_by_id(nni_group.id, load: [:nnis]) + + cvc_metrics = + DiffoExample.Nbn.CvcMetrics + |> Ash.Query.filter_input(instance_id: cvc.id) + |> Ash.Query.load(:value) + |> Ash.read_one!() + + nni_group_metrics = + DiffoExample.Nbn.NniGroupMetrics + |> Ash.Query.filter_input(instance_id: nni_group.id) + |> Ash.Query.load(:value) + |> Ash.read_one!() + + IO.puts("\n========== AVC.cvc (single-hop :cvlan) ==========") + IO.inspect(avc.cvc, label: "avc.cvc") + + IO.puts("\n========== AVC.nni_group (two-hop [:cvlan, :svlan]) ==========") + IO.inspect(avc.nni_group, label: "avc.nni_group") + + IO.puts("\n========== CVC.nni_group (single-hop :svlan) ==========") + IO.inspect(cvc.nni_group, label: "cvc.nni_group") + + IO.puts("\n========== CvcMetrics record ==========") + IO.inspect(cvc_metrics.value, label: "cvc_metrics.value") + + IO.puts("\n========== NniGroupMetrics record ==========") + IO.inspect(nni_group_metrics.value, label: "nni_group_metrics.value") + + IO.puts("\n========== NniGroup.nnis (brought-up via :contains) ==========") + IO.inspect(nni_group.nnis, label: "nni_group.nnis") + + IO.puts("\n========== AVC (TMF JSON) ==========") + + avc + |> Jason.encode!() + |> Diffo.Util.summarise_dates() + |> Jason.decode!() + |> Jason.encode!(pretty: true) + |> IO.puts() + + IO.puts("\n========== CVC (TMF JSON) ==========") + + cvc + |> Jason.encode!() + |> Diffo.Util.summarise_dates() + |> Jason.decode!() + |> Jason.encode!(pretty: true) + |> IO.puts() + + IO.puts("\n========== NniGroup (TMF JSON) ==========") + + nni_group + |> Jason.encode!() + |> Diffo.Util.summarise_dates() + |> Jason.decode!() + |> Jason.encode!(pretty: true) + |> IO.puts() + end + + test "PRI (NbnEthernet access) with full delivery chain — AVC + UNI + CVC + NTD" do + # CVC + cvlan pool + {:ok, cvc} = Nbn.build_cvc(%{}) + + {:ok, cvc} = + Nbn.define_cvc(cvc, %{ + characteristic_value_updates: [ + cvc: [svlan: 1, bandwidth: 1000], + cvlans: [first: 1, last: 100, assignable_type: "cvlan"] + ] + }) + + # NTD + port pool + {:ok, ntd} = Nbn.build_ntd(%{}) + + {:ok, ntd} = + Nbn.define_ntd(ntd, %{ + characteristic_value_updates: [ + ntd: [model: "Sercomm CG4000A", technology: :FTTP], + ports: [first: 1, last: 4, assignable_type: "port"] + ] + }) + + # AVC + UNI + {:ok, avc} = Nbn.build_avc(%{}) + + {:ok, _} = + Nbn.define_avc(avc, %{ + characteristic_value_updates: [avc: [bandwidth_profile: :home_fast]] + }) + + {:ok, uni} = Nbn.build_uni(%{}) + + {:ok, _} = + Nbn.define_uni(uni, %{ + characteristic_value_updates: [ + uni: [port: 1, encapsulation: "DSCP Mapped", technology: :FTTP] + ] + }) + + {:ok, _} = + Nbn.assign_cvlan(cvc, %{ + assignment: %Assignment{ + assignee_id: avc.id, + alias: :cvc, + operation: :auto_assign + } + }) + + {:ok, _} = + Nbn.assign_port(ntd, %{ + assignment: %Assignment{ + assignee_id: uni.id, + alias: :ntd, + operation: :auto_assign + } + }) + + # PRI owns AVC (named :circuit from PRI's view) and UNI (named :port). + {:ok, pri} = Nbn.build_nbn_ethernet(%{}) + + {:ok, _} = + Nbn.relate_nbn_ethernet(pri, %{ + relationships: [ + %Relationship{id: avc.id, direction: :forward, type: :owns, alias: :circuit}, + %Relationship{id: uni.id, direction: :forward, type: :owns, alias: :port} + ] + }) + + {:ok, pri} = Nbn.get_nbn_ethernet_by_id(pri.id, load: [:avc, :uni, :cvc, :ntd]) + + IO.puts("\n========== PRI.avc (single-hop via :avc owns) ==========") + IO.inspect(pri.avc, label: "pri.avc") + + IO.puts("\n========== PRI.uni (single-hop via :uni owns) ==========") + IO.inspect(pri.uni, label: "pri.uni") + + IO.puts("\n========== PRI.cvc (two-hop via :avc owns + :cvlan assignment) ==========") + IO.inspect(pri.cvc, label: "pri.cvc") + + IO.puts("\n========== PRI.ntd (two-hop via :uni owns + :port assignment) ==========") + IO.inspect(pri.ntd, label: "pri.ntd") + + IO.puts("\n========== PRI (TMF JSON) ==========") + + pri + |> Jason.encode!() + |> Diffo.Util.summarise_dates() + |> Jason.decode!() + |> Jason.encode!(pretty: true) + |> IO.puts() + end + + test "NTD with assigned UNIs" do + {:ok, ntd} = Nbn.build_ntd(%{}) + + {:ok, ntd} = + Nbn.define_ntd(ntd, %{ + characteristic_value_updates: [ + ntd: [ + model: "Sercomm CG4000A", + serial_number: "SCOMA1A057A2", + technology: :FTTP + ], + ports: [first: 1, last: 4, assignable_type: "port"] + ] + }) + + for {port_num, encap} <- [{1, "DSCP Mapped"}, {2, "untagged"}] do + {:ok, uni} = Nbn.build_uni(%{}) + + {:ok, _} = + Nbn.define_uni(uni, %{ + characteristic_value_updates: [ + uni: [port: port_num, encapsulation: encap, technology: :FTTP] + ] + }) + + {:ok, _} = + Nbn.assign_port(ntd, %{ + assignment: %Assignment{ + assignee_id: uni.id, + alias: :ntd, + operation: :auto_assign + } + }) + end + + {:ok, ntd} = Nbn.get_ntd_by_id(ntd.id, load: [:unis]) + + IO.puts("\n========== NTD.unis (brought-up via :port assignment) ==========") + IO.inspect(ntd.unis, label: "ntd.unis") + + IO.puts("\n========== NTD (TMF JSON) ==========") + + ntd + |> Jason.encode!() + |> Diffo.Util.summarise_dates() + |> Jason.decode!() + |> Jason.encode!(pretty: true) + |> IO.puts() + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 7b3a5b5..1b7a8ee 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -7,4 +7,4 @@ level = Application.get_env(:logger, :console) |> Keyword.get(:level) Logger.put_application_level(:diffo, level) Logger.put_application_level(:ash_neo4j, :error) AshNeo4j.Neo4jHelper.delete_all() -ExUnit.start() +ExUnit.start(exclude: [:show_neo4j])