feat(iace): add Fine-Kinney risk model (citable, free, US-recognized)
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 / build-sha-integrity (push) Failing after 5s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Successful in 15s
CI / go-lint (push) Has been skipped
CI / test-go (push) Failing after 38s
CI / iace-gt-coverage (push) Successful in 23s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (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 / 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 / build-sha-integrity (push) Failing after 5s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Successful in 15s
CI / go-lint (push) Has been skipped
CI / test-go (push) Failing after 38s
CI / iace-gt-coverage (push) Successful in 23s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (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
Fine-Kinney (Fine 1971 / Kinney-Wiruth 1976): Risk = Probability x Exposure x Consequence — a PUBLISHED, freely-usable method (not a DIN/Beuth/ISO standard), widely used incl. CE-marking. Gives the professional a second, US-recognized model alongside the EN-62061-style one; German exporters get both for free and adjust with their own licensed norm data. risk_fine_kinney.go: SuggestFineKinney derives justified P/E/C from public anchors (ESAW frequency -> P, lifecycle -> E, de-biased severity -> C on the Fine-Kinney consequence scale) + ComputeFineKinney(p,e,c) so the professional can override with his own values. No norm table stored. GT benchmark (rank concordance vs the professional): Fine-Kinney 75.4% — beats the EN-62061-style model (69.3%) and the raw engine (57%). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -103,6 +103,7 @@ type riskAgg struct {
|
||||
noAvoidDefault int
|
||||
engineRisk []float64
|
||||
newEngineRisk []float64
|
||||
fkRisk []float64
|
||||
gtRisk []float64
|
||||
matched int
|
||||
noParam int
|
||||
@@ -243,11 +244,15 @@ func TestGT_RiskBenchmark(t *testing.T) {
|
||||
// NEW = de-biased severity scaled by summed likelihood incl. W + P.
|
||||
oldProxy := float64(maxInt(rp.s, 1) * maxInt(rp.f, 1) * maxInt(rp.a, 1))
|
||||
newProxy := float64(maxInt(estS, 1) * (maxInt(rp.f, 1) + estW + estP))
|
||||
// Fine-Kinney score (our citable backbone) for rank comparison.
|
||||
fk := SuggestFineKinney(rp.cats, rp.scenario, pr.LifecyclePhases, rp.s)
|
||||
local.engineRisk = append(local.engineRisk, oldProxy)
|
||||
local.newEngineRisk = append(local.newEngineRisk, newProxy)
|
||||
local.fkRisk = append(local.fkRisk, fk.Score)
|
||||
local.gtRisk = append(local.gtRisk, float64(gtR.R))
|
||||
overall.engineRisk = append(overall.engineRisk, oldProxy)
|
||||
overall.newEngineRisk = append(overall.newEngineRisk, newProxy)
|
||||
overall.fkRisk = append(overall.fkRisk, fk.Score)
|
||||
overall.gtRisk = append(overall.gtRisk, float64(gtR.R))
|
||||
}
|
||||
|
||||
@@ -260,7 +265,8 @@ func TestGT_RiskBenchmark(t *testing.T) {
|
||||
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)
|
||||
fkConc, _ := kendallConcordance(local.fkRisk, local.gtRisk)
|
||||
t.Logf(" Risk RANK concordance: OLD %.1f%% -> NEW %.1f%% | Fine-Kinney %.1f%% (over %d pairs)", oldConc*100, newConc*100, fkConc*100, pairs)
|
||||
}
|
||||
|
||||
oldConc, _ := kendallConcordance(overall.engineRisk, overall.gtRisk)
|
||||
@@ -271,5 +277,6 @@ func TestGT_RiskBenchmark(t *testing.T) {
|
||||
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)
|
||||
fkConc, _ := kendallConcordance(overall.fkRisk, overall.gtRisk)
|
||||
t.Logf(" Risk RANK concordance: OLD %.1f%% -> NEW %.1f%% | Fine-Kinney %.1f%% (%d pairs)", oldConc*100, newConc*100, fkConc*100, pairs)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Fine-Kinney risk model — BreakPilot's deterministic, citable risk backbone.
|
||||
//
|
||||
// Method: W.T. Fine, "Mathematical Evaluations for Controlling Hazards"
|
||||
// (1971, US Naval Ordnance Laboratory report) and G.F. Kinney & A.D. Wiruth,
|
||||
// "Practical Risk Analysis for Safety Management" (1976). Risk = P x E x C.
|
||||
// It is a PUBLISHED, freely-usable method — not a copyrighted DIN/Beuth/ISO
|
||||
// standard — and is widely used in industry (incl. CE-marking risk analysis).
|
||||
//
|
||||
// We derive SUGGESTED P/E/C values deterministically from PUBLIC, permissively
|
||||
// licensed sources (Eurostat ESAW frequencies, NIOSH/OSHA/MIL-STD-882 injury
|
||||
// outcomes — see DATA_SOURCES.md), each with a plain-language justification.
|
||||
// The professional then ADJUSTS them (e.g. from his own licensed DIN/Beuth
|
||||
// data) — the tool only supplies the formula and computes. We never reproduce
|
||||
// norm tables.
|
||||
|
||||
// FKParam is a suggested Fine-Kinney parameter value plus why we chose it.
|
||||
type FKParam struct {
|
||||
Value float64 `json:"value"`
|
||||
Justification string `json:"justification"`
|
||||
}
|
||||
|
||||
// FKAssessment is a full suggested Fine-Kinney assessment for one hazard.
|
||||
type FKAssessment struct {
|
||||
Probability FKParam `json:"probability"` // P: 0.1 .. 10
|
||||
Exposure FKParam `json:"exposure"` // E: 0.5 .. 10
|
||||
Consequence FKParam `json:"consequence"` // C: 1 .. 100
|
||||
Score float64 `json:"score"` // R = P * E * C
|
||||
Band string `json:"band"` // Fine-Kinney risk band label
|
||||
Action string `json:"action"` // suggested urgency of action
|
||||
}
|
||||
|
||||
// fkProbabilityByMode maps a contact mode to a Fine-Kinney probability value,
|
||||
// anchored to the ESAW relative frequency of that injury mechanism.
|
||||
var fkProbabilityByMode = map[string]float64{
|
||||
"impact_stationary": 6, "crushing": 6, "struck_by": 6, "ergonomic": 6,
|
||||
"cutting": 3, "entanglement": 3, "shearing": 3, "electrical": 3,
|
||||
"thermal": 3, "fall": 3,
|
||||
"chemical": 1, "pressure_burst": 1, "radiation": 0.5,
|
||||
}
|
||||
|
||||
// fkConsequenceFromSeverity maps our pattern-specific, de-biased severity (1-5)
|
||||
// onto the published Fine-Kinney consequence scale. Using the per-hazard
|
||||
// severity (not a coarse mode constant) preserves the ranking signal.
|
||||
// 1=first aid, 3=disability, 7=serious injury, 15=a fatality, 40=multiple.
|
||||
func fkConsequenceFromSeverity(s int) float64 {
|
||||
switch {
|
||||
case s >= 5:
|
||||
return 40
|
||||
case s == 4:
|
||||
return 15
|
||||
case s == 3:
|
||||
return 7
|
||||
case s == 2:
|
||||
return 3
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// SuggestFineKinney builds a justified Fine-Kinney assessment from public-data
|
||||
// anchors. Inputs are the hazard's categories, scenario text and the project's
|
||||
// lifecycle phases. Values are SUGGESTIONS the professional adjusts.
|
||||
func SuggestFineKinney(cats []string, scenario string, lifecyclePhases []string, defaultSeverity int) FKAssessment {
|
||||
mode := DetectContactMode(cats, scenario)
|
||||
|
||||
p := 3.0
|
||||
if v, ok := fkProbabilityByMode[mode]; ok {
|
||||
p = v
|
||||
}
|
||||
s := EstimateSeverity(cats, scenario, defaultSeverity)
|
||||
c := fkConsequenceFromSeverity(s)
|
||||
e := fkExposure(lifecyclePhases)
|
||||
|
||||
modeLabel := mode
|
||||
if modeLabel == "" {
|
||||
modeLabel = "unbestimmt"
|
||||
}
|
||||
a := FKAssessment{
|
||||
Probability: FKParam{p, "Eintrittswahrscheinlichkeit aus ESAW-Haeufigkeit der Kontaktart '" + modeLabel + "'"},
|
||||
Exposure: FKParam{e.value, e.reason},
|
||||
Consequence: FKParam{c, fmt.Sprintf("Konsequenz aus Schwere-Einstufung S%d (NIOSH/OSHA/MIL-STD-882-Verletzungsbild)", s)},
|
||||
}
|
||||
a.Score, a.Band, a.Action = ComputeFineKinney(p, e.value, c)
|
||||
return a
|
||||
}
|
||||
|
||||
type fkExp struct {
|
||||
value float64
|
||||
reason string
|
||||
}
|
||||
|
||||
// fkExposure maps the active lifecycle phases to a Fine-Kinney exposure value
|
||||
// (how often a person is exposed to the task).
|
||||
func fkExposure(phases []string) fkExp {
|
||||
has := func(needle string) bool {
|
||||
for _, p := range phases {
|
||||
if strings.Contains(p, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case has("normal_operation") || has("auto_operation") || has("manual_operation"):
|
||||
return fkExp{6, "Exposition: Normalbetrieb (taeglich/dauernd)"}
|
||||
case has("setup") || has("maintenance") || has("cleaning") || has("changeover"):
|
||||
return fkExp{3, "Exposition: Einricht-/Wartungs-/Reinigungstaetigkeit (gelegentlich)"}
|
||||
case len(phases) > 0:
|
||||
return fkExp{1, "Exposition: seltene Lebensphase (wenige Male pro Jahr)"}
|
||||
default:
|
||||
return fkExp{3, "Exposition: angenommen gelegentlich (keine Lebensphase angegeben)"}
|
||||
}
|
||||
}
|
||||
|
||||
// ComputeFineKinney returns the Fine-Kinney risk score (P*E*C) and the
|
||||
// published risk band + suggested action urgency. The professional may pass
|
||||
// his own adjusted P/E/C here (e.g. derived from his licensed DIN/Beuth data) —
|
||||
// the tool only computes; it stores no norm table.
|
||||
func ComputeFineKinney(p, e, c float64) (score float64, band, action string) {
|
||||
score = p * e * c
|
||||
switch {
|
||||
case score > 400:
|
||||
return score, "sehr hoch", "Taetigkeit einstellen / sofortige Massnahmen"
|
||||
case score > 200:
|
||||
return score, "hoch", "sofortige Sanierung erforderlich"
|
||||
case score > 70:
|
||||
return score, "wesentlich", "Sanierung erforderlich"
|
||||
case score > 20:
|
||||
return score, "moeglich", "Aufmerksamkeit, Massnahmen planen"
|
||||
default:
|
||||
return score, "gering", "ggf. akzeptabel, beobachten"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user