Eishaan commited on
Commit
2f91d7e
·
1 Parent(s): e9da6e8

Add KML terrain and soil analysis layers

Browse files
README.md CHANGED
@@ -14,7 +14,7 @@ pinned: false
14
 
15
  Preliminary architecture site-analysis assistant for the Build Small Hackathon.
16
 
17
- Draw, pin, or upload a site boundary to generate a first-pass site-analysis board pack with climate context, OSM surroundings, sun/wind notes, evidence labels, and a site-visit checklist. The main output is a downloadable presentation-board PNG/PDF plus an editable Markdown report.
18
 
19
  This is not a legal boundary verifier, geotechnical survey, structural engineering tool, final foundation recommender, or building design generator. Public data may be coarse or incomplete. Verify findings on site and with qualified professionals.
20
 
@@ -28,10 +28,13 @@ Architecture students and early-career architects often begin studio projects by
28
  - Paste latitude/longitude or a Google Maps URL that contains coordinates.
29
  - Reverse-geocode the selected anchor/centroid into approximate address, city/state, and country context when available.
30
  - Upload DXF and choose detected boundary candidates.
 
31
  - Upload GeoJSON boundaries.
32
  - Generate a sheet-style presentation board preview with downloadable PNG and PDF.
33
  - Generate climate, sun/wind, context, evidence, checklist, and Markdown report export.
34
  - Label climate data as forecast/current, recent historical, or climate-normal style.
 
 
35
  - Keep all unsafe soil, legal-boundary, and foundation claims out of the report.
36
 
37
  ## Data Sources
@@ -40,13 +43,15 @@ Architecture students and early-career architects often begin studio projects by
40
  - Open-Meteo Historical Weather API for recent historical and climate-normal style summaries.
41
  - OpenStreetMap and Overpass API for mapped context features.
42
  - OpenStreetMap Nominatim for one generate-time reverse geocoding lookup from the site anchor/centroid.
 
 
43
  - Leaflet for the local interactive map UI, vendored in `assets/` to avoid CDN startup failures.
44
- - Local geometry calculations for drawn, pin-radius, GeoJSON, and DXF-derived boundaries.
45
  - Local deterministic report templates; no large LLM is required for this prototype.
46
 
47
  ## Boundary Accuracy
48
 
49
- Drawn map boundaries are approximate because OSM street tiles are not precise plot-tracing tools. Upload CAD/DXF or GeoJSON when a better boundary source is available. CAD boundaries are only as reliable as the uploaded drawing, and local CAD coordinates must be anchored before public climate/map layers can be interpreted.
50
 
51
  ## Run Locally
52
 
 
14
 
15
  Preliminary architecture site-analysis assistant for the Build Small Hackathon.
16
 
17
+ Draw, pin, or upload a site boundary to generate a first-pass site-analysis board pack with climate context, OSM surroundings, terrain and preliminary soil signals, sun/wind notes, evidence labels, and a site-visit checklist. The main output is a downloadable presentation-board PNG/PDF plus an editable Markdown report.
18
 
19
  This is not a legal boundary verifier, geotechnical survey, structural engineering tool, final foundation recommender, or building design generator. Public data may be coarse or incomplete. Verify findings on site and with qualified professionals.
20
 
 
28
  - Paste latitude/longitude or a Google Maps URL that contains coordinates.
29
  - Reverse-geocode the selected anchor/centroid into approximate address, city/state, and country context when available.
30
  - Upload DXF and choose detected boundary candidates.
31
+ - Upload Google Earth/GIS-style KML/KMZ boundaries.
32
  - Upload GeoJSON boundaries.
33
  - Generate a sheet-style presentation board preview with downloadable PNG and PDF.
34
  - Generate climate, sun/wind, context, evidence, checklist, and Markdown report export.
35
  - Label climate data as forecast/current, recent historical, or climate-normal style.
36
+ - Add OpenTopoData SRTM elevation sampling for early slope/drainage awareness.
37
+ - Add SoilGrids preliminary topsoil texture signals with professional-verification warnings.
38
  - Keep all unsafe soil, legal-boundary, and foundation claims out of the report.
39
 
40
  ## Data Sources
 
43
  - Open-Meteo Historical Weather API for recent historical and climate-normal style summaries.
44
  - OpenStreetMap and Overpass API for mapped context features.
45
  - OpenStreetMap Nominatim for one generate-time reverse geocoding lookup from the site anchor/centroid.
46
+ - OpenTopoData SRTM 90m elevation API for preliminary sampled elevation/relief.
47
+ - SoilGrids / ISRIC global soil model for preliminary topsoil texture indicators.
48
  - Leaflet for the local interactive map UI, vendored in `assets/` to avoid CDN startup failures.
49
+ - Local geometry calculations for drawn, pin-radius, KML/KMZ, GeoJSON, and DXF-derived boundaries.
50
  - Local deterministic report templates; no large LLM is required for this prototype.
51
 
52
  ## Boundary Accuracy
53
 
54
+ Drawn map boundaries are approximate because OSM street tiles are not precise plot-tracing tools. Upload CAD/DXF, KML/KMZ, or GeoJSON when a better boundary source is available. CAD boundaries are only as reliable as the uploaded drawing, and local CAD coordinates must be anchored before public climate/map layers can be interpreted.
55
 
56
  ## Run Locally
57
 
app.py CHANGED
@@ -18,15 +18,19 @@ from src.export import write_markdown_export
18
  from src.geocoding import reverse_geocode_site
19
  from src.geojson_parser import parse_geojson_file
20
  from src.geometry import normalize_map_state, selection_from_lat_lon
 
21
  from src.location import extract_coordinates_from_text
22
  from src.models import BoundaryCandidate, ReportBundle, SiteSelection
23
  from src.osm_context import fetch_osm_context
24
  from src.report import build_board_bundle, build_markdown_report
 
25
  from src.sun import summarize_sun_wind
 
26
  from src.upload_limits import (
27
  MAX_BOARD_PREVIEW_MB,
28
  MAX_DXF_MB,
29
  MAX_GEOJSON_MB,
 
30
  MAX_PDF_REFERENCE_MB,
31
  validate_upload,
32
  )
@@ -155,6 +159,7 @@ def generate_site_analysis(
155
  selected_candidate_id: str,
156
  dxf_state: dict[str, Any] | None,
157
  geojson_file: Any,
 
158
  pdf_file: Any,
159
  ):
160
  reset_evidence_counter()
@@ -167,6 +172,7 @@ def generate_site_analysis(
167
  selected_candidate_id=selected_candidate_id,
168
  dxf_state=dxf_state,
169
  geojson_file=geojson_file,
 
170
  )
171
  warnings.extend(setup_warnings)
172
  except Exception as exc: # noqa: BLE001
