neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
Log in to mark this repository.
Put back the electric count v3 dropped
efd60b3 · Gabriel Morell · 2026-09-04 15:53
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 | 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 | 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 | 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 | 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 | 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