Eishaan's picture
Upgrade submission diagram pack
3329d8f
Raw
History Blame Contribute Delete
36.1 kB
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