Skip to content

Commit 2548f63

Browse files
committed
#49 part 1 — AVC, CVC, NniGroup characteristic inheritance and metrics
* 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
1 parent b10838c commit 2548f63

14 files changed

Lines changed: 789 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
2626
* `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`
2727
* `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
2828

29+
### Features:
30+
* NBN — AVC, CVC, NniGroup characteristic inheritance and metrics (issue #49):
31+
* 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).
32+
* CVC inherits the upstream NniGroup's `nni_group` characteristic via the `:svlan` assignment.
33+
* New `cvc_metrics` characteristic on CVC carries `avcs_count` and `avcs_total_bandwidth` aggregated live over assigned AVCs.
34+
* 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`.
35+
* `BandwidthProfile.downstream/1` — atom-to-Mbps mapping used by the metrics aggregation. CVCs are treated as symmetric capacity in this model (satellite asymmetry ignored).
36+
37+
### Refactors (continued):
38+
* `DiffoExample.Calculations.InheritedCharacteristic` renamed to `InheritedCharacteristicViaAssignment` to make room for a future `InheritedCharacteristicViaRelationship` sibling (the latter lands in the PRI bring-up work).
39+
2940
## [v0.2.2](https://github.com/diffo-dev/diffo/compare/v0.2.1..v0.2.2) (2026-05-21)
3041

3142
### Maintenance:

lib/access/resources/card.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ defmodule DiffoExample.Access.Card do
9393
# in — derived live via the :slot assignment.
9494
calculate :shelf,
9595
{:array, :map},
96-
{DiffoExample.Calculations.InheritedCharacteristic,
96+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
9797
[via: [:slot], characteristic_module: DiffoExample.Access.ShelfCharacteristic]} do
9898
public? true
9999
end

