feat(iace): Risikograph EN ISO 13849-1 PLr + Methoden-Kopf im Bericht

Phase 17 of the risk-assessment polish. Two pieces:

A) PLr per EN ISO 13849-1 Anhang A (Risikograph)
   - HazardPattern.DefaultAvoidability (1 = P1, 2 = P2). Optional;
     defaults to P1 if unset (conservative — operator can raise after
     review).
   - ComputePLr(s,f,p) implements the canonical 8-leaf binary tree
     (S1F1P1 -> a, ..., S2F2P2 -> e). Pinned by 8 table-driven tests.
   - SeverityToS / ExposureToF map the existing 1-5 fields to the
     binary S/F at the documented threshold (3).
   - At project initialise, every hazard's Description is appended
     with "Risikograph EN ISO 13849-1 (Anhang A): S2 · F1 · P1 -> PLr c"
     so the audit value is visible without leaving the hazard view.
   - PatternMatch carries DefaultAvoidability so the init handler can
     pick it up without a second pattern lookup.

B) Methoden-Kopf am Bericht
   - GET /clarifications.html now opens with a standardised methodology
     block: ISO 12100 Anhang B (hazard ID) + ISO 13849-1 Anhang A
     (PLr graph) + ISO 12100 6.2/6.3/6.4 (reduction hierarchy). Same
     wording on every export, ready for the Anlagenbauer-Uebergabe.
   - Only norm identifiers — no norm text reproduced.

C) ISO12100Section in Hazard Description
   - When a pattern is labeled with ISO12100Section, the hazard
     description gets a "Klassifikation: EN ISO 12100 Anhang B,
     Abschnitt 6.3.5.4" suffix. Provenance for the auditor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-17 02:03:10 +02:00
