lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests services kachelplan.test.ts

12,9 KB Rohdatei
tests/services/kachelplan.test.ts — 301 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import {
3 KACHEL_KANTE,
4 KARTENDIENSTE,
5 MAX_MOSAIK_KANTE,
6 baueKachelplan,
7 dienstMit,
8 type Kachelplan,
9 } from '@/services/kartendienste';
10 import { ausschnittUm, kantenlaenge as kanteVon } from '@/domain/geometrie/projektion';
11
12 /**
13 * Kachelplan.
14 *
15 * Aus dem Mosaik werden Raeum- und Einfahrwege VERMESSEN. Die Laenge geht
16 * unmittelbar in die Zwischenzeit ein: tz = tue + (sr + Fahrzeuglaenge)/vr -
17 * se/ve. Ein Fehler von d Metern auf dem Raeumweg wirkt mit d/vr Sekunden -
18 * bei vr = 1,0 m/s (mobilitaetseingeschraenkter Fussgaenger) also fast eins zu
19 * eins.
20 *
21 * Daraus folgt, was hier geprueft wird, und warum es keine Kleinigkeiten sind:
22 *
23 * 1. LUECKENLOSIGKEIT. Fehlt zwischen zwei Kacheln ein Streifen oder ueberlappen
24 * sie einander, dann ist das Bild an dieser Stelle gestaucht oder gedehnt.
25 * Ein darueber hinweg abgegriffener Raeumweg waere zu kurz - und die
26 * Zwischenzeit zu knapp. Optisch faellt eine Naht von wenigen Metern in einem
27 * Luftbild niemandem auf.
28 *
29 * 2. GLEICHE BODENAUFLOESUNG. Waere eine Randkachel schmaler, haette das Bild
30 * einen ortsabhaengigen Massstab. Dieselbe Strecke ergaebe je nach Lage im
31 * Bild eine andere Laenge.
32 *
33 * 3. ZEILENRICHTUNG. Zeile 0 muss die oberste Bildzeile sein, also der
34 * noerdlichste Streifen. Waere die Richtung vertauscht, entstuende ein
35 * senkrecht gespiegeltes Bild - eine Fussgaengerfurt laege dann auf der
36 * falschen Seite des Knotenpunkts, ohne dass das Bild unplausibel aussaehe.
37 *
38 * 4. OBERGRENZEN. Keine Kachel groesser, als der Dienst zulaesst (sonst
39 * verweigert er den Abruf), und kein Mosaik groesser als MAX_MOSAIK_KANTE
40 * (sonst sprengt das eingebettete Bild die Projektdatei).
41 */
42
43 const KNOTEN = { breite: 50.9412784, laenge: 6.9582814 };
44
45 /** Ausschnitt einer Kachel, wieder aus ihrer Abrufadresse gelesen. */
46 interface Kachelfeld {
47 readonly minOst: number;
48 readonly minNord: number;
49 readonly maxOst: number;
50 readonly maxNord: number;
51 readonly kante: number;
52 }
53
54 function feldVon(plan: Kachelplan, spalte: number, zeile: number): Kachelfeld {
55 const kachel = plan.kacheln.find((k) => k.spalte === spalte && k.zeile === zeile);
56 if (kachel === undefined) throw new Error(`Kachel ${spalte}/${zeile} fehlt im Plan`);
57 const parameter = new URL(kachel.url).searchParams;
58 const bbox = (parameter.get('BBOX') ?? '').split(',').map(Number);
59 expect(bbox).toHaveLength(4);
60 return {
61 minOst: bbox[0] as number,
62 minNord: bbox[1] as number,
63 maxOst: bbox[2] as number,
64 maxNord: bbox[3] as number,
65 kante: Number(parameter.get('WIDTH')),
66 };
67 }
68
69 // Die BBOX wird auf Millimeter gerundet ausgegeben; auf zwei Stellen genau zu
70 // vergleichen ist damit die schaerfste sinnvolle Schranke.
71 const MILLIMETER = 2;
72
73 describe('Kachelzahl und Kachelgroesse', () => {
74 const nrw = dienstMit('nw-dop'); // 10 cm, hoechstens 5000 Bildpunkte
75 const bayern = dienstMit('by-dop'); // 40 cm, hoechstens 6000 Bildpunkte
76
77 it('kommt bei kleinen Ausschnitten mit einer einzigen Kachel aus', () => {
78 // 100 m aus einem 10-cm-Luftbild sind 1000 Bildpunkte - das schafft ein
79 // Dienst in einem Zug, ein Mosaik waere unnoetiger Aufwand.
80 const plan = baueKachelplan({ dienst: nrw, mitte: KNOTEN, kantenlaenge: 100 });
81 expect(plan.spalten).toBe(1);
82 expect(plan.zeilen).toBe(1);
83 expect(plan.kacheln).toHaveLength(1);
84 expect(plan.bildkante).toBe(1000);
85 expect(plan.meterJeBildpunkt).toBeCloseTo(0.1, 9);
86 });
87
88 it('teilt auf, sobald ein Abruf die Kachelkante ueberschreitet', () => {
89 // 200 m mit 10 cm sind 2000 Bildpunkte, also zwei Kacheln je Richtung.
90 const plan = baueKachelplan({ dienst: nrw, mitte: KNOTEN, kantenlaenge: 200 });
91 expect(plan.spalten).toBe(2);
92 expect(plan.kacheln).toHaveLength(4);
93 expect(plan.bildkante).toBe(2000);
94 expect(plan.kacheln.every((k) => k.kante === 1000)).toBe(true);
95 expect(plan.meterJeBildpunkt).toBeCloseTo(0.1, 9);
96 });
97
98 it('liefert bis 1000 m die volle Bodenaufloesung des Dienstes', () => {
99 // Das ist die Zusage hinter MAX_MOSAIK_KANTE: 1000 m mit 20 cm sind 5000
100 // Bildpunkte und passen darunter.
101 const plan = baueKachelplan({ dienst: dienstMit('he-dop'), mitte: KNOTEN, kantenlaenge: 1000 });
102 expect(plan.bildkante).toBe(5000);
103 expect(plan.meterJeBildpunkt).toBeCloseTo(0.2, 9);
104 });
105
106 it('fordert von Bayern nicht feiner an als die gelieferten 40 cm', () => {
107 // 2000 m mit 40 cm sind 5000 Bildpunkte. Mehr anzufordern waere leeres
108 // Vergroessern: Es sieht genauer aus, ohne genauer zu sein.
109 const plan = baueKachelplan({ dienst: bayern, mitte: KNOTEN, kantenlaenge: 2000 });
110 expect(plan.bildkante).toBe(5000);
111 expect(plan.meterJeBildpunkt).toBeCloseTo(0.4, 9);
112 expect(plan.spalten).toBe(4);
113 expect(plan.kacheln).toHaveLength(16);
114 expect(plan.kacheln.every((k) => k.kante === 1250)).toBe(true);
115 });
116
117 it('wird bei 2000 m zum Uebersichtsbild mit 28,6 cm je Bildpunkt', () => {
118 // Hier liegt die fachliche Gefahr: Ein Bildpunkt Zeichenfehler kostet
119 // 0,029 s Zwischenzeit beim Kfz und 0,286 s beim mobilitaetseingeschraenkten
120 // Fussgaenger. Der Wert muss deshalb stimmen - die Oberflaeche warnt daran.
121 const plan = baueKachelplan({ dienst: nrw, mitte: KNOTEN, kantenlaenge: 2000 });
122 expect(plan.bildkante).toBe(MAX_MOSAIK_KANTE);
123 expect(plan.meterJeBildpunkt).toBeCloseTo(0.2857, 4);
124 expect(plan.spalten).toBe(5);
125 expect(plan.kacheln).toHaveLength(25);
126 });
127
128 it('meldet einen Massstab, der zum Bild passt', () => {
129 for (const laenge of [50, 200, 750, 1200, 2000]) {
130 const plan = baueKachelplan({ dienst: nrw, mitte: KNOTEN, kantenlaenge: laenge });
131 expect(plan.meterJeBildpunkt * plan.bildkante).toBeCloseTo(laenge, 9);
132 expect(plan.bildkante).toBe(plan.spalten * (plan.kacheln[0]?.kante ?? 0));
133 }
134 });
135 });
136
137 describe('Obergrenzen', () => {
138 it('haelt MAX_MOSAIK_KANTE und die Grenze jedes Dienstes ein', () => {
139 for (const dienst of KARTENDIENSTE) {
140 for (const laenge of [20, 100, 500, 1000, 1500, 2000, 9000]) {
141 const plan = baueKachelplan({ dienst, mitte: KNOTEN, kantenlaenge: laenge });
142 const wo = `${dienst.id} bei ${laenge} m`;
143
144 expect(plan.bildkante, wo).toBeLessThanOrEqual(MAX_MOSAIK_KANTE);
145 expect(plan.bildkante, wo).toBeGreaterThan(0);
146 for (const kachel of plan.kacheln) {
147 expect(kachel.kante, wo).toBeLessThanOrEqual(KACHEL_KANTE);
148 expect(kachel.kante, wo).toBeLessThanOrEqual(dienst.maxKante);
149 expect(kachel.kante, wo).toBeGreaterThan(0);
150 }
151 }
152 }
153 });
154
155 it('fordert nirgends feiner an als die Bodenaufloesung des Dienstes', () => {
156 // Unterhalb von 512 Bildpunkten greift die vorhandene Untergrenze der
157 // Anwendung und nicht die Aufloesung des Dienstes - ein winziges Bild waere
158 // zum Vermessen unbrauchbar.
159 const UNTERGRENZE = 512;
160 for (const dienst of KARTENDIENSTE) {
161 for (const laenge of [200, 500, 1000, 2000]) {
162 const plan = baueKachelplan({ dienst, mitte: KNOTEN, kantenlaenge: laenge });
163 // Zulaessig ist ausserdem das Aufrunden auf gleich grosse Kacheln -
164 // hoechstens (spalten - 1) Bildpunkte.
165 expect(plan.bildkante, `${dienst.id} bei ${laenge} m`).toBeLessThanOrEqual(
166 Math.max(UNTERGRENZE, laenge / dienst.aufloesung + plan.spalten),
167 );
168 }
169 }
170 });
171 });
172
173 describe('Lueckenlosigkeit', () => {
174 const faelle = [
175 { dienst: dienstMit('nw-dop'), kantenlaenge: 200 },
176 { dienst: dienstMit('nw-dop'), kantenlaenge: 2000 },
177 { dienst: dienstMit('by-dop'), kantenlaenge: 1200 },
178 { dienst: dienstMit('sn-dop'), kantenlaenge: 900 },
179 ];
180
181 for (const fall of faelle) {
182 const name = `${fall.dienst.id} bei ${fall.kantenlaenge} m`;
183
184 it(`deckt den Gesamtausschnitt vollstaendig ab - ${name}`, () => {
185 const plan = baueKachelplan({ ...fall, mitte: KNOTEN });
186 const ganz = ausschnittUm(KNOTEN, fall.kantenlaenge);
187
188 const westrand = feldVon(plan, 0, 0);
189 const ostrand = feldVon(plan, plan.spalten - 1, plan.zeilen - 1);
190 expect(westrand.minOst).toBeCloseTo(ganz.minOst, MILLIMETER);
191 expect(westrand.maxNord).toBeCloseTo(ganz.maxNord, MILLIMETER);
192 expect(ostrand.maxOst).toBeCloseTo(ganz.maxOst, MILLIMETER);
193 expect(ostrand.minNord).toBeCloseTo(ganz.minNord, MILLIMETER);
194 });
195
196 it(`stoesst ohne Luecke und ohne Ueberlappung an - ${name}`, () => {
197 const plan = baueKachelplan({ ...fall, mitte: KNOTEN });
198 const teilbreite = kanteVon(ausschnittUm(KNOTEN, fall.kantenlaenge)) / plan.spalten;
199
200 for (let zeile = 0; zeile < plan.zeilen; zeile += 1) {
201 for (let spalte = 0; spalte < plan.spalten; spalte += 1) {
202 const feld = feldVon(plan, spalte, zeile);
203 if (spalte > 0) {
204 // Die Ostkante der linken Nachbarin ist die Westkante dieser Kachel -
205 // gleich, nicht ueberlappend und nicht auseinanderklaffend.
206 expect(feldVon(plan, spalte - 1, zeile).maxOst).toBeCloseTo(feld.minOst, MILLIMETER);
207 }
208 if (zeile > 0) {
209 expect(feldVon(plan, spalte, zeile - 1).minNord).toBeCloseTo(feld.maxNord, MILLIMETER);
210 }
211 expect(feld.maxOst - feld.minOst).toBeCloseTo(teilbreite, MILLIMETER);
212 expect(feld.maxNord - feld.minNord).toBeCloseTo(teilbreite, MILLIMETER);
213 }
214 }
215 });
216
217 it(`hat in jeder Kachel dieselbe Bodenaufloesung - ${name}`, () => {
218 const plan = baueKachelplan({ ...fall, mitte: KNOTEN });
219 for (const kachel of plan.kacheln) {
220 const feld = feldVon(plan, kachel.spalte, kachel.zeile);
221 expect(feld.kante).toBe(kachel.kante);
222 expect((feld.maxOst - feld.minOst) / feld.kante).toBeCloseTo(plan.meterJeBildpunkt, 6);
223 expect((feld.maxNord - feld.minNord) / feld.kante).toBeCloseTo(plan.meterJeBildpunkt, 6);
224 }
225 });
226 }
227 });
228
229 describe('Einsetzpunkte im Mosaik', () => {
230 const plan = baueKachelplan({ dienst: dienstMit('nw-dop'), mitte: KNOTEN, kantenlaenge: 2000 });
231
232 it('gibt jede Kachel genau einmal aus', () => {
233 const felder = plan.kacheln.map((k) => `${k.spalte}/${k.zeile}`);
234 expect(new Set(felder).size).toBe(plan.spalten * plan.zeilen);
235 expect(plan.kacheln).toHaveLength(plan.spalten * plan.zeilen);
236 });
237
238 it('fuellt das Mosaik luecken- und ueberlappungsfrei', () => {
239 for (const kachel of plan.kacheln) {
240 expect(kachel.x).toBe(kachel.spalte * kachel.kante);
241 expect(kachel.y).toBe(kachel.zeile * kachel.kante);
242 expect(kachel.x + kachel.kante).toBeLessThanOrEqual(plan.bildkante);
243 expect(kachel.y + kachel.kante).toBeLessThanOrEqual(plan.bildkante);
244 }
245 const flaeche = plan.kacheln.reduce((summe, k) => summe + k.kante * k.kante, 0);
246 expect(flaeche).toBe(plan.bildkante * plan.bildkante);
247 });
248
249 it('zaehlt die Zeilen von Norden nach Sueden', () => {
250 // Der Einsetzpunkt y waechst nach unten, der Nordwert nach oben. Waere das
251 // vertauscht, entstuende ein senkrecht gespiegeltes Luftbild.
252 const oben = feldVon(plan, 0, 0);
253 const unten = feldVon(plan, 0, plan.zeilen - 1);
254 expect(oben.maxNord).toBeGreaterThan(unten.maxNord);
255 expect(oben.minNord).toBeGreaterThan(unten.minNord);
256 });
257
258 it('zaehlt die Spalten von Westen nach Osten', () => {
259 const links = feldVon(plan, 0, 0);
260 const rechts = feldVon(plan, plan.spalten - 1, 0);
261 expect(rechts.minOst).toBeGreaterThan(links.minOst);
262 });
263 });
264
265 describe('Abrufadresse jeder Kachel', () => {
266 it('nennt Dienst, Bezugssystem und Bildgroesse', () => {
267 const dienst = dienstMit('nw-dop');
268 const plan = baueKachelplan({ dienst, mitte: KNOTEN, kantenlaenge: 600 });
269 for (const kachel of plan.kacheln) {
270 const adresse = new URL(kachel.url);
271 const parameter = adresse.searchParams;
272 expect(kachel.url.startsWith(dienst.basisUrl)).toBe(true);
273 expect(parameter.get('VERSION')).toBe('1.3.0');
274 expect(parameter.get('REQUEST')).toBe('GetMap');
275 expect(parameter.get('LAYERS')).toBe(dienst.ebene);
276 expect(parameter.get('FORMAT')).toBe(dienst.bildformat);
277 // Alle Kacheln in derselben Zone - sonst liessen sie sich nicht
278 // zusammensetzen.
279 expect(parameter.get('CRS')).toBe('EPSG:25832');
280 expect(parameter.get('WIDTH')).toBe(String(kachel.kante));
281 expect(parameter.get('HEIGHT')).toBe(String(kachel.kante));
282
283 // Achsfolge Ost/Nord: Ost liegt in Zone 32 nahe 500 000, Nord bei ueber
284 // 5 Millionen. Vertauscht liefe der Abruf ins Leere.
285 const bbox = (parameter.get('BBOX') ?? '').split(',').map(Number);
286 expect(bbox[0] as number).toBeLessThan(bbox[1] as number);
287 expect(bbox[0] as number).toBeLessThan(bbox[2] as number);
288 expect(bbox[1] as number).toBeLessThan(bbox[3] as number);
289 }
290 });
291
292 it('gibt die Quellenangabe des Ausschnitts mit', () => {
293 const plan = baueKachelplan({
294 dienst: dienstMit('nw-dop'),
295 mitte: KNOTEN,
296 kantenlaenge: 1500,
297 });
298 expect(plan.herkunft).toContain('Geobasis NRW');
299 expect(plan.herkunft).toContain('1500 m');
300 });
301 });