Guilherme Silberfarb Costa commited on
Commit
bc00a0d
·
1 Parent(s): 2ffc64b

Improve portable geospatial fallbacks and pesquisa loading

Browse files
backend/app/core/elaboracao/geocodificacao.py CHANGED
@@ -11,6 +11,8 @@ import os
11
  import numpy as np
12
  import pandas as pd
13
 
 
 
14
  from app.runtime_config import resolve_core_path
15
  from .core import NOMES_LAT, NOMES_LON
16
 
@@ -64,18 +66,36 @@ def carregar_eixos():
64
  if _gdf_eixos is not None:
65
  return _gdf_eixos
66
 
 
 
 
 
67
  try:
68
  import geopandas as gpd
69
  except ImportError:
70
- print("[geocodificacao] AVISO: geopandas não instalado — geocodificação indisponível.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  return None
72
 
73
- if not os.path.exists(_SHAPEFILE):
74
- print(f"[geocodificacao] AVISO: shapefile não encontrado em {_SHAPEFILE}")
75
  return None
76
 
77
- gdf = gpd.read_file(_SHAPEFILE, engine="fiona")
78
- gdf = gdf.to_crs("EPSG:4326")
79
  _gdf_eixos = gdf
80
  return _gdf_eixos
81
 
 
11
  import numpy as np
12
  import pandas as pd
13
 
14
+ from app.core.shapefile_runtime import load_vector_dataframe
15
+ from app.portable_runtime_log import append_runtime_log
16
  from app.runtime_config import resolve_core_path
17
  from .core import NOMES_LAT, NOMES_LON
18
 
 
66
  if _gdf_eixos is not None:
67
  return _gdf_eixos
68
 
69
+ if not os.path.exists(_SHAPEFILE):
70
+ print(f"[geocodificacao] AVISO: shapefile não encontrado em {_SHAPEFILE}")
71
+ return None
72
+
73
  try:
74
  import geopandas as gpd
75
  except ImportError:
76
+ gpd = None
77
+
78
+ if gpd is not None:
79
+ try:
80
+ gdf = gpd.read_file(_SHAPEFILE, engine="fiona")
81
+ gdf = gdf.to_crs("EPSG:4326")
82
+ _gdf_eixos = gdf
83
+ return _gdf_eixos
84
+ except Exception as exc:
85
+ append_runtime_log(f"[mesa] geocodificacao: falha no geopandas para eixos: {exc}")
86
+
87
+ try:
88
+ gdf = load_vector_dataframe(_SHAPEFILE, target_crs="EPSG:4326")
89
+ except Exception as exc:
90
+ print(f"[geocodificacao] AVISO: falha ao carregar eixos por fallback: {exc}")
91
+ append_runtime_log(f"[mesa] geocodificacao: fallback de eixos falhou: {exc}")
92
  return None
93
 
94
+ if gdf.empty:
95
+ append_runtime_log("[mesa] geocodificacao: fallback de eixos retornou base vazia")
96
  return None
97
 
98
+ append_runtime_log("[mesa] geocodificacao: usando fallback leve para eixos de logradouros")
 
99
  _gdf_eixos = gdf
100
  return _gdf_eixos
101
 
backend/app/core/map_layers.py CHANGED
@@ -8,6 +8,8 @@ from typing import Any
8
 
9
  import folium
10
  from branca.element import Element
 
 
11
  from app.runtime_config import resolve_core_path
12
 
13
  _BAIRROS_SHP_PATH = resolve_core_path("dados", "Bairros_LC12112_16.shp")
@@ -45,29 +47,43 @@ def _carregar_bairros_geojson() -> dict[str, Any] | None:
45
  return _BAIRROS_GEOJSON_CACHE
46
 
47
 
 
 
48
  try:
49
  import geopandas as gpd
50
  except Exception:
51
- return None
52
 
53
- try:
54
- gdf = gpd.read_file(_BAIRROS_SHP_PATH, engine="fiona")
55
- if gdf is None or gdf.empty:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  geojson = None
57
- else:
58
- if gdf.crs is not None:
59
- gdf = gdf.to_crs("EPSG:4326")
60
- campos = ["geometry"]
61
- for campo in _TOOLTIP_FIELDS:
62
- if campo in gdf.columns:
63
- campos.insert(0, campo)
64
- break
65
- gdf = gdf.loc[:, campos].copy()
66
- if _SIMPLIFY_TOLERANCE > 0:
67
- gdf["geometry"] = gdf.geometry.simplify(_SIMPLIFY_TOLERANCE, preserve_topology=True)
68
- geojson = json.loads(gdf.to_json(drop_id=True))
69
- except Exception:
70
- geojson = None
71
 
72
  with _BAIRROS_CACHE_LOCK:
73
  if geojson is not None:
 
8
 
9
  import folium
10
  from branca.element import Element
11
+ from app.core.shapefile_runtime import load_vector_geojson
12
+ from app.portable_runtime_log import append_runtime_log
13
  from app.runtime_config import resolve_core_path
14
 
15
  _BAIRROS_SHP_PATH = resolve_core_path("dados", "Bairros_LC12112_16.shp")
 
47
  return _BAIRROS_GEOJSON_CACHE
48
 
49
 
50
+ geojson = None
51
+
52
  try:
53
  import geopandas as gpd
54
  except Exception:
55
+ gpd = None
56
 
57
+ if gpd is not None:
58
+ try:
59
+ gdf = gpd.read_file(_BAIRROS_SHP_PATH, engine="fiona")
60
+ if gdf is not None and not gdf.empty:
61
+ if gdf.crs is not None:
62
+ gdf = gdf.to_crs("EPSG:4326")
63
+ campos = ["geometry"]
64
+ for campo in _TOOLTIP_FIELDS:
65
+ if campo in gdf.columns:
66
+ campos.insert(0, campo)
67
+ break
68
+ gdf = gdf.loc[:, campos].copy()
69
+ if _SIMPLIFY_TOLERANCE > 0:
70
+ gdf["geometry"] = gdf.geometry.simplify(_SIMPLIFY_TOLERANCE, preserve_topology=True)
71
+ geojson = json.loads(gdf.to_json(drop_id=True))
72
+ except Exception as exc:
73
+ append_runtime_log(f"[mesa] bairros: falha no geopandas para camada de bairros: {exc}")
74
+
75
+ if geojson is None:
76
+ try:
77
+ geojson = load_vector_geojson(
78
+ _BAIRROS_SHP_PATH,
79
+ target_crs="EPSG:4326",
80
+ property_fields=_TOOLTIP_FIELDS,
81
+ simplify_tolerance=_SIMPLIFY_TOLERANCE,
82
+ )
83
+ append_runtime_log("[mesa] bairros: usando fallback leve para camada de bairros")
84
+ except Exception as exc:
85
+ append_runtime_log(f"[mesa] bairros: fallback de bairros falhou: {exc}")
86
  geojson = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
  with _BAIRROS_CACHE_LOCK:
89
  if geojson is not None:
backend/app/core/shapefile_runtime.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import pandas as pd
7
+
8
+
9
+ def _build_transformer(source_crs: Any, target_crs: str | None):
10
+ if not source_crs or not target_crs:
11
+ return None
12
+
13
+ try:
14
+ from pyproj import CRS, Transformer
15
+ except Exception:
16
+ return None
17
+
18
+ try:
19
+ source = CRS.from_user_input(source_crs)
20
+ target = CRS.from_user_input(target_crs)
21
+ except Exception:
22
+ return None
23
+
24
+ if source == target:
25
+ return None
26
+
27
+ try:
28
+ return Transformer.from_crs(source, target, always_xy=True)
29
+ except Exception:
30
+ return None
31
+
32
+
33
+ def load_vector_dataframe(shapefile_path: str | Path, *, target_crs: str | None = "EPSG:4326") -> pd.DataFrame:
34
+ import fiona
35
+ from shapely.geometry import shape
36
+ from shapely.ops import transform as shapely_transform
37
+
38
+ path = Path(shapefile_path).expanduser().resolve()
39
+ if not path.exists():
40
+ raise FileNotFoundError(f"Shapefile nao encontrado: {path}")
41
+
42
+ rows: list[dict[str, Any]] = []
43
+ with fiona.open(path) as source:
44
+ transformer = _build_transformer(source.crs_wkt or source.crs, target_crs)
45
+ for feature in source:
46
+ properties = dict(feature.get("properties") or {})
47
+ geometry_raw = feature.get("geometry")
48
+ geometry = None
49
+ if geometry_raw:
50
+ geometry = shape(geometry_raw)
51
+ if transformer is not None:
52
+ geometry = shapely_transform(transformer.transform, geometry)
53
+ properties["geometry"] = geometry
54
+ rows.append(properties)
55
+
56
+ return pd.DataFrame(rows)
57
+
58
+
59
+ def load_vector_geojson(
60
+ shapefile_path: str | Path,
61
+ *,
62
+ target_crs: str | None = "EPSG:4326",
63
+ property_fields: tuple[str, ...] | list[str] | None = None,
64
+ simplify_tolerance: float = 0.0,
65
+ ) -> dict[str, Any]:
66
+ import fiona
67
+ from shapely.geometry import mapping, shape
68
+ from shapely.ops import transform as shapely_transform
69
+
70
+ path = Path(shapefile_path).expanduser().resolve()
71
+ if not path.exists():
72
+ raise FileNotFoundError(f"Shapefile nao encontrado: {path}")
73
+
74
+ feature_collection: dict[str, Any] = {
75
+ "type": "FeatureCollection",
76
+ "features": [],
77
+ }
78
+
79
+ wanted_fields = tuple(str(field).strip() for field in (property_fields or ()) if str(field).strip())
80
+ with fiona.open(path) as source:
81
+ transformer = _build_transformer(source.crs_wkt or source.crs, target_crs)
82
+ for feature in source:
83
+ properties_raw = dict(feature.get("properties") or {})
84
+ geometry_raw = feature.get("geometry")
85
+ if not geometry_raw:
86
+ continue
87
+
88
+ geometry = shape(geometry_raw)
89
+ if transformer is not None:
90
+ geometry = shapely_transform(transformer.transform, geometry)
91
+ if simplify_tolerance and geometry is not None:
92
+ geometry = geometry.simplify(float(simplify_tolerance), preserve_topology=True)
93
+
94
+ properties = (
95
+ {field: properties_raw.get(field) for field in wanted_fields if field in properties_raw}
96
+ if wanted_fields
97
+ else properties_raw
98
+ )
99
+ feature_collection["features"].append(
100
+ {
101
+ "type": "Feature",
102
+ "properties": properties,
103
+ "geometry": mapping(geometry),
104
+ }
105
+ )
106
+
107
+ return feature_collection
backend/app/portable_launcher.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  import socket
4
  import threading
5
  import time
@@ -92,6 +93,7 @@ def main() -> int:
92
  if settings.config_path is not None:
93
  _append_startup_log(f"[mesa] configuracao carregada de {settings.config_path}", log_path)
94
  _append_startup_log(f"[mesa] runtime local em {settings.runtime_dir}", log_path)
 
95
  server, url = _build_server(settings, log_path)
96
  _append_startup_log(f"[mesa] iniciando servidor local em {url}", log_path)
97
  except Exception:
 
1
  from __future__ import annotations
2
 
3
+ import os
4
  import socket
5
  import threading
6
  import time
 
93
  if settings.config_path is not None:
94
  _append_startup_log(f"[mesa] configuracao carregada de {settings.config_path}", log_path)
95
  _append_startup_log(f"[mesa] runtime local em {settings.runtime_dir}", log_path)
96
+ os.environ["MESA_STARTUP_LOG_FILE"] = str(log_path)
97
  server, url = _build_server(settings, log_path)
98
  _append_startup_log(f"[mesa] iniciando servidor local em {url}", log_path)
99
  except Exception:
backend/app/portable_runtime_log.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def _log_path() -> Path | None:
8
+ raw = str(os.getenv("MESA_STARTUP_LOG_FILE") or "").strip()
9
+ if not raw:
10
+ return None
11
+ try:
12
+ return Path(raw).expanduser().resolve()
13
+ except Exception:
14
+ return None
15
+
16
+
17
+ def append_runtime_log(message: str) -> None:
18
+ text = str(message).rstrip()
19
+ print(text, flush=True)
20
+
21
+ path = _log_path()
22
+ if path is None:
23
+ return
24
+
25
+ try:
26
+ path.parent.mkdir(parents=True, exist_ok=True)
27
+ with path.open("a", encoding="utf-8") as handle:
28
+ handle.write(f"{text}\n")
29
+ except Exception:
30
+ pass
backend/app/services/pesquisa_service.py CHANGED
@@ -26,6 +26,7 @@ from app.core.map_layers import (
26
  add_trabalhos_tecnicos_markers,
27
  add_zoom_responsive_circle_markers,
28
  )
 
29
  from app.services import model_repository, trabalhos_tecnicos_service
30
  from app.services.serializers import sanitize_value
31
 
@@ -2567,6 +2568,7 @@ def _carregar_catalogo_vias() -> list[dict[str, Any]]:
2567
 
2568
  gdf = geocodificacao.carregar_eixos()
2569
  if gdf is None or gdf.empty:
 
2570
  raise HTTPException(status_code=500, detail="Base de eixos indisponivel para localizar o avaliando")
2571
 
2572
  cols = {str(col).upper(): col for col in gdf.columns}
@@ -2575,6 +2577,7 @@ def _carregar_catalogo_vias() -> list[dict[str, Any]]:
2575
  prefixo_col = cols.get("NMIDEPRE")
2576
  abreviado_col = cols.get("NMIDEABR")
2577
  if cdlog_col is None or nome_col is None:
 
2578
  raise HTTPException(status_code=500, detail="Base de eixos sem colunas necessarias para localizar o avaliando")
2579
 
2580
  vistos: set[int] = set()
@@ -2597,6 +2600,7 @@ def _carregar_catalogo_vias() -> list[dict[str, Any]]:
2597
  }