parent 71d31c914b
commit 2afa5a179b
5 changed files with 126 additions and 0 deletions
@@ -415,6 +415,20 @@ func (h *IACEHandler) ExportClarificationsCSV(c *gin.Context) {
w.Flush()
}
// methodologyBlock returns the standardised methodology paragraph that
// must be printed at the start of every IACE risk-assessment report.
// Pure references to norm identifiers (no norm text) — kept here so
// the same wording appears in every export.
const methodologyBlock = `<section style="background:#f9fafb;border:1px solid #d1d5db;border-radius:6px;padding:12px;margin-bottom:18px;">
<h3 style="font-size:11pt;margin:0 0 6px 0;">Methodik der Risikobeurteilung</h3>
<ul style="margin:0;padding-left:18px;font-size:9.5pt;line-height:1.45;">
<li>Gefaehrdungsidentifikation nach <strong>EN ISO 12100</strong>, Anhang B (Tabelle B.1) — Mechanik, Elektrik, Thermik, Laerm, Vibration, Strahlung, Materialien/Substanzen, Ergonomie.</li>
<li>Bestimmung des erforderlichen Performance Levels (PLr) nach <strong>EN ISO 13849-1</strong>, Anhang A (Risikograph) aus S (Schwere), F (Haeufigkeit/Dauer) und P (Vermeidungsmoeglichkeit).</li>
<li>Massnahmen-Hierarchie nach ISO 12100, Abschnitt 6: <strong>6.2 Inhaerent sichere Konstruktion</strong> (Design) &rarr; <strong>6.3 Technische Schutzmassnahmen</strong> (Protection) &rarr; <strong>6.4 Benutzerinformation</strong> (Information).</li>
<li>Klaerungspunkte mit dem Anlagenbauer werden separat in der Klaerungs-Liste verwaltet (Audit-Trail mit Bearbeiter und Zeitstempel).</li>
</ul>
</section>`
// ExportClarificationsHTML handles GET /projects/:id/clarifications.html
// and returns a print-friendly standalone HTML document that the browser
// can render to PDF (no server-side PDF dependency needed). The Bediener
@@ -492,6 +506,7 @@ section .src { font-size: 8pt; color: #6b7280; margin-bottom: 6px; }
<div class="noprint">Tipp: Mit <kbd>Strg+P</kbd> / <kbd>Cmd+P</kbd> als PDF speichern.</div>
<h1>Klaerungsliste — %s</h1>
<div class="sub">Projekt-ID %s · Stand %s</div>
` + methodologyBlock + `
<div class="meta">
<span class="bar open">%d offen</span>
<span class="bar done">%d beantwortet</span>
@@ -219,6 +219,27 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
// scenario itself. Only the aggregated norm-references
// block is appended below for an at-a-glance audit trail.
desc := mp.ScenarioDE
// Phase 17: PLr per EN ISO 13849-1 Anhang A. The graph
// inputs come from the pattern's DefaultSeverity/Exposure
// (mapped to S1/S2 and F1/F2 at threshold 3) plus
// DefaultAvoidability (P1/P2). If avoidability is unset
// we default to P1 — the conservative direction is
// downward (lower PLr), the operator can raise it
// manually after expert review.
avoid := 1
if mp.DefaultAvoidability == 2 {
avoid = 2
}
if mp.DefaultSeverity > 0 && mp.DefaultExposure > 0 {
sBin := iace.SeverityToS(mp.DefaultSeverity)
fBin := iace.ExposureToF(mp.DefaultExposure)
plr := iace.ComputePLr(sBin, fBin, avoid)
desc += fmt.Sprintf("\n\nRisikograph EN ISO 13849-1 (Anhang A): S%d · F%d · P%d → PLr %s",
sBin, fBin, avoid, plr)
}
if mp.ISO12100Section != "" {
desc += "\n\nKlassifikation: EN ISO 12100 Anhang B, Abschnitt " + mp.ISO12100Section
}
hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{
ProjectID: projectID,
@@ -76,6 +76,52 @@ type HazardPattern struct {
// keep the library urheberrechtlich neutral (DIN/Beuth license).
// The frontend renders it as "EN ISO 12100 Abschnitt 6.3.5.5".
ISO12100Section string `json:"iso_12100_section,omitempty"`
// DefaultAvoidability is the P parameter of the EN ISO 13849-1
// risk graph (Annex A): 1 = avoidable under certain conditions, 2 =
// hardly avoidable. Combined with DefaultSeverity (S1/S2 derived
// at threshold 3) and DefaultExposure (F1/F2 at threshold 3) it
// feeds into the PLr (required Performance Level) computation,
// see ComputePLr.
DefaultAvoidability int `json:"default_avoidability,omitempty"` // 1 or 2
}
// ComputePLr returns the required Performance Level (PLr) per EN ISO
// 13849-1 Anhang A (Risikograph). Inputs are the three parameters of
// the graph in their 1/2 form:
// - s (Schwere): 1 = leicht/reversibel, 2 = schwer/irreversibel inkl. Tod
// - f (Haeufigkeit/Dauer): 1 = selten/kurz, 2 = haeufig/dauernd
// - p (Moeglichkeit Vermeidung): 1 = unter Bedingungen moeglich, 2 = kaum
// Return value is one of "a", "b", "c", "d", "e" (PLa..PLe).
//
// The mapping follows the canonical 8-leaf binary tree of the standard:
// S1 F1 P1 -> a
// S1 F1 P2 -> b
// S1 F2 P1 -> b
// S1 F2 P2 -> c
// S2 F1 P1 -> c
// S2 F1 P2 -> d
// S2 F2 P1 -> d
// S2 F2 P2 -> e
func ComputePLr(s, f, p int) string {
idx := 0
if s == 2 { idx += 4 }
if f == 2 { idx += 2 }
if p == 2 { idx += 1 }
return []string{"a", "b", "b", "c", "c", "d", "d", "e"}[idx]
}
// SeverityToS maps a 1-5 DefaultSeverity to the binary S parameter of
// EN ISO 13849-1: 1-2 -> S1 (leicht/reversibel), 3-5 -> S2 (schwer/Tod).
func SeverityToS(severity int) int {
if severity >= 3 { return 2 }
return 1
}
// ExposureToF maps a 1-5 DefaultExposure to the binary F parameter of
// EN ISO 13849-1: 1-2 -> F1 (selten/kurz), 3-5 -> F2 (haeufig/dauernd).
func ExposureToF(exposure int) int {
if exposure >= 3 { return 2 }
return 1
}
// Standard human roles for machinery interaction (ISO 12100 + BetrSichV).
@@ -70,6 +70,7 @@ type PatternMatch struct {
SuggestedMeasureIDs []string `json:"suggested_measure_ids,omitempty"`
ClarificationQuestionsDE []string `json:"clarification_questions_de,omitempty"`
ISO12100Section string `json:"iso_12100_section,omitempty"`
DefaultAvoidability int `json:"default_avoidability,omitempty"`
}
// HazardSuggestion is a suggested hazard from pattern matching.
@@ -226,6 +227,7 @@ func (e *PatternEngine) Match(input MatchInput) *MatchOutput {
SuggestedMeasureIDs: p.SuggestedMeasureIDs,
ClarificationQuestionsDE: p.ClarificationQuestionsDE,
ISO12100Section: p.ISO12100Section,
DefaultAvoidability: p.DefaultAvoidability,
})
for _, cat := range p.GeneratedHazardCats {
@@ -0,0 +1,42 @@
package iace
import "testing"
// TestComputePLr_Canonical8 pins the 8 leaves of the EN ISO 13849-1
// Annex A risk graph: S1/S2 x F1/F2 x P1/P2 -> a..e.
func TestComputePLr_Canonical8(t *testing.T) {
cases := []struct {
s, f, p int
want string
}{
{1, 1, 1, "a"},
{1, 1, 2, "b"},
{1, 2, 1, "b"},
{1, 2, 2, "c"},
{2, 1, 1, "c"},
{2, 1, 2, "d"},
{2, 2, 1, "d"},
{2, 2, 2, "e"}, // worst case: severe + frequent + hardly avoidable
}
for _, c := range cases {
got := ComputePLr(c.s, c.f, c.p)
if got != c.want {
t.Errorf("ComputePLr(S%d F%d P%d) = %q, want %q", c.s, c.f, c.p, got, c.want)
}
}
}
// TestSeverityExposureMapping ensures the 1-5 internal fields map to the
// correct binary S/F parameter at the documented threshold (3).
func TestSeverityExposureMapping(t *testing.T) {
for sev, wantS := range map[int]int{1: 1, 2: 1, 3: 2, 4: 2, 5: 2} {
if got := SeverityToS(sev); got != wantS {
t.Errorf("SeverityToS(%d) = %d, want %d", sev, got, wantS)
}
}
for exp, wantF := range map[int]int{1: 1, 2: 1, 3: 2, 4: 2, 5: 2} {
if got := ExposureToF(exp); got != wantF {
t.Errorf("ExposureToF(%d) = %d, want %d", exp, got, wantF)
}
}
}