lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain verkehrsstaerken.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { computeCycleTime } from '@/domain/rilsa/cycle'; |
| 3 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 4 | import { validateProject } from '@/domain/validation/engine'; |
| 5 | import { |
| 6 | createConflict, |
| 7 | createEmptyProject, |
| 8 | createPhase, |
| 9 | createSignalGroup, |
| 10 | createStandardIntersectionProject, |
| 11 | } from '@/domain/model/factory'; |
| 12 | import type { Project } from '@/domain/model/project'; |
| 13 | |
| 14 | /* |
| 15 | * Befund 32 (Fassung 5.9.0). |
| 16 | * |
| 17 | * Eine Phase ohne erfasste Verkehrsstaerke ging mit dem Saettigungsgrad 0 in |
| 18 | * die Summe Y ein, ohne dass irgendwo stand, dass die Zahl fehlt: Die |
| 19 | * Umlaufzeitermittlung meldete nur die LEERE Liste, und ihr einziger Aufrufer |
| 20 | * uebergab statt einer Luecke die Zahl 0. Die ermittelte Umlaufzeit faellt |
| 21 | * dadurch zu kurz aus - die Anlage erscheint leistungsfaehiger, als sie ist. |
| 22 | * |
| 23 | * Unterschieden werden muessen zwei Faelle, die beide das Gewicht 0 ergeben: |
| 24 | * - "nicht erfasst": Die Phase fuehrt Signalgruppen mit |
| 25 | * Saettigungsverkehrsstaerke (Kfz, OePNV), zu keiner davon ist eine |
| 26 | * Verkehrsstaerke eingetragen. Das ist die Luecke. |
| 27 | * - "kein Verkehr im Sinne der Rechnung": Eine reine Fussgaengerphase traegt |
| 28 | * ueberhaupt keine Saettigungsverkehrsstaerke. Ihr Gewicht ist im Regelfall |
| 29 | * 0, und daran ist nichts zu beanstanden. |
| 30 | */ |
| 31 | |
| 32 | const DATUM = new Date('2026-01-01T00:00:00Z'); |
| 33 | const CODE = 'saettigungsgrade-unvollstaendig'; |
| 34 | |
| 35 | describe('Umlaufzeitermittlung meldet fehlende Verkehrsstaerken', () => { |
| 36 | it('meldet die Phase, deren Verkehrsstaerke nicht erfasst ist', () => { |
| 37 | const ergebnis = computeCycleTime('webster', { |
| 38 | lostTime: 20, |
| 39 | criticalFlowRatios: [0.35, undefined], |
| 40 | }); |
| 41 | |
| 42 | const notiz = ergebnis.notes.find((n) => n.code === CODE); |
| 43 | expect(notiz).toBeDefined(); |
| 44 | expect(notiz?.severity).toBe('warnung'); |
| 45 | // Der Anwender muss wissen, WELCHE Phase gemeint ist - sonst sucht er in |
| 46 | // allen. |
| 47 | expect(notiz?.message).toContain('Phase 2'); |
| 48 | expect(notiz?.message).not.toContain('Phase 1'); |
| 49 | }); |
| 50 | |
| 51 | it('nennt alle betroffenen Phasen in einer einzigen Meldung', () => { |
| 52 | const ergebnis = computeCycleTime('webster', { |
| 53 | lostTime: 20, |
| 54 | criticalFlowRatios: [undefined, 0.3, undefined], |
| 55 | }); |
| 56 | |
| 57 | const notizen = ergebnis.notes.filter((n) => n.code === CODE); |
| 58 | expect(notizen).toHaveLength(1); |
| 59 | expect(notizen[0]?.message).toContain('Phase 1'); |
| 60 | expect(notizen[0]?.message).toContain('Phase 3'); |
| 61 | expect(notizen[0]?.message).not.toContain('Phase 2'); |
| 62 | }); |
| 63 | |
| 64 | it('sagt, in welche Richtung die Luecke wirkt', () => { |
| 65 | // Die gefaehrliche Richtung: Y faellt zu klein aus, die Umlaufzeit damit |
| 66 | // zu kurz. Ohne diesen Satz liest sich die Meldung wie eine Formsache. |
| 67 | const ergebnis = computeCycleTime('webster', { |
| 68 | lostTime: 20, |
| 69 | criticalFlowRatios: [0.35, undefined], |
| 70 | }); |
| 71 | |
| 72 | const notiz = ergebnis.notes.find((n) => n.code === CODE); |
| 73 | expect(notiz?.message).toContain('zu kurz'); |
| 74 | }); |
| 75 | |
| 76 | it('schweigt bei einem erfassten Saettigungsgrad 0', () => { |
| 77 | // 0 heisst "gezaehlt und kein Verkehr" - das ist eine Angabe, keine Luecke. |
| 78 | const ergebnis = computeCycleTime('webster', { |
| 79 | lostTime: 20, |
| 80 | criticalFlowRatios: [0.35, 0], |
| 81 | }); |
| 82 | |
| 83 | expect(ergebnis.notes.some((n) => n.code === CODE)).toBe(false); |
| 84 | }); |
| 85 | |
| 86 | it('rechnet mit der Summe der erfassten Saettigungsgrade weiter', () => { |
| 87 | // Die Luecke wird gemeldet, nicht geschaetzt: Ein Ersatzwert waere eine |
| 88 | // erfundene Verkehrsstaerke im Ausdruck. |
| 89 | const mitLuecke = computeCycleTime('webster', { |
| 90 | lostTime: 20, |
| 91 | criticalFlowRatios: [0.35, undefined], |
| 92 | }); |
| 93 | const ohnePhase = computeCycleTime('webster', { lostTime: 20, criticalFlowRatios: [0.35] }); |
| 94 | |
| 95 | expect(mitLuecke.totalFlowRatio).toBe(ohnePhase.totalFlowRatio); |
| 96 | expect(mitLuecke.cycleTime).toBe(ohnePhase.cycleTime); |
| 97 | }); |
| 98 | }); |
| 99 | |
| 100 | /** Vierarmiger Knotenpunkt, dem die Verkehrsstaerken der Ost/West-Phase fehlen. */ |
| 101 | function ohneVerkehrsstaerkenInPhase2(): Project { |
| 102 | const roh = createStandardIntersectionProject('Lücke', DATUM); |
| 103 | const k3 = roh.signalGroups.find((g) => g.name === 'K3')!; |
| 104 | const k4 = roh.signalGroups.find((g) => g.name === 'K4')!; |
| 105 | return { |
| 106 | ...roh, |
| 107 | demands: roh.demands.filter((d) => d.signalGroupId !== k3.id && d.signalGroupId !== k4.id), |
| 108 | }; |
| 109 | } |
| 110 | |
| 111 | /** Derselbe Knotenpunkt mit einer dritten, reinen Fussgaengerphase. */ |
| 112 | function mitReinerFussgaengerphase(): Project { |
| 113 | const roh = createStandardIntersectionProject('Furt', DATUM); |
| 114 | const f3 = createSignalGroup({ name: 'F3', mode: 'fuss', index: 6 }); |
| 115 | const phase3 = createPhase('Phase 3 - Furt', [f3.id]); |
| 116 | return { |
| 117 | ...roh, |
| 118 | signalGroups: [...roh.signalGroups, f3], |
| 119 | phases: [...roh.phases, phase3], |
| 120 | program: { ...roh.program, phaseOrder: [...roh.program.phaseOrder, phase3.id] }, |
| 121 | }; |
| 122 | } |
| 123 | |
| 124 | describe('Der Planaufbau reicht die Luecke weiter', () => { |
| 125 | it('meldet die Phase ohne erfasste Verkehrsstaerke im Signalzeitenplan', () => { |
| 126 | const plan = buildSignalPlan(ohneVerkehrsstaerkenInPhase2()); |
| 127 | |
| 128 | const notiz = plan.notes.find((n) => n.code === CODE); |
| 129 | expect(notiz).toBeDefined(); |
| 130 | expect(notiz?.message).toContain('Phase 2'); |
| 131 | }); |
| 132 | |
| 133 | it('schweigt, wenn alle Phasen mit Kraftfahrzeugen erfasst sind', () => { |
| 134 | const plan = buildSignalPlan(createStandardIntersectionProject('Vollständig', DATUM)); |
| 135 | |
| 136 | expect(plan.notes.some((n) => n.code === CODE)).toBe(false); |
| 137 | }); |
| 138 | |
| 139 | it('beanstandet eine reine Fussgaengerphase nicht', () => { |
| 140 | // Der Regelfall. Wuerde er gemeldet, stuende an jedem zweiten Plan eine |
| 141 | // Warnung, die nichts bedeutet - und die echten Luecken gingen darin unter. |
| 142 | const plan = buildSignalPlan(mitReinerFussgaengerphase()); |
| 143 | |
| 144 | expect(plan.notes.some((n) => n.code === CODE)).toBe(false); |
| 145 | }); |
| 146 | }); |
| 147 | |
| 148 | /** Einstreifige Verkehrsfuehrung ueber eine Engstelle, ohne Verkehrsstaerken. */ |
| 149 | function engstelleOhneVerkehrsstaerken(): Project { |
| 150 | const basis = createEmptyProject('Arbeitsstelle', DATUM, 'einstreifig'); |
| 151 | const nord = createSignalGroup({ name: 'A1', mode: 'kfz', index: 0 }); |
| 152 | const sued = createSignalGroup({ name: 'A2', mode: 'kfz', index: 1 }); |
| 153 | const phase1 = createPhase('Richtung Nord', [nord.id]); |
| 154 | const phase2 = createPhase('Richtung Süd', [sued.id]); |
| 155 | return { |
| 156 | ...basis, |
| 157 | signalGroups: [nord, sued], |
| 158 | conflicts: [ |
| 159 | { ...createConflict(nord.id, sued.id), clearingDistance: 200, enteringDistance: 0 }, |
| 160 | { ...createConflict(sued.id, nord.id), clearingDistance: 200, enteringDistance: 0 }, |
| 161 | ], |
| 162 | phases: [phase1, phase2], |
| 163 | program: { ...basis.program, phaseOrder: [phase1.id, phase2.id] }, |
| 164 | }; |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * Nachtrag zu Befund 32: EINE Ursache, EINE Zeile im Bericht. |
| 169 | * |
| 170 | * Der Pruefbericht wird gedruckt und traegt eine Warnungsbilanz. Ein Projekt |
| 171 | * ganz ohne Verkehrsstaerken erhielt danach drei Warnungen fuer denselben |
| 172 | * Grund - 'signalplan.saettigungsgrade-unvollstaendig', |
| 173 | * 'signalplan.keine-gewichtung' und 'leistungsfaehigkeit.keine-verkehrsstaerken' -, |
| 174 | * vorher zwei. Dasselbe Muster haben die Befunde 14, 35, 36 und 37 |
| 175 | * (Fassung 5.9.0). |
| 176 | */ |
| 177 | describe('Der Bericht nennt die fehlenden Verkehrsstaerken nur einmal', () => { |
| 178 | /** Derselbe Knotenpunkt, einmal mit und einmal ganz ohne Verkehrsstaerken. */ |
| 179 | const mitVerkehrsstaerken = createStandardIntersectionProject('Vollständig', DATUM); |
| 180 | const ohneVerkehrsstaerken: Project = { ...mitVerkehrsstaerken, demands: [] }; |
| 181 | |
| 182 | /** Regeln, die genau diese eine Ursache melden. */ |
| 183 | const URSACHE = [ |
| 184 | 'signalplan.saettigungsgrade-unvollstaendig', |
| 185 | 'signalplan.keine-gewichtung', |
| 186 | 'leistungsfaehigkeit.keine-verkehrsstaerken', |
| 187 | ]; |
| 188 | |
| 189 | it('zaehlt fuer ein Projekt ganz ohne Verkehrsstaerken zwei Warnungen, nicht drei', () => { |
| 190 | const mit = validateProject(mitVerkehrsstaerken, undefined, DATUM); |
| 191 | const ohne = validateProject(ohneVerkehrsstaerken, undefined, DATUM); |
| 192 | |
| 193 | // Festgehalten wird der Zuwachs, nicht die Gesamtzahl: Die uebrigen |
| 194 | // Warnungen des Beispielprojekts (Projektdaten, kurze Zwischenzeiten) |
| 195 | // stehen in beiden Berichten und gehen andere Regeln an. |
| 196 | expect(ohne.warningCount - mit.warningCount).toBe(2); |
| 197 | expect(ohne.findings.filter((f) => URSACHE.includes(f.rule)).map((f) => f.rule)).toEqual([ |
| 198 | 'signalplan.keine-gewichtung', |
| 199 | 'leistungsfaehigkeit.keine-verkehrsstaerken', |
| 200 | ]); |
| 201 | }); |
| 202 | |
| 203 | it('behaelt die Richtungsaussage, obwohl die Plan-Notiz entfaellt', () => { |
| 204 | // Die gefaehrliche Richtung stand nur in der entfallenden Notiz. Ohne |
| 205 | // diesen Satz liest der Planer die verbleibende Warnung als Formsache. |
| 206 | const ohne = validateProject(ohneVerkehrsstaerken, undefined, DATUM); |
| 207 | |
| 208 | const befund = ohne.findings.find( |
| 209 | (f) => f.rule === 'leistungsfaehigkeit.keine-verkehrsstaerken', |
| 210 | ); |
| 211 | expect(befund?.message).toContain('zu kurz'); |
| 212 | }); |
| 213 | |
| 214 | it('laesst die Plan-Notiz stehen, wenn nur einzelne Phasen fehlen', () => { |
| 215 | // Hier schweigt 'leistungsfaehigkeit.keine-verkehrsstaerken' - sie greift |
| 216 | // erst, wenn KEINE Signalgruppe eine Verkehrsstaerke traegt. Faellt die |
| 217 | // Plan-Notiz auch hier weg, verschweigt der Bericht die Luecke. |
| 218 | const bericht = validateProject(ohneVerkehrsstaerkenInPhase2(), undefined, DATUM); |
| 219 | |
| 220 | expect( |
| 221 | bericht.findings.some((f) => f.rule === 'leistungsfaehigkeit.keine-verkehrsstaerken'), |
| 222 | ).toBe(false); |
| 223 | const befund = bericht.findings.find((f) => f.rule === `signalplan.${CODE}`); |
| 224 | expect(befund).toBeDefined(); |
| 225 | expect(befund?.message).toContain('Phase 2'); |
| 226 | }); |
| 227 | |
| 228 | it('laesst die Plan-Notiz an der einstreifigen Verkehrsfuehrung stehen', () => { |
| 229 | // Dort verlangt der Bericht bewusst keine Verkehrsstaerken |
| 230 | // (schweigtBeiAnlagenart, checkPerformance); die Plan-Notiz ist die einzige |
| 231 | // Stelle, an der die Luecke ueberhaupt vorkommt. |
| 232 | const bericht = validateProject(engstelleOhneVerkehrsstaerken(), undefined, DATUM); |
| 233 | |
| 234 | expect( |
| 235 | bericht.findings.some((f) => f.rule === 'leistungsfaehigkeit.keine-verkehrsstaerken'), |
| 236 | ).toBe(false); |
| 237 | expect(bericht.findings.some((f) => f.rule === `signalplan.${CODE}`)).toBe(true); |
| 238 | }); |
| 239 | |
| 240 | it('nennt in der Ueberschrift nicht "einzelne" Phasen, wenn alle betroffen sind', () => { |
| 241 | // Die Meldung derselben Zeile nennt "Phase 1, Phase 2" - also alle. |
| 242 | const bericht = validateProject(engstelleOhneVerkehrsstaerken(), undefined, DATUM); |
| 243 | |
| 244 | const befund = bericht.findings.find((f) => f.rule === `signalplan.${CODE}`); |
| 245 | expect(befund?.message).toContain('Phase 1'); |
| 246 | expect(befund?.message).toContain('Phase 2'); |
| 247 | expect(befund?.title).not.toMatch(/einzelne/i); |
| 248 | }); |
| 249 | }); |