ORCA UV-Vis Calculation Pipeline

SkillFiles & storage

Generate ORCA input files for TD-DFT UV-Vis calculations and parse/plot the resulting absorption spectrum. Use when the user asks about UV-Vis spectra, absorption spectra, TD-DFT calculations, excited state calculations in ORCA, or wants to plot results from an ORCA TD-DFT output file. Also trigger when the user mentions oscillator strengths, electronic transitions, or simulated UV-Vis.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the ORCA UV-Vis Calculation Pipeline skill

What this skill tells your AI

The instructions your AI receives, as published by hello-qm/catgo-lrg in .claude/skills/orca-uv-vis/SKILL.md and read by ahel’s review.

This skill covers two stages: generating an ORCA input file for a TD-DFT calculation, and post-processing the output to plot a Gaussian-broadened UV-Vis absorption spectrum.

Scope: Input generation, local post-processing, and (optionally) HPC submission via the CatGo workflow engine. The "Submitting to HPC" section below covers the proven Expanse flow. If the user is running on their own non-CatGo infrastructure, just generate the input file from the template in Stage 1 and skip the submission section.

Target version: ORCA 6.x. Output block layout in ORCA 5 is close but not identical; the parser below is written against the ORCA 6 format used by CatGo's own parser (server/catgo/utils/orca_output.py::OrcaUvVisOutput).

Stage 1: Input Generation

