Skip to content

View

qcio.view

Tools for visualizing qcio objects in Jupyter Notebooks.

Design Decisions
  • The view function is the top-level method for viewing all qcio objects. It can accept one or many objects and will determine the appropriate viewer to use.
  • All functions that begin with "generate" will return a string of HTML. If users want to use this HTML to create a custom view, they can do so. If they want to display the HTML in a Jupyter Notebook, they can call display(HTML(html_string)) after importing from IPython.display import HTML, display.
  • The basic layout for viewing results (all Results objects) is a table of basic parameters followed by a structure viewer and results table or plot. DualProgramInputs add details for the subprogram. ---------------------------------------------------------------------------- | Structure | Success | Calculation Type | Program | Model | Keywords | ---------------------------------------------------------------------------- | | | | Structure Viewer (Optional) | Data Table or Plot | | | | ----------------------------------------------------------------------------

DEFAULT_WIDTH module-attribute

DEFAULT_WIDTH: int = 600

The default width of the viewer in pixels.

DEFAULT_HEIGHT module-attribute

DEFAULT_HEIGHT: int = 450

The default height of the viewer in pixels.

view

view(
    *objs: Results | Structure | list[Structure], **kwargs
) -> None

Top level method for viewing all qcio objects. This should be the only method you need to use to view any qcio object.

Parameters:

Name Type Description Default
*objs Results | Structure | list[Structure]

The Results or Structure objects to view. May pass one or more objects or one or more lists of Structure objects.

()
**kwargs

Additional keyword arguments to pass to the viewer functions.

{}

Returns:

Type Description
None

None. Displays the qcio objects in the Jupyter Notebook.

Source code in src/qcio/view.py
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
def view(
    *objs: Results | Structure | list[Structure],
    **kwargs,
) -> None:
    """
    Top level method for viewing all qcio objects. This should be the only method you
    need to use to view any qcio object.

    Args:
        *objs: The Results or Structure objects to view. May pass one or more
            objects or one or more lists of Structure objects.
        **kwargs: Additional keyword arguments to pass to the viewer functions.

    Returns:
        None. Displays the qcio objects in the Jupyter Notebook.
    """
    if all([isinstance(o, Structure) for o in objs]) or all(
        isinstance(o, Structure) for lst in objs for o in lst
    ):
        structures(*objs, **kwargs)  # type: ignore
        # Handle all structures in a single viewer
        return

    for obj in objs:
        if isinstance(obj, Structure) or isinstance(obj, list):
            structures(*objs, **kwargs)  # type: ignore

        elif isinstance(obj, Results):
            program_outputs(obj, **kwargs)

        else:
            raise NotImplementedError(f"Viewing of {type(obj)} is not implemented.")

program_outputs

program_outputs(
    *results: Results[
        ProgramInput | DualProgramInput, Data
    ],
    animate: bool = True,
    struct_viewer: bool = True,
    conformer_rmsd_threshold: float | None = None,
    conformer_rmsd_backend: str = "qcinf",
    conformer_rmsd_kwargs: dict | None = None,
    **kwargs,
) -> None

Display one or many Results objects.

Parameters:

Name Type Description Default
*results Results[ProgramInput | DualProgramInput, Data]

The Results objects to display.

()
animate bool

Whether to animate the structure if it is an optimization.

True
struct_viewer bool

Whether to display the structure viewer.

True
conformer_rmsd_threshold float | None

The threshold for RMSD to determine if conformers are unique. Defaults to 1.0 Bohr (0.53 Å).

None
conformer_rmsd_kwargs dict | None

Additional keyword arguments to pass to the conformer RMSD filtering function.

None
**kwargs

Additional keyword arguments to pass to the viewer functions.

{}

Returns:

Type Description
None

None. Displays the Results objects in the Jupyter Notebook.

