Diffo Places — GeographicLocation and GeoJSON

Copy Markdown View Source
Mix.install(
  [
    {:diffo, "~> 0.8.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. Geo and 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)

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.Exprst_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-native st_distance_in_meters expression).
  • 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)
end

Your 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
end
defmodule 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
end

Build 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 GHz

The 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