Source code for oowl._reference

"""Curated, source-backed European offshore reference farms."""

from __future__ import annotations

import copy
import hashlib
import json
import os
import tempfile
import time
import unicodedata
import urllib.request
from dataclasses import dataclass, replace
from functools import lru_cache
from pathlib import Path
from typing import Literal

import numpy as np
import shapely

from ._cluster import Cluster
from ._validation import _validate_integer
from .boundaries.base import _GeometryBoundary
from .layouts.base import Farm
from .resources import GWA
from .turbines import Tabulated, Turbine

__all__ = ["ReferenceFarmInfo", "ReferenceCatalog", "reference_farm"]

_CATALOG_PATH = Path(__file__).parent / "data" / "reference_farms.npz"
_CITATIONS = (
    "Fischereit, Vollmer & Hansen, Open European offshore wind turbine "
    "database, doi:10.5281/zenodo.19879819",
    "OpenStreetMap contributors, current and historical turbine nodes, ODbL 1.0",
    "EMODnet Human Activities wind-farm polygons, CC BY 4.0",
)

_GWA_DOWNLOAD_TIMEOUT = 30.0
_GWA_MAX_PAYLOAD_BYTES = 1024 * 1024


[docs] @dataclass(frozen=True) class ReferenceFarmInfo: """Searchable metadata for one packaged farm or named farm group. Coordinates of the centroid are WGS 84 degrees. ``boundary_kind`` is ``"area"``, ``"linear"`` for a single row of turbines, or ``"cluster"`` for a group. ``boundary_containment`` is the share of turbines inside the packaged boundary, ``boundary_containment_50m`` the share within 50 m of it and ``boundary_max_outside_distance_m`` the farthest turbine outside it, in meters. ``quality_flags`` name known caveats such as positions outside the boundary. ``fit_eligible`` marks an area farm of at least five distinct positions with 95 percent of them within 50 m of the boundary, and ``shape_fit_eligible`` an area farm whose boundary is one valid EMODnet polygon. A group reports its members' weakest containment and is eligible for neither fit. """ name: str aliases: tuple[str, ...] country: str commissioning_year: int | None latitude: float longitude: float n_turbines: int installation_type: str boundary_source: str boundary_kind: str boundary_containment: float boundary_containment_50m: float boundary_max_outside_distance_m: float boundary_source_names: tuple[str, ...] boundary_source_feature_ids: tuple[str, ...] boundary_adjustment: dict[str, object] boundary_vertex_count_raw: int quality_flags: tuple[str, ...] fit_eligible: bool shape_fit_eligible: bool source_dates: dict[str, str] is_group: bool = False members: tuple[str, ...] = () catalog_date: str = "" citations: tuple[str, ...] = _CITATIONS
@lru_cache(maxsize=1) def _catalog() -> dict[str, np.ndarray]: if not _CATALOG_PATH.exists(): raise RuntimeError( "OOWL's reference-farm catalog is missing; reinstall OOWL with " "its package data" ) with np.load(_CATALOG_PATH, allow_pickle=False) as data: arrays = {key: data[key] for key in data.files} if int(arrays.get("schema_version", 0)) != 5: raise RuntimeError("unsupported reference-farm catalog schema") return arrays def _json_tuple(value) -> tuple[str, ...]: return tuple(str(item) for item in json.loads(str(value))) def _year(value) -> int | None: """Commissioning year, stored as zero or less when unknown.""" year = int(value) return None if year <= 0 else year def _member_indices(data, members: tuple[str, ...]) -> list[int]: farm_names = list(map(str, data["farm_name"])) return [farm_names.index(name) for name in members] def _atomic_info(index: int) -> ReferenceFarmInfo: data = _catalog() return ReferenceFarmInfo( name=str(data["farm_name"][index]), aliases=_json_tuple(data["aliases_json"][index]), country=str(data["country"][index]), commissioning_year=_year(data["commissioning_year"][index]), latitude=float(data["centroid_latlon"][index, 0]), longitude=float(data["centroid_latlon"][index, 1]), n_turbines=int(data["n_turbines"][index]), installation_type=str(data["installation_type"][index]), boundary_source=str(data["boundary_source"][index]), boundary_kind=str(data["boundary_kind"][index]), boundary_containment=float(data["boundary_containment"][index]), boundary_containment_50m=float(data["boundary_containment_50m"][index]), boundary_max_outside_distance_m=float( data["boundary_max_outside_distance_m"][index] ), boundary_source_names=_json_tuple(data["boundary_source_names_json"][index]), boundary_source_feature_ids=_json_tuple( data["boundary_source_feature_ids_json"][index] ), boundary_adjustment=json.loads(str(data["boundary_adjustment_json"][index])), boundary_vertex_count_raw=int(data["boundary_vertex_count_raw"][index]), quality_flags=_json_tuple(data["quality_flags_json"][index]), fit_eligible=bool(data["fit_eligible"][index]), shape_fit_eligible=bool(data["shape_fit_eligible"][index]), source_dates={ "catalog" if key == "catalogue" else str(key): str(value) for key, value in json.loads(str(data["source_dates_json"][index])).items() }, catalog_date=str(data["catalogue_date"]), ) def _group_info(index: int) -> ReferenceFarmInfo: """Group metadata, aggregated from its member farms.""" data = _catalog() members = _json_tuple(data["group_members_json"][index]) member_indices = _member_indices(data, members) def union(key: str) -> tuple[str, ...]: """Members' values for ``key`` in first-seen order, without repeats.""" return tuple( dict.fromkeys( value for member in member_indices for value in _json_tuple(data[key][member]) ) ) member_source_dates = [ json.loads(str(data["source_dates_json"][member])) for member in member_indices ] emodnet_years = sorted( { year for source_dates in member_source_dates for year in source_dates["emodnet_update_years"].split(", ") if year } ) osm_dates = [ source_dates["osm_latest_selected_object"] for source_dates in member_source_dates if source_dates["osm_latest_selected_object"] ] return ReferenceFarmInfo( name=str(data["group_name"][index]), aliases=_json_tuple(data["group_aliases_json"][index]), country=str(data["group_country"][index]), commissioning_year=_year(data["group_commissioning_year"][index]), latitude=float(data["group_centroid_latlon"][index, 0]), longitude=float(data["group_centroid_latlon"][index, 1]), n_turbines=int(data["group_n_turbines"][index]), installation_type="Grounded", boundary_source="EMODnet", boundary_kind="cluster", boundary_containment=float( np.min(data["boundary_containment"][member_indices]) ), boundary_containment_50m=float( np.min(data["boundary_containment_50m"][member_indices]) ), boundary_max_outside_distance_m=float( np.max(data["boundary_max_outside_distance_m"][member_indices]) ), boundary_source_names=union("boundary_source_names_json"), boundary_source_feature_ids=union("boundary_source_feature_ids_json"), boundary_adjustment={"kind": "none", "source_geometry_preserved": True}, boundary_vertex_count_raw=int( np.sum(data["boundary_vertex_count_raw"][member_indices]) ), quality_flags=("exact_member_boundaries",), fit_eligible=False, shape_fit_eligible=False, source_dates={ "catalog": str(data["catalogue_date"]), "eww_cohort_cutoff": "2026-01-27", "emodnet_update_years": ", ".join(emodnet_years), "osm_latest_selected_object": max(osm_dates, default=""), }, is_group=True, members=members, catalog_date=str(data["catalogue_date"]), ) def _normalize_name(value: str) -> str: """Casefolded letters and digits only, with accents removed.""" text = unicodedata.normalize("NFKD", str(value)) text = "".join(char for char in text if not unicodedata.combining(char)) return "".join(char.casefold() for char in text if char.isalnum()) @lru_cache(maxsize=1) def _name_index() -> tuple[tuple[str, int, str, frozenset[str]], ...]: """``(kind, index, canonical, normalized names)`` for every farm and group. Cached once since the packaged catalog never changes. """ data = _catalog() return tuple( ( kind, index, str(canonical), frozenset( _normalize_name(candidate) for candidate in (str(canonical), *_json_tuple(alias_json)) ), ) for kind, names, aliases in ( ("farm", data["farm_name"], data["aliases_json"]), ("group", data["group_name"], data["group_aliases_json"]), ) for index, (canonical, alias_json) in enumerate(zip(names, aliases)) ) @lru_cache(maxsize=2) def _catalog_infos(include_groups: bool) -> tuple[ReferenceFarmInfo, ...]: """Metadata of every farm, then every group if ``include_groups``. Cached since the packaged catalog never changes. Callers copy the dictionary fields before handing an entry out. """ data = _catalog() infos = tuple(_atomic_info(index) for index in range(len(data["farm_name"]))) if include_groups: infos += tuple(_group_info(index) for index in range(len(data["group_name"]))) return infos def _resolve_name(name: str) -> tuple[str, int]: """Return ``("farm" | "group", index)`` for an exact name or alias.""" query = _normalize_name(name) if not query: raise ValueError("farm name must not be empty") exact = [] partial = [] for kind, index, canonical, normalized in _name_index(): if query in normalized: exact.append((kind, index, canonical)) elif any(query in item or item in query for item in normalized): partial.append(canonical) if len(exact) == 1: kind, index, _canonical = exact[0] return kind, index if len(exact) > 1: matches = sorted(canonical for _kind, _index, canonical in exact) raise ValueError( f"ambiguous reference farm {name!r}; matches: {', '.join(matches)}" ) matches = sorted(set(partial)) if not matches: raise KeyError(f"unknown reference farm {name!r}") raise ValueError( f"reference farm {name!r} is not an exact name or alias; " f"possible matches: {', '.join(matches)}" ) def _is_local(coordinate_frame: str) -> bool: if coordinate_frame == "local": return True if coordinate_frame.lower() in {"epsg:3035", "3035"}: return False raise ValueError("coordinate_frame must be 'local' or 'epsg:3035'") def _slice(offsets, index): return slice(int(offsets[index]), int(offsets[index + 1])) def _wkb_geometry(data, prefix: str, index: int): """Boundary ``index`` of the packed WKB arrays ``prefix``, or ``None``.""" segment = _slice(data[f"{prefix}_wkb_offsets"], index) payload = data[f"{prefix}_wkb_data"][segment].tobytes() if not payload: return None geometry = shapely.from_wkb(payload) if geometry is None or geometry.is_empty: raise ValueError("reference boundary is empty") return geometry def _turbine_types(data, type_indices: np.ndarray): """One shared turbine, or one per position when the types differ.""" types = {} for key in sorted(set(type_indices.tolist())): curve = _slice(data["curve_offsets"], key) types[key] = Tabulated( rotor_diameter=float(data["type_rotor_diameter"][key]), hub_height=float(data["type_hub_height"][key]), wind_speeds=data["curve_wind_speed"][curve], power_values=data["curve_power_w"][curve], ct_values=data["curve_ct"][curve], name=str(data["type_name"][key]), rated_power=float(data["type_rated_power_w"][key]), ) assigned = tuple(types[key] for key in type_indices.tolist()) if assigned and all(item is assigned[0] for item in assigned): return assigned[0] return assigned def _optional_int(value) -> int | None: """OpenStreetMap identifiers, stored as negative when absent.""" value = int(value) return None if value < 0 else value def _position_records(data, indices) -> list[dict]: return [ { "canonical_farm": str(data["position_canonical_farm"][index]), "longitude": float(data["positions_wgs84"][index, 0]), "latitude": float(data["positions_wgs84"][index, 1]), "position_source": str(data["position_source"][index]), "position_source_id": str(data["position_source_id"][index]), "osm_id": _optional_int(data["position_osm_id"][index]), "osm_version": _optional_int(data["position_osm_version"][index]), "osm_timestamp": str(data["position_osm_timestamp"][index]), "osm_valid_to": str(data["position_osm_valid_to"][index]), "reference": str(data["position_reference"][index]), "lifecycle_status": str(data["position_lifecycle_status"][index]), "status_source": str(data["position_status_source"][index]), "status_date": str(data["position_status_date"][index]), "status_evidence": str(data["position_status_evidence"][index]), } for index in map(int, indices) ] def _cache_path( info: ReferenceFarmInfo, roughness_length: float, reference_height: float ) -> Path: key = ( f"{info.latitude:.6f}|{info.longitude:.6f}|" f"{float(roughness_length):.8g}|{float(reference_height):.4f}|gwa-v4" ) digest = hashlib.sha256(key.encode()).hexdigest()[:20] # The XDG spec treats an empty value as unset. root = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") return root / "oowl" / "gwa" / f"{digest}.lib" def _cached_gwa( path: Path, info: ReferenceFarmInfo, roughness_length: float, reference_height: float, ) -> GWA: return GWA( info.latitude, info.longitude, roughness_length, reference_height, libfile_path=path, ) def _download_gwa( cache: Path, info: ReferenceFarmInfo, roughness_length: float, reference_height: float, ) -> GWA: """Download the libfile, validate it, then publish it as ``cache``. The download goes to a private temporary file. Linking it into place never replaces an entry that a concurrent download published first. """ url = f"{GWA.GWA_URL}?lat={info.latitude}&long={info.longitude}&areaname=oowl" request = urllib.request.Request(url, headers=GWA.REQUEST_HEADERS) cache.parent.mkdir(parents=True, exist_ok=True) temporary = tempfile.NamedTemporaryFile( mode="wb", dir=cache.parent, prefix=f".{cache.name}.", suffix=".tmp", delete=False, ) temporary_path = Path(temporary.name) try: deadline = time.monotonic() + _GWA_DOWNLOAD_TIMEOUT with ( temporary, urllib.request.urlopen( request, context=GWA._ssl_context(), timeout=_GWA_DOWNLOAD_TIMEOUT ) as response, ): content_length = response.headers.get("Content-Length") if ( content_length is not None and int(content_length) > _GWA_MAX_PAYLOAD_BYTES ): raise RuntimeError("Global Wind Atlas response is too large") downloaded = 0 while chunk := response.read(64 * 1024): downloaded += len(chunk) if downloaded > _GWA_MAX_PAYLOAD_BYTES: raise RuntimeError("Global Wind Atlas response is too large") if time.monotonic() > deadline: raise TimeoutError("Global Wind Atlas download timed out") temporary.write(chunk) resource = _cached_gwa(temporary_path, info, roughness_length, reference_height) try: os.link(temporary_path, cache) except FileExistsError: return _cached_gwa(cache, info, roughness_length, reference_height) resource.libfile_path = cache return resource finally: temporary_path.unlink(missing_ok=True) def _gwa_resource( info: ReferenceFarmInfo, roughness_length: float, reference_height: float ) -> GWA: """Global Wind Atlas resource at the farm centroid, cached on disk. An unreadable cache entry is removed and downloaded again. """ cache = _cache_path(info, roughness_length, reference_height) try: return _cached_gwa(cache, info, roughness_length, reference_height) except Exception: cache.unlink(missing_ok=True) return _download_gwa(cache, info, roughness_length, reference_height) def _reference_metadata(info, coordinate_frame, crs, indices): return { "kind": "reference_farm", "name": info.name, "layout_basis": "all_commissioned_positions", "country": info.country, "commissioning_year": info.commissioning_year, "catalog_date": info.catalog_date, "centroid_latitude": info.latitude, "centroid_longitude": info.longitude, "coordinate_frame": coordinate_frame, "crs": crs, "installation_type": info.installation_type, "boundary_source": info.boundary_source, "boundary_source_names": list(info.boundary_source_names), "boundary_source_feature_ids": list(info.boundary_source_feature_ids), "boundary_adjustment": dict(info.boundary_adjustment), "boundary_kind": info.boundary_kind, "boundary_containment": info.boundary_containment, "boundary_containment_50m": info.boundary_containment_50m, "boundary_max_outside_distance_m": info.boundary_max_outside_distance_m, "quality_flags": list(info.quality_flags), "source_dates": dict(info.source_dates), "turbine_records": _position_records(_catalog(), indices), "citations": list(info.citations), "modifiers": [], } def _reference_farm( info, positions, geometry, crs, indices, coordinate_frame, resource, roughness_length, reference_height, ): """Build one farm and attach the requested resource.""" data = _catalog() turbines = _turbine_types(data, data["position_type_index"][indices].astype(int)) boundary = None if geometry is None else _GeometryBoundary(geometry) farm = Farm( positions, boundary, metadata=_reference_metadata(info, coordinate_frame, crs, indices), turbines=turbines, ) if farm.n_turbines != info.n_turbines: raise RuntimeError("reference catalog count does not match loaded positions") if resource is None: return farm if reference_height is None: assigned = (turbines,) if isinstance(turbines, Turbine) else turbines reference_height = np.median([item.hub_height for item in assigned]) farm.resource = _gwa_resource( info, float(roughness_length), float(reference_height) ) return farm def _load_atomic( index, coordinate_frame, resource, roughness_length, reference_height ) -> Farm: data = _catalog() indices = np.arange( int(data["position_offsets"][index]), int(data["position_offsets"][index + 1]), dtype=np.int64, ) if _is_local(coordinate_frame): positions = data["positions_local"][indices].astype(float, copy=True) geometry = _wkb_geometry(data, "boundary_local", index) crs = str(data["local_crs"][index]) else: positions = data["positions_epsg3035"][indices].astype(float, copy=True) geometry = _wkb_geometry(data, "boundary_epsg3035", index) crs = "EPSG:3035" return _reference_farm( _atomic_info(index), positions, geometry, crs, indices, coordinate_frame, resource, roughness_length, reference_height, ) def _load_group( index, coordinate_frame, resource, roughness_length, reference_height ) -> Cluster: data = _catalog() group = _group_info(index) group_slice = _slice(data["group_position_offsets"], index) position_indices = data["group_position_indices"][group_slice].astype(np.int64) # Local member boundaries are packed group after group. first_member = sum( len(_json_tuple(value)) for value in data["group_members_json"][:index] ) # Members share the group's local frame, so their positions stay comparable. local = _is_local(coordinate_frame) if local: all_positions = data["group_positions_local"][group_slice].astype( float, copy=True ) crs = str(data["group_local_crs"][index]) else: all_positions = data["positions_epsg3035"][position_indices].astype( float, copy=True ) crs = "EPSG:3035" farms = [] start = 0 for member, farm_index in enumerate(_member_indices(data, group.members)): info = _atomic_info(farm_index) stop = start + info.n_turbines if local: geometry = _wkb_geometry( data, "group_member_boundary_local", first_member + member ) else: geometry = _wkb_geometry(data, "boundary_epsg3035", farm_index) farms.append( _reference_farm( info, all_positions[start:stop], geometry, crs, position_indices[start:stop], coordinate_frame, resource, roughness_length, reference_height, ) ) start = stop cluster = Cluster( tuple(farms), metadata={ "kind": "reference_cluster", "name": group.name, "members": list(group.members), "country": group.country, "commissioning_year": group.commissioning_year, "catalog_date": group.catalog_date, "centroid_latitude": group.latitude, "centroid_longitude": group.longitude, "coordinate_frame": coordinate_frame, "crs": crs, "citations": list(group.citations), }, ) if cluster.n_turbines != group.n_turbines: raise RuntimeError("reference catalog count does not match loaded positions") return cluster
[docs] class ReferenceCatalog: """Callable access to packaged offshore farms and catalog metadata."""
[docs] def __call__( self, name: str, *, coordinate_frame: str = "local", resource: Literal["gwa"] | None = None, roughness_length: float = 0.0002, reference_height: float | None = None, ) -> Farm | Cluster: """Load one real farm, or a group as a :class:`~oowl.Cluster` of its members. Every commissioned, source-backed turbine position is included. Parameters ---------- name Farm or group name or alias, ignoring case, accents, spaces and punctuation. coordinate_frame ``"local"`` for the farm's own projection in meters, or ``"epsg:3035"`` for the ETRS89 Lambert equal-area grid of Europe, also in meters. resource ``None`` loads offline. ``"gwa"`` downloads the Global Wind Atlas climate at each farm centroid and caches it under ``$XDG_CACHE_HOME/oowl/gwa``. roughness_length Surface roughness in meters for the Global Wind Atlas climate. The default is the WAsP value for water. reference_height Height in meters of the Global Wind Atlas climate. Defaults to the median hub height of the farm. Raises ------ KeyError If ``name`` matches nothing. TypeError If ``name``, ``coordinate_frame`` or a non-``None`` ``resource`` is not a string. ValueError If ``name`` is ambiguous or only partly matches, or an option is invalid. """ if not isinstance(name, str): raise TypeError("name must be a string") if not isinstance(coordinate_frame, str): raise TypeError("coordinate_frame must be a string") if resource is not None and not isinstance(resource, str): raise TypeError("resource must be None or a string") if resource not in (None, "gwa"): raise ValueError("resource must be None or 'gwa'") kind, index = _resolve_name(name) load = _load_group if kind == "group" else _load_atomic return load( index, coordinate_frame, resource, roughness_length, reference_height )
[docs] def list( self, country: str | None = None, commissioning_year_range: tuple[int, int] | None = None, min_turbines: int | None = None, *, include_groups: bool = False, ) -> tuple[ReferenceFarmInfo, ...]: """List packaged farms without loading their geometry. ``country`` is matched ignoring case, ``commissioning_year_range`` is an inclusive ``(first, last)`` pair that excludes farms of unknown year, and ``min_turbines`` is a positive lower bound. Named groups are left out unless ``include_groups`` is true, so no farm is listed twice. Raises ------ TypeError If ``country`` is not a string, ``commissioning_year_range`` is not a sequence or has an end that is not a number, or ``min_turbines`` is not a number. ValueError If ``commissioning_year_range`` does not have two ends, has an end that is not a whole number or is not ordered, or ``min_turbines`` is not a positive whole number. """ if country is not None and not isinstance(country, str): raise TypeError("country must be a string or None") if commissioning_year_range is not None: if isinstance(commissioning_year_range, (str, bytes)) or not hasattr( commissioning_year_range, "__len__" ): raise TypeError( "commissioning_year_range must be a (first, last) pair of years" ) if len(commissioning_year_range) != 2: raise ValueError( "commissioning_year_range must be a (first, last) pair of years" ) lo, hi = ( _validate_integer("commissioning_year_range", year, minimum=-(2**63)) for year in commissioning_year_range ) if lo > hi: raise ValueError("commissioning_year_range must be ordered") else: lo = hi = 0 if min_turbines is not None: min_turbines = _validate_integer("min_turbines", min_turbines) results = _catalog_infos(include_groups) return tuple( replace( item, boundary_adjustment=copy.deepcopy(item.boundary_adjustment), source_dates=dict(item.source_dates), ) for item in results if (country is None or item.country.casefold() == country.casefold()) and (min_turbines is None or item.n_turbines >= int(min_turbines)) and ( commissioning_year_range is None or ( item.commissioning_year is not None and lo <= item.commissioning_year <= hi ) ) )
reference_farm = ReferenceCatalog()