|
|
""" |
|
|
Plot Generator - Industrial Plot Layout Generation |
|
|
Generates optimal plot layouts within buildable area |
|
|
""" |
|
|
import numpy as np |
|
|
from shapely.geometry import Polygon, MultiPolygon, box, LineString |
|
|
from shapely.ops import unary_union |
|
|
from shapely.affinity import rotate, translate |
|
|
from typing import List, Tuple, Optional, Dict |
|
|
import logging |
|
|
import yaml |
|
|
from pathlib import Path |
|
|
import uuid |
|
|
|
|
|
from src.models.domain import SiteBoundary, Plot, PlotType, RoadNetwork |
|
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
class PlotGenerator: |
|
|
""" |
|
|
Industrial plot generator |
|
|
|
|
|
Responsibilities: |
|
|
- Generate plot subdivisions within buildable area |
|
|
- Optimize plot orientation for efficiency |
|
|
- Ensure road access for all plots |
|
|
- Handle varied plot sizes |
|
|
""" |
|
|
|
|
|
def __init__(self, regulations_path: str = "config/regulations.yaml"): |
|
|
""" |
|
|
Initialize plot generator |
|
|
|
|
|
Args: |
|
|
regulations_path: Path to regulations YAML |
|
|
""" |
|
|
self.regulations_path = Path(regulations_path) |
|
|
self.regulations = self._load_regulations() |
|
|
self.logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
plot_config = self.regulations.get('plot', {}) |
|
|
self.min_area = plot_config.get('minimum_area_sqm', 1000) |
|
|
self.max_area = plot_config.get('maximum_area_sqm', 50000) |
|
|
self.min_width = plot_config.get('minimum_width_m', 20) |
|
|
self.min_frontage = plot_config.get('minimum_frontage_m', 15) |
|
|
|
|
|
def _load_regulations(self) -> dict: |
|
|
"""Load regulations from YAML""" |
|
|
if not self.regulations_path.exists(): |
|
|
return {} |
|
|
with open(self.regulations_path, 'r', encoding='utf-8') as f: |
|
|
return yaml.safe_load(f) |
|
|
|
|
|
def generate_grid_plots( |
|
|
self, |
|
|
site: SiteBoundary, |
|
|
road_network: RoadNetwork, |
|
|
plot_width: float = 100, |
|
|
plot_depth: float = 100, |
|
|
road_setback: float = 5 |
|
|
) -> List[Plot]: |
|
|
""" |
|
|
Generate plots in a regular grid pattern |
|
|
|
|
|
Args: |
|
|
site: Site boundary |
|
|
road_network: Road network for access checking |
|
|
plot_width: Standard plot width |
|
|
plot_depth: Standard plot depth |
|
|
road_setback: Setback from roads |
|
|
|
|
|
Returns: |
|
|
List of Plot objects |
|
|
""" |
|
|
self.logger.info(f"Generating grid plots: {plot_width}m x {plot_depth}m") |
|
|
|
|
|
|
|
|
setback = self.regulations.get('setbacks', {}).get('boundary_minimum', 50) |
|
|
buildable = site.geometry.buffer(-setback) |
|
|
|
|
|
if buildable.is_empty: |
|
|
self.logger.warning("No buildable area after setback") |
|
|
return [] |
|
|
|
|
|
|
|
|
road_polygons = self._get_road_polygons(road_network) |
|
|
if road_polygons: |
|
|
road_union = unary_union(road_polygons) |
|
|
buildable = buildable.difference(road_union.buffer(road_setback)) |
|
|
|
|
|
if buildable.is_empty: |
|
|
self.logger.warning("No buildable area after road subtraction") |
|
|
return [] |
|
|
|
|
|
|
|
|
minx, miny, maxx, maxy = buildable.bounds |
|
|
|
|
|
plots = [] |
|
|
plot_id = 0 |
|
|
|
|
|
|
|
|
y = miny |
|
|
while y + plot_depth <= maxy: |
|
|
x = minx |
|
|
while x + plot_width <= maxx: |
|
|
plot_geom = box(x, y, x + plot_width, y + plot_depth) |
|
|
|
|
|
|
|
|
if buildable.contains(plot_geom): |
|
|
|
|
|
if plot_geom.area >= self.min_area: |
|
|
plot = Plot( |
|
|
id=f"plot_{plot_id:03d}", |
|
|
geometry=plot_geom, |
|
|
area_sqm=plot_geom.area, |
|
|
type=PlotType.INDUSTRIAL, |
|
|
width_m=plot_width, |
|
|
depth_m=plot_depth, |
|
|
frontage_m=plot_width, |
|
|
has_road_access=self._check_road_access(plot_geom, road_network), |
|
|
orientation_degrees=0 |
|
|
) |
|
|
plots.append(plot) |
|
|
plot_id += 1 |
|
|
elif buildable.intersects(plot_geom): |
|
|
|
|
|
intersection = buildable.intersection(plot_geom) |
|
|
if isinstance(intersection, Polygon) and intersection.area >= self.min_area: |
|
|
plot = Plot( |
|
|
id=f"plot_{plot_id:03d}", |
|
|
geometry=intersection, |
|
|
area_sqm=intersection.area, |
|
|
type=PlotType.INDUSTRIAL, |
|
|
width_m=self._estimate_width(intersection), |
|
|
depth_m=self._estimate_depth(intersection), |
|
|
frontage_m=self._estimate_width(intersection), |
|
|
has_road_access=self._check_road_access(intersection, road_network), |
|
|
orientation_degrees=0 |
|
|
) |
|
|
plots.append(plot) |
|
|
plot_id += 1 |
|
|
|
|
|
x += plot_width |
|
|
y += plot_depth |
|
|
|
|
|
self.logger.info(f"Generated {len(plots)} grid plots") |
|
|
return plots |
|
|
|
|
|
def generate_varied_plots( |
|
|
self, |
|
|
site: SiteBoundary, |
|
|
road_network: RoadNetwork, |
|
|
size_distribution: Dict[str, float] = None |
|
|
) -> List[Plot]: |
|
|
""" |
|
|
Generate plots with varied sizes |
|
|
|
|
|
Args: |
|
|
site: Site boundary |
|
|
road_network: Road network for access checking |
|
|
size_distribution: Dict of size:percentage, e.g., {'small': 0.3, 'medium': 0.5, 'large': 0.2} |
|
|
|
|
|
Returns: |
|
|
List of Plot objects |
|
|
""" |
|
|
if size_distribution is None: |
|
|
size_distribution = { |
|
|
'small': 0.3, |
|
|
'medium': 0.5, |
|
|
'large': 0.2 |
|
|
} |
|
|
|
|
|
size_specs = { |
|
|
'small': {'width': 40, 'depth': 50}, |
|
|
'medium': {'width': 70, 'depth': 70}, |
|
|
'large': {'width': 100, 'depth': 100} |
|
|
} |
|
|
|
|
|
self.logger.info("Generating varied size plots") |
|
|
|
|
|
setback = self.regulations.get('setbacks', {}).get('boundary_minimum', 50) |
|
|
buildable = site.geometry.buffer(-setback) |
|
|
|
|
|
road_polygons = self._get_road_polygons(road_network) |
|
|
if road_polygons: |
|
|
road_union = unary_union(road_polygons) |
|
|
buildable = buildable.difference(road_union.buffer(5)) |
|
|
|
|
|
if buildable.is_empty: |
|
|
return [] |
|
|
|
|
|
plots = [] |
|
|
remaining_area = buildable |
|
|
plot_id = 0 |
|
|
|
|
|
|
|
|
total_buildable = buildable.area |
|
|
target_areas = { |
|
|
size: total_buildable * pct for size, pct in size_distribution.items() |
|
|
} |
|
|
|
|
|
for size, target_area in target_areas.items(): |
|
|
generated_area = 0 |
|
|
specs = size_specs[size] |
|
|
|
|
|
while generated_area < target_area and not remaining_area.is_empty: |
|
|
|
|
|
plot = self._place_plot( |
|
|
remaining_area, |
|
|
specs['width'], |
|
|
specs['depth'], |
|
|
road_network, |
|
|
f"plot_{plot_id:03d}" |
|
|
) |
|
|
|
|
|
if plot: |
|
|
plots.append(plot) |
|
|
remaining_area = remaining_area.difference(plot.geometry.buffer(5)) |
|
|
generated_area += plot.area_sqm |
|
|
plot_id += 1 |
|
|
else: |
|
|
break |
|
|
|
|
|
self.logger.info(f"Generated {len(plots)} varied plots") |
|
|
return plots |
|
|
|
|
|
def _place_plot( |
|
|
self, |
|
|
available_area: Polygon, |
|
|
width: float, |
|
|
depth: float, |
|
|
road_network: RoadNetwork, |
|
|
plot_id: str |
|
|
) -> Optional[Plot]: |
|
|
""" |
|
|
Try to place a single plot in available area |
|
|
""" |
|
|
if available_area.is_empty: |
|
|
return None |
|
|
|
|
|
minx, miny, maxx, maxy = available_area.bounds |
|
|
|
|
|
|
|
|
for _ in range(50): |
|
|
x = np.random.uniform(minx, maxx - width) |
|
|
y = np.random.uniform(miny, maxy - depth) |
|
|
|
|
|
plot_geom = box(x, y, x + width, y + depth) |
|
|
|
|
|
if available_area.contains(plot_geom): |
|
|
return Plot( |
|
|
id=plot_id, |
|
|
geometry=plot_geom, |
|
|
area_sqm=plot_geom.area, |
|
|
type=PlotType.INDUSTRIAL, |
|
|
width_m=width, |
|
|
depth_m=depth, |
|
|
frontage_m=width, |
|
|
has_road_access=self._check_road_access(plot_geom, road_network) |
|
|
) |
|
|
|
|
|
return None |
|
|
|
|
|
def generate_green_spaces( |
|
|
self, |
|
|
site: SiteBoundary, |
|
|
industrial_plots: List[Plot], |
|
|
road_network: RoadNetwork, |
|
|
target_ratio: float = 0.15 |
|
|
) -> List[Plot]: |
|
|
""" |
|
|
Generate green space plots to meet minimum requirement |
|
|
|
|
|
Args: |
|
|
site: Site boundary |
|
|
industrial_plots: Existing industrial plots |
|
|
road_network: Road network |
|
|
target_ratio: Target green space ratio (default 15%) |
|
|
|
|
|
Returns: |
|
|
List of green space Plot objects |
|
|
""" |
|
|
self.logger.info(f"Generating green spaces (target: {target_ratio*100}%)") |
|
|
|
|
|
target_area = site.buildable_area_sqm * target_ratio |
|
|
|
|
|
|
|
|
used_polys = [p.geometry for p in industrial_plots] |
|
|
road_polys = self._get_road_polygons(road_network) |
|
|
|
|
|
all_used = unary_union(used_polys + road_polys) |
|
|
|
|
|
|
|
|
setback = self.regulations.get('setbacks', {}).get('boundary_minimum', 50) |
|
|
buildable = site.geometry.buffer(-setback) |
|
|
remaining = buildable.difference(all_used.buffer(2)) |
|
|
|
|
|
green_plots = [] |
|
|
|
|
|
if remaining.is_empty: |
|
|
self.logger.warning("No remaining area for green space") |
|
|
return green_plots |
|
|
|
|
|
|
|
|
if isinstance(remaining, MultiPolygon): |
|
|
polygons = list(remaining.geoms) |
|
|
else: |
|
|
polygons = [remaining] |
|
|
|
|
|
for i, poly in enumerate(polygons): |
|
|
if poly.area >= 50: |
|
|
green_plot = Plot( |
|
|
id=f"green_{i:03d}", |
|
|
geometry=poly, |
|
|
area_sqm=poly.area, |
|
|
type=PlotType.GREEN_SPACE, |
|
|
width_m=self._estimate_width(poly), |
|
|
depth_m=self._estimate_depth(poly) |
|
|
) |
|
|
green_plots.append(green_plot) |
|
|
|
|
|
total_green = sum(p.area_sqm for p in green_plots) |
|
|
self.logger.info( |
|
|
f"Generated {len(green_plots)} green plots, " |
|
|
f"total: {total_green:.0f}m² ({total_green/site.buildable_area_sqm*100:.1f}%)" |
|
|
) |
|
|
|
|
|
return green_plots |
|
|
|
|
|
def _get_road_polygons(self, road_network: RoadNetwork) -> List[Polygon]: |
|
|
"""Get road polygons from road network""" |
|
|
if not road_network: |
|
|
return [] |
|
|
|
|
|
road_config = self.regulations.get('roads', {}) |
|
|
primary_width = road_config.get('primary_width_m', 24) |
|
|
secondary_width = road_config.get('secondary_width_m', 16) |
|
|
|
|
|
polygons = [] |
|
|
|
|
|
if road_network.primary_roads: |
|
|
roads = road_network.primary_roads.geoms if hasattr(road_network.primary_roads, 'geoms') else [road_network.primary_roads] |
|
|
for road in roads: |
|
|
if isinstance(road, LineString): |
|
|
polygons.append(road.buffer(primary_width / 2, cap_style=2)) |
|
|
|
|
|
if road_network.secondary_roads: |
|
|
roads = road_network.secondary_roads.geoms if hasattr(road_network.secondary_roads, 'geoms') else [road_network.secondary_roads] |
|
|
for road in roads: |
|
|
if isinstance(road, LineString): |
|
|
polygons.append(road.buffer(secondary_width / 2, cap_style=2)) |
|
|
|
|
|
return polygons |
|
|
|
|
|
def _check_road_access( |
|
|
self, |
|
|
plot_geom: Polygon, |
|
|
road_network: RoadNetwork, |
|
|
max_distance: float = 200 |
|
|
) -> bool: |
|
|
"""Check if plot has road access within max distance""" |
|
|
if not road_network: |
|
|
return False |
|
|
|
|
|
all_roads = [] |
|
|
if road_network.primary_roads: |
|
|
roads = road_network.primary_roads.geoms if hasattr(road_network.primary_roads, 'geoms') else [road_network.primary_roads] |
|
|
all_roads.extend(roads) |
|
|
if road_network.secondary_roads: |
|
|
roads = road_network.secondary_roads.geoms if hasattr(road_network.secondary_roads, 'geoms') else [road_network.secondary_roads] |
|
|
all_roads.extend(roads) |
|
|
|
|
|
for road in all_roads: |
|
|
if plot_geom.distance(road) <= max_distance: |
|
|
return True |
|
|
|
|
|
return False |
|
|
|
|
|
def _estimate_width(self, geom: Polygon) -> float: |
|
|
"""Estimate plot width from geometry""" |
|
|
minx, miny, maxx, maxy = geom.bounds |
|
|
return maxx - minx |
|
|
|
|
|
def _estimate_depth(self, geom: Polygon) -> float: |
|
|
"""Estimate plot depth from geometry""" |
|
|
minx, miny, maxx, maxy = geom.bounds |
|
|
return maxy - miny |
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
from src.geometry.site_processor import SiteProcessor |
|
|
from src.geometry.road_network import RoadNetworkGenerator |
|
|
|
|
|
|
|
|
processor = SiteProcessor() |
|
|
coords = [(0, 0), (500, 0), (500, 500), (0, 500), (0, 0)] |
|
|
site = processor.import_from_coordinates(coords) |
|
|
|
|
|
|
|
|
road_gen = RoadNetworkGenerator() |
|
|
roads = road_gen.generate_grid_network(site, primary_spacing=150) |
|
|
|
|
|
|
|
|
plot_gen = PlotGenerator() |
|
|
|
|
|
|
|
|
grid_plots = plot_gen.generate_grid_plots( |
|
|
site, roads, |
|
|
plot_width=80, |
|
|
plot_depth=100 |
|
|
) |
|
|
|
|
|
print(f"Grid plots: {len(grid_plots)}") |
|
|
total_area = sum(p.area_sqm for p in grid_plots) |
|
|
print(f"Total industrial area: {total_area:.0f}m²") |
|
|
|
|
|
|
|
|
green_plots = plot_gen.generate_green_spaces( |
|
|
site, grid_plots, roads, |
|
|
target_ratio=0.15 |
|
|
) |
|
|
|
|
|
print(f"Green plots: {len(green_plots)}") |
|
|
green_area = sum(p.area_sqm for p in green_plots) |
|
|
print(f"Total green area: {green_area:.0f}m²") |
|
|
|
|
|
|
|
|
with_access = sum(1 for p in grid_plots if p.has_road_access) |
|
|
print(f"Plots with road access: {with_access}/{len(grid_plots)}") |
|
|
|