Skip to content

Commit 56a6874

Browse files
committed
#49 part 3 — PRI brings up the full delivery chain (avc, uni, cvc, ntd)
* 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
1 parent 069ed1f commit 56a6874

5 files changed

Lines changed: 268 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
3434
* New `cvc_metrics` characteristic on CVC carries `avcs_count` and `avcs_total_bandwidth` aggregated live over assigned AVCs.
3535
* 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`.
3636
* NBN — NTD brings up assigned UNIs as `unis[]` via the `:port` assignment (issue #49 part 2).
37+
* 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.
3738
* `BandwidthProfile.downstream/1` — atom-to-Mbps mapping used by the metrics aggregation. CVCs are treated as symmetric capacity in this model (satellite asymmetry ignored).
3839

3940
### Refactors (continued):
40-
* `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.
41+
* `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).
4142
* `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.
4243

4344
## [v0.2.2](https://github.com/diffo-dev/diffo/compare/v0.2.1..v0.2.2) (2026-05-21)

lib/diffo_example/calculations/inherited_characteristic_via_relationship.ex

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,17 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do
2323
## Options
2424
2525
- `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`.
26+
resource on the final source (e.g. `NniCharacteristic`). The calc
27+
queries this resource by `instance_id` and returns the `.value`.
2828
- `type:` *(optional)* — filter relationships by type atom (e.g. `:contains`).
2929
- `alias:` *(optional)* — filter relationships by alias atom (e.g. `:avc`).
30+
- `then_via:` *(optional)* — list of `AssignmentRelationship` aliases to
31+
walk **after** the relationship hop. Each step walks back through the
32+
target's incoming assignments (`target_id + alias` identity, so each
33+
step has cardinality ≤1). Use this for mixed paths — one relationship
34+
hop followed by one or more assignment hops — e.g. PRI's `:cvc`
35+
bring-up: `:avc` owns relationship, then `:cvlan` assignment back to
36+
the CVC.
3037
- `singular?:` *(optional, default `false`)* — unwrap to a single value
3138
when the consumer expects a 1-cardinality result (e.g. PRI's `:avc` or
3239
`:uni` aliased owns-relationship). Declare the calc's return type as
@@ -45,6 +52,13 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do
4552
calculate :avc, :map,
4653
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
4754
[alias: :avc, characteristic_module: AvcCharacteristic, singular?: true]}
55+
56+
# PRI brings up the singular CVC two-hop — :avc owns relationship,
57+
# then back via the AVC's incoming :cvlan assignment to the CVC.
58+
calculate :cvc, :map,
59+
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
60+
[alias: :avc, then_via: [:cvlan],
61+
characteristic_module: CvcCharacteristic, singular?: true]}
4862
"""
4963
use Ash.Resource.Calculation
5064
require Ash.Query
@@ -57,6 +71,7 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do
5771
characteristic_module = Keyword.fetch!(opts, :characteristic_module)
5872
type_filter = Keyword.get(opts, :type)
5973
alias_filter = Keyword.get(opts, :alias)
74+
then_via = Keyword.get(opts, :then_via, [])
6075
singular? = Keyword.get(opts, :singular?, false)
6176

6277
Enum.map(records, fn record ->
@@ -66,8 +81,10 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do
6681
|> Ash.read!(domain: Diffo.Provider)
6782
|> Enum.map(& &1.target_id)
6883

84+
final_ids = walk_assignments(target_ids, then_via)
85+
6986
values =
70-
Enum.flat_map(target_ids, fn id ->
87+
Enum.flat_map(final_ids, fn id ->
7188
characteristic_module
7289
|> Ash.Query.filter_input(instance_id: id)
7390
|> Ash.Query.load(:value)
@@ -79,6 +96,22 @@ defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do
7996
end)
8097
end
8198

99+
# Walks back through incoming `AssignmentRelationship` records for each
100+
# id, following `target_id + alias` (identity, ≤1 source per step).
101+
defp walk_assignments(ids, []), do: ids
102+
103+
defp walk_assignments(ids, [alias_step | rest]) do
104+
next_ids =
105+
Enum.flat_map(ids, fn id ->
106+
Diffo.Provider.AssignmentRelationship
107+
|> Ash.Query.filter_input(target_id: id, alias: alias_step)
108+
|> Ash.read!(domain: Diffo.Provider)
109+
|> Enum.map(& &1.source_id)
110+
end)
111+
112+
walk_assignments(next_ids, rest)
113+
end
114+
82115
defp filter_relationships(query, source_id, nil, nil),
83116
do: Ash.Query.filter_input(query, source_id: source_id)
84117

lib/nbn/resources/nbn_ethernet.ex

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,60 @@ defmodule DiffoExample.Nbn.NbnEthernet do
9494
end
9595
end
9696

97+
calculations do
98+
# The singular AVC this access owns — single-hop via :avc owns relationship.
99+
calculate :avc,
100+
:map,
101+
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
102+
[
103+
alias: :avc,
104+
characteristic_module: DiffoExample.Nbn.AvcCharacteristic,
105+
singular?: true
106+
]} do
107+
public? true
108+
end
109+
110+
# The singular UNI this access owns — single-hop via :uni owns relationship.
111+
calculate :uni,
112+
:map,
113+
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
114+
[
115+
alias: :uni,
116+
characteristic_module: DiffoExample.Nbn.UniCharacteristic,
117+
singular?: true
118+
]} do
119+
public? true
120+
end
121+
122+
# The singular CVC backing this access's AVC — two-hop via :avc owns
123+
# relationship, then back via the AVC's incoming :cvlan assignment.
124+
calculate :cvc,
125+
:map,
126+
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
127+
[
128+
alias: :avc,
129+
then_via: [:cvlan],
130+
characteristic_module: DiffoExample.Nbn.CvcCharacteristic,
131+
singular?: true
132+
]} do
133+
public? true
134+
end
135+
136+
# The singular NTD this access's UNI plugs into — two-hop via :uni
137+
# owns relationship, then back via the UNI's incoming :port assignment.
138+
calculate :ntd,
139+
:map,
140+
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
141+
[
142+
alias: :uni,
143+
then_via: [:port],
144+
characteristic_module: DiffoExample.Nbn.NtdCharacteristic,
145+
singular?: true
146+
]} do
147+
public? true
148+
end
149+
end
150+
97151
def identifier() do
98152
DiffoExample.Nbn.Util.identifier("PRI")
99153
end

test/nbn/nbn_ethernet_test.exs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,89 @@ defmodule DiffoExample.Nbn.NbnEthernetTest do
145145
assert encoding ==
146146
~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":{}}]})
147147
end
148+
149+
test "pri brings up avc, uni, cvc, ntd via relationship + assignment chains" do
150+
# CVC with cvlan pool, assigned to an AVC
151+
{:ok, cvc} = Nbn.build_cvc(%{})
152+
153+
{:ok, cvc} =
154+
Nbn.define_cvc(cvc, %{
155+
characteristic_value_updates: [
156+
cvc: [svlan: 1, bandwidth: 1000],
157+
cvlans: [first: 1, last: 100, assignable_type: "cvlan"]
158+
]
159+
})
160+
161+
# NTD with port pool, assigned to a UNI
162+
{:ok, ntd} = Nbn.build_ntd(%{})
163+
164+
{:ok, ntd} =
165+
Nbn.define_ntd(ntd, %{
166+
characteristic_value_updates: [
167+
ntd: [model: "Sercomm CG4000A", technology: :FTTP],
168+
ports: [first: 1, last: 4, assignable_type: "port"]
169+
]
170+
})
171+
172+
# AVC + UNI
173+
{:ok, avc} = Nbn.build_avc(%{})
174+
175+
{:ok, _} =
176+
Nbn.define_avc(avc, %{
177+
characteristic_value_updates: [avc: [bandwidth_profile: :home_fast]]
178+
})
179+
180+
{:ok, uni} = Nbn.build_uni(%{})
181+
182+
{:ok, _} =
183+
Nbn.define_uni(uni, %{
184+
characteristic_value_updates: [
185+
uni: [port: 1, encapsulation: "DSCP Mapped", technology: :FTTP]
186+
]
187+
})
188+
189+
# AVC takes a cvlan from CVC; UNI takes a port from NTD. Set explicit
190+
# aliases so the inheritance walks (target_id + alias identity)
191+
# resolve cleanly.
192+
{:ok, _} =
193+
Nbn.assign_cvlan(cvc, %{
194+
assignment: %Assignment{
195+
assignee_id: avc.id,
196+
alias: :cvlan,
197+
operation: :auto_assign
198+
}
199+
})
200+
201+
{:ok, _} =
202+
Nbn.assign_port(ntd, %{
203+
assignment: %Assignment{
204+
assignee_id: uni.id,
205+
alias: :port,
206+
operation: :auto_assign
207+
}
208+
})
209+
210+
# PRI owns the AVC and UNI
211+
{:ok, pri} = Nbn.build_nbn_ethernet(%{})
212+
213+
{:ok, _} =
214+
Nbn.relate_nbn_ethernet(pri, %{
215+
relationships: [
216+
%Relationship{id: avc.id, direction: :forward, type: :owns, alias: :avc},
217+
%Relationship{id: uni.id, direction: :forward, type: :owns, alias: :uni}
218+
]
219+
})
220+
221+
{:ok, pri} = Nbn.get_nbn_ethernet_by_id(pri.id, load: [:avc, :uni, :cvc, :ntd])
222+
223+
# Single-hop via :owns relationship
224+
assert %{bandwidth_profile: :home_fast} = pri.avc
225+
assert %{port: 1, encapsulation: "DSCP Mapped", technology: :FTTP} = pri.uni
226+
227+
# Two-hop: :owns relationship then :cvlan / :port assignment
228+
assert %{svlan: 1, bandwidth: 1000} = pri.cvc
229+
assert %{model: "Sercomm CG4000A", technology: :FTTP} = pri.ntd
230+
end
148231
end
149232

150233
describe "build uni" do

test/nbn/show_neo4j_test.exs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,99 @@ defmodule DiffoExample.Nbn.ShowNeo4jTest do
170170
|> IO.puts()
171171
end
172172

173+
test "PRI (NbnEthernet access) with full delivery chain — AVC + UNI + CVC + NTD" do
174+
# CVC + cvlan pool
175+
{:ok, cvc} = Nbn.build_cvc(%{})
176+
177+
{:ok, cvc} =
178+
Nbn.define_cvc(cvc, %{
179+
characteristic_value_updates: [
180+
cvc: [svlan: 1, bandwidth: 1000],
181+
cvlans: [first: 1, last: 100, assignable_type: "cvlan"]
182+
]
183+
})
184+
185+
# NTD + port pool
186+
{:ok, ntd} = Nbn.build_ntd(%{})
187+
188+
{:ok, ntd} =
189+
Nbn.define_ntd(ntd, %{
190+
characteristic_value_updates: [
191+
ntd: [model: "Sercomm CG4000A", technology: :FTTP],
192+
ports: [first: 1, last: 4, assignable_type: "port"]
193+
]
194+
})
195+
196+
# AVC + UNI
197+
{:ok, avc} = Nbn.build_avc(%{})
198+
199+
{:ok, _} =
200+
Nbn.define_avc(avc, %{
201+
characteristic_value_updates: [avc: [bandwidth_profile: :home_fast]]
202+
})
203+
204+
{:ok, uni} = Nbn.build_uni(%{})
205+
206+
{:ok, _} =
207+
Nbn.define_uni(uni, %{
208+
characteristic_value_updates: [
209+
uni: [port: 1, encapsulation: "DSCP Mapped", technology: :FTTP]
210+
]
211+
})
212+
213+
{:ok, _} =
214+
Nbn.assign_cvlan(cvc, %{
215+
assignment: %Assignment{
216+
assignee_id: avc.id,
217+
alias: :cvlan,
218+
operation: :auto_assign
219+
}
220+
})
221+
222+
{:ok, _} =
223+
Nbn.assign_port(ntd, %{
224+
assignment: %Assignment{
225+
assignee_id: uni.id,
226+
alias: :port,
227+
operation: :auto_assign
228+
}
229+
})
230+
231+
# PRI owns AVC and UNI
232+
{:ok, pri} = Nbn.build_nbn_ethernet(%{})
233+
234+
{:ok, _} =
235+
Nbn.relate_nbn_ethernet(pri, %{
236+
relationships: [
237+
%Relationship{id: avc.id, direction: :forward, type: :owns, alias: :avc},
238+
%Relationship{id: uni.id, direction: :forward, type: :owns, alias: :uni}
239+
]
240+
})
241+
242+
{:ok, pri} = Nbn.get_nbn_ethernet_by_id(pri.id, load: [:avc, :uni, :cvc, :ntd])
243+
244+
IO.puts("\n========== PRI.avc (single-hop via :avc owns) ==========")
245+
IO.inspect(pri.avc, label: "pri.avc")
246+
247+
IO.puts("\n========== PRI.uni (single-hop via :uni owns) ==========")
248+
IO.inspect(pri.uni, label: "pri.uni")
249+
250+
IO.puts("\n========== PRI.cvc (two-hop via :avc owns + :cvlan assignment) ==========")
251+
IO.inspect(pri.cvc, label: "pri.cvc")
252+
253+
IO.puts("\n========== PRI.ntd (two-hop via :uni owns + :port assignment) ==========")
254+
IO.inspect(pri.ntd, label: "pri.ntd")
255+
256+
IO.puts("\n========== PRI (TMF JSON) ==========")
257+
258+
pri
259+
|> Jason.encode!()
260+
|> Diffo.Util.summarise_dates()
261+
|> Jason.decode!()
262+
|> Jason.encode!(pretty: true)
263+
|> IO.puts()
264+
end
265+
173266
test "NTD with assigned UNIs" do
174267
{:ok, ntd} = Nbn.build_ntd(%{})
175268

0 commit comments

Comments
 (0)