Spaces:
Running
Running
| 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 = """<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>XYZ Molecule Viewer</title> | |
| <script src="https://unpkg.com/ngl@2.0.0-dev.37/dist/ngl.js"></script> | |
| <style> | |
| body {{ | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; | |
| margin: 0; | |
| padding: 0; | |
| background-color: #f5f5f5; | |
| color: #333; | |
| }} | |
| .container {{ | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| }} | |
| h1 {{ | |
| color: #2c3e50; | |
| margin-bottom: 1.5rem; | |
| font-weight: 600; | |
| }} | |
| h2 {{ | |
| color: #2c3e50; | |
| margin-top: 2rem; | |
| margin-bottom: 1rem; | |
| font-weight: 500; | |
| display: flex; | |
| align-items: center; | |
| cursor: pointer; | |
| }} | |
| h2::before {{ | |
| content: '▼'; | |
| font-size: 0.8em; | |
| margin-right: 0.5rem; | |
| transition: transform 0.3s ease; | |
| }} | |
| h2.collapsed::before {{ | |
| transform: rotate(-90deg); | |
| }} | |
| #viewer {{ | |
| width: 100%; | |
| height: 600px; | |
| border-radius: 8px; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| background: white; | |
| margin: 1rem 0; | |
| }} | |
| .info-section {{ | |
| background: white; | |
| padding: 1.5rem; | |
| border-radius: 8px; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| margin-top: 2rem; | |
| }} | |
| .info-section ul {{ | |
| list-style-type: none; | |
| padding: 0; | |
| margin: 0; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1rem; | |
| }} | |
| .info-section li {{ | |
| padding: 0; | |
| border: none; | |
| margin: 0; | |
| }} | |
| .sub-section {{ | |
| border: 1px solid #eee; | |
| border-radius: 4px; | |
| padding: 0.5rem; | |
| background: #fafafa; | |
| }} | |
| .sub-section h3 {{ | |
| color: #2c3e50; | |
| margin: 0; | |
| padding: 0.5rem; | |
| font-size: 1.1em; | |
| font-weight: 500; | |
| display: flex; | |
| align-items: center; | |
| cursor: pointer; | |
| user-select: none; | |
| background: #f8f9fa; | |
| border-radius: 4px; | |
| }} | |
| .sub-section h3::before {{ | |
| content: '▼'; | |
| font-size: 0.8em; | |
| margin-right: 0.5rem; | |
| transition: transform 0.3s ease; | |
| display: inline-block; | |
| }} | |
| .sub-section h3.collapsed::before {{ | |
| transform: rotate(-90deg); | |
| }} | |
| .sub-section-content {{ | |
| display: block; | |
| padding: 0.5rem; | |
| margin-top: 0.5rem; | |
| }} | |
| .sub-section-content.collapsed {{ | |
| display: none; | |
| }} | |
| .table-container {{ | |
| max-height: 300px; | |
| overflow-y: auto; | |
| border: 1px solid #eee; | |
| border-radius: 4px; | |
| margin: 0.5rem 0; | |
| }} | |
| .table-container table {{ | |
| margin: 0; | |
| border-collapse: collapse; | |
| width: 100%; | |
| }} | |
| .table-container thead {{ | |
| position: sticky; | |
| top: 0; | |
| background: #f8f9fa; | |
| z-index: 1; | |
| }} | |
| .table-container th {{ | |
| border-bottom: 2px solid #dee2e6; | |
| }} | |
| .table-container tbody tr:last-child td {{ | |
| border-bottom: none; | |
| }} | |
| .unit-toggle {{ | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| margin: 0.5rem 0; | |
| padding: 0.5rem; | |
| background: #f8f9fa; | |
| border: 1px solid #dee2e6; | |
| border-radius: 4px; | |
| font-size: 0.9em; | |
| }} | |
| .unit-toggle button {{ | |
| padding: 0.25rem 0.5rem; | |
| background: #fff; | |
| border: 1px solid #dee2e6; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 0.9em; | |
| }} | |
| .unit-toggle button.active {{ | |
| background: #2c3e50; | |
| color: white; | |
| border-color: #2c3e50; | |
| }} | |
| .unit-toggle button:hover:not(.active) {{ | |
| background: #e9ecef; | |
| }} | |
| .energy-value {{ | |
| display: inline-block; | |
| min-width: 100px; | |
| }} | |
| .regular-item {{ | |
| padding: 0.5rem; | |
| background: #f8f9fa; | |
| border-radius: 4px; | |
| border: 1px solid #eee; | |
| }} | |
| table {{ | |
| width: 100%; | |
| border-collapse: collapse; | |
| margin: 0.5rem 0; | |
| font-size: 0.9em; | |
| }} | |
| th, td {{ | |
| padding: 0.5rem; | |
| text-align: left; | |
| border-bottom: 1px solid #eee; | |
| }} | |
| th {{ | |
| background-color: #f8f9fa; | |
| font-weight: 600; | |
| color: #2c3e50; | |
| }} | |
| tr:hover {{ | |
| background-color: #f8f9fa; | |
| }} | |
| pre {{ | |
| background: #f8f9fa; | |
| padding: 1rem; | |
| border-radius: 4px; | |
| overflow-x: auto; | |
| margin: 0.5rem 0; | |
| }} | |
| code {{ | |
| font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | |
| font-size: 0.9em; | |
| }} | |
| .collapsible-content {{ | |
| transition: max-height 0.3s ease-out; | |
| max-height: 2000px; | |
| overflow: hidden; | |
| }} | |
| .collapsible-content.collapsed {{ | |
| max-height: 0; | |
| }} | |
| .vibrational-mode {{ | |
| background-color: #e8f5e9; | |
| }} | |
| .trans-rot-mode {{ | |
| background-color: #fff3e0; | |
| }} | |
| .mode-explanation {{ | |
| background-color: #f5f5f5; | |
| padding: 10px; | |
| border-radius: 4px; | |
| margin-bottom: 15px; | |
| border: 1px solid #e0e0e0; | |
| }} | |
| .mode-explanation p {{ | |
| margin: 5px 0; | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>XYZ Molecule Viewer</h1> | |
| <div id="viewer"></div> | |
| <div class="info-section"> | |
| <h2 onclick="toggleSection('calculation-results')">Calculation Results</h2> | |
| <div id="calculation-results" class="collapsible-content"> | |
| <ul> | |
| <!-- Results will be populated here --> | |
| </ul> | |
| </div> | |
| </div> | |
| <div class="info-section"> | |
| <h2 onclick="toggleSection('simulation-details')">Simulation Details</h2> | |
| <div id="simulation-details" class="collapsible-content"> | |
| <ul> | |
| <!-- Simulation details will be populated here --> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| function toggleSection(sectionId) {{ | |
| const content = document.getElementById(sectionId); | |
| const header = content.previousElementSibling; | |
| content.classList.toggle('collapsed'); | |
| header.classList.toggle('collapsed'); | |
| }} | |
| function toggleSubSection(sectionId) {{ | |
| const content = document.getElementById(sectionId); | |
| const header = content.previousElementSibling; | |
| if (content.style.display === 'none') {{ | |
| content.style.display = 'block'; | |
| header.classList.remove('collapsed'); | |
| }} else {{ | |
| content.style.display = 'none'; | |
| header.classList.add('collapsed'); | |
| }} | |
| }} | |
| // Initialize all sub-sections as expanded | |
| document.addEventListener('DOMContentLoaded', function() {{ | |
| const subSections = document.querySelectorAll('.sub-section-content'); | |
| subSections.forEach(section => {{ | |
| section.style.display = 'block'; | |
| }}); | |
| }}); | |
| const stage = new NGL.Stage("viewer", {{ backgroundColor: "white" }}); | |
| function xyzToPDB(xyzContent) {{ | |
| const lines = xyzContent.trim().split("\\n"); | |
| const numAtoms = parseInt(lines[0]); | |
| let pdbContent = ''; | |
| for (let i = 2; i < lines.length && i < numAtoms + 2; i++) {{ | |
| const parts = lines[i].trim().split(/\\s+/); | |
| if (parts.length >= 4) {{ | |
| const [atom, x, y, z] = parts; | |
| const atomName = atom.padEnd(3); | |
| const serial = String(i - 1).padStart(5); | |
| const xStr = parseFloat(x).toFixed(3).padStart(8); | |
| const yStr = parseFloat(y).toFixed(3).padStart(8); | |
| const zStr = parseFloat(z).toFixed(3).padStart(8); | |
| pdbContent += `HETATM${{serial}} ${{atomName}} MOL 1 ${{xStr}}${{yStr}}${{zStr}} 1.00 0.00\\n`; | |
| }} | |
| }} | |
| pdbContent += 'END\\n'; | |
| return pdbContent; | |
| }} | |
| const xyzData = atob("{encoded_xyz}"); | |
| const pdbContent = xyzToPDB(xyzData); | |
| stage.loadFile(new Blob([pdbContent], {{ type: 'text/plain' }}), {{ ext: 'pdb' }}).then(component => {{ | |
| component.addRepresentation("ball+stick"); | |
| component.autoView(); | |
| }}); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| 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""" | |
| <li> | |
| <div class="sub-section"> | |
| <h3 onclick="toggleSubSection('optimized-coords')">Optimized Coordinates</h3> | |
| <div id="optimized-coords" class="sub-section-content"> | |
| <pre><code>{xyz_str}</code></pre> | |
| </div> | |
| </div> | |
| </li>""") | |
| else: | |
| calc_results.append( | |
| "<li class='regular-item'><strong>Optimized Coordinates:</strong> N/A</li>" | |
| ) | |
| # Energy | |
| if ase_output.single_point_energy is not None: | |
| energy_ev = ase_output.single_point_energy | |
| calc_results.append(f""" | |
| <li class='regular-item'> | |
| <div class="unit-toggle"> | |
| <span>Energy Unit:</span> | |
| <button onclick="toggleEnergyUnit('ev')" class="active" data-unit="ev">eV</button> | |
| <button onclick="toggleEnergyUnit('kjmol')" data-unit="kjmol">kJ/mol</button> | |
| <button onclick="toggleEnergyUnit('kcalmol')" data-unit="kcalmol">kcal/mol</button> | |
| </div> | |
| <div> | |
| <strong>Single Point Energy</strong> (<span class="energy-unit">eV</span>): | |
| <span class="energy-value" data-ev="{energy_ev:.6f}"></span> | |
| </div> | |
| </li>""") | |
| else: | |
| calc_results.append( | |
| f"<li class='regular-item'><strong>Single Point Energy</strong> ({ase_output.energy_unit}): N/A</li>" | |
| ) | |
| # 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""" | |
| <div class="table-container"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th style="width: 80px;">Mode #</th> | |
| <th>Frequency ({freq_unit})</th> | |
| <th>Energy ({energy_unit})</th> | |
| <th>Type</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| """ | |
| # 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""" | |
| <tr class="{row_class}"> | |
| <td>{i}</td> | |
| <td>{freq}</td> | |
| <td>{energy}</td> | |
| <td>{mode_type}</td> | |
| </tr> | |
| """ | |
| freq_table += """ | |
| </tbody> | |
| </table> | |
| </div> | |
| """ | |
| # Add explanation about the modes | |
| mode_explanation = f""" | |
| <div class="mode-explanation"> | |
| <p><strong>Molecule Type:</strong> {'Linear' if is_linear else 'Non-linear'}</p> | |
| <p><strong>Mode Breakdown:</strong> {trans_rot_modes} translation/rotation modes + {3 * num_atoms - trans_rot_modes} vibrational modes</p> | |
| <p><em>Note: The first {trans_rot_modes} modes (highlighted in orange) are translation/rotation modes. The remaining modes (highlighted in green) are vibrational modes.</em></p> | |
| </div> | |
| """ | |
| calc_results.append(f""" | |
| <li> | |
| <div class="sub-section"> | |
| <h3 onclick="toggleSubSection('vib-freqs')">Vibrational Frequencies</h3> | |
| <div id="vib-freqs" class="sub-section-content"> | |
| {mode_explanation} | |
| {freq_table} | |
| </div> | |
| </div> | |
| </li>""") | |
| else: | |
| calc_results.append( | |
| "<li class='regular-item'><strong>Vibrational Frequencies:</strong> N/A</li>" | |
| ) | |
| # 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'<div><strong>Enthalpy:</strong> <span class="energy-value" data-ev="{enthalpy_ev}">{enthalpy_ev:.6f}</span></div>' | |
| ) | |
| if "entropy" in ase_output.thermochemistry: | |
| entropy_ev = ase_output.thermochemistry['entropy'] | |
| thermo_info.append( | |
| f'<div><strong>Entropy:</strong> <span class="energy-value" data-ev="{entropy_ev}">{entropy_ev:.6f}</span></div>' | |
| ) | |
| if "gibbs_free_energy" in ase_output.thermochemistry: | |
| gibbs_ev = ase_output.thermochemistry['gibbs_free_energy'] | |
| thermo_info.append( | |
| f'<div><strong>Gibbs Free Energy:</strong> <span class="energy-value" data-ev="{gibbs_ev}">{gibbs_ev:.6f}</span></div>' | |
| ) | |
| if thermo_info: | |
| calc_results.append(f""" | |
| <li class='regular-item'> | |
| <div class="unit-toggle"> | |
| <span>Energy Unit:</span> | |
| <button onclick="toggleEnergyUnit('ev')" class="active" data-unit="ev">eV</button> | |
| <button onclick="toggleEnergyUnit('kjmol')" data-unit="kjmol">kJ/mol</button> | |
| <button onclick="toggleEnergyUnit('kcalmol')" data-unit="kcalmol">kcal/mol</button> | |
| </div> | |
| <strong>Thermochemistry Values</strong> (<span class="energy-unit">eV</span>):<br> | |
| {"".join(thermo_info)} | |
| </li>""") | |
| else: | |
| calc_results.append( | |
| "<li class='regular-item'><strong>Thermochemistry Values:</strong> No values available</li>" | |
| ) | |
| else: | |
| calc_results.append( | |
| "<li class='regular-item'><strong>Thermochemistry Values:</strong> N/A</li>" | |
| ) | |
| # 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"<li class='regular-item'><strong>Optimization Status:</strong> <span style='{status_class}'>{status}</span></li>" | |
| ) | |
| # Error Information | |
| if ase_output.error: | |
| calc_results.append( | |
| f"<li class='regular-item'><strong>Error:</strong> <span style='color: #dc3545;'>{ase_output.error}</span></li>" | |
| ) | |
| # 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"<li><strong>Simulation Type:</strong> {ase_output.simulation_input.driver or 'N/A'}</li>" | |
| ) | |
| if ase_output.simulation_input.calculator: | |
| calc = ase_output.simulation_input.calculator | |
| calc_type = calc.calculator_type | |
| sim_details.append(f"<li><strong>Calculator:</strong> {calc_type}</li>") | |
| # 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"<tr><td>{param_name}</td><td>{value}</td></tr>") | |
| # If no parameters are set, show an empty table | |
| if not calc_params_html: | |
| calc_params_html.append( | |
| "<tr><td colspan='2'>No additional parameters set</td></tr>" | |
| ) | |
| sim_details.append(f""" | |
| <li> | |
| <div class='sub-section'> | |
| <h3 onclick='toggleSubSection("calc-params")'>Calculator Parameters</h3> | |
| <div id='calc-params' class='sub-section-content'> | |
| <div class='table-container' style='max-height: 200px; overflow-y: auto;'> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Parameter</th> | |
| <th>Value</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {''.join(calc_params_html)} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </li>""") | |
| # Optimization Parameters | |
| if ase_output.simulation_input.driver == "opt": | |
| sim_details.append( | |
| f"<li><strong>Optimizer:</strong> {ase_output.simulation_input.optimizer}</li>" | |
| ) | |
| sim_details.append( | |
| f"<li><strong>Force Convergence (fmax):</strong> {ase_output.simulation_input.fmax} eV/Å</li>" | |
| ) | |
| sim_details.append( | |
| f"<li><strong>Maximum Steps:</strong> {ase_output.simulation_input.steps}</li>" | |
| ) | |
| # Thermochemistry Parameters | |
| if ase_output.simulation_input.driver == "thermo": | |
| if ase_output.simulation_input.temperature: | |
| sim_details.append( | |
| f"<li><strong>Temperature:</strong> {ase_output.simulation_input.temperature} K</li>" | |
| ) | |
| sim_details.append( | |
| f"<li><strong>Pressure:</strong> {ase_output.simulation_input.pressure} Pa</li>" | |
| ) | |
| # Join all simulation details | |
| sim_details_html = "\n".join(sim_details) | |
| # Replace the empty content in both sections | |
| html_content = html_content.replace( | |
| '<ul>\n <!-- Results will be populated here -->\n </ul>', | |
| f'<ul>{calc_results_html}</ul>', | |
| 1, | |
| ) | |
| html_content = html_content.replace( | |
| '<ul>\n <!-- Simulation details will be populated here -->\n </ul>', | |
| f'<ul>{sim_details_html}</ul>', | |
| 1, | |
| ) | |
| # Add the JavaScript for unit conversion | |
| html_content = html_content.replace( | |
| '</body>', | |
| ''' | |
| <script> | |
| // Conversion factors | |
| const EV_TO_KJMOL = 96.485; // 1 eV = 96.485 kJ/mol | |
| const EV_TO_KCALMOL = 23.061; // 1 eV = 23.061 kcal/mol | |
| function toggleEnergyUnit(unit) { | |
| // Update button states | |
| document.querySelectorAll('.unit-toggle button').forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.unit === unit); | |
| }); | |
| // Update unit labels | |
| document.querySelectorAll('.energy-unit').forEach(label => { | |
| label.textContent = unit === 'ev' ? 'eV' : | |
| unit === 'kjmol' ? 'kJ/mol' : 'kcal/mol'; | |
| }); | |
| // Convert all energy values | |
| document.querySelectorAll('.energy-value').forEach(cell => { | |
| const evValue = parseFloat(cell.dataset.ev); | |
| let convertedValue; | |
| let precision; | |
| if (unit === 'ev') { | |
| convertedValue = evValue; | |
| precision = 6; | |
| } else if (unit === 'kjmol') { | |
| convertedValue = evValue * EV_TO_KJMOL; | |
| precision = 2; | |
| } else { // kcal/mol | |
| convertedValue = evValue * EV_TO_KCALMOL; | |
| precision = 2; | |
| } | |
| // Get the label from the parent div's strong element | |
| const label = cell.parentElement.querySelector('strong').textContent; | |
| // Update only the value part | |
| cell.textContent = convertedValue.toFixed(precision); | |
| }); | |
| } | |
| // Initialize energy values in eV when the page loads | |
| document.addEventListener('DOMContentLoaded', function() { | |
| // Set initial unit to eV | |
| document.querySelectorAll('.unit-toggle button[data-unit="ev"]').forEach(btn => { | |
| btn.click(); | |
| }); | |
| }); | |
| </script> | |
| </body> | |
| ''', | |
| ) | |
| return html_content | |