oowl 0.0.1__tar.gz

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.
Files changed (43) hide show
  1. oowl-0.0.1/LICENSE +7 -0
  2. oowl-0.0.1/PKG-INFO +124 -0
  3. oowl-0.0.1/README.md +101 -0
  4. oowl-0.0.1/pyproject.toml +34 -0
  5. oowl-0.0.1/setup.cfg +4 -0
  6. oowl-0.0.1/src/oowl/__init__.py +45 -0
  7. oowl-0.0.1/src/oowl/_random.py +71 -0
  8. oowl-0.0.1/src/oowl/api.py +243 -0
  9. oowl-0.0.1/src/oowl/boundaries/__init__.py +36 -0
  10. oowl-0.0.1/src/oowl/boundaries/base.py +117 -0
  11. oowl-0.0.1/src/oowl/boundaries/composites.py +102 -0
  12. oowl-0.0.1/src/oowl/boundaries/hull.py +38 -0
  13. oowl-0.0.1/src/oowl/boundaries/modifiers.py +141 -0
  14. oowl-0.0.1/src/oowl/boundaries/primitives.py +150 -0
  15. oowl-0.0.1/src/oowl/boundaries/radial.py +117 -0
  16. oowl-0.0.1/src/oowl/layouts/__init__.py +44 -0
  17. oowl-0.0.1/src/oowl/layouts/base.py +293 -0
  18. oowl-0.0.1/src/oowl/layouts/candidate.py +43 -0
  19. oowl-0.0.1/src/oowl/layouts/circular.py +86 -0
  20. oowl-0.0.1/src/oowl/layouts/composites.py +65 -0
  21. oowl-0.0.1/src/oowl/layouts/contour.py +162 -0
  22. oowl-0.0.1/src/oowl/layouts/fitters.py +272 -0
  23. oowl-0.0.1/src/oowl/layouts/grid.py +88 -0
  24. oowl-0.0.1/src/oowl/layouts/modifiers.py +78 -0
  25. oowl-0.0.1/src/oowl/layouts/pattern.py +53 -0
  26. oowl-0.0.1/src/oowl/layouts/poisson.py +129 -0
  27. oowl-0.0.1/src/oowl/layouts/string.py +132 -0
  28. oowl-0.0.1/src/oowl/plotting.py +288 -0
  29. oowl-0.0.1/src/oowl/site/__init__.py +27 -0
  30. oowl-0.0.1/src/oowl/site/base.py +193 -0
  31. oowl-0.0.1/src/oowl/site/directions.py +128 -0
  32. oowl-0.0.1/src/oowl/site/sampling.py +153 -0
  33. oowl-0.0.1/src/oowl/strings/__init__.py +20 -0
  34. oowl-0.0.1/src/oowl/strings/base.py +95 -0
  35. oowl-0.0.1/src/oowl/strings/composites.py +212 -0
  36. oowl-0.0.1/src/oowl/strings/modifiers.py +142 -0
  37. oowl-0.0.1/src/oowl/strings/primitives.py +96 -0
  38. oowl-0.0.1/src/oowl/windio.py +166 -0
  39. oowl-0.0.1/src/oowl.egg-info/PKG-INFO +124 -0
  40. oowl-0.0.1/src/oowl.egg-info/SOURCES.txt +41 -0
  41. oowl-0.0.1/src/oowl.egg-info/dependency_links.txt +1 -0
  42. oowl-0.0.1/src/oowl.egg-info/requires.txt +10 -0
  43. oowl-0.0.1/src/oowl.egg-info/top_level.txt +2 -0
