ORCA UV-Vis Calculation Pipeline
SkillFiles & storageGenerate 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.
No other account needed.
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-optfirst) - Solvent for CPCM (e.g. hexane, water, ethanol, dichloromethane, acetonitrile)
- Charge and multiplicity (default to
0 1if 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), NOTcatgo_workflow_engine(task-based). The graph-based tool auto-captures the viewer structure oncreate. Task-basedadd_taskdoesn't, so jobs fail with "No input structure provided". Param keys differ: graph-based usesmethod/basis, task-based usesorca_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)
| Parameter | Default | Description |
|---|---|---|
method | CAM-B3LYP | Functional |
basis | def2-TZVP | Basis set |
dispersion | (none) | D4 | D3BJ | D3 |
calc_type | tddft | tddft or steom (STEOM-DLPNO-CCSD) |
nroots | 10 | Number of excited states |
tda | true | Tamm-Dancoff approximation; pass false for full TD-DFT |
triplets | false | Compute triplet states |
donto | false | Natural transition orbitals |
solvation | none | CPCM | none |
solvent | water | Any ORCA-recognised solvent name |
aux_basis | def2-TZVP/C | Aux basis (used by STEOM path) |
num_cores / max_core_mb | 4 / 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_taskdoesn't auto-attach the viewer structure → "No input structure provided".partition=workq(Shaheen default) is invalid on Expanse → usedebug/shared/compute.partition=debugcapped at 30 min — TD-DFT/30-roots almost always needs more.- Missing
account=sdp126→ "Invalid account or account/partition combination". - Missing
module_loads+orca_dir→orcanot on PATH; the response solver silently produces nothing. - After re-connecting to Expanse, the session_id changes — re-discover via
/api/hpc/connectionsand update bothdefault_session_idand thecluster_configskey.
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-findSTEOM-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
Spectrumshape. - 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 ``
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