feat(train): routes planned through the instance's BRouter (ADR 0030, D44)
The routing endpoint plans a route through the waypoints for one of six profiles, for the people signed in, at most 50 waypoints and 30 plans a person a minute, nothing kept or logged; refused without BROUTER_URL. BRouter's GeoJSON is read into points, elevations, climb and descent above 3 m, turn hints and stretches by surface. Train's running profile for BRouter, halkyon-run.brf, is a first guess not yet tried on BRouter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
8325d2a08b
commit
781576726a
9 changed files with 2563 additions and 1 deletions
243
apps/train/halkyon_train_server/lib/src/routing/brouter.dart
Normal file
243
apps/train/halkyon_train_server/lib/src/routing/brouter.dart
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
|
||||
/// BRouter's name for each of Train's profiles (ADR 0030, D44): its own
|
||||
/// files, and `halkyon-run`, Train's, which the sidecar's profiles hold.
|
||||
String brouterProfileOf(RouteProfile profile) => switch (profile) {
|
||||
RouteProfile.run => 'halkyon-run',
|
||||
RouteProfile.hike => 'hiking-mountain',
|
||||
RouteProfile.roadBike => 'fastbike',
|
||||
RouteProfile.trekkingBike => 'trekking',
|
||||
RouteProfile.gravel => 'gravel',
|
||||
RouteProfile.mtb => 'mtb',
|
||||
};
|
||||
|
||||
/// BRouter found no route: outside the segments it holds, or no way.
|
||||
class NoRoute() implements Exception;
|
||||
|
||||
/// BRouter didn't answer, or answered nothing it reads.
|
||||
class BRouterFailed(final String message) implements Exception {
|
||||
@override
|
||||
String toString() => 'BRouterFailed: $message';
|
||||
}
|
||||
|
||||
/// The router (ADR 0030, D11, D44): the instance's BRouter.
|
||||
abstract interface class RoutePlanner() {
|
||||
/// The route through [waypoints] for [profile], as BRouter's GeoJSON.
|
||||
/// Throws [NoRoute] where there is none, [BRouterFailed] where it didn't
|
||||
/// answer.
|
||||
Future<Map<String, Object?>> route(
|
||||
List<RoutePoint> waypoints,
|
||||
RouteProfile profile,
|
||||
);
|
||||
}
|
||||
|
||||
/// [RoutePlanner] on BRouter's HTTP server at [address] (`http://brouter:17777/`),
|
||||
/// which only the server reaches; what it is asked is never logged.
|
||||
class HttpBRouter(final Uri address) implements RoutePlanner {
|
||||
/// How long a route may take: a long one across a country takes seconds.
|
||||
static const _timeout = Duration(seconds: 30);
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> route(
|
||||
List<RoutePoint> waypoints,
|
||||
RouteProfile profile,
|
||||
) async {
|
||||
final client = HttpClient()..connectionTimeout = _timeout;
|
||||
try {
|
||||
final uri = address
|
||||
.resolve('brouter')
|
||||
.replace(
|
||||
queryParameters: {
|
||||
'lonlats': [
|
||||
for (final point in waypoints) '${point.lon},${point.lat}',
|
||||
].join('|'),
|
||||
'profile': brouterProfileOf(profile),
|
||||
'alternativeidx': '0',
|
||||
'format': 'geojson',
|
||||
// Locus' turn hints: exits told apart from keeping left or right.
|
||||
'timode': '2',
|
||||
},
|
||||
);
|
||||
final HttpClientResponse response;
|
||||
final String body;
|
||||
try {
|
||||
response = await (await client.getUrl(uri)).close().timeout(_timeout);
|
||||
body = await utf8.decodeStream(response).timeout(_timeout);
|
||||
} on Object catch (error) {
|
||||
throw BRouterFailed(error.runtimeType.toString());
|
||||
}
|
||||
// BRouter answers a route it can't find with an error and a message.
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw NoRoute();
|
||||
}
|
||||
try {
|
||||
return jsonDecode(body) as Map<String, Object?>;
|
||||
} on Object {
|
||||
throw BRouterFailed('no GeoJSON');
|
||||
}
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The climb that counts, as a workout's (D24): GPS's or the elevation
|
||||
/// model's noise is a few metres.
|
||||
const _climbThreshold = 3;
|
||||
|
||||
/// The route [geojson] holds, as [HttpBRouter] answers it: BRouter's one
|
||||
/// feature, its coordinates `[lon, lat, elevation]`, its `voicehints`
|
||||
/// `[index, command, exit, metres to the next, angle]`, and its `messages`,
|
||||
/// a table of stretches whose first row names the columns (`Distance`,
|
||||
/// `WayTags`). Numbers in its properties come as strings.
|
||||
PlannedRoute plannedRouteOf(Map<String, Object?> geojson) {
|
||||
final feature = switch (geojson['features']) {
|
||||
[final Map<String, Object?> feature, ...] => feature,
|
||||
_ => throw BRouterFailed('no feature'),
|
||||
};
|
||||
final properties = feature['properties'] as Map<String, Object?>? ?? {};
|
||||
final coordinates = switch (feature['geometry']) {
|
||||
{'coordinates': final List<Object?> coordinates} => coordinates,
|
||||
_ => throw BRouterFailed('no coordinates'),
|
||||
};
|
||||
final points = <RoutePoint>[];
|
||||
final elevations = <double>[];
|
||||
var anyElevation = false;
|
||||
for (final coordinate in coordinates) {
|
||||
if (coordinate case [final num lon, final num lat, ...final rest]) {
|
||||
points.add(RoutePoint(lat: lat.toDouble(), lon: lon.toDouble()));
|
||||
final elevation = switch (rest) {
|
||||
[final num elevation, ...] => elevation.toDouble(),
|
||||
_ => null,
|
||||
};
|
||||
anyElevation |= elevation != null;
|
||||
elevations.add(elevation ?? elevations.lastOrNull ?? double.nan);
|
||||
}
|
||||
}
|
||||
if (points.length < 2) {
|
||||
throw BRouterFailed('no route');
|
||||
}
|
||||
// A start without an elevation takes the first known.
|
||||
final known = elevations.firstWhere((e) => !e.isNaN, orElse: () => 0);
|
||||
final filled = [
|
||||
for (final e in elevations)
|
||||
if (e.isNaN) known else e,
|
||||
];
|
||||
final (ascent, descent) = _climbs(filled);
|
||||
return PlannedRoute(
|
||||
points: points,
|
||||
elevations: anyElevation ? filled : const [],
|
||||
distanceMetres: _int(properties['track-length']) ?? 0,
|
||||
ascentMetres: anyElevation ? ascent : 0,
|
||||
descentMetres: anyElevation ? descent : 0,
|
||||
hints: [
|
||||
for (final hint in properties['voicehints'] as List<Object?>? ?? [])
|
||||
if (hint case [
|
||||
final Object index,
|
||||
final Object command,
|
||||
final Object exit,
|
||||
final Object metres,
|
||||
final Object angle,
|
||||
...,
|
||||
])
|
||||
if ((
|
||||
_int(index),
|
||||
_int(command),
|
||||
_int(exit),
|
||||
_int(metres),
|
||||
_int(angle),
|
||||
)
|
||||
case (
|
||||
final index?,
|
||||
final command?,
|
||||
final exit?,
|
||||
final metres?,
|
||||
final angle?,
|
||||
)
|
||||
when index >= 0 && index < points.length)
|
||||
RouteHint(
|
||||
index: index,
|
||||
command: command,
|
||||
exit: exit,
|
||||
metres: metres,
|
||||
angle: angle,
|
||||
),
|
||||
],
|
||||
stretches: _stretches(properties['messages']),
|
||||
);
|
||||
}
|
||||
|
||||
/// The climbs and descents above [_climbThreshold], each added up.
|
||||
(int, int) _climbs(List<double> elevations) {
|
||||
if (elevations.isEmpty) {
|
||||
return (0, 0);
|
||||
}
|
||||
var reference = elevations.first;
|
||||
var (up, down) = (0.0, 0.0);
|
||||
for (final elevation in elevations.skip(1)) {
|
||||
if (elevation > reference + _climbThreshold) {
|
||||
up += elevation - reference;
|
||||
reference = elevation;
|
||||
} else if (elevation < reference - _climbThreshold) {
|
||||
down += reference - elevation;
|
||||
reference = elevation;
|
||||
}
|
||||
}
|
||||
return (up.round(), down.round());
|
||||
}
|
||||
|
||||
/// The stretches of BRouter's messages: each row's length and way's tags,
|
||||
/// rows of the same surface and highway joined.
|
||||
List<RouteStretch> _stretches(Object? messages) {
|
||||
if (messages is! List<Object?> || messages.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
final header = messages.first;
|
||||
if (header is! List<Object?>) {
|
||||
return const [];
|
||||
}
|
||||
final (distance, tags) = (
|
||||
header.indexOf('Distance'),
|
||||
header.indexOf('WayTags'),
|
||||
);
|
||||
if (distance < 0 || tags < 0) {
|
||||
return const [];
|
||||
}
|
||||
final stretches = <RouteStretch>[];
|
||||
for (final row in messages.skip(1)) {
|
||||
if (row is! List<Object?> ||
|
||||
row.length <= (distance > tags ? distance : tags)) {
|
||||
continue;
|
||||
}
|
||||
final metres = _int(row[distance]);
|
||||
if (metres == null || metres <= 0) {
|
||||
continue;
|
||||
}
|
||||
final way = {
|
||||
for (final tag in '${row[tags]}'.split(' '))
|
||||
if (tag.split('=') case [final key, final value]) key: value,
|
||||
};
|
||||
final (surface, highway) = (way['surface'], way['highway']);
|
||||
final last = stretches.lastOrNull;
|
||||
if (last != null && last.surface == surface && last.highway == highway) {
|
||||
stretches[stretches.length - 1] = last.copyWith(
|
||||
metres: last.metres + metres,
|
||||
);
|
||||
} else {
|
||||
stretches.add(
|
||||
RouteStretch(metres: metres, surface: surface, highway: highway),
|
||||
);
|
||||
}
|
||||
}
|
||||
return stretches;
|
||||
}
|
||||
|
||||
int? _int(Object? value) => switch (value) {
|
||||
final int value => value,
|
||||
final num value => value.round(),
|
||||
final String value => num.tryParse(value)?.round(),
|
||||
_ => null,
|
||||
};
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
### A route the instance's BRouter planned (ADR 0030, D44): its points in
|
||||
### order, their elevations in metres (empty where BRouter knows none; a point
|
||||
### without one takes the one before), its length, its climb and descent
|
||||
### (climbs above 3 m, as a workout's), its turns, and its stretches in order.
|
||||
### Nothing of it is kept on the instance: the app seals what it saves.
|
||||
class: PlannedRoute
|
||||
fields:
|
||||
points: List<RoutePoint>
|
||||
elevations: List<double>
|
||||
distanceMetres: int
|
||||
ascentMetres: int
|
||||
descentMetres: int
|
||||
hints: List<RouteHint>
|
||||
stretches: List<RouteStretch>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
### A turn of a route planned, as BRouter hints it (ADR 0030, D45): at the
|
||||
### point [index] of the route, BRouter's command (1 straight on, 2 left, 3
|
||||
### slightly left, 4 sharply left, 5 right, 6 slightly right, 7 sharply
|
||||
### right, 8 keep left, 9 keep right, 10 and 11 U-turns, 13 and 14
|
||||
### roundabouts, 15 a 180° turn, 17 and 18 exits, 16 a beeline), the exit of
|
||||
### a roundabout, the metres to the next hint and the turn's angle.
|
||||
class: RouteHint
|
||||
fields:
|
||||
index: int
|
||||
command: int
|
||||
exit: int
|
||||
metres: int
|
||||
angle: int
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
### A point on the earth, in degrees (WGS 84): a waypoint asked for, or a
|
||||
### point of a route planned (ADR 0030, D44).
|
||||
class: RoutePoint
|
||||
fields:
|
||||
lat: double
|
||||
lon: double
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
### How a route is planned (ADR 0030, D44): running on Train's own BRouter
|
||||
### profile, hiking (`hiking-mountain`), a road bike (`fastbike`), a trekking
|
||||
### bike (`trekking`), gravel (`gravel`) or a mountain bike (`mtb`).
|
||||
enum: RouteProfile
|
||||
serialized: byName
|
||||
values:
|
||||
- run
|
||||
- hike
|
||||
- roadBike
|
||||
- trekkingBike
|
||||
- gravel
|
||||
- mtb
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
### Why the instance plans no route (ADR 0030, D44): `off` where it has no
|
||||
### BRouter, `invalid` for waypoints it doesn't take (fewer than two, more
|
||||
### than `Routing.maxWaypoints`, off the earth), `tooOften` beyond
|
||||
### `Routing.perMinute` plans a minute, `noRoute` where BRouter found none
|
||||
### (outside the region it holds, or no way between), `unreachable` where it
|
||||
### didn't answer.
|
||||
exception: RouteRefused
|
||||
fields:
|
||||
reason: String
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
### A stretch of a route planned with what it runs on (ADR 0030, D44): its
|
||||
### length, its OpenStreetMap `surface` and `highway` where tagged.
|
||||
class: RouteStretch
|
||||
fields:
|
||||
metres: int
|
||||
surface: String?
|
||||
highway: String?
|
||||
94
apps/train/halkyon_train_server/lib/src/routing/routing.dart
Normal file
94
apps/train/halkyon_train_server/lib/src/routing/routing.dart
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_train_server/src/routing/brouter.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// Planning routes on this instance (ADR 0030, D11, D44), through its
|
||||
/// BRouter where it has one, for a person as often as [perMinute] allows.
|
||||
/// Neither the waypoints nor the route are kept or logged; the counts live
|
||||
/// in memory alone.
|
||||
class Routing {
|
||||
Routing({this.router});
|
||||
|
||||
/// The routing of [pod], made the first time it is asked for, from the
|
||||
/// operator's `BROUTER_URL` (empty: none). Tests set their own.
|
||||
factory Routing.of(Serverpod pod) => _ofPod[pod] ??= Routing(
|
||||
router: switch (Platform.environment['BROUTER_URL']?.trim()) {
|
||||
final url? when url.isNotEmpty => HttpBRouter(Uri.parse(url)),
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
static final _ofPod = Expando<Routing>('Routing');
|
||||
|
||||
/// Makes [routing] the one of [pod].
|
||||
static void use(Serverpod pod, Routing routing) => _ofPod[pod] = routing;
|
||||
|
||||
/// The instance's router; none: it plans nothing.
|
||||
final RoutePlanner? router;
|
||||
|
||||
/// The most waypoints a route takes.
|
||||
static const maxWaypoints = 50;
|
||||
|
||||
/// The most routes a person may ask for in a minute: the planner asks
|
||||
/// again as each waypoint is set or dragged.
|
||||
static const perMinute = 30;
|
||||
|
||||
final _asked = HashMap<UuidValue, Queue<DateTime>>();
|
||||
|
||||
/// The route through [waypoints] for [profile], planned for [person].
|
||||
/// Refused ([RouteRefused]) without a router, for waypoints it doesn't
|
||||
/// take, beyond [perMinute], where BRouter finds none or doesn't answer.
|
||||
Future<PlannedRoute> plan(
|
||||
UuidValue person,
|
||||
List<RoutePoint> waypoints,
|
||||
RouteProfile profile,
|
||||
) async {
|
||||
final router = this.router;
|
||||
if (router == null) {
|
||||
throw RouteRefused(reason: 'off');
|
||||
}
|
||||
if (waypoints.length < 2 ||
|
||||
waypoints.length > maxWaypoints ||
|
||||
!waypoints.every(_onEarth)) {
|
||||
throw RouteRefused(reason: 'invalid');
|
||||
}
|
||||
_count(person);
|
||||
try {
|
||||
return plannedRouteOf(await router.route(waypoints, profile));
|
||||
} on NoRoute {
|
||||
throw RouteRefused(reason: 'noRoute');
|
||||
} on BRouterFailed {
|
||||
throw RouteRefused(reason: 'unreachable');
|
||||
}
|
||||
}
|
||||
|
||||
static bool _onEarth(RoutePoint point) =>
|
||||
point.lat.isFinite &&
|
||||
point.lon.isFinite &&
|
||||
point.lat.abs() <= 90 &&
|
||||
point.lon.abs() <= 180;
|
||||
|
||||
/// Counts a plan of [person]'s, refusing it beyond [perMinute] in the
|
||||
/// last minute.
|
||||
void _count(UuidValue person) {
|
||||
final now = clock.now();
|
||||
final asked = _asked.putIfAbsent(person, Queue.new);
|
||||
while (asked.isNotEmpty &&
|
||||
now.difference(asked.first) >= const Duration(minutes: 1)) {
|
||||
asked.removeFirst();
|
||||
}
|
||||
if (asked.length >= perMinute) {
|
||||
throw RouteRefused(reason: 'tooOften');
|
||||
}
|
||||
asked.addLast(now);
|
||||
// Those who planned nothing for a minute are forgotten.
|
||||
_asked.removeWhere(
|
||||
(_, times) =>
|
||||
times.isEmpty ||
|
||||
now.difference(times.last) >= const Duration(minutes: 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_train_server/src/routing/routing.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
/// Routes planned by the instance's BRouter (ADR 0030, D11, D44), for those
|
||||
/// signed in: BRouter has no sign-in of its own, so the server alone reaches
|
||||
/// it. Neither the waypoints nor the route are kept or logged. Refusals are
|
||||
/// `RouteRefused`.
|
||||
class RoutingEndpoint extends Endpoint {
|
||||
@override
|
||||
bool get requireLogin => true;
|
||||
|
||||
/// Whether the instance plans routes.
|
||||
Future<bool> offered(Session session) async =>
|
||||
Routing.of(session.serverpod).router != null;
|
||||
|
||||
/// The route through [waypoints], in order, for [profile]. Refused where
|
||||
/// the instance has no BRouter or it fails, for fewer than two or more
|
||||
/// than `Routing.maxWaypoints` waypoints, and beyond `Routing.perMinute`
|
||||
/// plans a minute.
|
||||
Future<PlannedRoute> plan(
|
||||
Session session,
|
||||
List<RoutePoint> waypoints,
|
||||
RouteProfile profile,
|
||||
) => Routing.of(
|
||||
session.serverpod,
|
||||
).plan(session.authenticated!.authUserId, waypoints, profile);
|
||||
}
|
||||
Loading…
Reference in a new issue