use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub enum LayoutError {
InvalidConfig(String),
OptimizationFailed(String),
}
impl fmt::Display for LayoutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidConfig(s) => write!(f, "invalid layout config: {s}"),
Self::OptimizationFailed(s) => write!(f, "optimization failed: {s}"),
}
}
}
impl std::error::Error for LayoutError {}
struct LcgRng {
state: u64,
}
impl LcgRng {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u64(&mut self) -> u64 {
self.state = self
.state
.wrapping_mul(6_364_136_223_846_793_005u64)
.wrapping_add(1_442_695_040_888_963_407u64);
self.state
}
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
fn uniform(&mut self, lo: f64, hi: f64) -> f64 {
lo + self.next_f64() * (hi - lo)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindRose {
pub directions_deg: Vec<f64>,
pub frequencies: Vec<f64>,
pub mean_speed_ms: Vec<f64>,
pub weibull_k: Vec<f64>,
}
impl WindRose {
pub fn uniform_12(mean_speed_ms: f64) -> Self {
let n = 12;
let dirs: Vec<f64> = (0..n).map(|i| i as f64 * 30.0).collect();
let freqs = vec![1.0 / n as f64; n];
let speeds = vec![mean_speed_ms; n];
let ks = vec![2.0; n];
Self {
directions_deg: dirs,
frequencies: freqs,
mean_speed_ms: speeds,
weibull_k: ks,
}
}
pub fn validate(&self) -> Result<(), LayoutError> {
let n = self.directions_deg.len();
if n == 0 {
return Err(LayoutError::InvalidConfig(
"wind rose has no sectors".to_string(),
));
}
if self.frequencies.len() != n || self.mean_speed_ms.len() != n || self.weibull_k.len() != n
{
return Err(LayoutError::InvalidConfig(
"wind rose arrays must all have the same length".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum LayoutOptMethod {
GridSearch {
rows: usize,
cols: usize,
},
Hexagonal,
GeneticAlgorithm {
population: usize,
generations: usize,
},
SimulatedAnnealing {
initial_temp: f64,
cooling_rate: f64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutConfig {
pub site_area_km2: f64,
pub n_turbines: usize,
pub turbine_rated_mw: f64,
pub turbine_rotor_diameter_m: f64,
pub min_spacing_diameters: f64,
pub n_wind_directions: usize,
pub n_wind_speeds: usize,
pub optimization_method: LayoutOptMethod,
}
impl LayoutConfig {
pub fn validate(&self) -> Result<(), LayoutError> {
if self.site_area_km2 <= 0.0 {
return Err(LayoutError::InvalidConfig(
"site_area_km2 must be positive".to_string(),
));
}
if self.n_turbines == 0 {
return Err(LayoutError::InvalidConfig(
"n_turbines must be >= 1".to_string(),
));
}
if self.turbine_rated_mw <= 0.0 {
return Err(LayoutError::InvalidConfig(
"turbine_rated_mw must be positive".to_string(),
));
}
if self.turbine_rotor_diameter_m <= 0.0 {
return Err(LayoutError::InvalidConfig(
"turbine_rotor_diameter_m must be positive".to_string(),
));
}
if self.min_spacing_diameters < 1.0 {
return Err(LayoutError::InvalidConfig(
"min_spacing_diameters must be >= 1.0".to_string(),
));
}
Ok(())
}
pub fn min_spacing_m(&self) -> f64 {
self.min_spacing_diameters * self.turbine_rotor_diameter_m
}
pub fn site_side_m(&self) -> f64 {
(self.site_area_km2 * 1e6_f64).sqrt()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct WindFarmTurbinePosition {
pub id: usize,
pub x_m: f64,
pub y_m: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindFarmLayoutResult {
pub positions: Vec<WindFarmTurbinePosition>,
pub annual_energy_gwh: f64,
pub capacity_factor_pct: f64,
pub wake_loss_pct: f64,
pub availability_factor_pct: f64,
pub lcoe_usd_per_mwh: f64,
pub n_constraint_violations: usize,
}
pub struct WindFarmLayoutOptimizer {
config: LayoutConfig,
wind_rose: WindRose,
}
impl WindFarmLayoutOptimizer {
pub fn new(config: LayoutConfig, wind_rose: WindRose) -> Self {
Self { config, wind_rose }
}
pub fn jensen_deficit(&self, x_m: f64, ct: f64, k_wake: f64) -> f64 {
if x_m <= 0.0 || ct <= 0.0 {
return 0.0;
}
let d = self.config.turbine_rotor_diameter_m;
let numerator = 1.0 - (1.0 - ct).max(0.0).sqrt();
let denominator = (1.0 + k_wake * x_m / d).powi(2);
(numerator / denominator.max(1e-12)).clamp(0.0, 1.0)
}
pub fn power_at_speed(&self, wind_speed_ms: f64) -> f64 {
let v_cut_in = 3.0_f64;
let v_rated = 12.0_f64;
let v_cut_out = 25.0_f64;
let p_rated = self.config.turbine_rated_mw;
if wind_speed_ms < v_cut_in || wind_speed_ms > v_cut_out {
return 0.0;
}
if wind_speed_ms >= v_rated {
return p_rated;
}
p_rated * (wind_speed_ms / v_rated).powi(3)
}
fn gross_aep_single(&self) -> f64 {
let n_speeds = self.config.n_wind_speeds.max(10);
let mut total_mwh = 0.0_f64;
let n_dir = self.wind_rose.directions_deg.len();
for dir_idx in 0..n_dir {
let freq = self.wind_rose.frequencies[dir_idx];
let mean_v = self.wind_rose.mean_speed_ms[dir_idx];
let k = self.wind_rose.weibull_k[dir_idx];
let gamma_1_plus_1_k = weibull_gamma_approx(k);
let lambda = mean_v / gamma_1_plus_1_k;
let v_max = 25.0_f64;
let dv = v_max / n_speeds as f64;
let mut mwh_dir = 0.0_f64;
for speed_bin in 0..n_speeds {
let v = (speed_bin as f64 + 0.5) * dv;
let p = self.power_at_speed(v);
let f_wei = weibull_pdf(v, k, lambda);
mwh_dir += p * f_wei * dv * 8760.0; }
total_mwh += mwh_dir * freq;
}
total_mwh / 1_000.0 }
pub fn compute_aep(&self, positions: &[WindFarmTurbinePosition]) -> f64 {
if positions.is_empty() {
return 0.0;
}
let n = positions.len();
let k_wake = 0.04_f64; let ct = 0.8_f64; let availability = 0.97_f64;
let n_dir = self.wind_rose.directions_deg.len();
let n_speeds = self.config.n_wind_speeds.max(10);
let mut total_gwh = 0.0_f64;
for dir_idx in 0..n_dir {
let dir_deg = self.wind_rose.directions_deg[dir_idx];
let dir_rad = dir_deg.to_radians();
let freq = self.wind_rose.frequencies[dir_idx];
let mean_v = self.wind_rose.mean_speed_ms[dir_idx];
let k = self.wind_rose.weibull_k[dir_idx];
let gamma_k = weibull_gamma_approx(k);
let lambda = mean_v / gamma_k;
let (sin_d, cos_d) = (dir_rad.sin(), dir_rad.cos());
let v_max = 25.0_f64;
let dv = v_max / n_speeds as f64;
for speed_bin in 0..n_speeds {
let v0 = (speed_bin as f64 + 0.5) * dv;
let f_wei = weibull_pdf(v0, k, lambda) * dv;
if f_wei < 1e-12 {
continue;
}
let mut eff_speeds = vec![v0; 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 = positions[i].x_m - positions[j].x_m;
let dy = positions[i].y_m - positions[j].y_m;
let x_down = dx * sin_d + dy * cos_d;
if x_down <= 0.0 {
continue;
}
let y_lat = (-dx * cos_d + dy * sin_d).abs();
let d = self.config.turbine_rotor_diameter_m;
let r_wake = (d / 2.0) + k_wake * x_down;
if y_lat > r_wake {
continue; }
let deficit = self.jensen_deficit(x_down, ct, k_wake);
deficit_sq_sum += deficit * deficit;
}
let total_deficit = deficit_sq_sum.sqrt().min(0.95);
eff_speeds[i] = v0 * (1.0 - total_deficit);
}
let farm_mw: f64 = eff_speeds.iter().map(|&v| self.power_at_speed(v)).sum();
total_gwh += farm_mw * f_wei * 8760.0 * freq * availability / 1_000.0;
}
}
total_gwh
}
pub fn check_spacing(&self, positions: &[WindFarmTurbinePosition]) -> usize {
let min_dist = self.config.min_spacing_m();
let mut violations = 0usize;
let n = positions.len();
for i in 0..n {
for j in (i + 1)..n {
let dx = positions[i].x_m - positions[j].x_m;
let dy = positions[i].y_m - positions[j].y_m;
if (dx * dx + dy * dy).sqrt() < min_dist {
violations += 1;
}
}
}
violations
}
fn grid_layout(&self, rows: usize, cols: usize) -> Vec<WindFarmTurbinePosition> {
let spacing = self
.config
.min_spacing_m()
.max(self.config.turbine_rotor_diameter_m * self.config.min_spacing_diameters);
let mut positions = Vec::new();
let mut id = 0;
'outer: for r in 0..rows {
for c in 0..cols {
if positions.len() >= self.config.n_turbines {
break 'outer;
}
positions.push(WindFarmTurbinePosition {
id,
x_m: r as f64 * spacing,
y_m: c as f64 * spacing,
});
id += 1;
}
}
positions
}
fn hexagonal_layout(&self) -> Vec<WindFarmTurbinePosition> {
let d = self.config.turbine_rotor_diameter_m;
let spacing = self.config.min_spacing_diameters * d;
let row_spacing = spacing * (3.0_f64.sqrt() / 2.0);
let side = self.config.site_side_m();
let cols = (side / spacing).floor() as usize + 1;
let rows = (side / row_spacing).floor() as usize + 1;
let mut positions = Vec::new();
let mut id = 0;
'outer: for r in 0..rows {
let offset = if r % 2 == 1 { spacing * 0.5 } else { 0.0 };
for c in 0..cols {
if positions.len() >= self.config.n_turbines {
break 'outer;
}
let x = r as f64 * row_spacing;
let y = c as f64 * spacing + offset;
if x > side || y > side + spacing {
continue;
}
positions.push(WindFarmTurbinePosition { id, x_m: x, y_m: y });
id += 1;
}
}
positions
}
fn random_layout(&self, rng: &mut LcgRng) -> Vec<WindFarmTurbinePosition> {
let side = self.config.site_side_m();
(0..self.config.n_turbines)
.map(|id| WindFarmTurbinePosition {
id,
x_m: rng.uniform(0.0, side),
y_m: rng.uniform(0.0, side),
})
.collect()
}
fn mutate(
&self,
layout: &[WindFarmTurbinePosition],
rng: &mut LcgRng,
) -> Vec<WindFarmTurbinePosition> {
let mut new_layout = layout.to_vec();
let idx = (rng.next_u64() % layout.len() as u64) as usize;
let d = self.config.turbine_rotor_diameter_m;
let side = self.config.site_side_m();
new_layout[idx].x_m = (new_layout[idx].x_m + rng.uniform(-d, d)).clamp(0.0, side);
new_layout[idx].y_m = (new_layout[idx].y_m + rng.uniform(-d, d)).clamp(0.0, side);
new_layout
}
fn run_genetic_algorithm(
&self,
pop_size: usize,
generations: usize,
) -> Vec<WindFarmTurbinePosition> {
let pop_size = pop_size.max(4);
let mut rng = LcgRng::new(12345);
let mut population: Vec<Vec<WindFarmTurbinePosition>> = (0..pop_size)
.map(|_| self.random_layout(&mut rng))
.collect();
let rows = (self.config.n_turbines as f64).sqrt().ceil() as usize;
let cols = self.config.n_turbines.div_ceil(rows);
population[0] = self.grid_layout(rows, cols);
let mut fitnesses: Vec<f64> = population.iter().map(|l| self.compute_aep(l)).collect();
for _gen in 0..generations {
let mut new_pop = Vec::with_capacity(pop_size);
for _ in 0..pop_size {
let a = (rng.next_u64() % pop_size as u64) as usize;
let b = (rng.next_u64() % pop_size as u64) as usize;
let winner = if fitnesses[a] >= fitnesses[b] { a } else { b };
let child = self.mutate(&population[winner], &mut rng);
new_pop.push(child);
}
let new_fitnesses: Vec<f64> = new_pop.iter().map(|l| self.compute_aep(l)).collect();
population = new_pop;
fitnesses = new_fitnesses;
}
let best_idx = fitnesses
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
population.remove(best_idx)
}
fn run_simulated_annealing(
&self,
initial_temp: f64,
cooling_rate: f64,
) -> Vec<WindFarmTurbinePosition> {
let rows = (self.config.n_turbines as f64).sqrt().ceil() as usize;
let cols = self.config.n_turbines.div_ceil(rows);
let mut current = self.grid_layout(rows, cols);
let mut current_aep = self.compute_aep(¤t);
let mut best = current.clone();
let mut best_aep = current_aep;
let mut temp = initial_temp;
let mut rng = LcgRng::new(99999);
let n_steps = (1.0 / (1.0 - cooling_rate.min(0.9999))) as usize + 500;
let n_steps = n_steps.min(2000);
for _ in 0..n_steps {
if temp < 1e-9 {
break;
}
let candidate = self.mutate(¤t, &mut rng);
let candidate_aep = self.compute_aep(&candidate);
let delta = candidate_aep - current_aep;
if delta > 0.0 || rng.next_f64() < (-delta / temp).exp() {
current = candidate;
current_aep = candidate_aep;
if current_aep > best_aep {
best = current.clone();
best_aep = current_aep;
}
}
temp *= cooling_rate;
}
best
}
fn compute_lcoe(&self, annual_energy_gwh: f64, n_turbines: usize) -> f64 {
let capex_per_mw = 4_500_000.0_f64; let installed_mw = n_turbines as f64 * self.config.turbine_rated_mw;
let capex = capex_per_mw * installed_mw;
let opex_pv = 0.03 * capex * 20.0;
let lifetime_mwh = annual_energy_gwh * 1_000.0 * 20.0;
if lifetime_mwh < 1.0 {
return f64::INFINITY;
}
(capex + opex_pv) / lifetime_mwh
}
pub fn optimize(&self) -> Result<WindFarmLayoutResult, LayoutError> {
self.config.validate()?;
self.wind_rose.validate()?;
let positions = match &self.config.optimization_method {
LayoutOptMethod::GridSearch { rows, cols } => self.grid_layout(*rows, *cols),
LayoutOptMethod::Hexagonal => self.hexagonal_layout(),
LayoutOptMethod::GeneticAlgorithm {
population,
generations,
} => self.run_genetic_algorithm(*population, *generations),
LayoutOptMethod::SimulatedAnnealing {
initial_temp,
cooling_rate,
} => self.run_simulated_annealing(*initial_temp, *cooling_rate),
};
if positions.is_empty() {
return Err(LayoutError::OptimizationFailed(
"no turbines placed — check site area and spacing constraints".to_string(),
));
}
let n = positions.len();
let annual_energy_gwh = self.compute_aep(&positions);
let n_constraint_violations = self.check_spacing(&positions);
let availability_factor_pct = 97.0_f64;
let gross_aep_gwh = self.gross_aep_single() * n as f64;
let wake_loss_pct = if gross_aep_gwh > 0.0 {
((gross_aep_gwh - annual_energy_gwh) / gross_aep_gwh * 100.0).clamp(0.0, 100.0)
} else {
0.0
};
let installed_mw = n as f64 * self.config.turbine_rated_mw;
let capacity_factor_pct = if installed_mw > 0.0 {
annual_energy_gwh * 1_000.0 / (installed_mw * 8760.0) * 100.0
} else {
0.0
};
let lcoe_usd_per_mwh = self.compute_lcoe(annual_energy_gwh, n);
Ok(WindFarmLayoutResult {
positions,
annual_energy_gwh,
capacity_factor_pct,
wake_loss_pct,
availability_factor_pct,
lcoe_usd_per_mwh,
n_constraint_violations,
})
}
}
fn weibull_gamma_approx(k: f64) -> f64 {
let x = 1.0 + 1.0 / k.max(0.5);
gamma_approx(x)
}
fn gamma_approx(x: f64) -> f64 {
let p = [
76.180_091_729_471_46_f64,
-86.505_320_329_416_77,
24.014_098_240_830_91,
-1.231_739_572_450_155,
1.208_650_973_866_179e-3,
-5.395_239_384_953_e-6,
];
let x = x - 1.0;
let mut ser = 1.000_000_000_190_015_f64;
let mut y = x;
for &c in &p {
y += 1.0;
ser += c / y;
}
let t = x + 5.5;
(2.0 * std::f64::consts::PI).sqrt() * t.powf(x + 0.5) * (-t).exp() * ser
}
fn weibull_pdf(v: f64, k: f64, lambda: f64) -> f64 {
if v <= 0.0 || lambda <= 0.0 || k <= 0.0 {
return 0.0;
}
let ratio = v / lambda;
(k / lambda) * ratio.powf(k - 1.0) * (-(ratio.powf(k))).exp()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_config(method: LayoutOptMethod, n: usize) -> LayoutConfig {
LayoutConfig {
site_area_km2: 25.0, n_turbines: n,
turbine_rated_mw: 5.0,
turbine_rotor_diameter_m: 126.0,
min_spacing_diameters: 5.0,
n_wind_directions: 12,
n_wind_speeds: 15,
optimization_method: method,
}
}
fn make_wind_rose() -> WindRose {
WindRose::uniform_12(8.0)
}
#[test]
fn test_grid_layout_no_violations() {
let config = make_config(LayoutOptMethod::GridSearch { rows: 3, cols: 3 }, 9);
let opt = WindFarmLayoutOptimizer::new(config, make_wind_rose());
let result = opt.optimize().expect("grid optimize should succeed");
assert_eq!(
result.positions.len(),
9,
"grid 3×3 should place 9 turbines"
);
assert_eq!(
result.n_constraint_violations, 0,
"regular grid should have zero spacing violations"
);
}
#[test]
fn test_hexagonal_layout_positive_aep() {
let config = make_config(LayoutOptMethod::Hexagonal, 6);
let opt = WindFarmLayoutOptimizer::new(config, make_wind_rose());
let result = opt.optimize().expect("hexagonal optimize should succeed");
assert!(
result.annual_energy_gwh > 0.0,
"hexagonal layout AEP should be positive, got {}",
result.annual_energy_gwh
);
assert_eq!(
result.n_constraint_violations, 0,
"hexagonal layout should satisfy spacing"
);
}
#[test]
fn test_jensen_deficit_formula() {
let config = make_config(LayoutOptMethod::Hexagonal, 4);
let opt = WindFarmLayoutOptimizer::new(config, make_wind_rose());
let d = 126.0_f64;
let ct = 0.8_f64;
let k = 0.04_f64;
let x = 5.0 * d;
let deficit = opt.jensen_deficit(x, ct, k);
let expected = (1.0 - (1.0 - ct).sqrt()) / (1.0 + k * 5.0).powi(2);
assert!(
(deficit - expected).abs() < 1e-6,
"Jensen deficit: got {deficit:.6}, expected {expected:.6}"
);
assert!(deficit > 0.0 && deficit < 1.0, "deficit must be in (0,1)");
assert_eq!(opt.jensen_deficit(0.0, ct, k), 0.0);
}
#[test]
fn test_more_turbines_more_aep() {
let rose = make_wind_rose();
let config4 = LayoutConfig {
site_area_km2: 100.0,
n_turbines: 4,
turbine_rated_mw: 5.0,
turbine_rotor_diameter_m: 126.0,
min_spacing_diameters: 5.0,
n_wind_directions: 12,
n_wind_speeds: 10,
optimization_method: LayoutOptMethod::GridSearch { rows: 2, cols: 2 },
};
let config9 = LayoutConfig {
n_turbines: 9,
optimization_method: LayoutOptMethod::GridSearch { rows: 3, cols: 3 },
..config4.clone()
};
let opt4 = WindFarmLayoutOptimizer::new(config4, rose.clone());
let opt9 = WindFarmLayoutOptimizer::new(config9, rose);
let aep4 = opt4
.optimize()
.expect("4-turbine optimize")
.annual_energy_gwh;
let aep9 = opt9
.optimize()
.expect("9-turbine optimize")
.annual_energy_gwh;
assert!(
aep9 > aep4,
"9-turbine AEP ({aep9:.3} GWh) should exceed 4-turbine ({aep4:.3} GWh)"
);
}
#[test]
fn test_spacing_violations_detected() {
let config = make_config(LayoutOptMethod::Hexagonal, 4);
let opt = WindFarmLayoutOptimizer::new(config, make_wind_rose());
let positions = vec![
WindFarmTurbinePosition {
id: 0,
x_m: 0.0,
y_m: 0.0,
},
WindFarmTurbinePosition {
id: 1,
x_m: 10.0,
y_m: 0.0,
}, WindFarmTurbinePosition {
id: 2,
x_m: 2000.0,
y_m: 0.0,
},
WindFarmTurbinePosition {
id: 3,
x_m: 4000.0,
y_m: 0.0,
},
];
let violations = opt.check_spacing(&positions);
assert!(
violations > 0,
"closely-spaced pair should register a violation"
);
}
#[test]
fn test_simulated_annealing_runs() {
let config = make_config(
LayoutOptMethod::SimulatedAnnealing {
initial_temp: 1.0,
cooling_rate: 0.95,
},
6,
);
let opt = WindFarmLayoutOptimizer::new(config, make_wind_rose());
let result = opt.optimize().expect("SA optimize should succeed");
assert!(
result.annual_energy_gwh > 0.0,
"SA result must have positive AEP"
);
}
#[test]
fn test_genetic_algorithm_runs() {
let config = make_config(
LayoutOptMethod::GeneticAlgorithm {
population: 10,
generations: 5,
},
6,
);
let opt = WindFarmLayoutOptimizer::new(config, make_wind_rose());
let result = opt.optimize().expect("GA optimize should succeed");
assert!(
result.annual_energy_gwh > 0.0,
"GA result must have positive AEP"
);
assert_eq!(result.positions.len(), 6);
}
#[test]
fn test_lcoe_finite_positive() {
let config = make_config(LayoutOptMethod::GridSearch { rows: 2, cols: 2 }, 4);
let opt = WindFarmLayoutOptimizer::new(config, make_wind_rose());
let result = opt.optimize().expect("optimize ok");
assert!(
result.lcoe_usd_per_mwh > 0.0 && result.lcoe_usd_per_mwh.is_finite(),
"LCOE must be finite and positive, got {}",
result.lcoe_usd_per_mwh
);
}
}