2598
  )
2599
  _CATALOGO_VIAS_CACHE = list(catalogo)
 
2600
  return list(catalogo)
2601
 
2602
 
 
26
  add_trabalhos_tecnicos_markers,
27
  add_zoom_responsive_circle_markers,
28
  )
29
+ from app.portable_runtime_log import append_runtime_log
30
  from app.services import model_repository, trabalhos_tecnicos_service
31
  from app.services.serializers import sanitize_value
32
 
 
2568
 
2569
  gdf = geocodificacao.carregar_eixos()
2570
  if gdf is None or gdf.empty:
2571
+ append_runtime_log("[mesa] pesquisa: base de eixos indisponivel para catalogo de logradouros")
2572
  raise HTTPException(status_code=500, detail="Base de eixos indisponivel para localizar o avaliando")
2573
 
2574
  cols = {str(col).upper(): col for col in gdf.columns}
 
2577
  prefixo_col = cols.get("NMIDEPRE")
2578
  abreviado_col = cols.get("NMIDEABR")
2579
  if cdlog_col is None or nome_col is None:
2580
+ append_runtime_log("[mesa] pesquisa: colunas obrigatorias dos eixos nao foram encontradas")
2581
  raise HTTPException(status_code=500, detail="Base de eixos sem colunas necessarias para localizar o avaliando")
