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