Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,20 @@
.vscode/
.claude/
.worktrees/

# Python caches and bytecode
__pycache__/
*.py[cod]

# Local virtual environments
.venv/
venv/

# Test and tooling caches
.pytest_cache/
.mypy_cache/
.ruff_cache/

# Local coverage artifacts
.coverage
htmlcov/
161 changes: 161 additions & 0 deletions webapp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
import sys
from dataclasses import dataclass
from html import escape
import math
from pathlib import Path
from typing import Any

import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import requests
import streamlit as st
import yaml

Expand Down Expand Up @@ -379,6 +381,39 @@ def render_sidebar(profiles: dict[str, SiteProfile]) -> None:
)
clear_results()

location_query = st.text_input(
"Search coastal/ocean area",
placeholder="e.g. Moray Firth, Scotland",
key="custom_location_query",
)
st.caption(
"Beta: search and coastal validation are approximate and need refinement."
)
if st.button(
"Find coordinates",
key="resolve_custom_location",
use_container_width=True,
):
if not location_query.strip():
st.warning("Enter a location to search.")
else:
resolved = resolve_custom_location(location_query.strip())
if not resolved["ok"]:
st.error(str(resolved["message"]))
elif not resolved["is_ocean_coastal"]:
st.warning(str(resolved["message"]))
st.caption(
"Pick a point in the sea near the coast (not inland)."
)
else:
st.session_state.custom_latitude = resolved["latitude"]
st.session_state.custom_longitude = resolved["longitude"]
st.success(
f"Set to {resolved['label']} "
f"({resolved['latitude']:.4f}, {resolved['longitude']:.4f})"
)
clear_results()

latitude = st.number_input(
"Latitude",
-90.0,
Expand Down Expand Up @@ -551,6 +586,132 @@ def render_header() -> None:
)


def resolve_custom_location(query: str) -> dict[str, Any]:
# TODO: Improve geocoding/coastal validation reliability (offline fallback,
# stricter sea-vs-land classification, and clearer candidate selection).
search_url = "https://nominatim.openstreetmap.org/search"
try:
response = requests.get(
search_url,
params={"q": query, "format": "jsonv2", "limit": 5},
headers={"User-Agent": "fleximorpv2/1.0"},
timeout=7,
)
response.raise_for_status()
results = response.json()
except requests.RequestException:
return {
"ok": False,
"message": "Location search unavailable right now. Try manual coordinates.",
}

if not isinstance(results, list) or not results:
return {"ok": False, "message": "No matching location found."}

candidates = []
for result in results:
try:
lat = float(result.get("lat"))
lon = float(result.get("lon"))
except (TypeError, ValueError):
continue
label = str(result.get("display_name", "Selected location"))
suitability = ocean_coastal_suitability(lat, lon, label)
candidates.append((suitability["score"], lat, lon, label, suitability))

if not candidates:
return {"ok": False, "message": "Could not parse location coordinates."}

candidates.sort(key=lambda item: item[0], reverse=True)
_, lat, lon, label, suitability = candidates[0]

return {
"ok": True,
"latitude": lat,
"longitude": lon,
"label": label,
"is_ocean_coastal": suitability["is_ocean_coastal"],
"message": suitability["message"],
}


def ocean_coastal_suitability(lat: float, lon: float, label: str) -> dict[str, Any]:
text = label.lower()
marine_words = ("sea", "ocean", "gulf", "bay", "channel", "strait", "coast")
inland_words = ("county", "district", "village", "city", "town", "province")
marine_hint = any(word in text for word in marine_words)
inland_hint = any(word in text for word in inland_words)

coastline_nearby = has_nearby_coastline(lat, lon)
score = 0
if marine_hint:
score += 2
if coastline_nearby:
score += 2
if inland_hint:
score -= 1

if marine_hint and coastline_nearby:
return {
"score": score,
"is_ocean_coastal": True,
"message": "Location is offshore/coastal and suitable.",
}
if coastline_nearby and not marine_hint:
return {
"score": score,
"is_ocean_coastal": False,
"message": "Location is near coast but may be on land. Move point offshore.",
}
return {
"score": score,
"is_ocean_coastal": False,
"message": "Location does not look coastal/oceanic.",
}


def has_nearby_coastline(lat: float, lon: float) -> bool:
# Fast fallback based on known case-study geography if network is unavailable.
case_sites = [(59.32, -155.90), (55.13, 1.48), (44.90, -66.98)]
for site_lat, site_lon in case_sites:
if haversine_km(lat, lon, site_lat, site_lon) <= 250:
return True

overpass_url = "https://overpass-api.de/api/interpreter"
query = (
"[out:json][timeout:10];"
f'way["natural"="coastline"](around:30000,{lat},{lon});'
"out ids;"
)
try:
response = requests.get(
overpass_url,
params={"data": query},
headers={"User-Agent": "fleximorpv2/1.0"},
timeout=10,
)
response.raise_for_status()
payload = response.json()
except requests.RequestException:
return False

elements = payload.get("elements", []) if isinstance(payload, dict) else []
return len(elements) > 0


def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
radius_km = 6371.0
d_lat = math.radians(lat2 - lat1)
d_lon = math.radians(lon2 - lon1)
a = (
math.sin(d_lat / 2) ** 2
+ math.cos(math.radians(lat1))
* math.cos(math.radians(lat2))
* math.sin(d_lon / 2) ** 2
)
return 2 * radius_km * math.asin(math.sqrt(a))


def render_workspace(profile: SiteProfile) -> None:
left, right = st.columns([1.35, 1], gap="large")
with left:
Expand Down