oowl 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oowl/__init__.py +45 -0
- oowl/_random.py +71 -0
- oowl/api.py +243 -0
- oowl/boundaries/__init__.py +36 -0
- oowl/boundaries/base.py +117 -0
- oowl/boundaries/composites.py +102 -0
- oowl/boundaries/hull.py +38 -0
- oowl/boundaries/modifiers.py +141 -0
- oowl/boundaries/primitives.py +150 -0
- oowl/boundaries/radial.py +117 -0
- oowl/layouts/__init__.py +44 -0
- oowl/layouts/base.py +293 -0
- oowl/layouts/candidate.py +43 -0
- oowl/layouts/circular.py +86 -0
- oowl/layouts/composites.py +65 -0
- oowl/layouts/contour.py +162 -0
- oowl/layouts/fitters.py +272 -0
- oowl/layouts/grid.py +88 -0
- oowl/layouts/modifiers.py +78 -0
- oowl/layouts/pattern.py +53 -0
- oowl/layouts/poisson.py +129 -0
- oowl/layouts/string.py +132 -0
- oowl/plotting.py +288 -0
- oowl/site/__init__.py +27 -0
- oowl/site/base.py +193 -0
- oowl/site/directions.py +128 -0
- oowl/site/sampling.py +153 -0
- oowl/strings/__init__.py +20 -0
- oowl/strings/base.py +95 -0
- oowl/strings/composites.py +212 -0
- oowl/strings/modifiers.py +142 -0
- oowl/strings/primitives.py +96 -0
- oowl/windio.py +166 -0
- oowl-0.0.1.dist-info/METADATA +124 -0
- oowl-0.0.1.dist-info/RECORD +38 -0
- oowl-0.0.1.dist-info/WHEEL +5 -0
- oowl-0.0.1.dist-info/licenses/LICENSE +7 -0
- oowl-0.0.1.dist-info/top_level.txt +1 -0
oowl/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""OOWL: onshore and offshore wind layout generation.
|
|
2
|
+
|
|
3
|
+
Three composable layers combined by a single entry point:
|
|
4
|
+
|
|
5
|
+
* :mod:`oowl.boundaries` -- region generators (``Circle``, ``Rectangle``,
|
|
6
|
+
``RadialPolygon``, ...), composable with modifiers (shipping channels,
|
|
7
|
+
exclusion zones).
|
|
8
|
+
* :mod:`oowl.layouts` -- turbine placement algorithms (``GridLayout``),
|
|
9
|
+
sized strictly by turbine count and fitted to a boundary by a pluggable
|
|
10
|
+
:class:`~oowl.layouts.fitters.Fitter`.
|
|
11
|
+
* :mod:`oowl.strings` -- optional ordered guide geometry consumed by
|
|
12
|
+
``StringLayout``.
|
|
13
|
+
|
|
14
|
+
Combine them with :func:`generate_farm`, and persist results with windIO via
|
|
15
|
+
:meth:`Farm.save <oowl.layouts.base.Farm.save>` / :func:`load`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from . import boundaries, layouts, plotting, site, strings
|
|
21
|
+
from .api import generate_farm
|
|
22
|
+
from .layouts import Farm
|
|
23
|
+
from .plotting import plot_boundary, plot_farm
|
|
24
|
+
from .site import SiteConditions, Weibull, WindRose, generate_site_conditions
|
|
25
|
+
from .windio import load, save
|
|
26
|
+
|
|
27
|
+
__version__ = "0.0.1"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"boundaries",
|
|
31
|
+
"layouts",
|
|
32
|
+
"strings",
|
|
33
|
+
"plotting",
|
|
34
|
+
"site",
|
|
35
|
+
"generate_farm",
|
|
36
|
+
"generate_site_conditions",
|
|
37
|
+
"Farm",
|
|
38
|
+
"SiteConditions",
|
|
39
|
+
"Weibull",
|
|
40
|
+
"WindRose",
|
|
41
|
+
"plot_boundary",
|
|
42
|
+
"plot_farm",
|
|
43
|
+
"load",
|
|
44
|
+
"save",
|
|
45
|
+
]
|
oowl/_random.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Shared random-number utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"as_rng",
|
|
9
|
+
"farthest_point_sample",
|
|
10
|
+
"resolve_seed",
|
|
11
|
+
"spawn_seeds",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_MAX_SEED = np.iinfo(np.uint64).max
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_seed(seed: int | np.random.Generator | None) -> int:
|
|
19
|
+
"""Resolve ``seed`` to a replayable non-negative integer.
|
|
20
|
+
|
|
21
|
+
``None`` receives fresh operating-system entropy. Passing a generator
|
|
22
|
+
consumes one integer from it, isolating the generated object from later
|
|
23
|
+
changes to that generator's state.
|
|
24
|
+
"""
|
|
25
|
+
if isinstance(seed, np.random.Generator):
|
|
26
|
+
return int(seed.integers(0, _MAX_SEED, dtype=np.uint64))
|
|
27
|
+
if seed is None:
|
|
28
|
+
return int(np.random.SeedSequence().entropy)
|
|
29
|
+
if not isinstance(seed, (int, np.integer)) or int(seed) < 0:
|
|
30
|
+
raise ValueError("seed must be a non-negative integer, Generator, or None")
|
|
31
|
+
return int(seed)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def spawn_seeds(seed: int, n_children: int) -> tuple[int, ...]:
|
|
35
|
+
"""Derive independent, deterministic child seeds from ``seed``."""
|
|
36
|
+
if n_children < 0:
|
|
37
|
+
raise ValueError("n_children must be >= 0")
|
|
38
|
+
children = np.random.SeedSequence(int(seed)).spawn(n_children)
|
|
39
|
+
return tuple(
|
|
40
|
+
int(child.generate_state(1, dtype=np.uint64)[0])
|
|
41
|
+
for child in children
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def as_rng(seed: int | np.random.Generator | None) -> np.random.Generator:
|
|
46
|
+
"""Return a NumPy ``Generator`` from a seed, generator, or ``None``."""
|
|
47
|
+
if isinstance(seed, np.random.Generator):
|
|
48
|
+
return seed
|
|
49
|
+
return np.random.default_rng(seed)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def farthest_point_sample(pts: np.ndarray, n: int, rng) -> np.ndarray:
|
|
53
|
+
"""Select *n* points from *pts* using farthest-point sampling.
|
|
54
|
+
|
|
55
|
+
Iteratively picks the point farthest from all previously selected points,
|
|
56
|
+
producing a spatially uniform subsample that preserves coverage.
|
|
57
|
+
"""
|
|
58
|
+
m = len(pts)
|
|
59
|
+
if m <= n:
|
|
60
|
+
return pts
|
|
61
|
+
|
|
62
|
+
selected = [int(rng.integers(m))]
|
|
63
|
+
dist_sq = np.sum((pts - pts[selected[0]]) ** 2, axis=1)
|
|
64
|
+
|
|
65
|
+
for _ in range(n - 1):
|
|
66
|
+
idx = int(np.argmax(dist_sq))
|
|
67
|
+
selected.append(idx)
|
|
68
|
+
new_dist_sq = np.sum((pts - pts[idx]) ** 2, axis=1)
|
|
69
|
+
dist_sq = np.minimum(dist_sq, new_dist_sq)
|
|
70
|
+
|
|
71
|
+
return pts[selected]
|
oowl/api.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Single public entry point for ordinary, multipart, and clustered farms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from copy import copy
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ._random import as_rng, resolve_seed, spawn_seeds
|
|
11
|
+
from .boundaries.base import Boundary
|
|
12
|
+
from .boundaries.composites import BoundaryCluster
|
|
13
|
+
from .layouts.base import Farm, LayoutAlgorithm, _allocate_counts, _combine_guides
|
|
14
|
+
from .layouts.composites import MixedLayout
|
|
15
|
+
|
|
16
|
+
__all__ = ["generate_farm"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def generate_farm(
|
|
20
|
+
boundary: Boundary | LayoutAlgorithm,
|
|
21
|
+
layout: LayoutAlgorithm | MixedLayout | Sequence[LayoutAlgorithm] | None = None,
|
|
22
|
+
seed=None,
|
|
23
|
+
) -> Farm:
|
|
24
|
+
"""Generate one exact-count layout, optionally fitted to a boundary.
|
|
25
|
+
|
|
26
|
+
A multipart ordinary boundary remains one farm and uses one algorithm.
|
|
27
|
+
``BoundaryCluster`` coordinates multiple logical farms and accepts one
|
|
28
|
+
fixed algorithm, a ``MixedLayout``, or an explicit algorithm per farm.
|
|
29
|
+
"""
|
|
30
|
+
if layout is None:
|
|
31
|
+
if not isinstance(boundary, LayoutAlgorithm):
|
|
32
|
+
raise TypeError("standalone generation requires a LayoutAlgorithm")
|
|
33
|
+
layout = boundary
|
|
34
|
+
boundary = None
|
|
35
|
+
|
|
36
|
+
if isinstance(boundary, BoundaryCluster):
|
|
37
|
+
if isinstance(layout, MixedLayout):
|
|
38
|
+
effective_seed = layout.seed if seed is None else seed
|
|
39
|
+
return _generate_distributed_cluster(
|
|
40
|
+
boundary,
|
|
41
|
+
layout.n_turbines,
|
|
42
|
+
layout.layouts,
|
|
43
|
+
layout.probabilities,
|
|
44
|
+
effective_seed,
|
|
45
|
+
selection_method="mixed",
|
|
46
|
+
)
|
|
47
|
+
if isinstance(layout, LayoutAlgorithm):
|
|
48
|
+
if layout.n_turbines is None:
|
|
49
|
+
raise ValueError("n_turbines is required for cluster generation")
|
|
50
|
+
effective_seed = layout.seed if seed is None else seed
|
|
51
|
+
return _generate_distributed_cluster(
|
|
52
|
+
boundary,
|
|
53
|
+
layout.n_turbines,
|
|
54
|
+
(layout,),
|
|
55
|
+
(1.0,),
|
|
56
|
+
effective_seed,
|
|
57
|
+
selection_method="fixed",
|
|
58
|
+
)
|
|
59
|
+
return _generate_explicit_cluster(boundary, layout, seed)
|
|
60
|
+
|
|
61
|
+
if boundary is None:
|
|
62
|
+
if not isinstance(layout, LayoutAlgorithm):
|
|
63
|
+
raise TypeError("standalone generation requires a LayoutAlgorithm")
|
|
64
|
+
algorithm = copy(layout)
|
|
65
|
+
if seed is not None:
|
|
66
|
+
algorithm.seed = seed
|
|
67
|
+
return algorithm.generate()
|
|
68
|
+
|
|
69
|
+
if isinstance(layout, MixedLayout):
|
|
70
|
+
return _generate_mixed_single(boundary, layout, seed)
|
|
71
|
+
if not isinstance(layout, LayoutAlgorithm):
|
|
72
|
+
raise TypeError("an ordinary Boundary requires one LayoutAlgorithm")
|
|
73
|
+
algorithm = copy(layout)
|
|
74
|
+
if seed is not None:
|
|
75
|
+
algorithm.seed = seed
|
|
76
|
+
return algorithm.generate(boundary)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _with_selection(result, root_seed, index, algorithm, child_seed):
|
|
80
|
+
provenance = dict(result.provenance)
|
|
81
|
+
provenance["seed"] = int(root_seed)
|
|
82
|
+
provenance["selection"] = {
|
|
83
|
+
"method": "mixed",
|
|
84
|
+
"index": int(index),
|
|
85
|
+
"algorithm": type(algorithm).__name__,
|
|
86
|
+
"seed": int(child_seed),
|
|
87
|
+
}
|
|
88
|
+
return Farm(
|
|
89
|
+
result.positions, result.boundary, provenance, guide=result.guide
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _generate_mixed_single(boundary, mixed, seed):
|
|
94
|
+
root_seed = resolve_seed(mixed.seed if seed is None else seed)
|
|
95
|
+
selection_seed, child_seed = spawn_seeds(root_seed, 2)
|
|
96
|
+
rng = as_rng(selection_seed)
|
|
97
|
+
index = int(rng.choice(len(mixed.layouts), p=mixed.probabilities))
|
|
98
|
+
algorithm = copy(mixed.layouts[index])
|
|
99
|
+
algorithm.n_turbines = mixed.n_turbines
|
|
100
|
+
algorithm.seed = child_seed
|
|
101
|
+
result = algorithm.generate(boundary)
|
|
102
|
+
return _with_selection(result, root_seed, index, algorithm, child_seed)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _generate_distributed_cluster(
|
|
106
|
+
boundary,
|
|
107
|
+
n_turbines,
|
|
108
|
+
layouts,
|
|
109
|
+
probabilities,
|
|
110
|
+
seed,
|
|
111
|
+
selection_method,
|
|
112
|
+
):
|
|
113
|
+
root_seed = resolve_seed(seed)
|
|
114
|
+
selection_seed, farm_root = spawn_seeds(root_seed, 2)
|
|
115
|
+
counts = _allocate_counts(
|
|
116
|
+
n_turbines, [farm.area for farm in boundary.boundaries]
|
|
117
|
+
)
|
|
118
|
+
selection_rng = as_rng(selection_seed)
|
|
119
|
+
selected = selection_rng.choice(
|
|
120
|
+
len(layouts), size=len(boundary.boundaries), p=probabilities
|
|
121
|
+
)
|
|
122
|
+
farm_seeds = spawn_seeds(farm_root, len(boundary.boundaries))
|
|
123
|
+
assignments = []
|
|
124
|
+
for count, layout_index, farm_seed in zip(counts, selected, farm_seeds):
|
|
125
|
+
layout_index = int(layout_index)
|
|
126
|
+
template = layouts[layout_index]
|
|
127
|
+
assignments.append((
|
|
128
|
+
template,
|
|
129
|
+
int(count),
|
|
130
|
+
farm_seed,
|
|
131
|
+
{
|
|
132
|
+
"method": selection_method,
|
|
133
|
+
"index": layout_index,
|
|
134
|
+
"algorithm": type(template).__name__,
|
|
135
|
+
},
|
|
136
|
+
))
|
|
137
|
+
return _aggregate_cluster(
|
|
138
|
+
boundary, assignments, root_seed, n_turbines
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _generate_explicit_cluster(boundary, layouts, seed):
|
|
143
|
+
if isinstance(layouts, LayoutAlgorithm) or not isinstance(layouts, Sequence):
|
|
144
|
+
raise TypeError(
|
|
145
|
+
"BoundaryCluster requires a sequence of LayoutAlgorithm objects"
|
|
146
|
+
)
|
|
147
|
+
layouts = tuple(layouts)
|
|
148
|
+
if len(layouts) != len(boundary.boundaries):
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"BoundaryCluster has {len(boundary.boundaries)} farms but "
|
|
151
|
+
f"received {len(layouts)} layouts"
|
|
152
|
+
)
|
|
153
|
+
for index, algorithm in enumerate(layouts):
|
|
154
|
+
if not isinstance(algorithm, LayoutAlgorithm):
|
|
155
|
+
raise TypeError(
|
|
156
|
+
f"cluster layout {index} must be a LayoutAlgorithm, "
|
|
157
|
+
f"not {type(algorithm).__name__}"
|
|
158
|
+
)
|
|
159
|
+
if algorithm.n_turbines is None:
|
|
160
|
+
raise ValueError(f"cluster layout {index} must define n_turbines")
|
|
161
|
+
|
|
162
|
+
root_seed = resolve_seed(seed) if seed is not None else None
|
|
163
|
+
override_seeds = (
|
|
164
|
+
spawn_seeds(root_seed, len(layouts)) if root_seed is not None else None
|
|
165
|
+
)
|
|
166
|
+
assignments = []
|
|
167
|
+
for index, template in enumerate(layouts):
|
|
168
|
+
override = override_seeds[index] if override_seeds is not None else None
|
|
169
|
+
assignments.append((
|
|
170
|
+
template,
|
|
171
|
+
template.n_turbines,
|
|
172
|
+
override,
|
|
173
|
+
{
|
|
174
|
+
"method": "explicit",
|
|
175
|
+
"index": index,
|
|
176
|
+
"algorithm": type(template).__name__,
|
|
177
|
+
},
|
|
178
|
+
))
|
|
179
|
+
return _aggregate_cluster(
|
|
180
|
+
boundary,
|
|
181
|
+
assignments,
|
|
182
|
+
root_seed,
|
|
183
|
+
sum(algorithm.n_turbines for algorithm in layouts),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _aggregate_cluster(boundary, assignments, root_seed, requested_n):
|
|
188
|
+
positions = []
|
|
189
|
+
guides = []
|
|
190
|
+
farms = []
|
|
191
|
+
start = 0
|
|
192
|
+
|
|
193
|
+
for index, (farm_boundary, assignment) in enumerate(
|
|
194
|
+
zip(boundary.boundaries, assignments)
|
|
195
|
+
):
|
|
196
|
+
template, count, override_seed, selection = assignment
|
|
197
|
+
count = int(count)
|
|
198
|
+
algorithm = copy(template)
|
|
199
|
+
algorithm.n_turbines = count
|
|
200
|
+
if override_seed is not None:
|
|
201
|
+
algorithm.seed = override_seed
|
|
202
|
+
|
|
203
|
+
child_provenance = None
|
|
204
|
+
child_seed = int(override_seed) if override_seed is not None else None
|
|
205
|
+
if count:
|
|
206
|
+
try:
|
|
207
|
+
result = algorithm.generate(farm_boundary)
|
|
208
|
+
except (TypeError, ValueError) as exc:
|
|
209
|
+
raise type(exc)(
|
|
210
|
+
f"Could not generate cluster farm {index}: {exc}"
|
|
211
|
+
) from exc
|
|
212
|
+
positions.append(result.positions)
|
|
213
|
+
guides.append(result.guide)
|
|
214
|
+
child_provenance = result.provenance
|
|
215
|
+
child_seed = child_provenance.get("seed")
|
|
216
|
+
|
|
217
|
+
stop = start + count
|
|
218
|
+
farms.append({
|
|
219
|
+
"farm_index": index,
|
|
220
|
+
"area": float(farm_boundary.area),
|
|
221
|
+
"n_turbines": count,
|
|
222
|
+
"start": start,
|
|
223
|
+
"stop": stop,
|
|
224
|
+
"seed": child_seed,
|
|
225
|
+
"selection": selection,
|
|
226
|
+
"layout": child_provenance,
|
|
227
|
+
})
|
|
228
|
+
start = stop
|
|
229
|
+
|
|
230
|
+
combined = np.concatenate(positions) if positions else np.empty((0, 2))
|
|
231
|
+
return Farm(
|
|
232
|
+
combined,
|
|
233
|
+
boundary,
|
|
234
|
+
{
|
|
235
|
+
"kind": "cluster",
|
|
236
|
+
"seed": int(root_seed) if root_seed is not None else None,
|
|
237
|
+
"requested_n": int(requested_n),
|
|
238
|
+
"achieved_n": int(len(combined)),
|
|
239
|
+
"farms": farms,
|
|
240
|
+
"modifiers": [],
|
|
241
|
+
},
|
|
242
|
+
guide=_combine_guides(guides),
|
|
243
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Boundary geometries for wind farms.
|
|
2
|
+
|
|
3
|
+
Concrete boundaries subclass :class:`Boundary` and return a Shapely
|
|
4
|
+
``Polygon`` / ``MultiPolygon``. Extras (shipping channels, exclusion zones) are
|
|
5
|
+
:class:`BoundaryModifier` objects applied via :class:`CompositeBoundary`, so
|
|
6
|
+
they compose with any boundary. :class:`BoundaryCluster` explicitly groups
|
|
7
|
+
boundaries belonging to separately configured farms.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .base import Boundary
|
|
13
|
+
from .composites import BoundaryCluster, CompositeBoundary
|
|
14
|
+
from .hull import ConvexHull
|
|
15
|
+
from .modifiers import BoundaryModifier, ExclusionZones, ShippingChannels
|
|
16
|
+
from .primitives import Annulus, Circle, Ellipse, Rectangle, RegularPolygon, Square
|
|
17
|
+
from .radial import RadialPolygon, RandomRadialPolygon, SmoothBlob
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Boundary",
|
|
21
|
+
"BoundaryCluster",
|
|
22
|
+
"CompositeBoundary",
|
|
23
|
+
"Rectangle",
|
|
24
|
+
"Square",
|
|
25
|
+
"RegularPolygon",
|
|
26
|
+
"Circle",
|
|
27
|
+
"Ellipse",
|
|
28
|
+
"Annulus",
|
|
29
|
+
"RadialPolygon",
|
|
30
|
+
"RandomRadialPolygon",
|
|
31
|
+
"SmoothBlob",
|
|
32
|
+
"ConvexHull",
|
|
33
|
+
"BoundaryModifier",
|
|
34
|
+
"ShippingChannels",
|
|
35
|
+
"ExclusionZones",
|
|
36
|
+
]
|
oowl/boundaries/base.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Base classes for boundary geometries.
|
|
2
|
+
|
|
3
|
+
A :class:`Boundary` owns a Shapely ``Polygon`` / ``MultiPolygon`` and exposes the
|
|
4
|
+
small interface that layout fitters need (``area``, ``bounds``, ``centroid``,
|
|
5
|
+
``contains``). Concrete shapes live in :mod:`primitives`, :mod:`radial` and
|
|
6
|
+
:mod:`hull`. Extras such as shipping channels and exclusion zones are applied as
|
|
7
|
+
:class:`~oowl.boundaries.modifiers.BoundaryModifier` objects through a
|
|
8
|
+
:class:`~oowl.boundaries.composites.CompositeBoundary`, so they mix
|
|
9
|
+
into *any* boundary.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from functools import cached_property
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import shapely
|
|
20
|
+
from shapely.geometry import MultiPolygon, Polygon
|
|
21
|
+
from shapely.geometry.base import BaseGeometry
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
24
|
+
from .modifiers import BoundaryModifier
|
|
25
|
+
|
|
26
|
+
__all__ = ["Boundary"]
|
|
27
|
+
|
|
28
|
+
Point2D = tuple[float, float]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Boundary(ABC):
|
|
32
|
+
"""Abstract wind farm boundary backed by a Shapely geometry."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def _build_geometry(self) -> BaseGeometry:
|
|
36
|
+
"""Construct and return the Shapely geometry for this boundary."""
|
|
37
|
+
|
|
38
|
+
@cached_property
|
|
39
|
+
def geometry(self) -> Polygon | MultiPolygon:
|
|
40
|
+
"""The Shapely ``Polygon`` / ``MultiPolygon`` for this boundary."""
|
|
41
|
+
geometry = self._build_geometry()
|
|
42
|
+
if not isinstance(geometry, (Polygon, MultiPolygon)):
|
|
43
|
+
raise TypeError("boundary geometry must be a Polygon or MultiPolygon")
|
|
44
|
+
if geometry.is_empty or geometry.area <= 0:
|
|
45
|
+
raise ValueError("boundary geometry must have positive area")
|
|
46
|
+
if not geometry.is_valid:
|
|
47
|
+
raise ValueError("boundary geometry must be valid")
|
|
48
|
+
return geometry
|
|
49
|
+
|
|
50
|
+
# -- properties layouts/fitters rely on -------------------------------
|
|
51
|
+
@property
|
|
52
|
+
def area(self) -> float:
|
|
53
|
+
return self.geometry.area
|
|
54
|
+
|
|
55
|
+
@cached_property
|
|
56
|
+
def bounds(self) -> tuple[float, float, float, float]:
|
|
57
|
+
return self.geometry.bounds
|
|
58
|
+
|
|
59
|
+
@cached_property
|
|
60
|
+
def centroid(self) -> Point2D:
|
|
61
|
+
c = self.geometry.centroid
|
|
62
|
+
return (c.x, c.y)
|
|
63
|
+
|
|
64
|
+
def contains(self, points) -> np.ndarray:
|
|
65
|
+
"""Vectorised point-in-boundary test, inclusive of the boundary edge.
|
|
66
|
+
|
|
67
|
+
Points lying exactly on the boundary count as contained, so turbines
|
|
68
|
+
that touch an edge are not spuriously dropped. For point queries this is
|
|
69
|
+
the ``covers`` predicate.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
points:
|
|
74
|
+
Array-like of shape ``(N, 2)`` (or ``(2,)``).
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
numpy.ndarray
|
|
79
|
+
Boolean array of length ``N``.
|
|
80
|
+
"""
|
|
81
|
+
pts = np.asarray(points, dtype=float).reshape(-1, 2)
|
|
82
|
+
return shapely.intersects_xy(self.geometry, pts[:, 0], pts[:, 1])
|
|
83
|
+
|
|
84
|
+
# -- composition (modifiers) ------------------------------------------
|
|
85
|
+
def with_modifiers(self, *modifiers: "BoundaryModifier"):
|
|
86
|
+
"""Return a new boundary with ``modifiers`` applied in order."""
|
|
87
|
+
from .composites import CompositeBoundary
|
|
88
|
+
|
|
89
|
+
return CompositeBoundary(self, modifiers)
|
|
90
|
+
|
|
91
|
+
def with_channels(self, **kwargs):
|
|
92
|
+
"""Cut shipping channels through this boundary (see ``ShippingChannels``)."""
|
|
93
|
+
from .modifiers import ShippingChannels
|
|
94
|
+
|
|
95
|
+
return self.with_modifiers(ShippingChannels(**kwargs))
|
|
96
|
+
|
|
97
|
+
def with_exclusions(self, *holes):
|
|
98
|
+
"""Subtract exclusion zones (geometries or boundaries) from this boundary."""
|
|
99
|
+
from .modifiers import ExclusionZones
|
|
100
|
+
|
|
101
|
+
return self.with_modifiers(ExclusionZones(holes))
|
|
102
|
+
|
|
103
|
+
# -- plotting ----------------------------------------------------------
|
|
104
|
+
def plot(self, ax=None, **kwargs):
|
|
105
|
+
from ..plotting import plot_boundary
|
|
106
|
+
|
|
107
|
+
return plot_boundary(self.geometry, ax=ax, **kwargs)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _GeometryBoundary(Boundary):
|
|
111
|
+
"""Internal boundary view over an existing polygon geometry."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, geometry: Polygon | MultiPolygon):
|
|
114
|
+
self._geometry = geometry
|
|
115
|
+
|
|
116
|
+
def _build_geometry(self) -> BaseGeometry:
|
|
117
|
+
return self._geometry
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Compositions of logical farm boundaries and modifier chains."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from shapely.ops import unary_union
|
|
8
|
+
|
|
9
|
+
from .base import Boundary, _GeometryBoundary
|
|
10
|
+
from .modifiers import BoundaryModifier
|
|
11
|
+
|
|
12
|
+
__all__ = ["CompositeBoundary", "BoundaryCluster"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CompositeBoundary(Boundary):
|
|
16
|
+
"""A base boundary with an ordered chain of geometry modifiers."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, base: Boundary, modifiers: Iterable[BoundaryModifier]):
|
|
19
|
+
if not isinstance(base, Boundary):
|
|
20
|
+
raise TypeError("base must be a Boundary")
|
|
21
|
+
modifiers = tuple(modifiers)
|
|
22
|
+
for modifier in modifiers:
|
|
23
|
+
if not isinstance(modifier, BoundaryModifier):
|
|
24
|
+
raise TypeError("boundary modifiers must be BoundaryModifier objects")
|
|
25
|
+
if isinstance(base, CompositeBoundary):
|
|
26
|
+
self.base = base.base
|
|
27
|
+
self.modifiers = base.modifiers + modifiers
|
|
28
|
+
else:
|
|
29
|
+
self.base = base
|
|
30
|
+
self.modifiers = modifiers
|
|
31
|
+
|
|
32
|
+
def _build_geometry(self):
|
|
33
|
+
geometry = self.base.geometry
|
|
34
|
+
for modifier in self.modifiers:
|
|
35
|
+
geometry = modifier.apply(geometry)
|
|
36
|
+
return geometry
|
|
37
|
+
|
|
38
|
+
def with_modifiers(self, *modifiers):
|
|
39
|
+
return CompositeBoundary(self.base, self.modifiers + tuple(modifiers))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BoundaryCluster(Boundary):
|
|
43
|
+
"""An ordered collection of non-overlapping logical wind farms."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, boundaries: Iterable[Boundary]):
|
|
46
|
+
try:
|
|
47
|
+
members = tuple(boundaries)
|
|
48
|
+
except TypeError as exc:
|
|
49
|
+
raise TypeError(
|
|
50
|
+
"boundaries must be an iterable of Boundary objects"
|
|
51
|
+
) from exc
|
|
52
|
+
if not members:
|
|
53
|
+
raise ValueError("BoundaryCluster requires at least one boundary")
|
|
54
|
+
for index, boundary in enumerate(members):
|
|
55
|
+
if isinstance(boundary, BoundaryCluster):
|
|
56
|
+
raise TypeError("nested BoundaryCluster objects are not supported")
|
|
57
|
+
if not isinstance(boundary, Boundary):
|
|
58
|
+
raise TypeError(
|
|
59
|
+
f"cluster member {index} must be a Boundary, "
|
|
60
|
+
f"not {type(boundary).__name__}"
|
|
61
|
+
)
|
|
62
|
+
for left in range(len(members)):
|
|
63
|
+
for right in range(left + 1, len(members)):
|
|
64
|
+
overlap = members[left].geometry.intersection(
|
|
65
|
+
members[right].geometry
|
|
66
|
+
)
|
|
67
|
+
if overlap.area > 0:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"cluster boundaries {left} and {right} overlap in area"
|
|
70
|
+
)
|
|
71
|
+
self.boundaries = members
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_components(cls, boundary: Boundary):
|
|
75
|
+
if not isinstance(boundary, Boundary):
|
|
76
|
+
raise TypeError("boundary must be a Boundary")
|
|
77
|
+
geometry = boundary.geometry
|
|
78
|
+
polygons = (
|
|
79
|
+
geometry.geoms
|
|
80
|
+
if geometry.geom_type == "MultiPolygon"
|
|
81
|
+
else [geometry]
|
|
82
|
+
)
|
|
83
|
+
return cls(_GeometryBoundary(polygon) for polygon in polygons)
|
|
84
|
+
|
|
85
|
+
def _build_geometry(self):
|
|
86
|
+
return unary_union([boundary.geometry for boundary in self.boundaries])
|
|
87
|
+
|
|
88
|
+
def with_modifiers(self, *modifiers):
|
|
89
|
+
for modifier in modifiers:
|
|
90
|
+
if not isinstance(modifier, BoundaryModifier):
|
|
91
|
+
raise TypeError("boundary modifiers must be BoundaryModifier objects")
|
|
92
|
+
geometries = [boundary.geometry for boundary in self.boundaries]
|
|
93
|
+
for modifier in modifiers:
|
|
94
|
+
geometries = modifier.apply_to_cluster(geometries)
|
|
95
|
+
members = [
|
|
96
|
+
_GeometryBoundary(geometry)
|
|
97
|
+
for geometry in geometries
|
|
98
|
+
if not geometry.is_empty and geometry.area > 0
|
|
99
|
+
]
|
|
100
|
+
if not members:
|
|
101
|
+
raise ValueError("cluster modifiers removed every farm boundary")
|
|
102
|
+
return BoundaryCluster(members)
|
oowl/boundaries/hull.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Point-cloud based boundary shapes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from shapely.geometry import MultiPoint, Polygon
|
|
7
|
+
from shapely.geometry.base import BaseGeometry
|
|
8
|
+
|
|
9
|
+
from .._random import as_rng, resolve_seed
|
|
10
|
+
from .base import Boundary, Point2D
|
|
11
|
+
|
|
12
|
+
__all__ = ["ConvexHull"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConvexHull(Boundary):
|
|
16
|
+
"""Convex boundary as the convex hull of random scattered points."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
n_points: int = 20,
|
|
21
|
+
spread: float = 1.0,
|
|
22
|
+
center: Point2D = (0.0, 0.0),
|
|
23
|
+
seed: int | np.random.Generator | None = None,
|
|
24
|
+
):
|
|
25
|
+
if n_points < 3:
|
|
26
|
+
raise ValueError("n_points must be >= 3")
|
|
27
|
+
self.n_points = n_points
|
|
28
|
+
self.spread = spread
|
|
29
|
+
self.center = center
|
|
30
|
+
self.seed = resolve_seed(seed)
|
|
31
|
+
|
|
32
|
+
def _build_geometry(self) -> BaseGeometry:
|
|
33
|
+
rng = as_rng(self.seed)
|
|
34
|
+
pts = rng.normal(loc=self.center, scale=self.spread, size=(self.n_points, 2))
|
|
35
|
+
hull = MultiPoint(pts).convex_hull
|
|
36
|
+
if not isinstance(hull, Polygon):
|
|
37
|
+
raise ValueError("convex hull did not produce a polygon; add more points")
|
|
38
|
+
return hull
|