361 lines
18 KiB
Python
361 lines
18 KiB
Python
#!/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})")
|