Source code in src/qcio/view.py
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
def program_outputs(
    *results: Results[ProgramInput | DualProgramInput, Data],
    animate: bool = True,
    struct_viewer: bool = True,
    conformer_rmsd_threshold: float | None = None,
    conformer_rmsd_backend: str = "qcinf",
    conformer_rmsd_kwargs: dict | None = None,
    **kwargs,
) -> None:
    """
    Display one or many Results objects.

    Args:
        *results: The Results objects to display.
        animate: Whether to animate the structure if it is an optimization.
        struct_viewer: Whether to display the structure viewer.
        conformer_rmsd_threshold: The threshold for RMSD to determine if conformers are
            unique. Defaults to 1.0 Bohr (0.53 Å).
        conformer_rmsd_kwargs: Additional keyword arguments to pass to the conformer
            RMSD filtering function.
        **kwargs: Additional keyword arguments to pass to the viewer functions.

    Returns:
        None. Displays the Results objects in the Jupyter Notebook.
    """

    width = kwargs.get("width", DEFAULT_WIDTH)
    height = kwargs.get("height", DEFAULT_HEIGHT)

    for i, result in enumerate(results):
        final_html = []
        final_html.append(generate_output_table(result))

        if isinstance(result.data, ConformerSearchData):
            structures = [result.input_data.structure]

            if conformer_rmsd_threshold is not None:
                keep_indices = filter_conformers_indices(
                    result.data.conformers,
                    backend=conformer_rmsd_backend,
                    threshold=conformer_rmsd_threshold,
                    **(conformer_rmsd_kwargs or {}),
                )
                conformers = [result.data.conformers[i] for i in keep_indices]
                energies_rel = result.data.conformer_energies_relative[keep_indices]
            else:
                conformers = result.data.conformers
                energies_rel = result.data.conformer_energies_relative

            structures += conformers
            titles_extra = ["Initial Structure"] + [
                f"Conformer {i}" for i in range(len(conformers))
            ]
            subtitles = ["Rel Energy: Unknown"] + [
                f"Rel Energy: +{re * constants.HARTREE_TO_KCAL_PER_MOL:.3f} kcal/mol"
                for re in energies_rel
            ]
            conf_viewer = generate_structure_viewer_html(
                *structures, titles_extra=titles_extra, subtitles=subtitles, **kwargs
            )
            final_html.append(conf_viewer)
            display(HTML("".join(final_html)))

        else:
            # Create structure viewer
            if not struct_viewer:
                structure_html = "struct_viewer = False"

            else:
                titles_extra = kwargs.pop("titles_extra", [])
                try:
                    title_extra = titles_extra[i]
                except IndexError:
                    title_extra = ""

                # Determine the Structure to use
                if isinstance(result.data, OptimizationData):
                    for_viewer: Structure | list[Structure]
                    if animate:
                        for_viewer = result.data.structures
                    else:
                        for_viewer = result.data.final_structure
                        title_extra += " (Final Structure)"

                elif isinstance(result.data, SinglePointData):
                    for_viewer = result.input_data.structure

                elif isinstance(result.data, Files):
                    for_viewer = result.input_data.structure
                else:
                    raise NotImplementedError(
                        f"Viewing of {type(result.data)} is not yet implemented."
                    )

                structure_html = generate_structure_viewer_html(
                    for_viewer,
                    titles_extra=[title_extra],
                    **kwargs,
                )

            # Create data table or plot
            if isinstance(result.data, OptimizationData):
                data_html = generate_optimization_plot(
                    result, figsize=(width / 100, height / 100)
                )
            else:
                data_html = generate_data_table(result.data)

            final_html.append(
                f"""
        <div style="text-align: center;">
            <div style="display: flex; align-items: center; justify-content: 
                space-around;">
                <div style="text-align: center; margin-right: 20px; flex: 1;">
                    <div style="display: inline-block; text-align: center;">
                        {structure_html}
                    </div>
                </div>
                <div style="width: {width}px; height: {height}px; text-align: center; 
                    margin-left: 20px; flex: 1; overflow: auto;">
                    {data_html}
                </div>
            </div>
        </div>
                """
            )

            display(HTML("".join(final_html)))

structures

structures(
    *structs: Structure | list[Structure], **kwargs
) -> None

Display one or many Structure objects.

Parameters:

Name Type Description Default
*structs Structure | list[Structure]

The Structure objects or list of objects to display. If a list is passed the structures will be animated.

()
**kwargs

Additional keyword arguments to pass to the viewer functions.