2582
 
2583
  vistos: set[int] = set()
 
2600
  }
2601
  )
2602
  _CATALOGO_VIAS_CACHE = list(catalogo)
2603
+ append_runtime_log(f"[mesa] pesquisa: catalogo de logradouros carregado com {len(catalogo)} vias")
2604
  return list(catalogo)
2605
 
2606
 
build/windows/mesa_frame_portable.spec CHANGED
@@ -2,7 +2,7 @@
2
 
3
  from pathlib import Path
4
 
5
- from PyInstaller.utils.hooks import collect_data_files, collect_submodules
6
 
7
 
8
  PROJECT_ROOT = Path.cwd().resolve()
@@ -18,9 +18,19 @@ if LOCAL_DATA_DIR.exists():
18
  datas += collect_data_files("safehttpx")
19
  datas += collect_data_files("gradio")
20
  datas += collect_data_files("gradio_client")
 
 
 
 
 
 
 
21
 
22
  hiddenimports = []
23
  hiddenimports += collect_submodules("app")
 
 
 
24
  hiddenimports += [
25
  "uvicorn.loops.auto",
26
  "uvicorn.protocols.http.auto",
@@ -31,7 +41,7 @@ hiddenimports += [
31
  a = Analysis(
32
  [str(PROJECT_ROOT / "backend" / "portable_app.py")],
33
  pathex=[str(BACKEND_DIR)],
34
- binaries=[],
35
  datas=datas,
36
  hiddenimports=hiddenimports,
37
  hookspath=[],
 
2
 
3
  from pathlib import Path
4
 
5
+ from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs, collect_submodules
6
 
7
 
8
  PROJECT_ROOT = Path.cwd().resolve()
 
18
  datas += collect_data_files("safehttpx")
19
  datas += collect_data_files("gradio")
20
  datas += collect_data_files("gradio_client")
21
+ datas += collect_data_files("fiona")
22
+ datas += collect_data_files("geopandas")
23
+ datas += collect_data_files("pyproj")
24
+
25
+ binaries = []
26
+ binaries += collect_dynamic_libs("fiona")
27
+ binaries += collect_dynamic_libs("pyproj")
28
 
29
  hiddenimports = []
30
  hiddenimports += collect_submodules("app")
31
+ hiddenimports += collect_submodules("fiona")
32
+ hiddenimports += collect_submodules("pyproj")
33
+ hiddenimports += collect_submodules("shapely")
34
  hiddenimports += [
35
  "uvicorn.loops.auto",
36
  "uvicorn.protocols.http.auto",
 
41
  a = Analysis(
42
  [str(PROJECT_ROOT / "backend" / "portable_app.py")],
43
  pathex=[str(BACKEND_DIR)],
44
+ binaries=binaries,
45
  datas=datas,
46
  hiddenimports=hiddenimports,
47
  hookspath=[],
build/windows/smoke_test_portable.ps1 CHANGED
@@ -15,6 +15,8 @@ $rootUrl = "http://127.0.0.1:$Port"
15
  $healthUrl = "$rootUrl/api/health"
16
  $loginUrl = "$rootUrl/api/auth/login"
17
  $meUrl = "$rootUrl/api/auth/me"
 
 
18
 
19
  New-Item -ItemType Directory -Force -Path $configDir | Out-Null
20
  New-Item -ItemType Directory -Force -Path $runtimeDir | Out-Null
@@ -78,6 +80,11 @@ try {
78
  throw "Smoke test failed: root path returned status $($rootResp.StatusCode)."
79
  }
80
 
 
 
 
 
 
81
  $loginBody = @{
82
  usuario = "smoke"
83
  matricula = "123456"
@@ -93,6 +100,11 @@ try {
93
  if ($meResp.usuario.usuario -ne "smoke") {
94
  throw "Smoke test failed: /api/auth/me returned unexpected user."
95
  }
 
 
 
 
 
96
  }
97
  finally {
98
  if ($proc -and -not $proc.HasExited) {
 
15
  $healthUrl = "$rootUrl/api/health"
16
  $loginUrl = "$rootUrl/api/auth/login"
17
  $meUrl = "$rootUrl/api/auth/me"
18
+ $logoUrl = "$rootUrl/logo_mesa.png"
19
+ $logradourosUrl = "$rootUrl/api/pesquisa/logradouros-eixos?limite=50"
20
 
21
  New-Item -ItemType Directory -Force -Path $configDir | Out-Null
22
  New-Item -ItemType Directory -Force -Path $runtimeDir | Out-Null
 
80
  throw "Smoke test failed: root path returned status $($rootResp.StatusCode)."
81
  }
82
 
83
+ $logoResp = Invoke-WebRequest -Uri $logoUrl -UseBasicParsing -TimeoutSec 5
84
+ if ($logoResp.StatusCode -ne 200) {
85
+ throw "Smoke test failed: logo path returned status $($logoResp.StatusCode)."
86
+ }
87
+
88
  $loginBody = @{
89
  usuario = "smoke"
90
  matricula = "123456"
 
100
  if ($meResp.usuario.usuario -ne "smoke") {
101
  throw "Smoke test failed: /api/auth/me returned unexpected user."
102
  }
103
+
104
+ $logradourosResp = Invoke-RestMethod -Uri $logradourosUrl -Headers @{ "X-Auth-Token" = $token } -TimeoutSec 10
105
+ if (-not $logradourosResp.logradouros_eixos -or $logradourosResp.logradouros_eixos.Count -lt 1) {
106
+ throw "Smoke test failed: /api/pesquisa/logradouros-eixos returned no suggestions."
107
+ }
108
  }
109
  finally {
110
  if ($proc -and -not $proc.HasExited) {
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -2029,8 +2029,18 @@ export default function PesquisaTab({
2029
  </div>
2030
 
2031
  <div className="row pesquisa-actions pesquisa-actions-primary">
2032
- <button type="button" onClick={() => void buscarModelos()} disabled={isPesquisaBusy}>
2033
- {searchLoading ? 'Pesquisando...' : 'Pesquisar'}
 
 
 
 
 
 
 
 
 
 
2034
  </button>
2035
  <button type="button" onClick={() => void onLimparFiltros()} disabled={isPesquisaBusy}>
2036
  Limpar filtros
@@ -2285,6 +2295,7 @@ export default function PesquisaTab({
2285
  </SectionBlock>
2286
  </div>
2287
 
 
2288
  <LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
2289
  </div>
2290
  )
 
2029
  </div>
2030
 
2031
  <div className="row pesquisa-actions pesquisa-actions-primary">
2032
+ <button
2033
+ type="button"
2034
+ className={searchLoading ? 'is-loading' : ''}
2035
+ onClick={() => void buscarModelos()}
2036
+ disabled={isPesquisaBusy}
2037
+ >
2038
+ {searchLoading ? (
2039
+ <>
2040
+ <span className="repo-open-btn-spinner" aria-hidden="true" />
2041
+ <span>Pesquisando...</span>
2042
+ </>
2043
+ ) : 'Pesquisar'}
2044
  </button>
2045
  <button type="button" onClick={() => void onLimparFiltros()} disabled={isPesquisaBusy}>
2046
  Limpar filtros
 
2295
  </SectionBlock>
2296
  </div>
2297
 
2298
+ <LoadingOverlay show={searchLoading} label="Pesquisando modelos..." />
2299
  <LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
2300
  </div>
2301
  )
frontend/src/styles.css CHANGED
@@ -2903,6 +2903,13 @@ button.pesquisa-coluna-remove:hover {
2903
  justify-content: flex-end;
2904
  }
2905
 
 
 
 
 
 
 
 
2906
  .pesquisa-mapa-actions {
2907
  align-items: flex-end;
2908
  gap: 12px;
 
2903
  justify-content: flex-end;
2904
  }
2905
 
2906
+ .pesquisa-actions-primary button.is-loading {
2907
+ display: inline-flex;
2908
+ align-items: center;
2909
+ justify-content: center;
2910
+ gap: 8px;
2911
+ }
2912
+
2913
  .pesquisa-mapa-actions {
2914
  align-items: flex-end;
2915
  gap: 12px;