lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain oepnvZwischenzeit.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { computeIntergreen } from '@/domain/rilsa/intergreen'; |
| 3 | import { |
| 4 | OEPNV_ANFAHRBESCHLEUNIGUNG, |
| 5 | RILSA_DEFAULTS, |
| 6 | SAFETY_FLOORS, |
| 7 | fahrzeugartBeschriftung, |
| 8 | type RilsaDefaults, |
| 9 | } from '@/domain/rilsa/constants'; |
| 10 | import { resolveRilsaSettings, type RilsaOverrides } from '@/domain/rilsa/settings'; |
| 11 | import type { ClearingStream, EnteringStream } from '@/domain/rilsa/types'; |
| 12 | import { parseProject } from '@/domain/model/schema'; |
| 13 | import { |
| 14 | createConflict, |
| 15 | createSignalGroup, |
| 16 | createStandardIntersectionProject, |
| 17 | } from '@/domain/model/factory'; |
| 18 | import { CURRENT_SCHEMA_VERSION, type Project } from '@/domain/model/project'; |
| 19 | |
| 20 | /** |
| 21 | * Befund A2: OePNV-Zwischenzeitfall nach RiLSA 2015. |
| 22 | * |
| 23 | * Primaer belegt durch das FGSV-Aenderungsblatt vom 29.07.2015, das die |
| 24 | * Originalseiten 23-25 der RiLSA 2015 wiedergibt: |
| 25 | * |
| 26 | * Fall 3 (ohne Halt): "Die Ueberfahrzeit betraegt bei Strassenbahnen: |
| 27 | * tue = 3 s bei Vmax <= 30 km/h, tue = 5 s bei 30 km/h < Vmax <= 50 km/h, |
| 28 | * tue = 7 s bei 50 km/h < Vmax <= 70 km/h." - "vr = Vmax/3,6" - |
| 29 | * "lFZ = 15 m bei Strassenbahnen, lFZ = 6 m bei Linienbussen". |
| 30 | * |
| 31 | * Fall 4 (bei Halt vor dem Knotenpunkt): "Ueberfahrzeit: tue = 0 s"; |
| 32 | * Anfahrbeschleunigung Strassenbahn a = 1,0 m/s2, Linienbus a = 1,2 m/s2; |
| 33 | * tue + tr = Wurzel aus (2*(s0+lFZ)/a), bei Erreichen von Vmax Restweg mit |
| 34 | * Vmax/3,6. |
| 35 | * |
| 36 | * Der Altbestand rechnete fest mit Ueberfahrzeit 0 s und vr = 10 m/s - die |
| 37 | * Zwischenzeit einer Bahn ohne Halt (Vmax 50, Raeumweg + Laenge 40 m) war |
| 38 | * damit rund 4 s zu kurz. |
| 39 | */ |
| 40 | |
| 41 | const FIXED_DATE = new Date('2026-01-01T00:00:00Z'); |
| 42 | |
| 43 | function bahnRaeumend(extra: Partial<ClearingStream> = {}): ClearingStream { |
| 44 | return { |
| 45 | mode: 'oepnv', |
| 46 | movement: 'geradeaus', |
| 47 | // 25 m Grundraeumweg + 15 m Fahrzeuglaenge = 40 m Raeumweg. |
| 48 | clearingDistance: 25, |
| 49 | vehicleClass: 'strassenbahn', |
| 50 | vZul: 50, |
| 51 | ...extra, |
| 52 | }; |
| 53 | } |
| 54 | |
| 55 | function kfzEinfahrend(extra: Partial<EnteringStream> = {}): EnteringStream { |
| 56 | return { mode: 'kfz', movement: 'geradeaus', enteringDistance: 8, ...extra }; |
| 57 | } |
| 58 | |
| 59 | describe('OePNV-Ueberfahrzeit: Vmax-Staffel (RiLSA Fall 3)', () => { |
| 60 | it('staffelt 3/5/7 s an den Grenzen 30, 50 und 70 km/h', () => { |
| 61 | const tue = (vZul: number): number => |
| 62 | computeIntergreen(bahnRaeumend({ vZul }), kfzEinfahrend()).crossingTime; |
| 63 | |
| 64 | expect(tue(20)).toBe(3); |
| 65 | expect(tue(30)).toBe(3); // "tue = 3 s bei Vmax <= 30 km/h" - einschliesslich |
| 66 | expect(tue(31)).toBe(5); // "30 km/h < Vmax" |
| 67 | expect(tue(50)).toBe(5); |
| 68 | expect(tue(51)).toBe(7); |
| 69 | expect(tue(70)).toBe(7); |
| 70 | }); |
| 71 | |
| 72 | it('rechnet das Zahlenbeispiel zu Befund A2: Bahn ohne Halt, Vmax 50, 40 m Raeumweg', () => { |
| 73 | // Befund A2: vor der Fassung 5.4.0 rechnete das Programm 0 + 40/10 = 4,0 s, |
| 74 | // die RiLSA 5 + 40/13,9 = 7,9 s (vor Einfahrzeitabzug, deshalb Einfahrweg 0). |
| 75 | const result = computeIntergreen(bahnRaeumend(), kfzEinfahrend({ enteringDistance: 0 })); |
| 76 | |
| 77 | expect(result.vehicleLength).toBe(15); |
| 78 | expect(result.clearingPath).toBe(40); |
| 79 | expect(result.crossingTime).toBe(5); |
| 80 | expect(result.clearingSpeed).toBeCloseTo(50 / 3.6, 6); |
| 81 | expect(result.clearingTime).toBeCloseTo(2.88, 3); |
| 82 | expect(result.raw).toBeCloseTo(7.88, 3); |
| 83 | expect(result.intergreen).toBe(8); |
| 84 | expect(result.raeumansatz).toBe('geschwindigkeit'); |
| 85 | expect(result.anfahrbeschleunigung).toBeNull(); |
| 86 | }); |
| 87 | }); |
| 88 | |
| 89 | describe('OePNV-Raeumgeschwindigkeit: Vmax/3,6 mit Schranken', () => { |
| 90 | it('raeumt mit Vmax/3,6 - auch oberhalb der alten Kfz-Schranke von 10 m/s', () => { |
| 91 | const result = computeIntergreen(bahnRaeumend({ vZul: 70 }), kfzEinfahrend()); |
| 92 | expect(result.clearingSpeed).toBeCloseTo(70 / 3.6, 6); |
| 93 | expect(result.notes.some((n) => n.severity === 'fehler')).toBe(false); |
| 94 | }); |
| 95 | |
| 96 | it('faellt ohne Vmax konservativ auf den Rueckfallwert zurueck und warnt', () => { |
| 97 | const result = computeIntergreen(bahnRaeumend({ vZul: 0 }), kfzEinfahrend()); |
| 98 | |
| 99 | expect(result.clearingSpeed).toBe(RILSA_DEFAULTS.clearingSpeed.oepnv); |
| 100 | expect(result.crossingTime).toBe(7); // oberste Staffelstufe, sichere Seite |
| 101 | expect( |
| 102 | result.notes.some((n) => n.code === 'oepnv-vmax-fehlt' && n.severity === 'warnung'), |
| 103 | ).toBe(true); |
| 104 | }); |
| 105 | |
| 106 | it('nimmt ohne Vmax den KLEINEREN von Rueckfallwert und bisherigem Verhalten', () => { |
| 107 | // Ein niedrigerer konfigurierter Rueckfallwert bleibt stehen ... |
| 108 | const niedrig: RilsaDefaults = { |
| 109 | ...RILSA_DEFAULTS, |
| 110 | clearingSpeed: { ...RILSA_DEFAULTS.clearingSpeed, oepnv: 8 }, |
| 111 | }; |
| 112 | expect( |
| 113 | computeIntergreen(bahnRaeumend({ vZul: 0 }), kfzEinfahrend(), niedrig).clearingSpeed, |
| 114 | ).toBe(8); |
| 115 | |
| 116 | // ... ein hoeherer wird auf die bisherigen 10 m/s gekappt (mit Fehler, |
| 117 | // siehe raeumgeschwindigkeitSchranken.test.ts). |
| 118 | const hoch: RilsaDefaults = { |
| 119 | ...RILSA_DEFAULTS, |
| 120 | clearingSpeed: { ...RILSA_DEFAULTS.clearingSpeed, oepnv: 15 }, |
| 121 | }; |
| 122 | expect(computeIntergreen(bahnRaeumend({ vZul: 0 }), kfzEinfahrend(), hoch).clearingSpeed).toBe( |
| 123 | SAFETY_FLOORS.maxClearingSpeedKfz, |
| 124 | ); |
| 125 | }); |
| 126 | |
| 127 | it('begrenzt bei Vmax ueber 70 km/h auf die OePNV-Obergrenze und warnt', () => { |
| 128 | // VwV-StVO zu Par. 37 Rn. 10: oberhalb 70 km/h sollen LSA nicht betrieben |
| 129 | // werden - die Staffel der RiLSA endet dort aus demselben Grund. |
| 130 | const result = computeIntergreen(bahnRaeumend({ vZul: 100 }), kfzEinfahrend()); |
| 131 | |
| 132 | expect(result.clearingSpeed).toBeCloseTo(SAFETY_FLOORS.maxClearingSpeedOepnv, 6); |
| 133 | expect(result.crossingTime).toBe(7); |
| 134 | expect( |
| 135 | result.notes.some((n) => n.code === 'oepnv-vmax-ueber-70' && n.severity === 'warnung'), |
| 136 | ).toBe(true); |
| 137 | }); |
| 138 | |
| 139 | it('deckelt eine Ueberschreibung gegen die Vmax des Stroms - nicht schneller raeumen als erlaubt faehrt', () => { |
| 140 | // 15 m/s = 54 km/h. Bei Vmax 50 unzulaessig ... |
| 141 | const bei50 = computeIntergreen( |
| 142 | bahnRaeumend({ vZul: 50, clearingSpeedOverride: 15 }), |
| 143 | kfzEinfahrend(), |
| 144 | ); |
| 145 | expect(bei50.clearingSpeed).toBeCloseTo(50 / 3.6, 6); |
| 146 | expect( |
| 147 | bei50.notes.some((n) => n.code === 'raeumgeschwindigkeit-zu-hoch' && n.severity === 'fehler'), |
| 148 | ).toBe(true); |
| 149 | |
| 150 | // ... bei Vmax 70 zulaessig (unterhalb 70/3,6 = 19,4 m/s). |
| 151 | const bei70 = computeIntergreen( |
| 152 | bahnRaeumend({ vZul: 70, clearingSpeedOverride: 15 }), |
| 153 | kfzEinfahrend(), |
| 154 | ); |
| 155 | expect(bei70.clearingSpeed).toBe(15); |
| 156 | expect(bei70.notes.some((n) => n.severity === 'fehler')).toBe(false); |
| 157 | |
| 158 | // Die absolute Obergrenze gilt auch bei Vmax 70. |
| 159 | const zuHoch = computeIntergreen( |
| 160 | bahnRaeumend({ vZul: 70, clearingSpeedOverride: 25 }), |
| 161 | kfzEinfahrend(), |
| 162 | ); |
| 163 | expect(zuHoch.clearingSpeed).toBeCloseTo(SAFETY_FLOORS.maxClearingSpeedOepnv, 6); |
| 164 | expect( |
| 165 | zuHoch.notes.some( |
| 166 | (n) => n.code === 'raeumgeschwindigkeit-zu-hoch' && n.severity === 'fehler', |
| 167 | ), |
| 168 | ).toBe(true); |
| 169 | }); |
| 170 | }); |
| 171 | |
| 172 | describe('OePNV-Haltefall: Anfahransatz (RiLSA Fall 4)', () => { |
| 173 | it('rechnet die Strassenbahn mit tue = 0 s und tr = Wurzel(2*sr/a), a = 1,0 m/s2', () => { |
| 174 | const result = computeIntergreen(bahnRaeumend({ haltVorKnoten: true }), kfzEinfahrend()); |
| 175 | |
| 176 | expect(result.crossingTime).toBe(0); |
| 177 | expect(result.raeumansatz).toBe('anfahren'); |
| 178 | expect(result.anfahrbeschleunigung).toBe(OEPNV_ANFAHRBESCHLEUNIGUNG.strassenbahn); |
| 179 | // sr = 40 m, a = 1,0: Wurzel(2*40/1,0) = 8,944 s. Vmax 50 km/h wird auf |
| 180 | // den 40 m nicht erreicht (Beschleunigungsweg 13,89^2/2 = 96,5 m). |
| 181 | expect(result.clearingTime).toBeCloseTo(Math.sqrt(80), 3); |
| 182 | // Zwischenzeit bleibt aus denselben Groessen nachrechenbar: |
| 183 | // 0 + 8,944 - 8/11,1 = 8,224 -> 9 s. |
| 184 | expect(result.raw).toBeCloseTo(8.944 - 8 / 11.1, 2); |
| 185 | expect(result.intergreen).toBe(9); |
| 186 | }); |
| 187 | |
| 188 | it('rechnet den Linienbus mit a = 1,2 m/s2', () => { |
| 189 | const result = computeIntergreen( |
| 190 | bahnRaeumend({ haltVorKnoten: true, vehicleClass: 'bus' }), |
| 191 | kfzEinfahrend(), |
| 192 | ); |
| 193 | |
| 194 | // Laengenzuschlag Linienbus 6 m (RiLSA-Rechenlaenge, Fall 3 woertlich; |
| 195 | // seit Befund B5 auch der Vorgabewert). sr = 25 + 6 = 31 m; |
| 196 | // Wurzel(2*31/1,2) = 7,188 s. |
| 197 | const srBus = 25 + RILSA_DEFAULTS.vehicleLength.bus; |
| 198 | expect(RILSA_DEFAULTS.vehicleLength.bus).toBe(6); |
| 199 | expect(result.anfahrbeschleunigung).toBe(OEPNV_ANFAHRBESCHLEUNIGUNG.bus); |
| 200 | expect(result.clearingPath).toBe(srBus); |
| 201 | expect(result.clearingTime).toBeCloseTo(Math.sqrt((2 * srBus) / 1.2), 3); |
| 202 | }); |
| 203 | |
| 204 | it('faehrt nach Erreichen von Vmax den Restweg mit Vmax/3,6', () => { |
| 205 | // Vmax 30 km/h = 8,333 m/s; Beschleunigungsweg 8,333^2/(2*1,0) = 34,72 m. |
| 206 | // sr = 85 + 15 = 100 m > 34,72 m -> zweiter Zweig der Formel: |
| 207 | // tr = 8,333/1,0 + (100 - 34,72)/8,333 = 16,167 s. |
| 208 | const result = computeIntergreen( |
| 209 | bahnRaeumend({ haltVorKnoten: true, vZul: 30, clearingDistance: 85 }), |
| 210 | kfzEinfahrend(), |
| 211 | ); |
| 212 | |
| 213 | const v = 30 / 3.6; |
| 214 | const erwartet = v / 1.0 + (100 - (v * v) / 2) / v; |
| 215 | expect(result.clearingTime).toBeCloseTo(erwartet, 3); |
| 216 | // Der Deckel wirkt: Die reine Wurzelformel ergaebe nur 14,14 s. |
| 217 | expect(result.clearingTime).toBeGreaterThan(Math.sqrt(200)); |
| 218 | }); |
| 219 | |
| 220 | it('laesst den Halt bei anderen Verkehrsarten wirkungslos', () => { |
| 221 | const mitHalt = computeIntergreen( |
| 222 | { |
| 223 | mode: 'kfz', |
| 224 | movement: 'geradeaus', |
| 225 | clearingDistance: 22, |
| 226 | vehicleClass: 'pkw', |
| 227 | vZul: 50, |
| 228 | haltVorKnoten: true, |
| 229 | }, |
| 230 | kfzEinfahrend(), |
| 231 | ); |
| 232 | expect(mitHalt.raeumansatz).toBe('geschwindigkeit'); |
| 233 | expect(mitHalt.crossingTime).toBe(3); |
| 234 | }); |
| 235 | }); |
| 236 | |
| 237 | describe('Fahrzeugart Strassenbahn', () => { |
| 238 | it('setzt die RiLSA-Rechenlaenge 15 m an', () => { |
| 239 | expect(RILSA_DEFAULTS.vehicleLength.strassenbahn).toBe(15); |
| 240 | const result = computeIntergreen(bahnRaeumend(), kfzEinfahrend()); |
| 241 | expect(result.vehicleLength).toBe(15); |
| 242 | expect(result.clearingPath).toBe(40); |
| 243 | }); |
| 244 | |
| 245 | it('beschriftet mit echtem Umlaut und dem tatsaechlichen Zuschlag', () => { |
| 246 | expect(fahrzeugartBeschriftung('strassenbahn')).toBe('Straßenbahn (15 m)'); |
| 247 | }); |
| 248 | |
| 249 | it('laesst den Laengenzuschlag nicht unter die Rechenlaenge sinken', () => { |
| 250 | const { defaults, notes } = resolveRilsaSettings({ vehicleLengthStrassenbahn: 10 }); |
| 251 | expect(defaults.vehicleLength.strassenbahn).toBe(SAFETY_FLOORS.minVehicleLengthStrassenbahn); |
| 252 | expect( |
| 253 | notes.some( |
| 254 | (n) => |
| 255 | n.code === 'vorgabe-unterschritten:vehicleLengthStrassenbahn' && n.severity === 'fehler', |
| 256 | ), |
| 257 | ).toBe(true); |
| 258 | }); |
| 259 | |
| 260 | it('nimmt eine begruendete oertliche Verlaengerung an', () => { |
| 261 | const { defaults, deviations } = resolveRilsaSettings({ vehicleLengthStrassenbahn: 30 }); |
| 262 | expect(defaults.vehicleLength.strassenbahn).toBe(30); |
| 263 | expect(deviations.some((d) => d.key === 'vehicleLengthStrassenbahn')).toBe(true); |
| 264 | }); |
| 265 | }); |
| 266 | |
| 267 | describe('Veraltete Vorgabe "Ueberfahrzeit OePNV"', () => { |
| 268 | it('wird gemeldet statt stillschweigend verworfen', () => { |
| 269 | // Dateien bis Fassung 5.3.5 koennen den Schluessel tragen; er hat keine |
| 270 | // Wirkung mehr, weil die Ueberfahrzeit der Vmax-Staffel folgt. |
| 271 | const alt = { crossingTimeOepnv: 2 } as unknown as RilsaOverrides; |
| 272 | const { notes } = resolveRilsaSettings(alt); |
| 273 | expect( |
| 274 | notes.some( |
| 275 | (n) => n.code === 'vorgabe-veraltet:crossingTimeOepnv' && n.severity === 'hinweis', |
| 276 | ), |
| 277 | ).toBe(true); |
| 278 | }); |
| 279 | }); |
| 280 | |
| 281 | describe('Projektdatei: strassenbahn und haltVorKnoten ueberleben den Hin- und Rueckweg', () => { |
| 282 | function projektMitBahn(): Project { |
| 283 | const base = createStandardIntersectionProject('ÖPNV-Rundreise', FIXED_DATE); |
| 284 | const bahn = { ...createSignalGroup({ name: 'B1', mode: 'oepnv', index: 6 }), id: 'sg-bahn' }; |
| 285 | const ersteGruppe = base.signalGroups[0]!; |
| 286 | return { |
| 287 | ...base, |
| 288 | signalGroups: [...base.signalGroups, { ...bahn, vehicleClass: 'strassenbahn', vZul: 50 }], |
| 289 | conflicts: [ |
| 290 | ...base.conflicts, |
| 291 | { |
| 292 | ...createConflict('sg-bahn', ersteGruppe.id), |
| 293 | id: 'cf-bahn', |
| 294 | clearingDistance: 25, |
| 295 | enteringDistance: 8, |
| 296 | haltVorKnoten: true, |
| 297 | }, |
| 298 | ], |
| 299 | }; |
| 300 | } |
| 301 | |
| 302 | it('traegt beide Angaben unveraendert durch Datei und Einlesen', () => { |
| 303 | const original = projektMitBahn(); |
| 304 | const { project, issues } = parseProject(JSON.parse(JSON.stringify(original)), FIXED_DATE); |
| 305 | |
| 306 | // Seit Befund C1 (blindenzusatz) steht das Schema auf 11; hier zaehlt nur, |
| 307 | // dass die Datei den aktuellen Stand traegt. |
| 308 | expect(project.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); |
| 309 | expect(project.signalGroups.find((g) => g.id === 'sg-bahn')?.vehicleClass).toBe('strassenbahn'); |
| 310 | expect(project.conflicts.find((c) => c.id === 'cf-bahn')?.haltVorKnoten).toBe(true); |
| 311 | // Keine Reparaturmeldung: 'strassenbahn' ist eine bekannte Fahrzeugart und |
| 312 | // darf beim Einlesen nicht auf 'pkw' zurueckfallen. |
| 313 | expect(issues.filter((i) => i.severity !== 'hinweis')).toEqual([]); |
| 314 | }); |
| 315 | |
| 316 | it('erfindet fuer Bestandsbeziehungen keinen Haltvermerk', () => { |
| 317 | // Eine Datei ohne das Feld sagt "ohne Halt"; beim Einlesen darf daraus |
| 318 | // keine Angabe werden, die niemand gemacht hat. |
| 319 | const original = projektMitBahn(); |
| 320 | const { project } = parseProject(JSON.parse(JSON.stringify(original)), FIXED_DATE); |
| 321 | const alteBeziehung = project.conflicts.find((c) => c.id !== 'cf-bahn'); |
| 322 | expect(alteBeziehung).toBeDefined(); |
| 323 | expect('haltVorKnoten' in (alteBeziehung as object)).toBe(false); |
| 324 | }); |
| 325 | }); |