@@ -221,6 +227,8 @@ def generate_site_analysis(
221
  lat, lon = selection.anchor_lat, selection.anchor_lon
222
  climate: dict[str, Any] = {"forecast": None, "recent_historical": None, "climate_normal": None}
223
  osm_context: dict[str, Any] = {"counts": {}, "features": []}
 
 
224
  site_identity: dict[str, Any] | None = None
225
  sun_summary: dict[str, str] = {
226
  "orientation_note": "Sun/orientation summary unavailable because no anchor coordinate is available.",
@@ -238,8 +246,12 @@ def generate_site_analysis(
238
  evidence.extend(sun_evidence)
239
  osm_context, osm_evidence = fetch_osm_context(selection)
240
  evidence.extend(osm_evidence)
 
 
 
 
241
  else:
242
- warnings.append("No anchor coordinate was available, so climate, sun, and OSM public-data layers were skipped.")
243
 
244
  diagram_paths = create_diagrams(selection, climate, osm_context, sun_summary)
245
  if len(diagram_paths) < 3:
@@ -256,6 +268,8 @@ def generate_site_analysis(
256
  site_identity=site_identity,
257
  evidence_rows=evidence,
258
  diagram_paths=diagram_paths,
 
 
259
  warnings=warnings,
260
  )
261
  if boundary_source:
@@ -278,6 +292,8 @@ def generate_site_analysis(
278
  evidence_rows=evidence,
279
  diagram_paths=diagram_paths,
280
  warnings=warnings,
 
 
281
  )
282
  evidence_html = evidence_to_html_table(evidence)
283
  padded_paths = diagram_paths + [None] * (3 - len(diagram_paths))
@@ -304,6 +320,7 @@ def _build_selection(
304
  selected_candidate_id: str,
305
  dxf_state: dict[str, Any] | None,
306
  geojson_file: Any,
 
307
  ) -> tuple[SiteSelection, list[Any], list[str]]:
308
  evidence = []
309
  warnings = []
@@ -384,6 +401,31 @@ def _build_selection(
384
  )
385
  return selection, evidence, warnings
386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  try:
388
  if map_state and map_state.strip() and map_state.strip() != "{}":
389
  selection = normalize_map_state(map_state)
@@ -473,7 +515,7 @@ def build_app() -> gr.Blocks:
473
  <p class="app-subtitle">A boundary-first analysis workspace for architecture students: map or upload a site, pull open context data, generate cited diagrams, and leave every risky claim for site verification.</p>
474
  </div>
475
  <div class="status-stack" aria-label="Product safeguards">
476
- <div><b>Open data</b><span>OSM, Open-Meteo, local geometry</span></div>
477
  <div><b>Evidence first</b><span>source, scope, confidence, limits</span></div>
478
  <div><b>No unsafe claims</b><span>site visit and professional checks stay explicit</span></div>
479
  </div>
@@ -484,7 +526,7 @@ def build_app() -> gr.Blocks:
484
  gr.HTML(
485
  """
486
  <div class="workflow-strip">
487
- <div><b>01</b><span>Select site</span><small>map, pin, DXF, GeoJSON</small></div>
488
  <div><b>02</b><span>Identify place</span><small>coordinates and approximate address</small></div>
489
  <div><b>03</b><span>Analyze context</span><small>climate, sun, roads, water, green context</small></div>
490
  <div><b>04</b><span>Export board pack</span><small>diagrams, evidence, checklist, markdown</small></div>
@@ -524,15 +566,17 @@ def build_app() -> gr.Blocks:
524
  <li>Boundary metrics: area, perimeter, input mode, accuracy label</li>
525
  <li>Climate views: forecast/current, recent historical, climate-normal style</li>
526
  <li>Context: OSM roads, water, green space, amenities where mapped</li>
 
527
  <li>Output: presentation board PNG/PDF, diagrams, evidence table, checklist, Markdown report</li>
528
  </ul>
529
  </div>
530
  """
531
  )
532
- with gr.Accordion("Optional uploads for MVP: CAD / GeoJSON / PDF reference", open=True, elem_classes=["upload-panel"]):
533
  with gr.Row():
534
  dxf_file = gr.File(label="DXF upload", file_types=[".dxf"], type="filepath")
535
  geojson_file = gr.File(label="GeoJSON upload", file_types=[".geojson", ".json"], type="filepath")
 
536
  pdf_file = gr.File(label="PDF reference only", file_types=[".pdf"], type="filepath")
537
  dxf_candidate = gr.Dropdown(label="DXF boundary candidate", choices=[], interactive=True)
538
  dxf_summary = gr.Markdown("Upload a DXF to inspect boundary candidates.")
@@ -580,6 +624,7 @@ def build_app() -> gr.Blocks:
580
  dxf_candidate,
581
  dxf_state,
582
  geojson_file,
 
583
  pdf_file,
584
  ],
585
  outputs=[
 
18
  from src.geocoding import reverse_geocode_site
19
  from src.geojson_parser import parse_geojson_file
20
  from src.geometry import normalize_map_state, selection_from_lat_lon
21
+ from src.kml_parser import parse_kml_file
22
  from src.location import extract_coordinates_from_text
23
  from src.models import BoundaryCandidate, ReportBundle, SiteSelection
24
  from src.osm_context import fetch_osm_context
25
  from src.report import build_board_bundle, build_markdown_report
26
+ from src.soil import fetch_soil
27
  from src.sun import summarize_sun_wind
28
+ from src.topography import fetch_topography
29
  from src.upload_limits import (
30
  MAX_BOARD_PREVIEW_MB,
31
  MAX_DXF_MB,
32
  MAX_GEOJSON_MB,
33
+ MAX_KML_MB,
34
  MAX_PDF_REFERENCE_MB,
35
  validate_upload,
36
  )
 
159
  selected_candidate_id: str,
160
  dxf_state: dict[str, Any] | None,
161
  geojson_file: Any,
162
+ kml_file: Any,
163
  pdf_file: Any,
164
  ):
165
  reset_evidence_counter()
 
172
  selected_candidate_id=selected_candidate_id,
173
  dxf_state=dxf_state,
174
  geojson_file=geojson_file,
175
+ kml_file=kml_file,
176
  )
177
  warnings.extend(setup_warnings)
178
  except Exception as exc: # noqa: BLE001
 
227
  lat, lon = selection.anchor_lat, selection.anchor_lon
228
  climate: dict[str, Any] = {"forecast": None, "recent_historical": None, "climate_normal": None}
229
  osm_context: dict[str, Any] = {"counts": {}, "features": []}
230
+ topography: dict[str, Any] = {}
231
+ soil: dict[str, Any] = {}
232
  site_identity: dict[str, Any] | None = None
233
  sun_summary: dict[str, str] = {
234
  "orientation_note": "Sun/orientation summary unavailable because no anchor coordinate is available.",
 
246
  evidence.extend(sun_evidence)
247
  osm_context, osm_evidence = fetch_osm_context(selection)
248
  evidence.extend(osm_evidence)
249
+ topography, topography_evidence = fetch_topography(selection)
250
+ evidence.extend(topography_evidence)
251
+ soil, soil_evidence = fetch_soil(selection)
252
+ evidence.extend(soil_evidence)
253
  else:
254
+ warnings.append("No anchor coordinate was available, so climate, sun, OSM, terrain, and soil public-data layers were skipped.")
255
 
256
  diagram_paths = create_diagrams(selection, climate, osm_context, sun_summary)
257
  if len(diagram_paths) < 3:
 
268
  site_identity=site_identity,
269
  evidence_rows=evidence,
270
  diagram_paths=diagram_paths,
271
+ topography=topography,
272
+ soil=soil,
273
  warnings=warnings,
274
  )
275
  if boundary_source:
 
292
  evidence_rows=evidence,
293
  diagram_paths=diagram_paths,
294
  warnings=warnings,
295
+ topography=topography,
296
+ soil=soil,
297
  )
298
  evidence_html = evidence_to_html_table(evidence)
299
  padded_paths = diagram_paths + [None] * (3 - len(diagram_paths))
 
320
  selected_candidate_id: str,
321
  dxf_state: dict[str, Any] | None,
322
  geojson_file: Any,
323
+ kml_file: Any,
324
  ) -> tuple[SiteSelection, list[Any], list[str]]:
325
  evidence = []
326
  warnings = []
 
401
  )
402
  return selection, evidence, warnings
403
 
404
+ if kml_file:
405
+ kml_path = validate_upload(
406
+ _file_path(kml_file),
407
+ allowed_suffixes={".kml", ".kmz"},
408
+ max_mb=MAX_KML_MB,
409
+ label="KML/KMZ",
410
+ )
411
+ selection = parse_kml_file(kml_path)
412
+ evidence.append(
413
+ make_evidence(
414
+ category="Boundary",
415
+ finding="KML/KMZ boundary was uploaded and treated as Google Earth / WGS84 geometry.",
416
+ source_name=kml_path.name,
417
+ source_url="",
418
+ source_type="uploaded KML/KMZ",
419
+ resolution_or_scope="uploaded polygon; Google Earth/GIS-style coordinates",
420
+ confidence="medium",
421
+ limitation="Boundary is only as reliable as the exported KML/KMZ source and is not legal/cadastral verification.",
422
+ design_implication="Use for boundary-based first-pass site analysis and board generation.",
423
+ verification_needed="Confirm with faculty CAD, survey, or official project documents.",
424
+ output_label="user_input",
425
+ )
426
+ )
427
+ return selection, evidence, warnings
428
+
429
  try:
430
  if map_state and map_state.strip() and map_state.strip() != "{}":
431
  selection = normalize_map_state(map_state)
 
515
  <p class="app-subtitle">A boundary-first analysis workspace for architecture students: map or upload a site, pull open context data, generate cited diagrams, and leave every risky claim for site verification.</p>
516
  </div>
517
  <div class="status-stack" aria-label="Product safeguards">
518
+ <div><b>Open data</b><span>OSM, Open-Meteo, OpenTopoData, SoilGrids</span></div>
519
  <div><b>Evidence first</b><span>source, scope, confidence, limits</span></div>
520
  <div><b>No unsafe claims</b><span>site visit and professional checks stay explicit</span></div>
521
  </div>
 
