Mix.install(
[
{:diffo, "~> 0.6.0"}
],
config: [
diffo: [ash_domains: [Diffo.Provider]]
],
consolidate_protocols: false
)Overview
A TMF Place says where something is. Diffo models Place as an abstract concept with
three concrete subtypes, all built on the Diffo.Provider.BasePlace fragment:
GeographicAddress(TMF673) — a postal address (street, postcode, country).GeographicSite(TMF674) — a named site (an exchange, a data centre).GeographicLocation(TMF675) — a geometry-bearing place: a point or a region in space.
This livebook focuses on GeographicLocation and how its geometry is carried on the
wire as GeoJSON.
A GeographicLocation holds one of two WGS-84 geometries:
location— a point (%Geo.Point{}), for point-like places.bounds— a polygon (%Geo.Polygon{}), for regions.
Geometry is stored with AshGeo.GeoJson and the underlying
Geo structs, in WGS-84 (srid: 4326). On encode it surfaces in
the TMF675 geoJson shape.
Coordinate order.
Geoand GeoJSON both use{lon, lat}(X, Y) — longitude first. This is the opposite of the "lat, long" most maps quote, so take care: Sydney CBD is{151.2093, -33.8688}, not{-33.8688, 151.2093}.
Installing Neo4j and Configuring Bolty
GeographicLocation persists through the Ash Neo4j DataLayer,
which requires Neo4j to be installed and running. You can install latest major Neo4j
versions from the community tab at Neo4j Deployment Center,
or use the 5.26.8 direct link.
Update the configuration below as necessary and evaluate.
config = [
uri: "bolt://localhost:7687",
auth: [username: "neo4j", password: "password"],
user_agent: "diffoLivebook/1",
pool_size: 15,
max_overflow: 3,
prefix: :default,
name: Bolt,
log: false,
log_hex: false
]Bolty needs a process in your supervision tree; this starts one if not already running:
AshNeo4j.BoltyHelper.start(config)Verify Neo4j is reachable:
AshNeo4j.BoltyHelper.is_connected()Creating a point GeographicLocation
The preferred consumer API is the Diffo.Provider type dispatcher,
create_place!/2 — it routes to the right subtype leaf for you. Give it a
%Geo.Point{} as location, and optionally a positional accuracy in metres:
sydney_cbd =
Diffo.Provider.create_place!(:GeographicLocation, %{
id: "LOC-SYD-CBD",
name: "Sydney CBD",
location: %Geo.Point{coordinates: {151.2093, -33.8688}, srid: 4326},
accuracy: 10.0
})The type is set to :GeographicLocation for you, and the point round-trips as a Geo
struct:
{sydney_cbd.type, sydney_cbd.location, sydney_cbd.accuracy}Creating a region GeographicLocation
For an area, set bounds to a %Geo.Polygon{} instead. A polygon is a list of linear
rings; the first ring is the outer boundary and must be closed (the last coordinate
equals the first):
sydney_region =
Diffo.Provider.create_place!(:GeographicLocation, %{
id: "LOC-SYD-REGION",
name: "Sydney region",
bounds: %Geo.Polygon{
coordinates: [
[
{151.0, -33.5},
{151.5, -33.5},
{151.5, -34.0},
{151.0, -34.0},
{151.0, -33.5}
]
],
srid: 4326
}
})
{sydney_region.type, sydney_region.bounds}The TMF675 GeoJSON wire shape
Diffo encodes the geometry into the TMF675 geoJson field on JSON encode. The @type
is rebranded to GeoJsonPoint or GeoJsonPolygon, an @baseType of
GeographicLocation is added, and the location / bounds attributes are replaced by a
nested geoJson.geometry in GeoJSON form:
Jason.encode!(sydney_cbd) |> Jason.decode!()Jason.encode!(sydney_region) |> Jason.decode!()A point encodes as:
{
"@type": "GeoJsonPoint",
"@baseType": "GeographicLocation",
"id": "LOC-SYD-CBD",
"name": "Sydney CBD",
"accuracy": 10.0,
"geoJson": { "geometry": { "type": "Point", "coordinates": [151.2093, -33.8688] } }
}Note the coordinates are emitted as a [lon, lat] array (GeoJSON order), and the
attribute-side location / bounds keys are not present on the wire.
Validation rules
BasePlace and BaseGeographicLocation enforce three geometry rules. Each of these fails:
# At most one of location / bounds may be set
Diffo.Provider.create_place(:GeographicLocation, %{
id: "LOC-BAD-BOTH",
name: "Both",
location: %Geo.Point{coordinates: {151.0, -33.0}, srid: 4326},
bounds: %Geo.Polygon{coordinates: [[{151.0, -33.0}, {151.5, -33.0}, {151.5, -33.5}, {151.0, -33.0}]], srid: 4326}
})# Geometry is only allowed when type is :GeographicLocation
Diffo.Provider.create_place(:GeographicSite, %{
id: "LOC-BAD-TYPE",
name: "Site with geometry",
bounds: %Geo.Polygon{coordinates: [[{151.0, -33.0}, {151.5, -33.0}, {151.5, -33.5}, {151.0, -33.0}]], srid: 4326}
})# A GeographicLocation requires at least one of location / bounds
Diffo.Provider.create_place(:GeographicLocation, %{id: "LOC-BAD-EMPTY", name: "No geometry"})Reading back and cross-world projection
get_place_by_id!/1 reads a Place node and projects it back to its concrete subtype
struct — you get a GeographicLocation back, geometry and all:
Diffo.Provider.get_place_by_id!("LOC-SYD-CBD")Projection is driven by AshNeo4j.worlds/1, which resolves a stored node to the concrete
leaf resource it belongs to:
AshNeo4j.worlds(sydney_cbd)A spatial calculation — RF link budget from distance
The out-of-the-box Diffo.Provider.GeographicLocation is just a leaf composing two
fragments. Your domain can define its own — adding domain attributes and calculations — by
composing the same BasePlace + BaseGeographicLocation fragments.
AshNeo4j ships graph-native spatial functions for use directly in Ash.Expr —
st_distance / st_distance_in_meters, st_dwithin, st_within, st_contains,
st_intersects, st_closest_point. In a query filter they push down to Neo4j's native
point.distance / point.withinBBox (indexed); in a calculation they evaluate in
Elixir against the loaded %Geo.*{} structs — and both paths agree, because AshNeo4j
matches Neo4j's WGS-84 distance model. No SQL, no PostGIS — the distance is computed in the
graph.
Here a CellSite models a simple free-space (Friis) RF link budget, the way an engineer
reads it — in dBm. It carries an eirp_dbm (Equivalent Isotropic Radiated Power, which
folds in the transmit-antenna gain) and a frequency_mhz, and exposes three calculations of
the signal at a given point:
distance_m— geodesic distance (the graph-nativest_distance_in_metersexpression).path_loss_db— free-space path loss,20·log10(d) + 20·log10(f) − 27.55(d in m, f in MHz).rssi_dbm— received signal level for an isotropic receiver:eirp_dbm − path_loss_db.
This is line-of-sight free space — the optimistic floor. A real CBD link loses tens of dB more to buildings, clutter and foliage. Using EIRP (Tx) and an isotropic Rx keeps the figure antenna-decoupled.
The dB maths needs log10, which Ash.Expr doesn't provide, so the link budget is an Elixir
calculation — while distance_m stays a pure graph expression. It reuses
AshNeo4j.Geo.haversine_meters/2 so its distance matches the point.distance the expression
path uses:
defmodule MyApp.LinkBudget do
@moduledoc "Free-space (Friis) link budget: path loss (dB) and isotropic RSSI (dBm)."
use Ash.Resource.Calculation
@impl true
def load(_query, opts, _context) do
case opts[:metric] do
:rssi_dbm -> [:location, :eirp_dbm, :frequency_mhz]
:path_loss_db -> [:location, :frequency_mhz]
end
end
@impl true
def calculate(records, opts, context) do
%Geo.Point{coordinates: at} = context.arguments.at
Enum.map(records, &metric(opts[:metric], &1, at))
end
defp metric(:path_loss_db, %{location: %Geo.Point{coordinates: site}, frequency_mhz: f}, at),
do: fspl_db(AshNeo4j.Geo.haversine_meters(site, at), f)
defp metric(
:rssi_dbm,
%{location: %Geo.Point{coordinates: site}, eirp_dbm: eirp, frequency_mhz: f},
at
),
do: Float.round(eirp - fspl_db(AshNeo4j.Geo.haversine_meters(site, at), f), 1)
# Friis free-space path loss; d in metres, f in MHz.
defp fspl_db(+0.0, _f), do: 0.0
defp fspl_db(d_m, f_mhz),
do: Float.round(20 * :math.log10(d_m) + 20 * :math.log10(f_mhz) - 27.55, 1)
endYour own leaf belongs in your own domain (the built-in
Diffo.Provider.GeographicLocation already fills that role in the Diffo.Provider
domain):
defmodule MyApp.Geo do
use Ash.Domain,
otp_app: :diffo,
validate_config_inclusion?: false,
# writes the :Provider label on every node in this domain, so provider-side readers
# (e.g. Diffo.Provider.get_place_by_id!/1, which MATCHes [:Provider, :Place]) can find
# and project your leaf — see Diffo.Provider.DomainFragment
fragments: [Diffo.Provider.DomainFragment]
resources do
# the domain is defined before the leaf below, so it can't list it explicitly —
# allow_unregistered? lets `domain: MyApp.Geo` accept MyApp.CellSite at runtime
allow_unregistered? true
end
enddefmodule MyApp.CellSite do
use Ash.Resource,
fragments: [Diffo.Provider.BasePlace, Diffo.Provider.BaseGeographicLocation],
domain: MyApp.Geo
attributes do
attribute :cell_id, :string, public?: true
# Equivalent Isotropic Radiated Power, in dBm (folds in the Tx antenna gain)
attribute :eirp_dbm, :float, public?: true
attribute :frequency_mhz, :float, public?: true, default: 3500.0
end
calculations do
# the distance expression between two points — pushes to Neo4j point.distance
calculate :distance_m, :float, expr(st_distance_in_meters(location, ^arg(:at))) do
argument :at, AshGeo.GeoJson do
constraints geo_types: [:point], force_srid: 4326
allow_nil? false
end
end
# free-space path loss (dB) and isotropic received signal level (dBm)
calculate :path_loss_db, :float, {MyApp.LinkBudget, metric: :path_loss_db} do
argument :at, AshGeo.GeoJson do
constraints geo_types: [:point], force_srid: 4326
allow_nil? false
end
end
calculate :rssi_dbm, :float, {MyApp.LinkBudget, metric: :rssi_dbm} do
argument :at, AshGeo.GeoJson do
constraints geo_types: [:point], force_srid: 4326
allow_nil? false
end
end
end
actions do
create :build do
accept [
:id,
:href,
:name,
:location,
:bounds,
:accuracy,
:cell_id,
:eirp_dbm,
:frequency_mhz
]
change set_attribute(:type, :GeographicLocation)
end
end
endBuild a tower, then ask for the distance, path loss and RSSI at a nearby point — the :at
point is supplied as each calculation's argument when you load it:
tower =
Ash.create!(
MyApp.CellSite,
%{
id: "CELL-SYD-1",
name: "Sydney CBD Tower",
location: %Geo.Point{coordinates: {151.2093, -33.8688}, srid: 4326},
eirp_dbm: 60.0,
frequency_mhz: 3500.0,
cell_id: "ENB-1001"
},
action: :build,
domain: MyApp.Geo
)
# Town Hall, ~513 m south-west
town_hall = %Geo.Point{coordinates: {151.2073, -33.8731}, srid: 4326}
tower
|> Ash.load!(
[distance_m: %{at: town_hall}, path_loss_db: %{at: town_hall}, rssi_dbm: %{at: town_hall}],
domain: MyApp.Geo
)
|> then(&{&1.distance_m, &1.path_loss_db, &1.rssi_dbm})
# => {~513.0, ~97.5, ~-37.5} (metres, dB, dBm) — free-space at 3.5 GHzThe same st_* functions shine in a query filter, where they push down to Neo4j —
e.g. every cell site within 2 km of the customer:
require Ash.Query
MyApp.CellSite
|> Ash.Query.filter(st_dwithin(location, ^town_hall, 2_000))
|> Ash.read!(domain: MyApp.Geo)BaseGeographicLocation carries the accuracy attribute, the geometry validation, and the
TMF675 geoJson encoding; the leaf adds only what is specific to it — here cell_id,
eirp_dbm / frequency_mhz, and the three calculations.
Projecting a custom leaf
A custom leaf projects exactly like the built-in subtypes — provided its domain opts into
provider polymorphism. Because MyApp.Geo composes Diffo.Provider.DomainFragment, every
node in it also gets the Provider label. So the node carries one label per world it belongs
to: Geo (the domain), CellSite (the module), Place (from BasePlace), and Provider
(the domain fragment):
tower.__metadata__.labels
# => ["Geo", "CellSite", "Place", "Provider"]Note there's no GeographicLocation label — the subtype fragments don't add one; subtype
identity is the module label plus the :type property. The Provider label is the
load-bearing one: the provider-side reader get_place_by_id!/1 MATCHes on
[:Provider, :Place], so it finds and projects your leaf back to its concrete type even
though it lives in your own domain:
Diffo.Provider.get_place_by_id!(tower.id)
# => %MyApp.CellSite{type: :GeographicLocation, location: %Geo.Point{...}, ...}AshNeo4j.worlds/1 shows the underlying resolution — the node's labels project to the
concrete (domain, resource):
AshNeo4j.worlds(tower)
# => [{MyApp.Geo, MyApp.CellSite}]Drop Diffo.Provider.DomainFragment from MyApp.Geo and the Provider label disappears —
get_place_by_id! would no longer match the node. That fragment is precisely what opts your
domain into the provider graph.
Where to next
- The
Diffo.Provider.ExtensionDSL — cheat sheet - Attaching a place to a Service or Resource via
place/place_ref, and inheriting one across the graph withinherited_place— see Using the Diffo Provider Extension.