use crate::error::OxiGridError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FoundationType {
Monopile,
Jacket,
TripodJacket,
FloatingTension,
FloatingSpar,
}
impl FoundationType {
pub fn for_depth(depth_m: f64) -> Self {
match depth_m as u64 {
0..=39 => FoundationType::Monopile,
40..=49 => FoundationType::Jacket,
50..=99 => FoundationType::TripodJacket,
100..=149 => FoundationType::FloatingTension,
_ => FoundationType::FloatingSpar,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OffshoreWindTurbine {
pub id: usize,
pub x_m: f64,
pub y_m: f64,
pub hub_height_m: f64,
pub rotor_diameter_m: f64,
pub rated_power_mw: f64,
pub rated_wind_speed: f64,
pub cut_in_wind: f64,
pub cut_out_wind: f64,
pub availability: f64,
pub foundation_type: FoundationType,
}
impl OffshoreWindTurbine {
pub fn new_15mw(id: usize, x_m: f64, y_m: f64) -> Self {
Self {
id,
x_m,
y_m,
hub_height_m: 120.0,
rotor_diameter_m: 200.0,
rated_power_mw: 15.0,
rated_wind_speed: 13.0,
cut_in_wind: 3.5,
cut_out_wind: 25.0,
availability: 0.97,
foundation_type: FoundationType::Monopile,
}
}
pub fn power_mw(&self, wind_speed: f64) -> f64 {
offshore_power_curve(
wind_speed,
self.cut_in_wind,
self.rated_wind_speed,
self.cut_out_wind,
self.rated_power_mw,
) * self.availability
}
pub fn thrust_coefficient(&self, wind_speed: f64) -> f64 {
if wind_speed < self.cut_in_wind || wind_speed > self.cut_out_wind {
return 0.0;
}
if wind_speed <= self.rated_wind_speed {
let frac = (wind_speed - self.cut_in_wind)
/ (self.rated_wind_speed - self.cut_in_wind).max(1e-9);
0.85 - 0.1 * frac
} else {
let ct_rated = 0.75;
ct_rated * (self.rated_wind_speed / wind_speed).powi(2)
}
}
}
pub fn offshore_power_curve(
wind_speed: f64,
cut_in: f64,
rated: f64,
cut_out: f64,
rated_power_mw: f64,
) -> f64 {
if wind_speed < cut_in || wind_speed > cut_out {
return 0.0;
}
if wind_speed >= rated {
return rated_power_mw;
}
let frac = (wind_speed - cut_in) / (rated - cut_in).max(1e-9);
rated_power_mw * frac.powi(3)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LarsenWakeModel {
pub c1: f64,
pub c2: f64,
pub turbulence_intensity: f64,
}
impl LarsenWakeModel {
pub fn new(turbulence_intensity: f64) -> Self {
Self {
c1: 0.04,
c2: 1.0,
turbulence_intensity,
}
}
pub fn offshore_default() -> Self {
Self::new(0.05)
}
pub fn velocity_deficit(
&self,
x_m: f64, r_m: f64, d_m: f64, ct: f64, ) -> f64 {
if x_m <= 0.0 || d_m <= 0.0 || ct <= 0.0 {
return 0.0;
}
let sigma = (self.c1 * x_m + self.turbulence_intensity * d_m).max(1e-6);
let deficit_centre = ct / (8.0 * (sigma / d_m).powi(2)).max(1e-9);
let gaussian = (-r_m.powi(2) / (2.0 * sigma.powi(2))).exp();
let raw = self.c2 * deficit_centre * gaussian;
raw.clamp(0.0, 1.0)
}
}
fn to_wind_frame(dx: f64, dy: f64, wind_direction_deg: f64) -> (f64, f64) {
let dir_rad = wind_direction_deg.to_radians();
let (sin_d, cos_d) = (dir_rad.sin(), dir_rad.cos());
let x_w = -(dx * sin_d + dy * cos_d);
let y_w = -dx * cos_d + dy * sin_d;
(x_w, y_w)
}
pub fn superpose_wakes(
turbines: &[OffshoreWindTurbine],
wind_speed: f64,
wind_direction_deg: f64,
wake_model: &LarsenWakeModel,
) -> Vec<f64> {
let n = turbines.len();
let mut speeds = vec![wind_speed; n];
for i in 0..n {
let mut total_deficit = 0.0_f64;
for j in 0..n {
if i == j {
continue;
}
let dx = turbines[i].x_m - turbines[j].x_m;
let dy = turbines[i].y_m - turbines[j].y_m;
let (x_w, y_w) = to_wind_frame(dx, dy, wind_direction_deg);
if x_w <= 0.0 {
continue;
}
let ct_j = turbines[j].thrust_coefficient(wind_speed);
let d_j = turbines[j].rotor_diameter_m;
let deficit = wake_model.velocity_deficit(x_w, y_w, d_j, ct_j);
total_deficit += deficit;
}
let effective = wind_speed * (1.0 - total_deficit.min(0.95));
speeds[i] = effective.max(0.0);
}
speeds
}
pub fn array_efficiency(
turbines: &[OffshoreWindTurbine],
wind_speed: f64,
wind_direction_deg: f64,
wake_model: &LarsenWakeModel,
) -> f64 {
if turbines.is_empty() {
return 1.0;
}
let effective_speeds = superpose_wakes(turbines, wind_speed, wind_direction_deg, wake_model);
let actual_power: f64 = turbines
.iter()
.zip(effective_speeds.iter())
.map(|(t, &v)| t.power_mw(v))
.sum();
let isolated_power: f64 = turbines.iter().map(|t| t.power_mw(wind_speed)).sum();
if isolated_power < 1e-9 {
return 1.0;
}
(actual_power / isolated_power).clamp(0.0, 1.0)
}
#[derive(Debug, Clone)]
pub struct LayoutOptimizer {
pub farm_area_m2: f64,
pub spacing_d_min: f64,
pub wind_rose: Vec<(f64, f64)>,
pub turbine_model: OffshoreWindTurbine,
pub max_turbines: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutResult {
pub turbine_positions: Vec<(f64, f64)>,
pub n_turbines: usize,
pub annual_energy_production_gwh: f64,
pub array_efficiency_pct: f64,
pub wake_loss_pct: f64,
pub capacity_factor: f64,
}
impl LayoutOptimizer {
pub fn new(farm_area_m2: f64, turbine_model: OffshoreWindTurbine, max_turbines: usize) -> Self {
let dirs = [0.0f64, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0];
let wind_rose: Vec<(f64, f64)> = dirs.iter().map(|&d| (d, 1.0 / 8.0)).collect();
Self {
farm_area_m2,
spacing_d_min: 7.0,
wind_rose,
turbine_model,
max_turbines,
}
}
pub fn with_wind_rose(mut self, wind_rose: Vec<(f64, f64)>) -> Self {
self.wind_rose = wind_rose;
self
}
fn farm_side_m(&self) -> f64 {
self.farm_area_m2.sqrt()
}
pub fn grid_layout(&self, rows: usize, cols: usize) -> Vec<(f64, f64)> {
let d = self.turbine_model.rotor_diameter_m;
let spacing = self.spacing_d_min.max(1.0) * d;
let n = (rows * cols).min(self.max_turbines);
let mut positions = Vec::with_capacity(n);
'outer: for r in 0..rows {
for c in 0..cols {
if positions.len() >= self.max_turbines {
break 'outer;
}
positions.push((r as f64 * spacing, c as f64 * spacing));
}
}
positions
}
pub fn staggered_layout(&self, rows: usize, cols: usize) -> Vec<(f64, f64)> {
let d = self.turbine_model.rotor_diameter_m;
let spacing_x = self.spacing_d_min.max(1.0) * d;
let spacing_y = self.spacing_d_min.max(1.0) * d;
let mut positions = Vec::with_capacity(rows * cols);
'outer: for r in 0..rows {
let x_offset = if r % 2 == 1 { spacing_y * 0.5 } else { 0.0 };
for c in 0..cols {
if positions.len() >= self.max_turbines {
break 'outer;
}
positions.push((r as f64 * spacing_x, c as f64 * spacing_y + x_offset));
}
}
positions
}
pub fn compute_aep(&self, positions: &[(f64, f64)], wake_model: &LarsenWakeModel) -> f64 {
if positions.is_empty() {
return 0.0;
}
let turbines: Vec<OffshoreWindTurbine> = positions
.iter()
.enumerate()
.map(|(id, &(x, y))| {
let mut t = self.turbine_model.clone();
t.id = id;
t.x_m = x;
t.y_m = y;
t
})
.collect();
let wind_speed_ref = 10.0_f64;
let hours_per_year = 8760.0_f64;
let mut weighted_power_mw = 0.0_f64;
for &(direction_deg, frequency) in &self.wind_rose {
let eff_speeds = superpose_wakes(&turbines, wind_speed_ref, direction_deg, wake_model);
let total_mw: f64 = turbines
.iter()
.zip(eff_speeds.iter())
.map(|(t, &v)| t.power_mw(v))
.sum();
weighted_power_mw += total_mw * frequency;
}
weighted_power_mw * hours_per_year / 1_000.0
}
pub fn optimize(&self, wake_model: &LarsenWakeModel) -> Result<LayoutResult, OxiGridError> {
let d = self.turbine_model.rotor_diameter_m;
let spacing = self.spacing_d_min.max(1.0) * d;
let side = self.farm_side_m();
let max_rows = ((side / spacing).floor() as usize).max(1);
let max_cols = max_rows;
if max_rows == 0 || max_cols == 0 {
return Err(OxiGridError::InvalidParameter(
"Farm area too small for minimum turbine spacing".to_string(),
));
}
let n_target = (max_rows * max_cols).min(self.max_turbines);
let rows = (n_target as f64).sqrt().ceil() as usize;
let cols = (n_target as f64 / rows as f64).ceil() as usize;
let grid_pos = self.grid_layout(rows, cols);
let stag_pos = self.staggered_layout(rows, cols);
let grid_aep = self.compute_aep(&grid_pos, wake_model);
let stag_aep = self.compute_aep(&stag_pos, wake_model);
let (best_pos, best_aep) = if stag_aep >= grid_aep {
(stag_pos, stag_aep)
} else {
(grid_pos, grid_aep)
};
let turbines: Vec<OffshoreWindTurbine> = best_pos
.iter()
.enumerate()
.map(|(id, &(x, y))| {
let mut t = self.turbine_model.clone();
t.id = id;
t.x_m = x;
t.y_m = y;
t
})
.collect();
let wind_speed_ref = 10.0_f64;
let arr_eff: f64 = if self.wind_rose.is_empty() {
1.0
} else {
self.wind_rose
.iter()
.map(|&(dir, freq)| {
array_efficiency(&turbines, wind_speed_ref, dir, wake_model) * freq
})
.sum()
};
let n = best_pos.len();
let installed_mw = n as f64 * self.turbine_model.rated_power_mw;
let capacity_factor = if installed_mw > 0.0 {
best_aep * 1_000.0 / (installed_mw * 8760.0)
} else {
0.0
};
Ok(LayoutResult {
turbine_positions: best_pos,
n_turbines: n,
annual_energy_production_gwh: best_aep,
array_efficiency_pct: arr_eff * 100.0,
wake_loss_pct: (1.0 - arr_eff) * 100.0,
capacity_factor,
})
}
}
#[derive(Debug, Clone)]
pub struct OffshoreWindFarm {
pub farm_id: usize,
pub turbines: Vec<OffshoreWindTurbine>,
pub layout: Vec<(f64, f64)>,
pub offshore_substation_bus: usize,
pub onshore_connection_bus: usize,
pub collection_voltage_kv: f64,
pub hvdc_link_id: Option<usize>,
pub wake_model: LarsenWakeModel,
}
impl OffshoreWindFarm {
pub fn new(
turbines: Vec<OffshoreWindTurbine>,
offshore_sub_bus: usize,
onshore_bus: usize,
) -> Self {
let layout: Vec<(f64, f64)> = turbines.iter().map(|t| (t.x_m, t.y_m)).collect();
let farm_id = offshore_sub_bus; Self {
farm_id,
turbines,
layout,
offshore_substation_bus: offshore_sub_bus,
onshore_connection_bus: onshore_bus,
collection_voltage_kv: 66.0,
hvdc_link_id: None,
wake_model: LarsenWakeModel::offshore_default(),
}
}
pub fn n_turbines(&self) -> usize {
self.turbines.len()
}
pub fn installed_capacity_mw(&self) -> f64 {
self.turbines.iter().map(|t| t.rated_power_mw).sum()
}
pub fn compute_power(&self, wind_speed: f64, wind_direction_deg: f64) -> f64 {
if self.turbines.is_empty() {
return 0.0;
}
let eff_speeds = superpose_wakes(
&self.turbines,
wind_speed,
wind_direction_deg,
&self.wake_model,
);
self.turbines
.iter()
.zip(eff_speeds.iter())
.map(|(t, &v)| t.power_mw(v))
.sum()
}
pub fn capacity_factor(&self, wind_rose: &[(f64, f64, f64)]) -> f64 {
if self.turbines.is_empty() || wind_rose.is_empty() {
return 0.0;
}
let rated_mw = self.installed_capacity_mw();
if rated_mw < 1e-9 {
return 0.0;
}
let mut weighted_power = 0.0_f64;
for &(speed, direction, prob) in wind_rose {
let farm_mw = self.compute_power(speed, direction);
weighted_power += farm_mw * prob;
}
(weighted_power / rated_mw).clamp(0.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_offshore_power_curve_below_cut_in() {
let p = offshore_power_curve(3.0, 3.5, 13.0, 25.0, 15.0);
assert_eq!(p, 0.0, "below cut-in should be zero");
}
#[test]
fn test_offshore_power_curve_at_rated() {
let p = offshore_power_curve(13.0, 3.5, 13.0, 25.0, 15.0);
assert!((p - 15.0).abs() < 0.01, "at rated wind speed: {p}");
}
#[test]
fn test_offshore_power_curve_above_cut_out() {
let p = offshore_power_curve(26.0, 3.5, 13.0, 25.0, 15.0);
assert_eq!(p, 0.0, "above cut-out should be zero");
}
#[test]
fn test_offshore_power_curve_cubic_region() {
let v_mid = 3.5 + (13.0 - 3.5) * 0.5; let p = offshore_power_curve(v_mid, 3.5, 13.0, 25.0, 15.0);
let expected = 15.0 * 0.5_f64.powi(3);
assert!(
(p - expected).abs() < 0.01,
"cubic region p={p:.3} expected≈{expected:.3}"
);
}
#[test]
fn test_larsen_wake_deficit_zero_at_upstream() {
let model = LarsenWakeModel::offshore_default();
let deficit = model.velocity_deficit(-100.0, 0.0, 200.0, 0.8);
assert_eq!(deficit, 0.0, "upstream: no deficit");
}
#[test]
fn test_larsen_wake_decays_with_distance() {
let model = LarsenWakeModel::offshore_default();
let d1 = model.velocity_deficit(500.0, 0.0, 200.0, 0.8);
let d2 = model.velocity_deficit(2000.0, 0.0, 200.0, 0.8);
assert!(
d2 < d1,
"wake deficit should decay downstream: d1={d1:.4} d2={d2:.4}"
);
}
#[test]
fn test_larsen_wake_deficit_bounded() {
let model = LarsenWakeModel::offshore_default();
let deficit = model.velocity_deficit(200.0, 0.0, 200.0, 0.95);
assert!(
(0.0..=1.0).contains(&deficit),
"deficit must be in [0,1]: {deficit}"
);
}
#[test]
fn test_array_efficiency_single_turbine() {
let turbine = OffshoreWindTurbine::new_15mw(0, 0.0, 0.0);
let model = LarsenWakeModel::offshore_default();
let eff = array_efficiency(&[turbine], 10.0, 270.0, &model);
assert!(
(eff - 1.0).abs() < 1e-9,
"single turbine efficiency must be 1.0: {eff}"
);
}
#[test]
fn test_array_efficiency_multiple_turbines_in_wake() {
let turbines = vec![
OffshoreWindTurbine::new_15mw(0, 0.0, 0.0),
OffshoreWindTurbine::new_15mw(1, 0.0, 1400.0), OffshoreWindTurbine::new_15mw(2, 0.0, 2800.0),
];
let model = LarsenWakeModel::offshore_default();
let eff = array_efficiency(&turbines, 10.0, 0.0, &model);
assert!(eff > 0.0 && eff <= 1.0, "efficiency in (0,1]: {eff}");
}
#[test]
fn test_grid_layout_correct_count() {
let turbine = OffshoreWindTurbine::new_15mw(0, 0.0, 0.0);
let opt = LayoutOptimizer::new(100_000_000.0, turbine, 50);
let pos = opt.grid_layout(3, 4);
assert_eq!(pos.len(), 12, "3×4 grid should have 12 positions");
}
#[test]
fn test_staggered_layout_correct_count() {
let turbine = OffshoreWindTurbine::new_15mw(0, 0.0, 0.0);
let opt = LayoutOptimizer::new(100_000_000.0, turbine, 50);
let pos = opt.staggered_layout(3, 4);
assert_eq!(pos.len(), 12, "3×4 staggered should have 12 positions");
}
#[test]
fn test_layout_optimizer_runs() {
let turbine = OffshoreWindTurbine::new_15mw(0, 0.0, 0.0);
let opt = LayoutOptimizer::new(400_000_000.0, turbine, 20); let model = LarsenWakeModel::offshore_default();
let result = opt
.optimize(&model)
.expect("layout optimization should succeed");
assert!(result.n_turbines > 0);
assert!(result.annual_energy_production_gwh > 0.0);
assert!(result.capacity_factor >= 0.0 && result.capacity_factor <= 1.0);
}
#[test]
fn test_foundation_type_shallow_water() {
assert_eq!(FoundationType::for_depth(25.0), FoundationType::Monopile);
}
#[test]
fn test_foundation_type_deep_water() {
assert_eq!(
FoundationType::for_depth(200.0),
FoundationType::FloatingSpar
);
}
#[test]
fn test_foundation_type_intermediate() {
assert_eq!(
FoundationType::for_depth(60.0),
FoundationType::TripodJacket
);
}
#[test]
fn test_offshore_farm_compute_power_at_zero_wind() {
let turbines = vec![
OffshoreWindTurbine::new_15mw(0, 0.0, 0.0),
OffshoreWindTurbine::new_15mw(1, 1400.0, 0.0),
];
let farm = OffshoreWindFarm::new(turbines, 1, 2);
let power = farm.compute_power(0.0, 270.0);
assert_eq!(power, 0.0);
}
#[test]
fn test_offshore_farm_capacity_factor_in_range() {
let turbines: Vec<OffshoreWindTurbine> = (0..4)
.map(|i| OffshoreWindTurbine::new_15mw(i, (i as f64) * 1400.0, 0.0))
.collect();
let farm = OffshoreWindFarm::new(turbines, 1, 2);
let wind_rose: Vec<(f64, f64, f64)> =
[0.0f64, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0]
.iter()
.map(|&d| (10.0, d, 1.0 / 8.0))
.collect();
let cf = farm.capacity_factor(&wind_rose);
assert!(
(0.0..=1.0).contains(&cf),
"capacity factor must be in [0,1]: {cf}"
);
}
#[test]
fn test_offshore_farm_installed_capacity() {
let turbines: Vec<OffshoreWindTurbine> = (0..5)
.map(|i| OffshoreWindTurbine::new_15mw(i, (i as f64) * 1400.0, 0.0))
.collect();
let farm = OffshoreWindFarm::new(turbines, 1, 2);
assert!((farm.installed_capacity_mw() - 75.0).abs() < 0.01);
}
}