526
  gr.HTML(
527
  """
528
  <div class="workflow-strip">
529
+ <div><b>01</b><span>Select site</span><small>map, pin, DXF, KML/KMZ, GeoJSON</small></div>
530
  <div><b>02</b><span>Identify place</span><small>coordinates and approximate address</small></div>
531
  <div><b>03</b><span>Analyze context</span><small>climate, sun, roads, water, green context</small></div>
532
  <div><b>04</b><span>Export board pack</span><small>diagrams, evidence, checklist, markdown</small></div>
 
566
  <li>Boundary metrics: area, perimeter, input mode, accuracy label</li>
567
  <li>Climate views: forecast/current, recent historical, climate-normal style</li>
568
  <li>Context: OSM roads, water, green space, amenities where mapped</li>
569
+ <li>Terrain and soil: OpenTopoData elevation and SoilGrids preliminary topsoil signals</li>
570
  <li>Output: presentation board PNG/PDF, diagrams, evidence table, checklist, Markdown report</li>
571
  </ul>
572
  </div>
573
  """
574
  )
575
+ with gr.Accordion("Optional uploads for MVP: CAD / Google Earth KML / GeoJSON / PDF reference", open=True, elem_classes=["upload-panel"]):
576
  with gr.Row():
577
  dxf_file = gr.File(label="DXF upload", file_types=[".dxf"], type="filepath")
578
  geojson_file = gr.File(label="GeoJSON upload", file_types=[".geojson", ".json"], type="filepath")
579
+ kml_file = gr.File(label="Google Earth KML/KMZ upload", file_types=[".kml", ".kmz"], type="filepath")
580
  pdf_file = gr.File(label="PDF reference only", file_types=[".pdf"], type="filepath")
581
  dxf_candidate = gr.Dropdown(label="DXF boundary candidate", choices=[], interactive=True)
582
  dxf_summary = gr.Markdown("Upload a DXF to inspect boundary candidates.")
 
624
  dxf_candidate,
625
  dxf_state,
626
  geojson_file,
627
+ kml_file,
628
  pdf_file,
629
  ],
