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"""
Mode #
Frequency ({freq_unit})
Energy ({energy_unit})
Type
"""
# 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"""
{i}
{freq}
{energy}
{mode_type}
"""
freq_table += """
"""
# Add explanation about the modes
mode_explanation = f"""
Molecule Type: {'Linear' if is_linear else 'Non-linear'}
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'
")
# 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
Parameter
Value
{''.join(calc_params_html)}
""")
# Optimization Parameters
if ase_output.simulation_input.driver == "opt":
sim_details.append(
f"