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
1 changed files with 2563 additions and 1 deletions
|
|
@ -0,0 +1,189 @@
|
|||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_train_server/src/routing/brouter.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// A route as BRouter's GeoJSON gives it (written from its `FormatJson` and
|
||||
/// `Formatter`, read 28 Oct 2026): numbers in the properties as strings,
|
||||
/// coordinates `[lon, lat, elevation]`, turn hints `[index, command, exit,
|
||||
/// metres, angle]`, and its messages a table whose first row names the
|
||||
/// columns.
|
||||
Map<String, Object?> brouterRoute({
|
||||
List<List<num>>? coordinates,
|
||||
List<Object?>? voicehints,
|
||||
}) => {
|
||||
'type': 'FeatureCollection',
|
||||
'features': [
|
||||
{
|
||||
'type': 'Feature',
|
||||
'properties': {
|
||||
'creator': 'BRouter-1.7.8',
|
||||
'name': 'brouter_trekking_0',
|
||||
'track-length': '1234',
|
||||
'filtered ascend': '9',
|
||||
'plain-ascend': '12',
|
||||
'total-time': '300',
|
||||
'voicehints':
|
||||
voicehints ??
|
||||
[
|
||||
[1, 2, 0, 400, -90],
|
||||
[2, 13, 2, 200, 45],
|
||||
],
|
||||
'messages': [
|
||||
[
|
||||
'Longitude',
|
||||
'Latitude',
|
||||
'Elevation',
|
||||
'Distance',
|
||||
'CostPerKm',
|
||||
'ElevCost',
|
||||
'TurnCost',
|
||||
'NodeCost',
|
||||
'InitialCost',
|
||||
'WayTags',
|
||||
'NodeTags',
|
||||
'Time',
|
||||
'Energy',
|
||||
],
|
||||
[
|
||||
'13404954',
|
||||
'52520008',
|
||||
'34',
|
||||
'300',
|
||||
'1000',
|
||||
'0',
|
||||
'0',
|
||||
'0',
|
||||
'0',
|
||||
'highway=residential surface=asphalt',
|
||||
'',
|
||||
'60',
|
||||
'0',
|
||||
],
|
||||
[
|
||||
'13405954',
|
||||
'52521008',
|
||||
'40',
|
||||
'200',
|
||||
'1000',
|
||||
'0',
|
||||
'0',
|
||||
'0',
|
||||
'0',
|
||||
'highway=residential surface=asphalt',
|
||||
'',
|
||||
'100',
|
||||
'0',
|
||||
],
|
||||
[
|
||||
'13406954',
|
||||
'52522008',
|
||||
'38',
|
||||
'734',
|
||||
'1300',
|
||||
'0',
|
||||
'0',
|
||||
'0',
|
||||
'0',
|
||||
'highway=track tracktype=grade2 surface=gravel',
|
||||
'',
|
||||
'300',
|
||||
'0',
|
||||
],
|
||||
],
|
||||
},
|
||||
'geometry': {
|
||||
'type': 'LineString',
|
||||
'coordinates':
|
||||
coordinates ??
|
||||
[
|
||||
[13.404954, 52.520008, 34.0],
|
||||
[13.405954, 52.521008, 40.25],
|
||||
[13.406954, 52.522008, 38.0],
|
||||
[13.407954, 52.523008, 30.5],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
void main() {
|
||||
test('a route reads with its points, elevations, length, hints and '
|
||||
'stretches, those of one way joined', () {
|
||||
final route = plannedRouteOf(brouterRoute());
|
||||
|
||||
expect(
|
||||
(route.points.first.lat, route.points.first.lon),
|
||||
(52.520008, 13.404954),
|
||||
);
|
||||
expect(route.points, hasLength(4));
|
||||
expect(route.elevations, [34.0, 40.25, 38.0, 30.5]);
|
||||
expect(route.distanceMetres, 1234);
|
||||
expect(route.hints.map((h) => (h.index, h.command, h.exit)), [
|
||||
(1, 2, 0),
|
||||
(2, 13, 2),
|
||||
]);
|
||||
expect(route.hints.first.metres, 400);
|
||||
expect(route.hints.first.angle, -90);
|
||||
expect(
|
||||
route.stretches.map((s) => (s.metres, s.surface, s.highway)),
|
||||
[(500, 'asphalt', 'residential'), (734, 'gravel', 'track')],
|
||||
);
|
||||
});
|
||||
|
||||
test("its climb and descent count only what passes 3 m, as a workout's", () {
|
||||
final route = plannedRouteOf(brouterRoute());
|
||||
|
||||
// Up 6.25 from 34; down to 38 is within 3 m of 40.25, to 30.5 is not.
|
||||
expect(route.ascentMetres, 6);
|
||||
expect(route.descentMetres, 10);
|
||||
});
|
||||
|
||||
test('without elevations it has none, nor a climb', () {
|
||||
final route = plannedRouteOf(
|
||||
brouterRoute(
|
||||
coordinates: [
|
||||
[13.40, 52.52],
|
||||
[13.41, 52.53],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(route.elevations, isEmpty);
|
||||
expect((route.ascentMetres, route.descentMetres), (0, 0));
|
||||
});
|
||||
|
||||
test('a hint it cannot read, or one off the route, is left out', () {
|
||||
final route = plannedRouteOf(
|
||||
brouterRoute(
|
||||
voicehints: [
|
||||
[1, 5, 0, 100, 90],
|
||||
[9, 2, 0, 100, -90],
|
||||
['x'],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(route.hints.map((h) => h.command), [5]);
|
||||
});
|
||||
|
||||
test('an answer without a route is a failure', () {
|
||||
expect(
|
||||
() => plannedRouteOf({'type': 'FeatureCollection', 'features': []}),
|
||||
throwsA(isA<BRouterFailed>()),
|
||||
);
|
||||
});
|
||||
|
||||
test("Train's profiles are BRouter's, running Train's own", () {
|
||||
expect(
|
||||
{for (final profile in RouteProfile.values) brouterProfileOf(profile)},
|
||||
{
|
||||
'halkyon-run',
|
||||
'hiking-mountain',
|
||||
'fastbike',
|
||||
'trekking',
|
||||
'gravel',
|
||||
'mtb',
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue