# Quantum Chemistry with PennyLane

## Table of Contents
1. [Molecular Hamiltonians](#molecular-hamiltonians)
2. [Variational Quantum Eigensolver (VQE)](#variational-quantum-eigensolver-vqe)
3. [Molecular Structure](#molecular-structure)
4. [Basis Sets and Mapping](#basis-sets-and-mapping)
5. [Excited States](#excited-states)
6. [Quantum Chemistry Workflows](#quantum-chemistry-workflows)

## Molecular Hamiltonians

### Building Molecular Hamiltonians

```python
import pennylane as qml
from pennylane import qchem
import numpy as np

# Define molecule
symbols = ['H', 'H']
geometry = np.array([[0.0, 0.0, -0.66140414], [0.0, 0.0, 0.66140414]])
molecule = qchem.Molecule(
    symbols,
    geometry,
    charge=0,
    mult=1,
    basis_name='sto-3g',
)

# Generate Hamiltonian
hamiltonian, n_qubits = qchem.molecular_hamiltonian(
    molecule,
    mapping='jordan_wigner',
)

print(f"Hamiltonian: {hamiltonian}")
print(f"Number of qubits needed: {n_qubits}")
```

### Jordan-Wigner Transformation

```python
# Hamiltonian is automatically in qubit form via Jordan-Wigner
# Manual transformation:
from pennylane import fermi

# Fermionic operators
a_0 = fermi.FermiC(0)  # Creation operator
a_1 = fermi.FermiA(1)  # Annihilation operator

# Convert to qubits
qubit_op = qml.jordan_wigner(a_0 * a_1)
```

### Bravyi-Kitaev Transformation

```python
# Alternative mapping (more efficient for some systems)
from pennylane.qchem import bravyi_kitaev

# Build Hamiltonian with Bravyi-Kitaev
hamiltonian, n_qubits = qchem.molecular_hamiltonian(
    molecule,
    mapping='bravyi_kitaev'
)
```

### Custom Hamiltonians

```python
# Build Hamiltonian from coefficients and operators
coeffs = [0.2, -0.8, 0.5]
obs = [
    qml.PauliZ(0),
    qml.PauliZ(0) @ qml.PauliZ(1),
    qml.PauliX(0) @ qml.PauliX(1)
]

H = qml.Hamiltonian(coeffs, obs)

# Or use simplified syntax
H = 0.2 * qml.PauliZ(0) - 0.8 * qml.PauliZ(0) @ qml.PauliZ(1) + 0.5 * qml.PauliX(0) @ qml.PauliX(1)
```

## Variational Quantum Eigensolver (VQE)

### Basic VQE Implementation

```python
from pennylane import numpy as np

# Define device
dev = qml.device('default.qubit', wires=n_qubits)

# Hartree-Fock state preparation
hf_state = qchem.hf_state(electrons=2, orbitals=n_qubits)

def ansatz(params, wires):
    """Variational ansatz."""
    qml.BasisState(hf_state, wires=wires)

    for i in range(len(wires)):
        qml.RY(params[i], wires=i)

    for i in range(len(wires)-1):
        qml.CNOT(wires=[i, i+1])

@qml.qnode(dev)
def vqe_circuit(params):
    ansatz(params, wires=range(n_qubits))
    return qml.expval(hamiltonian)

# Optimize
opt = qml.GradientDescentOptimizer(stepsize=0.4)
params = np.random.normal(0, np.pi, n_qubits, requires_grad=True)

for n in range(100):
    params, energy = opt.step_and_cost(vqe_circuit, params)

    if n % 20 == 0:
        print(f"Step {n}: Energy = {energy:.8f} Ha")

print(f"Final ground state energy: {energy:.8f} Ha")
```

### UCCSD Ansatz

```python
# Singles and doubles excitations
singles, doubles = qchem.excitations(electrons=2, orbitals=n_qubits)
s_wires, d_wires = qchem.excitations_to_wires(singles, doubles)

@qml.qnode(dev)
def uccsd_circuit(params):
    # Hartree-Fock reference
    qml.BasisState(hf_state, wires=range(n_qubits))

    # UCCSD ansatz
    qml.UCCSD(params, wires=range(n_qubits), s_wires=s_wires, d_wires=d_wires)

    return qml.expval(hamiltonian)

# Initialize parameters
n_params = len(singles) + len(doubles)
params = np.zeros(n_params, requires_grad=True)

# Optimize
opt = qml.AdamOptimizer(stepsize=0.1)
for n in range(100):
    params, energy = opt.step_and_cost(uccsd_circuit, params)
```

### Adaptive VQE

```python
def adaptive_vqe(hamiltonian, n_qubits, max_gates=10):
    """Adaptive VQE: Grow ansatz iteratively."""
    dev = qml.device('default.qubit', wires=n_qubits)

    # Start with HF state
    operations = []
    params = []

    hf_state = qchem.hf_state(electrons=2, orbitals=n_qubits)

    @qml.qnode(dev)
    def circuit(p):
        qml.BasisState(hf_state, wires=range(n_qubits))

        for op, param in zip(operations, p):
            op(param)

        return qml.expval(hamiltonian)

    # Iteratively add gates
    for _ in range(max_gates):
        # Find best gate to add
        best_op = None
        best_improvement = 0

        for candidate_op in generate_candidates():
            # Test adding this operation
            test_ops = operations + [candidate_op]
            test_params = params + [0.0]

            improvement = evaluate_improvement(test_ops, test_params)

            if improvement > best_improvement:
                best_improvement = improvement
                best_op = candidate_op

        if best_improvement < threshold:
            break

        operations.append(best_op)
        params.append(0.0)

        # Optimize current ansatz
        opt = qml.AdamOptimizer(stepsize=0.1)
        for _ in range(50):
            params = opt.step(circuit, params)

    return circuit, params
```

## Molecular Structure

### Defining Molecules

```python
# Simple diatomic
h2_symbols = ['H', 'H']
h2_coords = np.array([[0.0, 0.0, -0.66140414], [0.0, 0.0, 0.66140414]])

# Water molecule
h2o_symbols = ['O', 'H', 'H']
h2o_coords = np.array([
    [0.0, 0.0, 0.0],      # O
    [0.757, 0.586, 0.0],  # H
    [-0.757, 0.586, 0.0], # H
])

# From XYZ format
symbols, geometry = qchem.read_structure('molecule.xyz')
molecule = qchem.Molecule(symbols, geometry)
```

### Geometry Optimization

```python
def optimize_geometry(symbols, initial_coords, basis='sto-3g'):
    """Optimize molecular geometry."""

    def energy_surface(coords):
        molecule = qchem.Molecule(symbols, coords, basis_name=basis)
        H, n_qubits = qchem.molecular_hamiltonian(
            molecule
        )

        # Run VQE to get energy
        energy = run_vqe(H, n_qubits)
        return energy

    # Classical optimization of nuclear coordinates
    from scipy.optimize import minimize

    result = minimize(
        energy_surface,
        initial_coords,
        method='BFGS',
        options={'gtol': 1e-5}
    )

    return result.x, result.fun

optimized_coords, min_energy = optimize_geometry(h2_symbols, h2_coords)
print(f"Optimized geometry: {optimized_coords}")
print(f"Energy: {min_energy} Ha")
```

### Bond Dissociation Curves

```python
def dissociation_curve(symbols, axis=2, distances=None):
    """Calculate potential energy surface."""

    if distances is None:
        distances = np.linspace(0.5, 3.0, 20)

    energies = []

    for d in distances:
        coords = np.array([[0.0, 0.0, 0.0], [0.0, 0.0, d]])
        molecule = qchem.Molecule(symbols, coords, basis_name='sto-3g')

        H, n_qubits = qchem.molecular_hamiltonian(
            molecule
        )

        energy = run_vqe(H, n_qubits)
        energies.append(energy)

        print(f"Distance: {d:.2f} Å, Energy: {energy:.6f} Ha")

    return distances, energies

# H2 dissociation
distances, energies = dissociation_curve(['H', 'H'])

import matplotlib.pyplot as plt
plt.plot(distances, energies)
plt.xlabel('Bond length (Å)')
plt.ylabel('Energy (Ha)')
plt.title('H2 Dissociation Curve')
plt.show()
```

## Basis Sets and Mapping

### Basis Set Selection

```python
# Minimal basis (fastest, least accurate)
molecule = qchem.Molecule(symbols, coords, basis_name='sto-3g')
H_sto3g, n_qubits = qchem.molecular_hamiltonian(
    molecule
)

# Double-zeta basis
molecule = qchem.Molecule(symbols, coords, basis_name='6-31g')
H_631g, n_qubits = qchem.molecular_hamiltonian(
    molecule
)

# Large basis (slower, more accurate)
molecule = qchem.Molecule(symbols, coords, basis_name='cc-pvdz')
H_ccpvdz, n_qubits = qchem.molecular_hamiltonian(
    molecule
)
```

### Active Space Selection

```python
# Select active orbitals
active_electrons = 2
active_orbitals = 2

molecule = qchem.Molecule(symbols, coords, basis_name='sto-3g')
H_active, n_qubits = qchem.molecular_hamiltonian(
    molecule,
    active_electrons=active_electrons,
    active_orbitals=active_orbitals,
)

print(f"Full system: {len(symbols)} electrons")
print(f"Active space: {active_electrons} electrons in {active_orbitals} orbitals")
print(f"Qubits needed: {n_qubits}")
```

### Fermion-to-Qubit Mappings

```python
# Jordan-Wigner (default)
H_jw, n_q_jw = qchem.molecular_hamiltonian(
    molecule, mapping='jordan_wigner'
)

# Bravyi-Kitaev
H_bk, n_q_bk = qchem.molecular_hamiltonian(
    molecule, mapping='bravyi_kitaev'
)

# Parity
H_parity, n_q_parity = qchem.molecular_hamiltonian(
    molecule, mapping='parity'
)

print(f"Jordan-Wigner terms: {len(H_jw.ops)}")
print(f"Bravyi-Kitaev terms: {len(H_bk.ops)}")
```

## Excited States

### Quantum Subspace Expansion

```python
def quantum_subspace_expansion(hamiltonian, ground_state_params, excitations):
    """Calculate excited states via subspace expansion."""

    @qml.qnode(dev)
    def ground_state():
        ansatz(ground_state_params, wires=range(n_qubits))
        return qml.state()

    # Get ground state
    psi_0 = ground_state()

    # Generate excited state basis
    basis = [psi_0]

    for exc in excitations:
        @qml.qnode(dev)
        def excited_state():
            ansatz(ground_state_params, wires=range(n_qubits))
            # Apply excitation
            apply_excitation(exc)
            return qml.state()

        psi_exc = excited_state()
        basis.append(psi_exc)

    # Build Hamiltonian matrix in subspace
    n_basis = len(basis)
    H_matrix = np.zeros((n_basis, n_basis))

    for i in range(n_basis):
        for j in range(n_basis):
            H_matrix[i, j] = np.vdot(basis[i], hamiltonian @ basis[j])

    # Diagonalize
    eigenvalues, eigenvectors = np.linalg.eigh(H_matrix)

    return eigenvalues, eigenvectors
```

### SSVQE (Subspace-Search VQE)

```python
def ssvqe(hamiltonian, n_states=3):
    """Calculate multiple states simultaneously."""

    def cost_function(params):
        states = []

        for i in range(n_states):
            @qml.qnode(dev)
            def state_i():
                ansatz(params[i], wires=range(n_qubits))
                return qml.state()

            states.append(state_i())

        # Energy expectation
        energies = [np.vdot(s, hamiltonian @ s) for s in states]

        # Orthogonality penalty
        penalty = 0
        for i in range(n_states):
            for j in range(i+1, n_states):
                overlap = np.abs(np.vdot(states[i], states[j]))
                penalty += overlap ** 2

        return sum(energies) + 1000 * penalty

    # Initialize parameters for all states
    params = [np.random.random(n_params) for _ in range(n_states)]

    opt = qml.AdamOptimizer(stepsize=0.01)
    for _ in range(100):
        params = opt.step(cost_function, params)

    return params
```

## Quantum Chemistry Workflows

### Full VQE Workflow

```python
def full_chemistry_workflow(symbols, coords, basis='sto-3g'):
    """Complete quantum chemistry calculation."""

    print("1. Building molecular Hamiltonian...")
    molecule = qchem.Molecule(symbols, coords, basis_name=basis)
    H, n_qubits = qchem.molecular_hamiltonian(
        molecule
    )

    print(f"   Molecule: {' '.join(symbols)}")
    print(f"   Qubits: {n_qubits}")
    print(f"   Hamiltonian terms: {len(H.ops)}")

    print("\n2. Preparing Hartree-Fock state...")
    n_electrons = sum(qchem.atomic_numbers[s] for s in symbols)
    hf_state = qchem.hf_state(n_electrons, n_qubits)

    print("\n3. Running VQE...")
    energy, params = run_vqe(H, n_qubits, hf_state)

    print(f"\n4. Results:")
    print(f"   Ground state energy: {energy:.8f} Ha")

    print("\n5. Computing properties...")
    dipole = compute_dipole_moment(symbols, coords, params)
    print(f"   Dipole moment: {dipole:.4f} D")

    return {
        'energy': energy,
        'params': params,
        'dipole': dipole
    }

results = full_chemistry_workflow(['H', 'H'], h2_coords)
```

### Molecular Property Calculation

```python
def compute_molecular_properties(symbols, coords, vqe_params):
    """Calculate molecular properties from VQE solution."""

    # Energy
    molecule = qchem.Molecule(symbols, coords)
    H, n_qubits = qchem.molecular_hamiltonian(molecule)
    energy = vqe_circuit(vqe_params)

    # Dipole moment
    dipole_obs = qchem.dipole_moment(molecule)

    @qml.qnode(dev)
    def dipole_circuit(axis):
        ansatz(vqe_params, wires=range(n_qubits))
        return qml.expval(dipole_obs[axis])

    dipole = [dipole_circuit(i) for i in range(3)]
    dipole_magnitude = np.linalg.norm(dipole)

    # Particle number (sanity check)
    @qml.qnode(dev)
    def particle_number():
        ansatz(vqe_params, wires=range(n_qubits))
        N_op = qchem.particle_number(n_qubits)
        return qml.expval(N_op)

    n_particles = particle_number()

    return {
        'energy': energy,
        'dipole_moment': dipole_magnitude,
        'dipole_vector': dipole,
        'particle_number': n_particles
    }
```

### Reaction Energy Calculation

```python
def reaction_energy(reactants, products):
    """Calculate energy of chemical reaction."""

    # Calculate energies of reactants
    E_reactants = 0
    for molecule in reactants:
        symbols, coords = molecule
        mol = qchem.Molecule(symbols, coords)
        H, n_qubits = qchem.molecular_hamiltonian(mol)
        E_reactants += run_vqe(H, n_qubits)

    # Calculate energies of products
    E_products = 0
    for molecule in products:
        symbols, coords = molecule
        mol = qchem.Molecule(symbols, coords)
        H, n_qubits = qchem.molecular_hamiltonian(mol)
        E_products += run_vqe(H, n_qubits)

    # Reaction energy
    delta_E = E_products - E_reactants

    print(f"Reactant energy: {E_reactants:.6f} Ha")
    print(f"Product energy: {E_products:.6f} Ha")
    print(f"Reaction energy: {delta_E:.6f} Ha ({delta_E * 627.5:.2f} kcal/mol)")

    return delta_E

# Example: H2 dissociation
reactants = [((['H', 'H'], h2_coords_bonded))]
products = [((['H'], [0, 0, 0]), (['H'], [10, 0, 0]))]  # Separated atoms

delta_E = reaction_energy(reactants, products)
```

## Best Practices

1. **Start with small basis sets** - Use STO-3G for testing, upgrade for production
2. **Use active space** - Reduce qubits by selecting relevant orbitals
3. **Choose appropriate mapping** - Bravyi-Kitaev often reduces circuit depth
4. **Initialize with HF** - Start VQE from Hartree-Fock state
5. **Validate results** - Compare with classical methods (FCI, CCSD)
6. **Consider symmetries** - Exploit molecular symmetries to reduce complexity
7. **Use UCCSD for accuracy** - UCCSD ansatz is chemically motivated
8. **Monitor convergence** - Check gradient norms and energy variance
9. **Account for correlation** - Ensure ansatz captures electron correlation
10. **Benchmark thoroughly** - Test on known systems before novel molecules