{}

Returns:

Type Description
None

None. Displays the structures in the Jupyter Notebook.

Source code in src/qcio/view.py
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
def structures(
    *structs: Structure | list[Structure],
    **kwargs,
) -> None:
    """
    Display one or many Structure objects.

    Args:
        *structs: The Structure objects or list of objects to display. If a list is
            passed the structures will be animated.
        **kwargs: Additional keyword arguments to pass to the viewer functions.

    Returns:
        None. Displays the structures in the Jupyter Notebook.
    """
    display(HTML(generate_structure_viewer_html(*structs, **kwargs)))

generate_structure_viewer_html

generate_structure_viewer_html(
    *structs: Union[Structure, list[Structure]],
    width: int | None = None,
    height: int | None = None,
    titles: list[str] | None = None,
    subtitles: list[str] | None = None,
    titles_extra: list[str] | None = None,
    subtitles_extra: list[str] | None = None,
    distances: list[tuple[int, int]] | None = None,
    length_unit: LengthUnit = BOHR,
    style: dict | None = None,
    show_indices: bool = False,
    same_viewer: bool = False,
    view_2d: bool = False,
    interval: int = 100,
) -> str

Generate the core HTML viewer for a Structure or list of Structures using py3Dmol or 2D PNG images. These keywords may be passed to high level viewer functions.

Parameters:

Name Type Description Default
structs Union[Structure, list[Structure]]

The Structure or list of Structures to visualize.

()
width int | None

The width of the viewer in pixels. Defaults to 600.

None
height int | None

The height of the viewer in pixels. Defaults to 450.

None
titles list[str] | None

The titles to display above the viewer. Will default to the Structure name if not provided.

None
subtitles list[str] | None

The subtitles to display below the viewer.

None
titles_extra list[str] | None

Extra text to display after the title. This is useful for adding additional context to a default title.

None
subtitles_extra list[str] | None

Extra text to display after the subtitle. Useful for adding text after an autogenerated subtitle such as relative energy.

None
distances list[tuple[int, int]] | None

A list of tuples of atom indices to display distances between.

None
length_unit LengthUnit

The units to display the distances in. Defaults to bohr. May pass 'angstrom' to display in angstroms.

BOHR
style dict | None

A dictionary of styles to apply to the viewer (for py3Dmol). Should be a dictionary for AtomStyleSpec https://3dmol.org/doc/AtomStyleSpec.html

None
show_indices bool

Whether to show the atom indices in the viewer.

False
view_2d bool

Whether to display the structure in 2D (PNG images).

False
interval int

The interval in milliseconds for the animation.

100

Returns:

Name Type Description
str str

The HTML string for the viewer.

Raises:

Type Description
ValueError

If a list of Structures is passed and view_2d is True.

