Put back the electric count v3 dropped

efd60b3 · Gabriel Morell · 2026-09-04 15:53

12 files +143 -7
Message
{commit_body(@commit)}

Files changed

modified ?
+0 −0

Binary file — not rendered.

modified ?
+0 −0

Binary file — not rendered.

modified apps/room_gbfs/lib/room_gbfs/gbfs_worker.ex
+13 −1
@@ -158,9 +158,17 @@ defmodule RoomGbfs.Worker do
158 158 )
159 159
160 160 :stat_status ->
161 + # v3 dropped the electric count; the types stored from
162 + # vehicle_types.json are what puts it back.
163 + electric =
164 + id
165 + |> intify()
166 + |> RoomSanctum.Storage.gbfs_vehicle_types()
167 + |> RoomGbfs.V3.electric_type_ids()
168 +
161 169 data =
162 170 json.data.stations
163 − |> Enum.map(&RoomGbfs.V3.station_status/1)
171 + |> Enum.map(&RoomGbfs.V3.station_status(&1, electric))
164 172 |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
165 173 |> Enum.map(fn x ->
166 174 RoomSanctum.Storage.change_station_status(
@@ -377,6 +385,10 @@ defmodule RoomGbfs.Worker do
377 385 bcast(state.id, :parsing, 2, @tfh)
378 386
379 387 feeds
388 + # Vehicle types first: station_status needs them stored to
389 + # work out how many of its bikes are electric, and operators
390 + # list the feeds in whatever order suits them.
391 + |> Enum.sort_by(&RoomGbfs.V3.feed_order/1)
380 392 |> Enum.map(fn %{"name" => name, "url" => url} ->
381 393 # v3 renamed the loose-vehicle feed; everything below is
382 394 # written in v2's names, which is the one dialect this worker
modified apps/room_gbfs/lib/room_gbfs/v3.ex
+59 −6
@@ -18,13 +18,14 @@ defmodule RoomGbfs.V3 do
18 18 flag to thread through -- or to get wrong for the one operator who publishes
19 19 a v3 document with a v2 version string in it.
20 20
21 − What is *not* handled, and cannot be here: v3 has no `num_ebikes_available`.
22 − The electric count is derivable, but only by joining `vehicle_types_available`
23 − against `vehicle_types.json`, which is a different feed fetched separately
24 − and possibly later. A v3 station reports its total correctly and its electric
25 − count as nothing.
21 + The one field with no direct counterpart is `num_ebikes_available`, which v3
22 + dropped. It is recovered rather than abandoned: `vehicle_types_available`
23 + counts each type at the station and `vehicle_types.json` says which types are
24 + electric, so the two together give the number back. That needs the types
25 + loaded first, which is why `feed_order/1` exists.
26 26 """
27 27
28 +
28 29 @doc """
29 30 The feed list out of a discovery document, whichever version wrote it.
30 31
@@ -65,12 +66,64 @@ defmodule RoomGbfs.V3 do
65 66 system that rents scooters -- but it is a rename, and a reader looking for
66 67 the old key finds nothing rather than failing.
67 68 """
68 − def station_status(row) do
69 + def station_status(row, electric_ids \\ nil) do
69 70 row
70 71 |> rename(:num_vehicles_available, :num_bikes_available)
71 72 |> rename(:num_vehicles_disabled, :num_bikes_disabled)
73 + |> derive_ebikes(electric_ids)
74 + end
75 +
76 + @doc """
77 + The order to read the feeds in.
78 +
79 + Operators list them alphabetically or however their generator felt, and for
80 + every feed but one the order does not matter. `station_status` needs the
81 + vehicle types already stored to work out how many of the bikes at a station
82 + are electric, and the two PBSC systems list `vehicle_types` last -- so left
83 + alone, the electric count would be a refresh behind for ever.
84 + """
85 + def feed_order(%{"name" => name}), do: feed_order(name)
86 + def feed_order("vehicle_types"), do: 0
87 + def feed_order(_name), do: 1
88 +
89 + @doc """
90 + The vehicle type ids that count as electric, from `Storage.gbfs_vehicle_types/1`.
91 +
92 + Assist and full electric both; combustion and hybrid do not, and a car share
93 + publishing those has no business reporting an e-bike count at all.
94 + """
95 + @electric ~w(electric electric_assist)
96 +
97 + def electric_type_ids(vehicle_types) when is_map(vehicle_types) do
98 + vehicle_types
99 + |> Enum.filter(fn {_id, type} -> to_string(type.propulsion_type) in @electric end)
100 + |> MapSet.new(fn {id, _type} -> to_string(id) end)
101 + end
102 +
103 + # v3 has no num_ebikes_available. Where the station breaks its count down by
104 + # type and the types are known, the number is simply the electric ones added
105 + # up. Anything already carrying the field -- a v2 feed, or Lyft's extension
106 + # of it -- keeps what it had.
107 + defp derive_ebikes(row, nil), do: row
108 +
109 + defp derive_ebikes(%{num_ebikes_available: count} = row, _electric) when is_integer(count),
110 + do: row
111 +
112 + defp derive_ebikes(%{vehicle_types_available: types} = row, electric) when is_list(types) do
113 + count =
114 + Enum.reduce(types, 0, fn type, acc ->
115 + if to_string(Map.get(type, :vehicle_type_id)) in electric do
116 + acc + (Map.get(type, :count) || 0)
117 + else
118 + acc
119 + end
120 + end)
121 +
122 + Map.put(row, :num_ebikes_available, count)
72 123 end
73 124
125 + defp derive_ebikes(row, _electric), do: row
126 +
74 127 @doc """
75 128 A loose vehicle, in v2 terms.
76 129
modified apps/room_gbfs/test/room_gbfs/v3_test.exs
+71 −0
@@ -69,6 +69,77 @@ defmodule RoomGbfs.V3Test do
69 69 end
70 70 end
71 71
72 + describe "the electric count v3 dropped" do
73 + # PBSC's Chattanooga fleet, as it actually publishes it.
74 + defp types do
75 + %{
76 + "ICONIC" => %{propulsion_type: "human"},
77 + "FIT" => %{propulsion_type: "human"},
78 + "EFIT" => %{propulsion_type: "electric_assist"},
79 + "CHLOE" => %{propulsion_type: "electric"},
80 + "CAR" => %{propulsion_type: "combustion"}
81 + }
82 + end
83 +
84 + test "assist and full electric count, combustion does not" do
85 + assert Enum.sort(RoomGbfs.V3.electric_type_ids(types())) == ["CHLOE", "EFIT"]
86 + end
87 +
88 + test "the electric ones at a station are added back up" do
89 + # Two human bikes and one assist: three available, one of them electric.
90 + row = %{
91 + num_vehicles_available: 3,
92 + vehicle_types_available: [
93 + %{vehicle_type_id: "ICONIC", count: 2},
94 + %{vehicle_type_id: "EFIT", count: 1},
95 + %{vehicle_type_id: "CHLOE", count: 0}
96 + ]
97 + }
98 +
99 + out = V3.station_status(row, V3.electric_type_ids(types()))
100 +
101 + assert out.num_bikes_available == 3
102 + assert out.num_ebikes_available == 1
103 + end
104 +
105 + test "a feed that already reports the count keeps its own" do
106 + # Lyft publishes this as an extension; theirs is authoritative over
107 + # anything worked out from the type breakdown.
108 + row = %{
109 + num_bikes_available: 5,
110 + num_ebikes_available: 4,
111 + vehicle_types_available: [%{vehicle_type_id: "EFIT", count: 1}]
112 + }
113 +
114 + assert %{num_ebikes_available: 4} = V3.station_status(row, V3.electric_type_ids(types()))
115 + end
116 +
117 + test "no types loaded yet means no invented number" do
118 + row = %{num_vehicles_available: 3, vehicle_types_available: [%{vehicle_type_id: "EFIT", count: 1}]}
119 +
120 + refute Map.has_key?(V3.station_status(row, nil), :num_ebikes_available)
121 + end
122 +
123 + test "a station with no breakdown is left alone" do
124 + refute Map.has_key?(
125 + V3.station_status(%{num_vehicles_available: 3}, V3.electric_type_ids(types())),
126 + :num_ebikes_available
127 + )
128 + end
129 + end
130 +
131 + describe "feed order" do
132 + test "vehicle types are read before anything that needs them" do
133 + feeds = [
134 + %{"name" => "station_status"},
135 + %{"name" => "vehicle_types"},
136 + %{"name" => "station_information"}
137 + ]
138 +
139 + assert [%{"name" => "vehicle_types"} | _] = Enum.sort_by(feeds, &V3.feed_order/1)
140 + end
141 + end
142 +
72 143 describe "loose vehicles" do
73 144 test "v3's vehicle_id is v2's bike_id" do
74 145 assert %{bike_id: "b1"} = V3.vehicle_status(%{vehicle_id: "b1", lat: 1.0, lon: 2.0})

Parents: b52e7fa