feat(iace): Sprint 4B — ISO 12100 Hazard/Situation/Harm Trennung

ISO 12100 trennt: Hazard (Quelle) → Hazardous Situation (Person exponiert) → Harm (Verletzung).
Bisher war alles in einem Hazard-Record vermischt.

Implementierung als abgeleitetes Feld (keine DB-Migration noetig):
- HazardType Feld auf Hazard Entity ("hazard"|"hazardous_situation"|"harm")
- DeriveHazardType() berechnet Typ aus Scenario/PossibleHarm/Category
- Explizites Override moeglich (HazardType direkt setzen)
- GeneratedHazardType auf HazardPattern fuer Pattern-gesteuerte Zuweisung
- Store: GetHazard/ListHazards setzen HazardType automatisch
- Init-Handler: Fuellt jetzt TriggerEvent, PossibleHarm, AffectedPerson, HazardousZone
  aus Pattern-Match-Daten (vorher leer gelassen)

6 neue Tests: ScenarioAndHarm, HarmOnly, CategoryOnly, ExplicitOverride,
EmptyFallback, PatternMatchField

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-10 20:55:26 +02:00
parent 6e995b52d1
commit d339d1edc7
7 changed files with 119 additions and 10 deletions
@@ -140,13 +140,21 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
name = cat
}
scenario := mp.ScenarioDE
hazardType := mp.GeneratedHazardType
if hazardType == "" {
hazardType = iace.DefaultHazardType
}
hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{
ProjectID: projectID,
ComponentID: defaultCompID,
Name: name,
Description: scenario,
Category: cat,
Scenario: scenario,
ProjectID: projectID,
ComponentID: defaultCompID,
Name: name,
Description: scenario,
Category: cat,
Scenario: scenario,
TriggerEvent: mp.TriggerDE,
PossibleHarm: mp.HarmDE,
AffectedPerson: mp.AffectedDE,
HazardousZone: mp.ZoneDE,
})
if cerr == nil {
created++
@@ -46,6 +46,10 @@ type HazardPattern struct {
// interact with the machine. Empty/nil = fires for all roles (backwards compatible).
// Standard roles: operator, maintenance_tech, programmer, cleaning_staff, bystander, supervisor.
HumanRoles []string `json:"human_roles,omitempty"`
// GeneratedHazardType sets the ISO 12100 type for hazards created by this pattern.
// "hazard" = source only, "hazardous_situation" = person exposed, "harm" = injury.
// Empty = default (hazardous_situation).
GeneratedHazardType string `json:"generated_hazard_type,omitempty"`
}
// Standard human roles for machinery interaction (ISO 12100 + BetrSichV).
+31
View File
@@ -57,6 +57,37 @@ const (
HazardStatusClosed HazardStatus = "closed"
)
// HazardType distinguishes ISO 12100 concepts in the hazard chain:
// Hazard → Hazardous Situation → Harm
const (
HazardTypeHazard = "hazard" // Source of potential harm (e.g. rotating shaft)
HazardTypeHazardousSituation = "hazardous_situation" // Person exposed to hazard (e.g. operator near shaft)
HazardTypeHarm = "harm" // Injury outcome (e.g. entanglement)
DefaultHazardType = HazardTypeHazardousSituation
)
// DeriveHazardType determines the ISO 12100 hazard type from the hazard's fields.
// If an explicit type is set, it is returned as-is. Otherwise:
// - PossibleHarm filled + Scenario filled → "hazardous_situation" (most specific)
// - Only PossibleHarm filled → "harm"
// - Only TriggerEvent/Category → "hazard" (source only)
// - Default fallback → "hazardous_situation"
func DeriveHazardType(h *Hazard) string {
if h.HazardType != "" {
return h.HazardType
}
if h.Scenario != "" && h.PossibleHarm != "" {
return HazardTypeHazardousSituation
}
if h.PossibleHarm != "" && h.Scenario == "" {
return HazardTypeHarm
}
if h.Scenario == "" && h.PossibleHarm == "" && h.Category != "" {
return HazardTypeHazard
}
return DefaultHazardType
}
// AssessmentType represents the type of risk assessment
type AssessmentType string
@@ -111,6 +111,12 @@ type Hazard struct {
TriggerEvent string `json:"trigger_event,omitempty"`
AffectedPerson string `json:"affected_person,omitempty"`
PossibleHarm string `json:"possible_harm,omitempty"`
// HazardType distinguishes ISO 12100 concepts:
// "hazard" — the source of potential harm (e.g. mechanical movement)
// "hazardous_situation" — person exposed to hazard (e.g. person in motion range)
// "harm" — the injury outcome (e.g. crushing)
// Derived field — not stored in DB. Computed by DeriveHazardType().
HazardType string `json:"hazard_type,omitempty"`
ReviewStatus ReviewStatus `json:"review_status,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -56,7 +56,8 @@ type PatternMatch struct {
RequiresExpert bool `json:"requires_expert,omitempty"`
OperationalStates []string `json:"operational_states,omitempty"`
StateTransitions []string `json:"state_transitions,omitempty"`
HumanRoles []string `json:"human_roles,omitempty"`
HumanRoles []string `json:"human_roles,omitempty"`
GeneratedHazardType string `json:"generated_hazard_type,omitempty"`
}
// HazardSuggestion is a suggested hazard from pattern matching.
@@ -225,9 +226,10 @@ func (e *PatternEngine) Match(input MatchInput) *MatchOutput {
HazardCats: p.GeneratedHazardCats,
ExpertHintDE: p.ExpertHintDE,
RequiresExpert: p.RequiresExpertCalculation,
OperationalStates: p.OperationalStates,
StateTransitions: p.StateTransitions,
HumanRoles: p.HumanRoles,
OperationalStates: p.OperationalStates,
StateTransitions: p.StateTransitions,
HumanRoles: p.HumanRoles,
GeneratedHazardType: p.GeneratedHazardType,
})
for _, cat := range p.GeneratedHazardCats {
@@ -338,6 +338,62 @@ func TestEvidenceTypes_UniqueIDs(t *testing.T) {
}
}
// ══════════════════════════════════════════════════════════════════
// Sprint 4B: HazardType / ISO 12100 Trennung
// ══════════════════════════════════════════════════════════════════
func TestDeriveHazardType_WithScenarioAndHarm(t *testing.T) {
h := &Hazard{Scenario: "Person im Bewegungsraum", PossibleHarm: "Quetschung"}
if got := DeriveHazardType(h); got != HazardTypeHazardousSituation {
t.Errorf("expected hazardous_situation, got %s", got)
}
}
func TestDeriveHazardType_HarmOnly(t *testing.T) {
h := &Hazard{PossibleHarm: "Quetschung", Category: "mechanical"}
if got := DeriveHazardType(h); got != HazardTypeHarm {
t.Errorf("expected harm, got %s", got)
}
}
func TestDeriveHazardType_CategoryOnly(t *testing.T) {
h := &Hazard{Category: "mechanical_hazard"}
if got := DeriveHazardType(h); got != HazardTypeHazard {
t.Errorf("expected hazard, got %s", got)
}
}
func TestDeriveHazardType_ExplicitOverride(t *testing.T) {
h := &Hazard{HazardType: "harm", Scenario: "ignored", PossibleHarm: "ignored"}
if got := DeriveHazardType(h); got != "harm" {
t.Errorf("expected explicit harm, got %s", got)
}
}
func TestDeriveHazardType_EmptyFallback(t *testing.T) {
h := &Hazard{}
if got := DeriveHazardType(h); got != DefaultHazardType {
t.Errorf("expected default %s, got %s", DefaultHazardType, got)
}
}
func TestPatternMatch_GeneratedHazardType(t *testing.T) {
// Verify PatternMatch carries GeneratedHazardType
engine := NewPatternEngine()
result := engine.Match(MatchInput{
ComponentLibraryIDs: []string{"C001"},
EnergySourceIDs: []string{"EN01"},
})
// Most patterns don't set GeneratedHazardType (empty = default)
// Just verify the field exists and doesn't crash
for _, p := range result.MatchedPatterns {
_ = p.GeneratedHazardType // Should compile and not panic
}
if len(result.MatchedPatterns) == 0 {
t.Fatal("expected matched patterns")
}
}
func TestEvidenceTypes_SortOrder(t *testing.T) {
types := GetEvidenceTypeLibrary()
for i := 1; i < len(types); i++ {
@@ -95,6 +95,7 @@ func (s *Store) GetHazard(ctx context.Context, id uuid.UUID) (*Hazard, error) {
h.Status = HazardStatus(status)
h.ReviewStatus = ReviewStatus(reviewStatus)
h.HazardType = DeriveHazardType(&h)
return &h, nil
}
@@ -133,6 +134,7 @@ func (s *Store) ListHazards(ctx context.Context, projectID uuid.UUID) ([]Hazard,
h.Status = HazardStatus(status)
h.ReviewStatus = ReviewStatus(reviewStatus)
h.HazardType = DeriveHazardType(&h)
hazards = append(hazards, h)
}