Source code in src/qcio/view.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
def generate_structure_viewer_html(
    *structs: Union["Structure", list["Structure"]],
    width: int | None = None,
    height: int | None = None,
    titles: list[str] | None = None,
    subtitles: list[str] | None = None,
    titles_extra: list[str] | None = None,
    subtitles_extra: list[str] | None = None,
    distances: list[tuple[int, int]] | None = None,
    length_unit: LengthUnit = LengthUnit.BOHR,
    style: dict | None = None,
    show_indices: bool = False,
    same_viewer: bool = False,
    view_2d: bool = False,
    interval: int = 100,
) -> str:
    """
    Generate the core HTML viewer for a Structure or list of Structures using py3Dmol
    or 2D PNG images. These keywords may be passed to high level viewer functions.

    Args:
        structs: The Structure or list of Structures to visualize.
        width: The width of the viewer in pixels. Defaults to 600.
        height: The height of the viewer in pixels. Defaults to 450.
        titles: The titles to display above the viewer. Will default to the Structure
            name if not provided.
        subtitles: The subtitles to display below the viewer.
        titles_extra: Extra text to display after the title. This is useful for adding
            additional context to a default title.
        subtitles_extra: Extra text to display after the subtitle. Useful for adding
            text after an autogenerated subtitle such as relative energy.
        distances: A list of tuples of atom indices to display distances between.
        length_unit: The units to display the distances in. Defaults to bohr. May
            pass 'angstrom' to display in angstroms.
        style: A dictionary of styles to apply to the viewer (for py3Dmol). Should be a
            dictionary for AtomStyleSpec <https://3dmol.org/doc/AtomStyleSpec.html>
        show_indices: Whether to show the atom indices in the viewer.
        view_2d: Whether to display the structure in 2D (PNG images).
        interval: The interval in milliseconds for the animation.

    Returns:
        str: The HTML string for the viewer.

    Raises:
        ValueError: If a list of Structures is passed and view_2d is True.
    """

    # Set defaults
    width = width or DEFAULT_WIDTH
    height = height or DEFAULT_HEIGHT

    titles = titles or []
    subtitles = subtitles or []
    titles_extra = titles_extra or []
    subtitles_extra = subtitles_extra or []

    # Start HTML with flex container
    html_parts = [
        '<div style="display: flex; flex-wrap: wrap; justify-content: center;">'
    ]

    if not view_2d:
        # Create the viewer
        if len(structs) == 1 or same_viewer:
            viewer = p3d.view(width=width, height=height)
        else:
            # Determine the number of rows needed for multiple structures
            rows = math.ceil(len(structs) / 2)
            viewer = p3d.view(
                width=width * 2,
                height=height * rows,
                linked=False,
                viewergrid=(rows, 2),
            )

    for i, (struct, title, subtitle, title_extra, subtitle_extra) in enumerate(
        zip_longest(structs, titles, subtitles, titles_extra, subtitles_extra)
    ):
        # Set the title and subtitle
        if isinstance(struct, list):
            name = struct[0].ids.name
        else:
            name = struct.ids.name

        title = f"{title or name or ''}"
        title_extra = f"{title_extra or ''}"
        subtitle = f"{subtitle or ''}"
        subtitle_extra = f"{subtitle_extra or ''}"

        if view_2d:
            if isinstance(struct, list):
                raise ValueError(
                    "Cannot display multiple 2D structures in one viewer. Do not submit"
                    " Structures in a list. If you want to view multiple structures, "
                    "unpack your list with *my_list_of_structures."
                )
            adjusted_width, adjusted_height = int(width * 0.75), int(height * 0.75)
            mol = Chem.MolFromSmiles(struct.ids.smiles or struct.to_smiles())  # type: ignore
            img = Draw.MolToImage(mol, size=(adjusted_width, adjusted_height))
            buf = io.BytesIO()
            img.save(buf, format="PNG")
            img_str = base64.b64encode(buf.getvalue()).decode("utf-8")

            html_parts.append(
                f'<div style="margin: 10px; text-align: center; padding: 15px; width: '
                f'{adjusted_width}px; height: {adjusted_height + 60}px;">'
                f"<h2>{title} {title_extra}</h2>"
                f'<img src="data:image/png;base64,{img_str}" width="{adjusted_width}" '
                f'height="{adjusted_height}"/>'
                f"<h4>{subtitle} {subtitle_extra}</h4>"
                f"</div>"
            )
        else:
            if same_viewer:
                # No grid for single viewer (better performance than (1,1) grid)
                viewer_kwargs = {}
            else:
                # Sets the viewer to the correct grid position
                viewer_kwargs = {"viewer": divmod(i, 2)}

            if isinstance(struct, list):  # Animate lists of structures
                combined_xyz = "".join(s.to_xyz() for s in struct)
                viewer.addModelsAsFrames(combined_xyz, "xyz", **viewer_kwargs)
                viewer.animate(
                    {"loop": "forward", "interval": interval}, **viewer_kwargs
                )
            else:
                viewer.addModel(struct.to_xyz(), "xyz", **viewer_kwargs)

            viewer.addLabel(
                f"{title} {title_extra}",
                {
                    "position": {"x": width / 2, "y": 0, "z": 0},
                    "alignment": "topCenter",
                    "fontSize": 24,
                    "backgroundOpacity": 0,
                    "fontColor": "black",
                    "useScreen": True,
                },
                **viewer_kwargs,
            )

            viewer.addLabel(
                f"{subtitle} {subtitle_extra}",
                {
                    "position": {"x": width / 2, "y": height, "z": 0},
                    "alignment": "bottomCenter",
                    "fontSize": 20,
                    "backgroundOpacity": 0,
                    "fontColor": "black",
                    "useScreen": True,
                },
                **viewer_kwargs,
            )
            if distances:
                assert isinstance(struct, Structure), (
                    "Displaying distances for lists of structures is not yet implemented"
                )
                for atom1, atom2 in distances:
                    a1_coords = struct.geometry_angstrom[atom1]
                    a2_coords = struct.geometry_angstrom[atom2]
                    # Add line between the two atoms
                    viewer.addLine(
                        {
                            "start": {
                                "x": a1_coords[0],
                                "y": a1_coords[1],
                                "z": a1_coords[2],
                            },
                            "end": {
                                "x": a2_coords[0],
                                "y": a2_coords[1],
                                "z": a2_coords[2],
                            },
                            "color": "red",
                            "linewidth": 2,
                        },
                        **viewer_kwargs,
                    )
                    # Add a label showing the distance
                    midpoint = (a1_coords + a2_coords) / 2
                    distance = struct.distance(atom1, atom2, units=length_unit)
                    unit = "a₀"

                    if length_unit == LengthUnit.ANGSTROM:
                        unit = "Å"

                    viewer.addLabel(
                        f"{distance:.2f} {unit}",
                        {
                            "position": {
                                "x": midpoint[0],
                                "y": midpoint[1],
                                "z": midpoint[2],
                            },
                            "backgroundColor": "white",
                            "fontSize": 14,
                            "fontColor": "black",
                        },
                        **viewer_kwargs,
                    )

            if show_indices:
                ang_geom = (
                    struct.geometry_angstrom
                    if not isinstance(struct, list)
                    else struct[0].geometry_angstrom
                )
                for j, coord in enumerate(ang_geom):
                    viewer.addLabel(
                        str(j),
                        {"position": {"x": coord[0], "y": coord[1], "z": coord[2]}},
                        **viewer_kwargs,
                    )

    if not view_2d:
        viewer.setStyle(style or {"stick": {}, "sphere": {"scale": 0.3}})
        viewer.zoomTo()
        html_parts.append(f"{viewer.write_html()}")

    html_parts.append("</div>")
    return "".join(html_parts)

