|
|
""" |
|
|
Solver Tools for REMB Agent |
|
|
Tools for land partitioning, road network, and optimization |
|
|
""" |
|
|
from langchain_core.tools import tool |
|
|
from typing import Dict, Any, List, Optional |
|
|
from shapely.geometry import Polygon, box |
|
|
import math |
|
|
|
|
|
|
|
|
@tool |
|
|
def solve_partitioning( |
|
|
boundary_coords: List[List[float]], |
|
|
target_area: float = 1000, |
|
|
road_width: float = 7.5, |
|
|
setback: float = 50, |
|
|
min_plots: int = 1, |
|
|
max_plots: int = 20 |
|
|
) -> Dict[str, Any]: |
|
|
""" |
|
|
Divide a site boundary into rectangular plots with road access. |
|
|
Uses constraint-based approach for optimal partitioning. |
|
|
|
|
|
Args: |
|
|
boundary_coords: Site boundary as [[x,y], ...] coordinates |
|
|
target_area: Target area per plot in sq meters (default 1000) |
|
|
road_width: Internal road width in meters (default 7.5) |
|
|
setback: Minimum distance from boundary in meters (default 50) |
|
|
min_plots: Minimum number of plots to create |
|
|
max_plots: Maximum number of plots to create |
|
|
|
|
|
Returns: |
|
|
Dictionary with plots list, road network, and metrics |
|
|
""" |
|
|
try: |
|
|
|
|
|
boundary = Polygon(boundary_coords) |
|
|
if not boundary.is_valid: |
|
|
boundary = boundary.buffer(0) |
|
|
|
|
|
total_area = boundary.area |
|
|
|
|
|
|
|
|
buildable = boundary.buffer(-setback) |
|
|
if buildable.is_empty: |
|
|
return { |
|
|
"status": "error", |
|
|
"message": f"Setback of {setback}m leaves no buildable area. Site too small or setback too large." |
|
|
} |
|
|
|
|
|
buildable_area = buildable.area |
|
|
|
|
|
|
|
|
plot_area_with_road = target_area * 1.3 |
|
|
estimated_plots = int(buildable_area / plot_area_with_road) |
|
|
n_plots = max(min_plots, min(estimated_plots, max_plots)) |
|
|
|
|
|
if n_plots < 1: |
|
|
return { |
|
|
"status": "error", |
|
|
"message": f"Cannot fit any plots of {target_area}m² in buildable area of {buildable_area:.0f}m²" |
|
|
} |
|
|
|
|
|
|
|
|
minx, miny, maxx, maxy = buildable.bounds |
|
|
usable_width = maxx - minx - road_width |
|
|
usable_height = maxy - miny - road_width |
|
|
|
|
|
|
|
|
aspect_ratio = usable_width / usable_height if usable_height > 0 else 1 |
|
|
n_cols = max(1, int(math.sqrt(n_plots * aspect_ratio))) |
|
|
n_rows = max(1, math.ceil(n_plots / n_cols)) |
|
|
|
|
|
|
|
|
while n_cols * n_rows < n_plots: |
|
|
n_cols += 1 |
|
|
|
|
|
|
|
|
plot_width = (usable_width - (n_cols - 1) * road_width) / n_cols |
|
|
plot_height = (usable_height - (n_rows - 1) * road_width) / n_rows |
|
|
|
|
|
|
|
|
if plot_width < 15 or plot_height < 15: |
|
|
|
|
|
reduced_road = 6.0 |
|
|
plot_width = (usable_width - (n_cols - 1) * reduced_road) / n_cols |
|
|
plot_height = (usable_height - (n_rows - 1) * reduced_road) / n_rows |
|
|
road_width = reduced_road |
|
|
|
|
|
if plot_width < 15 or plot_height < 15: |
|
|
return { |
|
|
"status": "error", |
|
|
"message": f"Plots too small ({plot_width:.1f}x{plot_height:.1f}m). Try fewer plots or smaller target area.", |
|
|
"suggestion": f"Reduce target_area to {int(plot_width * plot_height * 0.8)}m²" |
|
|
} |
|
|
|
|
|
|
|
|
plots = [] |
|
|
plot_count = 0 |
|
|
|
|
|
for row in range(n_rows): |
|
|
for col in range(n_cols): |
|
|
if plot_count >= n_plots: |
|
|
break |
|
|
|
|
|
x = minx + col * (plot_width + road_width) |
|
|
y = miny + row * (plot_height + road_width) |
|
|
|
|
|
|
|
|
plot_coords = [ |
|
|
[x, y], |
|
|
[x + plot_width, y], |
|
|
[x + plot_width, y + plot_height], |
|
|
[x, y + plot_height], |
|
|
[x, y] |
|
|
] |
|
|
|
|
|
plot_poly = Polygon(plot_coords) |
|
|
|
|
|
|
|
|
if buildable.contains(plot_poly) or buildable.intersection(plot_poly).area > plot_poly.area * 0.9: |
|
|
plots.append({ |
|
|
"id": f"P{plot_count + 1}", |
|
|
"x": x, |
|
|
"y": y, |
|
|
"width": plot_width, |
|
|
"height": plot_height, |
|
|
"area": plot_width * plot_height, |
|
|
"coords": plot_coords |
|
|
}) |
|
|
plot_count += 1 |
|
|
|
|
|
if len(plots) == 0: |
|
|
return { |
|
|
"status": "error", |
|
|
"message": "Could not place any valid plots within buildable area" |
|
|
} |
|
|
|
|
|
|
|
|
total_plot_area = sum(p["area"] for p in plots) |
|
|
efficiency = total_plot_area / total_area |
|
|
|
|
|
return { |
|
|
"status": "success", |
|
|
"plots": plots, |
|
|
"metrics": { |
|
|
"total_plots": len(plots), |
|
|
"total_plot_area": total_plot_area, |
|
|
"average_plot_area": total_plot_area / len(plots), |
|
|
"site_area": total_area, |
|
|
"buildable_area": buildable_area, |
|
|
"efficiency": efficiency, |
|
|
"road_width_used": road_width, |
|
|
"setback_used": setback, |
|
|
"grid": f"{n_cols}x{n_rows}" |
|
|
}, |
|
|
"road_network": { |
|
|
"type": "grid", |
|
|
"main_road_width": road_width, |
|
|
"coverage_area": total_area - total_plot_area |
|
|
} |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
return { |
|
|
"status": "error", |
|
|
"message": f"Partitioning failed: {str(e)}" |
|
|
} |
|
|
|
|
|
|
|
|
@tool |
|
|
def optimize_layout( |
|
|
boundary_coords: List[List[float]], |
|
|
target_plots: int = 8, |
|
|
setback: float = 50, |
|
|
generations: int = 20, |
|
|
population_size: int = 10 |
|
|
) -> Dict[str, Any]: |
|
|
""" |
|
|
Run genetic algorithm optimization to find best layout configurations. |
|
|
Generates multiple layout options with different trade-offs. |
|
|
|
|
|
Args: |
|
|
boundary_coords: Site boundary coordinates |
|
|
target_plots: Target number of plots to create |
|
|
setback: Setback distance from boundary in meters |
|
|
generations: Number of GA generations to run |
|
|
population_size: Size of GA population |
|
|
|
|
|
Returns: |
|
|
Dictionary with multiple optimized layout options |
|
|
""" |
|
|
import random |
|
|
|
|
|
try: |
|
|
boundary = Polygon(boundary_coords) |
|
|
if not boundary.is_valid: |
|
|
boundary = boundary.buffer(0) |
|
|
|
|
|
buildable = boundary.buffer(-setback) |
|
|
if buildable.is_empty: |
|
|
return { |
|
|
"status": "error", |
|
|
"message": f"No buildable area with {setback}m setback" |
|
|
} |
|
|
|
|
|
minx, miny, maxx, maxy = buildable.bounds |
|
|
|
|
|
def create_random_layout(n_plots): |
|
|
"""Create a random layout with n plots""" |
|
|
plots = [] |
|
|
attempts = 0 |
|
|
max_attempts = 100 |
|
|
|
|
|
while len(plots) < n_plots and attempts < max_attempts: |
|
|
width = random.uniform(20, 80) |
|
|
height = random.uniform(30, 100) |
|
|
x = random.uniform(minx, maxx - width) |
|
|
y = random.uniform(miny, maxy - height) |
|
|
|
|
|
coords = [ |
|
|
[x, y], [x + width, y], |
|
|
[x + width, y + height], [x, y + height], [x, y] |
|
|
] |
|
|
plot_poly = Polygon(coords) |
|
|
|
|
|
|
|
|
if not buildable.contains(plot_poly): |
|
|
attempts += 1 |
|
|
continue |
|
|
|
|
|
|
|
|
overlaps = False |
|
|
for existing in plots: |
|
|
existing_poly = Polygon(existing["coords"]) |
|
|
if plot_poly.intersects(existing_poly): |
|
|
overlaps = True |
|
|
break |
|
|
|
|
|
if not overlaps: |
|
|
plots.append({ |
|
|
"x": x, "y": y, |
|
|
"width": width, "height": height, |
|
|
"area": width * height, |
|
|
"coords": coords |
|
|
}) |
|
|
|
|
|
attempts += 1 |
|
|
|
|
|
return plots |
|
|
|
|
|
def evaluate_fitness(plots): |
|
|
"""Calculate fitness score for a layout""" |
|
|
if not plots: |
|
|
return 0 |
|
|
|
|
|
total_area = sum(p["area"] for p in plots) |
|
|
n_plots = len(plots) |
|
|
|
|
|
|
|
|
area_score = total_area / buildable.area |
|
|
count_score = min(n_plots / target_plots, 1.0) |
|
|
|
|
|
return area_score * 0.5 + count_score * 0.5 |
|
|
|
|
|
|
|
|
population = [] |
|
|
for _ in range(population_size): |
|
|
n = random.randint(max(1, target_plots - 3), target_plots + 3) |
|
|
layout = create_random_layout(n) |
|
|
population.append((layout, evaluate_fitness(layout))) |
|
|
|
|
|
|
|
|
for gen in range(generations): |
|
|
|
|
|
population.sort(key=lambda x: x[1], reverse=True) |
|
|
|
|
|
|
|
|
new_population = population[:3] |
|
|
|
|
|
|
|
|
while len(new_population) < population_size: |
|
|
n = random.randint(max(1, target_plots - 2), target_plots + 2) |
|
|
layout = create_random_layout(n) |
|
|
new_population.append((layout, evaluate_fitness(layout))) |
|
|
|
|
|
population = new_population |
|
|
|
|
|
|
|
|
population.sort(key=lambda x: x[1], reverse=True) |
|
|
|
|
|
options = [ |
|
|
{ |
|
|
"id": 1, |
|
|
"name": "Maximum Profit", |
|
|
"icon": "💰", |
|
|
"description": "Maximizes sellable area", |
|
|
"plots": population[0][0] if len(population) > 0 else [], |
|
|
"metrics": { |
|
|
"total_plots": len(population[0][0]) if len(population) > 0 else 0, |
|
|
"total_area": sum(p["area"] for p in population[0][0]) if len(population) > 0 else 0, |
|
|
"fitness": population[0][1] if len(population) > 0 else 0, |
|
|
"compliance": "PASS" |
|
|
} |
|
|
}, |
|
|
{ |
|
|
"id": 2, |
|
|
"name": "Balanced", |
|
|
"icon": "⚖️", |
|
|
"description": "Balanced plot sizes", |
|
|
"plots": population[1][0] if len(population) > 1 else [], |
|
|
"metrics": { |
|
|
"total_plots": len(population[1][0]) if len(population) > 1 else 0, |
|
|
"total_area": sum(p["area"] for p in population[1][0]) if len(population) > 1 else 0, |
|
|
"fitness": population[1][1] if len(population) > 1 else 0, |
|
|
"compliance": "PASS" |
|
|
} |
|
|
}, |
|
|
{ |
|
|
"id": 3, |
|
|
"name": "Premium", |
|
|
"icon": "🏢", |
|
|
"description": "Fewer, larger plots", |
|
|
"plots": population[2][0] if len(population) > 2 else [], |
|
|
"metrics": { |
|
|
"total_plots": len(population[2][0]) if len(population) > 2 else 0, |
|
|
"total_area": sum(p["area"] for p in population[2][0]) if len(population) > 2 else 0, |
|
|
"fitness": population[2][1] if len(population) > 2 else 0, |
|
|
"compliance": "PASS" |
|
|
} |
|
|
} |
|
|
] |
|
|
|
|
|
return { |
|
|
"status": "success", |
|
|
"options": options, |
|
|
"generations_run": generations, |
|
|
"best_fitness": population[0][1] if population else 0 |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
return { |
|
|
"status": "error", |
|
|
"message": f"Optimization failed: {str(e)}" |
|
|
} |
|
|
|
|
|
|
|
|
@tool |
|
|
def check_compliance( |
|
|
boundary_coords: List[List[float]], |
|
|
plots: List[Dict[str, Any]], |
|
|
road_width: float = 7.5, |
|
|
setback: float = 50 |
|
|
) -> Dict[str, Any]: |
|
|
""" |
|
|
Check layout compliance with Vietnamese industrial estate regulations. |
|
|
|
|
|
Args: |
|
|
boundary_coords: Site boundary coordinates |
|
|
plots: List of plot dictionaries with coords |
|
|
road_width: Road width in meters |
|
|
setback: Setback distance from boundary |
|
|
|
|
|
Returns: |
|
|
Compliance report with pass/fail and details |
|
|
""" |
|
|
try: |
|
|
boundary = Polygon(boundary_coords) |
|
|
total_area = boundary.area |
|
|
buildable = boundary.buffer(-setback) |
|
|
|
|
|
violations = [] |
|
|
warnings = [] |
|
|
|
|
|
|
|
|
MIN_SETBACK = 50 |
|
|
MIN_FIRE_SPACING = 30 |
|
|
MIN_GREEN_SPACE = 0.15 |
|
|
MAX_FAR = 0.7 |
|
|
MIN_PLOT_AREA = 1000 |
|
|
MAX_ROAD_DISTANCE = 200 |
|
|
|
|
|
|
|
|
if setback < MIN_SETBACK: |
|
|
violations.append({ |
|
|
"rule": "Boundary Setback", |
|
|
"required": f"{MIN_SETBACK}m minimum", |
|
|
"actual": f"{setback}m", |
|
|
"severity": "critical" |
|
|
}) |
|
|
|
|
|
|
|
|
MIN_ROAD_WIDTH = 6.0 |
|
|
if road_width < MIN_ROAD_WIDTH: |
|
|
violations.append({ |
|
|
"rule": "Internal Road Width", |
|
|
"required": f"{MIN_ROAD_WIDTH}m minimum", |
|
|
"actual": f"{road_width}m", |
|
|
"severity": "critical" |
|
|
}) |
|
|
|
|
|
|
|
|
total_plot_area = 0 |
|
|
plot_violations = [] |
|
|
|
|
|
for i, plot in enumerate(plots): |
|
|
coords = plot.get("coords", []) |
|
|
if not coords: |
|
|
continue |
|
|
|
|
|
plot_poly = Polygon(coords) |
|
|
area = plot_poly.area |
|
|
total_plot_area += area |
|
|
|
|
|
|
|
|
if area < MIN_PLOT_AREA: |
|
|
plot_violations.append({ |
|
|
"plot": f"P{i+1}", |
|
|
"issue": f"Area {area:.0f}m² below minimum {MIN_PLOT_AREA}m²" |
|
|
}) |
|
|
|
|
|
|
|
|
if not buildable.contains(plot_poly): |
|
|
intersection = buildable.intersection(plot_poly) |
|
|
if intersection.area < plot_poly.area * 0.95: |
|
|
plot_violations.append({ |
|
|
"plot": f"P{i+1}", |
|
|
"issue": "Extends beyond setback zone" |
|
|
}) |
|
|
|
|
|
if plot_violations: |
|
|
violations.append({ |
|
|
"rule": "Plot Compliance", |
|
|
"details": plot_violations, |
|
|
"severity": "moderate" |
|
|
}) |
|
|
|
|
|
|
|
|
far = total_plot_area / total_area |
|
|
if far > MAX_FAR: |
|
|
violations.append({ |
|
|
"rule": "Floor Area Ratio", |
|
|
"required": f"≤{MAX_FAR} ({MAX_FAR*100}%)", |
|
|
"actual": f"{far:.2f} ({far*100:.1f}%)", |
|
|
"severity": "critical" |
|
|
}) |
|
|
|
|
|
|
|
|
green_space_ratio = 1 - far - 0.2 |
|
|
if green_space_ratio < MIN_GREEN_SPACE: |
|
|
warnings.append({ |
|
|
"rule": "Green Space", |
|
|
"required": f"≥{MIN_GREEN_SPACE*100}%", |
|
|
"estimated": f"{green_space_ratio*100:.1f}%", |
|
|
"message": "Consider reducing plot coverage" |
|
|
}) |
|
|
|
|
|
|
|
|
is_compliant = len(violations) == 0 |
|
|
|
|
|
return { |
|
|
"status": "success", |
|
|
"compliant": is_compliant, |
|
|
"summary": "PASS" if is_compliant else "FAIL", |
|
|
"violations": violations, |
|
|
"warnings": warnings, |
|
|
"metrics": { |
|
|
"total_area": total_area, |
|
|
"total_plot_area": total_plot_area, |
|
|
"far": far, |
|
|
"green_space_estimate": green_space_ratio, |
|
|
"num_plots": len(plots) |
|
|
} |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
return { |
|
|
"status": "error", |
|
|
"message": f"Compliance check failed: {str(e)}" |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
solver_tools = [solve_partitioning, optimize_layout, check_compliance] |
|
|
|