import os import json import base64 from typing import Optional from langchain_core.tools import tool from ase.data import chemical_symbols as _chemical_symbols from chemgraph.schemas.ase_input import ASEOutputSchema from chemgraph.tools.ase_tools import is_linear_molecule HTML_TEMPLATE = """ XYZ Molecule Viewer

XYZ Molecule Viewer

Calculation Results

Simulation Details

""" @tool def generate_html( results_json_path: str, output_path: str = "report.html", xyz_path: Optional[str] = None, ) -> str: """Generate an HTML report from the JSON results file produced by run_ase. Parameters ---------- results_json_path : str Path to the JSON file produced by the run_ase tool containing the full simulation results (energy, structure, frequencies, etc.). output_path : str Path where the HTML report will be saved. Defaults to "report.html". xyz_path : Optional[str] Optional path to an XYZ file for the 3D viewer. If not provided, the final_structure from the results JSON will be used. Returns ------- str Path to the generated HTML file """ # Validate results_json_path exists if not os.path.isfile(results_json_path): return ( f"Results JSON file not found: {results_json_path}. " "Please provide a valid path to the JSON file produced by the run_ase tool." ) # Validate xyz_path exists (if provided) if xyz_path is not None and not os.path.isfile(xyz_path): return ( f"XYZ file not found: {xyz_path}. " "Please provide a valid path to an XYZ file." ) # Load and parse the results JSON try: with open(results_json_path, "r", encoding="utf-8") as f: data = json.load(f) except json.JSONDecodeError as e: return ( f"Failed to parse JSON from {results_json_path}: {e}. " "The file may be corrupted or not valid JSON." ) # Validate the data against ASEOutputSchema try: ase_output = ASEOutputSchema(**data) except Exception as e: return ( f"Failed to validate results data from {results_json_path}: {e}. " "The JSON file may not contain valid ASE output data." ) # Get XYZ content either from file or final_structure if xyz_path is not None: with open(xyz_path, 'r') as f: xyz_content = f.read() else: if ase_output.final_structure is None: return ( "No XYZ file provided and no final_structure found in the results JSON. " "Please provide an xyz_path or ensure the simulation results include a final structure." ) # Convert final_structure to XYZ format num_atoms = len(ase_output.final_structure.numbers) xyz_lines = [str(num_atoms), "Optimized Structure"] for num, pos in zip( ase_output.final_structure.numbers, ase_output.final_structure.positions ): element = _chemical_symbols[num] if num < len(_chemical_symbols) else f"X{num}" x, y, z = pos xyz_lines.append(f"{element} {x:.6f} {y:.6f} {z:.6f}") xyz_content = "\n".join(xyz_lines) # Validate output directory exists output_dir = os.path.dirname(os.path.abspath(output_path)) if not os.path.isdir(output_dir): return ( f"Output directory does not exist: {output_dir}. " "Please provide a valid output path." ) # Generate the HTML report try: encoded_xyz = base64.b64encode(xyz_content.encode()).decode() html_content = HTML_TEMPLATE.format(encoded_xyz=encoded_xyz) # Add additional information to the HTML content html_content = add_additional_info_to_html(html_content, ase_output) with open(output_path, 'w', encoding='utf-8') as f: f.write(html_content) print(f"✅ HTML viewer created: {output_path}") return str(os.path.abspath(output_path)) except Exception as e: return f"Failed to generate HTML report: {e}" def add_additional_info_to_html(html_content: str, ase_output: ASEOutputSchema) -> str: """Add ASE calculation results to the HTML content. Parameters ---------- html_content : str The base HTML content ase_output : ASEOutputSchema The output from an ASE calculation Returns ------- str HTML content with additional information added """ # Calculation Results section calc_results = [] # Optimized Coordinates (from final structure) if ase_output.final_structure is not None: # Convert AtomsData to XYZ format num_atoms = len(ase_output.final_structure.numbers) xyz_lines = [str(num_atoms), "Optimized Structure"] for num, pos in zip( ase_output.final_structure.numbers, ase_output.final_structure.positions ): element = _chemical_symbols[num] if num < len(_chemical_symbols) else f"X{num}" x, y, z = pos xyz_lines.append(f"{element} {x:.6f} {y:.6f} {z:.6f}") xyz_str = "\n".join(xyz_lines) calc_results.append(f"""
  • Optimized Coordinates

    {xyz_str}
  • """) else: calc_results.append( "
  • Optimized Coordinates: N/A
  • " ) # Energy if ase_output.single_point_energy is not None: energy_ev = ase_output.single_point_energy calc_results.append(f"""
  • Energy Unit:
    Single Point Energy (eV):
  • """) else: calc_results.append( f"
  • Single Point Energy ({ase_output.energy_unit}): N/A
  • " ) # Vibrational Frequencies if ( ase_output.vibrational_frequencies and "frequencies" in ase_output.vibrational_frequencies ): freq_unit = ase_output.vibrational_frequencies.get("frequency_unit", "cm-1") energy_unit = ase_output.vibrational_frequencies.get("energy_unit", "meV") # Check if molecule is linear is_linear = is_linear_molecule.invoke({"atomsdata": ase_output.final_structure}) num_atoms = len(ase_output.final_structure.numbers) trans_rot_modes = 5 if is_linear else 6 # Number of translation/rotation modes # Create table header freq_table = f"""
    """ # Add table rows with mode numbers and highlighting for i, (freq, energy) in enumerate( zip( ase_output.vibrational_frequencies["frequencies"], ase_output.vibrational_frequencies["energies"], ), 1, ): # First 5 (linear) or 6 (non-linear) modes are translation/rotation mode_type = ( "Translation/Rotation" if i <= trans_rot_modes else "Vibrational" ) row_class = "trans-rot-mode" if i <= trans_rot_modes else "vibrational-mode" freq_table += f""" """ freq_table += """
    Mode # Frequency ({freq_unit}) Energy ({energy_unit}) Type
    {i} {freq} {energy} {mode_type}
    """ # Add explanation about the modes mode_explanation = f"""

    Molecule Type: {'Linear' if is_linear else 'Non-linear'}

    Mode Breakdown: {trans_rot_modes} translation/rotation modes + {3 * num_atoms - trans_rot_modes} vibrational modes

    Note: The first {trans_rot_modes} modes (highlighted in orange) are translation/rotation modes. The remaining modes (highlighted in green) are vibrational modes.

    """ calc_results.append(f"""
  • Vibrational Frequencies

    {mode_explanation} {freq_table}
  • """) else: calc_results.append( "
  • Vibrational Frequencies: N/A
  • " ) # Thermochemistry Values if ase_output.thermochemistry: thermo_info = [] # Add data attributes for conversion with labels if "enthalpy" in ase_output.thermochemistry: enthalpy_ev = ase_output.thermochemistry['enthalpy'] thermo_info.append( f'
    Enthalpy: {enthalpy_ev:.6f}
    ' ) if "entropy" in ase_output.thermochemistry: entropy_ev = ase_output.thermochemistry['entropy'] thermo_info.append( f'
    Entropy: {entropy_ev:.6f}
    ' ) if "gibbs_free_energy" in ase_output.thermochemistry: gibbs_ev = ase_output.thermochemistry['gibbs_free_energy'] thermo_info.append( f'
    Gibbs Free Energy: {gibbs_ev:.6f}
    ' ) if thermo_info: calc_results.append(f"""
  • Energy Unit:
    Thermochemistry Values (eV):
    {"".join(thermo_info)}
  • """) else: calc_results.append( "
  • Thermochemistry Values: No values available
  • " ) else: calc_results.append( "
  • Thermochemistry Values: N/A
  • " ) # Optimization Status if ase_output.simulation_input.driver == "opt": status = "Converged" if ase_output.converged else "Not Converged" status_class = "color: #28a745;" if ase_output.converged else "color: #dc3545;" calc_results.append( f"
  • Optimization Status: {status}
  • " ) # Error Information if ase_output.error: calc_results.append( f"
  • Error: {ase_output.error}
  • " ) # Join all results with proper spacing calc_results_html = "\n".join(calc_results) # Simulation Details section sim_details = [] # Driver and Calculator sim_details.append( f"
  • Simulation Type: {ase_output.simulation_input.driver or 'N/A'}
  • " ) if ase_output.simulation_input.calculator: calc = ase_output.simulation_input.calculator calc_type = calc.calculator_type sim_details.append(f"
  • Calculator: {calc_type}
  • ") # Get calculator parameters directly from the input calc_params = calc.model_dump() # Create a sub-section for calculator parameters calc_params_html = [] for param, value in calc_params.items(): # Format the parameter name nicely param_name = param.replace('_', ' ').title() # Handle boolean values if isinstance(value, bool): value = str(value) # Handle numeric values elif isinstance(value, (int, float)): value = f"{value:.6g}" # Handle None values elif value is None: value = "None" calc_params_html.append(f"{param_name}{value}") # If no parameters are set, show an empty table if not calc_params_html: calc_params_html.append( "No additional parameters set" ) sim_details.append(f"""
  • Calculator Parameters

    {''.join(calc_params_html)}
    Parameter Value
  • """) # Optimization Parameters if ase_output.simulation_input.driver == "opt": sim_details.append( f"
  • Optimizer: {ase_output.simulation_input.optimizer}
  • " ) sim_details.append( f"
  • Force Convergence (fmax): {ase_output.simulation_input.fmax} eV/Å
  • " ) sim_details.append( f"
  • Maximum Steps: {ase_output.simulation_input.steps}
  • " ) # Thermochemistry Parameters if ase_output.simulation_input.driver == "thermo": if ase_output.simulation_input.temperature: sim_details.append( f"
  • Temperature: {ase_output.simulation_input.temperature} K
  • " ) sim_details.append( f"
  • Pressure: {ase_output.simulation_input.pressure} Pa
  • " ) # Join all simulation details sim_details_html = "\n".join(sim_details) # Replace the empty content in both sections html_content = html_content.replace( '', f'', 1, ) html_content = html_content.replace( '', f'', 1, ) # Add the JavaScript for unit conversion html_content = html_content.replace( '', ''' ''', ) return html_content