generate_output_table

generate_output_table(*results: Results) -> str

Generate an HTML table displaying the basic parameters for Results objects.

Parameters:

Name Type Description Default
*results Results

The Results objects to display.

()

Returns:

Name Type Description
str str

A string of HTML displaying the Results objects in a table.

Source code in src/qcio/view.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
def generate_output_table(*results: Results) -> str:
    """
    Generate an HTML table displaying the basic parameters for Results objects.

    Args:
        *results: The Results objects to display.

    Returns:
        str: A string of HTML displaying the Results objects in a table.
    """
    styles = """
    <style>
        table {
            border-collapse: collapse;
            width: 100%;
        }
        th, td {
            border: 1px solid black;
            padding: 8px;
            text-align: left;
        }
        .inner-table {
            border: none;
            width: 100%;
        }
        .inner-table td {
            border: none;
            padding: 2px 5px;
        }
        .key, .value {
            text-align: left;
            white-space: nowrap;
        }
    </style>
    """

    table_header = """
    <table>
        <tr>
            <th>Structure</th>
            <th>Success</th>
            <th>Wall Time</th>
            <th>Calculation Type</th>
            <th>Program</th>
            <th>Model</th>
            <th>Keywords</th>
    """
    if any(res.input_data.files for res in results):
        table_header += "<th>Input Files</th>"

    if any(isinstance(res.input_data, DualProgramInput) for res in results):
        table_header += """
            <th>Subprogram</th>
            <th>Subprogram Model</th>
            <th>Subprogram Keywords</th>
        """
    table_header += "</tr>"

    table_rows = []
    for res in results:
        success_style = (
            'style="color: green; font-weight: bold;"'
            if res.success
            else 'style="color: red; font-weight: bold;"'
        )
        base_row = f"""
        <tr>
            <td>{
            generate_dictionary_string(
                {
                    "charge": res.input_data.structure.charge,
                    "multiplicity": res.input_data.structure.multiplicity,
                    "name": res.input_data.structure.ids.name or "",
                }
            )
        }</td>
            <td {success_style}>{res.success}</td>
            <td> {
            _format_time(res.provenance.wall_time)
            if res.provenance.wall_time
            else "No timing data"
        }</td>
            <td>{res.input_data.calctype.name}</td>
            <td>{f"{res.provenance.program} {res.provenance.program_version or ''}"}</td>
            <td>{
            generate_dictionary_string(
                res.input_data.model.model_dump(exclude=["extras"])
            )
            if res.input_data.model
            else ""
        }</td>
            <td>{generate_dictionary_string(res.input_data.keywords)}</td>
        """
        if res.input_data.files:
            base_row += f"<td>{generate_files_string(res.input_data.files)}</td>"

        if isinstance(res.input_data, DualProgramInput):
            base_row += f"""
            <td>{res.input_data.subprogram}</td>
            <td>{res.input_data.subprogram_args.model}</td>
            <td>{generate_dictionary_string(res.input_data.subprogram_args.keywords)}</td>
            """
        base_row += "</tr>"
        table_rows.append(base_row)

    table_footer = "</table>"
    return styles + table_header + "\n".join(table_rows) + table_footer