oowl-0.0.1/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2026 DTU Wind and Energy Systems
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
oowl-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: oowl
3
+ Version: 0.0.1
4
+ Summary: Onshore and offshore wind layout generation.
5
+ Author: DTU Wind Energy Systems
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://gitlab.windenergy.dtu.dk/fpwra/OOWL
8
+ Project-URL: Documentation, https://oowl-4781ed.pages.windenergy.dtu.dk
9
+ Keywords: wind farm,onshore,offshore,layout,boundary,geometry
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: numpy>=1.24
14
+ Requires-Dist: shapely>=2.0
15
+ Requires-Dist: matplotlib>=3.7
16
+ Requires-Dist: scipy>=1.10
17
+ Requires-Dist: windIO>=2.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7; extra == "dev"
20
+ Requires-Dist: jupyter; extra == "dev"
21
+ Requires-Dist: ipykernel; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # OOWL
25
+
26
+ Onshore and Offshore Wind Layouts (OOWL) generates diverse, exact-count
27
+ wind-farm layouts from composable geometry and placement methods.
28
+
29
+ The Python package is currently imported as `oowl`.
30
+
31
+ ```python
32
+ import oowl
33
+ from oowl import boundaries as B
34
+ from oowl import layouts as L
35
+
36
+ boundary = B.Rectangle(9000, 5000).with_channels(
37
+ n_channels=2, width=500, seed=3
38
+ )
39
+ farm = oowl.generate_farm(
40
+ boundary,
41
+ L.GridLayout(n_turbines=92, lattice_angle=60, seed=4),
42
+ )
43
+ farm.plot()
44
+ ```
45
+
46
+ ## Model
47
+
48
+ The public API has three independent layers:
49
+
50
+ - `boundaries` define the allowed polygonal farm area. A `MultiPolygon` remains
51
+ one logical farm; `BoundaryCluster` explicitly represents multiple farms.
52
+ - `strings` define optional ordered line guides. Breaks and meanders compose as
53
+ non-mutating geometry modifiers.
54
+ - `layouts` place an exact turbine count. Grids occupy selected intersections
55
+ of an aligned parallelogram grid, circular layouts select from boundary-
56
+ clipped concentric rings, contour layouts follow nested inward copies of the
57
+ farm boundary and retain a central point by default, Poisson layouts provide
58
+ blue-noise patterns, and string
59
+ farms distribute turbines along guides.
60
+
61
+ `oowl.generate_farm(...)` is the single entry point for ordinary boundaries,
62
+ multipart farms, clusters, and standalone string layouts.
63
+
64
+ ```python
65
+ contours = oowl.generate_farm(
66
+ boundary,
67
+ L.ContourLayout(n_turbines=80, include_center=True),
68
+ )
69
+ ```
70
+
71
+ ## Strings and clusters
72
+
73
+ ```python
74
+ from oowl import strings as S
75
+
76
+ guide = S.Parallel(S.Straight(), n_strings=5, spacing=0.18)
77
+ guide = guide.with_meander(roughness=0.03, seed=2)
78
+
79
+ string_farm = oowl.generate_farm(
80
+ B.Circle(3000),
81
+ L.StringLayout(60, strings=guide),
82
+ )
83
+
84
+ cluster = B.BoundaryCluster.from_components(boundary)
85
+ mixed = oowl.generate_farm(
86
+ cluster,
87
+ L.MixedLayout(
88
+ n_turbines=92,
89
+ layouts=[L.GridLayout(), L.StringLayout(strings=S.Straight())],
90
+ seed=8,
91
+ ),
92
+ )
93
+ ```
94
+
95
+ ## Units and windIO
96
+
97
+ Realized boundary and turbine coordinates are interpreted as metres. String
98
+ guide lengths and spacing are relative shape parameters when fitted to a
99
+ boundary; standalone `StringLayout.spacing` is dimensional.
100
+
101
+ `Farm.positions` is an ordered `(N, 2)` array that maps directly to windIO's
102
+ `coordinates.x` and `coordinates.y`. Cluster provenance stores contiguous farm
103
+ slices so a future adapter can emit windIO's array of wind-farm objects. The
104
+ package does not currently depend on windIO or model turbines, substations, or
105
+ electrical collection arrays.
106
+
107
+ ## Randomness and provenance
108
+
109
+ Explicit seeds reproduce a farm. An unseeded generation call receives fresh
110
+ entropy and records the resolved seed in `Farm.provenance`, so it can be
111
+ replayed later:
112
+
113
+ ```python
114
+ farm = oowl.generate_farm(boundary, L.GridLayout(92))
115
+ replay = oowl.generate_farm(
116
+ boundary,
117
+ L.GridLayout(92),
118
+ seed=farm.provenance["seed"],
119
+ )
120
+ ```
121
+
122
+ Random boundaries and guides resolve their seed when constructed. Reusing one
123
+ such object therefore reuses the same realized geometry, while constructing a
124
+ new unseeded object produces a new geometry.
oowl-0.0.1/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # OOWL
2
+
3
+ Onshore and Offshore Wind Layouts (OOWL) generates diverse, exact-count
4
+ wind-farm layouts from composable geometry and placement methods.
5
+
6
+ The Python package is currently imported as `oowl`.
7
+
8
+ ```python
9
+ import oowl
10
+ from oowl import boundaries as B
11
+ from oowl import layouts as L
12
+
13
+ boundary = B.Rectangle(9000, 5000).with_channels(
14
+ n_channels=2, width=500, seed=3
15
+ )
16
+ farm = oowl.generate_farm(
17
+ boundary,
18
+ L.GridLayout(n_turbines=92, lattice_angle=60, seed=4),
19
+ )
20
+ farm.plot()
21
+ ```
22
+
23
+ ## Model
24
+
25
+ The public API has three independent layers:
26
+
27
+ - `boundaries` define the allowed polygonal farm area. A `MultiPolygon` remains
28
+ one logical farm; `BoundaryCluster` explicitly represents multiple farms.
29
+ - `strings` define optional ordered line guides. Breaks and meanders compose as
30
+ non-mutating geometry modifiers.
31
+ - `layouts` place an exact turbine count. Grids occupy selected intersections
32
+ of an aligned parallelogram grid, circular layouts select from boundary-
33
+ clipped concentric rings, contour layouts follow nested inward copies of the
34
+ farm boundary and retain a central point by default, Poisson layouts provide
35
+ blue-noise patterns, and string
36
+ farms distribute turbines along guides.
37
+
38
+ `oowl.generate_farm(...)` is the single entry point for ordinary boundaries,
39
+ multipart farms, clusters, and standalone string layouts.
40
+
41
+ ```python
42
+ contours = oowl.generate_farm(
43
+ boundary,
44
+ L.ContourLayout(n_turbines=80, include_center=True),
45
+ )
46
+ ```
47
+
48
+ ## Strings and clusters
49
+
50
+ ```python
51
+ from oowl import strings as S
52
+
53
+ guide = S.Parallel(S.Straight(), n_strings=5, spacing=0.18)
54
+ guide = guide.with_meander(roughness=0.03, seed=2)
55
+
56
+ string_farm = oowl.generate_farm(
57
+ B.Circle(3000),
58
+ L.StringLayout(60, strings=guide),
59
+ )
60
+
61
+ cluster = B.BoundaryCluster.from_components(boundary)
62
+ mixed = oowl.generate_farm(
63
+ cluster,
64
+ L.MixedLayout(
65
+ n_turbines=92,
66
+ layouts=[L.GridLayout(), L.StringLayout(strings=S.Straight())],
67
+ seed=8,
68
+ ),
69
+ )
70
+ ```
71
+
72
+ ## Units and windIO
73
+
74
+ Realized boundary and turbine coordinates are interpreted as metres. String
75
+ guide lengths and spacing are relative shape parameters when fitted to a
76
+ boundary; standalone `StringLayout.spacing` is dimensional.
77
+
78
+ `Farm.positions` is an ordered `(N, 2)` array that maps directly to windIO's
79
+ `coordinates.x` and `coordinates.y`. Cluster provenance stores contiguous farm
80
+ slices so a future adapter can emit windIO's array of wind-farm objects. The
81
+ package does not currently depend on windIO or model turbines, substations, or
82
+ electrical collection arrays.
83
+
84
+ ## Randomness and provenance
85
+
86
+ Explicit seeds reproduce a farm. An unseeded generation call receives fresh
87
+ entropy and records the resolved seed in `Farm.provenance`, so it can be
88
+ replayed later:
89
+
90
+ ```python
91
+ farm = oowl.generate_farm(boundary, L.GridLayout(92))
92
+ replay = oowl.generate_farm(
93
+ boundary,
94
+ L.GridLayout(92),
95
+ seed=farm.provenance["seed"],
96
+ )
97
+ ```
98
+
99
+ Random boundaries and guides resolve their seed when constructed. Reusing one
100
+ such object therefore reuses the same realized geometry, while constructing a
101
+ new unseeded object produces a new geometry.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "oowl"
7
+ version = "0.0.1"
8
+ description = "Onshore and offshore wind layout generation."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "DTU Wind Energy Systems" }]
14
+ keywords = ["wind farm", "onshore", "offshore", "layout", "boundary", "geometry"]
15
+ dependencies = [
16
+ "numpy>=1.24",
17
+ "shapely>=2.0",
18
+ "matplotlib>=3.7",
19
+ "scipy>=1.10",
20
+ "windIO>=2.0",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=7", "jupyter", "ipykernel"]
25
+
26
+ [project.urls]
27
+ Repository = "https://gitlab.windenergy.dtu.dk/fpwra/OOWL"
28
+ Documentation = "https://oowl-4781ed.pages.windenergy.dtu.dk"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
oowl-0.0.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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]
@@ -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
+ ]