From 2548f6396d388bc120fcf8500abd53b91cb9df4f Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 23 May 2026 09:59:37 +0930 Subject: [PATCH 1/5] =?UTF-8?q?#49=20part=201=20=E2=80=94=20AVC,=20CVC,=20?= =?UTF-8?q?NniGroup=20characteristic=20inheritance=20and=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * AVC inherits CVC via :cvlan (single-hop) and NniGroup via [:cvlan, :svlan] (two-hop) — singular * CVC inherits NniGroup via :svlan; new :metrics characteristic with avcs_count, avcs_total_bandwidth * NniGroup new :metrics with cvcs/nnis counts and totals, plus utilization * NniGroup new :nnis calc — brings up each comprised NNI's typed value via :contains relationship * New InheritedCharacteristicViaAssignment (renamed) and InheritedCharacteristicViaRelationship sibling calcs * BandwidthProfile.downstream/1 maps profile atoms to Mbps * 3 new tests, 52/52 pass --- CHANGELOG.md | 11 + lib/access/resources/card.ex | 2 +- lib/access/resources/path.ex | 4 +- ...nherited_characteristic_via_assignment.ex} | 54 ++- ...herited_characteristic_via_relationship.ex | 93 ++++++ lib/nbn/nbn.ex | 4 + lib/nbn/resources/avc.ex | 29 ++ .../characteristic_values/cvc_metrics.ex | 96 ++++++ .../nni_group_metrics.ex | 145 +++++++++ lib/nbn/resources/cvc.ex | 17 + lib/nbn/resources/nni_group.ex | 12 + lib/nbn/resources/types/bandwidth_profile.ex | 32 ++ test/nbn/nbn_ethernet_test.exs | 308 ++++++++++++++++++ test/test_helper.exs | 2 +- 14 files changed, 789 insertions(+), 20 deletions(-) rename lib/diffo_example/calculations/{inherited_characteristic.ex => inherited_characteristic_via_assignment.ex} (55%) create mode 100644 lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex create mode 100644 lib/nbn/resources/characteristic_values/cvc_metrics.ex create mode 100644 lib/nbn/resources/characteristic_values/nni_group_metrics.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 3592cf3..dbe705b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,17 @@ 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). + * CVC inherits the upstream NniGroup's `nni_group` characteristic via the `:svlan` assignment. + * 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`. +* `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` to make room for a future `InheritedCharacteristicViaRelationship` sibling (the latter lands in the PRI bring-up work). + ## [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/resources/card.ex b/lib/access/resources/card.ex index 396014b..47f7c53 100644 --- a/lib/access/resources/card.ex +++ b/lib/access/resources/card.ex @@ -93,7 +93,7 @@ defmodule DiffoExample.Access.Card do # in — derived live via the :slot assignment. calculate :shelf, {:array, :map}, - {DiffoExample.Calculations.InheritedCharacteristic, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, [via: [:slot], characteristic_module: DiffoExample.Access.ShelfCharacteristic]} do public? true end diff --git a/lib/access/resources/path.ex b/lib/access/resources/path.ex index c56e2f2..5c80f21 100644 --- a/lib/access/resources/path.ex +++ b/lib/access/resources/path.ex @@ -80,7 +80,7 @@ defmodule DiffoExample.Access.Path do # assigned a port on — via the :port assignment. calculate :card, {:array, :map}, - {DiffoExample.Calculations.InheritedCharacteristic, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, [via: [:port], characteristic_module: DiffoExample.Access.CardCharacteristic]} do public? true end @@ -97,7 +97,7 @@ defmodule DiffoExample.Access.Path do # card, then the card's slot to its shelf. Two-hop via [:port, :slot]. calculate :shelf, {:array, :map}, - {DiffoExample.Calculations.InheritedCharacteristic, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, [ via: [:port, :slot], characteristic_module: DiffoExample.Access.ShelfCharacteristic diff --git a/lib/diffo_example/calculations/inherited_characteristic.ex b/lib/diffo_example/calculations/inherited_characteristic_via_assignment.ex similarity index 55% rename from lib/diffo_example/calculations/inherited_characteristic.ex rename to lib/diffo_example/calculations/inherited_characteristic_via_assignment.ex index 1495028..9d831e8 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 @@ -26,20 +31,33 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do - `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 :cvlan — AssignmentRelationship + # identity guarantees ≤1 source, so we declare :map and ask the calc + # to unwrap. + calculate :cvc, :map, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, + [via: [:cvlan], characteristic_module: CvcCharacteristic, singular?: true]} """ use Ash.Resource.Calculation @@ -50,6 +68,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 +81,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..efb2e6a --- /dev/null +++ b/lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex @@ -0,0 +1,93 @@ +# 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 each target (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`). + - `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 via the :avc alias. + calculate :avc, :map, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [alias: :avc, characteristic_module: AvcCharacteristic, 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) + 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) + + values = + Enum.flat_map(target_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 + + 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/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..93bcc25 100644 --- a/lib/nbn/resources/avc.ex +++ b/lib/nbn/resources/avc.ex @@ -96,6 +96,35 @@ defmodule DiffoExample.Nbn.Avc do end end + calculations do + # The CVC characteristic value brought up from the singular CVC this + # AVC is assigned a cvlan on — single-hop via the :cvlan assignment. + calculate :cvc, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, + [ + via: [:cvlan], + characteristic_module: DiffoExample.Nbn.CvcCharacteristic, + singular?: true + ]} do + public? true + end + + # The singular NniGroup characteristic value brought up transitively — + # cvlan to the CVC, then the CVC's svlan to its NniGroup. Two-hop via + # [:cvlan, :svlan]. + calculate :nni_group, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, + [ + via: [:cvlan, :svlan], + 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..d4d350c 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 assigned an svlan on — single-hop via the + # :svlan assignment. + calculate :nni_group, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, + [ + via: [:svlan], + 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/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/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/nbn/nbn_ethernet_test.exs b/test/nbn/nbn_ethernet_test.exs index 640b4e0..b8573f7 100644 --- a/test/nbn/nbn_ethernet_test.exs +++ b/test/nbn/nbn_ethernet_test.exs @@ -19,6 +19,150 @@ defmodule DiffoExample.Nbn.NbnEthernetTest do alias Diffo.Provider.Assignment alias Diffo.Provider.Instance.Relationship + @tag :show_json + test "show AVC, CVC, NniGroup as JSON (run with `mix test --only show_json`)" 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: :svlan, + operation: :auto_assign + } + }) + + cvc.id + end + + # Two NNIs comprised by this NniGroup — realistic capacities so + # utilization comes out in the 0–1 range we expect operationally. + 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: :cvlan, + 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 (live aggregate over assigned AVCs) ==========") + IO.inspect(cvc_metrics.value, label: "cvc_metrics.value") + + IO.puts("\n========== NniGroupMetrics record (cvcs/nnis aggregates + utilization) ==========") + IO.inspect(nni_group_metrics.value, label: "nni_group_metrics.value") + + IO.puts("\n========== NniGroup.nnis (brought-up via :contains Relationship) ==========") + IO.inspect(nni_group.nnis, label: "nni_group.nnis") + + IO.puts("\n========== AVC (TMF JSON, current state) ==========") + + avc + |> Jason.encode!() + |> Diffo.Util.summarise_dates() + |> Jason.decode!() + |> Jason.encode!(pretty: true) + |> IO.puts() + + IO.puts("\n========== CVC (TMF JSON, current state) ==========") + + cvc + |> Jason.encode!() + |> Diffo.Util.summarise_dates() + |> Jason.decode!() + |> Jason.encode!(pretty: true) + |> IO.puts() + + IO.puts("\n========== NniGroup (TMF JSON, current state) ==========") + + nni_group + |> Jason.encode!() + |> Diffo.Util.summarise_dates() + |> Jason.decode!() + |> Jason.encode!(pretty: true) + |> IO.puts() + end + describe "build nbn_ethernet" do test "create an nbn_ethernet access" do {:ok, access} = Nbn.build_nbn_ethernet(%{}) @@ -201,6 +345,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: :svlan, + 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: :cvlan, + 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 @@ -308,6 +507,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 +597,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/test_helper.exs b/test/test_helper.exs index 7b3a5b5..b5ad52e 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_json]) From 069ed1fa4b44964af7558b0ad2b453594051d99f Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 23 May 2026 15:08:09 +0930 Subject: [PATCH 2/5] =?UTF-8?q?#49=20part=202=20=E2=80=94=20NTD=20brings?= =?UTF-8?q?=20up=20assigned=20UNIs=20as=20unis[]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * NTD :unis calc — reverse-inherits each assigned UNI's typed value via :port * ReverseInheritedCharacteristic extended with thing: filter (source-side principle) * New test/nbn/show_neo4j_test.exs — sync, non-sandboxed exploration module that builds the NBN graph in real Neo4j for browser inspection. Both prior :show_json tests moved here and re-tagged :show_neo4j * DataCase reverted to its original shape — sandbox isolates without needing per-module wipes * 1 new sandboxed test, 53/53 pass --- CHANGELOG.md | 9 +- .../reverse_inherited_characteristic.ex | 47 +++- lib/nbn/resources/ntd.ex | 14 ++ test/nbn/nbn_ethernet_test.exs | 181 +++------------ test/nbn/show_neo4j_test.exs | 218 ++++++++++++++++++ test/test_helper.exs | 2 +- 6 files changed, 313 insertions(+), 158 deletions(-) create mode 100644 test/nbn/show_neo4j_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index dbe705b..41cdd12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,14 +28,17 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline ### 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). - * CVC inherits the upstream NniGroup's `nni_group` characteristic via the `:svlan` assignment. + * 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). * `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` to make room for a future `InheritedCharacteristicViaRelationship` sibling (the latter lands in the PRI bring-up work). +* `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. +* `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) 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/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/test/nbn/nbn_ethernet_test.exs b/test/nbn/nbn_ethernet_test.exs index b8573f7..a241a01 100644 --- a/test/nbn/nbn_ethernet_test.exs +++ b/test/nbn/nbn_ethernet_test.exs @@ -19,150 +19,6 @@ defmodule DiffoExample.Nbn.NbnEthernetTest do alias Diffo.Provider.Assignment alias Diffo.Provider.Instance.Relationship - @tag :show_json - test "show AVC, CVC, NniGroup as JSON (run with `mix test --only show_json`)" 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: :svlan, - operation: :auto_assign - } - }) - - cvc.id - end - - # Two NNIs comprised by this NniGroup — realistic capacities so - # utilization comes out in the 0–1 range we expect operationally. - 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: :cvlan, - 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 (live aggregate over assigned AVCs) ==========") - IO.inspect(cvc_metrics.value, label: "cvc_metrics.value") - - IO.puts("\n========== NniGroupMetrics record (cvcs/nnis aggregates + utilization) ==========") - IO.inspect(nni_group_metrics.value, label: "nni_group_metrics.value") - - IO.puts("\n========== NniGroup.nnis (brought-up via :contains Relationship) ==========") - IO.inspect(nni_group.nnis, label: "nni_group.nnis") - - IO.puts("\n========== AVC (TMF JSON, current state) ==========") - - avc - |> Jason.encode!() - |> Diffo.Util.summarise_dates() - |> Jason.decode!() - |> Jason.encode!(pretty: true) - |> IO.puts() - - IO.puts("\n========== CVC (TMF JSON, current state) ==========") - - cvc - |> Jason.encode!() - |> Diffo.Util.summarise_dates() - |> Jason.decode!() - |> Jason.encode!(pretty: true) - |> IO.puts() - - IO.puts("\n========== NniGroup (TMF JSON, current state) ==========") - - nni_group - |> Jason.encode!() - |> Diffo.Util.summarise_dates() - |> Jason.decode!() - |> Jason.encode!(pretty: true) - |> IO.puts() - end - describe "build nbn_ethernet" do test "create an nbn_ethernet access" do {:ok, access} = Nbn.build_nbn_ethernet(%{}) @@ -453,6 +309,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 diff --git a/test/nbn/show_neo4j_test.exs b/test/nbn/show_neo4j_test.exs new file mode 100644 index 0000000..85ca74e --- /dev/null +++ b/test/nbn/show_neo4j_test.exs @@ -0,0 +1,218 @@ +# 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: :svlan, + 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: :cvlan, + 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 "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, 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 b5ad52e..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(exclude: [:show_json]) +ExUnit.start(exclude: [:show_neo4j]) From 56a687454f4af6618d4eaa7ef4800399df47347b Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 23 May 2026 15:31:23 +0930 Subject: [PATCH 3/5] =?UTF-8?q?#49=20part=203=20=E2=80=94=20PRI=20brings?= =?UTF-8?q?=20up=20the=20full=20delivery=20chain=20(avc,=20uni,=20cvc,=20n?= =?UTF-8?q?td)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PRI single-hop avc/uni via :owns relationship; two-hop cvc/ntd via :owns then assignment * InheritedCharacteristicViaRelationship extended with then_via: for mixed paths * 1 new sandboxed test, 1 new show_neo4j exploration test, 54/54 pass --- CHANGELOG.md | 3 +- ...herited_characteristic_via_relationship.ex | 39 +++++++- lib/nbn/resources/nbn_ethernet.ex | 54 +++++++++++ test/nbn/nbn_ethernet_test.exs | 83 +++++++++++++++++ test/nbn/show_neo4j_test.exs | 93 +++++++++++++++++++ 5 files changed, 268 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cdd12..67e5b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,10 +34,11 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline * 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 `:avc` owns relationship, `uni` single-hop via the `:uni` owns relationship, `cvc` two-hop via `:avc` then `:cvlan`, and `ntd` two-hop via `:uni` then `:port`. All singular. * `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. +* `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) diff --git a/lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex b/lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex index efb2e6a..bd70cb7 100644 --- a/lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex +++ b/lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex @@ -23,10 +23,17 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do ## Options - `characteristic_module:` *(required)* — the typed characteristic Ash - resource on each target (e.g. `NniCharacteristic`). The calc queries - this resource by `instance_id` and returns the `.value`. + 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 `AssignmentRelationship` aliases to + walk **after** the relationship hop. Each step walks back through the + target's incoming assignments (`target_id + alias` identity, so each + step has cardinality ≤1). Use this for mixed paths — one relationship + hop followed by one or more assignment hops — e.g. PRI's `:cvc` + bring-up: `:avc` owns relationship, then `:cvlan` 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 @@ -45,6 +52,13 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do calculate :avc, :map, {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, [alias: :avc, characteristic_module: AvcCharacteristic, singular?: true]} + + # PRI brings up the singular CVC two-hop — :avc owns relationship, + # then back via the AVC's incoming :cvlan assignment to the CVC. + calculate :cvc, :map, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [alias: :avc, then_via: [:cvlan], + characteristic_module: CvcCharacteristic, singular?: true]} """ use Ash.Resource.Calculation require Ash.Query @@ -57,6 +71,7 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship 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 -> @@ -66,8 +81,10 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do |> Ash.read!(domain: Diffo.Provider) |> Enum.map(& &1.target_id) + final_ids = walk_assignments(target_ids, then_via) + values = - Enum.flat_map(target_ids, fn id -> + Enum.flat_map(final_ids, fn id -> characteristic_module |> Ash.Query.filter_input(instance_id: id) |> Ash.Query.load(:value) @@ -79,6 +96,22 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do 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) diff --git a/lib/nbn/resources/nbn_ethernet.ex b/lib/nbn/resources/nbn_ethernet.ex index 7dae763..d2c4d70 100644 --- a/lib/nbn/resources/nbn_ethernet.ex +++ b/lib/nbn/resources/nbn_ethernet.ex @@ -94,6 +94,60 @@ defmodule DiffoExample.Nbn.NbnEthernet do end end + calculations do + # The singular AVC this access owns — single-hop via :avc owns relationship. + calculate :avc, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [ + alias: :avc, + characteristic_module: DiffoExample.Nbn.AvcCharacteristic, + singular?: true + ]} do + public? true + end + + # The singular UNI this access owns — single-hop via :uni owns relationship. + calculate :uni, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [ + alias: :uni, + characteristic_module: DiffoExample.Nbn.UniCharacteristic, + singular?: true + ]} do + public? true + end + + # The singular CVC backing this access's AVC — two-hop via :avc owns + # relationship, then back via the AVC's incoming :cvlan assignment. + calculate :cvc, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [ + alias: :avc, + then_via: [:cvlan], + characteristic_module: DiffoExample.Nbn.CvcCharacteristic, + singular?: true + ]} do + public? true + end + + # The singular NTD this access's UNI plugs into — two-hop via :uni + # owns relationship, then back via the UNI's incoming :port assignment. + calculate :ntd, + :map, + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, + [ + alias: :uni, + then_via: [:port], + characteristic_module: DiffoExample.Nbn.NtdCharacteristic, + singular?: true + ]} do + public? true + end + end + def identifier() do DiffoExample.Nbn.Util.identifier("PRI") end diff --git a/test/nbn/nbn_ethernet_test.exs b/test/nbn/nbn_ethernet_test.exs index a241a01..889d8b9 100644 --- a/test/nbn/nbn_ethernet_test.exs +++ b/test/nbn/nbn_ethernet_test.exs @@ -145,6 +145,89 @@ 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. Set explicit + # aliases so the inheritance walks (target_id + alias identity) + # resolve cleanly. + {:ok, _} = + Nbn.assign_cvlan(cvc, %{ + assignment: %Assignment{ + assignee_id: avc.id, + alias: :cvlan, + operation: :auto_assign + } + }) + + {:ok, _} = + Nbn.assign_port(ntd, %{ + assignment: %Assignment{ + assignee_id: uni.id, + alias: :port, + operation: :auto_assign + } + }) + + # PRI owns the AVC and UNI + {:ok, pri} = Nbn.build_nbn_ethernet(%{}) + + {:ok, _} = + Nbn.relate_nbn_ethernet(pri, %{ + relationships: [ + %Relationship{id: avc.id, direction: :forward, type: :owns, alias: :avc}, + %Relationship{id: uni.id, direction: :forward, type: :owns, alias: :uni} + ] + }) + + {: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 diff --git a/test/nbn/show_neo4j_test.exs b/test/nbn/show_neo4j_test.exs index 85ca74e..f0a9068 100644 --- a/test/nbn/show_neo4j_test.exs +++ b/test/nbn/show_neo4j_test.exs @@ -170,6 +170,99 @@ defmodule DiffoExample.Nbn.ShowNeo4jTest do |> 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: :cvlan, + operation: :auto_assign + } + }) + + {:ok, _} = + Nbn.assign_port(ntd, %{ + assignment: %Assignment{ + assignee_id: uni.id, + alias: :port, + operation: :auto_assign + } + }) + + # PRI owns AVC and UNI + {:ok, pri} = Nbn.build_nbn_ethernet(%{}) + + {:ok, _} = + Nbn.relate_nbn_ethernet(pri, %{ + relationships: [ + %Relationship{id: avc.id, direction: :forward, type: :owns, alias: :avc}, + %Relationship{id: uni.id, direction: :forward, type: :owns, alias: :uni} + ] + }) + + {: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(%{}) From 0df10ebb22dd2da11181a2f5f01bd97ca96dab6f Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 23 May 2026 15:42:12 +0930 Subject: [PATCH 4/5] =?UTF-8?q?#49=20=E2=80=94=20consumer-aliases=20name?= =?UTF-8?q?=20the=20upstream=20related=20resource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convention refinement across PRs 1-3: aliases on assignments and on PRI's owns relationships now identify the related resource each consumer is part of (its domain role), not the slot/thing being received. * avc sets :cvc, cvc sets :nni_group, uni sets :ntd on their assignments * pri's owns relationships aliased :circuit (AVC) and :port (UNI) * inheritance walks updated to follow the new aliases * pool/metrics aggregations unaffected (still filter by `thing`) * memories and moduledoc examples revised --- CHANGELOG.md | 3 +- ...inherited_characteristic_via_assignment.ex | 12 ++++--- ...herited_characteristic_via_relationship.ex | 28 ++++++++++------- lib/nbn/resources/avc.ex | 11 ++++--- lib/nbn/resources/cvc.ex | 6 ++-- lib/nbn/resources/nbn_ethernet.ex | 31 ++++++++++++------- test/nbn/nbn_ethernet_test.exs | 22 +++++++------ test/nbn/show_neo4j_test.exs | 20 +++++++----- 8 files changed, 77 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e5b8f..7ae8509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,8 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline * 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 `:avc` owns relationship, `uni` single-hop via the `:uni` owns relationship, `cvc` two-hop via `:avc` then `:cvlan`, and `ntd` two-hop via `:uni` then `:port`. All singular. +* 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 — 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: 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). 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): diff --git a/lib/diffo_example/calculations/inherited_characteristic_via_assignment.ex b/lib/diffo_example/calculations/inherited_characteristic_via_assignment.ex index 9d831e8..22768d4 100644 --- a/lib/diffo_example/calculations/inherited_characteristic_via_assignment.ex +++ b/lib/diffo_example/calculations/inherited_characteristic_via_assignment.ex @@ -27,7 +27,9 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaAssignment 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`. @@ -52,12 +54,12 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaAssignment do {DiffoExample.Calculations.InheritedCharacteristicViaAssignment, [via: [:port, :slot], characteristic_module: ShelfCharacteristic]} - # AVC brings up its singular CVC via :cvlan — AssignmentRelationship - # identity guarantees ≤1 source, so we declare :map and ask the calc - # to unwrap. + # 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: [:cvlan], characteristic_module: CvcCharacteristic, singular?: true]} + [via: [:cvc], characteristic_module: CvcCharacteristic, singular?: true]} """ use Ash.Resource.Calculation diff --git a/lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex b/lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex index bd70cb7..9cf438e 100644 --- a/lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex +++ b/lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex @@ -27,13 +27,14 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do 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 `AssignmentRelationship` aliases to - walk **after** the relationship hop. Each step walks back through the - target's incoming assignments (`target_id + alias` identity, so each - step has cardinality ≤1). Use this for mixed paths — one relationship - hop followed by one or more assignment hops — e.g. PRI's `:cvc` - bring-up: `:avc` owns relationship, then `:cvlan` assignment back to - the CVC. + - `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 @@ -48,16 +49,19 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, [type: :contains, characteristic_module: NniCharacteristic]} - # PRI brings up the singular AVC it owns via the :avc alias. + # 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: :avc, characteristic_module: AvcCharacteristic, singular?: true]} + [alias: :circuit, characteristic_module: AvcCharacteristic, singular?: true]} - # PRI brings up the singular CVC two-hop — :avc owns relationship, - # then back via the AVC's incoming :cvlan assignment to the CVC. + # 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: :avc, then_via: [:cvlan], + [alias: :circuit, then_via: [:cvc], characteristic_module: CvcCharacteristic, singular?: true]} """ use Ash.Resource.Calculation diff --git a/lib/nbn/resources/avc.ex b/lib/nbn/resources/avc.ex index 93bcc25..0108c7b 100644 --- a/lib/nbn/resources/avc.ex +++ b/lib/nbn/resources/avc.ex @@ -98,12 +98,13 @@ defmodule DiffoExample.Nbn.Avc do calculations do # The CVC characteristic value brought up from the singular CVC this - # AVC is assigned a cvlan on — single-hop via the :cvlan assignment. + # 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: [:cvlan], + via: [:cvc], characteristic_module: DiffoExample.Nbn.CvcCharacteristic, singular?: true ]} do @@ -111,13 +112,13 @@ defmodule DiffoExample.Nbn.Avc do end # The singular NniGroup characteristic value brought up transitively — - # cvlan to the CVC, then the CVC's svlan to its NniGroup. Two-hop via - # [:cvlan, :svlan]. + # 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: [:cvlan, :svlan], + via: [:cvc, :nni_group], characteristic_module: DiffoExample.Nbn.NniGroupCharacteristic, singular?: true ]} do diff --git a/lib/nbn/resources/cvc.ex b/lib/nbn/resources/cvc.ex index d4d350c..e1bfc52 100644 --- a/lib/nbn/resources/cvc.ex +++ b/lib/nbn/resources/cvc.ex @@ -112,13 +112,13 @@ defmodule DiffoExample.Nbn.Cvc do calculations do # The NniGroup characteristic value brought up from the singular - # NniGroup this CVC is assigned an svlan on — single-hop via the - # :svlan assignment. + # 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: [:svlan], + via: [:nni_group], characteristic_module: DiffoExample.Nbn.NniGroupCharacteristic, singular?: true ]} do diff --git a/lib/nbn/resources/nbn_ethernet.ex b/lib/nbn/resources/nbn_ethernet.ex index d2c4d70..8a53da9 100644 --- a/lib/nbn/resources/nbn_ethernet.ex +++ b/lib/nbn/resources/nbn_ethernet.ex @@ -95,52 +95,59 @@ defmodule DiffoExample.Nbn.NbnEthernet do end calculations do - # The singular AVC this access owns — single-hop via :avc owns relationship. + # 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: :avc, + alias: :circuit, characteristic_module: DiffoExample.Nbn.AvcCharacteristic, singular?: true ]} do public? true end - # The singular UNI this access owns — single-hop via :uni owns relationship. + # The singular UNI this access owns — single-hop via the :port owns relationship. calculate :uni, :map, {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, [ - alias: :uni, + alias: :port, characteristic_module: DiffoExample.Nbn.UniCharacteristic, singular?: true ]} do public? true end - # The singular CVC backing this access's AVC — two-hop via :avc owns - # relationship, then back via the AVC's incoming :cvlan assignment. + # 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: :avc, - then_via: [:cvlan], + alias: :circuit, + then_via: [:cvc], characteristic_module: DiffoExample.Nbn.CvcCharacteristic, singular?: true ]} do public? true end - # The singular NTD this access's UNI plugs into — two-hop via :uni - # owns relationship, then back via the UNI's incoming :port assignment. + # 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: :uni, - then_via: [:port], + alias: :port, + then_via: [:ntd], characteristic_module: DiffoExample.Nbn.NtdCharacteristic, singular?: true ]} do diff --git a/test/nbn/nbn_ethernet_test.exs b/test/nbn/nbn_ethernet_test.exs index 889d8b9..a0aa2d7 100644 --- a/test/nbn/nbn_ethernet_test.exs +++ b/test/nbn/nbn_ethernet_test.exs @@ -186,14 +186,14 @@ defmodule DiffoExample.Nbn.NbnEthernetTest do ] }) - # AVC takes a cvlan from CVC; UNI takes a port from NTD. Set explicit - # aliases so the inheritance walks (target_id + alias identity) - # resolve cleanly. + # 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: :cvlan, + alias: :cvc, operation: :auto_assign } }) @@ -202,19 +202,21 @@ defmodule DiffoExample.Nbn.NbnEthernetTest do Nbn.assign_port(ntd, %{ assignment: %Assignment{ assignee_id: uni.id, - alias: :port, + alias: :ntd, operation: :auto_assign } }) - # PRI owns the AVC and UNI + # 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: :avc}, - %Relationship{id: uni.id, direction: :forward, type: :owns, alias: :uni} + %Relationship{id: avc.id, direction: :forward, type: :owns, alias: :circuit}, + %Relationship{id: uni.id, direction: :forward, type: :owns, alias: :port} ] }) @@ -313,7 +315,7 @@ defmodule DiffoExample.Nbn.NbnEthernetTest do Nbn.assign_svlan(nni_group, %{ assignment: %Assignment{ assignee_id: cvc.id, - alias: :svlan, + alias: :nni_group, operation: :auto_assign } }) @@ -329,7 +331,7 @@ defmodule DiffoExample.Nbn.NbnEthernetTest do Nbn.assign_cvlan(cvc, %{ assignment: %Assignment{ assignee_id: avc.id, - alias: :cvlan, + alias: :cvc, operation: :auto_assign } }) diff --git a/test/nbn/show_neo4j_test.exs b/test/nbn/show_neo4j_test.exs index f0a9068..77a8dc5 100644 --- a/test/nbn/show_neo4j_test.exs +++ b/test/nbn/show_neo4j_test.exs @@ -55,7 +55,7 @@ defmodule DiffoExample.Nbn.ShowNeo4jTest do Nbn.assign_svlan(nni_group, %{ assignment: %Assignment{ assignee_id: cvc.id, - alias: :svlan, + alias: :nni_group, operation: :auto_assign } }) @@ -102,7 +102,7 @@ defmodule DiffoExample.Nbn.ShowNeo4jTest do Nbn.assign_cvlan(cvc1, %{ assignment: %Assignment{ assignee_id: avc.id, - alias: :cvlan, + alias: :cvc, operation: :auto_assign } }) @@ -214,7 +214,7 @@ defmodule DiffoExample.Nbn.ShowNeo4jTest do Nbn.assign_cvlan(cvc, %{ assignment: %Assignment{ assignee_id: avc.id, - alias: :cvlan, + alias: :cvc, operation: :auto_assign } }) @@ -223,19 +223,19 @@ defmodule DiffoExample.Nbn.ShowNeo4jTest do Nbn.assign_port(ntd, %{ assignment: %Assignment{ assignee_id: uni.id, - alias: :port, + alias: :ntd, operation: :auto_assign } }) - # PRI owns AVC and UNI + # 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: :avc}, - %Relationship{id: uni.id, direction: :forward, type: :owns, alias: :uni} + %Relationship{id: avc.id, direction: :forward, type: :owns, alias: :circuit}, + %Relationship{id: uni.id, direction: :forward, type: :owns, alias: :port} ] }) @@ -290,7 +290,11 @@ defmodule DiffoExample.Nbn.ShowNeo4jTest do {:ok, _} = Nbn.assign_port(ntd, %{ - assignment: %Assignment{assignee_id: uni.id, operation: :auto_assign} + assignment: %Assignment{ + assignee_id: uni.id, + alias: :ntd, + operation: :auto_assign + } }) end From 2e5d6ff348960266524ddd156b0c359eed128b4c Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 23 May 2026 15:45:32 +0930 Subject: [PATCH 5/5] =?UTF-8?q?Access=20=E2=80=94=20adopt=20consumer-alias?= =?UTF-8?q?-names-related-resource=20convention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the NBN convention from #49: alias on the consumer's assignment names the upstream related resource it's part of, not the slot/thing being received. * Card sets :shelf on its slot assignment (was :slot) * Path sets :card on its port assignment (was :port) * Card.shelf walks via [:shelf]; Card.slot filters alias :shelf * Path.card walks via [:card]; Path.shelf via [:card, :shelf]; Path.port filters alias :card * Shelf.cards filters alias :shelf (was :slot) * ShelfTotalPorts filters alias :shelf * Tests updated; 54/54 pass --- CHANGELOG.md | 2 +- lib/access/calculations/shelf_total_ports.ex | 9 +++++---- lib/access/resources/card.ex | 13 +++++++------ lib/access/resources/path.ex | 18 ++++++++++-------- lib/access/resources/shelf.ex | 2 +- test/access/path_test.exs | 16 +++++++++------- test/access/shelf_test.exs | 7 ++++--- 7 files changed, 37 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ae8509..186bbcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline * 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 — 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: 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). Inheritance walks use these consumer-aliases. Pool/metric aggregations are unaffected — they still filter by `thing`. +* 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): 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 47f7c53..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.InheritedCharacteristicViaAssignment, - [via: [:slot], characteristic_module: DiffoExample.Access.ShelfCharacteristic]} do + [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 5c80f21..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.InheritedCharacteristicViaAssignment, - [via: [:port], characteristic_module: DiffoExample.Access.CardCharacteristic]} do + [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.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/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.