def calc_dist_in_direction_cluster(turbines, prevail_wind_direction, bin_size_deg=15): """Same as calc_dist_in_direction(), but intended for one cluster only. Calculates a squared distance matrix (and a squared direction matrix) and therefore RAM usage is O(len(turbines)^2). Parameters ---------- turbines : xr.DataSet as returned by load_turbines() prevail_wind_direction : xr.DataArray (dim = turbines) will be used to orientate distances relative to prevailing wind direction, pass an xr.DataArray with zeros to get distances per absolute directions (not relative to prevailing wind direction) bin_size_deg : float size of direction bins in degrees Returns ------- xr.DataArray dims: turbines, direction direction is relative to prevail_wind_direction, i.e. 0° = in prevailing wind direction, and otherwise counter-clockwise relative to 0° """ directions = calc_directions(turbines, prevail_wind_direction) # directions is actually not used here, because bins and range are provided (except for dtype) bin_edges = np.histogram_bin_edges(directions, bins=360 // bin_size_deg, range=(-np.pi, np.pi)) num_bins = len(bin_edges) - 1 # Attention, fencepost problem! # np.digitize does not return the n-th bin, but the n+1-th bin! # This is not a symmetric matrix, directions get flipped by 180° if dims is provided in wrong # order, but it is not at all clear how xarray defines the order (probably the order of # usage of dims 'targets' and 'turbines' in the arctan2() call above). bin_idcs = np.digitize(directions, bin_edges) - 1 bin_idcs = xr.DataArray( bin_idcs, dims=('targets', 'turbines'), # targets = closest turbines coords={'turbines': turbines.turbines}) locations = turbine_locations(turbines) distances = geolocation_distances(locations) # set distance to itself to INF to avoid zero distance minimums later distances[np.diag_indices_from(distances)] = np.inf distances = xr.DataArray(distances, dims=('turbines', 'targets'), coords={'turbines': turbines.turbines}) bin_centers = edges_to_center(bin_edges) direction_bins = xr.DataArray(np.arange(num_bins), dims='direction', coords={'direction': bin_centers}) return xr.where(bin_idcs == direction_bins, distances, np.inf).min(dim='targets')
def plot_locations(turbines=None, idcs=None, directions=None, colors=None): """Plot turbine locations and add arrows to indicate wind directions. FIXME does not use proper projection, probably valid only for small regions. Parameters ---------- turbines : xr.DataSet as returned by load_turbines() idcs : array_like of type boolean select turbines to plot directions : dict of form label: array_like array contains directions in rad colors : iterable color of arrows for each item in directions """ if turbines is None: turbines = load_turbines() if idcs is None: idcs = np.ones_like(turbines.xlong).astype(np.bool) if directions is None: directions = {} if colors is None: colors = [None] * len(directions) fig, ax = plt.subplots(1, 1, figsize=FIGSIZE) locations = turbine_locations(turbines.sel(turbines=idcs)) ax.plot(locations.T[1], locations.T[0], 'o', label='Wind turbine location') for (label, values), color in zip(directions.items(), colors): ax.quiver( locations.T[1], locations.T[0], np.cos(values.sel(turbines=idcs)), np.sin(values.sel(turbines=idcs)), width=0.002, label=label, color=color, ) ax.set_aspect('equal') plt.xlabel('Longitude [deg]') plt.ylabel('Latitude [deg]') plt.legend() return fig, ax
def calc_location_clusters(turbines, min_distance_km=0.5): """Calculate a partitioning of locations given in lang/long into clusters using the DBSCAN algorithm. Runtime: about 10-15 seconds for all turbines. Parameters ---------- turbines : xr.DataSet as returned by load_turbines() min_distance_km : float Returns ------- cluster_per_location : xr.DataArray (dims: turbines) for each location location the cluster index, -1 for outliers, see ``sklearn.cluster.DBSCAN`` clusters : np.ndarray of shape (M,) M is the number of clusters cluster_sizes : np.ndarray of shape (M,) the size for each cluster References ---------- https://geoffboeing.com/2014/08/clustering-to-reduce-spatial-data-set-size """ locations = turbine_locations(turbines) # Parameters for haversine formula kms_per_radian = EARTH_RADIUS_KM epsilon = min_distance_km / kms_per_radian clustering = DBSCAN(eps=epsilon, min_samples=2, algorithm='ball_tree', metric='haversine').fit(np.radians(locations)) cluster_per_location = clustering.labels_ clusters, cluster_sizes = np.unique(cluster_per_location, return_counts=True) cluster_per_location = xr.DataArray( cluster_per_location, dims='turbines', coords={'turbines': turbines.turbines}, name='cluster_per_location') # TODO rename to cluster? return cluster_per_location, clusters, cluster_sizes
import sys import logging from wind_repower_usa.config import INTERIM_DIR, COMPUTE_CONSTANT_DISTANCE_FACTORS from wind_repower_usa.geographic_coordinates import calc_min_distances from wind_repower_usa.load_data import load_turbines from wind_repower_usa.logging_config import setup_logging from wind_repower_usa.util import turbine_locations setup_logging() if not COMPUTE_CONSTANT_DISTANCE_FACTORS: logging.warning("Skipping because constant distance factors are disabled!") sys.exit() turbines = load_turbines() locations = turbine_locations(turbines) min_distances = calc_min_distances(locations) min_distances.to_netcdf(INTERIM_DIR / 'min_distances' / 'min_distances.nc')
def plot_optimized_cluster(turbines, cluster_per_location, is_optimal_location, turbine, distance_factors, prevail_wind_direction, step=3): plot_optimal_locations = plot_wind_directions = plot_thresholds = False plot_non_optimal_locations = True if step > 0: plot_wind_directions = True if step > 1: plot_thresholds = True if step > 2: plot_optimal_locations = True if step > 3: plot_non_optimal_locations = False plot_wind_directions = False fig, ax = plt.subplots(1, 1, figsize=FIGSIZE) # some arbitrary cluster with 70-100 turbines or so # probably: cluster=812 (but depends on clustering, therefore pinning via long/lat) x, y = -99.0, 45.92 # some point in cluster some_turbine_idx = (((turbines.xlong - x)**2 + (turbines.ylat - y)**2)**0.5).argmin() cluster = cluster_per_location[some_turbine_idx].values loc = 'upper left' locations = turbine_locations(turbines) idcs = cluster_per_location == cluster is_optimal_location = is_optimal_location.sum(dim='turbine_model') is_optimal_location = is_optimal_location.astype(np.bool) locations_old_ylat, locations_old_xlon = locations[idcs].T locations_new_ylat, locations_new_xlon = locations[idcs & is_optimal_location].T if plot_non_optimal_locations: ax.plot(locations_old_xlon, locations_old_ylat, 'o', markersize=4, color='#0d8085', label='Current location of wind turbine') def radial_plot(ax, angles, radius, center, label): angles = np.append(angles, angles[0]) radius = np.append(radius, radius[0]) points_complex = center + radius * np.exp(1j * angles) alpha = None if plot_thresholds else 0. # ugly hack to avoid changing figure size if not plot_thresholds: label = None ax.plot(points_complex.real, points_complex.imag, '-', color='gray', linewidth=0.4, label=label, alpha=alpha) return ax rotor_diameter = turbine.rotor_diameter_m has_label = False for idx in turbines.turbines[idcs & is_optimal_location]: radius = distance_factors / (EARTH_RADIUS_KM * 2 * np.pi) * 360 radius = radius * METER_TO_KM * rotor_diameter center = turbines.isel( turbines=idx).xlong + turbines.isel(turbines=idx).ylat * 1j radial_plot( ax, angles=distance_factors.direction + prevail_wind_direction.sel(turbines=idx), radius=radius, center=center.values, label='Minimum distance to other turbine' if not has_label else '') has_label = True if plot_optimal_locations: ax.plot(locations_new_xlon, locations_new_ylat, 'o', markersize=7, color='#c72321', fillstyle='none', label='Optimal location for {}'.format(turbine.name)) ax.legend(loc=loc) def add_arrow(label): # not sure why quiver key is not working # https://stackoverflow.com/a/22349717/859591 from matplotlib.legend_handler import HandlerPatch import matplotlib.patches as mpatches def make_legend_arrow(legend, orig_handle, xdescent, ydescent, width, height, fontsize): p = mpatches.FancyArrow(0, 0.5 * height, width, 0, length_includes_head=True, head_width=0.5 * height) return p arrow = plt.arrow(0, 0, 1, 1, color='k') handles, labels = ax.get_legend_handles_labels() labels = labels[:1] + [label] + labels[1:] handles = handles[:1] + [arrow] + handles[1:] plt.legend(handles, labels, handler_map={ mpatches.FancyArrow: HandlerPatch(patch_func=make_legend_arrow), }, loc=loc) if plot_wind_directions: ax.quiver(locations_old_xlon, locations_old_ylat, np.cos(prevail_wind_direction.sel(turbines=idcs)), np.sin(prevail_wind_direction.sel(turbines=idcs)), width=0.0017, color='k') add_arrow('Prevailing wind direction') ax.set_aspect('equal') plt.xlabel("Longitude [deg]") plt.ylabel("Latitude [deg]") return fig
def test_turbine_locations(): turbines = load_turbines() locations = turbine_locations(turbines) assert locations.shape == (turbines.sizes['turbines'], 2)
def calc_optimal_locations_cluster(turbines, turbine_models, distance_factors, prevail_wind_direction, power_generation): """For a set of locations, this will calculate an optimal subset of locations where turbines are to be placed, such that the power generation is maximized and a distance threshold is not violated: Objective function: maximize sum(power_generation[i], for all turbines i if is optimal location) s.t.: distance(i, j) >= min_distance for i,j where i and j are optimal locations This is meant to be run on a small set of locations, e.g. a couple of hundred (or thousands). Parameters ---------- turbines : xr.DataSet as returned by load_turbines(), but intended to be a subset due to memory use O(N^2) turbine_models : list of turbine_models.Turbine used for rotor diameter distance_factors : xr.DataArray (dim: direction) distance factor per direction relative to prevailing wind direction, will be expanded prevail_wind_direction : xr.DataArray (dim: turbines) prevailing wind direction for each turbine power_generation : xr.DataArray, dims: turbine_model, turbines for each turbine (N turbines) an expected power generation, scaling does not matter, so it does not matter if it is in GW or GWh/yr or 5*GWh/yr (averaging over 5 years) Returns ------- is_optimal_location : np.array of length K,N is_optimal_location[k,i] for a location i and model k: 1 == model k should be built, 0 == model k is not optimal, sum(axis=0)[i] == 0 means nothing is to be built at location i """ locations = turbine_locations(turbines) num_locations = locations.shape[0] num_models = len(turbine_models) assert len(locations.shape) == 2 assert locations.shape[1] == 2 assert power_generation.sizes['turbines'] == num_locations assert power_generation.sizes['turbine_model'] == num_models pairwise_distances = geolocation_distances(locations) # for each location, if True a new turbine should be built, otherwise only decommission old one is_optimal_location = cp.Variable((num_models, num_locations), boolean=True) rotor_diameter_km = np.array([x.rotor_diameter_m for x in turbine_models]) * METER_TO_KM pairwise_distances[np.diag_indices_from(pairwise_distances)] = np.inf pairwise_df = _calc_pairwise_df(turbines, distance_factors, prevail_wind_direction) # for a location i, a location j with j != i and a turbine model k at least one of the # following must hold: # - k is not built at i <==> right-hand-side of inequality equals 0 or -1 # - nothing is built at j <==> right-hand-side of inequality equals 0 or -1 # - i is far enough away from j for all j != i constraints = [ pairwise_distances[i, :] / pairwise_df[i, :] / rotor_diameter_km[k] >= (is_optimal_location[k, i] + cp.atoms.affine.sum.sum(is_optimal_location, axis=0) - 1) for k in range(num_models) for i in range(num_locations) ] constraints += [cp.atoms.affine.sum.sum(is_optimal_location, axis=0) <= 1] obj = cp.Maximize( cp.atoms.affine.sum.sum( cp.multiply(is_optimal_location, power_generation.values))) problem = cp.Problem(obj, constraints) problem.solve(solver=cp.GUROBI) if problem.status != 'optimal': raise RuntimeError("Optimization problem could not be" f"solved optimally: {problem.status}") return is_optimal_location.value, problem