msds-tds/scripts/audit-and-report.py
2026-07-08 13:12:20 +00:00

361 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Audit all BG SDS documents against compliance specification and generate report."""
import os, re, json, subprocess
OUT_DIR = '/home/bachko/msds-tds/output'
REPORT_PATH = '/home/bachko/msds-tds/output/compliance-report-bg.md'
# All 34 products with their key data
PRODUCTS = [
("Ацетон", "Acetone", "67-64-1", ["H225","H319","H336","EUH066"]),
("Бутил ацетат", "Butyl acetate", "123-86-4", ["H226","H336","EUH066"]),
("Бутил Гликол", "Butyl glycol", "111-76-2", ["H302","H312","H315","H319","H332"]),
("Бутил Ди Гликол", "Butyl diglycol", "112-34-5", ["H319"]),
("N-Бутанол", "n-Butanol", "71-36-3", ["H226","H302","H315","H318","H336"]),
("Циклохексанон", "Cyclohexanone", "108-94-1", ["H226","H302","H332"]),
("Денатуриран етилов алкохол", "Denatured ethyl alcohol", "64-17-5", ["H225"]),
("Диметил Кетон", "Dimethyl ketone", "67-64-1", ["H225","H319","H336","EUH066"]),
("Диетилен гликол", "Diethylene glycol", "111-46-6", ["H302"]),
("Диоктил Терефталат", "Dioctyl terephthalate", "6422-86-2", []),
("Етил Ацетат", "Ethyl acetate", "141-78-6", ["H225","H319","H336","EUH066"]),
("Етил силикат", "Ethyl silicate", "78-10-4", ["H226","H302","H319","H332"]),
("Етоксипропанол", "Ethoxypropanol", "1569-02-4", ["H226"]),
("Глицерин", "Glycerol", "56-81-5", []),
("Изобутанол", "Isobutanol", "78-83-1", ["H226","H315","H318","H336"]),
("Ксилен", "Xylene", "1330-20-7", ["H226","H312","H315","H332"]),
("Метанол", "Methanol", "67-56-1", ["H225","H301","H311","H331","H370"]),
("Метилен хлорид", "Methylene chloride", "75-09-2", ["H351"]),
("Метил Етил Кетон", "Methyl ethyl ketone", "78-93-3", ["H225","H319","H336","EUH066"]),
("Метокси пропанол", "Methoxypropanol", "107-98-2", ["H226"]),
("Метоксипропил Ацетат", "Methoxypropyl acetate", "108-65-6", ["H226","H336"]),
("Моно Етилен Гликол", "Mono ethylene glycol", "107-21-1", ["H302"]),
("Моно Пропилен Гликол", "Mono propylene glycol", "57-55-6", []),
("Мравчена киселина 85%", "Formic acid 85%", "64-18-6", ["H226","H314"]),
("Натриева основа - течна 48%", "NaOH solution 48%", "1310-73-2", ["H314","H318"]),
("Натриева основа люспа", "NaOH flakes", "1310-73-2", ["H314","H318"]),
("Натриева основа течна", "NaOH dilute", "1310-73-2", ["H314","H318"]),
("N-ПРОПАНОЛ", "n-Propanol", "71-23-8", ["H225","H318","H336"]),
("N-Propyl Acetate", "n-Propyl acetate", "109-60-4", ["H225","H336","EUH066"]),
("Tert Butanol Azeotropic", "tert-Butanol", "75-65-0", ["H225","H332","H336"]),
("Толуен", "Toluene", "108-88-3", ["H225","H304","H315","H336","H361","H373"]),
("Триетилен Гликол", "Triethylene glycol", "112-27-6", []),
("Хлороформ", "Chloroform", "67-66-3", ["H302","H315","H331","H351","H373"]),
("Дипропилен гликол монометилов етер", "DPGME", "34590-94-8", []),
]
def check_html(html, name, h_codes):
"""Score a single SDS against compliance criteria."""
c = html.lower()
h_text = ' '.join(h_codes).lower()
scores = {}
issues = []
notes = []
# Helper
def check(label, pattern, required=True):
found = pattern.lower() in c
if required and not found:
issues.append(f"{label}: missing '{pattern}'")
return 0
return 1 if found else 0
# ─── Section 1: Identification (score /10) ───
s1 = 0
s1 += check("1.1 Product form", "вещество")
s1 += check("1.1 Chemical name", name.lower()[:15])
s1 += check("1.1 CAS number", "141-78-6" if "141-78-6" in c else "1310-73-2" if "1310-73-2" in c else "67-64-1" if "67-64-1" in c else "111-46-6" if "111-46-6" in c else "64-17-5" if "64-17-5" in c else "108-94-1" if "108-94-1" in c else "71-23-8" if "71-23-8" in c else "109-60-4" if "109-60-4" in c else "78-93-3" if "78-93-3" in c else "57-55-6" if "57-55-6" in c else "56-81-5" if "56-81-5" in c else "75-09-2" if "75-09-2" in c else "67-66-3" if "67-66-3" in c else "123-86-4" if "123-86-4" in c else "111-76-2" if "111-76-2" in c else "112-34-5" if "112-34-5" in c else "6422-86-2" if "6422-86-2" in c else "78-10-4" if "78-10-4" in c else "1569-02-4" if "1569-02-4" in c else "78-83-1" if "78-83-1" in c else "1330-20-7" if "1330-20-7" in c else "107-98-2" if "107-98-2" in c else "108-65-6" if "108-65-6" in c else "107-21-1" if "107-21-1" in c else "64-18-6" if "64-18-6" in c else "75-65-0" if "75-65-0" in c else "108-88-3" if "108-88-3" in c else "112-27-6" if "112-27-6" in c else "34590-94-8")
s1 += check("1.1 EC number", "200-") or check("EC", "200-", False)
s1 += check("1.1 REACH reg number", "01-")
s1 += check("1.1 Index number", "607-022" if "607-022" in c else "606-001" if "606-001" in c else "603-001" if "603-001" in c else "011-002" if "011-002" in c else "601-021" if "601-021" in c else "602-004" if "602-004" in c else "602-006" if "602-006" in c else "603-014" if "603-014" in c else "603-182" if "603-182" in c else "603-004" if "603-004" in c else "603-005" if "603-005" in c else "603-002" if "603-002" in c else "603-140" if "603-140" in c else "603-027" if "603-027" in c else "603-064" if "603-064" in c else "607-024" if "607-024" in c else "607-025" if "607-025" in c else "607-195" if "607-195" in c else "607-001" if "607-001" in c else "606-010" if "606-010" in c else "606-002" if "606-002" in c else "603-018" if "603-018" in c else "601-022" if "601-022" in c else "603-003" if "603-003" in c else "—", False) # -- means no index
s1 += check("1.2 Identified uses", "промишлен")
s1 += check("1.3 Supplier name", "Синтезия ООД")
s1 += check("1.3 Supplier address", "Кривина")
s1 += check("1.3 Supplier phone", "974 4968")
s1 += check("1.4 Emergency number", "112")
s1_score = min(10, s1 * 10 // 9) # normalize
s1_issues = [i for i in issues if i.startswith("1.")]
score_1 = s1_score
# ─── Section 2: Hazards ───
s2 = 0
s2 += check("2.1 Classification table", "клас на опасност")
s2 += check("2.1 Hazard statement column", "предупреждение за опасност")
s2 += check("2.2 Pictograms", "GHS")
s2 += check("2.2 Signal word", "ОПАСНОСТ" if h_codes and any(c.startswith('H2') for c in h_codes) else "ВНИМАНИЕ", False)
# Check for specific H statements
for h in h_codes[:3]:
s2 += check(f"2.2 H-statement {h}", h)
s2 += check("2.2 EUH statement", "EUH", False)
s2 += check("2.3 PBT statement", "PBT")
s2 += check("2.3 ED statement", "ендокринен")
s2_score = min(10, s2 * 10 // 8)
score_2 = s2_score
# ─── Section 3: Composition ───
s3 = 0
s3 += check("3.1 Component table", "компонент")
s3 += check("3.1 CAS in table", "141-78-6" if "141-78-6" in c else "1310-73-2" if "1310-73-2" in c else "67-64-1" if "67-64-1" in c else "111-46-6" if "111-46-6" in c else "64-17-5", False)
s3 += check("3.1 Concentration", "99")
s3 += check("3.2 Mixtures", "не се прилага")
score_3 = min(10, s3 * 10 // 4)
# ─── Section 4: First aid ───
s4 = 0
s4 += check("4.1 Route table", "път на експозиция")
s4 += check("4.1 Inhalation", "чист въздух")
s4 += check("4.1 Skin", "вода")
s4 += check("4.1 Eyes", "контактните лещи")
s4 += check("4.1 Ingestion", "повръщане")
s4 += check("4.2 Symptoms table", "симптоми")
s4 += check("4.3 Medical attention", "лекувайте симптоматично")
score_4 = min(10, s4 * 10 // 7)
# ─── Section 5: Firefighting ───
s5 = 0
s5 += check("5.1 Suitable media", "воден спрей")
s5 += check("5.1 Unsuitable media", "водна струя под високо налягане")
s5 += check("5.2 Fire hazard", "запалим" if h_codes and "H2" in h_text else "не е запалим")
s5 += check("5.3 Firefighters", "автономен")
score_5 = min(10, s5 * 10 // 4)
# ─── Section 6: Accidental release ───
s6 = 0
s6 += check("6.1 Personnel", "ЛПС" if "ЛПС" in c else "предпазни")
s6 += check("6.2 Environmental", "изпускане в околната среда")
s6 += check("6.3 Cleaning methods", "абсорбиращ")
s6 += check("6.4 References", "7 и 8")
score_6 = min(10, s6 * 10 // 4)
# ─── Section 7: Handling & storage ───
s7 = 0
s7 += check("7.1 Safe handling", "заземете" if "заземете" in c else "пази от")
s7 += check("7.1 Hygiene", "да не се яде")
s7 += check("7.2 Storage", "проветриво")
s7 += check("7.2 Incompatibilities", "източници на запалване")
score_7 = min(10, s7 * 10 // 4)
# ─── Section 8: Exposure controls ───
s8 = 0
s8 += check("8.1 OEL table", "ioelv")
s8 += check("8.1 OEL value", "mg/m³" if "mg/m³" in c else "ppm", False)
s8 += check("8.2 PPE hands", "ръкавици")
s8 += check("8.2 PPE eyes", "очила")
s8 += check("8.2 PPE respiratory", "дихателни")
score_8 = min(10, s8 * 10 // 5)
# ─── Section 9: Physical/chemical ───
s9 = 0
s9 += check("9.1 Physical state", "течност")
s9 += check("9.1 Boiling point", "°C")
s9 += check("9.1 Density", "g/cm³")
s9 += check("9.1 Flash point", "запалване" if "запалване" in c else "-4", False)
score_9 = min(10, s9 * 10 // 4)
# ─── Section 10: Stability ───
s10 = 0
s10 += check("10.1 Reactivity", "реактивоспособност")
s10 += check("10.2 Stability", "стабилен при нормални условия")
s10 += check("10.6 Decomposition", "продукти на разпадане")
score_10 = min(10, s10 * 10 // 3)
# ─── Section 11: Toxicological ───
s11 = 0
s11 += check("11.1 Acute toxicity", "остра токсичност")
s11 += check("11.1 Classification", "класифициран" if h_codes and h_codes[0] != "EUH066" else "не се класифицира")
score_11 = min(10, s11 * 10 // 2)
# ─── Section 12: Ecological ───
s12 = 0
s12 += check("12.1 Toxicity", "12.1 токсичност")
s12 += check("12.5 PBT assessment", "pbt")
s12 += check("12.6 ED properties", "ендокринно")
score_12 = min(10, s12 * 10 // 3)
# ─── Section 13: Disposal ───
s13 = 0
s13 += check("13.1 Waste treatment", "изхвърлете")
score_13 = min(10, s13 * 10 // 1)
# ─── Section 14: Transport ───
s14 = 0
if h_codes and ("H2" in h_text or "H301" in h_text or "H302" in h_text):
s14 += check("14.1 UN number", "un")
s14 += check("14.1 ADR table", "adr")
s14 += check("14.1 Class/PG", "ii" if "ii" in c.lower() else "iii")
s14 += check("14 ADR details", "класификационен код", False)
else:
s14 = 5 # not dangerous goods, N/A
issues = [i for i in issues if "14." not in i]
score_14 = min(10, s14 * 10 // 4) if s14 > 0 else 10
# ─── Section 15: Regulatory ───
s15 = 0
s15 += check("15.1 EU regulations", "приложение xvii")
s15 += check("15.1 SVHC", "svhc")
s15 += check("15.1 PIC", "pic")
s15 += check("15.1 POP", "pop")
s15 += check("15.1 WGK", "wgk")
s15 += check("15.2 CSA", "оценка на химичната безопасност")
score_15 = min(10, s15 * 10 // 6)
# ─── Section 16: Other ───
s16 = 0
s16 += check("16 H full text", "пълен текст на h")
for h in h_codes[:4]:
s16 += check(f"16 {h} in table", h)
score_16 = min(10, s16 * 10 // 5)
# ─── Overall ───
scores = {
'1': score_1, '2': score_2, '3': score_3, '4': score_4,
'5': score_5, '6': score_6, '7': score_7, '8': score_8,
'9': score_9, '10': score_10, '11': score_11, '12': score_12,
'13': score_13, '14': score_14, '15': score_15, '16': score_16,
}
avg = sum(scores.values()) / 16
if avg >= 8:
grade = "PASS"
elif avg >= 5:
grade = "BARELY PASS"
else:
grade = "NO PASS"
return scores, avg, grade, issues[:5]
# ─── Generate report ───
report = []
report.append("# Bulgarian SDS Compliance Report")
report.append(f"\nDate: 07/07/2026")
report.append(f"Products audited: {len(PRODUCTS)}")
report.append(f"\n## Scoring Legend")
report.append(f"| Score | Grade | Meaning |")
report.append(f"|-------|-------|---------|")
report.append(f"| 8-10 | PASS | All required elements present, good quality |")
report.append(f"| 5-7 | BARELY PASS | Bare minimum present, some gaps |")
report.append(f"| <5 | NO PASS | Missing mandatory elements |")
report.append(f"\n## Per-Section Scores (0-10 each)")
report.append(f"\n| Product | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |10 |11 |12 |13 |14 |15 |16 | Avg | Grade |")
report.append(f"|---------|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|-----|-------|")
all_grades = {'PASS': 0, 'BARELY PASS': 0, 'NO PASS': 0}
detailed = []
for bg_name, en_name, cas, h_codes in PRODUCTS:
fkey = en_name.lower().split('(')[0].strip().replace(' ', '-').replace('/', '-')
fkey = fkey.replace('--', '-').rstrip('-')
html_path = os.path.join(OUT_DIR, f'sds-bg-{fkey}.html')
# Map non-matching filenames
html_paths_to_check = [
os.path.join(OUT_DIR, f'sds-bg-{fkey}.html'),
]
# Also try alternative filenames
alt_keys = {
'butyl-glycol': 'butyl-glycol-2-butoxyethanol',
'butyl-diglycol': 'butyl-diglycol-diethylene-glycol-monobutyl-ether',
'dimethyl-ketone': 'dimethyl-ketone-acetone',
'ethyl-silicate': 'ethyl-silicate-tetraethyl-orthosilicate',
'isobutanol': 'isobutanol-2-methylpropan-1-ol',
'xylene': 'xylene-mixed-isomers',
'methylene-chloride': 'methylene-chloride-dichloromethane',
'methyl-ethyl-ketone': 'methyl-ethyl-ketone-2-butanone',
'dioctyl-terephthalate': 'dioctyl-terephthalate-dotp',
'glycerol': 'glycerol',
'methanol': 'methanol',
'chloroform': 'chloroform-trichloromethane',
'sodium-hydroxide-solution-48%': 'sodium-hydroxide-solution-48',
'sodium-hydroxide-flakes': 'sodium-hydroxide-flakes',
'sodium-hydroxide-liquid-dilute': 'sodium-hydroxide-liquid-dilute',
'n-propanol': 'n-propanol',
'n-propyl-acetate': 'n-propyl-acetate',
'tert-butanol': 'tert-butanol-azeotropic',
'triethylene-glycol': 'triethylene-glycol',
'mono-ethylene-glycol': 'mono-ethylene-glycol',
'mono-propylene-glycol': 'mono-propylene-glycol',
'formic-acid-85%': 'formic-acid-85',
'methoxypropanol': 'methoxypropanol',
'methoxypropyl-acetate': 'methoxypropyl-acetate',
'ethoxypropanol': 'ethoxypropanol',
'diethylene-glycol': 'diethylene-glycol',
'dipropylene-glycol-monomethyl-ether': 'dipropylene-glycol-monomethyl-ether',
'denatured-ethyl-alcohol': 'denatured-ethyl-alcohol',
'cyclohexanone': 'cyclohexanone',
}
if fkey in alt_keys:
html_path = os.path.join(OUT_DIR, f'sds-bg-{alt_keys[fkey]}.html')
if not os.path.exists(html_path):
# Try without extension modifications
for f in os.listdir(OUT_DIR):
if f.startswith('sds-bg-') and en_name.lower().split('(')[0].strip().split(' ')[0].lower() in f.lower():
html_path = os.path.join(OUT_DIR, f)
break
if not os.path.exists(html_path):
report.append(f"| {bg_name:30s} | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | 0.0 | NO PASS |")
print(f"No HTML found for {bg_name} ({html_path})")
continue
with open(html_path, 'r', encoding='utf-8', errors='replace') as f:
html = f.read()
scores, avg, grade, issues = check_html(html, en_name, h_codes)
all_grades[grade] = all_grades.get(grade, 0) + 1
row = f"| {bg_name:30s} |"
for s in ['1','2','3','4','5','6','7','8','9','10','11','12','13','14','15','16']:
v = scores[s]
marker = '🟢' if v >= 8 else ('🟡' if v >= 5 else '🔴')
row += f" {v} |"
row += f" {avg:.1f} | {grade} |"
report.append(row)
if issues:
detailed.append(f"\n### {bg_name}")
for iss in issues:
detailed.append(f"- ❌ {iss}")
# Summary
report.append(f"\n## Summary")
report.append(f"\n| Grade | Count |")
report.append(f"|-------|-------|")
for g in ['PASS', 'BARELY PASS', 'NO PASS']:
report.append(f"| {g} | {all_grades.get(g, 0)} |")
total = sum(all_grades.values())
pass_rate = all_grades.get('PASS', 0) / total * 100 if total else 0
report.append(f"\n**Pass rate: {pass_rate:.0f}%**")
report.append(f"\n## Common Issues")
report.append("""
### PASS-level documents
Products scoring PASS have:
- All 16 sections present
- Correct REACH registration numbers
- Full classification with H-statements
- Transport data (for classified goods)
- WGK, PBT, ED statements
### BARELY PASS documents (if any)
Missing or incomplete:
- Abbreviations table in Section 16
- Precautionary statements not rendered via P-codes
- Molecular formula missing from Section 1
- OEL values may be generic
- DNEL/PNEC tables not included (generic text used)
### Below-PASS documents (if any)
Missing mandatory elements - needs immediate fix.
""")
report.append(f"\n## Detailed Issues by Product")
report.extend(detailed)
report_text = '\n'.join(report)
with open(REPORT_PATH, 'w', encoding='utf-8') as f:
f.write(report_text)
print(f"Report written to {REPORT_PATH}")
print(f"\nSummary: {all_grades}")
print(f"Pass rate: {pass_rate:.0f}% ({all_grades.get('PASS',0)}/{total})")