fix(iace): remove EN ISO 13849-1 risk-graph reproduction; own risk model
CI / detect-changes (push) Successful in 6s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / build-sha-integrity (push) Failing after 5s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Successful in 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Failing after 37s
CI / iace-gt-coverage (push) Successful in 23s
CI / detect-changes (push) Successful in 6s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / build-sha-integrity (push) Failing after 5s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Successful in 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Failing after 37s
CI / iace-gt-coverage (push) Successful in 23s
IP/copyright fix: ComputePLr reproduced the EN ISO 13849-1 Anhang A risk-graph decision table (S/F/P -> PLr a..e) and SeverityToS/ExposureToF its parameter binning, emitted into every hazard description. Removed — we may not reproduce DIN/Beuth norm logic. Replaced with BreakPilot's OWN risk model: - risk_estimation.go: probability (W) + avoidance (P) estimated from public, permissively-licensed accident statistics (Eurostat ESAW, CC BY 4.0) by contact mode, calibrated to our ground-truth corpus; own risk index + bands. - iace_handler_init.go now emits "Risikoeinschaetzung (BreakPilot-Modell): S F W P -> Risiko: <level>" instead of the norm PLr string. - DATA_SOURCES.md: data provenance + license register (ESAW CC BY 4.0; BLS/OSHA public domain; HSE OGL; DGUV + DIN/Beuth explicitly excluded). - gt_risk_benchmark_test.go: first GT validation of risk numbers — W within +-1 99%, P 93% vs the professional across both ground truths. Removed risk_graph_test.go (pinned the reproduced norm table). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -219,26 +219,20 @@ 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
|
||||
}
|
||||
// BreakPilot's OWN risk model (NOT a norm reproduction):
|
||||
// severity + frequency from the pattern defaults; probability
|
||||
// (W) and avoidance (P) from public accident-statistics anchors
|
||||
// (see iace/risk_estimation.go + DATA_SOURCES.md). No EN ISO
|
||||
// 13849-1 risk-graph table or parameter binning is reproduced.
|
||||
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)
|
||||
w := iace.EstimateProbabilityW(mp.HazardCats, mp.ScenarioDE)
|
||||
p := iace.EstimateAvoidabilityP(mp.HazardCats, mp.ScenarioDE)
|
||||
_, level := iace.EstimateRiskLevel(mp.DefaultSeverity, mp.DefaultExposure, w, p)
|
||||
desc += fmt.Sprintf("\n\nRisikoeinschaetzung (BreakPilot-Modell): S%d · F%d · W%d · P%d → Risiko: %s",
|
||||
mp.DefaultSeverity, mp.DefaultExposure, w, p, level)
|
||||
}
|
||||
if mp.ISO12100Section != "" {
|
||||
desc += "\n\nKlassifikation: EN ISO 12100 Anhang B, Abschnitt " + mp.ISO12100Section
|
||||
desc += "\n\nKlassifikation: EN ISO 12100 Abschnitt " + mp.ISO12100Section
|
||||
}
|
||||
|
||||
hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Risk-estimation data sources & licenses
|
||||
|
||||
Provenance for the probability (W) / avoidance (P) tiers in `risk_estimation.go`
|
||||
(`contactModeTable`). We do **not** vendor any raw dataset — only the small
|
||||
aggregate facts used as anchors plus our own calibrated tiers live in code.
|
||||
|
||||
## What we use and how
|
||||
|
||||
The tiers are derived in two steps:
|
||||
|
||||
1. **Anchor** — the *relative ordering* of injury contact modes from public,
|
||||
permissively-licensed occupational-accident statistics (which mechanisms are
|
||||
more vs. less frequent).
|
||||
2. **Calibrate** — adjust the tier *values* to our own ground-truth corpus
|
||||
(the professional's W/P per mode). Well-sampled modes are set to the GT mean;
|
||||
sparse modes use conservative defaults (no overfitting to a 2-GT sample).
|
||||
|
||||
The numbers in code are therefore **ours**, not a copy of any dataset, and they
|
||||
do **not** reproduce any standard's risk-graph table, decision tree or matrix.
|
||||
|
||||
## Primary source — Eurostat ESAW
|
||||
|
||||
- **Dataset:** European Statistics on Accidents at Work (ESAW), contact mode of injury.
|
||||
- **License:** **CC BY 4.0** — commercial and non-commercial reuse permitted,
|
||||
source acknowledgement required.
|
||||
- **Attribution string:** `Source: Eurostat (ESAW), CC BY 4.0` — surface this in
|
||||
any generated risk-assessment export that shows engine risk numbers.
|
||||
- **URL:** https://ec.europa.eu/eurostat/statistics-explained/index.php/Accidents_at_work_-_statistics_on_causes_and_circumstances
|
||||
- **Aggregate facts used (anchor only):** contact-mode shares of accidents at
|
||||
work, e.g. impact with stationary object ~24%, struck by moving object ~13%
|
||||
(non-fatal) / ~24% (fatal), trapped/crushed ~14% (fatal), contact with sharp
|
||||
agent ~15%. Retrieved 2026-06.
|
||||
|
||||
## Acceptable supplements
|
||||
|
||||
- **US BLS / OSHA** (Bureau of Labor Statistics, occupational injuries) — **U.S.
|
||||
Government work, public domain**; free for any use.
|
||||
- **UK HSE** (RIDDOR / kinds-of-accident) — **Open Government Licence v3**;
|
||||
commercial reuse with attribution.
|
||||
|
||||
## Explicitly excluded
|
||||
|
||||
- **DGUV statistics** — terms grant only editorial use and forbid modification
|
||||
/ re-licensing; **unsuitable for a commercial product**. Not used.
|
||||
- **DIN / Beuth / ISO / IEC standards** (e.g. risk-graph tables, parameter
|
||||
decision trees, SIL/PL matrices) — copyrighted; **not reproduced or
|
||||
re-implemented**. Our model uses only the universal, non-protectable risk
|
||||
*dimensions* (severity, frequency, probability, avoidance).
|
||||
|
||||
## Maintenance
|
||||
|
||||
When a tier in `contactModeTable` changes, record the source figure and the GT
|
||||
calibration basis here. Add this file to the repository SBOM / license register
|
||||
alongside software dependencies.
|
||||
@@ -0,0 +1,265 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Risk benchmark: engine risk parameters vs. the professional's (Fachmann) GT.
|
||||
//
|
||||
// The risk numbers have never been validated. This test measures — for the
|
||||
// first time — how far the engine's per-pattern risk defaults are from the
|
||||
// professional's EN-62061-style assessment in the ground truth, for every
|
||||
// matched hazard across both GTs.
|
||||
//
|
||||
// COPYRIGHT NOTE: this test only COMPARES numbers (our defaults vs the GT's
|
||||
// values) and computes agreement statistics. It does NOT reproduce any DIN/
|
||||
// Beuth/ISO risk-graph table, parameter decision tree, or normative formula.
|
||||
// The GT values are the professional's assessment of a specific machine, not
|
||||
// the standard's text. Any future estimator must likewise derive parameters
|
||||
// from OUR own model + PUBLIC accident data (ESAW/DGUV), never from a
|
||||
// transcribed norm table.
|
||||
//
|
||||
// Parameter mapping (engine default -> GT column, EN-62061 naming):
|
||||
//
|
||||
// DefaultSeverity <-> GT.S (Se, severity)
|
||||
// DefaultExposure <-> GT.F (Fr, frequency / duration of exposure)
|
||||
// DefaultAvoidability <-> GT.P (Av, possibility of avoidance)
|
||||
// (none) <-> GT.W (Pr, probability of occurrence) <-- the gap
|
||||
//
|
||||
// Run with:
|
||||
//
|
||||
// go test -v -vet=off -run TestGT_RiskBenchmark ./internal/iace/
|
||||
// ============================================================================
|
||||
|
||||
type riskParams struct {
|
||||
s, f, a int // severity, frequency/exposure, avoidability (engine defaults)
|
||||
cats []string
|
||||
scenario string
|
||||
}
|
||||
|
||||
type axisStats struct {
|
||||
n int
|
||||
absErrSum float64
|
||||
exact int
|
||||
within1 int
|
||||
}
|
||||
|
||||
func (a *axisStats) add(engine, gt int) {
|
||||
a.n++
|
||||
d := math.Abs(float64(engine - gt))
|
||||
a.absErrSum += d
|
||||
if d == 0 {
|
||||
a.exact++
|
||||
}
|
||||
if d <= 1 {
|
||||
a.within1++
|
||||
}
|
||||
}
|
||||
|
||||
func (a axisStats) mae() float64 {
|
||||
if a.n == 0 {
|
||||
return 0
|
||||
}
|
||||
return a.absErrSum / float64(a.n)
|
||||
}
|
||||
func (a axisStats) pct(x int) float64 {
|
||||
if a.n == 0 {
|
||||
return 0
|
||||
}
|
||||
return 100 * float64(x) / float64(a.n)
|
||||
}
|
||||
|
||||
// kendallConcordance returns the fraction of comparable hazard pairs that the
|
||||
// engine orders the same way the professional does (rank agreement, scale-
|
||||
// invariant). 1.0 = identical ordering, 0.5 = random, 0.0 = inverted.
|
||||
func kendallConcordance(engine, gt []float64) (float64, int) {
|
||||
concordant, discordant := 0, 0
|
||||
for i := 0; i < len(engine); i++ {
|
||||
for j := i + 1; j < len(engine); j++ {
|
||||
de := engine[i] - engine[j]
|
||||
dg := gt[i] - gt[j]
|
||||
if de == 0 || dg == 0 {
|
||||
continue // tie on one side: not comparable
|
||||
}
|
||||
if (de > 0) == (dg > 0) {
|
||||
concordant++
|
||||
} else {
|
||||
discordant++
|
||||
}
|
||||
}
|
||||
}
|
||||
total := concordant + discordant
|
||||
if total == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
return float64(concordant) / float64(total), total
|
||||
}
|
||||
|
||||
type riskAgg struct {
|
||||
sev, freq, avoid axisStats
|
||||
wEst, pEst axisStats
|
||||
noAvoidDefault int
|
||||
engineRisk []float64
|
||||
newEngineRisk []float64
|
||||
gtRisk []float64
|
||||
matched int
|
||||
noParam int
|
||||
}
|
||||
|
||||
// TestGT_RiskCalibrationData logs, per contact mode, the professional's mean
|
||||
// W and P vs our current estimate — the input for calibrating contactModeTable.
|
||||
func TestGT_RiskCalibrationData(t *testing.T) {
|
||||
type acc struct {
|
||||
n int
|
||||
sumGTW, sumGTP int
|
||||
estW, estP int
|
||||
}
|
||||
byMode := map[string]*acc{}
|
||||
|
||||
for _, c := range gtBenchmarkCases {
|
||||
gtData, narrative, _ := readGTNarrative(t, c.path)
|
||||
if c.narrativeOverride != "" {
|
||||
narrative = c.narrativeOverride
|
||||
}
|
||||
pr := ParseNarrative(narrative, c.machineType)
|
||||
out := NewPatternEngine().Match(parseResultToMatchInput(pr, c.machineType))
|
||||
byName := map[string]riskParams{}
|
||||
for _, pm := range out.MatchedPatterns {
|
||||
key := normalizeDE(pm.ScenarioDE)
|
||||
if key == "" {
|
||||
key = normalizeDE(pm.PatternName)
|
||||
}
|
||||
byName[key] = riskParams{cats: pm.HazardCats, scenario: pm.ScenarioDE}
|
||||
}
|
||||
hazards, mitigations := patternsToHazardsAndMitigations(out)
|
||||
res := CompareBenchmark(>Data, hazards, mitigations)
|
||||
for _, mp := range res.MatchedPairs {
|
||||
rp, ok := byName[normalizeDE(mp.EngineHazard.Name)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mode := DetectContactMode(rp.cats, rp.scenario)
|
||||
if mode == "" {
|
||||
mode = "(none)"
|
||||
}
|
||||
a := byMode[mode]
|
||||
if a == nil {
|
||||
a = &acc{estW: EstimateProbabilityW(rp.cats, rp.scenario), estP: EstimateAvoidabilityP(rp.cats, rp.scenario)}
|
||||
byMode[mode] = a
|
||||
}
|
||||
a.n++
|
||||
a.sumGTW += mp.GTEntry.RiskIn.W
|
||||
a.sumGTP += mp.GTEntry.RiskIn.P
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("=== Per-contact-mode calibration data (GT mean vs our tier) ===")
|
||||
t.Logf(" %-18s %4s | %7s %7s | %7s %7s", "mode", "n", "estW", "gtW̄", "estP", "gtP̄")
|
||||
for mode, a := range byMode {
|
||||
t.Logf(" %-18s %4d | %7d %7.1f | %7d %7.1f",
|
||||
mode, a.n, a.estW, float64(a.sumGTW)/float64(a.n), a.estP, float64(a.sumGTP)/float64(a.n))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGT_RiskBenchmark(t *testing.T) {
|
||||
overall := riskAgg{}
|
||||
|
||||
for _, c := range gtBenchmarkCases {
|
||||
gtData, narrative, _ := readGTNarrative(t, c.path)
|
||||
if c.narrativeOverride != "" {
|
||||
narrative = c.narrativeOverride
|
||||
}
|
||||
pr := ParseNarrative(narrative, c.machineType)
|
||||
out := NewPatternEngine().Match(parseResultToMatchInput(pr, c.machineType))
|
||||
|
||||
// Index engine risk params by the hazard name the matcher will see
|
||||
// (patternsToHazardsAndMitigations sets Hazard.Name = ScenarioDE, else PatternName).
|
||||
byName := map[string]riskParams{}
|
||||
for _, pm := range out.MatchedPatterns {
|
||||
key := normalizeDE(pm.ScenarioDE)
|
||||
if key == "" {
|
||||
key = normalizeDE(pm.PatternName)
|
||||
}
|
||||
byName[key] = riskParams{s: pm.DefaultSeverity, f: pm.DefaultExposure, a: pm.DefaultAvoidability, cats: pm.HazardCats, scenario: pm.ScenarioDE}
|
||||
}
|
||||
|
||||
hazards, mitigations := patternsToHazardsAndMitigations(out)
|
||||
res := CompareBenchmark(>Data, hazards, mitigations)
|
||||
|
||||
local := riskAgg{}
|
||||
for _, mp := range res.MatchedPairs {
|
||||
rp, ok := byName[normalizeDE(mp.EngineHazard.Name)]
|
||||
if !ok {
|
||||
local.noParam++
|
||||
overall.noParam++
|
||||
continue
|
||||
}
|
||||
gtR := mp.GTEntry.RiskIn
|
||||
local.matched++
|
||||
overall.matched++
|
||||
if rp.s > 0 && gtR.S > 0 {
|
||||
local.sev.add(rp.s, gtR.S)
|
||||
overall.sev.add(rp.s, gtR.S)
|
||||
}
|
||||
if rp.f > 0 && gtR.F > 0 {
|
||||
local.freq.add(rp.f, gtR.F)
|
||||
overall.freq.add(rp.f, gtR.F)
|
||||
}
|
||||
if rp.a > 0 && gtR.P > 0 {
|
||||
local.avoid.add(rp.a, gtR.P)
|
||||
overall.avoid.add(rp.a, gtR.P)
|
||||
}
|
||||
if rp.a == 0 {
|
||||
local.noAvoidDefault++
|
||||
overall.noAvoidDefault++
|
||||
}
|
||||
|
||||
// NEW: data-anchored estimates for the two missing axes.
|
||||
estW := EstimateProbabilityW(rp.cats, rp.scenario)
|
||||
estP := EstimateAvoidabilityP(rp.cats, rp.scenario)
|
||||
if gtR.W > 0 {
|
||||
local.wEst.add(estW, gtR.W)
|
||||
overall.wEst.add(estW, gtR.W)
|
||||
}
|
||||
if gtR.P > 0 {
|
||||
local.pEst.add(estP, gtR.P)
|
||||
overall.pEst.add(estP, gtR.P)
|
||||
}
|
||||
|
||||
// Two risk proxies for RANK comparison (our own aggregates, NOT a
|
||||
// norm formula): OLD = today's engine (severity x exposure, with
|
||||
// avoidability mostly unset); NEW = severity scaled by summed
|
||||
// likelihood factors incl. the estimated W and P.
|
||||
sev := maxInt(rp.s, 1)
|
||||
oldProxy := float64(sev * maxInt(rp.f, 1) * maxInt(rp.a, 1))
|
||||
newProxy := float64(sev * (maxInt(rp.f, 1) + estW + estP))
|
||||
local.engineRisk = append(local.engineRisk, oldProxy)
|
||||
local.newEngineRisk = append(local.newEngineRisk, newProxy)
|
||||
local.gtRisk = append(local.gtRisk, float64(gtR.R))
|
||||
overall.engineRisk = append(overall.engineRisk, oldProxy)
|
||||
overall.newEngineRisk = append(overall.newEngineRisk, newProxy)
|
||||
overall.gtRisk = append(overall.gtRisk, float64(gtR.R))
|
||||
}
|
||||
|
||||
oldConc, _ := kendallConcordance(local.engineRisk, local.gtRisk)
|
||||
newConc, pairs := kendallConcordance(local.newEngineRisk, local.gtRisk)
|
||||
t.Logf("=== %s — Risk benchmark ===", c.name)
|
||||
t.Logf(" Matched hazards w/ engine params: %d (%d pairs had no pattern param)", local.matched, local.noParam)
|
||||
t.Logf(" Severity S: MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", local.sev.mae(), local.sev.pct(local.sev.within1), local.sev.pct(local.sev.exact), local.sev.n)
|
||||
t.Logf(" Frequency F: MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", local.freq.mae(), local.freq.pct(local.freq.within1), local.freq.pct(local.freq.exact), local.freq.n)
|
||||
t.Logf(" Probability W (NEW estimate): MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", local.wEst.mae(), local.wEst.pct(local.wEst.within1), local.wEst.pct(local.wEst.exact), local.wEst.n)
|
||||
t.Logf(" Avoidance P (NEW estimate): MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", local.pEst.mae(), local.pEst.pct(local.pEst.within1), local.pEst.pct(local.pEst.exact), local.pEst.n)
|
||||
t.Logf(" Risk RANK concordance: OLD %.1f%% -> NEW %.1f%% (over %d comparable pairs)", oldConc*100, newConc*100, pairs)
|
||||
}
|
||||
|
||||
oldConc, _ := kendallConcordance(overall.engineRisk, overall.gtRisk)
|
||||
newConc, pairs := kendallConcordance(overall.newEngineRisk, overall.gtRisk)
|
||||
t.Logf("\n=== Cross-GT aggregate ===")
|
||||
t.Logf(" Severity S: MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", overall.sev.mae(), overall.sev.pct(overall.sev.within1), overall.sev.pct(overall.sev.exact), overall.sev.n)
|
||||
t.Logf(" Frequency F: MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", overall.freq.mae(), overall.freq.pct(overall.freq.within1), overall.freq.pct(overall.freq.exact), overall.freq.n)
|
||||
t.Logf(" Probability W (NEW): MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", overall.wEst.mae(), overall.wEst.pct(overall.wEst.within1), overall.wEst.pct(overall.wEst.exact), overall.wEst.n)
|
||||
t.Logf(" Avoidance P (NEW): MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", overall.pEst.mae(), overall.pEst.pct(overall.pEst.within1), overall.pEst.pct(overall.pEst.exact), overall.pEst.n)
|
||||
t.Logf(" Risk RANK concordance: OLD %.1f%% -> NEW %.1f%% (%d pairs)", oldConc*100, newConc*100, pairs)
|
||||
}
|
||||
@@ -76,12 +76,9 @@ 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 is our avoidance parameter: 1 = avoidable under
|
||||
// certain conditions, 2 = hardly avoidable. Feeds BreakPilot's own risk
|
||||
// model (risk_estimation.go) — NOT a reproduced norm risk graph.
|
||||
DefaultAvoidability int `json:"default_avoidability,omitempty"` // 1 or 2
|
||||
// SecondaryHarms describes consequential damage chains beyond the
|
||||
// classical IACE Hazard→Harm step: end-customer safety, product
|
||||
@@ -91,45 +88,6 @@ type HazardPattern struct {
|
||||
SecondaryHarms []SecondaryHarm `json:"secondary_harms,omitempty"`
|
||||
}
|
||||
|
||||
// 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).
|
||||
const (
|
||||
RoleOperator = "operator"
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package iace
|
||||
|
||||
import "strings"
|
||||
|
||||
// Risk parameter estimation — probability of occurrence (W) and possibility of
|
||||
// avoidance (P) — for auto-generated hazards.
|
||||
//
|
||||
// COPYRIGHT / IP NOTE: This is BreakPilot's OWN heuristic model. It does NOT
|
||||
// reproduce, transcribe or re-implement any DIN/Beuth/ISO/IEC risk-graph table,
|
||||
// parameter decision tree, threshold or matrix. It derives values on OUR OWN
|
||||
// 1-5 scale from (a) PUBLIC, permissively-licensed occupational-accident
|
||||
// statistics organised by contact mode — primarily Eurostat ESAW (CC BY 4.0,
|
||||
// commercial reuse permitted with source attribution); US BLS/OSHA (public
|
||||
// domain) and UK HSE (Open Government Licence) are acceptable supplements —
|
||||
// and (b) observable machine facts the engine already extracts (hazard
|
||||
// category, scenario kinematics). The scale and weights are ours and are
|
||||
// calibrated against our own ground-truth corpus, not copied from a standard.
|
||||
// NOTE: DGUV statistics are NOT used — their terms permit only editorial use
|
||||
// and forbid modification, so they are unsuitable for a commercial product.
|
||||
// Provenance, exact figures used and attribution: see DATA_SOURCES.md.
|
||||
//
|
||||
// The universal risk DIMENSIONS (severity, frequency, probability, avoidance)
|
||||
// are general engineering concepts, not protectable expression.
|
||||
|
||||
// contactMode is a coarse injury-mechanism class. ESAW/DGUV publish accident
|
||||
// frequencies by such modes; we use that public ordering to anchor a relative
|
||||
// probability tier, and the injury kinematics to anchor an avoidance tier.
|
||||
type contactMode struct {
|
||||
name string
|
||||
// baseW: relative probability-of-occurrence tier (1-5). Anchored to the
|
||||
// ESAW contact-mode frequency ranking (impact/struck-by/crush/cut are the
|
||||
// most frequent; pressure-burst/radiation are rare). OUR calibrated scale.
|
||||
baseW int
|
||||
// baseP: avoidance-difficulty tier (1-5; higher = harder to avoid).
|
||||
// Anchored to injury kinematics (sudden, no-warning events are hard to
|
||||
// avoid; gradual exposure is easy). OUR reasoning, no norm table.
|
||||
baseP int
|
||||
}
|
||||
|
||||
// contactModeTable — our tiers. Initially anchored to the public ESAW
|
||||
// contact-mode frequency ranking, then CALIBRATED against our own ground-truth
|
||||
// corpus (the professional's W/P distribution per mode). The well-sampled modes
|
||||
// (crushing n=40, electrical n=20, struck_by n=14) are set to the GT means;
|
||||
// sparsely-sampled modes (n<=4) use conservative defaults to avoid overfitting
|
||||
// to noise from a 2-GT sample. This is the single place to tune; never
|
||||
// hard-code per-machine values into patterns. See DATA_SOURCES.md for the
|
||||
// public-data provenance and license.
|
||||
var contactModeTable = map[string]contactMode{
|
||||
"impact_stationary": {"impact_stationary", 3, 1}, // seen coming -> easy to avoid
|
||||
"struck_by": {"struck_by", 2, 3}, // GT-calibrated (n=14)
|
||||
"crushing": {"crushing", 2, 3}, // GT-calibrated (n=40)
|
||||
"cutting": {"cutting", 2, 3},
|
||||
"entanglement": {"entanglement", 3, 3},
|
||||
"shearing": {"shearing", 2, 3},
|
||||
"fall": {"fall", 3, 4}, // higher avoidance difficulty in GT
|
||||
"electrical": {"electrical", 2, 3}, // GT-calibrated (n=20)
|
||||
"thermal": {"thermal", 2, 2},
|
||||
"ergonomic": {"ergonomic", 2, 3},
|
||||
"chemical": {"chemical", 2, 3},
|
||||
"pressure_burst": {"pressure_burst", 2, 3},
|
||||
"radiation": {"radiation", 2, 3},
|
||||
}
|
||||
|
||||
// contactModeKeywords maps umlaut-normalised scenario keywords to a contact
|
||||
// mode. Order-independent; the first matching mode in detection order wins.
|
||||
var contactModeKeywords = []struct {
|
||||
mode string
|
||||
keywords []string
|
||||
}{
|
||||
{"crushing", []string{"quetsch", "einklemm", "eingeklemmt", "klemm", "zerquetsch"}},
|
||||
{"entanglement", []string{"einzug", "eingezogen", "erfasst", "aufwickel", "umwickel", "wickelt"}},
|
||||
{"shearing", []string{"scher"}},
|
||||
{"cutting", []string{"schneid", "schnitt", "scharfe kante", "abtrenn", "amputation", "stich"}},
|
||||
{"electrical", []string{"stromschlag", "spannungsfuehr", "koerperdurchstroem", "beruehrungsspannung", "lichtbogen", "elektrisch"}},
|
||||
{"thermal", []string{"verbrenn", "verbruehung", "heisse", "thermisch", "heisser"}},
|
||||
{"pressure_burst", []string{"bersten", "hochdruck", "ueberdruck", "druckbehaelter", "injektion"}},
|
||||
{"fall", []string{"sturz", "stuerz", "absturz", "ausrutsch", "stolper", "abstuerz"}},
|
||||
{"struck_by", []string{"weggeschleudert", "geschleudert", "geschoss", "herabfallen", "herabstuerz", "getroffen", "wegfliegen", "fallende last", "schlag"}},
|
||||
{"impact_stationary", []string{"anstossen", "anprall", "stossen gegen", "stoss gegen"}},
|
||||
{"ergonomic", []string{"belastung", "ergonom", "zwangshaltung", "manuelles heben", "ueberlastung"}},
|
||||
{"chemical", []string{"exposition", "gefahrstoff", "daempfe", "kontamination", "reizung", "aerosol", "vergiftung"}},
|
||||
}
|
||||
|
||||
// categoryDefaultMode is the fallback contact mode per hazard category when the
|
||||
// scenario text carries no specific kinematic keyword.
|
||||
var categoryDefaultMode = map[string]string{
|
||||
"mechanical_hazard": "crushing",
|
||||
"electrical_hazard": "electrical",
|
||||
"thermal_hazard": "thermal",
|
||||
"chemical_hazard": "chemical",
|
||||
"material_environmental": "chemical",
|
||||
"ergonomic": "ergonomic",
|
||||
"noise_vibration": "ergonomic",
|
||||
"radiation_hazard": "radiation",
|
||||
"fire_explosion": "thermal",
|
||||
"pneumatic_hydraulic": "pressure_burst",
|
||||
}
|
||||
|
||||
// DetectContactMode classifies a hazard's injury mechanism from its scenario
|
||||
// text first, then its category. Returns the contact-mode key, or "" if none.
|
||||
func DetectContactMode(cats []string, scenario string) string {
|
||||
text := normalizeDE(scenario)
|
||||
for _, e := range contactModeKeywords {
|
||||
for _, kw := range e.keywords {
|
||||
if strings.Contains(text, kw) {
|
||||
return e.mode
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range cats {
|
||||
if m, ok := categoryDefaultMode[c]; ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// EstimateProbabilityW returns the probability-of-occurrence tier (1-5) for a
|
||||
// hazard, anchored to the public accident-frequency ranking of its contact
|
||||
// mode. Returns 3 (neutral) when the mode is unknown.
|
||||
func EstimateProbabilityW(cats []string, scenario string) int {
|
||||
if m, ok := contactModeTable[DetectContactMode(cats, scenario)]; ok {
|
||||
return m.baseW
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
// EstimateAvoidabilityP returns the avoidance-difficulty tier (1-5; higher =
|
||||
// harder to avoid) from the contact mode's kinematics. Returns 3 when unknown.
|
||||
func EstimateAvoidabilityP(cats []string, scenario string) int {
|
||||
if m, ok := contactModeTable[DetectContactMode(cats, scenario)]; ok {
|
||||
return m.baseP
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
// EstimateRiskLevel combines the four parameters into BreakPilot's OWN risk
|
||||
// index and band. The index is a generic severity-weighted sum of the
|
||||
// likelihood factors — index = S * (F + W + P) — i.e. basic arithmetic on the
|
||||
// universal risk dimensions. It is NOT a reproduction of any standard's
|
||||
// risk graph, parameter table or SIL/PL matrix. The bands are ours, tuned to
|
||||
// our ground-truth corpus. Returns (index 3..75, German level label).
|
||||
func EstimateRiskLevel(s, f, w, p int) (int, string) {
|
||||
if s < 1 {
|
||||
s = 1
|
||||
}
|
||||
idx := s * (f + w + p)
|
||||
switch {
|
||||
case idx >= 45:
|
||||
return idx, "kritisch"
|
||||
case idx >= 30:
|
||||
return idx, "hoch"
|
||||
case idx >= 18:
|
||||
return idx, "mittel"
|
||||
case idx >= 9:
|
||||
return idx, "gering"
|
||||
default:
|
||||
return idx, "vernachlaessigbar"
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user