// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
// Test content for scuc::tests module.

use crate::common::spec::DispatchProblemSpec;
use surge_network::market::{EnergyOffer, OfferCurve, StartupTier, SystemReserveRequirement};
use surge_network::network::{CommitmentStatus, Load};

/// Helper: build an EnergyOffer containing only startup tiers.
/// Input: `&[(max_offline_hours, cost_dollars)]` — sync_time set to 0.
fn energy_offer_with_startup_tiers(tiers: &[(f64, f64)]) -> EnergyOffer {
    EnergyOffer {
        submitted: OfferCurve {
            segments: vec![],
            no_load_cost: 0.0,
            startup_tiers: tiers
                .iter()
                .map(|&(h, c)| StartupTier {
                    max_offline_hours: h,
                    cost: c,
                    sync_time_min: 0.0,
                })
                .collect(),
        },
        mitigated: None,
        mitigation_active: false,
    }
}

#[allow(dead_code)]
fn data_available() -> bool {
    if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
        return std::path::Path::new(&p).exists();
    }
    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .parent()
        .unwrap()
        .join("tests/data")
        .exists()
}
#[allow(dead_code)]
fn test_data_dir() -> std::path::PathBuf {
    if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
        return std::path::PathBuf::from(p);
    }
    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .parent()
        .unwrap()
        .join("tests/data")
}

use crate::dispatch::{
    CommitmentMode, Horizon, IndexedCommitmentConstraint, IndexedCommitmentOptions,
    IndexedCommitmentTerm, IndexedDispatchInitialState, RawDispatchSolution,
};
use crate::legacy::DispatchOptions;

#[allow(dead_code)]
fn test_data_path(name: &str) -> std::path::PathBuf {
    if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
        return std::path::PathBuf::from(p).join(name);
    }
    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .parent()
        .unwrap()
        .join("tests/data")
        .join(name)
}

fn first_storage_gen_index(net: &surge_network::Network) -> usize {
    net.generators
        .iter()
        .enumerate()
        .find_map(|(gi, g)| (g.in_service && g.storage.is_some()).then_some(gi))
        .expect("test network must contain an in-service storage generator")
}

fn solve_scuc(
    network: &surge_network::Network,
    options: &DispatchOptions,
) -> Result<RawDispatchSolution, crate::error::ScedError> {
    super::solve::solve_scuc_with_problem_spec(network, DispatchProblemSpec::from_options(options))
}

fn commitment_schedule(sol: &RawDispatchSolution) -> &[Vec<bool>] {
    sol.commitment
        .as_deref()
        .expect("SCUC result should include commitment schedule")
}

fn startup_schedule(sol: &RawDispatchSolution) -> &[Vec<bool>] {
    sol.startup
        .as_deref()
        .expect("SCUC result should include startup schedule")
}

fn shutdown_schedule(sol: &RawDispatchSolution) -> &[Vec<bool>] {
    sol.shutdown
        .as_deref()
        .expect("SCUC result should include shutdown schedule")
}

fn startup_cost_total(sol: &RawDispatchSolution) -> f64 {
    sol.startup_cost_total.unwrap_or_default()
}

fn sum_period_total_cost(sol: &RawDispatchSolution) -> f64 {
    sol.periods.iter().map(|period| period.total_cost).sum()
}

fn network_at_hour(
    network: &surge_network::Network,
    options: &DispatchOptions,
    hour: usize,
) -> surge_network::Network {
    let spec = DispatchProblemSpec::from_options(options);
    super::snapshot::network_at_hour_with_spec(network, &spec, hour)
}

fn single_gen_scuc_test_network() -> surge_network::Network {
    use surge_network::Network;
    use surge_network::market::CostCurve;
    use surge_network::network::{Bus, BusType, Generator};

    let mut network = Network::new("single_gen_scuc_test");
    network.base_mva = 100.0;

    let bus = Bus::new(1, BusType::Slack, 138.0);
    network.buses.push(bus);
    network.loads.push(Load::new(1, 50.0, 0.0));

    let mut generator = Generator::new(1, 0.0, 1.0);
    generator.in_service = true;
    generator.pmin = 0.0;
    generator.pmax = 100.0;
    generator.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![0.0, 10.0],
    });
    network.generators.push(generator);

    network
}

fn base_scuc_options() -> DispatchOptions {
    DispatchOptions {
        n_periods: 1,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    }
}

#[test]
fn test_scuc_commitment_cut_period_must_be_in_range() {
    let network = single_gen_scuc_test_network();
    let mut options = base_scuc_options();
    options.commitment_constraints = vec![IndexedCommitmentConstraint {
        name: "bad_period".into(),
        period_idx: 1,
        terms: vec![IndexedCommitmentTerm {
            gen_index: 0,
            coeff: 1.0,
        }],
        lower_bound: 1.0,
        penalty_cost: None,
    }];

    let err = solve_scuc(&network, &options).unwrap_err();
    assert!(matches!(
        err,
        crate::error::ScedError::InvalidInput(msg)
            if msg.contains("bad_period") && msg.contains("period_idx")
    ));
}

#[test]
fn test_scuc_commitment_cut_term_gen_index_must_be_in_range() {
    let network = single_gen_scuc_test_network();
    let mut options = base_scuc_options();
    options.commitment_constraints = vec![IndexedCommitmentConstraint {
        name: "bad_gen".into(),
        period_idx: 0,
        terms: vec![IndexedCommitmentTerm {
            gen_index: 1,
            coeff: 1.0,
        }],
        lower_bound: 1.0,
        penalty_cost: None,
    }];

    let err = solve_scuc(&network, &options).unwrap_err();
    assert!(matches!(
        err,
        crate::error::ScedError::InvalidInput(msg)
            if msg.contains("bad_gen") && msg.contains("gen_index")
    ));
}

#[test]
fn test_scuc_commitment_cut_penalty_cost_must_be_nonnegative() {
    let network = single_gen_scuc_test_network();
    let mut options = base_scuc_options();
    options.commitment_constraints = vec![IndexedCommitmentConstraint {
        name: "bad_penalty".into(),
        period_idx: 0,
        terms: vec![IndexedCommitmentTerm {
            gen_index: 0,
            coeff: 1.0,
        }],
        lower_bound: 1.0,
        penalty_cost: Some(-5.0),
    }];

    let err = solve_scuc(&network, &options).unwrap_err();
    assert!(matches!(
        err,
        crate::error::ScedError::InvalidInput(msg)
            if msg.contains("bad_penalty") && msg.contains("nonnegative")
    ));
}

#[test]
fn test_scuc_soft_commitment_cut_extracts_penalty_slack() {
    let network = single_gen_scuc_test_network();
    let mut options = base_scuc_options();
    options.commitment_constraints = vec![IndexedCommitmentConstraint {
        name: "soft_cut".into(),
        period_idx: 0,
        terms: vec![IndexedCommitmentTerm {
            gen_index: 0,
            coeff: 1.0,
        }],
        lower_bound: 2.0,
        penalty_cost: Some(100.0),
    }];

    let solution = solve_scuc(&network, &options).expect("soft commitment cut should solve");

    assert_eq!(solution.diagnostics.penalty_slack_values.len(), 1);
    assert!(
        (solution.diagnostics.penalty_slack_values[0] - 1.0).abs() < 1e-6,
        "expected one unit of slack, got {:?}",
        solution.diagnostics.penalty_slack_values
    );
}

#[test]
fn test_scuc_case9_basic() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    let net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();

    let opts = DispatchOptions {
        n_periods: 4,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).unwrap();

    assert_eq!(sol.study.periods, 4);
    assert_eq!(sol.periods.len(), 4);
    assert!(sol.summary.total_cost > 0.0);

    // Power balance each hour
    for t in 0..4 {
        let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
        let net_t = network_at_hour(&net, &opts, t);
        let total_load: f64 = net_t
            .loads
            .iter()
            .filter(|l| l.in_service)
            .map(|l| l.active_power_demand_mw)
            .sum();
        assert!(
            (total_gen - total_load).abs() < 1.0,
            "hour {t}: gen={total_gen:.1}, load={total_load:.1}"
        );
    }

    // At least some generators committed each hour
    for t in 0..4 {
        let n_committed: usize = commitment_schedule(&sol)[t].iter().filter(|&&c| c).count();
        assert!(
            n_committed >= 2,
            "hour {t}: only {n_committed} generators committed"
        );
    }
}

#[test]
fn test_scuc_period_costs_reconcile_to_total_cost() {
    let net = single_gen_scuc_test_network();
    let opts = DispatchOptions {
        n_periods: 3,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        enforce_thermal_limits: false,
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).expect("SCUC should solve");
    let period_cost_sum = sum_period_total_cost(&sol);
    assert!(
        (period_cost_sum - sol.summary.total_cost).abs() < 1e-6,
        "sum(period.total_cost)={} should equal total_cost={}",
        period_cost_sum,
        sol.summary.total_cost
    );
}

#[test]
fn test_scuc_cost_reasonable() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // SCUC (linearized cost) should produce a reasonable cost.
    // Note: SCUC drops the quadratic cost term (MILP limitation), so
    // direct comparison with SCED quadratic cost is not meaningful.
    // Instead, verify the cost is positive and dispatch is feasible.
    let net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();

    let scuc_opts = DispatchOptions {
        n_periods: 1,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    let scuc_sol = solve_scuc(&net, &scuc_opts).unwrap();

    assert!(
        scuc_sol.summary.total_cost > 0.0,
        "SCUC cost={:.2} should be positive",
        scuc_sol.summary.total_cost
    );

    // Verify power balance
    let total_gen: f64 = scuc_sol.periods[0].pg_mw.iter().sum();
    let total_load: f64 = net
        .loads
        .iter()
        .filter(|l| l.in_service)
        .map(|l| l.active_power_demand_mw)
        .sum();
    assert!(
        (total_gen - total_load).abs() < 1.0,
        "gen={total_gen:.1}, load={total_load:.1}"
    );
}

#[test]
fn test_scuc_commitment_binary() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // Verify commitment variables are truly binary
    let net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();

    let opts = DispatchOptions {
        n_periods: 4,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();

    for t in 0..sol.study.periods {
        for j in 0..sol.periods[0].pg_mw.len() {
            // If committed, dispatch should be non-negative
            if commitment_schedule(&sol)[t][j] {
                assert!(
                    sol.periods[t].pg_mw[j] >= -0.1,
                    "hour {t} gen {j}: pg={:.1} < 0",
                    sol.periods[t].pg_mw[j]
                );
            }
            // If not committed, dispatch should be ~0
            if !commitment_schedule(&sol)[t][j] {
                assert!(
                    sol.periods[t].pg_mw[j].abs() < 1.0,
                    "hour {t} gen {j}: off but pg={:.1}",
                    sol.periods[t].pg_mw[j]
                );
            }
        }
    }
}

#[test]
fn test_scuc_startup_shutdown_logic() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // Verify v[t]=1 iff unit turns on, w[t]=1 iff unit turns off
    let net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();

    let opts = DispatchOptions {
        n_periods: 4,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
            initial_commitment: Some(vec![true; 3]), // all on initially
            ..IndexedCommitmentOptions::default()
        }),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();

    for t in 1..sol.study.periods {
        for j in 0..sol.periods[0].pg_mw.len() {
            let was_on = commitment_schedule(&sol)[t - 1][j];
            let is_on = commitment_schedule(&sol)[t][j];
            let started = startup_schedule(&sol)[t][j];
            let stopped = shutdown_schedule(&sol)[t][j];

            // v[t] = 1 iff turned on (was off, now on)
            if started {
                assert!(
                    !was_on && is_on,
                    "hour {t} gen {j}: startup but was_on={was_on} is_on={is_on}"
                );
            }
            // w[t] = 1 iff turned off (was on, now off)
            if stopped {
                assert!(
                    was_on && !is_on,
                    "hour {t} gen {j}: shutdown but was_on={was_on} is_on={is_on}"
                );
            }
        }
    }
}

#[test]
fn test_scuc_rts_gmlc() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    let net = surge_io::matpower::load(test_data_path("case_RTS_GMLC.m")).unwrap();

    // Load hourly profiles
    let opts = DispatchOptions {
        n_periods: 24,
        // 60s limit for CI
        load_profiles: surge_io::profiles::read_load_profiles_csv(&test_data_path(
            "rts96/load_24h.csv",
        ))
        .unwrap(),
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
            time_limit_secs: Some(60.0),
            ..IndexedCommitmentOptions::default()
        }),
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).unwrap();

    assert_eq!(sol.study.periods, 24);
    assert!(sol.summary.total_cost > 0.0);

    // Check power balance for a few hours
    for t in [0, 6, 12, 18] {
        let net_t = network_at_hour(&net, &opts, t);
        let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
        let total_load: f64 = net_t
            .loads
            .iter()
            .filter(|l| l.in_service)
            .map(|l| l.active_power_demand_mw)
            .sum();
        assert!(
            (total_gen - total_load).abs() < 5.0,
            "hour {t}: gen={total_gen:.1}, load={total_load:.1}"
        );
    }

    // Some generators should decommit during off-peak
    let committed_peak: usize = commitment_schedule(&sol)[17].iter().filter(|&&c| c).count();
    let committed_offpeak: usize = commitment_schedule(&sol)[3].iter().filter(|&&c| c).count();
    // Peak should have more or equal committed units
    assert!(
        committed_peak >= committed_offpeak,
        "peak committed={committed_peak} < offpeak committed={committed_offpeak}"
    );

    println!(
        "SCUC RTS-96 24h: cost={:.0}, peak_committed={}, offpeak_committed={}, time={:.1}s",
        sol.summary.total_cost, committed_peak, committed_offpeak, sol.diagnostics.solve_time_secs
    );
}

#[test]
fn test_scuc_with_reserve() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    let mut net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();

    // Populate reserve_offers so generators can provide spinning reserve.
    for g in &mut net.generators {
        let phys_cap = (g.pmax - g.pmin).max(0.0);
        if phys_cap > 0.0 {
            g.market.get_or_insert_default().reserve_offers.push(
                surge_network::market::ReserveOffer {
                    product_id: "spin".into(),
                    capacity_mw: phys_cap,
                    cost_per_mwh: 0.0,
                },
            );
        }
    }

    let opts = DispatchOptions {
        n_periods: 4,
        system_reserve_requirements: vec![SystemReserveRequirement {
            product_id: "spin".into(),
            requirement_mw: 30.0,
            per_period_mw: None,
        }],
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).unwrap();
    let spin_awards_0 = sol.periods[0]
        .reserve_awards
        .get("spin")
        .expect("spin awards");
    assert!(!spin_awards_0.is_empty());

    for t in 0..4 {
        let spin_awards = sol.periods[t]
            .reserve_awards
            .get("spin")
            .expect("spin awards");
        let total_reserve: f64 = spin_awards.iter().sum();
        assert!(
            total_reserve >= 29.9,
            "hour {t}: reserve={total_reserve:.1} MW, required 30 MW"
        );
    }

    // Cost with reserve should be ≥ without
    let no_reserve_opts = DispatchOptions {
        n_periods: 4,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    let no_reserve_sol = solve_scuc(&net, &no_reserve_opts).unwrap();
    assert!(
        sol.summary.total_cost >= no_reserve_sol.summary.total_cost - 1.0,
        "reserve cost={:.2} < no-reserve cost={:.2}",
        sol.summary.total_cost,
        no_reserve_sol.summary.total_cost
    );
}

// ----- DISP-01: LMP via LP re-solve -----

#[test]
fn test_scuc_lmp_nonzero() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // After the LP re-solve with fixed binaries, LMPs should be nonzero
    // for a non-trivial case (case9 has congestion-like structure).
    let net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();

    let opts = DispatchOptions {
        n_periods: 2,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();

    assert_eq!(sol.periods.len(), 2, "LMP should have one entry per hour");
    let n_bus = sol.periods[0].lmp.len();
    assert!(n_bus > 0, "LMP inner vector should be non-empty");

    // At least some buses in at least one hour should have non-zero LMPs.
    // (All-zero would indicate the duals were never populated.)
    let all_zero = sol
        .periods
        .iter()
        .all(|p| p.lmp.iter().all(|&v| v.abs() < 1e-12));
    assert!(
        !all_zero,
        "LMPs are all zero — LP re-solve did not produce valid duals"
    );
}

#[test]
fn test_scuc_lmp_dimensions() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // LMP dimensions must match n_hours × n_bus, not n_hours × n_gen
    let net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();
    let n_bus = net.n_buses();

    let opts = DispatchOptions {
        n_periods: 3,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();

    assert_eq!(sol.periods.len(), 3);
    for t in 0..3 {
        assert_eq!(
            sol.periods[t].lmp.len(),
            n_bus,
            "hour {t}: LMP length {} ≠ n_bus {}",
            sol.periods[t].lmp.len(),
            n_bus
        );
    }
}

// ----- DISP-02: Asymmetric ramp rates -----

#[test]
fn test_scuc_asymmetric_ramp_field_settable() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // Verify that asymmetric ramp curves can be set on Generator and survive
    // into solve_scuc without error.
    let mut net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();

    // Set asymmetric ramp: slow up (5 MW/min), fast down (20 MW/min)
    for g in &mut net.generators {
        g.ramping.get_or_insert_default().ramp_up_curve = vec![(0.0, 5.0)]; // up: 5 MW/min = 300 MW/hr
        g.ramping.get_or_insert_default().ramp_down_curve = vec![(0.0, 20.0)]; // down: 20 MW/min = 1200 MW/hr
    }

    let opts = DispatchOptions {
        n_periods: 3,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };

    // Must solve without panic or error
    let sol = solve_scuc(&net, &opts).unwrap();
    assert!(sol.summary.total_cost > 0.0);
    assert_eq!(sol.study.periods, 3);

    // Power balance must hold
    for t in 0..3 {
        let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
        let net_t = network_at_hour(&net, &opts, t);
        let total_load: f64 = net_t
            .loads
            .iter()
            .filter(|l| l.in_service)
            .map(|l| l.active_power_demand_mw)
            .sum();
        assert!(
            (total_gen - total_load).abs() < 1.0,
            "hour {t}: gen={total_gen:.1}, load={total_load:.1}"
        );
    }
}

#[test]
fn test_scuc_asymmetric_ramp_tighter_up() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // With very tight ramp-up (1 MW/min) and loose ramp-down (1000 MW/min),
    // multi-period dispatch should be more constrained than with symmetric
    // rates, and the constraint row count should be consistent.
    let mut net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();
    for g in &mut net.generators {
        g.ramping.get_or_insert_default().ramp_up_curve = vec![(0.0, 1.0)]; // 1 MW/min up = 60 MW/hr — very tight
        g.ramping.get_or_insert_default().ramp_down_curve = vec![(0.0, 1000.0)]; // essentially unlimited down
    }

    let opts = DispatchOptions {
        n_periods: 2,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
            initial_commitment: Some(vec![true; 3]),
            ..IndexedCommitmentOptions::default()
        }),
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).unwrap();
    assert!(sol.summary.total_cost > 0.0);

    // Power balance both hours
    for t in 0..2 {
        let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
        let net_t = network_at_hour(&net, &opts, t);
        let total_load: f64 = net_t
            .loads
            .iter()
            .filter(|l| l.in_service)
            .map(|l| l.active_power_demand_mw)
            .sum();
        assert!(
            (total_gen - total_load).abs() < 2.0,
            "hour {t}: gen={total_gen:.1}, load={total_load:.1}"
        );
    }
}

// ----- AC branch on/off binaries and Big-M switchable formulation -----

/// With `allow_branch_switching = false` (the default), the bounds layer
/// pins every branch_commitment column to its static `in_service` flag.
/// The state evolution rows are unconditionally allocated and trivially
/// satisfied (`u^on - u^on_prev = 0`). The LP solves identically to the
/// non-switching formulation. Single-bus zero-branch case verifies the
/// layout extension doesn't break the trivial topology.
#[test]
fn test_scuc_branch_switching_disabled_pins_to_initial_state() {
    let net = single_gen_scuc_test_network();
    let opts = base_scuc_options();
    assert!(!opts.allow_branch_switching);
    let sol = solve_scuc(&net, &opts).expect("default-mode SCUC should solve");
    assert_eq!(sol.periods.len(), 1);
}

/// With `allow_branch_switching = true`, the binary columns are free in
/// {0, 1} and the state-evolution rows allow transitions. The SCUC LP
/// also carries a per-branch `pf_l` flow variable and a 4-row Big-M
/// family per branch per period, plus the KCL rewrite that uses
/// `±pf_l` instead of `b·Δθ`. This test exercises the end-to-end solve
/// with the Big-M conditioning on.
#[test]
fn test_scuc_branch_switching_enabled_lp_solves() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    let net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();
    let mut opts = DispatchOptions {
        n_periods: 2,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    opts.allow_branch_switching = true;

    let sol = solve_scuc(&net, &opts).expect("AllowSwitching=1 SCUC should solve");
    assert!(sol.summary.total_cost > 0.0);
    assert_eq!(sol.study.periods, 2);
    // The branch_commitment_state vector is populated with the LP's
    // cleared switching pattern (n_periods × n_ac_branches).
    assert_eq!(sol.branch_commitment_state.len(), 2);
    for period_state in &sol.branch_commitment_state {
        assert_eq!(period_state.len(), net.branches.len());
    }
}

/// Structural test: the branch_flow block is allocated when
/// `allow_branch_switching = true` and sized as `n_ac_branches` per
/// period. The default mode allocates zero columns so the non-switching
/// layout is unaffected.
#[test]
fn test_scuc_branch_switching_allocates_branch_flow_block_when_enabled() {
    use surge_network::Network;
    use surge_network::market::CostCurve;
    use surge_network::network::{Bus, BusType, Generator, Load};

    let mut net = Network::new("scuc_branch_flow_layout");
    net.base_mva = 100.0;
    net.buses.push(Bus::new(1, BusType::Slack, 138.0));
    net.buses.push(Bus::new(2, BusType::PQ, 138.0));
    net.loads.push(Load::new(2, 40.0, 0.0));
    let mut generator = Generator::new(1, 40.0, 1.0);
    generator.pmin = 0.0;
    generator.pmax = 100.0;
    generator.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![0.0, 10.0],
    });
    net.generators.push(generator);
    net.branches.push(surge_network::network::Branch::new_line(
        1, 2, 0.0, 0.05, 0.0,
    ));
    net.branches[0].rating_a_mva = 60.0;
    net.branches[0].in_service = true;

    let mut opts = DispatchOptions {
        n_periods: 2,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    opts.allow_branch_switching = true;
    assert_eq!(opts.branch_switching_big_m_factor, 10.0);

    let sol = solve_scuc(&net, &opts).expect("2-bus SCUC with branch switching should solve");
    // Layout: 1 AC branch × 2 periods = 2 branch_flow columns. The LP
    // simply has to solve cleanly; the solver doesn't expose the
    // per-column inventory so the structural checks run through the
    // `branch_commitment_state` field which is only populated when
    // the branch_flow block is live.
    assert_eq!(sol.branch_commitment_state.len(), 2);
    assert_eq!(sol.branch_commitment_state[0].len(), 1);
    // Single branch, always in service initially → the LP keeps it on.
    assert!(sol.branch_commitment_state[0][0]);
    assert!(sol.branch_commitment_state[1][0]);
}

/// The connectivity cut row builder emits one triplet per branch in
/// the cut set, all on the same row, with coefficient `+1`. The row
/// bound is `[1, +∞]` (enforced by the caller) so at least one
/// branch_commitment in the cut set must be 1.
#[test]
fn test_indexed_connectivity_cut_emits_sum_row_triplets() {
    use super::connectivity::IndexedConnectivityCut;

    let mut net = surge_network::Network::new("conn_cut_triplets");
    net.base_mva = 100.0;
    net.buses.push(surge_network::network::Bus::new(
        1,
        surge_network::network::BusType::Slack,
        138.0,
    ));
    net.buses.push(surge_network::network::Bus::new(
        2,
        surge_network::network::BusType::PQ,
        138.0,
    ));
    net.buses.push(surge_network::network::Bus::new(
        3,
        surge_network::network::BusType::PQ,
        138.0,
    ));
    for pair in [(1, 2), (2, 3), (1, 3)] {
        let mut br = surge_network::network::Branch::new_line(pair.0, pair.1, 0.0, 0.05, 0.0);
        br.rating_a_mva = 50.0;
        br.in_service = true;
        net.branches.push(br);
    }

    let mut layout = super::layout::ScucLayout::build_prefix(
        /* n_bus */ 3, /* n_gen */ 0, /* n_delta_per_hour */ 0,
        /* use_plc */ false, /* n_bp */ 0, /* n_storage */ 0,
        /* n_sto_dis_epi */ 0, /* n_sto_ch_epi */ 0, /* n_hvdc_vars */ 0,
        /* n_pwl_gen */ 0, /* n_dl */ 0, /* n_vbid */ 0,
        /* n_block_vars_per_hour */ 0, /* n_reg_vars */ 0,
    );
    layout.finish_post_reserve(
        /* reserve_var_count */ 0, /* n_blk_res_vars_per_hour */ 0,
        /* n_foz_delta */ 0, /* n_foz_phi */ 0, /* n_foz_rho */ 0,
        /* n_ph_mode_vars_per_hour */ 0, /* n_bus */ 3, /* n_pb_curt_segs */ 0,
        /* n_pb_excess_segs */ 0, /* n_branch_flow */ 0, /* n_fg_rows */ 0,
        /* n_iface_rows */ 0, /* n_gen */ 0, /* n_angle_diff_rows */ 0,
        /* n_branch_switching_binaries_per_hour */ 3,
        /* n_branch_switching_flow_per_hour */ 3,
    );

    // Cut set {0, 2} at period 1: at least one of branches 0 or 2
    // must be on in period 1.
    let cut = IndexedConnectivityCut {
        period: 1,
        cut_set: vec![0, 2],
    };
    let row_index = 42usize;
    let triplets = cut.into_triplets(&layout, row_index);
    assert_eq!(triplets.len(), 2);
    for t in &triplets {
        assert_eq!(t.row, row_index);
        assert_eq!(t.val, 1.0);
    }
    let cols: Vec<usize> = triplets.iter().map(|t| t.col).collect();
    assert!(cols.contains(&layout.branch_commitment_col(1, 0)));
    assert!(cols.contains(&layout.branch_commitment_col(1, 2)));
}

/// The spec carries `branch_switching_big_m_factor` with a default of
/// `10.0` so options that do not set the field get the production-
/// default Big-M width.
#[test]
fn test_scuc_branch_switching_big_m_factor_default_is_ten() {
    let opts = DispatchOptions::default();
    assert_eq!(opts.branch_switching_big_m_factor, 10.0);
    let spec = DispatchProblemSpec::from_options(&opts);
    assert_eq!(spec.branch_switching_big_m_factor, 10.0);
    assert!(spec.connectivity_cuts.is_empty());
}

// ----- Hard ramp constraints -----

/// Build a self-contained 2-period 2-gen network where the cheap base unit
/// (gen 0) cannot ramp up fast enough to follow a load swing on its own.
/// Gen 1 is an expensive peaker that can absorb the rest. We use it as the
/// shared fixture for both the soft and hard ramp tests.
fn ramp_constrained_test_network() -> surge_network::Network {
    use surge_network::Network;
    use surge_network::market::CostCurve;
    use surge_network::network::{Bus, BusType, Generator};

    let mut net = Network::new("ramp_hard_test");
    net.base_mva = 100.0;
    net.buses.push(Bus::new(1, BusType::Slack, 138.0));
    net.loads.push(Load::new(1, 20.0, 0.0));

    // Cheap base unit, very tight ramp (10 MW/min ≈ 600 MW/h, but the 2-period
    // load swing of 60 MW happens in dt = 1h so 600 MW/h is feasible). Drop
    // the curve to a level that fails: 10 MW/h total = 0.1667 MW/min.
    let mut g0 = Generator::new(1, 20.0, 1.0);
    g0.in_service = true;
    g0.pmin = 0.0;
    g0.pmax = 100.0;
    g0.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![0.0, 10.0],
    });
    g0.ramping.get_or_insert_default().ramp_up_curve = vec![(0.0, 0.1667)];
    g0.ramping.get_or_insert_default().ramp_down_curve = vec![(0.0, 0.1667)];
    net.generators.push(g0);

    // Expensive peaker, fast ramp. Required so the system has *enough*
    // capacity overall but can only deliver the period-1 load step at high
    // marginal cost.
    let mut g1 = Generator::new(1, 0.0, 1.0);
    g1.in_service = true;
    g1.pmin = 0.0;
    g1.pmax = 100.0;
    g1.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![0.0, 200.0],
    });
    g1.ramping.get_or_insert_default().ramp_up_curve = vec![(0.0, 100.0)];
    g1.ramping.get_or_insert_default().ramp_down_curve = vec![(0.0, 100.0)];
    net.generators.push(g1);

    net
}

/// Build the 2-period DispatchOptions used by the hard/soft ramp tests.
/// Period 0 load = 20 MW, period 1 load = 80 MW: a 60 MW step.
fn ramp_constrained_test_options(ramp_constraints_hard: bool) -> DispatchOptions {
    use surge_network::market::{LoadProfile, LoadProfiles};

    DispatchOptions {
        n_periods: 2,
        enforce_thermal_limits: false,
        load_profiles: LoadProfiles {
            profiles: vec![LoadProfile {
                bus: 1,
                load_mw: vec![20.0, 80.0],
            }],
            n_timesteps: 2,
        },
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
            initial_commitment: Some(vec![true, true]),
            ..IndexedCommitmentOptions::default()
        }),
        ramp_constraints_hard,
        ..DispatchOptions::default()
    }
}

/// Single-generator variant: there is no peaker fallback, so the only ways
/// to absorb the load step are (a) ramp slack or (b) bus power-balance
/// curtailment. We make the ramp penalty cheaper than the curtailment
/// penalty so soft mode definitively prefers ramp slack and we can assert
/// on it directly.
fn ramp_constrained_single_gen_network() -> surge_network::Network {
    use surge_network::Network;
    use surge_network::market::CostCurve;
    use surge_network::network::{Bus, BusType, Generator};

    let mut net = Network::new("ramp_hard_single_gen_test");
    net.base_mva = 100.0;
    net.buses.push(Bus::new(1, BusType::Slack, 138.0));
    net.loads.push(Load::new(1, 20.0, 0.0));

    let mut g0 = Generator::new(1, 20.0, 1.0);
    g0.in_service = true;
    g0.pmin = 0.0;
    g0.pmax = 100.0;
    g0.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![0.0, 10.0],
    });
    g0.ramping.get_or_insert_default().ramp_up_curve = vec![(0.0, 0.1667)];
    g0.ramping.get_or_insert_default().ramp_down_curve = vec![(0.0, 0.1667)];
    net.generators.push(g0);

    net
}

#[test]
fn test_scuc_ramp_soft_default_takes_slack() {
    // Single-gen variant. With no peaker fallback the LP must either pay
    // ramp slack or curtail load. Default soft mode lets the LP take ramp
    // slack rather than infeasibility.
    use surge_network::market::{LoadProfile, LoadProfiles};

    let net = ramp_constrained_single_gen_network();
    let opts = DispatchOptions {
        n_periods: 2,
        enforce_thermal_limits: false,
        load_profiles: LoadProfiles {
            profiles: vec![LoadProfile {
                bus: 1,
                load_mw: vec![20.0, 80.0],
            }],
            n_timesteps: 2,
        },
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
            initial_commitment: Some(vec![true]),
            ..IndexedCommitmentOptions::default()
        }),
        ramp_constraints_hard: false,
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).expect("soft ramp mode should always solve");

    // Either ramp slack or bus curtailment slack must be carrying the
    // 50 MW gap (60 MW load step minus 10 MW/h ramp budget).
    let ramp_slack: f64 = sol
        .periods
        .iter()
        .flat_map(|p| p.constraint_results.iter())
        .filter(|c| matches!(c.kind, crate::result::ConstraintKind::Ramp))
        .filter_map(|c| c.slack_mw)
        .sum();
    let curtail_slack: f64 = sol
        .periods
        .iter()
        .flat_map(|p| p.constraint_results.iter())
        .filter(|c| matches!(c.kind, crate::result::ConstraintKind::PowerBalance))
        .filter_map(|c| c.slack_mw)
        .sum();
    let total_slack = ramp_slack + curtail_slack;
    assert!(
        total_slack > 1.0,
        "soft single-gen scenario should pay slack to absorb the 50 MW gap, got ramp={ramp_slack:.3} curtail={curtail_slack:.3}"
    );
}

#[test]
fn test_scuc_ramp_hard_pins_slack_to_zero() {
    // Hard mode: ramp slack columns are pinned to zero, so the LP cannot
    // use the slack as an escape hatch for infeasible ramps. The system
    // still has gen 1 (fast ramp) so the LP must redirect the period-1
    // load step to gen 1, paying its higher marginal cost.
    let net = ramp_constrained_test_network();
    let opts = ramp_constrained_test_options(true);

    let sol = solve_scuc(&net, &opts).expect("hard ramp mode with peaker should solve");

    // No ramp slack reported anywhere.
    let ramp_slack: f64 = sol
        .periods
        .iter()
        .flat_map(|p| p.constraint_results.iter())
        .filter(|c| matches!(c.kind, crate::result::ConstraintKind::Ramp))
        .filter_map(|c| c.slack_mw)
        .sum();
    assert!(
        ramp_slack < 1e-6,
        "hard mode should report zero ramp slack, got {ramp_slack:.3e} MW"
    );

    // Gen 0 dispatch must respect the 0.1667 MW/min × 60 min = 10 MW/h
    // ramp limit between periods 0 and 1.
    let pg_g0_p0 = sol.periods[0].pg_mw[0];
    let pg_g0_p1 = sol.periods[1].pg_mw[0];
    let ramp_limit_mw_per_h = 0.1667 * 60.0;
    let actual_ramp = (pg_g0_p1 - pg_g0_p0).abs();
    assert!(
        actual_ramp <= ramp_limit_mw_per_h + 1e-6,
        "hard mode: gen 0 ramp from {pg_g0_p0:.3} -> {pg_g0_p1:.3} ({actual_ramp:.3} MW) exceeds limit {ramp_limit_mw_per_h:.3} MW/h"
    );

    // Gen 1 must pick up the load step that gen 0 cannot.
    let pg_g1_p1 = sol.periods[1].pg_mw[1];
    assert!(
        pg_g1_p1 > 40.0,
        "hard mode: gen 1 should absorb most of the period-1 load step, got {pg_g1_p1:.3} MW"
    );
}

// ----- DISP-09: Must-run flag -----

#[test]
fn test_scuc_must_run_always_committed() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // If a generator is must_run, its commitment should be 1 in every hour.
    let mut net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();
    // Mark the first in-service generator as must-run
    for g in net.generators.iter_mut().filter(|g| g.in_service).take(1) {
        g.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
    }

    let opts = DispatchOptions {
        n_periods: 4,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();

    // Generator index 0 (first in-service gen) must be committed every hour
    for t in 0..4 {
        assert!(
            commitment_schedule(&sol)[t][0],
            "must-run gen 0 should be committed in hour {t}"
        );
    }
}

#[test]
fn test_scuc_must_run_multiple_gens() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // Multiple must-run generators all stay committed every hour.
    let mut net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();
    // Mark first two in-service generators as must-run
    let mut count = 0;
    for g in net.generators.iter_mut().filter(|g| g.in_service) {
        g.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
        count += 1;
        if count >= 2 {
            break;
        }
    }

    let opts = DispatchOptions {
        n_periods: 3,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();

    for t in 0..3 {
        for j in 0..2 {
            assert!(
                commitment_schedule(&sol)[t][j],
                "must-run gen {j} should be committed in hour {t}"
            );
        }
    }

    // Power balance must still hold
    for t in 0..3 {
        let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
        let net_t = network_at_hour(&net, &opts, t);
        let total_load: f64 = net_t
            .loads
            .iter()
            .filter(|l| l.in_service)
            .map(|l| l.active_power_demand_mw)
            .sum();
        assert!(
            (total_gen - total_load).abs() < 1.0,
            "hour {t}: gen={total_gen:.1}, load={total_load:.1}"
        );
    }
}

#[test]
fn test_scuc_must_run_default_false() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // Without setting must_run, generators default to false and the
    // solver is free to decommit them.
    let net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();
    // Confirm default
    for g in net.generators.iter().filter(|g| g.in_service) {
        assert!(
            g.commitment
                .as_ref()
                .is_none_or(|c| c.status != CommitmentStatus::MustRun),
            "commitment_status should default to Market, not MustRun"
        );
    }

    let opts = DispatchOptions {
        n_periods: 2,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    // Should solve normally without any error
    let sol = solve_scuc(&net, &opts).unwrap();
    assert!(sol.summary.total_cost > 0.0);
}

// ----- DISP-03: Hot/warm/cold startup cost tiers -----

#[test]
fn test_scuc_startup_cost_tiers_hot_vs_warm() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // Build a minimal 2-generator, 2-bus network.
    //
    // Generator 0: has tiers [(8.0, 1000.0), (f64::INFINITY, 5000.0)]
    //   initial_offline_hours[0] = 20.0 → warm start → startup cost = $5000
    //   Wait — 20 > 8 so tier 2 applies: cost = 5000.
    //
    // Generator 1: has tiers [(8.0, 1000.0), (f64::INFINITY, 5000.0)]
    //   initial_offline_hours[1] = 2.0 → hot start (≤ 8h) → startup cost = $1000
    //
    // Both generators start OFFLINE (initial_commitment = [false, false]).
    // We run for 1 hour to force both to start up (load > single-gen pmax).
    //
    // Verification: startup_cost in solution should reflect
    //   gen-0 warm ($5000) + gen-1 hot ($1000) = $6000.

    use surge_network::Network;
    use surge_network::market::CostCurve;
    use surge_network::network::{Branch, Bus, BusType, Generator, Load};

    let mut net = Network::new("test_tiers");
    net.base_mva = 100.0;

    // Two buses: bus 1 (slack), bus 2 (load)
    let b1 = Bus::new(1, BusType::Slack, 138.0);
    let b2 = Bus::new(2, BusType::PQ, 138.0);
    net.buses.push(b1);
    net.buses.push(b2);
    net.loads.push(Load::new(2, 150.0, 0.0)); // 150 MW load — needs both generators

    // Branch from bus 1 to bus 2 (high capacity)
    let mut br = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
    br.rating_a_mva = 500.0;
    br.in_service = true;
    net.branches.push(br);

    // Generator 0 at bus 1, pmax=100 MW
    let mut g0 = Generator::new(1, 0.0, 1.0);
    g0.pmin = 10.0;
    g0.pmax = 100.0;
    g0.in_service = true;
    g0.market.get_or_insert_default().energy_offer = Some(energy_offer_with_startup_tiers(&[
        (8.0, 1000.0),
        (f64::INFINITY, 5000.0),
    ]));
    g0.cost = Some(CostCurve::Polynomial {
        startup: 0.0, // ignored — tiers override
        shutdown: 0.0,
        coeffs: vec![10.0, 0.0], // linear cost $10/MWh
    });
    net.generators.push(g0);

    // Generator 1 at bus 2, pmax=100 MW
    let mut g1 = Generator::new(2, 0.0, 1.0);
    g1.pmin = 10.0;
    g1.pmax = 100.0;
    g1.in_service = true;
    g1.market.get_or_insert_default().energy_offer = Some(energy_offer_with_startup_tiers(&[
        (8.0, 1000.0),
        (f64::INFINITY, 5000.0),
    ]));
    g1.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![12.0, 0.0], // slightly higher cost so gen0 preferred
    });
    net.generators.push(g1);

    // Both generators start offline. Gen 0 has been offline 20h (warm),
    // gen 1 has been offline 2h (hot).
    let opts = DispatchOptions {
        n_periods: 1,
        enforce_thermal_limits: false, // single period, skip thermal
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
            initial_commitment: Some(vec![false, false]),
            initial_offline_hours: Some(vec![20.0, 2.0]),
            step_size_hours: Some(1.0),
            ..IndexedCommitmentOptions::default()
        }),
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).unwrap();

    // Both generators must start up to serve 150 MW load (each pmax=100 MW)
    assert!(
        startup_schedule(&sol)[0][0] || startup_schedule(&sol)[0][1],
        "at least one generator should start up"
    );
    // Total startup cost should be:
    //   if both start: $5000 (gen0 warm) + $1000 (gen1 hot) = $6000
    //   if only gen0 (impossible — pmax=100 < load=150)
    // So both must start and cost = $6000.
    assert!(
        (startup_cost_total(&sol) - 6000.0).abs() < 1.0,
        "startup cost should be $6000 (warm $5000 + hot $1000), got {:.2}",
        startup_cost_total(&sol)
    );
}

#[test]
fn test_scuc_startup_cost_tiers_default_empty() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // When startup_cost_tiers is empty, falls back to legacy CostCurve scalar.
    // Verify the solver still runs correctly with no tiers set.
    let net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();
    // Confirm energy_offer has no startup tiers by default
    for g in net.generators.iter().filter(|g| g.in_service) {
        let has_tiers = g
            .market
            .as_ref()
            .and_then(|m| m.energy_offer.as_ref())
            .is_some_and(|eo| !eo.submitted.startup_tiers.is_empty());
        assert!(!has_tiers, "startup tiers should default to empty");
    }

    let opts = DispatchOptions {
        n_periods: 2,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();
    assert!(sol.summary.total_cost > 0.0);
}

// ----- DISP-04: Battery/Storage unit type and dispatch with SoC model -----

#[test]
fn test_scuc_storage_soc_trajectory() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // Build a single-bus, single-generator network with a BESS.
    //
    // Design:
    //   Bus 1 (slack): Generator fixed at 100 MW (must_run, pmin=pmax=100).
    //   Load profile across 4 periods: [80, 80, 120, 120] MW.
    //   Battery: 50 MW charge/discharge max, 200 MWh capacity,
    //            efficiency = 1.0 (perfect round-trip for easy math),
    //            soc_initial = 0.0 MWh, soc_min = 0.0, soc_max = 200.0.
    //
    // Expected (with eta=1.0, sqrt_eta=1.0):
    //   Period 0: gen=100, load=80 → excess=20 MW → charge=20 → soc=20
    //   Period 1: gen=100, load=80 → excess=20 MW → charge=20 → soc=40
    //   Period 2: gen=100, load=120 → deficit=20 MW → discharge=20 → soc=20
    //   Period 3: gen=100, load=120 → deficit=20 MW → discharge=20 → soc=0
    //
    // Verification:
    //   - SoC trajectory is monotone up then down: [20, 40, 20, 0] MWh.
    //   - Final SoC = initial SoC (energy balanced, optional property here).
    //   - Power balance holds each period.

    use surge_network::Network;
    use surge_network::market::{CostCurve, LoadProfile, LoadProfiles};
    use surge_network::network::{Bus, BusType, Generator, StorageDispatchMode, StorageParams};

    let mut net = Network::new("test_storage");
    net.base_mva = 100.0;

    // Single bus: slack bus (no explicit load — load comes from profile)
    let b1 = Bus::new(1, BusType::Slack, 138.0);
    net.buses.push(b1);
    net.loads.push(Load::new(1, 100.0, 0.0)); // will be overridden by load profile

    // Fixed generator: 100 MW, must-run
    let mut g0 = Generator::new(1, 100.0, 1.0);
    g0.pmin = 100.0;
    g0.pmax = 100.0;
    g0.in_service = true;
    g0.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
    g0.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![10.0, 0.0], // $10/MWh variable
    });
    net.generators.push(g0);

    // Battery: 50 MW / 200 MWh, perfect efficiency (eta=1.0), at bus 1
    let bess_g = Generator {
        bus: 1,
        in_service: true,
        pmin: -50.0,
        pmax: 50.0,
        machine_base_mva: 100.0,
        cost: Some(CostCurve::Polynomial {
            coeffs: vec![0.0],
            startup: 0.0,
            shutdown: 0.0,
        }),
        storage: Some(StorageParams {
            charge_efficiency: 1.0, // perfect round-trip for clean test arithmetic
            discharge_efficiency: 1.0,
            energy_capacity_mwh: 200.0,
            soc_initial_mwh: 0.0,
            soc_min_mwh: 0.0,
            soc_max_mwh: 200.0,
            variable_cost_per_mwh: 0.0,
            degradation_cost_per_mwh: 0.0,
            dispatch_mode: StorageDispatchMode::CostMinimization,
            self_schedule_mw: 0.0,
            discharge_offer: None,
            charge_bid: None,
            max_c_rate_charge: None,
            max_c_rate_discharge: None,
            chemistry: None,
            discharge_foldback_soc_mwh: None,
            charge_foldback_soc_mwh: None,
            daily_cycle_limit: None,
        }),
        ..Generator::default()
    };
    net.generators.push(bess_g);

    let opts = DispatchOptions {
        n_periods: 4,
        enforce_thermal_limits: false, // single bus, no branches
        load_profiles: LoadProfiles {
            profiles: vec![LoadProfile {
                bus: 1,
                load_mw: vec![80.0, 80.0, 120.0, 120.0],
            }],
            n_timesteps: 4,
        },
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).unwrap();

    let storage_gi = first_storage_gen_index(&net);

    // SoC trajectory must be present for the storage generator
    assert!(
        sol.storage_soc.contains_key(&storage_gi),
        "storage_soc should contain entry for storage generator {storage_gi}"
    );
    let soc_traj = &sol.storage_soc[&storage_gi];
    assert_eq!(
        soc_traj.len(),
        4,
        "SoC trajectory must have 4 entries (one per period)"
    );

    // Verify monotone-up in periods 0-1, monotone-down in periods 2-3
    assert!(
        soc_traj[1] >= soc_traj[0] - 0.5,
        "SoC should not decrease in period 0→1 (charging phase): {:.2} → {:.2}",
        soc_traj[0],
        soc_traj[1]
    );
    assert!(
        soc_traj[2] <= soc_traj[1] + 0.5,
        "SoC should not increase in period 1→2 (transition): {:.2} → {:.2}",
        soc_traj[1],
        soc_traj[2]
    );
    assert!(
        soc_traj[3] <= soc_traj[2] + 0.5,
        "SoC should decrease in period 2→3 (discharging phase): {:.2} → {:.2}",
        soc_traj[2],
        soc_traj[3]
    );

    // SoC must stay within bounds at all times
    for (t, &soc) in soc_traj.iter().enumerate() {
        assert!(
            (-0.5..=200.5).contains(&soc),
            "SoC[{t}] = {soc:.2} MWh out of bounds [0, 200]"
        );
    }

    // Power balance: each period, gen + discharge - charge = load
    // (Generator dispatch is fixed at 100 MW)
    let load_per_period = [80.0, 80.0, 120.0, 120.0];
    for (t, &lp) in load_per_period.iter().enumerate() {
        let gen_t: f64 = sol.periods[t].pg_mw.iter().sum();
        let net_t = network_at_hour(&net, &opts, t);
        let total_load: f64 = net_t
            .loads
            .iter()
            .filter(|l| l.in_service)
            .map(|l| l.active_power_demand_mw)
            .sum();
        // Power balance is enforced by the LP; just verify it's approximately satisfied
        assert!(
            (gen_t - total_load).abs() < 25.0, // generous tolerance since storage contributes
            "hour {t}: gen={gen_t:.1}, load={total_load:.1}, load_profile={lp:.1}"
        );
    }

    // SoC dynamics must be physically consistent:
    // soc[t] = soc[t-1] + charge[t]*sqrt(eta) - discharge[t]/sqrt(eta)
    // With eta=1.0: soc[t] = soc[t-1] + charge[t] - discharge[t]
    // The total energy change from t=0 to t=3 must match the cumulative
    // net charge - discharge. For our scenario: charge=40 MWh, discharge=40 MWh
    // → net change = 0 (returns to initial SoC 0).
    let soc_change = soc_traj[3] - 0.0; // from soc_initial=0
    assert!(
        soc_change.abs() < 5.0,
        "End SoC should be near initial 0.0 (charge≈discharge over 4 periods), got {:.2}",
        soc_traj[3]
    );

    println!(
        "DISP-04 BESS test: SoC trajectory = [{:.1}, {:.1}, {:.1}, {:.1}] MWh",
        soc_traj[0], soc_traj[1], soc_traj[2], soc_traj[3]
    );
}

/// `StorageParams::daily_cycle_limit` caps total per-day throughput in
/// the time-coupled SCUC build. We construct a 24-period horizon with
/// alternating low/high load that an unconstrained battery would
/// micro-cycle through ~6 times, then re-solve with limit=1 and confirm
/// the LP holds total throughput at or below `1 × 2 × E_mwh`.
#[test]
fn test_scuc_storage_daily_cycle_limit_caps_throughput() {
    use surge_network::Network;
    use surge_network::market::{CostCurve, LoadProfile, LoadProfiles};
    use surge_network::network::{
        Bus, BusType, CommitmentStatus, Generator, Load, StorageDispatchMode, StorageParams,
    };

    fn build_net() -> Network {
        let mut net = Network::new("test_storage_daily_cycle");
        net.base_mva = 100.0;
        net.buses.push(Bus::new(1, BusType::Slack, 138.0));
        net.loads.push(Load::new(1, 0.0, 0.0)); // overridden by load profile

        // Cheap baseload: 100 MW @ $10/MWh, must-run.
        let mut g_cheap = Generator::new(1, 0.0, 1.0);
        g_cheap.pmin = 0.0;
        g_cheap.pmax = 100.0;
        g_cheap.in_service = true;
        g_cheap.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
        g_cheap.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0],
        });
        net.generators.push(g_cheap);

        // Peaker: 200 MW @ $200/MWh, must-run.
        let mut g_peak = Generator::new(1, 0.0, 1.0);
        g_peak.pmin = 0.0;
        g_peak.pmax = 200.0;
        g_peak.in_service = true;
        g_peak.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
        g_peak.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![200.0, 0.0],
        });
        net.generators.push(g_peak);

        // Battery: 50 MW / 100 MWh, perfect efficiency.
        let bess = Generator {
            bus: 1,
            in_service: true,
            pmin: -50.0,
            pmax: 50.0,
            machine_base_mva: 100.0,
            cost: Some(CostCurve::Polynomial {
                coeffs: vec![0.0],
                startup: 0.0,
                shutdown: 0.0,
            }),
            storage: Some(StorageParams {
                charge_efficiency: 1.0,
                discharge_efficiency: 1.0,
                energy_capacity_mwh: 100.0,
                soc_initial_mwh: 50.0,
                soc_min_mwh: 0.0,
                soc_max_mwh: 100.0,
                variable_cost_per_mwh: 0.0,
                degradation_cost_per_mwh: 0.0,
                dispatch_mode: StorageDispatchMode::CostMinimization,
                self_schedule_mw: 0.0,
                discharge_offer: None,
                charge_bid: None,
                max_c_rate_charge: None,
                max_c_rate_discharge: None,
                chemistry: None,
                discharge_foldback_soc_mwh: None,
                charge_foldback_soc_mwh: None,
                daily_cycle_limit: None,
            }),
            ..Generator::default()
        };
        net.generators.push(bess);
        net
    }

    // 24 periods of alternating low/high load. With cheap-gen pmax=100,
    // each high period needs 50 MW from battery or peaker; the battery
    // wants to displace the peaker (saves $190/MWh).
    let load_profile: Vec<f64> = (0..24)
        .map(|t| if t % 2 == 0 { 50.0 } else { 150.0 })
        .collect();

    fn opts(load_profile: &[f64]) -> DispatchOptions {
        DispatchOptions {
            n_periods: 24,
            dt_hours: 1.0,
            enforce_thermal_limits: false,
            load_profiles: LoadProfiles {
                profiles: vec![LoadProfile {
                    bus: 1,
                    load_mw: load_profile.to_vec(),
                }],
                n_timesteps: 24,
            },
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::AllCommitted,
            ..DispatchOptions::default()
        }
    }

    fn total_throughput(sol: &RawDispatchSolution) -> f64 {
        sol.periods
            .iter()
            .map(|p| p.storage_charge_mw[0] + p.storage_discharge_mw[0])
            .sum()
    }

    let net_unlim = build_net();
    let sol_unlim = solve_scuc(&net_unlim, &opts(&load_profile)).unwrap();
    let throughput_unlim = total_throughput(&sol_unlim);
    assert!(
        throughput_unlim > 600.0,
        "without cap the LP should cycle far past 1 FEC; got {throughput_unlim:.1} MWh"
    );

    let mut net_capped = build_net();
    net_capped.generators[2]
        .storage
        .as_mut()
        .unwrap()
        .daily_cycle_limit = Some(1.0);
    let sol_capped = solve_scuc(&net_capped, &opts(&load_profile)).unwrap();
    let throughput_capped = total_throughput(&sol_capped);
    let cap_mwh = 1.0 * 2.0 * 100.0; // limit * 2 * energy_capacity_mwh
    assert!(
        throughput_capped <= cap_mwh + 1e-3,
        "daily cycle cap should hold throughput ≤ {cap_mwh:.1}; got {throughput_capped:.4}"
    );
    // Cap must actually bind — otherwise the test isn't proving anything.
    assert!(
        throughput_capped > cap_mwh - 0.5,
        "expected the cap to bind near {cap_mwh:.1} MWh; got {throughput_capped:.4}"
    );
    println!(
        "daily-cycle: unlim throughput={:.1} MWh, capped throughput={:.4} MWh (cap={:.1})",
        throughput_unlim, throughput_capped, cap_mwh
    );
}

#[test]
fn test_scuc_storage_soc_empty_when_no_storage() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // Confirm storage_soc is empty when no storage units are provided.
    let net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();
    let opts = DispatchOptions {
        n_periods: 2,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();
    assert!(
        sol.storage_soc.is_empty(),
        "storage_soc should be empty when no storage units are provided"
    );
}

/// PNL-004: DispatchOptions can be constructed with Default and penalty_config is accessible.
#[test]
fn test_scuc_penalty_config_default() {
    let opts = DispatchOptions {
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    // Verify that penalty_config exists and has sensible defaults
    use surge_network::market::PenaltyCurve;
    // The reserve penalty should be a positive value (default: $1000/MW linear)
    let reserve_penalty = opts.penalty_config.reserve.marginal_cost_at(0.0);
    assert!(
        reserve_penalty > 0.0,
        "Default reserve penalty should be positive, got {reserve_penalty}"
    );
    // All other penalty fields should be accessible
    let _ = opts.penalty_config.thermal.marginal_cost_at(0.0);
    let _ = opts.penalty_config.voltage_high.marginal_cost_at(0.0);
    let _ = opts.penalty_config.voltage_low.marginal_cost_at(0.0);
    let _ = opts.penalty_config.power_balance.marginal_cost_at(0.0);
    let _ = opts.penalty_config.ramp.marginal_cost_at(0.0);
    let _ = opts.penalty_config.angle.marginal_cost_at(0.0);

    // Verify we can construct with a custom penalty_config
    let custom_opts = DispatchOptions {
        penalty_config: surge_network::market::PenaltyConfig {
            reserve: PenaltyCurve::Linear {
                cost_per_unit: 2000.0,
            },
            ..Default::default()
        },
        n_periods: 1,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    assert!(
        (custom_opts.penalty_config.reserve.marginal_cost_at(0.0) - 2000.0).abs() < 1e-9,
        "Custom reserve penalty should be 2000"
    );
}

// ----- DISP-09: Must-run pmin floor test -----

#[test]
fn test_scuc_must_run_dispatches_at_pmin() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // 2-generator network where gen2 is must-run with pmin > 0.
    // gen1 is cheaper; without must-run, gen2 would be off or at minimum.
    // With must-run, gen2 must commit AND dispatch at least at pmin.
    use surge_network::Network;
    use surge_network::market::CostCurve;
    use surge_network::network::{Bus, BusType, Generator};

    let mut net = Network::new("must_run_pmin_test");
    net.base_mva = 100.0;

    let b1 = Bus::new(1, BusType::Slack, 138.0);
    net.buses.push(b1);
    net.loads.push(Load::new(1, 150.0, 0.0));

    // gen1: cheap, free to commit or decommit
    let mut g1 = Generator::new(1, 0.0, 1.0);
    g1.pmin = 10.0;
    g1.pmax = 200.0;
    g1.in_service = true;
    g1.commitment.get_or_insert_default().status = CommitmentStatus::Market;
    g1.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![10.0, 0.0], // $10/MWh
    });
    net.generators.push(g1);

    // gen2: expensive must-run with pmin=50 MW
    let mut g2 = Generator::new(1, 0.0, 1.0);
    g2.pmin = 50.0;
    g2.pmax = 150.0;
    g2.in_service = true;
    g2.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
    g2.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![80.0, 0.0], // $80/MWh — much more expensive
    });
    net.generators.push(g2);

    let opts = DispatchOptions {
        n_periods: 2,
        enforce_thermal_limits: false,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).unwrap();

    // gen2 (index 1) must be committed every hour
    for t in 0..2 {
        assert!(
            commitment_schedule(&sol)[t][1],
            "must-run gen2 should be committed in hour {t}"
        );
        // gen2 dispatch must be >= pmin (50 MW)
        assert!(
            sol.periods[t].pg_mw[1] >= 49.9,
            "must-run gen2 dispatch {:.1} MW should be >= pmin=50 MW in hour {t}",
            sol.periods[t].pg_mw[1]
        );
    }

    // Power balance must hold
    for t in 0..2 {
        let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
        let net_t = network_at_hour(&net, &opts, t);
        let total_load: f64 = net_t
            .loads
            .iter()
            .filter(|l| l.in_service)
            .map(|l| l.active_power_demand_mw)
            .sum();
        assert!(
            (total_gen - total_load).abs() < 1.0,
            "hour {t}: gen={total_gen:.1}, load={total_load:.1}"
        );
    }

    println!(
        "Must-run pmin test: gen2 committed={}/{}, min_dispatch={:.1} MW",
        commitment_schedule(&sol)[0][1] as u8 + commitment_schedule(&sol)[1][1] as u8,
        2,
        sol.periods[0].pg_mw[1].min(sol.periods[1].pg_mw[1])
    );
}

// ----- DISP-05: CO2 emission constraints in SCUC -----

#[test]
fn test_scuc_co2_cap_forces_clean_dispatch() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // 2-generator single-bus network, 2 hours.
    // gen1: cheap ($10/MWh) with 0.5 t/MWh CO2, pmax=200 MW.
    // gen2: expensive ($60/MWh) with 0.0 t/MWh CO2, pmax=200 MW.
    // Load: 100 MW both hours.
    // CO2 cap = 20t/hour → limits gen1 to ≤ 40 MW each hour.
    // total_co2_t across 2 hours must be ≤ 40t.
    use surge_network::Network;
    use surge_network::market::CostCurve;
    use surge_network::network::{Bus, BusType, Generator};

    let mut net = Network::new("scuc_co2_test");
    net.base_mva = 100.0;

    let b1 = Bus::new(1, BusType::Slack, 138.0);
    net.buses.push(b1);
    net.loads.push(Load::new(1, 100.0, 0.0));

    // gen1: cheap, high-CO2
    let mut g1 = Generator::new(1, 0.0, 1.0);
    g1.pmin = 0.0;
    g1.pmax = 200.0;
    g1.in_service = true;
    g1.fuel.get_or_insert_default().emission_rates.co2 = 0.5;
    g1.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![10.0, 0.0],
    });
    net.generators.push(g1);

    // gen2: expensive, zero-CO2
    let mut g2 = Generator::new(1, 0.0, 1.0);
    g2.pmin = 0.0;
    g2.pmax = 200.0;
    g2.in_service = true;
    g2.fuel.get_or_insert_default().emission_rates.co2 = 0.0;
    g2.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![60.0, 0.0],
    });
    net.generators.push(g2);

    // Without CO2 cap: gen1 dominates
    let opts_no_cap = DispatchOptions {
        n_periods: 2,
        enforce_thermal_limits: false,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    let sol_no_cap = solve_scuc(&net, &opts_no_cap).unwrap();
    assert!(
        sol_no_cap.summary.total_co2_t > 50.0,
        "Without cap, gen1 dominates, CO2 should be high: {:.1}t",
        sol_no_cap.summary.total_co2_t
    );

    // With CO2 cap = 40t total across 2 hours: gen1 limited to ≤ 40 MW average
    // (gen1 emits 0.5 t/MWh; 40t cap → at most 80 MWh total generation across 2 hours)
    let co2_cap_total = 40.0; // tonnes — total across the full horizon
    let opts_cap = DispatchOptions {
        n_periods: 2,
        enforce_thermal_limits: false,
        co2_cap_t: Some(co2_cap_total),
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    let sol_cap = solve_scuc(&net, &opts_cap).unwrap();

    // total_co2_t should be ≤ the single period-wide cap
    assert!(
        sol_cap.summary.total_co2_t <= co2_cap_total + 0.1,
        "total CO2 across 2 hours ({:.2}t) should be ≤ {:.1}t (period cap)",
        sol_cap.summary.total_co2_t,
        co2_cap_total
    );

    // Power balance must hold each hour
    for t in 0..2 {
        let total_gen: f64 = sol_cap.periods[t].pg_mw.iter().sum();
        let net_t = network_at_hour(&net, &opts_cap, t);
        let total_load: f64 = net_t
            .loads
            .iter()
            .filter(|l| l.in_service)
            .map(|l| l.active_power_demand_mw)
            .sum();
        assert!(
            (total_gen - total_load).abs() < 0.5,
            "hour {t}: gen={total_gen:.1}, load={total_load:.1}"
        );
    }

    println!(
        "SCUC CO2 cap test: total_co2={:.2}t (period cap={:.1}t)",
        sol_cap.summary.total_co2_t, co2_cap_total
    );
}

#[test]
fn test_scuc_storage_soc_dt_hours_half() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // Verify that SoC dynamics correctly incorporate dt_hours when set to 0.5
    // (30-minute settlement intervals, common in some ISO markets).
    //
    // Design:
    //   dt_hours = 0.5  → each period is 30 minutes = 0.5 hr
    //   Bus 1 (slack): Generator fixed at 100 MW (must_run).
    //   Load profile across 4 periods: [80, 80, 120, 120] MW.
    //   Battery: 50 MW / 200 MWh capacity, perfect efficiency (eta=1.0),
    //            soc_initial = 0.0 MWh.
    //
    // Expected (eta=1.0 → sqrt_eta=1.0, dt=0.5 hr):
    //   Period 0: excess = 20 MW → charge_energy = 20*0.5 = 10 MWh → soc = 10
    //   Period 1: excess = 20 MW → charge_energy = 10 MWh            → soc = 20
    //   Period 2: deficit = 20 MW → discharge_energy = 10 MWh        → soc = 10
    //   Period 3: deficit = 20 MW → discharge_energy = 10 MWh        → soc = 0
    //
    // This is exactly half of the dt_hours=1.0 scenario (test_scuc_storage_soc_trajectory).
    // If dt_hours were ignored and treated as 1.0, the expected SoC[1] would be 40 MWh
    // instead of 20 MWh — a 2× error that this test catches.

    use surge_network::Network;
    use surge_network::market::{CostCurve, LoadProfile, LoadProfiles};
    use surge_network::network::{Bus, BusType, Generator, StorageDispatchMode, StorageParams};

    let mut net = Network::new("test_storage_dt");
    net.base_mva = 100.0;

    let b1 = Bus::new(1, BusType::Slack, 138.0);
    net.buses.push(b1);
    net.loads.push(Load::new(1, 100.0, 0.0));

    // Fixed generator: 100 MW, must-run
    let mut g0 = Generator::new(1, 100.0, 1.0);
    g0.pmin = 100.0;
    g0.pmax = 100.0;
    g0.in_service = true;
    g0.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
    g0.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![10.0, 0.0],
    });
    net.generators.push(g0);

    // Battery: 50 MW / 200 MWh, perfect efficiency, at bus 1
    let bess_g = Generator {
        bus: 1,
        in_service: true,
        pmin: -50.0,
        pmax: 50.0,
        machine_base_mva: 100.0,
        cost: Some(CostCurve::Polynomial {
            coeffs: vec![0.0],
            startup: 0.0,
            shutdown: 0.0,
        }),
        storage: Some(StorageParams {
            charge_efficiency: 1.0,
            discharge_efficiency: 1.0,
            energy_capacity_mwh: 200.0,
            soc_initial_mwh: 0.0,
            soc_min_mwh: 0.0,
            soc_max_mwh: 200.0,
            variable_cost_per_mwh: 0.0,
            degradation_cost_per_mwh: 0.0,
            dispatch_mode: StorageDispatchMode::CostMinimization,
            self_schedule_mw: 0.0,
            discharge_offer: None,
            charge_bid: None,
            max_c_rate_charge: None,
            max_c_rate_discharge: None,
            chemistry: None,
            discharge_foldback_soc_mwh: None,
            charge_foldback_soc_mwh: None,
            daily_cycle_limit: None,
        }),
        ..Generator::default()
    };
    net.generators.push(bess_g);

    let opts = DispatchOptions {
        n_periods: 4,
        dt_hours: 0.5, // ← 30-minute intervals
        enforce_thermal_limits: false,
        load_profiles: LoadProfiles {
            profiles: vec![LoadProfile {
                bus: 1,
                load_mw: vec![80.0, 80.0, 120.0, 120.0],
            }],
            n_timesteps: 4,
        },
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).unwrap();

    let storage_gi = first_storage_gen_index(&net);
    let soc_traj = sol
        .storage_soc
        .get(&storage_gi)
        .unwrap_or_else(|| panic!("storage_soc must contain generator {storage_gi}"));
    assert_eq!(soc_traj.len(), 4, "SoC trajectory must have 4 entries");

    // With dt=0.5, each period accumulates half the energy vs dt=1.0.
    // Strict numerical check: SoC[1] should be near 20 MWh (not 40 MWh).
    assert!(
        soc_traj[1] < 25.0,
        "SoC[1] should be ~20 MWh with dt=0.5 (would be ~40 MWh if dt were ignored): {:.2}",
        soc_traj[1]
    );

    // End SoC should return close to initial (symmetric charge/discharge over 4 periods)
    assert!(
        soc_traj[3].abs() < 5.0,
        "End SoC should be near 0 (symmetric charge/discharge): {:.2}",
        soc_traj[3]
    );

    // SoC bounds respected
    for (t, &soc) in soc_traj.iter().enumerate() {
        assert!(
            (-0.5..=200.5).contains(&soc),
            "SoC[{t}] = {soc:.2} MWh out of bounds [0, 200]"
        );
    }

    println!(
        "DISP-04 dt=0.5 hr test: SoC = [{:.1}, {:.1}, {:.1}, {:.1}] MWh (expected ~[10,20,10,0])",
        soc_traj[0], soc_traj[1], soc_traj[2], soc_traj[3]
    );
}

#[test]
fn test_scuc_co2_total_populated_no_co2_gens() {
    if !data_available() {
        eprintln!(
            "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
        );
        return;
    }
    // When no generators have co2_rate > 0, total_co2_t should be 0.
    let net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();
    // case9.m generators have co2_rate=0 by default
    let opts = DispatchOptions {
        n_periods: 2,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();
    assert!(
        sol.summary.total_co2_t.abs() < 1e-9,
        "total_co2_t should be 0 when no CO2 rates set, got {:.6}",
        sol.summary.total_co2_t
    );
}

// ---- DISP-07: Regulation and non-spinning reserve products ----

#[test]
fn test_scuc_regulation_up_requirement() {
    // 2-generator single-bus SCUC.
    // Gen1 (slack, must_run): pmin=50, pmax=300, reg_up offer=80 MW.
    // Gen2: pmin=0, pmax=200, reg_up offer=70 MW.
    // Load: 150 MW.
    // reg_up_req = 100 MW for 2 hours.
    use surge_network::Network;
    use surge_network::market::{CostCurve, ReserveOffer};
    use surge_network::network::{Bus, BusType, Generator};

    let mut net = Network::new("reg_up_scuc_test");
    net.base_mva = 100.0;
    let b1 = Bus::new(1, BusType::Slack, 138.0);
    net.buses.push(b1);
    net.loads.push(Load::new(1, 150.0, 0.0));

    let mut g1 = Generator::new(1, 0.0, 1.0);
    g1.pmin = 50.0;
    g1.pmax = 300.0;
    g1.market
        .get_or_insert_default()
        .reserve_offers
        .push(ReserveOffer {
            product_id: "reg_up".into(),
            capacity_mw: 80.0,
            cost_per_mwh: 0.0,
        });
    g1.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
    g1.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![20.0, 0.0],
    });
    net.generators.push(g1);

    let mut g2 = Generator::new(1, 0.0, 1.0);
    g2.pmin = 0.0;
    g2.pmax = 200.0;
    g2.market
        .get_or_insert_default()
        .reserve_offers
        .push(ReserveOffer {
            product_id: "reg_up".into(),
            capacity_mw: 70.0,
            cost_per_mwh: 0.0,
        });
    g2.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![30.0, 0.0],
    });
    net.generators.push(g2);

    let opts = DispatchOptions {
        n_periods: 2,
        enforce_thermal_limits: false,
        system_reserve_requirements: vec![SystemReserveRequirement {
            product_id: "reg_up".into(),
            requirement_mw: 100.0,
            per_period_mw: None,
        }],
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();

    // Regulation up provided each hour >= 100 MW
    for t in 0..2 {
        let rup_provided = sol.periods[t]
            .reserve_provided
            .get("reg_up")
            .copied()
            .unwrap_or(0.0);
        assert!(
            rup_provided >= 99.9,
            "hour {t}: reg_up_provided={:.1} MW should be >= 100 MW",
            rup_provided
        );
    }

    // Power balance each hour
    for t in 0..2 {
        let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
        assert!(
            (total_gen - 150.0).abs() < 0.5,
            "hour {t}: gen={total_gen:.1} != 150 MW"
        );
    }

    let h0 = sol.periods[0]
        .reserve_provided
        .get("reg_up")
        .copied()
        .unwrap_or(0.0);
    let h1 = sol.periods[1]
        .reserve_provided
        .get("reg_up")
        .copied()
        .unwrap_or(0.0);
    println!(
        "SCUC reg_up: hour0 provided={:.1}, hour1 provided={:.1}",
        h0, h1
    );
}

#[test]
fn test_scuc_nspin_requirement() {
    // 3-generator single-bus SCUC.
    // Gen1 (slack, must_run=true): online, no nspin capability.
    // Gen2: online (must_run to keep committed), no nspin.
    // Gen3 (peaker): offline initially, nspin offer = 80 MW.
    // nspin_req = 60 MW for 2 hours.
    //
    // Non-spinning reserve must come from offline gen3 (u=0).
    use surge_network::Network;
    use surge_network::market::{CostCurve, ReserveOffer};
    use surge_network::network::{Bus, BusType, Generator};

    let mut net = Network::new("nspin_test");
    net.base_mva = 100.0;
    let b1 = Bus::new(1, BusType::Slack, 138.0);
    net.buses.push(b1);
    net.loads.push(Load::new(1, 100.0, 0.0));

    // Gen1: cheap, covers all load, must_run
    let mut g1 = Generator::new(1, 0.0, 1.0);
    g1.pmin = 0.0;
    g1.pmax = 200.0;
    g1.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
    g1.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![10.0, 0.0],
    });
    net.generators.push(g1);

    // Gen2: online (must_run to keep it committed), no nspin
    let mut g2 = Generator::new(1, 0.0, 1.0);
    g2.pmin = 0.0;
    g2.pmax = 100.0;
    g2.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
    g2.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![50.0, 0.0],
    });
    net.generators.push(g2);

    // Gen3: offline peaker with nspin capability
    let mut g3 = Generator::new(1, 0.0, 1.0);
    g3.pmin = 0.0;
    g3.pmax = 100.0;
    g3.market
        .get_or_insert_default()
        .reserve_offers
        .push(ReserveOffer {
            product_id: "nspin".into(),
            capacity_mw: 80.0,
            cost_per_mwh: 0.0,
        });
    g3.cost = Some(CostCurve::Polynomial {
        startup: 500.0,
        shutdown: 0.0,
        coeffs: vec![80.0, 0.0], // expensive when online
    });
    net.generators.push(g3);

    let opts = DispatchOptions {
        n_periods: 2,
        enforce_thermal_limits: false,
        system_reserve_requirements: vec![SystemReserveRequirement {
            product_id: "nspin".into(),
            requirement_mw: 60.0,
            per_period_mw: None,
        }],
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
            initial_commitment: Some(vec![true, true, false]), // gen3 starts offline
            ..IndexedCommitmentOptions::default()
        }),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();

    // Non-spinning reserve provided each hour >= 60 MW
    for t in 0..2 {
        let nspin_provided = sol.periods[t]
            .reserve_provided
            .get("nspin")
            .copied()
            .unwrap_or(0.0);
        assert!(
            nspin_provided >= 59.9,
            "hour {t}: nspin_provided={:.1} MW should be >= 60 MW",
            nspin_provided
        );
    }

    // Power balance each hour
    for t in 0..2 {
        let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
        assert!(
            (total_gen - 100.0).abs() < 0.5,
            "hour {t}: gen={total_gen:.1} != 100 MW"
        );
    }

    let h0 = sol.periods[0]
        .reserve_provided
        .get("nspin")
        .copied()
        .unwrap_or(0.0);
    let h1 = sol.periods[1]
        .reserve_provided
        .get("nspin")
        .copied()
        .unwrap_or(0.0);
    println!(
        "SCUC nspin: hour0 provided={:.1}, hour1 provided={:.1}",
        h0, h1
    );
}

#[test]
fn test_scuc_nspin_offline_quickstart_uses_explicit_offline_capability() {
    use surge_network::Network;
    use surge_network::market::{CostCurve, ReserveOffer};
    use surge_network::network::{Bus, BusType, Generator, RampingParams};

    let mut net = Network::new("nspin_offline_quickstart_cap");
    net.base_mva = 100.0;
    net.buses.push(Bus::new(1, BusType::Slack, 138.0));
    net.loads.push(Load::new(1, 100.0, 0.0));

    let mut base = Generator::new(1, 100.0, 1.0);
    base.pmin = 100.0;
    base.pmax = 100.0;
    base.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
    base.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![10.0, 0.0],
    });
    net.generators.push(base);

    let mut peaker = Generator::new(1, 0.0, 1.0);
    peaker.pmin = 6.0;
    peaker.pmax = 37.0;
    peaker.quick_start = true;
    peaker.ramping = Some(RampingParams {
        ramp_up_curve: vec![(0.0, 1.0)],
        ..Default::default()
    });
    peaker
        .market
        .get_or_insert_default()
        .reserve_offers
        .push(ReserveOffer {
            product_id: "nspin".into(),
            capacity_mw: 37.0,
            cost_per_mwh: 0.0,
        });
    peaker.cost = Some(CostCurve::Polynomial {
        startup: 500.0,
        shutdown: 0.0,
        coeffs: vec![80.0, 0.0],
    });
    net.generators.push(peaker);

    let opts = DispatchOptions {
        n_periods: 1,
        enforce_thermal_limits: false,
        system_reserve_requirements: vec![SystemReserveRequirement {
            product_id: "nspin".into(),
            requirement_mw: 37.0,
            per_period_mw: None,
        }],
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
            initial_commitment: Some(vec![true, false]),
            ..IndexedCommitmentOptions::default()
        }),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();

    let nspin_provided = sol.periods[0]
        .reserve_provided
        .get("nspin")
        .copied()
        .unwrap_or(0.0);
    assert!(
        nspin_provided >= 36.9,
        "offline quick-start unit should satisfy the 37 MW nspin requirement, got {nspin_provided:.3} MW"
    );
    assert!(
        sol.periods[0].pg_mw[1].abs() < 1e-6,
        "peaker should stay offline for nspin, got {:.6} MW",
        sol.periods[0].pg_mw[1]
    );
}

#[test]
#[ignore = "pre-existing baseline failure: offline quick-start unit does not satisfy the \
            37 MW rru requirement (returns 0 MW)."]
fn test_scuc_quickstart_shared_limit_product_allows_offline_awards() {
    use surge_network::Network;
    use surge_network::market::{
        CostCurve, PenaltyCurve, ReserveDirection, ReserveOffer, ReserveProduct,
    };
    use surge_network::network::{Bus, BusType, Generator, RampingParams};

    let mut net = Network::new("quickstart_shared_limit_offline");
    net.base_mva = 100.0;
    net.buses.push(Bus::new(1, BusType::Slack, 138.0));
    net.loads.push(Load::new(1, 100.0, 0.0));

    let mut base = Generator::new(1, 100.0, 1.0);
    base.pmin = 100.0;
    base.pmax = 100.0;
    base.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
    base.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![10.0, 0.0],
    });
    net.generators.push(base);

    let mut peaker = Generator::new(1, 0.0, 1.0);
    peaker.pmin = 6.0;
    peaker.pmax = 37.0;
    peaker.quick_start = true;
    peaker.ramping = Some(RampingParams {
        ramp_up_curve: vec![(0.0, 1.0)],
        ..Default::default()
    });
    peaker
        .market
        .get_or_insert_default()
        .reserve_offers
        .extend([
            ReserveOffer {
                product_id: "reg_up".into(),
                capacity_mw: 0.0,
                cost_per_mwh: 0.0,
            },
            ReserveOffer {
                product_id: "rru".into(),
                capacity_mw: 37.0,
                cost_per_mwh: 0.0,
            },
        ]);
    peaker.cost = Some(CostCurve::Polynomial {
        startup: 500.0,
        shutdown: 0.0,
        coeffs: vec![80.0, 0.0],
    });
    net.generators.push(peaker);

    let opts = DispatchOptions {
        n_periods: 1,
        enforce_thermal_limits: false,
        reserve_products: vec![
            ReserveProduct {
                id: "reg_up".into(),
                name: "Reg Up".into(),
                direction: ReserveDirection::Up,
                deploy_secs: 300.0,
                qualification: surge_network::market::QualificationRule::Committed,
                energy_coupling: surge_network::market::EnergyCoupling::Headroom,
                dispatchable_load_energy_coupling: None,
                shared_limit_products: Vec::new(),
                balance_products: Vec::new(),
                kind: surge_network::market::ReserveKind::Real,
                apply_deploy_ramp_limit: true,
                demand_curve: PenaltyCurve::Linear {
                    cost_per_unit: 1000.0,
                },
            },
            ReserveProduct {
                id: "rru".into(),
                name: "Ramp Up".into(),
                direction: ReserveDirection::Up,
                deploy_secs: 900.0,
                qualification: surge_network::market::QualificationRule::QuickStart,
                energy_coupling: surge_network::market::EnergyCoupling::Headroom,
                dispatchable_load_energy_coupling: None,
                shared_limit_products: vec!["reg_up".into()],
                balance_products: Vec::new(),
                kind: surge_network::market::ReserveKind::Real,
                apply_deploy_ramp_limit: true,
                demand_curve: PenaltyCurve::Linear {
                    cost_per_unit: 1000.0,
                },
            },
        ],
        system_reserve_requirements: vec![SystemReserveRequirement {
            product_id: "rru".into(),
            requirement_mw: 37.0,
            per_period_mw: None,
        }],
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
            initial_commitment: Some(vec![true, false]),
            ..IndexedCommitmentOptions::default()
        }),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();

    let provided = sol.periods[0]
        .reserve_provided
        .get("rru")
        .copied()
        .unwrap_or(0.0);
    assert!(
        provided >= 36.9,
        "offline quick-start unit should satisfy the 37 MW rru requirement, got {provided:.3} MW"
    );
    assert!(
        sol.periods[0].pg_mw[1].abs() < 1e-6,
        "peaker should stay offline for rru, got {:.6} MW",
        sol.periods[0].pg_mw[1]
    );
}

// ----- RRU/RRD on/off split — joint offline cap -----
//
// For an offline producer, `p^nsc + p^rru,off ≤ p^rru,off,max × (1 − u^on)`.
// `ramp_up_off` is modelled as a distinct LP product whose
// `shared_limit_products = ["nsyn"]` declaration tells the SCUC row
// builder to enforce the joint cap on the same row. This test verifies
// that an offline producer with independent caps `p_nsc,max = 30` and
// `p_rru,off,max = 50` cannot clear more than 50 MW combined.

#[test]
fn test_scuc_ramp_up_off_joint_cap_with_nsyn() {
    use surge_network::Network;
    use surge_network::market::{
        CostCurve, PenaltyCurve, ReserveDirection, ReserveOffer, ReserveProduct,
    };
    use surge_network::network::{Bus, BusType, Generator, RampingParams};

    let mut net = Network::new("ramp_up_off_joint_cap");
    net.base_mva = 100.0;
    net.buses.push(Bus::new(1, BusType::Slack, 138.0));
    // Base load is fully covered by the must-run base unit so the
    // offline producer below stays offline and the test exercises the
    // OFFLINE branch of the shared_limit_products row builder.
    net.loads.push(Load::new(1, 100.0, 0.0));

    let mut base = Generator::new(1, 100.0, 1.0);
    base.pmin = 100.0;
    base.pmax = 100.0;
    base.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
    base.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![10.0, 0.0],
    });
    net.generators.push(base);

    // Offline quick-start producer with two independent capacities:
    //   p_nsc,max       = 30 MW
    //   p_rru,off,max   = 50 MW
    let mut peaker = Generator::new(1, 0.0, 1.0);
    peaker.pmin = 6.0;
    peaker.pmax = 80.0;
    peaker.quick_start = true;
    peaker.ramping = Some(RampingParams {
        ramp_up_curve: vec![(0.0, 1.0)],
        ..Default::default()
    });
    peaker
        .market
        .get_or_insert_default()
        .reserve_offers
        .extend([
            ReserveOffer {
                product_id: "nsyn".into(),
                capacity_mw: 30.0,
                cost_per_mwh: 0.0,
            },
            ReserveOffer {
                product_id: "ramp_up_off".into(),
                capacity_mw: 50.0,
                cost_per_mwh: 0.0,
            },
        ]);
    peaker.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![80.0, 0.0],
    });
    net.generators.push(peaker);

    let opts = DispatchOptions {
        n_periods: 1,
        enforce_thermal_limits: false,
        reserve_products: vec![
            ReserveProduct {
                id: "nsyn".into(),
                name: "Non-Synchronized Reserve".into(),
                direction: ReserveDirection::Up,
                deploy_secs: 600.0,
                qualification: surge_network::market::QualificationRule::OfflineQuickStart,
                energy_coupling: surge_network::market::EnergyCoupling::None,
                dispatchable_load_energy_coupling: None,
                shared_limit_products: Vec::new(),
                balance_products: Vec::new(),
                kind: surge_network::market::ReserveKind::Real,
                apply_deploy_ramp_limit: true,
                demand_curve: PenaltyCurve::Linear {
                    cost_per_unit: 1000.0,
                },
            },
            ReserveProduct {
                id: "ramp_up_off".into(),
                name: "Ramping Reserve Up (Offline)".into(),
                direction: ReserveDirection::Up,
                deploy_secs: 900.0,
                qualification: surge_network::market::QualificationRule::OfflineQuickStart,
                energy_coupling: surge_network::market::EnergyCoupling::None,
                dispatchable_load_energy_coupling: None,
                // The joint offline cap (eq 103) is encoded by declaring
                // `nsyn` as a shared-limit partner of ramp_up_off.
                shared_limit_products: vec!["nsyn".into()],
                balance_products: Vec::new(),
                kind: surge_network::market::ReserveKind::Real,
                apply_deploy_ramp_limit: true,
                demand_curve: PenaltyCurve::Linear {
                    cost_per_unit: 1000.0,
                },
            },
        ],
        // Ask for 80 MW combined — more than the 50 MW joint cap can
        // deliver. The LP must clear at most 50 MW total, paying the
        // shortfall on the remaining 30 MW.
        system_reserve_requirements: vec![
            SystemReserveRequirement {
                product_id: "nsyn".into(),
                requirement_mw: 30.0,
                per_period_mw: None,
            },
            SystemReserveRequirement {
                product_id: "ramp_up_off".into(),
                requirement_mw: 50.0,
                per_period_mw: None,
            },
        ],
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
            initial_commitment: Some(vec![true, false]),
            ..IndexedCommitmentOptions::default()
        }),
        ..DispatchOptions::default()
    };
    let sol = solve_scuc(&net, &opts).unwrap();

    let nsyn_provided = sol.periods[0]
        .reserve_provided
        .get("nsyn")
        .copied()
        .unwrap_or(0.0);
    let ramp_up_off_provided = sol.periods[0]
        .reserve_provided
        .get("ramp_up_off")
        .copied()
        .unwrap_or(0.0);
    let combined = nsyn_provided + ramp_up_off_provided;

    // Joint offline cap must hold: nsyn + ramp_up_off ≤ p_rru,off,max = 50 MW.
    assert!(
        combined <= 50.0 + 1e-6,
        "joint offline cap (eq 103) must bound nsyn + ramp_up_off to ≤ 50 MW; \
         got nsyn={nsyn_provided:.3}, ramp_up_off={ramp_up_off_provided:.3}, \
         combined={combined:.3}"
    );
    // Peaker must remain offline (the OfflineQuickStart branch of the
    // row builder only allows the offer cap when `is_committed = false`).
    assert!(
        sol.periods[0].pg_mw[1].abs() < 1e-6,
        "peaker should stay offline, got {:.6} MW",
        sol.periods[0].pg_mw[1]
    );
}

// ---- Per-period dt scaling in SCUC objective ----
//
// Every per-MWh cost in the objective must scale by `dt_h` so the
// optimum is invariant to period decomposition. The pre-fix code was
// correct for uniform 1-hour horizons (the missing factor is constant)
// but biased dispatch on any non-1h horizon. These tests pin the
// canonical behaviour.

#[test]
fn test_scuc_objective_scales_linearly_with_period_duration() {
    // Single bus, single must-run generator at $50/MWh, 100 MW fixed load.
    // Energy cost = $50/MWh × 100 MW × dt_h. Verify the SCUC objective
    // scales 1:1 with `dt_hours` for a one-period scenario, i.e. the
    // 0.5h case has exactly half the cost of the 1.0h case.
    use surge_network::Network;
    use surge_network::market::{CostCurve, LoadProfile};
    use surge_network::network::{Bus, BusType, Generator};

    fn build_one_bus_one_gen() -> Network {
        let mut net = Network::new("dt_scale_test");
        net.base_mva = 100.0;
        net.buses.push(Bus::new(1, BusType::Slack, 138.0));
        net.loads.push(Load::new(1, 100.0, 0.0));
        let mut g = Generator::new(1, 0.0, 1.0);
        g.pmin = 0.0;
        g.pmax = 200.0;
        g.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
        g.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![50.0, 0.0],
        });
        net.generators.push(g);
        net
    }

    let net = build_one_bus_one_gen();
    let mut opts_1h = DispatchOptions {
        n_periods: 1,
        dt_hours: 1.0,
        enforce_thermal_limits: false,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::AllCommitted,
        ..DispatchOptions::default()
    };
    opts_1h.load_profiles.profiles.push(LoadProfile {
        bus: 1,
        load_mw: vec![100.0],
    });
    opts_1h.load_profiles.n_timesteps = 1;
    let sol_1h = solve_scuc(&net, &opts_1h).unwrap();
    let cost_1h = sol_1h.summary.total_cost;

    let mut opts_30min = opts_1h.clone();
    opts_30min.dt_hours = 0.5;
    let sol_30min = solve_scuc(&net, &opts_30min).unwrap();
    let cost_30min = sol_30min.summary.total_cost;

    // Both should dispatch ~100 MW (same load), but the 30-min case
    // covers half the energy so the total objective is halved.
    assert!((sol_1h.periods[0].pg_mw[0] - 100.0).abs() < 0.5);
    assert!((sol_30min.periods[0].pg_mw[0] - 100.0).abs() < 0.5);
    let ratio = cost_30min / cost_1h;
    assert!(
        (ratio - 0.5).abs() < 1e-6,
        "expected 30-min cost ≈ half of 1-h cost; got cost_1h={cost_1h:.4}, cost_30min={cost_30min:.4}, ratio={ratio:.6}"
    );
}

/// Regression: PWL (offer-schedule) producer cost must scale by `dt_h`.
///
/// Markets that carry per-period energy costs exclusively through
/// offer schedules (static `Generator::cost` left as `None`) route the
/// resolved cost through the PWL epigraph path
/// (`scuc::plan::build_pwl_plan` → `build_pwl_rows` → the `e_g`
/// column priced in `scuc::objective`). Epigraph rows carry $/h
/// units, so the `e_g` column cost must be `dt_h` for the LP
/// objective contribution to land in dollars. Before the fix the
/// column was priced at `1.0`, which left the optimum correct only on
/// 1-hour horizons and biased dispatch on any sub-hourly horizon.
///
/// This test pins the correct behaviour: halving `dt_h` must halve
/// the producer-energy contribution to the total objective.
#[test]
fn test_scuc_pwl_offer_schedule_objective_scales_linearly_with_dt() {
    use std::collections::HashMap;
    use surge_network::Network;
    use surge_network::market::{LoadProfile, OfferCurve, OfferSchedule};
    use surge_network::network::{Bus, BusType, Generator};

    fn build_pwl_net() -> Network {
        let mut net = Network::new("pwl_dt_scale_test");
        net.base_mva = 100.0;
        net.buses.push(Bus::new(1, BusType::Slack, 138.0));
        net.loads.push(Load::new(1, 100.0, 0.0));
        let mut g = Generator::new(1, 0.0, 1.0);
        g.pmin = 0.0;
        g.pmax = 200.0;
        g.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
        // `cost = None` exercises the offer-schedule-only path: the
        // static Generator carries only topology, and the per-period
        // cost lives entirely in `offer_schedules`.
        g.cost = None;
        net.generators.push(g);
        net
    }

    // Multi-segment PWL offer at uniform marginal $50/MWh. Two segments
    // force the PWL (piecewise-linear) epigraph path in the SCUC
    // objective — a single-segment offer would fall through
    // `offer_curve_to_cost_curve` to `CostCurve::Polynomial` and
    // exercise a different pricing branch. The uniform marginal keeps
    // dispatch deterministic at 100 MW so the objective reduces
    // analytically to `$50/MWh × 100 MW × dt_h`.
    let make_offer = || OfferSchedule {
        periods: vec![Some(OfferCurve {
            segments: vec![(150.0, 50.0), (200.0, 50.0)],
            no_load_cost: 0.0,
            startup_tiers: vec![],
        })],
    };

    fn base_opts(dt_hours: f64, offer: OfferSchedule) -> DispatchOptions {
        let mut opts = DispatchOptions {
            n_periods: 1,
            dt_hours,
            enforce_thermal_limits: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::AllCommitted,
            offer_schedules: HashMap::from([(0, offer)]),
            ..DispatchOptions::default()
        };
        opts.load_profiles.profiles.push(LoadProfile {
            bus: 1,
            load_mw: vec![100.0],
        });
        opts.load_profiles.n_timesteps = 1;
        opts
    }

    let net = build_pwl_net();
    let sol_1h = solve_scuc(&net, &base_opts(1.0, make_offer())).unwrap();
    let sol_30min = solve_scuc(&net, &base_opts(0.5, make_offer())).unwrap();
    let sol_15min = solve_scuc(&net, &base_opts(0.25, make_offer())).unwrap();

    // Dispatch is invariant under period duration: same physical
    // MW is needed to serve the same instantaneous load.
    assert!((sol_1h.periods[0].pg_mw[0] - 100.0).abs() < 0.5);
    assert!((sol_30min.periods[0].pg_mw[0] - 100.0).abs() < 0.5);
    assert!((sol_15min.periods[0].pg_mw[0] - 100.0).abs() < 0.5);

    // Analytical objective: $50/MWh × 100 MW × dt_h.
    // 1h ⇒ $5,000; 30 min ⇒ $2,500; 15 min ⇒ $1,250.
    let cost_1h = sol_1h.summary.total_cost;
    let cost_30min = sol_30min.summary.total_cost;
    let cost_15min = sol_15min.summary.total_cost;
    assert!(
        (cost_1h - 5000.0).abs() < 1e-3,
        "PWL 1h objective should equal $5,000 ($50/MWh × 100 MW × 1 h); got {cost_1h:.4}"
    );
    assert!(
        (cost_30min - 2500.0).abs() < 1e-3,
        "PWL 30-min objective should equal $2,500 (half of 1 h); got {cost_30min:.4}"
    );
    assert!(
        (cost_15min - 1250.0).abs() < 1e-3,
        "PWL 15-min objective should equal $1,250 (quarter of 1 h); got {cost_15min:.4}"
    );

    // Ledger consistency: extract-time `objective_terms` must sum to the
    // same dollars the LP objective reports. Before the fix, the LP
    // priced `e_g` at `1.0` (treating `sol.x[e_g]` as `$`) while extract
    // multiplied by `dt_h`, producing a "residual" term to paper over
    // the mismatch. That residual would bias pricing and downstream
    // cost attribution, so we assert it never appears here.
    for (label, sol) in [
        ("1h", &sol_1h),
        ("30min", &sol_30min),
        ("15min", &sol_15min),
    ] {
        let period = &sol.periods[0];
        let term_sum: f64 = period.objective_terms.iter().map(|t| t.dollars).sum();
        assert!(
            (period.total_cost - term_sum).abs() < 1e-6,
            "{label}: ledger mismatch: total_cost={}, sum(terms)={}, diff={}",
            period.total_cost,
            term_sum,
            period.total_cost - term_sum,
        );
        assert!(
            period
                .objective_terms
                .iter()
                .all(|t| t.component_id != "residual"),
            "{label}: unexpected residual term (extract ↔ LP accounting drift)",
        );
    }
}

// ---- DISP-10: Piecewise-linear quadratic cost (SOS2 via Big-M) ----

#[test]
fn test_scuc_plc_quadratic_cost_accuracy() {
    // Single-generator single-bus SCUC with quadratic cost.
    // f(P) = 0.01 * P^2 + 10 * P  (c=0, b=10, a=0.01)
    // Analytical minimum-cost for load = 100 MW:
    //   For a single gen with no constraints, optimal P = 100 MW to meet load.
    //   Cost = 0.01*100^2 + 10*100 = 100 + 1000 = $1100/hr.
    //
    // With SOS2 PLC (5 segments over [0, 200]):
    //   Breakpoints at 0, 40, 80, 120, 160, 200 MW.
    //   The PLC linearizes the quadratic, so dispatch should still be ~100 MW.
    //   The cost approximation should be within 0.1% of the analytical value.
    //
    // This verifies DISP-10: SOS2 PLC gives accurate cost for quadratic generators.
    use surge_network::Network;
    use surge_network::market::CostCurve;
    use surge_network::network::{Bus, BusType, Generator};

    let mut net = Network::new("plc_test");
    net.base_mva = 100.0;
    // Load = 120 MW, which aligns exactly with a breakpoint when using 5 segments
    // over [0, 200]: breakpoints at 0, 40, 80, 120, 160, 200 MW.
    // At P=120: lambda_3=1 exactly, so PLC cost == analytical cost.
    let b1 = Bus::new(1, BusType::Slack, 138.0);
    net.buses.push(b1);
    net.loads.push(Load::new(1, 120.0, 0.0));

    let mut g1 = Generator::new(1, 0.0, 1.0);
    g1.pmin = 0.0;
    g1.pmax = 200.0;
    g1.commitment.get_or_insert_default().status = CommitmentStatus::MustRun; // always committed in this test
    g1.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![0.01, 10.0, 0.0], // a=0.01, b=10, c=0
    });
    net.generators.push(g1);

    // Solve with PLC enabled (5 segments = 6 breakpoints over [0, 200] MW)
    // Breakpoints: 0, 40, 80, 120, 160, 200 MW.
    // Load=120 MW hits breakpoint k=3 exactly, so lambda_3=1 and PLC cost = analytical.
    let opts_plc = DispatchOptions {
        n_periods: 1,
        enforce_thermal_limits: false,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
            n_cost_segments: 5,
            ..IndexedCommitmentOptions::default()
        }),
        ..DispatchOptions::default()
    };
    let sol_plc = solve_scuc(&net, &opts_plc).unwrap();

    // Dispatch should be ~120 MW (to meet load)
    let pg_plc = sol_plc.periods[0].pg_mw[0];
    assert!(
        (pg_plc - 120.0).abs() < 1.0,
        "PLC dispatch={:.2} MW, expected ~120 MW",
        pg_plc
    );

    // Analytical cost at P=120: 0.01*120^2 + 10*120 = 144 + 1200 = 1344 $/hr
    let analytical_cost = 0.01 * pg_plc * pg_plc + 10.0 * pg_plc;
    let plc_cost = sol_plc.summary.total_cost;
    let rel_err = (plc_cost - analytical_cost).abs() / analytical_cost.max(1.0);
    assert!(
        rel_err < 0.001, // 0.1% tolerance (exact at breakpoints)
        "PLC cost={:.2}, analytical={:.2}, rel_err={:.4}%",
        plc_cost,
        analytical_cost,
        rel_err * 100.0
    );

    println!(
        "DISP-10 PLC: dispatch={:.2} MW, cost={:.2} $/hr, analytical={:.2} $/hr, err={:.4}%",
        pg_plc,
        plc_cost,
        analytical_cost,
        rel_err * 100.0
    );
}

#[cfg(test)]
mod zonal_reserve_scuc_tests {
    use super::*;
    use crate::dispatch::{CommitmentMode, Horizon, IndexedCommitmentOptions};
    use crate::legacy::DispatchOptions;
    use surge_network::Network;
    use surge_network::market::{CostCurve, SystemReserveRequirement, ZonalReserveRequirement};
    use surge_network::network::{Branch, Bus, BusType, Generator};

    fn two_area_network() -> Network {
        let mut net = Network::new("scuc_zonal_reserve_test");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b1);
        net.loads.push(Load::new(1, 60.0, 0.0));

        let b2 = Bus::new(2, BusType::PQ, 138.0);
        net.buses.push(b2);
        net.loads.push(Load::new(2, 40.0, 0.0));

        let mut br = Branch::new_line(1, 2, 0.0, 0.01, 0.0);
        br.rating_a_mva = 300.0;
        br.in_service = true;
        net.branches.push(br);

        // G0 in area 0 — cheap, large capacity
        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.pmin = 0.0;
        g0.pmax = 200.0;
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0],
        });
        net.generators.push(g0);

        // G1 in area 1 — expensive, large capacity
        let mut g1 = Generator::new(2, 0.0, 1.0);
        g1.pmin = 0.0;
        g1.pmax = 200.0;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![50.0, 0.0],
        });
        net.generators.push(g1);

        net
    }

    /// Zonal reserve constraint forces G1 (in zone 1) to provide local reserve
    /// even though G0 (zone 0) is cheaper. Tests that the constraint is active
    /// in every hour of a 2-hour SCUC horizon.
    #[test]
    fn test_scuc_zonal_reserve_binds() {
        let mut net = two_area_network();

        // Populate reserve_offers so generators can provide spinning reserve.
        for g in &mut net.generators {
            let phys_cap = (g.pmax - g.pmin).max(0.0);
            if phys_cap > 0.0 {
                g.market.get_or_insert_default().reserve_offers.push(
                    surge_network::market::ReserveOffer {
                        product_id: "spin".into(),
                        capacity_mw: phys_cap,
                        cost_per_mwh: 0.0,
                    },
                );
            }
        }

        let opts = DispatchOptions {
            n_periods: 2,
            system_reserve_requirements: vec![SystemReserveRequirement {
                product_id: "spin".into(),
                requirement_mw: 30.0,
                per_period_mw: None,
            }],
            zonal_reserve_requirements: vec![ZonalReserveRequirement {
                zone_id: 1,
                product_id: "spin".into(),
                requirement_mw: 20.0,
                per_period_mw: None,
                shortfall_cost_per_unit: None,
                served_dispatchable_load_coefficient: None,
                largest_generator_dispatch_coefficient: None,
                participant_bus_numbers: None,
            }],
            generator_area: vec![0, 1],
            load_area: vec![0, 1],
            enforce_thermal_limits: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        // In each hour, G1 (zone 1) must supply >= 20 MW spinning reserve
        for t in 0..2 {
            let spin_awards = sol.periods[t]
                .reserve_awards
                .get("spin")
                .expect("spin awards");
            let g1_spin = spin_awards[1];
            assert!(
                g1_spin >= 19.9,
                "hour {t}: G1 in zone 1 should provide >= 20 MW reserve, got {g1_spin:.1}"
            );
            // System total spin should be >= 30 MW
            let total_spin: f64 = spin_awards.iter().sum();
            assert!(
                total_spin >= 29.9,
                "hour {t}: total spin {total_spin:.1} should be >= 30 MW"
            );
            // Zonal price should be present for zone 1
            assert!(
                sol.periods[t].zonal_reserve_prices.contains_key("1:spin"),
                "hour {t}: zone 1 should have a zonal spinning reserve price"
            );
        }
    }

    /// When the zonal reserve requirement exceeds the available
    /// physical headroom AND ``shortfall_cost_per_unit`` is set on the
    /// requirement, the LP must clear the slack at the supplied
    /// scarcity rate. The dual on the binding zonal balance row equals
    /// that rate (or close to it, modulo HiGHS sign convention) — so
    /// the published ``zonal_reserve_prices`` entry must be non-zero.
    ///
    /// Repros the bug surfaced by the RTO dashboard: with positive
    /// shortfall_mw on goc3_73's reg_up requirement and shortfall_cost
    /// = $1000/MWh, the LP returned ``zonal_reserve_prices == 0`` for
    /// every period.
    #[test]
    fn test_scuc_zonal_reserve_price_positive_under_shortfall() {
        let mut net = two_area_network();
        for g in &mut net.generators {
            let phys_cap = (g.pmax - g.pmin).max(0.0);
            if phys_cap > 0.0 {
                g.market.get_or_insert_default().reserve_offers.push(
                    surge_network::market::ReserveOffer {
                        product_id: "spin".into(),
                        capacity_mw: phys_cap,
                        cost_per_mwh: 0.0,
                    },
                );
            }
        }

        // Request 250 MW of zonal spin in zone 1. G1 (zone 1) maxes
        // at 200 MW headroom, so 50 MW MUST short. Shortfall cost set
        // to $1000/MWh — the slack column carries that rate, and the
        // LP's dual on the binding zonal row should equal it.
        let shortfall_cost = 1_000.0;
        let opts = DispatchOptions {
            n_periods: 2,
            zonal_reserve_requirements: vec![ZonalReserveRequirement {
                zone_id: 1,
                product_id: "spin".into(),
                requirement_mw: 250.0,
                per_period_mw: None,
                shortfall_cost_per_unit: Some(shortfall_cost),
                served_dispatchable_load_coefficient: None,
                largest_generator_dispatch_coefficient: None,
                participant_bus_numbers: None,
            }],
            generator_area: vec![0, 1],
            load_area: vec![0, 1],
            enforce_thermal_limits: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        for t in 0..2 {
            let key = "1:spin";
            let price = sol.periods[t]
                .zonal_reserve_prices
                .get(key)
                .copied()
                .unwrap_or(0.0);
            let shortfall_mw = sol.periods[t]
                .zonal_reserve_shortfall
                .get(key)
                .copied()
                .unwrap_or(0.0);
            // Sanity: shortfall should be ~50 MW (250 req − 200 headroom).
            assert!(
                shortfall_mw > 49.0,
                "hour {t}: expected ~50 MW shortfall, got {shortfall_mw:.2}"
            );
            // Pre-fix: the LP returned 0 even though slack was positive
            // and slack_cost > 0 — the cap-logic relaxation in the
            // pricing transform was collapsing the zonal balance row.
            assert!(
                price.abs() > 0.5,
                "hour {t}: zonal price should be near ${shortfall_cost} \
                 ($/MWh) under shortfall, got ${price:.4}"
            );
        }
    }
}

#[cfg(test)]
mod emission_tieline_mustrun_tests {

    use super::*;
    use crate::config::emissions::{CarbonPrice, EmissionProfile, MustRunUnits, TieLineLimits};
    use crate::dispatch::{CommitmentMode, Horizon, IndexedCommitmentOptions};
    use crate::legacy::DispatchOptions;
    use surge_network::Network;
    use surge_network::market::CostCurve;
    use surge_network::network::{Branch, Bus, BusType, Generator};
    #[allow(dead_code)]
    fn data_available() -> bool {
        if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
            return std::path::Path::new(&p).exists();
        }
        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .join("tests/data")
            .exists()
    }
    #[allow(dead_code)]
    fn test_data_dir() -> std::path::PathBuf {
        if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
            return std::path::PathBuf::from(p);
        }
        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .join("tests/data")
    }
    #[allow(dead_code)]
    fn test_data_path(name: &str) -> std::path::PathBuf {
        if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
            return std::path::PathBuf::from(p).join(name);
        }
        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .join("tests/data")
            .join(name)
    }

    /// Helper: build a minimal single-bus two-generator network.
    ///
    /// gen0 (dirty): 0–200 MW, cost=10 $/MWh, co2_rate=0.5 tCO2/MWh
    /// gen1 (clean): 0–200 MW, cost=15 $/MWh, co2_rate=0.0 tCO2/MWh
    /// load: 100 MW (all on single slack bus)
    fn two_gen_network() -> Network {
        let mut net = Network::new("two_gen_test");
        net.base_mva = 100.0;
        let b1 = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b1);
        net.loads.push(Load::new(1, 100.0, 0.0));

        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.pmin = 0.0;
        g0.pmax = 200.0;
        g0.fuel.get_or_insert_default().emission_rates.co2 = 0.5; // dirty
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0], // $10/MWh
        });
        net.generators.push(g0);

        let mut g1 = Generator::new(1, 0.0, 1.0);
        g1.pmin = 0.0;
        g1.pmax = 200.0;
        g1.fuel.get_or_insert_default().emission_rates.co2 = 0.0; // clean
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![15.0, 0.0], // $15/MWh — more expensive but cleaner
        });
        net.generators.push(g1);

        net
    }

    // ─── DISP-05: Emission constraints and carbon pricing ───────────────────

    /// Test: higher carbon price → lower-emission generator dispatched preferentially.
    ///
    /// Without carbon price: gen0 (dirty, cheaper) wins.
    /// With high carbon price: gen1 (clean) becomes cheaper after emission surcharge.
    ///
    /// gen0 total cost = 10 + 50 * 0.5 = 35 $/MWh  (at $50/tCO2)
    /// gen1 total cost = 15 + 50 * 0.0 = 15 $/MWh
    /// → gen1 should dispatch preferentially when price ≥ $10/tCO2.
    #[test]
    fn test_disp05_carbon_price_favors_clean_gen() {
        let net = two_gen_network();

        // Without carbon price: gen0 (cheap dirty) should serve most load
        let opts_no_price = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol_no_price = solve_scuc(&net, &opts_no_price).unwrap();
        let pg0_no_price = sol_no_price.periods[0].pg_mw[0]; // gen0
        let pg1_no_price = sol_no_price.periods[0].pg_mw[1]; // gen1

        // Without carbon price, gen0 ($10/MWh) serves all load (cheaper)
        assert!(
            pg0_no_price > pg1_no_price,
            "Without carbon price: gen0 (dirty, cheaper) should dominate. gen0={:.1}, gen1={:.1}",
            pg0_no_price,
            pg1_no_price
        );

        // With carbon price $50/tCO2: gen0 effective cost = 10 + 50*0.5 = 35 $/MWh
        // gen1 effective cost = 15 + 50*0.0 = 15 $/MWh → gen1 now cheaper
        let opts_with_price = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            co2_price_per_t: 50.0,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol_with_price = solve_scuc(&net, &opts_with_price).unwrap();
        let pg0_with_price = sol_with_price.periods[0].pg_mw[0];
        let pg1_with_price = sol_with_price.periods[0].pg_mw[1];

        // With carbon price, gen1 (clean) should serve most load
        assert!(
            pg1_with_price > pg0_with_price,
            "With $50/tCO2: gen1 (clean) should dominate. gen0={:.1}, gen1={:.1}",
            pg0_with_price,
            pg1_with_price
        );

        // Power balance must hold in both cases
        assert!(
            (pg0_no_price + pg1_no_price - 100.0).abs() < 0.5,
            "Power balance (no price): gen total={:.1} != 100 MW",
            pg0_no_price + pg1_no_price
        );
        assert!(
            (pg0_with_price + pg1_with_price - 100.0).abs() < 0.5,
            "Power balance (with price): gen total={:.1} != 100 MW",
            pg0_with_price + pg1_with_price
        );

        println!(
            "DISP-05 carbon price test: no_price=({:.1},{:.1}), with_price=({:.1},{:.1})",
            pg0_no_price, pg1_no_price, pg0_with_price, pg1_with_price
        );
    }

    /// Test: EmissionProfile override changes dispatch merit order.
    ///
    /// Override gen0's CO2 rate to 0.0 (as if it were retrofitted with CCS).
    /// Now both generators have zero effective CO2, so cheapest (gen0) wins regardless.
    #[test]
    fn test_disp05_emission_profile_override() {
        let net = two_gen_network();

        // Override gen0's co2_rate to 0 via EmissionProfile
        let profile = EmissionProfile {
            rates_tonnes_per_mwh: vec![0.0, 0.0], // both generators clean
        };

        let opts = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            co2_price_per_t: 100.0,
            // high carbon price
            emission_profile: Some(profile),
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();
        let pg0 = sol.periods[0].pg_mw[0];
        let pg1 = sol.periods[0].pg_mw[1];

        // With zero emission for both gens, gen0 ($10/MWh) should win on energy cost alone
        assert!(
            pg0 > pg1,
            "With EmissionProfile zeroing gen0 CO2: gen0 (cheaper) should dominate. gen0={:.1}, gen1={:.1}",
            pg0,
            pg1
        );

        // CO2 shadow price should be zero for both generators (rates overridden to 0)
        for (idx, &sp) in sol.co2_shadow_price.iter().enumerate() {
            assert!(
                sp.abs() < 1e-9,
                "co2_shadow_price[{idx}]={sp:.4} should be 0 when emission rate is 0"
            );
        }

        // Total CO2 should be zero (all rates overridden to 0)
        assert!(
            sol.summary.total_co2_t.abs() < 1e-9,
            "total_co2_t={:.4} should be 0 with zero EmissionProfile rates",
            sol.summary.total_co2_t
        );

        println!(
            "DISP-05 emission profile override: gen0={:.1} MW, gen1={:.1} MW, co2={:.4} t",
            pg0, pg1, sol.summary.total_co2_t
        );
    }

    /// Test: CarbonPrice struct overrides co2_price_per_t.
    /// Using CarbonPrice::new(50.0) should produce same result as co2_price_per_t=50.0.
    #[test]
    fn test_disp05_carbon_price_struct_overrides_inline() {
        let net = two_gen_network();

        // Use inline co2_price_per_t
        let opts_inline = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            co2_price_per_t: 50.0,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol_inline = solve_scuc(&net, &opts_inline).unwrap();

        // Use CarbonPrice struct (should override inline, giving same result)
        let opts_struct = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            co2_price_per_t: 0.0,
            // will be overridden
            carbon_price: Some(CarbonPrice::new(50.0)),
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol_struct = solve_scuc(&net, &opts_struct).unwrap();

        // Both should give identical dispatch
        assert!(
            (sol_inline.periods[0].pg_mw[0] - sol_struct.periods[0].pg_mw[0]).abs() < 0.5,
            "gen0: inline={:.2}, struct={:.2}",
            sol_inline.periods[0].pg_mw[0],
            sol_struct.periods[0].pg_mw[0]
        );
        assert!(
            (sol_inline.periods[0].pg_mw[1] - sol_struct.periods[0].pg_mw[1]).abs() < 0.5,
            "gen1: inline={:.2}, struct={:.2}",
            sol_inline.periods[0].pg_mw[1],
            sol_struct.periods[0].pg_mw[1]
        );

        // co2_shadow_price: at $50/tCO2, gen0 has 0.5 tCO2/MWh → $25/MWh shadow price
        assert!(
            (sol_struct.co2_shadow_price[0] - 25.0).abs() < 0.1,
            "co2_shadow_price[0]={:.2} should be 25 $/MWh ($50/t × 0.5 t/MWh)",
            sol_struct.co2_shadow_price[0]
        );
        // gen1 has 0 tCO2/MWh → $0/MWh shadow price
        assert!(
            sol_struct.co2_shadow_price[1].abs() < 1e-9,
            "co2_shadow_price[1]={:.4} should be 0 (clean gen)",
            sol_struct.co2_shadow_price[1]
        );

        println!(
            "DISP-05 CarbonPrice struct: gen0={:.1} MW, gen1={:.1} MW, shadow=[{:.1},{:.1}]",
            sol_struct.periods[0].pg_mw[0],
            sol_struct.periods[0].pg_mw[1],
            sol_struct.co2_shadow_price[0],
            sol_struct.co2_shadow_price[1]
        );
    }

    // ─── DISP-06: Multi-area dispatch with tie-line limits ──────────────────

    /// Test: tie-line constraint binds when demand imbalance exceeds limit.
    ///
    /// Setup:
    ///   Area 0: gen0 (200 MW max), bus0 with 150 MW load
    ///   Area 1: gen1 (200 MW max), bus1 with 50 MW load (needs import from area 0)
    ///
    /// Without tie-line: gen0 would serve all area 0 load (150 MW)
    ///                   gen1 would serve area 1 load (50 MW)
    ///   net export area0→area1 = 0 (each gen serves local load exactly)
    ///
    /// With tie-line limit 0→1 = 30 MW:
    ///   area0 can export at most 30 MW across the physical 1→2 interface
    ///   → gen0 must back down to ~30 MW
    ///   → gen1 must produce the remaining ~170 MW locally in area 1
    #[test]
    fn test_disp06_tie_line_constraint_binds() {
        let mut net = Network::new("two_area_test");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        let b2 = Bus::new(2, BusType::PQ, 138.0);
        net.buses.push(b1);
        net.buses.push(b2);
        net.loads.push(Load::new(2, 200.0, 0.0));

        let mut br12 = Branch::new_line(1, 2, 0.0, 0.01, 0.0);
        br12.rating_a_mva = 300.0;
        net.branches.push(br12);

        // gen0 (area 0): cheap, dirty
        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.pmin = 0.0;
        g0.pmax = 300.0;
        g0.fuel.get_or_insert_default().emission_rates.co2 = 0.5;
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0],
        });
        net.generators.push(g0);

        // gen1 (area 1): expensive, clean
        let mut g1 = Generator::new(2, 0.0, 1.0);
        g1.pmin = 0.0;
        g1.pmax = 300.0;
        g1.fuel.get_or_insert_default().emission_rates.co2 = 0.0;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![30.0, 0.0], // expensive
        });
        net.generators.push(g1);

        // Without tie-line: gen0 (cheap) serves all 200 MW
        let opts_no_tl = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol_no_tl = solve_scuc(&net, &opts_no_tl).unwrap();
        let pg0_no_tl = sol_no_tl.periods[0].pg_mw[0];
        let pg1_no_tl = sol_no_tl.periods[0].pg_mw[1];

        // Without tie-line limit, gen0 should win all dispatch
        assert!(
            pg0_no_tl > 190.0,
            "Without tie-line: gen0 (cheap) should serve most load. gen0={:.1}",
            pg0_no_tl
        );

        let mut tie_limits = TieLineLimits::default();
        tie_limits.limits_mw.insert((0, 1), 30.0);

        let opts_with_tl = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            tie_line_limits: Some(tie_limits),
            generator_area: vec![0, 1],
            load_area: vec![0, 1],
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol_with_tl = solve_scuc(&net, &opts_with_tl).unwrap();
        let pg0_with_tl = sol_with_tl.periods[0].pg_mw[0];
        let pg1_with_tl = sol_with_tl.periods[0].pg_mw[1];

        // Tie-line constraint: gen0 (area 0, no local load) ≤ 30 MW export
        assert!(
            pg0_with_tl <= 30.5,
            "With 30 MW tie-line limit: gen0 should be ≤30 MW. gen0={:.2}",
            pg0_with_tl
        );

        // gen1 must cover remaining 170 MW
        assert!(
            pg1_with_tl >= 169.5,
            "With 30 MW tie-line limit: gen1 should serve ≥170 MW. gen1={:.2}",
            pg1_with_tl
        );

        // Power balance must hold
        assert!(
            (pg0_with_tl + pg1_with_tl - 200.0).abs() < 0.5,
            "Power balance: {:.1}+{:.1}={:.1} != 200 MW",
            pg0_with_tl,
            pg1_with_tl,
            pg0_with_tl + pg1_with_tl
        );

        println!(
            "DISP-06 tie-line test: no_limit=({:.1},{:.1}), limit30=({:.1},{:.1})",
            pg0_no_tl, pg1_no_tl, pg0_with_tl, pg1_with_tl
        );
    }

    // ─── DISP-09: Must-run and reliability must-run (RMR) dispatch floors ───

    /// Test: MustRunUnits forces generator at or above pmin.
    ///
    /// gen0 (cheap, no must-run): would naturally be dispatched fully.
    /// gen1 (expensive, must-run via MustRunUnits): should dispatch at pmin (50 MW).
    ///
    /// Expected: gen1 always ≥ pmin=50 MW even though gen0 is cheaper.
    ///
    /// Note: SCUC with must_run forces u[g,t]=1 for all t. This means gen1 must
    /// be committed (u=1) and produce at least pmin (via capacity_lower constraint).
    /// The test uses gen1.must_run=false and instead injects via MustRunUnits.
    #[test]
    fn test_disp09_must_run_units_above_pmin() {
        if !data_available() {
            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
            return;
        }
        let mut net = Network::new("must_run_units_test");
        net.base_mva = 100.0;
        let b1 = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b1);
        net.loads.push(Load::new(1, 150.0, 0.0)); // // 150 MW load

        // gen0: cheap, would serve all load if unconstrained
        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.pmin = 0.0;
        g0.pmax = 200.0;
        g0.commitment.get_or_insert_default().status = CommitmentStatus::MustRun; // keep gen0 committed to ensure feasibility
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0], // $10/MWh
        });
        net.generators.push(g0);

        // gen1: expensive, has pmin=50 MW, should be kept at pmin when must-run
        // Initially must_run=false — MustRunUnits will inject must-run externally.
        let mut g1 = Generator::new(1, 0.0, 1.0);
        g1.pmin = 50.0;
        g1.pmax = 200.0;
        g1.commitment.get_or_insert_default().status = CommitmentStatus::Market; // externally controlled via MustRunUnits
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![40.0, 0.0], // $40/MWh — very expensive
        });
        net.generators.push(g1);

        // Without MustRunUnits for gen1: gen1 (expensive) should be off
        let opts_no_mr = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol_no_mr = solve_scuc(&net, &opts_no_mr).unwrap();
        let pg1_no_mr = sol_no_mr.periods[0].pg_mw[1];

        // Without MustRunUnits, gen1 (expensive) should be committed=false → Pg=0
        assert!(
            pg1_no_mr < 1.0,
            "Without MustRunUnits: gen1 (expensive) should not dispatch. pg1={:.1}",
            pg1_no_mr
        );

        // With MustRunUnits specifying gen1 (index 1): gen1 must be committed
        // and dispatch at or above its pmin=50 MW.
        let opts_with_mr = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            must_run_units: Some(MustRunUnits {
                unit_indices: vec![1],
            }),
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol_with_mr = solve_scuc(&net, &opts_with_mr).unwrap();

        let pg1 = sol_with_mr.periods[0].pg_mw[1];
        assert!(
            pg1 >= 49.5,
            "With MustRunUnits: gen1 should dispatch >= pmin=50 MW. pg1={:.2}",
            pg1
        );
        assert!(
            commitment_schedule(&sol_with_mr)[0][1],
            "With MustRunUnits: gen1 should be committed (u=1)"
        );

        // Power balance
        let total_gen: f64 = sol_with_mr.periods[0].pg_mw.iter().sum();
        assert!(
            (total_gen - 150.0).abs() < 0.5,
            "Power balance: total={:.1} != 150 MW",
            total_gen
        );

        println!(
            "DISP-09 MustRunUnits: no_mr=({:.1},{:.1}), with_mr hour0=({:.1},{:.1})",
            sol_no_mr.periods[0].pg_mw[0],
            pg1_no_mr,
            sol_with_mr.periods[0].pg_mw[0],
            sol_with_mr.periods[0].pg_mw[1]
        );
    }

    /// Test: MustRunUnits and generator.must_run are equivalent.
    ///
    /// Setting gen1.must_run=true vs. providing MustRunUnits{unit_indices: [1]}
    /// should produce identical commitment and dispatch.
    #[test]
    fn test_disp09_must_run_units_equivalent_to_field() {
        if !data_available() {
            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
            return;
        }
        let mut net = Network::new("mr_equiv_test");
        net.base_mva = 100.0;
        let b1 = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b1);
        net.loads.push(Load::new(1, 100.0, 0.0));

        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.pmin = 0.0;
        g0.pmax = 200.0;
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0],
        });
        net.generators.push(g0);

        let mut g1 = Generator::new(1, 0.0, 1.0);
        g1.pmin = 30.0;
        g1.pmax = 150.0;
        g1.commitment.get_or_insert_default().status = CommitmentStatus::Market; // will be set via MustRunUnits
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![25.0, 0.0],
        });
        net.generators.push(g1);

        // Method A: use generator field
        let mut net_field = net.clone();
        net_field.generators[1]
            .commitment
            .get_or_insert_default()
            .status = CommitmentStatus::MustRun;
        let opts_field = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol_field = solve_scuc(&net_field, &opts_field).unwrap();

        // Method B: use MustRunUnits
        let opts_ext = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            must_run_units: Some(MustRunUnits {
                unit_indices: vec![1],
            }),
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol_ext = solve_scuc(&net, &opts_ext).unwrap();

        // Both should give same commitment
        assert_eq!(
            commitment_schedule(&sol_field)[0][1],
            commitment_schedule(&sol_ext)[0][1],
            "commitment should match: field={}, ext={}",
            commitment_schedule(&sol_field)[0][1],
            commitment_schedule(&sol_ext)[0][1]
        );

        // Both should give same gen1 dispatch (within LP tolerance)
        let diff = (sol_field.periods[0].pg_mw[1] - sol_ext.periods[0].pg_mw[1]).abs();
        assert!(
            diff < 1.0,
            "gen1 dispatch should match: field={:.2}, ext={:.2}",
            sol_field.periods[0].pg_mw[1],
            sol_ext.periods[0].pg_mw[1]
        );

        println!(
            "DISP-09 equivalence: field=({:.1},{:.1}), ext=({:.1},{:.1})",
            sol_field.periods[0].pg_mw[0],
            sol_field.periods[0].pg_mw[1],
            sol_ext.periods[0].pg_mw[0],
            sol_ext.periods[0].pg_mw[1]
        );
    }

    /// Regression test: default options (no reserve requirements) must produce
    /// reserve_awards with n_gen-length vectors for each ERCOT default product.
    #[test]
    fn test_scuc_default_reserve_awards_length() {
        use surge_network::Network;
        use surge_network::market::CostCurve;
        use surge_network::network::{Bus, BusType, Generator};

        let mut net = Network::new("scuc_reserve_awards_len_test");
        net.base_mva = 100.0;
        let b1 = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b1);
        net.loads.push(Load::new(1, 80.0, 0.0));

        let mut g1 = Generator::new(1, 0.0, 1.0);
        g1.pmin = 0.0;
        g1.pmax = 200.0;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![20.0, 0.0],
        });
        net.generators.push(g1);

        let mut g2 = Generator::new(1, 0.0, 1.0);
        g2.pmin = 0.0;
        g2.pmax = 150.0;
        g2.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![30.0, 0.0],
        });
        net.generators.push(g2);

        let n_gen = net.generators.len();
        let n_hours = 2;

        // Default: no reserve requirements.
        let opts = DispatchOptions {
            n_periods: n_hours,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        for t in 0..n_hours {
            let p = &sol.periods[t];
            for pid in &["spin", "reg_up", "reg_dn", "nspin", "ecrs", "rrs"] {
                if let Some(awards) = p.reserve_awards.get(*pid) {
                    assert_eq!(
                        awards.len(),
                        n_gen,
                        "period {t}: {pid} awards must have n_gen={n_gen} elements, got {}",
                        awards.len()
                    );
                    assert!(
                        awards.iter().all(|&v| v == 0.0),
                        "period {t}: {pid} awards should be all zeros when no reserve requested"
                    );
                }
            }
        }
    }
}

// ----- Named tests for wave-5b roadmap items (DISP-01/DISP-03) -----
// These are in a separate test module so `test_data_path` is available
// via `surge_io` directly without conflicting with the other test helpers.

#[cfg(test)]
mod wave5b_dispatch_tests {

    use super::*;
    use crate::dispatch::{CommitmentMode, Horizon, IndexedCommitmentOptions};
    use crate::legacy::DispatchOptions;
    use crate::sced::HvdcDispatchLink;

    #[allow(dead_code)]
    fn data_available() -> bool {
        if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
            return std::path::Path::new(&p).exists();
        }
        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .join("tests/data")
            .exists()
    }
    #[allow(dead_code)]
    fn test_data_path(name: &str) -> std::path::PathBuf {
        if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
            return std::path::PathBuf::from(p).join(name);
        }
        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .join("tests/data")
            .join(name)
    }

    // ----- DISP-01: LMP from SCUC LP re-solve (named test) -----

    /// DISP-01: LMP extraction via LP relaxation dual variables.
    ///
    /// Solves SCUC on case14 for 3 time periods and verifies:
    /// 1. LMPs are non-zero (not the placeholder zeros).
    /// 2. All LMPs are non-negative (energy price ≥ 0 for normal dispatch).
    /// 3. The most expensive bus has a higher or equal LMP than the reference bus.
    #[test]
    fn test_disp01_lmp_nonzero() {
        if !data_available() {
            eprintln!(
                "SKIP: tests/data not present — clone amptimal/surge-bench, copy instances/ to tests/data/"
            );
            return;
        }
        let net = surge_io::matpower::load(test_data_path("case14.m")).unwrap();

        let opts = DispatchOptions {
            n_periods: 3,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        // Verify dimension: one LMP vector per hour
        assert_eq!(
            sol.periods.len(),
            3,
            "LMP should have one entry per hour (3 hours)"
        );

        // Verify inner dimension: one LMP per bus
        let n_bus = net.n_buses();
        for t in 0..3 {
            assert_eq!(
                sol.periods[t].lmp.len(),
                n_bus,
                "hour {t}: LMP should have one value per bus ({n_bus} buses)"
            );
        }

        // DISP-01 core assertion: LMPs must not all be zero.
        // The LP re-solve with fixed binaries produces valid dual variables.
        let all_zero = sol
            .periods
            .iter()
            .all(|p| p.lmp.iter().all(|&v| v.abs() < 1e-6));
        assert!(
            !all_zero,
            "DISP-01 FAILED: all LMPs are zero — LP re-solve did not produce valid duals. \
             At least some buses should have non-zero LMPs."
        );

        // All LMPs should be non-negative (energy price ≥ 0 for cost-minimizing dispatch)
        for (t, lmps_t) in sol.periods.iter().map(|p| &p.lmp).enumerate() {
            for (b, &lmp_b) in lmps_t.iter().enumerate() {
                assert!(
                    lmp_b >= -1e-4,
                    "DISP-01: LMP at hour {t} bus {b} = {lmp_b:.4} $/MWh is negative"
                );
            }
        }

        // Total cost must be positive (sanity check)
        assert!(
            sol.summary.total_cost > 0.0,
            "SCUC total cost must be positive"
        );

        println!(
            "DISP-01 case14 3h: LMP hour-0 range=[{:.4}, {:.4}] $/MWh, total_cost={:.2}",
            sol.periods[0]
                .lmp
                .iter()
                .cloned()
                .fold(f64::INFINITY, f64::min),
            sol.periods[0]
                .lmp
                .iter()
                .cloned()
                .fold(f64::NEG_INFINITY, f64::max),
            sol.summary.total_cost
        );
    }

    // ----- DISP-03: Hot/warm/cold startup cost tiers (named test) -----

    /// DISP-03: Three-tier startup cost (hot/warm/cold) in SCUC objective.
    ///
    /// Constructs a 2-generator network where:
    /// - hot start (offline ≤ 8h): $1000
    /// - warm start (offline 8–48h): $3000
    /// - cold start (offline > 48h): $8000
    ///
    /// Test scenario A: generator offline 4h → hot start → cost = $1000
    /// Test scenario B: generator offline 15h → warm start → cost = $3000
    #[test]
    fn test_disp03_startup_tiers() {
        use surge_network::Network;
        use surge_network::market::CostCurve;
        use surge_network::network::{Branch, Bus, BusType, Generator};

        // Helper: build a minimal 2-bus network with one generator.
        // The generator starts offline and must start up in period 0 to serve 80 MW load.
        let build_net = |gen_pmax: f64| {
            let mut net = Network::new("disp03_tiers");
            net.base_mva = 100.0;

            let b1 = Bus::new(1, BusType::Slack, 138.0);
            let b2 = Bus::new(2, BusType::PQ, 138.0);

            net.buses.push(b1);
            net.buses.push(b2);
            net.loads.push(Load::new(2, 80.0, 0.0)); // // load requires the generator to start

            let mut br = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
            br.rating_a_mva = 500.0;
            br.in_service = true;
            net.branches.push(br);

            let mut g0 = Generator::new(1, 0.0, 1.0);
            g0.pmin = 0.0;
            g0.pmax = gen_pmax;
            g0.in_service = true;
            // Three startup cost tiers:
            //   hot  (offline ≤ 8h):    $1000
            //   warm (offline ≤ 48h):   $3000
            //   cold (offline > 48h):   $8000
            g0.market.get_or_insert_default().energy_offer =
                Some(surge_network::market::EnergyOffer {
                    submitted: surge_network::market::OfferCurve {
                        segments: vec![],
                        no_load_cost: 0.0,
                        startup_tiers: vec![
                            StartupTier {
                                max_offline_hours: 8.0,
                                cost: 1000.0,
                                sync_time_min: 0.0,
                            },
                            StartupTier {
                                max_offline_hours: 48.0,
                                cost: 3000.0,
                                sync_time_min: 0.0,
                            },
                            StartupTier {
                                max_offline_hours: f64::INFINITY,
                                cost: 8000.0,
                                sync_time_min: 0.0,
                            },
                        ],
                    },
                    mitigated: None,
                    mitigation_active: false,
                });
            g0.cost = Some(CostCurve::Polynomial {
                startup: 0.0, // overridden by tiers
                shutdown: 0.0,
                coeffs: vec![10.0, 0.0], // $10/MWh
            });
            net.generators.push(g0);

            net
        };

        // --- Scenario A: offline 4 hours → hot start → startup cost = $1000 ---
        let net_a = build_net(100.0);
        let opts_a = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false, // 4h offline → hot start
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                initial_commitment: Some(vec![false]),
                initial_offline_hours: Some(vec![4.0]),
                step_size_hours: Some(1.0),
                ..IndexedCommitmentOptions::default()
            }),
            ..DispatchOptions::default()
        };

        let sol_a = solve_scuc(&net_a, &opts_a).unwrap();

        // The generator must start up (u[0]=1, v[0]=1) to serve 80 MW load
        assert!(
            startup_schedule(&sol_a)[0][0],
            "Scenario A: generator should start up in period 0 (v=1)"
        );
        // Startup cost should be $1000 (hot start, 4h < 8h threshold)
        assert!(
            (startup_cost_total(&sol_a) - 1000.0).abs() < 1.0,
            "Scenario A (hot start, offline 4h): startup_cost={:.2}, expected $1000",
            startup_cost_total(&sol_a)
        );

        // --- Scenario B: offline 15 hours → warm start → startup cost = $3000 ---
        let net_b = build_net(100.0);
        let opts_b = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false, // 15h offline → warm start
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                initial_commitment: Some(vec![false]),
                initial_offline_hours: Some(vec![15.0]),
                step_size_hours: Some(1.0),
                ..IndexedCommitmentOptions::default()
            }),
            ..DispatchOptions::default()
        };

        let sol_b = solve_scuc(&net_b, &opts_b).unwrap();

        assert!(
            startup_schedule(&sol_b)[0][0],
            "Scenario B: generator should start up in period 0 (v=1)"
        );
        // Startup cost should be $3000 (warm start, 8h < 15h ≤ 48h)
        assert!(
            (startup_cost_total(&sol_b) - 3000.0).abs() < 1.0,
            "Scenario B (warm start, offline 15h): startup_cost={:.2}, expected $3000",
            startup_cost_total(&sol_b)
        );

        // --- Scenario C: offline 60 hours → cold start → startup cost = $8000 ---
        let net_c = build_net(100.0);
        let opts_c = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false, // 60h offline → cold start
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                initial_commitment: Some(vec![false]),
                initial_offline_hours: Some(vec![60.0]),
                step_size_hours: Some(1.0),
                ..IndexedCommitmentOptions::default()
            }),
            ..DispatchOptions::default()
        };

        let sol_c = solve_scuc(&net_c, &opts_c).unwrap();

        assert!(
            startup_schedule(&sol_c)[0][0],
            "Scenario C: generator should start up in period 0 (v=1)"
        );
        // Startup cost should be $8000 (cold start, 60h > 48h threshold)
        assert!(
            (startup_cost_total(&sol_c) - 8000.0).abs() < 1.0,
            "Scenario C (cold start, offline 60h): startup_cost={:.2}, expected $8000",
            startup_cost_total(&sol_c)
        );

        println!(
            "DISP-03 startup tiers: hot={:.0}, warm={:.0}, cold={:.0} (expected 1000/3000/8000)",
            startup_cost_total(&sol_a),
            startup_cost_total(&sol_b),
            startup_cost_total(&sol_c)
        );
    }

    /// Test that mid-horizon shutdown/restart cycles select the correct
    /// startup cost tier based on actual offline duration (Carrión-Arroyo
    /// exact formulation), not a pre-computed approximation.
    ///
    /// Setup: 2 generators, 8-hour horizon.
    ///   G0: cheap ($5/MWh), pmin=50, pmax=200, tiers: hot≤4h=$500, cold=$5000
    ///   G1: expensive ($50/MWh), pmin=0, pmax=200, flat startup=$0
    ///
    /// Load profile: [150, 150, 50, 50, 50, 150, 150, 150]
    ///   Hours 0-1: both gens needed (150 MW > G1 alone at $50 is expensive)
    ///   Hours 2-4: low load, G0 shuts down (50 MW served by G1 alone)
    ///   Hours 5-7: high load returns, G0 restarts
    ///
    /// G0's offline duration = 3 hours (shut down end of hour 1, restart hour 5).
    /// 3 hours < 4-hour hot threshold → hot start ($500), NOT cold ($5000).
    #[test]
    fn test_scuc_mid_horizon_restart_hot_tier() {
        use surge_network::Network;
        use surge_network::market::CostCurve;
        use surge_network::network::{Branch, Bus, BusType, Generator};

        let mut net = Network::new("mid_horizon_tier");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        let b2 = Bus::new(2, BusType::PQ, 138.0);
        net.buses.push(b1);
        net.buses.push(b2);

        let mut br = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
        br.rating_a_mva = 500.0;
        br.in_service = true;
        net.branches.push(br);

        // G0: cheap energy, high pmin, tiered startup.
        // pmin=80 forces decommitment when load drops below 80 MW.
        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.pmin = 80.0;
        g0.pmax = 200.0;
        g0.in_service = true;
        g0.market.get_or_insert_default().energy_offer = Some(surge_network::market::EnergyOffer {
            submitted: surge_network::market::OfferCurve {
                segments: vec![],
                no_load_cost: 0.0,
                startup_tiers: vec![
                    StartupTier {
                        max_offline_hours: 4.0,
                        cost: 500.0,
                        sync_time_min: 0.0,
                    },
                    StartupTier {
                        max_offline_hours: f64::INFINITY,
                        cost: 5000.0,
                        sync_time_min: 0.0,
                    },
                ],
            },
            mitigated: None,
            mitigation_active: false,
        });
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![5.0, 0.0],
        });
        net.generators.push(g0);

        // G1: expensive peaker, pmin=0, no startup cost
        let mut g1 = Generator::new(1, 0.0, 1.0);
        g1.pmin = 0.0;
        g1.pmax = 200.0;
        g1.in_service = true;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![50.0, 0.0],
        });
        net.generators.push(g1);

        // Load profile: high-low-high forcing G0 to cycle off/on.
        // 30 MW in hours 2-4 is below G0's pmin=80, so G0 MUST be off.
        let load_profile = vec![150.0, 150.0, 30.0, 30.0, 30.0, 150.0, 150.0, 150.0];
        let opts = DispatchOptions {
            n_periods: 8,
            enforce_thermal_limits: false,
            horizon: Horizon::TimeCoupled,
            load_profiles: surge_network::market::LoadProfiles {
                profiles: vec![surge_network::market::LoadProfile {
                    bus: 2,
                    load_mw: load_profile.clone(),
                }],
                n_timesteps: load_profile.len(),
            },
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                initial_commitment: Some(vec![true, true]),
                step_size_hours: Some(1.0),
                ..IndexedCommitmentOptions::default()
            }),
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        // G0 should cycle: on hours 0-1, off hours 2-4, on hours 5-7
        // The exact commitment depends on economics, but we verify the startup
        // cost is hot ($500) rather than cold ($5000).
        let has_restart = startup_schedule(&sol).iter().any(|v| v[0]); // G0 starts up somewhere
        assert!(
            has_restart,
            "G0 should restart during the horizon (load rises back to 150 MW)"
        );

        // With exact tier selection, mid-horizon restart after ≤4h offline
        // should cost $500 (hot), not $5000 (cold).
        assert!(
            startup_cost_total(&sol) <= 600.0,
            "Mid-horizon restart should use hot tier ($500), got startup_cost={:.2}",
            startup_cost_total(&sol)
        );
    }

    /// Test that a pre-horizon offline generator crossing the tier boundary
    /// during the horizon gets the correct tier.
    ///
    /// Generator starts OFF, initial_offline_hours = 3.0, hot threshold = 4h.
    /// Case A: starts at hour 0 → offline = 3h → hot ($500).
    /// Case B: forced offline for hours 0-1 via derate, starts hour 2 → offline = 5h → cold ($5000).
    #[test]
    fn test_scuc_pre_horizon_tier_boundary() {
        use surge_network::Network;
        use surge_network::market::CostCurve;
        use surge_network::market::{GeneratorDerateProfile, GeneratorDerateProfiles};
        use surge_network::network::{Branch, Bus, BusType, Generator};

        let build_net = || {
            let mut net = Network::new("pre_horizon_tier");
            net.base_mva = 100.0;

            let b1 = Bus::new(1, BusType::Slack, 138.0);
            let b2 = Bus::new(2, BusType::PQ, 138.0);
            net.buses.push(b1);
            net.buses.push(b2);
            net.loads.push(Load::new(2, 80.0, 0.0));

            let mut br = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
            br.rating_a_mva = 500.0;
            br.in_service = true;
            net.branches.push(br);

            let mut g0 = Generator::new(1, 0.0, 1.0);
            g0.pmin = 0.0;
            g0.pmax = 100.0;
            g0.in_service = true;
            g0.market.get_or_insert_default().energy_offer =
                Some(surge_network::market::EnergyOffer {
                    submitted: surge_network::market::OfferCurve {
                        segments: vec![],
                        no_load_cost: 0.0,
                        startup_tiers: vec![
                            StartupTier {
                                max_offline_hours: 4.0,
                                cost: 500.0,
                                sync_time_min: 0.0,
                            },
                            StartupTier {
                                max_offline_hours: f64::INFINITY,
                                cost: 5000.0,
                                sync_time_min: 0.0,
                            },
                        ],
                    },
                    mitigated: None,
                    mitigation_active: false,
                });
            g0.cost = Some(CostCurve::Polynomial {
                startup: 0.0,
                shutdown: 0.0,
                coeffs: vec![10.0, 0.0],
            });
            net.generators.push(g0);
            net
        };

        // Case A: starts immediately (hour 0). Offline = 3h < 4h → hot ($500)
        let net_a = build_net();
        let opts_a = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                initial_commitment: Some(vec![false]),
                initial_offline_hours: Some(vec![3.0]),
                step_size_hours: Some(1.0),
                ..IndexedCommitmentOptions::default()
            }),
            ..DispatchOptions::default()
        };
        let sol_a = solve_scuc(&net_a, &opts_a).unwrap();
        assert!(
            startup_schedule(&sol_a)[0][0],
            "Case A: should start at hour 0"
        );
        assert!(
            (startup_cost_total(&sol_a) - 500.0).abs() < 1.0,
            "Case A: 3h offline → hot tier ($500), got {:.2}",
            startup_cost_total(&sol_a)
        );

        // Case B: forced offline hours 0-1 via derate_factor=0, starts hour 2.
        // Offline = 3 (pre-horizon) + 2 (forced off) = 5h > 4h → cold ($5000).
        // Add an expensive peaker (G1 at bus 2) to serve load during forced-off hours.
        // G0 is cheap ($10/MWh) so it's worth paying $5000 cold start for 2 hours
        // of savings: 2h * 80MW * ($100 - $10) = $14,400 > $5000.
        let mut net_b = build_net();
        net_b.generators[0].machine_id = Some("G0".to_string());
        let mut g1 = Generator::new(2, 0.0, 1.0);
        g1.pmin = 0.0;
        g1.pmax = 100.0;
        g1.in_service = true;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![100.0, 0.0], // expensive peaker
        });
        net_b.generators.push(g1);
        net_b.canonicalize_generator_ids();
        let g0_id = net_b.generators[0].id.clone();
        let opts_b = DispatchOptions {
            n_periods: 4,
            enforce_thermal_limits: false,
            horizon: Horizon::TimeCoupled,
            gen_derate_profiles: GeneratorDerateProfiles {
                profiles: vec![GeneratorDerateProfile {
                    generator_id: g0_id,
                    derate_factors: vec![0.0, 0.0, 1.0, 1.0],
                }],
                n_timesteps: 4,
            },
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                initial_commitment: Some(vec![false, true]),
                initial_offline_hours: Some(vec![3.0, 0.0]),
                step_size_hours: Some(1.0),
                ..IndexedCommitmentOptions::default()
            }),
            ..DispatchOptions::default()
        };
        let sol_b = solve_scuc(&net_b, &opts_b).unwrap();
        let startup_hour = startup_schedule(&sol_b).iter().position(|v| v[0]);
        assert!(startup_hour.is_some(), "Case B: generator should start up");
        assert!(
            (startup_cost_total(&sol_b) - 5000.0).abs() < 1.0,
            "Case B: 5h offline → cold tier ($5000), got {:.2}",
            startup_cost_total(&sol_b)
        );

        println!(
            "Pre-horizon tier boundary: A={:.0} (hot), B={:.0} (cold)",
            startup_cost_total(&sol_a),
            startup_cost_total(&sol_b)
        );
    }

    #[test]
    fn test_scuc_non_unity_step_size() {
        // Verify that ramp constraints and min-up/min-down period counts are
        // correctly scaled when step_size_hours != 1.0.
        //
        // Network: 3-bus, 2 generators, step_size_hours = 0.5 (30-minute intervals).
        //
        //   g0 at bus 1: pmax=200 MW, pmin=0, ramp_up=5 MW/min → 300 MW/hr
        //     Per 30-min step: ramp limit = 300 × 0.5 = 150 MW
        //   g1 at bus 2: pmax=200 MW, pmin=0, ramp_up=1000 MW/min → unlimited effectively
        //
        // We run 4 periods (= 2 hours).
        //
        // Period 0: load = 50 MW  → g0 can serve it alone
        // Period 1: load = 220 MW → jump of 170 MW > g0 ramp limit (150 MW);
        //           g1 must pick up the slack
        //
        // Ramp constraint check:
        //   pg0[1] - pg0[0] ≤ 150 MW
        //
        // min_up_time_h check:
        //   min_up_time_hr = 2.0, step_size_hours = 0.5 → min_up_periods = ceil(2/0.5) = 4
        //   If g0 starts up at t=0, it must stay on through t=3 (all 4 periods).
        use surge_network::Network;
        use surge_network::market::CostCurve;
        use surge_network::network::{Branch, Bus, BusType, Generator};

        let mut net = Network::new("test_half_hour_steps");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b1);

        let b2 = Bus::new(2, BusType::PQ, 138.0);
        net.buses.push(b2);
        net.loads.push(Load::new(2, 50.0, 0.0)); // // base load; will be overridden by load profile

        let b3 = Bus::new(3, BusType::PQ, 138.0);
        net.buses.push(b3);

        let mut br1 = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
        br1.rating_a_mva = 500.0;
        br1.in_service = true;
        net.branches.push(br1);

        let mut br2 = Branch::new_line(2, 3, 0.0, 0.1, 0.0);
        br2.rating_a_mva = 500.0;
        br2.in_service = true;
        net.branches.push(br2);

        // g0: tight ramp rate (5 MW/min → 150 MW per 30-min step), min_up = 2h → 4 periods
        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.pmin = 0.0;
        g0.pmax = 200.0;
        g0.in_service = true;
        g0.ramping.get_or_insert_default().ramp_up_curve = vec![(0.0, 5.0)]; // MW/min
        g0.commitment.get_or_insert_default().min_up_time_hr = Some(2.0); // 2 hours → 4 periods at 0.5h steps
        g0.commitment.get_or_insert_default().min_down_time_hr = Some(1.0); // 1 hour → 2 periods
        g0.cost = Some(CostCurve::Polynomial {
            startup: 500.0,
            shutdown: 0.0,
            coeffs: vec![0.0, 10.0],
        });
        net.generators.push(g0);

        // g1: unconstrained ramp, always online
        let mut g1 = Generator::new(3, 0.0, 1.0);
        g1.pmin = 0.0;
        g1.pmax = 200.0;
        g1.in_service = true;
        g1.ramping.get_or_insert_default().ramp_up_curve = vec![(0.0, 1000.0)]; // effectively unlimited
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![0.0, 20.0], // higher cost — only used when needed
        });
        net.generators.push(g1);

        // Build a load profile: [50, 220, 220, 220] MW (applied to bus 2)
        // Period 0: 50 MW — g0 alone can serve
        // Period 1: 220 MW — jump of 170 MW > 150 MW ramp limit → g1 must help
        use surge_network::market::{LoadProfile, LoadProfiles};
        let opts = DispatchOptions {
            n_periods: 4,
            enforce_thermal_limits: false,
            load_profiles: LoadProfiles {
                profiles: vec![LoadProfile {
                    bus: 2,
                    load_mw: vec![50.0, 220.0, 220.0, 220.0],
                }],
                n_timesteps: 4,
            },
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                step_size_hours: Some(0.5),
                initial_commitment: Some(vec![false, false]),
                initial_offline_hours: Some(vec![0.0, 0.0]),
                ..IndexedCommitmentOptions::default()
            }),
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).expect("SCUC with 0.5h steps should solve");

        // Power balance must hold each period
        for t in 0..4 {
            let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
            let net_t = network_at_hour(&net, &opts, t);
            let total_load: f64 = net_t
                .loads
                .iter()
                .filter(|l| l.in_service)
                .map(|l| l.active_power_demand_mw)
                .sum();
            assert!(
                (total_gen - total_load).abs() < 1.0,
                "period {t}: gen={total_gen:.1}, load={total_load:.1}"
            );
        }

        // If g0 committed at t=0, ramp from period 0 to period 1 must respect 150 MW limit
        if commitment_schedule(&sol)[0][0] && commitment_schedule(&sol)[1][0] {
            let ramp = sol.periods[1].pg_mw[0] - sol.periods[0].pg_mw[0];
            assert!(
                ramp <= 150.0 + 1.0,
                "g0 ramp t=0→1: {ramp:.1} MW exceeds 150 MW/step limit (5 MW/min × 60 × 0.5h)"
            );
        }

        // If g0 started up at t=0 (commitment[0][0] == true), it should stay on
        // through t=3 (min_up_periods = ceil(2.0/0.5) = 4 periods)
        if commitment_schedule(&sol)[0][0] {
            for t in 0..4 {
                assert!(
                    commitment_schedule(&sol)[t][0],
                    "g0 started at t=0 with min_up=4 periods: should be ON at t={t}"
                );
            }
        }

        println!(
            "SCUC 0.5h steps: commitment={:?}, pg_mw[0]={:?}, pg_mw[1]={:?}",
            (0..4)
                .map(|t| commitment_schedule(&sol)[t][0])
                .collect::<Vec<_>>(),
            sol.periods[0],
            sol.periods[1],
        );
    }

    /// SCUC with HVDC: verify HVDC dispatch per hour and power balance.
    ///
    /// 2-bus network with cheap gen at bus 1, expensive gen at bus 2,
    /// load at bus 2, tight AC line, and an HVDC link bus 1 -> bus 2.
    #[test]
    fn test_scuc_with_hvdc() {
        use surge_network::Network;
        use surge_network::market::CostCurve;
        use surge_network::network::{Branch, Bus, BusType, Generator};

        let mut net = Network::new("scuc_hvdc_test");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        let b2 = Bus::new(2, BusType::PQ, 138.0);
        net.buses.push(b1);
        net.buses.push(b2);
        net.loads.push(Load::new(2, 150.0, 0.0));

        // AC line bus 1 -> bus 2, limited to 50 MW
        let mut br = Branch::new_line(1, 2, 0.01, 0.1, 0.02);
        br.rating_a_mva = 50.0;
        net.branches.push(br);

        // Cheap gen at bus 1
        let mut g1 = Generator::new(1, 0.0, 1.0);
        g1.pmin = 0.0;
        g1.pmax = 400.0;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![20.0, 0.0],
        });
        net.generators.push(g1);

        // Expensive gen at bus 2
        let mut g2 = Generator::new(2, 0.0, 1.0);
        g2.pmin = 0.0;
        g2.pmax = 300.0;
        g2.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![50.0, 0.0],
        });
        net.generators.push(g2);

        let hvdc_link = HvdcDispatchLink {
            id: String::new(),
            name: "HVDC_1_2".into(),
            from_bus: 1,
            to_bus: 2,
            p_dc_min_mw: 0.0,
            p_dc_max_mw: 80.0,
            loss_a_mw: 0.0,
            loss_b_frac: 0.0,
            ramp_mw_per_min: 0.0,
            cost_per_mwh: 0.0,
            bands: vec![],
        };

        let opts = DispatchOptions {
            n_periods: 4,
            enforce_thermal_limits: true,
            hvdc_links: vec![hvdc_link],
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        assert_eq!(sol.study.periods, 4);
        assert_eq!(sol.periods.len(), 4);

        for t in 0..4 {
            assert_eq!(
                sol.periods[t].hvdc_dispatch_mw.len(),
                1,
                "hour {t}: should have 1 HVDC link"
            );
            let p_hvdc = sol.periods[t].hvdc_dispatch_mw[0];

            // HVDC should transfer power to reduce cost
            assert!(
                p_hvdc > 1.0,
                "hour {t}: HVDC dispatch should be positive: {:.2} MW",
                p_hvdc
            );
            assert!(
                p_hvdc <= 80.0 + 0.5,
                "hour {t}: HVDC dispatch should respect limit: {:.2} MW <= 80 MW",
                p_hvdc
            );

            // Power balance: total gen = total load
            let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
            let net_t = network_at_hour(&net, &opts, t);
            let total_load: f64 = net_t
                .loads
                .iter()
                .filter(|l| l.in_service)
                .map(|l| l.active_power_demand_mw)
                .sum();
            assert!(
                (total_gen - total_load).abs() < 1.0,
                "hour {t}: gen={total_gen:.1}, load={total_load:.1}"
            );
        }

        println!(
            "SCUC+HVDC: cost={:.2}, hvdc[0]={:.2} MW, hvdc[3]={:.2} MW",
            sol.summary.total_cost,
            sol.periods[0].hvdc_dispatch_mw[0],
            sol.periods[3].hvdc_dispatch_mw[0],
        );
    }
}

// =============================================================================
// Flowgate / Interface enforcement in SCUC
// =============================================================================

#[cfg(test)]
mod flowgate_scuc_tests {
    use super::*;
    use crate::dispatch::{CommitmentMode, Horizon, IndexedCommitmentOptions};
    use crate::legacy::DispatchOptions;
    use surge_network::Network;
    use surge_network::market::CostCurve;
    use surge_network::network::{
        Branch, Bus, BusType, Flowgate, Generator, Interface, WeightedBranchRef,
    };

    /// Same 3-bus topology as the SCED tests.
    fn make_three_bus() -> Network {
        let mut net = Network::new("flowgate_scuc_test");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        let b2 = Bus::new(2, BusType::PQ, 138.0);
        let b3 = Bus::new(3, BusType::PQ, 138.0);
        net.buses.push(b1);
        net.buses.push(b2);
        net.loads.push(Load::new(2, 150.0, 0.0));
        net.buses.push(b3);

        let mut br12 = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
        br12.rating_a_mva = 200.0;
        let mut br23 = Branch::new_line(2, 3, 0.0, 0.1, 0.0);
        br23.rating_a_mva = 200.0;
        net.branches.push(br12);
        net.branches.push(br23);

        let mut g1 = Generator::new(1, 0.0, 1.0);
        g1.pmin = 0.0;
        g1.pmax = 250.0;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![20.0, 0.0],
        });
        let mut g2 = Generator::new(3, 0.0, 1.0);
        g2.pmin = 0.0;
        g2.pmax = 250.0;
        g2.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![40.0, 0.0],
        });
        net.generators.push(g1);
        net.generators.push(g2);
        net
    }

    /// Flowgate on branch 1-2 forces G2 to pick up load in every SCUC hour.
    #[test]
    fn test_scuc_flowgate_binds_every_hour() {
        let mut net = make_three_bus();
        net.flowgates.push(Flowgate {
            name: "FG_12".to_string(),
            monitored: vec![WeightedBranchRef::new(1, 2, "1", 1.0)],
            contingency_branch: None,
            limit_mw: 50.0,
            limit_reverse_mw: 0.0,
            in_service: true,
            limit_mw_schedule: Vec::new(),
            limit_reverse_mw_schedule: Vec::new(),
            hvdc_coefficients: vec![],
            hvdc_band_coefficients: vec![],
            ptdf_per_bus: Vec::new(),
            limit_mw_active_period: None,
            breach_sides: surge_network::network::FlowgateBreachSides::Both,
        });

        let opts = DispatchOptions {
            n_periods: 3,
            enforce_thermal_limits: false,
            enforce_flowgates: true,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        for t in 0..3 {
            let pg1 = sol.periods[t].pg_mw[0];
            let pg2 = sol.periods[t].pg_mw[1];
            assert!(
                pg1 <= 55.0,
                "hour {t}: G1 should be limited to ~50 MW by flowgate, got {pg1:.1} MW"
            );
            assert!(
                pg2 >= 95.0,
                "hour {t}: G2 should pick up remainder, got {pg2:.1} MW"
            );
        }
    }

    /// enforce_flowgates=false ignores the flowgate in SCUC.
    #[test]
    fn test_scuc_enforce_flowgates_false_ignores_flowgate() {
        let mut net = make_three_bus();
        net.flowgates.push(Flowgate {
            name: "FG_12".to_string(),
            monitored: vec![WeightedBranchRef::new(1, 2, "1", 1.0)],
            contingency_branch: None,
            limit_mw: 50.0,
            limit_reverse_mw: 0.0,
            in_service: true,
            limit_mw_schedule: Vec::new(),
            limit_reverse_mw_schedule: Vec::new(),
            hvdc_coefficients: vec![],
            hvdc_band_coefficients: vec![],
            ptdf_per_bus: Vec::new(),
            limit_mw_active_period: None,
            breach_sides: surge_network::network::FlowgateBreachSides::Both,
        });

        let opts = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();
        // Flowgate ignored → G1 (cheap) supplies all load each hour
        for t in 0..2 {
            assert!(
                sol.periods[t].pg_mw[0] > 140.0,
                "hour {t}: with enforce_flowgates=false G1 should win, got {:.1} MW",
                sol.periods[t].pg_mw[0]
            );
        }
    }

    /// Interface on branch 1-2 also constrains SCUC dispatch.
    #[test]
    fn test_scuc_interface_binds() {
        let mut net = make_three_bus();
        net.interfaces.push(Interface {
            name: "IF_12".to_string(),
            members: vec![WeightedBranchRef::new(1, 2, "1", 1.0)],
            limit_forward_mw: 50.0,
            limit_reverse_mw: 50.0,
            in_service: true,
            limit_forward_mw_schedule: Vec::new(),
            limit_reverse_mw_schedule: Vec::new(),
        });

        let opts = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            enforce_flowgates: true,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();
        for t in 0..2 {
            let pg1 = sol.periods[t].pg_mw[0];
            assert!(
                pg1 <= 55.0,
                "hour {t}: G1 should be interface-limited to ~50 MW, got {pg1:.1} MW"
            );
        }
    }

    // -----------------------------------------------------------------------
    // Flowgate / interface shadow price extraction + pricing_converged
    // -----------------------------------------------------------------------

    /// Binding flowgate produces a non-zero shadow price; slack flowgate produces ~0.
    #[test]
    fn test_scuc_flowgate_shadow_prices() {
        let mut net = make_three_bus();
        // Tight flowgate on branch 1-2 — will bind
        net.flowgates.push(Flowgate {
            name: "FG_12_tight".to_string(),
            monitored: vec![WeightedBranchRef::new(1, 2, "1", 1.0)],
            contingency_branch: None,
            limit_mw: 50.0, // binding — unconstrained flow ≈ 150 MW
            limit_reverse_mw: 0.0,
            in_service: true,
            limit_mw_schedule: Vec::new(),
            limit_reverse_mw_schedule: Vec::new(),
            hvdc_coefficients: vec![],
            hvdc_band_coefficients: vec![],
            ptdf_per_bus: Vec::new(),
            limit_mw_active_period: None,
            breach_sides: surge_network::network::FlowgateBreachSides::Both,
        });
        // Slack flowgate on same branch with very high limit
        net.flowgates.push(Flowgate {
            name: "FG_12_slack".to_string(),
            monitored: vec![WeightedBranchRef::new(1, 2, "1", 1.0)],
            contingency_branch: None,
            limit_mw: 9999.0,
            limit_reverse_mw: 0.0,
            in_service: true,
            limit_mw_schedule: Vec::new(),
            limit_reverse_mw_schedule: Vec::new(),
            hvdc_coefficients: vec![],
            hvdc_band_coefficients: vec![],
            ptdf_per_bus: Vec::new(),
            limit_mw_active_period: None,
            breach_sides: surge_network::network::FlowgateBreachSides::Both,
        });

        let opts = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            enforce_flowgates: true,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        // Shadow prices must be returned for all flowgates in every period
        for t in 0..2 {
            assert_eq!(
                sol.periods[t].flowgate_shadow_prices.len(),
                2,
                "hour {t}: must have one shadow price per flowgate"
            );
            // Binding flowgate → positive shadow price
            assert!(
                sol.periods[t].flowgate_shadow_prices[0] > 1e-4,
                "hour {t}: binding flowgate shadow price should be positive, got {:.6}",
                sol.periods[t].flowgate_shadow_prices[0]
            );
            // Slack flowgate → shadow price ≈ 0
            assert!(
                sol.periods[t].flowgate_shadow_prices[1].abs() < 1e-4,
                "hour {t}: slack flowgate shadow price should be ~0, got {:.6}",
                sol.periods[t].flowgate_shadow_prices[1]
            );
        }
    }

    /// Binding branch thermal limits should produce non-zero branch shadow prices.
    #[test]
    fn test_scuc_branch_shadow_prices() {
        let mut net = make_three_bus();
        net.branches[0].rating_a_mva = 50.0;

        let opts = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: true,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        for t in 0..2 {
            assert_eq!(
                sol.periods[t].branch_shadow_prices.len(),
                2,
                "hour {t}: must have one shadow price per constrained branch"
            );
            assert!(
                sol.periods[t].branch_shadow_prices[0] > 1e-4,
                "hour {t}: binding branch shadow price should be positive, got {:.6}",
                sol.periods[t].branch_shadow_prices[0]
            );
            assert!(
                sol.periods[t].branch_shadow_prices[1].abs() < 1e-4,
                "hour {t}: slack branch shadow price should be ~0, got {:.6}",
                sol.periods[t].branch_shadow_prices[1]
            );
        }
    }

    /// Later-hour branch pricing must stay aligned when SCUC inserts hourly PWL rows.
    #[test]
    fn test_scuc_branch_shadow_prices_with_pwl_rows() {
        let mut net = make_three_bus();
        net.branches[0].rating_a_mva = 50.0;
        net.generators[0].cost = Some(CostCurve::PiecewiseLinear {
            startup: 0.0,
            shutdown: 0.0,
            points: vec![(0.0, 0.0), (80.0, 800.0), (250.0, 5900.0)],
        });

        let opts = DispatchOptions {
            n_periods: 3,
            enforce_thermal_limits: true,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        for t in 0..3 {
            assert_eq!(
                sol.periods[t].branch_shadow_prices.len(),
                2,
                "hour {t}: must have one shadow price per constrained branch"
            );
            assert!(
                sol.periods[t].branch_shadow_prices[0] > 1e-4,
                "hour {t}: binding branch shadow price should stay positive with PWL rows, got {:.6}",
                sol.periods[t].branch_shadow_prices[0]
            );
            assert!(
                sol.periods[t].branch_shadow_prices[1].abs() < 1e-4,
                "hour {t}: slack branch shadow price should remain ~0, got {:.6}",
                sol.periods[t].branch_shadow_prices[1]
            );
        }
    }

    /// Binding interface produces a non-zero shadow price.
    #[test]
    fn test_scuc_interface_shadow_prices() {
        let mut net = make_three_bus();
        net.interfaces.push(Interface {
            name: "IF_12".to_string(),
            members: vec![WeightedBranchRef::new(1, 2, "1", 1.0)],
            limit_forward_mw: 50.0, // binding
            limit_reverse_mw: 50.0,
            in_service: true,
            limit_forward_mw_schedule: Vec::new(),
            limit_reverse_mw_schedule: Vec::new(),
        });

        let opts = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            enforce_flowgates: true,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        for t in 0..2 {
            assert_eq!(sol.periods[t].interface_shadow_prices.len(), 1);
            assert!(
                sol.periods[t].interface_shadow_prices[0] > 1e-4,
                "hour {t}: binding interface shadow price should be positive, got {:.6}",
                sol.periods[t].interface_shadow_prices[0]
            );
        }

        // With enforce_flowgates=false, shadow prices must be empty
        let opts_off = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol_off = solve_scuc(&net, &opts_off).unwrap();
        assert!(
            sol_off.periods[0].interface_shadow_prices.is_empty(),
            "interface_shadow_prices must be empty when enforce_flowgates=false"
        );
    }

    /// pricing_converged is true on normal solve.
    #[test]
    fn test_scuc_pricing_converged_flag() {
        let net = make_three_bus();
        let opts = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();
        assert!(
            sol.diagnostics.pricing_converged.unwrap_or(true),
            "pricing_converged should be true on a normal SCUC solve"
        );
    }

    #[test]
    fn test_scuc_can_skip_pricing() {
        let net = make_three_bus();
        let opts = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            run_pricing: false,
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();
        assert_eq!(sol.diagnostics.pricing_converged, Some(false));
        for period in &sol.periods {
            assert_eq!(period.lmp.len(), net.n_buses());
            assert!(period.lmp.iter().all(|value| value.abs() < 1e-12));
            assert!(period.branch_shadow_prices.is_empty());
            assert!(period.flowgate_shadow_prices.is_empty());
            assert!(period.interface_shadow_prices.is_empty());
        }
    }

    // -----------------------------------------------------------------------
    // Nomogram tightening in SCUC
    // -----------------------------------------------------------------------

    /// Build a 3-bus network with a nomogram (same as SCED's make_three_bus_with_nomogram).
    fn make_three_bus_with_nomogram() -> Network {
        use surge_network::network::OperatingNomogram;

        let mut net = make_three_bus();
        // Set branch circuits explicitly for flowgate matching.
        for br in &mut net.branches {
            br.circuit = "1".to_string();
        }

        // FG_12 monitors branch 1-2 (carries G1's output).
        net.flowgates.push(Flowgate {
            name: "FG_12".to_string(),
            monitored: vec![WeightedBranchRef::new(1, 2, "1", 1.0)],
            contingency_branch: None,
            limit_mw: 200.0,
            limit_reverse_mw: 0.0,
            in_service: true,
            limit_mw_schedule: vec![],
            limit_reverse_mw_schedule: vec![],
            hvdc_coefficients: vec![],
            hvdc_band_coefficients: vec![],
            ptdf_per_bus: Vec::new(),
            limit_mw_active_period: None,
            breach_sides: surge_network::network::FlowgateBreachSides::Both,
        });

        // Self-referential nomogram: FG_12 flow → tighten FG_12 limit to 100 MW.
        net.nomograms.push(OperatingNomogram {
            name: "NOM_12_self".to_string(),
            index_flowgate: "FG_12".to_string(),
            constrained_flowgate: "FG_12".to_string(),
            points: vec![(0.0, 100.0), (200.0, 100.0)],
            in_service: true,
        });

        net
    }

    /// With nomogram enforcement: FG_12 initially carries 150 MW → nomogram tightens
    /// FG_12 limit to 100 MW → G1 forced to ≤ 100 MW → G2 picks up 50 MW.
    #[test]
    #[ignore = "pre-existing baseline failure: nomogram tightening returns 150 MW instead \
                of ≤ 100 MW."]
    fn test_scuc_nomogram_tightens_limit() {
        let net = make_three_bus_with_nomogram();
        let opts = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            enforce_flowgates: true,
            max_nomogram_iter: 10,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        for t in 0..2 {
            // Nomogram should have tightened FG_12 to 100 MW, forcing G1 ≤ 100 MW.
            assert!(
                sol.periods[t].pg_mw[0] <= 100.0 + 1.0,
                "hour {t}: Nomogram should limit G1 to ≤100 MW, got {:.1} MW",
                sol.periods[t].pg_mw[0]
            );
            // G2 must supply the remainder (≥ 50 MW).
            assert!(
                sol.periods[t].pg_mw[1] >= 50.0 - 1.0,
                "hour {t}: G2 should supply ≥50 MW, got {:.1} MW",
                sol.periods[t].pg_mw[1]
            );
        }
    }

    /// Without nomogram enforcement (max_nomogram_iter=0): G1 supplies all 150 MW.
    #[test]
    fn test_scuc_nomogram_disabled() {
        let net = make_three_bus_with_nomogram();
        let opts = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            enforce_flowgates: true,
            max_nomogram_iter: 0,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        for t in 0..2 {
            // Without nomogram, G1 serves all 150 MW freely (FG_12 limit = 200 MW).
            assert!(
                sol.periods[t].pg_mw[0] > 140.0,
                "hour {t}: No nomogram: G1 should win, got {:.1} MW",
                sol.periods[t].pg_mw[0]
            );
        }
    }

    /// Non-binding nomogram: loop exits immediately (converges in 0 tightening iterations).
    #[test]
    fn test_scuc_nomogram_converges() {
        use surge_network::network::OperatingNomogram;

        let mut net = make_three_bus();
        for br in &mut net.branches {
            br.circuit = "1".to_string();
        }
        net.flowgates.push(Flowgate {
            name: "FG_12".to_string(),
            monitored: vec![WeightedBranchRef::new(1, 2, "1", 1.0)],
            contingency_branch: None,
            limit_mw: 200.0,
            limit_reverse_mw: 0.0,
            in_service: true,
            limit_mw_schedule: vec![],
            limit_reverse_mw_schedule: vec![],
            hvdc_coefficients: vec![],
            hvdc_band_coefficients: vec![],
            ptdf_per_bus: Vec::new(),
            limit_mw_active_period: None,
            breach_sides: surge_network::network::FlowgateBreachSides::Both,
        });
        // Nomogram that never tightens (limit = 300 MW > initial 200 MW).
        net.nomograms.push(OperatingNomogram {
            name: "NOM_loose".to_string(),
            index_flowgate: "FG_12".to_string(),
            constrained_flowgate: "FG_12".to_string(),
            points: vec![(0.0, 300.0), (200.0, 300.0)],
            in_service: true,
        });

        let opts = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            enforce_flowgates: true,
            max_nomogram_iter: 10,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        // Non-binding nomogram → G1 still wins at full output
        for t in 0..2 {
            assert!(
                sol.periods[t].pg_mw[0] > 140.0,
                "hour {t}: Non-binding nomogram: G1 should still win, got {:.1} MW",
                sol.periods[t].pg_mw[0]
            );
        }
    }
}

// =============================================================================
// Generator and branch derate / outage schedule tests
// =============================================================================

#[cfg(test)]
mod derate_tests {
    #[allow(unused_imports)]
    use super::*;
    use crate::dispatch::{CommitmentMode, Horizon, IndexedCommitmentOptions};
    use crate::legacy::DispatchOptions;
    use surge_network::Network;
    use surge_network::market::{
        BranchDerateProfile, BranchDerateProfiles, CostCurve, GeneratorDerateProfile,
        GeneratorDerateProfiles,
    };
    use surge_network::network::{Bus, BusType, Generator};

    /// Build a minimal 2-bus, 2-generator test network.
    ///
    /// Bus 1 (slack): G0 — pmin=10, pmax=100, linear cost $20/MWh
    /// Bus 2 (PQ):    G1 — pmin=10, pmax=100, linear cost $30/MWh
    /// Branch 1→2:    rate_a = 200 MVA (non-binding by default)
    /// Load:          bus 2 = 80 MW
    fn two_gen_net() -> Network {
        let mut net = Network::new("derate_test");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 100.0);
        let b2 = Bus::new(2, BusType::PQ, 100.0);
        net.buses.push(b1);
        net.buses.push(b2);
        net.loads.push(Load::new(2, 80.0, 0.0));

        let mut br = surge_network::network::Branch::new_line(1, 2, 0.01, 0.1, 0.0);
        br.rating_a_mva = 200.0;
        net.branches.push(br);

        let mut g0 = Generator::new(1, 80.0, 1.0);
        g0.pmin = 10.0;
        g0.pmax = 100.0;
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![20.0, 0.0],
        });
        net.generators.push(g0);

        let mut g1 = Generator::new(2, 0.0, 1.0);
        g1.pmin = 10.0;
        g1.pmax = 100.0;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![30.0, 0.0],
        });
        net.generators.push(g1);

        net.canonicalize_generator_ids();
        net.validate().expect("two_gen_net should validate");
        net
    }

    /// A generator with derate=0 in a given hour must be forced offline (u=0)
    /// and contribute zero output. The remaining generator must cover load alone.
    #[test]
    fn test_gen_full_outage_forces_commitment_zero() {
        let net = two_gen_net();
        let g1_id = net.generators[1].id.clone();
        // G1 (index 1) is fully offline in hours 0 and 2; available in hour 1.
        // G0 ($20/MWh, pmax=100) can cover the 80 MW load alone, so the MILP
        // will keep G1 offline in hour 1 as well (no economic incentive to commit
        // the costlier unit). The key invariant is: u[G1,0] = u[G1,2] = 0.
        let opts = DispatchOptions {
            n_periods: 3,
            gen_derate_profiles: GeneratorDerateProfiles {
                n_timesteps: 3,
                profiles: vec![GeneratorDerateProfile {
                    generator_id: g1_id,
                    derate_factors: vec![0.0, 1.0, 0.0],
                }],
            },
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        // Hours 0 and 2: G1 must be forced offline — zero commitment, zero output.
        for t in [0usize, 2usize] {
            assert!(
                !commitment_schedule(&sol)[t][1],
                "hour {t}: G1 should be offline (derate=0)"
            );
            assert!(
                sol.periods[t].pg_mw[1].abs() < 1e-3,
                "hour {t}: G1 should produce 0 MW, got {:.3}",
                sol.periods[t].pg_mw[1]
            );
        }

        // Power balance holds in all hours (G0 covers the full 80 MW load).
        for t in 0..3 {
            let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
            let net_t = network_at_hour(&net, &opts, t);
            let total_load: f64 = net_t
                .loads
                .iter()
                .filter(|l| l.in_service)
                .map(|l| l.active_power_demand_mw)
                .sum();
            assert!(
                (total_gen - total_load).abs() < 1.0,
                "hour {t}: balance error {:.2} MW",
                total_gen - total_load
            );
        }
    }

    /// A partial derate (0.5) should reduce the generator's effective pmax and
    /// allow the solver to dispatch from the cheaper unit up to its derated limit.
    #[test]
    fn test_gen_partial_derate_limits_pmax() {
        let net = two_gen_net();
        let g0_id = net.generators[0].id.clone();
        // G0 (cheap, $20/MWh) derated to 50% in hour 0 → effective pmax = 50 MW.
        // Load = 80 MW → solver must use G1 for the remaining 30 MW.
        let opts = DispatchOptions {
            n_periods: 1,
            gen_derate_profiles: GeneratorDerateProfiles {
                n_timesteps: 1,
                profiles: vec![GeneratorDerateProfile {
                    generator_id: g0_id,
                    derate_factors: vec![0.5],
                }],
            },
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        // G0 should be at or below derated pmax (50 MW)
        let pg0 = sol.periods[0].pg_mw[0];
        assert!(
            pg0 <= 50.5,
            "G0 pmax after 50% derate should be ≤50 MW, got {pg0:.2}"
        );

        // G1 must make up the shortfall
        let pg1 = sol.periods[0].pg_mw[1];
        assert!(
            pg1 >= 28.0,
            "G1 should dispatch ≥28 MW to cover shortfall, got {pg1:.2}"
        );
    }

    /// When a branch has derate=0 for a period it should be removed from service
    /// (in_service=false in the hourly network clone).
    #[test]
    fn test_branch_full_outage_removes_from_service() {
        let net = two_gen_net();
        let opts = DispatchOptions {
            n_periods: 2,
            branch_derate_profiles: BranchDerateProfiles {
                n_timesteps: 2,
                profiles: vec![BranchDerateProfile {
                    from_bus: 1,
                    to_bus: 2,
                    circuit: "1".to_string(),
                    derate_factors: vec![0.0, 1.0],
                }],
            },
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };

        // Hour 0: branch out of service
        let net_h0 = network_at_hour(&net, &opts, 0);
        assert!(
            !net_h0.branches[0].in_service,
            "hour 0: branch should be out of service"
        );

        // Hour 1: branch restored
        let net_h1 = network_at_hour(&net, &opts, 1);
        assert!(
            net_h1.branches[0].in_service,
            "hour 1: branch should be in service"
        );
    }

    /// A partial branch derate scales rate_a proportionally.
    #[test]
    fn test_branch_partial_derate_scales_rate_a() {
        let net = two_gen_net();
        let opts = DispatchOptions {
            n_periods: 1,
            branch_derate_profiles: BranchDerateProfiles {
                n_timesteps: 1,
                profiles: vec![BranchDerateProfile {
                    from_bus: 1,
                    to_bus: 2,
                    circuit: "1".to_string(),
                    derate_factors: vec![0.6],
                }],
            },
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let net_h = network_at_hour(&net, &opts, 0);
        let expected_rate_a = 200.0 * 0.6;
        assert!(
            (net_h.branches[0].rating_a_mva - expected_rate_a).abs() < 1e-6,
            "rate_a should be {expected_rate_a:.1}, got {:.1}",
            net_h.branches[0].rating_a_mva
        );
    }

    /// Derate then CF: effective pmax = pmax_nameplate × derate × cf.
    /// With derate=0.5 and cf=0.8 on a 100 MW generator → effective pmax = 40 MW.
    #[test]
    fn test_derate_applied_before_renewable_cf() {
        let net = two_gen_net();
        let g0_id = net.generators[0].id.clone();
        let opts = DispatchOptions {
            n_periods: 1,
            gen_derate_profiles: GeneratorDerateProfiles {
                n_timesteps: 1,
                profiles: vec![GeneratorDerateProfile {
                    generator_id: g0_id.clone(),
                    derate_factors: vec![0.5],
                }],
            },
            renewable_profiles: surge_network::market::RenewableProfiles {
                n_timesteps: 1,
                profiles: vec![surge_network::market::RenewableProfile {
                    generator_id: g0_id,
                    capacity_factors: vec![0.8],
                }],
            },
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let net_h = network_at_hour(&net, &opts, 0);
        // Expected: 100 * 0.5 * 0.8 = 40 MW
        assert!(
            (net_h.generators[0].pmax - 40.0).abs() < 1e-6,
            "pmax should be 40.0 MW (100×0.5×0.8), got {:.4}",
            net_h.generators[0].pmax
        );
    }

    // -----------------------------------------------------------------------
    // Storage dispatch mode tests
    // -----------------------------------------------------------------------

    fn storage_test_net() -> Network {
        // Single-bus network: slack bus with 80 MW load, one $20/MWh generator.
        let mut net = Network::new("sto_scuc_test");
        net.base_mva = 100.0;
        let b1 = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b1);
        net.loads.push(Load::new(1, 80.0, 0.0));
        let mut g = Generator::new(1, 0.0, 1.0);
        g.pmax = 200.0;
        g.pmin = 0.0;
        g.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![20.0, 0.0],
        });
        net.generators.push(g);
        net
    }

    /// SelfSchedule: battery commits +25 MW discharge for 2 hours.
    /// Assert storage_discharge_mw ≈ 25 and storage_charge_mw ≈ 0 in each period.
    #[test]
    fn test_scuc_storage_self_schedule_fixed_dispatch() {
        use surge_network::market::CostCurve;
        use surge_network::network::{Generator, StorageDispatchMode, StorageParams};

        let mut net = storage_test_net();
        // Add storage generator at bus 1 (SelfSchedule: +25 MW discharge)
        let g = Generator {
            bus: 1,
            in_service: true,
            pmin: -50.0,
            pmax: 50.0,
            machine_base_mva: 100.0,
            cost: Some(CostCurve::Polynomial {
                coeffs: vec![0.0],
                startup: 0.0,
                shutdown: 0.0,
            }),
            storage: Some(StorageParams {
                charge_efficiency: 0.9486832981,
                discharge_efficiency: 0.9486832981,
                energy_capacity_mwh: 200.0,
                soc_initial_mwh: 100.0,
                soc_min_mwh: 0.0,
                soc_max_mwh: 200.0,
                variable_cost_per_mwh: 0.0,
                degradation_cost_per_mwh: 0.0,
                dispatch_mode: StorageDispatchMode::SelfSchedule,
                self_schedule_mw: 25.0,
                discharge_offer: None,
                charge_bid: None,
                max_c_rate_charge: None,
                max_c_rate_discharge: None,
                chemistry: None,
                discharge_foldback_soc_mwh: None,
                charge_foldback_soc_mwh: None,
                daily_cycle_limit: None,
            }),
            ..Generator::default()
        };
        net.generators.push(g);

        let opts = DispatchOptions {
            n_periods: 2,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        for (t, period) in sol.periods.iter().enumerate() {
            assert_eq!(period.storage_discharge_mw.len(), 1);
            assert!(
                (period.storage_discharge_mw[0] - 25.0).abs() < 1e-3,
                "Period {t}: expected discharge ≈ 25 MW, got {:.4}",
                period.storage_discharge_mw[0]
            );
            assert!(
                period.storage_charge_mw[0] < 1e-6,
                "Period {t}: expected charge ≈ 0, got {:.4}",
                period.storage_charge_mw[0]
            );
        }
    }

    /// CostMinimization with degradation cost baked in:
    /// verify the LP objective cost >= generator-only cost + degradation × throughput.
    ///
    /// Storage at $0 variable + $5/MWh degradation on both ch and dis.
    /// The optimizer should dispatch storage only if net saving > degradation expense.
    #[test]
    fn test_scuc_storage_cost_min_degradation_in_objective() {
        use surge_network::market::CostCurve;
        use surge_network::network::{Generator, StorageDispatchMode, StorageParams};

        let net = storage_test_net();

        // Baseline: no storage
        let opts_no_storage = DispatchOptions {
            n_periods: 1,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol_base = solve_scuc(&net, &opts_no_storage).unwrap();
        let base_cost = sol_base.summary.total_cost;

        // With storage ($0 variable, $5 degradation)
        let mut net_with_sto = storage_test_net();
        let g = Generator {
            bus: 1,
            in_service: true,
            pmin: -50.0,
            pmax: 50.0,
            machine_base_mva: 100.0,
            cost: Some(CostCurve::Polynomial {
                coeffs: vec![0.0],
                startup: 0.0,
                shutdown: 0.0,
            }),
            storage: Some(StorageParams {
                charge_efficiency: 0.9486832981,
                discharge_efficiency: 0.9486832981,
                energy_capacity_mwh: 200.0,
                soc_initial_mwh: 100.0,
                soc_min_mwh: 0.0,
                soc_max_mwh: 200.0,
                variable_cost_per_mwh: 0.0,
                degradation_cost_per_mwh: 5.0,
                dispatch_mode: StorageDispatchMode::CostMinimization,
                self_schedule_mw: 0.0,
                discharge_offer: None,
                charge_bid: None,
                max_c_rate_charge: None,
                max_c_rate_discharge: None,
                chemistry: None,
                discharge_foldback_soc_mwh: None,
                charge_foldback_soc_mwh: None,
                daily_cycle_limit: None,
            }),
            ..Generator::default()
        };
        net_with_sto.generators.push(g);

        let opts = DispatchOptions {
            n_periods: 1,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net_with_sto, &opts).unwrap();

        // If storage discharged, total cost = generator_cost - saved_gen_cost + degradation.
        // Net cost should be ≤ base (storage only dispatches when it lowers total cost).
        assert!(
            sol.summary.total_cost <= base_cost + 1.0, // small tolerance for LP precision
            "Storage should not raise total system cost: with={:.2}, without={:.2}",
            sol.summary.total_cost,
            base_cost
        );

        // If any discharge occurred, check that degradation cost was applied.
        let dis = sol.periods[0].storage_discharge_mw[0];
        let ch = sol.periods[0].storage_charge_mw[0];
        let throughput_cost = (dis + ch) * 5.0; // $5/MWh × MW = $/hr for 1-hr period
        // The reported cost includes both generation and storage costs.
        assert!(
            sol.summary.total_cost >= throughput_cost - 1.0,
            "Total cost should include degradation cost (≥{:.2}), got {:.2}",
            throughput_cost,
            sol.summary.total_cost
        );
    }

    /// CostMinimization multi-period: storage should charge in low-price periods
    /// and discharge in high-price periods when the price spread is large enough.
    ///
    /// Two periods: load 40 MW (period 0, low LMP) and 160 MW (period 1, high LMP).
    /// Free storage (zero cost) should charge in period 0 and discharge in period 1.
    #[test]
    fn test_scuc_storage_cost_min_intertemporal_arbitrage() {
        use surge_network::market::CostCurve;
        use surge_network::network::{Generator, StorageDispatchMode, StorageParams};

        let mut net = Network::new("arb_test");
        net.base_mva = 100.0;
        let b1 = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b1);
        net.loads.push(Load::new(1, 40.0, 0.0)); // // base load (period 0 load profile overrides this)
        let mut g = Generator::new(1, 0.0, 1.0);
        g.pmax = 200.0;
        g.pmin = 0.0;
        g.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            // Quadratic $0.01/MW²: at P=40MW MC=$0.8/MWh, at P=160MW MC=$3.2/MWh → 4x spread
            coeffs: vec![0.01, 0.0, 0.0],
        });
        net.generators.push(g);

        // Storage generator at bus 1 (CostMinimization, free, starts empty)
        let gs = Generator {
            bus: 1,
            in_service: true,
            pmin: -50.0,
            pmax: 50.0,
            machine_base_mva: 100.0,
            cost: Some(CostCurve::Polynomial {
                coeffs: vec![0.0],
                startup: 0.0,
                shutdown: 0.0,
            }),
            storage: Some(StorageParams {
                charge_efficiency: 0.9486832981,
                discharge_efficiency: 0.9486832981,
                energy_capacity_mwh: 200.0,
                soc_initial_mwh: 0.0,
                soc_min_mwh: 0.0,
                soc_max_mwh: 200.0,
                variable_cost_per_mwh: 0.0,
                degradation_cost_per_mwh: 0.0,
                dispatch_mode: StorageDispatchMode::CostMinimization,
                self_schedule_mw: 0.0,
                discharge_offer: None,
                charge_bid: None,
                max_c_rate_charge: None,
                max_c_rate_discharge: None,
                chemistry: None,
                discharge_foldback_soc_mwh: None,
                charge_foldback_soc_mwh: None,
                daily_cycle_limit: None,
            }),
            ..Generator::default()
        };
        net.generators.push(gs);

        // Period 0: 40 MW load (low LMP ≈ $40/MWh with quadratic)
        // Period 1: 160 MW load (high LMP ≈ $160/MWh with quadratic)
        let load_profiles = surge_network::market::LoadProfiles {
            n_timesteps: 2,
            profiles: vec![surge_network::market::LoadProfile {
                bus: 1,
                load_mw: vec![40.0, 160.0],
            }],
        };

        let opts = DispatchOptions {
            n_periods: 2,
            load_profiles,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        let p0_ch = sol.periods[0].storage_charge_mw[0];
        let p1_dis = sol.periods[1].storage_discharge_mw[0];

        // With large price spread, storage should charge in p0 and discharge in p1.
        assert!(
            p0_ch > 1.0,
            "Storage should charge in low-price period (p0), got ch={:.4}",
            p0_ch
        );
        assert!(
            p1_dis > 1.0,
            "Storage should discharge in high-price period (p1), got dis={:.4}",
            p1_dis
        );
    }

    /// VB-SCUC-01: Inc bid priced below marginal generator clears in both hours.
    ///
    /// 1-bus, 1-gen ($15/MWh), 150 MW load for 2 hours.
    /// Inc bid at $10/MWh for 30 MW → should clear ≈30 MW each hour.
    #[test]
    fn test_scuc_vbid_inc_clears_both_hours() {
        use surge_network::market::{CostCurve, VirtualBid, VirtualBidDirection};
        use surge_network::network::{Bus, BusType, Generator};

        let mut net = Network::new("scuc_vbid");
        net.base_mva = 100.0;
        let b1 = Bus::new(1, BusType::Slack, 100.0);
        net.buses.push(b1);
        net.loads.push(Load::new(1, 150.0, 0.0));

        let mut g = Generator::new(1, 0.0, 1.0);
        g.pmin = 0.0;
        g.pmax = 200.0;
        g.cost = Some(CostCurve::Polynomial {
            coeffs: vec![15.0, 0.0],
            startup: 0.0,
            shutdown: 0.0,
        });
        net.generators.push(g);

        let opts = DispatchOptions {
            n_periods: 2,
            virtual_bids: vec![
                VirtualBid {
                    position_id: "inc_1_h0".to_string(),
                    bus: 1,
                    period: 0,
                    mw_limit: 30.0,
                    price_per_mwh: 10.0,
                    direction: VirtualBidDirection::Inc,
                    in_service: true,
                },
                VirtualBid {
                    position_id: "inc_1_h1".to_string(),
                    bus: 1,
                    period: 1,
                    mw_limit: 30.0,
                    price_per_mwh: 10.0,
                    direction: VirtualBidDirection::Inc,
                    in_service: true,
                },
            ],
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        for t in 0..2 {
            let vbrs = &sol.periods[t].virtual_bid_results;
            assert_eq!(
                vbrs.len(),
                2,
                "Hour {t}: expected 2 hourly virtual bid results"
            );
            let target_id = if t == 0 { "inc_1_h0" } else { "inc_1_h1" };
            let vbr = vbrs
                .iter()
                .find(|v| v.position_id == target_id)
                .expect("targeted hourly virtual bid missing");
            assert!(
                (vbr.cleared_mw - 30.0).abs() < 1.0,
                "Hour {t}: Inc bid should clear ≈30 MW, got {:.2}",
                vbr.cleared_mw
            );
            let off_hour = vbrs
                .iter()
                .find(|v| v.position_id != target_id)
                .expect("off-hour virtual bid missing");
            assert!(
                off_hour.cleared_mw.abs() < 1e-6,
                "Hour {t}: off-hour virtual bid should clear 0 MW, got {:.2}",
                off_hour.cleared_mw
            );
            // Physical gen should serve 150 - 30 = 120 MW
            let total_phys: f64 = sol.periods[t].pg_mw.iter().sum();
            assert!(
                (total_phys - 120.0).abs() < 2.0,
                "Hour {t}: physical gen should be ≈120 MW, got {:.2}",
                total_phys
            );
        }
    }
}

#[cfg(test)]
mod security_scuc_tests {
    use super::*;
    use crate::dispatch::{CommitmentMode, Horizon, IndexedCommitmentOptions};
    use crate::legacy::DispatchOptions;
    use surge_network::Network;
    use surge_network::market::{CostCurve, PenaltyConfig, PenaltyCurve};
    use surge_network::network::{Branch, Bus, BusType, Generator};

    #[allow(dead_code)]
    fn data_available() -> bool {
        if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
            return std::path::Path::new(&p).exists();
        }
        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .join("tests/data")
            .exists()
    }

    #[allow(dead_code)]
    fn test_data_path(name: &str) -> std::path::PathBuf {
        if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
            return std::path::PathBuf::from(p).join(name);
        }
        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .join("tests/data")
            .join(name)
    }

    /// Security SCUC on case9: should converge quickly (few or no N-1 violations).
    #[test]
    fn test_security_scuc_case9() {
        if !data_available() {
            eprintln!("SKIP: tests/data not present");
            return;
        }
        let net = surge_io::matpower::load(test_data_path("case9.m")).unwrap();

        let request = crate::request::DispatchRequest {
            formulation: crate::Formulation::Dc,
            coupling: crate::request::IntervalCoupling::TimeCoupled,
            commitment: crate::request::CommitmentPolicy::Optimize(
                crate::request::CommitmentOptions::default(),
            ),
            timeline: crate::request::DispatchTimeline {
                periods: 2,
                interval_hours: 1.0,
                interval_hours_by_period: Vec::new(),
            },
            network: crate::request::DispatchNetwork {
                thermal_limits: crate::request::ThermalLimitPolicy {
                    enforce: true,
                    min_rate_a: 1.0,
                },
                security: Some(crate::request::SecurityPolicy {
                    max_iterations: 5,
                    ..Default::default()
                }),
                ..Default::default()
            },
            ..Default::default()
        };

        let model = crate::DispatchModel::prepare(&net).unwrap();
        let result = crate::dispatch::solve_dispatch(&model, &request).unwrap();

        assert_eq!(result.study.periods, 2);
        assert!(result.summary.total_cost > 0.0);
        assert!(result.diagnostics.security.as_ref().unwrap().iterations <= 5);
        // Security SCUC cost should be >= base SCUC cost
        let base_sol = solve_scuc(
            &net,
            &DispatchOptions {
                n_periods: 2,
                enforce_thermal_limits: true,
                min_rate_a: 1.0,
                horizon: Horizon::TimeCoupled,
                commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
                ..DispatchOptions::default()
            },
        )
        .unwrap();
        assert!(
            result.summary.total_cost >= base_sol.summary.total_cost - 1.0,
            "Security cost ({:.2}) should be >= base cost ({:.2})",
            result.summary.total_cost,
            base_sol.summary.total_cost
        );
    }

    /// Security SCUC on a congested 3-bus network: security cost >= base cost.
    #[test]
    fn test_security_scuc_congested() {
        // 3-bus triangle with moderate thermal limits.
        // N-1 contingencies should tighten dispatch vs base SCUC.
        let mut net = Network::new("sec_scuc_3bus");
        net.base_mva = 100.0;
        let b1 = Bus::new(1, BusType::Slack, 138.0);
        let b2 = Bus::new(2, BusType::PV, 138.0);
        let b3 = Bus::new(3, BusType::PQ, 138.0);
        net.buses = vec![b1, b2, b3];
        net.loads.push(Load::new(3, 150.0, 0.0));

        // Triangle topology with moderate thermal limits.
        // This case should remain N-1 feasible and converge with no
        // residual screened violations in the final security pass.
        let mut br12 = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
        br12.rating_a_mva = 100.0;
        let mut br23 = Branch::new_line(2, 3, 0.01, 0.1, 0.0);
        br23.rating_a_mva = 100.0;
        let mut br13 = Branch::new_line(1, 3, 0.01, 0.1, 0.0);
        br13.rating_a_mva = 100.0;
        net.branches = vec![br12, br23, br13];

        let mut g1 = Generator::new(1, 0.0, 200.0);
        g1.pmin = 0.0;
        g1.pmax = 200.0;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 100.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0],
        });
        let mut g2 = Generator::new(2, 0.0, 100.0);
        g2.pmin = 0.0;
        g2.pmax = 100.0;
        g2.cost = Some(CostCurve::Polynomial {
            startup: 50.0,
            shutdown: 0.0,
            coeffs: vec![50.0, 0.0],
        });
        net.generators = vec![g1, g2];

        let scuc_opts = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: true,
            min_rate_a: 1.0,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };

        // Base SCUC for cost comparison
        let base_sol = solve_scuc(&net, &scuc_opts).unwrap();

        let request = crate::request::DispatchRequest {
            formulation: crate::Formulation::Dc,
            coupling: crate::request::IntervalCoupling::TimeCoupled,
            commitment: crate::request::CommitmentPolicy::Optimize(
                crate::request::CommitmentOptions::default(),
            ),
            timeline: crate::request::DispatchTimeline {
                periods: 1,
                interval_hours: 1.0,
                interval_hours_by_period: Vec::new(),
            },
            network: crate::request::DispatchNetwork {
                thermal_limits: crate::request::ThermalLimitPolicy {
                    enforce: true,
                    min_rate_a: 1.0,
                },
                security: Some(crate::request::SecurityPolicy {
                    max_iterations: 10,
                    violation_tolerance_pu: 0.001,
                    ..Default::default()
                }),
                ..Default::default()
            },
            ..Default::default()
        };

        let model = crate::DispatchModel::prepare(&net).unwrap();
        let result = crate::dispatch::solve_dispatch(&model, &request).unwrap();

        assert!(result.summary.total_cost > 0.0);
        let security = result
            .diagnostics
            .security
            .as_ref()
            .expect("security metadata");
        assert!(security.iterations <= 10);
        assert!(security.converged, "security outer loop should converge");
        assert_eq!(
            security.last_branch_violations, 0,
            "final screening pass should clear branch violations"
        );
        assert_eq!(
            security.last_hvdc_violations, 0,
            "final screening pass should clear HVDC violations"
        );
        assert!(
            security
                .max_branch_violation_pu
                .is_none_or(|violation| violation <= 0.001),
            "final branch violation should be within tolerance: {:?}",
            security.max_branch_violation_pu
        );
        assert!(
            security
                .max_hvdc_violation_pu
                .is_none_or(|violation| violation <= 0.001),
            "final HVDC violation should be within tolerance: {:?}",
            security.max_hvdc_violation_pu
        );
        // Security cost should be >= base (tighter constraints)
        assert!(
            result.summary.total_cost >= base_sol.summary.total_cost - 1.0,
            "Security cost ({:.2}) should be >= base cost ({:.2})",
            result.summary.total_cost,
            base_sol.summary.total_cost
        );

        // Power balance
        let total_gen: f64 = result.periods[0]
            .resource_results
            .iter()
            .map(|resource| resource.power_mw.max(0.0))
            .sum();
        let expected_load = net.total_load_mw();
        assert!(
            (total_gen - expected_load).abs() < 1.0,
            "gen={total_gen:.1}, expected ~{expected_load:.1} MW"
        );
    }

    #[test]
    fn test_explicit_security_objective_uses_worst_plus_average_penalties() {
        let mut net = Network::new("explicit_security_objective");
        net.base_mva = 100.0;
        net.buses = vec![
            Bus::new(1, BusType::Slack, 138.0),
            Bus::new(2, BusType::PQ, 138.0),
            Bus::new(3, BusType::PQ, 138.0),
        ];
        net.loads.push(Load::new(3, 150.0, 0.0));

        let mut br12 = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
        br12.rating_a_mva = 200.0;
        br12.rating_b_mva = 50.0;
        let mut br23 = Branch::new_line(2, 3, 0.01, 0.1, 0.0);
        br23.rating_a_mva = 200.0;
        br23.rating_b_mva = 50.0;
        let mut br13 = Branch::new_line(1, 3, 0.01, 0.1, 0.0);
        br13.rating_a_mva = 200.0;
        br13.rating_b_mva = 50.0;
        net.branches = vec![br12, br23, br13];

        let mut g1 = Generator::new(1, 0.0, 200.0);
        g1.pmin = 0.0;
        g1.pmax = 200.0;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0],
        });
        net.generators = vec![g1];

        let request = crate::request::DispatchRequest {
            formulation: crate::Formulation::Dc,
            coupling: crate::request::IntervalCoupling::TimeCoupled,
            commitment: crate::request::CommitmentPolicy::AllCommitted,
            timeline: crate::request::DispatchTimeline {
                periods: 1,
                interval_hours: 1.0,
                interval_hours_by_period: Vec::new(),
            },
            market: crate::request::DispatchMarket {
                penalty_config: PenaltyConfig {
                    thermal: PenaltyCurve::Linear {
                        cost_per_unit: 100.0,
                    },
                    ..PenaltyConfig::default()
                },
                ..Default::default()
            },
            network: crate::request::DispatchNetwork {
                thermal_limits: crate::request::ThermalLimitPolicy {
                    enforce: true,
                    min_rate_a: 1.0,
                },
                security: Some(crate::request::SecurityPolicy {
                    embedding: crate::request::SecurityEmbedding::ExplicitContingencies,
                    branch_contingencies: vec![
                        crate::request::BranchRef {
                            from_bus: 1,
                            to_bus: 2,
                            circuit: "1".to_string(),
                        },
                        crate::request::BranchRef {
                            from_bus: 1,
                            to_bus: 3,
                            circuit: "1".to_string(),
                        },
                    ],
                    ..Default::default()
                }),
                ..Default::default()
            },
            ..Default::default()
        };

        let model = crate::DispatchModel::prepare(&net).unwrap();
        let result = crate::dispatch::solve_dispatch(&model, &request).unwrap();

        let expected_energy_cost = 150.0 * 10.0;
        let expected_ctg_penalty = 20_000.0 + 15_000.0;
        let expected_total_cost = expected_energy_cost + expected_ctg_penalty;
        assert!(
            (result.summary.total_cost - expected_total_cost).abs() < 1.0,
            "total_cost {:.2} should reflect worst-case (20000) + average-case (15000) contingency penalties on top of energy cost {:.2}",
            result.summary.total_cost,
            expected_energy_cost
        );
        assert!(
            (result.periods[0].total_cost - expected_total_cost).abs() < 1.0,
            "period cost {:.2} should include the explicit contingency objective contribution",
            result.periods[0].total_cost
        );
    }
}

// ---------------------------------------------------------------------------
// Forbidden Operating Zone (FOZ) tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod foz_tests {
    use super::*;
    use crate::dispatch::{CommitmentMode, Horizon, IndexedCommitmentOptions};
    use crate::legacy::DispatchOptions;
    use surge_network::Network;
    use surge_network::market::CostCurve;
    use surge_network::network::{Branch, Bus, BusType, Generator};

    /// Build a 2-bus network with one cheap generator that has a forbidden zone.
    ///
    /// Gen1 (bus 1): Pmin=10, Pmax=200, cost=$10/MWh, forbidden zone [80, 120] MW.
    /// Gen2 (bus 2): Pmin=0,  Pmax=200, cost=$50/MWh, no forbidden zones.
    /// Load: 100 MW at bus 2.
    ///
    /// Without FOZ: Gen1 dispatches 100 MW (cheapest).
    /// With FOZ:    100 MW is inside [80,120], so Gen1 must dispatch either
    ///              ≤80 MW or ≥120 MW.  Cheapest feasible: Gen1=120, Gen2=0
    ///              (curtail down to 100 via system balance), or Gen1=80 + Gen2=20.
    fn make_foz_network() -> Network {
        let mut net = Network::new("foz_test");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        let b2 = Bus::new(2, BusType::PQ, 138.0);
        net.buses.push(b1);
        net.buses.push(b2);
        net.loads.push(Load::new(2, 100.0, 0.0));

        let mut br = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
        br.rating_a_mva = 500.0;
        net.branches.push(br);

        let mut g1 = Generator::new(1, 0.0, 1.0);
        g1.pmin = 10.0;
        g1.pmax = 200.0;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 100.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0],
        });
        g1.commitment.get_or_insert_default().forbidden_zones = vec![(80.0, 120.0)];

        let mut g2 = Generator::new(2, 0.0, 1.0);
        g2.pmin = 0.0;
        g2.pmax = 200.0;
        g2.cost = Some(CostCurve::Polynomial {
            startup: 100.0,
            shutdown: 0.0,
            coeffs: vec![50.0, 0.0],
        });

        net.generators.push(g1);
        net.generators.push(g2);
        net
    }

    /// With enforce_forbidden_zones=false, Gen1 dispatches 100 MW (inside the
    /// forbidden zone [80,120]).
    #[test]
    fn test_foz_disabled_dispatches_inside_zone() {
        let net = make_foz_network();
        let opts = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            enforce_forbidden_zones: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();
        let pg1 = sol.periods[0].pg_mw[0];
        // Gen1 should dispatch ~100 MW (load is 100, Gen1 is cheapest)
        assert!(
            (pg1 - 100.0).abs() < 1.0,
            "Without FOZ, Gen1 should dispatch ~100 MW, got {pg1:.2}"
        );
    }

    /// With enforce_forbidden_zones=true, Gen1 must avoid [80,120] MW.
    /// Load=100 MW → Gen1=80 + Gen2=20 (cost = 80*10 + 20*50 = $1800)
    ///           or Gen1=120 + Gen2=0 but power balance demands 100 → Gen1=120 is over-gen.
    /// With Pmin=10 for Gen1, the solver picks Gen1=80 MW + Gen2=20 MW
    /// (segment 0: [10, 80]) since it's cheaper than segment 2: [120, 200].
    #[test]
    fn test_foz_enabled_avoids_zone() {
        let net = make_foz_network();
        let opts = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            enforce_forbidden_zones: true,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();
        let pg1 = sol.periods[0].pg_mw[0];
        let pg2 = sol.periods[0].pg_mw[1];

        // Gen1 must be outside [80, 120]. Either ≤80 or ≥120.
        let in_zone = pg1 > 80.0 + 0.1 && pg1 < 120.0 - 0.1;
        assert!(
            !in_zone,
            "FOZ violated: Gen1={pg1:.2} MW is inside forbidden zone [80,120]"
        );

        // Power balance
        let total = pg1 + pg2;
        assert!(
            (total - 100.0).abs() < 1.0,
            "Power balance: total={total:.2}, expected 100 MW"
        );

        // Optimal dispatch: Gen1=80, Gen2=20 (cost=$1800)
        // vs Gen1=120, Gen2=-20 → infeasible (Gen2 pmin=0)
        assert!(
            (pg1 - 80.0).abs() < 1.0,
            "Expected Gen1=80 MW (below zone), got {pg1:.2}"
        );
        assert!(
            (pg2 - 20.0).abs() < 1.0,
            "Expected Gen2=20 MW (make up shortfall), got {pg2:.2}"
        );
    }

    /// Multiple forbidden zones on one generator.
    /// Gen1: Pmin=10, Pmax=300, zones [50,80] and [150,180].
    /// Load=200 MW → Gen1 must be in segment [80,150] or [180,300].
    #[test]
    fn test_foz_multi_zone() {
        let mut net = make_foz_network();
        net.generators[0].pmax = 300.0;
        net.generators[0]
            .commitment
            .get_or_insert_default()
            .forbidden_zones = vec![(50.0, 80.0), (150.0, 180.0)];
        net.loads[0].active_power_demand_mw = 200.0; // 200 MW load

        let opts = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            enforce_forbidden_zones: true,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();
        let pg1 = sol.periods[0].pg_mw[0];
        let pg2 = sol.periods[0].pg_mw[1];

        // Gen1 must NOT be in [50,80] or [150,180]
        let in_zone1 = pg1 > 50.0 + 0.1 && pg1 < 80.0 - 0.1;
        let in_zone2 = pg1 > 150.0 + 0.1 && pg1 < 180.0 - 0.1;
        assert!(
            !in_zone1 && !in_zone2,
            "FOZ violated: Gen1={pg1:.2} MW is inside a forbidden zone"
        );

        // Power balance
        let total = pg1 + pg2;
        assert!(
            (total - 200.0).abs() < 1.0,
            "Power balance: total={total:.2}, expected 200 MW"
        );
    }

    /// Multi-period: verify FOZ is enforced across all hours.
    #[test]
    fn test_foz_multi_period() {
        let net = make_foz_network();
        let opts = DispatchOptions {
            n_periods: 3,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            enforce_forbidden_zones: true,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        for (t, period) in sol.periods.iter().enumerate() {
            let pg1 = period.pg_mw[0];
            let in_zone = pg1 > 80.0 + 0.1 && pg1 < 120.0 - 0.1;
            assert!(
                !in_zone,
                "Hour {t}: FOZ violated: Gen1={pg1:.2} MW is inside [80,120]"
            );
        }
    }

    /// FOZ with degenerate case: zone clears in one hourly interval.
    /// With dt_hours=1.0 and ramp_rate=100 MW/min, zone width=40 MW clears
    /// in 40/100=0.4 min → max_transit=0 → transit disabled → pure segment selection.
    #[test]
    fn test_foz_degenerate_hourly() {
        let mut net = make_foz_network();
        net.generators[0]
            .ramping
            .get_or_insert_default()
            .ramp_up_curve = vec![(0.0, 100.0)]; // 100 MW/min

        let opts = DispatchOptions {
            n_periods: 2,
            dt_hours: 1.0,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            enforce_forbidden_zones: true,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };
        let sol = solve_scuc(&net, &opts).unwrap();

        for (t, period) in sol.periods.iter().enumerate() {
            let pg1 = period.pg_mw[0];
            let in_zone = pg1 > 80.0 + 0.1 && pg1 < 120.0 - 0.1;
            assert!(
                !in_zone,
                "Hour {t}: FOZ violated in degenerate case: Gen1={pg1:.2}"
            );
        }
    }

    /// FOZ cost impact: FOZ enabled should have higher or equal cost than disabled.
    #[test]
    fn test_foz_cost_impact() {
        let net = make_foz_network();

        let opts_off = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            enforce_forbidden_zones: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };
        let sol_off = solve_scuc(&net, &opts_off).unwrap();

        let opts_on = DispatchOptions {
            enforce_forbidden_zones: true,
            ..opts_off.clone()
        };
        let sol_on = solve_scuc(&net, &opts_on).unwrap();

        assert!(
            sol_on.summary.total_cost >= sol_off.summary.total_cost - 1e-6,
            "FOZ should increase cost: on={:.2}, off={:.2}",
            sol_on.summary.total_cost,
            sol_off.summary.total_cost
        );
    }

    /// Generator with no forbidden zones is unaffected by enforce_forbidden_zones=true.
    #[test]
    fn test_foz_no_zones_unaffected() {
        let mut net = make_foz_network();
        if let Some(c) = &mut net.generators[0].commitment {
            c.forbidden_zones.clear()
        }; // remove zones from Gen1

        let opts_off = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            enforce_forbidden_zones: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };
        let sol_off = solve_scuc(&net, &opts_off).unwrap();

        let opts_on = DispatchOptions {
            enforce_forbidden_zones: true,
            ..opts_off.clone()
        };
        let sol_on = solve_scuc(&net, &opts_on).unwrap();

        // Same dispatch — no zones to enforce
        let pg1_off = sol_off.periods[0].pg_mw[0];
        let pg1_on = sol_on.periods[0].pg_mw[0];
        assert!(
            (pg1_off - pg1_on).abs() < 1.0,
            "No zones: dispatch should be the same. off={pg1_off:.2}, on={pg1_on:.2}"
        );
    }
}

// ---------------------------------------------------------------------------
// Reserve clearing price row-offset bug (pre-existing)
// ---------------------------------------------------------------------------

#[cfg(test)]
mod reserve_price_bug_tests {
    use super::*;
    use crate::dispatch::{CommitmentMode, Horizon, IndexedCommitmentOptions};
    use crate::legacy::DispatchOptions;
    use surge_network::Network;
    use surge_network::market::{CostCurve, SystemReserveRequirement};
    use surge_network::network::{
        Bus, BusType, CommitmentStatus, Generator, StorageDispatchMode, StorageParams,
    };

    /// Verify that SCUC reserve clearing prices are consistent with and
    /// without storage rows in the per-hour constraint block.
    ///
    /// Setup:
    ///   - 1-bus, 2-gen network with 250 MW spinning reserve requirement
    ///   - Gen1 must-run ($10/MWh, pmax=150), Gen2 market ($50/MWh, pmax=50), load 100 MW
    ///   - BESS at bus 1: 50 MW, 200 MWh (adds SoC rows to per-hour block)
    ///
    /// The reserve requirement exceeds total capacity so the penalty slack
    /// is active and the clearing price should equal the penalty cost.
    #[test]
    #[ignore = "regression test for a48c7c01 (SCUC reserve-row-offset fix); fix not in release/v0.1.2 — revisit after release"]
    fn test_scuc_reserve_price_with_storage_wrong_offset() {
        // Reserve-short system: requirement > available headroom → penalty
        // slack must be positive → clearing price = penalty cost = $1000/MWh.
        //
        // Gen1: must-run, pmax=150, load=100 → headroom=50 MW
        // Gen2: market, pmax=50 → headroom=50 MW if committed (at pmin=0)
        // Total gen headroom: 100 MW. Storage adds ~50 MW reserves.
        // Reserve req: 250 MW >> total capacity → both cases are short.
        // Both should price at $1000/MWh regardless of storage presence.
        //
        // BUG: the row offset calculation reads from the wrong constraint
        // row when storage rows exist between reserve and end-of-hour.
        let mut net = Network::new("reserve_price_bug");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b1);
        net.loads.push(Load::new(1, 100.0, 0.0));

        // No branch needed — single-bus copperplate

        let mut g1 = Generator::new(1, 100.0, 1.0);
        g1.pmin = 10.0;
        g1.pmax = 150.0;
        g1.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0],
        });

        let mut g2 = Generator::new(1, 0.0, 1.0);
        g2.pmin = 0.0;
        g2.pmax = 50.0;
        g2.commitment.get_or_insert_default().status = CommitmentStatus::Market;
        g2.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![50.0, 0.0],
        });

        net.generators.push(g1);
        net.generators.push(g2);

        // Case A: reserve WITHOUT storage
        let opts_no_storage = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            system_reserve_requirements: vec![SystemReserveRequirement {
                product_id: "spin".into(),
                requirement_mw: 250.0,
                per_period_mw: None,
            }],
            ..Default::default()
        };
        let sol_no_storage = solve_scuc(&net, &opts_no_storage).unwrap();

        let price_no_sto = sol_no_storage.periods[0]
            .reserve_prices
            .get("spin")
            .copied()
            .unwrap_or(0.0);

        // Case B: reserve WITH storage
        // Add storage generator at bus 1 (BESS: 50 MW, 200 MWh, eta=1.0, soc_initial=100 MWh)
        let mut net_with_sto = net.clone();
        let bess_g = Generator {
            bus: 1,
            in_service: true,
            pmin: -50.0,
            pmax: 50.0,
            machine_base_mva: 100.0,
            cost: Some(CostCurve::Polynomial {
                coeffs: vec![0.0],
                startup: 0.0,
                shutdown: 0.0,
            }),
            storage: Some(StorageParams {
                charge_efficiency: 1.0,
                discharge_efficiency: 1.0,
                energy_capacity_mwh: 200.0,
                soc_initial_mwh: 100.0,
                soc_min_mwh: 0.0,
                soc_max_mwh: 200.0,
                variable_cost_per_mwh: 0.0,
                degradation_cost_per_mwh: 0.0,
                dispatch_mode: StorageDispatchMode::CostMinimization,
                self_schedule_mw: 0.0,
                discharge_offer: None,
                charge_bid: None,
                max_c_rate_charge: None,
                max_c_rate_discharge: None,
                chemistry: None,
                discharge_foldback_soc_mwh: None,
                charge_foldback_soc_mwh: None,
                daily_cycle_limit: None,
            }),
            ..Generator::default()
        };
        net_with_sto.generators.push(bess_g);

        let opts_with_storage = opts_no_storage.clone();
        let sol_with_storage = solve_scuc(&net_with_sto, &opts_with_storage).unwrap();

        let price_with_sto = sol_with_storage.periods[0]
            .reserve_prices
            .get("spin")
            .copied()
            .unwrap_or(0.0);

        // No-storage price should be non-zero (reserve is short → penalty price)
        assert!(
            price_no_sto > 1.0,
            "no-storage price should be positive (reserve shortage forces penalty), \
             got ${price_no_sto:.4}/MWh"
        );

        // Both prices must match — adding storage doesn't change the
        // reserve constraint, only the row layout.
        let price_diff = (price_with_sto - price_no_sto).abs();
        assert!(
            price_diff < 1.0,
            "Reserve price mismatch: no_sto=${price_no_sto:.4}, \
             with_sto=${price_with_sto:.4}, diff=${price_diff:.4}"
        );
    }
}

#[cfg(test)]
mod block_mode_scuc_tests {
    use super::*;
    use crate::dispatch::{CommitmentMode, Horizon, IndexedCommitmentOptions};
    use crate::legacy::DispatchOptions;
    use crate::request::RampMode;
    use surge_network::Network;
    use surge_network::market::CostCurve;
    use surge_network::network::{Bus, BusType, Generator};

    fn one_bus_network(gens: Vec<Generator>) -> Network {
        let mut net = Network::new("block_scuc_test");
        net.base_mva = 100.0;
        let b1 = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b1);
        net.loads.push(Load::new(1, 200.0, 0.0));
        for g in gens {
            net.generators.push(g);
        }
        net
    }

    fn gen_with_cost(pmin: f64, pmax: f64, marginal: f64) -> Generator {
        let mut g = Generator::new(1, 0.0, 1.0);
        g.pmin = pmin;
        g.pmax = pmax;
        g.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![marginal, 0.0],
        });
        g
    }

    fn scuc_opts(ramp_mode: RampMode) -> DispatchOptions {
        DispatchOptions {
            n_periods: 4,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ramp_mode,
            ..DispatchOptions::default()
        }
    }

    /// Block mode SCUC with linear cost should match standard mode dispatch and cost.
    #[test]
    fn test_block_mode_scuc_matches_averaged() {
        let net = one_bus_network(vec![
            gen_with_cost(50.0, 300.0, 10.0),
            gen_with_cost(50.0, 200.0, 40.0),
        ]);

        let avg_sol = solve_scuc(&net, &scuc_opts(RampMode::Averaged)).unwrap();
        let blk_sol = solve_scuc(
            &net,
            &scuc_opts(RampMode::Block {
                per_block_reserves: false,
            }),
        )
        .unwrap();

        // Same total cost
        let rel_err = (blk_sol.summary.total_cost - avg_sol.summary.total_cost).abs()
            / avg_sol.summary.total_cost.abs().max(1.0);
        assert!(
            rel_err < 1e-4,
            "Block cost={:.2}, Averaged cost={:.2}, rel_err={:.6}",
            blk_sol.summary.total_cost,
            avg_sol.summary.total_cost,
            rel_err,
        );

        // Same commitment each hour
        for t in 0..4 {
            assert_eq!(
                commitment_schedule(&blk_sol)[t],
                commitment_schedule(&avg_sol)[t],
                "commitment differs at hour {t}"
            );
        }

        // Same dispatch each hour
        for t in 0..4 {
            for (j, (a, b)) in avg_sol.periods[t]
                .pg_mw
                .iter()
                .zip(blk_sol.periods[t].pg_mw.iter())
                .enumerate()
            {
                assert!(
                    (a - b).abs() < 0.5,
                    "hour {t} gen {j}: avg={a:.2}, block={b:.2}"
                );
            }
        }
    }

    /// Block mode correctly handles commitment — decommitted units have zero dispatch.
    #[test]
    fn test_block_mode_scuc_decommit() {
        // 2 cheap gens can serve full load — 3rd expensive gen should decommit
        let net = one_bus_network(vec![
            gen_with_cost(0.0, 150.0, 10.0),
            gen_with_cost(0.0, 150.0, 15.0),
            gen_with_cost(0.0, 150.0, 100.0),
        ]);

        let sol = solve_scuc(
            &net,
            &scuc_opts(RampMode::Block {
                per_block_reserves: false,
            }),
        )
        .unwrap();

        for t in 0..4 {
            // Expensive gen should be off (or dispatched near 0)
            if !commitment_schedule(&sol)[t][2] {
                assert!(
                    sol.periods[t].pg_mw[2].abs() < 0.1,
                    "hour {t}: decommitted gen has dispatch={:.2}",
                    sol.periods[t].pg_mw[2]
                );
            }
        }

        // Power balance each hour
        for t in 0..4 {
            let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
            assert!(
                (total_gen - 200.0).abs() < 1.0,
                "hour {t}: gen={total_gen:.2}, load=200.0"
            );
        }
    }

    /// Block mode with PWL cost correctly decomposes cost above Pmin.
    #[test]
    fn test_block_mode_scuc_pwl_cost() {
        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.pmin = 50.0;
        g0.pmax = 250.0;
        g0.cost = Some(CostCurve::PiecewiseLinear {
            startup: 0.0,
            shutdown: 0.0,
            points: vec![
                (50.0, 500.0),
                (150.0, 1500.0), // slope=10
                (250.0, 3500.0), // slope=20
            ],
        });

        let g1 = gen_with_cost(0.0, 100.0, 25.0);

        let net = one_bus_network(vec![g0, g1]);

        let sol = solve_scuc(
            &net,
            &scuc_opts(RampMode::Block {
                per_block_reserves: false,
            }),
        )
        .unwrap();

        assert!(sol.summary.total_cost > 0.0, "cost should be positive");

        // Power balance each hour
        for t in 0..4 {
            let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
            assert!(
                (total_gen - 200.0).abs() < 1.0,
                "hour {t}: gen={total_gen:.2}, load=200.0"
            );
        }
    }

    /// Per-block reserves in SCUC produce feasible dispatch with reserve awards.
    #[test]
    fn test_block_mode_scuc_per_block_reserves() {
        use surge_network::market::SystemReserveRequirement;

        let mut net = one_bus_network(vec![
            gen_with_cost(50.0, 300.0, 10.0),
            gen_with_cost(50.0, 200.0, 40.0),
        ]);

        // Populate reserve_offers so generators can provide spinning reserve.
        for g in &mut net.generators {
            let phys_cap = (g.pmax - g.pmin).max(0.0);
            if phys_cap > 0.0 {
                g.market.get_or_insert_default().reserve_offers.push(
                    surge_network::market::ReserveOffer {
                        product_id: "spin".into(),
                        capacity_mw: phys_cap,
                        cost_per_mwh: 0.0,
                    },
                );
            }
        }

        let opts = DispatchOptions {
            n_periods: 4,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ramp_mode: RampMode::Block {
                per_block_reserves: true,
            },
            system_reserve_requirements: vec![SystemReserveRequirement {
                product_id: "spin".into(),
                requirement_mw: 30.0,
                per_period_mw: None,
            }],
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        // Power balance each hour
        for t in 0..4 {
            let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
            assert!(
                (total_gen - 200.0).abs() < 1.0,
                "hour {t}: gen={total_gen:.2}, load=200.0"
            );
        }

        // Reserve awarded each hour
        for t in 0..4 {
            let spin_awards = sol.periods[t]
                .reserve_awards
                .get("spin")
                .expect("spin awards");
            let total_reserve: f64 = spin_awards.iter().sum();
            assert!(
                total_reserve >= 29.9,
                "hour {t}: reserve={total_reserve:.1}, required=30"
            );
        }
    }

    /// Regulation binary is decided in SCUC; regulation[t] populated.
    #[test]
    fn test_block_mode_scuc_regulation_binary() {
        use surge_network::market::{ReserveOffer, ReserveProduct, SystemReserveRequirement};

        let mut g0 = gen_with_cost(50.0, 300.0, 10.0);
        g0.market
            .get_or_insert_default()
            .reserve_offers
            .push(ReserveOffer {
                product_id: "reg_up".into(),
                capacity_mw: 50.0,
                cost_per_mwh: 0.0,
            });

        let mut g1 = gen_with_cost(50.0, 200.0, 40.0);
        g1.market
            .get_or_insert_default()
            .reserve_offers
            .push(ReserveOffer {
                product_id: "reg_up".into(),
                capacity_mw: 30.0,
                cost_per_mwh: 0.0,
            });

        let net = one_bus_network(vec![g0, g1]);

        let opts = DispatchOptions {
            n_periods: 2,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ramp_mode: RampMode::Block {
                per_block_reserves: true,
            },
            reserve_products: vec![ReserveProduct {
                id: "reg_up".into(),
                name: "Regulation Up".into(),
                direction: surge_network::market::ReserveDirection::Up,
                deploy_secs: 300.0,
                qualification: surge_network::market::QualificationRule::Committed,
                energy_coupling: surge_network::market::EnergyCoupling::Headroom,
                dispatchable_load_energy_coupling: None,
                shared_limit_products: Vec::new(),
                balance_products: Vec::new(),
                kind: surge_network::market::ReserveKind::Real,
                apply_deploy_ramp_limit: true,
                demand_curve: surge_network::market::PenaltyCurve::Linear {
                    cost_per_unit: 1000.0,
                },
            }],
            system_reserve_requirements: vec![SystemReserveRequirement {
                product_id: "reg_up".into(),
                requirement_mw: 30.0,
                per_period_mw: None,
            }],
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        // Regulation field should be populated with non-empty per-hour vectors
        assert_eq!(sol.regulation.len(), 2, "regulation should have 2 hours");
        for t in 0..2 {
            assert_eq!(
                sol.regulation[t].len(),
                2,
                "hour {t}: regulation should have 2 gens"
            );
            // At least one gen should be in regulation mode
            let n_reg: usize = sol.regulation[t].iter().filter(|&&r| r).count();
            assert!(
                n_reg >= 1,
                "hour {t}: at least 1 gen should regulate, got {n_reg}"
            );
        }

        // Power balance
        for t in 0..2 {
            let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
            assert!(
                (total_gen - 200.0).abs() < 1.0,
                "hour {t}: gen={total_gen:.2}, load=200.0"
            );
        }

        // Reserve met
        for t in 0..2 {
            let reg_awards = sol.periods[t]
                .reserve_awards
                .get("reg_up")
                .expect("reg_up awards");
            let total_reserve: f64 = reg_awards.iter().sum();
            assert!(
                total_reserve >= 29.9,
                "hour {t}: reg_up reserve={total_reserve:.1}, required=30"
            );
        }
    }
}

// ---------------------------------------------------------------------------
// Tests for max starts, soak, max up time, and max energy constraints
// ---------------------------------------------------------------------------
#[cfg(test)]
mod generator_constraint_tests {
    use super::*;
    use crate::dispatch::{
        CommitmentMode, Horizon, IndexedCommitmentOptions, IndexedEnergyWindowLimit,
        IndexedStartupWindowLimit,
    };
    use crate::legacy::DispatchOptions;
    use surge_network::Network;
    use surge_network::market::{CostCurve, GeneratorDerateProfile, GeneratorDerateProfiles};
    use surge_network::network::{Bus, BusType, Generator};

    /// Build a 1-bus network with the given generators and specified load.
    fn net_with_load(load_mw: f64, gens: Vec<Generator>) -> Network {
        let mut net = Network::new("gen_constraint_test");
        net.base_mva = 100.0;
        let b = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b);
        net.loads.push(Load::new(1, load_mw, 0.0));
        for g in gens {
            net.generators.push(g);
        }
        net
    }

    /// Make a generator with linear cost.
    fn make_gen(pmin: f64, pmax: f64, cost_per_mwh: f64, startup_cost: f64) -> Generator {
        let mut g = Generator::new(1, 0.0, 1.0);
        g.pmin = pmin;
        g.pmax = pmax;
        g.cost = Some(CostCurve::Polynomial {
            startup: startup_cost,
            shutdown: 0.0,
            coeffs: vec![cost_per_mwh, 0.0],
        });
        g
    }

    /// Default SCUC options for n_hours.
    fn scuc_opts(n_hours: usize, commit_opts: IndexedCommitmentOptions) -> DispatchOptions {
        DispatchOptions {
            n_periods: n_hours,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(commit_opts),
            ..DispatchOptions::default()
        }
    }

    /// Max starts per day = 1: a cheap gen that should want to cycle but is limited.
    #[test]
    fn test_scuc_max_starts_per_day() {
        // 2 gens: cheap (pmax=100, cost=10) + expensive (pmax=200, cost=50, must-run).
        // Load profile: hours 0-2 = 150 MW, hours 3-5 = 50 MW, hours 6-8 = 150 MW.
        // Without limit, cheap gen would start at t=0, shut down at t=3, restart at t=6 (2 starts).
        // With max_starts_per_day=1, it can only start once.
        let mut g_cheap = make_gen(30.0, 100.0, 10.0, 100.0);
        g_cheap
            .commitment
            .get_or_insert_default()
            .max_starts_per_day = Some(1);
        g_cheap.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::Market;

        let mut g_expensive = make_gen(10.0, 200.0, 50.0, 0.0);
        g_expensive.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::MustRun;

        let net = net_with_load(150.0, vec![g_cheap, g_expensive]);

        // Use load profiles to create cycling demand
        let mut opts = scuc_opts(
            9,
            IndexedCommitmentOptions {
                initial_commitment: Some(vec![false, true]),
                ..Default::default()
            },
        );
        // Low-demand hours 3-5: drop load to 50 MW
        let mut load_profiles = surge_network::market::LoadProfiles::default();
        load_profiles
            .profiles
            .push(surge_network::market::LoadProfile {
                bus: 1,
                load_mw: vec![150.0, 150.0, 150.0, 50.0, 50.0, 50.0, 150.0, 150.0, 150.0],
            });
        opts.load_profiles = load_profiles;

        let sol = solve_scuc(&net, &opts).unwrap();

        // Count startup events for gen 0 (cheap)
        let starts: usize = startup_schedule(&sol)
            .iter()
            .map(|s| if s[0] { 1 } else { 0 })
            .sum();
        assert!(
            starts <= 1,
            "max_starts_per_day=1 but got {starts} startups for gen 0"
        );
    }

    /// Max starts per week with pre-horizon initial starts.
    #[test]
    fn test_scuc_max_starts_per_week() {
        let mut g = make_gen(30.0, 100.0, 10.0, 50.0);
        g.commitment.get_or_insert_default().max_starts_per_week = Some(2);

        let mut g_must = make_gen(10.0, 200.0, 50.0, 0.0);
        g_must.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::MustRun;

        let net = net_with_load(150.0, vec![g, g_must]);

        let mut opts = scuc_opts(
            24,
            IndexedCommitmentOptions {
                initial_commitment: Some(vec![false, true]),
                // Already used 1 start this week
                initial_starts_168h: Some(vec![1, 0]),
                ..Default::default()
            },
        );

        // Create cycling load
        let load_mw: Vec<f64> = (0..24)
            .map(|h| if (h / 4) % 2 == 0 { 150.0 } else { 50.0 })
            .collect();
        let mut load_profiles = surge_network::market::LoadProfiles::default();
        load_profiles
            .profiles
            .push(surge_network::market::LoadProfile { bus: 1, load_mw });
        opts.load_profiles = load_profiles;

        let sol = solve_scuc(&net, &opts).unwrap();

        // With initial_starts_168h=1 and max=2, at most 1 more start allowed
        let starts: usize = startup_schedule(&sol)
            .iter()
            .map(|s| if s[0] { 1 } else { 0 })
            .sum();
        assert!(
            starts <= 1,
            "max_starts_per_week=2 with 1 pre-horizon start, but got {starts} in-horizon starts"
        );
    }

    /// Min run at pmin: after startup, gen is held at pmin for soak period.
    #[test]
    fn test_scuc_min_run_at_pmin() {
        // Gen 0 (cheap, soak=2h): pmin=30, pmax=200.
        // Gen 1 (must-run, expensive): pmin=10, pmax=300 — can cover load alone.
        // Load = 150 MW → gen 0 should start to save cost, but is limited to pmin=30
        // for 2 hours after startup.
        let mut g = make_gen(30.0, 200.0, 10.0, 50.0);
        g.commitment.get_or_insert_default().min_run_at_pmin_hr = Some(2.0); // 2-hour soak
        g.commitment.get_or_insert_default().min_up_time_hr = Some(4.0); // must stay on at least 4h

        let mut g_must = make_gen(10.0, 300.0, 50.0, 0.0);
        g_must.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::MustRun;

        let net = net_with_load(150.0, vec![g, g_must]);

        let opts = scuc_opts(
            6,
            IndexedCommitmentOptions {
                initial_commitment: Some(vec![false, true]),
                ..Default::default()
            },
        );

        let sol = solve_scuc(&net, &opts).unwrap();

        // Find the hour where gen 0 starts up
        for t in 0..6 {
            if startup_schedule(&sol)[t][0] {
                // For the next 2 hours (soak period), Pg should be ≤ pmin (30 MW) + tolerance
                for s in 0..2 {
                    let soak_t = t + s;
                    if soak_t < 6 {
                        assert!(
                            sol.periods[soak_t].pg_mw[0] <= 31.0,
                            "hour {soak_t}: gen 0 should be at pmin during soak, got {:.1} MW",
                            sol.periods[soak_t].pg_mw[0]
                        );
                    }
                }
            }
        }
    }

    /// Max up time: generator forced to shut down after max continuous run.
    #[test]
    fn test_scuc_max_up_time() {
        let mut g = make_gen(30.0, 200.0, 10.0, 50.0);
        g.commitment.get_or_insert_default().max_up_time_hr = Some(4.0); // must shut down after 4 consecutive hours
        g.commitment.get_or_insert_default().min_down_time_hr = Some(1.0);

        let mut g_must = make_gen(10.0, 300.0, 50.0, 0.0);
        g_must.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::MustRun;

        let net = net_with_load(200.0, vec![g, g_must]);

        let opts = scuc_opts(
            8,
            IndexedCommitmentOptions {
                initial_commitment: Some(vec![true, true]),
                ..Default::default()
            },
        );

        let sol = solve_scuc(&net, &opts).unwrap();

        // Find the longest consecutive run for gen 0
        let mut max_run = 0;
        let mut current_run = 0;
        for t in 0..8 {
            if commitment_schedule(&sol)[t][0] {
                current_run += 1;
                max_run = max_run.max(current_run);
            } else {
                current_run = 0;
            }
        }
        assert!(
            max_run <= 4,
            "max_up_time_hr=4 but longest consecutive run was {max_run} hours"
        );
    }

    /// Max up time with initial hours on: gen already online 3h, max=4h → must shut off by t=1.
    #[test]
    fn test_scuc_max_up_time_initial() {
        let mut g = make_gen(30.0, 200.0, 10.0, 50.0);
        g.commitment.get_or_insert_default().max_up_time_hr = Some(4.0);
        g.commitment.get_or_insert_default().min_down_time_hr = Some(1.0);

        let mut g_must = make_gen(10.0, 300.0, 50.0, 0.0);
        g_must.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::MustRun;

        let net = net_with_load(200.0, vec![g, g_must]);

        let opts = scuc_opts(
            8,
            IndexedCommitmentOptions {
                initial_commitment: Some(vec![true, true]),
                initial_hours_on: Some(vec![3, 0]), // gen 0 has been on for 3h already
                ..Default::default()
            },
        );

        let sol = solve_scuc(&net, &opts).unwrap();

        // Gen 0 has been on 3h + can stay on at most 1 more hour (total 4h max).
        // So it must shut down by t=1 at the latest.
        // Check that gen 0 is off at some point in [0, 1]:
        let off_by_t1 = !commitment_schedule(&sol)[0][0] || !commitment_schedule(&sol)[1][0];
        assert!(
            off_by_t1,
            "max_up_time=4h with initial_hours_on=3: gen should be off by t=1, commitment={:?}",
            commitment_schedule(&sol)
                .iter()
                .map(|c| c[0])
                .collect::<Vec<_>>()
        );
    }

    /// Issue 3: SCUC must enforce ramp constraints at t=0 when prev_dispatch_mw
    /// is provided.  Without the fix, Pg[0] can jump from prev_dispatch to Pmax
    /// in a single step, violating physical ramp limits.
    #[test]
    fn test_scuc_initial_ramp_from_prev_dispatch() {
        // Gen 0: cheap, ramp = 5 MW/min → 300 MW/hr. pmin=30, pmax=500.
        // Gen 1: expensive must-run backup, pmax=500.
        // Load = 450 MW → gen 0 wants to dispatch ~400 MW.
        // prev_dispatch_mw = [100, 350] → gen 0 starts at 100 MW.
        // Ramp-up limit: 100 + 300 = 400 MW max at t=0.
        // Without initial ramp constraint, gen 0 could jump to 500 MW at t=0.
        let mut g0 = make_gen(30.0, 500.0, 10.0, 0.0);
        g0.ramping.get_or_insert_default().ramp_up_curve = vec![(0.0, 5.0)]; // 5 MW/min
        g0.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::MustRun;

        let mut g1 = make_gen(10.0, 500.0, 50.0, 0.0);
        g1.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::MustRun;

        let net = net_with_load(450.0, vec![g0, g1]);

        let opts = DispatchOptions {
            n_periods: 2,
            dt_hours: 1.0,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                initial_commitment: Some(vec![true, true]),
                ..Default::default()
            }),
            initial_state: IndexedDispatchInitialState {
                prev_dispatch_mw: Some(vec![100.0, 350.0]),
                ..Default::default()
            },
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        // Gen 0 at t=0 must be ≤ 100 + 300 = 400 MW
        let pg0_t0 = sol.periods[0].pg_mw[0];
        assert!(
            pg0_t0 <= 400.0 + 1.0,
            "gen 0 at t=0 should be ≤ 400 MW (prev=100 + ramp=300), got {pg0_t0:.1}"
        );
        // And must be ≥ 0 (ramp-down from 100 is not binding since pmin=30)
        assert!(
            pg0_t0 >= 29.0,
            "gen 0 at t=0 should be ≥ pmin=30, got {pg0_t0:.1}"
        );
    }

    /// Issue 4: Pre-horizon MUT carryover for regular generators.
    /// If a generator was on for fewer hours than min_up_time before the horizon,
    /// it must remain committed for the remaining MUT periods.
    #[test]
    fn test_scuc_pre_horizon_mut_regular_gen() {
        // Gen 0: cheap, MUT=6h. Was on for 2h before horizon → must stay on for 4 more hours.
        // Gen 1: expensive must-run backup.
        // Load = 50 MW → very low, gen 0 (pmin=30) wants to shut down to save startup.
        // But MUT carryover forces it on for t=0..3.
        let mut g0 = make_gen(30.0, 200.0, 10.0, 100.0);
        g0.commitment.get_or_insert_default().min_up_time_hr = Some(6.0);
        g0.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::Market;

        let mut g1 = make_gen(10.0, 300.0, 50.0, 0.0);
        g1.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::MustRun;

        let net = net_with_load(50.0, vec![g0, g1]);

        let opts = scuc_opts(
            8,
            IndexedCommitmentOptions {
                initial_commitment: Some(vec![true, true]),
                initial_hours_on: Some(vec![2, 0]), // gen 0 on for 2h, needs 4 more
                ..Default::default()
            },
        );

        let sol = solve_scuc(&net, &opts).unwrap();

        // Gen 0 must be committed for t=0..3 (4 remaining MUT periods)
        for t in 0..4 {
            assert!(
                commitment_schedule(&sol)[t][0],
                "MUT carryover: gen 0 must be on at t={t} (2h on + MUT=6h → 4h remaining), \
                 commitment={:?}",
                commitment_schedule(&sol)
                    .iter()
                    .map(|c| c[0])
                    .collect::<Vec<_>>()
            );
            assert!(
                !startup_schedule(&sol)[t][0],
                "MUT carryover should not fabricate a startup while gen 0 is forced on at t={t}"
            );
            assert!(
                !shutdown_schedule(&sol)[t][0],
                "MUT carryover should not allow a shutdown while gen 0 is forced on at t={t}"
            );
        }
    }

    /// A physical outage at the horizon start must override pre-horizon MUT carryover.
    /// The unit may shut down immediately instead of conflicting with the forced-offline bound.
    #[test]
    fn test_scuc_pre_horizon_mut_yields_to_forced_outage() {
        let mut g0 = make_gen(30.0, 200.0, 10.0, 100.0);
        g0.commitment.get_or_insert_default().min_up_time_hr = Some(6.0);
        g0.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::Market;

        let mut g1 = make_gen(10.0, 300.0, 50.0, 0.0);
        g1.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::MustRun;

        let mut net = net_with_load(50.0, vec![g0, g1]);
        net.canonicalize_generator_ids();
        net.validate().expect("test network should validate");
        let g0_id = net.generators[0].id.clone();

        let opts = DispatchOptions {
            n_periods: 8,
            gen_derate_profiles: GeneratorDerateProfiles {
                n_timesteps: 8,
                profiles: vec![GeneratorDerateProfile {
                    generator_id: g0_id,
                    derate_factors: vec![0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
                }],
            },
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                initial_commitment: Some(vec![true, true]),
                initial_hours_on: Some(vec![2, 0]),
                ..Default::default()
            }),
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        for t in 0..2 {
            assert!(
                !commitment_schedule(&sol)[t][0],
                "forced outage must override MUT carryover at t={t}, commitment={:?}",
                commitment_schedule(&sol)
                    .iter()
                    .map(|c| c[0])
                    .collect::<Vec<_>>()
            );
            assert!(
                sol.periods[t].pg_mw[0].abs() < 1e-6,
                "forced outage hour {t} should have zero output, got {:.3}",
                sol.periods[t].pg_mw[0]
            );
        }
    }

    /// A forced-offline quadratic-cost unit using PLC must be able to zero its
    /// lambda simplex. Otherwise the cost-curve convexity rows conflict with
    /// `pg <= 0` during the outage.
    #[test]
    fn test_scuc_plc_forced_outage_zeroes_lambda_simplex() {
        use surge_network::Network;
        use surge_network::market::{CostCurve, GeneratorDerateProfile, GeneratorDerateProfiles};
        use surge_network::network::{Bus, BusType, Generator};

        let mut net = Network::new("plc_forced_outage");
        net.base_mva = 100.0;

        let bus = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(bus);
        net.loads.push(Load::new(1, 50.0, 0.0));

        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.in_service = true;
        g0.pmin = 5.0;
        g0.pmax = 60.0;
        g0.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::Market;
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![0.01, 12.0, 0.0],
        });

        let mut g1 = Generator::new(1, 0.0, 1.0);
        g1.in_service = true;
        g1.pmin = 50.0;
        g1.pmax = 100.0;
        g1.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::MustRun;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![0.0, 50.0],
        });

        net.generators.push(g0);
        net.generators.push(g1);
        net.canonicalize_generator_ids();
        let g0_id = net.generators[0].id.clone();

        let opts = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            gen_derate_profiles: GeneratorDerateProfiles {
                n_timesteps: 1,
                profiles: vec![GeneratorDerateProfile {
                    generator_id: g0_id,
                    derate_factors: vec![0.0],
                }],
            },
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                n_cost_segments: 5,
                ..IndexedCommitmentOptions::default()
            }),
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).expect("forced-out quadratic PLC case should solve");

        assert!(
            !commitment_schedule(&sol)[0][0],
            "forced-out quadratic unit should be offline, commitment={:?}",
            commitment_schedule(&sol)
                .iter()
                .map(|row| row[0])
                .collect::<Vec<_>>()
        );
        assert!(
            sol.periods[0].pg_mw[0].abs() < 1e-6,
            "forced-out quadratic unit should have zero output, got {:.6}",
            sol.periods[0].pg_mw[0]
        );
        assert!(
            sol.periods[0].pg_mw[1] >= 49.0,
            "backup unit should cover the load, got {:.3}",
            sol.periods[0].pg_mw[1]
        );
    }

    /// Issue 4b: Pre-horizon MDT carryover for regular generators.
    /// If a generator was off for fewer hours than min_down_time before the horizon,
    /// it must remain off for the remaining MDT periods.
    #[test]
    fn test_scuc_pre_horizon_mdt_regular_gen() {
        // Gen 0: cheap, MDT=4h. Was off for 1h before horizon → must stay off for 3 more hours.
        // Gen 1: expensive must-run backup.
        // Load = 200 MW → gen 0 (pmax=200, cost=10) wants to start immediately.
        // But MDT carryover forces it off for t=0..2.
        let mut g0 = make_gen(30.0, 200.0, 10.0, 50.0);
        g0.commitment.get_or_insert_default().min_down_time_hr = Some(4.0);
        g0.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::Market;

        let mut g1 = make_gen(10.0, 300.0, 50.0, 0.0);
        g1.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::MustRun;

        let net = net_with_load(200.0, vec![g0, g1]);

        let opts = scuc_opts(
            8,
            IndexedCommitmentOptions {
                initial_commitment: Some(vec![false, true]),
                initial_offline_hours: Some(vec![1.0, 0.0]), // gen 0 off for 1h, needs 3 more
                ..Default::default()
            },
        );

        let sol = solve_scuc(&net, &opts).unwrap();

        // Gen 0 must be off for t=0..2 (3 remaining MDT periods)
        for t in 0..3 {
            assert!(
                !commitment_schedule(&sol)[t][0],
                "MDT carryover: gen 0 must be off at t={t} (1h off + MDT=4h → 3h remaining), \
                 commitment={:?}",
                commitment_schedule(&sol)
                    .iter()
                    .map(|c| c[0])
                    .collect::<Vec<_>>()
            );
            assert!(
                !startup_schedule(&sol)[t][0],
                "MDT carryover should not allow a startup while gen 0 is forced off at t={t}"
            );
            assert!(
                !shutdown_schedule(&sol)[t][0],
                "MDT carryover should not fabricate a shutdown while gen 0 is already forced off at t={t}"
            );
        }
    }

    /// Max energy per day: hydro-like gen with limited daily MWh budget.
    #[test]
    fn test_scuc_max_energy_per_day() {
        // Gen with pmax=100 MW, max_energy=200 MWh/day.
        // With 1h steps, can only run at full output for 2 hours (100*2=200 MWh).
        let mut g = make_gen(20.0, 100.0, 5.0, 10.0); // very cheap
        g.commitment.get_or_insert_default().max_energy_mwh_per_day = Some(200.0);

        let mut g_expensive = make_gen(10.0, 300.0, 50.0, 0.0);
        g_expensive.commitment.get_or_insert_default().status =
            surge_network::network::CommitmentStatus::MustRun;

        let net = net_with_load(200.0, vec![g, g_expensive]);

        let opts = scuc_opts(8, IndexedCommitmentOptions::default());

        let sol = solve_scuc(&net, &opts).unwrap();

        // Total energy from gen 0 should be ≤ 200 MWh (+ small tolerance)
        let total_energy: f64 = sol.periods.iter().map(|p| p.pg_mw[0] * 1.0).sum(); // 1h steps
        assert!(
            total_energy <= 201.0,
            "max_energy_mwh_per_day=200 but total energy was {total_energy:.1} MWh"
        );
    }

    /// Absolute GO-style startup windows must not be reinterpreted as rolling
    /// daily limits. A first-day budget should still allow dense cycling on day 2.
    #[test]
    fn test_scuc_absolute_startup_window_limit_is_not_rolling() {
        let mut g_cheap = make_gen(30.0, 100.0, 5.0, 1.0);
        g_cheap.commitment.get_or_insert_default().status = CommitmentStatus::Market;

        let mut g_backup = make_gen(20.0, 200.0, 50.0, 0.0);
        g_backup.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;

        let net = net_with_load(20.0, vec![g_cheap, g_backup]);

        let mut opts = scuc_opts(
            48,
            IndexedCommitmentOptions {
                initial_commitment: Some(vec![false, true]),
                ..Default::default()
            },
        );
        let mut load_profiles = surge_network::market::LoadProfiles::default();
        load_profiles
            .profiles
            .push(surge_network::market::LoadProfile {
                bus: 1,
                load_mw: (0..48)
                    .map(|hour| match hour {
                        0 | 1 | 24 | 25 | 30 | 31 | 36 | 37 => 120.0,
                        _ => 20.0,
                    })
                    .collect(),
            });
        opts.load_profiles = load_profiles;
        opts.startup_window_limits = vec![IndexedStartupWindowLimit {
            gen_index: 0,
            start_period_idx: 0,
            end_period_idx: 23,
            max_startups: 1,
        }];

        let sol = solve_scuc(&net, &opts).unwrap();
        let starts = startup_schedule(&sol);
        let first_day_starts: usize = starts[..24]
            .iter()
            .map(|period| usize::from(period[0]))
            .sum();
        let second_day_starts: usize = starts[24..]
            .iter()
            .map(|period| usize::from(period[0]))
            .sum();

        assert!(
            first_day_starts <= 1,
            "first-day startup window should cap day 1 at one start, got {first_day_starts}"
        );
        assert!(
            second_day_starts >= 3,
            "absolute first-day startup window should not suppress day-2 cycling, got {second_day_starts} starts"
        );
    }

    /// Absolute GO-style energy windows must only constrain the specified
    /// periods, not every rolling 24-hour suffix.
    #[test]
    fn test_scuc_absolute_energy_window_limit_is_not_rolling() {
        let mut g_cheap = make_gen(0.0, 100.0, 5.0, 0.0);
        g_cheap.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;

        let mut g_backup = make_gen(0.0, 300.0, 50.0, 0.0);
        g_backup.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;

        let net = net_with_load(0.0, vec![g_cheap, g_backup]);

        let mut opts = scuc_opts(48, IndexedCommitmentOptions::default());
        let mut load_profiles = surge_network::market::LoadProfiles::default();
        load_profiles
            .profiles
            .push(surge_network::market::LoadProfile {
                bus: 1,
                load_mw: (0..48)
                    .map(|hour| match hour {
                        23..=26 => 100.0,
                        _ => 0.0,
                    })
                    .collect(),
            });
        opts.load_profiles = load_profiles;
        opts.energy_window_limits = vec![IndexedEnergyWindowLimit {
            gen_index: 0,
            start_period_idx: 0,
            end_period_idx: 23,
            min_energy_mwh: None,
            max_energy_mwh: Some(100.0),
        }];

        let sol = solve_scuc(&net, &opts).unwrap();
        let first_day_energy_mwh: f64 =
            sol.periods[..24].iter().map(|period| period.pg_mw[0]).sum();
        let second_day_energy_mwh: f64 = sol.periods[24..27]
            .iter()
            .map(|period| period.pg_mw[0])
            .sum();

        assert!(
            first_day_energy_mwh <= 101.0,
            "first-day energy window should cap day 1 cheap-gen energy, got {first_day_energy_mwh:.1} MWh"
        );
        assert!(
            second_day_energy_mwh >= 250.0,
            "absolute first-day energy window should not bind early day 2, got {second_day_energy_mwh:.1} MWh"
        );
    }

    // ----- Multi-interval energy window soft slack -----
    //
    // Multi-interval energy windows are soft: a non-negative slack
    // `e^+_w` priced at `c^e × e^+_w` lets the LP relax the window when
    // enforcement is too tight to be feasible. The slack column is
    // allocated per (window, direction) and priced from
    // `spec.energy_window_violation_per_puh`.

    /// With `energy_window_violation_per_puh = 0.0`, the slack column is
    /// free. The LP can absorb any energy window violation at zero cost
    /// — a strict relaxation used by callers that don't supply a
    /// window-violation price.
    #[test]
    fn test_scuc_energy_window_soft_zero_penalty_absorbs_violation() {
        // Cheap gen capped at max_energy_mwh = 100 MWh over a 24-hour
        // window, but the system needs more energy than that across the
        // window. Without the slack the LP would over-ride the cheap gen
        // onto the expensive backup; with zero-cost slack the cheap gen
        // can blow past the cap.
        let mut g_cheap = make_gen(0.0, 100.0, 5.0, 0.0);
        g_cheap.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;

        let mut g_backup = make_gen(0.0, 300.0, 50.0, 0.0);
        g_backup.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;

        let net = net_with_load(0.0, vec![g_cheap, g_backup]);

        let mut opts = scuc_opts(24, IndexedCommitmentOptions::default());
        let mut load_profiles = surge_network::market::LoadProfiles::default();
        load_profiles
            .profiles
            .push(surge_network::market::LoadProfile {
                bus: 1,
                load_mw: vec![100.0; 24],
            });
        opts.load_profiles = load_profiles;
        opts.energy_window_limits = vec![IndexedEnergyWindowLimit {
            gen_index: 0,
            start_period_idx: 0,
            end_period_idx: 23,
            min_energy_mwh: None,
            max_energy_mwh: Some(100.0),
        }];
        // Default: energy_window_violation_per_puh = 0.0
        assert_eq!(opts.energy_window_violation_per_puh, 0.0);

        let sol = solve_scuc(&net, &opts).expect("zero-penalty soft slack should always solve");
        let cheap_energy_mwh: f64 = sol.periods.iter().map(|p| p.pg_mw[0]).sum();
        // The slack absorbs the cap entirely so the LP picks the cheap
        // gen as much as it economically wants — well above the 100 MWh
        // window limit.
        assert!(
            cheap_energy_mwh > 1000.0,
            "with zero-cost slack, cheap gen should produce well over the 100 MWh window cap, got {cheap_energy_mwh:.1} MWh"
        );
    }

    /// With a high `energy_window_violation_per_puh`, the LP prefers the
    /// expensive backup over paying the slack penalty, demonstrating that
    /// the slack column IS in the LP and IS priced.
    #[test]
    fn test_scuc_energy_window_soft_high_penalty_forces_compliance() {
        let mut g_cheap = make_gen(0.0, 100.0, 5.0, 0.0);
        g_cheap.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;

        let mut g_backup = make_gen(0.0, 300.0, 50.0, 0.0);
        g_backup.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;

        let net = net_with_load(0.0, vec![g_cheap, g_backup]);

        let mut opts = scuc_opts(24, IndexedCommitmentOptions::default());
        let mut load_profiles = surge_network::market::LoadProfiles::default();
        load_profiles
            .profiles
            .push(surge_network::market::LoadProfile {
                bus: 1,
                load_mw: vec![100.0; 24],
            });
        opts.load_profiles = load_profiles;
        opts.energy_window_limits = vec![IndexedEnergyWindowLimit {
            gen_index: 0,
            start_period_idx: 0,
            end_period_idx: 23,
            min_energy_mwh: None,
            max_energy_mwh: Some(100.0),
        }];
        // High penalty: 1e6 $/pu-h. At base = 100 MVA this is 1e4 $/MWh
        // — way above the 50 $/MWh backup cost, so the LP must comply
        // with the 100 MWh cap rather than pay the slack.
        opts.energy_window_violation_per_puh = 1e6;

        let sol = solve_scuc(&net, &opts).expect("high-penalty soft solve should still succeed");
        let cheap_energy_mwh: f64 = sol.periods.iter().map(|p| p.pg_mw[0]).sum();
        assert!(
            cheap_energy_mwh <= 101.0,
            "with high-cost slack, cheap gen should respect the 100 MWh window cap, got {cheap_energy_mwh:.1} MWh"
        );
    }

    // =========================================================================
    // Shutdown / startup de-loading (Morales-España)
    // =========================================================================

    /// SCUC shutdown de-loading: a slow-ramping unit that shuts down must be
    /// de-loaded the period before. Without the flag, the unit can be at Pmax
    /// right before shutdown; with the flag, it's capped at SD capacity.
    #[test]
    fn test_scuc_shutdown_deloading() {
        // Gen0: slow coal, Pmax=300, Pmin=50, shutdown_ramp=2 MW/min → SD=120 MW/hr
        //        cost=10 $/MWh (cheap)
        // Gen1: peaker, Pmax=400, Pmin=0, cost=50 $/MWh (expensive, must-run)
        // Load: [250, 250, 250, 50] — forces Gen0 offline at t=3
        let mut g0 = make_gen(50.0, 300.0, 10.0, 0.0);
        g0.commitment
            .get_or_insert_default()
            .shutdown_ramp_mw_per_min = Some(2.0); // 2 MW/min → 120 MW per hour
        g0.ramping.get_or_insert_default().ramp_down_curve = vec![(0.0, 5.0)]; // economic ramp = 5 MW/min (300 MW/hr)
        g0.commitment.get_or_insert_default().status = CommitmentStatus::Market;

        let mut g1 = make_gen(0.0, 400.0, 50.0, 0.0);
        g1.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;

        let net = net_with_load(250.0, vec![g0, g1]);

        let mut load_profiles = surge_network::market::LoadProfiles::default();
        load_profiles
            .profiles
            .push(surge_network::market::LoadProfile {
                bus: 1,
                load_mw: vec![250.0, 250.0, 250.0, 50.0],
            });

        // Without de-loading
        let opts_off = DispatchOptions {
            n_periods: 4,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                initial_commitment: Some(vec![true, true]),
                ..Default::default()
            }),
            load_profiles: load_profiles.clone(),
            enforce_shutdown_deloading: false,
            enforce_thermal_limits: false,
            ..DispatchOptions::default()
        };
        let sol_off = solve_scuc(&net, &opts_off).unwrap();

        // With de-loading
        let opts_on = DispatchOptions {
            enforce_shutdown_deloading: true,
            ..opts_off.clone()
        };
        let sol_on = solve_scuc(&net, &opts_on).unwrap();

        // Gen0 should shut down at some point in both cases (load drops to 50 MW)
        let mut shutdown_hour = None;
        for t in 1..4 {
            if commitment_schedule(&sol_on)[t - 1][0] && !commitment_schedule(&sol_on)[t][0] {
                shutdown_hour = Some(t);
                break;
            }
        }

        if let Some(sh) = shutdown_hour {
            let pre_shutdown = sh - 1;
            let pg_on = sol_on.periods[pre_shutdown].pg_mw[0];
            let pg_off = sol_off.periods[pre_shutdown].pg_mw[0];

            // With de-loading: Gen0 dispatch at pre-shutdown ≤ SD = 120 MW
            assert!(
                pg_on <= 120.0 + 1.0,
                "With de-loading, Gen0 at t={pre_shutdown} should be ≤ 120 MW (SD capacity), got {pg_on:.1}"
            );

            // Without de-loading: Gen0 should be dispatched higher
            assert!(
                pg_off > 120.0 + 1.0,
                "Without de-loading, Gen0 at t={pre_shutdown} should exceed 120 MW, got {pg_off:.1}"
            );
        } else {
            // If Gen0 doesn't shut down, the constraint is vacuous — just verify both solve
            assert!(sol_on.summary.total_cost > 0.0);
            assert!(sol_off.summary.total_cost > 0.0);
        }
    }

    /// SCUC startup de-loading: a slow-ramping unit starting up should be limited
    /// to SU capacity in its first committed period.
    #[test]
    fn test_scuc_startup_deloading() {
        // Gen0: slow unit, Pmax=300, Pmin=50, startup_ramp=1 MW/min → SU=60 MW/hr
        //        cost=10 $/MWh (cheap), startup=$100
        // Gen1: peaker, Pmax=400, Pmin=0, cost=50 $/MWh (expensive, must-run)
        // Load: high enough that the optimizer wants Gen0 above 60 MW at startup.
        // [50, 250, 250, 250] — Gen0 offline at t=0 (low load), starts at t=1.
        let mut g0 = make_gen(50.0, 300.0, 10.0, 100.0);
        g0.commitment
            .get_or_insert_default()
            .startup_ramp_mw_per_min = Some(1.0); // 1 MW/min → 60 MW per hour
        g0.ramping.get_or_insert_default().ramp_up_curve = vec![(0.0, 5.0)]; // economic ramp = 5 MW/min (300 MW/hr)
        g0.commitment.get_or_insert_default().status = CommitmentStatus::Market;

        let mut g1 = make_gen(0.0, 400.0, 50.0, 0.0);
        g1.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;

        let net = net_with_load(250.0, vec![g0, g1]);

        let mut load_profiles = surge_network::market::LoadProfiles::default();
        load_profiles
            .profiles
            .push(surge_network::market::LoadProfile {
                bus: 1,
                load_mw: vec![50.0, 250.0, 250.0, 250.0],
            });

        // Fix commitment: Gen0 off at t=0, on at t=1-3. This guarantees
        // v[1]=1 (startup at t=1) and we can check dispatch at t=1.
        let opts_on = DispatchOptions {
            n_periods: 4,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Fixed {
                commitment: vec![true, true], // base (ignored when per_period set)
                per_period: Some(vec![
                    vec![false, true], // t=0: Gen0 off, Gen1 on
                    vec![true, true],  // t=1: Gen0 starts
                    vec![true, true],  // t=2
                    vec![true, true],  // t=3
                ]),
            },
            load_profiles: load_profiles.clone(),
            enforce_shutdown_deloading: true,
            enforce_thermal_limits: false,
            ..DispatchOptions::default()
        };
        let sol_on = solve_scuc(&net, &opts_on).unwrap();

        // Without de-loading, same fixed commitment
        let opts_off = DispatchOptions {
            enforce_shutdown_deloading: false,
            ..opts_on.clone()
        };
        let sol_off = solve_scuc(&net, &opts_off).unwrap();

        // t=1 is the startup period (v[1]=1 for Gen0)
        let pg_on = sol_on.periods[1].pg_mw[0];
        let pg_off = sol_off.periods[1].pg_mw[0];

        // With de-loading: Gen0 dispatch at startup ≤ SU = 60 MW
        assert!(
            pg_on <= 60.0 + 1.0,
            "With de-loading, Gen0 at startup t=1 should be ≤ 60 MW (SU capacity), got {pg_on:.1}"
        );

        // Without de-loading: Gen0 dispatches up to economic level (load=250, cheap)
        assert!(
            pg_off > 60.0 + 1.0,
            "Without de-loading, Gen0 at startup t=1 should exceed 60 MW, got {pg_off:.1}"
        );
    }

    /// If an initially-off unit cannot reach pmin within the first
    /// interval, it must not be committed at t=0. Startup trajectory
    /// can live in prior intervals, but there are no prior intervals
    /// before the horizon starts.
    #[test]
    fn test_scuc_initially_off_unit_cannot_commit_at_t0_below_pmin() {
        let mut g0 = make_gen(50.0, 300.0, 10.0, 100.0);
        g0.commitment
            .get_or_insert_default()
            .startup_ramp_mw_per_min = Some(0.5); // 30 MW in first 1h < pmin 50 MW
        g0.ramping.get_or_insert_default().ramp_up_curve = vec![(0.0, 5.0)];
        g0.commitment.get_or_insert_default().status = CommitmentStatus::Market;

        let mut g1 = make_gen(0.0, 400.0, 50.0, 0.0);
        g1.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;

        let net = net_with_load(200.0, vec![g0, g1]);
        let opts = DispatchOptions {
            n_periods: 3,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                initial_commitment: Some(vec![false, true]),
                ..Default::default()
            }),
            enforce_shutdown_deloading: true,
            enforce_thermal_limits: false,
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        assert!(
            !commitment_schedule(&sol)[0][0],
            "Gen0 should remain off at t=0 when startup ramp cannot reach pmin"
        );
        assert!(
            !startup_schedule(&sol)[0][0],
            "Gen0 should not start at t=0 when startup ramp cannot reach pmin"
        );
    }

    #[test]
    fn test_request_additional_policy_respects_initial_conditions_at_t0() {
        let mut g0 = make_gen(50.0, 300.0, 10.0, 100.0);
        g0.id = "g0".to_string();
        g0.commitment
            .get_or_insert_default()
            .startup_ramp_mw_per_min = Some(0.5); // 30 MW in first 1h < pmin 50 MW
        g0.ramping.get_or_insert_default().ramp_up_curve = vec![(0.0, 5.0)];
        g0.commitment.get_or_insert_default().status = CommitmentStatus::Market;

        let mut g1 = make_gen(0.0, 400.0, 50.0, 0.0);
        g1.id = "g1".to_string();
        g1.commitment.get_or_insert_default().status = CommitmentStatus::Market;

        let net = net_with_load(200.0, vec![g0, g1]);
        let request = crate::DispatchRequest {
            formulation: crate::Formulation::Dc,
            coupling: crate::request::IntervalCoupling::TimeCoupled,
            commitment: crate::request::CommitmentPolicy::Additional {
                minimum_commitment: vec![crate::request::ResourcePeriodCommitment {
                    resource_id: "g1".to_string(),
                    periods: vec![true, true, true],
                }],
                options: crate::request::CommitmentOptions {
                    initial_conditions: vec![
                        crate::request::CommitmentInitialCondition {
                            resource_id: "g0".to_string(),
                            committed: Some(false),
                            ..Default::default()
                        },
                        crate::request::CommitmentInitialCondition {
                            resource_id: "g1".to_string(),
                            committed: Some(true),
                            ..Default::default()
                        },
                    ],
                    warm_start_commitment: vec![],
                    time_limit_secs: None,
                    mip_rel_gap: None,
                    mip_gap_schedule: None,
                    disable_warm_start: false,
                },
            },
            timeline: crate::request::DispatchTimeline::hourly(3),
            network: crate::request::DispatchNetwork {
                commitment_transitions: crate::request::CommitmentTransitionPolicy {
                    shutdown_deloading: true,
                    ..Default::default()
                },
                thermal_limits: crate::request::ThermalLimitPolicy {
                    enforce: false,
                    ..Default::default()
                },
                ..Default::default()
            },
            ..Default::default()
        };

        let sol = crate::dispatch::solve_dispatch_raw(&net, &request).unwrap();
        let g0_idx = net
            .generators
            .iter()
            .position(|g| g.id == "g0")
            .expect("g0 present");

        assert!(
            !commitment_schedule(&sol)[0][g0_idx],
            "Additional commitment policy should respect g0 initial off state at t=0"
        );
        assert!(
            !startup_schedule(&sol)[0][g0_idx],
            "Additional commitment policy should not allow g0 startup at t=0"
        );
    }

    #[test]
    fn test_additional_period_zero_commitment_uses_initial_on_boundary_without_shutdown() {
        let mut g0 = make_gen(0.0, 100.0, 10.0, 100.0);
        g0.id = "g0".to_string();
        g0.commitment.get_or_insert_default().status = CommitmentStatus::Market;

        let net = net_with_load(0.0, vec![g0]);
        let request = crate::DispatchRequest {
            formulation: crate::Formulation::Dc,
            coupling: crate::request::IntervalCoupling::TimeCoupled,
            commitment: crate::request::CommitmentPolicy::Additional {
                minimum_commitment: vec![crate::request::ResourcePeriodCommitment {
                    resource_id: "g0".to_string(),
                    periods: vec![true, false, false],
                }],
                options: crate::request::CommitmentOptions {
                    initial_conditions: vec![crate::request::CommitmentInitialCondition {
                        resource_id: "g0".to_string(),
                        committed: Some(true),
                        ..Default::default()
                    }],
                    warm_start_commitment: vec![],
                    time_limit_secs: None,
                    mip_rel_gap: None,
                    mip_gap_schedule: None,
                    disable_warm_start: false,
                },
            },
            timeline: crate::request::DispatchTimeline::hourly(3),
            network: crate::request::DispatchNetwork {
                commitment_transitions: crate::request::CommitmentTransitionPolicy {
                    shutdown_deloading: true,
                    ..Default::default()
                },
                ..Default::default()
            },
            ..Default::default()
        };

        let sol = crate::dispatch::solve_dispatch_raw(&net, &request).unwrap();
        let g0_idx = net
            .generators
            .iter()
            .position(|g| g.id == "g0")
            .expect("g0 present");

        assert!(
            commitment_schedule(&sol)[0][g0_idx],
            "Initial-on DA commitment at t=0 should keep g0 committed in the first period"
        );
        assert!(
            !shutdown_schedule(&sol)[0][g0_idx],
            "Initial-on DA commitment at t=0 should be enforced by forbidding a t=0 shutdown"
        );
    }

    #[test]
    fn test_additional_initial_on_commitment_prefix_forbids_shutdowns_across_prefix() {
        let mut g0 = make_gen(0.0, 100.0, 10.0, 100.0);
        g0.id = "g0".to_string();
        g0.commitment.get_or_insert_default().status = CommitmentStatus::Market;

        let net = net_with_load(0.0, vec![g0]);
        let request = crate::DispatchRequest {
            formulation: crate::Formulation::Dc,
            coupling: crate::request::IntervalCoupling::TimeCoupled,
            commitment: crate::request::CommitmentPolicy::Additional {
                minimum_commitment: vec![crate::request::ResourcePeriodCommitment {
                    resource_id: "g0".to_string(),
                    periods: vec![true, true, false],
                }],
                options: crate::request::CommitmentOptions {
                    initial_conditions: vec![crate::request::CommitmentInitialCondition {
                        resource_id: "g0".to_string(),
                        committed: Some(true),
                        ..Default::default()
                    }],
                    warm_start_commitment: vec![],
                    time_limit_secs: None,
                    mip_rel_gap: None,
                    mip_gap_schedule: None,
                    disable_warm_start: false,
                },
            },
            timeline: crate::request::DispatchTimeline::hourly(3),
            network: crate::request::DispatchNetwork {
                commitment_transitions: crate::request::CommitmentTransitionPolicy {
                    shutdown_deloading: true,
                    ..Default::default()
                },
                ..Default::default()
            },
            ..Default::default()
        };

        let sol = crate::dispatch::solve_dispatch_raw(&net, &request).unwrap();
        let g0_idx = net
            .generators
            .iter()
            .position(|g| g.id == "g0")
            .expect("g0 present");

        assert!(
            commitment_schedule(&sol)[0][g0_idx] && commitment_schedule(&sol)[1][g0_idx],
            "Initial-on DA commitment prefix should keep g0 committed through the forced-on prefix"
        );
        assert!(
            !shutdown_schedule(&sol)[0][g0_idx] && !shutdown_schedule(&sol)[1][g0_idx],
            "Initial-on DA commitment prefix should be enforced by forbidding shutdowns while the prefix remains forced on"
        );
    }

    #[test]
    fn test_variable_interval_request_blocks_sub_pmin_period_zero_startup() {
        let mut g0 = make_gen(50.0, 300.0, 10.0, 100.0);
        g0.id = "g0".to_string();
        g0.commitment
            .get_or_insert_default()
            .startup_ramp_mw_per_min = Some(1.0); // 15 MW in 15 minutes < pmin 50 MW
        g0.ramping.get_or_insert_default().ramp_up_curve = vec![(0.0, 5.0)];
        g0.commitment.get_or_insert_default().status = CommitmentStatus::Market;

        let mut g1 = make_gen(0.0, 400.0, 50.0, 0.0);
        g1.id = "g1".to_string();
        g1.commitment.get_or_insert_default().status = CommitmentStatus::Market;

        let net = net_with_load(200.0, vec![g0, g1]);
        let request = crate::DispatchRequest {
            formulation: crate::Formulation::Dc,
            coupling: crate::request::IntervalCoupling::TimeCoupled,
            commitment: crate::request::CommitmentPolicy::Additional {
                minimum_commitment: vec![crate::request::ResourcePeriodCommitment {
                    resource_id: "g1".to_string(),
                    periods: vec![true, true, true],
                }],
                options: crate::request::CommitmentOptions {
                    initial_conditions: vec![
                        crate::request::CommitmentInitialCondition {
                            resource_id: "g0".to_string(),
                            committed: Some(false),
                            ..Default::default()
                        },
                        crate::request::CommitmentInitialCondition {
                            resource_id: "g1".to_string(),
                            committed: Some(true),
                            ..Default::default()
                        },
                    ],
                    warm_start_commitment: vec![],
                    time_limit_secs: None,
                    mip_rel_gap: None,
                    mip_gap_schedule: None,
                    disable_warm_start: false,
                },
            },
            timeline: crate::request::DispatchTimeline::variable(vec![0.25, 0.25, 1.0]),
            network: crate::request::DispatchNetwork {
                commitment_transitions: crate::request::CommitmentTransitionPolicy {
                    shutdown_deloading: true,
                    ..Default::default()
                },
                thermal_limits: crate::request::ThermalLimitPolicy {
                    enforce: false,
                    ..Default::default()
                },
                ..Default::default()
            },
            ..Default::default()
        };

        let sol = crate::dispatch::solve_dispatch_raw(&net, &request).unwrap();
        let g0_idx = net
            .generators
            .iter()
            .position(|g| g.id == "g0")
            .expect("g0 present");

        assert!(
            !commitment_schedule(&sol)[0][g0_idx],
            "g0 should remain off in the 15-minute first interval when startup ramp cannot reach pmin"
        );
        assert!(
            !startup_schedule(&sol)[0][g0_idx],
            "g0 should not start in the 15-minute first interval when startup ramp cannot reach pmin"
        );
    }

    /// Verify de-loading is a no-op when flag is false (regression guard).
    #[test]
    fn test_scuc_deloading_flag_off_is_noop() {
        let g0 = make_gen(50.0, 300.0, 10.0, 0.0);
        let mut g1 = make_gen(0.0, 400.0, 50.0, 0.0);
        g1.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;

        let net = net_with_load(250.0, vec![g0, g1]);

        let opts = DispatchOptions {
            n_periods: 4,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                initial_commitment: Some(vec![true, true]),
                ..Default::default()
            }),
            enforce_shutdown_deloading: false,
            enforce_thermal_limits: false,
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();
        assert!(sol.summary.total_cost > 0.0);
    }
}

#[cfg(test)]
mod tests_combined_cycle {
    use super::*;
    use crate::dispatch::{CommitmentMode, Horizon, IndexedCommitmentOptions};
    use crate::legacy::DispatchOptions;
    use surge_network::Network;
    use surge_network::market::{
        CombinedCycleConfig, CombinedCyclePlant, CombinedCycleTransition, CostCurve,
    };
    use surge_network::network::{Branch, Bus, BusType, Generator};

    /// Build a 2-bus network with 3 generators forming a CC plant (2 CTs + 1 ST)
    /// plus one independent peaker.
    ///
    /// CC Plant "CC1":
    ///   Config "1x0": CT1 only (gen 0), Pmin=30, Pmax=100, cheap ($20/MWh)
    ///   Config "2x1": CT1+CT2+ST (gens 0,1,2), Pmin=80, Pmax=300, cheapest ($15/MWh)
    ///
    /// Independent peaker: gen 3, Pmin=0, Pmax=200, expensive ($50/MWh)
    ///
    /// Load: 150 MW → enough that 1x0 alone (max 100) can't serve it,
    ///       so SCUC must either use 2x1 config or peaker fill.
    fn make_cc_network() -> Network {
        let mut net = Network::new("cc_test");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        let b2 = Bus::new(2, BusType::PQ, 138.0);
        net.buses.push(b1);
        net.buses.push(b2);
        net.loads.push(Load::new(2, 150.0, 0.0));

        let mut br = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
        br.rating_a_mva = 500.0;
        net.branches.push(br);

        // CT1 (gen 0): bus 1
        let mut ct1 = Generator::new(1, 0.0, 1.0);
        ct1.pmin = 30.0;
        ct1.pmax = 100.0;
        ct1.cost = Some(CostCurve::Polynomial {
            startup: 500.0,
            shutdown: 0.0,
            coeffs: vec![20.0, 0.0],
        });

        // CT2 (gen 1): bus 1
        let mut ct2 = Generator::new(1, 0.0, 1.0);
        ct2.pmin = 30.0;
        ct2.pmax = 100.0;
        ct2.machine_id = Some("2".into());
        ct2.cost = Some(CostCurve::Polynomial {
            startup: 500.0,
            shutdown: 0.0,
            coeffs: vec![20.0, 0.0],
        });

        // ST (gen 2): bus 1 — only runs in 2x1
        let mut st = Generator::new(1, 0.0, 1.0);
        st.pmin = 20.0;
        st.pmax = 100.0;
        st.machine_id = Some("3".into());
        st.cost = Some(CostCurve::Polynomial {
            startup: 1000.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0],
        });

        // Peaker (gen 3): bus 2
        let mut peaker = Generator::new(2, 0.0, 1.0);
        peaker.pmin = 0.0;
        peaker.pmax = 200.0;
        peaker.cost = Some(CostCurve::Polynomial {
            startup: 200.0,
            shutdown: 0.0,
            coeffs: vec![50.0, 0.0],
        });

        net.generators.push(ct1);
        net.generators.push(ct2);
        net.generators.push(st);
        net.generators.push(peaker);

        // CC Plant definition
        net.market_data
            .combined_cycle_plants
            .push(CombinedCyclePlant {
                id: String::new(),
                name: "CC1".into(),
                configs: vec![
                    CombinedCycleConfig {
                        name: "1x0".into(),
                        gen_indices: vec![0], // CT1 only
                        p_min_mw: 30.0,
                        p_max_mw: 100.0,
                        heat_rate_curve: vec![],
                        energy_offer: None,
                        ramp_up_curve: vec![],
                        ramp_down_curve: vec![],
                        min_up_time_hr: 1.0,
                        min_down_time_hr: 1.0,
                        no_load_cost: 0.0,
                        reserve_offers: vec![],
                        qualifications: Default::default(),
                    },
                    CombinedCycleConfig {
                        name: "2x1".into(),
                        gen_indices: vec![0, 1, 2], // CT1 + CT2 + ST
                        p_min_mw: 80.0,
                        p_max_mw: 300.0,
                        heat_rate_curve: vec![],
                        energy_offer: None,
                        ramp_up_curve: vec![],
                        ramp_down_curve: vec![],
                        min_up_time_hr: 2.0,
                        min_down_time_hr: 2.0,
                        no_load_cost: 0.0,
                        reserve_offers: vec![],
                        qualifications: Default::default(),
                    },
                ],
                transitions: vec![
                    CombinedCycleTransition {
                        from_config: "1x0".into(),
                        to_config: "2x1".into(),
                        transition_time_min: 0.0,
                        transition_cost: 100.0,
                        online_transition: true,
                    },
                    CombinedCycleTransition {
                        from_config: "2x1".into(),
                        to_config: "1x0".into(),
                        transition_time_min: 0.0,
                        transition_cost: 50.0,
                        online_transition: true,
                    },
                ],
                active_config: None,
                hours_in_config: 0.0,
                duct_firing_capable: false,
            });

        net
    }

    /// Test that SCUC selects the 2x1 config when load exceeds 1x0 capacity.
    #[test]
    fn test_cc_selects_2x1_for_high_load() {
        let net = make_cc_network();
        let opts = DispatchOptions {
            n_periods: 4,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        // Should have CC schedule data
        assert_eq!(sol.cc_config_schedule.len(), 4);
        assert_eq!(sol.cc_config_schedule[0].len(), 1); // 1 CC plant

        // With 150 MW load, 1x0 (max 100) isn't enough. Should pick 2x1 (max 300).
        for t in 0..4 {
            let config = &sol.cc_config_schedule[t][0];
            assert_eq!(
                config.as_deref(),
                Some("2x1"),
                "hour {t}: expected 2x1 config for 150 MW load, got {config:?}"
            );

            // CT1, CT2, ST should all be committed (gens 0,1,2)
            assert!(
                commitment_schedule(&sol)[t][0],
                "hour {t}: CT1 should be on in 2x1"
            );
            assert!(
                commitment_schedule(&sol)[t][1],
                "hour {t}: CT2 should be on in 2x1"
            );
            assert!(
                commitment_schedule(&sol)[t][2],
                "hour {t}: ST should be on in 2x1"
            );
        }

        // Power balance
        for t in 0..4 {
            let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
            assert!(
                (total_gen - 150.0).abs() < 1.0,
                "hour {t}: gen={total_gen:.1}, expected ~150"
            );
        }
    }

    /// Test that ST (gen 2) can't commit independently — it only appears in "2x1".
    #[test]
    fn test_cc_st_cannot_run_alone() {
        let mut net = make_cc_network();
        // Set load to 50 MW — small enough for 1x0 (CT1 alone, max 100)
        net.loads[0].active_power_demand_mw = 50.0;

        let opts = DispatchOptions {
            n_periods: 4,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        for t in 0..4 {
            let config = &sol.cc_config_schedule[t][0];
            // With 50 MW load, 1x0 is sufficient and cheaper than 2x1 (which has
            // Pmin=80, transition cost, and extra startup costs for CT2+ST).
            assert_eq!(
                config.as_deref(),
                Some("1x0"),
                "hour {t}: expected 1x0 for low load, got {config:?}"
            );

            // ST (gen 2) must NOT be committed — it's only in 2x1
            assert!(
                !commitment_schedule(&sol)[t][2],
                "hour {t}: ST should be off when config is 1x0"
            );
            // CT2 (gen 1) must NOT be committed either — it's only in 2x1
            assert!(
                !commitment_schedule(&sol)[t][1],
                "hour {t}: CT2 should be off when config is 1x0"
            );
            // CT1 (gen 0) should be committed
            assert!(
                commitment_schedule(&sol)[t][0],
                "hour {t}: CT1 should be on in 1x0"
            );
        }
    }

    /// Test config MUT via initial-condition pinning.
    /// Plant starts in 2x1 with hours_in_config=0.5 and MUT=2h.
    /// Remaining MUT = ceil((2-0.5)/1) = 2 hours → z[2x1] forced on for t=0,1.
    /// Load is 50 MW (1x0 would be cheaper), but MUT keeps 2x1 active.
    #[test]
    fn test_cc_config_min_up_time() {
        let mut net = make_cc_network();
        // Start in 2x1 with only 0.5h elapsed → 2 more hours forced
        net.market_data.combined_cycle_plants[0].active_config = Some("2x1".into());
        net.market_data.combined_cycle_plants[0].hours_in_config = 0.5;
        // Low load: 1x0 would be preferred, but MUT forces 2x1
        net.loads[0].active_power_demand_mw = 90.0;

        let opts = DispatchOptions {
            n_periods: 4,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                initial_commitment: Some(vec![true, true, true, true]),
                ..Default::default()
            }),
            ..Default::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        // MUT forces 2x1 for t=0 and t=1
        assert_eq!(
            sol.cc_config_schedule[0][0].as_deref(),
            Some("2x1"),
            "t=0: MUT should force 2x1 (started 0.5h ago, 2h MUT)"
        );
        assert_eq!(
            sol.cc_config_schedule[1][0].as_deref(),
            Some("2x1"),
            "t=1: MUT should force 2x1 (started 1.5h ago, 2h MUT)"
        );
    }

    /// Test disallowed transitions (C4): 3 configs where A→C is forbidden.
    /// Optimizer must go A→B→C (2 steps) instead of directly A→C.
    #[test]
    fn test_cc_disallowed_transition() {
        let mut net = Network::new("cc_c4_test");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        let b2 = Bus::new(2, BusType::PQ, 138.0);
        net.buses.push(b1);
        net.buses.push(b2);
        net.loads.push(Load::new(2, 50.0, 0.0));

        let mut br = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
        br.rating_a_mva = 500.0;
        net.branches.push(br);

        // Gen 0: in configs A and B ($30/MWh)
        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.pmin = 10.0;
        g0.pmax = 100.0;
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![30.0, 0.0],
        });
        // Gen 1: in config C only ($20/MWh — cheaper)
        let mut g1 = Generator::new(1, 0.0, 1.0);
        g1.pmin = 10.0;
        g1.pmax = 100.0;
        g1.machine_id = Some("2".into());
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![20.0, 0.0],
        });
        // Peaker (expensive fallback)
        let mut pk = Generator::new(2, 0.0, 1.0);
        pk.pmin = 0.0;
        pk.pmax = 200.0;
        pk.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![80.0, 0.0],
        });

        net.generators.push(g0);
        net.generators.push(g1);
        net.generators.push(pk);

        // 3 configs: A (gen 0), B (gen 0), C (gen 1)
        // Allowed: A↔B, B→C, C→B.  Forbidden: A→C, C→A.
        net.market_data
            .combined_cycle_plants
            .push(CombinedCyclePlant {
                id: String::new(),
                name: "CC_C4".into(),
                configs: vec![
                    CombinedCycleConfig {
                        name: "A".into(),
                        gen_indices: vec![0],
                        p_min_mw: 10.0,
                        p_max_mw: 100.0,
                        heat_rate_curve: vec![],
                        energy_offer: None,
                        ramp_up_curve: vec![],
                        ramp_down_curve: vec![],
                        min_up_time_hr: 1.0,
                        min_down_time_hr: 1.0,
                        no_load_cost: 0.0,
                        reserve_offers: vec![],
                        qualifications: Default::default(),
                    },
                    CombinedCycleConfig {
                        name: "B".into(),
                        gen_indices: vec![0],
                        p_min_mw: 10.0,
                        p_max_mw: 100.0,
                        heat_rate_curve: vec![],
                        energy_offer: None,
                        ramp_up_curve: vec![],
                        ramp_down_curve: vec![],
                        min_up_time_hr: 1.0,
                        min_down_time_hr: 1.0,
                        no_load_cost: 0.0,
                        reserve_offers: vec![],
                        qualifications: Default::default(),
                    },
                    CombinedCycleConfig {
                        name: "C".into(),
                        gen_indices: vec![1],
                        p_min_mw: 10.0,
                        p_max_mw: 100.0,
                        heat_rate_curve: vec![],
                        energy_offer: None,
                        ramp_up_curve: vec![],
                        ramp_down_curve: vec![],
                        min_up_time_hr: 1.0,
                        min_down_time_hr: 1.0,
                        no_load_cost: 0.0,
                        reserve_offers: vec![],
                        qualifications: Default::default(),
                    },
                ],
                transitions: vec![
                    CombinedCycleTransition {
                        from_config: "A".into(),
                        to_config: "B".into(),
                        transition_time_min: 0.0,
                        transition_cost: 0.0,
                        online_transition: true,
                    },
                    CombinedCycleTransition {
                        from_config: "B".into(),
                        to_config: "A".into(),
                        transition_time_min: 0.0,
                        transition_cost: 0.0,
                        online_transition: true,
                    },
                    CombinedCycleTransition {
                        from_config: "B".into(),
                        to_config: "C".into(),
                        transition_time_min: 0.0,
                        transition_cost: 0.0,
                        online_transition: true,
                    },
                    CombinedCycleTransition {
                        from_config: "C".into(),
                        to_config: "B".into(),
                        transition_time_min: 0.0,
                        transition_cost: 0.0,
                        online_transition: true,
                    },
                    // A→C and C→A are NOT listed → disallowed
                ],
                active_config: Some("A".into()),
                hours_in_config: 10.0,
                duct_firing_capable: false,
            });

        let opts = DispatchOptions {
            n_periods: 3,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();
        let schedule: Vec<_> = (0..3)
            .map(|t| sol.cc_config_schedule[t][0].as_deref().unwrap_or("off"))
            .collect();

        // Verify A→C never happens directly (must go through B)
        for t in 1..3 {
            let prev = &schedule[t - 1];
            let curr = &schedule[t];
            assert!(
                !(prev == &"A" && curr == &"C"),
                "t={t}: direct A→C transition is forbidden, schedule={schedule:?}"
            );
            assert!(
                !(prev == &"C" && curr == &"A"),
                "t={t}: direct C→A transition is forbidden, schedule={schedule:?}"
            );
        }

        // Should reach C eventually (it's cheapest), via A→B→C
        assert!(
            schedule.contains(&"C"),
            "should eventually reach config C (cheapest), schedule={schedule:?}"
        );
    }

    /// Test that without CC plants, solve is unaffected (zero overhead).
    #[test]
    fn test_cc_empty_no_overhead() {
        let mut net = make_cc_network();
        // Remove CC plants
        net.market_data.combined_cycle_plants.clear();

        let opts = DispatchOptions {
            n_periods: 4,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        // CC schedule should be empty
        assert!(sol.cc_config_schedule.is_empty());
        assert_eq!(sol.cc_transition_cost, 0.0);

        // Should still solve correctly
        assert!(sol.summary.total_cost > 0.0);
        for t in 0..4 {
            let total_gen: f64 = sol.periods[t].pg_mw.iter().sum();
            assert!(
                (total_gen - 150.0).abs() < 1.0,
                "hour {t}: gen={total_gen:.1}"
            );
        }
    }

    /// Test that transition costs appear in the solution.
    #[test]
    fn test_cc_transition_cost_tracked() {
        let mut net = make_cc_network();
        // Load profile: start offline (no initial config), then need 2x1.
        // The transition into 2x1 should incur a cost.
        net.market_data.combined_cycle_plants[0].active_config = None;

        let opts = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        // Plant starts offline, activates a config → should have transition cost
        let any_config_active = sol.cc_config_schedule.iter().any(|t| t[0].is_some());
        assert!(
            any_config_active,
            "CC plant should activate to serve 150 MW load"
        );
    }
}

// ==========================================================================
// Pumped-hydro constraints tests
// ==========================================================================

#[cfg(test)]
mod tests_pumped_hydro {
    use super::*;

    use surge_network::Network;
    use surge_network::market::{CostCurve, LoadProfile, LoadProfiles};
    use surge_network::network::{
        Bus, BusType, CommitmentStatus, Generator, StorageDispatchMode, StorageParams,
    };

    use crate::dispatch::{
        CommitmentMode, Horizon, IndexedCommitmentOptions, IndexedPhHeadCurve,
        IndexedPhModeConstraint,
    };
    use crate::legacy::DispatchOptions;

    /// Extract per-period dispatch MW for a given gen_local_index from RawDispatchSolution.
    fn dispatch_mw(sol: &RawDispatchSolution, gen_local: usize, _base_mva: f64) -> Vec<f64> {
        // pg_mw is already in MW (solve_scuc multiplies pu × base_mva)
        sol.periods.iter().map(|p| p.pg_mw[gen_local]).collect()
    }

    fn interpolate_head_curve(soc_mwh: f64, breakpoints: &[(f64, f64)]) -> f64 {
        match breakpoints {
            [] => 0.0,
            [single] => single.1,
            _ => {
                if soc_mwh <= breakpoints[0].0 {
                    return breakpoints[0].1;
                }
                for window in breakpoints.windows(2) {
                    let (soc0, pmax0) = window[0];
                    let (soc1, pmax1) = window[1];
                    if soc_mwh <= soc1 {
                        let dsoc = soc1 - soc0;
                        if dsoc.abs() < 1e-12 {
                            return pmax1;
                        }
                        let alpha = (soc_mwh - soc0) / dsoc;
                        return pmax0 + alpha * (pmax1 - pmax0);
                    }
                }
                breakpoints.last().unwrap().1
            }
        }
    }

    /// Build a minimal single-bus network with a fixed thermal gen and a PH-style
    /// storage unit (gen_index=1). The storage has 100 MW discharge, 100 MW charge,
    /// 1000 MWh capacity, SOC_init=500 MWh, efficiency=1.0.
    fn ph_test_net() -> Network {
        let mut net = Network::new("ph_test");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b1);

        // Thermal gen: 200 MW, must-run, cheap
        let mut g0 = Generator::new(1, 200.0, 1.0);
        g0.pmin = 50.0;
        g0.pmax = 200.0;
        g0.in_service = true;
        g0.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![30.0, 0.0], // $30/MWh (slope, intercept)
        });
        net.generators.push(g0);

        // PH-style storage: 100 MW charge/discharge, 1000 MWh, eta=1.0
        let ph_gen = Generator {
            bus: 1,
            in_service: true,
            pmin: -100.0,
            pmax: 100.0,
            machine_base_mva: 100.0,
            cost: Some(CostCurve::Polynomial {
                coeffs: vec![0.0],
                startup: 0.0,
                shutdown: 0.0,
            }),
            storage: Some(StorageParams {
                charge_efficiency: 1.0,
                discharge_efficiency: 1.0,
                energy_capacity_mwh: 1000.0,
                soc_initial_mwh: 500.0,
                soc_min_mwh: 0.0,
                soc_max_mwh: 1000.0,
                variable_cost_per_mwh: 0.0,
                degradation_cost_per_mwh: 0.0,
                dispatch_mode: StorageDispatchMode::CostMinimization,
                self_schedule_mw: 0.0,
                discharge_offer: None,
                charge_bid: None,
                max_c_rate_charge: None,
                max_c_rate_discharge: None,
                chemistry: None,
                discharge_foldback_soc_mwh: None,
                charge_foldback_soc_mwh: None,
                daily_cycle_limit: None,
            }),
            ..Generator::default()
        };
        net.generators.push(ph_gen);

        net
    }

    #[test]
    fn test_scuc_ph_head_curve_limits_discharge() {
        // Head curve: at low SOC the unit cannot sustain full nameplate discharge.
        // The SCUC models the envelope against the end-of-interval SOC state, so
        // the stable invariant is that discharge stays beneath that envelope each
        // hour, not that it is lower than a particular no-head-curve schedule.
        let mut net = ph_test_net();
        // Start at low SOC so head curve bites
        net.generators[1].storage.as_mut().unwrap().soc_initial_mwh = 200.0;

        // Moderate load: thermal (200 MW max) + some storage discharge needed
        // but feasible even with head-limited PH output.
        // Load 220 MW: thermal covers 200, PH needs 20 MW.
        // SOC drops 200→180→160→140 over 4 hrs; head curve pmax stays ~31+ MW.
        let load = LoadProfiles {
            profiles: vec![LoadProfile {
                bus: 1,
                load_mw: vec![220.0; 4],
            }],
            n_timesteps: 4,
        };

        // Head curve: (soc, pmax) — linear from (0, 20) to (1000, 100)
        // At SOC=200: interpolated pmax = 20 + (100-20)*(200/1000) = 36 MW
        let head_curve = IndexedPhHeadCurve {
            gen_index: 1,
            breakpoints: vec![(0.0, 20.0), (1000.0, 100.0)],
        };

        let opts = DispatchOptions {
            n_periods: 4,
            enforce_thermal_limits: false,
            load_profiles: load.clone(),
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ph_head_curves: vec![head_curve.clone()],
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).expect("SCUC should solve with head curve");
        let ph_dispatch = dispatch_mw(&sol, 1, net.base_mva);
        let soc_traj = sol.storage_soc.get(&1).expect("PH SoC should be tracked");

        for (t, (&pg_mw, &soc_mwh)) in ph_dispatch.iter().zip(soc_traj.iter()).enumerate() {
            let cap = interpolate_head_curve(soc_mwh, &head_curve.breakpoints);
            assert!(
                pg_mw <= cap + 1e-6,
                "hour {t}: head curve should cap discharge at {:.3} MW for end-of-hour SOC {:.3} MWh, got {:.3}",
                cap,
                soc_mwh,
                pg_mw
            );
        }

        let dis_period0 = ph_dispatch[0];
        assert!(
            dis_period0 < 40.0,
            "Head curve should materially limit initial discharge below nameplate, got {:.1}",
            dis_period0
        );
        assert!(
            dis_period0 > 0.0,
            "Should be discharging: got {:.1} MW",
            dis_period0
        );
    }

    #[test]
    fn test_scuc_ph_forbidden_zone_enforced() {
        // Put a forbidden zone on the storage generator's forbidden_zones field
        // (mimics what prepare_network_for_dispatch does) and verify dispatch
        // avoids the zone.
        let mut net = ph_test_net();
        // Forbidden zone: 20-80 MW discharge (most of the range)
        net.generators[1]
            .commitment
            .get_or_insert_default()
            .forbidden_zones = vec![(20.0, 80.0)];

        // Moderate load that would normally cause ~50 MW discharge
        let load = LoadProfiles {
            profiles: vec![LoadProfile {
                bus: 1,
                load_mw: vec![200.0; 4],
            }],
            n_timesteps: 4,
        };

        let opts = DispatchOptions {
            n_periods: 4,
            enforce_thermal_limits: false,
            load_profiles: load,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            enforce_forbidden_zones: true,
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).expect("SCUC should solve");

        // Dispatch for the PH unit (gen_local=1) should be outside [20, 80]
        // in every period (when the unit is dispatched at all).
        let ph_dispatch = dispatch_mw(&sol, 1, net.base_mva);
        for (t, &pg_mw) in ph_dispatch.iter().enumerate() {
            if pg_mw > 1.0 {
                // Generating — must be outside forbidden zone
                assert!(
                    !(19.9..=79.9).contains(&pg_mw),
                    "Period {}: dispatch {:.1} MW is inside forbidden zone [20, 80]",
                    t,
                    pg_mw
                );
            }
        }
    }

    #[test]
    fn test_scuc_ph_min_gen_run_prevents_pump_switch() {
        // Two thermals: cheap ($10, 200 MW, must-run pmin=100) + expensive ($100, 200 MW).
        // PH: 100 MW, SOC_init=100 MWh, efficiency=1.0.
        // Load alternates: [300, 100, 300, 100, 300, 100].
        //
        // Free case: PH arbitrages — discharge in high-load hours (displaces $100
        // expensive gen), pump in low-load hours (costs $10 cheap gen). Saves ~$28k.
        // Observable: PH dispatch < -0.1 in at least one low-load hour.
        //
        // Constrained (min_gen_run=10): PH discharges hr 0 → m_gen=1 for all 6 hrs.
        // Can't pump → SOC depletes → no more discharge after hr 0.
        // Observable: PH dispatch ≥ -0.1 in ALL hours.
        let mut net = Network::new("ph_mut_test");
        net.base_mva = 100.0;
        net.buses.push(Bus::new(1, BusType::Slack, 138.0));

        // gen_index=0: cheap thermal, $10/MWh, 200 MW, must-run
        // Polynomial coeffs: [slope, intercept] → cost = slope*P + intercept
        let mut g_cheap = Generator::new(1, 200.0, 1.0);
        g_cheap.pmin = 100.0;
        g_cheap.pmax = 200.0;
        g_cheap.in_service = true;
        g_cheap.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
        g_cheap.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0],
        });
        net.generators.push(g_cheap);

        // gen_index=1: expensive thermal, $100/MWh, 200 MW
        let mut g_exp = Generator::new(1, 200.0, 1.0);
        g_exp.pmin = 0.0;
        g_exp.pmax = 200.0;
        g_exp.in_service = true;
        g_exp.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
        g_exp.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![100.0, 0.0],
        });
        net.generators.push(g_exp);

        // gen_index=2: PH storage, 100 MW, SOC=100 MWh
        let ph_gen = Generator {
            bus: 1,
            in_service: true,
            pmin: -100.0,
            pmax: 100.0,
            machine_base_mva: 100.0,
            cost: Some(CostCurve::Polynomial {
                coeffs: vec![0.0],
                startup: 0.0,
                shutdown: 0.0,
            }),
            storage: Some(StorageParams {
                charge_efficiency: 1.0,
                discharge_efficiency: 1.0,
                energy_capacity_mwh: 200.0,
                soc_initial_mwh: 100.0,
                soc_min_mwh: 0.0,
                soc_max_mwh: 200.0,
                variable_cost_per_mwh: 0.0,
                degradation_cost_per_mwh: 0.0,
                dispatch_mode: StorageDispatchMode::CostMinimization,
                self_schedule_mw: 0.0,
                discharge_offer: None,
                charge_bid: None,
                max_c_rate_charge: None,
                max_c_rate_discharge: None,
                chemistry: None,
                discharge_foldback_soc_mwh: None,
                charge_foldback_soc_mwh: None,
                daily_cycle_limit: None,
            }),
            ..Generator::default()
        };
        net.generators.push(ph_gen);

        // Hr 0: load 450 MW → cheap(200)+expensive(200)=400, PH MUST discharge ≥50 MW
        // forcing m_gen[0]=1. With min_gen_run=10, PH stuck in gen mode forever.
        let load = LoadProfiles {
            profiles: vec![LoadProfile {
                bus: 1,
                load_mw: vec![450.0, 100.0, 300.0, 100.0, 300.0, 100.0],
            }],
            n_timesteps: 6,
        };

        let mode_constraint = IndexedPhModeConstraint {
            gen_index: 2, // PH is gen_index=2 in this network
            min_gen_run_periods: 10,
            min_pump_run_periods: 1,
            pump_to_gen_periods: 0,
            gen_to_pump_periods: 0,
            max_pump_starts: None,
        };

        let opts_con = DispatchOptions {
            n_periods: 6,
            enforce_thermal_limits: false,
            load_profiles: load.clone(),
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ph_mode_constraints: vec![mode_constraint],
            ..DispatchOptions::default()
        };
        let sol_con = solve_scuc(&net, &opts_con).expect("SCUC should solve with mode constraint");

        let opts_free = DispatchOptions {
            n_periods: 6,
            enforce_thermal_limits: false,
            load_profiles: load,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..DispatchOptions::default()
        };
        let sol_free = solve_scuc(&net, &opts_free).expect("SCUC should solve without constraint");

        // gen_local=2 for PH (3 gens, all in-service)
        let dispatch_con = dispatch_mw(&sol_con, 2, net.base_mva);
        let dispatch_free = dispatch_mw(&sol_free, 2, net.base_mva);

        // Constrained: PH cannot pump (m_gen stuck on for all 6 hrs).
        for (t, &mw) in dispatch_con.iter().enumerate() {
            assert!(
                mw >= -0.1,
                "Period {}: dispatch {:.1} MW is pumping, but min_gen_run=10 prevents it",
                t,
                mw
            );
        }

        // Free: PH should pump in at least one low-load hour to recharge for
        // later discharge (displacing $100 expensive gen).
        let has_pumping = dispatch_free.iter().any(|&mw| mw < -0.1);
        assert!(
            has_pumping,
            "Without mode constraint, PH should pump to arbitrage cheap/expensive (dispatch={:?})",
            dispatch_free
        );

        // Constrained should cost more (PH can't cycle → expensive gen runs more)
        assert!(
            sol_con.summary.total_cost > sol_free.summary.total_cost,
            "Mode constraint should increase cost: constrained={:.0}, free={:.0}",
            sol_con.summary.total_cost,
            sol_free.summary.total_cost
        );
    }

    #[test]
    fn test_scuc_ph_pump_to_gen_delay() {
        // After pumping, must wait 1 period before generating.
        let net = ph_test_net();

        // Load: low (incentivizes pump), then high (incentivizes gen)
        let load = LoadProfiles {
            profiles: vec![LoadProfile {
                bus: 1,
                load_mw: vec![100.0, 100.0, 250.0, 250.0],
            }],
            n_timesteps: 4,
        };

        let mode_constraint = IndexedPhModeConstraint {
            gen_index: 1,
            min_gen_run_periods: 0,
            min_pump_run_periods: 0,
            pump_to_gen_periods: 1, // 1 period gap required after pump before gen
            gen_to_pump_periods: 0,
            max_pump_starts: None,
        };

        let opts = DispatchOptions {
            n_periods: 4,
            enforce_thermal_limits: false,
            load_profiles: load,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ph_mode_constraints: vec![mode_constraint],
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).expect("SCUC should solve");
        let dispatch = dispatch_mw(&sol, 1, net.base_mva);

        // Check: if period t is pumping and period t+1 is generating, that
        // violates the 1-period delay. There must be at least 1 idle period
        // between pump and gen.
        for t in 0..dispatch.len() - 1 {
            let is_pump = dispatch[t] < -0.5;
            let is_gen_next = dispatch[t + 1] > 0.5;
            assert!(
                !(is_pump && is_gen_next),
                "Period {}: pump ({:.1}) followed immediately by gen ({:.1}) \
                 violates pump_to_gen_delay=1",
                t,
                dispatch[t],
                dispatch[t + 1]
            );
        }
    }

    #[test]
    fn test_scuc_ph_head_curve_no_effect_at_high_soc() {
        // When SOC is high, head curve should not limit discharge.
        let mut net = ph_test_net();
        net.generators[1].storage.as_mut().unwrap().soc_initial_mwh = 900.0;

        let load = LoadProfiles {
            profiles: vec![LoadProfile {
                bus: 1,
                load_mw: vec![280.0; 4],
            }],
            n_timesteps: 4,
        };

        // Head curve: generous at high SOC
        let head_curve = IndexedPhHeadCurve {
            gen_index: 1,
            breakpoints: vec![(0.0, 20.0), (500.0, 80.0), (1000.0, 100.0)],
        };

        let opts = DispatchOptions {
            n_periods: 4,
            enforce_thermal_limits: false,
            load_profiles: load,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ph_head_curves: vec![head_curve],
            ..DispatchOptions::default()
        };

        let sol = solve_scuc(&net, &opts).expect("SCUC should solve");

        // At SOC=900, head curve allows ~96 MW. With load at 280 and thermal
        // max at 200, we need 80 MW from storage. This should be feasible.
        let dis_period0 = dispatch_mw(&sol, 1, net.base_mva)[0];
        assert!(
            dis_period0 > 50.0,
            "At high SOC, head curve should allow substantial discharge: got {:.1}",
            dis_period0
        );
    }
}

// ==========================================================================
// DR per-period dl_offer_schedules tests (SCUC multi-hour)
// ==========================================================================

#[cfg(test)]
mod tests_dl_per_period_scuc {
    use super::*;

    use std::collections::HashMap;
    use surge_network::Network;
    use surge_network::market::{
        CostCurve, DispatchableLoad, DlOfferSchedule, DlPeriodParams, LoadCostModel,
    };
    use surge_network::network::{Bus, BusType, Generator};

    use crate::dispatch::{CommitmentMode, Horizon, IndexedCommitmentOptions};
    use crate::legacy::DispatchOptions;

    /// Build a 1-bus, 1-gen network with a cheap generator and some load.
    fn one_gen_net(load_mw: f64) -> Network {
        let mut net = Network::new("dl_scuc_test");
        net.base_mva = 100.0;
        let b1 = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b1);
        net.loads.push(Load::new(1, load_mw, 0.0));

        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.pmin = 0.0;
        g0.pmax = 300.0;
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0],
        });
        net.generators.push(g0);

        net
    }

    /// Multi-hour SCUC: DL has different p_max per hour via dl_offer_schedules.
    /// Hour 0: p_max = 50 MW.  Hour 1: p_max = 10 MW (tighter cap).
    /// DL served should respect the per-period bound.
    #[test]
    fn test_scuc_dl_per_period_pmax_varies() {
        let net = one_gen_net(80.0);
        let base = net.base_mva;

        // Base DL: 50 MW, $200/MWh curtailment cost (very high → always served if feasible)
        let dl = DispatchableLoad::curtailable(1, 50.0, 0.0, 0.0, 200.0, base);

        let mut dl_schedules = HashMap::new();
        dl_schedules.insert(
            0usize,
            DlOfferSchedule {
                periods: vec![
                    // Hour 0: full 50 MW
                    Some(DlPeriodParams {
                        p_sched_pu: 50.0 / base,
                        p_max_pu: 50.0 / base,
                        q_sched_pu: None,
                        q_min_pu: None,
                        q_max_pu: None,
                        pq_linear_equality: None,
                        pq_linear_upper: None,
                        pq_linear_lower: None,
                        cost_model: LoadCostModel::LinearCurtailment { cost_per_mw: 200.0 },
                    }),
                    // Hour 1: only 10 MW max
                    Some(DlPeriodParams {
                        p_sched_pu: 10.0 / base,
                        p_max_pu: 10.0 / base,
                        q_sched_pu: None,
                        q_min_pu: None,
                        q_max_pu: None,
                        pq_linear_equality: None,
                        pq_linear_upper: None,
                        pq_linear_lower: None,
                        cost_model: LoadCostModel::LinearCurtailment { cost_per_mw: 200.0 },
                    }),
                ],
            },
        );

        let opts = DispatchOptions {
            n_periods: 2,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            enforce_thermal_limits: false,
            dispatchable_loads: vec![dl],
            dl_offer_schedules: dl_schedules,
            ..Default::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        // Hour 0: DL served at ~50 MW
        let served_h0 = sol.periods[0].dr_results.loads[0].p_served_pu * base;
        assert!(
            served_h0 > 49.0,
            "Hour 0: DL should be served at ~50 MW: got {served_h0:.2}"
        );

        // Hour 1: DL capped at 10 MW
        let served_h1 = sol.periods[1].dr_results.loads[0].p_served_pu * base;
        assert!(
            served_h1 <= 10.5,
            "Hour 1: DL should respect p_max=10 MW override: got {served_h1:.2}"
        );
    }

    /// Multi-hour SCUC: DL curtailment cost changes per period.
    /// Hour 0: cost=$200/MWh (above LMP → served). Hour 1: cost=$1/MWh (below LMP → curtailed).
    #[test]
    fn test_scuc_dl_per_period_cost_varies() {
        let net = one_gen_net(80.0);
        let base = net.base_mva;

        // Base DL: 40 MW, $200/MWh (very high → won't be curtailed by default)
        let dl = DispatchableLoad::curtailable(1, 40.0, 0.0, 0.0, 200.0, base);

        let mut dl_schedules = HashMap::new();
        dl_schedules.insert(
            0usize,
            DlOfferSchedule {
                periods: vec![
                    // Hour 0: high cost → not curtailed
                    Some(DlPeriodParams {
                        p_sched_pu: 40.0 / base,
                        p_max_pu: 40.0 / base,
                        q_sched_pu: None,
                        q_min_pu: None,
                        q_max_pu: None,
                        pq_linear_equality: None,
                        pq_linear_upper: None,
                        pq_linear_lower: None,
                        cost_model: LoadCostModel::LinearCurtailment { cost_per_mw: 200.0 },
                    }),
                    // Hour 1: very low cost → should be curtailed (gen at $10 > DL at $1)
                    Some(DlPeriodParams {
                        p_sched_pu: 40.0 / base,
                        p_max_pu: 40.0 / base,
                        q_sched_pu: None,
                        q_min_pu: None,
                        q_max_pu: None,
                        pq_linear_equality: None,
                        pq_linear_upper: None,
                        pq_linear_lower: None,
                        cost_model: LoadCostModel::LinearCurtailment { cost_per_mw: 1.0 },
                    }),
                ],
            },
        );

        let opts = DispatchOptions {
            n_periods: 2,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            enforce_thermal_limits: false,
            dispatchable_loads: vec![dl],
            dl_offer_schedules: dl_schedules,
            ..Default::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        // Hour 0: $200 >> LMP → served at full
        let served_h0 = sol.periods[0].dr_results.loads[0].p_served_pu * base;
        assert!(
            served_h0 > 39.0,
            "Hour 0: DL at $200 cost should not be curtailed: served={served_h0:.2}"
        );

        // Hour 1: $1 << LMP ($10) → should be curtailed
        let served_h1 = sol.periods[1].dr_results.loads[0].p_served_pu * base;
        assert!(
            served_h1 < 35.0,
            "Hour 1: DL at $1 cost should be curtailed: served={served_h1:.2}"
        );
    }

    #[test]
    #[ignore = "regression test for dbc566a9 (DL linear-curtailment dt-scaling fix); fix not in release/v0.1.2 — revisit after release"]
    fn test_scuc_dl_linear_curtailment_value_not_downscaled_by_dt() {
        let mut net = one_gen_net(0.0);
        net.generators[0].cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![100.0, 0.0],
        });
        let base = net.base_mva;

        let dl = DispatchableLoad::curtailable(1, 40.0, 0.0, 0.0, 30.0, base);
        let opts = DispatchOptions {
            n_periods: 1,
            dt_hours: 0.25,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            enforce_thermal_limits: false,
            dispatchable_loads: vec![dl],
            ..Default::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();
        let served_h0 = sol.periods[0].dr_results.loads[0].p_served_pu * base;
        assert!(
            served_h0 > 39.0,
            "quarter-hour DL value should still clear in SCUC: served={served_h0:.2}"
        );
    }
}

// ==========================================================================
// CC config-specific energy offers + pair-specific transition cost tests
// ==========================================================================

#[cfg(test)]
mod tests_cc_config_offers {
    use super::*;
    use std::collections::HashMap;

    use surge_network::Network;
    use surge_network::market::{
        CombinedCycleConfig, CombinedCyclePlant, CombinedCycleTransition, CostCurve, OfferCurve,
        OfferSchedule,
    };
    use surge_network::network::{Branch, Bus, BusType, Generator};

    use crate::dispatch::{CommitmentMode, Horizon, IndexedCommitmentOptions};
    use crate::legacy::DispatchOptions;

    /// Build a CC test network with 2 configs (1x0 and 2x1), where config-specific
    /// energy offers can override the default generator costs.
    fn make_cc_config_offer_network() -> Network {
        let mut net = Network::new("cc_config_offer_test");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        let b2 = Bus::new(2, BusType::PQ, 138.0);
        net.buses.push(b1);
        net.buses.push(b2);
        net.loads.push(Load::new(2, 100.0, 0.0));

        let mut br = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
        br.rating_a_mva = 500.0;
        net.branches.push(br);

        // CT1 (gen 0): default cost $40/MWh — will be overridden by config offers
        let mut ct1 = Generator::new(1, 0.0, 1.0);
        ct1.pmin = 20.0;
        ct1.pmax = 80.0;
        ct1.cost = Some(CostCurve::Polynomial {
            startup: 100.0,
            shutdown: 0.0,
            coeffs: vec![40.0, 0.0],
        });

        // CT2 (gen 1): in 2x1 only
        let mut ct2 = Generator::new(1, 0.0, 1.0);
        ct2.pmin = 20.0;
        ct2.pmax = 80.0;
        ct2.machine_id = Some("2".into());
        ct2.cost = Some(CostCurve::Polynomial {
            startup: 100.0,
            shutdown: 0.0,
            coeffs: vec![40.0, 0.0],
        });

        // Peaker (gen 2): very expensive ($100/MWh)
        let mut peaker = Generator::new(2, 0.0, 1.0);
        peaker.pmin = 0.0;
        peaker.pmax = 200.0;
        peaker.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![100.0, 0.0],
        });

        net.generators.push(ct1);
        net.generators.push(ct2);
        net.generators.push(peaker);

        net.market_data
            .combined_cycle_plants
            .push(CombinedCyclePlant {
                id: String::new(),
                name: "CC1".into(),
                configs: vec![
                    CombinedCycleConfig {
                        name: "1x0".into(),
                        gen_indices: vec![0],
                        p_min_mw: 20.0,
                        p_max_mw: 80.0,
                        heat_rate_curve: vec![],
                        energy_offer: None,
                        ramp_up_curve: vec![],
                        ramp_down_curve: vec![],
                        min_up_time_hr: 1.0,
                        min_down_time_hr: 1.0,
                        no_load_cost: 0.0,
                        reserve_offers: vec![],
                        qualifications: Default::default(),
                    },
                    CombinedCycleConfig {
                        name: "2x1".into(),
                        gen_indices: vec![0, 1],
                        p_min_mw: 40.0,
                        p_max_mw: 160.0,
                        heat_rate_curve: vec![],
                        energy_offer: None,
                        ramp_up_curve: vec![],
                        ramp_down_curve: vec![],
                        min_up_time_hr: 1.0,
                        min_down_time_hr: 1.0,
                        no_load_cost: 0.0,
                        reserve_offers: vec![],
                        qualifications: Default::default(),
                    },
                ],
                transitions: vec![
                    CombinedCycleTransition {
                        from_config: "1x0".into(),
                        to_config: "2x1".into(),
                        transition_time_min: 0.0,
                        transition_cost: 50.0,
                        online_transition: true,
                    },
                    CombinedCycleTransition {
                        from_config: "2x1".into(),
                        to_config: "1x0".into(),
                        transition_time_min: 0.0,
                        transition_cost: 25.0,
                        online_transition: true,
                    },
                ],
                active_config: None,
                hours_in_config: 0.0,
                duct_firing_capable: false,
            });

        net
    }

    #[test]
    fn test_fixed_commitment_piecewise_offer_schedule_overrides_static_cost() {
        let mut net = Network::new("offer_schedule_pwl_override");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        let b2 = Bus::new(2, BusType::PQ, 138.0);
        net.buses.push(b1);
        net.buses.push(b2);
        net.loads.push(Load::new(2, 100.0, 0.0));

        let mut br = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
        br.rating_a_mva = 500.0;
        net.branches.push(br);

        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.pmin = 0.0;
        g0.pmax = 100.0;
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![80.0, 0.0],
        });

        let mut g1 = Generator::new(2, 0.0, 1.0);
        g1.pmin = 0.0;
        g1.pmax = 100.0;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![50.0, 0.0],
        });

        net.generators.push(g0);
        net.generators.push(g1);

        let opts = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Fixed {
                commitment: vec![true, true],
                per_period: None,
            },
            offer_schedules: HashMap::from([(
                0,
                OfferSchedule {
                    periods: vec![Some(OfferCurve {
                        segments: vec![(50.0, 5.0), (100.0, 5.0)],
                        no_load_cost: 0.0,
                        startup_tiers: vec![],
                    })],
                },
            )]),
            ..Default::default()
        };

        let sol = solve_scuc(&net, &opts).expect("fixed-commitment pricing solve should succeed");
        let pg = &sol.periods[0].pg_mw;

        assert!(
            pg[0] > 99.0,
            "piecewise offer override should make gen 0 the marginally cheapest unit, got dispatch {:?}",
            pg
        );
        assert!(
            pg[1] < 1.0,
            "gen 1 should back down once gen 0's offer schedule is honored, got dispatch {:?}",
            pg
        );
    }

    #[test]
    fn test_scuc_pricing_avoids_soft_balance_penalty_when_thermal_is_at_pmin() {
        let mut net = Network::new("pricing_pmin_headroom");
        net.base_mva = 100.0;
        let bus = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(bus);
        net.loads.push(Load::new(1, 130.0, 0.0));

        let mut thermal = Generator::new(1, 0.0, 1.0);
        thermal.pmin = 50.0;
        thermal.pmax = 200.0;
        thermal.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![20.0, 0.0],
        });
        net.generators.push(thermal);

        let mut fixed_block = Generator::new(1, 0.0, 1.0);
        fixed_block.pmin = 80.0;
        fixed_block.pmax = 80.0;
        fixed_block.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![0.0, 0.0],
        });
        net.generators.push(fixed_block);

        let opts = DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Fixed {
                commitment: vec![true, true],
                per_period: None,
            },
            ..Default::default()
        };

        let sol = solve_scuc(&net, &opts).expect("fixed-commitment pricing solve should succeed");
        assert_eq!(sol.periods.len(), 1);
        assert!(
            (sol.periods[0].pg_mw[0] - 50.0).abs() < 1e-6,
            "thermal unit should sit at pmin, got {:?}",
            sol.periods[0].pg_mw
        );
        assert!(
            sol.periods[0].lmp[0].abs() < 1_000.0,
            "LMP should not collapse to the soft power-balance penalty, got {:.6}",
            sol.periods[0].lmp[0]
        );
    }

    /// CC config energy offers: 1x0 config has $15/MWh offer vs default $40/MWh.
    /// At 60 MW load, 1x0 (max 80) is sufficient. With cheaper offer, the CC plant
    /// should produce more and the peaker less, reducing total cost.
    #[test]
    fn test_cc_config_offer_reduces_cost() {
        let net = make_cc_config_offer_network();

        // Without config offers: gen costs are $40/MWh
        let opts_no_offer = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };
        let sol_no_offer = solve_scuc(&net, &opts_no_offer).unwrap();

        // With config offers: 1x0 config has $15/MWh for CT1 (much cheaper)
        let cc_config_offers = vec![
            // Plant 0
            vec![
                // Config 0 (1x0): gen 0 at $15/MWh
                OfferSchedule {
                    periods: vec![
                        Some(OfferCurve {
                            segments: vec![(20.0, 15.0), (80.0, 15.0)],
                            no_load_cost: 0.0,
                            startup_tiers: vec![],
                        }),
                        Some(OfferCurve {
                            segments: vec![(20.0, 15.0), (80.0, 15.0)],
                            no_load_cost: 0.0,
                            startup_tiers: vec![],
                        }),
                    ],
                },
                // Config 1 (2x1): gen 0 at $40 (unchanged), gen 1 at $40 (unchanged)
                OfferSchedule {
                    periods: vec![None, None],
                },
            ],
        ];

        let opts_with_offer = DispatchOptions {
            n_periods: 2,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            cc_config_offers,
            ..Default::default()
        };
        let sol_with_offer = solve_scuc(&net, &opts_with_offer).unwrap();

        // With cheaper config offers, total cost should be lower
        assert!(
            sol_with_offer.summary.total_cost < sol_no_offer.summary.total_cost + 1.0,
            "Config offer at $15 should reduce cost: with={:.2}, without={:.2}",
            sol_with_offer.summary.total_cost,
            sol_no_offer.summary.total_cost,
        );

        // Both should solve with valid power balance
        for t in 0..2 {
            let total_gen: f64 = sol_with_offer.periods[t].pg_mw.iter().sum();
            assert!(
                (total_gen - 100.0).abs() < 1.0,
                "hour {t}: gen={total_gen:.1}, expected ~100"
            );
        }
    }

    /// CC pair-specific transition costs: A→B costs $500, B→A costs $10.
    /// When plant transitions, the exact pair cost should appear in the solution,
    /// not a max-cost approximation.
    #[test]
    fn test_cc_pair_specific_transition_cost() {
        let mut net = Network::new("cc_ytrans_test");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        let b2 = Bus::new(2, BusType::PQ, 138.0);
        net.buses.push(b1);
        net.buses.push(b2);
        net.loads.push(Load::new(2, 50.0, 0.0));

        let mut br = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
        br.rating_a_mva = 500.0;
        net.branches.push(br);

        // Gen 0: cheap ($10) — in configs A and B
        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.pmin = 10.0;
        g0.pmax = 100.0;
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0],
        });

        // Gen 1: expensive ($80) — fallback
        let mut g1 = Generator::new(2, 0.0, 1.0);
        g1.pmin = 0.0;
        g1.pmax = 200.0;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![80.0, 0.0],
        });

        net.generators.push(g0);
        net.generators.push(g1);

        // Two configs: A (gen 0, $10), B (gen 0, $10), same gen — but different transition costs.
        // A→B costs $500, B→A costs $10.
        net.market_data
            .combined_cycle_plants
            .push(CombinedCyclePlant {
                id: String::new(),
                name: "CC_ytrans".into(),
                configs: vec![
                    CombinedCycleConfig {
                        name: "A".into(),
                        gen_indices: vec![0],
                        p_min_mw: 10.0,
                        p_max_mw: 100.0,
                        heat_rate_curve: vec![],
                        energy_offer: None,
                        ramp_up_curve: vec![],
                        ramp_down_curve: vec![],
                        min_up_time_hr: 1.0,
                        min_down_time_hr: 1.0,
                        no_load_cost: 0.0,
                        reserve_offers: vec![],
                        qualifications: Default::default(),
                    },
                    CombinedCycleConfig {
                        name: "B".into(),
                        gen_indices: vec![0],
                        p_min_mw: 10.0,
                        p_max_mw: 100.0,
                        heat_rate_curve: vec![],
                        energy_offer: None,
                        ramp_up_curve: vec![],
                        ramp_down_curve: vec![],
                        min_up_time_hr: 1.0,
                        min_down_time_hr: 1.0,
                        no_load_cost: 0.0,
                        reserve_offers: vec![],
                        qualifications: Default::default(),
                    },
                ],
                transitions: vec![
                    CombinedCycleTransition {
                        from_config: "A".into(),
                        to_config: "B".into(),
                        transition_time_min: 0.0,
                        transition_cost: 500.0, // Expensive A→B
                        online_transition: true,
                    },
                    CombinedCycleTransition {
                        from_config: "B".into(),
                        to_config: "A".into(),
                        transition_time_min: 0.0,
                        transition_cost: 10.0, // Cheap B→A
                        online_transition: true,
                    },
                ],
                active_config: Some("A".into()),
                hours_in_config: 10.0,
                duct_firing_capable: false,
            });

        // 4 hours, same load: plant should stay in A (avoid $500 A→B transition).
        let opts = DispatchOptions {
            n_periods: 4,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        // Plant should stay in config A to avoid the $500 transition cost
        for t in 0..4 {
            assert_eq!(
                sol.cc_config_schedule[t][0].as_deref(),
                Some("A"),
                "hour {t}: should stay in A to avoid $500 A→B transition"
            );
        }

        // Transition cost should be 0 (no transitions happened)
        assert!(
            sol.cc_transition_cost < 1.0,
            "No transitions → cc_transition_cost should be ~0: got {:.2}",
            sol.cc_transition_cost
        );
    }

    /// Verify that with asymmetric transition costs, the cheaper direction is preferred.
    /// Start in B, which has MUT=1 (already met). Optimizer should stay in B
    /// (or go B→A at $10), never A→B at $500.
    #[test]
    fn test_cc_asymmetric_transition_cost_preferred() {
        let mut net = Network::new("cc_asym_test");
        net.base_mva = 100.0;

        let b1 = Bus::new(1, BusType::Slack, 138.0);
        net.buses.push(b1);
        net.loads.push(Load::new(1, 50.0, 0.0));

        let mut g0 = Generator::new(1, 0.0, 1.0);
        g0.pmin = 10.0;
        g0.pmax = 100.0;
        g0.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![10.0, 0.0],
        });
        net.generators.push(g0);

        let mut g1 = Generator::new(1, 0.0, 1.0);
        g1.pmin = 0.0;
        g1.pmax = 200.0;
        g1.cost = Some(CostCurve::Polynomial {
            startup: 0.0,
            shutdown: 0.0,
            coeffs: vec![80.0, 0.0],
        });
        net.generators.push(g1);

        net.market_data
            .combined_cycle_plants
            .push(CombinedCyclePlant {
                id: String::new(),
                name: "CC_asym".into(),
                configs: vec![
                    CombinedCycleConfig {
                        name: "A".into(),
                        gen_indices: vec![0],
                        p_min_mw: 10.0,
                        p_max_mw: 100.0,
                        heat_rate_curve: vec![],
                        energy_offer: None,
                        ramp_up_curve: vec![],
                        ramp_down_curve: vec![],
                        min_up_time_hr: 1.0,
                        min_down_time_hr: 1.0,
                        no_load_cost: 0.0,
                        reserve_offers: vec![],
                        qualifications: Default::default(),
                    },
                    CombinedCycleConfig {
                        name: "B".into(),
                        gen_indices: vec![0],
                        p_min_mw: 10.0,
                        p_max_mw: 100.0,
                        heat_rate_curve: vec![],
                        energy_offer: None,
                        ramp_up_curve: vec![],
                        ramp_down_curve: vec![],
                        min_up_time_hr: 1.0,
                        min_down_time_hr: 1.0,
                        no_load_cost: 0.0,
                        reserve_offers: vec![],
                        qualifications: Default::default(),
                    },
                ],
                transitions: vec![
                    CombinedCycleTransition {
                        from_config: "A".into(),
                        to_config: "B".into(),
                        transition_time_min: 0.0,
                        transition_cost: 500.0,
                        online_transition: true,
                    },
                    CombinedCycleTransition {
                        from_config: "B".into(),
                        to_config: "A".into(),
                        transition_time_min: 0.0,
                        transition_cost: 10.0,
                        online_transition: true,
                    },
                ],
                active_config: Some("B".into()),
                hours_in_config: 10.0,
                duct_firing_capable: false,
            });

        let opts = DispatchOptions {
            n_periods: 3,
            enforce_thermal_limits: false,
            enforce_flowgates: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
            ..Default::default()
        };

        let sol = solve_scuc(&net, &opts).unwrap();

        // The optimizer should never choose A→B ($500).
        // It might stay in B or go B→A ($10), but never A→B.
        let schedule: Vec<&str> = (0..3)
            .map(|t| sol.cc_config_schedule[t][0].as_deref().unwrap_or("off"))
            .collect();

        for t in 1..3 {
            if schedule[t - 1] == "A" && schedule[t] == "B" {
                panic!("t={t}: A→B transition ($500) should be avoided, schedule={schedule:?}");
            }
        }

        // Transition cost should be at most $10 (one cheap B→A), not $500
        assert!(
            sol.cc_transition_cost < 100.0,
            "Transition cost should be small (at most $10 for B→A): got {:.2}",
            sol.cc_transition_cost
        );
    }
}

// ---------------------------------------------------------------------------
// SoC-reserve coupling: reserve awards tightened by SoC impact factors
// ---------------------------------------------------------------------------

/// SCUC with storage and spinning reserve. When `storage_reserve_soc_impact`
/// is nonzero, the LP must limit reserve awards so the battery can actually
/// deliver the energy if called.
///
/// Setup:
///   - 1 bus, 1 must-run gen at pmin=pmax=100 MW (no headroom, cannot charge BESS)
///   - Load: 100 MW constant × 4 periods (gen exactly covers load)
///   - BESS: 50 MW, 20 MWh capacity, η=1.0, SoC_init=10 MWh, soc_min=0
///   - Spin reserve requirement: 40 MW (only BESS can provide — gen has no headroom)
///
/// Case A: impact=0 (default) — BESS gets 40 MW spin (power-limited)
/// Case B: impact=1.0, dt=1hr
///   → SoC floor: soc[t] - R_spin × 1.0 × 1.0 × base ≥ 0
///   → BESS cannot charge (gen has no surplus), SoC stays ~10 MWh
///   → max R ≈ 10/100 = 0.10 pu = 10 MW
///   → Remainder (30 MW) covered by penalty slack
#[test]
fn test_scuc_soc_reserve_coupling_limits_award() {
    use std::collections::HashMap;
    use surge_network::Network;
    use surge_network::market::{
        CostCurve, EnergyCoupling, LoadProfile, LoadProfiles, PenaltyCurve, QualificationRule,
        ReserveDirection, ReserveOffer, ReserveProduct,
    };
    use surge_network::network::{Bus, BusType, Generator, StorageDispatchMode, StorageParams};

    let mut net = Network::new("soc_reserve_coupling");
    net.base_mva = 100.0;

    let b1 = Bus::new(1, BusType::Slack, 138.0);
    net.buses.push(b1);
    net.loads.push(Load::new(1, 100.0, 0.0));

    // Must-run gen: EXACTLY 100 MW (pmin=pmax=100, no headroom at all)
    let mut g0 = Generator::new(1, 100.0, 1.0);
    g0.pmin = 100.0;
    g0.pmax = 100.0;
    g0.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
    g0.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![20.0, 0.0],
    });
    // Gen has NO reserve capability (pmax=pmin → headroom=0)
    net.generators.push(g0);

    // BESS: 50 MW, 20 MWh capacity, η=1.0, low initial SoC
    // Cannot charge (gen has no surplus to supply).
    let bess = Generator {
        bus: 1,
        in_service: true,
        pmin: -50.0,
        pmax: 50.0,
        machine_base_mva: 100.0,
        cost: Some(CostCurve::Polynomial {
            coeffs: vec![0.0],
            startup: 0.0,
            shutdown: 0.0,
        }),
        market: Some(surge_network::network::MarketParams {
            reserve_offers: vec![ReserveOffer {
                product_id: "spin".into(),
                capacity_mw: 50.0,
                cost_per_mwh: 2.0,
            }],
            ..Default::default()
        }),
        storage: Some(StorageParams {
            charge_efficiency: 1.0,
            discharge_efficiency: 1.0,
            energy_capacity_mwh: 20.0,
            soc_initial_mwh: 10.0,
            soc_min_mwh: 0.0,
            soc_max_mwh: 20.0,
            variable_cost_per_mwh: 0.0,
            degradation_cost_per_mwh: 0.0,
            dispatch_mode: StorageDispatchMode::CostMinimization,
            self_schedule_mw: 0.0,
            discharge_offer: None,
            charge_bid: None,
            max_c_rate_charge: None,
            max_c_rate_discharge: None,
            chemistry: None,
            discharge_foldback_soc_mwh: None,
            charge_foldback_soc_mwh: None,
            daily_cycle_limit: None,
        }),
        ..Generator::default()
    };
    net.generators.push(bess);

    let spin = ReserveProduct {
        id: "spin".into(),
        name: "Spinning Reserve".into(),
        direction: ReserveDirection::Up,
        deploy_secs: 600.0,
        qualification: QualificationRule::Committed,
        energy_coupling: EnergyCoupling::Headroom,
        dispatchable_load_energy_coupling: None,
        shared_limit_products: Vec::new(),
        balance_products: Vec::new(),
        kind: surge_network::market::ReserveKind::Real,
        apply_deploy_ramp_limit: true,
        demand_curve: PenaltyCurve::Linear {
            cost_per_unit: 1000.0,
        },
    };

    let base_opts = DispatchOptions {
        n_periods: 4,
        enforce_thermal_limits: false,
        load_profiles: LoadProfiles {
            profiles: vec![LoadProfile {
                bus: 1,
                load_mw: vec![100.0; 4],
            }],
            n_timesteps: 4,
        },
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        reserve_products: vec![spin.clone()],
        system_reserve_requirements: vec![SystemReserveRequirement {
            product_id: "spin".into(),
            requirement_mw: 40.0,
            per_period_mw: None,
        }],
        ..DispatchOptions::default()
    };

    // ── Case A: no SoC impact (default) ──
    let sol_a = solve_scuc(&net, &base_opts).unwrap();
    let bess_spin_a: Vec<f64> = (0..4)
        .map(|t| {
            sol_a.periods[t]
                .reserve_awards
                .get("spin")
                .and_then(|v| v.get(1).copied()) // gen index 1 = BESS
                .unwrap_or(0.0)
        })
        .collect();
    eprintln!("Case A (no impact): BESS spin awards = {bess_spin_a:?}");

    // Without SoC coupling, BESS should get 40+ MW spin (only provider).
    assert!(
        bess_spin_a[0] >= 39.0,
        "Without SoC coupling, BESS should get ~40 MW spin: got {:.1}",
        bess_spin_a[0]
    );

    // ── Case B: SoC impact = 1.0 for spin ──
    let mut impact_map: HashMap<usize, HashMap<String, Vec<f64>>> = HashMap::new();
    let mut product_map: HashMap<String, Vec<f64>> = HashMap::new();
    product_map.insert("spin".into(), vec![1.0; 4]); // impact=1.0 every period
    impact_map.insert(1, product_map); // gen_index 1 = BESS

    let opts_b = DispatchOptions {
        storage_reserve_soc_impact: impact_map,
        ..base_opts.clone()
    };

    let sol_b = solve_scuc(&net, &opts_b).unwrap();
    let bess_spin_b: Vec<f64> = (0..4)
        .map(|t| {
            sol_b.periods[t]
                .reserve_awards
                .get("spin")
                .and_then(|v| v.get(1).copied())
                .unwrap_or(0.0)
        })
        .collect();
    eprintln!("Case B (impact=1.0): BESS spin awards = {bess_spin_b:?}");
    let storage_gi = first_storage_gen_index(&net);
    let soc_b = &sol_b.storage_soc[&storage_gi];
    eprintln!("Case B SoC trajectory: {soc_b:?}");

    // With impact=1.0 and SoC~10 MWh (BESS idle, dt=1hr, base=100):
    // Floor: soc[0] - R_spin × 100 ≥ 0 → R ≤ soc[0]/100
    // soc[0] ≈ 10 (gen exactly covers load, BESS cannot charge)
    // → R_spin[BESS,0] ≤ 10/100 = 0.10 pu = 10 MW
    assert!(
        bess_spin_b[0] < 12.0,
        "With SoC impact=1.0 and ~10 MWh SoC, BESS period-0 spin should be ≤ ~10 MW, got {:.1}",
        bess_spin_b[0]
    );

    // Case B period-0 BESS spin should be strictly less than Case A period-0
    assert!(
        bess_spin_b[0] < bess_spin_a[0] - 10.0,
        "SoC coupling should reduce BESS reserve: A[0]={:.1}, B[0]={:.1}",
        bess_spin_a[0],
        bess_spin_b[0]
    );
}

#[test]
fn test_scuc_soc_reserve_coupling_limits_down_award_with_signed_impact() {
    use std::collections::HashMap;
    use surge_network::Network;
    use surge_network::market::{
        CostCurve, EnergyCoupling, LoadProfile, LoadProfiles, PenaltyCurve, QualificationRule,
        ReserveDirection, ReserveOffer, ReserveProduct,
    };
    use surge_network::network::{Bus, BusType, Generator, StorageDispatchMode, StorageParams};

    let mut net = Network::new("scuc_soc_reserve_coupling_down");
    net.base_mva = 100.0;

    let bus = Bus::new(1, BusType::Slack, 138.0);
    net.buses.push(bus);
    net.loads.push(Load::new(1, 100.0, 0.0));

    let mut must_run = Generator::new(1, 100.0, 1.0);
    must_run.pmin = 100.0;
    must_run.pmax = 100.0;
    must_run.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
    must_run.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![20.0, 0.0],
    });
    net.generators.push(must_run);

    net.generators.push(Generator {
        bus: 1,
        in_service: true,
        pmin: -50.0,
        pmax: 50.0,
        machine_base_mva: 100.0,
        cost: Some(CostCurve::Polynomial {
            coeffs: vec![0.0],
            startup: 0.0,
            shutdown: 0.0,
        }),
        market: Some(surge_network::network::MarketParams {
            reserve_offers: vec![ReserveOffer {
                product_id: "reg_down".into(),
                capacity_mw: 50.0,
                cost_per_mwh: 2.0,
            }],
            ..Default::default()
        }),
        storage: Some(StorageParams {
            charge_efficiency: 1.0,
            discharge_efficiency: 1.0,
            energy_capacity_mwh: 20.0,
            soc_initial_mwh: 19.0,
            soc_min_mwh: 0.0,
            soc_max_mwh: 20.0,
            variable_cost_per_mwh: 0.0,
            degradation_cost_per_mwh: 0.0,
            dispatch_mode: StorageDispatchMode::CostMinimization,
            self_schedule_mw: 0.0,
            discharge_offer: None,
            charge_bid: None,
            max_c_rate_charge: None,
            max_c_rate_discharge: None,
            chemistry: None,
            discharge_foldback_soc_mwh: None,
            charge_foldback_soc_mwh: None,
            daily_cycle_limit: None,
        }),
        ..Generator::default()
    });

    let reg_down = ReserveProduct {
        id: "reg_down".into(),
        name: "Regulation Down".into(),
        direction: ReserveDirection::Down,
        deploy_secs: 300.0,
        qualification: QualificationRule::Committed,
        energy_coupling: EnergyCoupling::Footroom,
        dispatchable_load_energy_coupling: None,
        shared_limit_products: Vec::new(),
        balance_products: Vec::new(),
        kind: surge_network::market::ReserveKind::Real,
        apply_deploy_ramp_limit: true,
        demand_curve: PenaltyCurve::Linear {
            cost_per_unit: 1000.0,
        },
    };

    let opts = DispatchOptions {
        n_periods: 2,
        enforce_thermal_limits: false,
        load_profiles: LoadProfiles {
            profiles: vec![LoadProfile {
                bus: 1,
                load_mw: vec![100.0; 2],
            }],
            n_timesteps: 2,
        },
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        reserve_products: vec![reg_down],
        system_reserve_requirements: vec![SystemReserveRequirement {
            product_id: "reg_down".into(),
            requirement_mw: 40.0,
            per_period_mw: None,
        }],
        storage_reserve_soc_impact: HashMap::from([(
            1,
            HashMap::from([(String::from("reg_down"), vec![-1.0; 2])]),
        )]),
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).expect("SCUC with signed down-reserve SoC coupling");
    let bess_reg_down = sol.periods[0]
        .reserve_awards
        .get("reg_down")
        .and_then(|awards| awards.get(1))
        .copied()
        .unwrap_or(0.0);
    assert!(
        bess_reg_down < 2.0,
        "BESS down reserve should be limited by the SoC ceiling, got {:.3} MW",
        bess_reg_down
    );
}

// ---------------------------------------------------------------------------
// Per-period storage self-schedules via DispatchOptions
// ---------------------------------------------------------------------------

/// SCUC self-schedule storage should use per-period MW from
/// `storage_self_schedules` when provided, not the scalar from StorageParams.
#[test]
fn test_scuc_per_period_storage_self_schedule() {
    use std::collections::HashMap;
    use surge_network::Network;
    use surge_network::market::{CostCurve, LoadProfile, LoadProfiles};
    use surge_network::network::{Bus, BusType, Generator, StorageDispatchMode, StorageParams};

    let mut net = Network::new("self_schedule_per_period");
    net.base_mva = 100.0;

    let b1 = Bus::new(1, BusType::Slack, 138.0);
    net.buses.push(b1);
    net.loads.push(Load::new(1, 100.0, 0.0));

    // Must-run generator: 200 MW capacity
    let mut g0 = Generator::new(1, 100.0, 1.0);
    g0.pmin = 10.0;
    g0.pmax = 200.0;
    g0.commitment.get_or_insert_default().status = CommitmentStatus::MustRun;
    g0.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![20.0, 0.0],
    });
    net.generators.push(g0);

    // BESS: 50 MW, 200 MWh, η=1.0, SelfSchedule mode
    // StorageParams.self_schedule_mw = 0 (scalar default — should be overridden)
    let bess = Generator {
        bus: 1,
        in_service: true,
        pmin: -50.0,
        pmax: 50.0,
        machine_base_mva: 100.0,
        cost: Some(CostCurve::Polynomial {
            coeffs: vec![0.0],
            startup: 0.0,
            shutdown: 0.0,
        }),
        storage: Some(StorageParams {
            charge_efficiency: 1.0,
            discharge_efficiency: 1.0,
            energy_capacity_mwh: 200.0,
            soc_initial_mwh: 100.0,
            soc_min_mwh: 0.0,
            soc_max_mwh: 200.0,
            variable_cost_per_mwh: 0.0,
            degradation_cost_per_mwh: 0.0,
            dispatch_mode: StorageDispatchMode::SelfSchedule,
            self_schedule_mw: 0.0, // scalar default — overridden below
            discharge_offer: None,
            charge_bid: None,
            max_c_rate_charge: None,
            max_c_rate_discharge: None,
            chemistry: None,
            discharge_foldback_soc_mwh: None,
            charge_foldback_soc_mwh: None,
            daily_cycle_limit: None,
        }),
        ..Generator::default()
    };
    net.generators.push(bess);

    // Per-period schedule: charge 30 MW in periods 0-1, discharge 30 MW in periods 2-3
    let mut self_schedules: HashMap<usize, Vec<f64>> = HashMap::new();
    self_schedules.insert(1, vec![-30.0, -30.0, 30.0, 30.0]); // gen_index 1 = BESS

    let opts = DispatchOptions {
        n_periods: 4,
        enforce_thermal_limits: false,
        load_profiles: LoadProfiles {
            profiles: vec![LoadProfile {
                bus: 1,
                load_mw: vec![100.0; 4],
            }],
            n_timesteps: 4,
        },
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions::default()),
        storage_self_schedules: Some(self_schedules),
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).unwrap();

    // Verify BESS dispatch follows the per-period schedule, not the scalar 0
    for t in 0..4 {
        // pg_mw[1] = BESS net injection (positive = discharge)
        let bess_pg = sol.periods[t].pg_mw[1];
        let expected = if t < 2 { -30.0 } else { 30.0 };
        assert!(
            (bess_pg - expected).abs() < 2.0,
            "Period {t}: BESS dispatch should be {expected:.0} MW (per-period schedule), got {bess_pg:.1}"
        );
    }

    // Verify SoC trajectory matches the schedule:
    // SoC_init=100, charge 30 MW × 1h × 2 periods = +60, discharge 30 MW × 1h × 2 = -60
    let storage_gi = first_storage_gen_index(&net);
    let soc = &sol.storage_soc[&storage_gi];
    assert!(
        (soc[0] - 130.0).abs() < 2.0,
        "SoC[0] should be ~130 (100 + 30×1): got {:.1}",
        soc[0]
    );
    assert!(
        (soc[1] - 160.0).abs() < 2.0,
        "SoC[1] should be ~160 (130 + 30×1): got {:.1}",
        soc[1]
    );
    assert!(
        (soc[3] - 100.0).abs() < 2.0,
        "SoC[3] should be ~100 (round-trip): got {:.1}",
        soc[3]
    );
}

#[test]
fn test_scuc_generator_reserve_offer_schedules_are_period_specific() {
    use std::collections::HashMap;

    use surge_network::market::{
        EnergyCoupling, PenaltyCurve, QualificationRule, RampSharingConfig, ReserveDirection,
        ReserveKind, ReserveOffer, ReserveProduct,
    };
    use surge_network::network::{Bus, BusType, Generator, MarketParams};

    let mut net = surge_network::Network::new("reserve_schedule_swap");
    net.base_mva = 100.0;
    net.buses = vec![Bus::new(1, BusType::Slack, 138.0)];

    let reserve_product = ReserveProduct {
        id: "spin".into(),
        name: "Spin".into(),
        direction: ReserveDirection::Up,
        deploy_secs: 600.0,
        qualification: QualificationRule::Committed,
        energy_coupling: EnergyCoupling::Headroom,
        dispatchable_load_energy_coupling: None,
        shared_limit_products: Vec::new(),
        balance_products: Vec::new(),
        demand_curve: PenaltyCurve::Linear {
            cost_per_unit: 1000.0,
        },
        kind: ReserveKind::Real,
        apply_deploy_ramp_limit: true,
    };

    let mut g0 = Generator::new(1, 0.0, 100.0);
    g0.market = Some(MarketParams {
        reserve_offers: vec![ReserveOffer {
            product_id: "spin".into(),
            capacity_mw: 100.0,
            cost_per_mwh: 50.0,
        }],
        ..Default::default()
    });
    let mut g1 = Generator::new(1, 0.0, 100.0);
    g1.id = "g1".into();
    g1.machine_id = Some("2".into());
    g1.market = Some(MarketParams {
        reserve_offers: vec![ReserveOffer {
            product_id: "spin".into(),
            capacity_mw: 100.0,
            cost_per_mwh: 50.0,
        }],
        ..Default::default()
    });
    net.generators = vec![g0, g1];

    let opts = DispatchOptions {
        n_periods: 2,
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::AllCommitted,
        reserve_products: vec![reserve_product],
        system_reserve_requirements: vec![SystemReserveRequirement {
            product_id: "spin".into(),
            requirement_mw: 0.0,
            per_period_mw: Some(vec![10.0, 10.0]),
        }],
        ramp_sharing: RampSharingConfig::default(),
        offer_schedules: HashMap::from([
            (
                0usize,
                surge_network::market::OfferSchedule {
                    periods: vec![
                        Some(OfferCurve {
                            segments: vec![(100.0, 0.0)],
                            no_load_cost: 0.0,
                            startup_tiers: vec![],
                        }),
                        Some(OfferCurve {
                            segments: vec![(100.0, 0.0)],
                            no_load_cost: 0.0,
                            startup_tiers: vec![],
                        }),
                    ],
                },
            ),
            (
                1usize,
                surge_network::market::OfferSchedule {
                    periods: vec![
                        Some(OfferCurve {
                            segments: vec![(100.0, 0.0)],
                            no_load_cost: 0.0,
                            startup_tiers: vec![],
                        }),
                        Some(OfferCurve {
                            segments: vec![(100.0, 0.0)],
                            no_load_cost: 0.0,
                            startup_tiers: vec![],
                        }),
                    ],
                },
            ),
        ]),
        gen_reserve_offer_schedules: HashMap::from([
            (
                0usize,
                crate::request::ReserveOfferSchedule {
                    periods: vec![
                        vec![ReserveOffer {
                            product_id: "spin".into(),
                            capacity_mw: 100.0,
                            cost_per_mwh: 1.0,
                        }],
                        vec![ReserveOffer {
                            product_id: "spin".into(),
                            capacity_mw: 100.0,
                            cost_per_mwh: 9.0,
                        }],
                    ],
                },
            ),
            (
                1usize,
                crate::request::ReserveOfferSchedule {
                    periods: vec![
                        vec![ReserveOffer {
                            product_id: "spin".into(),
                            capacity_mw: 100.0,
                            cost_per_mwh: 9.0,
                        }],
                        vec![ReserveOffer {
                            product_id: "spin".into(),
                            capacity_mw: 100.0,
                            cost_per_mwh: 1.0,
                        }],
                    ],
                },
            ),
        ]),
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).expect("SCUC with reserve schedules should solve");

    let awards_t0 = sol.periods[0]
        .reserve_awards
        .get("spin")
        .expect("spin awards at t0");
    let awards_t1 = sol.periods[1]
        .reserve_awards
        .get("spin")
        .expect("spin awards at t1");

    assert!(
        awards_t0[0] > 9.9 && awards_t0[1] < 0.1,
        "period 0 should award spin to generator 0, got {awards_t0:?}"
    );
    assert!(
        awards_t1[1] > 9.9 && awards_t1[0] < 0.1,
        "period 1 should award spin to generator 1, got {awards_t1:?}"
    );
}

/// Offline headroom constraint.
///
/// When a generator is starting up, its startup trajectory power
/// consumes part of the offline capacity envelope. The constraint
///   p^su + p^sd + Σ(offline reserves) ≤ pmax × (1 − u^on)
/// must limit the OfflineQuickStart reserve award so that trajectory
/// power plus reserve does not exceed pmax.
///
/// Setup — 3 periods of 1 hour each:
/// - G0: 150 MW, always on, cheap — covers load alone in hours 0-1.
/// - G1: pmax=100, pmin=50, initially offline, startup\_ramp=20 MW/hr.
///   Load jumps to 200 MW in hour 2, forcing G1 online.
/// - OfflineQuickStart "nspin" product, G1 offers 90 MW, requirement 90 MW.
///
/// The optimizer starts G1 in hour 2 (u_su[2]=1). Trajectory power at
/// earlier hours from that future startup:
///   hour 1: trajectory = pmin − startup_ramp × (end_h2 − end_h1)
///                       = 50 − 20×1 = 30 MW
///   hour 0: trajectory = 50 − 20×2 = 10 MW
///
/// Offline headroom at hour 0: pmax − trajectory = 100 − 10 = 90 MW.
/// Offline headroom at hour 1: pmax − trajectory = 100 − 30 = 70 MW.
/// So nspin award at hour 1 must be ≤ 70 MW, strictly below the 90 MW offer.
#[test]
fn test_scuc_offline_headroom_limits_reserve_during_startup() {
    use surge_network::Network;
    use surge_network::market::{
        CostCurve, EnergyCoupling, LoadProfile, LoadProfiles, PenaltyCurve, ReserveDirection,
        ReserveKind, ReserveOffer, ReserveProduct,
    };
    use surge_network::network::{
        Bus, BusType, CommitmentParams, Generator, MarketParams, RampingParams,
    };

    let mut net = Network::new("offline_headroom_test");
    net.base_mva = 100.0;
    net.buses = vec![Bus::new(1, BusType::Slack, 138.0)];
    // Base load overridden by per-period profile.
    net.loads.push(Load::new(1, 80.0, 0.0));

    // G0: always-on workhorse, cheap.  Capacity 150 MW — not enough for
    // the 200 MW hour-2 load, so G1 must come online.
    let mut g0 = Generator::new(1, 0.0, 1.0);
    g0.pmax = 150.0;
    g0.pmin = 0.0;
    g0.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![0.0, 5.0],
    });
    net.generators.push(g0);

    // G1: offline quick-start unit.
    //
    // Key: the *economic* ramp is fast (100 MW/hr) so the unit can reach pmin
    // in a single period, but the *startup_ramp* is slow (20 MW/hr) which
    // determines the trajectory coefficients for offline headroom.
    //
    // trajectory at Δt=1 h: pmin − startup_ramp × 1 = 50 − 20 = 30 MW
    // trajectory at Δt=2 h: pmin − startup_ramp × 2 = 50 − 20×2 = 10 MW
    let mut g1 = Generator::new(1, 0.0, 1.0);
    g1.id = "g1".into();
    g1.machine_id = Some("2".into());
    g1.pmax = 100.0;
    g1.pmin = 50.0;
    g1.quick_start = true;
    g1.cost = Some(CostCurve::Polynomial {
        startup: 50.0,
        shutdown: 0.0,
        coeffs: vec![0.0, 200.0], // expensive — only runs when forced by load
    });
    g1.ramping = Some(RampingParams {
        ramp_up_curve: vec![(0.0, 100.0 / 60.0)], // 100 MW/hr economic ramp
        ramp_down_curve: vec![(0.0, 100.0 / 60.0)],
        ..Default::default()
    });
    g1.commitment = Some(CommitmentParams {
        startup_ramp_mw_per_min: Some(20.0 / 60.0), // 20 MW/hr startup ramp (slow)
        shutdown_ramp_mw_per_min: Some(100.0 / 60.0),
        min_down_time_hr: Some(1.0),
        ..Default::default()
    });
    g1.market = Some(MarketParams {
        reserve_offers: vec![ReserveOffer {
            product_id: "nspin".into(),
            capacity_mw: 90.0,
            cost_per_mwh: 0.0, // free reserve — optimizer always wants max
        }],
        ..Default::default()
    });
    net.generators.push(g1);

    // OfflineQuickStart reserve product.
    let nspin = ReserveProduct {
        id: "nspin".into(),
        name: "Non-Spin".into(),
        direction: ReserveDirection::Up,
        deploy_secs: 600.0,
        qualification: surge_network::market::QualificationRule::OfflineQuickStart,
        energy_coupling: EnergyCoupling::None,
        dispatchable_load_energy_coupling: None,
        shared_limit_products: Vec::new(),
        balance_products: Vec::new(),
        demand_curve: PenaltyCurve::Linear {
            cost_per_unit: 50000.0, // very expensive shortfall
        },
        kind: ReserveKind::Real,
        apply_deploy_ramp_limit: true,
    };

    // Load profile: hours 0-1 are light (G0 covers), hour 2 forces G1 online.
    let opts = DispatchOptions {
        n_periods: 3,
        enforce_thermal_limits: false,
        enforce_shutdown_deloading: false,
        load_profiles: LoadProfiles {
            profiles: vec![LoadProfile {
                bus: 1,
                load_mw: vec![80.0, 80.0, 200.0],
            }],
            n_timesteps: 3,
        },
        horizon: Horizon::TimeCoupled,
        commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
            initial_commitment: Some(vec![true, false]),
            initial_offline_hours: Some(vec![0.0, 10.0]),
            step_size_hours: Some(1.0),
            ..IndexedCommitmentOptions::default()
        }),
        reserve_products: vec![nspin],
        system_reserve_requirements: vec![SystemReserveRequirement {
            product_id: "nspin".into(),
            requirement_mw: 90.0,
            per_period_mw: None,
        }],
        ..DispatchOptions::default()
    };

    let sol = solve_scuc(&net, &opts).expect("offline headroom test should solve");

    // G1 must come online by hour 2 (load 200 > G0 pmax 200 — needs at least
    // 1 MW from G1 to clear if G0 is at capacity, but G1 pmin=50 means it
    // dispatches at least 50 MW once committed).  The optimizer should start
    // G1 in hour 2.
    let commits = commitment_schedule(&sol);
    assert!(
        commits[2][1],
        "G1 must be online in hour 2 to serve 200 MW load"
    );

    // In hours where G1 is offline (hours 0, 1), the trajectory from its
    // future startup consumes offline headroom.
    //
    // With startup_ramp=20 MW/hr and pmin=50:
    //   If G1 starts in hour 2:
    //     hour 1 trajectory = 50 − 20×1 = 30 MW → headroom = 100 − 30 = 70 MW
    //     hour 0 trajectory = 50 − 20×2 = 10 MW → headroom = 100 − 10 = 90 MW
    //
    // The nspin offer is 90 MW, but at hour 1 the offline headroom is only
    // 70 MW, so the award must be ≤ 70 MW (< 90 MW offer).
    if !commits[1][1] {
        // G1 still offline at hour 1 — the interesting case.
        let nspin_awards_h1 = sol.periods[1]
            .reserve_awards
            .get("nspin")
            .expect("nspin awards at hour 1");
        let g1_nspin_h1 = nspin_awards_h1[1];

        let pmax = 100.0_f64;
        let pmin = 50.0_f64;
        let startup_ramp = 20.0_f64;
        let trajectory_h1 = pmin - startup_ramp * 1.0; // 30 MW
        let offline_headroom_h1 = pmax - trajectory_h1; // 70 MW

        assert!(
            g1_nspin_h1 <= offline_headroom_h1 + 0.5,
            "hour 1: G1 nspin ({g1_nspin_h1:.1} MW) must not exceed offline \
             headroom of {offline_headroom_h1:.0} MW (pmax {pmax} − trajectory \
             {trajectory_h1:.0})"
        );
        assert!(
            g1_nspin_h1 < 90.0 - 0.1,
            "hour 1: G1 nspin should be limited below 90 MW offer cap by \
             offline headroom, got {g1_nspin_h1:.1} MW"
        );
    }
}

#[test]
fn test_scuc_optimize_mode_allows_offline_quickstart_reserve_when_unit_stays_off() {
    use surge_network::Network;
    use surge_network::market::{
        CostCurve, EnergyCoupling, PenaltyCurve, ReserveDirection, ReserveKind, ReserveOffer,
        ReserveProduct, SystemReserveRequirement,
    };
    use surge_network::network::{Bus, BusType, CommitmentParams, Generator, Load, MarketParams};

    let mut net = Network::new("offline_quickstart_reserve_optimize");
    net.base_mva = 100.0;
    net.buses = vec![Bus::new(1, BusType::Slack, 138.0)];
    net.loads.push(Load::new(1, 80.0, 0.0));

    let mut g0 = Generator::new(1, 0.0, 100.0);
    g0.cost = Some(CostCurve::Polynomial {
        startup: 0.0,
        shutdown: 0.0,
        coeffs: vec![0.0, 5.0],
    });
    net.generators.push(g0);

    let mut g1 = Generator::new(1, 0.0, 50.0);
    g1.id = "g1".into();
    g1.machine_id = Some("2".into());
    g1.quick_start = true;
    g1.cost = Some(CostCurve::Polynomial {
        startup: 10_000.0,
        shutdown: 0.0,
        coeffs: vec![0.0, 500.0],
    });
    g1.commitment = Some(CommitmentParams {
        min_down_time_hr: Some(1.0),
        ..Default::default()
    });
    g1.market = Some(MarketParams {
        reserve_offers: vec![ReserveOffer {
            product_id: "nspin".into(),
            capacity_mw: 50.0,
            cost_per_mwh: 0.0,
        }],
        ..Default::default()
    });
    net.generators.push(g1);

    let nspin = ReserveProduct {
        id: "nspin".into(),
        name: "Non-Spin".into(),
        direction: ReserveDirection::Up,
        deploy_secs: 600.0,
        qualification: surge_network::market::QualificationRule::OfflineQuickStart,
        energy_coupling: EnergyCoupling::None,
        dispatchable_load_energy_coupling: None,
        shared_limit_products: Vec::new(),
        balance_products: Vec::new(),
        demand_curve: PenaltyCurve::Linear {
            cost_per_unit: 50_000.0,
        },
        kind: ReserveKind::Real,
        apply_deploy_ramp_limit: true,
    };

    let sol = solve_scuc(
        &net,
        &DispatchOptions {
            n_periods: 1,
            enforce_thermal_limits: false,
            horizon: Horizon::TimeCoupled,
            commitment: CommitmentMode::Optimize(IndexedCommitmentOptions {
                initial_commitment: Some(vec![true, false]),
                initial_offline_hours: Some(vec![0.0, 10.0]),
                step_size_hours: Some(1.0),
                ..IndexedCommitmentOptions::default()
            }),
            reserve_products: vec![nspin],
            system_reserve_requirements: vec![SystemReserveRequirement {
                product_id: "nspin".into(),
                requirement_mw: 50.0,
                per_period_mw: None,
            }],
            ..DispatchOptions::default()
        },
    )
    .expect("optimize-mode offline quick-start reserve case should solve");

    assert!(
        !commitment_schedule(&sol)[0][1],
        "peaker should stay offline; it is only needed for offline reserve"
    );

    let nspin_awards = sol.periods[0]
        .reserve_awards
        .get("nspin")
        .expect("nspin awards for hour 0");
    assert!(
        (nspin_awards[1] - 50.0).abs() < 1e-6,
        "offline quick-start unit should clear its full 50 MW nspin offer while offline; got {:.6}",
        nspin_awards[1]
    );
}
