From 6a584c7c790b453a8b8c00dcda348b702445f927 Mon Sep 17 00:00:00 2001 From: denis svinarchuk Date: Thu, 16 Jul 2026 15:22:26 +0100 Subject: [PATCH] test: add compare_analyzer tools for G-code slice comparison Add two standalone Python tools for deep comparison and analysis of .3mf slicing project files: - compare_slices.py: comprehensive slice comparison with filament usage, nozzle mapping, tool change sequences, prime tower analysis, temperature timeline, and automatic critical discrepancy detection - show_temp_plot.py: interactive HTML temperature timeline plotter for visualizing heater profiles during multi-nozzle prints Both tools use only Python stdlib (no external dependencies). Primary use case: regression testing H2C carousel purge volumes and BBS compatibility verification. --- tests/compare_analyzer/README.md | 88 ++ tests/compare_analyzer/compare_slices.py | 1282 ++++++++++++++++++++ tests/compare_analyzer/show_temp_plot.py | 1357 ++++++++++++++++++++++ 3 files changed, 2727 insertions(+) create mode 100644 tests/compare_analyzer/README.md create mode 100755 tests/compare_analyzer/compare_slices.py create mode 100755 tests/compare_analyzer/show_temp_plot.py diff --git a/tests/compare_analyzer/README.md b/tests/compare_analyzer/README.md new file mode 100644 index 0000000000..bc08fe5121 --- /dev/null +++ b/tests/compare_analyzer/README.md @@ -0,0 +1,88 @@ +# Compare Analyzer — G-code Slicing Comparison Tools + +Tools for deep comparison and analysis of `.3mf` slicing project files, designed for +verifying multi-nozzle (H2C carousel) and multi-extruder slicing correctness. + +## Tools + +### `compare_slices.py` — Slice Comparison Analyzer + +Deep comparison of two `.3mf` files (OrcaSlicer, BambuStudio, or any compatible slicer). +Generates a comprehensive Markdown report covering: + +- **Filament usage** — per-filament weight/length with color mapping +- **Nozzle/extruder mapping** — Vortek carousel slot assignments +- **Tool change sequences** — T-code ordering and count +- **Prime tower analysis** — tower entries, G-code line count +- **Temperature timeline** — pre-heat lead times, target temperatures per tool change +- **Retract parameters** — M620.11 analysis during nozzle switches +- **Filament change G-code blocks** — line-by-line diff of change_filament_gcode +- **Control command diff** — timeline of M/G-code differences +- **Critical discrepancy detection** — automatic flagging of weight/time anomalies + +#### Usage + +```bash +# Compare two slice files +python3 compare_slices.py file1.3mf file2.3mf + +# With custom labels +python3 compare_slices.py file1.3mf file2.3mf --labels "Upstream" "Fixed" +``` + +#### Output +Markdown report saved to `mp_reports/compare_report_YYYYMMDD_HHMMSS.md` + +#### Example: Detecting H2C purge regression +``` +⚠️ CRITICAL DISCREPANCY: Huge difference in part weight: + OrcaSlicer 60.90 g vs BambuStudio 17.47 g (difference 43.43 g or 71.3%). + The reason is incorrect nozzle mapping, causing huge AMS flushing. +``` + +--- + +### `show_temp_plot.py` — Temperature Timeline Plotter + +Generates interactive HTML temperature plots for analyzing thermal profiles during +multi-nozzle prints. Visualizes heater temperature commands (M104/M109) per tool change, +showing pre-heat timing and temperature convergence. + +#### Architecture +- H2C dual-extruder layout with Vortek carousel nozzles +- Physical heaters mapped dynamically: + - Heater 0: Extruder 2 (right nozzle slot, T0/T2/T3/T4) + - Heater 1: Extruder 1 (left nozzle slot, T1) +- Active heater mapping derived from G-code temperature signals + +#### Usage + +```bash +# Single file analysis +python3 show_temp_plot.py file.3mf + +# Side-by-side comparison of two files +python3 show_temp_plot.py file1.3mf file2.3mf +``` + +#### Output +Interactive HTML report saved to Desktop as `temp_plot_v3.html` + +--- + +## Requirements + +- **Python 3.8+** +- **No external dependencies** — uses only Python standard library + (`json`, `zipfile`, `xml.etree.ElementTree`, `difflib`, `webbrowser`) + +## Use Cases + +1. **Regression testing** — compare slices before/after code changes to verify + no unintended differences in purge volumes, tool ordering, or temperature timing +2. **BBS compatibility verification** — compare OrcaSlicer output against BambuStudio + reference slices to ensure behavioral parity +3. **H2C carousel validation** — verify per-slot nozzle tracking produces correct + purge volumes (not collapsed per-extruder) +4. **Temperature protocol analysis** — verify pre-heat lead times and cooling + temperatures during nozzle changes match expected profiles diff --git a/tests/compare_analyzer/compare_slices.py b/tests/compare_analyzer/compare_slices.py new file mode 100755 index 0000000000..cf321c7b7d --- /dev/null +++ b/tests/compare_analyzer/compare_slices.py @@ -0,0 +1,1282 @@ +""" +Slicing G-code Comparison Tool: OrcaSlicer vs BambuStudio (BBL) +==================================================================== + +The script performs a deep comparison of two `.3mf` slicing project files +(the first one from OrcaSlicer, the second from Bambu Studio) for debugging the Vortek H2C nozzle changer. + +Default behavior (file paths): +------------------------------------ +If only filenames are passed (e.g. `54orca.3mf` and `bbl.3mf`), the script searches by priority: +1. Looks for the file on the user's Desktop (~/Desktop/). +2. Looks for the file relative to the current working directory. +3. Supports absolute paths. + +What the script analyzes: +---------------------- +1. Summary metadata (slice_info.config): + - Print time, part weight, first layer time, slicer versions, printer model ID. + - filament_maps parameters, dynamic mapping, and filament switcher settings. + - Summary nozzle settings: extruder_nozzle_stats, filament_nozzle_map, filament_volume_map. +2. Preheat and Standby Cooldown Analysis: + - Matches M104/M109 heating commands of the target nozzle before toolchange (T). + - Calculates lead lines (how many G-code lines before physical T the heating started). + - Extracts standby cooldown temperatures of the inactive nozzle. +3. Vortek Nozzles comparison: + - Analyzes assigned nozzle IDs, extruder IDs, and diameters. +4. Differences in slicing settings (project_settings.config): + - Generates a comparison table of configuration discrepancies between slicers. +5. Nozzle changes, prime tower, and retracts: + - Total number of toolchanges and sequence. + - Prime Tower G-code volume (lines and blocks). + - Retract parameters during nozzle switch (M620.11 combinations of E, R, F). +6. Change filament G-code blocks (change_filament_gcode): + - Runs a unified diff for each toolchange G-code block. +7. Timeline of G-code control commands: + - Builds a differential track of toolchanges, nozzle switches, and heating events. +8. Critical discrepancies analysis: + - Signals invalid nozzle indices (out of H2C carousel limits). + - Detects nozzle collisions (duplicate slots causing AMS flush instead of physical swap). + - Pinpoints the Standard#7 inventory bug, weight discrepancies due to flushes, etc. + +Results are saved to: +/Users/denn/Develop/3dprint/dehancer lab/H2C_v2/mp_reports/compare_report_.md + +AGENTS NOTE: +When a slicing report is requested, do not print raw setting diffs or full timelines in chat. +Provide only the final analytical report of KEY and CRITICAL discrepancies (mapping errors, preheat differences, Vortek anomalies). +""" + +import os +import sys +import zipfile +import json +import xml.etree.ElementTree as ET +import difflib +from datetime import datetime + +DESKTOP_DIR = os.path.expanduser("~/Desktop") +REPORTS_DIR = "/Users/denn/Develop/3dprint/dehancer lab/H2C_v2/mp_reports" + +def escape_markdown_table(val): + if val is None: + return "None" + # Replace pipe with unicode vertical bar to avoid breaking markdown table columns + return str(val).replace("|", " ⎮ ") + +def find_file(filename): + if os.path.isabs(filename): + return filename + desktop_path = os.path.join(DESKTOP_DIR, filename) + if os.path.exists(desktop_path): + return desktop_path + if os.path.exists(filename): + return os.path.abspath(filename) + return None + +def parse_metadata(zip_file): + metadata = { + "version_info": {}, + "plate_meta": {}, + "filaments": [], + "nozzles": [] + } + try: + xml_data = zip_file.read("Metadata/slice_info.config") + root = ET.fromstring(xml_data) + + header = root.find("header") + if header is not None: + for item in header.findall("header_item"): + metadata["version_info"][item.attrib.get("key")] = item.attrib.get("value") + + plate = root.find("plate") + if plate is not None: + for item in plate.findall("metadata"): + metadata["plate_meta"][item.attrib.get("key")] = item.attrib.get("value") + + for fil in plate.findall("filament"): + metadata["filaments"].append({ + "id": fil.attrib.get("id"), + "type": fil.attrib.get("type"), + "color": fil.attrib.get("color"), + "used_g": fil.attrib.get("used_g"), + "used_m": fil.attrib.get("used_m"), + "nozzle_diameter": fil.attrib.get("nozzle_diameter"), + "volume_type": fil.attrib.get("volume_type") + }) + + for noz in plate.findall("nozzle"): + metadata["nozzles"].append({ + "id": noz.attrib.get("id"), + "extruder_id": noz.attrib.get("extruder_id"), + "nozzle_diameter": noz.attrib.get("nozzle_diameter") + }) + except KeyError: + pass + except Exception as e: + print(f"Error parsing metadata: {e}") + return metadata + +def parse_project_settings(zip_file): + try: + content = zip_file.read("Metadata/project_settings.config").decode('utf-8') + return json.loads(content) + except KeyError: + return {} + except Exception as e: + print(f"Error parsing project_settings: {e}") + return {} + +def parse_critical_gcode(zip_file, filament_maps_str=None): + critical_events = [] + total_lines = 0 + file_size = 0 + + # Try to parse filament_maps from Metadata/slice_info.config if not provided + if not filament_maps_str: + try: + xml_data = zip_file.read("Metadata/slice_info.config") + import xml.etree.ElementTree as ET + root = ET.fromstring(xml_data) + plate = root.find("plate") + if plate is not None: + for meta in plate.findall("metadata"): + if meta.attrib.get("key") == "filament_maps": + filament_maps_str = meta.attrib.get("value", "").strip() + break + except Exception: + pass + + if not filament_maps_str: + filament_maps_str = "1 1 1 1 1 2" + + extruder_map = {} + for i, v in enumerate(filament_maps_str.split()): + try: + extruder_map[i] = int(v) + except ValueError: + pass + + # For H2C, Extruder 1 (Left) maps to Heater 1, Extruder 2 (Right) maps to Heater 0 + heater_to_ext = {1: 1, 2: 0} + nozzle_map = {} + for fid, ext_id in extruder_map.items(): + nozzle_map[fid] = heater_to_ext.get(ext_id, 1) + + toolchange_count = 0 + toolchange_sequence = [] + prime_tower_blocks = 0 + prime_tower_lines = 0 + in_prime_tower = False + + m620_11_retracts = [] + temp_events = [] + + active_extruder = 0 + next_filament = 0 + temp_T0 = 0 + temp_T1 = 0 + temp_track = [] + + toolchange_blocks = [] + current_tc_block = [] + in_tc_block = False + in_m620_block = False + printing_started = False + m73_points = [] + try: + gcode_names = [name for name in zip_file.namelist() if name.endswith('.gcode')] + if not gcode_names: + raise KeyError("No .gcode file found in 3mf") + gcode_name = gcode_names[0] + info = zip_file.getinfo(gcode_name) + file_size = info.file_size + + with zip_file.open(gcode_name) as f: + for line_bytes in f: + total_lines += 1 + line = line_bytes.decode("utf-8", errors="ignore").strip() + + if "; CHANGE_LAYER" in line: + printing_started = True + + # Special check for H2C virtual temperature commands (e.g. ;VM104, ;VM109) + is_virtual_temp = False + if line.startswith(";VM104") or line.startswith(";VM109"): + is_virtual_temp = True + cmd_part = line[1:].split(";")[0].strip() + else: + cmd_part = line.split(";")[0].strip() + + if ";======== H2C filament_change ========" in line: + in_tc_block = True + current_tc_block = [f"Line {total_lines}: {line}"] + elif in_tc_block: + current_tc_block.append(f"Line {total_lines}: {line}") + if "M1002 gcode_claim_action : 0" in cmd_part: + in_tc_block = False + toolchange_blocks.append("\n".join(current_tc_block)) + + if not cmd_part: + continue + + words = cmd_part.split() + first_word = words[0] + + if first_word == "M628" and "S1" in cmd_part: + in_prime_tower = True + prime_tower_blocks += 1 + elif first_word == "M628" and "S0" in cmd_part: + in_prime_tower = False + + if in_prime_tower: + prime_tower_lines += 1 + + if first_word == "M73" and not first_word.startswith("M73.2"): + parts = cmd_part.split() + r_val = next((p for p in parts if p.startswith("R")), None) + p_val = next((p for p in parts if p.startswith("P")), None) + if r_val and p_val: + try: + r_min = int(r_val[1:]) + m73_points.append((total_lines, r_min)) + except ValueError: + pass + + is_critical = False + + if first_word.startswith("T") and first_word[1:].isdigit(): + is_critical = True + toolchange_count += 1 + toolchange_sequence.append(first_word) + t_val = int(first_word[1:]) + if t_val < 60000: + active_extruder = t_val + next_filament = t_val + temp_track.append((total_lines, active_extruder, temp_T0, temp_T1, f"T{t_val} (Active Filament: T{t_val})", in_m620_block, printing_started)) + + elif any(first_word.startswith(prefix) for prefix in ["M620", "M621", "M622", "M623", "M628", "M629", "M1002", "G29"]): + is_critical = True + if first_word == "M620": + in_m620_block = True + parts = cmd_part.split() + s_val = next((p for p in parts if p.startswith("S")), None) + if s_val and s_val.endswith("A") and len(s_val) > 2: + try: + next_filament = int(s_val[1:-1]) + except ValueError: + pass + elif first_word == "M621": + in_m620_block = False + elif first_word == "M620.11": + parts = cmd_part.split() + e_val = next((p for p in parts if p.startswith("E")), "E?") + r_val = next((p for p in parts if p.startswith("R")), "R?") + f_val = next((p for p in parts if p.startswith("F")), "F?") + m620_11_retracts.append(f"{e_val} {r_val} {f_val}") + elif first_word == "M620.15": + parts = cmd_part.split() + p_val = next((p for p in parts if p.startswith("P")), None) + c_val = next((p for p in parts if p.startswith("C")), None) + desc_parts = [] + # M620.15 is a firmware command that sets target temp of the INCOMING nozzle + # during the switch to prevent oozing / prepare for print. + target_heater = 1 if next_filament == 1 else 0 + if p_val: + desc_parts.append(f"Pre-cool P{p_val[1:]}°C") + temp_events.append((total_lines, f"M620.15 Pre-cool P{p_val[1:]}°C")) + try: + val = int(p_val[1:]) + if target_heater == 0: + temp_T0 = val + else: + temp_T1 = val + except ValueError: + pass + if c_val: + desc_parts.append(f"Target-cool C{c_val[1:]}°C") + temp_events.append((total_lines, f"M620.15 Target-cool C{c_val[1:]}°C")) + try: + val = int(c_val[1:]) + if target_heater == 0: + temp_T0 = val + else: + temp_T1 = val + except ValueError: + pass + desc = " & ".join(desc_parts) + temp_track.append((total_lines, active_extruder, temp_T0, temp_T1, f"M620.15 {desc}", in_m620_block, printing_started)) + + elif any(first_word.startswith(prefix) for prefix in ["M104", "M109", "VM104", "VM109"]): + is_critical = True + parts = cmd_part.split() + s_val = next((p for p in parts if p.startswith("S")), None) + t_val = next((p for p in parts if p.startswith("T")), "") + if s_val: + s_temp = int(s_val[1:]) + t_desc = f" T{t_val[1:]}" if t_val else "" + temp_events.append((total_lines, f"{first_word}{t_desc} S{s_val[1:]}°C")) + + # Determine target heater + if t_val: + # Explicit T parameter: T0 = heater 0, T1 = heater 1 + if t_val == "T0": + targeted_heater = 0 + elif t_val == "T1": + targeted_heater = 1 + else: + targeted_heater = 0 + else: + # No T param: applies to active heater + # H2C mapping: use nozzle_map + targeted_heater = nozzle_map.get(active_extruder, 1) + + if targeted_heater == 0: + temp_T0 = s_temp + else: + temp_T1 = s_temp + + temp_track.append((total_lines, active_extruder, temp_T0, temp_T1, f"{first_word}{t_desc} S{s_temp}°C", in_m620_block, printing_started)) + + if is_critical: + critical_events.append(f"Line {total_lines}: {cmd_part}") + + # Insert regular temperature samples every 100 lines to prevent linear interpolation artifacts + if total_lines % 100 == 0: + temp_track.append((total_lines, active_extruder, temp_T0, temp_T1, "Regular Sample", in_m620_block, printing_started)) + except KeyError: + pass + except Exception as e: + print(f"Error parsing G-code: {e}") + + stats = { + "toolchange_count": toolchange_count, + "toolchange_sequence": toolchange_sequence, + "prime_tower_blocks": prime_tower_blocks, + "prime_tower_lines": prime_tower_lines, + "m620_11_retracts": sorted(list(set(m620_11_retracts))), + "temp_events": temp_events, + "temp_track": temp_track, + "toolchange_blocks": toolchange_blocks, + "m73_points": m73_points + } + return critical_events, total_lines, file_size, stats + +def get_slicer_name(meta): + version_info = meta.get("version_info", {}) + if "OrcaSlicer-Version" in version_info: + return "OrcaSlicer" + elif "X-BBL-Client-Version" in version_info: + return "BambuStudio" + for k in version_info.keys(): + if "orcaslicer" in k.lower(): + return "OrcaSlicer" + if "bambu" in k.lower() or "bbl" in k.lower(): + return "BambuStudio" + return "UnknownSlicer" + +def analyze_critical_discrepancies(f1_meta, f2_meta, f1_settings, f2_settings, f1_stats, f2_stats): + discrepancies = [] + slicer1 = get_slicer_name(f1_meta) + slicer2 = get_slicer_name(f2_meta) + + # 1. Nozzle map index errors + def clean_map(m): + if not m: + return [] + if isinstance(m, str): + m = m.replace("[", "").replace("]", "").replace("'", "").replace(",", " ") + return [int(x) for x in m.split()] + elif isinstance(m, list): + return [int(x) for x in m] + return [] + + fnm1 = clean_map(f1_settings.get("filament_nozzle_map")) + fnm2 = clean_map(f2_settings.get("filament_nozzle_map")) + + if fnm1: + # Compute max valid nozzle slot from extruder_nozzle_stats + # Format: ['Standard#1', 'Standard#4|High Flow#2'] → total = 1+4+2 = 7, max_slot = 6 + max_nozzle_slot = 5 # default fallback + ens = f1_settings.get("extruder_nozzle_stats") or f2_settings.get("extruder_nozzle_stats") + if ens: + if isinstance(ens, str): + ens = ens.split("','") + total_nozzles = 0 + for entry in ens: + entry = entry.strip().strip("'\"[] ") + for part in entry.split("|"): + part = part.strip() + if "#" in part: + try: + total_nozzles += int(part.split("#")[1]) + except (ValueError, IndexError): + pass + if total_nozzles > 0: + max_nozzle_slot = total_nozzles - 1 + invalid = [x for x in fnm1 if x < 0 or x > max_nozzle_slot] + if invalid: + discrepancies.append({ + "level": "CRITICAL ERROR", + "message": f"{slicer1} `filament_nozzle_map` contains invalid nozzle slots {invalid} (out of H2C nozzle limits 0..{max_nozzle_slot}). This breaks physical switching." + }) + + # 2. Nozzle group collisions (multiple active filaments on one slot) + if fnm1: + active_filaments = [i for i, fil in enumerate(f1_meta["filaments"]) if fil.get("used_g") and float(fil["used_g"]) > 0] + used_slots = {} + for f_idx in active_filaments: + if f_idx < len(fnm1): + slot = fnm1[f_idx] + if slot != 0: + if slot in used_slots: + used_slots[slot].append(f_idx + 1) + else: + used_slots[slot] = [f_idx + 1] + for slot, fils in used_slots.items(): + if len(fils) > 1: + discrepancies.append({ + "level": "CRITICAL DISCREPANCY", + "message": f"{slicer1}: different active filament colors {fils} share the same carousel slot {slot} (duplicate nozzle). Because of this, filament changes cause AMS flushing instead of physical nozzle change!" + }) + + # 3. Toolchange counts comparison + tc1 = f1_stats["toolchange_count"] + tc2 = f2_stats["toolchange_count"] + if tc1 != tc2: + discrepancies.append({ + "level": "CRITICAL ERROR", + "message": f"Filament change count mismatch: {slicer1} has {tc1} changes, {slicer2} has {tc2} changes." + }) + + # 4. Filament maps differences + fmap1 = f1_meta["plate_meta"].get("filament_maps") + fmap2 = f2_meta["plate_meta"].get("filament_maps") + if fmap1 != fmap2: + discrepancies.append({ + "level": "WARNING", + "message": f"Filament maps differ: {slicer1} `{fmap1}` vs {slicer2} `{fmap2}`." + }) + + # 5. Weight discrepancy + w1 = float(f1_meta["plate_meta"].get("weight", 0)) + w2 = float(f2_meta["plate_meta"].get("weight", 0)) + if w1 > 0 and w2 > 0: + ratio = abs(w1 - w2) / max(w1, w2) + if ratio > 0.2: + discrepancies.append({ + "level": "CRITICAL DISCREPANCY", + "message": f"Huge difference in part weight: {slicer1} {w1:.2f} g vs {slicer2} {w2:.2f} g (difference {abs(w1-w2):.2f} g or {ratio*100:.1f}%). The reason is incorrect nozzle mapping in {slicer1}, causing huge AMS flushing." + }) + + # 6. Prediction time discrepancy + pred1 = float(f1_meta["plate_meta"].get("prediction", 0)) + pred2 = float(f2_meta["plate_meta"].get("prediction", 0)) + if pred1 > 0 and pred2 > 0: + ratio = abs(pred1 - pred2) / max(pred1, pred2) + if ratio > 0.2: + discrepancies.append({ + "level": "CRITICAL DISCREPANCY", + "message": f"Print time difference exceeds 20%: {slicer1} {int(pred1/60)} min vs {slicer2} {int(pred2/60)} min (difference {int((pred1-pred2)/60)} min or {ratio*100:.1f}%)." + }) + + # 7. Physical extruder map + pem1 = f1_settings.get("physical_extruder_map") + pem2 = f2_settings.get("physical_extruder_map") + if pem1 != pem2: + discrepancies.append({ + "level": "WARNING", + "message": f"Physical extruder map `physical_extruder_map` differs: {slicer1} {pem1} vs {slicer2} {pem2}." + }) + + # 8. Nozzle statistics (extruder_nozzle_stats) + ens1 = f1_settings.get("extruder_nozzle_stats") + ens2 = f2_settings.get("extruder_nozzle_stats") + if ens1 != ens2: + discrepancies.append({ + "level": "WARNING", + "message": f"Nozzle inventories `extruder_nozzle_stats` differ: {slicer1} `{ens1}` vs {slicer2} `{ens2}`." + }) + if ens1: + ens1_str = str(ens1) + if "Standard#7" in ens1_str: + discrepancies.append({ + "level": "CRITICAL ERROR", + "message": f"H2C nozzle changer bug detected in {slicer1}: `extruder_nozzle_stats` is set to `Standard#7` for both extruders. This causes incorrect linear nozzle list construction and mapping crash!" + }) + + return discrepancies + +def interpret_temp_action(desc, active_extruder): + try: + desc_clean = desc.replace("°C", "") + words = desc_clean.split() + if not words: + return "Control Command" + cmd = words[0] + + if cmd.startswith("T") and "Active Extruder" in desc: + return "Active Extruder Switch" + + if "Pre-cool" in desc: + p_temp = desc.split("P")[-1].replace("°C", "").strip() + return f"Virtual Preheat (Pre-cool) before change to {p_temp}°C" + + if "Target-cool" in desc: + c_temp = desc.split("C")[-1].replace("°C", "").strip() + return f"Set Target Active Nozzle Temp to {c_temp}°C" + + if cmd in ["M104", "M109", "VM104", "VM109"]: + s_val = None + t_val = None + for w in words: + if w.startswith("S"): + try: + s_val = int(w[1:]) + except ValueError: + pass + elif w.startswith("T") and not "Active" in desc: + t_val = w + + heater = active_extruder + if t_val == "T0": + heater = 0 + elif t_val == "T1": + heater = 1 + + heater_name = "Left (T0)" if heater == 0 else "Right (T1)" + + if s_val is not None: + if s_val <= 50: + action_type = "Cooling" if s_val > 0 else "Power Off" + target_desc = "standby (cooldown)" if s_val > 0 else "heater" + return f"{action_type} {heater_name} to {target_desc} ({s_val}°C)" + elif 150 <= s_val <= 215: + if heater != active_extruder: + return f"Standby Preheat {heater_name} to {s_val}°C" + else: + return f"Heating {heater_name} to intermediate temp {s_val}°C" + else: + action = "Stabilizing Temperature" if cmd == "M109" else "Heating" + return f"{action} {heater_name} to active print temp ({s_val}°C)" + except Exception as e: + return f"Temperature Command ({e})" + return "Control Command" + +def format_side_by_side_temp_track(f1_track, f2_track, f1_name, f2_name): + # 1. Find toolchanges for File 1 (Orca) + tc1 = [] + for idx, item in enumerate(f1_track): + desc = item[4] + line = item[0] + if desc.startswith("T") and "Active Extruder" in desc: + first_part = desc.split()[0] + t_num = first_part[1:] + if t_num.isdigit() and int(t_num) < 1000: + tc1.append((idx, line, int(t_num))) + + # 2. Find toolchanges for File 2 (BBL) + tc2 = [] + for idx, item in enumerate(f2_track): + desc = item[4] + line = item[0] + if desc.startswith("T") and "Active Extruder" in desc: + first_part = desc.split()[0] + t_num = first_part[1:] + if t_num.isdigit() and int(t_num) < 1000: + tc2.append((idx, line, int(t_num))) + + if not tc1 and not tc2: + res = ["##### Start Warmup Comparison (first 30 events):\n"] + res.append("| Orca: Line | Orca: Command | T0/T1 | BBL: Line | BBL: Command | T0/T1 |\n") + res.append("| :---: | :--- | :---: | :---: | :--- | :---: |\n") + limit = min(30, max(len(f1_track), len(f2_track))) + for i in range(limit): + item1 = f1_track[i] if i < len(f1_track) else None + item2 = f2_track[i] if i < len(f2_track) else None + + o_line = str(item1[0]) if item1 else "" + o_desc = item1[4] if item1 else "" + o_temps = f"{item1[2]}/{item1[3]}°C" if item1 else "" + + b_line = str(item2[0]) if item2 else "" + b_desc = item2[4] if item2 else "" + b_temps = f"{item2[2]}/{item2[3]}°C" if item2 else "" + + res.append(f"| {o_line} | {o_desc} | {o_temps} | {b_line} | {b_desc} | {b_temps} |\n") + return "".join(res) + + # 3. Create ranges around toolchanges for Orca + ranges1 = [] + for idx, line, t_num in tc1: + start_idx = max(0, idx - 10) + end_idx = min(len(f1_track) - 1, idx + 8) + ranges1.append((start_idx, end_idx, f"Change to T{t_num} (line {line})", line)) + + m_ranges1 = [] + if ranges1: + curr_start, curr_end, curr_label, curr_last_line = ranges1[0] + for start, end, label, line in ranges1[1:]: + if line - curr_last_line <= 1000: + curr_end = max(curr_end, end) + curr_label += f" -> T{label.split(' to T')[-1].split()[0]}" + curr_last_line = line + else: + m_ranges1.append((curr_start, curr_end, curr_label)) + curr_start, curr_end, curr_label, curr_last_line = start, end, label, line + m_ranges1.append((curr_start, curr_end, curr_label)) + + # Ranges for BBL + ranges2 = [] + for idx, line, t_num in tc2: + start_idx = max(0, idx - 10) + end_idx = min(len(f2_track) - 1, idx + 8) + ranges2.append((start_idx, end_idx, f"Change to T{t_num} (line {line})", line)) + + m_ranges2 = [] + if ranges2: + curr_start, curr_end, curr_label, curr_last_line = ranges2[0] + for start, end, label, line in ranges2[1:]: + if line - curr_last_line <= 1000: + curr_end = max(curr_end, end) + curr_label += f" -> T{label.split(' to T')[-1].split()[0]}" + curr_last_line = line + else: + m_ranges2.append((curr_start, curr_end, curr_label)) + curr_start, curr_end, curr_label, curr_last_line = start, end, label, line + m_ranges2.append((curr_start, curr_end, curr_label)) + + res = [] + + # Render Start Warmup + res.append("#### Start Warmup Before Printing:\n") + res.append("| Orca: Line | Orca: Event (Phase) | Orca T0/T1 | BBL: Line | BBL: Event (Phase) | BBL T0/T1 |\n") + res.append("| :---: | :--- | :---: | :---: | :--- | :---: |\n") + + first_start1 = m_ranges1[0][0] if m_ranges1 else len(f1_track) + first_start2 = m_ranges2[0][0] if m_ranges2 else len(f2_track) + + limit = max(first_start1, first_start2) + for i in range(limit): + item1 = f1_track[i] if i < first_start1 else None + item2 = f2_track[i] if i < first_start2 else None + + o_line, o_desc, o_temps = "", "", "" + if item1: + o_line = str(item1[0]) + act1 = interpret_temp_action(item1[4], item1[1]) + o_desc = f"{item1[4]} ({act1})" + o_temps = f"{item1[2]}/{item1[3]}°C" + + b_line, b_desc, b_temps = "", "", "" + if item2: + b_line = str(item2[0]) + act2 = interpret_temp_action(item2[4], item2[1]) + b_desc = f"{item2[4]} ({act2})" + b_temps = f"{item2[2]}/{item2[3]}°C" + + res.append(f"| {o_line} | {o_desc} | {o_temps} | {b_line} | {b_desc} | {b_temps} |\n") + res.append("\n") + + # Render transitions + num_transitions = max(len(m_ranges1), len(m_ranges2)) + for t_idx in range(num_transitions): + r1 = m_ranges1[t_idx] if t_idx < len(m_ranges1) else None + r2 = m_ranges2[t_idx] if t_idx < len(m_ranges2) else None + + label1 = r1[2] if r1 else "No transition" + label2 = r2[2] if r2 else "No transition" + + res.append(f"#### Transition #{t_idx + 1}:\n") + res.append(f"**Orca:** {label1} | **BBL:** {label2}\n\n") + res.append("| Orca: Line | Orca: Event (Phase) | Orca T0/T1 | BBL: Line | BBL: Event (Phase) | BBL T0/T1 |\n") + res.append("| :---: | :--- | :---: | :---: | :--- | :---: |\n") + + events1 = f1_track[r1[0]:r1[1]+1] if r1 else [] + events2 = f2_track[r2[0]:r2[1]+1] if r2 else [] + + max_ev = max(len(events1), len(events2)) + for i in range(max_ev): + item1 = events1[i] if i < len(events1) else None + item2 = events2[i] if i < len(events2) else None + + o_line, o_desc, o_temps = "", "", "" + if item1: + o_line = str(item1[0]) + act1 = interpret_temp_action(item1[4], item1[1]) + o_desc = f"{item1[4]} ({act1})" + o_temps = f"{item1[2]}/{item1[3]}°C" + if item1[4].startswith("T") and "Active Extruder" in item1[4] and not "T100" in item1[4]: + o_line = f"**{o_line}**" + o_desc = f"**{o_desc}**" + o_temps = f"**{o_temps}**" + + b_line, b_desc, b_temps = "", "", "" + if item2: + b_line = str(item2[0]) + act2 = interpret_temp_action(item2[4], item2[1]) + b_desc = f"{item2[4]} ({act2})" + b_temps = f"{item2[2]}/{item2[3]}°C" + if item2[4].startswith("T") and "Active Extruder" in item2[4] and not "T100" in item2[4]: + b_line = f"**{b_line}**" + b_desc = f"**{b_desc}**" + b_temps = f"**{b_temps}**" + + res.append(f"| {o_line} | {o_desc} | {o_temps} | {b_line} | {b_desc} | {b_temps} |\n") + res.append("\n") + + return "".join(res) + + +def get_nozzle_map(filament_maps_str, track): + if not filament_maps_str: + filament_maps_str = "1 1 1 1 1 2" + + extruder_map = {} + for i, v in enumerate(filament_maps_str.split()): + try: + extruder_map[i] = int(v) + except ValueError: + pass + + # H2C physical mapping: Left (Extruder 1) -> Heater 1, Right (Extruder 2) -> Heater 0 + heater_to_ext = {1: 1, 2: 0} + nozzle_map = {} + for fid, ext_id in extruder_map.items(): + nozzle_map[fid] = heater_to_ext.get(ext_id, 1) + + return nozzle_map + + +def analyze_preheat_cooldown_events(track, nozzle_map=None): + if nozzle_map is None: + nozzle_map = {1: 1} # Fallback H2C: T1 -> heater 1, others -> heater 0 + + events = [] + tc_indices = [] + + prev_nozzle = None + for idx, item in enumerate(track): + desc = item[4] + if desc.startswith("T") and "Active Filament" in desc: + first_part = desc.split()[0] + t_num_str = first_part[1:] + if t_num_str.isdigit(): + t_num = int(t_num_str) + if t_num >= 60000: + continue + + norm_nozzle = t_num + if t_num == 1000: + norm_nozzle = 0 + elif t_num == 1001: + norm_nozzle = 1 + + if prev_nozzle is not None and norm_nozzle != prev_nozzle: + target_ext = nozzle_map.get(norm_nozzle, 0) + source_ext = nozzle_map.get(prev_nozzle, 0) + if target_ext != source_ext: + tc_indices.append((idx, item[0], target_ext, source_ext, norm_nozzle)) + prev_nozzle = norm_nozzle + + for i, (tc_idx, tc_line, target_ext, source_ext, nozzle_num) in enumerate(tc_indices): + # 1. Look for preheat command for target_ext before the toolchange + preheat_line = None + preheat_temp = None + + prev_tc_idx = tc_indices[i - 1][0] if i > 0 else 0 + for k in range(tc_idx - 1, prev_tc_idx - 1, -1): + + desc_k = track[k][4] + active_ext_k = track[k][1] + + # Check M104 / M109 + if "M104" in desc_k or "M109" in desc_k: + words = desc_k.split() + s_temp_str = next((w[1:].replace("°C", "") for w in words if w.startswith("S")), None) + t_val = next((w for w in words if w.startswith("T")), None) + + targeted = None + if t_val == "T0": + targeted = 0 + elif t_val == "T1": + targeted = 1 + elif t_val is None: + targeted = nozzle_map.get(active_ext_k, 0) + + if targeted is not None and targeted == target_ext and s_temp_str and s_temp_str.isdigit(): + temp_val = int(s_temp_str) + if temp_val >= 150: # Real preheat threshold + preheat_line = track[k][0] + preheat_temp = temp_val + break # Stop at first (closest to toolchange) + + # Check M620.15 (Vortek pre-cooling/pre-heating commands) + elif "M620.15" in desc_k and target_ext == 1: + words = desc_k.split() + p_temp_str = next((w[1:].replace("°C", "") for w in words if w.startswith("P") and len(w) > 1 and w[1].isdigit()), None) + c_temp_str = next((w[1:].replace("°C", "") for w in words if w.startswith("C") and len(w) > 1 and w[1].isdigit()), None) + + temp_val = None + if p_temp_str and p_temp_str.isdigit(): + temp_val = int(p_temp_str) + elif c_temp_str and c_temp_str.isdigit(): + temp_val = int(c_temp_str) + + if temp_val and temp_val >= 150: + preheat_line = track[k][0] + preheat_temp = temp_val + + cooldown_line = None + cooldown_temp = None + start_look = max(0, tc_idx - 15) + end_look = min(len(track), tc_idx + 40) + + for k in range(start_look, end_look): + desc_k = track[k][4] + active_ext_k = track[k][1] + + if "M104" in desc_k or "M109" in desc_k: + words = desc_k.split() + s_temp_str = next((w[1:].replace("°C", "") for w in words if w.startswith("S")), None) + t_val = next((w for w in words if w.startswith("T")), None) + + targeted = None + if t_val == "T0": + targeted = 0 + elif t_val == "T1": + targeted = 1 + elif t_val is None: + targeted = nozzle_map.get(active_ext_k, 0) + + if targeted == source_ext and s_temp_str and s_temp_str.isdigit(): + temp_val = int(s_temp_str) + if temp_val < 200: + cooldown_line = track[k][0] + cooldown_temp = temp_val + break + + elif "M620.15" in desc_k and source_ext == 1: + words = desc_k.split() + p_temp_str = next((w[1:].replace("°C", "") for w in words if w.startswith("P") and len(w) > 1 and w[1].isdigit()), None) + c_temp_str = next((w[1:].replace("°C", "") for w in words if w.startswith("C") and len(w) > 1 and w[1].isdigit()), None) + + temp_val = None + if p_temp_str and p_temp_str.isdigit(): + temp_val = int(p_temp_str) + elif c_temp_str and c_temp_str.isdigit(): + temp_val = int(c_temp_str) + + if temp_val and temp_val < 200: + cooldown_line = track[k][0] + cooldown_temp = temp_val + break + + events.append({ + "tc_line": tc_line, + "target_ext": target_ext, + "nozzle_num": nozzle_num, + "preheat_line": preheat_line, + "preheat_temp": preheat_temp, + "cooldown_line": cooldown_line, + "cooldown_temp": cooldown_temp + }) + return events + + +def build_comparison_report(f1_name, f1_meta, f1_settings, f1_events, f1_lines, f1_size, f1_stats, + f2_name, f2_meta, f2_settings, f2_events, f2_lines, f2_size, f2_stats): + + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + slicer1 = get_slicer_name(f1_meta) + slicer2 = get_slicer_name(f2_meta) + + hdr1 = f"File 1 ({slicer1})" + hdr2 = f"File 2 ({slicer2})" + + v1_val = f1_meta["version_info"].get("OrcaSlicer-Version") or f1_meta["version_info"].get("X-BBL-Client-Version") or "Unknown" + v2_val = f2_meta["version_info"].get("OrcaSlicer-Version") or f2_meta["version_info"].get("X-BBL-Client-Version") or "Unknown" + + report = [] + report.append(f"# Slicing G-code Comparison (3MF): {f1_name} vs {f2_name}\n") + report.append(f"**Report Generation Date:** {now_str}\n") + + # 1. Summary table + report.append("## 1. Summary File Statistics\n") + report.append(f"| Metric | {hdr1} | {hdr2} | Difference |\n") + report.append("| :--- | :---: | :---: | :---: |\n") + report.append(f"| **File Name** | `{f1_name}` | `{f2_name}` | — |\n") + report.append(f"| **G-code Size** | {f1_size / (1024*1024):.2f} MB | {f2_size / (1024*1024):.2f} MB | { (f1_size - f2_size) / (1024*1024):+.2f} MB |\n") + report.append(f"| **Total G-code Lines** | {f1_lines:,} | {f2_lines:,} | {f1_lines - f2_lines:+,} |\n") + report.append(f"| **Critical Events** | {len(f1_events):,} | {len(f2_events):,} | {len(f1_events) - len(f2_events):+,} |\n") + + pred1 = float(f1_meta["plate_meta"].get("prediction", 0)) + pred2 = float(f2_meta["plate_meta"].get("prediction", 0)) + report.append(f"| **Print Time** | {int(pred1/60)} min | {int(pred2/60)} min | {int((pred1 - pred2)/60):+} min |\n") + + w1 = float(f1_meta["plate_meta"].get("weight", 0)) + w2 = float(f2_meta["plate_meta"].get("weight", 0)) + report.append(f"| **Total Part Weight** | {w1:.2f} g | {w2:.2f} g | {w1 - w2:+.2f} g |\n") + + fl1 = float(f1_meta["plate_meta"].get("first_layer_time", 0)) + fl2 = float(f2_meta["plate_meta"].get("first_layer_time", 0)) + report.append(f"| **First Layer Time** | {fl1/60:.2f} min | {fl2/60:.2f} min | {(fl1 - fl2)/60:+.2f} min |\n") + + report.append(f"| **Slicer Version** | {slicer1} `{v1_val}` | {slicer2} `{v2_val}` | — |\n") + + m1 = f1_meta["plate_meta"].get("printer_model_id", "Unknown") + m2 = f2_meta["plate_meta"].get("printer_model_id", "Unknown") + report.append(f"| **Printer Model ID** | `{m1}` | `{m2}` | — |\n") + + fmap1 = f1_meta["plate_meta"].get("filament_maps", "None") + fmap2 = f2_meta["plate_meta"].get("filament_maps", "None") + report.append(f"| **filament_maps Map** | `{fmap1}` | `{fmap2}` | — |\n") + + dynamic1 = f1_meta["plate_meta"].get("enable_filament_dynamic_map", "Unknown") + dynamic2 = f2_meta["plate_meta"].get("enable_filament_dynamic_map", "Unknown") + report.append(f"| **Dynamic Mapping** | `{dynamic1}` | `{dynamic2}` | — |\n") + + switcher1 = f1_meta["plate_meta"].get("has_filament_switcher", "Unknown") + switcher2 = f2_meta["plate_meta"].get("has_filament_switcher", "Unknown") + report.append(f"| **Nozzle Switcher** | `{switcher1}` | `{switcher2}` | — |\n") + + ens1 = f1_settings.get("extruder_nozzle_stats", "None") + ens2 = f2_settings.get("extruder_nozzle_stats", "None") + report.append(f"| **Nozzle Stats (extruder_nozzle_stats)** | `{escape_markdown_table(ens1)}` | `{escape_markdown_table(ens2)}` | — |\n") + + fnm1 = f1_settings.get("filament_nozzle_map", "None") + fnm2 = f2_settings.get("filament_nozzle_map", "None") + report.append(f"| **Nozzle Map (filament_nozzle_map)** | `{escape_markdown_table(fnm1)}` | `{escape_markdown_table(fnm2)}` | — |\n") + + fvm1 = f1_settings.get("filament_volume_map", "None") + fvm2 = f2_settings.get("filament_volume_map", "None") + report.append(f"| **Volume Maps (filament_volume_map)** | `{escape_markdown_table(fvm1)}` | `{escape_markdown_table(fvm2)}` | — |\n") + report.append("\n") + + # 2. Preheat and Standby analysis table + report.append("## 2. Preheat and Standby Cooldown Analysis\n") + report.append("The table matches preheating events of the target nozzle before change with the cooldown of the inactive nozzle.\n") + report.append("Lead (lines) shows how many G-code lines before physical toolchange `T` the printer sends `M104/M109` heating command.\n\n") + report.append(f"| Change # | Target Extruder | {slicer1}: Preheat | {slicer1}: Cooldown | {slicer2}: Preheat | {slicer2}: Cooldown |\n") + report.append("| :---: | :---: | :--- | :--- | :--- | :--- |\n") + + f1_nozzle_map = get_nozzle_map(f1_meta["plate_meta"].get("filament_maps"), f1_stats["temp_track"]) + f2_nozzle_map = get_nozzle_map(f2_meta["plate_meta"].get("filament_maps"), f2_stats["temp_track"]) + f1_preheat = analyze_preheat_cooldown_events(f1_stats["temp_track"], f1_nozzle_map) + f2_preheat = analyze_preheat_cooldown_events(f2_stats["temp_track"], f2_nozzle_map) + + max_preheat = max(len(f1_preheat), len(f2_preheat)) + for idx in range(max_preheat): + p1 = f1_preheat[idx] if idx < len(f1_preheat) else None + p2 = f2_preheat[idx] if idx < len(f2_preheat) else None + + target_str = f"T{p1['nozzle_num']}" if p1 else (f"T{p2['nozzle_num']}" if p2 else "") + + o_preheat_str = "—" + if p1 and p1["preheat_temp"] is not None: + lead = p1["tc_line"] - p1["preheat_line"] + o_preheat_str = f"{p1['preheat_temp']}°C ({lead} lines lead)" + + o_cooldown_str = "—" + if p1 and p1["cooldown_temp"] is not None: + o_cooldown_str = f"{p1['cooldown_temp']}°C" + + b_preheat_str = "—" + if p2 and p2["preheat_temp"] is not None: + lead = p2["tc_line"] - p2["preheat_line"] + b_preheat_str = f"{p2['preheat_temp']}°C ({lead} lines lead)" + + b_cooldown_str = "—" + if p2 and p2["cooldown_temp"] is not None: + b_cooldown_str = f"{p2['cooldown_temp']}°C" + + report.append(f"| {idx+1} | `{target_str}` | {o_preheat_str} | {o_cooldown_str} | {b_preheat_str} | {b_cooldown_str} |\n") + report.append("\n") + + # 3. Nozzle to Extruder mappings + report.append("## 3. Nozzle and Extruder Mapping (Vortek Nozzles)\n") + report.append(f"**{slicer1} `extruder_nozzle_stats`:** `{ens1}`\n\n") + report.append(f"**{slicer2} `extruder_nozzle_stats`:** `{ens2}`\n\n") + + report.append(f"### {hdr1}:\n") + if f1_meta["nozzles"]: + report.append("| Nozzle ID | Extruder ID | Diameter |\n| :---: | :---: | :---: |\n") + for noz in f1_meta["nozzles"]: + report.append(f"| {noz['id']} | {noz['extruder_id']} | {noz['nozzle_diameter']} |\n") + else: + report.append("*Nozzle mapping is not present in metadata*\n") + report.append("\n") + + report.append(f"### {hdr2}:\n") + if f2_meta["nozzles"]: + report.append("| Nozzle ID | Extruder ID | Diameter |\n| :---: | :---: | :---: |\n") + for noz in f2_meta["nozzles"]: + report.append(f"| {noz['id']} | {noz['extruder_id']} | {noz['nozzle_diameter']} |\n") + else: + report.append("*Nozzle mapping is not present in metadata*\n") + report.append("\n") + + # 4. Filaments used + report.append("## 4. Filaments Used\n") + report.append(f"### {hdr1}:\n") + report.append("| ID | Type | Color | Weight (g) | Length (m) | Nozzle Dia |\n| :---: | :--- | :---: | :---: | :---: | :---: |\n") + for fil in f1_meta["filaments"]: + c = fil["color"] + used_m = f"{float(fil.get('used_m', 0)):.2f} m" if fil.get("used_m") else "—" + report.append(f"| {fil['id']} | {fil['type']} | `{c}` | {fil['used_g']} g | {used_m} | {fil['nozzle_diameter']} |\n") + report.append("\n") + + report.append(f"### {hdr2}:\n") + report.append("| ID | Type | Color | Weight (g) | Length (m) | Nozzle Dia |\n| :---: | :--- | :---: | :---: | :---: | :---: |\n") + for fil in f2_meta["filaments"]: + c = fil["color"] + used_m = f"{float(fil.get('used_m', 0)):.2f} m" if fil.get("used_m") else "—" + report.append(f"| {fil['id']} | {fil['type']} | `{c}` | {fil['used_g']} g | {used_m} | {fil['nozzle_diameter']} |\n") + report.append("\n") + + # 5. Settings differences + report.append("## 5. Differences in Key Slicing Settings (project_settings.config)\n") + + interesting_keys = { + "extruder_nozzle_stats", + "filament_nozzle_map", + "filament_volume_map", + "physical_extruder_map", + "master_extruder_id", + "extruder_max_nozzle_count", + "has_filament_switcher", + "enable_filament_dynamic_map", + "filament_settings_id", + "filament_type", + "machine_load_filament_time", + "machine_unload_filament_time", + "enable_prime_tower", + "prime_tower_width", + "prime_tower_brim_width", + "wipe_tower_size", + "wipe_tower_width", + "filament_pre_cooling_temperature", + "filament_pre_cooling_temperature_nc", + "filament_retract_length_nc", + "filament_retract_speed_nc", + "filament_deretract_speed_nc", + "layer_height", + "initial_layer_print_height", + "wall_loops", + "sparse_infill_density", + "sparse_infill_pattern", + "bridge_flow_ratio", + "flush_volumes_matrix", + "flush_multiplier", + } + + diff_settings = {} + for key in sorted(interesting_keys): + v1 = f1_settings.get(key) + v2 = f2_settings.get(key) + if v1 != v2: + diff_settings[key] = (v1, v2) + + if diff_settings: + report.append(f"| Setting Key | Value in {slicer1} | Value in {slicer2} |\n") + report.append("| :--- | :--- | :--- |\n") + for key, (v_orca, v_bbl) in diff_settings.items(): + report.append(f"| `{key}` | `{escape_markdown_table(v_orca)}` | `{escape_markdown_table(v_bbl)}` |\n") + else: + report.append("*No significant differences in key slicing settings found.*\n") + report.append("\n") + + # 6. Deep Vortek & Print Logic Analysis + report.append("## 6. Detailed Vortek Logic and Print Parameters Analysis\n") + report.append("### A. Nozzle Changes and Tool Change Operations\n") + report.append(f"| Parameter | {hdr1} | {hdr2} |\n") + report.append("| :--- | :---: | :---: |\n") + report.append(f"| **Total nozzle/extruder changes (T)** | {f1_stats['toolchange_count']} | {f2_stats['toolchange_count']} |\n") + seq1 = " -> ".join(f1_stats['toolchange_sequence'][:12]) + ("..." if len(f1_stats['toolchange_sequence']) > 12 else "") + seq2 = " -> ".join(f2_stats['toolchange_sequence'][:12]) + ("..." if len(f2_stats['toolchange_sequence']) > 12 else "") + report.append(f"| **Change Sequence (first 12)** | `{seq1}` | `{seq2}` |\n") + report.append("\n") + report.append("### B. Prime Tower Comparison\n") + report.append(f"| Parameter | {hdr1} | {hdr2} |\n") + report.append("| :--- | :--- | :---: |\n") + report.append(f"| **Total Prime Tower Entries (M628 S1)** | {f1_stats['prime_tower_blocks']} | {f2_stats['prime_tower_blocks']} |\n") + report.append(f"| **Total G-code Lines Inside Tower** | {f1_stats['prime_tower_lines']:,} | {f2_stats['prime_tower_lines']:,} |\n") + report.append("\n") + report.append("### C. Retract Parameters During Nozzle Switch (M620.11)\n") + report.append(f"| {hdr1} | {hdr2} |\n") + report.append("| :--- | :--- |\n") + max_len = max(len(f1_stats['m620_11_retracts']), len(f2_stats['m620_11_retracts'])) + for i in range(max_len): + r1 = f1_stats['m620_11_retracts'][i] if i < len(f1_stats['m620_11_retracts']) else "" + r2 = f2_stats['m620_11_retracts'][i] if i < len(f2_stats['m620_11_retracts']) else "" + report.append(f"| `{r1}` | `{r2}` |\n") + report.append("\n") + + # Toolchange G-code Blocks Diff + report.append("### D. Change Filament G-code Blocks Analysis (change_filament_gcode)\n") + max_tc_blocks = max(len(f1_stats['toolchange_blocks']), len(f2_stats['toolchange_blocks'])) + diffs_count = 0 + diff_summary = [] + + def clean_gcode_lines(lines_list): + res = [] + for line in lines_list: + if line.startswith("Line "): + parts = line.split(": ", 1) + if len(parts) == 2: + res.append(parts[1]) + else: + res.append(line) + else: + res.append(line) + return res + + for b_idx in range(max_tc_blocks): + b1 = f1_stats['toolchange_blocks'][b_idx].splitlines() if b_idx < len(f1_stats['toolchange_blocks']) else [] + b2 = f2_stats['toolchange_blocks'][b_idx].splitlines() if b_idx < len(f2_stats['toolchange_blocks']) else [] + + b1_clean = clean_gcode_lines(b1) + b2_clean = clean_gcode_lines(b2) + + tc_diff = list(difflib.unified_diff(b1_clean, b2_clean, lineterm="")) + if tc_diff: + diffs_count += 1 + changed_lines = [l for l in tc_diff if l.startswith('+') or l.startswith('-')] + changed_lines = [l for l in changed_lines if not l.startswith('+++') and not l.startswith('---')] + commands_changed = set() + for cl in changed_lines: + cmd = cl[1:].strip().split()[0] if cl[1:].strip() else "" + if cmd: + commands_changed.add(cmd) + cmds_str = ", ".join(sorted(list(commands_changed))) + diff_summary.append(f"- **Change #{b_idx + 1}**: differences found ({len(changed_lines)} modified lines, commands: `{cmds_str}`)\n") + + if diffs_count == 0: + report.append("> [!NOTE]\n> All change filament G-code blocks (`change_filament_gcode`) are identical!\n\n") + else: + report.append(f"Filament change G-code blocks difference summary (total changes: {max_tc_blocks}, differing: {diffs_count}, identical: {max_tc_blocks - diffs_count}):\n") + limit = 10 + for item in diff_summary[:limit]: + report.append(item) + if len(diff_summary) > limit: + report.append(f"- ... and {len(diff_summary) - limit} more differing changes ...\n") + report.append("\n") + + # G-code critical events diff + report.append("## 6. Differences in G-code Control Commands (Toolchange / Nozzle Changer / Temp)\n") + + def clean_events(events_list): + res = [] + for ev in events_list: + if ev.startswith("Line "): + parts = ev.split(": ", 1) + if len(parts) == 2: + res.append(parts[1]) + else: + res.append(ev) + else: + res.append(ev) + return res + + events1_clean = clean_events(f1_events) + events2_clean = clean_events(f2_events) + + diff = difflib.unified_diff( + events1_clean, + events2_clean, + fromfile=slicer1, + tofile=slicer2, + n=2, + lineterm="" + ) + + diff_lines = list(diff) + if diff_lines: + diff_lines_clean = [dl for dl in diff_lines if not dl.startswith('---') and not dl.startswith('+++') and not dl.startswith('@@')] + report.append(f"Detected {len(diff_lines_clean)} lines of differences in control commands timeline.\n") + report.append("Showing key differences (max 12 lines):\n") + report.append("```diff\n") + for dline in diff_lines_clean[:12]: + report.append(f"{dline}\n") + if len(diff_lines_clean) > 12: + report.append(f"... and {len(diff_lines_clean) - 12} more difference lines ...\n") + report.append("```\n") + else: + report.append("> [!NOTE]\n") + report.append("> G-code control commands timeline is identical!\n") + + # Critical Discrepancies Analyzer + report.append("\n## 7. Critical Discrepancies and Errors Analysis (Analytics)\n") + discrepancies = analyze_critical_discrepancies(f1_meta, f2_meta, f1_settings, f2_settings, f1_stats, f2_stats) + if discrepancies: + report.append("| Status | Problem Description |\n") + report.append("| :--- | :--- |\n") + for item in discrepancies: + level_str = f"**{item['level']}**" + if "ERROR" in item["level"]: + level_str = f"❌ {item['level']}" + elif "DISCREPANCY" in item["level"]: + level_str = f"⚠️ {item['level']}" + else: + level_str = f"ℹ️ {item['level']}" + report.append(f"| {level_str} | {item['message']} |\n") + else: + report.append("> [!NOTE]\n> No critical discrepancies or slicing logic errors found.\n") + return "".join(report) + +def main(): + if len(sys.argv) < 3: + print("Usage: python3 compare_slices.py ") + sys.exit(1) + + f1_input = sys.argv[1] + f2_input = sys.argv[2] + + f1_path = find_file(f1_input) + f2_path = find_file(f2_input) + + if not f1_path: + print(f"Error: File '{f1_input}' not found on Desktop or local path.") + sys.exit(1) + if not f2_path: + print(f"Error: File '{f2_input}' not found on Desktop or local path.") + sys.exit(1) + + print(f"Analyzing File 1: {f1_path}") + with zipfile.ZipFile(f1_path, "r") as z1: + f1_meta = parse_metadata(z1) + f1_settings = parse_project_settings(z1) + f1_maps_str = f1_meta["plate_meta"].get("filament_maps") + f1_events, f1_lines, f1_size, f1_stats = parse_critical_gcode(z1, f1_maps_str) + + print(f"Analyzing File 2: {f2_path}") + with zipfile.ZipFile(f2_path, "r") as z2: + f2_meta = parse_metadata(z2) + f2_settings = parse_project_settings(z2) + f2_maps_str = f2_meta["plate_meta"].get("filament_maps") + f2_events, f2_lines, f2_size, f2_stats = parse_critical_gcode(z2, f2_maps_str) + + f1_basename = os.path.basename(f1_path) + f2_basename = os.path.basename(f2_path) + + report_content = build_comparison_report( + f1_basename, f1_meta, f1_settings, f1_events, f1_lines, f1_size, f1_stats, + f2_basename, f2_meta, f2_settings, f2_events, f2_lines, f2_size, f2_stats + ) + + os.makedirs(REPORTS_DIR, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + report_name = f"compare_report_{timestamp}.md" + report_path = os.path.join(REPORTS_DIR, report_name) + + with open(report_path, "w", encoding="utf-8") as f: + f.write(report_content) + + print("\n" + "="*50) + print(f"Comparison completed successfully!") + print(f"Report saved to: {report_path}") + print("="*50) + +if __name__ == "__main__": + main() diff --git a/tests/compare_analyzer/show_temp_plot.py b/tests/compare_analyzer/show_temp_plot.py new file mode 100755 index 0000000000..c006637304 --- /dev/null +++ b/tests/compare_analyzer/show_temp_plot.py @@ -0,0 +1,1357 @@ +#!/usr/bin/env python3 +""" +H2C Temperature Timeline Comparison Tool. +Generates interactive HTML with temperature plots for analyzing and comparing OrcaSlicer vs BambuStudio G-code. + +================================================================================ +ARCHITECTURE & MAPPING LOGIC: +================================================================================ +- H2C configuration utilizes a dual-extruder layout with 4 Vortek nozzles. +- Physical heaters are mapped as: + - Heater 0: Extruder 2 (physical RIGHT nozzle slot, T0/T2/T3/T4) + - Heater 1: Extruder 1 (physical LEFT nozzle slot, T1) +- The active heater mapping is derived dynamically based on active G-code temperature signals: + - When filament X is active and heater N is heated to printing temperature (>200°C), + we associate filament X with heater N. + +================================================================================ +EXECUTION & RUN RULES: +================================================================================ +1. Single File Analysis: + Generates a temperature profile layout for a single 3MF file. + Usage: + python3 show_temp_plot.py + +2. Comparison Mode (Two Files): + Renders a side-by-side alignment of temperature panels for both files (e.g. OrcaSlicer vs BambuStudio). + Usage: + python3 show_temp_plot.py + +Output: +- The script automatically outputs the compiled interactive HTML report to: + ~/Desktop/temp_plot.html +- Opens the HTML report in the system's default web browser immediately. +""" +import sys +import os +import zipfile +import json +import xml.etree.ElementTree as ET +import webbrowser + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) +try: + import compare_slices +except ImportError: + sys.path.append("/Users/denn/Develop/3dprint/dehancer lab/H2C_v2/scripts") + import compare_slices + + +def parse_filaments_and_colors(zip_file): + """Parse filament colors and types from Metadata/slice_info.config inside 3MF.""" + filaments = {} + try: + xml_data = zip_file.read("Metadata/slice_info.config") + root = ET.fromstring(xml_data) + plate = root.find("plate") + if plate is not None: + for fil in plate.findall("filament"): + fid = int(fil.attrib.get("id")) # 1-based in XML + color = fil.attrib.get("color") + ftype = fil.attrib.get("type", "PLA") + filaments[fid - 1] = {"color": color, "type": ftype} + except Exception as e: + print(f"Error parsing filaments: {e}") + return filaments + + +def parse_nozzle_groups(zip_file): + """ + Parse filament-to-extruder mapping from filament_maps in slice_info.config. + + filament_maps = "2 1 2 2 2" → {0: 2, 1: 1, 2: 2, 3: 2, 4: 2} + + Returns: + extruder_map: {filament_id_0based: extruder_id} + extruder_groups: {extruder_id: [list of filament IDs 0-based]} + """ + extruder_map = {} + + try: + xml_data = zip_file.read("Metadata/slice_info.config").decode("utf-8", errors="replace") + root = ET.fromstring(xml_data) + plate = root.find("plate") + if plate is not None: + for meta in plate.findall("metadata"): + if meta.attrib.get("key") == "filament_maps": + raw = meta.attrib.get("value", "").strip() + if raw: + for i, v in enumerate(raw.split()): + extruder_map[i] = int(v) + break + except Exception as e: + print(f" Warning: could not parse filament_maps: {e}") + + extruder_groups = {} + for fid, eid in extruder_map.items(): + extruder_groups.setdefault(eid, []).append(fid) + + return extruder_map, extruder_groups + + +def determine_heater_to_extruder(track, extruder_map): + # H2C physical mapping: Left (Extruder 1) -> Heater 1, Right (Extruder 2) -> Heater 0 + return {1: 1, 2: 0} + + +def build_timeline_and_interpolate(track, tool_changes, m73_points, total_lines, m400_weights=None): + if m400_weights is None: + m400_weights = {} + m73_points = sorted(list(set([(p[0], p[1]) for p in m73_points]))) + # Filter to only keep the first occurrence of each unique remaining time value + filtered_m73 = [] + seen_times = set() + for line, r_val in m73_points: + if r_val not in seen_times: + filtered_m73.append((line, r_val)) + seen_times.add(r_val) + m73_points = filtered_m73 + timeline = [] + if m73_points: + max_R = m73_points[0][1] + for _, r in m73_points[:5]: + if r > max_R: + max_R = r + for line, r in m73_points: + elapsed_sec = (max_R - r) * 60 + timeline.append((line, elapsed_sec)) + if timeline[0][0] > 1: + timeline.insert(0, (1, 0)) + if timeline[-1][0] < total_lines: + timeline.append((total_lines, max_R * 60)) + else: + timeline = [(1, 0), (total_lines, total_lines * 0.02)] + + line_to_time = {} + track_m620 = {t["line"]: t.get("in_m620", False) for t in track} + + for k in range(len(timeline) - 1): + line1, time1 = timeline[k] + line2, time2 = timeline[k + 1] + + dT = time2 - time1 + dL = line2 - line1 + if dL <= 0: + continue + + weights = [] + total_w = 0.0 + for l in range(line1, line2 + 1): + is_tc = track_m620.get(l, False) + w = 500.0 if is_tc else 1.0 + if l in m400_weights: + w += m400_weights[l] * 50.0 + weights.append((l, w)) + total_w += w + + current_t = time1 + if total_w == 0: + total_w = 1.0 + + for l, w in weights: + line_to_time[l] = current_t + current_t += (w / total_w) * dT + + def get_time(line): + l_round = int(round(line)) + if l_round in line_to_time: + return line_to_time[l_round] + + if line <= timeline[0][0]: + return timeline[0][1] + if line >= timeline[-1][0]: + return timeline[-1][1] + for k in range(len(timeline) - 1): + pt1 = timeline[k] + pt2 = timeline[k + 1] + if pt1[0] <= line <= pt2[0]: + if pt2[0] == pt1[0]: + return pt1[1] + return pt1[1] + (line - pt1[0]) / (pt2[0] - pt1[0]) * (pt2[1] - pt1[1]) + return 0 + + for t in track: + t["time"] = get_time(t["line"]) + for tc in tool_changes: + tc["time"] = get_time(tc["line"]) + + total_duration = timeline[-1][1] + return total_duration, get_time + + +def parse_file_data(filepath): + if not filepath or not os.path.exists(filepath): + return None + try: + with zipfile.ZipFile(filepath) as z: + filaments = parse_filaments_and_colors(z) + extruder_map, extruder_groups = parse_nozzle_groups(z) + _, total_lines, _, stats = compare_slices.parse_critical_gcode(z) + track_raw = stats["temp_track"] + m73_points = stats.get("m73_points", []) + + # Find the start of machine end gcode to cut off non-printing trailing commands (e.g. air filtration wait) + end_gcode_line = total_lines + for name in z.namelist(): + if name.endswith('.gcode'): + gcode_text = z.read(name).decode('utf-8', errors='replace') + gcode_lines = gcode_text.split('\n') + for i in range(len(gcode_lines) - 1, -1, -1): + gl = gcode_lines[i] + line_num = i + 1 + if ';' in gl and '=' not in gl: + if 'MACHINE_END_GCODE_START' in gl or 'filament end gcode' in gl or 'machine: H2C end' in gl: + end_gcode_line = line_num + break + break + + # Keep end_gcode_line but do not trim data arrays to preserve full time duration + pass + + # Parse TC, Wipe Tower, Toolchange zones and M400 weights from raw gcode + tc_zones_raw = [] # list of (start_line, end_line) + wipe_zones_raw = [] # list of (start_line, end_line) + toolchange_zones_raw = [] # list of (start_line, end_line) + m400_weights = {} + for name in z.namelist(): + if name.endswith('.gcode'): + gcode_text = z.read(name).decode('utf-8', errors='replace') + gcode_lines = gcode_text.split('\n') + nc_start = None + wipe_start = None + tc_block_start = None + for i, gl in enumerate(gcode_lines): + line_num = i + 1 + # Parse M400 delay commands + if 'M400' in gl: + import re + m_s = re.search(r'M400\s+S(\d+)', gl) + if m_s: + m400_weights[line_num] = float(m_s.group(1)) + else: + m_p = re.search(r'M400\s+P(\d+)', gl) + if m_p: + m400_weights[line_num] = float(m_p.group(1)) * 0.001 + # Match both Orca and BBS toolchange start comments + if 'CP TOOLCHANGE START' in gl: + tc_block_start = line_num + elif 'CP TOOLCHANGE END' in gl: + if tc_block_start is not None: + toolchange_zones_raw.append((tc_block_start, line_num)) + tc_block_start = None + + # Match both Orca (;_NOZZLE_CHANGE_START) and BBS (; NOZZLE_CHANGE_START) + if 'NOZZLE_CHANGE_START' in gl: + nc_start = line_num + elif 'NOZZLE_CHANGE_END' in gl: + if nc_start is not None: + tc_zones_raw.append((nc_start, line_num)) + nc_start = None + # Match both Orca (; CP TOOLCHANGE WIPE) and BBS (; CP_TOOLCHANGE_WIPE) + elif 'CP TOOLCHANGE WIPE' in gl or 'CP_TOOLCHANGE_WIPE' in gl: + wipe_start = line_num + elif '; CP TOOLCHANGE END' in gl: + if wipe_start is not None: + wipe_zones_raw.append((wipe_start, line_num)) + wipe_start = None + break + + # Extract preheat events in raw G-code lines format using dynamic nozzle_map + filament_maps_str = " ".join([str(extruder_map[i]) for i in sorted(extruder_map.keys())]) + nozzle_map_for_preheat = compare_slices.get_nozzle_map(filament_maps_str, track_raw) + raw_preheats = compare_slices.analyze_preheat_cooldown_events(track_raw, nozzle_map_for_preheat) + + track = [] + tool_changes = [] + current_tool = 0 + for line, active_ext, t0, t1, desc, in_m620_block, printing_started in track_raw: + if desc.startswith("T") and "Active Filament" in desc: + parts = desc.split() + t_name = parts[0] + if t_name[1:].isdigit(): + t_val = int(t_name[1:]) + if t_val < 60000: + current_tool = t_val + + track.append({ + "line": line, + "active": current_tool, + "t0": t0, + "t1": t1, + "desc": desc, + "in_m620": in_m620_block, + "printing_started": printing_started + }) + if desc.startswith("T") and "Active Filament" in desc: + parts = desc.split() + t_name = parts[0] + t_idx = -1 + if t_name[1:].isdigit(): + t_idx = int(t_name[1:]) + tool_changes.append({ + "line": line, + "name": t_name, + "idx": t_idx, + "desc": desc, + }) + + total_duration, get_time = build_timeline_and_interpolate(track, tool_changes, m73_points, total_lines, m400_weights) + end_gcode_time = get_time(end_gcode_line) + + # Convert preheat lines to time (seconds) + preheats = [] + for ev in raw_preheats: + if ev["preheat_line"] is not None and ev["tc_line"] is not None: + start_t = get_time(ev["preheat_line"]) + end_t = get_time(ev["tc_line"]) + preheats.append({ + "start_time": start_t, + "end_time": end_t, + "heater": ev["target_ext"], # 0 or 1 + "target_temp": ev["preheat_temp"], + "nozzle_num": ev["nozzle_num"] + }) + + heater_to_ext = determine_heater_to_extruder(track, extruder_map) + + nozzle_map = {} + for fid, ext_id in extruder_map.items(): + nozzle_map[fid] = heater_to_ext.get(ext_id, 0) + + # Convert TC/Wipe zones to time coordinates. + # M73 has 1-minute resolution, so TC zones (which last ~20-30s) often map + # to identical times. When that happens, estimate width from line count ratio. + def zone_to_time(start_line, end_line, min_dur=8.0): + t_start = get_time(start_line) + t_end = get_time(end_line) + actual_dur = t_end - t_start + if actual_dur < min_dur: + # M73 has 1-minute resolution and BBS TC is only ~7 lines, + # so interpolated duration can be <1s. Use line-based estimate. + estimated_dur = max(min_dur, (end_line - start_line) * 0.15) + t_end = t_start + estimated_dur + return {"start_time": t_start, "end_time": t_end} + + # Sequence nozzle changes (tc_zones) and wipe tower blocks (wipe_zones) sequentially + # to prevent visual overlaps caused by artificial duration extension. + all_sub_zones = [] + for s, e in tc_zones_raw: + all_sub_zones.append({"type": "nc", "start_line": s, "end_line": e}) + for s, e in wipe_zones_raw: + all_sub_zones.append({"type": "wipe", "start_line": s, "end_line": e}) + + all_sub_zones.sort(key=lambda z: z["start_line"]) + + tc_zones = [] + wipe_zones = [] + prev_end_time = -1.0 + min_dur = 8.0 + + for zone in all_sub_zones: + s_line = zone["start_line"] + e_line = zone["end_line"] + t_start = get_time(s_line) + t_end = get_time(e_line) + + actual_dur = t_end - t_start + dur = actual_dur + if actual_dur < min_dur: + dur = max(min_dur, (e_line - s_line) * 0.15) + + if t_start < prev_end_time: + t_start = prev_end_time + + t_end = t_start + dur + prev_end_time = t_end + + formatted_zone = {"start_time": t_start, "end_time": t_end} + if zone["type"] == "nc": + tc_zones.append(formatted_zone) + else: + wipe_zones.append(formatted_zone) + + toolchange_zones = [zone_to_time(s, e) for s, e in toolchange_zones_raw] + + + slicer_name = "OrcaSlicer" if "orca" in os.path.basename(filepath).lower() else ( + "BambuStudio" if "bbl" in os.path.basename(filepath).lower() else "Slicer" + ) + + print(f" Extruder groups: {extruder_groups}") + print(f" Heater→Extruder: {heater_to_ext}") + for fid, heater in sorted(nozzle_map.items()): + ext_id = extruder_map.get(fid, "?") + fil_info = filaments.get(fid, {}) + print(f" T{fid} ({fil_info.get('type','?')} {fil_info.get('color','?')}) → Extruder {ext_id} → Heater {heater}") + + cooldown_count = sum(1 for ev in raw_preheats if ev.get("cooldown_temp") is not None) + + return { + "filename": os.path.basename(filepath), + "slicer": slicer_name, + "total_lines": total_lines, + "total_duration": total_duration, + "end_gcode_time": end_gcode_time, + "filaments": filaments, + "nozzle_map": nozzle_map, + "extruder_groups": extruder_groups, + "heater_to_ext": heater_to_ext, + "tool_changes": tool_changes, + "preheats": preheats, + "preheat_count": len(preheats), + "cooldown_count": cooldown_count, + "tc_zones": tc_zones, + "wipe_zones": wipe_zones, + "toolchange_zones": toolchange_zones, + "track": track, + } + except Exception as e: + import traceback + print(f"Error parsing {filepath}: {e}") + traceback.print_exc() + return None + + +def find_desktop_3mf_files(): + desktop = os.path.expanduser("~/Desktop") + files = [os.path.join(desktop, f) for f in os.listdir(desktop) if f.endswith(".3mf")] + files = sorted(files, key=os.path.getmtime, reverse=True) + return files + + +def main(): + file1_path = None + file2_path = None + + if len(sys.argv) > 1: + file1_path = sys.argv[1] + if len(sys.argv) > 2: + file2_path = sys.argv[2] + + if not file1_path: + desktop_files = find_desktop_3mf_files() + if len(desktop_files) >= 2: + orcas = [f for f in desktop_files if "orca" in os.path.basename(f).lower()] + bbls = [f for f in desktop_files if "bbl" in os.path.basename(f).lower() or "bambu" in os.path.basename(f).lower()] + if orcas and bbls: + file1_path = orcas[0] + file2_path = bbls[0] + else: + file1_path = desktop_files[0] + file2_path = desktop_files[1] + elif len(desktop_files) == 1: + file1_path = desktop_files[0] + + if not file1_path: + print("Usage: python3 show_temp_plot.py [file2.3mf]") + sys.exit(1) + + print(f"Loading File 1: {file1_path}") + f1_data = parse_file_data(file1_path) + + f2_data = None + if file2_path: + print(f"Loading File 2: {file2_path}") + f2_data = parse_file_data(file2_path) + + if not f1_data: + print("Error: Failed to parse first file.") + sys.exit(1) + + js_data = { + "is_comparison": f2_data is not None, + "file1": f1_data, + "file2": f2_data, + "total_duration": max(f1_data["total_duration"], f2_data["total_duration"]) if f2_data else f1_data["total_duration"], + } + + html_content = HTML_TEMPLATE.replace("%DATA_JSON%", json.dumps(js_data)) + + output_html = os.path.join(os.path.dirname(file1_path), "temp_plot_v3.html") + with open(output_html, "w", encoding="utf-8") as f: + f.write(html_content) + + print(f"Interactive HTML report: {output_html}") + webbrowser.open("file://" + os.path.abspath(output_html)) + + +# H2C hardware fact: heater 0 = RIGHT nozzle, heater 1 = LEFT nozzle +# Panel layout: top panels show heater 0 (RIGHT), bottom panels show heater 1 (LEFT) +HTML_TEMPLATE = r""" + + + + H2C Temperature Timeline + + + + +
+ + + +""" + +if __name__ == "__main__": + main()