generate_optimization_plot

generate_optimization_plot(
    prog_output: Results, figsize=(6.4, 4.8), grid=True
) -> str

Generate a plot of the energy optimization by cycle for a single Results.

Parameters:

Name Type Description Default
prog_output Results

The Results to generate the plot for.

required
figsize

The size of the figure in inches.

(6.4, 4.8)
grid

Whether to display grid lines on the plot.

True

Returns:

Name Type Description
str str

A string of HTML displaying the plot as a png image encoded in base64.

Source code in src/qcio/view.py
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
def generate_optimization_plot(
    prog_output: Results, figsize=(6.4, 4.8), grid=True
) -> str:
    """
    Generate a plot of the energy optimization by cycle for a single Results.

    Args:
        prog_output: The Results to generate the plot for.
        figsize: The size of the figure in inches.
        grid: Whether to display grid lines on the plot.

    Returns:
        str: A string of HTML displaying the plot as a png image encoded in base64.
    """
    energies = prog_output.data.energies * constants.HARTREE_TO_KCAL_PER_MOL
    baseline_energy = energies[0]
    relative_energies = energies - baseline_energy
    last_is_nan = np.isnan(relative_energies[-1])

    if last_is_nan:
        try:
            delta_E = relative_energies[-2]
        except IndexError:
            # If there is only one energy point, set delta_E to nan
            delta_E = np.nan
    else:
        delta_E = relative_energies[-1]

    fig, ax1 = plt.subplots(figsize=figsize)
    color = "tab:blue"
    ax1.set_xlabel("Optimization Cycle")
    ax1.set_ylabel("Relative Energy (kcal/mol)", color=color)
    ax1.plot(relative_energies, label="Energy", marker="o", color="green")
    if last_is_nan:
        ax1.plot(len(relative_energies) - 1, delta_E, marker="x", color="red")
    ax1.tick_params(axis="y", labelcolor=color)
    ax1.text(
        0.95,
        0.85,
        f"ΔE = {delta_E:.2f} kcal/mol",
        verticalalignment="top",
        horizontalalignment="right",
        transform=ax1.transAxes,
        color="black",
        fontsize=14,
    )
    plt.title("Energy Optimization by Cycle", pad=20)
    ax1.legend(loc="upper right")
    fig.tight_layout(rect=(0, 0, 1, 0.95))

    buf = io.BytesIO()
    plt.savefig(buf, format="png", bbox_inches="tight")
    buf.seek(0)
    image_base64 = base64.b64encode(buf.read()).decode("utf-8")
    buf.close()
    plt.close(fig)  # Close the figure to avoid duplicate plots
    return (
        f'<img src="data:image/png;base64,{image_base64}" alt="Energy Optimization by '
        f'Cycle" style="width: 100%; max-width: {DEFAULT_WIDTH}px;">'
    )