6b41eec176
Makes the OSHA minimum-distance anchor visible per measure in a project without a DB schema change or re-seed: persisted mitigations store the measure NAME verbatim (not the catalog ID), and measure names are unique across the 578-entry library (pinned by test), so a name→ID resolver bridges the gap. Backend: MeasureIDByName + MinimumDistancesForMeasureName/LinksForMeasureName; /iace/minimum-distances now accepts ?measure_name=; link table enriched with measure_name for one-request UI matching. Frontend: useMinimumDistances loads the link table once and keys it by name; OshaDistanceNote renders the anchor (value/CFR/license/EU-hint/relation) on the matching measure group in the Maßnahmen tab. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
309 lines
13 KiB
Go
309 lines
13 KiB
Go
package iace
|
||
|
||
// Minimum-distance library — Task #18.
|
||
//
|
||
// Anchor source: OSHA 29 CFR 1910 Subpart O (US Federal Public Domain,
|
||
// 17 U.S.C. §105). The values below are reproduced verbatim from the
|
||
// Federal Code; conversions to metric are mathematical and carry no
|
||
// copyright. Engineering rounding to safe-side mm values is BreakPilot's
|
||
// recommendation and labelled as such.
|
||
//
|
||
// EU norm equivalents (EN ISO 13857, EN 349, EN 13855, EN 1010) are
|
||
// referenced by identifier only — no values are reproduced, because
|
||
// DIN/Beuth retain copyright on the wording. The DINComparisonNote
|
||
// field carries a human-curated judgement on whether the EU norm is
|
||
// stricter / looser / equivalent — this is a qualitative observation
|
||
// about a publicly available document, not a copy of its text.
|
||
//
|
||
// See LICENSE_RULES.md and project_attribution_strategy.md for the
|
||
// licensing logic. The OSHA values are R1 (verbatim public domain);
|
||
// the recommended metric values are BreakPilot engineering output (R3
|
||
// own-work). DIN references are R3 identifier-only.
|
||
|
||
// MinimumDistanceUnit denotes the original unit system of the source.
|
||
type MinimumDistanceUnit string
|
||
|
||
const (
|
||
UnitInch MinimumDistanceUnit = "inch"
|
||
UnitFoot MinimumDistanceUnit = "foot"
|
||
UnitMeter MinimumDistanceUnit = "meter"
|
||
UnitMM MinimumDistanceUnit = "mm"
|
||
)
|
||
|
||
// MinimumDistance is the data contract for a single safety-distance rule.
|
||
// It can be (a) a fixed gap value, (b) a distance range, or (c) a formula
|
||
// like OSHA's Ds = 63 in/s × Ts (hand-speed constant).
|
||
type MinimumDistance struct {
|
||
ID string `json:"id"` // MD_OSHA_001
|
||
// Source identifier — full CFR citation or norm reference.
|
||
SourceCFR string `json:"source_cfr,omitempty"` // "29 CFR §1910.217(c)(1)(i)"
|
||
SourceTable string `json:"source_table,omitempty"` // "Table O-10"
|
||
License string `json:"license"` // "US Federal Public Domain"
|
||
LicenseRule int `json:"license_rule"` // 1 / 2 / 3 (see LICENSE_RULES.md)
|
||
|
||
// Original verbatim value in the source's own unit.
|
||
OriginalUnit MinimumDistanceUnit `json:"original_unit"`
|
||
OriginalValue float64 `json:"original_value,omitempty"`
|
||
OriginalMin float64 `json:"original_min,omitempty"`
|
||
OriginalMax float64 `json:"original_max,omitempty"`
|
||
|
||
// Exact conversion to mm — no engineering rounding.
|
||
ExactMM float64 `json:"exact_mm,omitempty"`
|
||
ExactMinMM float64 `json:"exact_min_mm,omitempty"`
|
||
ExactMaxMM float64 `json:"exact_max_mm,omitempty"`
|
||
|
||
// Engineering-recommended metric value with safe-side rounding.
|
||
// For minimum distances: rounded up. For maximum opening widths:
|
||
// rounded down.
|
||
RecommendedMM int `json:"recommended_mm,omitempty"`
|
||
RecommendedMinMM int `json:"recommended_min_mm,omitempty"`
|
||
RecommendedMaxMM int `json:"recommended_max_mm,omitempty"`
|
||
RoundingNote string `json:"rounding_note,omitempty"`
|
||
|
||
// Optional formula constant (e.g. OSHA hand-speed 63 in/s).
|
||
FormulaInchPerSecond float64 `json:"formula_inch_per_second,omitempty"`
|
||
FormulaMMPerSecond float64 `json:"formula_mm_per_second,omitempty"`
|
||
FormulaDescription string `json:"formula_description,omitempty"`
|
||
|
||
Context string `json:"context"` // "Point of Operation Guarding mechanical presses"
|
||
BodyPart string `json:"body_part,omitempty"` // "finger" / "hand" / "head" / "foot" / "body"
|
||
HazardTags []string `json:"hazard_tags,omitempty"` // [crush_point, cutting_part, ...]
|
||
|
||
// EU norm cross-reference — IDENTIFIER ONLY, no values reproduced.
|
||
EUNormHints []EUNormHint `json:"eu_norm_hints,omitempty"`
|
||
}
|
||
|
||
// EUNormHint references an EU standard by identifier without reproducing
|
||
// any value or text from it. The DINComparisonNote is a human-curated
|
||
// qualitative judgement (stricter / equivalent / looser) — not a copy.
|
||
type EUNormHint struct {
|
||
Norm string `json:"norm"` // "EN ISO 13857"
|
||
Section string `json:"section,omitempty"` // "Tab. 4, Schutz gegen Hineingreifen"
|
||
DINComparisonNote string `json:"din_comparison_note,omitempty"`
|
||
}
|
||
|
||
// GetOSHAMinimumDistances returns the verbatim OSHA values for
|
||
// machine-guarding distances. All values are US Federal Public Domain
|
||
// (17 U.S.C. §105). Engineering rounding is BreakPilot's safe-side
|
||
// recommendation; OSHA values themselves are unchanged.
|
||
func GetOSHAMinimumDistances() []MinimumDistance {
|
||
return []MinimumDistance{
|
||
// OSHA Table O-10 row 1 — verbatim values, mathematical conversion,
|
||
// safe-side rounded engineering recommendation.
|
||
{
|
||
ID: "MD_OSHA_O10_R1",
|
||
SourceCFR: "29 CFR §1910.217(c)(1)(i)",
|
||
SourceTable: "Table O-10 row 1",
|
||
License: "US Federal Public Domain (17 U.S.C. §105)",
|
||
LicenseRule: 1,
|
||
OriginalUnit: UnitInch,
|
||
OriginalMin: 0.5, OriginalMax: 1.5, OriginalValue: 0.25,
|
||
ExactMinMM: 12.7, ExactMaxMM: 38.1, ExactMM: 6.35,
|
||
RecommendedMinMM: 15, RecommendedMaxMM: 40, RecommendedMM: 6,
|
||
RoundingNote: "Distance auf 5-mm-Raster aufgerundet, opening auf 1-mm-Raster abgerundet (konservativ in beide Richtungen).",
|
||
Context: "Point-of-Operation Guarding bei mechanischen Pressen",
|
||
BodyPart: "finger",
|
||
HazardTags: []string{"crush_point", "cutting_part"},
|
||
EUNormHints: []EUNormHint{
|
||
{Norm: "EN ISO 13857", Section: "Tab. 4 (Hineingreifen)",
|
||
DINComparisonNote: "Andere Methodik (Reichweitenmodell). Unabhaengig pruefen — Werte koennen abweichen."},
|
||
},
|
||
},
|
||
// OSHA Table O-10 row 4 — used as a worked example in the strategy
|
||
// discussion. Distance 3.5-5.5 in, opening max 5/8 in.
|
||
{
|
||
ID: "MD_OSHA_O10_R4",
|
||
SourceCFR: "29 CFR §1910.217(c)(1)(i)",
|
||
SourceTable: "Table O-10 row 4",
|
||
License: "US Federal Public Domain (17 U.S.C. §105)",
|
||
LicenseRule: 1,
|
||
OriginalUnit: UnitInch,
|
||
OriginalMin: 3.5, OriginalMax: 5.5, OriginalValue: 0.625,
|
||
ExactMinMM: 88.9, ExactMaxMM: 139.7, ExactMM: 15.875,
|
||
RecommendedMinMM: 90, RecommendedMaxMM: 140, RecommendedMM: 15,
|
||
RoundingNote: "Distance 88.9→90 (+1.1 mm), 139.7→140 (+0.3 mm) aufgerundet; Opening 15.875→15 (-0.875 mm) abgerundet.",
|
||
Context: "Point-of-Operation Guarding bei mechanischen Pressen",
|
||
BodyPart: "finger",
|
||
HazardTags: []string{"crush_point", "cutting_part"},
|
||
EUNormHints: []EUNormHint{
|
||
{Norm: "EN ISO 13857", Section: "Tab. 4 (Hineingreifen)",
|
||
DINComparisonNote: "Andere Methodik (Reichweitenmodell). Compliance-Annotation pflegen."},
|
||
},
|
||
},
|
||
// OSHA §1910.212(a)(5) — fan blade guards. Verbatim 1/2 inch.
|
||
{
|
||
ID: "MD_OSHA_212_FAN",
|
||
SourceCFR: "29 CFR §1910.212(a)(5)",
|
||
License: "US Federal Public Domain (17 U.S.C. §105)",
|
||
LicenseRule: 1,
|
||
OriginalUnit: UnitInch,
|
||
OriginalValue: 0.5,
|
||
ExactMM: 12.7,
|
||
RecommendedMM: 12,
|
||
RoundingNote: "Luefterblatt-Schutzgitter: max. Spaltoeffnung 1/2 in = 12.7 mm. Konservativ auf 12 mm abgerundet.",
|
||
Context: "Lüfterblätter unter 7 ft (2.13 m) Höhe",
|
||
BodyPart: "finger",
|
||
HazardTags: []string{"rotating_part", "cutting_part"},
|
||
EUNormHints: []EUNormHint{
|
||
{Norm: "EN ISO 13857", Section: "Tab. 4",
|
||
DINComparisonNote: "DIN-Wert pruefen."},
|
||
},
|
||
},
|
||
// OSHA §1910.217 Hand-Speed Constant — formula Ds = 63 in/s × Ts
|
||
{
|
||
ID: "MD_OSHA_217_PSDI",
|
||
SourceCFR: "29 CFR §1910.217 (Ds = 63 in/s × Ts)",
|
||
License: "US Federal Public Domain (17 U.S.C. §105)",
|
||
LicenseRule: 1,
|
||
OriginalUnit: UnitInch,
|
||
FormulaInchPerSecond: 63.0,
|
||
FormulaMMPerSecond: 1600.2,
|
||
FormulaDescription: "Hand-Speed-Konstante 63 in/s ≈ 1600 mm/s. " +
|
||
"Ds (Mindestabstand) = 63 × Ts (Stoppzeit Presse in Sekunden).",
|
||
Context: "PSDI Presence-Sensing Device Initiation und Two-Hand-Trip",
|
||
BodyPart: "hand",
|
||
HazardTags: []string{"crush_point", "high_speed"},
|
||
EUNormHints: []EUNormHint{
|
||
{Norm: "EN 13855", Section: "Sicherheitsabstaende",
|
||
DINComparisonNote: "EN 13855 nutzt andere Konstante (1600 mm/s ≈ identisch); EU-Norm unabhaengig pruefen."},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// MinimumDistanceNote is the ready-to-print licensing posture for the
|
||
// minimum-distance reference — shown by the API so an auditor sees WHY the
|
||
// OSHA values may be reproduced while the EU norms are reference-only.
|
||
const MinimumDistanceNote = "OSHA-Werte (29 CFR 1910) sind US-Public-Domain " +
|
||
"(17 U.S.C. §105) und werden verbatim wiedergegeben; die mm-Umrechnung ist " +
|
||
"mathematisch, die sicherheitsseitige Rundung ist BreakPilot-Empfehlung. " +
|
||
"EU-Normen (EN ISO 13857/13854/13855, EN 349) werden nur per Kennung " +
|
||
"referenziert — keine Werte reproduziert."
|
||
|
||
// GetMinimumDistanceByID returns the OSHA distance entry with the given ID.
|
||
func GetMinimumDistanceByID(id string) (MinimumDistance, bool) {
|
||
for _, md := range GetOSHAMinimumDistances() {
|
||
if md.ID == id {
|
||
return md, true
|
||
}
|
||
}
|
||
return MinimumDistance{}, false
|
||
}
|
||
|
||
// MeasureDistanceLink connects a protective measure to the OSHA distance
|
||
// entries that anchor it. Relation makes the nature of the link explicit so
|
||
// the join is honest rather than implying every measure's prose IS the OSHA
|
||
// value:
|
||
// - "value_source" — the OSHA value is the source the measure's own
|
||
// mm figure is derived from (it appears in the measure prose).
|
||
// - "public_domain_crossref" — the measure is dimensioned by an EU norm; the
|
||
// OSHA entry is offered as the public-domain pendant for independent check.
|
||
type MeasureDistanceLink struct {
|
||
MeasureID string `json:"measure_id"`
|
||
MeasureName string `json:"measure_name,omitempty"` // resolved from the library for name-based UI matching
|
||
DistanceIDs []string `json:"distance_ids"`
|
||
Relation string `json:"relation"`
|
||
Note string `json:"note,omitempty"`
|
||
}
|
||
|
||
const (
|
||
LinkValueSource = "value_source"
|
||
LinkCrossRef = "public_domain_crossref"
|
||
)
|
||
|
||
// AllMeasureDistanceLinks returns the curated measure→OSHA-distance links.
|
||
// Conservative on purpose: only measures whose CONTEXT genuinely matches an
|
||
// OSHA entry are linked. Measures whose "OSHA" citation is loose or carries an
|
||
// ISO value (e.g. M340 robot teach speed, M368 air-receiver wall) are NOT
|
||
// linked — that would imply a public-domain anchor that does not exist.
|
||
func AllMeasureDistanceLinks() []MeasureDistanceLink {
|
||
links := []MeasureDistanceLink{
|
||
{
|
||
MeasureID: "M600",
|
||
DistanceIDs: []string{"MD_OSHA_217_PSDI"},
|
||
Relation: LinkValueSource,
|
||
Note: "Hand-Speed-Konstante 1.600 mm/s (63 in/s) ist die Obergrenze, aus der die Kriechgeschwindigkeit am Endanschlag abgeleitet ist.",
|
||
},
|
||
{
|
||
MeasureID: "M254",
|
||
DistanceIDs: []string{"MD_OSHA_O10_R1", "MD_OSHA_O10_R4"},
|
||
Relation: LinkCrossRef,
|
||
Note: "OSHA Table O-10 (Point-of-Operation an mechanischen Pressen) als Public-Domain-Pendant zur ISO-13855-Methode — Werte eigenstaendig pruefen.",
|
||
},
|
||
{
|
||
MeasureID: "M065",
|
||
DistanceIDs: []string{"MD_OSHA_212_FAN"},
|
||
Relation: LinkCrossRef,
|
||
Note: "OSHA §1910.212(a)(5) Luefterschutz (max. 12 mm Spaltoeffnung) als Public-Domain-Pendant zu ISO 13857.",
|
||
},
|
||
}
|
||
// Enrich each link with the measure name so a name-based UI (persisted
|
||
// mitigations carry the name, not the ID) can match without a second lookup.
|
||
lib := GetProtectiveMeasureLibrary()
|
||
for i := range links {
|
||
for _, m := range lib {
|
||
if m.ID == links[i].MeasureID {
|
||
links[i].MeasureName = m.Name
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return links
|
||
}
|
||
|
||
// LinksForMeasure returns the distance links declared for one measure.
|
||
func LinksForMeasure(measureID string) []MeasureDistanceLink {
|
||
var out []MeasureDistanceLink
|
||
for _, l := range AllMeasureDistanceLinks() {
|
||
if l.MeasureID == measureID {
|
||
out = append(out, l)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// MinimumDistancesForMeasure resolves the OSHA distance entries linked to a
|
||
// protective measure. This is the join that finally lets the OSHA mm values
|
||
// "flow into" the measures (read-side), without mutating the measure object.
|
||
func MinimumDistancesForMeasure(measureID string) []MinimumDistance {
|
||
var out []MinimumDistance
|
||
for _, l := range LinksForMeasure(measureID) {
|
||
for _, id := range l.DistanceIDs {
|
||
if md, ok := GetMinimumDistanceByID(id); ok {
|
||
out = append(out, md)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// MeasureIDByName resolves a protective-measure name to its catalog ID. Measure
|
||
// names are unique across the library (verified by test), so a PERSISTED
|
||
// mitigation — which stores the measure name verbatim but NOT the catalog ID —
|
||
// can still be joined to its OSHA distance link without a DB schema change or a
|
||
// re-seed. This is the bridge that lets a project's measures surface the anchor.
|
||
func MeasureIDByName(name string) (string, bool) {
|
||
for _, m := range GetProtectiveMeasureLibrary() {
|
||
if m.Name == name {
|
||
return m.ID, true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// MinimumDistancesForMeasureName resolves OSHA distances by measure NAME.
|
||
func MinimumDistancesForMeasureName(name string) []MinimumDistance {
|
||
if id, ok := MeasureIDByName(name); ok {
|
||
return MinimumDistancesForMeasure(id)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// LinksForMeasureName resolves distance links by measure NAME.
|
||
func LinksForMeasureName(name string) []MeasureDistanceLink {
|
||
if id, ok := MeasureIDByName(name); ok {
|
||
return LinksForMeasure(id)
|
||
}
|
||
return nil
|
||
}
|