diff --git a/NEWS.md b/NEWS.md index daa4cc5..e47171d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,13 @@ # Release notes -## Version 0.1.0 (2026-05-28) +## Version 0.1.0 (2026-06-05) + +### Extended WindFarmParameters + +* Extended `WindFarmParameters` to include an optional `turbine_power_curve` argument, which allows users to specify a custom power curve for the wind turbine. + The `turbine_power_curve` is expected to be a `DataFrame` with columns `wind_speed` and `power_curve`, where `wind_speed` represents the wind speed values (in m/s) and `power_curve` represents the corresponding power output of the turbine at those wind speeds. +* The parameters `sigma` and `wakeloss` were also added to `WindFarmParameters` to enable all options of the `wind_power_timeseries` repository. +* The `shape` parameter was corrected from a string to a float. ### Added WindFarmParameters diff --git a/docs/src/library/internals/methods-EMLI.md b/docs/src/library/internals/methods-EMLI.md index c720c4f..eabec78 100644 --- a/docs/src/library/internals/methods-EMLI.md +++ b/docs/src/library/internals/methods-EMLI.md @@ -19,6 +19,7 @@ EMLI.pvgis_profile EMLI.get_pvgis_data EMLI.get_met_data EMLI.heat_demand_profile +EMLI.to_pandas_series ``` ## [Macros](@id lib-int-mac-util) diff --git a/src/datastructures.jl b/src/datastructures.jl index 9a5f48f..bd277eb 100644 --- a/src/datastructures.jl +++ b/src/datastructures.jl @@ -100,10 +100,13 @@ end lat::Real, lon::Real, turbine_height::Real; - orientation = missing, - shape = missing, + orientation::Union{Real, Nothing} = nothing, + shape::Union{Real, Nothing} = nothing, method::String = "Ninja", source::String = "NORA3", + turbine_power_curve::Union{DataFrame, String, Nothing} = nothing, + sigma::Union{Real, Nothing} = nothing, + wakeloss::Union{Real, Nothing} = nothing, ) A structure to hold wind farm parameters and metadata for wind power time series generation. @@ -113,13 +116,28 @@ A structure to hold wind farm parameters and metadata for wind power time series - **`lat::Real`** is the latitude of the location in decimal degrees (e.g., `52.5` for 52°30′ N, `-33.75` for 33°45′ S). - **`lon::Real`** is the longitude of the location in decimal degrees (e.g., `13.5` for 13°30′ E, `-122.25` for 122°15′ W). - **`turbine_height::Real`** is the height of the wind turbines in meters. -- **`orientation`** is the orientation of the wind farm (default: `missing`). -- **`shape`** is the shape of the wind farm (default: `missing`). +- **`orientation::Union{Real, Nothing}`** is the orientation of the wind farm (default: `nothing`). + It is given in degrees from north, typically aligned with dominant wind direction (e.g., 0 for north, + 90 for east, 180 for south, 270 for west). Must be in the interval `[0, 360)` if provided. +- **`shape::Union{Real, Nothing}`** is the aspect ratio (must be positive if provided), number of columns + (i.e. number of turbines in a row) divided by number of rows of turbines (default: `nothing`). - **`method::String`** is the chosen method for data retrieval. The user can choose between the strings "Ninja", "Tradewind_offshore", "Tradewind_upland", and "Tradewind_lowland". The default value is "Ninja". - **`source::String`** is the data source for wind data. The user can choose between the strings "NORA3" and "ERA5". The default value is "NORA3". +- **`turbine_power_curve::Union{String, DataFrame, Nothing}`** optional power curve input + (e.g. curve name or dataset-based interpolated curve), default: `nothing`. + For `String` input, available options are: "VestasV80", "Tradewind_lowland", "Tradewind_upland", + "Tradewind_offshore", "Tradewind_offshore_2030", "IEA_15MW_240_RWT", "IEA_10MW_198_RWT", + "NREL_5MW_126_RWT", and "DTU_10MW_178_RWT". + For `DataFrame` input, the `DataFrame` must contain two columns: "wind_speed" and "power_curve", where + "wind_speed" is the wind speed in m/s and "power_curve" is the normalized power output (both must be non-negative) + corresponding to each wind speed (values are normalized by default by the `wind_power_timeseries` module). + Values are set to zero for wind speeds outside the range of the provided power curve. + Must have at least 2 rows to allow for interpolation. +- **`sigma::Union{Real, Nothing}`** optional Ninja smoothing parameter, default: `nothing`. +- **`wakeloss::Union{Real, Nothing}`** optional Ninja wakeloss parameter, default: `nothing`. !!! note "Key word argument in constructors" If not all fields with default values are provided, the user must use the keyword arguments. @@ -129,19 +147,26 @@ struct WindFarmParameters <: AbstractParameters lat::Real lon::Real turbine_height::Real - orientation::Any - shape::Any + orientation::Union{Real,Nothing} + shape::Union{Real,Nothing} method::String source::String + turbine_power_curve::Union{String,DataFrame,Nothing} + sigma::Union{Real,Nothing} + wakeloss::Union{Real,Nothing} + function WindFarmParameters( id::String, lat::Real, lon::Real, turbine_height::Real, - orientation::Any, - shape::Any, + orientation::Union{Real,Nothing}, + shape::Union{Real,Nothing}, method::String, source::String, + turbine_power_curve::Union{String,DataFrame,Nothing}, + sigma::Union{Real,Nothing}, + wakeloss::Union{Real,Nothing}, ) errors = String[] if lat < -90 || lat > 90 @@ -164,6 +189,59 @@ struct WindFarmParameters <: AbstractParameters push!(errors, "source must be one of $(sources).") end + if orientation isa Real && (orientation < 0 || orientation >= 360) + push!(errors, "orientation must be in [0, 360) or `nothing`.") + end + + if shape isa Real && shape <= 0 + push!(errors, "shape must be a positive Real or `nothing`.") + end + + if turbine_power_curve isa DataFrame + required_columns = ["wind_speed", "power_curve"] + if nrow(turbine_power_curve) < 2 + push!(errors, "turbine_power_curve DataFrame must have at least 2 rows.") + end + missing_columns = + [col for col ∈ required_columns if !(col in names(turbine_power_curve))] + if !isempty(missing_columns) + push!( + errors, + "turbine_power_curve DataFrame must contain columns: $(required_columns). Missing: $(missing_columns).", + ) + end + + for col ∈ required_columns + if col ∈ names(turbine_power_curve) + vals = turbine_power_curve[!, col] + if any(ismissing, vals) || any(x -> !(x isa Real), vals) || + any(x -> x < 0, vals) + push!( + errors, + "turbine_power_curve '$col' values must be non-negative Reals.", + ) + end + end + end + elseif turbine_power_curve isa String + valid_curves = + ("VestasV80", "Tradewind_lowland", "Tradewind_upland", "Tradewind_offshore", + "Tradewind_offshore_2030", "IEA_15MW_240_RWT", "IEA_10MW_198_RWT", + "NREL_5MW_126_RWT", + "DTU_10MW_178_RWT") + if !(turbine_power_curve in valid_curves) + push!(errors, "turbine_power_curve string must be one of $(valid_curves).") + end + end + + if sigma isa Real && sigma < 0 + push!(errors, "sigma must be a non-negative Real or `nothing`.") + end + + if wakeloss isa Real && (wakeloss < 0 || wakeloss > 1) + push!(errors, "wakeloss must be a Real in [0, 1] or `nothing`.") + end + if !isempty(errors) throw(ArgumentError(join(errors, " "))) end @@ -177,6 +255,9 @@ struct WindFarmParameters <: AbstractParameters shape, method, source, + turbine_power_curve, + sigma, + wakeloss, ) end end @@ -185,10 +266,13 @@ function WindFarmParameters( lat::Real, lon::Real, turbine_height::Real; - orientation = missing, - shape = missing, + orientation::Union{Real,Nothing} = nothing, + shape::Union{Real,Nothing} = nothing, method::String = "Ninja", source::String = "NORA3", + turbine_power_curve::Union{String,DataFrame,Nothing} = nothing, + sigma::Union{Real,Nothing} = nothing, + wakeloss::Union{Real,Nothing} = nothing, ) return WindFarmParameters( id, @@ -199,6 +283,9 @@ function WindFarmParameters( shape, method, source, + turbine_power_curve, + sigma, + wakeloss, ) end @@ -303,6 +390,9 @@ function WindPower( data::Vector{<:ExtensionData} = ExtensionData[], data_path::String = "", ) + power_curve = wind_params.turbine_power_curve + turbine_power_curve = + power_curve isa DataFrame ? to_pandas_series(power_curve) : power_curve power = call_python_function( "wind_power_timeseries", "sample.wind_power"; @@ -312,6 +402,9 @@ function WindPower( method = wind_params.method, data_path = data_path, source = wind_params.source, + turbine_power_curve = turbine_power_curve, + sigma = wind_params.sigma, + wakeloss = wind_params.wakeloss, ) profile = OperationalProfile(power) diff --git a/src/utils.jl b/src/utils.jl index 97ec2ec..293bb64 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -509,3 +509,12 @@ function get_met_data( end return df end + +""" + to_pandas_series(df::DataFrame) + +Convert a power-curve DataFrame to a Pandas Series. +The DataFrame must contain columns "wind_speed" (used as the Series index) and "power_curve" (used as the Series values). +""" +to_pandas_series(df::DataFrame) = + pyimport("pandas").Series(df[!, "power_curve"], index = df[!, "wind_speed"]) diff --git a/submodules/wind_power_timeseries b/submodules/wind_power_timeseries index 5151758..2fe585d 160000 --- a/submodules/wind_power_timeseries +++ b/submodules/wind_power_timeseries @@ -1 +1 @@ -Subproject commit 51517589464ab0ab034785d47e481b8caf369682 +Subproject commit 2fe585dc83371e83a75ce959e2e0ca61f3a616b1 diff --git a/test/Project.toml b/test/Project.toml index 6d6926a..7b1c0f0 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,5 +1,6 @@ [deps] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" EnergyModelsBase = "5d7e687e-f956-46f3-9045-6f5a5fd49f50" EnergyModelsHeat = "ad1b8b27-e232-4da9-b498-bea9c19a30d7" diff --git a/test/runtests.jl b/test/runtests.jl index 19dca59..5b53390 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -9,6 +9,7 @@ using Dates using JSON using HiGHS using JuMP +using DataFrames const EMLI = EnergyModelsLanguageInterfaces const EMB = EnergyModelsBase diff --git a/test/test_windpower.jl b/test/test_windpower.jl index d21625b..61a84f0 100644 --- a/test/test_windpower.jl +++ b/test/test_windpower.jl @@ -6,9 +6,12 @@ 5.0, 100.0; orientation = 180, - shape = "circular", + shape = 2.0, method = "Ninja", source = "NORA3", + turbine_power_curve = nothing, + sigma = nothing, + wakeloss = nothing, ) params2 = WindFarmParameters( @@ -17,9 +20,12 @@ 5.0, 100.0, 180, - "circular", + 2.0, "Ninja", "NORA3", + nothing, + nothing, + nothing, ) for params ∈ (params1, params2) @@ -28,7 +34,7 @@ @test params.lon == 5.0 @test params.turbine_height == 100.0 @test params.orientation == 180 - @test params.shape == "circular" + @test params.shape == 2.0 @test params.method == "Ninja" @test params.source == "NORA3" end @@ -56,29 +62,259 @@ "wf1", 52.0, 5.0, 100.0; source = "invalid", ) + + # invalid orientation + @test_throws ArgumentError WindFarmParameters( + "wf1", 52.0, 5.0, 100.0; + orientation = -10.0, + ) + @test_throws ArgumentError WindFarmParameters( + "wf1", 52.0, 5.0, 100.0; + orientation = 360.0, + ) + + # invalid shape + @test_throws ArgumentError WindFarmParameters( + "wf1", 52.0, 5.0, 100.0; + shape = 0.0, + ) + @test_throws ArgumentError WindFarmParameters( + "wf1", 52.0, 5.0, 100.0; + shape = -1.0, + ) + + @test_throws ArgumentError WindFarmParameters( + "wf1", 52.0, 5.0, 100.0; + turbine_power_curve = "invalid", + ) + @test_throws ArgumentError WindFarmParameters( + "wf1", 52.0, 5.0, 100.0; + turbine_power_curve = DataFrame(invalid = [0.0, 3.0]), + ) + @test_throws ArgumentError WindFarmParameters( + "wf1", 52.0, 5.0, 100.0; + turbine_power_curve = DataFrame( + wind_speed = [0.0, 3.0], + power_curve = [0.0, -0.5], + ), + ) + @test_throws ArgumentError WindFarmParameters( + "wf1", 52.0, 5.0, 100.0; + turbine_power_curve = DataFrame( + wind_speed = [0.0, -3.0], + power_curve = [0.0, 0.5], + ), + ) + + # invalid sigma + @test_throws ArgumentError WindFarmParameters( + "wf1", 52.0, 5.0, 100.0; + sigma = -0.1, + ) + + # invalid wakeloss + @test_throws ArgumentError WindFarmParameters( + "wf1", 52.0, 5.0, 100.0; + wakeloss = -0.1, + ) + @test_throws ArgumentError WindFarmParameters( + "wf1", 52.0, 5.0, 100.0; + wakeloss = 1.1, + ) end end @testset "WindPower" begin - case, modeltype = simple_graph_wind() - wind = get_node(case, "Windfarm") # The WindPower node - - # Run the model - m = EMB.run_model(case, modeltype, OPTIMIZER) - - # Extraction of the time structure - 𝒯 = get_time_struct(case) - - # Run of the general tests - general_tests(m) - - # Test that cap_use is correctly with respect to the profile. - # - EMB.constraints_capacity(m, n::NonDisRES, 𝒯::TimeStructure, modeltype::EnergyModel) - # - 28 as we have 28 operational periods per strategic period and a single strategic - # period with curtailment - @test sum(value.(m[:curtailment][wind, t]) > 0.0 for t ∈ 𝒯) == 28 - @test sum( - value.(m[:cap_use][wind, t]) + value.(m[:curtailment][wind, t]) ≈ - EMRP.profile(wind, t) * value.(m[:cap_inst][wind, t]) for t ∈ 𝒯, atol ∈ TEST_ATOL - ) == length(𝒯) + @testset "Utilities" begin + # Create the general data for the activation cost node + 𝒯 = TwoLevel(2, 1, SimpleTimes(4, 1)) + power = ResourceCarrier("Power", 0.0) + wind_node = WindPower( + "Windfarm", + FixedProfile(50), + OperationalProfile([1, 2, 3, 5]), + FixedProfile(1), + FixedProfile(2), + Dict(power => 2), + ) + + # Test the EMB utility functions + @test capacity(wind_node) == FixedProfile(50) + @test opex_var(wind_node) == FixedProfile(1) + @test opex_fixed(wind_node) == FixedProfile(2) + @test all( + EMRP.profile(wind_node, t) == OperationalProfile([1, 2, 3, 5])[t] for t ∈ 𝒯 + ) + @test all( + EMRP.profile(wind_node)[t] == OperationalProfile([1, 2, 3, 5])[t] for t ∈ 𝒯 + ) + @test outputs(wind_node) == [power] + @test node_data(wind_node) == ExtensionData[] + end + + @testset "Mathematical formulation" begin + for _ ∈ 1:2 # Run the test two times to also test running from stored files (from first run) + case, modeltype = simple_graph_wind() + wind = get_node(case, "Windfarm") # The WindPower node + + # Run the model + m = EMB.run_model(case, modeltype, OPTIMIZER) + + # Extraction of the time structure + 𝒯 = get_time_struct(case) + + # Run of the general tests + general_tests(m) + + ref_values = OperationalProfile( + [ + 0.05392155871086372 + 0.06593995234130348 + 0.10709543333338498 # <- leading to curtailment + 0.05602965048633869 + 0.04120640299099798 + 0.06871412996829987 + 0.0567443917628802 + 0.010421408219884402 + 0.0024146808530687916 + 0.01026380514282864 + 0.02149572164305347 + 0.02496252052948427 + 0.0289288257505998 + 0.025383268189000156 + 0.024929140168785634 + 0.026437806889332234 + 0.061005199466452116 + 0.045339748171755824 + 0.050099956532362384 + 0.06672067031250274 + 0.06878357668493178 + 0.050558812511585796 + 0.020751829278872737 + 0.008541570639539572 + ], + ) + curtailment = 100 * 0.10709543333338498 - 8 # wind.cap * profile - 8 * sink.cap + + @test all( + isapprox(EMRP.profile(wind, t), ref_values[t]; atol = TEST_ATOL) for + t ∈ 𝒯 + ) + + # Test that cap_use is correctly with respect to the profile. + # - EMB.constraints_capacity(m, n::NonDisRES, 𝒯::TimeStructure, modeltype::EnergyModel) + # - 4, as we have one operational period per strategic period with curtailment + @test sum(value.(m[:curtailment][wind, t]) > 0.0 for t ∈ 𝒯) == 4 + @test isapprox( + sum(value.(m[:curtailment][wind, t]) for t ∈ 𝒯), + 4 * curtailment; + atol = TEST_ATOL, + ) + @test sum( + isapprox( + value.(m[:cap_use][wind, t]) + value.(m[:curtailment][wind, t]), + EMRP.profile(wind, t) * value.(m[:cap_inst][wind, t]); + atol = TEST_ATOL, + ) for t ∈ 𝒯 + ) == length(𝒯) + end + + max_wind_speed = 25.0 + wind_speed = collect(range(0, max_wind_speed; length = 2)) # m/s + power_fun(u) = (u / max_wind_speed) .^ 3 + power_curve = power_fun.(wind_speed) + df = DataFrame(wind_speed = wind_speed, power_curve = power_curve) + case, modeltype = + simple_graph_wind(; turbine_power_curve = df, sigma = 0.0, wakeloss = 0.0) + wind = get_node(case, "Windfarm") # The WindPower node + + # Run the model + m = EMB.run_model(case, modeltype, OPTIMIZER) + + # Extraction of the time structure + 𝒯 = get_time_struct(case) + + # Run of the general tests + general_tests(m) + + wind_speed_100m = [ + 4.56 + 4.94 + 5.79 + 4.81 + 4.21 + 4.88 + 4.44 + 2.7 + 1.74 + 3.09 + 3.76 + 3.93 + 4.09 + 3.96 + 3.92 + 4.02 + 5.04 + 4.64 + 4.62 + 4.9 + 5.09 + 5.0 + 3.98 + 3.19 + ] + wind_speed_250m = [ + 5.29 + 5.43 + 6.08 + 5.06 + 4.98 + 5.67 + 5.64 + 4.14 + 3.29 + 3.47 + 3.98 + 4.08 + 4.24 + 4.08 + 4.09 + 4.1 + 5.03 + 4.68 + 4.99 + 5.53 + 5.38 + 4.54 + 3.64 + 3.01 + ] + + function interp1d(x) + if x <= wind_speed[1] || x >= wind_speed[end] + return 0.0 + end + i = searchsortedlast(wind_speed, x) + x1, x2 = wind_speed[i], wind_speed[i+1] + y1, y2 = power_curve[i], power_curve[i+1] + return y1 + (y2 - y1) * (x - x1) / (x2 - x1) + end + + # Get nora3 heights from height = 150 + turbine_height = 150 + z = turbine_height + z1, z2 = 100, 250 + + u1 = wind_speed_100m + u2 = wind_speed_250m + alpha = log.(u2 ./ u1) ./ log.(z2 ./ z1) + windspeed_at_hub = u1 .* (z / z1) .^ alpha + ref_values = interp1d.(windspeed_at_hub) + ref_values_profile = OperationalProfile(ref_values) + + @test all( + isapprox(EMRP.profile(wind, t), ref_values_profile[t]; atol = TEST_ATOL) for + t ∈ 𝒯 + ) + end end diff --git a/test/utils.jl b/test/utils.jl index 4593289..cda706b 100644 --- a/test/utils.jl +++ b/test/utils.jl @@ -40,7 +40,7 @@ function small_graph(; source = nothing, sink = nothing, ops = SimpleTimes(24, 2 if isnothing(sink) sink = RefSink( 3, - FixedProfile(20), + FixedProfile(8), Dict(:surplus => FixedProfile(1e3), :deficit => FixedProfile(1e6)), Dict(Power => 1), ) @@ -70,17 +70,29 @@ function simple_graph_wind(; opex_fixed = FixedProfile(50e3), output = Dict(Power => 1.0), profile = nothing, + orientation = nothing, + shape = nothing, + method::String = "Ninja", + source::String = "NORA3", + turbine_power_curve::Union{DataFrame,String,Nothing} = nothing, + sigma::Union{Real,Nothing} = nothing, + wakeloss::Union{Real,Nothing} = nothing, ) # Creation of the initial problem with the NonDisRES node time_start = DateTime("2022-05-01T00:00:00") - time_end = DateTime("2022-05-03T23:00:00") + time_end = DateTime("2022-05-01T23:00:00") wind_params = WindFarmParameters( "windfarm_1", 55, 9, 150; - orientation = missing, - shape = missing, + orientation, + shape, + method, + source, + turbine_power_curve, + sigma, + wakeloss, ) data_path = mkpath(joinpath(testdir, "data", "WindPower")) if isnothing(profile) @@ -105,7 +117,7 @@ function simple_graph_wind(; output, ) end - return small_graph(source = wind, ops = SimpleTimes(72, 1)) + return small_graph(source = wind, ops = SimpleTimes(24, 1)) end function simple_graph_buildings(; cap_p = nothing,