Before generating the input file, ask the user for:

  • XYZ geometry file path (assume already optimized; if the user hasn't optimized, route them to orca-opt first)
  • Solvent for CPCM (e.g. hexane, water, ethanol, dichloromethane, acetonitrile)
  • Charge and multiplicity (default to 0 1 if not specified)

Generate an ORCA input file with these fixed settings:

  • Functional: CAM-B3LYP
  • Basis set: DEF2-TZVP
  • Full TD-DFT (not TDA)
  • 30 roots
  • CPCM solvation inline on the keyword line
  • No RI / auxiliary basis
  • No special SCF convergence tricks

Template:

!CAM-B3LYP DEF2-TZVP CPCM(HEXANE)
%TDDFT
   NROOTS   30
END
%output jsongbwfile True jsonpropfile True end
*XYZFILE 0 1 geometry.xyz

Substitute the user's solvent, charge, multiplicity, and XYZ path. If the user provides inline coordinates instead of a file, use * XYZ <charge> <mult> followed by the coordinates and close with *.

The %output ... end line makes ORCA emit the JSON files OPI's Output.parse() consumes during post-processing (Stage 2). Keep it.

Building the %tddft block with OPI

OPI's BlockTddft exposes every TD-DFT knob (nroots, iroot, irootmult, maxdim, maxiter, etol, rtol, tda, lrcpcm, cpcmeq, donto, saveunrnatorb, spinflip, soc, socgrad, triplets, ...) as a typed Pydantic field. Bad keys raise at construction.

The catgo backend builds the route line, %pal, %maxcore, charge/multiplicity, and geometry from node params — it does not emit a %TDDFT block of its own. So OPI only contributes the %tddft and %output blocks here, which we paste into extra_blocks as text. Do not use Calculator.write_input() — that writes a full input file and would duplicate the route line / pal / geometry the backend already emits.

from opi.input.blocks import BlockTddft, BlockOutput

tddft_block = BlockTddft(nroots=30, tda=False, triplets=False, donto=True)
output_block = BlockOutput(jsongbwfile=True, jsonpropfile=True)

extra_blocks_text = tddft_block.format_orca() + "\n" + output_block.format_orca()
# Pass extra_blocks_text into the node's `extra_blocks` param.

format_orca() emits exactly one %...end block per call. The result for the snippet above is:

%tddft
    nroots 30
    tda False
    donto True
    triplets False
end
%output
    jsonpropfile True
    jsongbwfile True
end

TD-DFT with 30 roots at def2-TZVP is expensive — tell the user this is a heavy calculation and that they should expect to run it on a cluster or at least overnight on a workstation, not on a laptop.

Submitting to HPC (Expanse) — proven flow

Use this when the user wants the CatGo workflow engine to run the TD-DFT job on Expanse. Skip if they only want the input file.

Use catgo_workflow (graph-based), NOT catgo_workflow_engine (task-based). The graph-based tool auto-captures the viewer structure on create. Task-based add_task doesn't, so jobs fail with "No input structure provided". Param keys differ: graph-based uses method/basis, task-based uses orca_method/orca_basis.

1. Confirm structure is loaded and find session_id

catgo_view(action: "get_state")
curl -s http://localhost:8000/api/hpc/connections

Copy the session_id for host: login.expanse.sdsc.edu.

2. Create the workflow

catgo_workflow(action: "create", name: "UV-Vis CAM-B3LYP TD-DFT")

3. Add the TD-DFT node

Use the orca_uvvis node type, which has dedicated TD-DFT params (nroots, triplets, tda, donto, solvation, solvent, calc_type, aux_basis) plus the standard dispersion field. Do NOT use extra_keywords or extra_blocks — they are NOT read by the engine and are silently dropped.

catgo_workflow(action: "batch", workflow_id: "<wf_id>", operations: [
  {"op": "add_node", "node_type": "orca_uvvis", "label": "tddft",
   "params": {
     "software": "orca",
     "method": "CAM-B3LYP",
     "basis": "def2-TZVP",
     "calc_type": "tddft",
     "nroots": 30,
     "tda": false,
     "triplets": false,
     "donto": true,
     "solvation": "CPCM",
     "solvent": "hexane",
     "dispersion": "D4",
     "charge": 0,
     "multiplicity": 1,
     "num_cores": 16,
     "max_core_mb": 4000
   }},
  {"op": "connect", "from_id": "<structure_input_id>", "to_id": "tddft",
   "from_handle": "structure", "to_handle": "structure"}
])

Canonical UV-Vis node params (verified against server/workflow/engines/orca.py:244-268)

ParameterDefaultDescription
methodCAM-B3LYPFunctional
basisdef2-TZVPBasis set
dispersion(none)D4 | D3BJ | D3
calc_typetddfttddft or steom (STEOM-DLPNO-CCSD)
nroots10Number of excited states
tdatrueTamm-Dancoff approximation; pass false for full TD-DFT
tripletsfalseCompute triplet states
dontofalseNatural transition orbitals
solvationnoneCPCM | none
solventwaterAny ORCA-recognised solvent name
aux_basisdef2-TZVP/CAux basis (used by STEOM path)
num_cores / max_core_mb4 / 4000

4. Run with the full HPC run_config

TD-DFT with 30 roots at def2-TZVP is heavy — bump walltime and use shared or compute (debug caps at 30 min). Read server/templates/orca_generic.sh and pass its contents as default_template.

catgo_workflow(action: "run", workflow_id: "<wf_id>", run_config: {
  "execution_mode": "hpc",
  "default_session_id": "<expanse_session_id>",
  "base_work_dir": "/expanse/lustre/projects/sdp126/jyang25/ORCA/catgo",
  "default_job_params": {
    "nodes": 1, "ntasks": 16, "cpus_per_task": 1,
    "walltime": "12:00:00", "partition": "shared"
  },
  "cluster_configs": {
    "<expanse_session_id>": {
      "account": "sdp126",
      "partition": "shared",
      "module_loads": "module load cpu/0.17.3b\nmodule load gcc/10.2.0/npcyll4\nexport PATH=$HOME/openmpi-4.1.8/bin:$PATH\nexport LD_LIBRARY_PATH=$HOME/openmpi-4.1.8/lib:$LD_LIBRARY_PATH",
      "orca_dir": "/home/jyang25/orca_6_1_1_RRP8",
      "default_template": "<contents of server/templates/orca_generic.sh>",
      "default_job_params": {
        "nodes": 1, "ntasks": 16, "cpus_per_task": 1,
        "walltime": "12:00:00", "partition": "shared"
      }
    }
  }
})

The local-scratch template stages I/O to $TMPDIR/orca_$SLURM_JOB_ID and copies results back. Required on Expanse — Lustre kills ORCA's many-small-file I/O during the 30-root TD-DFT response solver.

5. Monitor

catgo_workflow(action: "status", workflow_id: "<wf_id>")

6. Pull files for post-processing

When status is COMPLETED, pull ORCA.out plus the JSON files OPI parses, then run the parser/plotter from Stage 2 below against the local copy:

mkdir -p ./local_run
for f in ORCA.out ORCA.property.json ORCA.json; do
  curl -s -X POST http://localhost:8000/api/hpc/files/read-content \
    -H 'Content-Type: application/json' \
    -d "{\"session_id\":\"<expanse_session_id>\",\"file_path\":\"<work_dir>/$f\"}" \
    > ./local_run/$f
done

Submission gotchas

  • catgo_workflow_engine.add_task doesn't auto-attach the viewer structure → "No input structure provided".
  • partition=workq (Shaheen default) is invalid on Expanse → use debug/shared/compute.
  • partition=debug capped at 30 min — TD-DFT/30-roots almost always needs more.
  • Missing account=sdp126 → "Invalid account or account/partition combination".
  • Missing module_loads + orca_dirorca not on PATH; the response solver silently produces nothing.
  • After re-connecting to Expanse, the session_id changes — re-discover via /api/hpc/connections and update both default_session_id and the cluster_configs key.

Stage 2: Post-Processing

Reading the Spectrum via OPI

OPI surfaces the absorption spectrum as output.results_properties.geometries[-1].absorption_spectrum — a list[Spectrum] keyed by representation ("Length" / "Velocity") and pointgroup. This replaces the regex parser entirely:

  • The rfind-vs-find STEOM-DLPNO-CCSD workaround is unnecessary; the model returns one entry per (representation, pointgroup) and you pick the one you want.
  • The "STEOM rows have state labels, TD-DFT rows don't" branch is unnecessary; both produce the same Spectrum shape.
  • Bonus: ECD rotational strengths come for free at geometries[-1].ecd_spectrum.

Spectrum.excitationenergies is a list of rows; columns are unnamed in the model but the order for ORCA 6.1.1 is fixed at [energy_eV, energy_cm, wavelength_nm, fosc, |mu|^2]. The shared helper exposes this as UVVIS_COLS.

Reference Parser and Plotter

import sys
sys.path.insert(0, ".claude/skills")  # for the _shared helper
import numpy as np
import matplotlib.pyplot as plt
from _shared.orca_opi import parse_local, UVVIS_COLS


def parse_orca_tddft(work_dir="./local_run"):
    out = parse_local(work_dir)
    spectra = out.results_properties.geometries[-1].absorption_spectrum
    if not spectra:
        raise ValueError("No absorption_spectrum block found — check ORCA.property.json")
    # Prefer length representation; fall back to whatever's first.
    target = next((s for s in spectra if s.representation == "Length"), spectra[0])

    wl_col = UVVIS_COLS["wavelength_nm"]
    f_col = UVVIS_COLS["fosc"]
    wavelengths = np.array([row[wl_col] for row in target.excitationenergies])
    fosc = np.array([row[f_col] for row in target.excitationenergies])
    return wavelengths, fosc


def gaussian(x, center, height, sigma=15):
    return height * np.exp(-0.5 * ((x - center) / sigma) ** 2)


def plot_uv_vis(wavelengths, fosc, output_png="uv_vis_spectrum.png"):
    x = np.linspace(200, 800, 2000)
    spectrum = sum(gaussian(x, w, f) for w, f in zip(wavelengths, fosc))
    peak = spectrum.max()
    if peak > 0:
        spectrum = spectrum / peak

    fig, ax = plt.subplots(figsize=(8, 5))
    ax.plot(x, spectrum, color="#2563eb", linewidth=1.5, label="Broadened")
    f_peak = fosc.max() if fosc.size and fosc.max() > 0 else 1.0
    ax.vlines(wavelengths, 0, fosc / f_peak, color="#ef4444", alpha=0.7, label="Transitions")
    ax.set_xlabel("Wavelength (nm)")
    ax.set_ylabel("Normalized Absorbance")
    ax.set_xlim(200, 800)
    ax.set_ylim(0, 1.05)
    ax.legend(loc="upper right", frameon=False)
    fig.tight_layout()
    fig.savefig(output_png, dpi=150)
    print(f"Spectrum saved to {output_png}")


if __name__ == "__main__":
    work_dir = sys.argv[1] if len(sys.argv) > 1 else "./local_run"
    png_name = sys.argv[2] if len(sys.argv) > 2 else "uv_vis_spectrum.png"
    wl, f = parse_orca_tddft(work_dir)
    plot_uv_vis(wl, f, png_name)

Viewing the spectrum in the IDE

After plot_uv_vis(...) writes the PNG, surface it inline with the shared helper:

from _shared.orca_opi import show_png
show_png("uv_vis_spectrum.png", "UV-Vis spectrum")
# prints `![UV-Vis spectrum](uv_vis_spectrum.png)`

Then reply to the user with that markdown link so Claude Code renders the figure inline in chat.

Plotting Defaults

  • Wavelength grid: 200–800 nm, 2000 points
  • Gaussian broadening: σ = 15 nm in wavelength space (simple and visually reasonable for most organic chromophores; call out that eV-space broadening is more physically correct if the user cares about vibronic lineshapes)
  • Envelope normalized to max = 1
  • Sticks normalized by the maximum oscillator strength so they sit on the same axis as the envelope
  • Figure size 8×5, DPI 150
  • Colors: envelope #2563eb (blue), sticks #ef4444 (red) — matches CatGo's red-TS / blue-accent conventions

Requesting eV on the x-axis

If the user asks for eV instead of nm, convert via E_eV = 1240 / wavelength_nm and rebuild the x-grid from (e.g.) 1.5–6.5 eV. Broadening σ of ~0.3 eV is a sensible default in eV space.

Signals

GitHub stars
198
Forks
23
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
orca-uv-vis
Source
github.com/hello-qm/catgo-lrg