use serde::{Deserialize, Serialize};
const V_CUT_IN: f64 = 3.0;
const V_RATED: f64 = 12.0;
const V_CUT_OUT: f64 = 25.0;
const K_WAKE: f64 = 0.07;
const CT: f64 = 0.8;
const HOURS_PER_YEAR: f64 = 8_760.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CurtailmentReason {
GridCongestion,
SystemFrequency,
NoiseCurtailment,
ThermalLimit,
TurbulenceAvoidance,
IcingCondition,
MaintenanceAccess,
BatFriendly,
MarketPrice,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum ActivePowerMode {
MaximumPower,
DeltaControl,
AbsolutePower,
BalancingMode,
RampRateLimited,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum WakeSteeringMode {
Disabled,
Static,
Dynamic,
Sector,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurbineSetpoint {
pub turbine_id: usize,
pub power_setpoint_kw: f64,
pub yaw_offset_deg: f64,
pub curtailment_reason: Option<CurtailmentReason>,
pub pitch_angle_deg: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FarmOperatingPoint {
pub timestamp_h: f64,
pub wind_speed_ms: f64,
pub wind_direction_deg: f64,
pub ambient_temperature_c: f64,
pub total_power_mw: f64,
pub available_power_mw: f64,
pub curtailed_power_mw: f64,
pub turbine_setpoints: Vec<TurbineSetpoint>,
pub farm_efficiency: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlantController {
pub farm_id: usize,
pub name: String,
pub n_turbines: usize,
pub rated_power_mw: f64,
pub farm_rated_mw: f64,
pub control_mode: ActivePowerMode,
pub power_setpoint_mw: f64,
pub frequency_deadband_hz: f64,
pub frequency_droop_pct: f64,
pub ramp_rate_mw_per_min: f64,
pub delta_reserve_pct: f64,
}
impl PlantController {
pub fn new(farm_id: usize, n_turbines: usize, rated_power_mw: f64) -> Self {
let farm_rated_mw = rated_power_mw * n_turbines as f64;
Self {
farm_id,
name: format!("Farm-{}", farm_id),
n_turbines,
rated_power_mw,
farm_rated_mw,
control_mode: ActivePowerMode::MaximumPower,
power_setpoint_mw: farm_rated_mw,
frequency_deadband_hz: 0.2,
frequency_droop_pct: 4.0,
ramp_rate_mw_per_min: farm_rated_mw * 0.1, delta_reserve_pct: 10.0,
}
}
pub fn compute_setpoints(
&self,
available_power_mw: f64,
grid_frequency_hz: f64,
) -> Vec<TurbineSetpoint> {
let target_mw = self.target_farm_power(available_power_mw, grid_frequency_hz);
let per_turbine_mw = if self.n_turbines > 0 {
(target_mw / self.n_turbines as f64).max(0.0)
} else {
0.0
};
let per_turbine_kw = per_turbine_mw * 1000.0;
let rated_kw = self.rated_power_mw * 1000.0;
let pitch_deg = if per_turbine_kw >= rated_kw {
0.0
} else {
20.0 * (1.0 - per_turbine_kw / rated_kw.max(1.0))
};
let curtailment_reason = self.curtailment_reason_for_mode(available_power_mw, target_mw);
(0..self.n_turbines)
.map(|id| TurbineSetpoint {
turbine_id: id,
power_setpoint_kw: per_turbine_kw,
yaw_offset_deg: 0.0, curtailment_reason,
pitch_angle_deg: pitch_deg,
})
.collect()
}
pub fn compute_frequency_response(&self, grid_frequency_hz: f64, current_power_mw: f64) -> f64 {
let delta_f = 50.0 - grid_frequency_hz; if delta_f.abs() <= self.frequency_deadband_hz {
return 0.0;
}
let p_ref = current_power_mw.max(0.0);
let response = p_ref * (delta_f / 50.0) / (self.frequency_droop_pct / 100.0);
response.clamp(-self.farm_rated_mw, self.farm_rated_mw)
}
pub fn apply_ramp_limit(&self, current_mw: f64, target_mw: f64, dt_s: f64) -> f64 {
let max_change = self.ramp_rate_mw_per_min * dt_s / 60.0;
let delta = (target_mw - current_mw).clamp(-max_change, max_change);
(current_mw + delta).clamp(0.0, self.farm_rated_mw)
}
pub fn compute_delta_reserve(&self, available_mw: f64) -> f64 {
available_mw * self.delta_reserve_pct / 100.0
}
fn target_farm_power(&self, available_mw: f64, freq_hz: f64) -> f64 {
let base = match self.control_mode {
ActivePowerMode::MaximumPower => available_mw,
ActivePowerMode::DeltaControl => {
available_mw - self.compute_delta_reserve(available_mw)
}
ActivePowerMode::AbsolutePower => self.power_setpoint_mw.min(available_mw),
ActivePowerMode::BalancingMode => self.power_setpoint_mw.min(available_mw),
ActivePowerMode::RampRateLimited => available_mw,
};
let freq_delta = self.compute_frequency_response(freq_hz, base);
(base + freq_delta).clamp(0.0, available_mw)
}
fn curtailment_reason_for_mode(
&self,
available_mw: f64,
target_mw: f64,
) -> Option<CurtailmentReason> {
if target_mw >= available_mw - 1e-3 {
return None;
}
match self.control_mode {
ActivePowerMode::AbsolutePower | ActivePowerMode::BalancingMode => {
Some(CurtailmentReason::GridCongestion)
}
ActivePowerMode::DeltaControl => Some(CurtailmentReason::SystemFrequency),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WakeSteering {
pub mode: WakeSteeringMode,
pub layout: Vec<(f64, f64)>,
pub rotor_diameter_m: f64,
pub max_yaw_offset_deg: f64,
pub yaw_rate_deg_per_s: f64,
}
impl WakeSteering {
pub fn new(layout: Vec<(f64, f64)>, rotor_diameter_m: f64) -> Self {
Self {
mode: WakeSteeringMode::Disabled,
layout,
rotor_diameter_m,
max_yaw_offset_deg: 20.0,
yaw_rate_deg_per_s: 0.3,
}
}
pub fn compute_yaw_offsets(&self, wind_direction_deg: f64, wind_speed_ms: f64) -> Vec<f64> {
let n = self.layout.len();
match self.mode {
WakeSteeringMode::Disabled => vec![0.0; n],
WakeSteeringMode::Static => {
(0..n)
.map(|i| {
let has_downstream =
(0..n).any(|j| j != i && self.is_upstream(i, j, wind_direction_deg));
if has_downstream {
self.max_yaw_offset_deg * 0.5
} else {
0.0
}
})
.collect()
}
WakeSteeringMode::Dynamic => self.dynamic_offsets(wind_direction_deg, wind_speed_ms),
WakeSteeringMode::Sector => {
let sector_aligned = [0.0_f64, 90.0, 180.0, 270.0]
.iter()
.any(|&s| angle_diff(wind_direction_deg, s) < 15.0);
let offset = if sector_aligned {
self.max_yaw_offset_deg * 0.6
} else {
0.0
};
(0..n)
.map(|i| {
let has_downstream =
(0..n).any(|j| j != i && self.is_upstream(i, j, wind_direction_deg));
if has_downstream {
offset
} else {
0.0
}
})
.collect()
}
}
}
pub fn compute_wake_loss_factor(&self, wind_direction_deg: f64) -> f64 {
let n = self.layout.len();
if n <= 1 {
return 0.0;
}
let rotated = self.rotate_to_wind_frame(wind_direction_deg);
let d = self.rotor_diameter_m;
let mut total_deficit = 0.0_f64;
for i in 0..n {
let mut deficit_sq = 0.0_f64;
for j in 0..n {
if i == j {
continue;
}
let dx = rotated[i].0 - rotated[j].0;
let dy = (rotated[i].1 - rotated[j].1).abs();
if dx <= 0.0 {
continue;
}
let r_wake = d / 2.0 + K_WAKE * dx;
if dy < r_wake {
let deficit = (1.0 - (1.0 - CT).sqrt()) * (d / 2.0 / r_wake).powi(2);
deficit_sq += deficit * deficit;
}
}
total_deficit += deficit_sq.sqrt();
}
(total_deficit / n as f64).clamp(0.0, 0.3)
}
pub fn upstream_turbines(&self, turbine_idx: usize, wind_dir_deg: f64) -> Vec<usize> {
let n = self.layout.len();
let rotated = self.rotate_to_wind_frame(wind_dir_deg);
let d = self.rotor_diameter_m;
let (xi, yi) = rotated[turbine_idx];
(0..n)
.filter(|&j| {
if j == turbine_idx {
return false;
}
let (xj, yj) = rotated[j];
let dx = xi - xj; if dx <= 0.0 {
return false;
}
let dy = (yi - yj).abs();
let r_wake = d / 2.0 + K_WAKE * dx;
dy < r_wake
})
.collect()
}
fn rotate_to_wind_frame(&self, wind_dir_deg: f64) -> Vec<(f64, f64)> {
let dir_rad = wind_dir_deg.to_radians();
let (sin_d, cos_d) = (dir_rad.sin(), dir_rad.cos());
self.layout
.iter()
.map(|&(x, y)| {
let x_w = -x * sin_d - y * cos_d;
let y_w = -x * cos_d + y * sin_d;
(x_w, y_w)
})
.collect()
}
fn dynamic_offsets(&self, wind_direction_deg: f64, wind_speed_ms: f64) -> Vec<f64> {
let n = self.layout.len();
let speed_factor = ((wind_speed_ms - V_CUT_IN) / (V_RATED - V_CUT_IN)).clamp(0.0, 1.0);
(0..n)
.map(|i| {
let n_downstream = (0..n)
.filter(|&j| j != i && self.is_upstream(i, j, wind_direction_deg))
.count();
if n_downstream == 0 {
0.0
} else {
let base = self.max_yaw_offset_deg
* (n_downstream as f64 / n as f64).min(1.0)
* (1.0 - speed_factor * 0.4);
base.min(self.max_yaw_offset_deg)
}
})
.collect()
}
fn is_upstream(&self, i: usize, j: usize, wind_dir_deg: f64) -> bool {
let rotated = self.rotate_to_wind_frame(wind_dir_deg);
let (x_wi, y_wi) = rotated[i];
let (x_wj, y_wj) = rotated[j];
let dx = x_wj - x_wi; if dx <= 0.0 {
return false;
}
let dy = (y_wj - y_wi).abs();
let r_wake = self.rotor_diameter_m / 2.0 + K_WAKE * dx;
dy < r_wake
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurtailmentSchedule {
pub start_hour: f64,
pub end_hour: f64,
pub max_power_pct: f64,
pub reason: CurtailmentReason,
pub turbine_ids: Vec<usize>,
}
impl CurtailmentSchedule {
pub fn is_active(&self, timestamp_h: f64) -> bool {
if self.start_hour <= self.end_hour {
timestamp_h >= self.start_hour && timestamp_h < self.end_hour
} else {
timestamp_h >= self.start_hour || timestamp_h < self.end_hour
}
}
pub fn applies_to(&self, turbine_id: usize) -> bool {
self.turbine_ids.is_empty() || self.turbine_ids.contains(&turbine_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrequencyResponseController {
pub droop_setting_pct: f64,
pub response_time_s: f64,
pub deadband_hz: f64,
pub reserved_capacity_pct: f64,
pub nominal_freq_hz: f64,
}
impl FrequencyResponseController {
pub fn new_50hz(droop_setting_pct: f64) -> Self {
Self {
droop_setting_pct,
response_time_s: 2.0,
deadband_hz: 0.015, reserved_capacity_pct: 5.0,
nominal_freq_hz: 50.0,
}
}
pub fn response_power_mw(
&self,
grid_freq_hz: f64,
current_power_mw: f64,
capacity_mw: f64,
) -> f64 {
let delta_f = self.nominal_freq_hz - grid_freq_hz;
if delta_f.abs() <= self.deadband_hz {
return 0.0;
}
let reserved_mw = capacity_mw * self.reserved_capacity_pct / 100.0;
let max_up = reserved_mw;
let max_down = current_power_mw;
let raw =
current_power_mw * (delta_f / self.nominal_freq_hz) / (self.droop_setting_pct / 100.0);
raw.clamp(-max_down, max_up)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlantOperationsManager {
pub controller: PlantController,
pub wake_steering: WakeSteering,
pub curtailment_schedule: Vec<CurtailmentSchedule>,
pub frequency_controller: FrequencyResponseController,
pub turbine_positions: Vec<(f64, f64)>,
}
impl PlantOperationsManager {
pub fn new(controller: PlantController, turbine_positions: Vec<(f64, f64)>) -> Self {
let layout = turbine_positions.clone();
let rotor_diameter_m = 120.0; let droop = controller.frequency_droop_pct;
Self {
wake_steering: WakeSteering::new(layout, rotor_diameter_m),
curtailment_schedule: Vec::new(),
frequency_controller: FrequencyResponseController::new_50hz(droop),
turbine_positions,
controller,
}
}
pub fn run_operating_step(
&mut self,
wind_speed_ms: f64,
wind_dir_deg: f64,
ambient_temp_c: f64,
grid_freq_hz: f64,
timestamp_h: f64,
) -> FarmOperatingPoint {
let wake_speeds = self.wake_corrected_speeds(wind_speed_ms, wind_dir_deg);
let rated_kw = self.controller.rated_power_mw * 1000.0;
let available_kw: Vec<f64> = wake_speeds
.iter()
.map(|&v| turbine_power_kw(v, rated_kw))
.collect();
let available_power_mw = available_kw.iter().sum::<f64>() / 1000.0;
let mut setpoints = self
.controller
.compute_setpoints(available_power_mw, grid_freq_hz);
let yaw_offsets = self
.wake_steering
.compute_yaw_offsets(wind_dir_deg, wind_speed_ms);
for sp in setpoints.iter_mut() {
let offset = yaw_offsets.get(sp.turbine_id).copied().unwrap_or(0.0);
sp.yaw_offset_deg = offset;
}
setpoints = self.apply_curtailment_schedule(timestamp_h, setpoints);
let total_power_mw = setpoints.iter().map(|s| s.power_setpoint_kw).sum::<f64>() / 1000.0;
let curtailed_power_mw = (available_power_mw - total_power_mw).max(0.0);
let farm_efficiency = if available_power_mw > 1e-6 {
(total_power_mw / available_power_mw).clamp(0.0, 1.0)
} else {
1.0
};
let _ = ambient_temp_c;
FarmOperatingPoint {
timestamp_h,
wind_speed_ms,
wind_direction_deg: wind_dir_deg,
ambient_temperature_c: ambient_temp_c,
total_power_mw,
available_power_mw,
curtailed_power_mw,
turbine_setpoints: setpoints,
farm_efficiency,
}
}
pub fn apply_curtailment_schedule(
&self,
timestamp_h: f64,
mut setpoints: Vec<TurbineSetpoint>,
) -> Vec<TurbineSetpoint> {
for rule in &self.curtailment_schedule {
if !rule.is_active(timestamp_h) {
continue;
}
let rated_kw = self.controller.rated_power_mw * 1000.0;
let max_kw = rated_kw * rule.max_power_pct / 100.0;
for sp in setpoints.iter_mut() {
if !rule.applies_to(sp.turbine_id) {
continue;
}
if sp.power_setpoint_kw > max_kw {
sp.power_setpoint_kw = max_kw;
sp.curtailment_reason = Some(rule.reason);
}
}
}
setpoints
}
pub fn compute_farm_power_curve(&self, wind_speeds: &[f64]) -> Vec<f64> {
let rated_kw = self.controller.rated_power_mw * 1000.0;
let n = self.controller.n_turbines as f64;
wind_speeds
.iter()
.map(|&v| turbine_power_kw(v, rated_kw) * n / 1000.0)
.collect()
}
pub fn estimate_annual_energy_production(&self, wind_rose: &[(f64, f64, f64)]) -> f64 {
let rated_kw = self.controller.rated_power_mw * 1000.0;
wind_rose
.iter()
.map(|&(dir_deg, speed_ms, freq)| {
let wake_speeds = self.wake_corrected_speeds(speed_ms, dir_deg);
let total_kw: f64 = wake_speeds
.iter()
.map(|&v| turbine_power_kw(v, rated_kw))
.sum();
let power_mw = total_kw / 1000.0;
power_mw * freq * HOURS_PER_YEAR
})
.sum()
}
fn wake_corrected_speeds(&self, u_inf: f64, wind_dir_deg: f64) -> Vec<f64> {
let n = self.turbine_positions.len();
if n == 0 {
return Vec::new();
}
let dir_rad = wind_dir_deg.to_radians();
let (sin_d, cos_d) = (dir_rad.sin(), dir_rad.cos());
let rotated: Vec<(f64, f64)> = self
.turbine_positions
.iter()
.map(|&(x, y)| {
let x_w = -x * sin_d - y * cos_d;
let y_w = -x * cos_d + y * sin_d;
(x_w, y_w)
})
.collect();
let d = self.wake_steering.rotor_diameter_m;
let mut speeds = vec![u_inf; n];
for i in 0..n {
let mut deficit_sq_sum = 0.0_f64;
for j in 0..n {
if i == j {
continue;
}
let dx = rotated[i].0 - rotated[j].0;
let dy = (rotated[i].1 - rotated[j].1).abs();
if dx <= 0.0 {
continue;
}
let r_wake = d / 2.0 + K_WAKE * dx;
if dy < r_wake {
let deficit = (1.0 - (1.0 - CT).sqrt()) * ((d / 2.0) / r_wake).powi(2);
deficit_sq_sum += deficit * deficit;
}
}
speeds[i] = (u_inf * (1.0 - deficit_sq_sum.sqrt())).max(0.0);
}
speeds
}
}
pub fn turbine_power_kw(wind_speed_ms: f64, rated_kw: f64) -> f64 {
if !(V_CUT_IN..V_CUT_OUT).contains(&wind_speed_ms) {
0.0
} else if wind_speed_ms >= V_RATED {
rated_kw
} else {
let frac = (wind_speed_ms - V_CUT_IN) / (V_RATED - V_CUT_IN);
rated_kw * frac.powi(3)
}
}
fn angle_diff(a: f64, b: f64) -> f64 {
((a - b).rem_euclid(360.0)).min((b - a).rem_euclid(360.0))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_controller(n: usize, rated_mw: f64) -> PlantController {
PlantController::new(1, n, rated_mw)
}
fn two_turbine_layout() -> Vec<(f64, f64)> {
vec![(0.0, 0.0), (600.0, 0.0)] }
fn make_manager(n: usize, rated_mw: f64) -> PlantOperationsManager {
let positions: Vec<(f64, f64)> = (0..n).map(|i| (i as f64 * 600.0, 0.0)).collect();
let ctrl = make_controller(n, rated_mw);
PlantOperationsManager::new(ctrl, positions)
}
#[test]
fn test_turbine_power_curve_cutin() {
assert_eq!(turbine_power_kw(2.0, 2000.0), 0.0);
assert_eq!(turbine_power_kw(0.0, 2000.0), 0.0);
}
#[test]
fn test_turbine_power_curve_rated() {
let p = turbine_power_kw(V_RATED, 2000.0);
assert!((p - 2000.0).abs() < 1e-6, "p={}", p);
}
#[test]
fn test_turbine_power_curve_cutout() {
assert_eq!(turbine_power_kw(26.0, 2000.0), 0.0);
assert_eq!(turbine_power_kw(25.0, 2000.0), 0.0); }
#[test]
fn test_turbine_power_curve_cubic() {
let v_mid = V_CUT_IN + (V_RATED - V_CUT_IN) * 0.5;
let p = turbine_power_kw(v_mid, 2000.0);
let expected = 2000.0 * 0.125;
assert!((p - expected).abs() < 1.0, "p={} expected≈{}", p, expected);
}
#[test]
fn test_frequency_droop_high_freq() {
let ctrl = make_controller(4, 2.0);
let delta = ctrl.compute_frequency_response(50.5, 4.0);
assert!(delta < 0.0, "delta should be negative, got {}", delta);
}
#[test]
fn test_frequency_droop_low_freq() {
let ctrl = make_controller(4, 2.0);
let delta = ctrl.compute_frequency_response(49.5, 4.0);
assert!(delta > 0.0, "delta should be positive, got {}", delta);
}
#[test]
fn test_frequency_droop_deadband() {
let ctrl = make_controller(4, 2.0);
let delta = ctrl.compute_frequency_response(50.1, 4.0);
assert_eq!(delta, 0.0, "within deadband → no response");
}
#[test]
fn test_delta_control_reserve() {
let mut ctrl = make_controller(4, 2.0);
ctrl.control_mode = ActivePowerMode::DeltaControl;
ctrl.delta_reserve_pct = 10.0;
let available = 6.0_f64;
let reserve = ctrl.compute_delta_reserve(available);
assert!((reserve - 0.6).abs() < 1e-9, "reserve={}", reserve);
let setpoints = ctrl.compute_setpoints(available, 50.0);
let total: f64 = setpoints.iter().map(|s| s.power_setpoint_kw).sum::<f64>() / 1000.0;
assert!(
total <= available - reserve + 1e-6,
"total={} reserve={}",
total,
reserve
);
}
#[test]
fn test_ramp_rate_limit_up() {
let ctrl = make_controller(4, 2.0);
let dt_s = 30.0; let max_ramp = ctrl.ramp_rate_mw_per_min * dt_s / 60.0;
let achieved = ctrl.apply_ramp_limit(0.0, 10.0, dt_s);
assert!(
achieved <= max_ramp + 1e-9,
"achieved={} max_ramp={}",
achieved,
max_ramp
);
}
#[test]
fn test_ramp_rate_limit_down() {
let ctrl = make_controller(4, 2.0);
let dt_s = 30.0;
let max_ramp = ctrl.ramp_rate_mw_per_min * dt_s / 60.0;
let achieved = ctrl.apply_ramp_limit(8.0, 0.0, dt_s);
assert!(
achieved >= 8.0 - max_ramp - 1e-9,
"achieved={} max_ramp={}",
achieved,
max_ramp
);
}
#[test]
fn test_wake_loss_downwind() {
let layout = two_turbine_layout();
let ws = WakeSteering::new(layout, 120.0);
let factor = ws.compute_wake_loss_factor(270.0);
assert!(
factor > 0.0,
"downwind turbine should show wake loss, factor={}",
factor
);
}
#[test]
fn test_wake_loss_upwind() {
let layout = two_turbine_layout();
let ws = WakeSteering::new(layout, 120.0);
let factor = ws.compute_wake_loss_factor(90.0);
assert!(factor >= 0.0, "wake loss factor must be non-negative");
}
#[test]
fn test_yaw_offset_static() {
let layout = two_turbine_layout();
let mut ws = WakeSteering::new(layout, 120.0);
ws.mode = WakeSteeringMode::Static;
let offsets = ws.compute_yaw_offsets(270.0, 8.0);
assert_eq!(offsets.len(), 2);
assert!(
offsets[0] > 0.0,
"upstream turbine should have yaw offset, got {}",
offsets[0]
);
assert_eq!(
offsets[1], 0.0,
"downstream turbine should not have yaw offset"
);
}
#[test]
fn test_curtailment_schedule_active() {
let mut mgr = make_manager(2, 2.0);
mgr.curtailment_schedule.push(CurtailmentSchedule {
start_hour: 22.0,
end_hour: 6.0,
max_power_pct: 50.0,
reason: CurtailmentReason::NoiseCurtailment,
turbine_ids: vec![],
});
let setpoints: Vec<TurbineSetpoint> = (0..2)
.map(|id| TurbineSetpoint {
turbine_id: id,
power_setpoint_kw: 2000.0,
yaw_offset_deg: 0.0,
curtailment_reason: None,
pitch_angle_deg: 0.0,
})
.collect();
let result = mgr.apply_curtailment_schedule(23.0, setpoints);
for sp in &result {
assert!(
sp.power_setpoint_kw <= 1000.0 + 1e-9,
"turbine {} should be curtailed to 50%: {}",
sp.turbine_id,
sp.power_setpoint_kw
);
}
}
#[test]
fn test_curtailment_schedule_inactive() {
let mut mgr = make_manager(2, 2.0);
mgr.curtailment_schedule.push(CurtailmentSchedule {
start_hour: 22.0,
end_hour: 6.0,
max_power_pct: 50.0,
reason: CurtailmentReason::NoiseCurtailment,
turbine_ids: vec![],
});
let setpoints: Vec<TurbineSetpoint> = (0..2)
.map(|id| TurbineSetpoint {
turbine_id: id,
power_setpoint_kw: 2000.0,
yaw_offset_deg: 0.0,
curtailment_reason: None,
pitch_angle_deg: 0.0,
})
.collect();
let result = mgr.apply_curtailment_schedule(12.0, setpoints);
for sp in &result {
assert!(
sp.power_setpoint_kw > 1000.0,
"turbine {} should not be curtailed at noon: {}",
sp.turbine_id,
sp.power_setpoint_kw
);
}
}
#[test]
fn test_setpoints_sum() {
let ctrl = make_controller(5, 2.0);
let available = 8.0_f64;
let setpoints = ctrl.compute_setpoints(available, 50.0);
let total_mw: f64 = setpoints.iter().map(|s| s.power_setpoint_kw).sum::<f64>() / 1000.0;
assert!(
(total_mw - available).abs() < 1e-4,
"total={} available={}",
total_mw,
available
);
}
#[test]
fn test_upstream_turbine_identification() {
let layout = two_turbine_layout();
let ws = WakeSteering::new(layout, 120.0);
let upstream_of_1 = ws.upstream_turbines(1, 270.0);
assert!(
upstream_of_1.contains(&0),
"turbine 0 should be upstream of turbine 1: {:?}",
upstream_of_1
);
let upstream_of_0 = ws.upstream_turbines(0, 270.0);
assert!(
upstream_of_0.is_empty(),
"turbine 0 should have no upstream for westerly wind: {:?}",
upstream_of_0
);
}
#[test]
fn test_farm_power_curve_shape() {
let mgr = make_manager(3, 2.0);
let speeds: Vec<f64> = (0..20).map(|i| i as f64).collect();
let powers = mgr.compute_farm_power_curve(&speeds);
assert_eq!(powers[0], 0.0, "0 m/s → 0 MW");
assert_eq!(powers[2], 0.0, "2 m/s → 0 MW");
let ramp: Vec<f64> = speeds
.iter()
.zip(powers.iter())
.filter(|&(&v, _)| (V_CUT_IN..V_RATED).contains(&v))
.map(|(_, &p)| p)
.collect();
for w in ramp.windows(2) {
assert!(
w[1] >= w[0] - 1e-9,
"power curve not monotone: {} < {}",
w[1],
w[0]
);
}
}
#[test]
fn test_annual_energy_estimation() {
let mgr = make_manager(5, 2.0);
let wind_rose = vec![
(0.0, 10.0, 0.25),
(90.0, 10.0, 0.25),
(180.0, 10.0, 0.25),
(270.0, 10.0, 0.25),
];
let aep = mgr.estimate_annual_energy_production(&wind_rose);
assert!(aep > 0.0, "AEP should be positive, got {}", aep);
assert!(aep < 100_000.0, "AEP unreasonably large: {}", aep);
}
#[test]
fn test_operating_step_output() {
let mut mgr = make_manager(3, 2.0);
let op = mgr.run_operating_step(10.0, 270.0, 15.0, 50.0, 0.0);
assert_eq!(
op.turbine_setpoints.len(),
3,
"should have one setpoint per turbine"
);
assert!(
op.available_power_mw >= 0.0,
"available_power_mw must be non-negative"
);
assert!(
op.total_power_mw >= 0.0,
"total_power_mw must be non-negative"
);
assert!(
op.farm_efficiency >= 0.0 && op.farm_efficiency <= 1.0 + 1e-9,
"farm_efficiency out of range: {}",
op.farm_efficiency
);
assert!(
op.curtailed_power_mw >= -1e-6,
"curtailed_power_mw should not be strongly negative"
);
}
}