| from __future__ import annotations |
|
|
| import math |
| import tempfile |
| import textwrap |
| from pathlib import Path |
| from typing import Any |
|
|
| from PIL import Image, ImageDraw, ImageFont |
|
|
| from .models import SiteSelection |
|
|
|
|
| INK = "#111817" |
| INK_2 = "#33423f" |
| MUTED = "#687572" |
| PAPER = "#f6f1e7" |
| PANEL = "#ffffff" |
| LINE = "#c8d0cc" |
| TEAL = "#006b62" |
| TEAL_DARK = "#0f4b46" |
| TEAL_LIGHT = "#d9efeb" |
| OCHRE = "#d9972b" |
| OCHRE_LIGHT = "#f4dfb5" |
| CLAY = "#b55a3d" |
| CLAY_LIGHT = "#edd0c5" |
| BLUE = "#356fa3" |
| BLUE_LIGHT = "#d9e8f5" |
| GREEN = "#2f7d59" |
| GREEN_LIGHT = "#dcefdc" |
| CHARCOAL = "#0f1c1b" |
|
|
| MONTH_LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] |
|
|
|
|
| def create_diagrams( |
| selection: SiteSelection, |
| climate: dict[str, Any], |
| osm_context: dict[str, Any], |
| sun_summary: dict[str, str], |
| topography: dict[str, Any] | None = None, |
| soil: dict[str, Any] | None = None, |
| ) -> list[str]: |
| out_dir = Path(tempfile.mkdtemp(prefix="sis_diagrams_")) |
| creators = ( |
| lambda: create_climate_chart(climate, out_dir / "climate.png"), |
| lambda: create_sun_wind_diagram(sun_summary, out_dir / "sun_wind.png"), |
| lambda: create_context_diagram(selection, osm_context, out_dir / "context.png"), |
| lambda: create_access_edges_diagram(selection, osm_context, out_dir / "access_edges.png"), |
| lambda: create_climate_strategy_sheet(climate, sun_summary, out_dir / "climate_strategy.png"), |
| lambda: create_constraints_matrix_sheet( |
| selection, |
| climate, |
| osm_context, |
| sun_summary, |
| topography or {}, |
| soil or {}, |
| out_dir / "constraints_matrix.png", |
| ), |
| ) |
| paths: list[str] = [] |
| for creator in creators: |
| try: |
| paths.append(str(creator())) |
| except Exception: |
| continue |
| return paths |
|
|
|
|
| def create_climate_chart(climate: dict[str, Any], output_path: Path) -> Path: |
| import matplotlib |
|
|
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
|
|
| months = _monthly_rows(climate) |
| if not _has_monthly_values(months): |
| return _empty_chart(output_path, "Climate summary", "Climate data unavailable for this run.") |
|
|
| x = [m.get("month") or idx + 1 for idx, m in enumerate(months)] |
| temps = [_value(m.get("temperature_c"), 0.0) for m in months] |
| rain = [_value(m.get("precipitation_mm"), 0.0) for m in months] |
| humidity = [m.get("humidity_pct") for m in months] |
|
|
| fig, ax1 = plt.subplots(figsize=(10.8, 5.8), dpi=160) |
| fig.patch.set_facecolor(PANEL) |
| ax1.set_facecolor(PANEL) |
| ax1.bar(x, rain, color=TEAL, alpha=0.72, label="Precipitation (mm)", width=0.66) |
| ax1.set_ylabel("Precipitation (mm)", color=TEAL_DARK, fontsize=10, fontweight="bold") |
| ax1.tick_params(axis="y", labelcolor=TEAL_DARK, labelsize=9) |
| ax1.set_xticks(range(1, 13), MONTH_LABELS) |
| ax1.grid(axis="y", color="#dfe5e2", linewidth=0.8) |
|
|
| ax2 = ax1.twinx() |
| ax2.plot(x, temps, color=CLAY, linewidth=2.8, marker="o", markersize=4.2, label="Mean temp (C)") |
| ax2.set_ylabel("Mean temperature (C)", color=CLAY, fontsize=10, fontweight="bold") |
| ax2.tick_params(axis="y", labelcolor=CLAY, labelsize=9) |
|
|
| if any(v is not None for v in humidity): |
| ax3 = ax1.twinx() |
| ax3.spines["right"].set_position(("axes", 1.10)) |
| ax3.plot(x, [_value(v, 0.0) for v in humidity], color=OCHRE, linewidth=1.8, linestyle="--", label="Humidity (%)") |
| ax3.set_ylabel("Humidity (%)", color="#8b5d12", fontsize=9, fontweight="bold") |
| ax3.tick_params(axis="y", labelcolor="#8b5d12", labelsize=8) |
| ax3.spines["right"].set_color("#d9c48a") |
|
|
| ax1.set_title("Climate diagram - design context", loc="left", fontsize=15, fontweight="bold", color=INK, pad=14) |
| ax1.text( |
| 0, |
| 1.02, |
| "Monthly precipitation, temperature and humidity from labelled Open-Meteo views. Not on-site measurement.", |
| transform=ax1.transAxes, |
| fontsize=8.8, |
| color=MUTED, |
| ) |
| for spine in ax1.spines.values(): |
| spine.set_color(LINE) |
| for spine in ax2.spines.values(): |
| spine.set_visible(False) |
|
|
| fig.tight_layout(rect=(0, 0.06, 1, 0.96)) |
| fig.text(0.02, 0.02, "Source: Open-Meteo. Confidence: medium. Verify local conditions on site.", fontsize=8, color=INK_2) |
| fig.savefig(output_path, bbox_inches="tight") |
| plt.close(fig) |
| return output_path |
|
|
|
|
| def create_sun_wind_diagram(sun_summary: dict[str, str], output_path: Path) -> Path: |
| import matplotlib |
|
|
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
|
|
| fig = plt.figure(figsize=(9.8, 6.8), dpi=160) |
| fig.patch.set_facecolor(PANEL) |
| grid = fig.add_gridspec(1, 2, width_ratios=[1.12, 0.88], wspace=0.08) |
| ax = fig.add_subplot(grid[0, 0], projection="polar") |
| note_ax = fig.add_subplot(grid[0, 1]) |
| note_ax.axis("off") |
| ax.set_facecolor(PANEL) |
| ax.set_theta_zero_location("N") |
| ax.set_theta_direction(-1) |
| ax.set_yticklabels([]) |
| ax.set_xticks([0, math.pi / 2, math.pi, 3 * math.pi / 2]) |
| ax.set_xticklabels(["N", "E", "S", "W"], fontsize=11, fontweight="bold", color=INK) |
| ax.grid(color="#d9dfdc") |
| ax.spines["polar"].set_color("#5d6865") |
| ax.bar(math.radians(90), 1.0, width=math.radians(34), color=OCHRE_LIGHT, edgecolor=OCHRE, linewidth=1.0) |
| ax.bar(math.radians(270), 1.0, width=math.radians(34), color=CLAY_LIGHT, edgecolor=CLAY, linewidth=1.0) |
| sun_side = sun_summary.get("sun_side", "southern") |
| side_angle = 180 if sun_side == "southern" else 0 |
| ax.arrow(math.radians(side_angle), 0.18, 0, 0.52, width=0.018, color=CLAY, alpha=0.92) |
| ax.text(math.radians(90), 0.82, "morning\nsun", ha="center", va="center", fontsize=9, color="#7a5515") |
| ax.text(math.radians(270), 0.82, "afternoon\nheat", ha="center", va="center", fontsize=9, color="#7a3322") |
| ax.text(math.radians(side_angle), 0.82, "solar\narc cue", ha="center", va="center", fontsize=8.5, color=CLAY) |
| ax.set_title("Sun / wind orientation", y=1.08, fontsize=15, fontweight="bold", color=INK) |
|
|
| notes = [ |
| ("Orientation", sun_summary.get("orientation_note") or "Use site latitude for early solar orientation checks."), |
| ("East / west", sun_summary.get("east_west_note") or "Verify glare and afternoon heat on west-facing edges."), |
| ("Wind", sun_summary.get("wind_note") or "Regional/modelled wind must be checked on site."), |
| ("Limit", "No surrounding-building shadow or microclimate simulation."), |
| ] |
| y = 0.95 |
| for label, value in notes: |
| note_ax.text(0.02, y, label.upper(), fontsize=8.5, fontweight="bold", color=TEAL_DARK, transform=note_ax.transAxes) |
| note_ax.text(0.02, y - 0.06, _wrap(value, 44), fontsize=9.4, color=INK_2, va="top", transform=note_ax.transAxes) |
| y -= 0.22 |
| fig.text(0.04, 0.035, "Source: deterministic orientation calculation + weather wind cue where available.", fontsize=8, color=INK_2) |
| fig.savefig(output_path, bbox_inches="tight", pad_inches=0.18) |
| plt.close(fig) |
| return output_path |
|
|
|
|
| def create_context_diagram(selection: SiteSelection, osm_context: dict[str, Any], output_path: Path) -> Path: |
| import matplotlib |
|
|
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
|
|
| fig, ax = plt.subplots(figsize=(9.0, 6.2), dpi=160) |
| fig.patch.set_facecolor(PANEL) |
| ax.set_facecolor(PANEL) |
| feature_layers = _osm_feature_layers(osm_context.get("features") or []) |
| _draw_base_context(ax, selection, feature_layers) |
| counts = osm_context.get("counts") or {} |
| summary = "\n".join(f"{key}: {value}" for key, value in list(counts.items())[:7]) or "OSM context unavailable or sparse." |
| ax.text( |
| 0.03, |
| 0.96, |
| summary, |
| transform=ax.transAxes, |
| va="top", |
| ha="left", |
| fontsize=9.5, |
| color=INK, |
| bbox={"facecolor": "white", "alpha": 0.90, "edgecolor": LINE, "boxstyle": "round,pad=0.45"}, |
| ) |
| _map_chrome(ax, "Geographic context - preliminary") |
| fig.text( |
| 0.02, |
| 0.02, |
| "Source: user geometry + OpenStreetMap/Overpass geometry where available. Verify roads, water, vegetation, access and building context on site.", |
| fontsize=8, |
| color=INK_2, |
| ) |
| fig.tight_layout() |
| fig.savefig(output_path, bbox_inches="tight") |
| plt.close(fig) |
| return output_path |
|
|
|
|
| def create_access_edges_diagram(selection: SiteSelection, osm_context: dict[str, Any], output_path: Path) -> Path: |
| import matplotlib |
|
|
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
|
|
| fig = plt.figure(figsize=(13.2, 8.0), dpi=160) |
| fig.patch.set_facecolor(PANEL) |
| grid = fig.add_gridspec(1, 3, width_ratios=[2.1, 0.04, 1.02]) |
| ax = fig.add_subplot(grid[0, 0]) |
| notes_ax = fig.add_subplot(grid[0, 2]) |
| notes_ax.axis("off") |
|
|
| feature_layers = _osm_feature_layers(osm_context.get("features") or []) |
| _draw_base_context(ax, selection, feature_layers, emphasize_roads=True) |
| _draw_edge_labels(ax, selection) |
| _map_chrome(ax, "Access, edges and mapped context") |
|
|
| counts = osm_context.get("counts") or {} |
| notes = [ |
| ("Mapped roads/access", f"{_count_prefix(counts, 'roads/access')} features. Verify entry points, road width, turning, pedestrian edge and service access."), |
| ("Built context", f"{_count_prefix(counts, 'buildings')} mapped buildings. Verify adjacent heights, windows, privacy and shadow."), |
| ("Water / drainage", f"{_count_contains(counts, 'water')} mapped items. Verify edge condition, flooding, humidity and seasonal water movement."), |
| ("Green / open", f"{_count_contains(counts, 'green')} mapped items. Verify tree species, shade, root zones and removable/retainable vegetation."), |
| ("Boundary accuracy", selection.accuracy_label.replace("_", " ") + ". Drawn boundaries are approximate."), |
| ] |
| notes_ax.text(0.0, 0.98, "EDGE CHECKLIST", fontsize=10, color=TEAL_DARK, fontweight="bold", transform=notes_ax.transAxes) |
| notes_ax.text(0.0, 0.925, "What this map can and cannot support", fontsize=18, color=INK, fontweight="bold", transform=notes_ax.transAxes) |
| y = 0.82 |
| for label, value in notes: |
| notes_ax.add_patch(plt.Rectangle((0, y - 0.01), 0.98, 0.13, color="#f4f7f5", ec=LINE, lw=0.8, transform=notes_ax.transAxes)) |
| notes_ax.text(0.035, y + 0.08, label, fontsize=9.8, fontweight="bold", color=INK, transform=notes_ax.transAxes) |
| notes_ax.text(0.035, y + 0.035, _wrap(value, 44), fontsize=8.7, color=INK_2, va="top", transform=notes_ax.transAxes) |
| y -= 0.155 |
| notes_ax.text( |
| 0, |
| 0.03, |
| "Use this as a site-visit planning diagram, not legal plot verification. OSM may be incomplete or outdated.", |
| fontsize=8.4, |
| color=MUTED, |
| wrap=True, |
| transform=notes_ax.transAxes, |
| ) |
| fig.savefig(output_path, bbox_inches="tight", pad_inches=0.22) |
| plt.close(fig) |
| return output_path |
|
|
|
|
| def create_climate_strategy_sheet(climate: dict[str, Any], sun_summary: dict[str, str], output_path: Path) -> Path: |
| img = Image.new("RGB", (1800, 1120), PANEL) |
| draw = ImageDraw.Draw(img) |
| fonts = _fonts() |
| months = _monthly_rows(climate) |
|
|
| draw.rectangle((0, 0, 1800, 130), fill=CHARCOAL) |
| draw.text((60, 34), "CLIMATE STRATEGY SHEET", fill="#8fd5cd", font=fonts["kicker"]) |
| draw.text((60, 70), "Design cues from current, recent and climate-normal style data", fill="#ffffff", font=fonts["title"]) |
|
|
| current = climate.get("forecast") or {} |
| normal = climate.get("climate_normal") or climate.get("recent_historical") or {} |
| cards = [ |
| ("Current temp", _metric(current.get("current_temperature_c"), "C"), "forecast/current"), |
| ("Humidity", _metric(current.get("current_humidity_pct"), "%"), "forecast/current"), |
| ("Wind", _metric(current.get("current_wind_speed_kmh"), "km/h"), "forecast/current"), |
| ("Annual rain", _metric(normal.get("total_precipitation_mm"), "mm"), "climate-normal style"), |
| ] |
| x = 60 |
| for title, value, source in cards: |
| draw.rounded_rectangle((x, 165, x + 390, 285), radius=10, fill=PAPER, outline=LINE, width=2) |
| draw.text((x + 24, 188), title.upper(), fill=TEAL_DARK, font=fonts["micro_bold"]) |
| draw.text((x + 24, 216), value, fill=INK, font=fonts["value"]) |
| draw.text((x + 24, 260), source, fill=MUTED, font=fonts["micro"]) |
| x += 420 |
|
|
| chart_box = (60, 335, 1740, 720) |
| draw.rounded_rectangle(chart_box, radius=10, fill="#fbfbf8", outline=LINE, width=2) |
| draw.text((88, 360), "Monthly climate pattern", fill=INK, font=fonts["panel"]) |
| if _has_monthly_values(months): |
| rain_values = [_value(m.get("precipitation_mm"), 0.0) for m in months] |
| temp_values = [_value(m.get("temperature_c"), 0.0) for m in months] |
| max_rain = max(max(rain_values), 1) |
| min_temp = min(temp_values) if temp_values else 0 |
| max_temp = max(temp_values) if temp_values else 1 |
| base_y = 630 |
| bar_top = 425 |
| slot = 126 |
| for idx, month in enumerate(months[:12]): |
| mx = 116 + idx * slot |
| rain = _value(month.get("precipitation_mm"), 0.0) |
| temp = _value(month.get("temperature_c"), 0.0) |
| bar_h = int((rain / max_rain) * 190) |
| temp_y = base_y - int(((temp - min_temp) / max((max_temp - min_temp), 1)) * 170) |
| fill = TEAL if rain >= 100 else "#9abcb7" |
| draw.rectangle((mx, base_y - bar_h, mx + 42, base_y), fill=fill) |
| draw.ellipse((mx + 58, temp_y - 6, mx + 70, temp_y + 6), fill=CLAY) |
| draw.text((mx - 2, base_y + 18), MONTH_LABELS[idx], fill=INK_2, font=fonts["micro_bold"]) |
| if rain >= 100: |
| draw.text((mx - 8, base_y - bar_h - 20), "rain", fill=TEAL_DARK, font=fonts["tiny"]) |
| if temp >= 30: |
| draw.text((mx + 47, temp_y - 28), "hot", fill=CLAY, font=fonts["tiny"]) |
| draw.text((88, 690), "Bars: precipitation. Dots: mean temperature. Rain/hot labels are design cues, not exact comfort thresholds.", fill=MUTED, font=fonts["micro"]) |
| else: |
| draw.text((88, 450), "Climate data unavailable. The report should not make climate claims from missing data.", fill=CLAY, font=fonts["body"]) |
|
|
| cue_box = (60, 760, 1740, 1040) |
| draw.rounded_rectangle(cue_box, radius=10, fill=PAPER, outline=LINE, width=2) |
| draw.text((88, 770), "Studio-use cues", fill=INK, font=fonts["panel"]) |
| cues = _climate_cues(climate, sun_summary) |
| col_x = [88, 650, 1210] |
| y_start = 830 |
| for idx, cue in enumerate(cues[:6]): |
| cx = col_x[idx % 3] |
| cy = y_start + (idx // 3) * 104 |
| draw.ellipse((cx, cy + 8, cx + 12, cy + 20), fill=OCHRE) |
| _draw_wrapped(draw, cue, (cx + 26, cy, cx + 500, cy + 88), fonts["body"], INK_2, max_lines=3) |
| draw.text((60, 1070), "Source: Open-Meteo labelled views + deterministic sun/orientation notes. Verify local microclimate, shade and wind on site.", fill=MUTED, font=fonts["micro"]) |
| img.save(output_path, quality=95) |
| return output_path |
|
|
|
|
| def create_constraints_matrix_sheet( |
| selection: SiteSelection, |
| climate: dict[str, Any], |
| osm_context: dict[str, Any], |
| sun_summary: dict[str, str], |
| topography: dict[str, Any], |
| soil: dict[str, Any], |
| output_path: Path, |
| ) -> Path: |
| img = Image.new("RGB", (2000, 1460), PANEL) |
| draw = ImageDraw.Draw(img) |
| fonts = _fonts() |
| draw.rectangle((0, 0, 2000, 130), fill=CHARCOAL) |
| draw.text((58, 34), "CONSTRAINTS / OPPORTUNITIES / VERIFICATION", fill="#8fd5cd", font=fonts["kicker"]) |
| draw.text((58, 70), "Evidence-backed matrix for studio discussion", fill="#ffffff", font=fonts["title"]) |
|
|
| rows = _matrix_rows(selection, climate, osm_context, sun_summary, topography, soil) |
| x = 58 |
| y = 168 |
| widths = [240, 470, 470, 610] |
| headers = ["Layer", "Evidence or data", "Design use", "Verify before final claim"] |
| header_h = 62 |
| draw.rectangle((x, y, x + sum(widths), y + header_h), fill="#eef4f2", outline=LINE) |
| hx = x |
| for width, header in zip(widths, headers): |
| draw.text((hx + 18, y + 20), header.upper(), fill=TEAL_DARK, font=fonts["micro_bold"]) |
| hx += width |
| draw.line((hx, y, hx, y + header_h), fill=LINE, width=2) |
| y += header_h |
| row_h = 116 |
| for idx, row in enumerate(rows): |
| fill = "#ffffff" if idx % 2 == 0 else "#f8faf8" |
| draw.rectangle((x, y, x + sum(widths), y + row_h), fill=fill, outline=LINE) |
| cx = x |
| for col_idx, width in enumerate(widths): |
| text = row[col_idx] |
| font = fonts["body_bold"] if col_idx == 0 else fonts["body"] |
| color = INK if col_idx == 0 else INK_2 |
| _draw_wrapped(draw, text, (cx + 18, y + 18, cx + width - 18, y + row_h - 12), font, color, max_lines=4) |
| cx += width |
| draw.line((cx, y, cx, y + row_h), fill=LINE, width=1) |
| y += row_h |
| footer = ( |
| "Matrix is a planning and verification aid. It separates computed geometry, public data and interpretation. " |
| "Do not use it as legal boundary, geotechnical, structural or final design advice." |
| ) |
| _draw_wrapped(draw, footer, (58, y + 28, 1900, y + 92), fonts["micro"], MUTED, max_lines=2) |
| img.save(output_path, quality=95) |
| return output_path |
|
|
|
|
| def _empty_chart(output_path: Path, title: str, message: str) -> Path: |
| import matplotlib |
|
|
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
|
|
| fig, ax = plt.subplots(figsize=(9.5, 5.2), dpi=150) |
| fig.patch.set_facecolor(PANEL) |
| ax.set_facecolor(PANEL) |
| ax.axis("off") |
| ax.text(0.03, 0.88, title, transform=ax.transAxes, fontsize=18, fontweight="bold", color=INK) |
| ax.text(0.03, 0.72, message, transform=ax.transAxes, fontsize=15, fontweight="bold", color=CLAY) |
| ax.text( |
| 0.03, |
| 0.58, |
| "The report must omit unsupported claims where source retrieval failed. Retry later or verify with official/studio-required sources.", |
| transform=ax.transAxes, |
| fontsize=10.5, |
| color=INK_2, |
| wrap=True, |
| ) |
| ax.text( |
| 0.03, |
| 0.15, |
| "Source status: unavailable. Confidence: low. Use site visit and alternate climate sources before making design claims.", |
| transform=ax.transAxes, |
| fontsize=8.5, |
| color=MUTED, |
| ) |
| fig.savefig(output_path, bbox_inches="tight") |
| plt.close(fig) |
| return output_path |
|
|
|
|
| def _draw_base_context( |
| ax: Any, |
| selection: SiteSelection, |
| feature_layers: dict[str, list[list[tuple[float, float]]]], |
| *, |
| emphasize_roads: bool = False, |
| ) -> None: |
| import matplotlib.pyplot as plt |
| from matplotlib.patches import Circle, Polygon |
|
|
| ax.set_facecolor("#fbfbf8") |
| _draw_feature_polygons(ax, feature_layers.get("landuse", []), "#e9dfcf", "#7c6b57", "land use", zorder=1) |
| _draw_feature_polygons(ax, feature_layers.get("green/open", []), GREEN_LIGHT, GREEN, "green / open", zorder=2) |
| _draw_feature_polygons(ax, feature_layers.get("water", []), BLUE_LIGHT, BLUE, "water / drainage", zorder=2) |
| _draw_feature_polygons(ax, feature_layers.get("buildings", []), "#d8d1c8", "#4a4740", "mapped buildings", zorder=3) |
| _draw_feature_lines(ax, feature_layers.get("roads/access", []), "#283936", "roads / access", zorder=4, width=2.4 if emphasize_roads else 1.6) |
| _draw_feature_points(ax, feature_layers.get("amenity", []), OCHRE, "amenities") |
|
|
| has_site_geometry = False |
| if selection.geometry_geojson: |
| coords = selection.geometry_geojson.get("coordinates", [[]])[0] |
| pts = [(lon, lat) for lon, lat in coords] |
| if len(pts) >= 3: |
| ax.add_patch(Polygon(pts, fill=True, alpha=0.30, color=TEAL, ec=TEAL_DARK, lw=3.0, zorder=8, label="site boundary")) |
| xs, ys = zip(*pts) |
| pad_x = max((max(xs) - min(xs)) * 0.45, 0.0007) |
| pad_y = max((max(ys) - min(ys)) * 0.45, 0.0007) |
| ax.set_xlim(min(xs) - pad_x, max(xs) + pad_x) |
| ax.set_ylim(min(ys) - pad_y, max(ys) + pad_y) |
| has_site_geometry = True |
| elif selection.anchor_lat is not None and selection.anchor_lon is not None: |
| radius_deg = (selection.radius_m or 250) / 111_000 |
| ax.add_patch( |
| Circle( |
| (selection.anchor_lon, selection.anchor_lat), |
| radius_deg, |
| fill=True, |
| alpha=0.24, |
| color=TEAL, |
| ec=TEAL_DARK, |
| lw=2.8, |
| zorder=8, |
| label="pin radius", |
| ) |
| ) |
| ax.scatter([selection.anchor_lon], [selection.anchor_lat], color=CLAY, s=75, zorder=9) |
| ax.set_xlim(selection.anchor_lon - radius_deg * 1.45, selection.anchor_lon + radius_deg * 1.45) |
| ax.set_ylim(selection.anchor_lat - radius_deg * 1.45, selection.anchor_lat + radius_deg * 1.45) |
| has_site_geometry = True |
|
|
| if not has_site_geometry and feature_layers: |
| all_points = [pt for features in feature_layers.values() for feature in features for pt in feature] |
| if all_points: |
| xs = [lon for lon, _ in all_points] |
| ys = [lat for _, lat in all_points] |
| ax.set_xlim(min(xs), max(xs)) |
| ax.set_ylim(min(ys), max(ys)) |
|
|
| if any(feature_layers.values()) or has_site_geometry: |
| handles, labels = ax.get_legend_handles_labels() |
| dedup = {} |
| for handle, label in zip(handles, labels): |
| dedup.setdefault(label, handle) |
| if dedup: |
| ax.legend(dedup.values(), dedup.keys(), loc="lower right", fontsize=7.8, frameon=True, facecolor="white", edgecolor=LINE) |
|
|
|
|
| def _map_chrome(ax: Any, title: str) -> None: |
| ax.text(0.94, 0.93, "N", transform=ax.transAxes, ha="center", fontsize=12, fontweight="bold", color=INK) |
| ax.arrow(0.94, 0.82, 0, 0.08, transform=ax.transAxes, width=0.004, color=INK, length_includes_head=True, head_width=0.025, head_length=0.035) |
| ax.set_title(title, loc="left", fontsize=14, fontweight="bold", color=INK, pad=12) |
| ax.set_xlabel("") |
| ax.set_ylabel("") |
| ax.ticklabel_format(useOffset=False, style="plain") |
| ax.tick_params(labelsize=8, colors=MUTED) |
| ax.grid(alpha=0.16, color="#cfd7d3") |
| for spine in ax.spines.values(): |
| spine.set_color(LINE) |
|
|
|
|
| def _draw_edge_labels(ax: Any, selection: SiteSelection) -> None: |
| if not selection.bbox: |
| return |
| min_lon, min_lat, max_lon, max_lat = selection.bbox |
| cx = (min_lon + max_lon) / 2 |
| cy = (min_lat + max_lat) / 2 |
| labels = [ |
| (cx, max_lat, "N edge: check shade / adjacent height", "center", "bottom"), |
| (max_lon, cy, "E edge: morning approach / glare", "left", "center"), |
| (cx, min_lat, "S edge: solar exposure / drainage", "center", "top"), |
| (min_lon, cy, "W edge: afternoon heat", "right", "center"), |
| ] |
| for x, y, text, ha, va in labels: |
| ax.text( |
| x, |
| y, |
| text, |
| fontsize=8.0, |
| color=INK, |
| ha=ha, |
| va=va, |
| bbox={"facecolor": "white", "edgecolor": LINE, "alpha": 0.86, "boxstyle": "round,pad=0.28"}, |
| zorder=10, |
| ) |
|
|
|
|
| def _osm_feature_layers(features: list[dict[str, Any]]) -> dict[str, list[list[tuple[float, float]]]]: |
| buckets: dict[str, list[list[tuple[float, float]]]] = { |
| "roads/access": [], |
| "water": [], |
| "green/open": [], |
| "amenity": [], |
| "landuse": [], |
| "buildings": [], |
| } |
| for feature in features: |
| geometry = _feature_geometry(feature) |
| if not geometry: |
| continue |
| tags = feature.get("tags") or {} |
| if "highway" in tags: |
| buckets["roads/access"].append(geometry) |
| elif tags.get("natural") == "water" or "waterway" in tags: |
| buckets["water"].append(geometry) |
| elif tags.get("leisure") == "park" or tags.get("landuse") in {"forest", "grass", "recreation_ground", "meadow", "orchard"}: |
| buckets["green/open"].append(geometry) |
| elif "building" in tags: |
| buckets["buildings"].append(geometry) |
| elif "amenity" in tags: |
| buckets["amenity"].append(geometry) |
| elif "landuse" in tags: |
| buckets["landuse"].append(geometry) |
| return {key: value for key, value in buckets.items() if value} |
|
|
|
|
| def _feature_geometry(feature: dict[str, Any]) -> list[tuple[float, float]]: |
| geometry = feature.get("geometry") or [] |
| points = [ |
| (float(point["lon"]), float(point["lat"])) |
| for point in geometry |
| if point.get("lat") is not None and point.get("lon") is not None |
| ] |
| if points: |
| return points |
| if feature.get("type") == "node": |
| lat, lon = feature.get("lat"), feature.get("lon") |
| return [(float(lon), float(lat))] if lat is not None and lon is not None else [] |
| center = feature.get("center") or {} |
| lat, lon = center.get("lat"), center.get("lon") |
| return [(float(lon), float(lat))] if lat is not None and lon is not None else [] |
|
|
|
|
| def _draw_feature_lines( |
| ax: Any, |
| features: list[list[tuple[float, float]]], |
| color: str, |
| label: str, |
| *, |
| zorder: int = 3, |
| width: float = 1.5, |
| ) -> None: |
| labelled = False |
| for points in features[:70]: |
| if len(points) < 2: |
| continue |
| xs = [lon for lon, _ in points] |
| ys = [lat for _, lat in points] |
| ax.plot(xs, ys, color=color, linewidth=width, alpha=0.78, label=label if not labelled else None, zorder=zorder) |
| labelled = True |
|
|
|
|
| def _draw_feature_polygons( |
| ax: Any, |
| features: list[list[tuple[float, float]]], |
| fill: str, |
| edge: str, |
| label: str, |
| *, |
| zorder: int = 2, |
| ) -> None: |
| from matplotlib.patches import Polygon |
|
|
| labelled = False |
| for points in features[:45]: |
| if len(points) < 3: |
| continue |
| ax.add_patch( |
| Polygon( |
| points, |
| closed=True, |
| facecolor=fill, |
| edgecolor=edge, |
| linewidth=1.0, |
| alpha=0.58, |
| label=label if not labelled else None, |
| zorder=zorder, |
| ) |
| ) |
| labelled = True |
|
|
|
|
| def _draw_feature_points(ax: Any, features: list[list[tuple[float, float]]], color: str, label: str) -> None: |
| points = [feature[0] for feature in features[:45] if feature] |
| if not points: |
| return |
| xs = [lon for lon, _ in points] |
| ys = [lat for _, lat in points] |
| ax.scatter(xs, ys, s=30, color=color, marker="o", alpha=0.82, label=label, zorder=5) |
|
|
|
|
| def _monthly_rows(climate: dict[str, Any]) -> list[dict[str, Any]]: |
| normal = climate.get("climate_normal") or climate.get("recent_historical") or {} |
| months = normal.get("months") or [] |
| if len(months) >= 12: |
| return months[:12] |
| return months |
|
|
|
|
| def _has_monthly_values(months: list[dict[str, Any]]) -> bool: |
| return bool(months) and any( |
| m.get("temperature_c") is not None or m.get("precipitation_mm") is not None or m.get("humidity_pct") is not None |
| for m in months |
| ) |
|
|
|
|
| def _climate_cues(climate: dict[str, Any], sun_summary: dict[str, str]) -> list[str]: |
| months = _monthly_rows(climate) |
| cues = [ |
| "Keep forecast/current, recent historical and climate-normal labels separate.", |
| "Treat western exposure as a shading and glare check for openings and outdoor edges.", |
| "Verify wind on site; buildings and trees can override regional modelled data.", |
| "Use rainfall pattern to plan drainage checks, plinth levels and site-visit timing.", |
| ] |
| if _has_monthly_values(months): |
| rain = [_value(m.get("precipitation_mm"), 0.0) for m in months] |
| temps = [_value(m.get("temperature_c"), 0.0) for m in months] |
| wet = [MONTH_LABELS[i] for i, value in enumerate(rain[:12]) if value >= 100] |
| hot = [MONTH_LABELS[i] for i, value in enumerate(temps[:12]) if value >= 30] |
| if wet: |
| cues.insert(0, "High-rain months: " + ", ".join(wet[:6]) + ". Check runoff, waterlogging and covered movement.") |
| if hot: |
| cues.insert(1, "Hot months: " + ", ".join(hot[:6]) + ". Check shade, ventilation and heat-exposed edges.") |
| wind_note = sun_summary.get("wind_note") |
| if wind_note: |
| cues.append(wind_note) |
| return cues |
|
|
|
|
| def _matrix_rows( |
| selection: SiteSelection, |
| climate: dict[str, Any], |
| osm_context: dict[str, Any], |
| sun_summary: dict[str, str], |
| topography: dict[str, Any], |
| soil: dict[str, Any], |
| ) -> list[tuple[str, str, str, str]]: |
| counts = osm_context.get("counts") or {} |
| current = climate.get("forecast") or {} |
| normal = climate.get("climate_normal") or climate.get("recent_historical") or {} |
| return [ |
| ( |
| "Boundary", |
| f"{selection.selection_type.replace('_', ' ')}; area {_metric(selection.area_sqm, 'sqm')}; perimeter {_metric(selection.perimeter_m, 'm')}.", |
| "Use for early site form, scale and sheet setup.", |
| "Confirm plot line with CAD/KML/GeoJSON/faculty file or survey. Drawn map is approximate.", |
| ), |
| ( |
| "Climate", |
| f"Current {_metric(current.get('current_temperature_c'), 'C')}, humidity {_metric(current.get('current_humidity_pct'), '%')}, annual rain {_metric(normal.get('total_precipitation_mm'), 'mm')}.", |
| "Frame heat, rain, comfort and site-visit timing.", |
| "Use official/studio climate source if required; verify microclimate on site.", |
| ), |
| ( |
| "Sun / west edge", |
| _sun_matrix_text(sun_summary), |
| "Test shading, glare, facade openings and outdoor waiting spaces.", |
| "Measure actual obstruction, adjacent heights and shade during site visit.", |
| ), |
| ( |
| "Roads / access", |
| f"{_count_prefix(counts, 'roads/access')} mapped road/access features from OSM.", |
| "Initial access, service and circulation thinking.", |
| "Verify road width, entry, turning radius, pedestrian edge, parking and service access.", |
| ), |
| ( |
| "Water / drainage", |
| f"{_count_contains(counts, 'water')} mapped water/drainage features from OSM.", |
| "Flag flood, runoff, humidity, view and buffer questions.", |
| "Check water edge, drains, seasonal change and waterlogging in person.", |
| ), |
| ( |
| "Vegetation / open", |
| f"{_count_contains(counts, 'green')} mapped green/open features from OSM.", |
| "Shade, ecology, tree retention and open-space opportunities.", |
| "Survey tree locations, species, canopy, roots and removal constraints.", |
| ), |
| ( |
| "Terrain", |
| _terrain_matrix_text(topography), |
| "Early drainage, accessibility, plinth and cut-fill awareness.", |
| "Use CAD contours, survey levels and on-site drainage observation before final claims.", |
| ), |
| ( |
| "Soil / ground", |
| _soil_matrix_text(soil), |
| "Only a prompt for ground-risk questions.", |
| "Get geotechnical/professional verification before foundation or excavation decisions.", |
| ), |
| ( |
| "Culture / activity", |
| "Manual/user note layer only; not auto-inferred.", |
| "Record local movement, users, material language, informal activity and site timings.", |
| "Observe, photograph and speak to local users where appropriate.", |
| ), |
| ] |
|
|
|
|
| def _terrain_matrix_text(topography: dict[str, Any]) -> str: |
| if not topography: |
| return "No public elevation result available." |
| return ( |
| f"Mean elevation {_metric(topography.get('mean_elevation_m'), 'm')}; " |
| f"relief {_metric(topography.get('relief_m'), 'm')}; " |
| f"slope {_metric(topography.get('approx_slope_pct'), '%')}." |
| ) |
|
|
|
|
| def _sun_matrix_text(sun_summary: dict[str, str]) -> str: |
| side = sun_summary.get("sun_side", "southern") |
| return f"Morning cue east; west edge afternoon heat/glare; solar arc generally {side}." |
|
|
|
|
| def _soil_matrix_text(soil: dict[str, Any]) -> str: |
| if not soil: |
| return "No soil signal available in this run." |
| pieces = [str(soil.get("texture_signal") or "SoilGrids signal")] |
| for key, label in (("clay_pct", "clay"), ("sand_pct", "sand"), ("silt_pct", "silt"), ("ph_h2o", "pH")): |
| if soil.get(key) is not None: |
| suffix = "%" if key.endswith("_pct") else "" |
| pieces.append(f"{label} {soil[key]}{suffix}") |
| return "; ".join(pieces) + "." |
|
|
|
|
| def _count_prefix(counts: dict[str, Any], prefix: str) -> int: |
| total = 0 |
| for key, value in counts.items(): |
| if str(key).startswith(prefix): |
| try: |
| total += int(value) |
| except (TypeError, ValueError): |
| pass |
| return total |
|
|
|
|
| def _count_contains(counts: dict[str, Any], needle: str) -> int: |
| total = 0 |
| for key, value in counts.items(): |
| if needle in str(key): |
| try: |
| total += int(value) |
| except (TypeError, ValueError): |
| pass |
| return total |
|
|
|
|
| def _metric(value: Any, suffix: str) -> str: |
| if value is None: |
| return "n/a" |
| try: |
| number = float(value) |
| except (TypeError, ValueError): |
| return str(value) |
| if abs(number) >= 1000 and suffix in {"sqm", "mm"}: |
| return f"{number:,.0f} {suffix}" |
| if number == round(number): |
| return f"{number:.0f} {suffix}" |
| return f"{number:.1f} {suffix}" |
|
|
|
|
| def _value(value: Any, fallback: float) -> float: |
| try: |
| if value is None: |
| return fallback |
| return float(value) |
| except (TypeError, ValueError): |
| return fallback |
|
|
|
|
| def _wrap(value: str, width: int) -> str: |
| return "\n".join(textwrap.wrap(str(value), width=width)) |
|
|
|
|
| def _fonts() -> dict[str, ImageFont.ImageFont]: |
| def load(size: int, bold: bool = False) -> ImageFont.ImageFont: |
| names = ( |
| ["segoeuib.ttf", "arialbd.ttf", "DejaVuSans-Bold.ttf"] if bold else ["segoeui.ttf", "arial.ttf", "DejaVuSans.ttf"] |
| ) |
| for name in names: |
| try: |
| return ImageFont.truetype(name, size) |
| except OSError: |
| continue |
| return ImageFont.load_default() |
|
|
| return { |
| "title": load(38, True), |
| "kicker": load(20, True), |
| "panel": load(26, True), |
| "body": load(23), |
| "body_bold": load(23, True), |
| "micro": load(17), |
| "micro_bold": load(17, True), |
| "tiny": load(14, True), |
| "value": load(34, True), |
| } |
|
|
|
|
| def _draw_wrapped( |
| draw: ImageDraw.ImageDraw, |
| text: str, |
| box: tuple[int, int, int, int], |
| font: ImageFont.ImageFont, |
| fill: str, |
| *, |
| max_lines: int | None = None, |
| line_spacing: int = 6, |
| ) -> int: |
| x1, y1, x2, y2 = box |
| words = str(text).replace("\n", " ").split() |
| lines: list[str] = [] |
| line = "" |
| for word in words: |
| test = f"{line} {word}".strip() |
| if draw.textbbox((0, 0), test, font=font)[2] <= x2 - x1: |
| line = test |
| else: |
| if line: |
| lines.append(line) |
| line = word |
| if line: |
| lines.append(line) |
| if max_lines is not None and len(lines) > max_lines: |
| lines = lines[:max_lines] |
| lines[-1] = _ellipsize_to_width(draw, lines[-1], font, x2 - x1) |
| y = y1 |
| line_height = draw.textbbox((0, 0), "Ag", font=font)[3] + line_spacing |
| for line in lines: |
| if y + line_height > y2: |
| break |
| draw.text((x1, y), line, font=font, fill=fill) |
| y += line_height |
| return y |
|
|
|
|
| def _ellipsize_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, width: int) -> str: |
| ellipsis = "..." |
| value = text.rstrip() |
| if draw.textbbox((0, 0), value, font=font)[2] <= width: |
| return value |
| while value and draw.textbbox((0, 0), value + ellipsis, font=font)[2] > width: |
| value = value[:-1].rstrip() |
| return (value or text[:1]) + ellipsis |
|
|