mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
fix(tours): checklist entries within their contract, whole locks and a leaner reading
A lock flight chained into one group of more than 30 parts broke the checklist's contract and made every decision about it fail; an entry now names its nearest part and the first others, a portage lists at most 30 obstacles and a checklist at most 100 own entries. Reading along a line takes in the parts of a lock the corridor cut off, so its data basis no longer depends on how the route runs. A line or an area stands at the middle of its extent, so adding a node leaves it in place, and an earlier decision is judged by where the obstacle was, not by the route point of its passage. The cache keeps its readings without their lines, for one data version at a time, and the data version is read without the region boundaries.
This commit is contained in:
@@ -18,6 +18,22 @@ export interface PaddleFeatureRow {
|
||||
max_lng: number;
|
||||
}
|
||||
|
||||
/** The columns a stored object is read with. */
|
||||
const FEATURE_COLUMNS = [
|
||||
'region_id',
|
||||
'osm_ref',
|
||||
'kind',
|
||||
'group_key',
|
||||
'name',
|
||||
'geometry',
|
||||
'tags',
|
||||
'tag_hash',
|
||||
'min_lat',
|
||||
'min_lng',
|
||||
'max_lat',
|
||||
'max_lng',
|
||||
] as const;
|
||||
|
||||
/** A box in degrees, south to north and west to east. */
|
||||
export interface Box {
|
||||
south: number;
|
||||
@@ -43,20 +59,22 @@ export class PaddleFeaturesRepository extends TrekRepository<PaddleFeatures> {
|
||||
async findInBox(box: Box, limit: number): Promise<PaddleFeatureRow[]> {
|
||||
return await this.db()
|
||||
.selectFrom('paddle_features')
|
||||
.select([
|
||||
'region_id',
|
||||
'osm_ref',
|
||||
'kind',
|
||||
'group_key',
|
||||
'name',
|
||||
'geometry',
|
||||
'tags',
|
||||
'tag_hash',
|
||||
'min_lat',
|
||||
'min_lng',
|
||||
'max_lat',
|
||||
'max_lng',
|
||||
])
|
||||
.select(FEATURE_COLUMNS)
|
||||
.where('min_lat', '<=', box.north)
|
||||
.where('max_lat', '>=', box.south)
|
||||
.where('min_lng', '<=', box.east)
|
||||
.where('max_lng', '>=', box.west)
|
||||
.orderBy('id', 'asc')
|
||||
.limit(limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** The parts of the given locks whose box reaches into `box`, at most `limit` of them, in a stable order. */
|
||||
async findGroupParts(keys: readonly string[], box: Box, limit: number): Promise<PaddleFeatureRow[]> {
|
||||
return await this.db()
|
||||
.selectFrom('paddle_features')
|
||||
.select(FEATURE_COLUMNS)
|
||||
.where('group_key', 'in', [...keys])
|
||||
.where('min_lat', '<=', box.north)
|
||||
.where('max_lat', '>=', box.south)
|
||||
.where('min_lng', '<=', box.east)
|
||||
|
||||
@@ -49,6 +49,11 @@ export class PaddleRegionsRepository extends TrekRepository<PaddleRegions> {
|
||||
return await this.db().selectFrom('paddle_regions').select(COLUMNS).orderBy('name', 'asc').execute();
|
||||
}
|
||||
|
||||
/** When each region was last read, by id: what the obstacle data's version is made of, without the boundaries. */
|
||||
async readTimes(): Promise<Array<{ id: string; imported_at: string | null }>> {
|
||||
return await this.db().selectFrom('paddle_regions').select(['id', 'imported_at']).orderBy('id', 'asc').execute();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PaddleRegionRow | undefined> {
|
||||
return await this.db().selectFrom('paddle_regions').select(COLUMNS).where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
|
||||
/** The widest a run of a line is read at once, in degrees a side; a long tour is read run by run. */
|
||||
const RUN_SPAN_DEG = 0.1;
|
||||
/** How far round the parts of a lock found along a line its other parts are looked for, in degrees (about 2 km). */
|
||||
const LOCK_PART_MARGIN_DEG = 0.02;
|
||||
|
||||
/** What the data holds along a paddle tour's line, as the checklist reads it. */
|
||||
export interface PaddleObstaclesAlong {
|
||||
@@ -140,15 +142,18 @@ export class PaddleObstaclesService {
|
||||
const seen = new Set<string>();
|
||||
const features: PaddleFeature[] = [];
|
||||
const regions = new Map<string, PaddleRegionRow>();
|
||||
const take = (row: PaddleFeatureRow) => {
|
||||
if (seen.has(row.osm_ref)) return;
|
||||
seen.add(row.osm_ref);
|
||||
const feature = featureOf(row);
|
||||
if (feature) features.push(feature);
|
||||
};
|
||||
for (const run of runs) {
|
||||
for (const row of run.rows.slice(0, PADDLE_OBSTACLES_CAP)) {
|
||||
if (seen.has(row.osm_ref)) continue;
|
||||
seen.add(row.osm_ref);
|
||||
const feature = featureOf(row);
|
||||
if (feature) features.push(feature);
|
||||
}
|
||||
run.rows.slice(0, PADDLE_OBSTACLES_CAP).forEach(take);
|
||||
for (const region of run.regions) regions.set(region.id, region);
|
||||
}
|
||||
// The parts of a lock the corridor cut off: its checklist entry reads the lock whole, however the route runs.
|
||||
(await this.lockPartsAround(runs.flatMap((run) => run.rows.slice(0, PADDLE_OBSTACLES_CAP)))).forEach(take);
|
||||
const held = [...regions.values()];
|
||||
return {
|
||||
features,
|
||||
@@ -167,13 +172,27 @@ export class PaddleObstaclesService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Every stored part of the locks among the rows, within a margin round the parts found. */
|
||||
private async lockPartsAround(rows: readonly PaddleFeatureRow[]): Promise<PaddleFeatureRow[]> {
|
||||
const grouped = rows.filter((row) => row.group_key !== null);
|
||||
if (grouped.length === 0) return [];
|
||||
const keys = [...new Set(grouped.flatMap((row) => row.group_key ?? []))];
|
||||
const box = {
|
||||
south: Math.min(...grouped.map((row) => row.min_lat)) - LOCK_PART_MARGIN_DEG,
|
||||
west: Math.min(...grouped.map((row) => row.min_lng)) - LOCK_PART_MARGIN_DEG,
|
||||
north: Math.max(...grouped.map((row) => row.max_lat)) + LOCK_PART_MARGIN_DEG,
|
||||
east: Math.max(...grouped.map((row) => row.max_lng)) + LOCK_PART_MARGIN_DEG,
|
||||
};
|
||||
return await this.features.findGroupParts(keys, box, PADDLE_OBSTACLES_CAP);
|
||||
}
|
||||
|
||||
/**
|
||||
* A text that changes whenever the data a checklist reads could have:
|
||||
* an object added or removed, a region read again or removed. The checklist
|
||||
* caches its reading under it.
|
||||
*/
|
||||
async dataVersion(): Promise<string> {
|
||||
const [stats, regions] = await Promise.all([this.features.stats(), this.regions.list()]);
|
||||
const [stats, regions] = await Promise.all([this.features.stats(), this.regions.readTimes()]);
|
||||
return `${stats.count}:${stats.max_id ?? '-'}:${regions.map((region) => `${region.id}@${region.imported_at ?? '-'}`).join(';')}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,29 +13,39 @@ export interface PaddleCheckEvaluation {
|
||||
data: Pick<PaddleCheck, 'data' | 'data_as_of' | 'read_at'>;
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 256;
|
||||
/** A reading as kept: without its line, which the caller holds and the key already stands for. */
|
||||
type CachedEvaluation = Omit<PaddleCheckEvaluation, 'line'>;
|
||||
|
||||
const MAX_ENTRIES = 64;
|
||||
|
||||
/**
|
||||
* The evaluations of the last lines read, keyed by the line, the boat and
|
||||
* the version of the obstacle data, so the tours list of a trip reads its
|
||||
* paddle tours' obstacles once and not on every call. Held in memory: one
|
||||
* process serves the app, and a new data version simply misses.
|
||||
* The evaluations of the last lines read, keyed by the line and the boat,
|
||||
* so the tours list of a trip reads its paddle tours' obstacles once and not
|
||||
* on every call. Held in memory for one version of the obstacle data: a new
|
||||
* version empties it, since nothing read before can be served again.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaddleCheckCache {
|
||||
private readonly entries = new Map<string, PaddleCheckEvaluation>();
|
||||
private readonly entries = new Map<string, CachedEvaluation>();
|
||||
private version: string | null = null;
|
||||
|
||||
get(key: string): PaddleCheckEvaluation | undefined {
|
||||
const hit = this.entries.get(key);
|
||||
if (hit) {
|
||||
// Read again, kept longer: the map's order is the order of use.
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, hit);
|
||||
}
|
||||
return hit;
|
||||
/** The obstacle data's current version; a different one drops every reading kept. */
|
||||
atVersion(version: string): void {
|
||||
if (version === this.version) return;
|
||||
this.entries.clear();
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
set(key: string, value: PaddleCheckEvaluation): void {
|
||||
get(key: string, line: PaddleCheckLine): PaddleCheckEvaluation | undefined {
|
||||
const hit = this.entries.get(key);
|
||||
if (!hit) return undefined;
|
||||
// Read again, kept longer: the map's order is the order of use.
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, hit);
|
||||
return { ...hit, line };
|
||||
}
|
||||
|
||||
set(key: string, { line: _line, ...value }: PaddleCheckEvaluation): void {
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, value);
|
||||
if (this.entries.size > MAX_ENTRIES) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
obstacleEntriesOf,
|
||||
ownEntriesOf,
|
||||
PADDLE_CHECK_MAX_ENTRIES,
|
||||
PADDLE_CHECK_MAX_OWN,
|
||||
PADDLE_CHECK_VERSION,
|
||||
paddleCheckLineOfPoints,
|
||||
paddleCheckLineOfTrack,
|
||||
@@ -107,8 +108,9 @@ export class TourPaddleCheckService {
|
||||
async evaluate(line: PaddleCheckLine, boat: PaddleBoatType | null): Promise<PaddleCheckEvaluation> {
|
||||
try {
|
||||
const version = await this.obstacles.dataVersion();
|
||||
const key = `${PADDLE_CHECK_VERSION}|${shortHash(JSON.stringify([line.points, line.portages, line.locks]))}|${boat ?? ''}|${version}`;
|
||||
const hit = this.cache.get(key);
|
||||
this.cache.atVersion(version);
|
||||
const key = `${PADDLE_CHECK_VERSION}|${shortHash(JSON.stringify([line.points, line.portages, line.locks]))}|${boat ?? ''}`;
|
||||
const hit = this.cache.get(key, line);
|
||||
if (hit) return hit;
|
||||
const along = await this.obstacles.readAlong(line.points);
|
||||
const candidates = obstacleCandidatesOf({
|
||||
@@ -160,7 +162,9 @@ export class TourPaddleCheckService {
|
||||
own = [];
|
||||
data = UNAVAILABLE;
|
||||
}
|
||||
// The checklist carries as many entries and own portages and locks as its contract holds.
|
||||
entries = entries.slice(0, PADDLE_CHECK_MAX_ENTRIES);
|
||||
own = own.slice(0, PADDLE_CHECK_MAX_OWN);
|
||||
const summary = paddleCheckSummaryOf(entries, own, data);
|
||||
return { entries, own, check: { ...summary, revision: shortHash(JSON.stringify({ entries, own, data })) } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user