lib/access/resources/path.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ defmodule DiffoExample.Access.Path do
8080
# assigned a port on — via the :port assignment.
8181
calculate :card,
8282
{:array, :map},
83-
{DiffoExample.Calculations.InheritedCharacteristic,
83+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
8484
[via: [:port], characteristic_module: DiffoExample.Access.CardCharacteristic]} do
8585
public? true
8686
end
@@ -97,7 +97,7 @@ defmodule DiffoExample.Access.Path do
9797
# card, then the card's slot to its shelf. Two-hop via [:port, :slot].
9898
calculate :shelf,
9999
{:array, :map},
100-
{DiffoExample.Calculations.InheritedCharacteristic,
100+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
101101
[
102102
via: [:port, :slot],
103103
characteristic_module: DiffoExample.Access.ShelfCharacteristic

lib/diffo_example/calculations/inherited_characteristic.ex renamed to lib/diffo_example/calculations/inherited_characteristic_via_assignment.ex

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# SPDX-License-Identifier: MIT
44

5-
defmodule DiffoExample.Calculations.InheritedCharacteristic do
5+
defmodule DiffoExample.Calculations.InheritedCharacteristicViaAssignment do
66
@moduledoc """
77
Brings up a typed characteristic value from an assignment-source instance.
88
@@ -11,10 +11,15 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do
1111
`AssignmentRelationship` by alias to reach source instances, then queries
1212
the typed characteristic resource on each source and returns its `.value`.
1313
14-
Local-to-this-repo for now. Worth yarning upstream as a diffo-side
15-
`inherited_characteristic` DSL declaration backed by a
16-
`Diffo.Provider.Calculations.InheritedCharacteristic` calc, sitting
17-
alongside the existing inherited-place and inherited-party machinery.
14+
Sibling to `InheritedCharacteristicViaRelationship`, which performs the
15+
analogous traversal over `DefinedSimpleRelationship` edges (forward,
16+
source → target). Pick the right calc by the kind of edge being
17+
traversed — assignment vs. relationship.
18+
19+
Local-to-this-repo for now. Worth yarning upstream as a pair of
20+
diffo-side DSL declarations backed by analogous calcs in the provider
21+
extension, sitting alongside the existing inherited-place and
22+
inherited-party machinery.
1823
1924
## Options
2025
@@ -26,20 +31,33 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do
2631
- `characteristic_module:` *(required)* — the typed characteristic Ash
2732
resource on the final source (e.g. `ShelfCharacteristic`). The calc
2833
queries this resource by `instance_id` and returns the `.value`.
34+
- `singular?:` *(optional, default `false`)* — when `true`, unwraps the
35+
result to a single value (or `nil`) rather than a list. Safe whenever
36+
every hop in `via:` traverses an `AssignmentRelationship` with identity
37+
`[:target_id, :alias]` — that guarantees ≤1 source per step, so the
38+
walk yields at most one value. Declare the calc's return type as `:map`
39+
(rather than `{:array, :map}`) when using this option.
2940
3041
## Example
3142
3243
# Card brings up its shelf's typed characteristic via the slot
3344
# assignment the shelf made to it (alias :slot on the incoming
3445
# AssignmentRelationship).
35-
calculate :shelf, :map,
36-
{DiffoExample.Calculations.InheritedCharacteristic,
46+
calculate :shelf, {:array, :map},
47+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
3748
[via: [:slot], characteristic_module: ShelfCharacteristic]}
3849
3950
# Path brings up the same via a two-hop chain — port-then-slot.
40-
calculate :shelf, :map,
41-
{DiffoExample.Calculations.InheritedCharacteristic,
51+
calculate :shelf, {:array, :map},
52+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
4253
[via: [:port, :slot], characteristic_module: ShelfCharacteristic]}
54+
55+
# AVC brings up its singular CVC via :cvlan — AssignmentRelationship
56+
# identity guarantees ≤1 source, so we declare :map and ask the calc
57+
# to unwrap.
58+
calculate :cvc, :map,
59+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
60+
[via: [:cvlan], characteristic_module: CvcCharacteristic, singular?: true]}
4361
"""
4462
use Ash.Resource.Calculation
4563

@@ -50,6 +68,7 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do
5068
def calculate(records, opts, _context) do
5169
via = Keyword.fetch!(opts, :via)
5270
characteristic_module = Keyword.fetch!(opts, :characteristic_module)
71+
singular? = Keyword.get(opts, :singular?, false)
5372

5473
Enum.map(records, fn record ->
5574
final_ids =
@@ -62,13 +81,16 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do
6281
end)
6382
end)
6483

65-
Enum.flat_map(final_ids, fn id ->
66-
characteristic_module
67-
|> Ash.Query.filter_input(instance_id: id)
68-
|> Ash.Query.load(:value)
69-
|> Ash.read!()
70-
|> Enum.map(& &1.value)
71-
end)
84+
values =
85+
Enum.flat_map(final_ids, fn id ->
86+
characteristic_module
87+
|> Ash.Query.filter_input(instance_id: id)
88+
|> Ash.Query.load(:value)
89+
|> Ash.read!()
90+
|> Enum.map(& &1.value)
91+
end)
92+
93+
if singular?, do: List.first(values), else: values
7294
end)
7395
end
7496
end
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do
6+
@moduledoc """
7+
Brings up typed characteristic values from target instances reached via
8+
forward `Diffo.Provider.Relationship` edges (source → target), optionally
9+
filtered by `type:` and/or `alias:`.
10+
11+
Sibling to `InheritedCharacteristicViaAssignment`, which performs the
12+
analogous traversal over `AssignmentRelationship` edges. Pick the right
13+
calc by the kind of edge being traversed — relationship vs. assignment.
14+
15+
Use this when the edge between the consuming instance and the target was
16+
created by a `:relate` action (a `Provider.Relationship` record). Use
17+
`InheritedCharacteristicViaAssignment` when the edge was created by the
18+
Assigner (an `AssignmentRelationship` record).
19+
20+
Local-to-this-repo for now. Worth yarning upstream alongside the
21+
assignment variant as a pair of provider-side calcs.
22+
23+
## Options
24+
25+
- `characteristic_module:` *(required)* — the typed characteristic Ash
26+
resource on each target (e.g. `NniCharacteristic`). The calc queries
27+
this resource by `instance_id` and returns the `.value`.
28+
- `type:` *(optional)* — filter relationships by type atom (e.g. `:contains`).
29+
- `alias:` *(optional)* — filter relationships by alias atom (e.g. `:avc`).
30+
- `singular?:` *(optional, default `false`)* — unwrap to a single value
31+
when the consumer expects a 1-cardinality result (e.g. PRI's `:avc` or
32+
`:uni` aliased owns-relationship). Declare the calc's return type as
33+
`:map` (rather than `{:array, :map}`) when using this option.
34+
35+
## Examples
36+
37+
# NniGroup brings up the typed characteristic of every NNI it
38+
# comprises — forward traversal of :contains relationships, returns
39+
# a list of NniCharacteristic values.
40+
calculate :nnis, {:array, :map},
41+
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
42+
[type: :contains, characteristic_module: NniCharacteristic]}
43+
44+
# PRI brings up the singular AVC it owns via the :avc alias.
45+
calculate :avc, :map,
46+
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
47+
[alias: :avc, characteristic_module: AvcCharacteristic, singular?: true]}
48+
"""
49+
use Ash.Resource.Calculation
50+
require Ash.Query
51+
52+
@impl true
53+
def load(_query, _opts, _context), do: []
54+
55+
@impl true
56+
def calculate(records, opts, _context) do
57+
characteristic_module = Keyword.fetch!(opts, :characteristic_module)
58+
type_filter = Keyword.get(opts, :type)
59+
alias_filter = Keyword.get(opts, :alias)
60+
singular? = Keyword.get(opts, :singular?, false)
61+
62+
Enum.map(records, fn record ->
63+
target_ids =
64+
Diffo.Provider.Relationship
65+
|> filter_relationships(record.id, type_filter, alias_filter)
66+
|> Ash.read!(domain: Diffo.Provider)
67+
|> Enum.map(& &1.target_id)
68+
69+
values =
70+
Enum.flat_map(target_ids, fn id ->
71+
characteristic_module
72+
|> Ash.Query.filter_input(instance_id: id)
73+
|> Ash.Query.load(:value)
74+
|> Ash.read!()
75+
|> Enum.map(& &1.value)
76+
end)
77+
78+
if singular?, do: List.first(values), else: values
79+
end)
80+
end
81+
82+
defp filter_relationships(query, source_id, nil, nil),
83+
do: Ash.Query.filter_input(query, source_id: source_id)
84+
85+
defp filter_relationships(query, source_id, type, nil),
86+
do: Ash.Query.filter_input(query, source_id: source_id, type: type)
87+
88+
defp filter_relationships(query, source_id, nil, alias_name),
89+
do: Ash.Query.filter_input(query, source_id: source_id, alias: alias_name)
90+
91+
defp filter_relationships(query, source_id, type, alias_name),
92+
do: Ash.Query.filter_input(query, source_id: source_id, type: type, alias: alias_name)
93+
end

lib/nbn/nbn.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ defmodule DiffoExample.Nbn do
2727
alias DiffoExample.Nbn.Rsp
2828
alias DiffoExample.Nbn.AvcCharacteristic
2929
alias DiffoExample.Nbn.CvcCharacteristic
30+
alias DiffoExample.Nbn.CvcMetrics
3031
alias DiffoExample.Nbn.NniGroupCharacteristic
32+
alias DiffoExample.Nbn.NniGroupMetrics
3133
alias DiffoExample.Nbn.NniCharacteristic
3234
alias DiffoExample.Nbn.NtdCharacteristic
3335
alias DiffoExample.Nbn.UniCharacteristic
@@ -220,7 +222,9 @@ defmodule DiffoExample.Nbn do
220222

221223
resource AvcCharacteristic
222224
resource CvcCharacteristic
225+
resource CvcMetrics
223226
resource NniGroupCharacteristic
227+
resource NniGroupMetrics
224228
resource NniCharacteristic
225229
resource NtdCharacteristic
226230
resource UniCharacteristic

lib/nbn/resources/avc.ex

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,35 @@ defmodule DiffoExample.Nbn.Avc do
9696
end
9797
end
9898

99+
calculations do
100+
# The CVC characteristic value brought up from the singular CVC this
101+
# AVC is assigned a cvlan on — single-hop via the :cvlan assignment.
102+
calculate :cvc,
103+
:map,
104+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
105+
[
106+
via: [:cvlan],
107+
characteristic_module: DiffoExample.Nbn.CvcCharacteristic,
108+
singular?: true
109+
]} do
110+
public? true
111+
end
112+
113+
# The singular NniGroup characteristic value brought up transitively —
114+
# cvlan to the CVC, then the CVC's svlan to its NniGroup. Two-hop via
115+
# [:cvlan, :svlan].
116+
calculate :nni_group,
117+
:map,
118+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
119+
[
120+
via: [:cvlan, :svlan],
121+
characteristic_module: DiffoExample.Nbn.NniGroupCharacteristic,
122+
singular?: true
123+
]} do
124+
public? true
125+
end
126+
end
127+
99128
def identifier() do
100129
DiffoExample.Nbn.Util.identifier("AVC")
101130
end
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule DiffoExample.Nbn.CvcMetrics do
6+
@moduledoc """
7+
Local metrics characteristic for a CVC — `avcs_count` and
8+
`avcs_total_bandwidth` aggregated live across the AVCs the CVC has
9+
assigned a cvlan to. Not inheritable.
10+
"""
11+
use Ash.Resource,
12+
fragments: [Diffo.Provider.BaseCharacteristic],
13+
domain: DiffoExample.Nbn
14+
15+
resource do
16+
description "Live metrics for a CVC — count and total downstream bandwidth across its assigned AVCs"
17+
plural_name :cvc_metrics
18+
end
19+
20+
calculations do
21+
calculate :value,
22+
Diffo.Type.CharacteristicValue,
23+
DiffoExample.Nbn.CvcMetrics.ValueCalculation do
24+
public? true
25+
end
26+
end
27+
28+
preparations do
29+
prepare build(load: [:value])
30+
end
31+
32+
jason do
33+
pick [:name, :value]
34+
compact true
35+
end
36+
end
37+
38+
defmodule DiffoExample.Nbn.CvcMetrics.Value do
39+
@moduledoc false
40+
use Ash.TypedStruct, extensions: [AshJason.TypedStruct]
41+
42+
jason do
43+
pick [:avcs_count, :avcs_total_bandwidth]
44+
compact true
45+
rename avcs_count: "avcsCount", avcs_total_bandwidth: "avcsTotalBandwidth"
46+
end
47+
48+
typed_struct do
49+
field :avcs_count, :integer
50+
field :avcs_total_bandwidth, :integer
51+
end
52+
end
53+
54+
defmodule DiffoExample.Nbn.CvcMetrics.ValueCalculation do
55+
@moduledoc false
56+
use Ash.Resource.Calculation
57+
58+
require Ash.Query
59+
60+
alias DiffoExample.Nbn.AvcCharacteristic
61+
alias DiffoExample.Nbn.BandwidthProfile
62+
alias DiffoExample.Nbn.CvcMetrics
63+
64+
@impl true
65+
def load(_, _, _), do: []
66+
67+
@impl true
68+
def calculate(records, _, _) do
69+
Enum.map(records, fn r ->
70+
# AVCs the CVC has assigned a cvlan to live on the target side of
71+
# outgoing AssignmentRelationships sourced from this CVC's :cvlan
72+
# pool. Filter by `thing` (the pool's thing name) rather than `alias`
73+
# — alias is the consumer's slot name and may be unset.
74+
avc_ids =
75+
Diffo.Provider.AssignmentRelationship
76+
|> Ash.Query.filter_input(source_id: r.instance_id, thing: :cvlan)
77+
|> Ash.read!(domain: Diffo.Provider)
78+
|> Enum.map(& &1.target_id)
79+
80+
avcs =
81+
Enum.flat_map(avc_ids, fn id ->
82+
AvcCharacteristic
83+
|> Ash.Query.filter_input(instance_id: id)
84+
|> Ash.read!()
85+
end)
86+
87+
%CvcMetrics.Value{
88+
avcs_count: length(avcs),
89+
avcs_total_bandwidth:
90+
Enum.reduce(avcs, 0, fn avc, acc ->
91+
acc + BandwidthProfile.downstream(avc.bandwidth_profile)
92+
end)
93+
}
94+
end)
95+
end
96+
end

0 commit comments

Comments
 (0)