lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain ersatzbelege.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { computeIntergreen } from '@/domain/rilsa/intergreen'; |
| 3 | import { schwerverkehrsfaktor } from '@/domain/rilsa/capacity'; |
| 4 | import { HBS_PKW_GLEICHWERTE, RILSA_DEFAULTS } from '@/domain/rilsa/constants'; |
| 5 | import { QUELLEN } from '@/domain/rilsa/quellen'; |
| 6 | import { resolveRilsaSettings, settingFields, wirkungslosigkeit } from '@/domain/rilsa/settings'; |
| 7 | import type { ClearingStream, EnteringStream } from '@/domain/rilsa/types'; |
| 8 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 9 | import { validateProject } from '@/domain/validation/engine'; |
| 10 | import { loadProject } from '@/domain/model/migrate'; |
| 11 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 12 | import { CURRENT_SCHEMA_VERSION, type Project } from '@/domain/model/project'; |
| 13 | |
| 14 | /** |
| 15 | * Fassung 5.5.0, Fragen E1, E2, E5 und E6 - die Ersatzbelege aus oeffentlichen |
| 16 | * Quellen. |
| 17 | * |
| 18 | * Diese vier Fragen galten bis zum 15.08.2026 als "nur gegen die Druckausgabe |
| 19 | * pruefbar". Eine Recherche hat oeffentliche Ersatzbelege gehoben; massgebend |
| 20 | * ist, was einer kritischen Pruefung jedes einzelnen Belegs standhielt - |
| 21 | * mehrere Belege hielten ihr nicht stand. |
| 22 | * |
| 23 | * E6 (Rechenaenderung) - BASt-Bericht V 400 (2025), S. 10, woertlich: |
| 24 | * "fSV = (qLV + 1,75 * qLkw+Bus + 2,5 * qLkwK) / qKfz (2-5)" |
| 25 | * "fSV = (qLV + 1,9 * qSV) / qKfz (2-6)" |
| 26 | * "tB,j = fSV * f1 * f2 * 1,8" - "qS,j = 3600 / tB,j" |
| 27 | * und S. 68 zur Herkunft des bisherigen Programmwerts: |
| 28 | * "Im HCM 2010 (TRB, 2010) wurde an LSA ein konstanter Pkw-Gleichwert von |
| 29 | * 2,00 verwendet." |
| 30 | * Der Altstand rechnete `qS = 2000 / (1 + pSV)` - genau dieser HCM-Wert, unter |
| 31 | * der HBS-Quelle gefuehrt. |
| 32 | * |
| 33 | * E2 (waehlbar, Vorgabe unveraendert) - Hamburger LSA-Leitfaden, Anlage A: |
| 34 | * "geradeausfahrende Kfz (schnell): vr 10 m/s, tue 3 s, lFZ 6 m" |
| 35 | * "abbiegende Kfz (langsam): vr 7 m/s, tue 2 s, lFZ 6 m" |
| 36 | * Die 60-km/h-Spalte liest bei s0 = 14 m den Wert 5,0 s - mit tue = 4 s |
| 37 | * muessten dort 6,0 s stehen. Eine geschwindigkeitsabhaengige Ueberfahrzeit ist |
| 38 | * damit arithmetisch ausgeschlossen. Der Wortlaut der RiLSA zu den Faellen 1 |
| 39 | * und 2 bleibt oeffentlich unzugaenglich; deshalb waehlbar, nicht umgestellt. |
| 40 | * |
| 41 | * E1 (waehlbar, Vorgabe AUS) - eine einzige Quellenlinie fuer "5 m/s bei |
| 42 | * Kurvenradius unter 10 m". Aus dem Regelwerk woertlich belegt ist nur die |
| 43 | * qualitative Radfahrerregel (Fall 5, verdeckte Textschicht des |
| 44 | * FGSV-Aenderungsblatts): "Sie ist zu verringern, wenn Radfahrer vor oder der |
| 45 | * Radfahrerfurt sehr enge Boegen durchfahren." |
| 46 | * |
| 47 | * E5 (Beleglage) - die drei QSV-Tafeln sind zweifach herausgeberseitig belegt |
| 48 | * (FGSV-Einfuehrungskolloquium 2015, Folie "Qualitaetskriterien"; BASt V 400 |
| 49 | * Tab. 2-2). |
| 50 | */ |
| 51 | |
| 52 | const DATUM = new Date('2026-01-01T00:00:00Z'); |
| 53 | |
| 54 | // --- E6: Schwerverkehrsfaktor ---------------------------------------------- |
| 55 | |
| 56 | describe('E6 - Schwerverkehr nach HBS 2015 statt Pkw-Gleichwert 2,0', () => { |
| 57 | it('haelt die belegten Pkw-Gleichwerte fest', () => { |
| 58 | // Die drei Zahlen aus V 400, S. 68. Sie stehen als benannte Konstanten und |
| 59 | // nicht mehr hart in plan/signalPlan.ts. |
| 60 | expect(HBS_PKW_GLEICHWERTE.lkwUndBus).toBe(1.75); |
| 61 | expect(HBS_PKW_GLEICHWERTE.lkwMitAnhaenger).toBe(2.5); |
| 62 | expect(HBS_PKW_GLEICHWERTE.schwerverkehrPauschal).toBe(1.9); |
| 63 | }); |
| 64 | |
| 65 | it('rechnet Gl. 2-6 als fSV = 1 + 0,90 * pSV', () => { |
| 66 | expect(schwerverkehrsfaktor(0)).toBe(1); |
| 67 | expect(schwerverkehrsfaktor(0.25)).toBeCloseTo(1.225, 12); |
| 68 | expect(schwerverkehrsfaktor(1)).toBeCloseTo(1.9, 12); |
| 69 | }); |
| 70 | |
| 71 | it('trifft die Belegzahlen der Saettigungsverkehrsstaerke', () => { |
| 72 | // Standardbedingungen, reiner Pkw-Verkehr: 1,8 s je Kfz -> 2000 Kfz/h. |
| 73 | const grund = RILSA_DEFAULTS.capacity.saturationFlow; |
| 74 | expect(grund).toBe(2000); |
| 75 | expect(grund / schwerverkehrsfaktor(0)).toBe(2000); |
| 76 | // 25 % Schwerverkehr: 2000 / 1,225 = 1632,7 Kfz/h. |
| 77 | expect(grund / schwerverkehrsfaktor(0.25)).toBeCloseTo(1632.653, 3); |
| 78 | }); |
| 79 | |
| 80 | it('bestaetigt Gl. 2-6 aus Gl. 2-5 fuer den in V 400 genannten Lastzuganteil', () => { |
| 81 | /* |
| 82 | * V 400 zu Bild 2-1 d): "Bei einem Anteil der Lkw mit Anhaenger und |
| 83 | * Sattel-Kfz (Fahrzeugklasse LkwK) am Schwerverkehr von 20 % ergibt sich |
| 84 | * der Funktionsverlauf der Gleichung (2-6)." |
| 85 | * |
| 86 | * Nachgerechnet: mit 20 % LkwK am Schwerverkehr ist |
| 87 | * fSV = 1 + 0,75*0,8*p + 1,50*0,2*p = 1 + 0,9*p - Gl. 2-5 und Gl. 2-6 |
| 88 | * fallen dort exakt zusammen. Das ist die Gegenprobe darauf, dass beide |
| 89 | * Gleichungen richtig umgesetzt sind: Ein Vorzeichen- oder |
| 90 | * Bezugsgroessenfehler in einer der beiden wuerde sie auseinanderfallen |
| 91 | * lassen. |
| 92 | */ |
| 93 | for (const pSV of [0, 0.05, 0.1, 0.2, 0.35, 0.5]) { |
| 94 | const ausAufteilung = schwerverkehrsfaktor(pSV, { |
| 95 | lkwUndBus: 0.8 * pSV, |
| 96 | lkwMitAnhaenger: 0.2 * pSV, |
| 97 | }); |
| 98 | expect(ausAufteilung, `pSV ${pSV}`).toBeCloseTo(schwerverkehrsfaktor(pSV), 12); |
| 99 | } |
| 100 | }); |
| 101 | |
| 102 | it('laesst den Schwerverkehr nicht groesser werden als den Verkehr', () => { |
| 103 | // Unmoegliche Aufteilung: 80 % Lkw + 80 % Lastzuege. Ohne Begrenzung |
| 104 | // ergaebe sich fSV = 2,8 - eine Saettigungsverkehrsstaerke von 714 Kfz/h, |
| 105 | // die kein Fahrstreifen hat. Gekuerzt wird der leichtere Anteil. |
| 106 | expect(schwerverkehrsfaktor(0, { lkwUndBus: 0.8, lkwMitAnhaenger: 0.8 })).toBeCloseTo( |
| 107 | 1 + 0.75 * 0.2 + 1.5 * 0.8, |
| 108 | 12, |
| 109 | ); |
| 110 | expect(schwerverkehrsfaktor(0, { lkwUndBus: 1, lkwMitAnhaenger: 1 })).toBeCloseTo(2.5, 12); |
| 111 | }); |
| 112 | |
| 113 | it('rechnet den Lastzugfall mit Gl. 2-5 deutlich unguenstiger', () => { |
| 114 | // 20 % Schwerverkehr, davon alles Lastzuege: fSV = 1 + 1,50*0,20 = 1,300. |
| 115 | const nurLastzuege = schwerverkehrsfaktor(0.2, { lkwUndBus: 0, lkwMitAnhaenger: 0.2 }); |
| 116 | expect(nurLastzuege).toBeCloseTo(1.3, 12); |
| 117 | expect(2000 / nurLastzuege).toBeCloseTo(1538.46, 2); |
| 118 | // Der Altstand kam hier auf 2000/1,2 = 1667 Kfz/h, also 8,3 % zu gross - |
| 119 | // die eigentliche unsichere Richtung, die Gl. 2-5 abfaengt. |
| 120 | expect(2000 / 1.2 / (2000 / nurLastzuege)).toBeCloseTo(1.083, 3); |
| 121 | }); |
| 122 | |
| 123 | it('nennt die Fehlerrichtung gegenueber dem Altstand ehrlich', () => { |
| 124 | /* |
| 125 | * MUTATIONSNACHWEIS zugleich: Der Altstand rechnete 2000/(1+pSV). Diese |
| 126 | * Erwartungen schlagen gegen ihn fehl, weil er bei jedem pSV > 0 einen |
| 127 | * anderen Wert liefert. |
| 128 | */ |
| 129 | for (const pSV of [0.05, 0.1, 0.2, 0.3]) { |
| 130 | const neu = 2000 / schwerverkehrsfaktor(pSV); |
| 131 | const alt = 2000 / (1 + pSV); |
| 132 | // Weniger konservativ: die Kapazitaet faellt groesser aus als bisher. |
| 133 | expect(neu, `pSV ${pSV}`).toBeGreaterThan(alt); |
| 134 | // Und zwar um hoechstens 2,3 % - die Zahl, die in die Unterlagen gehoert. |
| 135 | expect(neu / alt).toBeLessThanOrEqual(1.024); |
| 136 | } |
| 137 | expect(2000 / schwerverkehrsfaktor(0.3) / (2000 / 1.3)).toBeCloseTo(1.0236, 4); |
| 138 | }); |
| 139 | |
| 140 | it('setzt den Faktor im Signalzeitenplan ein - nicht mehr das feste 2,0', () => { |
| 141 | const roh = createStandardIntersectionProject('Schwerverkehr', DATUM); |
| 142 | const k1 = roh.signalGroups.find((g) => g.name === 'K1')!; |
| 143 | expect(k1.movement).toBe('geradeaus'); |
| 144 | expect(k1.lanes).toBe(1); |
| 145 | |
| 146 | const project: Project = { |
| 147 | ...roh, |
| 148 | demands: [{ signalGroupId: k1.id, volume: 600, heavyVehicleShare: 0.25 }], |
| 149 | }; |
| 150 | const plan = buildSignalPlan(project); |
| 151 | const gruppe = plan.groups.find((g) => g.groupId === k1.id); |
| 152 | expect(gruppe?.capacity).not.toBeNull(); |
| 153 | // 2000 / 1,225 = 1632,65 - der Altstand lieferte hier 2000/1,25 = 1600. |
| 154 | expect(gruppe!.capacity!.saturationFlow).toBeCloseTo(1632.653, 3); |
| 155 | expect(gruppe!.capacity!.saturationFlow).not.toBeCloseTo(1600, 1); |
| 156 | }); |
| 157 | |
| 158 | it('fuehrt Gl. 2-5 nicht als wirkungslosen Kennwert in der Vorgabenverwaltung', () => { |
| 159 | // Befund C20: keine Zahl unter der Ueberschrift "Vorgaben", die in kein |
| 160 | // Ergebnis eingeht. Die Pkw-Gleichwerte sind deshalb Konstanten mit Beleg |
| 161 | // und kein Vorgabenfeld. |
| 162 | const schluessel = settingFields().map((f) => String(f.key)); |
| 163 | for (const verboten of ['pkwGleichwertLkw', 'pkwGleichwertLastzug', 'schwerverkehrsfaktor']) { |
| 164 | expect(schluessel).not.toContain(verboten); |
| 165 | } |
| 166 | }); |
| 167 | |
| 168 | it('sagt im Fundstellenverzeichnis, welche Gleichung gerechnet wird', () => { |
| 169 | const hinweis = QUELLEN['hbs-kapazitaet'].hinweis ?? ''; |
| 170 | expect(hinweis).toContain('1,75'); |
| 171 | expect(hinweis).toContain('2,5'); |
| 172 | expect(hinweis).toContain('1,9'); |
| 173 | // Die falsche Zuschreibung ist benannt, nicht spurlos verschwunden. |
| 174 | expect(hinweis).toContain('HCM 2010'); |
| 175 | // Und die Grenzen der Rechnung stehen dabei. |
| 176 | expect(hinweis).toContain('Standardbedingungen'); |
| 177 | expect(hinweis).toContain('Mischfahrstreifen'); |
| 178 | }); |
| 179 | }); |
| 180 | |
| 181 | // --- E2: Ueberfahrzeit-Ansatz ---------------------------------------------- |
| 182 | |
| 183 | /** Abbiegender Kfz-Strom, 20 m Raeumweg, ohne Einfahrweg. */ |
| 184 | function abbieger(overrides: Partial<ClearingStream> = {}): ClearingStream { |
| 185 | return { |
| 186 | mode: 'kfz', |
| 187 | movement: 'rechts', |
| 188 | clearingDistance: 20, |
| 189 | vehicleClass: 'pkw', |
| 190 | vZul: 70, |
| 191 | ...overrides, |
| 192 | }; |
| 193 | } |
| 194 | |
| 195 | const OHNE_EINFAHRWEG: EnteringStream = { mode: 'kfz', movement: 'geradeaus', enteringDistance: 0 }; |
| 196 | |
| 197 | describe('E2 - Ueberfahrzeit als waehlbarer Ansatz', () => { |
| 198 | it('rechnet ohne Angabe wie bisher mit der Gelbzeit', () => { |
| 199 | // Kein Feld gesetzt: der Stand bis Schema 11. Bei 70 km/h sind das 5 s. |
| 200 | const ohne = computeIntergreen(abbieger(), OHNE_EINFAHRWEG); |
| 201 | const ausdruecklich = computeIntergreen( |
| 202 | abbieger({ ueberfahrzeitAnsatz: 'gelbzeit' }), |
| 203 | OHNE_EINFAHRWEG, |
| 204 | ); |
| 205 | expect(ohne.crossingTime).toBe(5); |
| 206 | expect(ausdruecklich.crossingTime).toBe(5); |
| 207 | // tue 5 s + (20 + 6)/7 = 8,714 -> 9 s |
| 208 | expect(ohne.intergreen).toBe(9); |
| 209 | expect(ohne.raw).toBeCloseTo(8.714, 3); |
| 210 | }); |
| 211 | |
| 212 | it('rechnet bei "fest" mit 3 s geradeaus und 2 s abbiegend', () => { |
| 213 | const abbiegend = computeIntergreen(abbieger({ ueberfahrzeitAnsatz: 'fest' }), OHNE_EINFAHRWEG); |
| 214 | expect(abbiegend.crossingTime).toBe(2); |
| 215 | // tue 2 s + 26/7 = 5,714 -> 6 s, also 3 s kuerzer als mit Gelbzeitkopplung. |
| 216 | expect(abbiegend.intergreen).toBe(6); |
| 217 | |
| 218 | const geradeaus = computeIntergreen( |
| 219 | abbieger({ movement: 'geradeaus', ueberfahrzeitAnsatz: 'fest' }), |
| 220 | OHNE_EINFAHRWEG, |
| 221 | ); |
| 222 | expect(geradeaus.crossingTime).toBe(3); |
| 223 | // vr geradeaus 10 m/s: 3 + 26/10 = 5,6 -> 6 s |
| 224 | expect(geradeaus.intergreen).toBe(6); |
| 225 | }); |
| 226 | |
| 227 | it('bleibt bei 50 km/h geradeaus in beiden Ansaetzen gleich', () => { |
| 228 | // Dort faellt die Gelbzeit (3 s) mit dem festen Wert zusammen - die Wahl |
| 229 | // aendert nichts, und der Test haelt fest, dass sie es auch nicht soll. |
| 230 | const gemeinsam = { movement: 'geradeaus' as const, vZul: 50 }; |
| 231 | const gelb = computeIntergreen(abbieger(gemeinsam), OHNE_EINFAHRWEG); |
| 232 | const fest = computeIntergreen( |
| 233 | abbieger({ ...gemeinsam, ueberfahrzeitAnsatz: 'fest' }), |
| 234 | OHNE_EINFAHRWEG, |
| 235 | ); |
| 236 | expect(fest.crossingTime).toBe(gelb.crossingTime); |
| 237 | expect(fest.intergreen).toBe(gelb.intergreen); |
| 238 | }); |
| 239 | |
| 240 | it('haelt die Untergrenze tue + tr >= tG + 1 s ein', () => { |
| 241 | /* |
| 242 | * UMGEDREHT MIT 5.27.0. Hier stand das Gegenteil: Die Regel sei "NICHT |
| 243 | * eingebaut", weil sie "als woertliche Quelle nur eine Webseite" habe, |
| 244 | * "die sich im selben Text widerspricht". Sie steht in der RiLSA 2015, |
| 245 | * Abschnitt 2.5.2, unmittelbar vor der Aufzaehlung der sechs Faelle, und |
| 246 | * gilt den Faellen 1 und 2. |
| 247 | * |
| 248 | * Bei 70 km/h ist tG = 5 s, die Untergrenze also 6 s. Mit festem Ansatz |
| 249 | * und 1 m Raeumweg ergibt die Rechnung 2 + 7/7 = 3,0 s; angesetzt werden |
| 250 | * 6 s. Dieselbe Beziehung lieferte bis 5.26.0 3 s. |
| 251 | */ |
| 252 | const kurz = computeIntergreen( |
| 253 | abbieger({ clearingDistance: 1, ueberfahrzeitAnsatz: 'fest' }), |
| 254 | OHNE_EINFAHRWEG, |
| 255 | ); |
| 256 | expect(kurz.intergreen).toBe(6); |
| 257 | expect(kurz.ueberfahrRaeumzeitUntergrenze).toBe(6); |
| 258 | // Die Einzelwerte bleiben, was sie sind - angehoben wird die SUMME. |
| 259 | expect(kurz.crossingTime).toBe(2); |
| 260 | expect(kurz.clearingTime).toBe(1); |
| 261 | const meldung = kurz.notes.find((n) => n.code === 'ueberfahr-raeumzeit-untergrenze'); |
| 262 | expect(meldung?.severity).toBe('hinweis'); |
| 263 | expect(meldung?.message).toContain('2.5.2'); |
| 264 | }); |
| 265 | |
| 266 | it('meldet die Untergrenze nicht, wo sie nicht greift', () => { |
| 267 | // Derselbe Strom mit langem Raeumweg: 2 + 26/7 = 5,71 s liegt unter 6 s |
| 268 | // und wird angehoben - mit 40 m dagegen 2 + 46/7 = 8,57 s, darueber. |
| 269 | const lang = computeIntergreen( |
| 270 | abbieger({ clearingDistance: 40, ueberfahrzeitAnsatz: 'fest' }), |
| 271 | OHNE_EINFAHRWEG, |
| 272 | ); |
| 273 | expect(lang.ueberfahrRaeumzeitUntergrenze).toBeNull(); |
| 274 | expect(lang.intergreen).toBe(9); |
| 275 | expect(lang.notes.some((n) => n.code === 'ueberfahr-raeumzeit-untergrenze')).toBe(false); |
| 276 | }); |
| 277 | |
| 278 | it('laesst Rad, Fuss und OePNV von der Untergrenze unberuehrt', () => { |
| 279 | /* |
| 280 | * Die RiLSA nennt die Bedingung ausdruecklich nur fuer die Faelle 1 und 2 - |
| 281 | * geradeaus fahrende und abbiegende Kraftfahrzeuge. Sie auf die uebrigen |
| 282 | * Verkehrsarten zu uebertragen hiesse, das Regelwerk zu ergaenzen; beim |
| 283 | * OePNV gaebe es das tG dafuer nicht einmal. |
| 284 | */ |
| 285 | const rad = computeIntergreen( |
| 286 | { mode: 'rad', movement: 'geradeaus', clearingDistance: 1, vehicleClass: 'rad', vZul: 70 }, |
| 287 | OHNE_EINFAHRWEG, |
| 288 | ); |
| 289 | expect(rad.ueberfahrRaeumzeitUntergrenze).toBeNull(); |
| 290 | // 1 s Ueberfahrzeit + 1/4 s = 1,25 -> 2 s. Mit Untergrenze waeren es 6 s. |
| 291 | expect(rad.intergreen).toBe(2); |
| 292 | }); |
| 293 | |
| 294 | it('laesst die Ueberfahrzeit auch bei "fest" nicht unterschreiten', () => { |
| 295 | // Die harte Kfz-Schranke gilt in beiden Ansaetzen - sonst waere ein |
| 296 | // Wechsel des Ansatzes der Weg, aus einem Fehler eine Warnung zu machen. |
| 297 | const ergebnis = computeIntergreen( |
| 298 | abbieger({ ueberfahrzeitAnsatz: 'fest', crossingTimeOverride: 0 }), |
| 299 | OHNE_EINFAHRWEG, |
| 300 | ); |
| 301 | expect(ergebnis.crossingTime).toBe(2); |
| 302 | const meldung = ergebnis.notes.find((n) => n.code === 'ueberfahrzeit-unter-gelbzeitschranke'); |
| 303 | expect(meldung?.severity).toBe('fehler'); |
| 304 | // Und sie behauptet nicht mehr, der Wert sei die Gelbzeit. |
| 305 | expect(meldung?.message).toContain('feste'); |
| 306 | expect(meldung?.message).not.toContain('die Gelbzeit von'); |
| 307 | }); |
| 308 | |
| 309 | it('nimmt die Projektwahl in den Plan und meldet sie als Abweichung', () => { |
| 310 | const roh = createStandardIntersectionProject('Ansatz', DATUM); |
| 311 | const gelbzeit = buildSignalPlan(roh); |
| 312 | const fest = buildSignalPlan({ |
| 313 | ...roh, |
| 314 | settings: { ...roh.settings, ueberfahrzeitAnsatz: 'fest' }, |
| 315 | }); |
| 316 | |
| 317 | // Der Musterknotenpunkt faehrt 50 km/h geradeaus; dort sind beide Ansaetze |
| 318 | // gleich. Massgebend ist deshalb, dass die Wahl ueberhaupt ankommt: |
| 319 | const k1 = roh.signalGroups.find((g) => g.name === 'K1')!; |
| 320 | const f2 = roh.signalGroups.find((g) => g.name === 'F2')!; |
| 321 | const schluessel = `${k1.id}|${f2.id}`; |
| 322 | expect(gelbzeit.intergreens.get(schluessel)?.calculation?.crossingTime).toBe(3); |
| 323 | expect(fest.intergreens.get(schluessel)?.calculation?.crossingTime).toBe(3); |
| 324 | |
| 325 | // Bei 70 km/h trennen sich die Wege um 2 s. |
| 326 | const schnell: Project = { |
| 327 | ...roh, |
| 328 | signalGroups: roh.signalGroups.map((g) => (g.id === k1.id ? { ...g, vZul: 70 } : g)), |
| 329 | settings: { ...roh.settings, ueberfahrzeitAnsatz: 'fest' }, |
| 330 | }; |
| 331 | const schnellPlan = buildSignalPlan(schnell); |
| 332 | expect(schnellPlan.intergreens.get(schluessel)?.calculation?.crossingTime).toBe(3); |
| 333 | const schnellGelb = buildSignalPlan({ ...schnell, settings: roh.settings }); |
| 334 | expect(schnellGelb.intergreens.get(schluessel)?.calculation?.crossingTime).toBe(5); |
| 335 | |
| 336 | const bericht = validateProject( |
| 337 | { ...roh, settings: { ...roh.settings, ueberfahrzeitAnsatz: 'fest' } }, |
| 338 | fest, |
| 339 | DATUM, |
| 340 | ); |
| 341 | const befund = bericht.findings.find( |
| 342 | (f) => f.rule === 'zwischenzeiten.ueberfahrzeit-ansatz-fest', |
| 343 | ); |
| 344 | expect(befund?.severity).toBe('warnung'); |
| 345 | expect(befund?.message).toContain('VERKÜRZT'); |
| 346 | expect(befund?.suggestion).toContain('Begründen Sie die Wahl'); |
| 347 | // Die Vorgabe erzeugt keinen Befund. |
| 348 | const ohne = validateProject(roh, gelbzeit, DATUM); |
| 349 | expect(ohne.findings.map((f) => f.rule)).not.toContain( |
| 350 | 'zwischenzeiten.ueberfahrzeit-ansatz-fest', |
| 351 | ); |
| 352 | }); |
| 353 | |
| 354 | it('kennzeichnet die festen Werte als wirkungslos, solange die Gelbzeit gilt', () => { |
| 355 | // Befund C20 in neuer Gestalt: Die beiden Vorgabenfelder gibt es, aber sie |
| 356 | // geben nicht vor zu wirken, wenn der Ansatz sie nicht abruft. |
| 357 | expect( |
| 358 | wirkungslosigkeit('praxis-ueberfahrzeit-fest', 'HBS', { ueberfahrzeitAnsatz: 'gelbzeit' }), |
| 359 | ).not.toBeNull(); |
| 360 | expect( |
| 361 | wirkungslosigkeit('praxis-ueberfahrzeit-fest', 'HBS', { ueberfahrzeitAnsatz: 'fest' }), |
| 362 | ).toBeNull(); |
| 363 | // Ohne Kenntnis des Ansatzes wird NICHTS behauptet - lieber eine |
| 364 | // Begruendung zu viel verlangen als eine Wirkung zu Unrecht bestreiten. |
| 365 | expect(wirkungslosigkeit('praxis-ueberfahrzeit-fest', 'HBS')).toBeNull(); |
| 366 | }); |
| 367 | |
| 368 | /* |
| 369 | * Gegenstueck zum Vorherigen (Fassung 5.5.0, Befund B6): |
| 370 | * Dieselbe Lage, dieselbe Auskunft - bis dahin bekam der eine Kennwert einen |
| 371 | * Vermerk und der andere keinen, ohne dass der Quelltext den Unterschied |
| 372 | * begruendete. |
| 373 | */ |
| 374 | it('kennzeichnet die Raeumgeschwindigkeit bei engem Innenradius als wirkungslos, solange kein Merkmal gesetzt ist', () => { |
| 375 | expect( |
| 376 | wirkungslosigkeit('praxis-enger-radius', 'HBS', { engerRadiusGesetzt: false }), |
| 377 | ).not.toBeNull(); |
| 378 | expect( |
| 379 | wirkungslosigkeit('praxis-enger-radius', 'HBS', { engerRadiusGesetzt: true }), |
| 380 | ).toBeNull(); |
| 381 | expect(wirkungslosigkeit('praxis-enger-radius', 'HBS')).toBeNull(); |
| 382 | }); |
| 383 | |
| 384 | it('haelt die belegten Werte in den Vorgaben und laesst sie anpassen', () => { |
| 385 | expect(RILSA_DEFAULTS.crossingTime.kfzGeradeaus).toBe(3); |
| 386 | expect(RILSA_DEFAULTS.crossingTime.kfzAbbiegend).toBe(2); |
| 387 | |
| 388 | const { defaults } = resolveRilsaSettings({ crossingTimeKfzAbbiegend: 2.5 }); |
| 389 | expect(defaults.crossingTime.kfzAbbiegend).toBe(2.5); |
| 390 | const ergebnis = computeIntergreen( |
| 391 | abbieger({ ueberfahrzeitAnsatz: 'fest' }), |
| 392 | OHNE_EINFAHRWEG, |
| 393 | defaults, |
| 394 | ); |
| 395 | expect(ergebnis.crossingTime).toBe(2.5); |
| 396 | }); |
| 397 | }); |
| 398 | |
| 399 | // --- E1: enger Innenradius -------------------------------------------------- |
| 400 | |
| 401 | describe('E1 - enger Innenradius als Merkmal der Konfliktbeziehung', () => { |
| 402 | it('raeumt mit 5,0 m/s statt 7,0 m/s und verlaengert die Zwischenzeit', () => { |
| 403 | const ohne = computeIntergreen(abbieger(), OHNE_EINFAHRWEG); |
| 404 | const mit = computeIntergreen(abbieger({ engerRadius: true }), OHNE_EINFAHRWEG); |
| 405 | |
| 406 | expect(ohne.clearingSpeed).toBe(7); |
| 407 | expect(mit.clearingSpeed).toBe(5); |
| 408 | // 5 s + 26/5 = 10,2 -> 11 s gegen 9 s: die sichere Richtung. |
| 409 | expect(mit.intergreen).toBe(11); |
| 410 | expect(mit.intergreen).toBeGreaterThan(ohne.intergreen); |
| 411 | }); |
| 412 | |
| 413 | it('wirkt nur beim Abbiegen', () => { |
| 414 | const geradeaus = computeIntergreen( |
| 415 | abbieger({ movement: 'geradeaus', engerRadius: true }), |
| 416 | OHNE_EINFAHRWEG, |
| 417 | ); |
| 418 | expect(geradeaus.clearingSpeed).toBe(RILSA_DEFAULTS.clearingSpeed.kfzStraight); |
| 419 | }); |
| 420 | |
| 421 | it('ist ohne Angabe aus - Altdateien rechnen unveraendert', () => { |
| 422 | expect(RILSA_DEFAULTS.clearingSpeed.kfzTurningEngerRadius).toBe(5); |
| 423 | const ohneFeld = computeIntergreen(abbieger(), OHNE_EINFAHRWEG); |
| 424 | const ausdruecklichAus = computeIntergreen(abbieger({ engerRadius: false }), OHNE_EINFAHRWEG); |
| 425 | expect(ohneFeld.clearingSpeed).toBe(7); |
| 426 | expect(ausdruecklichAus.clearingSpeed).toBe(7); |
| 427 | }); |
| 428 | |
| 429 | it('meldet das gesetzte Merkmal als begruendungspflichtige Abweichung', () => { |
| 430 | const roh = createStandardIntersectionProject('Radius', DATUM); |
| 431 | const k1 = roh.signalGroups.find((g) => g.name === 'K1')!; |
| 432 | const f2 = roh.signalGroups.find((g) => g.name === 'F2')!; |
| 433 | const project: Project = { |
| 434 | ...roh, |
| 435 | // K1 biegt hier rechts ab und quert dabei die Furt F2. |
| 436 | signalGroups: roh.signalGroups.map((g) => |
| 437 | g.id === k1.id ? { ...g, movement: 'rechts' } : g, |
| 438 | ), |
| 439 | conflicts: roh.conflicts.map((c) => |
| 440 | c.fromId === k1.id && c.toId === f2.id ? { ...c, engerRadius: true } : c, |
| 441 | ), |
| 442 | }; |
| 443 | const bericht = validateProject(project, buildSignalPlan(project), DATUM); |
| 444 | const befund = bericht.findings.find((f) => f.rule === 'zwischenzeiten.enger-radius'); |
| 445 | expect(befund?.severity).toBe('hinweis'); |
| 446 | // Dieselbe Schreibweise wie in Ansicht und Ausdruck (ansaetze.ts): "5,0 m/s" |
| 447 | // und nicht "5 m/s" - eine Geschwindigkeit, die je Ausgabe anders aussieht, |
| 448 | // liest sich wie zwei verschiedene Werte. |
| 449 | expect(befund?.message).toContain('5,0 m/s'); |
| 450 | expect(befund?.message).toContain('7,0 m/s'); |
| 451 | expect(befund?.message).toContain('länger'); |
| 452 | expect(befund?.message).toContain('RiLSA 2015'); |
| 453 | // Seit 5.27.0 die RiLSA und nicht mehr "Praxisansatz": Abschnitt 2.5.2, |
| 454 | // Fall 2 nennt die 5 m/s und die Radiusschwelle. |
| 455 | expect(befund?.reference).toContain('RiLSA 2015'); |
| 456 | expect(befund?.reference).not.toContain('Praxisansatz'); |
| 457 | }); |
| 458 | |
| 459 | it('meldet ein Merkmal, das an dieser Beziehung nichts bewirkt', () => { |
| 460 | // Gesetzt an einem geradeausfahrenden Strom: Das Kaestchen steht an, die |
| 461 | // Zwischenzeit aendert sich nicht. Schweigen waere hier die stille |
| 462 | // Ersetzung, gegen die die Befunde C7 bis C12 angetreten sind. |
| 463 | const roh = createStandardIntersectionProject('Radius ohne Wirkung', DATUM); |
| 464 | const k1 = roh.signalGroups.find((g) => g.name === 'K1')!; |
| 465 | const f2 = roh.signalGroups.find((g) => g.name === 'F2')!; |
| 466 | const project: Project = { |
| 467 | ...roh, |
| 468 | conflicts: roh.conflicts.map((c) => |
| 469 | c.fromId === k1.id && c.toId === f2.id ? { ...c, engerRadius: true } : c, |
| 470 | ), |
| 471 | }; |
| 472 | const bericht = validateProject(project, buildSignalPlan(project), DATUM); |
| 473 | const befund = bericht.findings.find( |
| 474 | (f) => f.rule === 'zwischenzeiten.enger-radius-ohne-wirkung', |
| 475 | ); |
| 476 | expect(befund?.severity).toBe('warnung'); |
| 477 | expect(befund?.message).toContain('geht'); |
| 478 | }); |
| 479 | |
| 480 | it('laesst den Wert nicht ueber die Raeumgeschwindigkeit des Regelfalls steigen', () => { |
| 481 | // Ein "enger Innenradius", der schneller raeumt als der Regelabbieger, |
| 482 | // waere eine Verkuerzung durch die Hintertuer. |
| 483 | const feld = settingFields().find((f) => f.key === 'clearingSpeedKfzTurningEngerRadius'); |
| 484 | expect(feld?.max).toBe(RILSA_DEFAULTS.clearingSpeed.kfzTurning); |
| 485 | const { defaults, notes } = resolveRilsaSettings({ clearingSpeedKfzTurningEngerRadius: 9 }); |
| 486 | expect(defaults.clearingSpeed.kfzTurningEngerRadius).toBe(7); |
| 487 | expect(notes.some((n) => n.code.includes('clearingSpeedKfzTurningEngerRadius'))).toBe(true); |
| 488 | }); |
| 489 | }); |
| 490 | |
| 491 | // --- Schemastand 12 --------------------------------------------------------- |
| 492 | |
| 493 | describe('Schemastand 12 - Rundreise und Altdateien', () => { |
| 494 | it('steht inzwischen auf 13 - die Felder aus Schema 12 bleiben', () => { |
| 495 | // Die Felder engerRadius und ueberfahrzeitAnsatz kamen mit |
| 496 | // Schema 12. Mit dem Lastzuganteil ist die Zahl auf 13 gestiegen |
| 497 | // (TrafficDemand.lastzugAnteil); geprueft wird hier weiter, dass die |
| 498 | // Angaben aus 12 Speichern und Laden unveraendert ueberstehen. |
| 499 | expect(CURRENT_SCHEMA_VERSION).toBe(19); |
| 500 | }); |
| 501 | |
| 502 | it('traegt beide neuen Angaben durch Speichern und Laden', () => { |
| 503 | const roh = createStandardIntersectionProject('Rundreise', DATUM); |
| 504 | const erster = roh.conflicts[0]!; |
| 505 | const project: Project = { |
| 506 | ...roh, |
| 507 | conflicts: roh.conflicts.map((c) => (c.id === erster.id ? { ...c, engerRadius: true } : c)), |
| 508 | settings: { ...roh.settings, ueberfahrzeitAnsatz: 'fest' }, |
| 509 | }; |
| 510 | |
| 511 | const geladen = loadProject(JSON.parse(JSON.stringify(project)), DATUM); |
| 512 | expect(geladen.issues).toEqual([]); |
| 513 | expect(geladen.project.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); |
| 514 | expect(geladen.project.settings.ueberfahrzeitAnsatz).toBe('fest'); |
| 515 | expect(geladen.project.conflicts.find((c) => c.id === erster.id)?.engerRadius).toBe(true); |
| 516 | // Und die uebrigen Beziehungen bekommen das Feld NICHT hinzugeschrieben. |
| 517 | expect(geladen.project.conflicts.filter((c) => 'engerRadius' in c)).toHaveLength(1); |
| 518 | }); |
| 519 | |
| 520 | it('liest eine Datei aus Schemastand 11 ohne Meldung und ohne Rechenaenderung', () => { |
| 521 | const roh = createStandardIntersectionProject('Altdatei', DATUM); |
| 522 | const alt = JSON.parse(JSON.stringify(roh)) as Record<string, unknown>; |
| 523 | alt['schemaVersion'] = 11; |
| 524 | // Genau der Zustand einer Datei bis Fassung 5.4.0: kein Ansatz, kein |
| 525 | // Radiusmerkmal. |
| 526 | delete (alt['settings'] as Record<string, unknown>)['ueberfahrzeitAnsatz']; |
| 527 | for (const conflict of alt['conflicts'] as Record<string, unknown>[]) { |
| 528 | delete conflict['engerRadius']; |
| 529 | } |
| 530 | |
| 531 | const geladen = loadProject(alt, DATUM); |
| 532 | expect(geladen.issues).toEqual([]); |
| 533 | expect(geladen.project.settings.ueberfahrzeitAnsatz).toBe('gelbzeit'); |
| 534 | expect(geladen.project.conflicts.every((c) => c.engerRadius === undefined)).toBe(true); |
| 535 | |
| 536 | // Und die Zwischenzeiten sind dieselben wie vor der Umstellung. |
| 537 | const vorher = buildSignalPlan(roh); |
| 538 | const nachher = buildSignalPlan(geladen.project); |
| 539 | for (const [schluessel, wert] of vorher.intergreens) { |
| 540 | expect(nachher.intergreens.get(schluessel)?.value, schluessel).toBe(wert.value); |
| 541 | } |
| 542 | const bericht = validateProject(geladen.project, nachher, DATUM); |
| 543 | for (const regel of [ |
| 544 | 'zwischenzeiten.ueberfahrzeit-ansatz-fest', |
| 545 | 'zwischenzeiten.enger-radius', |
| 546 | 'zwischenzeiten.enger-radius-ohne-wirkung', |
| 547 | ]) { |
| 548 | expect(bericht.findings.map((f) => f.rule)).not.toContain(regel); |
| 549 | } |
| 550 | }); |
| 551 | |
| 552 | it('faellt bei unlesbarem Ansatz auf die konservative Seite zurueck', () => { |
| 553 | const roh = createStandardIntersectionProject('Unlesbar', DATUM); |
| 554 | const kaputt = JSON.parse(JSON.stringify(roh)) as Record<string, unknown>; |
| 555 | (kaputt['settings'] as Record<string, unknown>)['ueberfahrzeitAnsatz'] = 'irgendwas'; |
| 556 | |
| 557 | const geladen = loadProject(kaputt, DATUM); |
| 558 | expect(geladen.project.settings.ueberfahrzeitAnsatz).toBe('gelbzeit'); |
| 559 | // Nicht stillschweigend: Der Bericht der Datei sagt, was ersetzt wurde. |
| 560 | expect(geladen.issues.some((i) => i.path === 'settings.ueberfahrzeitAnsatz')).toBe(true); |
| 561 | }); |
| 562 | }); |
| 563 | |
| 564 | // --- E5 und E7: Beleglage --------------------------------------------------- |
| 565 | |
| 566 | describe('E5 und E7 - Pruefstaende nach der neuen Beleglage', () => { |
| 567 | it('fuehrt die Qualitaetsstufen als gesichert und sagt, worauf das beruht', () => { |
| 568 | const quelle = QUELLEN['hbs-qualitaetsstufen']; |
| 569 | expect(quelle.pruefstand).toBe('gesichert'); |
| 570 | const hinweis = quelle.hinweis ?? ''; |
| 571 | // Zwei Herausgeberquellen, zehn Jahre auseinander. |
| 572 | expect(hinweis).toContain('FGSV-Einführungskolloquium'); |
| 573 | expect(hinweis).toContain('V 400'); |
| 574 | // Alle drei Tafeln. |
| 575 | expect(hinweis).toContain('20/35/50/70'); |
| 576 | expect(hinweis).toContain('5/15/25/40/60'); |
| 577 | expect(hinweis).toContain('30/40/55/70/85'); |
| 578 | // Und die Einschraenkung: zwei Wiedergaben EINES Originals. |
| 579 | expect(hinweis).toContain('Wiedergaben eines Originals'); |
| 580 | // Die Tabelle D-4 des BASt-QM-Leitfadens ist ausdruecklich ausgeschlossen. |
| 581 | expect(hinweis).toContain('HBS 2001'); |
| 582 | }); |
| 583 | |
| 584 | it('nennt die belegte Ermittlung der maximalen Wartezeit', () => { |
| 585 | // V 400 unmittelbar unter Tab. 2-2 - das stuetzt die Umsetzung aus B4. |
| 586 | expect(QUELLEN['hbs-qualitaetsstufen'].hinweis).toContain('Sperrzeit'); |
| 587 | }); |
| 588 | |
| 589 | it('traegt die 95-Prozent-Regel des FGSV-Dialogs am Abbiegerfaktor', () => { |
| 590 | expect(QUELLEN['hbs-abminderung'].hinweis).toContain('x ≥ 95 %'); |
| 591 | expect(QUELLEN['hbs-abminderung'].hinweis).toContain('0 Kfz/h'); |
| 592 | }); |
| 593 | |
| 594 | it('sagt bei den Rad- und Fusswerten, was woertlich belegt ist', () => { |
| 595 | const hinweis = QUELLEN['rilsa-zwischenzeiten'].hinweis ?? ''; |
| 596 | expect(hinweis).toContain('vr = 4 m/s'); |
| 597 | expect(hinweis).toContain('tü = 0 s'); |
| 598 | expect(hinweis).toContain('vr = 1,2 m/s'); |
| 599 | // Und dass dieses Programm strenger ist als das Zitat. |
| 600 | expect(hinweis).toContain('1,5 m/s'); |
| 601 | expect(hinweis).toContain('1,2 m/s'); |
| 602 | }); |
| 603 | |
| 604 | it('nimmt den RiLSA-Bezug vom empfohlenen Umlaufzeitbereich', () => { |
| 605 | const hinweis = QUELLEN['rilsa-umlaufzeit-regelbereich'].hinweis ?? ''; |
| 606 | expect(QUELLEN['rilsa-umlaufzeit-regelbereich'].pruefstand).toBe('abzugleichen'); |
| 607 | expect(hinweis).toContain('50 bis 75'); |
| 608 | expect(hinweis).toContain('an keine weiteren Vorgaben gebunden'); |
| 609 | }); |
| 610 | }); |