Support polygon-only analysis flow
Browse files- app.py +93 -4
- src/sample_data.py +21 -3
- tests/test_app_defaults.py +79 -0
- tests/test_sample_data.py +24 -0
app.py
CHANGED
|
@@ -312,10 +312,28 @@ def generate_site_analysis(
|
|
| 312 |
osm_context=osm_context,
|
| 313 |
topography=topography,
|
| 314 |
soil=soil,
|
|
|
|
|
|
|
| 315 |
)
|
| 316 |
evidence.extend(sample_evidence)
|
| 317 |
warnings.extend(sample_warnings)
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
diagram_paths = create_diagrams(
|
| 320 |
selection,
|
| 321 |
climate,
|
|
@@ -571,6 +589,63 @@ def _current_wind_direction(climate: dict[str, Any]) -> float | None:
|
|
| 571 |
return None
|
| 572 |
|
| 573 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
def _file_path(file_obj: Any) -> str:
|
| 575 |
return str(getattr(file_obj, "name", file_obj))
|
| 576 |
|
|
@@ -624,10 +699,24 @@ def build_app() -> gr.Blocks:
|
|
| 624 |
)
|
| 625 |
with gr.Column(scale=5, elem_classes=["control-panel"]):
|
| 626 |
gr.Markdown("### Project Setup")
|
| 627 |
-
project_name = gr.Textbox(
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
manual_location = gr.Textbox(
|
| 632 |
label="Lat/lon or Google Maps URL",
|
| 633 |
placeholder="21.002, 70.245 or a Google Maps URL with coordinates",
|
|
|
|
| 312 |
osm_context=osm_context,
|
| 313 |
topography=topography,
|
| 314 |
soil=soil,
|
| 315 |
+
anchor_lat=selection.anchor_lat,
|
| 316 |
+
anchor_lon=selection.anchor_lon,
|
| 317 |
)
|
| 318 |
evidence.extend(sample_evidence)
|
| 319 |
warnings.extend(sample_warnings)
|
| 320 |
|
| 321 |
+
(
|
| 322 |
+
project_name,
|
| 323 |
+
site_name,
|
| 324 |
+
project_type,
|
| 325 |
+
boundary_source,
|
| 326 |
+
auto_label_warnings,
|
| 327 |
+
) = _resolve_project_fields(
|
| 328 |
+
project_name=project_name,
|
| 329 |
+
site_name=site_name,
|
| 330 |
+
project_type=project_type,
|
| 331 |
+
boundary_source=boundary_source,
|
| 332 |
+
selection=selection,
|
| 333 |
+
site_identity=site_identity,
|
| 334 |
+
)
|
| 335 |
+
warnings.extend(auto_label_warnings)
|
| 336 |
+
|
| 337 |
diagram_paths = create_diagrams(
|
| 338 |
selection,
|
| 339 |
climate,
|
|
|
|
| 589 |
return None
|
| 590 |
|
| 591 |
|
| 592 |
+
def _resolve_project_fields(
|
| 593 |
+
*,
|
| 594 |
+
project_name: str,
|
| 595 |
+
site_name: str,
|
| 596 |
+
project_type: str,
|
| 597 |
+
boundary_source: str,
|
| 598 |
+
selection: SiteSelection,
|
| 599 |
+
site_identity: dict[str, Any] | None,
|
| 600 |
+
) -> tuple[str, str, str, str, list[str]]:
|
| 601 |
+
warnings: list[str] = []
|
| 602 |
+
project = (project_name or "").strip()
|
| 603 |
+
site = (site_name or "").strip()
|
| 604 |
+
ptype = (project_type or "").strip()
|
| 605 |
+
source = (boundary_source or "").strip()
|
| 606 |
+
|
| 607 |
+
if not project:
|
| 608 |
+
project = "Preliminary Site Analysis"
|
| 609 |
+
warnings.append("Project name was not provided; the app used a generic title.")
|
| 610 |
+
if not site or site.lower() in {"untitled site", "untitled", "site"}:
|
| 611 |
+
site = _auto_site_name(selection, site_identity)
|
| 612 |
+
warnings.append("Site name was not provided; the app generated a label from the selected area.")
|
| 613 |
+
if not ptype:
|
| 614 |
+
ptype = "Early-stage architecture site analysis"
|
| 615 |
+
if not source:
|
| 616 |
+
source = _auto_boundary_source(selection)
|
| 617 |
+
warnings.append("Boundary source was not provided; the report labels the selection from the input mode.")
|
| 618 |
+
return project, site, ptype, source, warnings
|
| 619 |
+
|
| 620 |
+
|
| 621 |
+
def _auto_site_name(selection: SiteSelection, site_identity: dict[str, Any] | None) -> str:
|
| 622 |
+
if site_identity:
|
| 623 |
+
for key in ("city", "town", "village", "district", "state"):
|
| 624 |
+
value = site_identity.get(key)
|
| 625 |
+
if value:
|
| 626 |
+
return f"{value} selected site"
|
| 627 |
+
display = site_identity.get("display_name")
|
| 628 |
+
if display:
|
| 629 |
+
return f"{str(display).split(',')[0]} selected site"
|
| 630 |
+
if selection.anchor_lat is not None and selection.anchor_lon is not None:
|
| 631 |
+
return f"Selected site {selection.anchor_lat:.5f}, {selection.anchor_lon:.5f}"
|
| 632 |
+
return "Selected site area"
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
def _auto_boundary_source(selection: SiteSelection) -> str:
|
| 636 |
+
if selection.selection_type in {"drawn_polygon", "rectangle"}:
|
| 637 |
+
return "User-drawn boundary on OpenStreetMap base map; approximate and not legal/cadastral."
|
| 638 |
+
if selection.selection_type == "pin_radius":
|
| 639 |
+
return "User-selected pin and radius; approximate context analysis, not exact boundary."
|
| 640 |
+
if selection.selection_type == "dxf_boundary":
|
| 641 |
+
return "Uploaded DXF boundary candidate selected by user."
|
| 642 |
+
if selection.selection_type == "geojson_boundary":
|
| 643 |
+
return "Uploaded GeoJSON boundary selected by user."
|
| 644 |
+
if selection.selection_type == "kml_boundary":
|
| 645 |
+
return "Uploaded KML/KMZ boundary selected by user."
|
| 646 |
+
return "User-provided site selection."
|
| 647 |
+
|
| 648 |
+
|
| 649 |
def _file_path(file_obj: Any) -> str:
|
| 650 |
return str(getattr(file_obj, "name", file_obj))
|
| 651 |
|
|
|
|
| 699 |
)
|
| 700 |
with gr.Column(scale=5, elem_classes=["control-panel"]):
|
| 701 |
gr.Markdown("### Project Setup")
|
| 702 |
+
project_name = gr.Textbox(
|
| 703 |
+
label="Project name (optional)",
|
| 704 |
+
placeholder="Auto: Preliminary Site Analysis",
|
| 705 |
+
info="Leave blank if you only want to draw/select a site and generate analysis.",
|
| 706 |
+
)
|
| 707 |
+
site_name = gr.Textbox(
|
| 708 |
+
label="Site name (optional)",
|
| 709 |
+
placeholder="Auto-filled from approximate address or coordinates",
|
| 710 |
+
info="If blank, the app labels the selected polygon/pin from reverse geocoding or coordinates.",
|
| 711 |
+
)
|
| 712 |
+
project_type = gr.Textbox(
|
| 713 |
+
label="Project type (optional)",
|
| 714 |
+
placeholder="Thesis, resort, housing, institute...",
|
| 715 |
+
)
|
| 716 |
+
boundary_source = gr.Textbox(
|
| 717 |
+
label="Boundary source (optional)",
|
| 718 |
+
placeholder="Auto-labelled from map draw, pin radius, CAD, KML/KMZ, or GeoJSON",
|
| 719 |
+
)
|
| 720 |
manual_location = gr.Textbox(
|
| 721 |
label="Lat/lon or Google Maps URL",
|
| 722 |
placeholder="21.002, 70.245 or a Google Maps URL with coordinates",
|
src/sample_data.py
CHANGED
|
@@ -7,9 +7,19 @@ from .evidence import make_evidence
|
|
| 7 |
from .models import EvidenceItem
|
| 8 |
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
text = " ".join([project_name or "", site_name or "", boundary_source or ""]).lower()
|
| 12 |
-
return "chorwad" in text and "sample" in text
|
| 13 |
|
| 14 |
|
| 15 |
def apply_chorwad_sample_fallbacks(
|
|
@@ -22,8 +32,10 @@ def apply_chorwad_sample_fallbacks(
|
|
| 22 |
osm_context: dict[str, Any],
|
| 23 |
topography: dict[str, Any],
|
| 24 |
soil: dict[str, Any],
|
|
|
|
|
|
|
| 25 |
) -> tuple[dict[str, Any] | None, dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any], list[EvidenceItem], list[str]]:
|
| 26 |
-
if not is_sample_site(project_name, site_name, boundary_source):
|
| 27 |
return site_identity, climate, osm_context, topography, soil, [], []
|
| 28 |
|
| 29 |
evidence: list[EvidenceItem] = []
|
|
@@ -70,6 +82,12 @@ def apply_chorwad_sample_fallbacks(
|
|
| 70 |
return site_identity, climate, osm_context, topography, soil, evidence, warnings
|
| 71 |
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
def _missing_climate(climate: dict[str, Any]) -> bool:
|
| 74 |
for key in ("forecast", "recent_historical", "climate_normal"):
|
| 75 |
value = climate.get(key)
|
|
|
|
| 7 |
from .models import EvidenceItem
|
| 8 |
|
| 9 |
|
| 10 |
+
CHORWAD_SAMPLE_LAT = 21.00248
|
| 11 |
+
CHORWAD_SAMPLE_LON = 70.24537
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def is_sample_site(
|
| 15 |
+
project_name: str,
|
| 16 |
+
site_name: str,
|
| 17 |
+
boundary_source: str,
|
| 18 |
+
anchor_lat: float | None = None,
|
| 19 |
+
anchor_lon: float | None = None,
|
| 20 |
+
) -> bool:
|
| 21 |
text = " ".join([project_name or "", site_name or "", boundary_source or ""]).lower()
|
| 22 |
+
return ("chorwad" in text and "sample" in text) or _near_chorwad_sample(anchor_lat, anchor_lon)
|
| 23 |
|
| 24 |
|
| 25 |
def apply_chorwad_sample_fallbacks(
|
|
|
|
| 32 |
osm_context: dict[str, Any],
|
| 33 |
topography: dict[str, Any],
|
| 34 |
soil: dict[str, Any],
|
| 35 |
+
anchor_lat: float | None = None,
|
| 36 |
+
anchor_lon: float | None = None,
|
| 37 |
) -> tuple[dict[str, Any] | None, dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any], list[EvidenceItem], list[str]]:
|
| 38 |
+
if not is_sample_site(project_name, site_name, boundary_source, anchor_lat, anchor_lon):
|
| 39 |
return site_identity, climate, osm_context, topography, soil, [], []
|
| 40 |
|
| 41 |
evidence: list[EvidenceItem] = []
|
|
|
|
| 82 |
return site_identity, climate, osm_context, topography, soil, evidence, warnings
|
| 83 |
|
| 84 |
|
| 85 |
+
def _near_chorwad_sample(anchor_lat: float | None, anchor_lon: float | None) -> bool:
|
| 86 |
+
if anchor_lat is None or anchor_lon is None:
|
| 87 |
+
return False
|
| 88 |
+
return abs(anchor_lat - CHORWAD_SAMPLE_LAT) <= 0.01 and abs(anchor_lon - CHORWAD_SAMPLE_LON) <= 0.01
|
| 89 |
+
|
| 90 |
+
|
| 91 |
def _missing_climate(climate: dict[str, Any]) -> bool:
|
| 92 |
for key in ("forecast", "recent_historical", "climate_normal"):
|
| 93 |
value = climate.get(key)
|
tests/test_app_defaults.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
|
| 3 |
+
from app import _resolve_project_fields
|
| 4 |
+
from src.models import SiteSelection
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class AppDefaultTests(unittest.TestCase):
|
| 8 |
+
def test_polygon_only_metadata_is_auto_labelled(self):
|
| 9 |
+
selection = SiteSelection(
|
| 10 |
+
id="S1",
|
| 11 |
+
selection_type="drawn_polygon",
|
| 12 |
+
coordinate_mode="wgs84",
|
| 13 |
+
geometry_geojson={"type": "Polygon", "coordinates": []},
|
| 14 |
+
local_geometry=None,
|
| 15 |
+
anchor_lat=21.00248,
|
| 16 |
+
anchor_lon=70.24537,
|
| 17 |
+
radius_m=None,
|
| 18 |
+
area_sqm=11978,
|
| 19 |
+
perimeter_m=439,
|
| 20 |
+
centroid=(21.00248, 70.24537),
|
| 21 |
+
bbox=(70.2447, 21.0019, 70.246, 21.003),
|
| 22 |
+
unit_source="WGS84 drawn map",
|
| 23 |
+
accuracy_label="user-drawn approximate boundary",
|
| 24 |
+
source_files=[],
|
| 25 |
+
selected_boundary_id=None,
|
| 26 |
+
limitations=["Drawn map boundaries are approximate."],
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
project, site, project_type, source, warnings = _resolve_project_fields(
|
| 30 |
+
project_name="",
|
| 31 |
+
site_name="",
|
| 32 |
+
project_type="",
|
| 33 |
+
boundary_source="",
|
| 34 |
+
selection=selection,
|
| 35 |
+
site_identity={"city": "Chorwad", "state": "Gujarat", "country": "India"},
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
self.assertEqual(project, "Preliminary Site Analysis")
|
| 39 |
+
self.assertEqual(site, "Chorwad selected site")
|
| 40 |
+
self.assertEqual(project_type, "Early-stage architecture site analysis")
|
| 41 |
+
self.assertIn("User-drawn boundary", source)
|
| 42 |
+
self.assertGreaterEqual(len(warnings), 3)
|
| 43 |
+
|
| 44 |
+
def test_default_untitled_site_is_replaced(self):
|
| 45 |
+
selection = SiteSelection(
|
| 46 |
+
id="S2",
|
| 47 |
+
selection_type="pin_radius",
|
| 48 |
+
coordinate_mode="wgs84",
|
| 49 |
+
geometry_geojson=None,
|
| 50 |
+
local_geometry=None,
|
| 51 |
+
anchor_lat=21.0,
|
| 52 |
+
anchor_lon=70.0,
|
| 53 |
+
radius_m=250,
|
| 54 |
+
area_sqm=196349.5,
|
| 55 |
+
perimeter_m=1570.8,
|
| 56 |
+
centroid=(21.0, 70.0),
|
| 57 |
+
bbox=None,
|
| 58 |
+
unit_source="meters",
|
| 59 |
+
accuracy_label="pin-radius approximation",
|
| 60 |
+
source_files=[],
|
| 61 |
+
selected_boundary_id=None,
|
| 62 |
+
limitations=["This is approximate context analysis."],
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
_, site, _, source, _ = _resolve_project_fields(
|
| 66 |
+
project_name="Architecture Site Analysis",
|
| 67 |
+
site_name="Untitled site",
|
| 68 |
+
project_type="",
|
| 69 |
+
boundary_source="",
|
| 70 |
+
selection=selection,
|
| 71 |
+
site_identity=None,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
self.assertEqual(site, "Selected site 21.00000, 70.00000")
|
| 75 |
+
self.assertIn("pin and radius", source)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
unittest.main()
|
tests/test_sample_data.py
CHANGED
|
@@ -8,6 +8,8 @@ class SampleDataTests(unittest.TestCase):
|
|
| 8 |
self.assertTrue(is_sample_site("Chorwad Coastal Thesis Sample", "", "sample drawn boundary"))
|
| 9 |
self.assertFalse(is_sample_site("Real thesis", "Chorwad site", "faculty CAD"))
|
| 10 |
self.assertFalse(is_sample_site("Sample", "Mumbai site", "manual pin"))
|
|
|
|
|
|
|
| 11 |
|
| 12 |
def test_sample_fallback_fills_missing_layers_and_warns(self):
|
| 13 |
identity, climate, context, topo, soil, evidence, warnings = apply_chorwad_sample_fallbacks(
|
|
@@ -63,6 +65,28 @@ class SampleDataTests(unittest.TestCase):
|
|
| 63 |
self.assertEqual(evidence, [])
|
| 64 |
self.assertEqual(warnings, [])
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
if __name__ == "__main__":
|
| 68 |
unittest.main()
|
|
|
|
| 8 |
self.assertTrue(is_sample_site("Chorwad Coastal Thesis Sample", "", "sample drawn boundary"))
|
| 9 |
self.assertFalse(is_sample_site("Real thesis", "Chorwad site", "faculty CAD"))
|
| 10 |
self.assertFalse(is_sample_site("Sample", "Mumbai site", "manual pin"))
|
| 11 |
+
self.assertTrue(is_sample_site("", "", "", anchor_lat=21.00248, anchor_lon=70.24537))
|
| 12 |
+
self.assertFalse(is_sample_site("", "", "", anchor_lat=19.076, anchor_lon=72.8777))
|
| 13 |
|
| 14 |
def test_sample_fallback_fills_missing_layers_and_warns(self):
|
| 15 |
identity, climate, context, topo, soil, evidence, warnings = apply_chorwad_sample_fallbacks(
|
|
|
|
| 65 |
self.assertEqual(evidence, [])
|
| 66 |
self.assertEqual(warnings, [])
|
| 67 |
|
| 68 |
+
def test_coordinate_sample_fallback_supports_polygon_only_demo(self):
|
| 69 |
+
identity, climate, context, topo, soil, evidence, warnings = apply_chorwad_sample_fallbacks(
|
| 70 |
+
project_name="",
|
| 71 |
+
site_name="",
|
| 72 |
+
boundary_source="",
|
| 73 |
+
site_identity=None,
|
| 74 |
+
climate={"forecast": None, "recent_historical": None, "climate_normal": None},
|
| 75 |
+
osm_context={"counts": {}, "features": []},
|
| 76 |
+
topography={},
|
| 77 |
+
soil={},
|
| 78 |
+
anchor_lat=21.00248,
|
| 79 |
+
anchor_lon=70.24537,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
self.assertEqual(identity["state"], "Gujarat")
|
| 83 |
+
self.assertIn("forecast", climate)
|
| 84 |
+
self.assertGreater(context["counts"]["water"], 0)
|
| 85 |
+
self.assertGreater(topo["mean_elevation_m"], 0)
|
| 86 |
+
self.assertIn("texture_signal", soil)
|
| 87 |
+
self.assertEqual(len(evidence), 1)
|
| 88 |
+
self.assertIn("demo/testing", warnings[0])
|
| 89 |
+
|
| 90 |
|
| 91 |
if __name__ == "__main__":
|
| 92 |
unittest.main()
|