630
  outputs=[
assets/map.js CHANGED
@@ -208,7 +208,7 @@ function requestLeafletFallback() {
208
  script.onload = () => initSiteMap();
209
  script.onerror = () => {
210
  if (status) {
211
- status.textContent = "Map library could not load. Use the lat/lon field or upload DXF/GeoJSON, then generate the report.";
212
  }
213
  };
214
  document.head.appendChild(script);
@@ -227,7 +227,7 @@ function waitForLeafletAndInit(attempt = 0) {
227
  if (attempt > 80) {
228
  const status = document.getElementById("map-status");
229
  if (status) {
230
- status.textContent = "Map could not initialize. Use the lat/lon field or upload DXF/GeoJSON, then generate the report.";
231
  }
232
  return;
233
  }
 
208
  script.onload = () => initSiteMap();
209
  script.onerror = () => {
210
  if (status) {
211
+ status.textContent = "Map library could not load. Use the lat/lon field or upload DXF/KML/GeoJSON, then generate the report.";
212
  }
213
  };
214
  document.head.appendChild(script);
 
227
  if (attempt > 80) {
228
  const status = document.getElementById("map-status");
229
  if (status) {
230
+ status.textContent = "Map could not initialize. Use the lat/lon field or upload DXF/KML/GeoJSON, then generate the report.";
231
  }
232
  return;
233
  }
src/board_export.py CHANGED
@@ -38,6 +38,8 @@ def create_board_artifacts(
38
  evidence_rows: Iterable[EvidenceItem],
39
  diagram_paths: list[str],
40
  warnings: list[str],
 
 
41
  ) -> dict[str, str]:
42
  out_dir = Path(tempfile.mkdtemp(prefix="sis_board_"))
43
  png_path = out_dir / "site-intelligence-presentation-board.png"
@@ -96,10 +98,10 @@ def create_board_artifacts(
96
  line_spacing=8,
97
  )
98
 
99
- _panel(draw, (965, 778, 1575, 1158), "04 Design cues", fonts)
100
  _draw_bullets(
101
  draw,
102
- _design_cues(selection, osm_context, culture_notes),
103
  (995, 850, 1545, 1112),
104
  fonts["body"],
105
  INK_2,
@@ -270,7 +272,7 @@ def _draw_wrapped_text(
270
  lines.append(line)
271
  if max_lines is not None and len(lines) > max_lines:
272
  lines = lines[:max_lines]
273
- lines[-1] = _clip(lines[-1], max(8, len(lines[-1]) - 3)) + "..."
274
  y = y1
275
  line_height = draw.textbbox((0, 0), "Ag", font=font)[3] + line_spacing
276
  for line in lines:
@@ -370,7 +372,13 @@ def _sun_caption(sun_summary: dict[str, str]) -> str:
370
  return " ".join(piece for piece in pieces if piece)
371
 
372
 
373
- def _design_cues(selection: SiteSelection, osm_context: dict[str, Any], culture_notes: str) -> list[str]:
 
 
 
 
 
 
374
  counts = osm_context.get("counts") or {}
375
  cues = [
376
  "Treat west and afternoon exposure as a shading and glare item to test in massing.",
@@ -378,6 +386,12 @@ def _design_cues(selection: SiteSelection, osm_context: dict[str, Any], culture_
378
  "Check drainage, waterlogging, runoff, and low points during the site visit.",
379
  "Keep soil and foundation language as professional-verification prompts, not recommendations.",
380
  ]
 
 
 
 
 
 
381
  if counts.get("natural/water") or counts.get("water"):
382
  cues.insert(1, "Mapped water context should trigger edge, flood, drainage, humidity, and view checks.")
383
  if counts.get("leisure/green") or counts.get("natural/wood"):
@@ -389,6 +403,23 @@ def _design_cues(selection: SiteSelection, osm_context: dict[str, Any], culture_
389
  return cues
390
 
391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  def _verification_items(evidence_rows: list[EvidenceItem], warnings: list[str]) -> list[str]:
393
  items = [
394
  "Confirm actual plot edges with CAD, KML, GeoJSON, faculty drawing, or site survey.",
@@ -413,3 +444,13 @@ def _verification_items(evidence_rows: list[EvidenceItem], warnings: list[str])
413
  def _clip(text: object, limit: int) -> str:
414
  value = "" if text is None else str(text)
415
  return value if len(value) <= limit else value[: max(0, limit - 3)].rstrip() + "..."
 
 
 
 
 
 
 
 
 
 
 
38
  evidence_rows: Iterable[EvidenceItem],
39
  diagram_paths: list[str],
40
  warnings: list[str],
41
+ topography: dict[str, Any] | None = None,
42
+ soil: dict[str, Any] | None = None,
43
  ) -> dict[str, str]:
44
  out_dir = Path(tempfile.mkdtemp(prefix="sis_board_"))
45
  png_path = out_dir / "site-intelligence-presentation-board.png"
 
98
  line_spacing=8,
99
  )
100
 
101
+ _panel(draw, (965, 778, 1575, 1158), "04 Terrain, ground and design cues", fonts)
102
  _draw_bullets(
103
  draw,
104
+ _design_cues(selection, osm_context, culture_notes, topography, soil),
105
  (995, 850, 1545, 1112),
106
  fonts["body"],
107
  INK_2,
 
272
  lines.append(line)
273
  if max_lines is not None and len(lines) > max_lines:
274
  lines = lines[:max_lines]
275
+ lines[-1] = _ellipsize_to_width(draw, lines[-1], font, x2 - x1)
276
  y = y1
277
  line_height = draw.textbbox((0, 0), "Ag", font=font)[3] + line_spacing
278
  for line in lines:
 
372
  return " ".join(piece for piece in pieces if piece)
373
 
374
 
375
+ def _design_cues(
376
+ selection: SiteSelection,
377
+ osm_context: dict[str, Any],
378
+ culture_notes: str,
379
+ topography: dict[str, Any] | None,
380
+ soil: dict[str, Any] | None,
381
+ ) -> list[str]:
382
  counts = osm_context.get("counts") or {}
383
  cues = [
384
  "Treat west and afternoon exposure as a shading and glare item to test in massing.",
 
386
  "Check drainage, waterlogging, runoff, and low points during the site visit.",
387
  "Keep soil and foundation language as professional-verification prompts, not recommendations.",
388
  ]
389
+ terrain_cue = _terrain_cue(topography)
390
+ if terrain_cue:
391
+ cues.insert(0, terrain_cue)
392
+ soil_cue = _soil_cue(soil)
393
+ if soil_cue:
394
+ cues.insert(1, soil_cue)
395
  if counts.get("natural/water") or counts.get("water"):
396
  cues.insert(1, "Mapped water context should trigger edge, flood, drainage, humidity, and view checks.")
397
  if counts.get("leisure/green") or counts.get("natural/wood"):
 
403
  return cues
404
 
405
 
406
+ def _terrain_cue(topography: dict[str, Any] | None) -> str | None:
407
+ if not topography:
408
+ return "Terrain data unavailable; use CAD contours, site levels, and drainage observation."
409
+ relief = topography.get("relief_m")
410
+ slope = topography.get("approx_slope_pct")
411
+ if relief is None and slope is None:
412
+ return "Public terrain sampling is incomplete; verify slope and drainage manually."
413
+ return f"Terrain: relief {relief} m, sampled slope {slope}%; verify contours and drainage."
414
+
415
+
416
+ def _soil_cue(soil: dict[str, Any] | None) -> str | None:
417
+ if not soil:
418
+ return "Soil data unavailable; request geotechnical/professional ground information."
419
+ texture = soil.get("texture_signal", "soil texture signal")
420
+ return f"SoilGrids: {texture}; professional ground verification only."
421
+
422
+
423
  def _verification_items(evidence_rows: list[EvidenceItem], warnings: list[str]) -> list[str]:
424
  items = [
425
  "Confirm actual plot edges with CAD, KML, GeoJSON, faculty drawing, or site survey.",
 
444
  def _clip(text: object, limit: int) -> str:
445
  value = "" if text is None else str(text)
446
  return value if len(value) <= limit else value[: max(0, limit - 3)].rstrip() + "..."
447
+
448
+
449
+ def _ellipsize_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, width: int) -> str:
450
+ ellipsis = "..."
451
+ value = text.rstrip()
452
+ if draw.textbbox((0, 0), value, font=font)[2] <= width:
453
+ return value
454
+ while value and draw.textbbox((0, 0), value + ellipsis, font=font)[2] > width:
455
+ value = value[:-1].rstrip()
456
+ return (value or text[:1]) + ellipsis
src/diagrams.py CHANGED
@@ -174,9 +174,25 @@ def create_context_diagram(selection: SiteSelection, osm_context: dict[str, Any]
174
  ax.scatter([selection.anchor_lon], [selection.anchor_lat], color="#b55a3d", s=70, zorder=3)
175
  ax.set_xlim(selection.anchor_lon - radius_deg * 1.4, selection.anchor_lon + radius_deg * 1.4)
176
  ax.set_ylim(selection.anchor_lat - radius_deg * 1.4, selection.anchor_lat + radius_deg * 1.4)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  counts = osm_context.get("counts") or {}
178
  summary = "\n".join(f"{key}: {value}" for key, value in list(counts.items())[:7]) or "OSM context unavailable or sparse."
179
  ax.text(0.03, 0.96, summary, transform=ax.transAxes, va="top", ha="left", fontsize=9.5, color="#101817", bbox={"facecolor": "white", "alpha": 0.88, "edgecolor": "#c8d0cc", "boxstyle": "round,pad=0.45"})
 
 
180
  ax.text(0.94, 0.93, "N", transform=ax.transAxes, ha="center", fontsize=12, fontweight="bold", color="#101817")
181
  ax.arrow(0.94, 0.82, 0, 0.08, transform=ax.transAxes, width=0.004, color="#101817", length_includes_head=True, head_width=0.025, head_length=0.035)
182
  ax.set_title("Geographic context - preliminary", loc="left", fontsize=14, fontweight="bold", color="#101817", pad=12)
@@ -190,3 +206,36 @@ def create_context_diagram(selection: SiteSelection, osm_context: dict[str, Any]
190
  fig.savefig(output_path, bbox_inches="tight")
191
  plt.close(fig)
192
  return output_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  ax.scatter([selection.anchor_lon], [selection.anchor_lat], color="#b55a3d", s=70, zorder=3)
175
  ax.set_xlim(selection.anchor_lon - radius_deg * 1.4, selection.anchor_lon + radius_deg * 1.4)
176
  ax.set_ylim(selection.anchor_lat - radius_deg * 1.4, selection.anchor_lat + radius_deg * 1.4)
177
+ feature_points = _osm_feature_points(osm_context.get("features") or [])
178
+ for label, color, marker in (
179
+ ("roads/access", "#66736f", "."),
180
+ ("water", "#245a93", "s"),
181
+ ("green/open", "#2f7d59", "^"),
182
+ ("amenity", "#d9972b", "o"),
183
+ ("landuse", "#7c6b57", "x"),
184
+ ):
185
+ coords = feature_points.get(label, [])
186
+ if not coords:
187
+ continue
188
+ xs = [lon for lon, _ in coords[:40]]
189
+ ys = [lat for _, lat in coords[:40]]
190
+ ax.scatter(xs, ys, s=34 if marker != "." else 20, color=color, marker=marker, alpha=0.82, label=label, zorder=4)
191
  counts = osm_context.get("counts") or {}
192
  summary = "\n".join(f"{key}: {value}" for key, value in list(counts.items())[:7]) or "OSM context unavailable or sparse."
193
  ax.text(0.03, 0.96, summary, transform=ax.transAxes, va="top", ha="left", fontsize=9.5, color="#101817", bbox={"facecolor": "white", "alpha": 0.88, "edgecolor": "#c8d0cc", "boxstyle": "round,pad=0.45"})
194
+ if feature_points:
195
+ ax.legend(loc="lower right", fontsize=7.5, frameon=True, facecolor="white", edgecolor="#c8d0cc")
196
  ax.text(0.94, 0.93, "N", transform=ax.transAxes, ha="center", fontsize=12, fontweight="bold", color="#101817")
197
  ax.arrow(0.94, 0.82, 0, 0.08, transform=ax.transAxes, width=0.004, color="#101817", length_includes_head=True, head_width=0.025, head_length=0.035)
198
  ax.set_title("Geographic context - preliminary", loc="left", fontsize=14, fontweight="bold", color="#101817", pad=12)
 
206
  fig.savefig(output_path, bbox_inches="tight")
207
  plt.close(fig)
208
  return output_path
209
+
210
+
211
+ def _osm_feature_points(features: list[dict[str, Any]]) -> dict[str, list[tuple[float, float]]]:
212
+ buckets: dict[str, list[tuple[float, float]]] = {
213
+ "roads/access": [],
214
+ "water": [],
215
+ "green/open": [],
216
+ "amenity": [],
217
+ "landuse": [],
218
+ }
219
+ for feature in features:
220
+ lat, lon = _feature_center(feature)
221
+ if lat is None or lon is None:
222
+ continue
223
+ tags = feature.get("tags") or {}
224
+ if "highway" in tags:
225
+ buckets["roads/access"].append((lon, lat))
226
+ elif tags.get("natural") == "water" or "waterway" in tags:
227
+ buckets["water"].append((lon, lat))
228
+ elif tags.get("leisure") == "park" or tags.get("landuse") in {"forest", "grass", "recreation_ground"}:
229
+ buckets["green/open"].append((lon, lat))
230
+ elif "amenity" in tags:
231
+ buckets["amenity"].append((lon, lat))
232
+ elif "landuse" in tags:
233
+ buckets["landuse"].append((lon, lat))
234
+ return {key: value for key, value in buckets.items() if value}
235
+
236
+
237
+ def _feature_center(feature: dict[str, Any]) -> tuple[float | None, float | None]:
238
+ if feature.get("type") == "node":
239
+ return feature.get("lat"), feature.get("lon")
240
+ center = feature.get("center") or {}
241
+ return center.get("lat"), center.get("lon")
src/kml_parser.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ import zipfile
5
+ from pathlib import Path
6
+ from typing import Any
7
+ from xml.etree import ElementTree as ET
8
+
9
+ from .geometry import calculate_site_metrics
10
+ from .models import SiteSelection
11
+
12
+
13
+ def parse_kml_file(path: str | Path) -> SiteSelection:
14
+ source = Path(path)
15
+ xml_text = _read_kml_text(source)
16
+ root = ET.fromstring(xml_text)
17
+ polygons = _extract_polygons(root)
18
+ if not polygons:
19
+ raise ValueError("KML/KMZ must contain at least one Polygon boundary.")
20
+ geometry = _largest_polygon(polygons)
21
+ metrics = calculate_site_metrics(geometry)
22
+ return SiteSelection(
23
+ id=f"S-{uuid.uuid4().hex[:8]}",
24
+ selection_type="kml_boundary",
25
+ coordinate_mode="wgs84",
26
+ geometry_geojson=geometry,
27
+ local_geometry=None,
28
+ anchor_lat=metrics["centroid"][0],
29
+ anchor_lon=metrics["centroid"][1],
30
+ radius_m=None,
31
+ area_sqm=metrics["area_sqm"],
32
+ perimeter_m=metrics["perimeter_m"],
33
+ centroid=metrics["centroid"],
34
+ bbox=metrics["bbox"],
35
+ unit_source="Google Earth / KML WGS84 coordinates",
36
+ accuracy_label="uploaded Google Earth/KML boundary",
37
+ source_files=[source.name],
38
+ selected_boundary_id=None,
39
+ limitations=[
40
+ "KML/KMZ geometry is treated as a user-exported Google Earth or GIS boundary.",
41
+ "It is not legal/cadastral boundary verification.",
42
+ "Verify against faculty CAD, survey, or project documents before plot-level decisions.",
43
+ ],
44
+ )
45
+
46
+
47
+ def _read_kml_text(source: Path) -> str:
48
+ suffix = source.suffix.lower()
49
+ if suffix == ".kml":
50
+ return source.read_text(encoding="utf-8-sig")
51
+ if suffix == ".kmz":
52
+ with zipfile.ZipFile(source) as archive:
53
+ kml_names = [name for name in archive.namelist() if name.lower().endswith(".kml")]
54
+ if not kml_names:
55
+ raise ValueError("KMZ archive does not contain a KML file.")
56
+ with archive.open(kml_names[0]) as handle:
57
+ return handle.read().decode("utf-8-sig")
58
+ raise ValueError("KML parser supports only .kml and .kmz files.")
59
+
60
+
61
+ def _extract_polygons(root: ET.Element) -> list[dict[str, Any]]:
62
+ polygons: list[dict[str, Any]] = []
63
+ for polygon in root.iter():
64
+ if not _tag_endswith(polygon.tag, "Polygon"):
65
+ continue
66
+ coordinates_text = None
67
+ for child in polygon.iter():
68
+ if _tag_endswith(child.tag, "coordinates") and child.text:
69
+ coordinates_text = child.text
70
+ break
71
+ if not coordinates_text:
72
+ continue
73
+ ring = _parse_coordinates(coordinates_text)
74
+ if len(ring) >= 4:
75
+ polygons.append({"type": "Polygon", "coordinates": [ring]})
76
+ return polygons
77
+
78
+
79
+ def _parse_coordinates(text: str) -> list[list[float]]:
80
+ points: list[list[float]] = []
81
+ for token in text.replace("\n", " ").replace("\t", " ").split():
82
+ parts = [part for part in token.split(",") if part != ""]
83
+ if len(parts) < 2:
84
+ continue
85
+ lon = float(parts[0])
86
+ lat = float(parts[1])
87
+ if not (-180 <= lon <= 180 and -90 <= lat <= 90):
88
+ raise ValueError("KML coordinates do not look like WGS84 longitude/latitude.")
89
+ points.append([lon, lat])
90
+ if points and points[0] != points[-1]:
91
+ points.append(points[0])
92
+ return points
93
+
94
+
95
+ def _largest_polygon(polygons: list[dict[str, Any]]) -> dict[str, Any]:
96
+ scored = []
97
+ for geometry in polygons:
98
+ try:
99
+ scored.append((calculate_site_metrics(geometry)["area_sqm"] or 0, geometry))
100
+ except Exception:
101
+ continue
102
+ if not scored:
103
+ raise ValueError("KML polygons could not be converted to valid site geometry.")
104
+ return max(scored, key=lambda item: item[0])[1]
105
+
106
+
107
+ def _tag_endswith(tag: str, suffix: str) -> bool:
108
+ return tag.endswith("}" + suffix) or tag == suffix
src/models.py CHANGED
@@ -10,6 +10,7 @@ SelectionType = Literal[
10
  "pin_radius",
11
  "dxf_boundary",
12
  "geojson_boundary",
 
13
  "reference_only",
14
  ]
15
  CoordinateMode = Literal["wgs84", "local_cad", "anchored_local_cad", "unknown"]
 
10
  "pin_radius",
11
  "dxf_boundary",
12
  "geojson_boundary",
13
+ "kml_boundary",
14
  "reference_only",
15
  ]
16
  CoordinateMode = Literal["wgs84", "local_cad", "anchored_local_cad", "unknown"]
src/report.py CHANGED
@@ -15,9 +15,21 @@ def build_board_bundle(
15
  site_identity: dict | None,
16
  evidence_rows,
17
  diagram_paths: list[str],
 
 
18
  warnings: list[str] | None = None,
19
  ) -> ReportBundle:
20
- board = build_board_markdown(project_name, site_name, selection, climate, osm_context, sun_summary, site_identity)
 
 
 
 
 
 
 
 
 
 
21
  checklist = build_site_visit_checklist()
22
  bundle = ReportBundle(
23
  board_markdown=board,
@@ -38,6 +50,8 @@ def build_board_markdown(
38
  osm_context: dict,
39
  sun_summary: dict[str, str],
40
  site_identity: dict | None = None,
 
 
41
  ) -> str:
42
  area = _fmt(selection.area_sqm, "sqm")
43
  perimeter = _fmt(selection.perimeter_m, "m")
@@ -71,6 +85,12 @@ def build_board_markdown(
71
 
72
  {context_text}
73
 
 
 
 
 
 
 
74
  ### Sun / Wind
75
 
76
  - {sun_summary.get("orientation_note", "Orientation summary unavailable.")}
@@ -85,7 +105,7 @@ def build_board_markdown(
85
  - Treat water, green, built-up, and amenity context as source-backed only where evidence rows exist.
86
  - Keep culture/demographic context as editable field observations unless supported by reliable sources.
87
 
88
- {build_detailed_site_analysis(selection, climate, osm_context, sun_summary, site_identity)}
89
  """
90
 
91
 
@@ -95,6 +115,8 @@ def build_detailed_site_analysis(
95
  osm_context: dict,
96
  sun_summary: dict[str, str],
97
  site_identity: dict | None,
 
 
98
  ) -> str:
99
  context_counts = osm_context.get("counts") or {}
100
  current = climate.get("forecast") or {}
@@ -198,7 +220,8 @@ These references are used as learning/checklist references only. They are not tr
198
  **What the app can support now**
199
 
200
  - The current prototype records mapped green/water features where OSM data is available.
201
- - It does not yet run DEM slope, contour extraction, flood-risk, NDVI, or tree-canopy analysis.
 
202
 
203
  **Why it matters**
204
 
@@ -362,6 +385,7 @@ These references are used as learning/checklist references only. They are not tr
362
 
363
  **What the app says safely**
364
 
 
365
  - This prototype does not generate final soil or foundation recommendations.
366
  - Soil/foundation is treated as a professional-verification item.
367
 
@@ -475,6 +499,35 @@ def _context_count_lines(context_counts: dict) -> str:
475
  return "\n".join(lines)
476
 
477
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  def _climate_summary_lines(climate: dict) -> str:
479
  forecast = climate.get("forecast") or {}
480
  recent = climate.get("recent_historical") or {}
 
15
  site_identity: dict | None,
16
  evidence_rows,
17
  diagram_paths: list[str],
18
+ topography: dict | None = None,
19
+ soil: dict | None = None,
20
  warnings: list[str] | None = None,
21
  ) -> ReportBundle:
22
+ board = build_board_markdown(
23
+ project_name,
24
+ site_name,
25
+ selection,
26
+ climate,
27
+ osm_context,
28
+ sun_summary,
29
+ site_identity,
30
+ topography=topography,
31
+ soil=soil,
32
+ )
33
  checklist = build_site_visit_checklist()
34
  bundle = ReportBundle(
35
  board_markdown=board,
 
50
  osm_context: dict,
51
  sun_summary: dict[str, str],
52
  site_identity: dict | None = None,
53
+ topography: dict | None = None,
54
+ soil: dict | None = None,
55
  ) -> str:
56
  area = _fmt(selection.area_sqm, "sqm")
57
  perimeter = _fmt(selection.perimeter_m, "m")
 
85
 
86
  {context_text}
87
 
88
+ ### Terrain / Soil Signals
89
+
90
+ {_topography_summary(topography)}
91
+
92
+ {_soil_summary(soil)}
93
+
94
  ### Sun / Wind
95
 
96
  - {sun_summary.get("orientation_note", "Orientation summary unavailable.")}
 
105
  - Treat water, green, built-up, and amenity context as source-backed only where evidence rows exist.
106
  - Keep culture/demographic context as editable field observations unless supported by reliable sources.
107
 
108
+ {build_detailed_site_analysis(selection, climate, osm_context, sun_summary, site_identity, topography, soil)}
109
  """
110
 
111
 
 
115
  osm_context: dict,
116
  sun_summary: dict[str, str],
117
  site_identity: dict | None,
118
+ topography: dict | None = None,
119
+ soil: dict | None = None,
120
  ) -> str:
121
  context_counts = osm_context.get("counts") or {}
122
  current = climate.get("forecast") or {}
 
220
  **What the app can support now**
221
 
222
  - The current prototype records mapped green/water features where OSM data is available.
223
+ {_topography_summary(topography)}
224
+ - It does not yet run contour extraction, flood-risk, NDVI, or tree-canopy analysis.
225
 
226
  **Why it matters**
227
 
 
385
 
386
  **What the app says safely**
387
 
388
+ {_soil_summary(soil)}
389
  - This prototype does not generate final soil or foundation recommendations.
390
  - Soil/foundation is treated as a professional-verification item.
391
 
 
499
  return "\n".join(lines)
500
 
501
 
502
+ def _topography_summary(topography: dict | None) -> str:
503
+ if not topography:
504
+ return "- Topography/elevation unavailable in this run; verify slope, contours, drainage, and low points manually."
505
+ mean_elev = topography.get("mean_elevation_m", "n/a")
506
+ relief = topography.get("relief_m", "n/a")
507
+ slope = topography.get("approx_slope_pct", "n/a")
508
+ interpretation = topography.get("interpretation", "Use only as preliminary terrain context.")
509
+ return (
510
+ f"- Public elevation signal: mean {mean_elev} m, sampled relief {relief} m, "
511
+ f"approximate sampled slope {slope}%. {interpretation}"
512
+ )
513
+
514
+
515
+ def _soil_summary(soil: dict | None) -> str:
516
+ if not soil:
517
+ return "- Soil signal unavailable in this run; use geotechnical report, local engineer input, or official soil maps."
518
+ pieces = [soil.get("texture_signal", "soil texture signal unavailable")]
519
+ if soil.get("clay_pct") is not None:
520
+ pieces.append(f"clay {soil['clay_pct']}%")
521
+ if soil.get("sand_pct") is not None:
522
+ pieces.append(f"sand {soil['sand_pct']}%")
523
+ if soil.get("silt_pct") is not None:
524
+ pieces.append(f"silt {soil['silt_pct']}%")
525
+ if soil.get("ph_h2o") is not None:
526
+ pieces.append(f"pH {soil['ph_h2o']}")
527
+ implication = soil.get("design_implication", "Use only as a preliminary prompt for soil verification.")
528
+ return "- Preliminary SoilGrids signal: " + ", ".join(pieces) + f". {implication}"
529
+
530
+
531
  def _climate_summary_lines(climate: dict) -> str:
532
  forecast = climate.get("forecast") or {}
533
  recent = climate.get("recent_historical") or {}
src/soil.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .evidence import make_evidence
6
+ from .http_client import get_json
7
+ from .models import EvidenceItem, SiteSelection
8
+
9
+ SOILGRIDS_URL = "https://rest.isric.org/soilgrids/v2.0/properties/query"
10
+
11
+
12
+ def fetch_soil(selection: SiteSelection) -> tuple[dict[str, Any], list[EvidenceItem]]:
13
+ if selection.anchor_lat is None or selection.anchor_lon is None:
14
+ return {}, [
15
+ make_evidence(
16
+ category="Soil / ground",
17
+ finding="SoilGrids lookup was skipped because no real-world site coordinate is available.",
18
+ source_name="SoilGrids / ISRIC",
19
+ source_url="https://docs.isric.org/globaldata/soilgrids/",
20
+ source_type="global soil model",
21
+ resolution_or_scope="not available",
22
+ confidence="low",
23
+ limitation="Soil lookup needs a WGS84 coordinate.",
24
+ design_implication="Use field/geotechnical verification before any soil-related design assumptions.",
25
+ verification_needed="Provide a map/KML/GeoJSON boundary or anchor coordinate.",
26
+ output_label="professional_verification_required",
27
+ )
28
+ ]
29
+ try:
30
+ data = get_json(
31
+ SOILGRIDS_URL,
32
+ {
33
+ "lon": selection.anchor_lon,
34
+ "lat": selection.anchor_lat,
35
+ "property": ["clay", "sand", "silt", "phh2o", "bdod", "soc"],
36
+ "depth": "0-5cm",
37
+ "value": "mean",
38
+ },
39
+ timeout=30,
40
+ )
41
+ values = _extract_soil_values(data)
42
+ soil = _interpret_soil(values)
43
+ return soil, [
44
+ make_evidence(
45
+ category="Soil / ground",
46
+ finding=_soil_finding(soil),
47
+ source_name="SoilGrids / ISRIC",
48
+ source_url="https://docs.isric.org/globaldata/soilgrids/",
49
+ source_type="global soil model",
50
+ resolution_or_scope="anchor coordinate; 0-5 cm modelled topsoil properties",
51
+ confidence="low",
52
+ limitation="SoilGrids is coarse global model data, not plot-level geotechnical testing or bearing-capacity evidence.",
53
+ design_implication=soil.get("design_implication", "Use only as a ground-condition prompt."),
54
+ verification_needed="Obtain site-specific geotechnical/professional verification before foundation or excavation decisions.",
55
+ output_label="professional_verification_required",
56
+ )
57
+ ]
58
+ except Exception as exc: # noqa: BLE001
59
+ return {}, [
60
+ make_evidence(
61
+ category="Soil / ground",
62
+ finding="SoilGrids soil properties could not be retrieved.",
63
+ source_name="SoilGrids / ISRIC",
64
+ source_url="https://docs.isric.org/globaldata/soilgrids/",
65
+ source_type="global soil model",
66
+ resolution_or_scope="not available",
67
+ confidence="low",
68
+ limitation=f"SoilGrids request failed: {type(exc).__name__}.",
69
+ design_implication="Do not infer soil type from this missing layer.",
70
+ verification_needed="Use geotechnical report, soil test, local engineer input, or official soil maps.",
71
+ output_label="professional_verification_required",
72
+ )
73
+ ]
74
+
75
+
76
+ def _extract_soil_values(data: dict[str, Any]) -> dict[str, float | None]:
77
+ values: dict[str, float | None] = {}
78
+ for layer in data.get("properties", {}).get("layers", []):
79
+ name = layer.get("name")
80
+ depths = layer.get("depths") or []
81
+ if not name or not depths:
82
+ continue
83
+ mean = (depths[0].get("values") or {}).get("mean")
84
+ values[name] = float(mean) if mean is not None else None
85
+ return values
86
+
87
+
88
+ def _interpret_soil(values: dict[str, float | None]) -> dict[str, Any]:
89
+ clay_pct = _gkg_to_pct(values.get("clay"))
90
+ sand_pct = _gkg_to_pct(values.get("sand"))
91
+ silt_pct = _gkg_to_pct(values.get("silt"))
92
+ ph = _ph_value(values.get("phh2o"))
93
+ texture = "mixed or uncertain texture"
94
+ implication = "Use as a preliminary prompt for soil verification; do not make foundation decisions from this layer."
95
+ if clay_pct is not None and clay_pct >= 35:
96
+ texture = "clay-heavy topsoil signal"
97
+ implication = "Check drainage, shrink-swell behaviour, water retention, and settlement risk with professional testing."
98
+ elif sand_pct is not None and sand_pct >= 55:
99
+ texture = "sandy topsoil signal"
100
+ implication = "Check drainage, erosion, excavation stability, and bearing conditions with professional testing."
101
+ elif silt_pct is not None and silt_pct >= 45:
102
+ texture = "silt-heavy topsoil signal"
103
+ implication = "Check waterlogging, compaction, erosion, and drainage behaviour with professional testing."
104
+ return {
105
+ "texture_signal": texture,
106
+ "clay_pct": clay_pct,
107
+ "sand_pct": sand_pct,
108
+ "silt_pct": silt_pct,
109
+ "ph_h2o": ph,
110
+ "bulk_density_raw": values.get("bdod"),
111
+ "soc_raw": values.get("soc"),
112
+ "design_implication": implication,
113
+ "safe_note": "Preliminary SoilGrids model output only; verify with geotechnical/professional sources.",
114
+ }
115
+
116
+
117
+ def _soil_finding(soil: dict[str, Any]) -> str:
118
+ parts = [soil.get("texture_signal") or "soil texture signal unavailable"]
119
+ if soil.get("clay_pct") is not None:
120
+ parts.append(f"clay {soil['clay_pct']}%")
121
+ if soil.get("sand_pct") is not None:
122
+ parts.append(f"sand {soil['sand_pct']}%")
123
+ if soil.get("silt_pct") is not None:
124
+ parts.append(f"silt {soil['silt_pct']}%")
125
+ if soil.get("ph_h2o") is not None:
126
+ parts.append(f"pH {soil['ph_h2o']}")
127
+ return "SoilGrids preliminary topsoil signal: " + ", ".join(parts) + "."
128
+
129
+
130
+ def _gkg_to_pct(value: float | None) -> float | None:
131
+ return round(value / 10, 1) if value is not None else None
132
+
133
+
134
+ def _ph_value(value: float | None) -> float | None:
135
+ return round(value / 10, 1) if value is not None else None
src/topography.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from statistics import mean
5
+ from typing import Any
6
+
7
+ from .evidence import make_evidence
8
+ from .geometry import EARTH_RADIUS_M
9
+ from .http_client import get_json
10
+ from .models import EvidenceItem, SiteSelection
11
+
12
+ OPENTOPO_URL = "https://api.opentopodata.org/v1/srtm90m"
13
+
14
+
15
+ def fetch_topography(selection: SiteSelection) -> tuple[dict[str, Any], list[EvidenceItem]]:
16
+ points = _sample_points(selection)
17
+ if not points:
18
+ return {}, [
19
+ make_evidence(
20
+ category="Topography",
21
+ finding="Topography sampling was skipped because no real-world site coordinate is available.",
22
+ source_name="OpenTopoData",
23
+ source_url="https://www.opentopodata.org/api/",
24
+ source_type="public elevation API",
25
+ resolution_or_scope="not available",
26
+ confidence="low",
27
+ limitation="Elevation needs a WGS84 coordinate.",
28
+ design_implication="Use user/CAD contour notes until the site is georeferenced.",
29
+ verification_needed="Provide a map/KML/GeoJSON boundary or anchor coordinate.",
30
+ output_label="site_visit_required",
31
+ )
32
+ ]
33
+ try:
34
+ locations = "|".join(f"{lat:.6f},{lon:.6f}" for lat, lon in points)
35
+ data = get_json(OPENTOPO_URL, {"locations": locations}, timeout=25)
36
+ elevations = [
37
+ float(item["elevation"])
38
+ for item in data.get("results", [])
39
+ if item.get("elevation") is not None
40
+ ]
41
+ if not elevations:
42
+ raise ValueError("No elevation values returned.")
43
+ relief = max(elevations) - min(elevations)
44
+ diagonal_m = _selection_diagonal_m(selection) or max(selection.radius_m or 250, 1)
45
+ slope_pct = (relief / diagonal_m) * 100 if diagonal_m else None
46
+ topo = {
47
+ "source": "OpenTopoData SRTM 90m",
48
+ "sample_count": len(elevations),
49
+ "min_elevation_m": round(min(elevations), 1),
50
+ "max_elevation_m": round(max(elevations), 1),
51
+ "mean_elevation_m": round(mean(elevations), 1),
52
+ "relief_m": round(relief, 1),
53
+ "approx_slope_pct": round(slope_pct, 2) if slope_pct is not None else None,
54
+ "interpretation": _topography_interpretation(relief, slope_pct),
55
+ }
56
+ return topo, [
57
+ make_evidence(
58
+ category="Topography",
59
+ finding=(
60
+ f"Elevation sampling found mean elevation {topo['mean_elevation_m']} m "
61
+ f"and approximate relief {topo['relief_m']} m across sampled points."
62
+ ),
63
+ source_name="OpenTopoData SRTM 90m",
64
+ source_url="https://www.opentopodata.org/api/",
65
+ source_type="public elevation API",
66
+ resolution_or_scope="sampled centroid and boundary/bbox points; SRTM-scale terrain",
67
+ confidence="medium",
68
+ limitation="SRTM/OpenTopoData is not a site survey and may miss small plot-level slopes, steps, retaining walls, and drains.",
69
+ design_implication="Use for early slope/drainage awareness and decide what to verify on site.",
70
+ verification_needed="Check actual slope, contours, waterlogging, retaining edges, and drainage outlets during site visit.",
71
+ output_label="public_data",
72
+ )
73
+ ]
74
+ except Exception as exc: # noqa: BLE001
75
+ return {}, [
76
+ make_evidence(
77
+ category="Topography",
78
+ finding="Topography/elevation data could not be retrieved.",
79
+ source_name="OpenTopoData",
80
+ source_url="https://www.opentopodata.org/api/",
81
+ source_type="public elevation API",
82
+ resolution_or_scope="not available",
83
+ confidence="low",
84
+ limitation=f"API request failed: {type(exc).__name__}.",
85
+ design_implication="Do not infer slope or drainage direction from this missing layer.",
86
+ verification_needed="Use CAD contours, survey levels, site observation, or another DEM source.",
87
+ output_label="site_visit_required",
88
+ )
89
+ ]
90
+
91
+
92
+ def _sample_points(selection: SiteSelection) -> list[tuple[float, float]]:
93
+ points: list[tuple[float, float]] = []
94
+ if selection.anchor_lat is not None and selection.anchor_lon is not None:
95
+ points.append((selection.anchor_lat, selection.anchor_lon))
96
+ if selection.bbox:
97
+ min_lon, min_lat, max_lon, max_lat = selection.bbox
98
+ points.extend(
99
+ [
100
+ (min_lat, min_lon),
101
+ (min_lat, max_lon),
102
+ (max_lat, min_lon),
103
+ (max_lat, max_lon),
104
+ ((min_lat + max_lat) / 2, min_lon),
105
+ ((min_lat + max_lat) / 2, max_lon),
106
+ ]
107
+ )
108
+ return _dedupe(points)[:8]
109
+
110
+
111
+ def _dedupe(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
112
+ seen = set()
113
+ out = []
114
+ for lat, lon in points:
115
+ key = (round(lat, 6), round(lon, 6))
116
+ if key not in seen:
117
+ seen.add(key)
118
+ out.append((lat, lon))
119
+ return out
120
+
121
+
122
+ def _selection_diagonal_m(selection: SiteSelection) -> float | None:
123
+ if not selection.bbox:
124
+ return None
125
+ min_lon, min_lat, max_lon, max_lat = selection.bbox
126
+ return _distance_m(min_lat, min_lon, max_lat, max_lon)
127
+
128
+
129
+ def _distance_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
130
+ phi1 = math.radians(lat1)
131
+ phi2 = math.radians(lat2)
132
+ dphi = math.radians(lat2 - lat1)
133
+ dlambda = math.radians(lon2 - lon1)
134
+ a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
135
+ return 2 * EARTH_RADIUS_M * math.atan2(math.sqrt(a), math.sqrt(1 - a))
136
+
137
+
138
+ def _topography_interpretation(relief_m: float, slope_pct: float | None) -> str:
139
+ if slope_pct is None:
140
+ return "Elevation was sampled, but slope could not be estimated."
141
+ if relief_m < 1.0 and slope_pct < 1.0:
142
+ return "Public elevation samples suggest a broadly flat site, but micro-drainage still needs site verification."
143
+ if slope_pct < 3.0:
144
+ return "Public elevation samples suggest a gentle level change; verify drainage and accessible-route gradients."
145
+ return "Public elevation samples suggest meaningful level change; verify contours, cut-fill, drainage, and retaining edges."
src/upload_limits.py CHANGED
@@ -6,6 +6,7 @@ from typing import Iterable
6
 
7
  MAX_DXF_MB = 10
8
  MAX_GEOJSON_MB = 5
 
9
  MAX_PDF_REFERENCE_MB = 15
10
  MAX_BOARD_PREVIEW_MB = 12
11
 
 
6
 
7
  MAX_DXF_MB = 10
8
  MAX_GEOJSON_MB = 5
9
+ MAX_KML_MB = 8
10
  MAX_PDF_REFERENCE_MB = 15
11
  MAX_BOARD_PREVIEW_MB = 12
12
 
tests/test_kml_parser.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import tempfile
2
+ import unittest
3
+ import zipfile
4
+ from pathlib import Path
5
+
6
+ from src.kml_parser import parse_kml_file
7
+
8
+
9
+ KML_TEXT = """<?xml version="1.0" encoding="UTF-8"?>
10
+ <kml xmlns="http://www.opengis.net/kml/2.2">
11
+ <Document>
12
+ <Placemark>
13
+ <name>Site boundary</name>
14
+ <Polygon>
15
+ <outerBoundaryIs>
16
+ <LinearRing>
17
+ <coordinates>
18
+ 70.2450,21.0020,0 70.2460,21.0020,0 70.2460,21.0030,0 70.2450,21.0030,0 70.2450,21.0020,0
19
+ </coordinates>
20
+ </LinearRing>
21
+ </outerBoundaryIs>
22
+ </Polygon>
23
+ </Placemark>
24
+ </Document>
25
+ </kml>
26
+ """
27
+
28
+
29
+ class KmlParserTests(unittest.TestCase):
30
+ def test_kml_polygon_creates_wgs84_selection(self):
31
+ with tempfile.TemporaryDirectory() as tmp:
32
+ path = Path(tmp) / "site.kml"
33
+ path.write_text(KML_TEXT, encoding="utf-8")
34
+
35
+ selection = parse_kml_file(path)
36
+
37
+ self.assertEqual(selection.selection_type, "kml_boundary")
38
+ self.assertEqual(selection.coordinate_mode, "wgs84")
39
+ self.assertGreater(selection.area_sqm or 0, 0)
40
+ self.assertAlmostEqual(selection.anchor_lat or 0, 21.0024, places=3)
41
+ self.assertAlmostEqual(selection.anchor_lon or 0, 70.2454, places=3)
42
+ self.assertIn("legal/cadastral", " ".join(selection.limitations))
43
+
44
+ def test_kmz_polygon_creates_selection(self):
45
+ with tempfile.TemporaryDirectory() as tmp:
46
+ path = Path(tmp) / "site.kmz"
47
+ with zipfile.ZipFile(path, "w") as archive:
48
+ archive.writestr("doc.kml", KML_TEXT)
49
+
50
+ selection = parse_kml_file(path)
51
+
52
+ self.assertEqual(selection.selection_type, "kml_boundary")
53
+ self.assertEqual(selection.source_files, ["site.kmz"])
54
+
55
+ def test_kml_without_polygon_is_rejected(self):
56
+ with tempfile.TemporaryDirectory() as tmp:
57
+ path = Path(tmp) / "empty.kml"
58
+ path.write_text("<kml><Document /></kml>", encoding="utf-8")
59
+
60
+ with self.assertRaises(ValueError):
61
+ parse_kml_file(path)
62
+
63
+
64
+ if __name__ == "__main__":
65
+ unittest.main()
tests/test_report.py CHANGED
@@ -93,6 +93,20 @@ class ReportTests(unittest.TestCase):
93
  "wind_note": "Regional wind direction is modelled and needs site verification.",
94
  "limitation": "No surrounding-building shadow simulation.",
95
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
  board = build_board_markdown(
98
  "Resort Thesis",
@@ -102,6 +116,8 @@ class ReportTests(unittest.TestCase):
102
  osm_context,
103
  sun_summary,
104
  {"city": "Chorwad", "state": "Gujarat", "country": "India"},
 
 
105
  )
106
 
107
  self.assertIn("Detailed Site Analysis Workbook", board)
@@ -109,6 +125,9 @@ class ReportTests(unittest.TestCase):
109
  self.assertIn("Urban Design Lab", board)
110
  self.assertIn("Diagram And Sheet Production Checklist", board)
111
  self.assertIn("Before, During And After Site Visit", board)
 
 
 
112
  self.assertEqual(find_forbidden_phrases(board), [])
113
 
114
 
 
93
  "wind_note": "Regional wind direction is modelled and needs site verification.",
94
  "limitation": "No surrounding-building shadow simulation.",
95
  }
96
+ topography = {
97
+ "mean_elevation_m": 11.2,
98
+ "relief_m": 3.4,
99
+ "approx_slope_pct": 1.2,
100
+ "interpretation": "Public elevation samples suggest a gentle level change.",
101
+ }
102
+ soil = {
103
+ "texture_signal": "clay-heavy topsoil signal",
104
+ "clay_pct": 42.0,
105
+ "sand_pct": 30.0,
106
+ "silt_pct": 28.0,
107
+ "ph_h2o": 7.2,
108
+ "design_implication": "Check drainage and settlement risk with professional testing.",
109
+ }
110
 
111
  board = build_board_markdown(
112
  "Resort Thesis",
 
116
  osm_context,
117
  sun_summary,
118
  {"city": "Chorwad", "state": "Gujarat", "country": "India"},
119
+ topography=topography,
120
+ soil=soil,
121
  )
122
 
123
  self.assertIn("Detailed Site Analysis Workbook", board)
 
125
  self.assertIn("Urban Design Lab", board)
126
  self.assertIn("Diagram And Sheet Production Checklist", board)
127
  self.assertIn("Before, During And After Site Visit", board)
128
+ self.assertIn("Public elevation signal", board)
129
+ self.assertIn("Preliminary SoilGrids signal", board)
130
+ self.assertNotIn("- -", board)
131
  self.assertEqual(find_forbidden_phrases(board), [])
132
 
133
 
tests/test_soil.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from unittest.mock import patch
3
+
4
+ from src.geometry import selection_from_lat_lon
5
+ from src.safety import find_forbidden_phrases
6
+ from src.soil import fetch_soil
7
+
8
+
9
+ def _soil_payload(clay=420, sand=300, silt=280, phh2o=72):
10
+ return {
11
+ "properties": {
12
+ "layers": [
13
+ {"name": "clay", "depths": [{"values": {"mean": clay}}]},
14
+ {"name": "sand", "depths": [{"values": {"mean": sand}}]},
15
+ {"name": "silt", "depths": [{"values": {"mean": silt}}]},
16
+ {"name": "phh2o", "depths": [{"values": {"mean": phh2o}}]},
17
+ {"name": "bdod", "depths": [{"values": {"mean": 140}}]},
18
+ {"name": "soc", "depths": [{"values": {"mean": 18}}]},
19
+ ]
20
+ }
21
+ }
22
+
23
+
24
+ class SoilTests(unittest.TestCase):
25
+ def test_fetch_soil_interprets_clay_heavy_signal_safely(self):
26
+ selection = selection_from_lat_lon(21.002, 70.245, radius_m=250)
27
+ with patch("src.soil.get_json", return_value=_soil_payload()):
28
+ soil, evidence = fetch_soil(selection)
29
+
30
+ self.assertEqual(soil["texture_signal"], "clay-heavy topsoil signal")
31
+ self.assertEqual(soil["clay_pct"], 42.0)
32
+ self.assertEqual(soil["ph_h2o"], 7.2)
33
+ joined = evidence[0].finding + evidence[0].design_implication + evidence[0].verification_needed
34
+ self.assertIn("professional", joined)
35
+ self.assertEqual(find_forbidden_phrases(joined), [])
36
+
37
+ def test_fetch_soil_failure_returns_missing_data_evidence(self):
38
+ selection = selection_from_lat_lon(21.002, 70.245, radius_m=250)
39
+ with patch("src.soil.get_json", side_effect=TimeoutError("slow")):
40
+ soil, evidence = fetch_soil(selection)
41
+
42
+ self.assertEqual(soil, {})
43
+ self.assertEqual(evidence[0].confidence, "low")
44
+ self.assertIn("Do not infer soil type", evidence[0].design_implication)
45
+
46
+
47
+ if __name__ == "__main__":
48
+ unittest.main()
tests/test_topography.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from unittest.mock import patch
3
+
4
+ from src.geometry import selection_from_lat_lon
5
+ from src.topography import fetch_topography
6
+
7
+
8
+ class TopographyTests(unittest.TestCase):
9
+ def test_fetch_topography_summarizes_elevation_samples(self):
10
+ selection = selection_from_lat_lon(21.002, 70.245, radius_m=250)
11
+ fake_response = {
12
+ "results": [
13
+ {"elevation": 12.0},
14
+ {"elevation": 14.5},
15
+ {"elevation": 13.0},
16
+ {"elevation": 16.0},
17
+ ]
18
+ }
19
+ with patch("src.topography.get_json", return_value=fake_response):
20
+ topo, evidence = fetch_topography(selection)
21
+
22
+ self.assertEqual(topo["sample_count"], 4)
23
+ self.assertEqual(topo["min_elevation_m"], 12.0)
24
+ self.assertEqual(topo["max_elevation_m"], 16.0)
25
+ self.assertGreaterEqual(topo["relief_m"], 4.0)
26
+ self.assertEqual(evidence[0].category, "Topography")
27
+ self.assertIn("not a site survey", evidence[0].limitation)
28
+
29
+ def test_fetch_topography_failure_returns_safe_evidence(self):
30
+ selection = selection_from_lat_lon(21.002, 70.245, radius_m=250)
31
+ with patch("src.topography.get_json", side_effect=TimeoutError("slow")):
32
+ topo, evidence = fetch_topography(selection)
33
+
34
+ self.assertEqual(topo, {})
35
+ self.assertEqual(evidence[0].confidence, "low")
36
+ self.assertIn("Do not infer slope", evidence[0].design_implication)
37
+
38
+
39
+ if __name__ == "__main__":
40
+ unittest.main()