lsa-planer

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

/ src services export csv.ts

37,0 KB Rohdatei
src/services/export/csv.ts — 783 Zeilen
1 import { aspectTotals } from '@/domain/plan/aspects';
2 import {
3 UMLAUFZEIT_ERSATZWERT_VERMERK,
4 hatSaettigungsverkehrsstaerke,
5 intergreenKey,
6 umlaufzeitIstErsatzwert,
7 type GreenInterval,
8 type SignalPlan,
9 } from '@/domain/plan/signalPlan';
10 import type { Seconds } from '@/domain/units';
11 import {
12 MOVEMENT_LABELS,
13 TRAFFIC_MODE_LABELS,
14 fahrzeugartBeschriftung,
15 } from '@/domain/rilsa/constants';
16 import { ueberfahrzeitHerkunft } from '@/domain/rilsa/ansaetze';
17 import type { Project, SignalGroup } from '@/domain/model/project';
18 import {
19 KRITERIUM_LABELS,
20 engerRadiusZelle,
21 // Anteil, Aufteilung, Faktor und Gleichung eines Stroms - aus derselben
22 // Funktion des Fachkerns, mit der der Plan die Saettigungsverkehrsstaerke
23 // bildet (plan/signalPlan.ts, groupSaturationFlow), und mit denselben Worten
24 // wie im Ausdruck (Fassung 5.6.0, Frage E6).
25 schwerverkehrsangabe,
26 verfahrenLabel,
27 } from './bewertung';
28 import { wegherkunftZelle } from './wegherkunft';
29 import { CATEGORY_LABELS, SEVERITY_LABELS, type ValidationReport } from '@/domain/validation';
30 import * as fmt from '@/ui/format';
31
32 /**
33 * Tabellenausgabe fuer die Weiterverarbeitung.
34 *
35 * Trennzeichen ist das Semikolon und Dezimaltrennzeichen das Komma - so oeffnet
36 * Excel in deutscher Einstellung die Datei ohne Importdialog. Zusaetzlich wird
37 * eine Byte-Reihenfolge-Markierung vorangestellt, damit Umlaute richtig
38 * erkannt werden; ohne sie zeigte der Altbestand in Excel "ü" statt "ü".
39 *
40 * Die vier PLAN-Tabellen (Signalgruppen, beide Zwischenzeiten, Phasen) nehmen
41 * seit Fassung 5.4.0 (Befund C7) einen Pruefkontext entgegen und
42 * schreiben bei Warnungen einen Vermerk als letzte Zeile (csvVermerke, dort
43 * die Begruendung fuer "letzte" statt "erste"). Der Pruefbericht selbst bleibt
44 * ohne - er IST der Kontext.
45 */
46
47 const SEPARATOR = ';';
48 const BOM = '';
49
50 /*
51 * Zeichen, mit denen eine Tabellenkalkulation eine Formel beginnen laesst.
52 *
53 * Bemerkungen und Beanstandungen sind freier Text. Beginnt eine Zelle mit
54 * einem dieser Zeichen, wertet Excel sie beim Oeffnen als Formel aus - eine
55 * Bemerkung "=Bordkante" wird zum Bezugsfehler, und boesartig gebaute Inhalte
56 * koennen weiter gehen. Ein vorangestelltes Hochkomma macht daraus wieder
57 * Text; die Tabellenkalkulation zeigt es nicht an.
58 */
59 const FORMELSTART = /^[=+\-@\t\r]/;
60
61 /**
62 * Eine Zahl in deutscher Schreibweise - die darf NICHT entwertet werden.
63 *
64 * Der Rechenwert der Zwischenzeit kann negativ sein (tue + tr - te vor dem
65 * Abschneiden auf 0), und ein vorangestelltes Hochkomma machte aus dieser Zahl
66 * Text. Die Spalte waere in jeder Auswertung unbrauchbar.
67 */
68 const ZAHL = /^-?\d+(?:,\d+)?$/;
69
70 function escape(value: string): string {
71 const sicher = FORMELSTART.test(value) && !ZAHL.test(value) ? `'${value}` : value;
72 // Auch das einzeln stehende CR. Der Datensatztrenner ist \r\n; ein CR ohne
73 // folgendes LF fiel durch die Pruefung auf \n und zerriss die Zeile. Zu
74 // erreichen ueber ein Bemerkungsfeld, in dem ein CR steht - etwa aus einer
75 // eingelesenen Projektdatei.
76 if (sicher.includes(SEPARATOR) || /["\r\n]/.test(sicher)) {
77 return `"${sicher.replace(/"/g, '""')}"`;
78 }
79 return sicher;
80 }
81
82 function toCsv(rows: readonly (readonly string[])[]): string {
83 return BOM + rows.map((row) => row.map(escape).join(SEPARATOR)).join('\r\n');
84 }
85
86 /** Wahrheitswert als Anwendertext - "ja"/"nein" statt "true"/"false". */
87 function jaNein(wert: boolean): string {
88 return wert ? 'ja' : 'nein';
89 }
90
91 /**
92 * Was eine PLAN-Tabelle ueber das Pruefergebnis wissen muss.
93 *
94 * Ein ganzer ValidationReport erfuellt das ebenso wie `{ warningCount: n }`;
95 * mehr braucht die Tabelle nicht, und mehr soll sie nicht sehen. Die
96 * Fehlerzahl ist freiwillig, weil die Oberflaeche eine Tabelle mit Fehlern
97 * gar nicht erst ausgibt (Sperre); wer die Funktionen unmittelbar aufruft,
98 * bekommt mit ihr den Fehler-Vermerk.
99 */
100 export type CsvPruefkontext = Pick<ValidationReport, 'warningCount'> &
101 Partial<Pick<ValidationReport, 'errorCount'>>;
102
103 /**
104 * Vermerkzeilen einer Plan-Tabelle - als LETZTE Zeilen, nach den Daten.
105 *
106 * KORREKTUR (Befund C7): Die Plan-Tabellen (Signalgruppen, Zwischenzeiten,
107 * Phasen) verliessen das Programm ohne jeden Bezug zum Pruefbericht - wer die
108 * Datei weiterreichte, reichte die Zahlen ohne die Warnungen weiter, die
109 * dazugehoeren. Jetzt steht in der Datei, dass der Pruefbericht Warnungen
110 * meldet. Die Zeile ist eine einzelne Zelle und keine Datenzeile.
111 *
112 * Zugleich der Ort fuer den Ersatzwert-Vermerk (Befund C11): Ist die
113 * Umlaufzeit nur ein Ersatzwert (uebersaettigt), stehen Rotzeiten,
114 * Kapazitaeten und Phasenbeginne auf einer Zahl, die kein Bemessungsergebnis
115 * ist. Das muss in der Datei stehen, nicht nur am Bildschirm.
116 *
117 * WARUM AM ENDE UND NICHT IN ZEILE 1: Die Kopfzeile bleibt Zeile 1. Erstens
118 * erwarten die bestehenden Tabellen-Tests sie dort - auch fuer einen
119 * uebersaettigten Plan (csv.test.ts, "nennt bei Ueberlastung das Kriterium"),
120 * der den Ersatzwert-Vermerk auch OHNE Pruefkontext bekommt; ein Vermerk in
121 * Zeile 1 schoebe die Kopfzeile dort weg. Zweitens sind diese Tabellen zum
122 * Auswerten gedacht ("die Stufe soll filterbar bleiben"): Excel-Autofilter,
123 * PowerQuery und jeder CSV-Leser mit Kopfzeile nehmen Zeile 1 als Kopf; eine
124 * Hinweiszeile davor macht aus dem Vermerk den Spaltennamen und aus jeder
125 * Datenzeile eine mit zu vielen Spalten. Am Ende ist der Vermerk eine
126 * einzelne Textzelle unter der letzten Datenzeile - sichtbar in jeder
127 * Tabellenkalkulation, unschaedlich fuer jede Auswertung.
128 */
129 export function csvVermerke(
130 plan: Pick<SignalPlan, 'cycleTime' | 'cycleResult'> | null,
131 kontext?: CsvPruefkontext,
132 ): string[][] {
133 const zeilen: string[][] = [];
134 // Fehler zuerst: Die Sperre der Oberflaeche laesst eine solche Tabelle nicht
135 // hinaus; erzeugt sie jemand an ihr vorbei, sagt die Datei selbst, dass sie
136 // keine freigegebene Planunterlage ist.
137 if (kontext?.errorCount !== undefined && kontext.errorCount > 0) {
138 const anzahl = kontext.errorCount === 1 ? '1 Fehler' : `${kontext.errorCount} Fehler`;
139 zeilen.push([
140 `Hinweis: Prüfbericht meldet ${anzahl} – die Tabelle ist keine freigegebene Planunterlage; ` +
141 'siehe Prüfbericht',
142 ]);
143 }
144 if (kontext !== undefined && kontext.warningCount > 0) {
145 const anzahl = kontext.warningCount === 1 ? '1 Warnung' : `${kontext.warningCount} Warnungen`;
146 zeilen.push([`Hinweis: Prüfbericht meldet ${anzahl} – siehe Prüfbericht`]);
147 }
148 if (plan !== null && umlaufzeitIstErsatzwert(plan)) {
149 zeilen.push([
150 `Hinweis: Umlaufzeit ${fmt.seconds(plan.cycleTime)} ${UMLAUFZEIT_ERSATZWERT_VERMERK}; ` +
151 'alle umlaufabhängigen Werte dieser Tabelle beruhen auf dem Ersatzwert',
152 ]);
153 }
154 return zeilen;
155 }
156
157 /**
158 * ALLE Freigabezeitfenster einer Signalgruppe als eine Zelle: "12–40; 60–72".
159 *
160 * KORREKTUR (Befund C22): Die Signalzeitentabelle des Ausdrucks fuehrte zwei
161 * Spalten "Freigabe von" und "Freigabe bis" und fuellte sie aus `greens[0]` -
162 * dem ERSTEN Fenster. Daneben stand in "Freigabezeit" die SUMME aller Fenster.
163 * Bei einer Signalgruppe, die in zwei getrennten Phasen freigegeben ist, ergab
164 * das eine Zeile, die sich nicht mehr aufloesen laesst: "von 12 bis 40" neben
165 * "Freigabezeit 40 s" - 28 s Fenster, 40 s Summe, und das zweite Fenster fehlt
166 * ganz. Wer danach schaltet oder danach prueft, hat eine falsche
167 * Signalzeitentabelle. Jetzt nennt eine Spalte alle Fenster, und die
168 * Summenspalte heisst Summe.
169 *
170 * Zahlen mit hoechstens einer Nachkommastelle: Die Fenstergrenzen entstehen aus
171 * der Freigabezeitverteilung und sind nicht ganzzahlig; auf ganze Sekunden
172 * gerundet ginge die Summe der gedruckten Fenster nicht mehr gegen die
173 * gedruckte Freigabezeit auf.
174 *
175 * WARUM HIER UND NICHT IM PDF: Ausdruck und Tabellenausgabe muessen dieselbe
176 * Zeichenkette zeigen - wer beide nebeneinanderlegt, vergleicht Zeile fuer
177 * Zeile. Die Funktion steht in der Tabellenausgabe, weil diese die
178 * abhaengigkeitsaermere der beiden ist: Das PDF darf die Tabellenausgabe
179 * einbinden, umgekehrt zoege die Tabellenausgabe die PDF-Bibliothek nach sich.
180 */
181 export function freigabefensterZelle(greens: readonly GreenInterval[], cycleTime: Seconds): string {
182 if (greens.length === 0) return '–';
183 // Dauerfreigabe ueber den ganzen Umlauf: Der Rueckfall in den Umlauf ergaebe
184 // Ende 0 und damit "0–0" - im Altbestand stand in dieser Zeile "0 s / 0 s".
185 if (greens.length === 1 && greens[0]!.duration >= cycleTime) {
186 return `0–${fmt.numShort(cycleTime, 1)} (ganzer Umlauf)`;
187 }
188 return (
189 [...greens]
190 /*
191 * Nach Umlaufposition, nicht in Speicherreihenfolge: `greens` entsteht
192 * aus der Phasenfolge und ist nicht sortiert - eine Gruppe mit den
193 * Fenstern [116,28] und [56,28] stand als "116–24; 56–84" in der Zeile.
194 * Eine Signalzeitentabelle wird von oben nach unten gegen den Umlauf
195 * gelesen; eine Zeile, die rueckwaerts springt, liest sich wie ein Fehler
196 * in der Schaltung.
197 */
198 .sort((a, b) => a.start - b.start)
199 .map((g) => {
200 const roh = g.start + g.duration;
201 const rest = roh % cycleTime;
202 /*
203 * Ein Fenster kann ueber den Umlaufbeginn hinausreichen; dann liegt das
204 * Ende VOR dem Beginn ("72–12"). Das ist keine Verwechslung, sondern der
205 * Sachverhalt, und die Fussnote der Tabelle sagt es.
206 *
207 * Schliesst ein Fenster dagegen GENAU am Umlaufende, ist der Rest 0 -
208 * und "60–0" behauptete ein Fenster, das rueckwaerts laeuft, statt
209 * eines, das bis zum Umlaufende reicht. Die Dauerfreigabe oben war
210 * wegen derselben Modulo-Null gesondert behandelt, dieser Fall nicht.
211 */
212 const ende = rest === 0 && g.duration > 0 ? cycleTime : rest;
213 return `${fmt.numShort(g.start, 1)}–${fmt.numShort(ende, 1)}`;
214 })
215 .join('; ')
216 );
217 }
218
219 /**
220 * Fussnote zur Freigabezeit - im Ausdruck unter der Signalzeitentabelle, in der
221 * Tabellenausgabe als Spaltenname (Befund C22). Beide Ausgaben muessen dasselbe
222 * sagen: Die Freigabezeit ist die Summe, nicht die Dauer des ersten Fensters.
223 */
224 export const FREIGABEFENSTER_FUSSNOTE =
225 'Freigabefenster: alle Zeitfenster mit Freigabe im Umlauf, in Sekunden ab Umlaufbeginn; mehrere ' +
226 'Fenster entstehen, wenn eine Signalgruppe in getrennten Phasen freigegeben ist. Reicht ein ' +
227 'Fenster über den Umlaufbeginn hinaus, liegt sein Ende vor seinem Beginn. Die Freigabezeit ist ' +
228 'die Summe aller Fenster, nicht die Dauer des ersten.';
229
230 /**
231 * Die an einer Signalgruppe EINGETRAGENE Hoechstfreigabezeit - oder null, wenn
232 * keine brauchbare eingetragen ist.
233 *
234 * WARUM SIE IN DIE UNTERLAGE GEHOERT: Seit der Fassung 5.10.0 wirkt
235 * `SignalGroup.maxGreenOverride` in der Freigabezeitverteilung -
236 * `phaseMaxGreen` (plan/signalPlan.ts) setzt fuer eine Phase die KLEINSTE
237 * Vorgabe ihrer Signalgruppen an statt des Regelwerts. In keiner Ausgabe stand
238 * sie: Die gedruckte Phasendauer liess sich aus den gedruckten Groessen nicht
239 * mehr herleiten, und das ist der Zweck der Unterlage.
240 *
241 * DIE ZELLE NENNT DEN EINTRAG UND NICHT DIE GELTENDE SCHRANKE: Die erste
242 * Fassung dieser Funktion setzte ohne Eintrag den Regelwert ein und sagte zu,
243 * die Spalte nenne die Schranke, die gilt. Das war an drei Lagen widerlegt: bei
244 * einer festen Freigabezeit der Phase (sie geht der Vorgabe vor), bei einer
245 * Vorgabe unter der Mindestfreigabezeit (dann gilt diese - und der Pruefbericht
246 * derselben Unterlage sagte das Gegenteil der Tabelle) und bei einem negativen
247 * Eintrag, den das Einlesen durchlaesst. Vor allem aber ist die geltende
248 * Schranke keine Eigenschaft EINER Signalgruppe: Sie entsteht je PHASE aus der
249 * kleinsten Vorgabe aller ihrer Gruppen. Eine Zelle je Gruppe kann sie nicht
250 * nennen, ohne zu behaupten, was sie nicht weiss.
251 *
252 * Damit steht die Auswahlregel auch nur noch an einer Stelle: `phaseMaxGreen`
253 * im Fachkern entscheidet, was gilt; diese Funktion liest ein Feld. Dass die
254 * Zelle eine Vorgabe fuehrt, sagt in der Tabellenausgabe der Spaltenname; wer
255 * ueber die wirksame Schranke entscheidet und was ohne jede Vorgabe in einer
256 * Phase gilt, sagt im Ausdruck der Absatz unter der Signalgruppentabelle
257 * (pdf.ts, drawSignalGroupTable).
258 *
259 * NULL BEI EINEM UNBRAUCHBAREN EINTRAG: Ein nicht endlicher Wert hat keine
260 * lesbare Schreibweise - "NaN s" waere keine Angabe. Das Einlesen hat ihn
261 * bereits gemeldet und als nicht gesetzt behandelt (schema.ts,
262 * `optionalNumGemeldet`), und `phaseMaxGreen` uebergeht ihn ebenso.
263 *
264 * WARUM HIER UND NICHT IM PDF: derselbe Grund wie bei `freigabefensterZelle`
265 * darueber - Ausdruck und Tabellenausgabe muessen dieselbe Angabe zeigen, und
266 * die Tabellenausgabe ist die abhaengigkeitsaermere der beiden. Die
267 * Schreibweise der leeren Zelle bleibt jeder Ausgabe selbst ueberlassen: hier
268 * leer wie jede nicht gebildete Groesse, im Ausdruck "–".
269 */
270 export function hoechstfreigabezeitVorgabe(group: SignalGroup): Seconds | null {
271 const vorgabe = group.maxGreenOverride;
272 return vorgabe === null || !Number.isFinite(vorgabe) ? null : vorgabe;
273 }
274
275 /** Signalgruppen mit Signalzeiten und Leistungsfaehigkeit. */
276 export function signalGroupsCsv(
277 project: Project,
278 plan: SignalPlan,
279 kontext?: CsvPruefkontext,
280 ): string {
281 const rows: string[][] = [
282 [
283 'Signalgruppe',
284 'Verkehrsart',
285 'Fahrbeziehung',
286 'V zul [km/h]',
287 'Fahrstreifen',
288 'Fahrzeugart',
289 // Merkmale der Fussgaengerfurt, die in die Mindestfreigabezeit eingehen
290 // (Befund C1): Zusatzeinrichtung fuer Blinde und Sehbehinderte (Freigabe
291 // fuer die ganze statt die halbe Furt) und erhoehter Zeitbedarf (1,0
292 // statt 1,2 m/s). "ja"/"nein" nur fuer Fussgaengergruppen, sonst leer -
293 // wie jede Groesse, die fuer die Zeile nicht gebildet wird. Ohne die
294 // Spalten stuende eine Mindestfreigabezeit von 6 s neben einem Regelwert
295 // von 5 s ohne erkennbaren Grund.
296 'Blindenzusatz',
297 'Erhöhter Zeitbedarf',
298 'Rot-Gelb [s]',
299 'Gelb [s]',
300 'Mindestfreigabezeit [s]',
301 /*
302 * Die Vorgabe zur oberen Schranke der Freigabezeitverteilung, neben der
303 * unteren (Fassung 5.10.0): Ohne sie stand in der Unterlage eine
304 * Phasendauer, die sich aus den gedruckten Groessen nicht herleiten
305 * liess.
306 *
307 * "VORGABE" STEHT IM SPALTENNAMEN: Die Zelle nennt den Eintrag der
308 * Signalgruppe, nicht die im Plan wirksame Schranke - die bildet
309 * `phaseMaxGreen` je PHASE aus der kleinsten Vorgabe ihrer Gruppen, und
310 * eine feste Freigabezeit der Phase wie auch die Mindestfreigabezeit
311 * gehen ihr vor (siehe `hoechstfreigabezeitVorgabe`). Leer heisst deshalb
312 * "keine Vorgabe" und nicht "keine Schranke" - dieselbe Form wie bei der
313 * Spalte "Lastzuganteil ... (leer = nicht erfasst)".
314 */
315 'Höchstfreigabezeit: Vorgabe der Signalgruppe [s] (leer = keine)',
316 // Befund C22: Ohne diese Spalte stand die Summe der Freigabezeiten allein
317 // da, und aus der Tabelle liess sich nicht ablesen, WANN die Gruppe Gruen
318 // hat - erst recht nicht bei mehreren Fenstern. Die Summenspalte heisst
319 // seitdem auch Summe: "Freigabezeit 40" neben "Freigabefenster 12–40;
320 // 60–72" liesse sonst das erste Fenster fuer die ganze Freigabe halten.
321 'Freigabefenster [s]',
322 'Freigabezeit (Summe aller Fenster) [s]',
323 'Rot [s]',
324 'Verkehrsstärke [Fz/h]',
325 // Der Anteil mindert die Saettigungsverkehrsstaerke und stand in keiner
326 // Ausgabe; die Kapazitaet daneben war damit nicht herleitbar.
327 //
328 // DIE EINHEIT STEHT IM KOPF, NICHT AM WERT (Fassung 5.10.0): Die Spalte
329 // trug ihre Prozentzahl ohne jeden Massstab, waehrend der Ausdruck an
330 // derselben Stelle "12 %" schreibt (pdf.ts, schwerverkehrZelle) und in
331 // der Nachbarspalte der Auslastungsgrad als Zahl zwischen 0 und 1 steht.
332 // Am Wert waere die Einheit falsch aufgehoben: "12 %" liest eine
333 // Tabellenkalkulation je nach Einstellung als Text oder als 0,12. Die
334 // Klammerform ist die der uebrigen Einheitenspalten dieser Ausgabe.
335 'Schwerverkehrsanteil [%]',
336 /*
337 * NEU (Schema 13): die Aufteilung des Schwerverkehrs, aus der sich nach
338 * HBS 2015 Gl. 2-5 rechnen laesst.
339 *
340 * DER LEERE WERT IST EINE AUSSAGE und steht deshalb im Spaltennamen:
341 * Leer heisst "nicht gezaehlt" und fuehrt auf Gl. 2-6 mit dem
342 * Pauschalwert, "0" heisst "gezaehlt, keine Lastzuege" und fuehrt auf
343 * Gl. 2-5. Beide ergeben verschiedene Saettigungsverkehrsstaerken; in
344 * einer Auswertung, die die leere Zelle als 0 liest, waeren sie
345 * dieselbe.
346 */
347 'Lastzuganteil am Schwerverkehr [%] (leer = nicht erfasst)',
348 /*
349 * NEU (Fassung 5.5.0, Frage E6): der Anpassungsfaktor,
350 * mit dem der Anteil in die Saettigungsverkehrsstaerke eingeht. Zwischen
351 * dem Anteil und der Spalte daneben lag eine Umrechnung, die in keiner
352 * Ausgabe stand - und die Erlaeuterung des Ausdrucks nannte dafuer ein
353 * Pkw-Aequivalent von 2,0, das nachweislich nicht der Wert des HBS ist.
354 *
355 * BERICHTIGT (Fassung 5.10.0): Hier stand "Mit fSV laesst sich die Spalte
356 * nachrechnen: qS = 3600 / (fSV · tB)". Mit fSV allein geht das nicht -
357 * die Spalte "Saettigungsverkehrsstaerke" fuehrt qS0 · n · fA / fSV, also
358 * auch die Fahrstreifenzahl (Spalte "Fahrstreifen") und bei links und
359 * rechts abbiegenden Stroemen die Abminderung fA aus den Vorgaben. Der
360 * Erlaeuterungsabsatz des Ausdrucks nennt die vollstaendige Kette
361 * (bewertung.ts, schwerverkehrSatz); fSV ist der Teil, den die Tabelle
362 * ohne eigene Spalte nicht hergaebe.
363 */
364 'Schwerverkehrsfaktor fSV',
365 /*
366 * NEU (Schema 13): nach welcher Gleichung dieser Faktor gebildet wurde.
367 * Seit beide Gleichungen im Einsatz sind, laesst sich fSV aus dem Anteil
368 * allein nicht mehr nachrechnen - zwei Zeilen mit demselben
369 * Schwerverkehrsanteil koennen verschiedene Faktoren tragen. Die Spalte
370 * nennt auch die Annahme, unter der Gl. 2-6 steht (20 % Lastzuege am
371 * Schwerverkehr), und den Fall des verworfenen Eintrags: Ein
372 * unbrauchbarer Lastzuganteil faellt auf Gl. 2-6 zurueck, und das darf
373 * die Datei nicht verschweigen.
374 */
375 'Gleichung für fSV (HBS 2015)',
376 'Sättigungsverkehrsstärke [Fz/h]',
377 // Die Abflusszeit tA ist die Groesse, aus der sich die Kapazitaet
378 // nachrechnen laesst (C = qS · tA/tU). Nach HBS 2015 ist sie tF + 1 s je
379 // Freigabezeitfenster, nach HCM die Freigabezeit selbst - ohne die Spalte
380 // ginge die Kapazitaet neben der Freigabezeit nicht auf (Befund B2).
381 'Abflusszeit tA [s]',
382 'Kapazität [Fz/h]',
383 'Auslastungsgrad',
384 'Mittlere Wartezeit [s]',
385 // Laengste Sperrzeit im Umlauf. Fuer Fussgaenger und Radverkehr ist sie
386 // nach HBS 2015 das Bewertungskriterium (Befund B4); fuer Kfz und OePNV
387 // eine Kenngroesse, die im Ausdruck ebenfalls steht.
388 'Maximale Wartezeit [s]',
389 'Qualitätsstufe',
390 // Woran die Stufe gemessen wurde: mittlere Wartezeit, maximale Wartezeit
391 // oder Ueberlastung (q > C). Ein blosses "F" liesse offen, ob die
392 // Wartezeit oder die Kapazitaet den Ausschlag gab (Befund B1).
393 'Kriterium der Stufe',
394 // Ein blosses "B" sagt nichts: Dieselbe Wartezeit ergibt nach HBS 2015
395 // und nach HCM verschiedene Stufen - und seit Fassung 5.4.0
396 // (Befund B2) auch verschiedene Wartezeiten, weil beide Verfahren
397 // wirklich getrennt gerechnet werden. Benannt wie im Ausdruck.
398 'Bewertungsverfahren',
399 ],
400 ];
401
402 for (const group of project.signalGroups) {
403 const planned = plan.groups.find((g) => g.groupId === group.id);
404 const demand = project.demands.find((d) => d.signalGroupId === group.id);
405 const totals = planned ? aspectTotals(planned, plan.cycleTime) : null;
406 // Fuer Fussgaenger und Radverkehr gibt es weder Kapazitaet noch Stufe nach
407 // HCM: `capacity` ist dann null und `serviceLevel` kann null sein. Beide
408 // Faelle muessen als leere Zelle erscheinen, nicht als Absturz.
409 const delay = planned?.delay ?? null;
410 const level = delay?.serviceLevel ?? null;
411 // Verkehrsstaerke und Schwerverkehrsanteil nur fuer Kfz und OePNV. Fuer
412 // Fussgaenger und Radverkehr kann eine Zahl gespeichert sein (Altdatei aus
413 // der Zeit vor Fassung 5.4.0, Befund B4) - sie geht in keine
414 // Rechnung ein, und der Pruefbericht sagt das. In der Tabelle stuende sie
415 // neben Kapazitaet und Auslastungsgrad, die es fuer diese Gruppen nicht
416 // gibt, und laese sich als Eingangsgroesse. Leer wie jede Groesse, die fuer
417 // die Zeile nicht gebildet wird.
418 const nachfrage = demand && hatSaettigungsverkehrsstaerke(group.mode) ? demand : null;
419 // Anteil, Aufteilung, Faktor und Gleichung an einer Stelle gebildet (seit
420 // Schema 13). Vorher rief diese Datei `schwerverkehrsfaktor(anteil)` selbst auf,
421 // also OHNE die Aufteilung: Bei erfassten Lastzuegen stand die Spalte fSV
422 // neben einer Saettigungsverkehrsstaerke, die zu einem anderen Faktor
423 // gehoert.
424 const schwerverkehr = nachfrage ? schwerverkehrsangabe(nachfrage) : null;
425 const maxVorgabe = hoechstfreigabezeitVorgabe(group);
426
427 rows.push([
428 group.name,
429 TRAFFIC_MODE_LABELS[group.mode],
430 // Dieselben Beschriftungen wie am Bildschirm und im PDF. Zuvor standen
431 // hier die Schluessel: "rechts" statt "rechts abbiegend". Ein Abgleich
432 // zwischen Tabelle und Ausdruck stimmte damit Zeile fuer Zeile nicht.
433 MOVEMENT_LABELS[group.movement],
434 fmt.numShort(group.vZul),
435 // Ueber fmt und nicht ueber String (Fassung 5.10.0): Eine gebrochene
436 // Fahrstreifenzahl stand hier als "2.5" mit englischem Punkt, gegen die
437 // Zusage im Kopf dieser Datei. Das Einlesen rundet sie seit der
438 // Fassung 5.10.0 ab und meldet die Ersetzung (schema.ts,
439 // `ganzzahlAbrunden`) - eine zweite Wache und kein Ersatz fuer diese:
440 // `SignalGroup.lanes` traegt im Typ keine Ganzzahlschranke, und die Zelle
441 // hat deutsch zu schreiben, was sie bekommt. Bei ganzen Zahlen aendert
442 // sich nichts: numShort laesst nachlaufende Nullen weg.
443 fmt.numShort(group.lanes),
444 fahrzeugartBeschriftung(group.vehicleClass, plan.defaults),
445 group.mode === 'fuss' ? jaNein(group.blindenzusatz === true) : '',
446 group.mode === 'fuss' ? jaNein(group.reducedMobility) : '',
447 planned ? fmt.numShort(planned.times.redYellow) : '',
448 planned ? fmt.numShort(planned.times.yellow) : '',
449 planned ? fmt.numShort(planned.times.minGreen) : '',
450 // Ohne `planned`: Die Vorgabe ist eine Eingangsgroesse der Gruppe und
451 // kein Ergebnis des Plans - sie steht auch dann, wenn die Gruppe keiner
452 // Phase zugeordnet ist.
453 maxVorgabe === null ? '' : fmt.numShort(maxVorgabe),
454 planned ? freigabefensterZelle(planned.greens, plan.cycleTime) : '',
455 planned ? fmt.numShort(planned.totalGreen) : '',
456 totals ? fmt.numShort(totals.rot) : '',
457 /*
458 * LEER AUCH BEI 0 (Fassung 5.10.0): Ein Datensatz ist noch keine
459 * Zaehlung. Wird in der Signalgruppentabelle allein der
460 * Schwerverkehrsanteil ausgefuellt, legt die Oberflaeche einen Datensatz
461 * mit `volume: 0` an; der Fachkern liest ihn seit Fassung 5.10.0 als
462 * "keine Angabe" (signalPlan.ts, erfassteVerkehrsstaerke; Befunde 12 und
463 * 18), der Bildschirm zeichnet das Feld leer (Befund 58), und der
464 * Ausdruck setzt an allen drei Stellen "–". Bliebe hier "0" stehen, sagte
465 * dieselbe Zeile in zwei Ausgaben zweierlei.
466 *
467 * LEER UND NICHT "–": Dieselbe Schreibweise wie in der Nachbarspalte
468 * "Lastzuganteil ... (leer = nicht erfasst)" und wie bei jeder Groesse,
469 * die diese Ausgabe fuer eine Zeile nicht bildet; ein Gedankenstrich
470 * waere in einer Zahlenspalte Text (siehe die Begruendung zur
471 * Kreuztabelle weiter unten).
472 *
473 * Die Spalten zum Schwerverkehr bleiben dagegen stehen: Der Anteil geht
474 * unabhaengig von der Verkehrsstaerke in die Saettigungsverkehrsstaerke
475 * derselben Zeile ein (signalPlan.ts, groupSaturationFlow), und ohne ihn
476 * waere die daneben gedruckte Zahl nicht mehr herleitbar.
477 */
478 nachfrage === null || nachfrage.volume <= 0 ? '' : fmt.numShort(nachfrage.volume),
479 // Aus derselben Angabe wie fSV zwei Spalten weiter - nicht noch einmal
480 // aus `nachfrage`, sonst stuende in der einen Spalte der eingetragene
481 // und in der anderen der gerechnete Anteil (bewertung.ts, anteilProzent).
482 schwerverkehr ? schwerverkehr.anteilProzent : '',
483 schwerverkehr ? schwerverkehr.lastzuganteilProzent : '',
484 schwerverkehr ? fmt.numShort(schwerverkehr.fsv, 3) : '',
485 schwerverkehr ? schwerverkehr.gleichungSpalte : '',
486 planned?.capacity ? fmt.numShort(planned.capacity.saturationFlow) : '',
487 planned?.capacity ? fmt.numShort(planned.capacity.abflusszeit, 1) : '',
488 planned?.capacity ? fmt.numShort(planned.capacity.capacity) : '',
489 planned?.capacity?.degreeOfSaturation === undefined
490 ? ''
491 : fmt.numShort(planned.capacity.degreeOfSaturation, 3),
492 delay ? fmt.numShort(delay.averageDelay, 1) : '',
493 delay ? fmt.numShort(delay.maximumDelay, 1) : '',
494 // Nur der Buchstabe: Den Grund fuer ein F traegt die Spalte daneben; in
495 // einer Auswertung soll die Stufe filterbar bleiben.
496 level ? level.grade : '',
497 level ? KRITERIUM_LABELS[level.kriterium] : '',
498 delay ? verfahrenLabel(delay.verfahren) : '',
499 ]);
500 }
501
502 // Rotzeit, Kapazitaet und Auslastung haengen an der Umlaufzeit - deshalb
503 // hier neben dem Warnungsvermerk auch der Ersatzwert-Vermerk (Befund C11).
504 rows.push(...csvVermerke(plan, kontext));
505 return toCsv(rows);
506 }
507
508 /**
509 * Was in einer Zelle der Herkunftsmatrix steht.
510 *
511 * Die Diagonale bleibt leer: Eine Signalgruppe gegen sich selbst ist kein
512 * Sachverhalt, ueber dessen Herkunft sich etwas sagen liesse.
513 */
514 const HERKUNFT_VERTRAEGLICH = 'verträglich';
515
516 /**
517 * Zwischenzeitenmatrix als Kreuztabelle, gefolgt von der Herkunftsmatrix.
518 *
519 * WARUM ZWEI TABELLEN (Fassung 5.8.0): Der Ausdruck unterscheidet drei
520 * Sachverhalte, die diese Ausgabe bis dahin auf zwei Darstellungen zusammenzog.
521 *
522 * - Der Ausdruck setzt hinter eine von Hand vorgegebene Zwischenzeit einen
523 * Stern. Hier stand dieselbe Zahl ohne Kennzeichnung: Wer die Datei
524 * auswertete, sah nicht, welche Werte die Berechnung ersetzt hatten - und
525 * genau das ist der Nachweis, den eine Pruefstelle sucht.
526 * - Die leere Zelle stand fuer ZWEI Dinge: die Diagonale und ein
527 * vertraegliches Paar ohne erfasste Konfliktbeziehung. Im Ausdruck ist das
528 * erste leer und das zweite ein Punkt. Diese Ausgabe ist zugleich die
529 * einzige maschinell lesbare Quelle dafuer, WELCHE Paare vertraeglich
530 * sind - der Rechenweg (intergreenDetailCsv) fuehrt nur die erfassten
531 * Beziehungen.
532 *
533 * WARUM NICHT DER STERN WIE IM AUSDRUCK: "7*" ist keine Zahl mehr. Eine
534 * Tabellenkalkulation liest die Zelle als Text, und in einer Spalte, in der
535 * einzelne Werte vorgegeben sind, stuenden Zahl und Text gemischt - Summen,
536 * Hoechstwerte und Vergleiche schluegen dort fehl. Die Kreuztabelle wird
537 * gerechnet; das ist ihr Zweck und der Unterschied zum Ausdruck, der gelesen
538 * wird. Sie bleibt deshalb rein numerisch, und die Kennzeichnung steht in
539 * einer zweiten Kreuztabelle gleichen Zuschnitts darunter. Beide stehen in
540 * DERSELBEN Datei, weil die Oberflaeche Matrix und Rechenweg als getrennte
541 * Schaltflaechen anbietet: Wer nur die Matrix laedt, haette den Nachweis sonst
542 * nicht.
543 *
544 * Die Vermerke bleiben die letzten Zeilen der Datei (Befund C7) - die zweite
545 * Tabelle steht davor.
546 */
547 export function intergreenCsv(
548 project: Project,
549 plan: SignalPlan,
550 kontext?: CsvPruefkontext,
551 ): string {
552 const groups = project.signalGroups;
553 const kopf = ['räumt \\ fährt ein', ...groups.map((g) => g.name)];
554 const rows: string[][] = [kopf];
555
556 for (const from of groups) {
557 rows.push([
558 from.name,
559 ...groups.map((to) => {
560 if (from.id === to.id) return '';
561 const resolved = plan.intergreens.get(intergreenKey(from.id, to.id));
562 return resolved ? fmt.numShort(resolved.value, 0) : '';
563 }),
564 ]);
565 }
566
567 /*
568 * Leerzeile als Trenner: Eine Tabellenkalkulation erkennt daran das Ende des
569 * ersten Blocks, und beim Lesen mit dem Auge steht der Kopf der zweiten
570 * Tabelle nicht unmittelbar unter der letzten Wertezeile.
571 *
572 * Die zweite Tabelle bekommt DIESELBE Kopfzeile wie die erste. Damit liegen
573 * beide Bloecke Zelle auf Zelle uebereinander - wer sie verknuepft, braucht
574 * keine Zuordnung, nur denselben Zeilen- und Spaltenschluessel. Die
575 * Titelzeile darueber sagt, was der Block enthaelt, und erklaert die leere
576 * Zelle: Sie waere sonst die einzige Angabe, die sich nicht von selbst
577 * versteht.
578 */
579 rows.push([]);
580 rows.push(['Herkunft der Zwischenzeit – leere Zelle: dieselbe Signalgruppe']);
581 rows.push(kopf);
582
583 for (const from of groups) {
584 rows.push([
585 from.name,
586 ...groups.map((to) => {
587 if (from.id === to.id) return '';
588 const resolved = plan.intergreens.get(intergreenKey(from.id, to.id));
589 return resolved ? resolved.source : HERKUNFT_VERTRAEGLICH;
590 }),
591 ]);
592 }
593
594 // Zwischenzeiten haengen nicht an der Umlaufzeit - nur der Warnungsvermerk.
595 rows.push(...csvVermerke(null, kontext));
596 return toCsv(rows);
597 }
598
599 /** Zwischenzeiten mit vollstaendigem Rechenweg. */
600 export function intergreenDetailCsv(
601 project: Project,
602 plan: SignalPlan,
603 kontext?: CsvPruefkontext,
604 ): string {
605 const rows: string[][] = [
606 [
607 'Räumende SG',
608 'Einfahrende SG',
609 'Räumweg [m]',
610 'Fahrzeuglänge [m]',
611 'Räumweg gesamt [m]',
612 'Räumgeschwindigkeit [m/s]',
613 /*
614 * NEU (Fassung 5.5.0, Frage E1): das Merkmal der einzelnen
615 * Konfliktbeziehung, das die Raeumgeschwindigkeit daneben senkt. Ohne die
616 * Spalte stuenden in derselben Tabelle 5,0 und 7,0 m/s nebeneinander,
617 * ohne dass der Unterschied erklaerbar waere. Die Zelle nennt die volle
618 * Beschriftung samt Vorbehalt (bewertung.ts, engerRadiusZelle) - eine
619 * Tabellenausgabe hat keine Fussnote, in der er sonst stuende.
620 */
621 'Enger Innenradius',
622 // Sagt, wie die Raeumzeit aus den Spalten daneben entsteht: im Regelfall
623 // Raeumweg gesamt / Raeumgeschwindigkeit, beim OePNV mit Halt vor dem
624 // Knotenpunkt der Anfahransatz der RiLSA (Fall 4) - dort ist die
625 // Raeumgeschwindigkeit nur die Obergrenze beim Beschleunigen.
626 'Räumansatz',
627 'Räumzeit [s]',
628 'Einfahrweg [m]',
629 'Einfahrgeschwindigkeit [m/s]',
630 'Einfahrzeit [s]',
631 'Überfahrzeit [s]',
632 /*
633 * NEU (Fassung 5.5.0, Frage E2): woher die Ueberfahrzeit daneben stammt. Bei
634 * Kraftfahrzeugen ist das der gewaehlte Rechenansatz - er gilt fuer den
635 * ganzen Plan, steht aber in jeder Zeile, weil die Tabellenausgabe zum
636 * Auswerten und zum Zusammenfuehren mehrerer Plaene gedacht ist und eine
637 * Angabe, die nur einmal am Ende stuende, dabei verlorenginge. Dieselbe
638 * Beschriftung wie am Bildschirm und im Ausdruck.
639 *
640 * Bei den uebrigen Verkehrsarten steht deren eigene Herleitung da
641 * (Fassung 5.5.0, Befund F5): Der Kfz-Ansatz erreicht sie nicht, und seine Beschriftung
642 * neben einer Fussgaenger-Ueberfahrzeit war schlicht falsch. Die Spalte
643 * heisst deshalb "Herkunft" und nicht mehr "Ansatz".
644 */
645 'Überfahrzeit-Herkunft',
646 'Rechenwert [s]',
647 'Zwischenzeit [s]',
648 // "Herkunft" allein war zweideutig: Die Spalte sagt, ob der ZWISCHENZEIT-
649 // wert gerechnet oder vorgegeben ist - nicht, woher Raeum- und Einfahrweg
650 // stammen. Im PDF steht daneben eine Spalte "Herkunft der Wege"; wer
651 // beide Ausgaben nebeneinanderlegte, hielt "berechnet" fuer eine Aussage
652 // ueber die Vermessung.
653 'Herkunft der Zwischenzeit',
654 'Herkunft der Wege',
655 'Bemerkung',
656 ],
657 ];
658
659 for (const conflict of project.conflicts) {
660 const from = project.signalGroups.find((g) => g.id === conflict.fromId);
661 const to = project.signalGroups.find((g) => g.id === conflict.toId);
662 const resolved = plan.intergreens.get(intergreenKey(conflict.fromId, conflict.toId));
663 if (!from || !to || !resolved) continue;
664 const calc = resolved.calculation;
665
666 rows.push([
667 from.name,
668 to.name,
669 fmt.numShort(conflict.clearingDistance, 2),
670 calc ? fmt.numShort(calc.vehicleLength, 2) : '',
671 calc ? fmt.numShort(calc.clearingPath, 2) : '',
672 calc ? fmt.numShort(calc.clearingSpeed, 2) : '',
673 // Das Merkmal steht auch dann da, wenn die Zwischenzeit von Hand
674 // vorgegeben ist (calc === null): Es ist eine Angabe ueber die
675 // Oertlichkeit und keine Zwischengroesse der Rechnung.
676 engerRadiusZelle(conflict.engerRadius, 'nein'),
677 calc
678 ? calc.raeumansatz === 'anfahren'
679 ? `Anfahren nach Halt (RiLSA Fall 4), a = ${fmt.num(calc.anfahrbeschleunigung ?? 0, 1)} m/s²`
680 : 'konstante Geschwindigkeit'
681 : '',
682 calc ? fmt.numShort(calc.clearingTime, 3) : '',
683 fmt.numShort(conflict.enteringDistance, 2),
684 calc ? fmt.numShort(calc.enteringSpeed, 2) : '',
685 calc ? fmt.numShort(calc.enteringTime, 3) : '',
686 calc ? fmt.numShort(calc.crossingTime, 2) : '',
687 // Leer, wo keine Ueberfahrzeit gerechnet wurde: Bei vorgegebener
688 // Zwischenzeit hat der Ansatz nichts bestimmt, und eine Angabe daneben
689 // behauptete das Gegenteil.
690 //
691 // KORREKTUR (Fassung 5.5.0, Befund F5): Hier stand die Beschriftung des
692 // Kfz-Ansatzes in JEDER Zeile - auch neben der 0,00 s eines
693 // Fussgaengerstroms und neben den 5 s eines OePNV-Stroms aus der
694 // Vmax-Staffel. Der Ansatz gilt ausschliesslich fuer Kraftfahrzeuge.
695 // Woher die Ueberfahrzeit je Verkehrsart stammt, entscheidet jetzt der
696 // Fachkern (ansaetze.ts, ueberfahrzeitHerkunft).
697 //
698 // KORREKTUR (Fassung 5.9.0): Diese Aufrufstelle gab nur `vorgegeben` mit.
699 // Eine von resolveCrossingTime VERWORFENE Handeingabe - etwa 0 s neben
700 // einer Gelbzeit von 3 s - wies die Spalte weiter als "an dieser
701 // Beziehung von Hand eingetragen" aus, waehrend die Spalte links davon
702 // den Regelwert zeigte. Wie in conflictsView.ts gehen deshalb der
703 // Vorgabewert und der angesetzte Wert mit; entschieden wird im Fachkern.
704 //
705 // KORREKTUR (Fassung 5.43.0): Das VERFAHREN geht mit. An einer
706 // einstreifigen Verkehrsfuehrung steht in der Spalte "Ueberfahrzeit [s]"
707 // links davon die feste Zahl des Abschnitts 5.2.2 - 4 s -, und daneben
708 // stand die Beschriftung des Kfz-Ansatzes, der dort nichts bestimmt.
709 calc
710 ? ueberfahrzeitHerkunft(calc.verfahren, from.mode, project.settings.ueberfahrzeitAnsatz, {
711 haltVorKnoten: conflict.haltVorKnoten === true,
712 vorgegeben: conflict.crossingTimeOverride !== null,
713 ...(conflict.crossingTimeOverride !== null
714 ? { vorgabewert: conflict.crossingTimeOverride }
715 : {}),
716 angesetzt: calc.crossingTime,
717 })
718 : '',
719 calc ? fmt.numShort(calc.raw, 3) : '',
720 fmt.numShort(resolved.value, 0),
721 resolved.source,
722 // Wie im PDF zwei getrennte Angaben: Die Herkunft der Wege fuehrt das
723 // Programm selbst, die Bemerkung ist freier Text und kann aelter sein als
724 // der Wert neben ihr.
725 wegherkunftZelle(conflict),
726 conflict.note,
727 ]);
728 }
729
730 // Zwischenzeiten haengen nicht an der Umlaufzeit - nur der Warnungsvermerk.
731 rows.push(...csvVermerke(null, kontext));
732 return toCsv(rows);
733 }
734
735 /**
736 * Pruefbericht als Tabelle.
737 *
738 * Bewusst OHNE Pruefkontext und ohne Sperre (Befund C7): Der Pruefbericht ist
739 * keine Planunterlage, sondern das Dokument der Fehler - er muss immer
740 * ausgebbar sein, gerade wenn der Export gesperrt ist.
741 */
742 export function reportCsv(report: ValidationReport): string {
743 const rows: string[][] = [
744 ['Art', 'Bereich', 'Beanstandung', 'Beschreibung', 'Zu tun', 'Fundstelle'],
745 ];
746 for (const finding of report.findings) {
747 rows.push([
748 SEVERITY_LABELS[finding.severity],
749 CATEGORY_LABELS[finding.category],
750 finding.title,
751 finding.message,
752 finding.suggestion,
753 finding.reference,
754 ]);
755 }
756 return toCsv(rows);
757 }
758
759 /** Phasen mit Freigabezeiten und Uebergaengen. */
760 export function phasesCsv(project: Project, plan: SignalPlan, kontext?: CsvPruefkontext): string {
761 const rows: string[][] = [
762 ['Nr.', 'Phase', 'Signalgruppen', 'Beginn [s]', 'Freigabezeit [s]', 'Übergangszeit danach [s]'],
763 ];
764
765 plan.phases.forEach((phase, index) => {
766 const transition = plan.transitions[index];
767 rows.push([
768 String(index + 1),
769 phase.name,
770 phase.signalGroupIds
771 .map((id) => project.signalGroups.find((g) => g.id === id)?.name ?? '?')
772 .join(', '),
773 fmt.numShort(phase.start, 1),
774 fmt.numShort(phase.duration, 1),
775 transition ? fmt.numShort(transition.duration, 1) : '',
776 ]);
777 });
778
779 // Beginn und Dauer der Phasen sind auf die Umlaufzeit verteilt - deshalb
780 // hier neben dem Warnungsvermerk auch der Ersatzwert-Vermerk (Befund C11).
781 rows.push(...csvVermerke(plan, kontext));
782 return toCsv(rows);
783 }