Guilherme Silberfarb Costa commited on
Commit
d6c9678
·
1 Parent(s): ab8a8e6

Initial commit for HF Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +8 -0
  2. .gitignore +13 -0
  3. Dockerfile +47 -0
  4. README.md +68 -0
  5. backend/app/__init__.py +1 -0
  6. backend/app/api/__init__.py +1 -0
  7. backend/app/api/elaboracao.py +341 -0
  8. backend/app/api/health.py +11 -0
  9. backend/app/api/session.py +20 -0
  10. backend/app/api/visualizacao.py +106 -0
  11. backend/app/core/dados/EixosLogradouros.cpg +1 -0
  12. backend/app/core/dados/EixosLogradouros.dbf +3 -0
  13. backend/app/core/dados/EixosLogradouros.prj +1 -0
  14. backend/app/core/dados/EixosLogradouros.shp +3 -0
  15. backend/app/core/dados/EixosLogradouros.shp.xml +2 -0
  16. backend/app/core/dados/EixosLogradouros.shx +3 -0
  17. backend/app/core/elaboracao/__init__.py +0 -0
  18. backend/app/core/elaboracao/app.py +1910 -0
  19. backend/app/core/elaboracao/avaliadores.json +74 -0
  20. backend/app/core/elaboracao/carregamento.py +623 -0
  21. backend/app/core/elaboracao/charts.py +745 -0
  22. backend/app/core/elaboracao/core.py +2077 -0
  23. backend/app/core/elaboracao/formatadores.py +764 -0
  24. backend/app/core/elaboracao/geocodificacao.py +458 -0
  25. backend/app/core/elaboracao/modelo.py +991 -0
  26. backend/app/core/elaboracao/outliers.py +301 -0
  27. backend/app/core/visualizacao/__init__.py +0 -0
  28. backend/app/core/visualizacao/app.py +1477 -0
  29. backend/app/main.py +46 -0
  30. backend/app/models/__init__.py +1 -0
  31. backend/app/models/session.py +64 -0
  32. backend/app/services/__init__.py +1 -0
  33. backend/app/services/elaboracao_service.py +1124 -0
  34. backend/app/services/serializers.py +89 -0
  35. backend/app/services/session_store.py +37 -0
  36. backend/app/services/visualizacao_service.py +380 -0
  37. backend/requirements.txt +16 -0
  38. backend/run_backend.sh +4 -0
  39. frontend/index.html +12 -0
  40. frontend/package-lock.json +0 -0
  41. frontend/package.json +21 -0
  42. frontend/public/logo_mesa.png +3 -0
  43. frontend/src/App.jsx +79 -0
  44. frontend/src/api.js +165 -0
  45. frontend/src/components/DataTable.jsx +33 -0
  46. frontend/src/components/ElaboracaoTab.jsx +1331 -0
  47. frontend/src/components/MapFrame.jsx +16 -0
  48. frontend/src/components/PlotFigure.jsx +48 -0
  49. frontend/src/components/SectionBlock.jsx +23 -0
  50. frontend/src/components/VisualizacaoTab.jsx +351 -0
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ **/.DS_Store
3
+ **/__pycache__
4
+ **/.pytest_cache
5
+ **/.venv
6
+ frontend/node_modules
7
+ frontend/dist
8
+ backend/.venv
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .venv/
7
+
8
+ # Node
9
+ node_modules/
10
+ frontend/dist/
11
+
12
+ # System
13
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-bookworm-slim AS frontend-builder
2
+
3
+ WORKDIR /src/frontend
4
+
5
+ COPY frontend/package*.json ./
6
+ RUN npm ci
7
+
8
+ COPY frontend/ ./
9
+ ARG VITE_API_BASE=.
10
+ ENV VITE_API_BASE=${VITE_API_BASE}
11
+ RUN npm run build
12
+
13
+
14
+ FROM python:3.11-slim
15
+
16
+ ENV PYTHONDONTWRITEBYTECODE=1 \
17
+ PYTHONUNBUFFERED=1
18
+
19
+ RUN apt-get update && \
20
+ apt-get install -y --no-install-recommends \
21
+ build-essential \
22
+ gdal-bin \
23
+ libgdal-dev \
24
+ libgeos-dev \
25
+ libproj-dev \
26
+ proj-bin \
27
+ proj-data \
28
+ libspatialindex-dev && \
29
+ rm -rf /var/lib/apt/lists/*
30
+
31
+ WORKDIR /app/backend
32
+
33
+ COPY backend/requirements.txt ./
34
+ RUN pip install --no-cache-dir --upgrade pip && \
35
+ pip install --no-cache-dir -r requirements.txt
36
+
37
+ COPY backend/ ./
38
+ COPY --from=frontend-builder /src/frontend/dist /app/frontend/dist
39
+
40
+ RUN useradd -m -u 1000 user && \
41
+ chown -R user:user /app
42
+
43
+ USER user
44
+
45
+ EXPOSE 7860
46
+
47
+ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
README.md CHANGED
@@ -1,4 +1,5 @@
1
  ---
 
2
  title: Mesa React
3
  emoji: 🌖
4
  colorFrom: purple
@@ -8,3 +9,70 @@ pinned: false
8
  ---
9
 
10
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ <<<<<<< HEAD
3
  title: Mesa React
4
  emoji: 🌖
5
  colorFrom: purple
 
9
  ---
10
 
11
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
12
+ =======
13
+ title: mesa-frame
14
+ sdk: docker
15
+ app_port: 7860
16
+ ---
17
+
18
+ # MESA Frame (FastAPI + React)
19
+
20
+ Rearquitetura do app MESA com:
21
+
22
+ - `backend/` em FastAPI
23
+ - `frontend/` em React (Vite)
24
+ - Reuso do core estatistico e de negocio original (elaboracao + visualizacao)
25
+
26
+ ## Estrutura
27
+
28
+ - `backend/app/main.py`: inicializacao da API
29
+ - `backend/app/api/`: rotas de sessao, elaboracao e visualizacao
30
+ - `backend/app/services/`: orquestracao dos fluxos
31
+ - `backend/app/core/elaboracao`: core de elaboracao reaproveitado
32
+ - `backend/app/core/visualizacao`: core de visualizacao reaproveitado
33
+ - `frontend/src`: interface React
34
+
35
+ ## Backend
36
+
37
+ ```bash
38
+ cd backend
39
+ python -m venv .venv
40
+ source .venv/bin/activate
41
+ pip install -r requirements.txt
42
+ ./run_backend.sh
43
+ ```
44
+
45
+ API: `http://localhost:8000`
46
+ Swagger: `http://localhost:8000/docs`
47
+
48
+ ## Frontend
49
+
50
+ ```bash
51
+ cd frontend
52
+ npm install
53
+ npm run dev
54
+ ```
55
+
56
+ Frontend: `http://localhost:5173`
57
+
58
+ Para apontar para outro backend:
59
+
60
+ ```bash
61
+ VITE_API_BASE=http://localhost:8000 npm run dev
62
+ ```
63
+
64
+ ## Funcionalidades cobertas
65
+
66
+ - Upload CSV/Excel/.dai
67
+ - Selecao de aba Excel
68
+ - Mapeamento manual de coordenadas
69
+ - Geocodificacao e correcoes
70
+ - Selecao de variaveis Y/X
71
+ - Busca/adocao de transformacoes
72
+ - Ajuste de modelo OLS e diagnosticos
73
+ - Filtros, iteracao e historico de outliers
74
+ - Avaliacao individual e exportacao de avaliacoes
75
+ - Exportacao de modelo `.dai` e base `.csv`
76
+ - Visualizacao completa de modelo salvo `.dai`
77
+ - Avaliacao individual na aba de visualizacao
78
+ >>>>>>> 6e64df9 (Initial commit for HF Space)
backend/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # App package
backend/app/api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # API routers package
backend/app/api/elaboracao.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ from fastapi import APIRouter, File, Form, UploadFile
7
+ from fastapi.responses import FileResponse
8
+ from pydantic import BaseModel, Field
9
+
10
+ from app.services import elaboracao_service
11
+ from app.services.session_store import session_store
12
+
13
+
14
+ router = APIRouter(prefix="/api/elaboracao", tags=["elaboracao"])
15
+
16
+
17
+ class SessionPayload(BaseModel):
18
+ session_id: str
19
+
20
+
21
+ class ConfirmSheetPayload(SessionPayload):
22
+ sheet_name: str
23
+
24
+
25
+ class MapCoordsPayload(SessionPayload):
26
+ col_lat: str
27
+ col_lon: str
28
+
29
+
30
+ class GeocodePayload(SessionPayload):
31
+ col_cdlog: str
32
+ col_num: str
33
+ auto_200: bool = False
34
+
35
+
36
+ class CorrecaoGeo(BaseModel):
37
+ linha: int
38
+ numero_corrigido: str | None = None
39
+
40
+
41
+ class GeocodeCorrecaoPayload(SessionPayload):
42
+ correcoes: list[CorrecaoGeo] = Field(default_factory=list)
43
+ auto_200: bool = False
44
+
45
+
46
+ class ApplySelectionPayload(SessionPayload):
47
+ coluna_y: str
48
+ colunas_x: list[str]
49
+ dicotomicas: list[str] = Field(default_factory=list)
50
+ codigo_alocado: list[str] = Field(default_factory=list)
51
+ percentuais: list[str] = Field(default_factory=list)
52
+ outliers_anteriores: list[int] = Field(default_factory=list)
53
+ grau_min_coef: int = 1
54
+ grau_min_f: int = 0
55
+
56
+
57
+ class ClassificarXPayload(SessionPayload):
58
+ colunas_x: list[str] = Field(default_factory=list)
59
+
60
+
61
+ class SearchTransformPayload(SessionPayload):
62
+ grau_min_coef: int = 1
63
+ grau_min_f: int = 0
64
+
65
+
66
+ class AdoptSuggestionPayload(SessionPayload):
67
+ indice: int
68
+
69
+
70
+ class FitModelPayload(SessionPayload):
71
+ transformacao_y: str
72
+ transformacoes_x: dict[str, str] = Field(default_factory=dict)
73
+ dicotomicas: list[str] = Field(default_factory=list)
74
+ codigo_alocado: list[str] = Field(default_factory=list)
75
+ percentuais: list[str] = Field(default_factory=list)
76
+
77
+
78
+ class DispersaoPayload(SessionPayload):
79
+ tipo: str
80
+
81
+
82
+ class OutlierFiltro(BaseModel):
83
+ variavel: str
84
+ operador: str
85
+ valor: float
86
+
87
+
88
+ class OutlierFilterPayload(SessionPayload):
89
+ filtros: list[OutlierFiltro] = Field(default_factory=list)
90
+
91
+
92
+ class OutlierRestartPayload(SessionPayload):
93
+ outliers_texto: str | None = None
94
+ reincluir_texto: str | None = None
95
+ grau_min_coef: int = 3
96
+ grau_min_f: int = 3
97
+
98
+
99
+ class OutlierSummaryPayload(SessionPayload):
100
+ outliers_texto: str | None = None
101
+ reincluir_texto: str | None = None
102
+
103
+
104
+ class AvaliacaoPayload(SessionPayload):
105
+ valores_x: dict[str, Any]
106
+ indice_base: str | None = None
107
+
108
+
109
+ class AvaliacaoDeletePayload(SessionPayload):
110
+ indice: str | None = None
111
+ indice_base: str | None = None
112
+
113
+
114
+ class AvaliacaoBasePayload(SessionPayload):
115
+ indice_base: str | None = None
116
+
117
+
118
+ class ExportModeloPayload(SessionPayload):
119
+ nome_arquivo: str
120
+ elaborador: dict[str, Any] | None = None
121
+
122
+
123
+ class UpdateMapaPayload(SessionPayload):
124
+ variavel_mapa: str | None = None
125
+
126
+
127
+ @router.post("/upload")
128
+ async def upload_file(
129
+ session_id: str = Form(...),
130
+ file: UploadFile = File(...),
131
+ ) -> dict[str, Any]:
132
+ session = session_store.get(session_id)
133
+ conteudo = await file.read()
134
+ elaboracao_service.save_uploaded_file(session, file.filename, conteudo)
135
+ return elaboracao_service.load_uploaded_file(session)
136
+
137
+
138
+ @router.post("/confirm-sheet")
139
+ def confirm_sheet(payload: ConfirmSheetPayload) -> dict[str, Any]:
140
+ session = session_store.get(payload.session_id)
141
+ return elaboracao_service.load_uploaded_file(session, selected_sheet=payload.sheet_name)
142
+
143
+
144
+ @router.post("/map-coords")
145
+ def map_coords(payload: MapCoordsPayload) -> dict[str, Any]:
146
+ session = session_store.get(payload.session_id)
147
+ return elaboracao_service.mapear_coordenadas_manualmente(session, payload.col_lat, payload.col_lon)
148
+
149
+
150
+ @router.post("/geocodificar")
151
+ def geocodificar(payload: GeocodePayload) -> dict[str, Any]:
152
+ session = session_store.get(payload.session_id)
153
+ return elaboracao_service.geocodificar(session, payload.col_cdlog, payload.col_num, auto_200=payload.auto_200)
154
+
155
+
156
+ @router.post("/geocodificar-correcoes")
157
+ def geocodificar_correcoes(payload: GeocodeCorrecaoPayload) -> dict[str, Any]:
158
+ session = session_store.get(payload.session_id)
159
+ correcoes = [item.model_dump() for item in payload.correcoes]
160
+ return elaboracao_service.aplicar_correcoes_geocodificacao(session, correcoes, auto_200=payload.auto_200)
161
+
162
+
163
+ @router.post("/apply-selection")
164
+ def apply_selection(payload: ApplySelectionPayload) -> dict[str, Any]:
165
+ session = session_store.get(payload.session_id)
166
+ return elaboracao_service.apply_selection(
167
+ session,
168
+ coluna_y=payload.coluna_y,
169
+ colunas_x=payload.colunas_x,
170
+ dicotomicas=payload.dicotomicas,
171
+ codigo_alocado=payload.codigo_alocado,
172
+ percentuais=payload.percentuais,
173
+ outliers_anteriores=payload.outliers_anteriores,
174
+ grau_min_coef=payload.grau_min_coef,
175
+ grau_min_f=payload.grau_min_f,
176
+ )
177
+
178
+
179
+ @router.post("/classify-x")
180
+ def classify_x(payload: ClassificarXPayload) -> dict[str, Any]:
181
+ session = session_store.get(payload.session_id)
182
+ return elaboracao_service.classificar_tipos_variaveis_x(session, payload.colunas_x)
183
+
184
+
185
+ @router.post("/search-transformations")
186
+ def search_transformations(payload: SearchTransformPayload) -> dict[str, Any]:
187
+ session = session_store.get(payload.session_id)
188
+ return elaboracao_service.search_transformacoes(
189
+ session,
190
+ grau_min_coef=payload.grau_min_coef,
191
+ grau_min_f=payload.grau_min_f,
192
+ )
193
+
194
+
195
+ @router.post("/adopt-suggestion")
196
+ def adopt_suggestion(payload: AdoptSuggestionPayload) -> dict[str, Any]:
197
+ session = session_store.get(payload.session_id)
198
+ return elaboracao_service.adotar_sugestao(session, payload.indice)
199
+
200
+
201
+ @router.post("/fit-model")
202
+ def fit_model(payload: FitModelPayload) -> dict[str, Any]:
203
+ session = session_store.get(payload.session_id)
204
+ return elaboracao_service.fit_model(
205
+ session,
206
+ transformacao_y=payload.transformacao_y,
207
+ transformacoes_x=payload.transformacoes_x,
208
+ dicotomicas=payload.dicotomicas,
209
+ codigo_alocado=payload.codigo_alocado,
210
+ percentuais=payload.percentuais,
211
+ )
212
+
213
+
214
+ @router.post("/model-dispersao")
215
+ def model_dispersao(payload: DispersaoPayload) -> dict[str, Any]:
216
+ session = session_store.get(payload.session_id)
217
+ return elaboracao_service.gerar_grafico_dispersao_modelo(session, payload.tipo)
218
+
219
+
220
+ @router.post("/outliers/apply-filters")
221
+ def outliers_apply_filters(payload: OutlierFilterPayload) -> dict[str, Any]:
222
+ session = session_store.get(payload.session_id)
223
+ filtros = [item.model_dump() for item in payload.filtros]
224
+ return elaboracao_service.apply_outlier_filters(session, filtros)
225
+
226
+
227
+ @router.post("/outliers/restart")
228
+ def outliers_restart(payload: OutlierRestartPayload) -> dict[str, Any]:
229
+ session = session_store.get(payload.session_id)
230
+ return elaboracao_service.reiniciar_iteracao(
231
+ session,
232
+ outliers_texto=payload.outliers_texto,
233
+ reincluir_texto=payload.reincluir_texto,
234
+ grau_min_coef=payload.grau_min_coef,
235
+ grau_min_f=payload.grau_min_f,
236
+ )
237
+
238
+
239
+ @router.post("/outliers/summary")
240
+ def outliers_summary(payload: OutlierSummaryPayload) -> dict[str, str]:
241
+ session = session_store.get(payload.session_id)
242
+ texto = elaboracao_service.resumir_outliers(
243
+ session.outliers_anteriores,
244
+ payload.outliers_texto,
245
+ payload.reincluir_texto,
246
+ )
247
+ return {"resumo": texto}
248
+
249
+
250
+ @router.post("/outliers/clear-history")
251
+ def outliers_clear_history(payload: SessionPayload) -> dict[str, Any]:
252
+ session = session_store.get(payload.session_id)
253
+ return elaboracao_service.limpar_historico_outliers(session)
254
+
255
+
256
+ @router.post("/evaluation/fields")
257
+ def evaluation_fields(payload: SessionPayload) -> dict[str, Any]:
258
+ session = session_store.get(payload.session_id)
259
+ return {"campos": elaboracao_service.build_campos_avaliacao(session)}
260
+
261
+
262
+ @router.post("/evaluation/calculate")
263
+ def evaluation_calculate(payload: AvaliacaoPayload) -> dict[str, Any]:
264
+ session = session_store.get(payload.session_id)
265
+ return elaboracao_service.calcular_avaliacao_elaboracao(session, payload.valores_x, payload.indice_base)
266
+
267
+
268
+ @router.post("/evaluation/clear")
269
+ def evaluation_clear(payload: SessionPayload) -> dict[str, Any]:
270
+ session = session_store.get(payload.session_id)
271
+ return elaboracao_service.limpar_avaliacoes_elaboracao(session)
272
+
273
+
274
+ @router.post("/evaluation/delete")
275
+ def evaluation_delete(payload: AvaliacaoDeletePayload) -> dict[str, Any]:
276
+ session = session_store.get(payload.session_id)
277
+ return elaboracao_service.excluir_avaliacao_elaboracao(session, payload.indice, payload.indice_base)
278
+
279
+
280
+ @router.post("/evaluation/base")
281
+ def evaluation_base(payload: AvaliacaoBasePayload) -> dict[str, Any]:
282
+ session = session_store.get(payload.session_id)
283
+ return elaboracao_service.atualizar_base_avaliacao_elaboracao(session, payload.indice_base)
284
+
285
+
286
+ @router.get("/evaluation/export")
287
+ def evaluation_export(session_id: str) -> FileResponse:
288
+ session = session_store.get(session_id)
289
+ caminho = elaboracao_service.exportar_avaliacoes_elaboracao(session)
290
+ return FileResponse(
291
+ path=caminho,
292
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
293
+ filename=os.path.basename(caminho),
294
+ )
295
+
296
+
297
+ @router.get("/avaliadores")
298
+ def listar_avaliadores() -> dict[str, Any]:
299
+ return {"avaliadores": elaboracao_service.list_avaliadores()}
300
+
301
+
302
+ @router.post("/export-model")
303
+ def export_model(payload: ExportModeloPayload) -> FileResponse:
304
+ session = session_store.get(payload.session_id)
305
+ caminho, _ = elaboracao_service.exportar_modelo(session, payload.nome_arquivo, elaborador=payload.elaborador)
306
+ return FileResponse(
307
+ path=caminho,
308
+ media_type="application/octet-stream",
309
+ filename=os.path.basename(caminho),
310
+ )
311
+
312
+
313
+ @router.get("/export-base")
314
+ def export_base(session_id: str, filtered: bool = True) -> FileResponse:
315
+ session = session_store.get(session_id)
316
+ caminho = elaboracao_service.exportar_base(session, usar_filtrado=filtered)
317
+ return FileResponse(
318
+ path=caminho,
319
+ media_type="text/csv",
320
+ filename=os.path.basename(caminho),
321
+ )
322
+
323
+
324
+ @router.post("/map/update")
325
+ def map_update(payload: UpdateMapaPayload) -> dict[str, Any]:
326
+ session = session_store.get(payload.session_id)
327
+ return elaboracao_service.atualizar_mapa(session, payload.variavel_mapa)
328
+
329
+
330
+ @router.get("/context")
331
+ def context(session_id: str) -> dict[str, Any]:
332
+ session = session_store.get(session_id)
333
+ return {
334
+ "coluna_y": session.coluna_y,
335
+ "colunas_x": session.colunas_x,
336
+ "dicotomicas": session.dicotomicas,
337
+ "codigo_alocado": session.codigo_alocado,
338
+ "percentuais": session.percentuais,
339
+ "outliers_anteriores": session.outliers_anteriores,
340
+ "iteracao": session.iteracao,
341
+ }
backend/app/api/health.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter
4
+
5
+
6
+ router = APIRouter(tags=["health"])
7
+
8
+
9
+ @router.get("/api/health")
10
+ def health() -> dict[str, str]:
11
+ return {"status": "ok"}
backend/app/api/session.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from app.services.session_store import session_store
6
+
7
+
8
+ router = APIRouter(prefix="/api/sessions", tags=["sessions"])
9
+
10
+
11
+ @router.post("")
12
+ def create_session() -> dict[str, str]:
13
+ session = session_store.create()
14
+ return {"session_id": session.session_id}
15
+
16
+
17
+ @router.delete("/{session_id}")
18
+ def delete_session(session_id: str) -> dict[str, str]:
19
+ session_store.delete(session_id)
20
+ return {"status": "ok"}
backend/app/api/visualizacao.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ from fastapi import APIRouter, File, Form, UploadFile
7
+ from fastapi.responses import FileResponse
8
+ from pydantic import BaseModel
9
+
10
+ from app.services import elaboracao_service, visualizacao_service
11
+ from app.services.session_store import session_store
12
+
13
+
14
+ router = APIRouter(prefix="/api/visualizacao", tags=["visualizacao"])
15
+
16
+
17
+ class SessionPayload(BaseModel):
18
+ session_id: str
19
+
20
+
21
+ class MapaPayload(SessionPayload):
22
+ variavel_mapa: str | None = None
23
+
24
+
25
+ class AvaliacaoPayload(SessionPayload):
26
+ valores_x: dict[str, Any]
27
+ indice_base: str | None = None
28
+
29
+
30
+ class AvaliacaoDeletePayload(SessionPayload):
31
+ indice: str | None = None
32
+ indice_base: str | None = None
33
+
34
+
35
+ class AvaliacaoBasePayload(SessionPayload):
36
+ indice_base: str | None = None
37
+
38
+
39
+ @router.post("/upload")
40
+ async def upload_file(
41
+ session_id: str = Form(...),
42
+ file: UploadFile = File(...),
43
+ ) -> dict[str, Any]:
44
+ session = session_store.get(session_id)
45
+ conteudo = await file.read()
46
+ caminho = elaboracao_service.save_uploaded_file(session, file.filename, conteudo)
47
+ return visualizacao_service.carregar_modelo(session, caminho)
48
+
49
+
50
+ @router.post("/exibir")
51
+ def exibir(payload: SessionPayload) -> dict[str, Any]:
52
+ session = session_store.get(payload.session_id)
53
+ return visualizacao_service.exibir_modelo(session)
54
+
55
+
56
+ @router.post("/map/update")
57
+ def map_update(payload: MapaPayload) -> dict[str, Any]:
58
+ session = session_store.get(payload.session_id)
59
+ return visualizacao_service.atualizar_mapa(session, payload.variavel_mapa)
60
+
61
+
62
+ @router.post("/evaluation/fields")
63
+ def evaluation_fields(payload: SessionPayload) -> dict[str, Any]:
64
+ session = session_store.get(payload.session_id)
65
+ return {"campos": visualizacao_service.campos_avaliacao(session)}
66
+
67
+
68
+ @router.post("/evaluation/calculate")
69
+ def evaluation_calculate(payload: AvaliacaoPayload) -> dict[str, Any]:
70
+ session = session_store.get(payload.session_id)
71
+ return visualizacao_service.calcular_avaliacao(session, payload.valores_x, payload.indice_base)
72
+
73
+
74
+ @router.post("/evaluation/clear")
75
+ def evaluation_clear(payload: SessionPayload) -> dict[str, Any]:
76
+ session = session_store.get(payload.session_id)
77
+ return visualizacao_service.limpar_avaliacoes(session)
78
+
79
+
80
+ @router.post("/evaluation/delete")
81
+ def evaluation_delete(payload: AvaliacaoDeletePayload) -> dict[str, Any]:
82
+ session = session_store.get(payload.session_id)
83
+ return visualizacao_service.excluir_avaliacao(session, payload.indice, payload.indice_base)
84
+
85
+
86
+ @router.post("/evaluation/base")
87
+ def evaluation_base(payload: AvaliacaoBasePayload) -> dict[str, Any]:
88
+ session = session_store.get(payload.session_id)
89
+ return visualizacao_service.atualizar_base(session, payload.indice_base)
90
+
91
+
92
+ @router.get("/evaluation/export")
93
+ def evaluation_export(session_id: str) -> FileResponse:
94
+ session = session_store.get(session_id)
95
+ caminho = visualizacao_service.exportar_avaliacoes(session)
96
+ return FileResponse(
97
+ path=caminho,
98
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
99
+ filename=os.path.basename(caminho),
100
+ )
101
+
102
+
103
+ @router.post("/clear")
104
+ def clear(payload: SessionPayload) -> dict[str, Any]:
105
+ session = session_store.get(payload.session_id)
106
+ return visualizacao_service.limpar_tudo_visualizacao(session)
backend/app/core/dados/EixosLogradouros.cpg ADDED
@@ -0,0 +1 @@
 
 
1
+ UTF-8
backend/app/core/dados/EixosLogradouros.dbf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cf668c9b7bbcac420d23188cfb3101b7dcee2bc4b4873b8220d73afd4257f4f6
3
+ size 6432720
backend/app/core/dados/EixosLogradouros.prj ADDED
@@ -0,0 +1 @@
 
 
1
+ PROJCS["TM-POA",GEOGCS["GCS_SIRGAS_2000",DATUM["D_SIRGAS_2000",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",300000.0],PARAMETER["False_Northing",5000000.0],PARAMETER["Central_Meridian",-51.0],PARAMETER["Scale_Factor",0.999995],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]]
backend/app/core/dados/EixosLogradouros.shp ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:718687b7eff9723d63eee89749c7a338a7fe00c1cae4cb8bc952f967d5b47d23
3
+ size 10248444
backend/app/core/dados/EixosLogradouros.shp.xml ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <metadata xml:lang="pt"><Esri><CreaDate>20241119</CreaDate><CreaTime>11233400</CreaTime><ArcGISFormat>1.0</ArcGISFormat><SyncOnce>FALSE</SyncOnce><DataProperties><itemProps><itemName Sync="TRUE">EixosLogradouros</itemName><imsContentType Sync="TRUE">002</imsContentType><itemSize Sync="TRUE">0.000</itemSize><itemLocation><linkage Sync="TRUE">file://\\pmpa-fs3\smams-usig$\Publicação Site PDDUA\PDDUA_shp\EixoLogradouro\EixosLogradouros.shp</linkage><protocol Sync="TRUE">Local Area Network</protocol></itemLocation></itemProps><coordRef><type Sync="TRUE">Projected</type><geogcsn Sync="TRUE">GCS_SIRGAS_2000</geogcsn><csUnits Sync="TRUE">Linear Unit: Meter (1.000000)</csUnits><projcsn Sync="TRUE">TM-POA</projcsn><peXml Sync="TRUE">&lt;ProjectedCoordinateSystem xsi:type='typens:ProjectedCoordinateSystem' xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance' xmlns:xs='http://www.w3.org/2001/XMLSchema' xmlns:typens='http://www.esri.com/schemas/ArcGIS/10.7'&gt;&lt;WKT&gt;PROJCS[&amp;quot;TM-POA&amp;quot;,GEOGCS[&amp;quot;GCS_SIRGAS_2000&amp;quot;,DATUM[&amp;quot;D_SIRGAS_2000&amp;quot;,SPHEROID[&amp;quot;GRS_1980&amp;quot;,6378137.0,298.257222101]],PRIMEM[&amp;quot;Greenwich&amp;quot;,0.0],UNIT[&amp;quot;Degree&amp;quot;,0.0174532925199433]],PROJECTION[&amp;quot;Transverse_Mercator&amp;quot;],PARAMETER[&amp;quot;False_Easting&amp;quot;,300000.0],PARAMETER[&amp;quot;False_Northing&amp;quot;,5000000.0],PARAMETER[&amp;quot;Central_Meridian&amp;quot;,-51.0],PARAMETER[&amp;quot;Scale_Factor&amp;quot;,0.999995],PARAMETER[&amp;quot;Latitude_Of_Origin&amp;quot;,0.0],UNIT[&amp;quot;Meter&amp;quot;,1.0]]&lt;/WKT&gt;&lt;XOrigin&gt;-5323100&lt;/XOrigin&gt;&lt;YOrigin&gt;-5002100&lt;/YOrigin&gt;&lt;XYScale&gt;450265407.00157917&lt;/XYScale&gt;&lt;ZOrigin&gt;-100000&lt;/ZOrigin&gt;&lt;ZScale&gt;10000&lt;/ZScale&gt;&lt;MOrigin&gt;-100000&lt;/MOrigin&gt;&lt;MScale&gt;10000&lt;/MScale&gt;&lt;XYTolerance&gt;0.001&lt;/XYTolerance&gt;&lt;ZTolerance&gt;0.001&lt;/ZTolerance&gt;&lt;MTolerance&gt;0.001&lt;/MTolerance&gt;&lt;HighPrecision&gt;true&lt;/HighPrecision&gt;&lt;/ProjectedCoordinateSystem&gt;</peXml></coordRef><lineage><Process ToolSource="c:\program files (x86)\arcgis\desktop10.7\ArcToolbox\Toolboxes\Conversion Tools.tbx\FeatureClassToFeatureClass" Date="20241119" Time="112349">FeatureClassToFeatureClass "Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV" "Y:\Publicação Site PDDUA\PDDUA_shp\EixoLogradouro" EixosLogradouros.shp # "CDLOG "CDLOG" true true false 4 Long 0 7 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,CDLOG,-1,-1;CDSEG "CDSEG" true true false 4 Long 0 6 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,CDSEG,-1,-1;NRIMPINI "NRIMPINI" true true false 4 Long 0 5 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,NRIMPINI,-1,-1;NRIMPFIN "NRIMPFIN" true true false 4 Long 0 5 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,NRIMPFIN,-1,-1;NRPARINI "NRPARINI" true true false 4 Long 0 5 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,NRPARINI,-1,-1;NRPARFIN "NRPARFIN" true true false 4 Long 0 5 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,NRPARFIN,-1,-1;MTORIGEM "MTORIGEM" true true false 20 Text 0 0 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,MTORIGEM,-1,-1;MTQUAL "MTQUAL" true true false 10 Text 0 0 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,MTQUAL,-1,-1;CDTIPO "CDTIPO" true true false 2 Short 0 2 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,CDTIPO,-1,-1;MTVETOR "MTVETOR" true true false 26 Text 0 0 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,MTVETOR,-1,-1;FGCOMP "FGCOMP" true true false 8 Double 2 13 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,FGCOMP,-1,-1;SEL "SEL" true true false 2 Short 0 2 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,SEL,-1,-1;LABEL "LABEL" true true false 2 Short 0 4 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,LABEL,-1,-1;CDIDELOG "CDIDELOG" true false false 4 Long 0 7 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,CDIDELOG,-1,-1;NMIDELOG "NMIDELOG" true false false 37 Text 0 0 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,NMIDELOG,-1,-1;CDIDECAT "CDIDECAT" true false false 5 Text 0 0 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,CDIDECAT,-1,-1;NMIDEPRE "NMIDEPRE" true false false 3 Text 0 0 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,NMIDEPRE,-1,-1;NMIDEABR "NMIDEABR" true false false 16 Text 0 0 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,NMIDEABR,-1,-1;GEOM_LEN "GEOM_LEN" false false true 0 Double 0 0 ,First,#,Database Connections\GEOEIXOS.sde\GEOSMURB.EIXOS_CONV_VIEW\GEOSMURB.EIXOS_VIEW_CONV,GEOM.LEN,-1,-1" #</Process></lineage></DataProperties><SyncDate>20241119</SyncDate><SyncTime>11233400</SyncTime><ModDate>20241119</ModDate><ModTime>11233400</ModTime></Esri><dataIdInfo><envirDesc Sync="TRUE"> Version 6.2 (Build 9200) ; Esri ArcGIS 10.7.0.10348</envirDesc><dataLang><languageCode value="por" Sync="TRUE"></languageCode><countryCode value="BRA" Sync="TRUE"></countryCode></dataLang><idCitation><resTitle Sync="TRUE">EixosLogradouros</resTitle><presForm><PresFormCd value="005" Sync="TRUE"></PresFormCd></presForm></idCitation><spatRpType><SpatRepTypCd value="001" Sync="TRUE"></SpatRepTypCd></spatRpType></dataIdInfo><mdLang><languageCode value="por" Sync="TRUE"></languageCode><countryCode value="BRA" Sync="TRUE"></countryCode></mdLang><mdChar><CharSetCd value="004" Sync="TRUE"></CharSetCd></mdChar><distInfo><distFormat><formatName Sync="TRUE">Shapefile</formatName></distFormat><distTranOps><transSize Sync="TRUE">0.000</transSize></distTranOps></distInfo><mdHrLv><ScopeCd value="005" Sync="TRUE"></ScopeCd></mdHrLv><mdHrLvName Sync="TRUE">dataset</mdHrLvName><refSysInfo><RefSystem><refSysID><identCode code="0" Sync="TRUE"></identCode></refSysID></RefSystem></refSysInfo><spatRepInfo><VectSpatRep><geometObjs Name="EixosLogradouros"><geoObjTyp><GeoObjTypCd value="002" Sync="TRUE"></GeoObjTypCd></geoObjTyp><geoObjCnt Sync="TRUE">0</geoObjCnt></geometObjs><topLvl><TopoLevCd value="001" Sync="TRUE"></TopoLevCd></topLvl></VectSpatRep></spatRepInfo><spdoinfo><ptvctinf><esriterm Name="EixosLogradouros"><efeatyp Sync="TRUE">Simple</efeatyp><efeageom code="3" Sync="TRUE"></efeageom><esritopo Sync="TRUE">FALSE</esritopo><efeacnt Sync="TRUE">0</efeacnt><spindex Sync="TRUE">FALSE</spindex><linrefer Sync="TRUE">FALSE</linrefer></esriterm></ptvctinf></spdoinfo><eainfo><detailed Name="EixosLogradouros"><enttyp><enttypl Sync="TRUE">EixosLogradouros</enttypl><enttypt Sync="TRUE">Feature Class</enttypt><enttypc Sync="TRUE">0</enttypc></enttyp><attr><attrlabl Sync="TRUE">FID</attrlabl><attalias Sync="TRUE">FID</attalias><attrtype Sync="TRUE">OID</attrtype><attwidth Sync="TRUE">4</attwidth><atprecis Sync="TRUE">0</atprecis><attscale Sync="TRUE">0</attscale><attrdef Sync="TRUE">Internal feature number.</attrdef><attrdefs Sync="TRUE">Esri</attrdefs><attrdomv><udom Sync="TRUE">Sequential unique whole numbers that are automatically generated.</udom></attrdomv></attr><attr><attrlabl Sync="TRUE">Shape</attrlabl><attalias Sync="TRUE">Shape</attalias><attrtype Sync="TRUE">Geometry</attrtype><attwidth Sync="TRUE">0</attwidth><atprecis Sync="TRUE">0</atprecis><attscale Sync="TRUE">0</attscale><attrdef Sync="TRUE">Feature geometry.</attrdef><attrdefs Sync="TRUE">Esri</attrdefs><attrdomv><udom Sync="TRUE">Coordinates defining the features.</udom></attrdomv></attr><attr><attrlabl Sync="TRUE">CDLOG</attrlabl><attalias Sync="TRUE">CDLOG</attalias><attrtype Sync="TRUE">Integer</attrtype><attwidth Sync="TRUE">7</attwidth><atprecis Sync="TRUE">7</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">CDSEG</attrlabl><attalias Sync="TRUE">CDSEG</attalias><attrtype Sync="TRUE">Integer</attrtype><attwidth Sync="TRUE">6</attwidth><atprecis Sync="TRUE">6</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">NRIMPINI</attrlabl><attalias Sync="TRUE">NRIMPINI</attalias><attrtype Sync="TRUE">Integer</attrtype><attwidth Sync="TRUE">5</attwidth><atprecis Sync="TRUE">5</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">NRIMPFIN</attrlabl><attalias Sync="TRUE">NRIMPFIN</attalias><attrtype Sync="TRUE">Integer</attrtype><attwidth Sync="TRUE">5</attwidth><atprecis Sync="TRUE">5</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">NRPARINI</attrlabl><attalias Sync="TRUE">NRPARINI</attalias><attrtype Sync="TRUE">Integer</attrtype><attwidth Sync="TRUE">5</attwidth><atprecis Sync="TRUE">5</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">NRPARFIN</attrlabl><attalias Sync="TRUE">NRPARFIN</attalias><attrtype Sync="TRUE">Integer</attrtype><attwidth Sync="TRUE">5</attwidth><atprecis Sync="TRUE">5</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">MTORIGEM</attrlabl><attalias Sync="TRUE">MTORIGEM</attalias><attrtype Sync="TRUE">String</attrtype><attwidth Sync="TRUE">20</attwidth><atprecis Sync="TRUE">0</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">MTQUAL</attrlabl><attalias Sync="TRUE">MTQUAL</attalias><attrtype Sync="TRUE">String</attrtype><attwidth Sync="TRUE">10</attwidth><atprecis Sync="TRUE">0</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">CDTIPO</attrlabl><attalias Sync="TRUE">CDTIPO</attalias><attrtype Sync="TRUE">SmallInteger</attrtype><attwidth Sync="TRUE">2</attwidth><atprecis Sync="TRUE">2</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">MTVETOR</attrlabl><attalias Sync="TRUE">MTVETOR</attalias><attrtype Sync="TRUE">String</attrtype><attwidth Sync="TRUE">26</attwidth><atprecis Sync="TRUE">0</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">FGCOMP</attrlabl><attalias Sync="TRUE">FGCOMP</attalias><attrtype Sync="TRUE">Double</attrtype><attwidth Sync="TRUE">14</attwidth><atprecis Sync="TRUE">13</atprecis><attscale Sync="TRUE">2</attscale></attr><attr><attrlabl Sync="TRUE">SEL</attrlabl><attalias Sync="TRUE">SEL</attalias><attrtype Sync="TRUE">SmallInteger</attrtype><attwidth Sync="TRUE">2</attwidth><atprecis Sync="TRUE">2</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">LABEL</attrlabl><attalias Sync="TRUE">LABEL</attalias><attrtype Sync="TRUE">SmallInteger</attrtype><attwidth Sync="TRUE">4</attwidth><atprecis Sync="TRUE">4</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">CDIDELOG</attrlabl><attalias Sync="TRUE">CDIDELOG</attalias><attrtype Sync="TRUE">Integer</attrtype><attwidth Sync="TRUE">7</attwidth><atprecis Sync="TRUE">7</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">NMIDELOG</attrlabl><attalias Sync="TRUE">NMIDELOG</attalias><attrtype Sync="TRUE">String</attrtype><attwidth Sync="TRUE">37</attwidth><atprecis Sync="TRUE">0</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">CDIDECAT</attrlabl><attalias Sync="TRUE">CDIDECAT</attalias><attrtype Sync="TRUE">String</attrtype><attwidth Sync="TRUE">5</attwidth><atprecis Sync="TRUE">0</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">NMIDEPRE</attrlabl><attalias Sync="TRUE">NMIDEPRE</attalias><attrtype Sync="TRUE">String</attrtype><attwidth Sync="TRUE">3</attwidth><atprecis Sync="TRUE">0</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">NMIDEABR</attrlabl><attalias Sync="TRUE">NMIDEABR</attalias><attrtype Sync="TRUE">String</attrtype><attwidth Sync="TRUE">16</attwidth><atprecis Sync="TRUE">0</atprecis><attscale Sync="TRUE">0</attscale></attr><attr><attrlabl Sync="TRUE">GEOM_LEN</attrlabl><attalias Sync="TRUE">GEOM_LEN</attalias><attrtype Sync="TRUE">Double</attrtype><attwidth Sync="TRUE">19</attwidth><atprecis Sync="TRUE">0</atprecis><attscale Sync="TRUE">0</attscale></attr></detailed></eainfo><mdDateSt Sync="TRUE">20241119</mdDateSt></metadata>
backend/app/core/dados/EixosLogradouros.shx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ee0d38b37aba5823e51880dbcf6244c881397fa0e92b765806a28472388f36c1
3
+ size 258676
backend/app/core/elaboracao/__init__.py ADDED
File without changes
backend/app/core/elaboracao/app.py ADDED
@@ -0,0 +1,1910 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ app.py - Interface Gradio para elaboração de modelos estatísticos.
4
+
5
+ Contém criar_aba() (construção de UI + event wiring) e callbacks auxiliares pequenos.
6
+ Lógica de negócio delegada a módulos: modelo.py, carregamento.py, outliers.py, formatadores.py.
7
+ """
8
+
9
+ import gradio as gr
10
+ import pandas as pd
11
+ import os
12
+ import json
13
+
14
+ _avaliadores_path = os.path.join(os.path.dirname(__file__), "avaliadores.json")
15
+ with open(_avaliadores_path, encoding="utf-8") as _f:
16
+ _avaliadores_raw = json.load(_f)
17
+ _avaliadores_lista = _avaliadores_raw.get("avaliadores", [])
18
+ _avaliadores_nomes = [a["nome_completo"] for a in _avaliadores_lista]
19
+ _avaliadores_dict = {a["nome_completo"]: a for a in _avaliadores_lista}
20
+
21
+ # Módulos locais — lógica de negócio
22
+ from .core import (
23
+ obter_colunas_numericas,
24
+ exportar_base_csv,
25
+ TRANSFORMACOES,
26
+ )
27
+ from .charts import criar_mapa
28
+
29
+ # Módulos locais — formatação
30
+ from .formatadores import TITULO, carregar_css, criar_header_secao
31
+
32
+ # Módulos locais — callbacks de domínio
33
+ from .modelo import (
34
+ MAX_VARS_X,
35
+ ao_mudar_tipo_grafico,
36
+ ao_mudar_y_sem_estatisticas,
37
+ aplicar_selecao_callback,
38
+ buscar_transformacoes_callback,
39
+ ajustar_modelo_callback,
40
+ _atualizar_campos_transformacoes_com_flag,
41
+ adotar_sugestao,
42
+ exportar_modelo_callback,
43
+ popular_campos_avaliacao_callback,
44
+ avaliar_imovel_callback,
45
+ limpar_avaliacoes_callback,
46
+ excluir_avaliacao_callback,
47
+ atualizar_base_avaliacao_callback,
48
+ exportar_avaliacoes_excel_callback,
49
+ atualizar_interativo_dicotomicas,
50
+ popular_dicotomicas_callback,
51
+ )
52
+ from .carregamento import (
53
+ ao_carregar_arquivo,
54
+ confirmar_aba_callback,
55
+ limpar_historico_callback,
56
+ )
57
+ from .geocodificacao import (
58
+ padronizar_coords,
59
+ geocodificar,
60
+ aplicar_correcoes_e_regeodificar,
61
+ formatar_status_geocodificacao,
62
+ preparar_display_falhas,
63
+ )
64
+ from .outliers import (
65
+ aplicar_filtros_callback,
66
+ adicionar_filtro_callback,
67
+ remover_ultimo_filtro_callback,
68
+ limpar_filtros_callback,
69
+ reiniciar_iteracao_callback,
70
+ atualizar_resumo_outliers,
71
+ )
72
+
73
+
74
+ # ============================================================
75
+ # CALLBACKS AUXILIARES (pequenos, diretamente ligados ao event wiring)
76
+ # ============================================================
77
+
78
+ def download_tabela_callback(df, prefixo="tabela"):
79
+ """Callback genérico para download de DataFrame como CSV."""
80
+ if df is None:
81
+ return gr.update(value=None, visible=False)
82
+
83
+ try:
84
+ import tempfile
85
+
86
+ if isinstance(df, pd.DataFrame):
87
+ if df.empty:
88
+ return gr.update(value=None, visible=False)
89
+
90
+ fd, caminho = tempfile.mkstemp(suffix=".csv", prefix=f"{prefixo}_")
91
+ os.close(fd)
92
+ df.to_csv(caminho, index=True, encoding="utf-8-sig", sep=";", decimal=",")
93
+ return gr.update(value=caminho, visible=True)
94
+ else:
95
+ return gr.update(value=None, visible=False)
96
+ except Exception as e:
97
+ print(f"Erro ao exportar tabela: {e}")
98
+ return gr.update(value=None, visible=False)
99
+
100
+
101
+ def _extrair_var_mapa(var_mapa):
102
+ """Retorna None se for 'Visualização Padrão' ou vazio, senão retorna o nome da variável."""
103
+ if not var_mapa or var_mapa == "Visualização Padrão":
104
+ return None
105
+ return var_mapa
106
+
107
+
108
+ def ao_clicar_tabela(df, var_mapa, evt: gr.SelectData):
109
+ """Callback quando clica em linha da tabela."""
110
+ if df is None or evt is None:
111
+ return "<p>Carregue dados para ver o mapa.</p>"
112
+
113
+ # Pega o índice da linha clicada
114
+ indice = evt.index[0] + 1 # Ajusta para índice baseado em 1
115
+
116
+ return criar_mapa(df, indice_destacado=indice, tamanho_col=_extrair_var_mapa(var_mapa))
117
+
118
+
119
+ def atualizar_mapa_callback(df_filtrado, df_original, var_mapa):
120
+ """Callback quando a variável de dimensionamento do mapa é alterada."""
121
+ df = df_filtrado if df_filtrado is not None else df_original
122
+ if df is None:
123
+ return "<p>Carregue dados para ver o mapa.</p>"
124
+ return criar_mapa(df, tamanho_col=_extrair_var_mapa(var_mapa))
125
+
126
+
127
+ MAX_FALHAS_GEO = 20 # Slots pré-alocados para correção individual de falhas de geocodificação
128
+
129
+ # ============================================================
130
+ # CALLBACKS DE RESOLUÇÃO DE COORDENADAS (Seção 1 — painel lat/lon)
131
+ # ============================================================
132
+
133
+ def _revelar_secao_2(df, mapa):
134
+ """Retorna os 9 valores comuns a todos os callbacks de resolução de coords."""
135
+ from datetime import datetime, timezone, timedelta
136
+ gmt_minus_3 = timezone(timedelta(hours=-3))
137
+ ts = datetime.now(gmt_minus_3).strftime("%H:%M:%S")
138
+ return (
139
+ df, # estado_df
140
+ df, # estado_df_filtrado
141
+ mapa, # mapa_html
142
+ gr.update(visible=False), # row_coords_panel
143
+ gr.update(visible=True, value=criar_header_secao(2, "Visualizar Dados", ts)), # header_secao_2
144
+ gr.update(visible=True, open=True), # accordion_secao_2
145
+ gr.update(visible=True), # header_secao_3
146
+ gr.update(visible=True, open=True), # accordion_secao_3
147
+ )
148
+
149
+
150
+ def confirmar_mapeamento_callback(df, col_lat, col_lon):
151
+ """Opção 1: copia col_lat → 'lat' e col_lon → 'lon', libera Seção 2.
152
+
153
+ CONTRACT: 10 itens — html_erro_mapeamento + 8 de _revelar_secao_2 + status
154
+
155
+ Valida 4 aspectos antes de aceitar as colunas:
156
+ 1. Conversibilidade numérica (≥50% dos valores)
157
+ 2. Limites teóricos (lat −90/+90, lon −180/+180, ≥80%)
158
+ 3. Inversão entre colunas (lat parece lon e vice-versa)
159
+ 4. Casas decimais (coordenadas geográficas raramente são inteiros exatos)
160
+ """
161
+ def _erro_mapeamento(linhas_html):
162
+ html = (
163
+ '<div style="color:#c0392b;margin-top:6px;padding:8px 12px;'
164
+ 'background:#fdf2f2;border-radius:6px;border-left:3px solid #c0392b">'
165
+ '<strong>⚠ Diagnóstico das colunas selecionadas:</strong><br>'
166
+ + linhas_html +
167
+ '</div>'
168
+ )
169
+ return (
170
+ gr.update(value=html),
171
+ gr.update(), gr.update(), gr.update(),
172
+ gr.update(), gr.update(), gr.update(), gr.update(), gr.update(),
173
+ gr.update(),
174
+ )
175
+
176
+ if df is None or not col_lat or not col_lon:
177
+ return _erro_mapeamento("Selecione as colunas de latitude e longitude antes de confirmar.")
178
+
179
+ n_total = len(df)
180
+ if n_total == 0:
181
+ return _erro_mapeamento("O DataFrame está vazio.")
182
+
183
+ # Aspecto 1 — Conversibilidade numérica
184
+ lat_num = pd.to_numeric(df[col_lat], errors="coerce")
185
+ lon_num = pd.to_numeric(df[col_lon], errors="coerce")
186
+ lat_vals = lat_num.dropna()
187
+ lon_vals = lon_num.dropna()
188
+ lat_conv = len(lat_vals) / n_total
189
+ lon_conv = len(lon_vals) / n_total
190
+
191
+ erros_conv = []
192
+ if lat_conv < 0.5:
193
+ erros_conv.append(
194
+ f"• <strong>{col_lat}</strong>: apenas {lat_conv:.0%} dos valores são numéricos "
195
+ f"— a coluna provavelmente não contém coordenadas."
196
+ )
197
+ if lon_conv < 0.5:
198
+ erros_conv.append(
199
+ f"• <strong>{col_lon}</strong>: apenas {lon_conv:.0%} dos valores são numéricos "
200
+ f"— a coluna provavelmente não contém coordenadas."
201
+ )
202
+ if erros_conv:
203
+ return _erro_mapeamento("<br>".join(erros_conv))
204
+
205
+ # Aspecto 2 — Limites teóricos
206
+ lat_in_range = ((lat_vals >= -90) & (lat_vals <= 90)).mean()
207
+ lon_in_range = ((lon_vals >= -180) & (lon_vals <= 180)).mean()
208
+
209
+ # Aspecto 3 — Inversão de colunas
210
+ lat_in_lon_range = ((lat_vals >= -180) & (lat_vals <= 180)).mean()
211
+ lon_in_lat_range = ((lon_vals >= -90) & (lon_vals <= 90)).mean()
212
+ inversao = lat_in_range < 0.5 and lat_in_lon_range > 0.8 and lon_in_lat_range > 0.8
213
+
214
+ # Aspecto 4 — Casas decimais
215
+ lat_tem_decimais = (lat_vals % 1 != 0).mean() if len(lat_vals) > 0 else 0.0
216
+ lon_tem_decimais = (lon_vals % 1 != 0).mean() if len(lon_vals) > 0 else 0.0
217
+
218
+ linhas = []
219
+
220
+ if inversao:
221
+ linhas.append(
222
+ f"• As colunas parecem estar <strong>invertidas</strong>: "
223
+ f"<strong>{col_lat}</strong> tem {1 - lat_in_range:.0%} dos valores fora de −90 a +90 "
224
+ f"(fora do intervalo de latitude), mas dentro de −180 a +180 (intervalo de longitude). "
225
+ f"<strong>{col_lon}</strong> tem {lon_in_lat_range:.0%} dos valores em −90 a +90 "
226
+ f"(faixa típica de latitude). Tente trocar as colunas."
227
+ )
228
+ else:
229
+ if lat_in_range < 0.8:
230
+ linhas.append(
231
+ f"• <strong>{col_lat}</strong>: apenas {lat_in_range:.0%} dos valores estão "
232
+ f"em −90 a +90 (esperado para latitude)."
233
+ )
234
+ if lon_in_range < 0.8:
235
+ linhas.append(
236
+ f"• <strong>{col_lon}</strong>: apenas {lon_in_range:.0%} dos valores estão "
237
+ f"em −180 a +180 (esperado para longitude)."
238
+ )
239
+
240
+ # Aspecto 4 só é avaliado se não há outros problemas (seria ruído adicional)
241
+ if not linhas:
242
+ if lat_tem_decimais < 0.1:
243
+ linhas.append(
244
+ f"• <strong>{col_lat}</strong>: {lat_tem_decimais:.0%} dos valores têm casas decimais "
245
+ f"— coordenadas geográficas normalmente são valores de ponto flutuante, não inteiros exatos."
246
+ )
247
+ if lon_tem_decimais < 0.1:
248
+ linhas.append(
249
+ f"• <strong>{col_lon}</strong>: {lon_tem_decimais:.0%} dos valores têm casas decimais "
250
+ f"— coordenadas geográficas normalmente são valores de ponto flutuante, não inteiros exatos."
251
+ )
252
+
253
+ if linhas:
254
+ return _erro_mapeamento("<br>".join(linhas))
255
+
256
+ df_novo = padronizar_coords(df, col_lat, col_lon)
257
+ mapa = criar_mapa(df_novo)
258
+ return (
259
+ gr.update(value=""), # html_erro_mapeamento — limpar
260
+ ) + _revelar_secao_2(df_novo, mapa) + ("Coordenadas mapeadas. Seção 2 liberada.",)
261
+
262
+
263
+ def geocodificar_callback(df, col_cdlog, col_num, auto_200):
264
+ """Opção 3: executa geocodificação por interpolação em eixos.
265
+
266
+ CONTRACT: 65 itens —
267
+ estado_geo_temp, estado_df_falhas, html_geo_status,
268
+ 20×falha_rows, 20×falha_htmls, 20×falha_inputs,
269
+ btn_aplicar_correcoes_geo, btn_usar_coords_geo
270
+ """
271
+ N = MAX_FALHAS_GEO
272
+ _no_slots = (
273
+ *[gr.update(visible=False)] * N,
274
+ *[gr.update(value="")] * N,
275
+ *[gr.update(value=None)] * N,
276
+ )
277
+
278
+ if df is None or not col_cdlog or not col_num:
279
+ return (
280
+ None, None,
281
+ "<p>Selecione as colunas CDLOG e Número Predial.</p>",
282
+ *_no_slots,
283
+ gr.update(visible=False), gr.update(visible=False),
284
+ )
285
+ try:
286
+ df_resultado, df_falhas, ajustados = geocodificar(df, col_cdlog, col_num, auto_200)
287
+ except RuntimeError as e:
288
+ return (
289
+ None, None,
290
+ f'<div style="color:red;padding:8px">{e}</div>',
291
+ *_no_slots,
292
+ gr.update(visible=False), gr.update(visible=False),
293
+ )
294
+
295
+ html_status = formatar_status_geocodificacao(df_resultado, df_falhas, ajustados)
296
+ n_falhas = len(df_falhas)
297
+ tem_coords = df_resultado["lat"].notna().any() if "lat" in df_resultado.columns else False
298
+ completo = n_falhas == 0 and tem_coords
299
+
300
+ if n_falhas > N:
301
+ html_status += (
302
+ f'<div style="color:#c0392b;margin-top:6px">⚠ {n_falhas} falhas excedem o limite '
303
+ f'de {N} correções manuais. Corrija os endereços na planilha-fonte e recarregue.</div>'
304
+ )
305
+ return (
306
+ df_resultado, None,
307
+ html_status,
308
+ *_no_slots,
309
+ gr.update(visible=False), gr.update(visible=False),
310
+ )
311
+
312
+ # Monta atualizações para os N slots
313
+ row_updates, html_updates, num_updates = [], [], []
314
+ for i in range(N):
315
+ if i < n_falhas:
316
+ row = df_falhas.iloc[i]
317
+ sugestoes = row.get("sugestoes", "")
318
+ linha = row["_idx"]
319
+ row_updates.append(gr.update(visible=True))
320
+ html_updates.append(gr.update(value=(
321
+ f'<div style="padding:4px 8px;background:#fff8f0;border-left:3px solid #f39c12;'
322
+ f'border-radius:4px;font-size:0.9em">'
323
+ f'<strong>Linha {linha}</strong> · CDLOG {row["cdlog"]} · '
324
+ f'Nº atual: <strong>{row["numero_atual"]}</strong> · {row["motivo"]}'
325
+ + (f'<br><span style="color:#555">Sugestões: {sugestoes}</span>' if sugestoes else '')
326
+ + '</div>'
327
+ )))
328
+ num_updates.append(gr.update(value=None, label=f"Nº Corrigido (linha {linha})"))
329
+ else:
330
+ row_updates.append(gr.update(visible=False))
331
+ html_updates.append(gr.update(value=""))
332
+ num_updates.append(gr.update(value=None))
333
+
334
+ return (
335
+ df_resultado, # estado_geo_temp
336
+ df_falhas if n_falhas > 0 else None, # estado_df_falhas
337
+ html_status, # html_geo_status
338
+ *row_updates, # 20 falha_rows
339
+ *html_updates, # 20 falha_htmls
340
+ *num_updates, # 20 falha_inputs
341
+ gr.update(visible=n_falhas > 0), # btn_aplicar_correcoes_geo
342
+ gr.update(visible=completo), # btn_usar_coords_geo
343
+ )
344
+
345
+
346
+ def aplicar_correcoes_geo_callback(df_original, df_falhas, *args):
347
+ """Opção 3: aplica correções manuais e re-geocodifica.
348
+
349
+ CONTRACT: inputs = estado_geo_temp (1) + estado_df_falhas (1) + 20 falha_inputs + 3 params = 25.
350
+ CONTRACT: 65 itens retornados (mesmo formato de geocodificar_callback).
351
+ """
352
+ N = MAX_FALHAS_GEO
353
+ correcoes_vals = list(args[:N])
354
+ col_cdlog, col_num, auto_200 = args[N], args[N + 1], args[N + 2]
355
+
356
+ _no_slots = (
357
+ *[gr.update(visible=False)] * N,
358
+ *[gr.update(value="")] * N,
359
+ *[gr.update(value=None)] * N,
360
+ )
361
+
362
+ if df_original is None or df_falhas is None or not col_cdlog or not col_num:
363
+ return (
364
+ None, None,
365
+ "<p>Sem dados para processar.</p>",
366
+ *_no_slots,
367
+ gr.update(visible=False), gr.update(visible=False),
368
+ )
369
+
370
+ # Monta coluna "numero_corrigido" a partir dos valores dos gr.Number
371
+ df_falhas_fmt = df_falhas.copy()
372
+ num_corrigidos = []
373
+ for i, val in enumerate(correcoes_vals[:len(df_falhas_fmt)]):
374
+ if val is not None and not (isinstance(val, float) and pd.isna(val)):
375
+ num_corrigidos.append(str(int(val)))
376
+ else:
377
+ num_corrigidos.append("")
378
+ # Preenche com "" os slots além do tamanho real de df_falhas
379
+ num_corrigidos += [""] * max(0, len(df_falhas_fmt) - len(num_corrigidos))
380
+ df_falhas_fmt["numero_corrigido"] = num_corrigidos
381
+
382
+ try:
383
+ df_resultado, df_falhas_novas, ajustados, manuais = aplicar_correcoes_e_regeodificar(
384
+ df_original, df_falhas_fmt, col_cdlog, col_num, auto_200
385
+ )
386
+ except RuntimeError as e:
387
+ return (
388
+ None, None,
389
+ f'<div style="color:red;padding:8px">{e}</div>',
390
+ *_no_slots,
391
+ gr.update(visible=False), gr.update(visible=False),
392
+ )
393
+
394
+ html_status = formatar_status_geocodificacao(df_resultado, df_falhas_novas, ajustados, manuais)
395
+ n_falhas = len(df_falhas_novas)
396
+ tem_coords = df_resultado["lat"].notna().any() if "lat" in df_resultado.columns else False
397
+ completo = n_falhas == 0 and tem_coords
398
+
399
+ if n_falhas > N:
400
+ html_status += (
401
+ f'<div style="color:#c0392b;margin-top:6px">⚠ {n_falhas} falhas excedem o limite '
402
+ f'de {N} correções manuais. Corrija os endereços na planilha-fonte e recarregue.</div>'
403
+ )
404
+ return (
405
+ df_resultado, None,
406
+ html_status,
407
+ *_no_slots,
408
+ gr.update(visible=False), gr.update(visible=False),
409
+ )
410
+
411
+ row_updates, html_updates, num_updates = [], [], []
412
+ for i in range(N):
413
+ if i < n_falhas:
414
+ row = df_falhas_novas.iloc[i]
415
+ sugestoes = row.get("sugestoes", "")
416
+ linha = row["_idx"]
417
+ row_updates.append(gr.update(visible=True))
418
+ html_updates.append(gr.update(value=(
419
+ f'<div style="padding:4px 8px;background:#fff8f0;border-left:3px solid #f39c12;'
420
+ f'border-radius:4px;font-size:0.9em">'
421
+ f'<strong>Linha {linha}</strong> · CDLOG {row["cdlog"]} · '
422
+ f'Nº atual: <strong>{row["numero_atual"]}</strong> · {row["motivo"]}'
423
+ + (f'<br><span style="color:#555">Sugestões: {sugestoes}</span>' if sugestoes else '')
424
+ + '</div>'
425
+ )))
426
+ num_updates.append(gr.update(value=None, label=f"Nº Corrigido (linha {linha})"))
427
+ else:
428
+ row_updates.append(gr.update(visible=False))
429
+ html_updates.append(gr.update(value=""))
430
+ num_updates.append(gr.update(value=None))
431
+
432
+ return (
433
+ df_resultado,
434
+ df_falhas_novas if n_falhas > 0 else None,
435
+ html_status,
436
+ *row_updates,
437
+ *html_updates,
438
+ *num_updates,
439
+ gr.update(visible=n_falhas > 0), # btn_aplicar_correcoes_geo
440
+ gr.update(visible=completo), # btn_usar_coords_geo
441
+ )
442
+
443
+
444
+ def confirmar_geocodificacao_callback(df_geo):
445
+ """Opção 3: confirma uso das coords geocodificadas e libera Seção 2."""
446
+ if df_geo is None:
447
+ return (
448
+ gr.update(), gr.update(), gr.update(),
449
+ gr.update(), gr.update(), gr.update(), gr.update(), gr.update(),
450
+ "Sem dados geocodificados.",
451
+ )
452
+ mapa = criar_mapa(df_geo)
453
+ return _revelar_secao_2(df_geo, mapa) + ("Geocodificação concluída. Seção 2 liberada.",)
454
+
455
+
456
+ def confirmar_sem_coords_callback(df):
457
+ """Prossegue para Seção 2 sem coordenadas completas (decisão explícita do usuário)."""
458
+ if df is None:
459
+ return (
460
+ gr.update(), gr.update(), gr.update(),
461
+ gr.update(), gr.update(), gr.update(), gr.update(), gr.update(),
462
+ "Sem dados carregados.",
463
+ )
464
+ mapa = criar_mapa(df)
465
+ return _revelar_secao_2(df, mapa) + ("Seção 2 liberada sem coordenadas completas.",)
466
+
467
+
468
+ # ============================================================
469
+ # INTERFACE GRADIO
470
+ # ============================================================
471
+
472
+ def criar_aba():
473
+ """Cria conteúdo da aba de elaboração (sem wrapper gr.Blocks)."""
474
+
475
+ # Estados
476
+ estado_df = gr.State(None)
477
+ estado_modelo = gr.State(None)
478
+ estado_estatisticas = gr.State(None)
479
+ estado_outliers_anteriores = gr.State([]) # Lista de índices excluídos em iterações anteriores
480
+ estado_iteracao = gr.State(1) # Contador de iterações
481
+ estado_avaliacoes = gr.State([]) # Lista de avaliações acumuladas
482
+
483
+ # ========================================
484
+ # SEÇÃO 1: IMPORTAR DADOS (sempre visível e aberta)
485
+ # ========================================
486
+ # Estado para armazenar o arquivo temporariamente (usado quando há múltiplas abas)
487
+ estado_arquivo_temp = gr.State(None)
488
+ estado_flag_carregamento = gr.State(False)
489
+ estado_geo_temp = gr.State(None) # DataFrame durante geocodificação (antes de confirmar)
490
+ estado_df_falhas = gr.State(None) # df_falhas mais recente (geocodificação)
491
+
492
+ header_secao_1 = gr.HTML(criar_header_secao(1, "Importar Dados"))
493
+ with gr.Accordion(
494
+ label="▼ Mostrar / Ocultar",
495
+ open=True,
496
+ elem_classes="section-accordion"
497
+ ) as accordion_secao_1:
498
+ with gr.Group(elem_classes="section-content") as conteudo_secao_1:
499
+ # Estado A: Upload (visível inicialmente)
500
+ with gr.Row(visible=True) as row_upload:
501
+ upload = gr.File(
502
+ label="Carregar arquivo (Excel, CSV ou Modelo .dai)",
503
+ scale=2
504
+ )
505
+ status = gr.Textbox(
506
+ label="Status",
507
+ interactive=False,
508
+ scale=2
509
+ )
510
+
511
+ # Estado B: Seleção de aba — apenas para Excel multi-aba (oculto inicialmente)
512
+ with gr.Group(visible=False) as row_selecao_aba:
513
+ with gr.Row():
514
+ dropdown_aba = gr.Dropdown(
515
+ label="Selecionar Aba",
516
+ choices=[],
517
+ interactive=True,
518
+ )
519
+ with gr.Row():
520
+ btn_confirmar_aba = gr.Button("Selecionar Aba", variant="primary")
521
+
522
+ # Estado C: Pós-carregamento (oculto inicialmente)
523
+ with gr.Row(visible=False) as row_pos_carga:
524
+ with gr.Column(scale=5):
525
+ html_nome_arquivo_carregado = gr.HTML(value="")
526
+ with gr.Column(scale=2):
527
+ btn_reiniciar_app = gr.Button(
528
+ "Reiniciar Aplicação",
529
+ variant="danger"
530
+ )
531
+ html_aviso_reiniciar = gr.HTML(value=(
532
+ '<div style="background-color:#fff3cd; border:1px solid #ffc107; '
533
+ 'border-radius:8px; padding:8px 12px; margin-top:8px; font-size:1em;">'
534
+ '<strong>Atenção:</strong> Ao reiniciar, toda a aplicação MESA será '
535
+ 'recarregada e <strong>o conteúdo de todas as abas será perdido</strong>. '
536
+ 'Para iniciar uma nova sessão MESA sem perder o trabalho atual, '
537
+ 'abra uma <strong>nova aba do navegador</strong>.'
538
+ '</div>'
539
+ ))
540
+
541
+ # Estado D: Resolução de Coordenadas (oculto inicialmente)
542
+ with gr.Row(visible=False) as row_coords_panel:
543
+ with gr.Column():
544
+ html_aviso_coords = gr.HTML(value="")
545
+
546
+ # Tela de escolha (visível inicialmente)
547
+ with gr.Row() as row_escolha_opcao:
548
+ with gr.Column():
549
+ with gr.Row():
550
+ btn_escolher_mapear = gr.Button(
551
+ "Mapear colunas existentes para lat/lon",
552
+ variant="primary",
553
+ scale=10,
554
+ )
555
+ gr.HTML(
556
+ '<div style="align-self:center;text-align:center;'
557
+ 'min-width:36px">'
558
+ '<span style="display:inline-block;font-size:0.75rem;'
559
+ 'font-weight:600;color:#9ca3af;letter-spacing:0.05em;'
560
+ 'text-transform:uppercase;background:#f3f4f6;'
561
+ 'border-radius:999px;padding:3px 8px">ou</span>'
562
+ '</div>',
563
+ scale=1,
564
+ )
565
+ btn_escolher_geocodificar = gr.Button(
566
+ "Geocodificar automaticamente",
567
+ variant="primary",
568
+ scale=10,
569
+ )
570
+ gr.HTML(
571
+ '<div style="align-self:center;text-align:center;'
572
+ 'min-width:36px">'
573
+ '<span style="display:inline-block;font-size:0.75rem;'
574
+ 'font-weight:600;color:#9ca3af;letter-spacing:0.05em;'
575
+ 'text-transform:uppercase;background:#f3f4f6;'
576
+ 'border-radius:999px;padding:3px 8px">ou</span>'
577
+ '</div>',
578
+ scale=1,
579
+ )
580
+ btn_prosseguir_sem_coords = gr.Button(
581
+ "Prosseguir sem mapear coordenadas",
582
+ variant="primary",
583
+ scale=10,
584
+ )
585
+ with gr.Row(visible=False) as row_confirmacao_prosseguir:
586
+ with gr.Column():
587
+ gr.HTML(
588
+ '<div style="background:#fff3cd;border:1px solid #ffc107;'
589
+ 'border-radius:8px;padding:8px 12px;margin-top:4px">'
590
+ '<strong>Atenção:</strong> Funcionalidades dependentes de '
591
+ 'geolocalização (mapa, análises espaciais) não funcionarão '
592
+ 'para registros sem coordenadas ou apresentarão resultados '
593
+ 'incompletos. Deseja prosseguir mesmo assim?'
594
+ '</div>'
595
+ )
596
+ btn_confirmar_prosseguir = gr.Button(
597
+ "Confirmar — prosseguir mesmo assim",
598
+ variant="stop",
599
+ )
600
+
601
+ # Painel Mapear (oculto — botão Voltar no topo)
602
+ with gr.Row(visible=False) as row_opcao_mapear:
603
+ with gr.Column():
604
+ btn_voltar_mapear = gr.Button("← Voltar", variant="secondary", size="sm")
605
+ with gr.Row():
606
+ dropdown_col_lat_manual = gr.Dropdown(
607
+ label="Coluna → lat",
608
+ choices=[],
609
+ interactive=True,
610
+ )
611
+ dropdown_col_lon_manual = gr.Dropdown(
612
+ label="Coluna → lon",
613
+ choices=[],
614
+ interactive=True,
615
+ )
616
+ html_erro_mapeamento = gr.HTML(value="")
617
+ btn_confirmar_mapeamento = gr.Button(
618
+ "Confirmar mapeamento",
619
+ variant="primary",
620
+ )
621
+
622
+ # Painel Geocodificar (oculto — botão Voltar no topo)
623
+ with gr.Row(visible=False) as row_opcao_geocodificar:
624
+ with gr.Column():
625
+ btn_voltar_geocodificar = gr.Button("← Voltar", variant="secondary", size="sm")
626
+ with gr.Row():
627
+ dropdown_cdlog_geo = gr.Dropdown(
628
+ label="Coluna CDLOG (código do logradouro)",
629
+ choices=[],
630
+ interactive=True,
631
+ )
632
+ dropdown_num_geo = gr.Dropdown(
633
+ label="Coluna Número Predial",
634
+ choices=[],
635
+ interactive=True,
636
+ )
637
+ checkbox_auto_200 = gr.Checkbox(
638
+ label=(
639
+ "Permitir correção automática de ofício para números cuja "
640
+ "diferença do intervalo válido mais próximo seja ≤ 200"
641
+ ),
642
+ value=True,
643
+ )
644
+ btn_geocodificar = gr.Button("Geocodificar", variant="primary")
645
+ html_geo_status = gr.HTML(value="<div style='min-height:40px'></div>")
646
+
647
+ # 20 slots de correção individual (ocultos inicialmente)
648
+ falha_rows = []
649
+ falha_htmls = []
650
+ falha_inputs = []
651
+ for i in range(MAX_FALHAS_GEO):
652
+ with gr.Row(visible=False) as _fr:
653
+ with gr.Column(scale=3):
654
+ _fh = gr.HTML(value="")
655
+ with gr.Column(scale=1):
656
+ _fi = gr.Number(label="Nº Corrigido", precision=0, value=None)
657
+ falha_rows.append(_fr)
658
+ falha_htmls.append(_fh)
659
+ falha_inputs.append(_fi)
660
+
661
+ btn_aplicar_correcoes_geo = gr.Button(
662
+ "Aplicar correções e re-geocodificar",
663
+ visible=False,
664
+ )
665
+ btn_usar_coords_geo = gr.Button(
666
+ "Confirmar",
667
+ variant="primary",
668
+ visible=False,
669
+ )
670
+
671
+
672
+ # ========================================
673
+ # SEÇÃO 2: VISUALIZAR DADOS (ativada ao carregar arquivo)
674
+ # ========================================
675
+ header_secao_2 = gr.HTML(criar_header_secao(2, "Visualizar Dados"), visible=True)
676
+ with gr.Accordion(
677
+ label="▼ Mostrar / Ocultar",
678
+ open=True,
679
+ visible=False,
680
+ elem_classes="section-accordion"
681
+ ) as accordion_secao_2:
682
+ with gr.Group(elem_classes="section-content") as conteudo_secao_2:
683
+ with gr.Accordion("Outliers Excluídos", open=True, visible=False) as accordion_outliers_anteriores:
684
+ html_outliers_anteriores = gr.HTML(value="")
685
+ btn_limpar_historico = gr.Button("Limpar Histórico e Reiniciar do Zero", variant="secondary")
686
+
687
+ with gr.Accordion("Dados de Mercado", open=True):
688
+ tabela_dados = gr.Dataframe(
689
+ label="",
690
+ interactive=False,
691
+ max_height=400
692
+ )
693
+ with gr.Row():
694
+ btn_download_dados = gr.Button("Baixar Dados (CSV)", variant="secondary", size="sm")
695
+ download_dados_file = gr.File(label="", visible=False)
696
+
697
+ dropdown_mapa_var = gr.Dropdown(
698
+ label="Variável para dimensionar pontos no mapa",
699
+ choices=["Visualização Padrão"],
700
+ value="Visualização Padrão",
701
+ interactive=True,
702
+ allow_custom_value=False
703
+ )
704
+
705
+ with gr.Accordion("Mapa", open=True):
706
+ mapa_html = gr.HTML(
707
+ value="<p>Carregue dados para ver o mapa.</p>",
708
+ label=""
709
+ )
710
+
711
+ # ========================================
712
+ # SEÇÃO 3: SELECIONAR VARIÁVEL DEPENDENTE (ativada ao carregar arquivo)
713
+ # ========================================
714
+ header_secao_3 = gr.HTML(criar_header_secao(3, "Selecionar Variável Dependente"), visible=True)
715
+ with gr.Accordion(
716
+ label="▼ Mostrar / Ocultar",
717
+ open=True,
718
+ visible=False,
719
+ elem_classes="section-accordion"
720
+ ) as accordion_secao_3:
721
+ with gr.Group(elem_classes="section-content") as conteudo_secao_3:
722
+ with gr.Row():
723
+ dropdown_y = gr.Dropdown(
724
+ label="Variável Dependente (y)",
725
+ choices=[],
726
+ interactive=True,
727
+ scale=2
728
+ )
729
+ with gr.Row():
730
+ btn_aplicar_y = gr.Button("Aplicar Seleção", variant="primary", scale=1)
731
+
732
+ # ========================================
733
+ # SEÇÃO 4: SELECIONAR VARIÁVEIS INDEPENDENTES (ativada ao selecionar Y)
734
+ # ========================================
735
+ header_secao_4 = gr.HTML(criar_header_secao(4, "Selecionar Variáveis Independentes"), visible=True)
736
+ with gr.Accordion(
737
+ label="▼ Mostrar / Ocultar",
738
+ open=True,
739
+ visible=False,
740
+ elem_classes="section-accordion"
741
+ ) as accordion_secao_4:
742
+ with gr.Group(elem_classes="section-content") as conteudo_secao_4:
743
+ with gr.Row(elem_classes="checkbox-selecionar-todos"):
744
+ checkbox_selecionar_todos = gr.Checkbox(
745
+ label="Marcar ou desmarcar todas as variáveis",
746
+ value=True,
747
+ interactive=True
748
+ )
749
+ with gr.Row():
750
+ checkboxes_x = gr.CheckboxGroup(
751
+ label="Variáveis Independentes (X)",
752
+ choices=[],
753
+ interactive=True
754
+ )
755
+ with gr.Row():
756
+ checkboxes_dicotomicas = gr.CheckboxGroup(
757
+ label="Variáveis Dicotômicas (0/1)",
758
+ choices=[], value=[], visible=False, interactive=True,
759
+ info="Transformação fixa em (x). Avaliação: apenas 0 ou 1."
760
+ )
761
+ with gr.Row():
762
+ checkboxes_codigo_alocado = gr.CheckboxGroup(
763
+ label="Variáveis de Código Alocado/Ajustado",
764
+ choices=[], value=[], visible=False, interactive=True,
765
+ info="Transformação livre. Avaliação: apenas inteiros no intervalo observado."
766
+ )
767
+ with gr.Row():
768
+ checkboxes_percentuais = gr.CheckboxGroup(
769
+ label="Variáveis Percentuais (0 a 1)",
770
+ choices=[], value=[], visible=False, interactive=True,
771
+ info="Transformação fixa em (x). Avaliação: apenas valores entre 0 e 1."
772
+ )
773
+ with gr.Row():
774
+ btn_aplicar_selecao_x = gr.Button("Aplicar Seleção", variant="primary", scale=1)
775
+ html_aviso_multicolinearidade = gr.HTML("", visible=False)
776
+
777
+ # Estados para outliers (usados na seção de Análise de Outliers após o modelo)
778
+ estado_metricas = gr.State(None)
779
+ estado_df_filtrado = gr.State(None)
780
+
781
+ # ========================================
782
+ # SEÇÃO 5: ESTATÍSTICAS DAS VARIÁVEIS SELECIONADAS (ativada por Aplicar Seleção)
783
+ # ========================================
784
+ header_secao_5 = gr.HTML(criar_header_secao(5, "Estatísticas das Variáveis Selecionadas"), visible=True)
785
+ with gr.Accordion(
786
+ label="▼ Mostrar / Ocultar",
787
+ open=True,
788
+ visible=False,
789
+ elem_classes="section-accordion"
790
+ ) as accordion_secao_5:
791
+ with gr.Group(elem_classes="section-content") as conteudo_secao_5:
792
+ tabela_estatisticas = gr.Dataframe(
793
+ label="",
794
+ interactive=False,
795
+ value=None,
796
+ wrap=True
797
+ )
798
+ with gr.Row():
799
+ btn_download_estatisticas = gr.Button("Baixar Estatísticas (CSV)", variant="secondary", size="sm")
800
+ download_estatisticas_file = gr.File(label="", visible=False)
801
+
802
+ # ========================================
803
+ # SEÇÃO 6: TESTE DE MICRONUMEROSIDADE (ativada por Aplicar Seleção)
804
+ # ========================================
805
+ header_secao_6 = gr.HTML(criar_header_secao(6, "Teste de Micronumerosidade"), visible=True)
806
+ with gr.Accordion(
807
+ label="▼ Mostrar / Ocultar",
808
+ open=True,
809
+ visible=False,
810
+ elem_classes="section-accordion"
811
+ ) as accordion_secao_6:
812
+ with gr.Group(elem_classes="section-content") as conteudo_secao_6:
813
+ html_micronumerosidade = gr.HTML(value="")
814
+
815
+ # ========================================
816
+ # SEÇÃO 7: GRÁFICOS DE DISPERSÃO (ativada por Aplicar Seleção)
817
+ # ========================================
818
+ header_secao_7 = gr.HTML(criar_header_secao(7, "Gráficos de Dispersão das Variáveis Independentes"), visible=True)
819
+ with gr.Accordion(
820
+ label="▼ Mostrar / Ocultar",
821
+ open=True,
822
+ visible=False,
823
+ elem_classes="section-accordion"
824
+ ) as accordion_secao_7:
825
+ with gr.Group(elem_classes="section-content") as conteudo_secao_7:
826
+ plot_dispersao = gr.Plot(label="")
827
+
828
+ # ========================================
829
+ # SEÇÃO 8: TRANSFORMAÇÕES SUGERIDAS (ativada por Aplicar Seleção)
830
+ # ========================================
831
+ # Estado para armazenar resultados da busca
832
+ estado_resultados_busca = gr.State([])
833
+
834
+ header_secao_8 = gr.HTML(criar_header_secao(8, "Transformações Sugeridas"), visible=True)
835
+ with gr.Accordion(
836
+ label="▼ Mostrar / Ocultar",
837
+ open=True,
838
+ visible=False,
839
+ elem_classes="section-accordion"
840
+ ) as accordion_secao_8:
841
+ with gr.Group(elem_classes="section-content") as conteudo_secao_8:
842
+ with gr.Row():
843
+ slider_grau_coef = gr.Radio(
844
+ choices=[("Sem enquadramento", 0), ("Grau I (p≤30%)", 1), ("Grau II (p≤20%)", 2), ("Grau III (p≤10%)", 3)],
845
+ value=3,
846
+ label="Grau mínimo de significância dos coeficientes",
847
+ type="value"
848
+ )
849
+ slider_grau_f = gr.Radio(
850
+ choices=[("Sem enquadramento", 0), ("Grau I (α=5%)", 1), ("Grau II (α=2%)", 2), ("Grau III (α=1%)", 3)],
851
+ value=3,
852
+ label="Grau mínimo do Teste F",
853
+ type="value"
854
+ )
855
+ busca_html = gr.HTML(value="")
856
+
857
+ # ========================================
858
+ # SEÇÃO 9: APLICAÇÃO DAS TRANSFORMAÇÕES (ativada por Aplicar Seleção)
859
+ # ========================================
860
+ header_secao_9 = gr.HTML(criar_header_secao(9, "Aplicação das Transformações"), visible=True)
861
+ with gr.Accordion(
862
+ label="▼ Mostrar / Ocultar",
863
+ open=True,
864
+ visible=False,
865
+ elem_classes="section-accordion"
866
+ ) as accordion_secao_9:
867
+ with gr.Group(elem_classes="section-content") as conteudo_secao_9:
868
+ # Área destacada para botões de adoção
869
+ gr.HTML("""
870
+ <div class="adotar-header">
871
+ <span class="adotar-titulo">Adote uma das otimizações de transformação sugeridas na seção anterior</span>
872
+ </div>
873
+ """)
874
+ with gr.Row(elem_classes="adotar-row"):
875
+ btn_adotar_1 = gr.Button("✓ Adotar #1", visible=False, elem_classes="btn-adotar", variant="primary")
876
+ btn_adotar_2 = gr.Button("✓ Adotar #2", visible=False, elem_classes="btn-adotar", variant="primary")
877
+ btn_adotar_3 = gr.Button("✓ Adotar #3", visible=False, elem_classes="btn-adotar", variant="primary")
878
+ btn_adotar_4 = gr.Button("✓ Adotar #4", visible=False, elem_classes="btn-adotar", variant="primary")
879
+ btn_adotar_5 = gr.Button("✓ Adotar #5", visible=False, elem_classes="btn-adotar", variant="primary")
880
+
881
+ gr.HTML("<div class='adotar-separator'><span>ou configure manualmente</span></div>")
882
+
883
+ gr.Markdown("*Selecione a transformação para cada variável (dicotômicas ficam fixas em (x))*", elem_classes="transf-instrucao")
884
+
885
+ # Cards: os 3 gr.Row são transparentes (display:contents via CSS) e fluem
886
+ # num único container flex (.transf-all-cards), garantindo distribuição uniforme
887
+ # 3 linhas × 8 = 24 slots; apenas os 20 primeiros são rastreados
888
+ # Card Y fica antes dos cards X (mesmo container flex)
889
+ transf_x_rows = []
890
+ transf_x_columns = []
891
+ transf_x_labels = []
892
+ transf_x_dropdowns = []
893
+
894
+ with gr.Column(elem_classes="transf-all-cards"):
895
+ # Card da variável dependente Y (borda azul, label dinâmico com nome + "(Y)")
896
+ with gr.Row(visible=False, elem_classes="transf-cards-row") as transf_y_row:
897
+ with gr.Column(scale=1, min_width=110, elem_classes="transf-card transf-card-y", visible=False) as transf_y_col:
898
+ transf_y_label = gr.HTML(value="", visible=False, elem_classes="transf-card-label")
899
+ transformacao_y = gr.Dropdown(
900
+ choices=TRANSFORMACOES,
901
+ value="(x)",
902
+ label="",
903
+ show_label=False,
904
+ interactive=True,
905
+ visible=True,
906
+ )
907
+
908
+ for i in range((MAX_VARS_X + 7) // 8): # 3 rows
909
+ row = gr.Row(visible=False, elem_classes="transf-cards-row")
910
+ with row:
911
+ for j in range(8):
912
+ k = i * 8 + j
913
+ col = gr.Column(scale=1, min_width=110, elem_classes="transf-card", visible=False)
914
+ with col:
915
+ label = gr.HTML(value="", visible=False, elem_classes="transf-card-label")
916
+ dropdown = gr.Dropdown(
917
+ choices=TRANSFORMACOES,
918
+ value="(x)",
919
+ label="",
920
+ show_label=False,
921
+ interactive=True,
922
+ visible=False,
923
+ )
924
+ if k < MAX_VARS_X:
925
+ transf_x_columns.append(col)
926
+ transf_x_labels.append(label)
927
+ transf_x_dropdowns.append(dropdown)
928
+ transf_x_rows.append(row)
929
+
930
+ with gr.Row():
931
+ btn_ajustar = gr.Button("Aplicar transformações e ajustar modelo", variant="primary", scale=1)
932
+
933
+ # ========================================
934
+ # SEÇÃO 10: GRÁFICOS DE DISPERSÃO (VARIÁVEIS TRANSFORMADAS) (ativada por Ajustar Modelo)
935
+ # ========================================
936
+ header_secao_10 = gr.HTML(criar_header_secao(10, "Gráficos de Dispersão (Variáveis Transformadas)"), visible=True)
937
+ with gr.Accordion(
938
+ label="▼ Mostrar / Ocultar",
939
+ open=True,
940
+ visible=False,
941
+ elem_classes="section-accordion"
942
+ ) as accordion_secao_10:
943
+ with gr.Column(elem_classes="section-content") as conteudo_secao_10:
944
+ dropdown_tipo_grafico_dispersao = gr.Dropdown(
945
+ choices=[
946
+ "Variáveis Independentes Transformadas X Variável Dependente Transformada",
947
+ "Variáveis Independentes Transformadas X Resíduo Padronizado"
948
+ ],
949
+ value="Variáveis Independentes Transformadas X Variável Dependente Transformada",
950
+ label="Tipo de Gráfico",
951
+ interactive=True,
952
+ visible=True,
953
+ elem_id="dropdown_dispersao"
954
+ )
955
+ plot_dispersao_transf = gr.Plot(label="", visible=True)
956
+
957
+ # ========================================
958
+ # SEÇÃO 11: DIAGNÓSTICO DE MODELO (ativada por Ajustar Modelo)
959
+ # ========================================
960
+ header_secao_11 = gr.HTML(criar_header_secao(11, "Diagnóstico de Modelo"), visible=True)
961
+ with gr.Accordion(
962
+ label="▼ Mostrar / Ocultar",
963
+ open=True,
964
+ visible=False,
965
+ elem_classes="section-accordion"
966
+ ) as accordion_secao_11:
967
+ with gr.Group(elem_classes="section-content") as conteudo_secao_11:
968
+ diagnosticos_html = gr.HTML(value="")
969
+
970
+ with gr.Row():
971
+ with gr.Accordion("Tabela de Coeficientes", open=True):
972
+ tabela_coef = gr.Dataframe(
973
+ label="",
974
+ interactive=False,
975
+ max_height=400
976
+ )
977
+ with gr.Row():
978
+ btn_download_coef = gr.Button("Baixar Coeficientes (CSV)", variant="secondary", size="sm")
979
+ download_coef_file = gr.File(label="", visible=False)
980
+
981
+ with gr.Accordion("Valores Observados vs Calculados", open=True):
982
+ tabela_obs_calc = gr.Dataframe(
983
+ label="",
984
+ interactive=False,
985
+ max_height=400
986
+ )
987
+ with gr.Row():
988
+ btn_download_obs_calc = gr.Button("Baixar Obs vs Calc (CSV)", variant="secondary", size="sm")
989
+ download_obs_calc_file = gr.File(label="", visible=False)
990
+
991
+ # ========================================
992
+ # SEÇÃO 12: GRÁFICOS DE DIAGNÓSTICO DO MODELO (ativada por Ajustar Modelo)
993
+ # ========================================
994
+ header_secao_12 = gr.HTML(criar_header_secao(12, "Gráficos de Diagnóstico do Modelo"), visible=True)
995
+ with gr.Accordion(
996
+ label="▼ Mostrar / Ocultar",
997
+ open=True,
998
+ visible=False,
999
+ elem_classes="section-accordion"
1000
+ ) as accordion_secao_12:
1001
+ with gr.Group(elem_classes="section-content") as conteudo_secao_12:
1002
+ with gr.Row():
1003
+ plot_obs_calc = gr.Plot(label="Observados vs Calculados")
1004
+ plot_residuos = gr.Plot(label="Resíduos")
1005
+
1006
+ with gr.Row():
1007
+ plot_hist = gr.Plot(label="Histograma dos Resíduos")
1008
+ plot_cook = gr.Plot(label="Distância de Cook")
1009
+
1010
+ with gr.Row():
1011
+ plot_corr = gr.Plot(label="Matriz de Correlação")
1012
+
1013
+ # ========================================
1014
+ # SEÇÃO 13: ANALISAR OUTLIERS (ativada por Ajustar Modelo)
1015
+ # ========================================
1016
+ # Estado para armazenar o número de filtros visíveis
1017
+ estado_n_filtros = gr.State(2) # Começa com 2 filtros padrão
1018
+
1019
+ # Opções de operadores
1020
+ OPERADORES = ["<=", ">=", "<", ">", "="]
1021
+ # Opções base de variáveis (serão atualizadas dinamicamente)
1022
+ VARIAVEIS_BASE = ["Resíduo Pad.", "Resíduo Stud.", "Cook"]
1023
+
1024
+ header_secao_13 = gr.HTML(criar_header_secao(13, "Analisar Outliers"), visible=True)
1025
+ with gr.Accordion(
1026
+ label="▼ Mostrar / Ocultar",
1027
+ open=True,
1028
+ visible=False,
1029
+ elem_classes="section-accordion"
1030
+ ) as accordion_secao_13:
1031
+ with gr.Group(elem_classes="section-content") as conteudo_secao_13:
1032
+ gr.HTML('<div class="outlier-subheader">Métricas de Outliers</div>')
1033
+ gr.HTML('<div class="outlier-dica">Métricas calculadas com base no modelo ajustado (resíduos com transformações aplicadas)</div>')
1034
+
1035
+ tabela_metricas = gr.Dataframe(
1036
+ label="Métricas para identificação de outliers",
1037
+ interactive=False,
1038
+ max_height=300
1039
+ )
1040
+ with gr.Row():
1041
+ btn_download_metricas = gr.Button("Baixar Métricas (CSV)", variant="secondary", size="sm")
1042
+ download_metricas_file = gr.File(label="", visible=False)
1043
+
1044
+ # ========================================
1045
+ # SEÇÃO 14: EXCLUSÃO DE OUTLIERS (ativada por Ajustar Modelo)
1046
+ # ========================================
1047
+ header_secao_14 = gr.HTML(criar_header_secao(14, "Exclusão ou Reinclusão de Outliers"), visible=True)
1048
+ with gr.Accordion(
1049
+ label="▼ Mostrar / Ocultar",
1050
+ open=True,
1051
+ visible=False,
1052
+ elem_classes="section-accordion"
1053
+ ) as accordion_secao_14:
1054
+ with gr.Group(elem_classes="section-content") as conteudo_secao_14:
1055
+ html_outliers_sec14 = gr.HTML(value="")
1056
+ gr.HTML('<div class="outlier-subheader">Filtrar Outliers</div>')
1057
+ gr.HTML('<div class="outlier-dica">Outliers = linhas que satisfazem QUALQUER filtro (lógica OR / União)</div>')
1058
+
1059
+ # Filtros dinâmicos (máximo 4 filtros)
1060
+ filtro_rows = []
1061
+ filtro_vars = []
1062
+ filtro_ops = []
1063
+ filtro_vals = []
1064
+
1065
+ for i in range(4):
1066
+ visible = i < 2 # Filtros 1 e 2 visíveis por padrão
1067
+ # Só define valores padrão para os filtros visíveis (0 e 1)
1068
+ # Filtros ocultos (2 e 3) começam com None para não serem aplicados
1069
+ if i == 0:
1070
+ valor_padrao = -2.0
1071
+ operador_padrao = "<="
1072
+ var_padrao = "Resíduo Pad."
1073
+ elif i == 1:
1074
+ valor_padrao = 2.0
1075
+ operador_padrao = ">="
1076
+ var_padrao = "Resíduo Pad."
1077
+ else:
1078
+ valor_padrao = None
1079
+ operador_padrao = None
1080
+ var_padrao = None
1081
+
1082
+ with gr.Row(visible=visible, elem_classes="filtro-row") as row:
1083
+ var_dropdown = gr.Dropdown(
1084
+ label=f"Variável {i+1}",
1085
+ choices=VARIAVEIS_BASE,
1086
+ value=var_padrao,
1087
+ scale=2
1088
+ )
1089
+ op_dropdown = gr.Dropdown(
1090
+ label="Operador",
1091
+ choices=OPERADORES,
1092
+ value=operador_padrao,
1093
+ scale=1
1094
+ )
1095
+ val_input = gr.Number(
1096
+ label="Valor",
1097
+ value=valor_padrao,
1098
+ scale=1
1099
+ )
1100
+
1101
+ filtro_rows.append(row)
1102
+ filtro_vars.append(var_dropdown)
1103
+ filtro_ops.append(op_dropdown)
1104
+ filtro_vals.append(val_input)
1105
+
1106
+ with gr.Row(elem_classes="btn-filtro-acao-row"):
1107
+ btn_adicionar_filtro = gr.Button("+", variant="secondary", scale=0, min_width=50, elem_classes="btn-filtro-acao btn-adicionar-filtro")
1108
+ btn_remover_ultimo = gr.Button("−", variant="secondary", scale=0, min_width=50, elem_classes="btn-filtro-acao btn-remover-filtro")
1109
+ btn_resetar_filtros = gr.Button("↺", variant="secondary", scale=0, min_width=50, elem_classes="btn-filtro-acao btn-voltar-padrao")
1110
+
1111
+ with gr.Row():
1112
+ btn_aplicar_filtro = gr.Button("Aplicar Filtros", variant="primary", scale=1)
1113
+
1114
+ gr.HTML('<div class="outlier-divider"><span class="arrow">▼</span></div>')
1115
+ gr.HTML('<div class="outlier-subheader">Confirmar Filtros Selecionados ou Ajustar Manualmente</div>')
1116
+ gr.HTML('<div class="outlier-dica">Edite os índices manualmente ou confirme os resultados dos filtros acima</div>')
1117
+
1118
+ with gr.Row():
1119
+ outliers_texto = gr.Textbox(
1120
+ label="Índices a Excluir",
1121
+ placeholder="Ex: 5, 12, 23",
1122
+ scale=3
1123
+ )
1124
+ reincluir_texto = gr.Textbox(
1125
+ label="Índices a Reincluir",
1126
+ placeholder="Ex: 5, 12",
1127
+ scale=3
1128
+ )
1129
+
1130
+ with gr.Row():
1131
+ txt_resumo_outliers = gr.Textbox(
1132
+ label="Resumo",
1133
+ value="Excluídos: 0 | A excluir: 0 | A reincluir: 0 | Total: 0",
1134
+ interactive=False,
1135
+ elem_classes="resumo-outliers"
1136
+ )
1137
+
1138
+ with gr.Row():
1139
+ btn_reiniciar_iteracao = gr.Button(
1140
+ "Atualizar Modelo (Excluir/Reincluir Outliers)",
1141
+ variant="primary",
1142
+ scale=2,
1143
+ elem_classes="btn-reiniciar-iteracao"
1144
+ )
1145
+ btn_download_base = gr.Button("Baixar Base Tratada (CSV)", variant="secondary", scale=1)
1146
+ download_base_file = gr.File(label="", visible=False)
1147
+
1148
+ # ========================================
1149
+ # SEÇÃO 15: AVALIAÇÃO DE IMÓVEL (ativada por Ajustar Modelo)
1150
+ # ========================================
1151
+ N_COLS_AVAL = 4
1152
+ N_ROWS_AVAL = MAX_VARS_X // N_COLS_AVAL # 5
1153
+
1154
+ header_secao_15 = gr.HTML(criar_header_secao(15, "Avaliação de Imóvel"), visible=True)
1155
+ with gr.Accordion(
1156
+ label="▼ Mostrar / Ocultar",
1157
+ open=True,
1158
+ visible=False,
1159
+ elem_classes="section-accordion"
1160
+ ) as accordion_secao_15:
1161
+ with gr.Group(elem_classes="section-content"):
1162
+ # Grid de inputs (5 rows × 4 cols = 20 inputs pré-criados) — visual de cards
1163
+ aval_rows = []
1164
+ aval_inputs = []
1165
+ with gr.Column(elem_classes="aval-all-cards"):
1166
+ for i in range(N_ROWS_AVAL):
1167
+ with gr.Row(visible=False, elem_classes="aval-cards-row") as aval_row:
1168
+ for j in range(N_COLS_AVAL):
1169
+ inp = gr.Number(label="", visible=False, interactive=True, elem_classes="aval-card")
1170
+ aval_inputs.append(inp)
1171
+ aval_rows.append(aval_row)
1172
+
1173
+ with gr.Row():
1174
+ btn_calcular_avaliacao = gr.Button("Calcular Avaliação", variant="primary", scale=2)
1175
+ btn_limpar_avaliacoes = gr.Button("Limpar Avaliações", variant="secondary", scale=1)
1176
+ with gr.Row():
1177
+ dropdown_base_avaliacao = gr.Dropdown(
1178
+ label="Base p/ comparação",
1179
+ choices=[],
1180
+ value=None,
1181
+ interactive=True,
1182
+ scale=1,
1183
+ )
1184
+
1185
+ resultado_avaliacao_html = gr.HTML("")
1186
+ excluir_aval_trigger = gr.Textbox(
1187
+ label="", elem_id="excluir-aval-elab", container=False,
1188
+ elem_classes="trigger-hidden"
1189
+ )
1190
+
1191
+ with gr.Row():
1192
+ btn_exportar_avaliacoes = gr.Button("Salvar Avaliações em Excel", variant="secondary")
1193
+ download_avaliacoes_file = gr.File(label="", visible=False)
1194
+
1195
+ # ========================================
1196
+ # SEÇÃO 16: EXPORTAR MODELO (ativada por Ajustar Modelo)
1197
+ # ========================================
1198
+ header_secao_16 = gr.HTML(criar_header_secao(16, "Exportar Modelo"), visible=True)
1199
+ with gr.Accordion(
1200
+ label="▼ Mostrar / Ocultar",
1201
+ open=True,
1202
+ visible=False,
1203
+ elem_classes="section-accordion"
1204
+ ) as accordion_secao_16:
1205
+ with gr.Group(elem_classes="section-content") as conteudo_secao_16:
1206
+ with gr.Row():
1207
+ nome_arquivo = gr.Textbox(
1208
+ label="Nome do arquivo",
1209
+ placeholder="modelo_01",
1210
+ scale=2
1211
+ )
1212
+ dropdown_elaborador = gr.Dropdown(
1213
+ label="Elaborador",
1214
+ choices=_avaliadores_nomes,
1215
+ value=None,
1216
+ scale=2
1217
+ )
1218
+ btn_exportar = gr.Button("Exportar .dai", variant="primary", scale=1)
1219
+
1220
+ status_exportar = gr.Textbox(
1221
+ label="Status da exportação",
1222
+ interactive=False
1223
+ )
1224
+
1225
+ download_modelo_file = gr.File(
1226
+ label="",
1227
+ visible=False
1228
+ )
1229
+
1230
+ # ========================================
1231
+ # EVENTOS
1232
+ # ========================================
1233
+
1234
+ # --- Output lists compartilhadas ---
1235
+
1236
+ # CONTRACT: 196 itens — usada por upload.upload e btn_confirmar_aba.click
1237
+ # Se alterar, atualizar: carregamento.py (5 funções retornam 196 itens)
1238
+ _outputs_carregar = [
1239
+ estado_df,
1240
+ status,
1241
+ dropdown_aba,
1242
+ dropdown_y,
1243
+ tabela_dados,
1244
+ tabela_estatisticas,
1245
+ checkboxes_x,
1246
+ mapa_html,
1247
+ estado_df_filtrado,
1248
+ estado_outliers_anteriores,
1249
+ estado_iteracao,
1250
+ accordion_outliers_anteriores,
1251
+ html_outliers_anteriores,
1252
+ estado_arquivo_temp,
1253
+ # Seções 2 e 3
1254
+ header_secao_2,
1255
+ accordion_secao_2,
1256
+ dropdown_mapa_var,
1257
+ header_secao_3,
1258
+ accordion_secao_3,
1259
+ # Reset seções 4-16 (states)
1260
+ estado_modelo, estado_metricas, estado_resultados_busca, estado_avaliacoes,
1261
+ # Headers, accordions e conteúdo das seções 4-16
1262
+ header_secao_4, accordion_secao_4, checkboxes_dicotomicas, checkboxes_codigo_alocado, checkboxes_percentuais,
1263
+ html_aviso_multicolinearidade,
1264
+ header_secao_5, accordion_secao_5,
1265
+ header_secao_6, accordion_secao_6, html_micronumerosidade,
1266
+ header_secao_7, accordion_secao_7, plot_dispersao,
1267
+ header_secao_8, accordion_secao_8, slider_grau_coef, slider_grau_f, busca_html,
1268
+ header_secao_9, accordion_secao_9, transformacao_y,
1269
+ btn_adotar_1, btn_adotar_2, btn_adotar_3, btn_adotar_4, btn_adotar_5,
1270
+ ] + [transf_y_row, transf_y_col, transf_y_label] + transf_x_rows + transf_x_columns + transf_x_labels + transf_x_dropdowns + [
1271
+ header_secao_10, accordion_secao_10, dropdown_tipo_grafico_dispersao, plot_dispersao_transf,
1272
+ header_secao_11, accordion_secao_11, diagnosticos_html, tabela_coef, tabela_obs_calc,
1273
+ header_secao_12, accordion_secao_12, plot_obs_calc, plot_residuos, plot_hist, plot_cook, plot_corr,
1274
+ header_secao_13, accordion_secao_13, tabela_metricas,
1275
+ header_secao_14, accordion_secao_14, html_outliers_sec14, outliers_texto, reincluir_texto, txt_resumo_outliers,
1276
+ # Seção 15: Avaliação de Imóvel
1277
+ header_secao_15, accordion_secao_15,
1278
+ ] + aval_rows + aval_inputs + [
1279
+ resultado_avaliacao_html, dropdown_base_avaliacao, excluir_aval_trigger, download_avaliacoes_file,
1280
+ # Seção 16: Exportar Modelo
1281
+ header_secao_16, accordion_secao_16, nome_arquivo, status_exportar,
1282
+ # Controles de visibilidade da seção 1 (pós-carregamento)
1283
+ row_upload, row_selecao_aba, row_pos_carga, html_nome_arquivo_carregado,
1284
+ # Flag para evitar que dropdown_y.change() sobrescreva valores durante carregamento
1285
+ estado_flag_carregamento,
1286
+ ] + filtro_vars + [ # 4 dropdowns de variável dos filtros (choices atualizados no fluxo .dai)
1287
+ # Painel de coordenadas (Seção 1, Estado D) — itens 184-192
1288
+ row_coords_panel, # 184
1289
+ html_aviso_coords, # 185
1290
+ dropdown_col_lat_manual, # 186
1291
+ dropdown_col_lon_manual, # 187
1292
+ dropdown_cdlog_geo, # 188
1293
+ dropdown_num_geo, # 189
1294
+ row_escolha_opcao, # 190
1295
+ row_opcao_mapear, # 191
1296
+ row_opcao_geocodificar, # 192
1297
+ ]
1298
+
1299
+ # CONTRACT: 33 itens — usada por btn_ajustar.click e btn_reiniciar_iteracao.then
1300
+ # Se alterar, atualizar: modelo.py (ajustar_modelo_callback retorna 33 itens)
1301
+ _outputs_ajustar = [
1302
+ estado_modelo,
1303
+ diagnosticos_html,
1304
+ tabela_coef,
1305
+ tabela_obs_calc,
1306
+ plot_dispersao_transf,
1307
+ dropdown_tipo_grafico_dispersao,
1308
+ plot_obs_calc,
1309
+ plot_residuos,
1310
+ plot_hist,
1311
+ plot_cook,
1312
+ plot_corr,
1313
+ tabela_metricas,
1314
+ estado_metricas,
1315
+ txt_resumo_outliers,
1316
+ estado_avaliacoes,
1317
+ # Seções 10-16
1318
+ header_secao_10, accordion_secao_10,
1319
+ header_secao_11, accordion_secao_11,
1320
+ header_secao_12, accordion_secao_12,
1321
+ header_secao_13, accordion_secao_13,
1322
+ header_secao_14, accordion_secao_14,
1323
+ header_secao_15, accordion_secao_15,
1324
+ header_secao_16, accordion_secao_16,
1325
+ ] + filtro_vars
1326
+
1327
+ # CONTRACT: 27 itens — usada por .then() após ajustar/reiniciar para popular campos de avaliação
1328
+ _outputs_popular_avaliacao = aval_rows + aval_inputs + [resultado_avaliacao_html, dropdown_base_avaliacao]
1329
+
1330
+ # --- Inputs compartilhados para ajustar modelo ---
1331
+ _inputs_ajustar = [
1332
+ estado_df_filtrado, estado_df, dropdown_y, checkboxes_x,
1333
+ transformacao_y, estado_outliers_anteriores, checkboxes_dicotomicas, checkboxes_codigo_alocado, checkboxes_percentuais
1334
+ ] + transf_x_dropdowns
1335
+
1336
+ # Upload de arquivo
1337
+ upload.upload(
1338
+ ao_carregar_arquivo,
1339
+ inputs=[upload],
1340
+ outputs=_outputs_carregar
1341
+ ).then(
1342
+ popular_campos_avaliacao_callback,
1343
+ inputs=[estado_modelo, tabela_estatisticas],
1344
+ outputs=_outputs_popular_avaliacao
1345
+ ).then(
1346
+ popular_dicotomicas_callback,
1347
+ inputs=[estado_modelo, checkboxes_x, estado_df],
1348
+ outputs=[checkboxes_dicotomicas, checkboxes_codigo_alocado, checkboxes_percentuais]
1349
+ )
1350
+
1351
+ # Confirmação de aba (para Excel multi-aba)
1352
+ btn_confirmar_aba.click(
1353
+ confirmar_aba_callback,
1354
+ inputs=[estado_arquivo_temp, dropdown_aba],
1355
+ outputs=_outputs_carregar
1356
+ ).then(
1357
+ popular_campos_avaliacao_callback,
1358
+ inputs=[estado_modelo, tabela_estatisticas],
1359
+ outputs=_outputs_popular_avaliacao
1360
+ ).then(
1361
+ popular_dicotomicas_callback,
1362
+ inputs=[estado_modelo, checkboxes_x, estado_df],
1363
+ outputs=[checkboxes_dicotomicas, checkboxes_codigo_alocado, checkboxes_percentuais]
1364
+ )
1365
+
1366
+ # Reiniciar aplicação (reload da página)
1367
+ btn_reiniciar_app.click(
1368
+ fn=None,
1369
+ inputs=None,
1370
+ outputs=None,
1371
+ js="() => { window.location.reload(); }"
1372
+ )
1373
+
1374
+ # ---- Painel de coordenadas (Seção 1, Estado D) ----
1375
+
1376
+ # Tela de escolha → entrar no painel Mapear
1377
+ btn_escolher_mapear.click(
1378
+ fn=lambda: (gr.update(visible=False), gr.update(visible=True)),
1379
+ outputs=[row_escolha_opcao, row_opcao_mapear],
1380
+ )
1381
+
1382
+ # Tela de escolha → entrar no painel Geocodificar
1383
+ btn_escolher_geocodificar.click(
1384
+ fn=lambda: (gr.update(visible=False), gr.update(visible=True)),
1385
+ outputs=[row_escolha_opcao, row_opcao_geocodificar],
1386
+ )
1387
+
1388
+ # Voltar do painel Mapear (limpa erro, reseta confirmação, volta à escolha)
1389
+ btn_voltar_mapear.click(
1390
+ fn=lambda: (gr.update(visible=False), gr.update(visible=True), gr.update(value=""), gr.update(visible=False)),
1391
+ outputs=[row_opcao_mapear, row_escolha_opcao, html_erro_mapeamento, row_confirmacao_prosseguir],
1392
+ )
1393
+
1394
+ # Voltar do painel Geocodificar (reset completo do estado de geocodificação — 68 itens)
1395
+ N = MAX_FALHAS_GEO
1396
+ btn_voltar_geocodificar.click(
1397
+ fn=lambda: (
1398
+ None, None, "",
1399
+ *[gr.update(visible=False)] * N,
1400
+ *[gr.update(value="")] * N,
1401
+ *[gr.update(value=None)] * N,
1402
+ gr.update(visible=False), gr.update(visible=False),
1403
+ gr.update(visible=False),
1404
+ gr.update(visible=False), gr.update(visible=True),
1405
+ ),
1406
+ outputs=(
1407
+ [estado_geo_temp, estado_df_falhas, html_geo_status]
1408
+ + falha_rows + falha_htmls + falha_inputs
1409
+ + [btn_aplicar_correcoes_geo, btn_usar_coords_geo,
1410
+ row_confirmacao_prosseguir,
1411
+ row_opcao_geocodificar, row_escolha_opcao]
1412
+ ),
1413
+ )
1414
+
1415
+ # Opção 1: confirmar mapeamento manual de colunas
1416
+ btn_confirmar_mapeamento.click(
1417
+ fn=confirmar_mapeamento_callback,
1418
+ inputs=[estado_df, dropdown_col_lat_manual, dropdown_col_lon_manual],
1419
+ outputs=[
1420
+ html_erro_mapeamento,
1421
+ estado_df, estado_df_filtrado, mapa_html,
1422
+ row_coords_panel, header_secao_2, accordion_secao_2,
1423
+ header_secao_3, accordion_secao_3, status,
1424
+ ]
1425
+ )
1426
+
1427
+ # Opção 3: geocodificar por eixos (CONTRACT: 67 itens)
1428
+ _outputs_geo = (
1429
+ [estado_geo_temp, estado_df_falhas, html_geo_status]
1430
+ + falha_rows + falha_htmls + falha_inputs
1431
+ + [btn_aplicar_correcoes_geo, btn_usar_coords_geo]
1432
+ )
1433
+
1434
+ btn_geocodificar.click(
1435
+ fn=geocodificar_callback,
1436
+ inputs=[estado_df, dropdown_cdlog_geo, dropdown_num_geo, checkbox_auto_200],
1437
+ outputs=_outputs_geo,
1438
+ show_progress="full",
1439
+ )
1440
+
1441
+ btn_aplicar_correcoes_geo.click(
1442
+ fn=aplicar_correcoes_geo_callback,
1443
+ inputs=(
1444
+ [estado_geo_temp, estado_df_falhas]
1445
+ + falha_inputs
1446
+ + [dropdown_cdlog_geo, dropdown_num_geo, checkbox_auto_200]
1447
+ ),
1448
+ outputs=_outputs_geo,
1449
+ show_progress="full",
1450
+ )
1451
+
1452
+ btn_usar_coords_geo.click(
1453
+ fn=confirmar_geocodificacao_callback,
1454
+ inputs=[estado_geo_temp],
1455
+ outputs=[
1456
+ estado_df, estado_df_filtrado, mapa_html,
1457
+ row_coords_panel, header_secao_2, accordion_secao_2,
1458
+ header_secao_3, accordion_secao_3, status,
1459
+ ]
1460
+ )
1461
+
1462
+ # Prosseguir sem coordenadas completas (dois cliques: exibe aviso → confirma)
1463
+ btn_prosseguir_sem_coords.click(
1464
+ fn=lambda: gr.update(visible=True),
1465
+ outputs=[row_confirmacao_prosseguir]
1466
+ )
1467
+
1468
+ btn_confirmar_prosseguir.click(
1469
+ fn=lambda geo_df, orig_df: confirmar_sem_coords_callback(geo_df if geo_df is not None else orig_df),
1470
+ inputs=[estado_geo_temp, estado_df],
1471
+ outputs=[
1472
+ estado_df, estado_df_filtrado, mapa_html,
1473
+ row_coords_panel, header_secao_2, accordion_secao_2,
1474
+ header_secao_3, accordion_secao_3, status,
1475
+ ]
1476
+ )
1477
+
1478
+ # Mudança de y (NÃO mostra seção 4, NÃO atualiza estatísticas - só atualiza checkboxes)
1479
+ dropdown_y.change(
1480
+ ao_mudar_y_sem_estatisticas,
1481
+ inputs=[estado_df, dropdown_y, estado_flag_carregamento],
1482
+ outputs=[checkboxes_x, header_secao_4, accordion_secao_4, estado_flag_carregamento]
1483
+ ).then(
1484
+ popular_dicotomicas_callback,
1485
+ inputs=[estado_modelo, checkboxes_x, estado_df],
1486
+ outputs=[checkboxes_dicotomicas, checkboxes_codigo_alocado, checkboxes_percentuais]
1487
+ )
1488
+
1489
+ # Selecionar/Desselecionar todos via checkbox
1490
+ def toggle_selecionar_todos(selecionar, df, coluna_y):
1491
+ """Marca ou desmarca todas as variáveis independentes."""
1492
+ if selecionar:
1493
+ # Marcar todos
1494
+ colunas_x = [col for col in obter_colunas_numericas(df) if col != coluna_y] if df is not None and coluna_y else []
1495
+ return gr.update(value=colunas_x)
1496
+ else:
1497
+ # Desmarcar todos
1498
+ return gr.update(value=[])
1499
+
1500
+ checkbox_selecionar_todos.change(
1501
+ toggle_selecionar_todos,
1502
+ inputs=[checkbox_selecionar_todos, estado_df, dropdown_y],
1503
+ outputs=[checkboxes_x]
1504
+ )
1505
+
1506
+ # Clique na tabela -> atualiza mapa
1507
+ tabela_dados.select(
1508
+ ao_clicar_tabela,
1509
+ inputs=[estado_df, dropdown_mapa_var],
1510
+ outputs=[mapa_html]
1511
+ )
1512
+
1513
+ # Mudança de variável no dropdown do mapa -> atualiza mapa
1514
+ dropdown_mapa_var.input(
1515
+ atualizar_mapa_callback,
1516
+ inputs=[estado_df_filtrado, estado_df, dropdown_mapa_var],
1517
+ outputs=[mapa_html]
1518
+ )
1519
+
1520
+ # Seleção de X -> atualiza campos de transformação (preview)
1521
+ checkboxes_x.change(
1522
+ _atualizar_campos_transformacoes_com_flag,
1523
+ inputs=[estado_df, checkboxes_x, estado_flag_carregamento, dropdown_y],
1524
+ outputs=[transf_y_row, transf_y_col, transf_y_label] + transf_x_rows + transf_x_columns + transf_x_labels + transf_x_dropdowns + [estado_flag_carregamento, checkboxes_dicotomicas, checkboxes_codigo_alocado, checkboxes_percentuais]
1525
+ )
1526
+
1527
+ # Mudança de qualquer checkbox de tipo -> atualiza interactive dos dropdowns de transformação
1528
+ _inputs_interativo = [checkboxes_x, checkboxes_dicotomicas, checkboxes_codigo_alocado, checkboxes_percentuais, estado_df]
1529
+ checkboxes_dicotomicas.change(
1530
+ atualizar_interativo_dicotomicas,
1531
+ inputs=_inputs_interativo,
1532
+ outputs=transf_x_dropdowns
1533
+ )
1534
+ checkboxes_codigo_alocado.change(
1535
+ atualizar_interativo_dicotomicas,
1536
+ inputs=_inputs_interativo,
1537
+ outputs=transf_x_dropdowns
1538
+ )
1539
+ checkboxes_percentuais.change(
1540
+ atualizar_interativo_dicotomicas,
1541
+ inputs=_inputs_interativo,
1542
+ outputs=transf_x_dropdowns
1543
+ )
1544
+
1545
+ # Aplicar seleção de Y -> atualiza X disponíveis e MOSTRA seção 4 (NÃO atualiza estatísticas)
1546
+ btn_aplicar_y.click(
1547
+ lambda df, y: ao_mudar_y_sem_estatisticas(df, y, mostrar_secao_x=True),
1548
+ inputs=[estado_df, dropdown_y],
1549
+ outputs=[checkboxes_x, header_secao_4, accordion_secao_4, estado_flag_carregamento]
1550
+ ).then(
1551
+ popular_dicotomicas_callback,
1552
+ inputs=[estado_modelo, checkboxes_x, estado_df],
1553
+ outputs=[checkboxes_dicotomicas, checkboxes_codigo_alocado, checkboxes_percentuais]
1554
+ )
1555
+
1556
+ # Aplicar seleção de variáveis X -> atualiza estatísticas, busca transformações, dispersão e micronumerosidade
1557
+ btn_aplicar_selecao_x.click(
1558
+ aplicar_selecao_callback,
1559
+ inputs=[estado_df, dropdown_y, checkboxes_x, estado_outliers_anteriores, checkboxes_dicotomicas, checkboxes_codigo_alocado, checkboxes_percentuais],
1560
+ outputs=[
1561
+ estado_df_filtrado,
1562
+ tabela_estatisticas,
1563
+ html_micronumerosidade,
1564
+ plot_dispersao,
1565
+ slider_grau_coef, slider_grau_f,
1566
+ busca_html,
1567
+ estado_resultados_busca,
1568
+ btn_adotar_1, btn_adotar_2, btn_adotar_3, btn_adotar_4, btn_adotar_5,
1569
+ # Seções 5-9
1570
+ header_secao_5, accordion_secao_5,
1571
+ header_secao_6, accordion_secao_6,
1572
+ header_secao_7, accordion_secao_7,
1573
+ header_secao_8, accordion_secao_8,
1574
+ header_secao_9, accordion_secao_9,
1575
+ ] + [transf_y_row, transf_y_col, transf_y_label] + transf_x_rows + transf_x_columns + transf_x_labels + transf_x_dropdowns + [html_aviso_multicolinearidade]
1576
+ )
1577
+
1578
+ # Re-executar busca ao alterar grau (modo manual, sem fallback)
1579
+ def _rebuscar_transformacoes(df, coluna_y, colunas_x, dicotomicas, codigo_alocado, percentuais, grau_coef, grau_f):
1580
+ if df is None or coluna_y is None or not colunas_x:
1581
+ return (gr.update(), gr.update(), *[gr.update()] * 5)
1582
+ html, resultados, _, *btn_updates = buscar_transformacoes_callback(
1583
+ df, coluna_y, colunas_x, dicotomicas, codigo_alocado, percentuais, int(grau_coef), int(grau_f)
1584
+ )
1585
+ return (html, resultados, *btn_updates)
1586
+
1587
+ _inputs_rebuscar = [estado_df_filtrado, dropdown_y, checkboxes_x, checkboxes_dicotomicas, checkboxes_codigo_alocado, checkboxes_percentuais, slider_grau_coef, slider_grau_f]
1588
+ _outputs_rebuscar = [busca_html, estado_resultados_busca,
1589
+ btn_adotar_1, btn_adotar_2, btn_adotar_3, btn_adotar_4, btn_adotar_5]
1590
+
1591
+ slider_grau_coef.change(
1592
+ _rebuscar_transformacoes,
1593
+ inputs=_inputs_rebuscar,
1594
+ outputs=_outputs_rebuscar
1595
+ )
1596
+
1597
+ slider_grau_f.change(
1598
+ _rebuscar_transformacoes,
1599
+ inputs=_inputs_rebuscar,
1600
+ outputs=_outputs_rebuscar
1601
+ )
1602
+
1603
+ # Aplicar filtros de outliers
1604
+ btn_aplicar_filtro.click(
1605
+ aplicar_filtros_callback,
1606
+ inputs=[estado_metricas, estado_n_filtros] + filtro_vars + filtro_ops + filtro_vals,
1607
+ outputs=[outliers_texto]
1608
+ )
1609
+
1610
+ # Adicionar filtro
1611
+ btn_adicionar_filtro.click(
1612
+ adicionar_filtro_callback,
1613
+ inputs=[estado_n_filtros],
1614
+ outputs=[estado_n_filtros] + filtro_rows
1615
+ )
1616
+
1617
+ # Remover último filtro
1618
+ btn_remover_ultimo.click(
1619
+ remover_ultimo_filtro_callback,
1620
+ inputs=[estado_n_filtros],
1621
+ outputs=[estado_n_filtros] + filtro_rows
1622
+ )
1623
+
1624
+ # Resetar filtros ao padrão
1625
+ btn_resetar_filtros.click(
1626
+ limpar_filtros_callback,
1627
+ inputs=[],
1628
+ outputs=[estado_n_filtros] + filtro_rows + filtro_vars + filtro_ops + filtro_vals + [outliers_texto]
1629
+ )
1630
+
1631
+ # Atualizar resumo de outliers quando usuário edita os campos
1632
+ outliers_texto.change(
1633
+ atualizar_resumo_outliers,
1634
+ inputs=[estado_outliers_anteriores, outliers_texto, reincluir_texto],
1635
+ outputs=[txt_resumo_outliers]
1636
+ )
1637
+
1638
+ reincluir_texto.change(
1639
+ atualizar_resumo_outliers,
1640
+ inputs=[estado_outliers_anteriores, outliers_texto, reincluir_texto],
1641
+ outputs=[txt_resumo_outliers]
1642
+ )
1643
+
1644
+ # Aplicar filtro também atualiza o resumo
1645
+ btn_aplicar_filtro.click(
1646
+ atualizar_resumo_outliers,
1647
+ inputs=[estado_outliers_anteriores, outliers_texto, reincluir_texto],
1648
+ outputs=[txt_resumo_outliers]
1649
+ )
1650
+
1651
+ # Download da base tratada (CSV)
1652
+ def download_base_callback(df_filtrado, df_original):
1653
+ """Callback para download da base tratada."""
1654
+ # Usa df_filtrado se disponível, senão usa df_original
1655
+ df_para_exportar = df_filtrado if df_filtrado is not None else df_original
1656
+
1657
+ if df_para_exportar is None:
1658
+ return gr.update(value=None, visible=False)
1659
+
1660
+ try:
1661
+ if hasattr(df_para_exportar, 'empty') and df_para_exportar.empty:
1662
+ return gr.update(value=None, visible=False)
1663
+
1664
+ caminho = exportar_base_csv(df_para_exportar)
1665
+ if caminho:
1666
+ return gr.update(value=caminho, visible=True)
1667
+ else:
1668
+ return gr.update(value=None, visible=False)
1669
+ except Exception as e:
1670
+ print(f"Erro ao exportar CSV: {e}")
1671
+ return gr.update(value=None, visible=False)
1672
+
1673
+ btn_download_base.click(
1674
+ download_base_callback,
1675
+ inputs=[estado_df_filtrado, estado_df],
1676
+ outputs=[download_base_file]
1677
+ )
1678
+
1679
+ # Botões "Adotar Sugestão"
1680
+ btn_adotar_1.click(
1681
+ lambda res, cols_x: adotar_sugestao(0, res, cols_x),
1682
+ inputs=[estado_resultados_busca, checkboxes_x],
1683
+ outputs=[transformacao_y] + transf_x_dropdowns
1684
+ )
1685
+ btn_adotar_2.click(
1686
+ lambda res, cols_x: adotar_sugestao(1, res, cols_x),
1687
+ inputs=[estado_resultados_busca, checkboxes_x],
1688
+ outputs=[transformacao_y] + transf_x_dropdowns
1689
+ )
1690
+ btn_adotar_3.click(
1691
+ lambda res, cols_x: adotar_sugestao(2, res, cols_x),
1692
+ inputs=[estado_resultados_busca, checkboxes_x],
1693
+ outputs=[transformacao_y] + transf_x_dropdowns
1694
+ )
1695
+ btn_adotar_4.click(
1696
+ lambda res, cols_x: adotar_sugestao(3, res, cols_x),
1697
+ inputs=[estado_resultados_busca, checkboxes_x],
1698
+ outputs=[transformacao_y] + transf_x_dropdowns
1699
+ )
1700
+ btn_adotar_5.click(
1701
+ lambda res, cols_x: adotar_sugestao(4, res, cols_x),
1702
+ inputs=[estado_resultados_busca, checkboxes_x],
1703
+ outputs=[transformacao_y] + transf_x_dropdowns
1704
+ )
1705
+
1706
+ # Ajustar modelo (usa dados filtrados se disponíveis) e calcula métricas de outliers
1707
+ dropdown_tipo_grafico_dispersao.change(
1708
+ fn=ao_mudar_tipo_grafico,
1709
+ inputs=[dropdown_tipo_grafico_dispersao, estado_modelo],
1710
+ outputs=[plot_dispersao_transf]
1711
+ )
1712
+
1713
+ btn_ajustar.click(
1714
+ lambda df_filt, df_orig, col_y, cols_x, transf_y, outliers_ant, dicot, cod_aloc, perc, *dd_vals: ajustar_modelo_callback(
1715
+ df_filt if df_filt is not None else df_orig, col_y, cols_x, transf_y, outliers_ant, dicot, cod_aloc, perc, *dd_vals
1716
+ ),
1717
+ inputs=_inputs_ajustar,
1718
+ outputs=_outputs_ajustar
1719
+ ).then(
1720
+ popular_campos_avaliacao_callback,
1721
+ inputs=[estado_modelo, tabela_estatisticas],
1722
+ outputs=_outputs_popular_avaliacao
1723
+ )
1724
+
1725
+ # Reiniciar iteração (combina outliers e recomeça) e depois ajusta o modelo
1726
+ btn_reiniciar_iteracao.click(
1727
+ reiniciar_iteracao_callback,
1728
+ inputs=[
1729
+ estado_df, estado_outliers_anteriores, outliers_texto, reincluir_texto,
1730
+ estado_iteracao, dropdown_y, checkboxes_x, checkboxes_dicotomicas, checkboxes_codigo_alocado, checkboxes_percentuais, slider_grau_coef, slider_grau_f
1731
+ ],
1732
+ outputs=[
1733
+ estado_outliers_anteriores,
1734
+ estado_iteracao,
1735
+ estado_df_filtrado,
1736
+ tabela_dados,
1737
+ tabela_estatisticas,
1738
+ header_secao_5,
1739
+ html_outliers_anteriores,
1740
+ html_outliers_sec14,
1741
+ accordion_outliers_anteriores,
1742
+ outliers_texto,
1743
+ reincluir_texto,
1744
+ tabela_metricas,
1745
+ estado_metricas,
1746
+ header_secao_13,
1747
+ txt_resumo_outliers,
1748
+ mapa_html,
1749
+ # Novos outputs para seções 2, 6, 7, 8
1750
+ header_secao_2,
1751
+ html_micronumerosidade,
1752
+ header_secao_6,
1753
+ plot_dispersao,
1754
+ header_secao_7,
1755
+ busca_html,
1756
+ estado_resultados_busca,
1757
+ header_secao_8,
1758
+ btn_adotar_1, btn_adotar_2, btn_adotar_3, btn_adotar_4, btn_adotar_5,
1759
+ slider_grau_coef,
1760
+ slider_grau_f,
1761
+ ]
1762
+ ).then(
1763
+ # Após reiniciar, ajusta modelo automaticamente para calcular métricas de outliers
1764
+ lambda df_filt, df_orig, col_y, cols_x, transf_y, outliers_ant, dicot, cod_aloc, perc, *dd_vals: ajustar_modelo_callback(
1765
+ df_filt if df_filt is not None else df_orig, col_y, cols_x, transf_y, outliers_ant, dicot, cod_aloc, perc, *dd_vals
1766
+ ),
1767
+ inputs=_inputs_ajustar,
1768
+ outputs=_outputs_ajustar
1769
+ ).then(
1770
+ popular_campos_avaliacao_callback,
1771
+ inputs=[estado_modelo, tabela_estatisticas],
1772
+ outputs=_outputs_popular_avaliacao
1773
+ ).then(
1774
+ fn=None,
1775
+ inputs=None,
1776
+ outputs=None,
1777
+ js="""() => {
1778
+ if ('parentIFrame' in window) {
1779
+ window.parentIFrame.scrollTo({top: 0, behavior: 'smooth'});
1780
+ } else {
1781
+ window.scrollTo({top: 0, behavior: 'smooth'});
1782
+ }
1783
+ }"""
1784
+ )
1785
+
1786
+ # Limpar histórico de outliers
1787
+ btn_limpar_historico.click(
1788
+ limpar_historico_callback,
1789
+ inputs=[estado_df],
1790
+ outputs=[
1791
+ estado_outliers_anteriores,
1792
+ estado_iteracao,
1793
+ estado_df_filtrado,
1794
+ tabela_dados,
1795
+ tabela_estatisticas,
1796
+ mapa_html,
1797
+ html_outliers_anteriores,
1798
+ accordion_outliers_anteriores,
1799
+ header_secao_2,
1800
+ # Reset seções 4-16 (states)
1801
+ estado_modelo, estado_metricas, estado_resultados_busca, estado_avaliacoes,
1802
+ # Headers, accordions e conteúdo das seções 4-16
1803
+ header_secao_4, accordion_secao_4, checkboxes_dicotomicas, checkboxes_codigo_alocado, checkboxes_percentuais,
1804
+ html_aviso_multicolinearidade,
1805
+ header_secao_5, accordion_secao_5,
1806
+ header_secao_6, accordion_secao_6, html_micronumerosidade,
1807
+ header_secao_7, accordion_secao_7, plot_dispersao,
1808
+ header_secao_8, accordion_secao_8, slider_grau_coef, slider_grau_f, busca_html,
1809
+ header_secao_9, accordion_secao_9, transformacao_y,
1810
+ btn_adotar_1, btn_adotar_2, btn_adotar_3, btn_adotar_4, btn_adotar_5,
1811
+ ] + transf_x_rows + transf_x_columns + transf_x_labels + transf_x_dropdowns + [
1812
+ header_secao_10, accordion_secao_10, dropdown_tipo_grafico_dispersao, plot_dispersao_transf,
1813
+ header_secao_11, accordion_secao_11, diagnosticos_html, tabela_coef, tabela_obs_calc,
1814
+ header_secao_12, accordion_secao_12, plot_obs_calc, plot_residuos, plot_hist, plot_cook, plot_corr,
1815
+ header_secao_13, accordion_secao_13, tabela_metricas,
1816
+ header_secao_14, accordion_secao_14, html_outliers_sec14, outliers_texto, reincluir_texto, txt_resumo_outliers,
1817
+ # Seção 15: Avaliação de Imóvel
1818
+ header_secao_15, accordion_secao_15,
1819
+ ] + aval_rows + aval_inputs + [
1820
+ resultado_avaliacao_html, dropdown_base_avaliacao, excluir_aval_trigger, download_avaliacoes_file,
1821
+ # Seção 16: Exportar Modelo
1822
+ header_secao_16, accordion_secao_16, nome_arquivo, status_exportar,
1823
+ ] + filtro_vars
1824
+ )
1825
+
1826
+ # Exportar (usa dados filtrados se disponíveis)
1827
+ btn_exportar.click(
1828
+ lambda res_modelo, df_filt, df_orig, stats, nome, elab_nome, outliers: exportar_modelo_callback(
1829
+ res_modelo, df_filt if df_filt is not None else df_orig, df_orig, stats, nome,
1830
+ _avaliadores_dict.get(elab_nome) if elab_nome else None,
1831
+ outliers or []
1832
+ ),
1833
+ inputs=[estado_modelo, estado_df_filtrado, estado_df, tabela_estatisticas, nome_arquivo, dropdown_elaborador, estado_outliers_anteriores],
1834
+ outputs=[status_exportar, download_modelo_file]
1835
+ )
1836
+
1837
+ # Avaliação de imóvel (seção 15)
1838
+ btn_calcular_avaliacao.click(
1839
+ avaliar_imovel_callback,
1840
+ inputs=[estado_modelo, tabela_estatisticas, estado_avaliacoes, dropdown_base_avaliacao] + aval_inputs,
1841
+ outputs=[resultado_avaliacao_html, estado_avaliacoes, dropdown_base_avaliacao]
1842
+ )
1843
+
1844
+ btn_limpar_avaliacoes.click(
1845
+ limpar_avaliacoes_callback,
1846
+ inputs=[],
1847
+ outputs=[resultado_avaliacao_html, estado_avaliacoes, dropdown_base_avaliacao]
1848
+ )
1849
+
1850
+ excluir_aval_trigger.change(
1851
+ excluir_avaliacao_callback,
1852
+ inputs=[excluir_aval_trigger, estado_avaliacoes, dropdown_base_avaliacao],
1853
+ outputs=[resultado_avaliacao_html, estado_avaliacoes, dropdown_base_avaliacao, excluir_aval_trigger]
1854
+ )
1855
+
1856
+ dropdown_base_avaliacao.change(
1857
+ atualizar_base_avaliacao_callback,
1858
+ inputs=[estado_avaliacoes, dropdown_base_avaliacao],
1859
+ outputs=[resultado_avaliacao_html]
1860
+ )
1861
+
1862
+ btn_exportar_avaliacoes.click(
1863
+ exportar_avaliacoes_excel_callback,
1864
+ inputs=[estado_avaliacoes],
1865
+ outputs=[download_avaliacoes_file]
1866
+ )
1867
+
1868
+ # Downloads de tabelas
1869
+ btn_download_estatisticas.click(
1870
+ lambda df: download_tabela_callback(df, "estatisticas"),
1871
+ inputs=[tabela_estatisticas],
1872
+ outputs=[download_estatisticas_file]
1873
+ )
1874
+
1875
+ btn_download_dados.click(
1876
+ lambda df: download_tabela_callback(df, "dados"),
1877
+ inputs=[tabela_dados],
1878
+ outputs=[download_dados_file]
1879
+ )
1880
+
1881
+ btn_download_coef.click(
1882
+ lambda df: download_tabela_callback(df, "coeficientes"),
1883
+ inputs=[tabela_coef],
1884
+ outputs=[download_coef_file]
1885
+ )
1886
+
1887
+ btn_download_obs_calc.click(
1888
+ lambda df: download_tabela_callback(df, "obs_calc"),
1889
+ inputs=[tabela_obs_calc],
1890
+ outputs=[download_obs_calc_file]
1891
+ )
1892
+
1893
+ btn_download_metricas.click(
1894
+ lambda df: download_tabela_callback(df, "metricas"),
1895
+ inputs=[tabela_metricas],
1896
+ outputs=[download_metricas_file]
1897
+ )
1898
+
1899
+
1900
+
1901
+ # ============================================================
1902
+ # MAIN
1903
+ # ============================================================
1904
+
1905
+ if __name__ == "__main__":
1906
+ css = carregar_css()
1907
+ with gr.Blocks(title="Elaboração de Modelos", css=css) as app:
1908
+ gr.Markdown(TITULO)
1909
+ criar_aba()
1910
+ app.queue().launch()
backend/app/core/elaboracao/avaliadores.json ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "avaliadores": [
3
+ {
4
+ "nome_completo": "David Schuch Bertoglio",
5
+ "titulo": "Coordenador da Equipe de Suporte, Judiciais e Locações",
6
+ "cargo": "Engenheiro",
7
+ "conselho": "CREA",
8
+ "numero_conselho": "114.44",
9
+ "estado_conselho": "RS",
10
+ "matricula_sem_digito": "95654801",
11
+ "lotacao": "SMF/RM/DAI/ESJL"
12
+ },
13
+ {
14
+ "nome_completo": "Jéssica Lange",
15
+ "titulo": "Auxiliar da Equipe de Avaliações",
16
+ "cargo": "Arquiteta",
17
+ "conselho": "CAU",
18
+ "numero_conselho": "A64935-0",
19
+ "estado_conselho": "RS",
20
+ "matricula_sem_digito": "1279688",
21
+ "lotacao": "EAV/DAI/RM/SMF"
22
+ },
23
+ {
24
+ "nome_completo": "Debora Fonseca Alves",
25
+ "titulo": "Auxiliar da Equipe de Avaliações",
26
+ "cargo": "Engenheira",
27
+ "conselho": "CREA",
28
+ "numero_conselho": "190.257",
29
+ "estado_conselho": "RS",
30
+ "matricula_sem_digito": "1507850",
31
+ "lotacao": "SMF/RM/DAI/EAV"
32
+ },
33
+ {
34
+ "nome_completo": "Vanessa Staats Basso",
35
+ "titulo": "Auxiliar da Equipe de Avaliações",
36
+ "cargo": "Engenheira",
37
+ "conselho": "CREA",
38
+ "numero_conselho": "194.957",
39
+ "estado_conselho": "RS",
40
+ "matricula_sem_digito": "1507540",
41
+ "lotacao": "SMF/RM/DAI/EAV"
42
+ },
43
+ {
44
+ "nome_completo": "Fernanda Pontel",
45
+ "titulo": "Auxiliar da Equipe de Suporte, Judiciais e Locações",
46
+ "cargo": "Arquiteta",
47
+ "conselho": "CAU",
48
+ "numero_conselho": "A50731-8",
49
+ "estado_conselho": "RS",
50
+ "matricula_sem_digito": "1036130",
51
+ "lotacao": "SMF/RM/DAI/ESJL"
52
+ },
53
+ {
54
+ "nome_completo": "Adriana Kirsch Bissigo",
55
+ "titulo": "Auxiliar da Equipe de Suporte, Judiciais e Locações",
56
+ "cargo": "Engenheira",
57
+ "conselho": "CREA_RS",
58
+ "numero_conselho": "69342",
59
+ "estado_conselho": "RS",
60
+ "matricula_sem_digito": "188661",
61
+ "lotacao": "SMF/RM/DAI/ESJL"
62
+ },
63
+ {
64
+ "nome_completo": "Roberta Brenner Ayub",
65
+ "titulo": "Arquiteta da Equipe de Suporte, Judiciais e Locações",
66
+ "cargo": "Arquiteta",
67
+ "conselho": "CAU",
68
+ "numero_conselho": "A29665-1",
69
+ "estado_conselho": "RS",
70
+ "matricula_sem_digito": "370440",
71
+ "lotacao": "SMF/RM/DAI/ESJL"
72
+ }
73
+ ]
74
+ }
backend/app/core/elaboracao/carregamento.py ADDED
@@ -0,0 +1,623 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ carregamento.py - Carga de arquivos, reset de estado e inicialização.
4
+
5
+ Callbacks que lidam com upload de CSV/Excel/.dai,
6
+ seleção de aba Excel, reset de seções e limpar histórico.
7
+ """
8
+
9
+ import gradio as gr
10
+ import os
11
+ from datetime import datetime, timezone, timedelta
12
+
13
+ from .formatadores import arredondar_df, criar_header_secao, formatar_lista_variaveis_html
14
+ from .modelo import (
15
+ aplicar_selecao_callback,
16
+ ajustar_modelo_callback,
17
+ MAX_VARS_X,
18
+ )
19
+ from .core import (
20
+ detectar_abas_excel,
21
+ carregar_arquivo,
22
+ carregar_dai,
23
+ obter_colunas_numericas,
24
+ identificar_coluna_y_padrao,
25
+ formatar_transformacao,
26
+ )
27
+ from .charts import criar_mapa
28
+ from .geocodificacao import verificar_coords, auto_detectar_colunas_geo, padronizar_coords
29
+
30
+
31
+ # ============================================================
32
+ # RESET DE SEÇÕES
33
+ # ============================================================
34
+
35
+ def _valores_reset_secoes_4_a_16():
36
+ """Valores de reset para seções 4-16 (headers, accordions e conteúdo).
37
+
38
+ Headers são resetados para remover timestamps. Accordions são fechados.
39
+ Usado por carregar_dados_do_arquivo e limpar_historico_callback para limpar
40
+ todas as seções quando um novo arquivo é carregado ou o histórico é resetado.
41
+
42
+ CONTRACT: Retorna 156 itens.
43
+ Se alterar, atualizar: TODAS as funções neste arquivo que retornam 193 itens
44
+ (ao_carregar_arquivo, _resultado_selecao_aba, confirmar_aba_callback,
45
+ carregar_dados_de_dai, carregar_dados_do_arquivo) + limpar_historico_callback.
46
+ """
47
+ n_rows = (MAX_VARS_X + 7) // 8 # 3
48
+ return (
49
+ # States
50
+ None, # estado_modelo
51
+ None, # estado_metricas
52
+ [], # estado_resultados_busca
53
+ [], # estado_avaliacoes
54
+ # Section 4
55
+ gr.update(value=criar_header_secao(4, "Selecionar Variáveis Independentes")), # header_secao_4
56
+ gr.update(visible=False), # accordion_secao_4
57
+ gr.update(choices=[], value=[], visible=False), # checkboxes_dicotomicas
58
+ gr.update(choices=[], value=[], visible=False), # checkboxes_codigo_alocado
59
+ gr.update(choices=[], value=[], visible=False), # checkboxes_percentuais
60
+ gr.update(value="", visible=False), # html_aviso_multicolinearidade
61
+ # Section 5
62
+ gr.update(value=criar_header_secao(5, "Estatísticas das Variáveis Selecionadas")), # header_secao_5
63
+ gr.update(visible=False), # accordion_secao_5
64
+ # Section 6
65
+ gr.update(value=criar_header_secao(6, "Teste de Micronumerosidade (NBR 14.653-2)")), # header_secao_6
66
+ gr.update(visible=False), # accordion_secao_6
67
+ "", # html_micronumerosidade
68
+ # Section 7
69
+ gr.update(value=criar_header_secao(7, "Gráficos de Dispersão das Variáveis Independentes")), # header_secao_7
70
+ gr.update(visible=False), # accordion_secao_7
71
+ None, # plot_dispersao
72
+ # Section 8
73
+ gr.update(value=criar_header_secao(8, "Transformações Sugeridas")), # header_secao_8
74
+ gr.update(visible=False), # accordion_secao_8
75
+ gr.update(value=3), # slider_grau_coef
76
+ gr.update(value=3), # slider_grau_f
77
+ "", # busca_html
78
+ # Section 9
79
+ gr.update(value=criar_header_secao(9, "Aplicação das Transformações")), # header_secao_9
80
+ gr.update(visible=False), # accordion_secao_9
81
+ gr.update(value="(x)"), # transformacao_y
82
+ *[gr.update(visible=False) for _ in range(5)], # btn_adotar 1-5
83
+ gr.update(visible=False), # transf_y_row
84
+ gr.update(visible=False), # transf_y_col
85
+ gr.update(value="", visible=False), # transf_y_label
86
+ # transf_x_rows, transf_x_columns, transf_x_labels, transf_x_dropdowns (63 itens)
87
+ *[gr.update(visible=False) for _ in range(n_rows)],
88
+ *[gr.update(visible=False) for _ in range(MAX_VARS_X)],
89
+ *[gr.update(value="", visible=False) for _ in range(MAX_VARS_X)],
90
+ *[gr.update(value="(x)", interactive=True, visible=False) for _ in range(MAX_VARS_X)],
91
+ # Section 10
92
+ gr.update(value=criar_header_secao(10, "Gráficos de Dispersão (Variáveis Transformadas)")), # header_secao_10
93
+ gr.update(visible=False), # accordion_secao_10
94
+ gr.update(value="Variáveis Independentes Transformadas X Variável Dependente Transformada", visible=False), # dropdown_tipo_grafico_dispersao
95
+ None, # plot_dispersao_transf
96
+ # Section 11
97
+ gr.update(value=criar_header_secao(11, "Diagnóstico de Modelo")), # header_secao_11
98
+ gr.update(visible=False), # accordion_secao_11
99
+ "", # diagnosticos_html
100
+ None, # tabela_coef
101
+ None, # tabela_obs_calc
102
+ # Section 12
103
+ gr.update(value=criar_header_secao(12, "Gráficos de Diagnóstico do Modelo")), # header_secao_12
104
+ gr.update(visible=False), # accordion_secao_12
105
+ None, None, None, None, None, # plots: obs_calc, residuos, hist, cook, corr
106
+ # Section 13
107
+ gr.update(value=criar_header_secao(13, "Analisar Outliers")), # header_secao_13
108
+ gr.update(visible=False), # accordion_secao_13
109
+ None, # tabela_metricas
110
+ # Section 14 (exclusão)
111
+ gr.update(value=criar_header_secao(14, "Exclusão ou Reinclusão de Outliers")), # header_secao_14
112
+ gr.update(visible=False), # accordion_secao_14
113
+ "", # html_outliers_sec14
114
+ "", # outliers_texto
115
+ "", # reincluir_texto
116
+ "Excluídos: 0 | A excluir: 0 | A reincluir: 0 | Total: 0", # txt_resumo_outliers
117
+ # Section 15 (Avaliação de Imóvel)
118
+ gr.update(value=criar_header_secao(15, "Avaliação de Imóvel")), # header_secao_15
119
+ gr.update(visible=False), # accordion_secao_15
120
+ *[gr.update(visible=False) for _ in range(MAX_VARS_X // 4)], # aval_rows (5)
121
+ *[gr.update(visible=False, value=None, label="") for _ in range(MAX_VARS_X)], # aval_inputs (20)
122
+ "", # resultado_avaliacao_html
123
+ gr.update(choices=[], value=None), # dropdown_base_avaliacao
124
+ "", # excluir_aval_trigger
125
+ gr.update(value=None, visible=False), # download_avaliacoes_file
126
+ # Section 16 (Exportar Modelo)
127
+ gr.update(value=criar_header_secao(16, "Exportar Modelo")), # header_secao_16
128
+ gr.update(visible=False), # accordion_secao_16
129
+ gr.update(value=""), # nome_arquivo
130
+ gr.update(value=""), # status_exportar
131
+ )
132
+
133
+
134
+ # ============================================================
135
+ # CALLBACKS DE CARREGAMENTO
136
+ # ============================================================
137
+
138
+ def ao_carregar_arquivo(arquivo):
139
+ """Callback quando arquivo é carregado. Detecta se há múltiplas abas no Excel.
140
+
141
+ CONTRACT: Retorna 193 itens para _outputs_carregar (app.py:criar_aba).
142
+ Se alterar, atualizar: _outputs_carregar em app.py + TODAS as 5 funções neste arquivo.
143
+ """
144
+ caminho_arquivo = arquivo.name if hasattr(arquivo, 'name') else str(arquivo)
145
+ nome_exibicao = "Arquivo carregado: " + os.path.basename(caminho_arquivo)
146
+
147
+ # Se for arquivo .dai, carrega modelo completo
148
+ if caminho_arquivo.endswith('.dai'):
149
+ return carregar_dados_de_dai(caminho_arquivo)
150
+
151
+ # Detecta abas do Excel
152
+ abas, msg_abas, sucesso_abas = detectar_abas_excel(caminho_arquivo)
153
+
154
+ # Se há múltiplas abas, mostra seletor de aba (não carrega dados ainda)
155
+ if sucesso_abas and len(abas) > 1:
156
+ return _resultado_selecao_aba(caminho_arquivo, abas)
157
+
158
+ # Se não há múltiplas abas, carrega diretamente
159
+ return carregar_dados_do_arquivo(caminho_arquivo, None,
160
+ nome_arquivo_exibicao=nome_exibicao)
161
+
162
+
163
+ def _resultado_selecao_aba(caminho_arquivo, abas):
164
+ """Retorna tupla para estado B: mostra seletor de aba, não carrega dados ainda.
165
+
166
+ CONTRACT: Retorna 193 itens para _outputs_carregar (app.py:criar_aba).
167
+ """
168
+ return (
169
+ None, # estado_df (sem dados ainda)
170
+ "Arquivo com múltiplas abas detectado. Selecione uma aba e confirme.", # status
171
+ gr.update(choices=abas, value=abas[0]), # dropdown_aba
172
+ gr.update(choices=[], value=None), # dropdown_y
173
+ None, # tabela_dados
174
+ None, # tabela_estatisticas
175
+ gr.update(choices=[]), # checkboxes_x
176
+ "<p>Carregue um arquivo para ver o mapa.</p>", # mapa
177
+ None, # estado_df_filtrado
178
+ [], # estado_outliers_anteriores
179
+ 1, # estado_iteracao
180
+ gr.update(visible=False), # accordion_outliers_anteriores
181
+ "", # html_outliers_anteriores
182
+ caminho_arquivo, # estado_arquivo_temp (manter caminho para confirmar_aba)
183
+ # Seções 2 e 3 - ocultas
184
+ gr.update(visible=False), # header_secao_2
185
+ gr.update(visible=False), # accordion_secao_2
186
+ gr.update(choices=["Visualização Padrão"], value="Visualização Padrão"), # dropdown_mapa_var
187
+ gr.update(visible=False), # header_secao_3
188
+ gr.update(visible=False), # accordion_secao_3
189
+ # Reset seções 4-16
190
+ *_valores_reset_secoes_4_a_16(),
191
+ # Controles de visibilidade da seção 1
192
+ gr.update(visible=False), # row_upload (ocultar upload)
193
+ gr.update(visible=True), # row_selecao_aba (mostrar seletor de aba)
194
+ gr.update(visible=False), # row_pos_carga (ocultar restart)
195
+ gr.update(value=""), # html_nome_arquivo_carregado
196
+ False, # estado_flag_carregamento
197
+ *[gr.update()] * 4, # filtro_vars (no-op)
198
+ # Painel de coordenadas (no-op — dados ainda não carregados)
199
+ gr.update(visible=False), gr.update(value=""),
200
+ gr.update(choices=[]), gr.update(choices=[]),
201
+ gr.update(choices=[], value=None), gr.update(choices=[], value=None),
202
+ gr.update(visible=True), # row_escolha_opcao — reset para tela de escolha
203
+ gr.update(visible=False), # row_opcao_mapear
204
+ gr.update(visible=False), # row_opcao_geocodificar
205
+ )
206
+
207
+
208
+ def confirmar_aba_callback(caminho_arquivo, nome_aba):
209
+ """Callback quando usuário confirma seleção de aba para Excel multi-aba.
210
+
211
+ CONTRACT: Retorna 193 itens para _outputs_carregar (app.py:criar_aba).
212
+ """
213
+ if caminho_arquivo is None or nome_aba is None:
214
+ return (
215
+ None, "Erro: arquivo não encontrado. Recarregue a página.",
216
+ gr.update(choices=[], value=None), # dropdown_aba
217
+ gr.update(choices=[], value=None), # dropdown_y
218
+ None, None, gr.update(choices=[]),
219
+ "<p>Carregue um arquivo para ver o mapa.</p>",
220
+ None, [], 1, gr.update(visible=False),
221
+ "", None,
222
+ gr.update(visible=False), gr.update(visible=False),
223
+ gr.update(choices=["Visualização Padrão"], value="Visualização Padrão"),
224
+ gr.update(visible=False), gr.update(visible=False),
225
+ *_valores_reset_secoes_4_a_16(),
226
+ # Volta ao estado de upload em caso de erro
227
+ gr.update(visible=True), # row_upload
228
+ gr.update(visible=False), # row_selecao_aba
229
+ gr.update(visible=False), # row_pos_carga
230
+ gr.update(value=""), # html_nome_arquivo_carregado
231
+ False, # estado_flag_carregamento
232
+ *[gr.update()] * 4, # filtro_vars (no-op)
233
+ # Painel de coordenadas (no-op — erro)
234
+ gr.update(visible=False), gr.update(value=""),
235
+ gr.update(choices=[]), gr.update(choices=[]),
236
+ gr.update(choices=[], value=None), gr.update(choices=[], value=None),
237
+ gr.update(visible=True), # row_escolha_opcao — reset para tela de escolha
238
+ gr.update(visible=False), # row_opcao_mapear
239
+ gr.update(visible=False), # row_opcao_geocodificar
240
+ )
241
+
242
+ nome_exibicao = "Arquivo carregado: "+os.path.basename(caminho_arquivo)
243
+ nome_exibicao_completo = f"{nome_exibicao} — Aba: {nome_aba}"
244
+ return carregar_dados_do_arquivo(caminho_arquivo, nome_aba,
245
+ nome_arquivo_exibicao=nome_exibicao_completo)
246
+
247
+
248
+ def carregar_dados_de_dai(caminho_arquivo):
249
+ """Carrega arquivo .dai e popula toda a interface com o modelo reconstruído.
250
+
251
+ Executa o mesmo fluxo que o usuário faria manualmente:
252
+ 1. Carrega dados (como carregar_dados_do_arquivo)
253
+ 2. Aplica seleção de variáveis (como aplicar_selecao_callback)
254
+ 3. Sobrescreve transformações com valores do .dai
255
+ 4. Ajusta modelo (como ajustar_modelo_callback)
256
+
257
+ CONTRACT: Retorna 196 itens para _outputs_carregar (app.py:criar_aba).
258
+ Consome aplicar_selecao_callback por índice (r[0], r[1], ..., r[23:]).
259
+ Consome ajustar_modelo_callback por índice (m[0], ..., m[32]).
260
+ """
261
+ df, coluna_y, colunas_x, transformacao_y, transformacoes_x, dicotomicas, codigo_alocado, percentuais, msg, sucesso, elaborador, outliers_excluidos = carregar_dai(caminho_arquivo)
262
+
263
+ nome_exibicao = os.path.basename(caminho_arquivo)
264
+ html_nome = f"<h2 style='margin:0 0 12px 0; font-size:1.4em;'>{nome_exibicao}</h2>"
265
+ if elaborador:
266
+ nome_elab = elaborador.get("nome_completo", "")
267
+ cargo = elaborador.get("cargo", "")
268
+ conselho = elaborador.get("conselho", "")
269
+ num_conselho = elaborador.get("numero_conselho", "")
270
+ estado_conselho = elaborador.get("estado_conselho", "")
271
+ matricula = elaborador.get("matricula_sem_digito", "")
272
+ lotacao = elaborador.get("lotacao", "")
273
+ linha2 = f"{cargo} · {conselho}/{estado_conselho} {num_conselho}" if cargo else ""
274
+ linha3 = f"Matrícula: {matricula} · {lotacao}" if matricula else ""
275
+ if nome_elab:
276
+ info_variaveis = (
277
+ [f"{coluna_y}: {formatar_transformacao(transformacao_y, is_y=True)}"]
278
+ + [f"{col}: {formatar_transformacao(transformacoes_x.get(col, '(x)'))}"
279
+ for col in colunas_x]
280
+ )
281
+ variaveis_lado_direito = formatar_lista_variaveis_html(info_variaveis)
282
+ html_nome += (
283
+ '<div style="display:flex; justify-content:space-between; align-items:flex-start; '
284
+ 'gap:24px; background:#e9ecef; border-left:5px solid #6c757d; '
285
+ 'border-radius:6px; padding:14px 18px; color:#495057; line-height:1.8; margin-top:8px;">'
286
+ '<div>'
287
+ f'<span style="display:block; font-size:1.15em; font-weight:600; color:#212529; margin-bottom:4px;">{nome_elab}</span>'
288
+ + (f'<span style="display:block; font-size:1em;">{linha2}</span>' if linha2 else '')
289
+ + (f'<span style="display:block; font-size:0.95em; color:#6c757d;">{linha3}</span>' if linha3 else '')
290
+ + '</div>'
291
+ + f'<div>{variaveis_lado_direito}</div>'
292
+ + '</div>'
293
+ )
294
+
295
+ if outliers_excluidos:
296
+ lista_str = ", ".join(map(str, sorted(outliers_excluidos)))
297
+ n = len(outliers_excluidos)
298
+ html_outliers_content = (
299
+ '<div style="display:flex; gap:16px; align-items:baseline; padding:10px 16px; '
300
+ 'background:var(--background-fill-secondary,#f8f9fa); border-radius:8px; '
301
+ 'border:1px solid var(--border-color-primary,#e2e8f0); flex-wrap:wrap;">'
302
+ f'<span style="font-weight:600; color:var(--body-text-color,#495057); white-space:nowrap;">'
303
+ f'{n} outlier(s) excluídos do modelo ajustado</span>'
304
+ f'<span style="color:var(--body-text-color-subdued,#6c757d); font-size:0.92em;">'
305
+ f'Índices: {lista_str}</span>'
306
+ '</div>'
307
+ )
308
+ else:
309
+ html_outliers_content = ""
310
+
311
+ if not sucesso:
312
+ return (
313
+ None, msg, gr.update(), gr.update(choices=[], value=None),
314
+ None, None, gr.update(choices=[]), "<p>Erro ao carregar modelo.</p>",
315
+ None, [], 1, gr.update(visible=False),
316
+ "", None,
317
+ gr.update(visible=False), gr.update(visible=False),
318
+ gr.update(choices=["Visualização Padrão"], value="Visualização Padrão"),
319
+ gr.update(visible=False), gr.update(visible=False),
320
+ *_valores_reset_secoes_4_a_16(),
321
+ # Volta ao estado de upload em caso de erro
322
+ gr.update(visible=True), # row_upload
323
+ gr.update(visible=False), # row_selecao_aba
324
+ gr.update(visible=False), # row_pos_carga
325
+ gr.update(value=""), # html_nome_arquivo_carregado
326
+ False, # estado_flag_carregamento
327
+ *[gr.update()] * 4, # filtro_vars (no-op)
328
+ # Painel de coordenadas (no-op — .dai isento)
329
+ gr.update(visible=False), gr.update(value=""),
330
+ gr.update(choices=[]), gr.update(choices=[]),
331
+ gr.update(choices=[], value=None), gr.update(choices=[], value=None),
332
+ gr.update(visible=True), # row_escolha_opcao — reset para tela de escolha
333
+ gr.update(visible=False), # row_opcao_mapear
334
+ gr.update(visible=False), # row_opcao_geocodificar
335
+ )
336
+
337
+ colunas_numericas = obter_colunas_numericas(df)
338
+ gmt_minus_3 = timezone(timedelta(hours=-3))
339
+ timestamp = datetime.now(gmt_minus_3).strftime("%H:%M:%S")
340
+ mapa_html_val = criar_mapa(df)
341
+
342
+ # --- Passo 2: Simula "Aplicar Seleção" (seções 5-9) ---
343
+ r = aplicar_selecao_callback(df, coluna_y, colunas_x, outliers_excluidos, dicotomicas, codigo_alocado, percentuais)
344
+ # r: [df_filtrado, tabela_est, html_micro, plot_disp, slider_coef, slider_f,
345
+ # busca_html, resultados, btn1-5(5), secoes5-9 headers/accordions(10), campos_transf(66)]
346
+
347
+ # --- Passo 3: Sobrescreve dropdowns de transformação com valores do .dai ---
348
+ campos_transf = list(r[23:89]) # 66 itens: y_row(1)+y_col(1)+y_label(1)+rows(3)+columns(20)+labels(20)+dropdowns(20)
349
+ n_rows = (MAX_VARS_X + 7) // 8
350
+ dropdown_offset = 3 + n_rows + MAX_VARS_X + MAX_VARS_X # 3 + 3 + 20 + 20 = 46
351
+ for i, col in enumerate(colunas_x):
352
+ if i < MAX_VARS_X:
353
+ # Dicotômicas e percentuais: travar. Código alocado: livre.
354
+ travar = col in dicotomicas or col in percentuais
355
+ campos_transf[dropdown_offset + i] = gr.update(
356
+ value=transformacoes_x.get(col, "(x)"),
357
+ interactive=not travar,
358
+ visible=True
359
+ )
360
+
361
+ # --- Passo 4: Simula "Ajustar Modelo" (seções 10-16) ---
362
+ df_para_ajuste = r[0] if r[0] is not None else df # df filtrado (sem outliers excluídos)
363
+ dropdown_vals = [transformacoes_x.get(colunas_x[i], "(x)") if i < len(colunas_x) else "(x)"
364
+ for i in range(MAX_VARS_X)]
365
+ m = ajustar_modelo_callback(df_para_ajuste, coluna_y, colunas_x, transformacao_y, outliers_excluidos, dicotomicas, codigo_alocado, percentuais, *dropdown_vals)
366
+ # m[0]:resultado [1]:diag [2]:coef [3]:obs_calc [4]:plot_disp [5]:dropdown
367
+ # m[6-10]:plots [11]:metricas [12]:est_metricas [13]:resumo
368
+ # m[14]:estado_avaliacoes
369
+ # m[15-16]:h10,a10 [17-18]:h11,a11 [19-20]:h12,a12 [21-22]:h13,a13
370
+ # m[23-24]:h14,a14 [25-26]:h15_aval,a15_aval [27-28]:h16_exp,a16_exp
371
+ # m[29-32]:filtro_vars
372
+
373
+ n_aval_rows = MAX_VARS_X // 4 # 5
374
+
375
+ return (
376
+ # --- Seções 1-3: dados básicos ---
377
+ df, # estado_df
378
+ msg, # status
379
+ gr.update(), # dropdown_aba (no-op)
380
+ gr.update(choices=colunas_numericas, value=coluna_y), # dropdown_y
381
+ arredondar_df(df), # tabela_dados
382
+ r[1], # tabela_estatisticas (de aplicar_selecao)
383
+ gr.update(choices=colunas_numericas, value=colunas_x), # checkboxes_x
384
+ mapa_html_val, # mapa
385
+ r[0], # estado_df_filtrado (de aplicar_selecao)
386
+ outliers_excluidos, # estado_outliers_anteriores
387
+ 1, # estado_iteracao
388
+ gr.update(visible=bool(outliers_excluidos)), # accordion_outliers_anteriores
389
+ html_outliers_content, # html_outliers_anteriores
390
+ None, # estado_arquivo_temp
391
+ gr.update(visible=True, value=criar_header_secao(2, "Visualizar Dados", timestamp)),
392
+ gr.update(visible=True, open=True), # accordion_secao_2
393
+ gr.update(choices=["Visualização Padrão"] + colunas_numericas, value="Visualização Padrão"),
394
+ gr.update(visible=True), # header_secao_3
395
+ gr.update(visible=True, open=True), # accordion_secao_3
396
+ # --- States seções 4-16 ---
397
+ m[0], # estado_modelo (de ajustar)
398
+ m[12], # estado_metricas (de ajustar)
399
+ r[7], # estado_resultados_busca (de aplicar_selecao)
400
+ m[14], # estado_avaliacoes (de ajustar — reset [])
401
+ # --- Seção 4: seleção de variáveis ---
402
+ gr.update(visible=True, value=criar_header_secao(4, "Selecionar Variáveis Independentes", timestamp)),
403
+ gr.update(visible=True, open=True), # accordion_secao_4
404
+ gr.update(choices=list(colunas_x), value=list(dicotomicas), visible=True), # checkboxes_dicotomicas
405
+ gr.update(choices=list(colunas_x), value=list(codigo_alocado), visible=True), # checkboxes_codigo_alocado
406
+ gr.update(choices=list(colunas_x), value=list(percentuais), visible=True), # checkboxes_percentuais
407
+ gr.update(value="", visible=False), # html_aviso_multicolinearidade
408
+ # --- Seções 5-9: headers/accordions (de aplicar_selecao) ---
409
+ r[13], r[14], # header_secao_5, accordion_secao_5
410
+ r[15], r[16], # header_secao_6, accordion_secao_6
411
+ r[2], # html_micronumerosidade
412
+ r[17], r[18], # header_secao_7, accordion_secao_7
413
+ r[3], # plot_dispersao
414
+ r[19], r[20], # header_secao_8, accordion_secao_8
415
+ r[4], r[5], # slider_grau_coef, slider_grau_f
416
+ r[6], # busca_html
417
+ r[21], r[22], # header_secao_9, accordion_secao_9
418
+ gr.update(value=transformacao_y), # transformacao_y dropdown (do .dai)
419
+ r[8], r[9], r[10], r[11], r[12], # btn_adotar 1-5
420
+ # --- Campos de transformação (com dropdowns sobrescritos) ---
421
+ *campos_transf,
422
+ # --- Seções 10-16 (de ajustar_modelo_callback) ---
423
+ m[15], m[16], # header_secao_10, accordion_secao_10
424
+ m[5], # dropdown_tipo_grafico_dispersao
425
+ m[4], # plot_dispersao_transf
426
+ m[17], m[18], # header_secao_11, accordion_secao_11
427
+ m[1], # diagnosticos_html
428
+ m[2], # tabela_coef
429
+ m[3], # tabela_obs_calc
430
+ m[19], m[20], # header_secao_12, accordion_secao_12
431
+ m[6], m[7], m[8], m[9], m[10], # plots: obs_calc, resid, hist, cook, corr
432
+ m[21], m[22], # header_secao_13, accordion_secao_13
433
+ m[11], # tabela_metricas
434
+ m[23], m[24], # header_secao_14, accordion_secao_14
435
+ html_outliers_content, # html_outliers_sec14
436
+ "", # outliers_texto
437
+ "", # reincluir_texto
438
+ m[13], # txt_resumo_outliers
439
+ # --- Seção 15: Avaliação de Imóvel ---
440
+ m[25], m[26], # header_secao_15, accordion_secao_15
441
+ *[gr.update(visible=False) for _ in range(n_aval_rows)], # aval_rows (populados pelo .then)
442
+ *[gr.update(visible=False, value=None, label="") for _ in range(MAX_VARS_X)], # aval_inputs (populados pelo .then)
443
+ "", # resultado_avaliacao_html
444
+ gr.update(choices=[], value=None), # dropdown_base_avaliacao
445
+ "", # excluir_aval_trigger
446
+ gr.update(value=None, visible=False), # download_avaliacoes_file
447
+ # --- Seção 16: Exportar Modelo ---
448
+ m[27], m[28], # header_secao_16, accordion_secao_16
449
+ gr.update(value=""), # nome_arquivo
450
+ gr.update(value=""), # status_exportar
451
+ # Transição para estado C (pós-carregamento)
452
+ gr.update(visible=False), # row_upload
453
+ gr.update(visible=False), # row_selecao_aba
454
+ gr.update(visible=True), # row_pos_carga
455
+ gr.update(value=html_nome), # html_nome_arquivo_carregado
456
+ 2, # estado_flag_carregamento (contador: 2 side-effects esperados)
457
+ m[29], m[30], m[31], m[32], # filtro_vars (choices com colunas originais)
458
+ # Painel de coordenadas (no-op — .dai isento)
459
+ gr.update(visible=False), gr.update(value=""),
460
+ gr.update(choices=[]), gr.update(choices=[]),
461
+ gr.update(choices=[], value=None), gr.update(choices=[], value=None),
462
+ gr.update(visible=True), # row_escolha_opcao — reset para tela de escolha
463
+ gr.update(visible=False), # row_opcao_mapear
464
+ gr.update(visible=False), # row_opcao_geocodificar
465
+ )
466
+
467
+
468
+ def carregar_dados_do_arquivo(caminho_arquivo, nome_aba, nome_arquivo_exibicao=""):
469
+ """Carrega dados de um arquivo, opcionalmente de uma aba específica.
470
+
471
+ Args:
472
+ caminho_arquivo: Caminho do arquivo a carregar
473
+ nome_aba: Nome da aba a carregar (apenas para Excel)
474
+ nome_arquivo_exibicao: Nome do arquivo para exibir na interface pós-carregamento
475
+
476
+ CONTRACT: Retorna 193 itens para _outputs_carregar (app.py:criar_aba).
477
+ """
478
+ df, msg, sucesso = carregar_arquivo(caminho_arquivo, nome_aba)
479
+
480
+ if not sucesso:
481
+ return (
482
+ None, # estado_df
483
+ msg, # status
484
+ gr.update(), # dropdown_aba
485
+ gr.update(choices=[], value=None), # dropdown_y
486
+ None, # tabela_dados
487
+ None, # tabela_estatisticas
488
+ gr.update(choices=[]), # checkboxes_x
489
+ "<p>Carregue um arquivo para ver o mapa.</p>", # mapa
490
+ None, # estado_df_filtrado
491
+ [], # estado_outliers_anteriores
492
+ 1, # estado_iteracao
493
+ gr.update(visible=False), # accordion_outliers_anteriores
494
+ "", # html_outliers_anteriores
495
+ None, # estado_arquivo_temp
496
+ # Seções 2 e 3 - ocultas quando erro
497
+ gr.update(visible=False), # header_secao_2
498
+ gr.update(visible=False), # accordion_secao_2
499
+ gr.update(choices=["Visualização Padrão"], value="Visualização Padrão"), # dropdown_mapa_var
500
+ gr.update(visible=False), # header_secao_3
501
+ gr.update(visible=False), # accordion_secao_3
502
+ # Reset seções 4-16
503
+ *_valores_reset_secoes_4_a_16(),
504
+ # Volta ao estado de upload em caso de erro
505
+ gr.update(visible=True), # row_upload
506
+ gr.update(visible=False), # row_selecao_aba
507
+ gr.update(visible=False), # row_pos_carga
508
+ gr.update(value=""), # html_nome_arquivo_carregado
509
+ False, # estado_flag_carregamento
510
+ *[gr.update()] * 4, # filtro_vars (no-op)
511
+ # Painel de coordenadas (no-op — erro)
512
+ gr.update(visible=False), gr.update(value=""),
513
+ gr.update(choices=[]), gr.update(choices=[]),
514
+ gr.update(choices=[], value=None), gr.update(choices=[], value=None),
515
+ gr.update(visible=True), # row_escolha_opcao — reset para tela de escolha
516
+ gr.update(visible=False), # row_opcao_mapear
517
+ gr.update(visible=False), # row_opcao_geocodificar
518
+ )
519
+
520
+ # Verificar e padronizar coordenadas
521
+ tem_coords, col_lat, col_lon = verificar_coords(df)
522
+ todas_colunas = df.columns.tolist()
523
+
524
+ if tem_coords:
525
+ df = padronizar_coords(df, col_lat, col_lon)
526
+ aviso_coords_html = ""
527
+ cdlog_auto = None
528
+ num_auto = None
529
+ else:
530
+ cdlog_auto, num_auto = auto_detectar_colunas_geo(df)
531
+ n = len(df)
532
+ aviso_coords_html = (
533
+ '<div style="background:#f8d7da;border:1px solid #f5c2c7;border-radius:8px;'
534
+ f'padding:10px 14px;margin-bottom:8px"><strong>⚠️ Colunas lat/lon não '
535
+ f'encontradas</strong> — {n} registro(s) sem coordenadas padronizadas.<br>'
536
+ '</div>'
537
+ )
538
+
539
+ # Identifica colunas (após padronização de coords)
540
+ colunas_numericas = obter_colunas_numericas(df)
541
+ coluna_y_padrao = identificar_coluna_y_padrao(df)
542
+
543
+ # Variáveis X padrão: todas exceto Y
544
+ colunas_x_padrao = [col for col in colunas_numericas if col != coluna_y_padrao]
545
+
546
+ # NÃO calcula estatísticas no carregamento - apenas ao clicar em "Aplicar Seleção"
547
+
548
+ # Mapa
549
+ mapa_html = criar_mapa(df)
550
+
551
+ # Timestamp para seção 2
552
+ gmt_minus_3 = timezone(timedelta(hours=-3))
553
+ timestamp = datetime.now(gmt_minus_3).strftime("%H:%M:%S")
554
+
555
+ return (
556
+ df, # estado_df
557
+ msg, # status
558
+ gr.update(), # dropdown_aba (no-op)
559
+ gr.update(choices=colunas_numericas, value=coluna_y_padrao), # dropdown_y
560
+ arredondar_df(df), # tabela_dados
561
+ gr.update(value=None), # tabela_estatisticas - NÃO mostra até clicar em Aplicar Seleção
562
+ gr.update(choices=colunas_numericas, value=[]), # checkboxes_x (nenhuma marcada por padrão)
563
+ mapa_html, # mapa
564
+ df, # estado_df_filtrado (inicia com dados completos)
565
+ [], # estado_outliers_anteriores
566
+ 1, # estado_iteracao
567
+ gr.update(visible=False), # accordion_outliers_anteriores
568
+ "", # html_outliers_anteriores
569
+ None, # estado_arquivo_temp
570
+ # Seções 2 e 3 — header sempre visível; accordion oculto até coords resolvidas
571
+ gr.update(visible=True, value=criar_header_secao(2, "Visualizar Dados", timestamp) if tem_coords else criar_header_secao(2, "Visualizar Dados")), # header_secao_2
572
+ gr.update(visible=tem_coords, open=True), # accordion_secao_2
573
+ gr.update(choices=["Visualização Padrão"] + colunas_numericas, value="Visualização Padrão"), # dropdown_mapa_var
574
+ gr.update(visible=True), # header_secao_3
575
+ gr.update(visible=tem_coords, open=True), # accordion_secao_3
576
+ # Reset seções 4-16
577
+ *_valores_reset_secoes_4_a_16(),
578
+ # Transição para estado C (pós-carregamento)
579
+ gr.update(visible=False), # row_upload
580
+ gr.update(visible=False), # row_selecao_aba
581
+ gr.update(visible=True), # row_pos_carga
582
+ gr.update(value=f"<h3>{nome_arquivo_exibicao}</h3>"), # html_nome_arquivo_carregado
583
+ 2, # estado_flag_carregamento (contador: 2 side-effects esperados)
584
+ *[gr.update()] * 4, # filtro_vars (no-op — seções 13-14 ocultas)
585
+ # Painel de coordenadas (Estado D) — mostrado apenas se coords ausentes
586
+ gr.update(visible=not tem_coords), # row_coords_panel
587
+ gr.update(value=aviso_coords_html), # html_aviso_coords
588
+ gr.update(choices=todas_colunas if not tem_coords else []), # dropdown_col_lat_manual
589
+ gr.update(choices=todas_colunas if not tem_coords else []), # dropdown_col_lon_manual
590
+ gr.update(choices=todas_colunas if not tem_coords else [], value=cdlog_auto), # dropdown_cdlog_geo
591
+ gr.update(choices=todas_colunas if not tem_coords else [], value=num_auto), # dropdown_num_geo
592
+ gr.update(visible=True), # row_escolha_opcao — reset para tela de escolha
593
+ gr.update(visible=False), # row_opcao_mapear
594
+ gr.update(visible=False), # row_opcao_geocodificar
595
+ )
596
+
597
+
598
+ # ============================================================
599
+ # LIMPAR HISTÓRICO
600
+ # ============================================================
601
+
602
+ def limpar_historico_callback(df_original):
603
+ """Limpa o histórico de outliers e reinicia do zero.
604
+ Reseta tudo como se o arquivo tivesse acabado de ser carregado.
605
+
606
+ CONTRACT: Retorna 169 itens para btn_limpar_historico.click (app.py:criar_aba).
607
+ Se alterar, atualizar: app.py (outputs de btn_limpar_historico.click).
608
+ """
609
+ mapa = criar_mapa(df_original)
610
+
611
+ return (
612
+ [], # estado_outliers_anteriores (vazio)
613
+ 1, # estado_iteracao (reinicia)
614
+ df_original, # estado_df_filtrado (dados originais)
615
+ arredondar_df(df_original), # tabela_dados
616
+ gr.update(value=None), # tabela_estatisticas (limpa)
617
+ mapa, # mapa_html
618
+ "", # html_outliers_anteriores
619
+ gr.update(visible=False), # accordion_outliers_anteriores (esconde)
620
+ gr.update(value=criar_header_secao(2, "Visualizar Dados")), # header_secao_2
621
+ *_valores_reset_secoes_4_a_16(), # reset completo seções 4-15
622
+ *[gr.update()] * 4, # filtro_vars (no-op — seções ocultas após reset)
623
+ )
backend/app/core/elaboracao/charts.py ADDED
@@ -0,0 +1,745 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ charts.py - Gráficos Plotly e mapa Folium para elaboração de modelos
4
+ """
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ import plotly.graph_objects as go
9
+ from plotly.subplots import make_subplots
10
+ from scipy import stats
11
+ import folium
12
+ from folium import plugins
13
+ import branca.colormap as cm
14
+
15
+ # ============================================================
16
+ # CONSTANTES DE ESTILO
17
+ # ============================================================
18
+
19
+ COR_PRINCIPAL = '#FF8C00' # Laranja
20
+ COR_LINHA = '#dc3545' # Vermelho
21
+ COR_PONTOS = '#6c757d' # Cinza
22
+ COR_FUNDO = '#f8f9fa' # Cinza claro
23
+ COR_GRADE = '#e0e0e0' # Grade
24
+
25
+
26
+ # ============================================================
27
+ # GRÁFICOS DE DISPERSÃO X vs Y
28
+ # ============================================================
29
+
30
+ def criar_graficos_dispersao(X, y, max_por_linha=3):
31
+ """
32
+ Cria gráficos de dispersão de cada coluna de X contra y.
33
+ Inclui reta de regressão e coeficiente de correlação.
34
+
35
+ Retorna figura Plotly.
36
+ """
37
+ if X is None or y is None or X.empty:
38
+ return None
39
+
40
+ n_colunas = min(max_por_linha, len(X.columns))
41
+ n_linhas = (len(X.columns) + n_colunas - 1) // n_colunas
42
+
43
+ y_nome = y.name if hasattr(y, 'name') and y.name else "y"
44
+ y_vals = pd.to_numeric(pd.Series(y), errors="coerce").to_numpy()
45
+
46
+ titulos = [f"{col} × {y_nome}" for col in X.columns]
47
+
48
+ fig = make_subplots(
49
+ rows=n_linhas,
50
+ cols=n_colunas,
51
+ subplot_titles=titulos,
52
+ horizontal_spacing=0.05,
53
+ vertical_spacing=0.06,
54
+ )
55
+
56
+ indices_x = X.index.values
57
+
58
+ for idx, coluna in enumerate(X.columns):
59
+ x_vals = pd.to_numeric(X[coluna], errors="coerce").to_numpy()
60
+
61
+ mask = np.isfinite(x_vals) & np.isfinite(y_vals)
62
+ x_valid = x_vals[mask]
63
+ y_valid = y_vals[mask]
64
+ indices_valid = indices_x[mask]
65
+
66
+ if len(x_valid) < 2:
67
+ continue
68
+
69
+ row = (idx // n_colunas) + 1
70
+ col = (idx % n_colunas) + 1
71
+
72
+ x_unicos = np.unique(x_valid)
73
+ eh_dicotomica = len(x_unicos) <= 2
74
+
75
+ fig.add_trace(
76
+ go.Scatter(
77
+ x=x_valid,
78
+ y=y_valid,
79
+ mode='markers',
80
+ name=coluna,
81
+ marker=dict(size=7, color=COR_PONTOS, opacity=0.78),
82
+ customdata=indices_valid,
83
+ showlegend=False,
84
+ cliponaxis=False,
85
+ hovertemplate=f"<b>Índice:</b> %{{customdata}}<br><b>{coluna}:</b> %{{x:.2f}}<br><b>{y_nome}:</b> %{{y:.2f}}<extra></extra>",
86
+ ),
87
+ row=row,
88
+ col=col,
89
+ )
90
+
91
+ corr = np.nan
92
+ if np.nanstd(x_valid) > 0 and np.nanstd(y_valid) > 0:
93
+ corr = float(np.corrcoef(x_valid, y_valid)[0, 1])
94
+
95
+ # Para variáveis dicotômicas, não desenha reta de regressão no gráfico de inspeção.
96
+ if not eh_dicotomica and len(x_valid) >= 3 and np.nanstd(x_valid) > 0:
97
+ a, b = np.polyfit(x_valid, y_valid, 1)
98
+ x_sorted = np.sort(x_valid)
99
+ y_linha = a * x_sorted + b
100
+ fig.add_trace(
101
+ go.Scatter(
102
+ x=x_sorted,
103
+ y=y_linha,
104
+ mode='lines',
105
+ name=f'Regressão {coluna}',
106
+ line=dict(color=COR_PRINCIPAL, width=2),
107
+ showlegend=False,
108
+ hoverinfo='skip',
109
+ ),
110
+ row=row,
111
+ col=col,
112
+ )
113
+
114
+ if fig.layout.annotations and idx < len(fig.layout.annotations):
115
+ titulo = f"{coluna} × {y_nome}"
116
+ if np.isfinite(corr):
117
+ titulo = f"{titulo}<br>r = {corr:.3f}"
118
+ fig.layout.annotations[idx].text = titulo
119
+
120
+ x_min, x_max = float(np.min(x_valid)), float(np.max(x_valid))
121
+ if np.isclose(x_min, x_max):
122
+ x_pad = max(abs(x_min) * 0.15, 0.5)
123
+ elif eh_dicotomica:
124
+ x_pad = max((x_max - x_min) * 0.35, 0.30)
125
+ else:
126
+ x_pad = max((x_max - x_min) * 0.08, 0.05)
127
+
128
+ y_min, y_max = float(np.min(y_valid)), float(np.max(y_valid))
129
+ if np.isclose(y_min, y_max):
130
+ y_pad = max(abs(y_min) * 0.15, 0.5)
131
+ else:
132
+ y_pad = max((y_max - y_min) * 0.08, 0.05)
133
+
134
+ fig.update_xaxes(range=[x_min - x_pad, x_max + x_pad], row=row, col=col)
135
+ fig.update_yaxes(range=[y_min - y_pad, y_max + y_pad], row=row, col=col)
136
+
137
+ fig.update_layout(
138
+ height=max(380, 380 * n_linhas),
139
+ showlegend=False,
140
+ plot_bgcolor=COR_FUNDO,
141
+ paper_bgcolor='white',
142
+ margin=dict(t=78, r=20, b=48, l=54),
143
+ )
144
+ fig.update_annotations(showarrow=False)
145
+
146
+ fig.update_xaxes(showgrid=True, gridcolor=COR_GRADE)
147
+ fig.update_yaxes(showgrid=True, gridcolor=COR_GRADE)
148
+
149
+ return fig
150
+
151
+
152
+ # ============================================================
153
+ # GRÁFICOS DE DIAGNÓSTICO DO MODELO
154
+ # ============================================================
155
+
156
+ def criar_grafico_obs_calc(y_obs, y_calc, indices=None):
157
+ """Gráfico de valores observados vs calculados."""
158
+ if y_obs is None or y_calc is None:
159
+ return None
160
+
161
+ y_obs = np.array(y_obs)
162
+ y_calc = np.array(y_calc)
163
+
164
+ fig = go.Figure()
165
+
166
+ # Pontos
167
+ scatter_args = dict(
168
+ x=y_calc,
169
+ y=y_obs,
170
+ mode='markers',
171
+ marker=dict(color=COR_PRINCIPAL, size=10, line=dict(color='black', width=1)),
172
+ name='Dados',
173
+ )
174
+
175
+ if indices is not None:
176
+ scatter_args['customdata'] = indices
177
+ scatter_args['hovertemplate'] = "<b>Índice:</b> %{customdata}<br><b>Calculado:</b> %{x:.2f}<br><b>Observado:</b> %{y:.2f}<extra></extra>"
178
+ else:
179
+ scatter_args['hovertemplate'] = "<b>Calculado:</b> %{x:.2f}<br><b>Observado:</b> %{y:.2f}<extra></extra>"
180
+
181
+ fig.add_trace(go.Scatter(**scatter_args))
182
+
183
+ # Linha de identidade
184
+ min_val = min(y_obs.min(), y_calc.min())
185
+ max_val = max(y_obs.max(), y_calc.max())
186
+ margin = (max_val - min_val) * 0.05
187
+
188
+ fig.add_trace(go.Scatter(
189
+ x=[min_val - margin, max_val + margin],
190
+ y=[min_val - margin, max_val + margin],
191
+ mode='lines',
192
+ line=dict(color=COR_LINHA, dash='dash', width=2),
193
+ name='Linha de identidade'
194
+ ))
195
+
196
+ fig.update_layout(
197
+ title=dict(text='Observados vs Calculados', x=0.5),
198
+ xaxis_title='Valores Calculados',
199
+ yaxis_title='Valores Observados',
200
+ showlegend=True,
201
+ plot_bgcolor='white',
202
+ height=400
203
+ )
204
+
205
+ fig.update_xaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black')
206
+ fig.update_yaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black')
207
+
208
+ return fig
209
+
210
+
211
+ def criar_grafico_residuos(y_calc, residuos, indices=None):
212
+ """Gráfico de resíduos vs valores ajustados."""
213
+ if y_calc is None or residuos is None:
214
+ return None
215
+
216
+ y_calc = np.array(y_calc)
217
+ residuos = np.array(residuos)
218
+
219
+ fig = go.Figure()
220
+
221
+ scatter_args = dict(
222
+ x=y_calc,
223
+ y=residuos,
224
+ mode='markers',
225
+ marker=dict(color=COR_PRINCIPAL, size=10, line=dict(color='black', width=1)),
226
+ name='Resíduos',
227
+ )
228
+
229
+ if indices is not None:
230
+ scatter_args['customdata'] = indices
231
+ scatter_args['hovertemplate'] = "<b>Índice:</b> %{customdata}<br><b>Ajustado:</b> %{x:.2f}<br><b>Resíduo:</b> %{y:.4f}<extra></extra>"
232
+ else:
233
+ scatter_args['hovertemplate'] = "<b>Ajustado:</b> %{x:.2f}<br><b>Resíduo:</b> %{y:.4f}<extra></extra>"
234
+
235
+ fig.add_trace(go.Scatter(**scatter_args))
236
+
237
+ # Linhas de referência
238
+ fig.add_hline(y=0, line_dash="dash", line_color=COR_LINHA, line_width=2)
239
+ fig.add_hline(y=2, line_dash="dot", line_color='gray', line_width=1)
240
+ fig.add_hline(y=-2, line_dash="dot", line_color='gray', line_width=1)
241
+
242
+ fig.update_layout(
243
+ title=dict(text='Resíduos vs Valores Ajustados', x=0.5),
244
+ xaxis_title='Valores Ajustados',
245
+ yaxis_title='Resíduos Padronizados',
246
+ showlegend=False,
247
+ plot_bgcolor='white',
248
+ height=400
249
+ )
250
+
251
+ fig.update_xaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black')
252
+ fig.update_yaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black')
253
+
254
+ return fig
255
+
256
+
257
+ def criar_histograma_residuos(residuos):
258
+ """Histograma dos resíduos com curva normal."""
259
+ if residuos is None:
260
+ return None
261
+
262
+ residuos = np.array(residuos)
263
+
264
+ fig = go.Figure()
265
+
266
+ # Histograma
267
+ fig.add_trace(go.Histogram(
268
+ x=residuos,
269
+ histnorm='probability density',
270
+ marker=dict(color=COR_PRINCIPAL, line=dict(color='black', width=1)),
271
+ opacity=0.7,
272
+ name='Resíduos'
273
+ ))
274
+
275
+ # Curva normal
276
+ mu, sigma = np.mean(residuos), np.std(residuos)
277
+ x_norm = np.linspace(residuos.min() - sigma, residuos.max() + sigma, 100)
278
+ y_norm = stats.norm.pdf(x_norm, mu, sigma)
279
+
280
+ fig.add_trace(go.Scatter(
281
+ x=x_norm,
282
+ y=y_norm,
283
+ mode='lines',
284
+ line=dict(color=COR_LINHA, width=3),
285
+ name='Curva Normal'
286
+ ))
287
+
288
+ fig.update_layout(
289
+ title=dict(text='Distribuição dos Resíduos', x=0.5),
290
+ xaxis_title='Resíduos',
291
+ yaxis_title='Densidade',
292
+ showlegend=True,
293
+ plot_bgcolor='white',
294
+ barmode='overlay',
295
+ height=400
296
+ )
297
+
298
+ fig.update_xaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black')
299
+ fig.update_yaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black')
300
+
301
+ return fig
302
+
303
+ def criar_graficos_dispersao_residuos(X, residuos, max_por_linha=3):
304
+ """
305
+ Cria gráficos de dispersão de cada coluna de X contra os resíduos padronizados.
306
+ Inclui linhas de referência em 0 e ±2.
307
+
308
+ Retorna figura Plotly.
309
+ """
310
+ if X is None or residuos is None or X.empty:
311
+ return None
312
+
313
+ n_colunas = min(max_por_linha, len(X.columns))
314
+ n_linhas = (len(X.columns) + n_colunas - 1) // n_colunas
315
+
316
+ # Nome dos resíduos
317
+ y_nome = "Resíduo Padronizado"
318
+ y_vals = residuos
319
+
320
+ titulos = [f"{col} × {y_nome}" for col in X.columns]
321
+
322
+ fig = make_subplots(
323
+ rows=n_linhas,
324
+ cols=n_colunas,
325
+ subplot_titles=titulos,
326
+ horizontal_spacing=0.05,
327
+ vertical_spacing=0.06,
328
+ )
329
+
330
+ # Obtém índices do DataFrame X
331
+ indices_x = X.index.values
332
+
333
+ for idx, coluna in enumerate(X.columns):
334
+ x_vals = X[coluna].values
335
+
336
+ # Alinha dados válidos
337
+ mask = ~(np.isnan(x_vals) | np.isnan(y_vals))
338
+ x_valid = x_vals[mask]
339
+ y_valid = y_vals[mask]
340
+ indices_valid = indices_x[mask]
341
+
342
+ if len(x_valid) < 1:
343
+ continue
344
+
345
+ row = (idx // n_colunas) + 1
346
+ col = (idx % n_colunas) + 1
347
+
348
+ # Pontos
349
+ fig.add_trace(
350
+ go.Scatter(
351
+ x=x_valid,
352
+ y=y_valid,
353
+ mode='markers',
354
+ name=coluna,
355
+ marker=dict(size=7, color=COR_PONTOS),
356
+ customdata=indices_valid,
357
+ showlegend=False,
358
+ hovertemplate=f"<b>Índice:</b> %{{customdata}}<br><b>{coluna}:</b> %{{x:.2f}}<br><b>Resíduo:</b> %{{y:.2f}}<extra></extra>"
359
+ ),
360
+ row=row, col=col
361
+ )
362
+
363
+ # Linha horizontal em 0
364
+ fig.add_hline(y=0, line_dash="dash", line_color=COR_LINHA, line_width=2, row=row, col=col)
365
+ # Linhas em ±2
366
+ fig.add_hline(y=2, line_dash="dot", line_color='gray', line_width=1, row=row, col=col)
367
+ fig.add_hline(y=-2, line_dash="dot", line_color='gray', line_width=1, row=row, col=col)
368
+
369
+ fig.update_layout(
370
+ height=max(380, 380 * n_linhas),
371
+ showlegend=False,
372
+ plot_bgcolor=COR_FUNDO,
373
+ paper_bgcolor='white',
374
+ margin=dict(t=78, r=20, b=48, l=54),
375
+ )
376
+ fig.update_annotations(showarrow=False)
377
+
378
+ fig.update_xaxes(showgrid=True, gridcolor=COR_GRADE)
379
+ fig.update_yaxes(showgrid=True, gridcolor=COR_GRADE)
380
+
381
+ return fig
382
+
383
+ def criar_grafico_cook(cook_distances, indices=None, limite=None):
384
+ """Gráfico de Distância de Cook."""
385
+ if cook_distances is None:
386
+ return None
387
+
388
+ cook = np.array(cook_distances)
389
+ n = len(cook)
390
+
391
+ # Usa índices fornecidos ou gera sequenciais
392
+ if indices is None:
393
+ indices = np.arange(1, n + 1)
394
+ else:
395
+ indices = np.array(indices)
396
+
397
+ if limite is None:
398
+ limite = 4 / n
399
+
400
+ fig = go.Figure()
401
+
402
+ # Hastes (usa posição sequencial para o eixo X, mas guarda índice real)
403
+ x_pos = np.arange(len(cook))
404
+ for i, (idx, valor) in enumerate(zip(indices, cook)):
405
+ cor = COR_LINHA if valor > limite else COR_PRINCIPAL
406
+ fig.add_trace(go.Scatter(
407
+ x=[i, i],
408
+ y=[0, valor],
409
+ mode='lines',
410
+ line=dict(color=cor, width=1.5),
411
+ showlegend=False,
412
+ hoverinfo='skip'
413
+ ))
414
+
415
+ # Pontos
416
+ cores = [COR_LINHA if v > limite else COR_PRINCIPAL for v in cook]
417
+ fig.add_trace(go.Scatter(
418
+ x=x_pos,
419
+ y=cook,
420
+ mode='markers',
421
+ marker=dict(color=cores, size=8, line=dict(color='black', width=1)),
422
+ name='Distância de Cook',
423
+ showlegend=False,
424
+ customdata=indices,
425
+ hovertemplate='<b>Índice:</b> %{customdata}<br><b>Cook:</b> %{y:.4f}<extra></extra>'
426
+ ))
427
+
428
+ # Linha de corte
429
+ fig.add_hline(
430
+ y=limite,
431
+ line_dash="dash",
432
+ line_color='gray',
433
+ annotation_text=f"4/n = {limite:.4f}",
434
+ annotation_position="top right"
435
+ )
436
+
437
+ fig.update_layout(
438
+ title=dict(text='Distância de Cook', x=0.5),
439
+ xaxis_title='Observação (ver índice no hover)',
440
+ yaxis_title='Distância de Cook',
441
+ showlegend=False,
442
+ plot_bgcolor='white',
443
+ height=400
444
+ )
445
+
446
+ fig.update_xaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black', showticklabels=False)
447
+ fig.update_yaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black')
448
+
449
+ return fig
450
+
451
+
452
+ def criar_matriz_correlacao(df, colunas=None):
453
+ """
454
+ Heatmap de correlação com estilo premium:
455
+ Diagonal limpa, linha divisória e cores customizadas.
456
+ """
457
+ if df is None or df.empty:
458
+ return None
459
+
460
+ # Seleção de colunas e limpeza de dados constantes
461
+ if colunas:
462
+ df_corr = df[colunas].select_dtypes(include=[np.number])
463
+ else:
464
+ df_corr = df.select_dtypes(include=[np.number])
465
+
466
+ if df_corr.shape[1] < 2:
467
+ return None
468
+
469
+ # Garantir que não existam colunas com variância zero (evita NaNs na correlação)
470
+ variancias = df_corr.var(ddof=0)
471
+ df_corr = df_corr.loc[:, variancias.fillna(0) > 0]
472
+
473
+ if df_corr.shape[1] < 2:
474
+ return None
475
+
476
+ # Cálculo da correlação
477
+ corr = df_corr.corr()
478
+
479
+ # --- LÓGICA DE ESTILO DA PRIMEIRA FUNÇÃO ---
480
+
481
+ # 1. Remover diagonal (substitui por NaN para não colorir o Heatmap)
482
+ mask = np.eye(len(corr), dtype=bool)
483
+ corr_masked = corr.where(~mask)
484
+
485
+ # 2. Preparar texto (esconder NaNs da diagonal)
486
+ text = np.where(
487
+ np.isnan(corr_masked.values),
488
+ "",
489
+ np.round(corr_masked.values, 2).astype(str)
490
+ )
491
+
492
+ # 3. Definição da Colorscale customizada (Divergente com centro branco largo)
493
+ custom_colorscale = [
494
+ [0.00, "rgb(103,0,31)"], [0.08, "rgb(178,24,43)"],
495
+ [0.16, "rgb(214,96,77)"], [0.24, "rgb(244,165,130)"],
496
+ [0.32, "rgb(253,219,199)"],
497
+ [0.45, "rgb(255,255,255)"], [0.55, "rgb(255,255,255)"], # Branco neutro
498
+ [0.68, "rgb(209,229,240)"], [0.76, "rgb(146,197,222)"],
499
+ [0.84, "rgb(67,147,195)"], [0.92, "rgb(33,102,172)"],
500
+ [1.00, "rgb(5,48,97)"]
501
+ ]
502
+
503
+ fig = go.Figure(data=go.Heatmap(
504
+ z=corr_masked.values,
505
+ x=corr.columns,
506
+ y=corr.index,
507
+ text=text,
508
+ texttemplate="%{text}",
509
+ textfont=dict(size=10),
510
+ zmin=-1, zmax=1, zmid=0,
511
+ colorscale=custom_colorscale,
512
+ colorbar=dict(title='Correlação'),
513
+ hovertemplate="%{x} × %{y}<br>ρ = %{z:.3f}<extra></extra>"
514
+ ))
515
+
516
+ # 4. Adicionar a linha diagonal visual
517
+ fig.add_shape(
518
+ type="line",
519
+ xref="paper", yref="paper",
520
+ x0=0, y0=1, x1=1, y1=0,
521
+ line=dict(color="rgba(0,0,0,0.35)", width=1),
522
+ layer="above"
523
+ )
524
+
525
+ fig.update_layout(
526
+ title=dict(text="Matriz de Correlação", x=0.5),
527
+ height=600,
528
+ template='plotly_white',
529
+ xaxis=dict(tickangle=45, showgrid=False),
530
+ yaxis=dict(autorange='reversed', showgrid=False)
531
+ )
532
+
533
+ return fig
534
+
535
+ # ============================================================
536
+ # GRÁFICOS COMBINADOS DE DIAGNÓSTICO
537
+ # ============================================================
538
+
539
+ def criar_painel_diagnostico(resultado_modelo):
540
+ """
541
+ Cria painel com todos os gráficos de diagnóstico.
542
+ Retorna dict com figuras individuais.
543
+ """
544
+ if resultado_modelo is None:
545
+ return {}
546
+
547
+ tabela = resultado_modelo.get("tabela_obs_calc")
548
+ if tabela is None:
549
+ return {}
550
+
551
+ # Extrai índices da tabela
552
+ indices = tabela["Índice"].values if "Índice" in tabela.columns else None
553
+
554
+ y_obs = tabela["Observado"].values if "Observado" in tabela.columns else None
555
+ y_calc = tabela["Calculado"].values if "Calculado" in tabela.columns else None
556
+ residuos_pad = tabela["Resíduo Pad."].values if "Resíduo Pad." in tabela.columns else None
557
+ cook = tabela["Cook"].values if "Cook" in tabela.columns else None
558
+
559
+ return {
560
+ "obs_calc": criar_grafico_obs_calc(y_obs, y_calc, indices),
561
+ "residuos": criar_grafico_residuos(y_calc, residuos_pad, indices),
562
+ "histograma": criar_histograma_residuos(residuos_pad),
563
+ "cook": criar_grafico_cook(cook, indices)
564
+ }
565
+
566
+
567
+ # ============================================================
568
+ # MAPA FOLIUM
569
+ # ============================================================
570
+
571
+ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, indice_destacado=None, tamanho_col=None):
572
+ """
573
+ Cria mapa Folium com os dados.
574
+
575
+ Parâmetros:
576
+ df: DataFrame com os dados
577
+ lat_col: nome da coluna de latitude
578
+ lon_col: nome da coluna de longitude
579
+ cor_col: coluna para colorir os pontos (opcional)
580
+ indice_destacado: índice do ponto a destacar (opcional)
581
+ tamanho_col: coluna numérica para dimensionar os círculos (opcional)
582
+
583
+ Retorna:
584
+ HTML do mapa
585
+ """
586
+ # Verifica se colunas existem
587
+ cols_lower = {str(c).lower(): c for c in df.columns}
588
+
589
+ lat_real = None
590
+ lon_real = None
591
+
592
+ for nome in ["lat", "latitude", "siat_latitude"]:
593
+ if nome in cols_lower:
594
+ lat_real = cols_lower[nome]
595
+ break
596
+
597
+ for nome in ["lon", "longitude", "long", "siat_longitude"]:
598
+ if nome in cols_lower:
599
+ lon_real = cols_lower[nome]
600
+ break
601
+
602
+ if lat_real is None or lon_real is None:
603
+ return "<p>Coordenadas (lat/lon) não encontradas nos dados.</p>"
604
+
605
+ # Filtra dados válidos
606
+ df_mapa = df.dropna(subset=[lat_real, lon_real]).copy()
607
+ if df_mapa.empty:
608
+ return "<p>Sem coordenadas válidas para exibir.</p>"
609
+
610
+ # Cria mapa
611
+ centro_lat = df_mapa[lat_real].mean()
612
+ centro_lon = df_mapa[lon_real].mean()
613
+
614
+ m = folium.Map(
615
+ location=[centro_lat, centro_lon],
616
+ zoom_start=12,
617
+ tiles=None
618
+ )
619
+
620
+ # Camadas base
621
+ folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True).add_to(m)
622
+ folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True).add_to(m)
623
+
624
+ # Se tamanho_col fornecido mas cor_col não, usa mesma variável para cor
625
+ if tamanho_col and not cor_col:
626
+ cor_col = tamanho_col
627
+
628
+ # Colormap se houver coluna de cor (verde → vermelho)
629
+ colormap = None
630
+ if cor_col and cor_col in df_mapa.columns:
631
+ vmin = df_mapa[cor_col].min()
632
+ vmax = df_mapa[cor_col].max()
633
+ colormap = cm.LinearColormap(
634
+ colors=["#2ecc71", "#a8e06c", "#f1c40f", "#e67e22", "#e74c3c"],
635
+ vmin=vmin,
636
+ vmax=vmax,
637
+ caption=cor_col
638
+ )
639
+ colormap.add_to(m)
640
+
641
+ # Escala de tamanho proporcional
642
+ raio_min, raio_max = 3, 18
643
+ tamanho_func = None
644
+ if tamanho_col and tamanho_col in df_mapa.columns:
645
+ t_min = df_mapa[tamanho_col].min()
646
+ t_max = df_mapa[tamanho_col].max()
647
+ if t_max > t_min:
648
+ tamanho_func = lambda v, _min=t_min, _max=t_max: raio_min + (v - _min) / (_max - _min) * (raio_max - raio_min)
649
+ else:
650
+ tamanho_func = lambda v: (raio_min + raio_max) / 2
651
+
652
+ # Camada de índices (oculta por padrão, ativável pelo controle de camadas)
653
+ camada_indices = folium.FeatureGroup(name="Índices", show=False)
654
+
655
+ # Adiciona pontos
656
+ for idx, row in df_mapa.iterrows():
657
+ # Cor do ponto
658
+ if colormap and cor_col:
659
+ cor = colormap(row[cor_col])
660
+ else:
661
+ cor = COR_PRINCIPAL
662
+
663
+ # Calcula raio
664
+ if idx == indice_destacado:
665
+ raio = raio_max + 4
666
+ elif tamanho_func:
667
+ raio = tamanho_func(row[tamanho_col])
668
+ else:
669
+ raio = 4
670
+ peso = 3 if idx == indice_destacado else 1
671
+
672
+ # Popup com informações
673
+ popup_html = f"<b>Índice: {idx}</b><br>"
674
+ for col in df_mapa.columns:
675
+ if str(col).lower() not in ['lat', 'latitude', 'lon', 'longitude']:
676
+ val = row[col]
677
+ if isinstance(val, (int, float)):
678
+ popup_html += f"{col}: {val:.2f}<br>"
679
+ else:
680
+ popup_html += f"{col}: {val}<br>"
681
+
682
+ # Tooltip (hover): índice + variável selecionada no dropdown
683
+ tooltip_html = (
684
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:14px;"
685
+ " line-height:1.7; padding:2px 4px;'>"
686
+ f"<b>Índice {idx}</b>"
687
+ )
688
+ if tamanho_col and tamanho_col in df_mapa.columns:
689
+ val_t = row[tamanho_col]
690
+ val_str = f"{val_t:.2f}" if isinstance(val_t, (int, float)) else str(val_t)
691
+ tooltip_html += (
692
+ f"<br><span style='color:#555;'>{tamanho_col}:</span>"
693
+ f" <b>{val_str}</b>"
694
+ )
695
+ tooltip_html += "</div>"
696
+
697
+ folium.CircleMarker(
698
+ location=[row[lat_real], row[lon_real]],
699
+ radius=raio,
700
+ popup=folium.Popup(popup_html, max_width=300),
701
+ tooltip=folium.Tooltip(tooltip_html, sticky=True),
702
+ color='black',
703
+ weight=peso,
704
+ fill=True,
705
+ fillColor=cor,
706
+ fillOpacity=0.8 if idx == indice_destacado else 0.6
707
+ ).add_to(m)
708
+
709
+ # Label com índice (na camada togglável)
710
+ folium.Marker(
711
+ location=[row[lat_real], row[lon_real]],
712
+ icon=folium.DivIcon(
713
+ html=f'<div style="font-size:16px; font-weight:bold; color:#333; text-align:center; line-height:{int(raio*2)}px; width:{int(raio*2)}px; margin-left:-{int(raio)}px; margin-top:-{int(raio)}px;">{idx}</div>',
714
+ icon_size=(int(raio*2), int(raio*2)),
715
+ icon_anchor=(int(raio), int(raio))
716
+ )
717
+ ).add_to(camada_indices)
718
+
719
+ camada_indices.add_to(m)
720
+
721
+ # Controles
722
+ folium.LayerControl().add_to(m)
723
+ plugins.Fullscreen().add_to(m)
724
+ plugins.MeasureControl(
725
+ primary_length_unit='meters',
726
+ secondary_length_unit='kilometers',
727
+ primary_area_unit='sqmeters',
728
+ secondary_area_unit='hectares'
729
+ ).add_to(m)
730
+
731
+ # Ajusta bounds
732
+ bounds = [
733
+ [df_mapa[lat_real].min(), df_mapa[lon_real].min()],
734
+ [df_mapa[lat_real].max(), df_mapa[lon_real].max()]
735
+ ]
736
+ m.fit_bounds(bounds)
737
+
738
+ return m._repr_html_()
739
+
740
+
741
+ def criar_mapa_simples(df):
742
+ """
743
+ Versão simplificada do mapa sem destaque.
744
+ """
745
+ return criar_mapa(df, indice_destacado=None)
backend/app/core/elaboracao/core.py ADDED
@@ -0,0 +1,2077 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ core.py - Lógica de negócio para elaboração de modelos estatísticos
4
+ Contém: carregamento de dados, estatísticas, transformações, modelo OLS, diagnósticos
5
+ """
6
+
7
+ import os
8
+ import pandas as pd
9
+ # Desabilita StringDtype para compatibilidade entre versões do pandas
10
+ pd.set_option('future.infer_string', False)
11
+ import numpy as np
12
+ import statsmodels.api as sm
13
+ from statsmodels.stats.diagnostic import het_breuschpagan
14
+ from statsmodels.stats.stattools import durbin_watson
15
+ from statsmodels.stats.outliers_influence import OLSInfluence
16
+ from scipy.stats import kstest
17
+ from itertools import product
18
+ from joblib import dump, load
19
+ import io
20
+
21
+
22
+ # ============================================================
23
+ # CONSTANTES
24
+ # ============================================================
25
+
26
+ TRANSFORMACOES = ["(x)", "1/(x)", "ln(x)", "exp(x)", "(x)^2", "raiz(x)", "1/raiz(x)"]
27
+
28
+ # Nomes comuns para identificar colunas especiais
29
+ NOMES_VUNIT = {"vunit", "vu", "valor_unitario", "vuloc", "vupriv", "vuaconst", "vuapriv", "valor unitário"}
30
+ NOMES_LAT = {"lat", "latitude", "y", "siat_latitude"}
31
+ NOMES_LON = {"lon", "longitude", "long", "x", "siat_longitude"}
32
+
33
+ # Tabela Durbin-Watson
34
+ DW_TABLE = {
35
+ 0.05: {
36
+ 1: {"n": [25, 50, 100, 200], "dL": [1.10, 1.32, 1.49, 1.57], "dU": [1.54, 1.62, 1.70, 1.75]},
37
+ 2: {"n": [25, 50, 100, 200], "dL": [1.06, 1.30, 1.47, 1.55], "dU": [1.50, 1.60, 1.69, 1.74]},
38
+ 3: {"n": [25, 50, 100, 200], "dL": [1.02, 1.28, 1.45, 1.54], "dU": [1.47, 1.58, 1.68, 1.73]},
39
+ 4: {"n": [25, 50, 100, 200], "dL": [0.98, 1.26, 1.43, 1.52], "dU": [1.44, 1.56, 1.66, 1.72]},
40
+ },
41
+ 0.01: {
42
+ 1: {"n": [25, 50, 100, 200], "dL": [1.05, 1.32, 1.52, 1.66], "dU": [1.21, 1.40, 1.56, 1.68]},
43
+ 2: {"n": [25, 50, 100, 200], "dL": [0.98, 1.28, 1.50, 1.65], "dU": [1.30, 1.45, 1.58, 1.69]},
44
+ 3: {"n": [25, 50, 100, 200], "dL": [0.90, 1.24, 1.48, 1.64], "dU": [1.41, 1.49, 1.60, 1.70]},
45
+ 4: {"n": [25, 50, 100, 200], "dL": [0.83, 1.20, 1.46, 1.63], "dU": [1.52, 1.54, 1.63, 1.72]},
46
+ },
47
+ }
48
+
49
+
50
+ # ============================================================
51
+ # CARREGAMENTO DE DADOS
52
+ # ============================================================
53
+
54
+ def detectar_abas_excel(arquivo):
55
+ """
56
+ Detecta as abas (sheets) de um arquivo Excel.
57
+
58
+ Args:
59
+ arquivo: Pode ser um objeto de arquivo ou um caminho (string)
60
+
61
+ Retorna:
62
+ tuple: (lista_abas, mensagem, sucesso)
63
+ """
64
+ if arquivo is None:
65
+ return [], "Nenhum arquivo enviado.", False
66
+
67
+ try:
68
+ # Obtém o caminho/nome do arquivo
69
+ caminho = arquivo.name if hasattr(arquivo, 'name') else str(arquivo)
70
+
71
+ if caminho.endswith(('.xlsx', '.xls')):
72
+ # Usa o caminho para abrir o arquivo (funciona com path ou file object)
73
+ excel_file = pd.ExcelFile(caminho)
74
+ abas = excel_file.sheet_names
75
+ return abas, f"Arquivo com {len(abas)} aba(s) detectada(s)", True
76
+ else:
77
+ # CSV não tem abas
78
+ return [], "Arquivo CSV (sem abas)", True
79
+
80
+ except Exception as e:
81
+ return [], f"Erro ao detectar abas: {str(e)}", False
82
+
83
+
84
+ def carregar_arquivo(arquivo, nome_aba=None):
85
+ """
86
+ Carrega arquivo Excel ou CSV e retorna DataFrame.
87
+ Detecta automaticamente o separador do CSV.
88
+
89
+ Args:
90
+ arquivo: Arquivo a ser carregado (objeto de arquivo ou caminho string)
91
+ nome_aba: Nome da aba a carregar (apenas para Excel). Se None, carrega a primeira.
92
+
93
+ Retorna:
94
+ tuple: (df, mensagem, sucesso)
95
+ """
96
+ if arquivo is None:
97
+ return None, "Nenhum arquivo enviado.", False
98
+
99
+ try:
100
+ # Obtém o caminho do arquivo
101
+ caminho = arquivo.name if hasattr(arquivo, 'name') else str(arquivo)
102
+
103
+ if caminho.endswith(('.xlsx', '.xls')):
104
+ # Carrega aba específica ou a primeira (usa caminho para poder reabrir)
105
+ df = pd.read_excel(caminho, sheet_name=nome_aba if nome_aba else 0)
106
+ aba_info = f" (aba: {nome_aba})" if nome_aba else ""
107
+ elif caminho.endswith('.csv'):
108
+ # Tenta detectar separador
109
+ with open(caminho, 'rb') as f:
110
+ content = f.read()
111
+
112
+ # Tenta diferentes separadores
113
+ for sep in [',', ';', '\t']:
114
+ try:
115
+ df = pd.read_csv(io.BytesIO(content), sep=sep)
116
+ if len(df.columns) > 1:
117
+ break
118
+ except:
119
+ continue
120
+ else:
121
+ df = pd.read_csv(io.BytesIO(content))
122
+ aba_info = ""
123
+ else:
124
+ return None, "Formato de arquivo não suportado.", False
125
+
126
+ # Reinicia índice começando em 1
127
+ df = df.reset_index(drop=True)
128
+ df.index = df.index + 1
129
+
130
+ # Garante nomes de colunas como string (evita mismatch com colunas numéricas, ex: anos)
131
+ df.columns = df.columns.astype(str)
132
+
133
+ return df, f"Arquivo carregado: {os.path.basename(caminho)}{aba_info} ({len(df)} linhas, {len(df.columns)} colunas)", True
134
+
135
+ except Exception as e:
136
+ return None, f"Erro ao carregar arquivo: {str(e)}", False
137
+
138
+
139
+ def _migrar_pacote_v1_para_v2(pacote):
140
+ """Converte pacote .dai v1 (flat) para estrutura v2 (nested)."""
141
+ resumo = pacote.get("modelos_resumos", {})
142
+ return {
143
+ "versao": 2,
144
+ "dados": {
145
+ "df": pacote["Xy_preview_out_coords"],
146
+ "estatisticas": pacote["estatisticas"],
147
+ },
148
+ "transformacoes": {
149
+ "info": pacote["formatted_top_transformation_info"],
150
+ "X": pacote["top_X_esc"],
151
+ "y": pacote["top_y_esc"],
152
+ "dicotomicas": pacote.get("dicotomicas", []),
153
+ },
154
+ "modelo": {
155
+ "sm": pacote["modelos_sm"],
156
+ "coeficientes": pacote["tabelas_coef"],
157
+ "obs_calc": pacote["tabelas_obs_calc"],
158
+ "diagnosticos": {
159
+ "gerais": {
160
+ "n": resumo.get("n"),
161
+ "k": resumo.get("k"),
162
+ "desvio_padrao_residuos": resumo.get("desvio_padrao_residuos"),
163
+ "mse": resumo.get("mse"),
164
+ "r2": resumo.get("r2"),
165
+ "r2_ajustado": resumo.get("r2_ajustado"),
166
+ "r_pearson": resumo.get("r_pearson"),
167
+ },
168
+ "teste_f": {
169
+ "estatistica": resumo.get("Fc"),
170
+ "p_valor": resumo.get("p_valor_F"),
171
+ "interpretacao": resumo.get("Interpretacao_F"),
172
+ },
173
+ "teste_ks": {
174
+ "estatistica": resumo.get("ks_stat"),
175
+ "p_valor": resumo.get("ks_p"),
176
+ "interpretacao": resumo.get("Interpretacao_KS"),
177
+ },
178
+ "teste_normalidade": {
179
+ "percentuais": resumo.get("perc_resid"),
180
+ },
181
+ "teste_dw": {
182
+ "estatistica": resumo.get("dw"),
183
+ "interpretacao": resumo.get("Interpretacao_DW"),
184
+ },
185
+ "teste_bp": {
186
+ "estatistica": resumo.get("bp_lm"),
187
+ "p_valor": resumo.get("bp_p"),
188
+ "interpretacao": resumo.get("Interpretacao_BP"),
189
+ },
190
+ "equacao": resumo.get("equacao"),
191
+ },
192
+ },
193
+ }
194
+
195
+
196
+ def carregar_dai(caminho):
197
+ """
198
+ Carrega arquivo .dai e extrai DataFrame, variáveis e transformações.
199
+
200
+ Retorna:
201
+ tuple: (df, coluna_y, colunas_x, transformacao_y, transformacoes_x, dicotomicas, codigo_alocado, percentuais, mensagem, sucesso)
202
+ """
203
+ try:
204
+ pacote = load(caminho)
205
+
206
+ # Retrocompatibilidade: converte v1 (flat) para v2 (nested)
207
+ if "versao" not in pacote:
208
+ pacote = _migrar_pacote_v1_para_v2(pacote)
209
+
210
+ # Extrai novos campos (se existirem)
211
+ df_completo = pacote["dados"].get("df_completo", None)
212
+ outliers_excluidos = pacote["dados"].get("outliers_excluidos", [])
213
+
214
+ # Extrai DataFrame — preserva índices originais se df_completo disponível
215
+ if df_completo is not None:
216
+ # .dai novo: usa DataFrame completo com índices originais (sem reset!)
217
+ df = df_completo.copy()
218
+ else:
219
+ # .dai antigo: usa df filtrado e renumera índices (retrocompat)
220
+ df = pacote["dados"]["df"].copy()
221
+ df = df.reset_index(drop=True)
222
+ df.index = df.index + 1
223
+
224
+ # Normaliza nomes de colunas para string (evita mismatch com colunas numéricas, ex: anos)
225
+ df.columns = df.columns.astype(str)
226
+
227
+ # Parseia transformações
228
+ info_transf = pacote["transformacoes"]["info"]
229
+
230
+ # Primeiro elemento: Y (ex: "VUNIT: ln(y)")
231
+ nome_y, transf_y = info_transf[0].split(": ", 1)
232
+ coluna_y = nome_y.strip()
233
+ transformacao_y = transf_y.strip().replace("(y)", "(x)")
234
+
235
+ # Demais: X (ex: "Area: ln(x)")
236
+ colunas_x = []
237
+ transformacoes_x = {}
238
+ for item in info_transf[1:]:
239
+ nome_x, transf_x = item.split(": ", 1)
240
+ nome_x = nome_x.strip()
241
+ colunas_x.append(nome_x)
242
+ transformacoes_x[nome_x] = transf_x.strip()
243
+
244
+ # Extrai dicotômicas, código alocado e percentuais (3 listas separadas)
245
+ dicotomicas = pacote["transformacoes"].get("dicotomicas", None)
246
+ codigo_alocado_salvo = pacote["transformacoes"].get("codigo_alocado", None)
247
+ percentuais_salvo = pacote["transformacoes"].get("percentuais", None)
248
+
249
+ # Normaliza para string (colunas numéricas ficam como inteiros quando serializadas diretamente)
250
+ if dicotomicas is not None:
251
+ dicotomicas = [str(c) for c in dicotomicas]
252
+ if codigo_alocado_salvo is not None:
253
+ codigo_alocado_salvo = [str(c) for c in codigo_alocado_salvo]
254
+ if percentuais_salvo is not None:
255
+ percentuais_salvo = [str(c) for c in percentuais_salvo]
256
+
257
+ if dicotomicas is None:
258
+ # Retrocompat: .dai antigos sem nenhuma info
259
+ dicotomicas = [col for col in colunas_x
260
+ if set(df[col].dropna().unique()).issubset({0, 1, 0.0, 1.0})]
261
+ codigo_alocado_salvo = detectar_codigo_alocado(df, colunas_x)
262
+ percentuais_salvo = detectar_percentuais(df, colunas_x)
263
+ elif codigo_alocado_salvo is None:
264
+ # Retrocompat: .dai com "dicotomicas" misturadas (dic+cod), sem separação
265
+ reais_01 = [c for c in dicotomicas if set(df[c].dropna().unique()).issubset({0, 1, 0.0, 1.0})]
266
+ codigo_alocado_salvo = [c for c in dicotomicas if c not in reais_01]
267
+ dicotomicas = reais_01
268
+ percentuais_salvo = detectar_percentuais(df, colunas_x)
269
+
270
+ elaborador = pacote.get("elaborador", None)
271
+ msg = f"Modelo .dai carregado: {os.path.basename(caminho)} ({len(df)} dados, Y={coluna_y}, {len(colunas_x)} variáveis X)"
272
+ return df, coluna_y, colunas_x, transformacao_y, transformacoes_x, dicotomicas, codigo_alocado_salvo or [], percentuais_salvo or [], msg, True, elaborador, outliers_excluidos
273
+
274
+ except Exception as e:
275
+ return None, None, None, None, None, [], [], [], f"Erro ao carregar .dai: {str(e)}", False, None, []
276
+
277
+
278
+ def identificar_coluna_y_padrao(df):
279
+ """
280
+ Identifica a coluna padrão para variável dependente (y).
281
+ Prioriza VUNIT se existir.
282
+ """
283
+ colunas_lower = {str(col).lower(): col for col in df.columns}
284
+
285
+ for nome in NOMES_VUNIT:
286
+ if nome in colunas_lower:
287
+ return colunas_lower[nome]
288
+
289
+ # Se não encontrar, retorna primeira coluna numérica
290
+ numericas = df.select_dtypes(include=[np.number]).columns.tolist()
291
+ return numericas[0] if numericas else None
292
+
293
+
294
+ def identificar_colunas_coords(df):
295
+ """
296
+ Identifica colunas de latitude e longitude.
297
+ Retorna tuple (lat_col, lon_col) ou (None, None).
298
+ """
299
+ colunas_lower = {str(col).lower(): col for col in df.columns}
300
+
301
+ lat_col = None
302
+ lon_col = None
303
+
304
+ for nome in NOMES_LAT:
305
+ if nome in colunas_lower:
306
+ lat_col = colunas_lower[nome]
307
+ break
308
+
309
+ for nome in NOMES_LON:
310
+ if nome in colunas_lower:
311
+ lon_col = colunas_lower[nome]
312
+ break
313
+
314
+ return lat_col, lon_col
315
+
316
+
317
+ def obter_colunas_numericas(df, excluir_coords=True):
318
+ """
319
+ Retorna lista de colunas numéricas.
320
+ Opcionalmente exclui lat/lon.
321
+ """
322
+ numericas = df.select_dtypes(include=[np.number]).columns.tolist()
323
+
324
+ if excluir_coords:
325
+ lat_col, lon_col = identificar_colunas_coords(df)
326
+ if lat_col and lat_col in numericas:
327
+ numericas.remove(lat_col)
328
+ if lon_col and lon_col in numericas:
329
+ numericas.remove(lon_col)
330
+
331
+ return numericas
332
+
333
+
334
+ # ============================================================
335
+ # ESTATÍSTICAS
336
+ # ============================================================
337
+
338
+ def calcular_estatisticas_variaveis(df, coluna_y=None, indices_usar=None, colunas=None):
339
+ """
340
+ Calcula estatísticas para cada coluna numérica.
341
+ Se coluna_y for especificada, inclui correlação com y.
342
+ Se colunas for especificada, calcula apenas para essas colunas.
343
+
344
+ Parâmetros:
345
+ df: DataFrame com os dados
346
+ coluna_y: nome da coluna da variável dependente (opcional)
347
+ indices_usar: lista de índices a incluir (se None, usa todos)
348
+ colunas: lista de colunas a calcular (se None, usa todas numéricas)
349
+
350
+ Retorna DataFrame com estatísticas.
351
+ """
352
+ # Filtra por índices se especificado
353
+ df_calc = df.copy()
354
+ if indices_usar is not None:
355
+ df_calc = df_calc.loc[indices_usar]
356
+
357
+ # Usa colunas especificadas ou todas numéricas
358
+ if colunas is not None:
359
+ numericas = [c for c in colunas if c in df_calc.columns and pd.api.types.is_numeric_dtype(df_calc[c])]
360
+ else:
361
+ numericas = obter_colunas_numericas(df_calc)
362
+
363
+ estatisticas = []
364
+
365
+ for col in numericas:
366
+ serie = df_calc[col].dropna()
367
+
368
+ stats = {
369
+ "Variável": col,
370
+ "Contagem": len(serie),
371
+ "Média": serie.mean(),
372
+ "Mediana": serie.median(),
373
+ "Mínimo": serie.min(),
374
+ "Máximo": serie.max(),
375
+ "Desvio Padrão": serie.std(),
376
+ }
377
+
378
+ # Coeficiente de variação
379
+ if stats["Média"] != 0:
380
+ stats["CV (%)"] = abs(stats["Desvio Padrão"] / stats["Média"]) * 100
381
+ else:
382
+ stats["CV (%)"] = np.nan
383
+
384
+ # Correlação com y
385
+ if coluna_y and col != coluna_y:
386
+ y_serie = df_calc[coluna_y].dropna()
387
+ comum = serie.index.intersection(y_serie.index)
388
+ if len(comum) > 2:
389
+ stats["Correlação (y)"] = np.corrcoef(serie.loc[comum], y_serie.loc[comum])[0, 1]
390
+ else:
391
+ stats["Correlação (y)"] = np.nan
392
+ else:
393
+ stats["Correlação (y)"] = np.nan if col != coluna_y else 1.0
394
+
395
+ # Detecta se é dicotômica
396
+ valores_unicos = set(serie.unique())
397
+ stats["Dicotômica"] = valores_unicos.issubset({0, 1, 0.0, 1.0})
398
+
399
+ estatisticas.append(stats)
400
+
401
+ df_stats = pd.DataFrame(estatisticas)
402
+
403
+ # Ordena por correlação absoluta (maiores primeiro)
404
+ if coluna_y:
405
+ df_stats["_abs_corr"] = df_stats["Correlação (y)"].abs()
406
+ df_stats = df_stats.sort_values("_abs_corr", ascending=False)
407
+ df_stats = df_stats.drop(columns=["_abs_corr"])
408
+
409
+ return df_stats.reset_index(drop=True)
410
+
411
+
412
+ # ============================================================
413
+ # ANÁLISE DE OUTLIERS
414
+ # ============================================================
415
+
416
+ def calcular_metricas_outliers(df, coluna_y, colunas_x, indices_participantes=None):
417
+ """
418
+ Ajusta modelo simples (sem transformações) para calcular métricas de outliers.
419
+
420
+ Parâmetros:
421
+ df: DataFrame com os dados
422
+ coluna_y: nome da coluna da variável dependente
423
+ colunas_x: lista de nomes das colunas independentes
424
+ indices_participantes: lista de índices a incluir (se None, usa todos)
425
+
426
+ Retorna:
427
+ DataFrame com métricas: Índice, Observado, Calculado, Resíduo,
428
+ Resíduo Pad., Resíduo Stud., Cook
429
+ """
430
+ if not colunas_x:
431
+ return None
432
+
433
+ # Filtra por participantes
434
+ df_calc = df.copy()
435
+ if indices_participantes is not None:
436
+ df_calc = df_calc.loc[indices_participantes]
437
+
438
+ # Prepara y (sem transformação)
439
+ y = df_calc[coluna_y].values
440
+
441
+ # Prepara X (sem transformação)
442
+ X = df_calc[colunas_x].copy()
443
+
444
+ # Remove linhas com NaN ou Inf
445
+ mask = ~(np.isnan(y) | np.isinf(y))
446
+ for col in X.columns:
447
+ mask &= ~(np.isnan(X[col]) | np.isinf(X[col]))
448
+
449
+ y_final = y[mask]
450
+ X_final = X.loc[mask.values if hasattr(mask, 'values') else mask]
451
+ indices_final = df_calc.index[mask]
452
+
453
+ if len(y_final) < len(colunas_x) + 2:
454
+ return None
455
+
456
+ try:
457
+ # Ajusta modelo simples
458
+ X_sm = sm.add_constant(X_final)
459
+ modelo = sm.OLS(y_final, X_sm).fit()
460
+
461
+ # Calcula métricas
462
+ residuos = modelo.resid
463
+ n = len(y_final)
464
+ k = len(colunas_x)
465
+ desvio_padrao = np.sqrt(np.sum(residuos**2) / (n - k - 1))
466
+ residuos_pad = residuos / desvio_padrao
467
+
468
+ influence = OLSInfluence(modelo)
469
+ residuos_stud = influence.resid_studentized
470
+ cook = influence.cooks_distance[0]
471
+
472
+ # Monta DataFrame
473
+ df_metricas = pd.DataFrame({
474
+ "Índice": indices_final,
475
+ "Observado": y_final,
476
+ "Calculado": modelo.predict(),
477
+ "Resíduo": residuos,
478
+ "Resíduo Pad.": residuos_pad,
479
+ "Resíduo Stud.": residuos_stud,
480
+ "Cook": cook
481
+ })
482
+ df_metricas = df_metricas.set_index("Índice")
483
+
484
+ return df_metricas
485
+
486
+ except Exception as e:
487
+ print(f"Erro ao calcular métricas de outliers: {e}")
488
+ return None
489
+
490
+
491
+ def sugerir_outliers(df_metricas, coluna, valor):
492
+ """
493
+ Aplica filtro simétrico e retorna lista de índices sugeridos como outliers.
494
+ Filtra: coluna <= -valor OU coluna >= +valor
495
+
496
+ Parâmetros:
497
+ df_metricas: DataFrame com métricas de outliers
498
+ coluna: nome da coluna para filtro (ex: "Resíduo Pad.")
499
+ valor: valor absoluto do limite (ex: 2.0 significa <= -2 OU >= +2)
500
+
501
+ Retorna:
502
+ Lista de índices sugeridos como outliers
503
+ """
504
+ if df_metricas is None or df_metricas.empty:
505
+ return []
506
+
507
+ if coluna not in df_metricas.columns:
508
+ return []
509
+
510
+ # Filtro simétrico: <= -valor OU >= +valor
511
+ mask = (df_metricas[coluna] <= -valor) | (df_metricas[coluna] >= valor)
512
+ indices = df_metricas[mask].index.tolist()
513
+
514
+ return sorted(indices)
515
+
516
+
517
+ # ============================================================
518
+ # TRANSFORMAÇÕES
519
+ # ============================================================
520
+
521
+ def aplicar_transformacao(data, transformacao):
522
+ """
523
+ Aplica uma transformação matemática aos dados.
524
+ """
525
+ data = np.asarray(data, dtype=float)
526
+
527
+ # Evita overflow em exp
528
+ if transformacao == "exp(x)" and (data > 50).any():
529
+ return data
530
+
531
+ if transformacao == "(x)":
532
+ return data
533
+ elif transformacao == "1/(x)":
534
+ return 1 / (data + 0.001)
535
+ elif transformacao == "ln(x)":
536
+ return np.log(np.maximum(data, 0.001))
537
+ elif transformacao == "exp(x)":
538
+ return np.exp(data)
539
+ elif transformacao == "(x)^2":
540
+ return data ** 2
541
+ elif transformacao == "raiz(x)":
542
+ return np.sqrt(np.maximum(data, 0))
543
+ elif transformacao == "1/raiz(x)":
544
+ return 1 / (np.sqrt(np.maximum(data, 0)) + 0.001)
545
+ else:
546
+ return data
547
+
548
+
549
+ def inverter_transformacao_y(data, transformacao):
550
+ """
551
+ Inverte a transformação aplicada em y para obter valores na escala original.
552
+ """
553
+ data = np.asarray(data, dtype=float)
554
+
555
+ if transformacao in ["(y)", "(x)", "direct"]:
556
+ return data
557
+ elif transformacao in ["ln(y)", "ln(x)"]:
558
+ return np.exp(data)
559
+ elif transformacao in ["1/(y)", "1/(x)"]:
560
+ return 1 / (data + 0.001)
561
+ elif transformacao in ["exp(y)", "exp(x)"]:
562
+ return np.log(np.maximum(data, 0.001))
563
+ elif transformacao in ["(y)^2", "(x)^2"]:
564
+ return np.sqrt(np.maximum(data, 0))
565
+ elif transformacao in ["raiz(y)", "raiz(x)"]:
566
+ return data ** 2
567
+ elif transformacao in ["1/raiz(y)", "1/raiz(x)"]:
568
+ return 1 / (data ** 2 + 0.001)
569
+ else:
570
+ return data
571
+
572
+
573
+ def formatar_transformacao(transformacao, is_y=False):
574
+ """
575
+ Formata a string da transformação para exibição.
576
+ """
577
+ if transformacao == "(x)":
578
+ return "(y)" if is_y else "(x)"
579
+ elif transformacao == "1/(x)":
580
+ return "1/(y)" if is_y else "1/(x)"
581
+ elif transformacao == "ln(x)":
582
+ return "ln(y)" if is_y else "ln(x)"
583
+ elif transformacao == "exp(x)":
584
+ return "exp(y)" if is_y else "exp(x)"
585
+ elif transformacao == "(x)^2":
586
+ return "(y)^2" if is_y else "(x)^2"
587
+ elif transformacao == "raiz(x)":
588
+ return "raiz(y)" if is_y else "raiz(x)"
589
+ elif transformacao == "1/raiz(x)":
590
+ return "1/raiz(y)" if is_y else "1/raiz(x)"
591
+ else:
592
+ return transformacao
593
+
594
+
595
+ def detectar_dicotomicas(df, colunas):
596
+ """
597
+ Detecta quais colunas são dicotômicas (apenas 0 e 1).
598
+ Retorna lista de nomes de colunas dicotômicas.
599
+ """
600
+ dicotomicas = []
601
+ for col in colunas:
602
+ valores = set(df[col].dropna().unique())
603
+ if valores.issubset({0, 1, 0.0, 1.0}):
604
+ dicotomicas.append(col)
605
+ return dicotomicas
606
+
607
+
608
+ def detectar_codigo_alocado(df, colunas):
609
+ """
610
+ Detecta variáveis de código alocado/ajustado.
611
+ Critérios: todos os valores são inteiros, ≥3 valores distintos, nenhum zero.
612
+ Exclui variáveis já detectadas como dicotômicas (0/1).
613
+ Retorna lista de nomes de colunas de código alocado.
614
+ """
615
+ resultado = []
616
+ for col in colunas:
617
+ valores = df[col].dropna().unique()
618
+ valores_set = set(valores)
619
+ # Pelo menos 3 valores distintos
620
+ if len(valores_set) < 3:
621
+ continue
622
+ # Todos devem ser inteiros
623
+ try:
624
+ if not all(float(v) == int(float(v)) for v in valores):
625
+ continue
626
+ except (ValueError, TypeError):
627
+ continue
628
+ # Nenhum valor zero
629
+ if any(float(v) == 0 for v in valores):
630
+ continue
631
+ resultado.append(col)
632
+ return resultado
633
+
634
+
635
+ def detectar_percentuais(df, colunas):
636
+ """
637
+ Detecta variáveis percentuais (todos valores entre 0 e 1, com decimais ou >2 distintos).
638
+ Exclui variáveis já detectadas como dicotômicas puras (só {0, 1}).
639
+ Retorna lista de nomes de colunas percentuais.
640
+ """
641
+ resultado = []
642
+ for col in colunas:
643
+ valores = df[col].dropna().unique()
644
+ if len(valores) < 2:
645
+ continue
646
+ # Todos devem estar entre 0 e 1
647
+ try:
648
+ if not all(0 <= float(v) <= 1 for v in valores):
649
+ continue
650
+ except (ValueError, TypeError):
651
+ continue
652
+ # Não pode ser dicotômica pura (só {0,1})
653
+ if set(valores).issubset({0, 1, 0.0, 1.0}):
654
+ continue
655
+ resultado.append(col)
656
+ return resultado
657
+
658
+
659
+ # ============================================================
660
+ # VERIFICAÇÃO DE MULTICOLINEARIDADE
661
+ # ============================================================
662
+
663
+ def verificar_multicolinearidade(df, colunas_x):
664
+ """Verifica multicolinearidade na matriz de regressoras (dados brutos, sem transformação).
665
+
666
+ Calcula o posto da matriz (X + intercepto) para detectar dependência linear exata
667
+ e o VIF de cada variável para detectar colinearidade forte (mas não perfeita).
668
+
669
+ Retorna dict com:
670
+ 'perfeita': bool — posto deficiente (matriz singular)
671
+ 'alta': bool — alguma variável com VIF > 10
672
+ 'vif': dict {col: float} — VIF de cada variável (vazio se perfeita ou < 2 vars)
673
+ 'vars_alta': list — variáveis com VIF > 10
674
+ 'posto': int — posto efetivo da matriz aumentada
675
+ 'ncolunas': int — número de colunas da matriz aumentada
676
+ """
677
+ from statsmodels.stats.outliers_influence import variance_inflation_factor
678
+
679
+ n_vars = len(colunas_x)
680
+ vazio = {'perfeita': False, 'alta': False, 'vif': {}, 'vars_alta': [], 'posto': 0, 'ncolunas': 0}
681
+
682
+ if n_vars < 2:
683
+ return vazio
684
+
685
+ try:
686
+ X_df = df[list(colunas_x)].dropna()
687
+ if len(X_df) == 0:
688
+ return vazio
689
+ X = X_df.values.astype(float)
690
+
691
+ # Remove colunas com variância zero (constantes puras — capturadas como dependência com intercepto)
692
+ std = X.std(axis=0)
693
+ X = X[:, std > 0]
694
+ cols_validas = [c for c, s in zip(colunas_x, std) if s > 0]
695
+
696
+ if len(cols_validas) < 2:
697
+ return vazio
698
+
699
+ X_const = np.column_stack([np.ones(len(X)), X])
700
+ posto = int(np.linalg.matrix_rank(X_const))
701
+ ncolunas = X_const.shape[1]
702
+
703
+ if posto < ncolunas:
704
+ return {
705
+ 'perfeita': True, 'alta': True, 'vif': {}, 'vars_alta': [],
706
+ 'posto': posto, 'ncolunas': ncolunas,
707
+ }
708
+
709
+ # Calcula VIF apenas quando n > k (amostra suficiente)
710
+ if len(X_const) <= ncolunas:
711
+ return vazio
712
+
713
+ vif = {}
714
+ for i, col in enumerate(cols_validas):
715
+ try:
716
+ v = float(variance_inflation_factor(X_const, i + 1)) # +1 pula intercepto
717
+ vif[col] = v
718
+ except Exception:
719
+ vif[col] = float('inf') # OLS interno singular → colinearidade exata para esta variável
720
+
721
+ # Verifica se algum VIF é infinito (colinearidade que o rank numérico não capturou)
722
+ if any(np.isinf(v) for v in vif.values()):
723
+ vars_inf = [c for c, v in vif.items() if np.isinf(v)]
724
+ vif_finito = {c: v for c, v in vif.items() if not np.isinf(v)}
725
+ return {
726
+ 'perfeita': True, 'alta': True,
727
+ 'vif': vif_finito, 'vars_alta': vars_inf,
728
+ 'posto': posto, 'ncolunas': ncolunas,
729
+ }
730
+
731
+ vars_alta = [c for c, v in vif.items() if v > 10]
732
+ return {
733
+ 'perfeita': False,
734
+ 'alta': len(vars_alta) > 0,
735
+ 'vif': vif,
736
+ 'vars_alta': vars_alta,
737
+ 'posto': posto,
738
+ 'ncolunas': ncolunas,
739
+ }
740
+ except Exception:
741
+ return vazio
742
+
743
+
744
+ # ============================================================
745
+ # MODELO OLS E DIAGNÓSTICOS
746
+ # ============================================================
747
+
748
+ def ajustar_modelo(df, coluna_y, colunas_x, transformacao_y, transformacoes_x, indices_usar=None):
749
+ """
750
+ Ajusta modelo OLS com as transformações especificadas.
751
+
752
+ Parâmetros:
753
+ df: DataFrame com os dados
754
+ coluna_y: nome da coluna da variável dependente
755
+ colunas_x: lista de nomes das colunas independentes
756
+ transformacao_y: transformação para y
757
+ transformacoes_x: dict {coluna: transformação} para X
758
+ indices_usar: lista de índices a incluir (se None, usa todos)
759
+
760
+ Retorna:
761
+ dict com resultados do modelo ou None se falhar
762
+ """
763
+ if not colunas_x:
764
+ return None
765
+
766
+ # Filtra por índices se especificado
767
+ df_modelo = df.copy()
768
+ if indices_usar is not None:
769
+ df_modelo = df_modelo.loc[indices_usar]
770
+
771
+ # Prepara y transformado
772
+ y = df_modelo[coluna_y].values
773
+ y_transf = aplicar_transformacao(y, transformacao_y)
774
+
775
+ # Prepara X transformado
776
+ X_transf = pd.DataFrame(index=df_modelo.index)
777
+ for col in colunas_x:
778
+ transf = transformacoes_x.get(col, "(x)")
779
+ X_transf[col] = aplicar_transformacao(df_modelo[col].values, transf)
780
+
781
+ # Remove linhas com NaN ou Inf
782
+ mask = ~(np.isnan(y_transf) | np.isinf(y_transf))
783
+ for col in X_transf.columns:
784
+ mask &= ~(np.isnan(X_transf[col]) | np.isinf(X_transf[col]))
785
+
786
+ y_final = y_transf[mask]
787
+ X_final = X_transf.loc[mask.values if hasattr(mask, 'values') else mask]
788
+ indices_final = df_modelo.index[mask]
789
+
790
+ if len(y_final) < len(colunas_x) + 2:
791
+ return None
792
+
793
+ try:
794
+ # Ajusta modelo
795
+ X_sm = sm.add_constant(X_final)
796
+ modelo = sm.OLS(y_final, X_sm).fit()
797
+
798
+ # Calcula diagnósticos
799
+ diagnosticos = calcular_diagnosticos(modelo, X_final, y_final, coluna_y, transformacao_y, transformacoes_x)
800
+
801
+ # Tabela de coeficientes
802
+ tabela_coef = pd.DataFrame({
803
+ "Variável": modelo.params.index,
804
+ "Coeficiente": modelo.params.values,
805
+ "Erro Padrão": modelo.bse.values,
806
+ "t-Student": modelo.tvalues.values,
807
+ "p-valor": modelo.pvalues.values,
808
+ "Significância": [classificar_significancia(p) for p in modelo.pvalues.values]
809
+ })
810
+
811
+ # Tabela obs vs calc
812
+ y_obs = y_final
813
+ y_calc = modelo.predict()
814
+ residuos = modelo.resid
815
+ residuos_pad = residuos / diagnosticos["desvio_padrao_residuos"]
816
+
817
+ influence = OLSInfluence(modelo)
818
+ residuos_stud = influence.resid_studentized
819
+ cook = influence.cooks_distance[0]
820
+
821
+ tabela_obs_calc = pd.DataFrame({
822
+ "Índice": indices_final,
823
+ "Observado": y_obs,
824
+ "Calculado": y_calc,
825
+ "Resíduo": residuos,
826
+ "Resíduo Pad.": residuos_pad,
827
+ "Resíduo Stud.": residuos_stud,
828
+ "Cook": cook
829
+ })
830
+
831
+ # Dados transformados
832
+ X_esc_info = [f"{col}: {formatar_transformacao(transformacoes_x.get(col, '(x)'))}" for col in colunas_x]
833
+ y_esc_info = f"{coluna_y}: {formatar_transformacao(transformacao_y, is_y=True)}"
834
+
835
+ return {
836
+ "modelo_sm": modelo,
837
+ "diagnosticos": diagnosticos,
838
+ "tabela_coef": tabela_coef,
839
+ "tabela_obs_calc": tabela_obs_calc,
840
+ "X_transformado": X_final,
841
+ "y_transformado": pd.Series(y_final, index=indices_final, name=coluna_y),
842
+ "transformacoes_x": transformacoes_x.copy(),
843
+ "transformacao_y": transformacao_y,
844
+ "colunas_x": colunas_x.copy(),
845
+ "coluna_y": coluna_y,
846
+ "X_esc_info": X_esc_info,
847
+ "y_esc_info": y_esc_info,
848
+ "indices_usados": indices_final.tolist()
849
+ }
850
+
851
+ except Exception as e:
852
+ print(f"Erro ao ajustar modelo: {e}")
853
+ return None
854
+
855
+
856
+ def calcular_diagnosticos(modelo, X, y, coluna_y, transformacao_y, transformacoes_x):
857
+ """
858
+ Calcula todos os diagnósticos do modelo.
859
+ """
860
+ n = len(y)
861
+ k = X.shape[1]
862
+ residuos = modelo.resid
863
+
864
+ desvio_padrao_residuos = np.sqrt(np.sum(residuos**2) / (n - k - 1))
865
+ residuos_pad = residuos / desvio_padrao_residuos
866
+
867
+ # Métricas básicas
868
+ mse = np.mean(residuos**2)
869
+ r2 = modelo.rsquared
870
+ r2_ajustado = modelo.rsquared_adj
871
+ r_pearson = np.corrcoef(y, modelo.predict())[0, 1]
872
+ Fc = modelo.fvalue
873
+ p_valor_F = modelo.f_pvalue
874
+
875
+ # Interpretação F
876
+ if p_valor_F < 0.01:
877
+ interp_F = "✅ Fc > Ft: Significante ao nível de 1% (α = 1%) — Grau III"
878
+ elif p_valor_F < 0.02:
879
+ interp_F = "✅ Fc > Ft: Significante ao nível de 2% (α = 2%) — Grau II"
880
+ elif p_valor_F < 0.05:
881
+ interp_F = "✅ Fc > Ft: Significante ao nível de 5% (α = 5%) — Grau I"
882
+ else:
883
+ interp_F = "❌ Fc < Ft: Não significante ao nível de 5%"
884
+
885
+ # Teste KS (normalidade)
886
+ ks_stat, ks_p = kstest(residuos, "norm", args=(np.mean(residuos), np.std(residuos, ddof=1)))
887
+ if ks_p >= 0.05:
888
+ interp_KS = "✅ Não foi identificada evidência estatística de violação da normalidade dos resíduos (α = 5%)"
889
+ elif ks_p >= 0.02:
890
+ interp_KS = "❌ Rejeita-se a hipótese de normalidade dos resíduos (α = 5%)"
891
+ elif ks_p >= 0.01:
892
+ interp_KS = "❌ Rejeita-se a hipótese de normalidade dos resíduos (α = 2%)"
893
+ else:
894
+ interp_KS = "❌ Rejeita-se a hipótese de normalidade dos resíduos (α = 1%)"
895
+
896
+ # Percentuais da curva normal (formato compatível com .dai)
897
+ intervalos = [(-1.00, 1.00), (-1.64, 1.64), (-1.96, 1.96)]
898
+ percentuais = []
899
+ for min_int, max_int in intervalos:
900
+ count = np.sum((residuos_pad >= min_int) & (residuos_pad <= max_int))
901
+ perc = round(count / len(residuos) * 100, 0)
902
+ percentuais.append(f"{perc:.0f}%")
903
+ perc_resid = ", ".join(percentuais)
904
+
905
+ # Durbin-Watson
906
+ dw = durbin_watson(residuos)
907
+ k_dw = min(max(1, k), 4)
908
+
909
+ # Limites críticos a 5% (referência padrão)
910
+ tab5 = DW_TABLE[0.05][k_dw]
911
+ dL = np.interp(n, tab5["n"], tab5["dL"])
912
+ dU = np.interp(n, tab5["n"], tab5["dU"])
913
+
914
+ # Limites críticos a 1% (para escalonamento)
915
+ tab1 = DW_TABLE[0.01][k_dw]
916
+ dL_01 = np.interp(n, tab1["n"], tab1["dL"])
917
+
918
+ if dw < dL:
919
+ if dw < dL_01:
920
+ interp_DW = f"❌ Autocorrelação positiva (DW < dL = {dL_01:.4f}, α = 1%)"
921
+ else:
922
+ interp_DW = f"❌ Autocorrelação positiva (DW < dL = {dL:.4f}, α = 5%)"
923
+ elif dw > 4 - dL:
924
+ if dw > 4 - dL_01:
925
+ interp_DW = f"❌ Autocorrelação negativa (DW > 4−dL = {4-dL_01:.4f}, α = 1%)"
926
+ else:
927
+ interp_DW = f"❌ Autocorrelação negativa (DW > 4−dL = {4-dL:.4f}, α = 5%)"
928
+ elif dU < dw < 4 - dU:
929
+ interp_DW = f"✅ Não foi identificada evidência estatística de autocorrelação dos resíduos (dU = {dU:.4f} < DW < 4−dU = {4-dU:.4f})"
930
+ else:
931
+ interp_DW = f"⚠️ Região inconclusiva (dL = {dL:.4f}, dU = {dU:.4f})"
932
+
933
+ # Breusch-Pagan
934
+ bp_lm, bp_p, bp_f, bp_fp = het_breuschpagan(residuos, sm.add_constant(X))
935
+ if bp_p >= 0.05:
936
+ interp_BP = "✅ Não foi identificada evidência estatística de heterocedasticidade (α = 5%)"
937
+ elif bp_p >= 0.02:
938
+ interp_BP = "❌ Rejeita-se a hipótese de homocedasticidade (α = 5%)"
939
+ elif bp_p >= 0.01:
940
+ interp_BP = "❌ Rejeita-se a hipótese de homocedasticidade (α = 2%)"
941
+ else:
942
+ interp_BP = "❌ Rejeita-se a hipótese de homocedasticidade (α = 1%)"
943
+
944
+ # Equação do modelo
945
+ equacao = formatar_equacao(modelo, coluna_y, transformacao_y, transformacoes_x, list(X.columns))
946
+
947
+ # Gera formatted_output para compatibilidade com .dai
948
+ formatted_output = f"""Número de observações: {n}
949
+ Número de variáveis independentes: {k}
950
+ Desvio padrão dos resíduos: {desvio_padrao_residuos:.4f}
951
+ MSE: {mse:.4f}
952
+ R²: {r2:.4f}
953
+ R² ajustado: {r2_ajustado:.4f}
954
+ Correlação Pearson: {r_pearson:.4f}
955
+ Estatística F: {Fc:.4f} (p-valor: {p_valor_F:.4f}) -> {interp_F}
956
+ ----------------------------------------------------------------------
957
+ Teste de Normalidade (Kolmogorov-Smirnov):
958
+ Estatística KS: {ks_stat:.4f}, p-valor: {ks_p:.4f} -> {interp_KS}
959
+ Teste de Normalidade (Comparação com a curva normal) – percentuais atingidos: {perc_resid}
960
+ • Ideal 68% → aceitável entre 64% e 75%
961
+ • Ideal 90% → aceitável entre 88% e 95%
962
+ • Ideal 95% → aceitável entre 95% e 100%
963
+ ----------------------------------------------------------------------
964
+ Teste de Autocorrelação (Durbin-Watson):
965
+ DW: {dw:.4f} (dL = {dL:.4f}, dU = {dU:.4f}) -> {interp_DW}
966
+ ----------------------------------------------------------------------
967
+ Teste de Homocedasticidade (Breusch-Pagan):
968
+ Estatística LM: {bp_lm:.4f}, p-valor: {bp_p:.4f}
969
+ {interp_BP}
970
+ ----------------------------------------------------------------------
971
+
972
+ Equação do Modelo:
973
+ {equacao}"""
974
+
975
+ return {
976
+ "n": n,
977
+ "k": k,
978
+ "desvio_padrao_residuos": desvio_padrao_residuos,
979
+ "mse": mse,
980
+ "r2": r2,
981
+ "r2_ajustado": r2_ajustado,
982
+ "r_pearson": r_pearson,
983
+ "Fc": Fc,
984
+ "p_valor_F": p_valor_F,
985
+ "interp_F": interp_F,
986
+ "ks_stat": ks_stat,
987
+ "ks_p": ks_p,
988
+ "interp_KS": interp_KS,
989
+ "perc_resid": perc_resid,
990
+ "dw": dw,
991
+ "interp_DW": interp_DW,
992
+ "bp_lm": bp_lm,
993
+ "bp_p": bp_p,
994
+ "interp_BP": interp_BP,
995
+ "equacao": equacao,
996
+ "formatted_output": formatted_output
997
+ }
998
+
999
+
1000
+ def classificar_significancia(p_valor):
1001
+ """Classifica significância segundo NBR 14.653-2."""
1002
+ if p_valor <= 0.10:
1003
+ return "Grau III"
1004
+ elif p_valor <= 0.20:
1005
+ return "Grau II"
1006
+ elif p_valor <= 0.30:
1007
+ return "Grau I"
1008
+ else:
1009
+ return "Sem enquadramento"
1010
+
1011
+
1012
+ def formatar_equacao(modelo, coluna_y, transformacao_y, transformacoes_x, colunas_x):
1013
+ """Formata a equação do modelo."""
1014
+ equacao = f"{modelo.params['const']:.6f}"
1015
+
1016
+ for col in colunas_x:
1017
+ coef = modelo.params[col]
1018
+ transf = transformacoes_x.get(col, "(x)")
1019
+
1020
+ if transf == "ln(x)":
1021
+ termo = f"ln({col})"
1022
+ elif transf == "1/(x)":
1023
+ termo = f"1/{col}"
1024
+ elif transf == "(x)^2":
1025
+ termo = f"({col})²"
1026
+ elif transf == "raiz(x)":
1027
+ termo = f"√({col})"
1028
+ elif transf == "1/raiz(x)":
1029
+ termo = f"1/√({col})"
1030
+ elif transf == "exp(x)":
1031
+ termo = f"exp({col})"
1032
+ else:
1033
+ termo = col
1034
+
1035
+ sinal = "+" if coef >= 0 else "-"
1036
+ equacao += f" {sinal} {abs(coef):.6f} × {termo}"
1037
+
1038
+ # Aplica inversão de y
1039
+ if transformacao_y == "ln(x)":
1040
+ equacao = f"exp({equacao})"
1041
+ elif transformacao_y == "(x)^2":
1042
+ equacao = f"√({equacao})"
1043
+ elif transformacao_y == "1/(x)":
1044
+ equacao = f"1/({equacao})"
1045
+ elif transformacao_y == "raiz(x)":
1046
+ equacao = f"({equacao})²"
1047
+ elif transformacao_y == "1/raiz(x)":
1048
+ equacao = f"1/({equacao})²"
1049
+
1050
+ return f"{coluna_y} = {equacao}"
1051
+
1052
+
1053
+ # ============================================================
1054
+ # BUSCA AUTOMÁTICA DE TRANSFORMAÇÕES (OTIMIZADA)
1055
+ # ============================================================
1056
+
1057
+ def _calcular_r2_numpy(X, y):
1058
+ """
1059
+ Calcula R² usando operações matriciais diretas (mais rápido que statsmodels).
1060
+ """
1061
+ n = len(y)
1062
+ if n == 0:
1063
+ return -np.inf
1064
+
1065
+ # Adiciona constante
1066
+ X_const = np.column_stack([np.ones(n), X])
1067
+
1068
+ try:
1069
+ # OLS: beta = (X'X)^-1 X'y
1070
+ XtX = X_const.T @ X_const
1071
+ Xty = X_const.T @ y
1072
+ beta = np.linalg.solve(XtX, Xty)
1073
+
1074
+ # R²
1075
+ y_pred = X_const @ beta
1076
+ ss_res = np.sum((y - y_pred) ** 2)
1077
+ ss_tot = np.sum((y - np.mean(y)) ** 2)
1078
+
1079
+ if ss_tot == 0:
1080
+ return -np.inf
1081
+
1082
+ return 1 - (ss_res / ss_tot)
1083
+ except:
1084
+ return -np.inf
1085
+
1086
+
1087
+ def _calcular_pvalores_numpy(X, y):
1088
+ """
1089
+ Calcula p-valores dos coeficientes X e p-valor do teste F usando operações matriciais.
1090
+ Retorna (p_valores_coeficientes, p_valor_f) ou (None, None) se falhar.
1091
+ p_valores_coeficientes inclui a constante (intercepto) no índice 0.
1092
+ """
1093
+ from scipy.stats import t as t_dist, f as f_dist
1094
+ n = len(y)
1095
+ X_const = np.column_stack([np.ones(n), X])
1096
+ k = X_const.shape[1] # inclui constante
1097
+ if n <= k:
1098
+ return None, None
1099
+ try:
1100
+ XtX = X_const.T @ X_const
1101
+ XtX_inv = np.linalg.inv(XtX)
1102
+ beta = XtX_inv @ (X_const.T @ y)
1103
+ y_pred = X_const @ beta
1104
+ residuals = y - y_pred
1105
+ ss_res = np.sum(residuals ** 2)
1106
+ sigma2 = ss_res / (n - k)
1107
+ if sigma2 <= 0:
1108
+ return None, None
1109
+ diag_vals = np.diag(sigma2 * XtX_inv)
1110
+ if np.any(diag_vals < 0):
1111
+ return None, None # matriz mal-condicionada (multicolinearidade)
1112
+ se = np.sqrt(diag_vals)
1113
+ if np.any(se == 0):
1114
+ return None, None
1115
+ t_stats = beta / se
1116
+ p_valores = 2 * t_dist.sf(np.abs(t_stats), df=n - k)
1117
+
1118
+ # Teste F: (SS_reg / (k-1)) / (SS_res / (n-k))
1119
+ y_mean = np.mean(y)
1120
+ ss_tot = np.sum((y - y_mean) ** 2)
1121
+ ss_reg = ss_tot - ss_res
1122
+ df_reg = k - 1 # graus de liberdade da regressão (exclui constante)
1123
+ df_res = n - k
1124
+ if df_reg > 0 and df_res > 0 and ss_res > 0:
1125
+ f_stat = (ss_reg / df_reg) / (ss_res / df_res)
1126
+ p_valor_f = f_dist.sf(f_stat, df_reg, df_res)
1127
+ else:
1128
+ p_valor_f = 1.0
1129
+
1130
+ return p_valores, p_valor_f # inclui constante (intercepto) no índice 0
1131
+ except:
1132
+ return None, None
1133
+
1134
+
1135
+ def _precomputar_transformacoes(df, colunas):
1136
+ """
1137
+ Pré-computa todas as transformações válidas para as colunas.
1138
+ Retorna dict: {(coluna, transformacao): array}
1139
+ """
1140
+ cache = {}
1141
+
1142
+ for col in colunas:
1143
+ valores = df[col].values.astype(float)
1144
+ for transf in TRANSFORMACOES:
1145
+ dados_transf = aplicar_transformacao(valores, transf)
1146
+
1147
+ # Só armazena se válido
1148
+ if not (np.isnan(dados_transf).any() or np.isinf(dados_transf).any()):
1149
+ cache[(col, transf)] = dados_transf
1150
+
1151
+ return cache
1152
+
1153
+
1154
+ def _buscar_stepwise(colunas_x, colunas_livres, transformacoes_fixas, y_transf, x_cache, df_busca):
1155
+ """
1156
+ Busca stepwise: otimiza uma variável por vez.
1157
+ Mais rápido para muitas variáveis (O(7*n) em vez de O(7^n)).
1158
+ """
1159
+ # Inicializa com transformação (x) para todas
1160
+ transf_x_atuais = {col: "(x)" for col in colunas_livres}
1161
+
1162
+ # Para cada variável X, encontrar melhor transformação
1163
+ for col in colunas_livres:
1164
+ melhor_transf = "(x)"
1165
+ melhor_r2 = -np.inf
1166
+
1167
+ for transf in TRANSFORMACOES:
1168
+ if (col, transf) not in x_cache:
1169
+ continue
1170
+
1171
+ # Testa com esta transformação
1172
+ transf_teste = transf_x_atuais.copy()
1173
+ transf_teste[col] = transf
1174
+
1175
+ # Monta matriz X
1176
+ X_arrays = []
1177
+ valido = True
1178
+ for c in colunas_x:
1179
+ if c in transformacoes_fixas:
1180
+ t = transformacoes_fixas[c]
1181
+ else:
1182
+ t = transf_teste.get(c, "(x)")
1183
+
1184
+ if (c, t) in x_cache:
1185
+ X_arrays.append(x_cache[(c, t)])
1186
+ else:
1187
+ valido = False
1188
+ break
1189
+
1190
+ if not valido or not X_arrays:
1191
+ continue
1192
+
1193
+ X = np.column_stack(X_arrays)
1194
+ r2 = _calcular_r2_numpy(X, y_transf)
1195
+
1196
+ if r2 > melhor_r2:
1197
+ melhor_r2 = r2
1198
+ melhor_transf = transf
1199
+
1200
+ transf_x_atuais[col] = melhor_transf
1201
+
1202
+ # Calcula R² final
1203
+ transf_x_final = {**transformacoes_fixas, **transf_x_atuais}
1204
+ X_arrays = []
1205
+ for c in colunas_x:
1206
+ t = transf_x_final.get(c, "(x)")
1207
+ if (c, t) in x_cache:
1208
+ X_arrays.append(x_cache[(c, t)])
1209
+
1210
+ if X_arrays:
1211
+ X = np.column_stack(X_arrays)
1212
+ r2_final = _calcular_r2_numpy(X, y_transf)
1213
+ else:
1214
+ r2_final = -np.inf
1215
+
1216
+ return transf_x_final, r2_final
1217
+
1218
+
1219
+ def _buscar_exaustivo_numpy(colunas_x, colunas_livres, transformacoes_fixas, y_transf, x_cache, top_n):
1220
+ """
1221
+ Busca exaustiva usando numpy para cálculo rápido de R².
1222
+ Para uso quando o número de combinações é pequeno (<= 5000).
1223
+ """
1224
+ n_livres = len(colunas_livres)
1225
+ resultados_transf = []
1226
+
1227
+ for combo in product(TRANSFORMACOES, repeat=n_livres):
1228
+ # Monta dict de transformações
1229
+ transf_x = transformacoes_fixas.copy()
1230
+ for i, col in enumerate(colunas_livres):
1231
+ transf_x[col] = combo[i]
1232
+
1233
+ # Monta matriz X
1234
+ X_arrays = []
1235
+ valido = True
1236
+ for col in colunas_x:
1237
+ t = transf_x.get(col, "(x)")
1238
+ if (col, t) in x_cache:
1239
+ X_arrays.append(x_cache[(col, t)])
1240
+ else:
1241
+ valido = False
1242
+ break
1243
+
1244
+ if not valido or not X_arrays:
1245
+ continue
1246
+
1247
+ X = np.column_stack(X_arrays)
1248
+ r2 = _calcular_r2_numpy(X, y_transf)
1249
+
1250
+ if r2 > -np.inf:
1251
+ resultados_transf.append((r2, transf_x.copy()))
1252
+
1253
+ # Ordena e retorna top
1254
+ resultados_transf.sort(key=lambda x: x[0], reverse=True)
1255
+ return resultados_transf[:top_n]
1256
+
1257
+
1258
+ def _classificar_grau_coef(p):
1259
+ """Classifica p-valor de coeficiente em grau de enquadramento (0-3)."""
1260
+ if p is None or p > 0.30:
1261
+ return 0
1262
+ if p > 0.20:
1263
+ return 1
1264
+ if p > 0.10:
1265
+ return 2
1266
+ return 3
1267
+
1268
+
1269
+ def _classificar_grau_f(p):
1270
+ """Classifica p-valor do teste F em grau de enquadramento (0-3)."""
1271
+ if p is None or p > 0.05:
1272
+ return 0
1273
+ if p > 0.02:
1274
+ return 1
1275
+ if p > 0.01:
1276
+ return 2
1277
+ return 3
1278
+
1279
+
1280
+ def buscar_melhores_transformacoes(df, coluna_y, colunas_x, transformacoes_fixas=None, transformacao_y_fixa=None,
1281
+ indices_usar=None, top_n=5,
1282
+ grau_min_coef=3, grau_min_f=3):
1283
+ """
1284
+ Busca otimizada das melhores combinações de transformações para maximizar R².
1285
+
1286
+ Usa estratégia híbrida:
1287
+ - Para poucos combos (<=5000): busca exaustiva com numpy (rápida)
1288
+ - Para muitos combos (>5000): busca stepwise (O(7*n) em vez de O(7^n))
1289
+
1290
+ Parâmetros:
1291
+ df: DataFrame
1292
+ coluna_y: variável dependente
1293
+ colunas_x: lista de variáveis independentes
1294
+ transformacoes_fixas: dict {coluna: transformação} para variáveis com transformação fixa
1295
+ transformacao_y_fixa: transformação fixa para y (ou None para testar todas)
1296
+ indices_usar: lista de índices a incluir (se None, usa todos)
1297
+ top_n: número de melhores modelos a retornar
1298
+ grau_min_coef: grau mínimo de significância dos coeficientes (3=Grau III p≤10%, 2=Grau II p≤20%, 1=Grau I p≤30%, 0=sem filtro)
1299
+ grau_min_f: grau mínimo do teste F (3=Grau III α=1%, 2=Grau II α=2%, 1=Grau I α=5%, 0=sem filtro)
1300
+
1301
+ Retorna:
1302
+ Lista de dicts com informações dos top N modelos
1303
+ """
1304
+ if transformacoes_fixas is None:
1305
+ transformacoes_fixas = {}
1306
+
1307
+ # Filtra por índices se especificado
1308
+ df_busca = df.copy()
1309
+ if indices_usar is not None:
1310
+ df_busca = df_busca.loc[indices_usar]
1311
+
1312
+ # Detecta dicotômicas e percentuais e fixa em (x) (código alocado fica livre)
1313
+ dicotomicas = detectar_dicotomicas(df_busca, colunas_x)
1314
+ percentuais = detectar_percentuais(df_busca, colunas_x)
1315
+ for col in dicotomicas + percentuais:
1316
+ if col not in transformacoes_fixas:
1317
+ transformacoes_fixas[col] = "(x)"
1318
+
1319
+ # Identifica colunas livres (não fixas)
1320
+ colunas_livres = [col for col in colunas_x if col not in transformacoes_fixas]
1321
+ n_livres = len(colunas_livres)
1322
+
1323
+ # Define transformações de Y a testar
1324
+ if transformacao_y_fixa:
1325
+ transformacoes_y = [transformacao_y_fixa]
1326
+ else:
1327
+ transformacoes_y = TRANSFORMACOES
1328
+
1329
+ # Pré-computa todas as transformações de X (apenas colunas livres)
1330
+ x_cache = _precomputar_transformacoes(df_busca, colunas_livres)
1331
+
1332
+ # Adiciona transformações fixas ao cache
1333
+ for col, transf in transformacoes_fixas.items():
1334
+ if col in colunas_x:
1335
+ dados = aplicar_transformacao(df_busca[col].values.astype(float), transf)
1336
+ if not (np.isnan(dados).any() or np.isinf(dados).any()):
1337
+ x_cache[(col, transf)] = dados
1338
+
1339
+ # Calcula número total de combinações
1340
+ total_combos = len(TRANSFORMACOES) ** n_livres if n_livres > 0 else 1
1341
+ usar_stepwise = total_combos > 5000
1342
+
1343
+ # Mapeamento de grau para limiar de p-valor
1344
+ _LIMIARES_COEF = {3: 0.10, 2: 0.20, 1: 0.30}
1345
+ _LIMIARES_F = {3: 0.01, 2: 0.02, 1: 0.05}
1346
+ limiar_coef = _LIMIARES_COEF.get(grau_min_coef)
1347
+ limiar_f = _LIMIARES_F.get(grau_min_f)
1348
+
1349
+ # Coleta mais candidatos quando vai filtrar
1350
+ tem_filtro = limiar_coef is not None or limiar_f is not None
1351
+ top_n_interno = top_n * 10 if tem_filtro else top_n
1352
+
1353
+ resultados = []
1354
+
1355
+ for transf_y in transformacoes_y:
1356
+ # Pré-computa Y transformado
1357
+ y_transf = aplicar_transformacao(df_busca[coluna_y].values.astype(float), transf_y)
1358
+
1359
+ # Se y_transf tem problemas, pula
1360
+ if np.isnan(y_transf).any() or np.isinf(y_transf).any():
1361
+ continue
1362
+
1363
+ if n_livres == 0:
1364
+ # Todas as variáveis são fixas, só calcula R²
1365
+ X_arrays = [x_cache[(col, transformacoes_fixas[col])]
1366
+ for col in colunas_x if (col, transformacoes_fixas.get(col, "(x)")) in x_cache]
1367
+ if X_arrays:
1368
+ X = np.column_stack(X_arrays)
1369
+ r2 = _calcular_r2_numpy(X, y_transf)
1370
+ if r2 > -np.inf:
1371
+ resultados.append({
1372
+ "r2": r2,
1373
+ "transformacao_y": transf_y,
1374
+ "transformacoes_x": transformacoes_fixas.copy()
1375
+ })
1376
+ elif usar_stepwise:
1377
+ # Busca stepwise para muitas variáveis
1378
+ transf_x, r2 = _buscar_stepwise(
1379
+ colunas_x, colunas_livres, transformacoes_fixas,
1380
+ y_transf, x_cache, df_busca
1381
+ )
1382
+ if r2 > -np.inf:
1383
+ resultados.append({
1384
+ "r2": r2,
1385
+ "transformacao_y": transf_y,
1386
+ "transformacoes_x": transf_x
1387
+ })
1388
+ else:
1389
+ # Busca exaustiva para poucas variáveis
1390
+ top_combos = _buscar_exaustivo_numpy(
1391
+ colunas_x, colunas_livres, transformacoes_fixas,
1392
+ y_transf, x_cache, top_n_interno
1393
+ )
1394
+ for r2, transf_x in top_combos:
1395
+ resultados.append({
1396
+ "r2": r2,
1397
+ "transformacao_y": transf_y,
1398
+ "transformacoes_x": transf_x
1399
+ })
1400
+
1401
+ # Ordena por R²
1402
+ resultados = sorted(resultados, key=lambda x: x["r2"], reverse=True)
1403
+
1404
+ # Filtra por significância dos coeficientes e/ou teste F
1405
+ if tem_filtro and resultados:
1406
+ resultados_filtrados = []
1407
+ for r in resultados:
1408
+ transf_y = r["transformacao_y"]
1409
+ transf_x = r["transformacoes_x"]
1410
+
1411
+ # Monta Y transformado
1412
+ y_transf = aplicar_transformacao(df_busca[coluna_y].values.astype(float), transf_y)
1413
+ if np.isnan(y_transf).any() or np.isinf(y_transf).any():
1414
+ continue
1415
+
1416
+ # Monta X transformado
1417
+ X_arrays = []
1418
+ valido = True
1419
+ for col in colunas_x:
1420
+ t = transf_x.get(col, "(x)")
1421
+ if (col, t) in x_cache:
1422
+ X_arrays.append(x_cache[(col, t)])
1423
+ else:
1424
+ valido = False
1425
+ break
1426
+
1427
+ if not valido or not X_arrays:
1428
+ continue
1429
+
1430
+ X = np.column_stack(X_arrays)
1431
+ p_valores_coef, p_valor_f = _calcular_pvalores_numpy(X, y_transf)
1432
+
1433
+ # Filtro de significância dos coeficientes
1434
+ if limiar_coef is not None:
1435
+ if p_valores_coef is None or not all(p <= limiar_coef for p in p_valores_coef):
1436
+ continue
1437
+
1438
+ # Filtro do teste F
1439
+ if limiar_f is not None:
1440
+ if p_valor_f is None or p_valor_f >= limiar_f:
1441
+ continue
1442
+
1443
+ resultados_filtrados.append(r)
1444
+
1445
+ if len(resultados_filtrados) >= top_n:
1446
+ break
1447
+
1448
+ resultados = resultados_filtrados
1449
+
1450
+ resultados = resultados[:top_n]
1451
+
1452
+ # Adiciona ranking
1453
+ for i, r in enumerate(resultados):
1454
+ r["rank"] = i + 1
1455
+
1456
+ # Enriquece resultados com graus de enquadramento por variável e teste F
1457
+ for r in resultados:
1458
+ y_tc = aplicar_transformacao(df_busca[coluna_y].values.astype(float), r["transformacao_y"])
1459
+ X_arrs, valido = [], True
1460
+ for col in colunas_x:
1461
+ t = r["transformacoes_x"].get(col, "(x)")
1462
+ if (col, t) in x_cache:
1463
+ X_arrs.append(x_cache[(col, t)])
1464
+ else:
1465
+ valido = False
1466
+ break
1467
+ if valido and X_arrs and not (np.isnan(y_tc).any() or np.isinf(y_tc).any()):
1468
+ p_vals, p_f = _calcular_pvalores_numpy(np.column_stack(X_arrs), y_tc)
1469
+ if p_vals is not None:
1470
+ r["graus_coef"] = {col: _classificar_grau_coef(float(p_vals[i + 1]))
1471
+ for i, col in enumerate(colunas_x)}
1472
+ else:
1473
+ r["graus_coef"] = {col: 0 for col in colunas_x}
1474
+ r["grau_f"] = _classificar_grau_f(p_f) if p_f is not None else 0
1475
+ else:
1476
+ r["graus_coef"] = {col: 0 for col in colunas_x}
1477
+ r["grau_f"] = 0
1478
+
1479
+ return resultados
1480
+
1481
+
1482
+ # ============================================================
1483
+ # MICRONUMEROSIDADE (NBR 14.653-2)
1484
+ # ============================================================
1485
+
1486
+ def _testar_frequencia_coluna(df, coluna, n):
1487
+ """Testa critérios de frequência mínima por categoria para uma coluna.
1488
+
1489
+ Retorna dict com 'valido' (bool) e 'mensagens' (list).
1490
+ """
1491
+ if coluna not in df.columns:
1492
+ return {"valido": True, "mensagens": []}
1493
+
1494
+ frequencia = df[coluna].value_counts()
1495
+ ni_valido = True
1496
+ mensagens = []
1497
+
1498
+ for categoria, ni in frequencia.items():
1499
+ if n <= 30:
1500
+ if ni < 3:
1501
+ ni_valido = False
1502
+ mensagens.append(f"ni={ni} < 3 na categoria {categoria}")
1503
+ else:
1504
+ mensagens.append(f"ni={ni} ≥ 3 na categoria {categoria}")
1505
+ elif 30 < n <= 100:
1506
+ if ni < 0.1 * n:
1507
+ ni_valido = False
1508
+ mensagens.append(f"ni={ni} < {int(0.1 * n)} (10% de n) na categoria {categoria}")
1509
+ else:
1510
+ mensagens.append(f"ni={ni} ≥ {int(0.1 * n)} (10% de n) na categoria {categoria}")
1511
+ elif n > 100:
1512
+ if ni <= 10:
1513
+ ni_valido = False
1514
+ mensagens.append(f"ni={ni} ≤ 10 na categoria {categoria}")
1515
+ else:
1516
+ mensagens.append(f"ni={ni} > 10 na categoria {categoria}")
1517
+
1518
+ return {"valido": ni_valido, "mensagens": mensagens}
1519
+
1520
+
1521
+ def testar_micronumerosidade(df, colunas_x, dicotomicas=None, codigo_alocado=None):
1522
+ """
1523
+ Testa critérios de micronumerosidade da NBR 14.653-2.
1524
+
1525
+ Usa as listas de dicotômicas e códigos alocados dos checkboxes (não auto-detecta por dtype).
1526
+ """
1527
+ dicotomicas = dicotomicas or []
1528
+ codigo_alocado = codigo_alocado or []
1529
+
1530
+ n = len(df)
1531
+ k = len(colunas_x)
1532
+
1533
+ # Condição geral: n >= 3*(k+1)
1534
+ condicao_geral = n >= 3 * (k + 1)
1535
+
1536
+ resultados_dicotomicas = {}
1537
+ for coluna in dicotomicas:
1538
+ resultados_dicotomicas[coluna] = _testar_frequencia_coluna(df, coluna, n)
1539
+
1540
+ resultados_codigo_alocado = {}
1541
+ for coluna in codigo_alocado:
1542
+ resultados_codigo_alocado[coluna] = _testar_frequencia_coluna(df, coluna, n)
1543
+
1544
+ return {
1545
+ "n": n,
1546
+ "k": k,
1547
+ "condicao_geral_ok": condicao_geral,
1548
+ "dicotomicas": resultados_dicotomicas,
1549
+ "codigo_alocado": resultados_codigo_alocado,
1550
+ }
1551
+
1552
+
1553
+ # ============================================================
1554
+ # EXPORTAR BASE TRATADA (CSV)
1555
+ # ============================================================
1556
+
1557
+ def exportar_base_csv(df):
1558
+ """
1559
+ Exporta DataFrame para arquivo CSV temporário.
1560
+
1561
+ Parâmetros:
1562
+ df: DataFrame a exportar
1563
+
1564
+ Retorna:
1565
+ str: Caminho do arquivo CSV gerado
1566
+ """
1567
+ import tempfile
1568
+ import os
1569
+
1570
+ if df is None or df.empty:
1571
+ return None
1572
+
1573
+ # Cria arquivo temporário
1574
+ fd, caminho = tempfile.mkstemp(suffix=".csv", prefix="base_tratada_")
1575
+ os.close(fd)
1576
+
1577
+ # Salva CSV
1578
+ df.to_csv(caminho, index=True, encoding="utf-8-sig", sep=";", decimal=",")
1579
+
1580
+ return caminho
1581
+
1582
+
1583
+ # ============================================================
1584
+ # EXPORTAR MODELO (.dai)
1585
+ # ============================================================
1586
+
1587
+ def exportar_modelo_dai(resultado_modelo, df_original, df_completo=None, estatisticas=None,
1588
+ nome_arquivo="", elaborador=None, outliers_excluidos=None):
1589
+ """
1590
+ Exporta o modelo em formato .dai v2 (estrutura nested).
1591
+
1592
+ Parâmetros:
1593
+ resultado_modelo: dict com resultado do ajuste
1594
+ df_original: DataFrame filtrado (sem outliers) usado no ajuste
1595
+ df_completo: DataFrame original completo (com outliers) — para restauração futura
1596
+ estatisticas: DataFrame com estatísticas (mantido como DataFrame)
1597
+ nome_arquivo: nome do arquivo de saída
1598
+ elaborador: dict com dados do avaliador (ou None)
1599
+ outliers_excluidos: lista de índices excluídos como outliers (ou None)
1600
+
1601
+ Retorna:
1602
+ tuple: (caminho_arquivo, mensagem)
1603
+ """
1604
+ import tempfile
1605
+
1606
+ if resultado_modelo is None:
1607
+ return None, "Nenhum modelo para exportar"
1608
+
1609
+ try:
1610
+ # Função auxiliar para converter StringDtype para object e limpar DataFrame
1611
+ def limpar_df_para_export(df):
1612
+ """Converte StringDtype para object e remove colunas Unnamed."""
1613
+ df_clean = df.copy()
1614
+ # Remove colunas 'Unnamed: X'
1615
+ cols_unnamed = [c for c in df_clean.columns if str(c).startswith('Unnamed')]
1616
+ if cols_unnamed:
1617
+ df_clean = df_clean.drop(columns=cols_unnamed)
1618
+ # Converte StringDtype para object (compatibilidade entre versões do pandas)
1619
+ for col in df_clean.columns:
1620
+ if pd.api.types.is_string_dtype(df_clean[col]):
1621
+ df_clean[col] = df_clean[col].astype(object)
1622
+ # Converte index se for string dtype (compatibilidade com .dai)
1623
+ if pd.api.types.is_string_dtype(df_clean.index):
1624
+ df_clean.index = df_clean.index.astype(object)
1625
+ return df_clean
1626
+
1627
+ # Monta pacote
1628
+ lat_col, lon_col = identificar_colunas_coords(df_original)
1629
+
1630
+ # DataFrame com coords para o mapa
1631
+ df_coords = limpar_df_para_export(df_original)
1632
+ if lat_col:
1633
+ df_coords = df_coords.rename(columns={lat_col: "lat"})
1634
+ if lon_col:
1635
+ df_coords = df_coords.rename(columns={lon_col: "lon"})
1636
+
1637
+ # Filtra apenas índices usados no modelo
1638
+ indices_modelo = resultado_modelo["indices_usados"]
1639
+ df_modelo = df_coords.loc[indices_modelo] if indices_modelo else df_coords
1640
+ df_modelo.index.name = None # Remove nome do índice
1641
+
1642
+ # Prepara df_completo (DataFrame original com todos os dados, inclusive outliers)
1643
+ if df_completo is not None:
1644
+ df_completo_export = limpar_df_para_export(df_completo)
1645
+ if lat_col:
1646
+ df_completo_export = df_completo_export.rename(columns={lat_col: "lat"})
1647
+ if lon_col:
1648
+ df_completo_export = df_completo_export.rename(columns={lon_col: "lon"})
1649
+ df_completo_export.index.name = None
1650
+ else:
1651
+ df_completo_export = None
1652
+
1653
+ # Prepara tabela_coef no formato compatível (índice = Variável, sem coluna "Variável")
1654
+ tabela_coef_export = limpar_df_para_export(resultado_modelo["tabela_coef"])
1655
+ if "Variável" in tabela_coef_export.columns:
1656
+ tabela_coef_export = tabela_coef_export.set_index("Variável")
1657
+ tabela_coef_export.index.name = None # Remove nome do índice
1658
+ # Converte index para object após set_index (compatibilidade .dai)
1659
+ if pd.api.types.is_string_dtype(tabela_coef_export.index):
1660
+ tabela_coef_export.index = tabela_coef_export.index.astype(object)
1661
+ # Renomeia p-valor para p-value
1662
+ tabela_coef_export = tabela_coef_export.rename(columns={"p-valor": "p-value"})
1663
+
1664
+ # Prepara tabela_obs_calc no formato compatível
1665
+ tabela_obs_calc_export = limpar_df_para_export(resultado_modelo["tabela_obs_calc"])
1666
+ # Move Índice para o index do DataFrame
1667
+ if "Índice" in tabela_obs_calc_export.columns:
1668
+ tabela_obs_calc_export = tabela_obs_calc_export.set_index("Índice")
1669
+ tabela_obs_calc_export.index.name = None # Remove nome do índice
1670
+ # Renomeia colunas para formato compatível
1671
+ tabela_obs_calc_export = tabela_obs_calc_export.rename(columns={
1672
+ "Resíduo Pad.": "Resíduo Padronizado",
1673
+ "Resíduo Stud.": "Resíduo Studentizado",
1674
+ "Cook": "Distância de Cook"
1675
+ })
1676
+
1677
+ # Extrai diagnosticos do resultado do modelo
1678
+ diag = resultado_modelo["diagnosticos"].copy()
1679
+
1680
+ # Prepara estatisticas no formato compatível (índice = Variável)
1681
+ estatisticas_df = estatisticas if isinstance(estatisticas, pd.DataFrame) else pd.DataFrame(estatisticas)
1682
+ estatisticas_export = limpar_df_para_export(estatisticas_df)
1683
+ if "Variável" in estatisticas_export.columns:
1684
+ estatisticas_export = estatisticas_export.set_index("Variável")
1685
+ estatisticas_export.index.name = None # Remove nome do índice
1686
+ # Converte index para object após set_index (compatibilidade .dai)
1687
+ if pd.api.types.is_string_dtype(estatisticas_export.index):
1688
+ estatisticas_export.index = estatisticas_export.index.astype(object)
1689
+ # Adiciona coluna Tipo (dtype) se não existir
1690
+ if "Tipo" not in estatisticas_export.columns:
1691
+ # Obtém tipos das colunas do DataFrame original
1692
+ tipos = {}
1693
+ for var in estatisticas_export.index:
1694
+ if var in df_original.columns:
1695
+ tipos[var] = str(df_original[var].dtype)
1696
+ else:
1697
+ tipos[var] = "unknown"
1698
+ estatisticas_export.insert(0, "Tipo", pd.Series(tipos))
1699
+ # Mantém apenas colunas compatíveis (na ordem correta)
1700
+ colunas_manter = ["Tipo", "Contagem", "Média", "Mediana", "Mínimo", "Máximo", "Desvio Padrão"]
1701
+ colunas_existentes = [c for c in colunas_manter if c in estatisticas_export.columns]
1702
+ estatisticas_export = estatisticas_export[colunas_existentes]
1703
+
1704
+ # Converte dtypes para compatibilidade com formato .dai
1705
+ if "Contagem" in estatisticas_export.columns:
1706
+ estatisticas_export["Contagem"] = estatisticas_export["Contagem"].astype(str).astype(object)
1707
+ if "Tipo" in estatisticas_export.columns:
1708
+ estatisticas_export["Tipo"] = estatisticas_export["Tipo"].astype(object)
1709
+
1710
+ # Limpa top_X_esc e top_y_esc
1711
+ top_X_esc_export = limpar_df_para_export(resultado_modelo["X_transformado"])
1712
+ top_X_esc_export.index.name = None # Remove nome do índice
1713
+ top_y_esc_export = resultado_modelo["y_transformado"].copy()
1714
+ top_y_esc_export.index.name = None # Remove nome do índice
1715
+
1716
+ pacote = {
1717
+ "versao": 2,
1718
+ "elaborador": elaborador,
1719
+ "dados": {
1720
+ "df": df_modelo,
1721
+ "df_completo": df_completo_export,
1722
+ "outliers_excluidos": outliers_excluidos or [],
1723
+ "estatisticas": estatisticas_export,
1724
+ },
1725
+ "transformacoes": {
1726
+ "info": [resultado_modelo["y_esc_info"]] + resultado_modelo["X_esc_info"],
1727
+ "X": top_X_esc_export,
1728
+ "y": top_y_esc_export,
1729
+ "dicotomicas": resultado_modelo.get("dicotomicas", []),
1730
+ "codigo_alocado": resultado_modelo.get("codigo_alocado", []),
1731
+ "percentuais": resultado_modelo.get("percentuais", []),
1732
+ },
1733
+ "modelo": {
1734
+ "sm": resultado_modelo["modelo_sm"],
1735
+ "coeficientes": tabela_coef_export,
1736
+ "obs_calc": tabela_obs_calc_export,
1737
+ "diagnosticos": {
1738
+ "gerais": {
1739
+ "n": diag.get("n"),
1740
+ "k": diag.get("k"),
1741
+ "desvio_padrao_residuos": diag.get("desvio_padrao_residuos"),
1742
+ "mse": diag.get("mse"),
1743
+ "r2": diag.get("r2"),
1744
+ "r2_ajustado": diag.get("r2_ajustado"),
1745
+ "r_pearson": diag.get("r_pearson"),
1746
+ },
1747
+ "teste_f": {
1748
+ "estatistica": diag.get("Fc"),
1749
+ "p_valor": diag.get("p_valor_F"),
1750
+ "interpretacao": diag.get("interp_F"),
1751
+ },
1752
+ "teste_ks": {
1753
+ "estatistica": diag.get("ks_stat"),
1754
+ "p_valor": diag.get("ks_p"),
1755
+ "interpretacao": diag.get("interp_KS"),
1756
+ },
1757
+ "teste_normalidade": {
1758
+ "percentuais": diag.get("perc_resid"),
1759
+ },
1760
+ "teste_dw": {
1761
+ "estatistica": diag.get("dw"),
1762
+ "interpretacao": diag.get("interp_DW"),
1763
+ },
1764
+ "teste_bp": {
1765
+ "estatistica": diag.get("bp_lm"),
1766
+ "p_valor": diag.get("bp_p"),
1767
+ "interpretacao": diag.get("interp_BP"),
1768
+ },
1769
+ "equacao": diag.get("equacao"),
1770
+ },
1771
+ },
1772
+ }
1773
+
1774
+ # Função para converter recursivamente StringDtype para object (compatibilidade HF)
1775
+ def converter_para_serializacao(obj):
1776
+ """Converte recursivamente todos os StringDtype para object dtype."""
1777
+ if isinstance(obj, pd.DataFrame):
1778
+ df = obj.copy()
1779
+ if pd.api.types.is_string_dtype(df.index):
1780
+ df.index = df.index.astype(object)
1781
+ for col in df.columns:
1782
+ if pd.api.types.is_string_dtype(df[col]):
1783
+ df[col] = df[col].astype(object)
1784
+ return df
1785
+ elif isinstance(obj, pd.Series):
1786
+ s = obj.copy()
1787
+ if pd.api.types.is_string_dtype(s):
1788
+ s = s.astype(object)
1789
+ if pd.api.types.is_string_dtype(s.index):
1790
+ s.index = s.index.astype(object)
1791
+ return s
1792
+ elif isinstance(obj, dict):
1793
+ return {k: converter_para_serializacao(v) for k, v in obj.items()}
1794
+ elif isinstance(obj, list):
1795
+ return [converter_para_serializacao(v) for v in obj]
1796
+ else:
1797
+ return obj
1798
+
1799
+ # Converte todo o pacote para garantir compatibilidade entre versões do pandas
1800
+ pacote = converter_para_serializacao(pacote)
1801
+
1802
+ # Cria arquivo no diretório temporário com o nome exato dado pelo usuário
1803
+ nome_base = nome_arquivo.replace(".dai", "") if nome_arquivo.endswith(".dai") else nome_arquivo
1804
+ caminho = os.path.join(tempfile.gettempdir(), f"{nome_base}.dai")
1805
+
1806
+ dump(pacote, caminho)
1807
+
1808
+ return caminho, f"Modelo pronto para download"
1809
+
1810
+ except Exception as e:
1811
+ return None, f"Erro ao exportar: {str(e)}"
1812
+
1813
+
1814
+ # ============================================================
1815
+ # EXPORTAÇÃO DE AVALIAÇÕES EM EXCEL
1816
+ # ============================================================
1817
+
1818
+ def exportar_avaliacoes_excel(avaliacoes_lista):
1819
+ """Exporta lista de avaliações como arquivo Excel.
1820
+
1821
+ Args:
1822
+ avaliacoes_lista: lista de dicts retornados por avaliar_imovel().
1823
+
1824
+ Returns:
1825
+ str caminho do arquivo temporário, ou None se lista vazia.
1826
+ """
1827
+ import tempfile
1828
+ import pandas as pd
1829
+
1830
+ if not avaliacoes_lista:
1831
+ return None
1832
+
1833
+ n = len(avaliacoes_lista)
1834
+ colunas_x = list(avaliacoes_lista[0]["valores_x"].keys())
1835
+
1836
+ # Montar dados: linhas = variáveis + métricas, colunas = avaliações
1837
+ dados = {}
1838
+ for i, aval in enumerate(avaliacoes_lista):
1839
+ col_name = f"Aval. {i + 1}"
1840
+ valores = []
1841
+ # Variáveis X
1842
+ for var in colunas_x:
1843
+ valores.append(aval["valores_x"].get(var, ""))
1844
+ # Métricas
1845
+ valores.append(aval["estimado"])
1846
+ valores.append(aval["ca_inf"])
1847
+ valores.append(aval["ca_sup"])
1848
+ valores.append(aval["ic_inf"])
1849
+ valores.append(aval["ic_sup"])
1850
+ valores.append(f'{aval["perc_inf"]:.1f}%')
1851
+ valores.append(f'{aval["perc_sup"]:.1f}%')
1852
+ valores.append(f'{aval["amplitude"]:.1f}%')
1853
+ valores.append(aval["precisao"])
1854
+ valores.append(aval["fundamentacao"])
1855
+ dados[col_name] = valores
1856
+
1857
+ indice = colunas_x + [
1858
+ "Estimado", "CA −15%", "CA +15%",
1859
+ "IC 80% Inf.", "IC 80% Sup.",
1860
+ "% Inf.", "% Sup.", "Amplitude",
1861
+ "Precisão", "Fundamentação",
1862
+ ]
1863
+
1864
+ df = pd.DataFrame(dados, index=indice)
1865
+ caminho = os.path.join(tempfile.gettempdir(), "avaliacoes_mesa.xlsx")
1866
+ df.to_excel(caminho, index=True)
1867
+ return caminho
1868
+
1869
+
1870
+ # ============================================================
1871
+ # AVALIAÇÃO DE IMÓVEL
1872
+ # ============================================================
1873
+
1874
+ def avaliar_imovel(modelo_sm, valores_x, colunas_x, transformacoes_x, transformacao_y, estatisticas_df, dicotomicas=None, codigo_alocado=None, percentuais=None):
1875
+ """
1876
+ Avalia um imóvel com base no modelo ajustado, incluindo verificação de
1877
+ extrapolação e cálculo da fronteira amostral conforme NBR 14.653-2.
1878
+
1879
+ Args:
1880
+ modelo_sm: Objeto OLSResults do statsmodels.
1881
+ valores_x: dict {coluna: valor} com os valores de entrada.
1882
+ colunas_x: list de nomes das variáveis X (na ordem do modelo).
1883
+ transformacoes_x: dict {coluna: transformacao} para cada X.
1884
+ transformacao_y: str com a transformação aplicada em Y.
1885
+ estatisticas_df: DataFrame com colunas Variável, Mínimo, Máximo (entre outras).
1886
+ dicotomicas: list de nomes de colunas dicotômicas (0/1).
1887
+ codigo_alocado: list de nomes de colunas de código alocado/ajustado.
1888
+ percentuais: list de nomes de colunas percentuais (0 a 1).
1889
+
1890
+ Returns:
1891
+ dict com resultado da avaliação ou None em caso de erro.
1892
+ """
1893
+ try:
1894
+ # --------------------------------------------------------
1895
+ # 1. MONTAR X_aval
1896
+ # --------------------------------------------------------
1897
+ X_aval = pd.DataFrame([[valores_x[col] for col in colunas_x]], columns=colunas_x)
1898
+ X_fronteira = X_aval.copy()
1899
+
1900
+ # --------------------------------------------------------
1901
+ # 2. VERIFICAR EXTRAPOLAÇÃO
1902
+ # --------------------------------------------------------
1903
+ # Obter limites das estatísticas
1904
+ if "Variável" in estatisticas_df.columns:
1905
+ limites = estatisticas_df.set_index("Variável")[["Mínimo", "Máximo"]].copy()
1906
+ else:
1907
+ limites = estatisticas_df[["Mínimo", "Máximo"]].copy()
1908
+
1909
+ for col in limites.columns:
1910
+ limites[col] = pd.to_numeric(limites[col], errors="coerce")
1911
+
1912
+ extrapolacoes = {}
1913
+ houve_extrapolacao = False
1914
+ qtd_extrapolacoes = 0
1915
+ qtd_extrapolacoes_acima_limites = 0
1916
+ qtd_extrapolacoes_dentro_limites = 0
1917
+
1918
+ for col in colunas_x:
1919
+ val = float(X_aval[col].values[0])
1920
+ X_fronteira[col] = val
1921
+ extrapolacoes[col] = {"status": "ok", "percentual": 0.0, "direcao": "ok"}
1922
+
1923
+ if col not in limites.index:
1924
+ continue
1925
+
1926
+ min_val = float(limites.loc[col, "Mínimo"])
1927
+ max_val = float(limites.loc[col, "Máximo"])
1928
+
1929
+ # Dicotômica, código alocado ou percentual — validação já feita no callback
1930
+ if col in (dicotomicas or []):
1931
+ extrapolacoes[col] = {"status": "dicotomica", "percentual": 0.0, "direcao": "ok"}
1932
+ continue
1933
+ if col in (codigo_alocado or []):
1934
+ extrapolacoes[col] = {"status": "codigo_alocado", "percentual": 0.0, "direcao": "ok"}
1935
+ continue
1936
+ if col in (percentuais or []):
1937
+ extrapolacoes[col] = {"status": "percentual", "percentual": 0.0, "direcao": "ok"}
1938
+ continue
1939
+
1940
+ # Acima do máximo
1941
+ if val > max_val:
1942
+ houve_extrapolacao = True
1943
+ qtd_extrapolacoes += 1
1944
+ X_fronteira[col] = max_val
1945
+ percentual = round(((val / max_val) - 1) * 100, 1) if max_val != 0 else 0.0
1946
+
1947
+ if percentual > 100:
1948
+ qtd_extrapolacoes_acima_limites += 1
1949
+ extrapolacoes[col] = {"status": "grave", "percentual": percentual, "direcao": "acima"}
1950
+ else:
1951
+ qtd_extrapolacoes_dentro_limites += 1
1952
+ extrapolacoes[col] = {"status": "warning", "percentual": percentual, "direcao": "acima"}
1953
+
1954
+ # Abaixo do mínimo
1955
+ elif val < min_val:
1956
+ houve_extrapolacao = True
1957
+ qtd_extrapolacoes += 1
1958
+ X_fronteira[col] = min_val
1959
+ percentual = round((1 - (val / min_val)) * 100, 1) if min_val != 0 else 0.0
1960
+
1961
+ if percentual > 50:
1962
+ qtd_extrapolacoes_acima_limites += 1
1963
+ extrapolacoes[col] = {"status": "grave", "percentual": percentual, "direcao": "abaixo"}
1964
+ else:
1965
+ qtd_extrapolacoes_dentro_limites += 1
1966
+ extrapolacoes[col] = {"status": "warning", "percentual": percentual, "direcao": "abaixo"}
1967
+
1968
+ # --------------------------------------------------------
1969
+ # 3. APLICAR TRANSFORMAÇÕES
1970
+ # --------------------------------------------------------
1971
+ X_aval_transf = X_aval.copy()
1972
+ for col in colunas_x:
1973
+ transf = transformacoes_x.get(col, "(x)")
1974
+ X_aval_transf[col] = aplicar_transformacao(X_aval_transf[col], transf)
1975
+
1976
+ # --------------------------------------------------------
1977
+ # 4. ADICIONAR CONSTANTE E PREVER
1978
+ # --------------------------------------------------------
1979
+ X_aval_const = sm.add_constant(X_aval_transf, has_constant="add")
1980
+ X_aval_const = X_aval_const[modelo_sm.model.exog_names]
1981
+
1982
+ pred = modelo_sm.get_prediction(X_aval_const)
1983
+ y_mean = pred.predicted_mean
1984
+ alpha = 0.20 # IC 80% (NBR 14.653-2)
1985
+ ic = pred.conf_int(alpha=alpha)
1986
+
1987
+ # --------------------------------------------------------
1988
+ # 5. INVERTER TRANSFORMAÇÃO Y
1989
+ # --------------------------------------------------------
1990
+ y_pred = inverter_transformacao_y(y_mean, transformacao_y)
1991
+ y_ic_inf = inverter_transformacao_y(ic[:, 0], transformacao_y)
1992
+ y_ic_sup = inverter_transformacao_y(ic[:, 1], transformacao_y)
1993
+
1994
+ estimado = float(y_pred[0])
1995
+ ic_inf = float(y_ic_inf[0])
1996
+ ic_sup = float(y_ic_sup[0])
1997
+
1998
+ # --------------------------------------------------------
1999
+ # 6. CAMPO DE ARBÍTRIO (±15%, NBR 14.653-2)
2000
+ # --------------------------------------------------------
2001
+ ca_inf = estimado * 0.85
2002
+ ca_sup = estimado * 1.15
2003
+
2004
+ # --------------------------------------------------------
2005
+ # 7. AMPLITUDE E PRECISÃO (NBR 14.653-2 tabela 2)
2006
+ # --------------------------------------------------------
2007
+ perc_inf = ((estimado - ic_inf) / estimado) * 100 if estimado != 0 else 0.0
2008
+ perc_sup = ((ic_sup - estimado) / estimado) * 100 if estimado != 0 else 0.0
2009
+ amplitude = perc_inf + perc_sup
2010
+
2011
+ if amplitude > 50:
2012
+ precisao = "Sem enquadramento"
2013
+ elif amplitude > 40:
2014
+ precisao = "Grau I"
2015
+ elif amplitude > 30:
2016
+ precisao = "Grau II"
2017
+ else:
2018
+ precisao = "Grau III"
2019
+
2020
+ # --------------------------------------------------------
2021
+ # 8. FRONTEIRA AMOSTRAL (se houve extrapolação)
2022
+ # --------------------------------------------------------
2023
+ fronteira = None
2024
+ perc_ext = None
2025
+ fundamentacao = "Grau III"
2026
+
2027
+ if houve_extrapolacao:
2028
+ X_front_transf = X_fronteira.copy()
2029
+ for col in colunas_x:
2030
+ transf = transformacoes_x.get(col, "(x)")
2031
+ X_front_transf[col] = aplicar_transformacao(X_front_transf[col], transf)
2032
+
2033
+ X_front_const = sm.add_constant(X_front_transf, has_constant="add")
2034
+ X_front_const = X_front_const[modelo_sm.model.exog_names]
2035
+
2036
+ pred_front = modelo_sm.get_prediction(X_front_const)
2037
+ y_fronteira = inverter_transformacao_y(pred_front.predicted_mean, transformacao_y)
2038
+ fronteira = float(y_fronteira[0])
2039
+
2040
+ perc_ext = float(abs(((fronteira - estimado) / fronteira) * 100)) if fronteira != 0 else 0.0
2041
+
2042
+ # --------------------------------------------------------
2043
+ # 9. FUNDAMENTAÇÃO (NBR 14.653-2 tabela 1)
2044
+ # --------------------------------------------------------
2045
+ if qtd_extrapolacoes == 0:
2046
+ fundamentacao = "Grau III"
2047
+ elif qtd_extrapolacoes_acima_limites > 0:
2048
+ fundamentacao = "Sem enquadramento"
2049
+ elif perc_ext > 20:
2050
+ fundamentacao = "Sem enquadramento"
2051
+ elif qtd_extrapolacoes == 1:
2052
+ fundamentacao = "Grau I" if perc_ext > 15 else "Grau II"
2053
+ else: # >1 extrapolação, sem grave, impacto ≤20%
2054
+ fundamentacao = "Grau I"
2055
+
2056
+ return {
2057
+ "valores_x": {col: float(valores_x[col]) for col in colunas_x},
2058
+ "extrapolacoes": extrapolacoes,
2059
+ "estimado": estimado,
2060
+ "ca_inf": ca_inf,
2061
+ "ca_sup": ca_sup,
2062
+ "ic_inf": ic_inf,
2063
+ "ic_sup": ic_sup,
2064
+ "perc_inf": round(perc_inf, 2),
2065
+ "perc_sup": round(perc_sup, 2),
2066
+ "amplitude": round(amplitude, 2),
2067
+ "precisao": precisao,
2068
+ "houve_extrapolacao": houve_extrapolacao,
2069
+ "fronteira": fronteira,
2070
+ "perc_ext": round(perc_ext, 2) if perc_ext is not None else None,
2071
+ "qtd_extrapolacoes": qtd_extrapolacoes,
2072
+ "fundamentacao": fundamentacao,
2073
+ }
2074
+
2075
+ except Exception as e:
2076
+ print(f"Erro ao avaliar imóvel: {e}")
2077
+ return None
backend/app/core/elaboracao/formatadores.py ADDED
@@ -0,0 +1,764 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ formatadores.py - Formatação HTML e helpers de exibição para a aba Elaboração.
4
+
5
+ Funções puras: recebem dados, retornam strings HTML/texto.
6
+ Sem dependência de Gradio.
7
+ """
8
+
9
+ import os
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+
14
+ # ============================================================
15
+ # CONSTANTES
16
+ # ============================================================
17
+
18
+ TITULO = """
19
+ # ELABORAÇÃO DE MODELOS ESTATÍSTICOS
20
+ ### Divisão de Avaliação de Imóveis
21
+ ---
22
+ """
23
+
24
+
25
+ # ============================================================
26
+ # FUNÇÕES DE FORMATAÇÃO
27
+ # ============================================================
28
+
29
+ def arredondar_df(df, decimais=4):
30
+ """Arredonda apenas colunas numéricas de um DataFrame e adiciona coluna Índice."""
31
+ if df is None:
32
+ return None
33
+ df_copy = df.copy()
34
+ # Adiciona índice como primeira coluna (se não existir)
35
+ if "Índice" not in df_copy.columns:
36
+ df_copy.insert(0, "Índice", df_copy.index)
37
+ colunas_numericas = df_copy.select_dtypes(include=[np.number]).columns
38
+ # Não arredonda a coluna Índice
39
+ colunas_numericas = [c for c in colunas_numericas if c != "Índice"]
40
+ df_copy[colunas_numericas] = df_copy[colunas_numericas].round(decimais)
41
+ return df_copy
42
+
43
+
44
+ def carregar_css():
45
+ """Carrega CSS externo."""
46
+ css_path = os.path.join(os.path.dirname(__file__), "styles.css")
47
+ try:
48
+ with open(css_path, "r", encoding="utf-8") as f:
49
+ return f.read()
50
+ except FileNotFoundError:
51
+ return ""
52
+
53
+
54
+ def criar_header_secao(numero: int, titulo: str, timestamp: str = "") -> str:
55
+ """Cria um header HTML estilizado para seções, opcionalmente com timestamp."""
56
+ timestamp_html = f' <span class="section-timestamp">(Atualizado às {timestamp})</span>' if timestamp else ""
57
+ return f'''
58
+ <div class="section-header">
59
+ <span class="section-number">{numero}</span>
60
+ <h2 class="section-title">{titulo}{timestamp_html}</h2>
61
+ </div>
62
+ '''
63
+
64
+
65
+ def formatar_lista_variaveis_html(info_variaveis):
66
+ """Gera o bloco HTML da lista de variáveis (dependente + independentes).
67
+
68
+ info_variaveis: lista de strings no formato ["Y_NOME: transf", "X1: transf", ...]
69
+ Retorna HTML pronto para ser inserido como lado direito do badge do elaborador.
70
+ """
71
+ if not info_variaveis:
72
+ return ""
73
+
74
+ y_str = info_variaveis[0]
75
+ y_parts = y_str.split(": ", 1)
76
+ y_nome = y_parts[0].strip()
77
+ y_transf = y_parts[1].strip() if len(y_parts) > 1 else ""
78
+ y_transf_display = "" if y_transf in ("(y)", "(x)", "y", "x", "") else y_transf
79
+ y_badge = (
80
+ "<span style='background:#cce5ff;color:#004085;border-radius:4px;"
81
+ f"padding:3px 10px;font-size:1em;font-weight:600;'>{y_nome}"
82
+ + (f" <span style='font-weight:400;font-size:1em;'>{y_transf_display}</span>"
83
+ if y_transf_display else "")
84
+ + "</span>"
85
+ )
86
+
87
+ x_badges = []
88
+ for item in info_variaveis[1:]:
89
+ parts = item.split(": ", 1)
90
+ nome = parts[0].strip()
91
+ transf = parts[1].strip() if len(parts) > 1 else ""
92
+ transf_display = "" if transf in ("(x)", "(y)", "x", "y", "") else transf
93
+ badge = (
94
+ "<span style='background:#ced4da;color:#343a40;border-radius:4px;"
95
+ f"padding:3px 8px;font-size:1em;display:inline-block;margin:2px 3px 2px 0;'>{nome}"
96
+ + (f" <span style='color:#6c757d;font-size:1em;'>{transf_display}</span>"
97
+ if transf_display else "")
98
+ + "</span>"
99
+ )
100
+ x_badges.append(badge)
101
+
102
+ return (
103
+ "<div style='font-size:0.9em;line-height:2;'>"
104
+ "<div><span style='font-weight:600;color:#495057;margin-right:8px;font-size:1.1em;'>Variável Dependente:</span>"
105
+ + y_badge + "</div>"
106
+ "<div style='margin-top:4px;'>"
107
+ "<span style='font-weight:600;color:#495057;margin-right:8px;font-size:1.1em;'>Variáveis Independentes:</span>"
108
+ + "".join(x_badges) + "</div>"
109
+ "</div>"
110
+ )
111
+
112
+
113
+ def formatar_diagnosticos_html(diagnosticos):
114
+ """Formata diagnósticos como HTML no estilo field-row do MODELOS_DAI."""
115
+ if not diagnosticos:
116
+ return "<p>Nenhum modelo ajustado.</p>"
117
+
118
+ html = '<div class="dai-card">'
119
+
120
+ # Estatísticas Gerais
121
+ html += '<div class="section-title-orange">Estatísticas Gerais</div>'
122
+ html += f'''<div class="field-row"><span class="field-row-label">Número de observações</span><span class="field-row-value">{diagnosticos["n"]}</span></div>'''
123
+ html += f'''<div class="field-row"><span class="field-row-label">Número de variáveis independentes</span><span class="field-row-value">{diagnosticos["k"]}</span></div>'''
124
+ html += f'''<div class="field-row"><span class="field-row-label">Desvio padrão dos resíduos</span><span class="field-row-value">{diagnosticos["desvio_padrao_residuos"]:.4f}</span></div>'''
125
+ html += f'''<div class="field-row"><span class="field-row-label">MSE</span><span class="field-row-value">{diagnosticos["mse"]:.4f}</span></div>'''
126
+ html += f'''<div class="field-row"><span class="field-row-label">R²</span><span class="field-row-value">{diagnosticos["r2"]:.4f}</span></div>'''
127
+ html += f'''<div class="field-row"><span class="field-row-label">R² ajustado</span><span class="field-row-value">{diagnosticos["r2_ajustado"]:.4f}</span></div>'''
128
+ html += f'''<div class="field-row"><span class="field-row-label">Correlação Pearson</span><span class="field-row-value">{diagnosticos["r_pearson"]:.4f}</span></div>'''
129
+
130
+ # Teste F
131
+ html += '<div class="section-title-orange">Teste F</div>'
132
+ html += f'''<div class="field-row"><span class="field-row-label">Estatística F</span><span class="field-row-value">{diagnosticos["Fc"]:.4f}</span></div>'''
133
+ html += f'''<div class="field-row"><span class="field-row-label">P-valor</span><span class="field-row-value">{diagnosticos["p_valor_F"]:.4f}</span></div>'''
134
+ html += f'''<div class="field-row"><span class="field-row-label">Interpretação</span><span class="field-row-value">{diagnosticos["interp_F"]}</span></div>'''
135
+
136
+ # Teste de Normalidade (KS)
137
+ html += '<div class="section-title-orange">Teste de Normalidade (Kolmogorov-Smirnov)</div>'
138
+ html += f'''<div class="field-row"><span class="field-row-label">Estatística KS</span><span class="field-row-value">{diagnosticos["ks_stat"]:.4f}</span></div>'''
139
+ html += f'''<div class="field-row"><span class="field-row-label">P-valor</span><span class="field-row-value">{diagnosticos["ks_p"]:.4f}</span></div>'''
140
+ html += f'''<div class="field-row"><span class="field-row-label">Interpretação</span><span class="field-row-value">{diagnosticos["interp_KS"]}</span></div>'''
141
+
142
+ # Teste de Normalidade (Curva Normal)
143
+ html += '<div class="section-title-orange">Teste de Normalidade (Comparação com a Curva Normal)</div>'
144
+ html += f'''<div class="field-row"><span class="field-row-label">Percentuais Atingidos</span><span class="field-row-value">{diagnosticos["perc_resid"]}</span></div>'''
145
+ html += '<div class="interpretation-label">Interpretação</div>'
146
+ html += '<div class="interpretation-item">• Ideal 68% → aceitável entre 64% e 75%</div>'
147
+ html += '<div class="interpretation-item">• Ideal 90% → aceitável entre 88% e 95%</div>'
148
+ html += '<div class="interpretation-item">• Ideal 95% → aceitável entre 95% e 100%</div>'
149
+
150
+ # Teste de Autocorrelação (Durbin-Watson)
151
+ html += '<div class="section-title-orange">Teste de Autocorrelação (Durbin-Watson)</div>'
152
+ html += f'''<div class="field-row"><span class="field-row-label">Estatística DW</span><span class="field-row-value">{diagnosticos["dw"]:.4f}</span></div>'''
153
+ html += f'''<div class="field-row"><span class="field-row-label">Interpretação</span><span class="field-row-value">{diagnosticos["interp_DW"]}</span></div>'''
154
+
155
+ # Teste de Homocedasticidade (Breusch-Pagan)
156
+ html += '<div class="section-title-orange">Teste de Homocedasticidade (Breusch-Pagan)</div>'
157
+ html += f'''<div class="field-row"><span class="field-row-label">Estatística LM</span><span class="field-row-value">{diagnosticos["bp_lm"]:.4f}</span></div>'''
158
+ html += f'''<div class="field-row"><span class="field-row-label">P-valor</span><span class="field-row-value">{diagnosticos["bp_p"]:.4f}</span></div>'''
159
+ html += f'''<div class="field-row"><span class="field-row-label">Interpretação</span><span class="field-row-value">{diagnosticos["interp_BP"]}</span></div>'''
160
+
161
+ # Equação do Modelo
162
+ html += '<div class="section-title-orange">Equação do Modelo</div>'
163
+ html += f'<div class="equation-box">{diagnosticos["equacao"]}</div>'
164
+
165
+ html += '</div>'
166
+ return html
167
+
168
+
169
+ def formatar_busca_html(resultados_busca):
170
+ """Formata resultados da busca automática como HTML, incluindo graus de enquadramento."""
171
+ if not resultados_busca:
172
+ return "<p>Execute a busca automática para ver resultados.</p>"
173
+
174
+ _GRAU_LABEL = {3: "Grau III", 2: "Grau II", 1: "Grau I", 0: "Sem enq."}
175
+ _GRAU_COR = {3: "#2e7d32", 2: "#1565c0", 1: "#e65100", 0: "#b71c1c"}
176
+
177
+ html = '<div class="busca-container">'
178
+
179
+ for r in resultados_busca:
180
+ graus_coef = r.get("graus_coef", {})
181
+ grau_f = r.get("grau_f", 0)
182
+ grau_f_label = _GRAU_LABEL.get(grau_f, "Sem enq.")
183
+ grau_f_cor = _GRAU_COR.get(grau_f, "#b71c1c")
184
+
185
+ html += f'''
186
+ <div class="modelo-card" style="border: 1px solid #ddd; border-radius: 8px; padding: 12px; margin: 8px 0; background: #f9f9f9;">
187
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
188
+ <span class="modelo-rank" style="font-weight: bold; font-size: 1.1em;">#{r["rank"]}</span>
189
+ <span class="modelo-r2" style="background: #e3f2fd; padding: 4px 8px; border-radius: 4px;">R² = {r["r2"]:.4f}</span>
190
+ </div>
191
+ <div class="modelo-transf" style="font-size: 0.95em;">
192
+ <b>y:</b> {r["transformacao_y"]}<br>
193
+ '''
194
+ for col, transf in r["transformacoes_x"].items():
195
+ grau = graus_coef.get(col, 0)
196
+ label = _GRAU_LABEL.get(grau, "Sem enq.")
197
+ cor = _GRAU_COR.get(grau, "#b71c1c")
198
+ html += (f'<b>{col}:</b> {transf}'
199
+ f' &mdash; <span style="color:{cor}; font-weight:bold; font-size:0.85em;">{label}</span><br>')
200
+
201
+ html += f'''
202
+ </div>
203
+ <div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid #e0e0e0; font-size: 0.85em;">
204
+ Teste F: <span style="color:{grau_f_cor}; font-weight:bold;">{grau_f_label}</span>
205
+ </div>
206
+ </div>
207
+ '''
208
+
209
+ html += '</div>'
210
+ return html
211
+
212
+
213
+ def _renderizar_secao_micro(titulo, resultados_dict):
214
+ """Renderiza uma seção de micronumerosidade (dicotômicas ou códigos alocados)."""
215
+ if not resultados_dict:
216
+ return ""
217
+
218
+ html = f'<div class="section-title-orange">{titulo}</div>'
219
+ html += '<div class="micro-grid">'
220
+
221
+ for coluna, info in resultados_dict.items():
222
+ status = "✅" if info["valido"] else "⚠️"
223
+ status_class = "micro-ok" if info["valido"] else "micro-warn"
224
+ mensagens_lista = info["mensagens"]
225
+ mensagens_html = "".join(f'<div class="micro-msg">{msg}</div>' for msg in mensagens_lista)
226
+
227
+ html += f'''
228
+ <div class="teste-item micro-card {status_class}">
229
+ <div class="micro-card-head">
230
+ <span class="teste-nome micro-title">{coluna}</span>
231
+ <span class="teste-valor micro-status">{status}</span>
232
+ </div>
233
+ <div class="micro-msg-grid">
234
+ {mensagens_html}
235
+ </div>
236
+ </div>
237
+ '''
238
+
239
+ html += '</div>'
240
+ return html
241
+
242
+
243
+ def formatar_micronumerosidade_html(resultado):
244
+ """Formata resultado do teste de micronumerosidade como HTML."""
245
+
246
+ if not resultado:
247
+ return "<p>Clique em 'Aplicar Seleção' para verificar micronumerosidade.</p>"
248
+
249
+ html = '<div class="diagnosticos-container">'
250
+ html += '<div class="section-title-orange">Teste de Micronumerosidade (NBR 14.653-2)</div>'
251
+
252
+ # =========================
253
+ # Condição Geral
254
+ # =========================
255
+ n, k = resultado["n"], resultado["k"]
256
+ condicao_ok = resultado["condicao_geral_ok"]
257
+ req = 3 * (k + 1)
258
+
259
+ status_geral = (
260
+ f"✅ n = {n} ≥ 3(k+1) = {req}"
261
+ if condicao_ok
262
+ else f"❌ n = {n} < 3(k+1) = {req}"
263
+ )
264
+
265
+ html += f'''
266
+ <div class="micro-summary-grid">
267
+ <div class="stat-item micro-summary-card">
268
+ <span class="stat-label">n (observações)</span>
269
+ <span class="stat-value micro-summary-value">{n}</span>
270
+ </div>
271
+ <div class="stat-item micro-summary-card">
272
+ <span class="stat-label">k (variáveis)</span>
273
+ <span class="stat-value micro-summary-value">{k}</span>
274
+ </div>
275
+ <div class="stat-item micro-summary-card micro-summary-wide">
276
+ <span class="stat-label">Condição geral</span>
277
+ <span class="stat-value micro-summary-status">{status_geral}</span>
278
+ </div>
279
+ </div>
280
+ '''
281
+
282
+ # =========================
283
+ # Seções separadas: Dicotômicas e Códigos Alocados
284
+ # =========================
285
+ dic_html = _renderizar_secao_micro("Variáveis Dicotômicas", resultado.get("dicotomicas", {}))
286
+ cod_html = _renderizar_secao_micro("Códigos Ajustados/Alocados", resultado.get("codigo_alocado", {}))
287
+
288
+ if dic_html or cod_html:
289
+ html += dic_html + cod_html
290
+ else:
291
+ html += '''
292
+ <p style="color: #6c757d; font-style: italic; margin-top: 8px;">
293
+ Nenhuma variável dicotômica ou de código alocado selecionada.
294
+ </p>
295
+ '''
296
+
297
+ html += '</div>'
298
+ return html
299
+
300
+ def formatar_outliers_anteriores_html(n_outliers, lista_indices):
301
+ """Formata informações de outliers excluídos como HTML card."""
302
+ lista_str = lista_indices if lista_indices else "Nenhum"
303
+ return f'''
304
+ <div style="display: flex; gap: 16px; align-items: baseline;
305
+ padding: 10px 16px; background: var(--background-fill-secondary, #f8f9fa);
306
+ border-radius: 8px; border: 1px solid var(--border-color-primary, #e2e8f0);
307
+ flex-wrap: wrap;">
308
+ <span style="font-weight: 600; color: var(--body-text-color, #495057); white-space: nowrap;">
309
+ {n_outliers} outlier(s) excluídos do modelo ajustado
310
+ </span>
311
+ <span style="color: var(--body-text-color-subdued, #6c757d); font-size: 0.92em;">
312
+ Índices: {lista_str}
313
+ </span>
314
+ </div>'''
315
+
316
+
317
+ # ============================================================
318
+ # AVALIAÇÃO DE IMÓVEL
319
+ # ============================================================
320
+
321
+ _CORES_GRAU = {
322
+ "Grau III": "#28a745",
323
+ "Grau II": "#17a2b8",
324
+ "Grau I": "#e67e00",
325
+ "Sem enquadramento": "#dc3545",
326
+ }
327
+
328
+
329
+ def _formatar_brl(valor):
330
+ """Formata n��mero no padrão brasileiro: R$ 350.000,00."""
331
+ if valor is None or (isinstance(valor, float) and np.isnan(valor)):
332
+ return "—"
333
+ s = f"{valor:,.2f}"
334
+ s = s.replace(",", "X").replace(".", ",").replace("X", ".")
335
+ return f"R$ {s}"
336
+
337
+
338
+ def _popup_grau(conteudo_html):
339
+ """Gera ícone ⓘ com popup estilizado no hover.
340
+
341
+ Args:
342
+ conteudo_html: HTML interno do popup.
343
+
344
+ Returns:
345
+ str HTML do ícone + popup.
346
+ """
347
+ return (
348
+ '<span style="position: relative; display: inline-block; cursor: help;" '
349
+ 'class="mesa-popup-wrap">'
350
+ '<span style="font-size: 0.85em; opacity: 0.7;">ⓘ</span>'
351
+ '<span class="mesa-popup-content" style="'
352
+ 'display: none; position: absolute; bottom: calc(100% + 8px); right: 0;'
353
+ ' z-index: 1000; width: 520px;'
354
+ ' background: #fff; border: 1px solid #dee2e6; border-radius: 8px;'
355
+ ' box-shadow: 0 4px 16px rgba(0,0,0,0.15); padding: 10px 14px;'
356
+ ' font-size: 12px; font-weight: 400; color: #333; text-align: left;'
357
+ ' line-height: 1.4; white-space: normal;">'
358
+ f'{conteudo_html}'
359
+ '</span>'
360
+ '</span>'
361
+ )
362
+
363
+
364
+ def _popup_precisao_html(aval):
365
+ """Gera conteúdo HTML do popup para o grau de Precisão."""
366
+ amplitude = aval["amplitude"]
367
+ grau = aval["precisao"]
368
+
369
+ regras = [
370
+ ("≤30%", "Grau III", "#28a745"),
371
+ ("≤40%", "Grau II", "#17a2b8"),
372
+ ("≤50%", "Grau I", "#e67e00"),
373
+ (">50%", "Sem enquadramento", "#dc3545"),
374
+ ]
375
+
376
+ html = (
377
+ '<div style="font-weight: 600; margin-bottom: 4px; color: #495057;">'
378
+ 'Precisão — NBR 14.653-2, Tabela 2'
379
+ '<span style="font-weight: 400; font-size: 11px; color: #6c757d; margin-left: 8px;">'
380
+ 'Amplitude do IC 80% em relação ao estimado.</span></div>'
381
+ '<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 6px;">'
382
+ f'<span>Amplitude do IC 80%: <b>{amplitude:.1f}%</b></span>'
383
+ '</div>'
384
+ '<table style="width: 100%; border-collapse: collapse; font-size: 11px;">'
385
+ '<tr style="border-bottom: 1px solid #e9ecef;">'
386
+ '<th style="text-align: left; padding: 2px 6px; color: #6c757d;">Amplitude</th>'
387
+ '<th style="text-align: left; padding: 2px 6px; color: #6c757d;">Grau</th>'
388
+ '</tr>'
389
+ )
390
+ for faixa, nome, cor in regras:
391
+ ativo = (nome == grau)
392
+ bg = f'background: {cor}11;' if ativo else ''
393
+ fw = 'font-weight: 600;' if ativo else ''
394
+ marca = ' ◀' if ativo else ''
395
+ html += (
396
+ f'<tr style="border-bottom: 1px solid #f0f0f0; {bg}">'
397
+ f'<td style="padding: 2px 6px; {fw}">{faixa}</td>'
398
+ f'<td style="padding: 2px 6px; color: {cor}; {fw}">{nome}{marca}</td>'
399
+ '</tr>'
400
+ )
401
+ html += '</table>'
402
+ return html
403
+
404
+
405
+ def _popup_fundamentacao_html(aval):
406
+ """Gera conteúdo HTML do popup para o grau de Fundamentação."""
407
+ grau = aval["fundamentacao"]
408
+ cor_grau = _CORES_GRAU.get(grau, "#495057")
409
+
410
+ _header = (
411
+ '<div style="font-weight: 600; margin-bottom: 4px; color: #495057;">'
412
+ 'Fundamentação — NBR 14.653-2, Tabela 1</div>'
413
+ '<div style="font-size: 11px; color: #6c757d; margin-bottom: 6px; line-height: 1.45;">'
414
+ 'Depende de quantas variáveis extrapolam os limites amostrais e do '
415
+ '<b style="color:#333;">impacto no valor estimado</b>. '
416
+ 'O impacto é calculado aplicando o modelo duas vezes: uma com os valores informados '
417
+ '(incluindo os extrapolados) e outra simulando as variáveis extrapoladas no seu limite '
418
+ 'amostral mais próximo (mínimo ou máximo, conforme o caso). A diferença percentual entre '
419
+ 'os dois valores unitários resultantes é o impacto no estimado. '
420
+ 'É esse impacto — e não a % de extrapolação individual de cada variável — que define o grau, '
421
+ 'exceto no caso de extrapolação grave'
422
+ ' (variável >100% acima do máximo amostral, >50% abaixo do mínimo amostral, ou valor '
423
+ 'inválido em dicotômica), que resulta automaticamente em Sem enquadramento.'
424
+ '</div>'
425
+ )
426
+
427
+ if not aval["houve_extrapolacao"]:
428
+ return (
429
+ f'{_header}'
430
+ '<div>Nenhuma variável extrapolou os limites amostrais. '
431
+ f'<span style="color: #28a745; font-weight: 600;">→ {grau}</span></div>'
432
+ )
433
+
434
+ n = aval["qtd_extrapolacoes"]
435
+ perc = aval.get("perc_ext", 0) or 0
436
+ tem_grave = any(
437
+ info["status"] == "grave"
438
+ for info in aval["extrapolacoes"].values()
439
+ )
440
+
441
+ # Motivo conciso
442
+ if tem_grave:
443
+ motivo = "Extrapolação grave (>100% do máx., >50% abaixo do mín., ou dicotômica inválida)."
444
+ elif perc > 20:
445
+ motivo = f"Impacto de {perc:.1f}% no estimado, acima do limite de 20%."
446
+ elif n >= 2:
447
+ motivo = f"{n} variáveis extrapoladas, impacto de {perc:.1f}% no estimado."
448
+ elif n == 1 and perc > 15:
449
+ motivo = f"1 variável extrapolada, impacto de {perc:.1f}% no estimado (>15%)."
450
+ else:
451
+ motivo = f"1 variável extrapolada, impacto de {perc:.1f}% no estimado (≤15%)."
452
+
453
+ # Variáveis extrapoladas inline
454
+ vars_items = []
455
+ for col, info in aval["extrapolacoes"].items():
456
+ st = info.get("status", "ok")
457
+ if st in ("warning", "grave"):
458
+ p = info.get("percentual", 0)
459
+ direcao = info.get("direcao", "")
460
+ icone = '⚠️' if st == "warning" else '❌'
461
+ if direcao == "acima":
462
+ desc = f'{p:.1f}% acima do máx.'
463
+ elif direcao == "abaixo":
464
+ desc = f'{p:.1f}% abaixo do mín.'
465
+ elif info.get("valor_invalido"):
466
+ desc = 'valor inválido (dicot.)'
467
+ else:
468
+ desc = f'{p:.1f}% fora'
469
+ vars_items.append(f'{icone} <b>{col}</b>: {desc}')
470
+
471
+ # Regras
472
+ regras = [
473
+ ("Nenhuma extrapolação", "Grau III"),
474
+ ("1 variável extrapolada, impacto ≤15% no estimado", "Grau II"),
475
+ ("1 variável extrapolada c/ impacto >15% e ≤20%, ou >1 variável extrapolada c/ impacto ≤20%", "Grau I"),
476
+ ("Impacto >20% no estimado, ou extrapolação grave", "Sem enq."),
477
+ ]
478
+
479
+ html = _header
480
+
481
+ # Resumo + variáveis numa linha compacta
482
+ resumo = f'{n} variável(is) extrapolada(s)'
483
+ if perc is not None and perc > 0:
484
+ resumo += f', impacto de <b>{perc:.1f}%</b> no estimado'
485
+ html += f'<div style="margin-bottom: 3px;">{resumo}.</div>'
486
+
487
+ if vars_items:
488
+ vars_str = ' &nbsp;│&nbsp; '.join(vars_items)
489
+ html += (
490
+ f'<div style="background: #f8f9fa; border-radius: 4px; padding: 4px 8px;'
491
+ f' margin-bottom: 4px; font-size: 11px;">{vars_str}</div>'
492
+ )
493
+
494
+ html += (
495
+ f'<div style="margin-bottom: 4px; font-size: 11px; color: #495057;">'
496
+ f'{motivo} <span style="color: {cor_grau}; font-weight: 600;">→ {grau}</span></div>'
497
+ )
498
+
499
+ # Tabela compacta
500
+ html += (
501
+ '<table style="width: 100%; border-collapse: collapse; font-size: 11px;">'
502
+ '<tr style="border-bottom: 1px solid #e9ecef;">'
503
+ '<th style="text-align: left; padding: 2px 6px; color: #6c757d;">Condição</th>'
504
+ '<th style="text-align: left; padding: 2px 6px; color: #6c757d;">Grau</th>'
505
+ '</tr>'
506
+ )
507
+ for cond, nome in regras:
508
+ ativo = (nome == grau) or (nome == "Sem enq." and grau == "Sem enquadramento")
509
+ bg = f'background: {cor_grau}11;' if ativo else ''
510
+ fw = 'font-weight: 600;' if ativo else ''
511
+ marca = ' ◀' if ativo else ''
512
+ html += (
513
+ f'<tr style="border-bottom: 1px solid #f0f0f0; {bg}">'
514
+ f'<td style="padding: 2px 6px; {fw}">{cond}</td>'
515
+ f'<td style="padding: 2px 6px; {fw}">{nome}{marca}</td>'
516
+ '</tr>'
517
+ )
518
+ html += '</table>'
519
+ return html
520
+
521
+
522
+ def formatar_avaliacao_html(avaliacoes_lista, indice_base=0, elem_id_excluir="excluir-aval-elab"):
523
+ """Formata resultados de avaliação como tabela HTML acumulada.
524
+
525
+ Mostra tabela desde a 1ª avaliação. Cada coluna = 1 avaliação.
526
+ Extrapolação com % ao lado do ícone. Tooltips nos graus.
527
+ Linha "Estimado / Base" compara cada avaliação com a base.
528
+ Linha "Excluir" com ícone de lixeira por coluna.
529
+
530
+ Args:
531
+ avaliacoes_lista: lista de dicts retornados por core.avaliar_imovel().
532
+ indice_base: índice (0-based) da avaliação base para comparação.
533
+ elem_id_excluir: elem_id do Textbox hidden que recebe o índice a excluir.
534
+
535
+ Returns:
536
+ str HTML.
537
+ """
538
+ if not avaliacoes_lista:
539
+ return ""
540
+
541
+ n = len(avaliacoes_lista)
542
+
543
+ # Corrigir índice base se fora de range
544
+ if indice_base < 0 or indice_base >= n:
545
+ indice_base = 0
546
+
547
+ # Estilos reutilizáveis
548
+ _td = 'style="padding: 6px 12px; border-bottom: 1px solid #f0f0f0;"'
549
+ _td_r = 'style="text-align: right; padding: 6px 12px; border-bottom: 1px solid #f0f0f0;"'
550
+ _td_bold = 'style="padding: 8px 12px; border-bottom: 1px solid #dee2e6; font-weight: 600;"'
551
+ _td_bold_r = 'style="text-align: right; padding: 8px 12px; border-bottom: 1px solid #dee2e6; font-weight: 600;"'
552
+
553
+ html = (
554
+ '<style>'
555
+ '.mesa-popup-wrap:hover .mesa-popup-content { display: block !important; }'
556
+ '</style>'
557
+ '<div style="overflow-x: auto;">'
558
+ )
559
+ html += '<table style="width: 100%; border-collapse: collapse; font-size: 13px;">'
560
+
561
+ # Header
562
+ html += '<tr style="background: #f8f9fa;">'
563
+ html += '<th style="text-align: left; padding: 8px 12px; border-bottom: 2px solid #dee2e6; font-weight: 600; color: #495057;"></th>'
564
+ for i in range(n):
565
+ html += f'<th style="text-align: right; padding: 8px 12px; border-bottom: 2px solid #dee2e6; font-weight: 600; color: #495057;">Aval. {i+1}</th>'
566
+ html += '</tr>'
567
+
568
+ # Variáveis X
569
+ colunas_x = list(avaliacoes_lista[0]["valores_x"].keys())
570
+ for col in colunas_x:
571
+ html += '<tr>'
572
+ html += f'<td {_td} style="padding: 6px 12px; border-bottom: 1px solid #f0f0f0; font-weight: 500; color: #495057;">{col}</td>'
573
+ for aval in avaliacoes_lista:
574
+ val = aval["valores_x"].get(col, 0)
575
+ ext = aval["extrapolacoes"].get(col, {})
576
+ status = ext.get("status", "ok")
577
+ perc = ext.get("percentual", 0)
578
+ val_fmt = f"{val:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
579
+
580
+ if status == "ok":
581
+ celula = f'{val_fmt} ✅'
582
+ elif status in ("dicotomica", "codigo_alocado", "percentual"):
583
+ celula = f'{val_fmt} \u2014'
584
+ elif status == "warning":
585
+ celula = f'{val_fmt} \u26a0\ufe0f {perc:.1f}%'
586
+ elif ext.get("valor_invalido"):
587
+ celula = f'{val_fmt} \u274c'
588
+ else: # grave
589
+ celula = f'{val_fmt} \u274c {perc:.1f}%'
590
+
591
+ html += f'<td {_td_r}>{celula}</td>'
592
+ html += '</tr>'
593
+
594
+ # Estimado
595
+ html += '<tr style="background: #f8f9fa; font-weight: 600;">'
596
+ html += f'<td {_td_bold}>Estimado</td>'
597
+ for aval in avaliacoes_lista:
598
+ html += f'<td {_td_bold_r} style="text-align: right; padding: 8px 12px; border-bottom: 1px solid #dee2e6; font-weight: 600;">{_formatar_brl(aval["estimado"])}</td>'
599
+ html += '</tr>'
600
+
601
+ # Estimado / Base (só se houver mais de 1 avaliação)
602
+ if n > 1:
603
+ estimado_base = avaliacoes_lista[indice_base]["estimado"]
604
+ html += '<tr style="background: #fff8f0; font-size: 12px;">'
605
+ html += f'<td style="padding: 4px 12px; border-bottom: 1px solid #f0f0f0; font-weight: 500; color: #6c757d; font-style: italic;">Estimado / Base (Aval. {indice_base + 1})</td>'
606
+ for i, aval in enumerate(avaliacoes_lista):
607
+ if i == indice_base:
608
+ celula = '<span style="color: #FF8C00; font-weight: 600;">Base</span>'
609
+ else:
610
+ if estimado_base != 0:
611
+ diff_perc = ((aval["estimado"] / estimado_base) - 1) * 100
612
+ sinal = "+" if diff_perc >= 0 else "\u2212"
613
+ cor = "#28a745" if diff_perc >= 0 else "#dc3545"
614
+ celula = f'<span style="color: {cor}; font-weight: 600;">{sinal}{abs(diff_perc):.1f}%</span>'
615
+ else:
616
+ celula = '\u2014'
617
+ html += f'<td style="text-align: right; padding: 4px 12px; border-bottom: 1px solid #f0f0f0;">{celula}</td>'
618
+ html += '</tr>'
619
+
620
+ # CA
621
+ html += f'<tr><td {_td}>CA \u221215%</td>'
622
+ for aval in avaliacoes_lista:
623
+ html += f'<td {_td_r}>{_formatar_brl(aval["ca_inf"])}</td>'
624
+ html += '</tr>'
625
+ html += f'<tr><td {_td}>CA +15%</td>'
626
+ for aval in avaliacoes_lista:
627
+ html += f'<td {_td_r}>{_formatar_brl(aval["ca_sup"])}</td>'
628
+ html += '</tr>'
629
+
630
+ # IC
631
+ html += f'<tr><td {_td}>IC 80% Inf.</td>'
632
+ for aval in avaliacoes_lista:
633
+ html += f'<td {_td_r}>{_formatar_brl(aval["ic_inf"])} (\u2212{aval["perc_inf"]:.1f}%)</td>'
634
+ html += '</tr>'
635
+ html += f'<tr><td {_td}>IC 80% Sup.</td>'
636
+ for aval in avaliacoes_lista:
637
+ html += f'<td {_td_r}>{_formatar_brl(aval["ic_sup"])} (+{aval["perc_sup"]:.1f}%)</td>'
638
+ html += '</tr>'
639
+
640
+ # Amplitude
641
+ html += f'<tr><td {_td}>Amplitude</td>'
642
+ for aval in avaliacoes_lista:
643
+ html += f'<td {_td_r}>{aval["amplitude"]:.1f}%</td>'
644
+ html += '</tr>'
645
+
646
+ # Precisão (negrito, cor, popup)
647
+ html += '<tr style="background: #f8f9fa;">'
648
+ html += f'<td {_td_bold}>Precisão</td>'
649
+ for aval in avaliacoes_lista:
650
+ cor = _CORES_GRAU.get(aval["precisao"], "#495057")
651
+ popup = _popup_grau(_popup_precisao_html(aval))
652
+ html += (
653
+ f'<td {_td_bold_r} style="text-align: right; padding: 8px 12px; '
654
+ f'border-bottom: 1px solid #dee2e6; color: {cor}; font-weight: 600;">'
655
+ f'{aval["precisao"]} {popup}'
656
+ f'</td>'
657
+ )
658
+ html += '</tr>'
659
+
660
+ # Fundamentação (negrito, cor, popup)
661
+ html += '<tr style="background: #f8f9fa;">'
662
+ html += f'<td {_td_bold}>Fundamentação</td>'
663
+ for aval in avaliacoes_lista:
664
+ cor = _CORES_GRAU.get(aval["fundamentacao"], "#495057")
665
+ popup = _popup_grau(_popup_fundamentacao_html(aval))
666
+ html += (
667
+ f'<td {_td_bold_r} style="text-align: right; padding: 8px 12px; '
668
+ f'border-bottom: 1px solid #dee2e6; color: {cor}; font-weight: 600;">'
669
+ f'{aval["fundamentacao"]} {popup}'
670
+ f'</td>'
671
+ )
672
+ html += '</tr>'
673
+
674
+ # Excluir (linha com lixeiras → botão vermelho temporário)
675
+ # JS inline em cada onclick (scripts via innerHTML não são executados)
676
+ _js_trigger = (
677
+ "var el=document.querySelector('#{eid} textarea')"
678
+ "||document.querySelector('#{eid} input');"
679
+ "if(el){{el.value=String({idx});"
680
+ "el.dispatchEvent(new Event('input',{{bubbles:true}}));"
681
+ "el.dispatchEvent(new Event('change',{{bubbles:true}}));}}"
682
+ )
683
+ html += '<tr style="background: #fff5f5;">'
684
+ html += f'<td style="padding: 6px 12px; border-bottom: 1px solid #f0f0f0; font-weight: 500; color: #dc3545; font-size: 12px;">Excluir</td>'
685
+ for i in range(n):
686
+ idx_1 = i + 1
687
+ uid = f'{elem_id_excluir}-{idx_1}'
688
+ # Clicar lixeira → mostra botão vermelho por 10s
689
+ onclick_trash = (
690
+ f"document.getElementById('{uid}-trash').style.display='none';"
691
+ f"document.getElementById('{uid}-btn').style.display='inline-block';"
692
+ f"clearTimeout(window['_t_{uid}']);"
693
+ f"window['_t_{uid}']=setTimeout(function(){{"
694
+ f"document.getElementById('{uid}-btn').style.display='none';"
695
+ f"document.getElementById('{uid}-trash').style.display='inline';"
696
+ f"}},10000);"
697
+ )
698
+ # Clicar botão vermelho → dispara exclusão inline
699
+ onclick_btn = (
700
+ f"clearTimeout(window['_t_{uid}']);"
701
+ + _js_trigger.format(eid=elem_id_excluir, idx=idx_1)
702
+ )
703
+ html += (
704
+ f'<td style="text-align: right; padding: 6px 12px; border-bottom: 1px solid #f0f0f0;">'
705
+ f'<span id="{uid}-trash" onclick="{onclick_trash}" '
706
+ f'style="cursor: pointer; color: #dc3545; font-size: 18px;" '
707
+ f'title="Excluir Avaliação {idx_1}">'
708
+ f'\U0001f5d1\ufe0f</span>'
709
+ f'<span id="{uid}-btn" onclick="{onclick_btn}" '
710
+ f'style="display:none; cursor:pointer; background:#dc3545; color:white; '
711
+ f'padding:2px 10px; border-radius:4px; font-size:12px; font-weight:600;">'
712
+ f'Excluir</span>'
713
+ f'</td>'
714
+ )
715
+ html += '</tr>'
716
+
717
+ html += '</table></div>'
718
+
719
+ return html
720
+
721
+
722
+ def formatar_aviso_multicolinearidade(resultado):
723
+ """Formata HTML de aviso de multicolinearidade para exibição na seção 4.
724
+
725
+ Args:
726
+ resultado: dict retornado por core.verificar_multicolinearidade, ou None
727
+
728
+ Returns:
729
+ tuple (html: str, visible: bool)
730
+ """
731
+ if not resultado or (not resultado.get('perfeita') and not resultado.get('alta')):
732
+ return "", False
733
+
734
+ if resultado.get('perfeita'):
735
+ posto = resultado.get('posto', '?')
736
+ ncolunas = resultado.get('ncolunas', '?')
737
+ html = (
738
+ '<div style="margin-top:12px; padding:12px 16px; background:#fdecea; '
739
+ 'border-left:4px solid #c0392b; border-radius:6px; color:#7b1a1a;">'
740
+ '<strong>⛔ Multicolinearidade Perfeita Detectada</strong><br>'
741
+ f'<span style="font-size:0.93em;">A matriz de regressoras tem dependência linear exata entre variáveis '
742
+ f'(posto {posto} &lt; {ncolunas} colunas). '
743
+ 'O modelo OLS <strong>não poderá ser estimado</strong> com as variáveis selecionadas. '
744
+ 'Verifique se há dummies em excesso, variáveis redundantes ou combinações lineares exatas.</span>'
745
+ '</div>'
746
+ )
747
+ return html, True
748
+
749
+ # Alta (VIF > 10)
750
+ vif = resultado.get('vif', {})
751
+ vars_alta = resultado.get('vars_alta', [])
752
+ itens = ", ".join(
753
+ f"<strong>{c}</strong> (VIF={vif[c]:.1f})" for c in vars_alta if c in vif
754
+ )
755
+ html = (
756
+ '<div style="margin-top:12px; padding:12px 16px; background:#fff8e1; '
757
+ 'border-left:4px solid #f39c12; border-radius:6px; color:#7d5a00;">'
758
+ '<strong>⚠️ Multicolinearidade Alta Detectada</strong><br>'
759
+ f'<span style="font-size:0.93em;">Variáveis com VIF &gt; 10: {itens}. '
760
+ 'A alta colinearidade pode causar instabilidade nos coeficientes estimados. '
761
+ 'Considere remover variáveis redundantes antes da estimação.</span>'
762
+ '</div>'
763
+ )
764
+ return html, True
backend/app/core/elaboracao/geocodificacao.py ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ geocodificacao.py - Verificação, padronização e geocodificação de coordenadas geográficas.
4
+
5
+ Integrado na Seção 1 do MESA: verifica se lat/lon existem no DataFrame carregado
6
+ e, caso contrário, oferece geocodificação por interpolação em eixos de logradouros
7
+ (shapefile EixosLogradouros - Porto Alegre/RS - SIRGAS 2000 → EPSG:4326).
8
+ """
9
+
10
+ import os
11
+ import pandas as pd
12
+
13
+ from .core import NOMES_LAT, NOMES_LON
14
+
15
+ # ============================================================
16
+ # CAMINHO DO SHAPEFILE
17
+ # ============================================================
18
+
19
+ _BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # raiz MESA/
20
+ _SHAPEFILE = os.path.join(_BASE, "dados", "EixosLogradouros.shp")
21
+
22
+ # Cache em módulo — carregado uma vez por sessão
23
+ _gdf_eixos = None
24
+
25
+
26
+ def carregar_eixos():
27
+ """Carrega e cacheia o GeoDataFrame dos eixos de logradouros.
28
+
29
+ Retorna None e registra aviso se geopandas/fiona não estiverem disponíveis
30
+ ou se o shapefile não for encontrado.
31
+ """
32
+ global _gdf_eixos
33
+ if _gdf_eixos is not None:
34
+ return _gdf_eixos
35
+
36
+ try:
37
+ import geopandas as gpd
38
+ except ImportError:
39
+ print("[geocodificacao] AVISO: geopandas não instalado — geocodificação indisponível.")
40
+ return None
41
+
42
+ if not os.path.exists(_SHAPEFILE):
43
+ print(f"[geocodificacao] AVISO: shapefile não encontrado em {_SHAPEFILE}")
44
+ return None
45
+
46
+ gdf = gpd.read_file(_SHAPEFILE, engine="fiona")
47
+ gdf = gdf.to_crs("EPSG:4326")
48
+ _gdf_eixos = gdf
49
+ return _gdf_eixos
50
+
51
+
52
+ # ============================================================
53
+ # VERIFICAÇÃO E PADRONIZAÇÃO DE COORDENADAS
54
+ # ============================================================
55
+
56
+ def verificar_coords(df):
57
+ """Verifica se o DataFrame possui colunas de lat e lon com ao menos 1 valor não-nulo.
58
+
59
+ Reutiliza NOMES_LAT / NOMES_LON de core.py (busca case-insensitive).
60
+
61
+ Retorna:
62
+ (True, col_lat, col_lon) — se encontradas e válidas
63
+ (False, None, None) — caso contrário
64
+ """
65
+ colunas_lower = {str(col).lower(): col for col in df.columns}
66
+
67
+ col_lat = None
68
+ for nome in NOMES_LAT:
69
+ if nome in colunas_lower:
70
+ col_lat = colunas_lower[nome]
71
+ break
72
+
73
+ col_lon = None
74
+ for nome in NOMES_LON:
75
+ if nome in colunas_lower:
76
+ col_lon = colunas_lower[nome]
77
+ break
78
+
79
+ if col_lat is None or col_lon is None:
80
+ return False, None, None
81
+
82
+ # Verifica se há ao menos 1 valor não-nulo em cada coluna
83
+ lat_ok = pd.to_numeric(df[col_lat], errors="coerce").notna().any()
84
+ lon_ok = pd.to_numeric(df[col_lon], errors="coerce").notna().any()
85
+
86
+ if lat_ok and lon_ok:
87
+ return True, col_lat, col_lon
88
+
89
+ return False, None, None
90
+
91
+
92
+ def padronizar_coords(df, col_lat, col_lon):
93
+ """Cria (ou mantém) as colunas 'lat' e 'lon' no DataFrame.
94
+
95
+ Se col_lat/col_lon já são 'lat'/'lon', não faz nada além de garantir
96
+ que os valores são numéricos. Caso contrário, copia os valores
97
+ e remove as colunas originais.
98
+
99
+ Retorna:
100
+ DataFrame com colunas 'lat' e 'lon' padronizadas.
101
+ """
102
+ df = df.copy()
103
+
104
+ if col_lat != "lat":
105
+ df["lat"] = pd.to_numeric(df[col_lat], errors="coerce")
106
+ df = df.drop(columns=[col_lat])
107
+ else:
108
+ df["lat"] = pd.to_numeric(df["lat"], errors="coerce")
109
+
110
+ if col_lon != "lon":
111
+ df["lon"] = pd.to_numeric(df[col_lon], errors="coerce")
112
+ df = df.drop(columns=[col_lon])
113
+ else:
114
+ df["lon"] = pd.to_numeric(df["lon"], errors="coerce")
115
+
116
+ return df
117
+
118
+
119
+ # ============================================================
120
+ # AUTO-DETECÇÃO DE COLUNAS PARA GEOCODIFICAÇÃO
121
+ # ============================================================
122
+
123
+ def auto_detectar_colunas_geo(df):
124
+ """Tenta identificar automaticamente as colunas de CDLOG e número predial.
125
+
126
+ Retorna:
127
+ (col_cdlog, col_num) — strings ou None se não detectado
128
+ """
129
+ colunas_upper = {str(c).upper(): c for c in df.columns}
130
+
131
+ # CDLOG
132
+ col_cdlog = colunas_upper.get("CDLOG") or colunas_upper.get("CTM")
133
+
134
+ # Número predial — em ordem de prioridade
135
+ col_num = None
136
+ for nome in ["Nº GEO", "NUM_GEO", "NUM", "NUMERO"]:
137
+ if nome in colunas_upper:
138
+ col_num = colunas_upper[nome]
139
+ break
140
+
141
+ return col_cdlog, col_num
142
+
143
+
144
+ # ============================================================
145
+ # GEOCODIFICAÇÃO POR INTERPOLAÇÃO EM EIXOS
146
+ # ============================================================
147
+
148
+ def geocodificar(df, col_cdlog, col_num, auto_200=False):
149
+ """Geocodifica registros do DataFrame usando interpolação em eixos de logradouros.
150
+
151
+ Para cada linha:
152
+ 1. Localiza segmentos do logradouro pelo CDLOG no shapefile
153
+ 2. Determina lado par/ímpar do número predial
154
+ 3. Encontra segmento cujo intervalo contenha o número
155
+ 4. Se encontrado: interpola o ponto na fração ao longo da geometria LineString
156
+ 5. Se não encontrado:
157
+ - Gera sugestões dos números mais próximos (5 pares + 5 ímpares)
158
+ - Se diferença ≤ 200 e auto_200=True: corrige automaticamente e interpola
159
+ - Senão: registra na tabela de falhas
160
+
161
+ Args:
162
+ df: DataFrame com col_cdlog e col_num (pode ter _idx; criado se ausente)
163
+ col_cdlog: Nome da coluna com o código do logradouro (CDLOG/CTM)
164
+ col_num: Nome da coluna com o número predial
165
+ auto_200: Se True, corrige automaticamente números com diferença ≤ 200
166
+
167
+ Retorna:
168
+ df_resultado: DataFrame completo com colunas 'lat' e 'lon' (None para falhas)
169
+ df_falhas: DataFrame com falhas para correção manual
170
+ ajustados: Lista de dicts {idx, numero_original, numero_usado} auto-corrigidos
171
+ """
172
+ gdf_eixos = carregar_eixos()
173
+ if gdf_eixos is None:
174
+ raise RuntimeError(
175
+ "Shapefile de eixos não disponível. "
176
+ "Verifique se geopandas e fiona estão instalados e se o arquivo "
177
+ f"'{_SHAPEFILE}' existe."
178
+ )
179
+
180
+ df = df.copy()
181
+
182
+ # Garante coluna _idx para rastreamento
183
+ if "_idx" not in df.columns:
184
+ df["_idx"] = range(len(df))
185
+
186
+ df[col_num] = pd.to_numeric(df[col_num], errors="coerce").fillna(0).astype(int)
187
+
188
+ lats = []
189
+ lons = []
190
+ falhas = []
191
+ ajustados = []
192
+
193
+ for _, row in df.iterrows():
194
+ idx = row["_idx"]
195
+ cdlog = row[col_cdlog]
196
+ numero = int(row[col_num])
197
+
198
+ # --- Passo 1: buscar segmentos do CDLOG ---
199
+ segmentos = gdf_eixos[gdf_eixos["CDLOG"] == cdlog]
200
+
201
+ if segmentos.empty:
202
+ lats.append(None)
203
+ lons.append(None)
204
+ falhas.append({
205
+ "_idx": idx,
206
+ "cdlog": cdlog,
207
+ "numero_atual": numero,
208
+ "motivo": "CDLOG não encontrado",
209
+ "sugestoes": "",
210
+ "numero_corrigido": "",
211
+ })
212
+ continue
213
+
214
+ # --- Passo 2: determinar lado par/ímpar ---
215
+ lado = "Par" if numero % 2 == 0 else "Ímpar"
216
+ ini_col, fim_col = (
217
+ ("NRPARINI", "NRPARFIN") if lado == "Par" else ("NRIMPINI", "NRIMPFIN")
218
+ )
219
+
220
+ segmentos = segmentos.copy()
221
+ segmentos[ini_col] = pd.to_numeric(segmentos[ini_col], errors="coerce")
222
+ segmentos[fim_col] = pd.to_numeric(segmentos[fim_col], errors="coerce")
223
+ segmentos = segmentos.dropna(subset=[ini_col, fim_col])
224
+
225
+ # --- Passo 3: encontrar segmento com intervalo válido ---
226
+ cond = (segmentos[ini_col] <= numero) & (segmentos[fim_col] >= numero)
227
+ segmentos_validos = segmentos[cond]
228
+
229
+ if segmentos_validos.empty:
230
+ # --- Passo 5: intervalo não encontrado — gera sugestões ---
231
+ sugestoes_str = ""
232
+ numero_para_interpolar = None
233
+
234
+ if not segmentos.empty:
235
+ diffs = (segmentos[ini_col] - numero).abs()
236
+ min_index = diffs.idxmin()
237
+ linha_proxima = segmentos.loc[min_index]
238
+
239
+ ini = linha_proxima[ini_col]
240
+ fim = linha_proxima[fim_col]
241
+
242
+ if pd.notna(ini) and pd.notna(fim):
243
+ ini_i, fim_i = int(ini), int(fim)
244
+ todos = list(range(ini_i, fim_i + 1))
245
+ pares = sorted([n for n in todos if n % 2 == 0], key=lambda x: abs(x - numero))
246
+ impares = sorted([n for n in todos if n % 2 != 0], key=lambda x: abs(x - numero))
247
+ sugestoes = sorted(pares[:5] + impares[:5], key=lambda x: abs(x - numero))
248
+ sugestoes_str = ", ".join(map(str, sugestoes))
249
+
250
+ # Auto-correção ≤ 200
251
+ if sugestoes and auto_200:
252
+ melhor = sugestoes[0]
253
+ diferenca = abs(numero - melhor)
254
+ if diferenca <= 200:
255
+ numero_para_interpolar = melhor
256
+ ajustados.append({
257
+ "idx": idx,
258
+ "numero_original": numero,
259
+ "numero_usado": melhor,
260
+ })
261
+
262
+ if numero_para_interpolar is not None:
263
+ # Interpola com o número corrigido automaticamente
264
+ cond2 = (segmentos[ini_col] <= numero_para_interpolar) & \
265
+ (segmentos[fim_col] >= numero_para_interpolar)
266
+ seg2 = segmentos[cond2]
267
+ if seg2.empty:
268
+ # Usa segmento mais próximo mesmo assim
269
+ seg2 = segmentos.loc[[min_index]]
270
+
271
+ linha = seg2.iloc[0]
272
+ geom = linha.geometry
273
+ ini_v = linha[ini_col]
274
+ fim_v = linha[fim_col]
275
+
276
+ if fim_v == ini_v:
277
+ lats.append(None)
278
+ lons.append(None)
279
+ else:
280
+ frac = (numero_para_interpolar - ini_v) / (fim_v - ini_v)
281
+ frac = max(0.0, min(1.0, frac))
282
+ ponto = geom.interpolate(geom.length * frac)
283
+ lons.append(ponto.x)
284
+ lats.append(ponto.y)
285
+ else:
286
+ lats.append(None)
287
+ lons.append(None)
288
+ falhas.append({
289
+ "_idx": idx,
290
+ "cdlog": cdlog,
291
+ "numero_atual": numero,
292
+ "motivo": "Numeração fora do intervalo",
293
+ "sugestoes": sugestoes_str,
294
+ "numero_corrigido": "",
295
+ })
296
+ continue
297
+
298
+ # --- Passo 4: interpolação normal ---
299
+ linha = segmentos_validos.iloc[0]
300
+ geom = linha.geometry
301
+ ini = linha[ini_col]
302
+ fim = linha[fim_col]
303
+
304
+ if fim == ini:
305
+ lats.append(None)
306
+ lons.append(None)
307
+ continue
308
+
309
+ frac = (numero - ini) / (fim - ini)
310
+ frac = max(0.0, min(1.0, frac))
311
+ ponto = geom.interpolate(geom.length * frac)
312
+ lons.append(ponto.x)
313
+ lats.append(ponto.y)
314
+
315
+ df["lat"] = lats
316
+ df["lon"] = lons
317
+
318
+ df_falhas = pd.DataFrame(
319
+ falhas,
320
+ columns=["_idx", "cdlog", "numero_atual", "motivo", "sugestoes", "numero_corrigido"]
321
+ ) if falhas else pd.DataFrame(
322
+ columns=["_idx", "cdlog", "numero_atual", "motivo", "sugestoes", "numero_corrigido"]
323
+ )
324
+
325
+ return df, df_falhas, ajustados
326
+
327
+
328
+ def aplicar_correcoes_e_regeodificar(df_original, df_falhas, col_cdlog, col_num, auto_200=False):
329
+ """Aplica correções manuais da tabela de falhas e re-executa a geocodificação.
330
+
331
+ Args:
332
+ df_original: DataFrame acumulado da rodada anterior (com coluna _idx e col_num já corrigido)
333
+ df_falhas: DataFrame editável com coluna 'numero_corrigido' preenchida pelo usuário
334
+ col_cdlog: Nome da coluna CDLOG
335
+ col_num: Nome da coluna número predial
336
+ auto_200: Repassado para geocodificar()
337
+
338
+ Retorna:
339
+ (df_resultado, df_falhas_novas, ajustados, manuais)
340
+ manuais: lista de _idx que receberam correção manual nesta rodada
341
+ """
342
+ df = df_original.copy()
343
+
344
+ # Garante coluna _idx (mesma lógica de geocodificar)
345
+ if "_idx" not in df.columns:
346
+ df["_idx"] = range(len(df))
347
+
348
+ # Aplica correções e rastreia quais _idx foram corrigidos manualmente
349
+ manuais = []
350
+ for _, row in df_falhas.iterrows():
351
+ corrigido = str(row.get("numero_corrigido", "")).strip()
352
+ if corrigido and corrigido not in ("", "nan"):
353
+ try:
354
+ novo_num = int(float(corrigido))
355
+ idx = row["_idx"]
356
+ df.loc[df["_idx"] == idx, col_num] = novo_num
357
+ manuais.append(idx)
358
+ except (ValueError, TypeError):
359
+ pass
360
+
361
+ df_resultado, df_falhas_novas, ajustados = geocodificar(df, col_cdlog, col_num, auto_200)
362
+ return df_resultado, df_falhas_novas, ajustados, manuais
363
+
364
+
365
+ # ============================================================
366
+ # FORMATAÇÃO HTML DO STATUS DE GEOCODIFICAÇÃO
367
+ # ============================================================
368
+
369
+ def formatar_status_geocodificacao(df_resultado, df_falhas, ajustados, manuais=None):
370
+ """Gera HTML de resumo do resultado da geocodificação.
371
+
372
+ Args:
373
+ df_resultado: DataFrame com colunas lat/lon
374
+ df_falhas: DataFrame de falhas restantes
375
+ ajustados: Lista de registros auto-corrigidos
376
+ manuais: Lista de _idx corrigidos manualmente nesta rodada (opcional)
377
+
378
+ Retorna:
379
+ String HTML com resumo colorido.
380
+ """
381
+ total = len(df_resultado)
382
+ n_falhas = len(df_falhas)
383
+ n_ajustados = len(ajustados)
384
+ n_manuais = len(manuais) if manuais else 0
385
+ n_ok = total - n_falhas
386
+
387
+ linhas = [
388
+ '<div style="background:#f0f4ff;border:1px solid #c0d0f0;border-radius:8px;'
389
+ 'padding:10px 14px;margin-top:8px">',
390
+ f'<strong>Resultado da geocodificação</strong> — {total} registros processados<br>',
391
+ f'<span style="color:#1a7a1a">✔ {n_ok} geocodificados com sucesso</span><br>',
392
+ ]
393
+
394
+ if n_ajustados > 0:
395
+ linhas.append(
396
+ f'<span style="color:#7a5a00">✏ {n_ajustados} corrigidos automaticamente '
397
+ f'(diferença ≤ 200)</span><br>'
398
+ )
399
+
400
+ if n_manuais > 0:
401
+ linhas.append(
402
+ f'<span style="color:#2980b9">✎ {n_manuais} corrigidos manualmente</span><br>'
403
+ )
404
+
405
+ if n_falhas > 0:
406
+ linhas.append(
407
+ f'<span style="color:#c0392b">✘ {n_falhas} com falha — '
408
+ f'preencha "Nº Corrigido" na tabela abaixo e aplique as correções</span>'
409
+ )
410
+ else:
411
+ linhas.append('<span style="color:#1a7a1a">Nenhuma falha restante.</span>')
412
+
413
+ linhas.append("</div>")
414
+ return "".join(linhas)
415
+
416
+
417
+ def preparar_display_falhas(df_falhas):
418
+ """Prepara exibição de falhas: tabela HTML descritiva + DataFrame mínimo para correção.
419
+
420
+ Args:
421
+ df_falhas: DataFrame com colunas [_idx, cdlog, numero_atual, motivo, sugestoes]
422
+
423
+ Retorna:
424
+ html_str: Tabela HTML legível (read-only) com as falhas
425
+ df_correcoes: DataFrame com colunas ["Nº Linha", "Nº Corrigido"] para edição pelo usuário
426
+ """
427
+ if df_falhas is None or df_falhas.empty:
428
+ return "", pd.DataFrame(columns=["Nº Linha", "Nº Corrigido"])
429
+
430
+ linhas = [
431
+ '<div style="overflow-x:auto;margin-top:8px">',
432
+ '<table style="width:100%;border-collapse:collapse;font-size:0.9em">',
433
+ '<thead><tr style="background:#f0f4ff">',
434
+ '<th style="border:1px solid #c0d0f0;padding:6px 10px;text-align:left">Nº Linha</th>',
435
+ '<th style="border:1px solid #c0d0f0;padding:6px 10px;text-align:left">CDLOG</th>',
436
+ '<th style="border:1px solid #c0d0f0;padding:6px 10px;text-align:left">Nº Atual</th>',
437
+ '<th style="border:1px solid #c0d0f0;padding:6px 10px;text-align:left">Motivo</th>',
438
+ '<th style="border:1px solid #c0d0f0;padding:6px 10px;text-align:left">Sugestões</th>',
439
+ '</tr></thead><tbody>',
440
+ ]
441
+ for _, row in df_falhas.iterrows():
442
+ linhas.append(
443
+ f'<tr>'
444
+ f'<td style="border:1px solid #dde;padding:5px 10px">{row["_idx"]}</td>'
445
+ f'<td style="border:1px solid #dde;padding:5px 10px">{row["cdlog"]}</td>'
446
+ f'<td style="border:1px solid #dde;padding:5px 10px">{row["numero_atual"]}</td>'
447
+ f'<td style="border:1px solid #dde;padding:5px 10px">{row["motivo"]}</td>'
448
+ f'<td style="border:1px solid #dde;padding:5px 10px">{row["sugestoes"]}</td>'
449
+ f'</tr>'
450
+ )
451
+ linhas.append('</tbody></table></div>')
452
+
453
+ df_correcoes = pd.DataFrame({
454
+ "Nº Linha": df_falhas["_idx"].tolist(),
455
+ "Nº Corrigido": [""] * len(df_falhas),
456
+ })
457
+
458
+ return "".join(linhas), df_correcoes
backend/app/core/elaboracao/modelo.py ADDED
@@ -0,0 +1,991 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ modelo.py - Ajuste de modelo OLS, transformações e seleção de variáveis.
4
+
5
+ Callbacks que lidam com a lógica do modelo estatístico:
6
+ seleção X/Y, ajuste OLS, busca de transformações, exportação.
7
+ """
8
+
9
+ import gradio as gr
10
+ import pandas as pd
11
+ from datetime import datetime, timezone, timedelta
12
+
13
+ from .formatadores import (
14
+ criar_header_secao,
15
+ formatar_diagnosticos_html,
16
+ formatar_busca_html,
17
+ formatar_micronumerosidade_html,
18
+ formatar_aviso_multicolinearidade,
19
+ )
20
+ from .core import (
21
+ obter_colunas_numericas,
22
+ calcular_estatisticas_variaveis,
23
+ ajustar_modelo,
24
+ buscar_melhores_transformacoes,
25
+ testar_micronumerosidade,
26
+ detectar_dicotomicas,
27
+ detectar_codigo_alocado,
28
+ detectar_percentuais,
29
+ exportar_modelo_dai,
30
+ avaliar_imovel,
31
+ exportar_avaliacoes_excel,
32
+ verificar_multicolinearidade,
33
+ )
34
+ from .formatadores import formatar_avaliacao_html
35
+ from .charts import (
36
+ criar_graficos_dispersao,
37
+ criar_graficos_dispersao_residuos,
38
+ criar_painel_diagnostico,
39
+ criar_matriz_correlacao,
40
+ )
41
+
42
+
43
+ # ============================================================
44
+ # CONSTANTES
45
+ # ============================================================
46
+
47
+ MAX_VARS_X = 20
48
+
49
+
50
+ # ============================================================
51
+ # UTILITÁRIOS DE TRANSFORMAÇÃO
52
+ # ============================================================
53
+
54
+ def obter_transformacoes_dos_dropdowns(colunas_x, *valores_dropdowns):
55
+ """Coleta transformações dos valores dos dropdowns."""
56
+ transformacoes = {}
57
+ for i, col in enumerate(colunas_x):
58
+ if i < len(valores_dropdowns) and valores_dropdowns[i]:
59
+ transformacoes[col] = valores_dropdowns[i]
60
+ else:
61
+ transformacoes[col] = "(x)"
62
+ return transformacoes
63
+
64
+
65
+ def atualizar_campos_transformacoes(df, colunas_x, dicotomicas=None, coluna_y=None):
66
+ """Atualiza visibilidade e valores dos campos de transformação.
67
+
68
+ CONTRACT: Retorna 66 itens para transf_y_row(1) + transf_y_col(1) + transf_y_label(1)
69
+ + transf_x_rows(3) + transf_x_columns(20) + transf_x_labels(20) + transf_x_dropdowns(20).
70
+ Se alterar, atualizar: app.py (outputs de checkboxes_x.change e btn_aplicar_selecao_x.click)
71
+ + _atualizar_campos_transformacoes_com_flag (neste arquivo)
72
+ + aplicar_selecao_callback (neste arquivo, campos_vazios).
73
+ """
74
+ n_rows = (MAX_VARS_X + 7) // 8 # 3 rows (8 cards por linha)
75
+
76
+ # Y card updates
77
+ if coluna_y and df is not None and colunas_x:
78
+ y_label_html = f'<span class="transf-label-text transf-label-y">{coluna_y} (Y)</span>'
79
+ y_updates = [
80
+ gr.update(visible=True), # transf_y_row
81
+ gr.update(visible=True), # transf_y_col
82
+ gr.update(value=y_label_html, visible=True), # transf_y_label
83
+ ]
84
+ else:
85
+ y_updates = [
86
+ gr.update(visible=False), # transf_y_row
87
+ gr.update(visible=False), # transf_y_col
88
+ gr.update(value="", visible=False), # transf_y_label
89
+ ]
90
+
91
+ if df is None or not colunas_x:
92
+ # Esconde todos
93
+ row_updates = [gr.update(visible=False)] * n_rows
94
+ column_updates = [gr.update(visible=False)] * MAX_VARS_X
95
+ label_updates = [gr.update(value="", visible=False)] * MAX_VARS_X
96
+ dropdown_updates = [gr.update(value="(x)", interactive=True, visible=False)] * MAX_VARS_X
97
+ return y_updates + row_updates + column_updates + label_updates + dropdown_updates
98
+
99
+ if dicotomicas is None:
100
+ dicotomicas = detectar_dicotomicas(df, colunas_x)
101
+
102
+ # Updates para rows (visibilidade) - 8 por linha
103
+ row_updates = []
104
+ for i in range(n_rows):
105
+ idx_base = i * 8
106
+ visivel = idx_base < len(colunas_x)
107
+ row_updates.append(gr.update(visible=visivel))
108
+
109
+ # Updates para columns, labels e dropdowns
110
+ column_updates = []
111
+ label_updates = []
112
+ dropdown_updates = []
113
+
114
+ for i in range(MAX_VARS_X):
115
+ if i < len(colunas_x):
116
+ col = colunas_x[i]
117
+ eh_marcada = col in dicotomicas
118
+ # Só trava transformação se variável marcada E tem valores zero (dicotômica 0/1)
119
+ travar = False
120
+ if eh_marcada and col in df.columns:
121
+ valores_col = set(df[col].dropna().unique())
122
+ travar = 0 in valores_col or 0.0 in valores_col
123
+ column_updates.append(gr.update(visible=True))
124
+ label_updates.append(gr.update(value=f'<span class="transf-label-text">{col}</span>', visible=True))
125
+ dropdown_updates.append(gr.update(
126
+ value="(x)",
127
+ interactive=not travar,
128
+ visible=True
129
+ ))
130
+ else:
131
+ column_updates.append(gr.update(visible=False))
132
+ label_updates.append(gr.update(value="", visible=False))
133
+ dropdown_updates.append(gr.update(value="(x)", interactive=True, visible=False))
134
+
135
+ return y_updates + row_updates + column_updates + label_updates + dropdown_updates
136
+
137
+
138
+ def _atualizar_campos_transformacoes_com_flag(df, colunas_x, flag_carregamento, coluna_y=None):
139
+ """Wrapper que pula reset de transformações durante carregamento programático.
140
+
141
+ CONTRACT: Retorna 70 itens (66 campos + estado_flag_carregamento + 3 checkboxes).
142
+ Se alterar, atualizar: app.py (outputs de checkboxes_x.change).
143
+ """
144
+ # Flag é um contador inteiro: decrementa a cada side-effect processado
145
+ if flag_carregamento:
146
+ n_items = 3 + (MAX_VARS_X + 7) // 8 + MAX_VARS_X + MAX_VARS_X + MAX_VARS_X # 66
147
+ return [gr.update() for _ in range(n_items)] + [max(0, int(flag_carregamento) - 1), gr.update(), gr.update(), gr.update()]
148
+
149
+ if df is not None and colunas_x:
150
+ dicotomicas_01 = detectar_dicotomicas(df, colunas_x)
151
+ codigo_alocado = detectar_codigo_alocado(df, colunas_x)
152
+ percentuais = detectar_percentuais(df, colunas_x)
153
+ else:
154
+ dicotomicas_01 = []
155
+ codigo_alocado = []
156
+ percentuais = []
157
+
158
+ todas_marcadas = dicotomicas_01 + codigo_alocado + percentuais
159
+ campos = atualizar_campos_transformacoes(df, colunas_x, todas_marcadas, coluna_y=coluna_y)
160
+ choices = list(colunas_x) if colunas_x else []
161
+ vis = bool(colunas_x)
162
+ return campos + [
163
+ False,
164
+ gr.update(choices=choices, value=dicotomicas_01, visible=vis),
165
+ gr.update(choices=choices, value=codigo_alocado, visible=vis),
166
+ gr.update(choices=choices, value=percentuais, visible=vis),
167
+ ]
168
+
169
+
170
+ # ============================================================
171
+ # HANDLERS DE MUDANÇA DE SELEÇÃO
172
+ # ============================================================
173
+
174
+ def ao_mudar_tipo_grafico(tipo, resultado_modelo):
175
+ """Atualiza o gráfico de dispersão com base na seleção do dropdown."""
176
+ if not resultado_modelo:
177
+ return None
178
+
179
+ try:
180
+ X_transf = resultado_modelo["X_transformado"]
181
+
182
+ if "Resíduo" in tipo:
183
+ # Gráfico X vs Resíduos
184
+ tabela = resultado_modelo.get("tabela_obs_calc")
185
+ if tabela is not None and "Resíduo Pad." in tabela.columns:
186
+ residuos = tabela["Resíduo Pad."].values
187
+ return criar_graficos_dispersao_residuos(X_transf, residuos)
188
+ else:
189
+ return None
190
+ else:
191
+ # Gráfico X vs Y (Padrão)
192
+ y_transf = resultado_modelo["y_transformado"]
193
+ return criar_graficos_dispersao(X_transf, y_transf)
194
+
195
+ except Exception as e:
196
+ print(f"Erro ao atualizar gráfico: {e}")
197
+ return None
198
+
199
+
200
+ def ao_mudar_y_sem_estatisticas(df, coluna_y, flag_carregamento=False, mostrar_secao_x=False):
201
+ """Callback quando variável y é alterada (sem calcular estatísticas).
202
+
203
+ Apenas atualiza os checkboxes de X disponíveis.
204
+ As estatísticas são calculadas apenas ao clicar em 'Aplicar Seleção' na seção 4.
205
+
206
+ Args:
207
+ df: DataFrame com os dados
208
+ coluna_y: Nome da coluna Y selecionada
209
+ flag_carregamento: Se True, mudança veio de carregamento programático (skip reset)
210
+ mostrar_secao_x: Se True, mostra a seção 4 (usado pelo botão Aplicar da seção 3)
211
+ """
212
+ # Se mudança veio de carregamento programático, não sobrescrever valores
213
+ # Flag é um contador inteiro: decrementa a cada side-effect processado
214
+ if flag_carregamento:
215
+ return (
216
+ gr.update(), # checkboxes_x — manter valor do carregamento
217
+ gr.update(), # header_secao_4 — manter visibilidade
218
+ gr.update(), # accordion_secao_4 — manter visibilidade
219
+ max(0, int(flag_carregamento) - 1), # estado_flag_carregamento — decrementar
220
+ )
221
+
222
+ if df is None or coluna_y is None:
223
+ return (
224
+ gr.update(choices=[]), # checkboxes_x
225
+ gr.update(visible=False), # header_secao_4
226
+ gr.update(visible=False), # accordion_secao_4
227
+ False, # estado_flag_carregamento
228
+ )
229
+
230
+ # Lista de X disponíveis (exclui y) - todas marcadas por padrão
231
+ colunas_x = [col for col in obter_colunas_numericas(df) if col != coluna_y]
232
+
233
+ return (
234
+ gr.update(choices=colunas_x, value=colunas_x), # checkboxes_x
235
+ gr.update(visible=mostrar_secao_x), # header_secao_4
236
+ gr.update(visible=mostrar_secao_x, open=mostrar_secao_x), # accordion_secao_4
237
+ False, # estado_flag_carregamento
238
+ )
239
+
240
+
241
+ # ============================================================
242
+ # ESTATÍSTICAS
243
+ # ============================================================
244
+
245
+ def atualizar_estatisticas_auto(df_filtrado, coluna_y, colunas_x):
246
+ """Recalcula estatísticas automaticamente e retorna timestamp.
247
+
248
+ CONTRACT: Retorna 2 itens (estatisticas, timestamp).
249
+ Se alterar, atualizar: outliers.py (reiniciar_iteracao_callback, destructuring).
250
+ """
251
+ if df_filtrado is None or coluna_y is None:
252
+ return None, None
253
+
254
+ # Filtra apenas colunas selecionadas + Y
255
+ colunas_usar = [coluna_y] + list(colunas_x) if colunas_x else [coluna_y]
256
+ colunas_disponiveis = [c for c in colunas_usar if c in df_filtrado.columns]
257
+
258
+ if not colunas_disponiveis:
259
+ return None, None
260
+
261
+ estatisticas = calcular_estatisticas_variaveis(df_filtrado, coluna_y, colunas=colunas_disponiveis)
262
+ gmt_minus_3 = timezone(timedelta(hours=-3))
263
+ timestamp = datetime.now(gmt_minus_3).strftime("%H:%M:%S")
264
+
265
+ return estatisticas.round(4), timestamp
266
+
267
+
268
+ # ============================================================
269
+ # CALLBACKS PRINCIPAIS
270
+ # ============================================================
271
+
272
+ def ajustar_modelo_callback(df, coluna_y, colunas_x, transformacao_y, outliers_anteriores, dicotomicas, codigo_alocado, percentuais, *valores_dropdowns):
273
+ """Callback para ajustar o modelo e calcular métricas de outliers.
274
+
275
+ CONTRACT: Retorna 33 itens para _outputs_ajustar (app.py:criar_aba).
276
+ Se alterar, atualizar: _outputs_ajustar em app.py + carregamento.py (indices em carregar_dados_de_dai).
277
+ """
278
+ # Variáveis disponíveis para filtro (métricas + todas as colunas originais do DataFrame)
279
+ colunas_metricas = {"Observado", "Calculado", "Resíduo", "Resíduo Pad.", "Resíduo Stud.", "Cook"}
280
+ colunas_originais = [c for c in df.columns if c not in colunas_metricas] if df is not None else []
281
+ variaveis_filtro = ["Resíduo Pad.", "Resíduo Stud.", "Cook"] + colunas_originais
282
+ filtro_var_updates = [gr.update(choices=variaveis_filtro)] * 4
283
+
284
+ # Updates para seções 10-16 ocultas (7 seções × 2 = 14 itens)
285
+ secoes_ocultas = (
286
+ gr.update(visible=False), gr.update(visible=False), # header_secao_10, accordion_secao_10
287
+ gr.update(visible=False), gr.update(visible=False), # header_secao_11, accordion_secao_11
288
+ gr.update(visible=False), gr.update(visible=False), # header_secao_12, accordion_secao_12
289
+ gr.update(visible=False), gr.update(visible=False), # header_secao_13, accordion_secao_13
290
+ gr.update(visible=False), gr.update(visible=False), # header_secao_14, accordion_secao_14
291
+ gr.update(visible=False), gr.update(visible=False), # header_secao_15, accordion_secao_15 (Avaliação)
292
+ gr.update(visible=False), gr.update(visible=False), # header_secao_16, accordion_secao_16 (Exportar)
293
+ )
294
+
295
+ if df is None or coluna_y is None or not colunas_x:
296
+ return (
297
+ None, # estado_modelo
298
+ "", # diagnosticos_html
299
+ None, # tabela_coef
300
+ None, # tabela_obs_calc
301
+ None, # plot_dispersao_transf
302
+ gr.update(visible=False), # dropdown_tipo_grafico_dispersao
303
+ None, None, None, None, None, # gráficos
304
+ None, # tabela_metricas
305
+ None, # estado_metricas
306
+ f"Excluídos: {len(outliers_anteriores) if outliers_anteriores else 0} | A excluir: 0 | A reincluir: 0 | Total: {len(outliers_anteriores) if outliers_anteriores else 0}", # txt_resumo_outliers
307
+ [], # estado_avaliacoes (reset)
308
+ *secoes_ocultas, # seções 10-16 ocultas
309
+ *filtro_var_updates # atualiza dropdowns de filtro
310
+ )
311
+
312
+ # Extrai transformações dos dropdowns
313
+ transformacoes_x = obter_transformacoes_dos_dropdowns(colunas_x, *valores_dropdowns)
314
+
315
+ # Ajusta modelo
316
+ resultado = ajustar_modelo(
317
+ df, coluna_y, colunas_x,
318
+ transformacao_y, transformacoes_x
319
+ )
320
+
321
+ if resultado is None:
322
+ return (
323
+ None, # estado_modelo
324
+ "", # diagnosticos_html
325
+ None, # tabela_coef
326
+ None, # tabela_obs_calc
327
+ None, # plot_dispersao_transf
328
+ gr.update(visible=False), # dropdown_tipo_grafico_dispersao
329
+ None, None, None, None, None, # gráficos
330
+ None, # tabela_metricas
331
+ None, # estado_metricas
332
+ f"Outliers anteriores: {len(outliers_anteriores) if outliers_anteriores else 0} | Excluir: 0 | Reincluir: 0 | Total: {len(outliers_anteriores) if outliers_anteriores else 0}",
333
+ [], # estado_avaliacoes (reset)
334
+ *secoes_ocultas, # seções 10-16 ocultas
335
+ *filtro_var_updates # atualiza dropdowns de filtro
336
+ )
337
+
338
+ # Formata diagnósticos
339
+ diagnosticos_html = formatar_diagnosticos_html(resultado["diagnosticos"])
340
+ gmt_minus_3 = timezone(timedelta(hours=-3))
341
+ timestamp = datetime.now(gmt_minus_3).strftime("%H:%M:%S")
342
+
343
+ # Gráficos de dispersão com variáveis transformadas
344
+ try:
345
+ X_transf = resultado["X_transformado"]
346
+ y_transf = resultado["y_transformado"]
347
+ fig_dispersao_transf = criar_graficos_dispersao(X_transf, y_transf)
348
+ except Exception as e:
349
+ print(f"Erro ao gerar gráficos de dispersão transformados: {e}")
350
+ fig_dispersao_transf = None
351
+
352
+ # Gr��ficos de diagnóstico
353
+ graficos = criar_painel_diagnostico(resultado)
354
+
355
+ # Correlação (com variáveis transformadas)
356
+ try:
357
+ X_transf_corr = resultado["X_transformado"].copy()
358
+ y_transf_corr = resultado["y_transformado"].copy()
359
+
360
+ # Cria DataFrame temporário para correlação
361
+ df_corr_temp = X_transf_corr
362
+ # Adiciona y (garantindo nome correto)
363
+ df_corr_temp[coluna_y] = y_transf_corr
364
+
365
+ colunas_corr = [coluna_y] + list(colunas_x)
366
+ fig_corr = criar_matriz_correlacao(df_corr_temp, colunas_corr)
367
+ except Exception as e:
368
+ print(f"Erro ao gerar matriz de correlação transformada: {e}")
369
+ fig_corr = None
370
+
371
+ # Extrai métricas de outliers da tabela_obs_calc (já contém resíduos, Cook, etc.)
372
+ tabela_metricas = resultado["tabela_obs_calc"].copy()
373
+
374
+ # Para o estado de filtros:
375
+ # 1. Usa set_index para que o índice do DataFrame seja os índices originais
376
+ # 2. Adiciona as variáveis X para permitir filtros por variáveis
377
+ tabela_metricas_estado = tabela_metricas.set_index("Índice")
378
+
379
+ # Adiciona todas as colunas originais ao estado para filtros
380
+ indices_usados = resultado["indices_usados"]
381
+ df_filtrado = df.loc[indices_usados]
382
+ colunas_novas = {
383
+ col: df_filtrado[col].values
384
+ for col in df.columns
385
+ if col not in tabela_metricas_estado.columns
386
+ }
387
+ if colunas_novas:
388
+ tabela_metricas_estado = pd.concat(
389
+ [tabela_metricas_estado, pd.DataFrame(colunas_novas, index=tabela_metricas_estado.index)],
390
+ axis=1
391
+ )
392
+
393
+ # Resumo de outliers
394
+ n_anteriores = len(outliers_anteriores) if outliers_anteriores else 0
395
+ resumo = f"Excluídos: {n_anteriores} | A excluir: 0 | A reincluir: 0 | Total: {n_anteriores}"
396
+
397
+ # Updates para seções 10-16 visíveis com timestamp nos headers (7 seções × 2 = 14 itens)
398
+ secoes_visiveis = (
399
+ gr.update(visible=True, value=criar_header_secao(10, "Gráficos de Dispersão com Modelo Ajustado", timestamp)), # header_secao_10
400
+ gr.update(visible=True, open=True), # accordion_secao_10
401
+ gr.update(visible=True, value=criar_header_secao(11, "Diagnóstico de Modelo", timestamp)), # header_secao_11
402
+ gr.update(visible=True, open=True), # accordion_secao_11
403
+ gr.update(visible=True, value=criar_header_secao(12, "Gráficos de Diagnóstico do Modelo", timestamp)), # header_secao_12
404
+ gr.update(visible=True, open=True), # accordion_secao_12
405
+ gr.update(visible=True, value=criar_header_secao(13, "Analisar Outliers", timestamp)), # header_secao_13
406
+ gr.update(visible=True, open=True), # accordion_secao_13
407
+ gr.update(visible=True, value=criar_header_secao(14, "Exclusão ou Reinclusão de Outliers", timestamp)), # header_secao_14
408
+ gr.update(visible=True, open=True), # accordion_secao_14
409
+ gr.update(visible=True, value=criar_header_secao(15, "Avaliação de Imóvel", timestamp)), # header_secao_15
410
+ gr.update(visible=True, open=True), # accordion_secao_15
411
+ gr.update(visible=True, value=criar_header_secao(16, "Exportar Modelo", timestamp)), # header_secao_16
412
+ gr.update(visible=True, open=True), # accordion_secao_16
413
+ )
414
+
415
+ resultado["dicotomicas"] = list(dicotomicas) if dicotomicas else []
416
+ resultado["codigo_alocado"] = list(codigo_alocado) if codigo_alocado else []
417
+ resultado["percentuais"] = list(percentuais) if percentuais else []
418
+
419
+ return (
420
+ resultado,
421
+ diagnosticos_html,
422
+ resultado["tabela_coef"].round(4),
423
+ resultado["tabela_obs_calc"].round(4),
424
+ fig_dispersao_transf, # plot_dispersao_transf
425
+ gr.update(value="Variáveis Independentes Transformadas X Variável Dependente Transformada", visible=True), # dropdown_tipo_grafico_dispersao
426
+ graficos.get("obs_calc"),
427
+ graficos.get("residuos"),
428
+ graficos.get("histograma"),
429
+ graficos.get("cook"),
430
+ fig_corr,
431
+ tabela_metricas.round(4), # tabela_metricas (para exibição - com coluna Índice)
432
+ tabela_metricas_estado, # estado_metricas (com set_index - para filtros)
433
+ resumo, # txt_resumo_outliers
434
+ [], # estado_avaliacoes (reset ao re-ajustar)
435
+ *secoes_visiveis, # seções 10-16 visíveis
436
+ *filtro_var_updates # atualiza dropdowns de filtro
437
+ )
438
+
439
+
440
+ def buscar_transformacoes_callback(df, coluna_y, colunas_x, dicotomicas=None, codigo_alocado=None, percentuais=None, grau_min_coef=1, grau_min_f=0):
441
+ """Callback para busca automática de transformações.
442
+
443
+ CONTRACT: Retorna 8 itens (html, resultados, timestamp, btn1..btn5).
444
+ Se alterar, atualizar: outliers.py (reiniciar_iteracao_callback, destructuring)
445
+ + app.py (outputs de btn_buscar_transf.click).
446
+ """
447
+ btn_hidden = [gr.update(visible=False)] * 5
448
+
449
+ if df is None or coluna_y is None or not colunas_x:
450
+ return ("<p>Selecione as variáveis primeiro.</p>", [], "", *btn_hidden)
451
+
452
+ # Verifica se colunas têm dados válidos (não 100% NaN)
453
+ colunas_vazias = []
454
+ for col in colunas_x:
455
+ if col in df.columns and df[col].isna().all():
456
+ colunas_vazias.append(col)
457
+
458
+ if colunas_vazias:
459
+ msg = f"<p style='color: red;'><b>Erro:</b> As seguintes colunas estão completamente vazias (sem dados): <b>{', '.join(colunas_vazias)}</b></p>"
460
+ msg += "<p>Selecione apenas variáveis que contenham dados válidos.</p>"
461
+ return (msg, [], "", *btn_hidden)
462
+
463
+ # Verifica se Y tem dados válidos
464
+ if coluna_y in df.columns and df[coluna_y].isna().all():
465
+ msg = f"<p style='color: red;'><b>Erro:</b> A variável dependente <b>{coluna_y}</b> está completamente vazia.</p>"
466
+ return (msg, [], "", *btn_hidden)
467
+
468
+ # Fixa dicotômicas e percentuais em (x) — código alocado fica livre
469
+ transformacoes_fixas = {}
470
+ if dicotomicas is None:
471
+ dicotomicas = detectar_dicotomicas(df, colunas_x)
472
+ if percentuais is None:
473
+ percentuais = detectar_percentuais(df, colunas_x)
474
+ for col in (dicotomicas or []) + (percentuais or []):
475
+ transformacoes_fixas[col] = "(x)"
476
+
477
+ # Busca melhores transformações
478
+ resultados = buscar_melhores_transformacoes(
479
+ df, coluna_y, colunas_x,
480
+ transformacoes_fixas=transformacoes_fixas,
481
+ top_n=5,
482
+ grau_min_coef=int(grau_min_coef),
483
+ grau_min_f=int(grau_min_f)
484
+ )
485
+
486
+ if not resultados:
487
+ msg = (
488
+ "<p style='color: orange;'><b>Aviso:</b> Nenhuma combinação de transformações resultou "
489
+ "em todos os coeficientes com ao menos Grau I de significância (p&nbsp;≤&nbsp;30%).</p>"
490
+ "<p>Considere revisar as variáveis selecionadas ou verificar a qualidade dos dados.</p>"
491
+ )
492
+ return (msg, [], "", *btn_hidden)
493
+
494
+ html = formatar_busca_html(resultados)
495
+ gmt_minus_3 = timezone(timedelta(hours=-3))
496
+ timestamp = datetime.now(gmt_minus_3).strftime("%H:%M:%S")
497
+
498
+ # Atualiza visibilidade dos botões "Adotar"
499
+ btn_updates = [gr.update(visible=(i < len(resultados))) for i in range(5)]
500
+
501
+ return (html, resultados, timestamp, *btn_updates)
502
+
503
+
504
+ def aplicar_selecao_callback(df, coluna_y, colunas_x, outliers_anteriores, dicotomicas=None, codigo_alocado=None, percentuais=None):
505
+ """Aplica seleção de variáveis, atualiza estatísticas e busca transformações automaticamente.
506
+
507
+ NÃO calcula métricas de outliers aqui - isso é feito ao ajustar o modelo.
508
+ Filtra dados excluindo outliers de iterações anteriores.
509
+ Gera gráficos de dispersão e teste de micronumerosidade.
510
+ Busca transformações com fallback automático (começa em Grau III/III e reduz se necessário).
511
+
512
+ CONTRACT: Retorna 90 itens para btn_aplicar_selecao_x.click (app.py:criar_aba).
513
+ Se alterar, atualizar: app.py (outputs) + carregamento.py (indices em carregar_dados_de_dai).
514
+ """
515
+ n_rows = (MAX_VARS_X + 7) // 8 # 3 rows (8 por linha)
516
+
517
+ # Valores padrão quando não há dados
518
+ campos_vazios = (
519
+ [gr.update(visible=False)] * 3 + # 3: transf_y_row, transf_y_col, transf_y_label
520
+ [gr.update(visible=False)] * n_rows + # 3: transf_x_rows
521
+ [gr.update(visible=False)] * MAX_VARS_X + # 20: transf_x_columns
522
+ [gr.update(value="", visible=False)] * MAX_VARS_X + # 20: transf_x_labels
523
+ [gr.update(value="(x)", interactive=True, visible=False)] * MAX_VARS_X # 20: transf_x_dropdowns
524
+ )
525
+
526
+ # Botões de adotar escondidos por padrão
527
+ btn_hidden = [gr.update(visible=False)] * 5
528
+
529
+ # Valores padrão para dispersão e micronumerosidade
530
+ dispersao_vazio = None
531
+ micro_vazio = "<p>Clique em 'Aplicar Seleção' para verificar micronumerosidade.</p>"
532
+
533
+ # Updates para seções 5-9 ocultas
534
+ secoes_ocultas = (
535
+ gr.update(visible=False), gr.update(visible=False), # header_secao_5, accordion_secao_5
536
+ gr.update(visible=False), gr.update(visible=False), # header_secao_6, accordion_secao_6
537
+ gr.update(visible=False), gr.update(visible=False), # header_secao_7, accordion_secao_7
538
+ gr.update(visible=False), gr.update(visible=False), # header_secao_8, accordion_secao_8
539
+ gr.update(visible=False), gr.update(visible=False), # header_secao_9, accordion_secao_9
540
+ )
541
+
542
+ if df is None or coluna_y is None:
543
+ return (
544
+ None, # estado_df_filtrado
545
+ gr.update(value=None), # tabela_estatisticas
546
+ micro_vazio, # html_micronumerosidade
547
+ dispersao_vazio, # plot_dispersao
548
+ gr.update(), # slider_grau_coef (no-op)
549
+ gr.update(), # slider_grau_f (no-op)
550
+ "", # busca_html
551
+ [], # estado_resultados_busca
552
+ *btn_hidden, # botões adotar
553
+ *secoes_ocultas, # seções 5-9 ocultas
554
+ *campos_vazios, # campos de transformação
555
+ gr.update(value="", visible=False), # html_aviso_multicolinearidade
556
+ )
557
+
558
+ if not colunas_x:
559
+ return (
560
+ df,
561
+ gr.update(value=None), # tabela_estatisticas
562
+ micro_vazio, # html_micronumerosidade
563
+ dispersao_vazio, # plot_dispersao
564
+ gr.update(), # slider_grau_coef (no-op)
565
+ gr.update(), # slider_grau_f (no-op)
566
+ "", # busca_html
567
+ [], # estado_resultados_busca
568
+ *btn_hidden, # botões adotar
569
+ *secoes_ocultas, # seções 5-9 ocultas
570
+ *campos_vazios, # campos de transformação
571
+ gr.update(value="", visible=False), # html_aviso_multicolinearidade
572
+ )
573
+
574
+ # Filtra dados excluindo outliers de iterações anteriores
575
+ df_filtrado = df.copy()
576
+ if outliers_anteriores:
577
+ df_filtrado = df_filtrado.drop(index=outliers_anteriores, errors='ignore')
578
+
579
+ # Calcula estatísticas com dados filtrados
580
+ estatisticas, _ = atualizar_estatisticas_auto(df_filtrado, coluna_y, colunas_x)
581
+
582
+ # Timestamp para as novas seções
583
+ gmt_minus_3 = timezone(timedelta(hours=-3))
584
+ timestamp = datetime.now(gmt_minus_3).strftime("%H:%M:%S")
585
+
586
+ # Teste de micronumerosidade
587
+ try:
588
+ resultado_micro = testar_micronumerosidade(df_filtrado, list(colunas_x),
589
+ dicotomicas=dicotomicas, codigo_alocado=codigo_alocado)
590
+ html_micro = formatar_micronumerosidade_html(resultado_micro)
591
+ except Exception as e:
592
+ print(f"Erro ao testar micronumerosidade: {e}")
593
+ html_micro = f"<p style='color: red;'>Erro ao calcular micronumerosidade: {e}</p>"
594
+
595
+ # Gera gráficos de dispersão
596
+ try:
597
+ X = df_filtrado[list(colunas_x)]
598
+ y = df_filtrado[coluna_y]
599
+ fig_dispersao = criar_graficos_dispersao(X, y)
600
+ except Exception as e:
601
+ print(f"Erro ao gerar gráficos de dispersão: {e}")
602
+ fig_dispersao = None
603
+
604
+ # Atualiza campos de transformação (junta todas as marcadas para lock)
605
+ todas_marcadas = (dicotomicas or []) + (codigo_alocado or []) + (percentuais or [])
606
+ campos_updates = atualizar_campos_transformacoes(df, colunas_x, todas_marcadas, coluna_y=coluna_y)
607
+
608
+ # Busca automática de transformações: critério mínimo (Grau I nos coeficientes, sem filtro F)
609
+ busca_html_result, resultados_busca, _, *btn_updates = buscar_transformacoes_callback(
610
+ df_filtrado, coluna_y, colunas_x, dicotomicas, codigo_alocado, percentuais,
611
+ grau_min_coef=1, grau_min_f=0
612
+ )
613
+
614
+ # Radio buttons refletem o grau mínimo REAL presente nos resultados
615
+ if resultados_busca:
616
+ grau_coef_usado = min(
617
+ min(r.get("graus_coef", {}).values(), default=0)
618
+ for r in resultados_busca
619
+ )
620
+ grau_f_usado = min(r.get("grau_f", 0) for r in resultados_busca)
621
+ else:
622
+ grau_coef_usado = 1
623
+ grau_f_usado = 0
624
+
625
+ # Updates para seções 5-9 visíveis com timestamp nos headers
626
+ secoes_visiveis = (
627
+ gr.update(visible=True, value=criar_header_secao(5, "Estatísticas das Variáveis Selecionadas", timestamp)), # header_secao_5
628
+ gr.update(visible=True, open=True), # accordion_secao_5
629
+ gr.update(visible=True, value=criar_header_secao(6, "Teste de Micronumerosidade (NBR 14.653-2)", timestamp)), # header_secao_6
630
+ gr.update(visible=True, open=True), # accordion_secao_6
631
+ gr.update(visible=True, value=criar_header_secao(7, "Gráficos de Dispersão das Variáveis Independentes", timestamp)), # header_secao_7
632
+ gr.update(visible=True, open=True), # accordion_secao_7
633
+ gr.update(visible=True, value=criar_header_secao(8, "Transformações Sugeridas", timestamp)), # header_secao_8
634
+ gr.update(visible=True, open=True), # accordion_secao_8
635
+ gr.update(visible=True, value=criar_header_secao(9, "Aplicação das Transformações", timestamp)), # header_secao_9
636
+ gr.update(visible=True, open=True), # accordion_secao_9
637
+ )
638
+
639
+ # Verifica multicolinearidade (dados brutos, antes das transformações da seção 9)
640
+ resultado_multi = verificar_multicolinearidade(df_filtrado, list(colunas_x))
641
+ html_multi, visivel_multi = formatar_aviso_multicolinearidade(resultado_multi)
642
+
643
+ return (
644
+ df_filtrado, # estado_df_filtrado
645
+ gr.update(value=estatisticas), # tabela_estatisticas
646
+ html_micro, # html_micronumerosidade
647
+ fig_dispersao, # plot_dispersao
648
+ gr.update(value=grau_coef_usado), # slider_grau_coef
649
+ gr.update(value=grau_f_usado), # slider_grau_f
650
+ busca_html_result, # busca_html
651
+ resultados_busca, # estado_resultados_busca
652
+ *btn_updates, # botões adotar
653
+ *secoes_visiveis, # seções 5-9 visíveis
654
+ *campos_updates, # campos de transformação
655
+ gr.update(value=html_multi, visible=visivel_multi), # html_aviso_multicolinearidade (item 90)
656
+ )
657
+
658
+
659
+ def adotar_sugestao(indice, resultados, colunas_x):
660
+ """Preenche os dropdowns com a sugestão selecionada.
661
+
662
+ CONTRACT: Retorna 21 itens (1 transf_y + 20 transf_x_dropdowns).
663
+ Se alterar, atualizar: app.py (outputs de btn_adotar_N.click).
664
+ """
665
+ if not resultados or indice >= len(resultados):
666
+ return [gr.update()] * (1 + MAX_VARS_X)
667
+
668
+ sugestao = resultados[indice]
669
+
670
+ # Update para transformação de Y
671
+ updates = [gr.update(value=sugestao["transformacao_y"])]
672
+
673
+ # Updates para transformações de X
674
+ transf_x = sugestao["transformacoes_x"]
675
+ for i in range(MAX_VARS_X):
676
+ if i < len(colunas_x):
677
+ col = colunas_x[i]
678
+ updates.append(gr.update(value=transf_x.get(col, "(x)")))
679
+ else:
680
+ updates.append(gr.update())
681
+
682
+ return updates
683
+
684
+
685
+ # ============================================================
686
+ # CALLBACKS DE AVALIAÇÃO (Seção 15)
687
+ # ============================================================
688
+
689
+ N_AVAL_ROWS = MAX_VARS_X // 4 # 5 rows (4 inputs por linha)
690
+
691
+
692
+ def popular_campos_avaliacao_callback(estado_modelo, tabela_estatisticas):
693
+ """Popula inputs da seção 15 com labels dinâmicos após ajuste.
694
+
695
+ Chamado via .then() após ajustar_modelo_callback ou carregar .dai.
696
+
697
+ CONTRACT: Retorna 27 itens (5 aval_rows + 20 aval_inputs + resultado_html + dropdown_base).
698
+ Se alterar, atualizar: app.py (_outputs_popular_avaliacao).
699
+ """
700
+ rows_hidden = [gr.update(visible=False)] * N_AVAL_ROWS
701
+ inputs_hidden = [gr.update(visible=False, value=None, label="")] * MAX_VARS_X
702
+ dropdown_reset = gr.update(choices=[], value=None)
703
+
704
+ if estado_modelo is None:
705
+ return (*rows_hidden, *inputs_hidden, "", dropdown_reset)
706
+
707
+ try:
708
+ colunas_x = estado_modelo["colunas_x"]
709
+ n_vars = len(colunas_x)
710
+
711
+ # Extrair min/max das estatísticas
712
+ import pandas as pd
713
+ if tabela_estatisticas is not None and isinstance(tabela_estatisticas, pd.DataFrame):
714
+ if "Variável" in tabela_estatisticas.columns:
715
+ est_idx = tabela_estatisticas.set_index("Variável")
716
+ else:
717
+ est_idx = tabela_estatisticas
718
+ else:
719
+ est_idx = pd.DataFrame()
720
+
721
+ # Rows visíveis
722
+ import math
723
+ n_rows_vis = math.ceil(n_vars / 4)
724
+ rows_updates = [gr.update(visible=(r < n_rows_vis)) for r in range(N_AVAL_ROWS)]
725
+
726
+ # Inputs com labels dinâmicos
727
+ dicotomicas = estado_modelo.get("dicotomicas", [])
728
+ codigo_alocado = estado_modelo.get("codigo_alocado", [])
729
+ percentuais = estado_modelo.get("percentuais", [])
730
+ inputs_updates = []
731
+ for i in range(MAX_VARS_X):
732
+ if i < n_vars:
733
+ col = colunas_x[i]
734
+ if col in dicotomicas:
735
+ placeholder = "0 ou 1"
736
+ elif col in codigo_alocado and col in est_idx.index:
737
+ min_val = est_idx.loc[col, "Mínimo"]
738
+ max_val = est_idx.loc[col, "Máximo"]
739
+ placeholder = f"cód. {int(min_val)} a {int(max_val)}"
740
+ elif col in percentuais:
741
+ placeholder = "0 a 1"
742
+ elif col in est_idx.index:
743
+ min_val = est_idx.loc[col, "Mínimo"]
744
+ max_val = est_idx.loc[col, "Máximo"]
745
+ placeholder = f"{min_val} — {max_val}"
746
+ else:
747
+ placeholder = ""
748
+ inputs_updates.append(gr.update(visible=True, value=None, label=col, placeholder=placeholder, interactive=True))
749
+ else:
750
+ inputs_updates.append(gr.update(visible=False, value=None, label="", placeholder=""))
751
+
752
+ return (*rows_updates, *inputs_updates, "", dropdown_reset)
753
+
754
+ except Exception as e:
755
+ print(f"Erro ao popular campos de avaliação: {e}")
756
+ return (*rows_hidden, *inputs_hidden, "", dropdown_reset)
757
+
758
+
759
+ def avaliar_imovel_callback(estado_modelo, tabela_estatisticas, estado_avaliacoes, indice_base_str, *aval_inputs):
760
+ """Avalia um imóvel usando o modelo ajustado (seção 15).
761
+
762
+ CONTRACT: Retorna 3 itens (resultado_html, estado_avaliacoes, dropdown_update).
763
+ Se alterar, atualizar: app.py (outputs de btn_calcular_avaliacao.click).
764
+ """
765
+ _err = lambda msg: (msg, estado_avaliacoes or [], gr.update())
766
+ if estado_modelo is None:
767
+ return _err("Ajuste um modelo primeiro.")
768
+
769
+ import pandas as pd
770
+ colunas_x = estado_modelo["colunas_x"]
771
+ n_vars = len(colunas_x)
772
+
773
+ # Extrair valores dos inputs
774
+ valores_x = {}
775
+ for i, col in enumerate(colunas_x):
776
+ if i >= len(aval_inputs) or aval_inputs[i] is None:
777
+ return _err("Preencha todos os campos antes de calcular.")
778
+ valores_x[col] = float(aval_inputs[i])
779
+
780
+ # Extrair transformações do resultado do modelo
781
+ transformacoes_x = estado_modelo.get("transformacoes_x", {})
782
+ transformacao_y = estado_modelo.get("transformacao_y", "(x)")
783
+
784
+ # Obter estatísticas
785
+ if tabela_estatisticas is not None and isinstance(tabela_estatisticas, pd.DataFrame):
786
+ estatisticas_df = tabela_estatisticas
787
+ else:
788
+ return _err("Estatísticas não disponíveis.")
789
+
790
+ # Validar variáveis dicotômicas, código alocado e percentuais ANTES de avaliar
791
+ dicotomicas = estado_modelo.get("dicotomicas", [])
792
+ codigo_alocado = estado_modelo.get("codigo_alocado", [])
793
+ percentuais = estado_modelo.get("percentuais", [])
794
+
795
+ if "Variável" in estatisticas_df.columns:
796
+ est_idx = estatisticas_df.set_index("Variável")
797
+ else:
798
+ est_idx = estatisticas_df
799
+
800
+ for col in colunas_x:
801
+ val = valores_x[col]
802
+ if col in dicotomicas:
803
+ if val not in (0, 0.0, 1, 1.0):
804
+ return _err(f"<p style='color:red;'><b>Erro:</b> A variável <b>{col}</b> é dicotômica e aceita apenas valores 0 ou 1. "
805
+ f"Valor informado: {val}</p>")
806
+ elif col in codigo_alocado and col in est_idx.index:
807
+ min_val = float(est_idx.loc[col, "Mínimo"])
808
+ max_val = float(est_idx.loc[col, "Máximo"])
809
+ eh_inteiro = (float(val) == int(float(val)))
810
+ if not eh_inteiro or val < min_val or val > max_val:
811
+ return _err(f"<p style='color:red;'><b>Erro:</b> A variável <b>{col}</b> é de código alocado/ajustado e aceita apenas "
812
+ f"valores inteiros de {int(min_val)} a {int(max_val)}. Valor informado: {val}</p>")
813
+ elif col in percentuais:
814
+ if val < 0 or val > 1:
815
+ return _err(f"<p style='color:red;'><b>Erro:</b> A variável <b>{col}</b> é percentual e aceita apenas "
816
+ f"valores entre 0 e 1. Valor informado: {val}</p>")
817
+
818
+ resultado = avaliar_imovel(
819
+ modelo_sm=estado_modelo["modelo_sm"],
820
+ valores_x=valores_x,
821
+ colunas_x=colunas_x,
822
+ transformacoes_x=transformacoes_x,
823
+ transformacao_y=transformacao_y,
824
+ estatisticas_df=estatisticas_df,
825
+ dicotomicas=dicotomicas,
826
+ codigo_alocado=codigo_alocado,
827
+ percentuais=percentuais,
828
+ )
829
+
830
+ if resultado is None:
831
+ return _err("Erro ao calcular avaliação.")
832
+
833
+ # Acumular resultado
834
+ nova_lista = list(estado_avaliacoes or []) + [resultado]
835
+ indice_base = int(indice_base_str) - 1 if indice_base_str else 0
836
+ html = formatar_avaliacao_html(nova_lista, indice_base=indice_base, elem_id_excluir="excluir-aval-elab")
837
+
838
+ # Atualizar choices do dropdown de base
839
+ choices = [str(i + 1) for i in range(len(nova_lista))]
840
+ # Se é a primeira avaliação, setar base como "1"
841
+ base_val = indice_base_str if indice_base_str else "1"
842
+
843
+ return html, nova_lista, gr.update(choices=choices, value=base_val)
844
+
845
+
846
+ def limpar_avaliacoes_callback():
847
+ """Limpa o histórico de avaliações da seção 15.
848
+
849
+ CONTRACT: Retorna 3 itens (resultado_html, estado_avaliacoes, dropdown_update).
850
+ Se alterar, atualizar: app.py (outputs de btn_limpar_avaliacoes.click).
851
+ """
852
+ return "", [], gr.update(choices=[], value=None)
853
+
854
+
855
+ def excluir_avaliacao_callback(indice_str, estado_avaliacoes, indice_base_str):
856
+ """Exclui uma avaliação da lista (seção 15).
857
+
858
+ CONTRACT: Retorna 4 itens (resultado_html, estado_avaliacoes, dropdown_update, trigger_reset).
859
+ """
860
+ if not indice_str or not indice_str.strip() or not estado_avaliacoes:
861
+ return gr.update(), estado_avaliacoes or [], gr.update(), ""
862
+
863
+ try:
864
+ idx = int(indice_str.strip()) - 1
865
+ except ValueError:
866
+ return gr.update(), estado_avaliacoes or [], gr.update(), ""
867
+
868
+ if idx < 0 or idx >= len(estado_avaliacoes):
869
+ return gr.update(), estado_avaliacoes or [], gr.update(), ""
870
+
871
+ nova_lista = [a for i, a in enumerate(estado_avaliacoes) if i != idx]
872
+
873
+ if not nova_lista:
874
+ return "", [], gr.update(choices=[], value=None), ""
875
+
876
+ # Ajustar índice base
877
+ base = int(indice_base_str) - 1 if indice_base_str else 0
878
+ if base >= len(nova_lista):
879
+ base = len(nova_lista) - 1
880
+ if base < 0:
881
+ base = 0
882
+
883
+ choices = [str(i + 1) for i in range(len(nova_lista))]
884
+ html = formatar_avaliacao_html(nova_lista, indice_base=base, elem_id_excluir="excluir-aval-elab")
885
+ return html, nova_lista, gr.update(choices=choices, value=str(base + 1)), ""
886
+
887
+
888
+ def atualizar_base_avaliacao_callback(estado_avaliacoes, indice_base_str):
889
+ """Re-renderiza HTML quando o dropdown de base muda (seção 15).
890
+
891
+ CONTRACT: Retorna 1 item (resultado_html).
892
+ """
893
+ if not estado_avaliacoes:
894
+ return ""
895
+ indice = int(indice_base_str) - 1 if indice_base_str else 0
896
+ return formatar_avaliacao_html(estado_avaliacoes, indice_base=indice, elem_id_excluir="excluir-aval-elab")
897
+
898
+
899
+ def exportar_avaliacoes_excel_callback(estado_avaliacoes):
900
+ """Exporta avaliações para Excel (seção 15).
901
+
902
+ CONTRACT: Retorna 1 item (download_file_update).
903
+ """
904
+ if not estado_avaliacoes:
905
+ return gr.update(value=None, visible=False)
906
+ caminho = exportar_avaliacoes_excel(estado_avaliacoes)
907
+ if caminho:
908
+ return gr.update(value=caminho, visible=True)
909
+ return gr.update(value=None, visible=False)
910
+
911
+
912
+ def exportar_modelo_callback(resultado_modelo, df, df_completo, estatisticas,
913
+ nome_arquivo, elaborador=None, outliers_excluidos=None):
914
+ """Callback para exportar modelo com download."""
915
+ if resultado_modelo is None:
916
+ return "Nenhum modelo para exportar. Ajuste o modelo primeiro.", gr.update(value=None, visible=False)
917
+
918
+ if not nome_arquivo or not nome_arquivo.strip():
919
+ return "Informe o nome do arquivo.", gr.update(value=None, visible=False)
920
+
921
+ caminho, msg = exportar_modelo_dai(
922
+ resultado_modelo, df, df_completo, estatisticas, nome_arquivo.strip(),
923
+ elaborador=elaborador, outliers_excluidos=outliers_excluidos or []
924
+ )
925
+
926
+ if caminho:
927
+ return msg, gr.update(value=caminho, visible=True)
928
+ else:
929
+ return msg, gr.update(value=None, visible=False)
930
+
931
+
932
+ # ============================================================
933
+ # CALLBACKS DE DICOTÔMICAS
934
+ # ============================================================
935
+
936
+ def atualizar_interativo_dicotomicas(colunas_x, dicotomicas, codigo_alocado, percentuais, df):
937
+ """Atualiza interactive dos dropdowns de transformação conforme os 3 tipos.
938
+
939
+ Dicotômicas e percentuais: transformação travada em (x).
940
+ Código alocado: transformação livre.
941
+
942
+ Handler para checkboxes_dicotomicas.change(), checkboxes_codigo_alocado.change(),
943
+ checkboxes_percentuais.change().
944
+
945
+ CONTRACT: Retorna 20 itens (transf_x_dropdowns).
946
+ Se alterar, atualizar: app.py (outputs dos .change dos 3 checkboxes).
947
+ """
948
+ updates = []
949
+ for i in range(MAX_VARS_X):
950
+ if i < len(colunas_x):
951
+ col = colunas_x[i]
952
+ if col in (dicotomicas or []) or col in (percentuais or []):
953
+ updates.append(gr.update(interactive=False, value="(x)"))
954
+ else:
955
+ updates.append(gr.update(interactive=True))
956
+ else:
957
+ updates.append(gr.update())
958
+ return updates
959
+
960
+
961
+ def popular_dicotomicas_callback(estado_modelo, colunas_x, estado_df):
962
+ """Popula os 3 checkboxes: dicotômicas, código alocado e percentuais.
963
+
964
+ Para .dai: usa listas salvas no modelo.
965
+ Para CSV/Excel: auto-detecta os 3 tipos.
966
+
967
+ CONTRACT: Retorna 3 itens (checkboxes_dicotomicas, checkboxes_codigo_alocado, checkboxes_percentuais).
968
+ Se alterar, atualizar: app.py (outputs do .then de popular_dicotomicas).
969
+ """
970
+ empty = gr.update(choices=[], value=[], visible=False)
971
+ if not colunas_x:
972
+ return empty, empty, empty
973
+
974
+ choices = list(colunas_x)
975
+
976
+ if estado_modelo is not None:
977
+ dic = estado_modelo.get("dicotomicas", [])
978
+ cod = estado_modelo.get("codigo_alocado", [])
979
+ perc = estado_modelo.get("percentuais", [])
980
+ elif estado_df is not None:
981
+ dic = detectar_dicotomicas(estado_df, colunas_x)
982
+ cod = detectar_codigo_alocado(estado_df, colunas_x)
983
+ perc = detectar_percentuais(estado_df, colunas_x)
984
+ else:
985
+ return empty, empty, empty
986
+
987
+ return (
988
+ gr.update(choices=choices, value=dic, visible=True),
989
+ gr.update(choices=choices, value=cod, visible=True),
990
+ gr.update(choices=choices, value=perc, visible=True),
991
+ )
backend/app/core/elaboracao/outliers.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ outliers.py - Filtros dinâmicos, exclusão/reinclusão e iteração de outliers.
4
+
5
+ Callbacks que lidam com filtros de outliers, exclusão,
6
+ reinclusão e reinício de iteração do modelo.
7
+ """
8
+
9
+ import gradio as gr
10
+
11
+ from .formatadores import (
12
+ arredondar_df,
13
+ criar_header_secao,
14
+ formatar_micronumerosidade_html,
15
+ formatar_outliers_anteriores_html,
16
+ )
17
+ from .modelo import buscar_transformacoes_callback, atualizar_estatisticas_auto
18
+ from .core import testar_micronumerosidade
19
+ from .charts import criar_graficos_dispersao, criar_mapa
20
+
21
+
22
+ # ============================================================
23
+ # FILTROS
24
+ # ============================================================
25
+
26
+ def aplicar_filtros_callback(metricas_estado, n_filtros, *args):
27
+ """Aplica múltiplos filtros de outliers.
28
+
29
+ n_filtros: número de filtros visíveis (apenas esses serão aplicados)
30
+ args: var1, var2, var3, var4, op1, op2, op3, op4, val1, val2, val3, val4
31
+ Retorna lista de índices que satisfazem QUALQUER filtro (OR lógico).
32
+ """
33
+ if metricas_estado is None:
34
+ return ""
35
+
36
+ # Extrai filtros dos argumentos (apenas os filtros visíveis)
37
+ # Ordem: [4 vars] + [4 ops] + [4 vals]
38
+ filtros = []
39
+ for i in range(n_filtros): # Só processa os filtros visíveis
40
+ var = args[i] # vars estão nos índices 0-3
41
+ op = args[i + 4] # ops estão nos índices 4-7
42
+ val = args[i + 8] # vals estão nos índices 8-11
43
+ if var is not None and op is not None and val is not None:
44
+ # Converte valor para float (pode vir como string)
45
+ try:
46
+ val_float = float(val)
47
+ except (ValueError, TypeError):
48
+ continue
49
+ filtros.append({"variavel": var, "operador": op, "valor": val_float})
50
+
51
+ if not filtros:
52
+ return ""
53
+
54
+ # Aplica filtros com lógica OR
55
+ indices_outliers = set()
56
+
57
+ for filtro in filtros:
58
+ var = filtro["variavel"]
59
+ op = filtro["operador"]
60
+ val = filtro["valor"]
61
+
62
+ if var not in metricas_estado.columns:
63
+ continue
64
+
65
+ # Aplica operador
66
+ if op == "<=":
67
+ mask = metricas_estado[var] <= val
68
+ elif op == ">=":
69
+ mask = metricas_estado[var] >= val
70
+ elif op == "<":
71
+ mask = metricas_estado[var] < val
72
+ elif op == ">":
73
+ mask = metricas_estado[var] > val
74
+ elif op == "=":
75
+ mask = metricas_estado[var] == val
76
+ else:
77
+ continue
78
+
79
+ # Usa o índice do DataFrame (que agora são os índices originais dos dados via set_index)
80
+ indices_filtro = metricas_estado[mask].index.tolist()
81
+ indices_outliers.update(indices_filtro)
82
+
83
+ # Converte para inteiros e ordena
84
+ indices_ordenados = sorted([int(i) for i in indices_outliers])
85
+ return ", ".join(map(str, indices_ordenados))
86
+
87
+
88
+ def adicionar_filtro_callback(n_filtros_atual):
89
+ """Callback para adicionar um novo filtro (mostra a próxima row oculta).
90
+
91
+ CONTRACT: Retorna 5 itens (n_filtros + 4 row updates).
92
+ Se alterar, atualizar: app.py (outputs de btn_adicionar_filtro.click).
93
+ """
94
+ n_novo = min(n_filtros_atual + 1, 4)
95
+
96
+ # Retorna: novo estado + 4 updates de visibilidade para rows
97
+ updates = [n_novo]
98
+ for i in range(4):
99
+ updates.append(gr.update(visible=(i < n_novo)))
100
+
101
+ return tuple(updates)
102
+
103
+
104
+ def remover_ultimo_filtro_callback(n_filtros_atual):
105
+ """Remove o último filtro visível.
106
+
107
+ CONTRACT: Retorna 5 itens (n_filtros + 4 row updates).
108
+ Se alterar, atualizar: app.py (outputs de btn_remover_filtro.click).
109
+ """
110
+ n_novo = max(0, n_filtros_atual - 1)
111
+ return (n_novo,) + tuple(gr.update(visible=(i < n_novo)) for i in range(4))
112
+
113
+
114
+ def limpar_filtros_callback():
115
+ """Limpa todos os filtros e restaura os padrões.
116
+
117
+ CONTRACT: Retorna 18 itens (n_filtros + 4 rows + 4 vars + 4 ops + 4 vals + outliers_texto).
118
+ Se alterar, atualizar: app.py (outputs de btn_limpar_filtros.click).
119
+ """
120
+ return (
121
+ 2, # estado_n_filtros (volta para 2 filtros padrão)
122
+ gr.update(visible=True), # row 0
123
+ gr.update(visible=True), # row 1
124
+ gr.update(visible=False), # row 2
125
+ gr.update(visible=False), # row 3
126
+ gr.update(value="Resíduo Pad."), # var 0
127
+ gr.update(value="Resíduo Pad."), # var 1
128
+ gr.update(value="Resíduo Pad."), # var 2
129
+ gr.update(value="Resíduo Pad."), # var 3
130
+ gr.update(value="<="), # op 0
131
+ gr.update(value=">="), # op 1
132
+ gr.update(value=">="), # op 2
133
+ gr.update(value=">="), # op 3
134
+ gr.update(value=-2.0), # val 0
135
+ gr.update(value=2.0), # val 1
136
+ gr.update(value=0.0), # val 2
137
+ gr.update(value=0.0), # val 3
138
+ "" # outliers_texto
139
+ )
140
+
141
+
142
+ # ============================================================
143
+ # ITERAÇÃO DE OUTLIERS
144
+ # ============================================================
145
+
146
+ def reiniciar_iteracao_callback(df_original, outliers_anteriores, outliers_texto, reincluir_texto, iteracao_atual, coluna_y, colunas_x, dicotomicas, codigo_alocado, percentuais, grau_min_coef=3, grau_min_f=3):
147
+ """Combina outliers anteriores com novos (excluindo reincluídos), atualiza estado e reinicia análise.
148
+ Recalcula todas as seções (2 em diante) considerando a ausência dos outliers.
149
+
150
+ CONTRACT: Retorna 30 itens para btn_reiniciar_iteracao.click (app.py:criar_aba).
151
+ Se alterar, atualizar: app.py (outputs de btn_reiniciar_iteracao.click).
152
+ Consome buscar_transformacoes_callback por destructuring (html, resultados, _, *btns).
153
+ Consome atualizar_estatisticas_auto por destructuring (stats, timestamp).
154
+ """
155
+ # Parse dos novos outliers
156
+ novos_outliers = []
157
+ if outliers_texto and outliers_texto.strip():
158
+ try:
159
+ novos_outliers = [int(x.strip()) for x in outliers_texto.split(",") if x.strip()]
160
+ except:
161
+ pass
162
+
163
+ # Parse dos índices a reincluir
164
+ reincluir = []
165
+ if reincluir_texto and reincluir_texto.strip():
166
+ try:
167
+ reincluir = [int(x.strip()) for x in reincluir_texto.split(",") if x.strip()]
168
+ except:
169
+ pass
170
+
171
+ # Remove reincluídos dos anteriores, depois combina com novos (sem duplicatas)
172
+ anteriores_atualizados = [i for i in (outliers_anteriores or []) if i not in reincluir]
173
+ outliers_combinados = list(set(anteriores_atualizados + novos_outliers))
174
+ outliers_combinados.sort()
175
+
176
+ # Incrementa iteração
177
+ nova_iteracao = (iteracao_atual or 1) + 1
178
+
179
+ # Cria DataFrame filtrado
180
+ df_filtrado = df_original.copy()
181
+ if outliers_combinados:
182
+ df_filtrado = df_filtrado.drop(index=outliers_combinados, errors='ignore')
183
+
184
+ # Recalcula estatísticas (seção 5)
185
+ estatisticas, timestamp = atualizar_estatisticas_auto(df_filtrado, coluna_y, colunas_x)
186
+
187
+ # Recalcula micronumerosidade (seção 6)
188
+ try:
189
+ resultado_micro = testar_micronumerosidade(df_filtrado, list(colunas_x),
190
+ dicotomicas=dicotomicas, codigo_alocado=codigo_alocado)
191
+ html_micro = formatar_micronumerosidade_html(resultado_micro)
192
+ except Exception as e:
193
+ html_micro = f"<p style='color: red;'>Erro ao calcular micronumerosidade: {e}</p>"
194
+
195
+ # Recalcula gráficos de dispersão originais (seção 7)
196
+ try:
197
+ X = df_filtrado[list(colunas_x)]
198
+ y = df_filtrado[coluna_y]
199
+ print(f"[reiniciar_iteracao] Criando gráfico dispersão com {len(df_filtrado)} pontos (excluídos: {outliers_combinados})")
200
+ fig_dispersao = criar_graficos_dispersao(X, y)
201
+ except Exception as e:
202
+ print(f"Erro ao gerar gráficos de dispersão: {e}")
203
+ fig_dispersao = None
204
+
205
+ # Recalcula busca de transformações (seção 8)
206
+ try:
207
+ busca_html_result, resultados_busca, _, *btn_updates = buscar_transformacoes_callback(
208
+ df_filtrado, coluna_y, colunas_x, dicotomicas, codigo_alocado, percentuais, int(grau_min_coef), int(grau_min_f)
209
+ )
210
+ except Exception as e:
211
+ busca_html_result = f"<p style='color: red;'>Erro na busca: {e}</p>"
212
+ resultados_busca = []
213
+ btn_updates = [gr.update(visible=False) for _ in range(5)]
214
+
215
+ # Radio buttons coerentes com os novos resultados
216
+ if resultados_busca:
217
+ grau_coef_min = min(
218
+ min(r.get("graus_coef", {}).values(), default=0)
219
+ for r in resultados_busca
220
+ )
221
+ grau_f_min = min(r.get("grau_f", 0) for r in resultados_busca)
222
+ else:
223
+ grau_coef_min = int(grau_min_coef)
224
+ grau_f_min = int(grau_min_f)
225
+
226
+ # Atualiza textos
227
+ html_outliers_ant = formatar_outliers_anteriores_html(
228
+ len(outliers_combinados),
229
+ ", ".join(map(str, outliers_combinados)) if outliers_combinados else ""
230
+ )
231
+
232
+ # Visibilidade do accordion
233
+ accordion_visivel = len(outliers_combinados) > 0
234
+
235
+ # Mapa atualizado
236
+ mapa_html = criar_mapa(df_filtrado)
237
+
238
+ # Header updates com timestamp
239
+ header_2_update = gr.update(value=criar_header_secao(2, "Visualizar Dados", timestamp))
240
+ header_5_update = gr.update(value=criar_header_secao(5, "Estatísticas das Variáveis Selecionadas", timestamp))
241
+ header_6_update = gr.update(value=criar_header_secao(6, "Teste de Micronumerosidade (NBR 14.653-2)", timestamp))
242
+ header_7_update = gr.update(value=criar_header_secao(7, "Gráficos de Dispersão das Variáveis Independentes", timestamp))
243
+ header_8_update = gr.update(value=criar_header_secao(8, "Transformações Sugeridas", timestamp))
244
+
245
+ return (
246
+ outliers_combinados, # estado_outliers_anteriores
247
+ nova_iteracao, # estado_iteracao
248
+ df_filtrado, # estado_df_filtrado
249
+ arredondar_df(df_filtrado), # tabela_dados
250
+ estatisticas, # tabela_estatisticas
251
+ header_5_update, # header_secao_5
252
+ html_outliers_ant, # html_outliers_anteriores
253
+ html_outliers_ant, # html_outliers_sec14
254
+ gr.update(visible=accordion_visivel), # accordion_outliers_anteriores
255
+ "", # outliers_texto (limpo)
256
+ "", # reincluir_texto (limpo)
257
+ None, # tabela_metricas (limpa)
258
+ None, # estado_metricas (limpo)
259
+ gr.update(value=criar_header_secao(13, "Analisar Outliers")), # header_secao_13
260
+ f"Excluídos: {len(outliers_combinados)} | A excluir: 0 | A reincluir: 0 | Total: {len(outliers_combinados)}", # txt_resumo_outliers
261
+ mapa_html, # mapa_html atualizado
262
+ # Novos outputs para seções 2, 6, 7, 8
263
+ header_2_update, # header_secao_2
264
+ html_micro, # html_micronumerosidade
265
+ header_6_update, # header_secao_6
266
+ fig_dispersao, # plot_dispersao
267
+ header_7_update, # header_secao_7
268
+ busca_html_result, # busca_html
269
+ resultados_busca, # estado_resultados_busca
270
+ header_8_update, # header_secao_8
271
+ *btn_updates, # botões adotar (5 botões)
272
+ gr.update(value=grau_coef_min), # slider_grau_coef
273
+ gr.update(value=grau_f_min), # slider_grau_f
274
+ )
275
+
276
+
277
+ def atualizar_resumo_outliers(outliers_anteriores, outliers_texto, reincluir_texto):
278
+ """Atualiza o resumo de outliers quando o usuário edita os campos."""
279
+ n_anteriores = len(outliers_anteriores) if outliers_anteriores else 0
280
+
281
+ novos_outliers = []
282
+ if outliers_texto and outliers_texto.strip():
283
+ try:
284
+ novos_outliers = [int(x.strip()) for x in outliers_texto.split(",") if x.strip()]
285
+ except:
286
+ pass
287
+
288
+ reincluir = []
289
+ if reincluir_texto and reincluir_texto.strip():
290
+ try:
291
+ reincluir = [int(x.strip()) for x in reincluir_texto.split(",") if x.strip()]
292
+ except:
293
+ pass
294
+
295
+ n_novos = len(novos_outliers)
296
+ n_reincluir = len(reincluir)
297
+ # Calcula total: anteriores menos reincluídos, mais novos
298
+ anteriores_atualizados = [i for i in (outliers_anteriores or []) if i not in reincluir]
299
+ n_total = len(set(anteriores_atualizados + novos_outliers))
300
+
301
+ return f"Excluídos: {n_anteriores} | A excluir: {n_novos} | A reincluir: {n_reincluir} | Total: {n_total}"
backend/app/core/visualizacao/__init__.py ADDED
File without changes
backend/app/core/visualizacao/app.py ADDED
@@ -0,0 +1,1477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # IMPORTAÇÕES
3
+ # ============================================================
4
+ import gradio as gr
5
+ import pandas as pd
6
+ import numpy as np
7
+ import folium
8
+ from folium import plugins
9
+ from joblib import load
10
+ import os
11
+ import re
12
+ import traceback
13
+
14
+
15
+ # Importações para gráficos (trazidas de graficos.py)
16
+ from scipy import stats
17
+ import plotly.graph_objects as go
18
+ from statsmodels.stats.outliers_influence import OLSInfluence
19
+ import branca.colormap as cm
20
+ import math
21
+
22
+ from app.core.elaboracao.core import avaliar_imovel, _migrar_pacote_v1_para_v2, exportar_avaliacoes_excel
23
+ from app.core.elaboracao.formatadores import formatar_avaliacao_html
24
+
25
+ # ============================================================
26
+ # CONSTANTES
27
+ # ============================================================
28
+ CHAVES_ESPERADAS = ["versao", "dados", "transformacoes", "modelo"]
29
+
30
+ # Constantes para avaliação
31
+ MAX_VARS_AVAL = 20
32
+ N_COLS_AVAL = 2
33
+ N_ROWS_AVAL = MAX_VARS_AVAL // N_COLS_AVAL # 10
34
+
35
+ # Cores consistentes (trazidas de graficos.py)
36
+ COR_PRINCIPAL = '#FF8C00' # Laranja
37
+ COR_LINHA = '#dc3545' # Vermelho para linhas de referência
38
+
39
+ # ============================================================
40
+ # FUNÇÃO: CARREGAR CSS EXTERNO
41
+ # ============================================================
42
+ def carregar_css():
43
+ """Carrega o arquivo CSS externo."""
44
+ css_path = os.path.join(os.path.dirname(__file__), "styles.css")
45
+ try:
46
+ with open(css_path, "r", encoding="utf-8") as f:
47
+ return f.read()
48
+ except FileNotFoundError:
49
+ print(f"Aviso: Arquivo CSS não encontrado em {css_path}")
50
+ return ""
51
+
52
+ # ============================================================
53
+ # LÓGICA DE GERAÇÃO DE GRÁFICOS (ADAPTADA DE GRAFICOS.PY)
54
+ # ============================================================
55
+
56
+ def _criar_grafico_obs_calc(y_obs, y_calc, indices=None):
57
+ """Cria gráfico de valores observados vs calculados (Plotly Figure)."""
58
+ try:
59
+ fig = go.Figure()
60
+
61
+ scatter_args = dict(
62
+ x=y_calc,
63
+ y=y_obs,
64
+ mode='markers',
65
+ marker=dict(
66
+ color=COR_PRINCIPAL,
67
+ size=10,
68
+ line=dict(color='black', width=1)
69
+ ),
70
+ name='Dados',
71
+ )
72
+ if indices is not None:
73
+ scatter_args['customdata'] = indices
74
+ scatter_args['hovertemplate'] = "<b>Índice:</b> %{customdata}<br><b>Calculado:</b> %{x:.2f}<br><b>Observado:</b> %{y:.2f}<extra></extra>"
75
+ else:
76
+ scatter_args['hovertemplate'] = "<b>Calculado:</b> %{x:.2f}<br><b>Observado:</b> %{y:.2f}<extra></extra>"
77
+
78
+ # Scatter plot
79
+ fig.add_trace(go.Scatter(**scatter_args))
80
+
81
+ # Linha de identidade (45 graus)
82
+ min_val = min(min(y_obs), min(y_calc))
83
+ max_val = max(max(y_obs), max(y_calc))
84
+ margin = (max_val - min_val) * 0.05
85
+
86
+ fig.add_trace(go.Scatter(
87
+ x=[min_val - margin, max_val + margin],
88
+ y=[min_val - margin, max_val + margin],
89
+ mode='lines',
90
+ line=dict(color=COR_LINHA, dash='dash', width=2),
91
+ name='Linha de identidade'
92
+ ))
93
+
94
+ fig.update_layout(
95
+ title=dict(text='Valores Observados vs Calculados', x=0.5),
96
+ xaxis_title='Valores Calculados',
97
+ yaxis_title='Valores Observados',
98
+ showlegend=True,
99
+ plot_bgcolor='white',
100
+ margin=dict(l=60, r=40, t=60, b=60)
101
+ )
102
+
103
+ fig.update_xaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black')
104
+ fig.update_yaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black')
105
+
106
+ return fig
107
+ except Exception as e:
108
+ print(f"Erro ao criar gráfico obs vs calc: {e}")
109
+ return None
110
+
111
+ def _criar_grafico_residuos(y_calc, residuos, indices=None):
112
+ """Cria gráfico de resíduos vs valores ajustados (Plotly Figure)."""
113
+ try:
114
+ fig = go.Figure()
115
+
116
+ scatter_args = dict(
117
+ x=y_calc,
118
+ y=residuos,
119
+ mode='markers',
120
+ marker=dict(
121
+ color=COR_PRINCIPAL,
122
+ size=10,
123
+ line=dict(color='black', width=1)
124
+ ),
125
+ name='Resíduos',
126
+ )
127
+ if indices is not None:
128
+ scatter_args['customdata'] = indices
129
+ scatter_args['hovertemplate'] = "<b>Índice:</b> %{customdata}<br><b>Ajustado:</b> %{x:.2f}<br><b>Resíduo:</b> %{y:.4f}<extra></extra>"
130
+ else:
131
+ scatter_args['hovertemplate'] = "<b>Ajustado:</b> %{x:.2f}<br><b>Resíduo:</b> %{y:.4f}<extra></extra>"
132
+
133
+ fig.add_trace(go.Scatter(**scatter_args))
134
+
135
+ fig.add_hline(y=0, line_dash="dash", line_color=COR_LINHA, line_width=2)
136
+
137
+ fig.update_layout(
138
+ title=dict(text='Resíduos vs Valores Ajustados', x=0.5),
139
+ xaxis_title='Valores Ajustados',
140
+ yaxis_title='Resíduos',
141
+ showlegend=False,
142
+ plot_bgcolor='white',
143
+ margin=dict(l=60, r=40, t=60, b=60)
144
+ )
145
+
146
+ fig.update_xaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black')
147
+ fig.update_yaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black')
148
+
149
+ return fig
150
+ except Exception as e:
151
+ print(f"Erro ao criar gráfico de resíduos: {e}")
152
+ return None
153
+
154
+ def _criar_histograma_residuos(residuos):
155
+ """Cria histograma dos resíduos com curva normal (Plotly Figure)."""
156
+ try:
157
+ fig = go.Figure()
158
+
159
+ fig.add_trace(go.Histogram(
160
+ x=residuos,
161
+ histnorm='probability density',
162
+ marker=dict(color=COR_PRINCIPAL, line=dict(color='black', width=1)),
163
+ opacity=0.7,
164
+ name='Resíduos'
165
+ ))
166
+
167
+ mu, sigma = np.mean(residuos), np.std(residuos)
168
+ x_norm = np.linspace(min(residuos) - sigma, max(residuos) + sigma, 100)
169
+ y_norm = stats.norm.pdf(x_norm, mu, sigma)
170
+
171
+ fig.add_trace(go.Scatter(
172
+ x=x_norm,
173
+ y=y_norm,
174
+ mode='lines',
175
+ line=dict(color=COR_LINHA, width=3),
176
+ name='Curva Normal'
177
+ ))
178
+
179
+ fig.update_layout(
180
+ title=dict(text='Distribuição dos Resíduos', x=0.5),
181
+ xaxis_title='Resíduos',
182
+ yaxis_title='Densidade',
183
+ showlegend=True,
184
+ plot_bgcolor='white',
185
+ barmode='overlay',
186
+ margin=dict(l=60, r=40, t=60, b=60)
187
+ )
188
+
189
+ fig.update_xaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black')
190
+ fig.update_yaxes(showgrid=True, gridcolor='lightgray', showline=True, linecolor='black')
191
+
192
+ return fig
193
+ except Exception as e:
194
+ print(f"Erro ao criar histograma: {e}")
195
+ return None
196
+
197
+
198
+ def _criar_grafico_cook(modelos_sm):
199
+ """Cria gráfico de Distância de Cook (Plotly Figure)."""
200
+ try:
201
+ if modelos_sm is None: return None
202
+
203
+ influence = OLSInfluence(modelos_sm)
204
+ cooks_d = influence.cooks_distance[0]
205
+
206
+ n = len(cooks_d)
207
+ indices = np.arange(1, n + 1)
208
+ limite = 4 / n
209
+
210
+ fig = go.Figure()
211
+
212
+ # Hastes (linhas verticais)
213
+ for idx, valor in zip(indices, cooks_d):
214
+ cor = COR_LINHA if valor > limite else COR_PRINCIPAL
215
+ fig.add_trace(
216
+ go.Scatter(x=[idx, idx],
217
+ y=[0, valor],
218
+ mode='lines',
219
+ line=dict(color=cor, width=1.5),
220
+ showlegend=False,
221
+ hoverinfo='skip'))
222
+
223
+ # Pontos
224
+ cores_pontos = [
225
+ COR_LINHA if v > limite else COR_PRINCIPAL for v in cooks_d
226
+ ]
227
+ fig.add_trace(
228
+ go.Scatter(
229
+ x=indices,
230
+ y=cooks_d,
231
+ mode='markers',
232
+ marker=dict(color=cores_pontos,
233
+ size=8,
234
+ line=dict(color='black', width=1)),
235
+ name='Distância de Cook',
236
+ hovertemplate='Obs: %{x}<br>Cook: %{y:.4f}<extra></extra>'))
237
+
238
+ fig.add_hline(y=limite,
239
+ line_dash="dash",
240
+ line_color='gray',
241
+ annotation_text=f"4/n = {limite:.4f}",
242
+ annotation_position="top right")
243
+
244
+ fig.update_layout(title=dict(text='Distância de Cook', x=0.5),
245
+ xaxis_title='Observação',
246
+ yaxis_title='Distância de Cook',
247
+ plot_bgcolor='white',
248
+ margin=dict(l=60, r=40, t=60, b=60))
249
+
250
+ fig.update_xaxes(showgrid=True,
251
+ gridcolor='lightgray',
252
+ showline=True,
253
+ linecolor='black')
254
+ fig.update_yaxes(showgrid=True,
255
+ gridcolor='lightgray',
256
+ showline=True,
257
+ linecolor='black')
258
+
259
+ return fig
260
+ except Exception as e:
261
+ print(f"Erro ao criar gráfico de Cook: {e}")
262
+ return None
263
+
264
+ def _criar_grafico_correlacao(modelos_sm):
265
+ """Gera heatmap de correlação com cores customizadas e diagonal limpa."""
266
+ try:
267
+ if modelos_sm is None or not hasattr(modelos_sm, 'model'):
268
+ return None
269
+
270
+ model = modelos_sm.model
271
+ X = model.exog
272
+ X_names = model.exog_names
273
+ y = model.endog
274
+ y_name = getattr(model, 'endog_names', 'Variável Dependente')
275
+
276
+ df_X = pd.DataFrame(X, columns=X_names)
277
+ df_X = df_X.drop(
278
+ columns=[c for c in df_X.columns if str(c).lower() in ('const', 'intercept')],
279
+ errors='ignore'
280
+ )
281
+
282
+ df_y = pd.DataFrame({y_name: y})
283
+ df = pd.concat([df_y, df_X], axis=1)
284
+
285
+ # garantir numérico
286
+ df = df.apply(pd.to_numeric, errors='coerce')
287
+
288
+ # remover colunas constantes
289
+ variancias = df.var(ddof=0)
290
+ df = df.loc[:, variancias.fillna(0) > 0]
291
+
292
+ if df.shape[1] < 2:
293
+ return None
294
+
295
+ # ✅ CRIAR corr
296
+ corr = df.corr()
297
+
298
+ if corr.isnull().values.all():
299
+ return None
300
+
301
+ # ✅ remover diagonal (SEM numpy in-place)
302
+ mask = np.eye(len(corr), dtype=bool)
303
+ corr = corr.where(~mask)
304
+
305
+ # texto
306
+ text = np.where(
307
+ np.isnan(corr.values),
308
+ "",
309
+ np.round(corr.values, 2).astype(str)
310
+ )
311
+
312
+ fig = go.Figure(go.Heatmap(
313
+ z=corr.values,
314
+ x=corr.columns,
315
+ y=corr.index,
316
+ text=text,
317
+ texttemplate="%{text}",
318
+ textfont=dict(size=10),
319
+ zmin=-1,
320
+ zmax=1,
321
+ zmid=0,
322
+ colorscale = [
323
+ [0.00, "rgb(103,0,31)"],
324
+ [0.08, "rgb(178,24,43)"],
325
+ [0.16, "rgb(214,96,77)"],
326
+ [0.24, "rgb(244,165,130)"],
327
+ [0.32, "rgb(253,219,199)"],
328
+
329
+ # faixa branca larga (inalterada)
330
+ [0.45, "rgb(255,255,255)"],
331
+ [0.55, "rgb(255,255,255)"],
332
+
333
+ [0.68, "rgb(209,229,240)"],
334
+ [0.76, "rgb(146,197,222)"],
335
+ [0.84, "rgb(67,147,195)"],
336
+ [0.92, "rgb(33,102,172)"],
337
+ [1.00, "rgb(5,48,97)"]
338
+ ],
339
+ colorbar=dict(title='Correlação'),
340
+ hovertemplate="%{x} × %{y}<br>ρ = %{z:.3f}<extra></extra>"
341
+ ))
342
+
343
+ # ✅ diagonal VISUAL (robusta)
344
+ fig.add_shape(
345
+ type="line",
346
+ xref="paper",
347
+ yref="paper",
348
+ x0=0, y0=1,
349
+ x1=1, y1=0,
350
+ line=dict(color="rgba(0,0,0,0.35)", width=1),
351
+ layer="above"
352
+ )
353
+
354
+ fig.update_layout(
355
+ title=dict(text="Matriz de Correlação", x=0.5),
356
+ height=600,
357
+ template='plotly_white',
358
+ xaxis=dict(tickangle=45, showgrid=False),
359
+ yaxis=dict(autorange='reversed', showgrid=False)
360
+ )
361
+
362
+ return fig
363
+
364
+ except Exception as e:
365
+ print(f"Erro na geração do gráfico: {e}")
366
+ traceback.print_exc()
367
+ return None
368
+
369
+ # def _criar_grafico_correlacao(modelos_sm):
370
+ # """Gera heatmap de correlação (Plotly Figure)."""
371
+ # try:
372
+ # if modelos_sm is None or not hasattr(modelos_sm, 'model'):
373
+ # return None
374
+
375
+ # model = modelos_sm.model
376
+ # if not hasattr(model, 'exog') or not hasattr(model, 'endog'):
377
+ # return None
378
+
379
+ # X = model.exog
380
+ # X_names = model.exog_names
381
+ # y = model.endog
382
+ # y_name = getattr(model, 'endog_names', 'Variável Dependente')
383
+
384
+ # df_X = pd.DataFrame(X, columns=X_names)
385
+
386
+ # # Remover constantes explícitas
387
+ # df_X = df_X.drop(
388
+ # columns=[c for c in df_X.columns if c.lower() in ('const', 'intercept')],
389
+ # errors='ignore'
390
+ # )
391
+
392
+ # df_y = pd.DataFrame({y_name: y})
393
+ # df = pd.concat([df_y, df_X], axis=1)
394
+
395
+ # # Remover colunas constantes
396
+ # variancias = df.var(ddof=0)
397
+ # df = df.loc[:, variancias.fillna(0) > 0]
398
+
399
+ # if df.shape[1] < 2: return None
400
+
401
+ # corr = df.corr()
402
+ # if corr.isnull().values.all(): return None
403
+
404
+ # text = np.round(corr.values, 2).astype(str)
405
+
406
+ # fig = go.Figure(data=go.Heatmap(
407
+ # z=corr.values,
408
+ # x=corr.columns,
409
+ # y=corr.index,
410
+ # text=text,
411
+ # texttemplate="%{text}",
412
+ # textfont=dict(size=10),
413
+ # zmin=-1, zmax=1,
414
+ # colorscale = [
415
+ # [0.0, "rgb(178,24,43)"], # red
416
+ # [0.35, "rgb(178,24,43)"],
417
+
418
+ # [0.45, "rgb(255,255,255)"], # start white
419
+ # [0.55, "rgb(255,255,255)"], # end white
420
+
421
+ # [0.65, "rgb(33,102,172)"],
422
+ # [1.0, "rgb(33,102,172)"] # blue
423
+ # ],
424
+ # colorbar=dict(title='Correlação')
425
+ # ))
426
+
427
+ # fig.update_layout(
428
+ # title=dict(text="Matriz de Correlação", x=0.5),
429
+ # height=600,
430
+ # template='plotly_white',
431
+ # xaxis=dict(tickangle=45, showgrid=False),
432
+ # yaxis=dict(autorange='reversed', showgrid=True)
433
+ # )
434
+
435
+ # return fig
436
+ # except Exception:
437
+ # return None
438
+
439
+ def gerar_todos_graficos(pacote):
440
+ """Orquestra a geração de todos os gráficos a partir do pacote."""
441
+ graficos = {
442
+ "obs_calc": None,
443
+ "residuos": None,
444
+ "hist": None,
445
+ "cook": None,
446
+ "corr": None
447
+ }
448
+
449
+ obs_calc = pacote["modelo"]["obs_calc"]
450
+ modelos_sm = pacote["modelo"]["sm"]
451
+
452
+ # Identificar vetores
453
+ y_obs = None
454
+ y_calc = None
455
+ residuos = None
456
+ indices = None
457
+
458
+ # Tenta pegar do DataFrame obs_calc
459
+ if obs_calc is not None and not obs_calc.empty:
460
+ cols_lower = {str(c).lower(): c for c in obs_calc.columns}
461
+
462
+ # Extrair índices do DataFrame
463
+ indices = obs_calc.index.values if obs_calc.index is not None else None
464
+
465
+ # Encontrar coluna observada
466
+ for nome in ['observado', 'obs', 'y_obs', 'y', 'valor_observado']:
467
+ if nome in cols_lower:
468
+ y_obs = obs_calc[cols_lower[nome]].values
469
+ break
470
+
471
+ # Encontrar coluna calculada
472
+ for nome in ['calculado', 'calc', 'y_calc', 'y_hat', 'previsto']:
473
+ if nome in cols_lower:
474
+ y_calc = obs_calc[cols_lower[nome]].values
475
+ break
476
+
477
+ # Encontrar coluna resíduos
478
+ for nome in ['residuo', 'residuos', 'resid']:
479
+ if nome in cols_lower:
480
+ residuos = obs_calc[cols_lower[nome]].values
481
+ break
482
+
483
+ # Fallback para o objeto statsmodels
484
+ if modelos_sm is not None:
485
+ try:
486
+ if y_obs is None and hasattr(modelos_sm.model, 'endog'):
487
+ y_obs = modelos_sm.model.endog
488
+ if y_calc is None and hasattr(modelos_sm, 'fittedvalues'):
489
+ y_calc = modelos_sm.fittedvalues
490
+ if residuos is None and hasattr(modelos_sm, 'resid'):
491
+ residuos = modelos_sm.resid
492
+ except Exception:
493
+ pass
494
+
495
+ # Cálculo manual se necessário
496
+ if residuos is None and y_obs is not None and y_calc is not None:
497
+ residuos = np.array(y_obs) - np.array(y_calc)
498
+
499
+ # Converter para numpy
500
+ y_obs = np.array(y_obs) if y_obs is not None else None
501
+ y_calc = np.array(y_calc) if y_calc is not None else None
502
+ residuos = np.array(residuos) if residuos is not None else None
503
+
504
+ # Gerar cada gráfico
505
+ if y_obs is not None and y_calc is not None:
506
+ graficos["obs_calc"] = _criar_grafico_obs_calc(y_obs, y_calc, indices)
507
+
508
+ if residuos is not None and y_calc is not None:
509
+ graficos["residuos"] = _criar_grafico_residuos(y_calc, residuos, indices)
510
+
511
+ if residuos is not None:
512
+ graficos["hist"] = _criar_histograma_residuos(residuos)
513
+
514
+ if modelos_sm is not None:
515
+ graficos["cook"] = _criar_grafico_cook(modelos_sm)
516
+ graficos["corr"] = _criar_grafico_correlacao(modelos_sm)
517
+
518
+ return graficos
519
+
520
+ # ============================================================
521
+ # FUNÇÃO: REORGANIZAR DIAGNOSTICOS PARA EXIBIÇÃO
522
+ # ============================================================
523
+ def reorganizar_modelos_resumos(diagnosticos):
524
+ """
525
+ Converte diagnosticos v2 (já agrupado) para formato de exibição HTML.
526
+ """
527
+ gerais = diagnosticos.get("gerais", {})
528
+ return {
529
+ "estatisticas_gerais": {
530
+ "n": {"nome": "Número de observações", "valor": gerais.get("n")},
531
+ "k": {"nome": "Número de variáveis independentes", "valor": gerais.get("k")},
532
+ "desvio_padrao_residuos": {"nome": "Desvio padrão dos resíduos", "valor": gerais.get("desvio_padrao_residuos")},
533
+ "mse": {"nome": "MSE", "valor": gerais.get("mse")},
534
+ "r2": {"nome": "R²", "valor": gerais.get("r2")},
535
+ "r2_ajustado": {"nome": "R² ajustado", "valor": gerais.get("r2_ajustado")},
536
+ "r_pearson": {"nome": "Correlação Pearson", "valor": gerais.get("r_pearson")}
537
+ },
538
+ "teste_f": {
539
+ "nome": "Teste F",
540
+ "estatistica": diagnosticos.get("teste_f", {}).get("estatistica"),
541
+ "pvalor": diagnosticos.get("teste_f", {}).get("p_valor"),
542
+ "interpretacao": diagnosticos.get("teste_f", {}).get("interpretacao")
543
+ },
544
+ "teste_ks": {
545
+ "nome": "Teste de Normalidade (Kolmogorov-Smirnov)",
546
+ "estatistica": diagnosticos.get("teste_ks", {}).get("estatistica"),
547
+ "pvalor": diagnosticos.get("teste_ks", {}).get("p_valor"),
548
+ "interpretacao": diagnosticos.get("teste_ks", {}).get("interpretacao")
549
+ },
550
+ "perc_resid": {
551
+ "nome": "Teste de Normalidade (Comparação com a Curva Normal)",
552
+ "valor": diagnosticos.get("teste_normalidade", {}).get("percentuais"),
553
+ "interpretacao": [
554
+ "Ideal 68% → aceitável entre 64% e 75%",
555
+ "Ideal 90% → aceitável entre 88% e 95%",
556
+ "Ideal 95% → aceitável entre 95% e 100%"
557
+ ]
558
+ },
559
+ "teste_dw": {
560
+ "nome": "Teste de Autocorrelação (Durbin-Watson)",
561
+ "estatistica": diagnosticos.get("teste_dw", {}).get("estatistica"),
562
+ "interpretacao": diagnosticos.get("teste_dw", {}).get("interpretacao")
563
+ },
564
+ "teste_bp": {
565
+ "nome": "Teste de Homocedasticidade (Breusch-Pagan)",
566
+ "estatistica": diagnosticos.get("teste_bp", {}).get("estatistica"),
567
+ "pvalor": diagnosticos.get("teste_bp", {}).get("p_valor"),
568
+ "interpretacao": diagnosticos.get("teste_bp", {}).get("interpretacao")
569
+ },
570
+ "equacao": diagnosticos.get("equacao")
571
+ }
572
+
573
+ # ============================================================
574
+ # FUNÇÃO: FORMATAR VALOR MONETÁRIO
575
+ # ============================================================
576
+ def formatar_monetario(valor):
577
+ if pd.isna(valor): return "N/A"
578
+ return f"R$ {valor:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
579
+
580
+ # ============================================================
581
+ # FUNÇÃO: FORMATAR RESUMO COMO HTML
582
+ # ============================================================
583
+ def formatar_resumo_html(resumo_reorganizado):
584
+
585
+ def criar_titulo_secao(titulo):
586
+ return f'<div class="section-title-orange-solid">{titulo}</div>'
587
+
588
+ def criar_linha_campo(campo, valor):
589
+ return f"""<div class="field-row"><span class="field-row-label">{campo}</span><span class="field-row-value">{valor}</span></div>"""
590
+
591
+ def criar_linha_interpretacao(interpretacao):
592
+ return f"""<div class="field-row"><span class="field-row-label">Interpretação</span><span class="field-row-value-italic">{interpretacao}</span></div>"""
593
+
594
+ def formatar_numero(valor, casas_decimais=4):
595
+ if valor is None: return "N/A"
596
+ if isinstance(valor, (int, float, np.floating)): return f"{valor:.{casas_decimais}f}"
597
+ return str(valor)
598
+
599
+ linhas_html = []
600
+
601
+ # 1. Estatísticas Gerais
602
+ estat_gerais = resumo_reorganizado.get("estatisticas_gerais", {})
603
+ if estat_gerais:
604
+ linhas_html.append(criar_titulo_secao("Estatísticas Gerais"))
605
+ for chave, dados in estat_gerais.items():
606
+ linhas_html.append(criar_linha_campo(dados.get("nome", chave), formatar_numero(dados.get("valor"))))
607
+
608
+ # 2. Testes (ordem: F, KS, Curva Normal, DW, BP)
609
+ for chave_teste, label in [("teste_f", "Estatística F"), ("teste_ks", "Estatística KS")]:
610
+ teste = resumo_reorganizado.get(chave_teste, {})
611
+ if teste.get("estatistica") is not None:
612
+ linhas_html.append(criar_titulo_secao(teste.get("nome")))
613
+ linhas_html.append(criar_linha_campo(label, formatar_numero(teste["estatistica"])))
614
+ if teste.get("pvalor") is not None:
615
+ linhas_html.append(criar_linha_campo("P-valor", formatar_numero(teste["pvalor"])))
616
+ if teste.get("interpretacao"):
617
+ linhas_html.append(criar_linha_interpretacao(teste["interpretacao"]))
618
+
619
+ # Percentuais (Comparação com a Curva Normal — logo após KS)
620
+ perc_resid = resumo_reorganizado.get("perc_resid", {})
621
+ if perc_resid.get("valor") is not None:
622
+ linhas_html.append(criar_titulo_secao(perc_resid.get("nome")))
623
+ linhas_html.append(criar_linha_campo("Percentuais Atingidos", perc_resid["valor"]))
624
+ if perc_resid.get("interpretacao"):
625
+ linhas_html.append('<div class="interpretation-label">Interpretação</div>')
626
+ for ideal in perc_resid["interpretacao"]:
627
+ linhas_html.append(f'<div class="interpretation-item">• {ideal}</div>')
628
+
629
+ for chave_teste, label in [("teste_dw", "Estatística DW"), ("teste_bp", "Estatística LM")]:
630
+ teste = resumo_reorganizado.get(chave_teste, {})
631
+ if teste.get("estatistica") is not None:
632
+ linhas_html.append(criar_titulo_secao(teste.get("nome")))
633
+ linhas_html.append(criar_linha_campo(label, formatar_numero(teste["estatistica"])))
634
+ if teste.get("pvalor") is not None:
635
+ linhas_html.append(criar_linha_campo("P-valor", formatar_numero(teste["pvalor"])))
636
+ if teste.get("interpretacao"):
637
+ linhas_html.append(criar_linha_interpretacao(teste["interpretacao"]))
638
+
639
+ # Equação
640
+ equacao = resumo_reorganizado.get("equacao")
641
+ if equacao:
642
+ linhas_html.append(criar_titulo_secao("Equação do Modelo"))
643
+ linhas_html.append(f'<div class="equation-box">{equacao}</div>')
644
+
645
+ return f"""<div class="dai-card scrollable-container">{"".join(linhas_html)}</div>"""
646
+
647
+ # ============================================================
648
+ # FUNÇÃO: CRIAR TÍTULO DE SEÇÃO ESTILIZADO
649
+ # ============================================================
650
+ def criar_titulo_secao_html(titulo):
651
+ return f'<div class="section-title-orange">{titulo}</div>'
652
+
653
+ # ============================================================
654
+ # FUNÇÃO: FORMATAR ESCALAS/TRANSFORMAÇÕES
655
+ # ============================================================
656
+ def formatar_escalas_html(escalas_raw):
657
+ if isinstance(escalas_raw, pd.DataFrame): itens = escalas_raw.iloc[:, 0].tolist()
658
+ elif isinstance(escalas_raw, list): itens = escalas_raw
659
+ else: itens = [str(escalas_raw)]
660
+
661
+ itens = [str(item) for item in itens if item and str(item).strip()]
662
+ if not itens: return "<p style='color: #6c757d; font-style: italic;'>Nenhuma transformação disponível.</p>"
663
+
664
+ max_chars = max(len(item) for item in itens)
665
+ largura_min = max(150, max_chars * 7 + 28)
666
+ cards_html = ""
667
+ for item in itens:
668
+ if ":" in item:
669
+ partes = item.split(":", 1)
670
+ conteudo = f"""<span style="font-weight: 600; color: #495057;">{partes[0].strip()}:</span><span style="font-weight: 400; color: #6c757d; margin-left: 4px;">{partes[1].strip()}</span>"""
671
+ else:
672
+ conteudo = f"""<span style="font-weight: 600; color: #495057;">{item}</span>"""
673
+ cards_html += f"""<div class="dai-card-light" style="min-width: {largura_min}px;">{conteudo}</div>"""
674
+
675
+ return f"""<div class="dai-card">{criar_titulo_secao_html("Escalas / Transformações")}<div class="dai-cards-grid" style="grid-template-columns: repeat(auto-fill, minmax({largura_min}px, 1fr));">{cards_html}</div></div>"""
676
+
677
+ # ============================================================
678
+ # FUNÇÃO: GERAR MAPA FOLIUM (com suporte a dimensionamento por variável)
679
+ # ============================================================
680
+ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None, col_y=None):
681
+ """
682
+ Cria mapa Folium com os dados, com suporte a dimensionamento proporcional.
683
+
684
+ Parâmetros:
685
+ df: DataFrame com os dados
686
+ lat_col: nome da coluna de latitude
687
+ lon_col: nome da coluna de longitude
688
+ cor_col: coluna para colorir os pontos (opcional)
689
+ tamanho_col: coluna numérica para dimensionar os círculos (opcional)
690
+
691
+ Retorna:
692
+ HTML do mapa
693
+ """
694
+ # Verifica se colunas existem
695
+ cols_lower = {str(c).lower(): c for c in df.columns}
696
+
697
+ lat_real = None
698
+ lon_real = None
699
+
700
+ for nome in ["lat", "latitude", "siat_latitude"]:
701
+ if nome in cols_lower:
702
+ lat_real = cols_lower[nome]
703
+ break
704
+
705
+ for nome in ["lon", "longitude", "long", "siat_longitude"]:
706
+ if nome in cols_lower:
707
+ lon_real = cols_lower[nome]
708
+ break
709
+
710
+ if lat_real is None or lon_real is None:
711
+ return "<p>Coordenadas (lat/lon) não encontradas nos dados.</p>"
712
+
713
+ # Filtra dados válidos
714
+ df_mapa = df.dropna(subset=[lat_real, lon_real]).copy()
715
+ if df_mapa.empty:
716
+ return "<p>Sem coordenadas válidas para exibir.</p>"
717
+
718
+ # Cria mapa
719
+ centro_lat = df_mapa[lat_real].mean()
720
+ centro_lon = df_mapa[lon_real].mean()
721
+
722
+ m = folium.Map(
723
+ location=[centro_lat, centro_lon],
724
+ zoom_start=12,
725
+ tiles=None
726
+ )
727
+
728
+ # Camadas base
729
+ folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True).add_to(m)
730
+ folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True).add_to(m)
731
+
732
+ # Se tamanho_col fornecido mas cor_col não, usa mesma variável para cor
733
+ if tamanho_col and tamanho_col != "Visualização Padrão" and not cor_col:
734
+ cor_col = tamanho_col
735
+
736
+ # Colormap se houver coluna de cor (verde → vermelho)
737
+ colormap = None
738
+ if cor_col and cor_col in df_mapa.columns:
739
+ vmin = df_mapa[cor_col].min()
740
+ vmax = df_mapa[cor_col].max()
741
+ colormap = cm.LinearColormap(
742
+ colors=["#2ecc71", "#a8e06c", "#f1c40f", "#e67e22", "#e74c3c"],
743
+ vmin=vmin,
744
+ vmax=vmax,
745
+ caption=cor_col
746
+ )
747
+ colormap.add_to(m)
748
+
749
+ # Escala de tamanho proporcional
750
+ raio_min, raio_max = 3, 18
751
+ tamanho_func = None
752
+ if tamanho_col and tamanho_col != "Visualização Padrão" and tamanho_col in df_mapa.columns:
753
+ t_min = df_mapa[tamanho_col].min()
754
+ t_max = df_mapa[tamanho_col].max()
755
+ if t_max > t_min:
756
+ tamanho_func = lambda v, _min=t_min, _max=t_max: raio_min + (v - _min) / (_max - _min) * (raio_max - raio_min)
757
+ else:
758
+ tamanho_func = lambda v: (raio_min + raio_max) / 2
759
+
760
+ # Adiciona pontos
761
+ for idx, row in df_mapa.iterrows():
762
+ # Cor do ponto
763
+ if colormap and cor_col:
764
+ cor = colormap(row[cor_col])
765
+ else:
766
+ cor = COR_PRINCIPAL
767
+
768
+ # Calcula raio
769
+ if tamanho_func:
770
+ raio = tamanho_func(row[tamanho_col])
771
+ else:
772
+ raio = 8
773
+
774
+ # Popup com informações
775
+ itens = []
776
+ for col, val in row.items():
777
+ if str(col).lower() not in ['lat', 'latitude', 'lon', 'longitude']:
778
+ col_norm = str(col).lower()
779
+ if isinstance(val, (int, float, np.floating)):
780
+ if any(k in col_norm for k in ["valor", "preco", "vu", "vunit"]):
781
+ val_fmt = formatar_monetario(val)
782
+ else:
783
+ val_fmt = f"{val:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
784
+ else:
785
+ val_fmt = str(val)
786
+ itens.append((col, val_fmt))
787
+
788
+ MAX_ITENS = 8
789
+ colunas_html = []
790
+ for i in range(0, len(itens), MAX_ITENS):
791
+ chunk = itens[i:i+MAX_ITENS]
792
+ trs = "".join([f"<tr style='border-bottom: 1px solid #e9ecef;'><td style='padding:4px 8px 4px 0; color:#6c757d; font-weight:500;'>{c}</td><td style='padding:4px 0; text-align:right; color:#495057;'>{v}</td></tr>" for c, v in chunk])
793
+ style = "border-left: 2px solid #dee2e6; padding-left: 20px;" if i > 0 else ""
794
+ colunas_html.append(f"<div style='flex: 0 0 auto; {style}'><table style='border-collapse:collapse; font-size:12px;'>{trs}</table></div>")
795
+
796
+ popup_html = f"""<div style="font-family:'Segoe UI'; border-radius:8px; overflow:hidden;"><div style="background:#6c757d; color:white; padding:10px 15px; font-weight:600;">Dados do Registro</div><div style="padding:12px 15px; background:#f8f9fa;"><div style="display:flex; gap:20px;">{"".join(colunas_html)}</div></div></div>"""
797
+
798
+ # Tooltip (hover): índice + variável selecionada no dropdown (ou dependente como fallback)
799
+ tooltip_col = (
800
+ tamanho_col if (tamanho_col and tamanho_col != "Visualização Padrão"
801
+ and tamanho_col in df_mapa.columns)
802
+ else (col_y if col_y and col_y in df_mapa.columns else None)
803
+ )
804
+ # Usa coluna "index" (original, gerada pelo reset_index) quando disponível
805
+ idx_display = int(row["index"]) if "index" in row.index else idx
806
+ tooltip_html = (
807
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:14px;"
808
+ " line-height:1.7; padding:2px 4px;'>"
809
+ f"<b>Índice {idx_display}</b>"
810
+ )
811
+ if tooltip_col and tooltip_col in row.index:
812
+ val_t = row[tooltip_col]
813
+ col_norm = str(tooltip_col).lower()
814
+ if isinstance(val_t, (int, float, np.floating)):
815
+ if any(k in col_norm for k in ["valor", "preco", "vu", "vunit"]):
816
+ val_str = formatar_monetario(val_t)
817
+ else:
818
+ val_str = f"{val_t:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
819
+ else:
820
+ val_str = str(val_t)
821
+ tooltip_html += (
822
+ f"<br><span style='color:#555;'>{tooltip_col}:</span>"
823
+ f" <b>{val_str}</b>"
824
+ )
825
+ tooltip_html += "</div>"
826
+
827
+ folium.CircleMarker(
828
+ location=[row[lat_real], row[lon_real]],
829
+ radius=raio,
830
+ popup=folium.Popup(popup_html, max_width=280 * len(colunas_html)),
831
+ tooltip=folium.Tooltip(tooltip_html, sticky=True),
832
+ color='black',
833
+ weight=1,
834
+ fill=True,
835
+ fillColor=cor,
836
+ fillOpacity=0.7
837
+ ).add_to(m)
838
+
839
+ # Controles
840
+ folium.LayerControl().add_to(m)
841
+ plugins.Fullscreen().add_to(m)
842
+ plugins.MeasureControl(
843
+ primary_length_unit='meters',
844
+ secondary_length_unit='kilometers',
845
+ primary_area_unit='sqmeters',
846
+ secondary_area_unit='hectares'
847
+ ).add_to(m)
848
+
849
+ # Ajusta bounds
850
+ bounds = [
851
+ [df_mapa[lat_real].min(), df_mapa[lon_real].min()],
852
+ [df_mapa[lat_real].max(), df_mapa[lon_real].max()]
853
+ ]
854
+ m.fit_bounds(bounds)
855
+
856
+ return m._repr_html_()
857
+
858
+ # ============================================================
859
+ # FUNÇÃO: CARREGAR + VALIDAR MODELO (.dai)
860
+ # ============================================================
861
+ def carregar_modelo_gradio(arquivo):
862
+ if arquivo is None: return None, "Nenhum arquivo enviado."
863
+ try:
864
+ pacote = load(arquivo.name)
865
+ if not isinstance(pacote, dict): return None, "Arquivo inválido."
866
+ # Retrocompatibilidade: converte v1 (flat) para v2 (nested)
867
+ if "versao" not in pacote:
868
+ pacote = _migrar_pacote_v1_para_v2(pacote)
869
+ faltantes = [k for k in CHAVES_ESPERADAS if k not in pacote]
870
+ if faltantes: return None, f"Pacote incompleto. Faltando: {faltantes}"
871
+ return pacote, f"Modelo carregado: {os.path.basename(arquivo.name)}"
872
+ except Exception as e: return None, f"Erro: {e}"
873
+
874
+ # ============================================================
875
+ # FUNÇÃO: DESEMPACOTAR + EXIBIR CONTEÚDO
876
+ # ============================================================
877
+ def exibir_modelo(pacote):
878
+ # Retorna Nones se pacote vazio. Total de outputs = 14 (+ dropdown choices)
879
+ if pacote is None:
880
+ return [None] * 13 + [gr.update(choices=["Visualização Padrão"])]
881
+
882
+ # 1. Dados
883
+ dados = pacote["dados"]["df"].reset_index()
884
+ for col in dados.columns:
885
+ if str(col).lower() in ["lat", "lon"]: dados[col] = dados[col].round(6)
886
+ elif pd.api.types.is_numeric_dtype(dados[col]): dados[col] = dados[col].round(2)
887
+
888
+ # 2. Estatísticas
889
+ estat = pd.DataFrame(pacote["dados"]["estatisticas"])
890
+ if not isinstance(estat.index, pd.RangeIndex):
891
+ estat.insert(0, "Variável", estat.index.astype(str))
892
+ estat = estat.reset_index(drop=True)
893
+ estat = estat.round(2)
894
+
895
+ # 3. Escalas
896
+ escalas_html = formatar_escalas_html(pacote["transformacoes"]["info"])
897
+
898
+ # 4. X e y
899
+ X = pacote["transformacoes"]["X"].reset_index()
900
+ y = pacote["transformacoes"]["y"].reset_index()
901
+ if 'index' in y.columns and 'index' in X.columns: y = y.drop(columns=['index'])
902
+ df_X_y = pd.concat([X, y], axis=1).loc[:, ~pd.concat([X, y], axis=1).columns.duplicated()].round(2)
903
+
904
+ # 5. Resumo
905
+ resumo_html = formatar_resumo_html(reorganizar_modelos_resumos(pacote["modelo"]["diagnosticos"]))
906
+
907
+ # 6. Coeficientes
908
+ tab_coef = pd.DataFrame(pacote["modelo"]["coeficientes"])
909
+ if not isinstance(tab_coef.index, pd.RangeIndex):
910
+ tab_coef.insert(0, "Variável", tab_coef.index.astype(str))
911
+ tab_coef = tab_coef.reset_index(drop=True)
912
+ mask = tab_coef["Variável"].str.lower().isin(["intercept", "const", "(intercept)"])
913
+ if mask.any(): tab_coef = pd.concat([tab_coef[mask], tab_coef[~mask]], ignore_index=True)
914
+ tab_coef = tab_coef.round(2)
915
+
916
+ # 7. Obs x Calc
917
+ tab_obs_calc = pacote["modelo"]["obs_calc"].reset_index().round(2)
918
+
919
+ # 8. Gráficos (GERAÇÃO DINÂMICA)
920
+ figs_dict = gerar_todos_graficos(pacote)
921
+
922
+ # 9. Mapa
923
+ info_transf = pacote["transformacoes"]["info"]
924
+ nome_y = info_transf[0].split(": ", 1)[0].strip() if info_transf else None
925
+ mapa_html = criar_mapa(dados, col_y=nome_y)
926
+
927
+ # 10. Dropdown de variáveis para o mapa
928
+ colunas_numericas = [col for col in dados.select_dtypes(include=[np.number]).columns
929
+ if str(col).lower() not in ['lat', 'lon', 'latitude', 'longitude', 'index']]
930
+ choices_mapa = ["Visualização Padrão"] + colunas_numericas
931
+
932
+ return (
933
+ dados,
934
+ estat,
935
+ escalas_html,
936
+ df_X_y,
937
+ resumo_html,
938
+ tab_coef,
939
+ tab_obs_calc,
940
+ figs_dict["obs_calc"], # Plot 1
941
+ figs_dict["residuos"], # Plot 2
942
+ figs_dict["hist"], # Plot 3
943
+ figs_dict["cook"], # Plot 4
944
+ figs_dict["corr"], # Plot 5
945
+ mapa_html,
946
+ gr.update(choices=choices_mapa, value="Visualização Padrão") # Dropdown update
947
+ )
948
+
949
+
950
+ def atualizar_mapa_callback(dados, var_mapa, pacote):
951
+ """Atualiza o mapa quando a variável de dimensionamento é alterada."""
952
+ if dados is None or dados.empty:
953
+ return "<p>Carregue dados para ver o mapa.</p>"
954
+
955
+ tamanho_col = None if var_mapa == "Visualização Padrão" else var_mapa
956
+ nome_y = None
957
+ if pacote:
958
+ info_transf = pacote["transformacoes"]["info"]
959
+ nome_y = info_transf[0].split(": ", 1)[0].strip() if info_transf else None
960
+ return criar_mapa(dados, tamanho_col=tamanho_col, col_y=nome_y)
961
+
962
+ def popular_inputs_avaliacao(pacote):
963
+ """Popula campos de avaliação a partir do pacote .dai carregado.
964
+
965
+ CONTRACT: Retorna 30 itens (N_ROWS_AVAL + MAX_VARS_AVAL).
966
+ """
967
+ rows_hidden = [gr.update(visible=False)] * N_ROWS_AVAL
968
+ inputs_hidden = [gr.update(visible=False, value=None, label="")] * MAX_VARS_AVAL
969
+
970
+ if pacote is None:
971
+ return (*rows_hidden, *inputs_hidden)
972
+
973
+ info_transf = pacote["transformacoes"]["info"]
974
+ estatisticas = pacote["dados"]["estatisticas"]
975
+ # Normaliza para string — colunas_x vem de split de strings, mas listas do DAI
976
+ # podem ter inteiros se o CSV original tinha colunas numéricas (ex: anos 2015, 2016...)
977
+ dicotomicas = [str(d) for d in (pacote["transformacoes"].get("dicotomicas", []) or [])]
978
+ codigo_alocado = [str(c) for c in (pacote["transformacoes"].get("codigo_alocado", []) or [])]
979
+ percentuais = [str(p) for p in (pacote["transformacoes"].get("percentuais", []) or [])]
980
+
981
+ colunas_x = []
982
+ for item in info_transf[1:]:
983
+ nome_x, _ = item.split(": ", 1)
984
+ colunas_x.append(nome_x.strip())
985
+
986
+ n_vars = len(colunas_x)
987
+ n_rows_vis = math.ceil(n_vars / N_COLS_AVAL)
988
+
989
+ if estatisticas is not None:
990
+ est = pd.DataFrame(estatisticas)
991
+ if "Variável" in est.columns:
992
+ est_idx = est.set_index("Variável")
993
+ elif not isinstance(est.index, pd.RangeIndex):
994
+ est_idx = est
995
+ else:
996
+ est_idx = pd.DataFrame()
997
+ # Normaliza index para string (mesmo motivo das listas acima)
998
+ if not est_idx.empty:
999
+ est_idx.index = est_idx.index.map(str)
1000
+ else:
1001
+ est_idx = pd.DataFrame()
1002
+
1003
+ rows_updates = [gr.update(visible=(r < n_rows_vis)) for r in range(N_ROWS_AVAL)]
1004
+
1005
+ inputs_updates = []
1006
+ for i in range(MAX_VARS_AVAL):
1007
+ if i < n_vars:
1008
+ col = colunas_x[i]
1009
+ if col in dicotomicas:
1010
+ placeholder = "0 ou 1"
1011
+ elif col in codigo_alocado and col in est_idx.index:
1012
+ min_val = est_idx.loc[col, "Mínimo"]
1013
+ max_val = est_idx.loc[col, "Máximo"]
1014
+ placeholder = f"cód. {int(min_val)} a {int(max_val)}"
1015
+ elif col in percentuais:
1016
+ placeholder = "0 a 1"
1017
+ elif col in est_idx.index:
1018
+ min_val = est_idx.loc[col, "Mínimo"]
1019
+ max_val = est_idx.loc[col, "Máximo"]
1020
+ placeholder = f"{min_val} — {max_val}"
1021
+ else:
1022
+ placeholder = ""
1023
+ inputs_updates.append(gr.update(visible=True, value=None, label=col, placeholder=placeholder, interactive=True))
1024
+ else:
1025
+ inputs_updates.append(gr.update(visible=False, value=None, label="", placeholder=""))
1026
+
1027
+ return (*rows_updates, *inputs_updates)
1028
+
1029
+
1030
+ def calcular_avaliacao_viz(pacote, estado_avaliacoes, indice_base_str, *aval_inputs):
1031
+ """Calcula avaliação usando o modelo carregado na aba Visualização.
1032
+
1033
+ CONTRACT: Retorna 3 itens (resultado_html, estado_avaliacoes, dropdown_update).
1034
+ """
1035
+ _err = lambda msg: (msg, estado_avaliacoes or [], gr.update())
1036
+ if pacote is None:
1037
+ return _err("Carregue e exiba um modelo primeiro.")
1038
+
1039
+ info_transf = pacote["transformacoes"]["info"]
1040
+ nome_y, transf_y = info_transf[0].split(": ", 1)
1041
+ transformacao_y = transf_y.strip().replace("(y)", "(x)")
1042
+
1043
+ colunas_x = []
1044
+ transformacoes_x = {}
1045
+ for item in info_transf[1:]:
1046
+ nome_x, transf_x = item.split(": ", 1)
1047
+ nome_x = nome_x.strip()
1048
+ colunas_x.append(nome_x)
1049
+ transformacoes_x[nome_x] = transf_x.strip()
1050
+
1051
+ valores_x = {}
1052
+ for i, col in enumerate(colunas_x):
1053
+ if i >= len(aval_inputs) or aval_inputs[i] is None:
1054
+ return _err("Preencha todos os campos.")
1055
+ valores_x[col] = float(aval_inputs[i])
1056
+
1057
+ # Validar variáveis dicotômicas, código alocado e percentuais ANTES de avaliar
1058
+ dicotomicas = pacote["transformacoes"].get("dicotomicas", [])
1059
+ codigo_alocado = pacote["transformacoes"].get("codigo_alocado", [])
1060
+ percentuais = pacote["transformacoes"].get("percentuais", [])
1061
+ estatisticas_df = pacote["dados"]["estatisticas"]
1062
+ import pandas as pd
1063
+ if isinstance(estatisticas_df, pd.DataFrame):
1064
+ if "Variável" in estatisticas_df.columns:
1065
+ est_idx = estatisticas_df.set_index("Variável")
1066
+ else:
1067
+ est_idx = estatisticas_df
1068
+ else:
1069
+ est_idx = pd.DataFrame()
1070
+
1071
+ for col in colunas_x:
1072
+ val = valores_x[col]
1073
+ if col in (dicotomicas or []):
1074
+ if val not in (0, 0.0, 1, 1.0):
1075
+ return _err(f"<p style='color:red;'><b>Erro:</b> A variável <b>{col}</b> é dicotômica e aceita apenas valores 0 ou 1. "
1076
+ f"Valor informado: {val}</p>")
1077
+ elif col in (codigo_alocado or []) and col in est_idx.index:
1078
+ min_val = float(est_idx.loc[col, "Mínimo"])
1079
+ max_val = float(est_idx.loc[col, "Máximo"])
1080
+ eh_inteiro = (float(val) == int(float(val)))
1081
+ if not eh_inteiro or val < min_val or val > max_val:
1082
+ return _err(f"<p style='color:red;'><b>Erro:</b> A variável <b>{col}</b> é de código alocado/ajustado e aceita apenas "
1083
+ f"valores inteiros de {int(min_val)} a {int(max_val)}. Valor informado: {val}</p>")
1084
+ elif col in (percentuais or []):
1085
+ if val < 0 or val > 1:
1086
+ return _err(f"<p style='color:red;'><b>Erro:</b> A variável <b>{col}</b> é percentual e aceita apenas "
1087
+ f"valores entre 0 e 1. Valor informado: {val}</p>")
1088
+
1089
+ resultado = avaliar_imovel(
1090
+ modelo_sm=pacote["modelo"]["sm"],
1091
+ valores_x=valores_x,
1092
+ colunas_x=colunas_x,
1093
+ transformacoes_x=transformacoes_x,
1094
+ transformacao_y=transformacao_y,
1095
+ estatisticas_df=estatisticas_df,
1096
+ dicotomicas=dicotomicas,
1097
+ codigo_alocado=codigo_alocado,
1098
+ percentuais=percentuais,
1099
+ )
1100
+
1101
+ if resultado is None:
1102
+ return _err("Erro ao calcular avaliação.")
1103
+
1104
+ nova_lista = list(estado_avaliacoes or []) + [resultado]
1105
+ indice_base = int(indice_base_str) - 1 if indice_base_str else 0
1106
+ html = formatar_avaliacao_html(nova_lista, indice_base=indice_base, elem_id_excluir="excluir-aval-viz")
1107
+ choices = [str(i + 1) for i in range(len(nova_lista))]
1108
+ base_val = indice_base_str if indice_base_str else "1"
1109
+ return html, nova_lista, gr.update(choices=choices, value=base_val)
1110
+
1111
+
1112
+ def excluir_avaliacao_viz(indice_str, estado_avaliacoes, indice_base_str):
1113
+ """Exclui uma avaliação (Visualização).
1114
+
1115
+ CONTRACT: Retorna 4 itens (resultado_html, estado_avaliacoes, dropdown_update, trigger_reset).
1116
+ """
1117
+ if not indice_str or not indice_str.strip() or not estado_avaliacoes:
1118
+ return gr.update(), estado_avaliacoes or [], gr.update(), ""
1119
+ try:
1120
+ idx = int(indice_str.strip()) - 1
1121
+ except ValueError:
1122
+ return gr.update(), estado_avaliacoes or [], gr.update(), ""
1123
+ if idx < 0 or idx >= len(estado_avaliacoes):
1124
+ return gr.update(), estado_avaliacoes or [], gr.update(), ""
1125
+
1126
+ nova_lista = [a for i, a in enumerate(estado_avaliacoes) if i != idx]
1127
+ if not nova_lista:
1128
+ return "", [], gr.update(choices=[], value=None), ""
1129
+
1130
+ base = int(indice_base_str) - 1 if indice_base_str else 0
1131
+ if base >= len(nova_lista):
1132
+ base = len(nova_lista) - 1
1133
+ if base < 0:
1134
+ base = 0
1135
+
1136
+ choices = [str(i + 1) for i in range(len(nova_lista))]
1137
+ html = formatar_avaliacao_html(nova_lista, indice_base=base, elem_id_excluir="excluir-aval-viz")
1138
+ return html, nova_lista, gr.update(choices=choices, value=str(base + 1)), ""
1139
+
1140
+
1141
+ def atualizar_base_avaliacao_viz(estado_avaliacoes, indice_base_str):
1142
+ """Re-renderiza HTML quando o dropdown de base muda (Visualização)."""
1143
+ if not estado_avaliacoes:
1144
+ return ""
1145
+ indice = int(indice_base_str) - 1 if indice_base_str else 0
1146
+ return formatar_avaliacao_html(estado_avaliacoes, indice_base=indice, elem_id_excluir="excluir-aval-viz")
1147
+
1148
+
1149
+ def exportar_avaliacoes_excel_viz(estado_avaliacoes):
1150
+ """Exporta avaliações para Excel (Visualização)."""
1151
+ if not estado_avaliacoes:
1152
+ return gr.update(value=None, visible=False)
1153
+ caminho = exportar_avaliacoes_excel(estado_avaliacoes)
1154
+ if caminho:
1155
+ return gr.update(value=caminho, visible=True)
1156
+ return gr.update(value=None, visible=False)
1157
+
1158
+
1159
+ def _formatar_badge_completo(pacote):
1160
+ """Retorna HTML do badge combinado: elaborador (esquerda) + variáveis (direita) com flex space-between."""
1161
+ if not pacote:
1162
+ return ""
1163
+
1164
+ # --- Lado direito: lista de variáveis ---
1165
+ variaveis_html = ""
1166
+ info = pacote.get("transformacoes", {}).get("info")
1167
+ if info:
1168
+ y_str = info[0]
1169
+ y_parts = y_str.split(": ", 1)
1170
+ y_nome = y_parts[0].strip()
1171
+ y_transf = y_parts[1].strip() if len(y_parts) > 1 else ""
1172
+ y_transf_display = "" if y_transf in ("(y)", "(x)", "y", "x", "") else y_transf
1173
+ y_badge = (
1174
+ "<span style='background:#cce5ff;color:#004085;border-radius:4px;"
1175
+ f"padding:3px 10px;font-size:1em;font-weight:600;'>{y_nome}"
1176
+ + (f" <span style='font-weight:400;font-size:0.85em;'>{y_transf_display}</span>"
1177
+ if y_transf_display else "")
1178
+ + "</span>"
1179
+ )
1180
+ x_badges = []
1181
+ for item in info[1:]:
1182
+ parts = item.split(": ", 1)
1183
+ nome = parts[0].strip()
1184
+ transf = parts[1].strip() if len(parts) > 1 else ""
1185
+ transf_display = "" if transf in ("(x)", "(y)", "x", "y", "") else transf
1186
+ badge = (
1187
+ "<span style='background:#ced4da;color:#343a40;border-radius:4px;"
1188
+ f"padding:3px 8px;font-size:1em;display:inline-block;margin:2px 3px 2px 0;'>{nome}"
1189
+ + (f" <span style='color:#6c757d;font-size:1em;'>{transf_display}</span>"
1190
+ if transf_display else "")
1191
+ + "</span>"
1192
+ )
1193
+ x_badges.append(badge)
1194
+ variaveis_html = (
1195
+ "<div style='font-size:0.9em;line-height:2;'>"
1196
+ "<div><span style='font-weight:600;color:#495057;margin-right:8px;font-size:1.1em;'>Variável Dependente:</span>"
1197
+ + y_badge + "</div>"
1198
+ "<div style='margin-top:4px;'>"
1199
+ "<span style='font-weight:600;color:#495057;margin-right:8px;font-size:1.1em;'>Variáveis Independentes:</span>"
1200
+ + "".join(x_badges) + "</div>"
1201
+ "</div>"
1202
+ )
1203
+
1204
+ # --- Lado esquerdo: elaborador ---
1205
+ elaborador = pacote.get("elaborador")
1206
+ nome = elaborador.get("nome_completo", "") if elaborador else ""
1207
+ if not nome:
1208
+ # Sem elaborador: só exibe variáveis (sem flex desnecessário)
1209
+ if not variaveis_html:
1210
+ return ""
1211
+ return (
1212
+ '<div style="background:#e9ecef; border-left:5px solid #6c757d; border-radius:6px; '
1213
+ 'padding:14px 18px; margin-top:8px;">'
1214
+ + variaveis_html
1215
+ + '</div>'
1216
+ )
1217
+
1218
+ cargo = elaborador.get("cargo", "")
1219
+ conselho = elaborador.get("conselho", "")
1220
+ num = elaborador.get("numero_conselho", "")
1221
+ estado = elaborador.get("estado_conselho", "")
1222
+ matricula = elaborador.get("matricula_sem_digito", "")
1223
+ lotacao = elaborador.get("lotacao", "")
1224
+ linha2 = f"{cargo} · {conselho}/{estado} {num}".strip(" ·") if cargo else ""
1225
+ linha3 = f"Matrícula: {matricula} · {lotacao}".strip(" ·") if matricula else ""
1226
+
1227
+ elab_html = (
1228
+ '<div style="line-height:1.8;">'
1229
+ f'<span style="display:block; font-size:1.05em; font-weight:600; color:#212529; margin-bottom:4px;">{nome}</span>'
1230
+ + (f'<span style="display:block; font-size:0.95em;">{linha2}</span>' if linha2 else '')
1231
+ + (f'<span style="display:block; font-size:0.9em; color:#6c757d;">{linha3}</span>' if linha3 else '')
1232
+ + '</div>'
1233
+ )
1234
+
1235
+ return (
1236
+ '<div style="background:#e9ecef; border-left:5px solid #6c757d; border-radius:6px; '
1237
+ 'padding:14px 18px; color:#495057; margin-top:8px; display:flex; '
1238
+ 'justify-content:space-between; align-items:flex-start; gap:24px;">'
1239
+ + elab_html
1240
+ + (f'<div>{variaveis_html}</div>' if variaveis_html else '')
1241
+ + '</div>'
1242
+ )
1243
+
1244
+
1245
+ def limpar_tudo():
1246
+ return (
1247
+ None, "", None, None, None, "", None, "", None, None,
1248
+ None, None, None, None, None, # 5 gráficos nulos
1249
+ "", None,
1250
+ gr.update(choices=["Visualização Padrão"], value="Visualização Padrão"), # Dropdown reset
1251
+ # Reset avaliação
1252
+ *[gr.update(visible=False) for _ in range(N_ROWS_AVAL)],
1253
+ *[gr.update(visible=False, value=None, label="") for _ in range(MAX_VARS_AVAL)],
1254
+ "", # resultado_aval_html
1255
+ [], # estado_avaliacoes
1256
+ gr.update(choices=[], value=None), # dropdown_base_aval
1257
+ "", # excluir_aval_trigger_viz
1258
+ gr.update(value=None, visible=False), # download_aval_file
1259
+ "", # elaborador_badge
1260
+ )
1261
+
1262
+ # ============================================================
1263
+ # INTERFACE GRADIO
1264
+ # ============================================================
1265
+
1266
+ description = f"""
1267
+ # <p style="text-align: center;">MODELOS ESTATÍSTICOS</p>
1268
+ <p style="text-align: center;">Divisão de Avaliação de Imóveis</p>
1269
+ <hr style="color: #333; background-color: #333; height: 1px; border: none;">
1270
+ """
1271
+
1272
+ def criar_aba():
1273
+ """Cria conteúdo da aba de visualização (sem wrapper gr.Blocks)."""
1274
+ # --------------------------------------------------------
1275
+ # ESTADO
1276
+ # --------------------------------------------------------
1277
+ estado_pacote = gr.State(None)
1278
+ estado_dados = gr.State(None) # Armazena os dados para atualizar o mapa
1279
+
1280
+ # --------------------------------------------------------
1281
+ # CONTROLES (TOPO)
1282
+ # --------------------------------------------------------
1283
+ with gr.Group(elem_classes="upload-area"):
1284
+ upload = gr.File(label="Enviar modelo salvo (.dai)", file_types=[".dai"], scale=1)
1285
+ with gr.Row(equal_height=True):
1286
+ status = gr.Textbox(show_label=False, interactive=False, scale=4, lines=1)
1287
+ elaborador_badge = gr.HTML("")
1288
+ with gr.Row(equal_height=True):
1289
+ btn_exibir = gr.Button("Exibir modelo", scale=2, variant="primary")
1290
+ btn_limpar = gr.Button("Limpar tudo", scale=1, variant="secondary")
1291
+
1292
+ # --------------------------------------------------------
1293
+ # MAPA (RETRÁTIL VIA ACCORDION)
1294
+ # --------------------------------------------------------
1295
+ with gr.Accordion("Mapa de Distribuição dos Dados", open=True, elem_classes="map-accordion"):
1296
+ dropdown_mapa_var = gr.Dropdown(
1297
+ label="Variável para dimensionar pontos no mapa",
1298
+ choices=["Visualização Padrão"],
1299
+ value="Visualização Padrão",
1300
+ interactive=True,
1301
+ allow_custom_value=False
1302
+ )
1303
+ out_mapa = gr.HTML(label="", elem_id="map-frame")
1304
+
1305
+ # --------------------------------------------------------
1306
+ # CONTEÚDO (LARGURA TOTAL)
1307
+ # --------------------------------------------------------
1308
+ with gr.Column(elem_classes="content-panel"):
1309
+ with gr.Tabs(elem_classes="tabs-container"):
1310
+ with gr.Tab("Dados"):
1311
+ gr.HTML(criar_titulo_secao_html("Dados Utilizados"))
1312
+ out_dados = gr.Dataframe(show_label=False, max_height=300)
1313
+ gr.HTML(criar_titulo_secao_html("Estatísticas"))
1314
+ out_estat = gr.Dataframe(show_label=False, max_height=300)
1315
+
1316
+ with gr.Tab("Transformações"):
1317
+ out_escalas = gr.HTML()
1318
+ gr.HTML(criar_titulo_secao_html("X e y Transformados"))
1319
+ out_df_xy = gr.Dataframe(show_label=False, max_height=400)
1320
+
1321
+ with gr.Tab("Resumo"):
1322
+ out_resumo = gr.HTML()
1323
+
1324
+ with gr.Tab("Coeficientes"):
1325
+ gr.HTML(criar_titulo_secao_html("Tabela de Coeficientes"))
1326
+ out_coef = gr.Dataframe(show_label=False, max_height=700)
1327
+
1328
+ with gr.Tab("Obs x Calc"):
1329
+ gr.HTML(criar_titulo_secao_html("Tabela Obs x Calc"))
1330
+ out_obs = gr.Dataframe(show_label=False, max_height=600)
1331
+
1332
+ with gr.Tab("Gráficos") as tab_graficos:
1333
+ with gr.Group():
1334
+ # Linha 1 — dois gráficos, 50% / 50%
1335
+ with gr.Row():
1336
+ out_plot_obs = gr.Plot(label="Obs vs Calc")
1337
+ out_plot_res = gr.Plot(label="Resíduos vs Ajustados")
1338
+
1339
+ # Linha 2 — dois gráficos, 50% / 50%
1340
+ with gr.Row():
1341
+ out_plot_hist = gr.Plot(label="Histograma Resíduos")
1342
+ out_plot_cook = gr.Plot(label="Distância de Cook")
1343
+
1344
+ # Linha 3 — gráfico sozinho, largura máxima
1345
+ with gr.Row():
1346
+ out_plot_corr = gr.Plot(label="Correlação")
1347
+
1348
+ with gr.Tab("Avaliação"):
1349
+ gr.HTML(criar_titulo_secao_html("Avaliação Individual"))
1350
+ aval_rows = []
1351
+ aval_inputs = []
1352
+ with gr.Column(elem_classes="aval-all-cards"):
1353
+ for _i_row in range(N_ROWS_AVAL):
1354
+ with gr.Row(visible=False, elem_classes="aval-cards-row") as _aval_row:
1355
+ for _j_col in range(N_COLS_AVAL):
1356
+ _inp = gr.Number(label="", visible=False, interactive=True, elem_classes="aval-card")
1357
+ aval_inputs.append(_inp)
1358
+ aval_rows.append(_aval_row)
1359
+ with gr.Row():
1360
+ btn_calcular_aval = gr.Button("Calcular", variant="primary", scale=2)
1361
+ btn_limpar_aval = gr.Button("Limpar", variant="secondary", scale=1)
1362
+ dropdown_base_aval = gr.Dropdown(
1363
+ label="Base p/ comparação",
1364
+ choices=[],
1365
+ value=None,
1366
+ interactive=True,
1367
+ scale=1,
1368
+ )
1369
+ resultado_aval_html = gr.HTML("")
1370
+ excluir_aval_trigger_viz = gr.Textbox(
1371
+ label="", elem_id="excluir-aval-viz", container=False,
1372
+ elem_classes="trigger-hidden"
1373
+ )
1374
+ estado_avaliacoes = gr.State([])
1375
+ with gr.Row():
1376
+ btn_exportar_aval = gr.Button("Salvar Avaliações em Excel", variant="secondary")
1377
+ download_aval_file = gr.File(label="", visible=False)
1378
+
1379
+ with gr.Tab("Avaliação em Massa"):
1380
+ gr.HTML(criar_titulo_secao_html("Avaliação em Lote"))
1381
+ gr.HTML("""<div class="placeholder-alert"><p>Módulo em desenvolvimento</p></div>""")
1382
+
1383
+ # --------------------------------------------------------
1384
+ # EVENTOS
1385
+ # --------------------------------------------------------
1386
+ upload.upload(
1387
+ carregar_modelo_gradio, inputs=upload, outputs=[estado_pacote, status]
1388
+ ).then(
1389
+ _formatar_badge_completo, inputs=estado_pacote, outputs=elaborador_badge
1390
+ )
1391
+
1392
+ btn_exibir.click(
1393
+ exibir_modelo,
1394
+ inputs=estado_pacote,
1395
+ outputs=[
1396
+ out_dados, out_estat, out_escalas, out_df_xy, out_resumo, out_coef, out_obs,
1397
+ out_plot_obs, out_plot_res, out_plot_hist, out_plot_cook, out_plot_corr,
1398
+ out_mapa, dropdown_mapa_var
1399
+ ],
1400
+ ).then(
1401
+ popular_inputs_avaliacao,
1402
+ inputs=estado_pacote,
1403
+ outputs=aval_rows + aval_inputs
1404
+ ).then(
1405
+ lambda x: x, # Copia out_dados para estado_dados
1406
+ inputs=out_dados,
1407
+ outputs=estado_dados
1408
+ )
1409
+
1410
+ # Atualiza mapa quando dropdown muda
1411
+ dropdown_mapa_var.change(
1412
+ atualizar_mapa_callback,
1413
+ inputs=[estado_dados, dropdown_mapa_var, estado_pacote],
1414
+ outputs=out_mapa
1415
+ )
1416
+
1417
+ btn_limpar.click(
1418
+ limpar_tudo,
1419
+ outputs=[
1420
+ estado_pacote, status, upload,
1421
+ out_dados, out_estat, out_escalas, out_df_xy, out_resumo, out_coef, out_obs,
1422
+ out_plot_obs, out_plot_res, out_plot_hist, out_plot_cook, out_plot_corr,
1423
+ out_mapa, estado_dados,
1424
+ dropdown_mapa_var,
1425
+ ] + aval_rows + aval_inputs + [resultado_aval_html, estado_avaliacoes,
1426
+ dropdown_base_aval, excluir_aval_trigger_viz, download_aval_file,
1427
+ elaborador_badge]
1428
+ )
1429
+
1430
+ # Avaliação individual
1431
+ btn_calcular_aval.click(
1432
+ calcular_avaliacao_viz,
1433
+ inputs=[estado_pacote, estado_avaliacoes, dropdown_base_aval] + aval_inputs,
1434
+ outputs=[resultado_aval_html, estado_avaliacoes, dropdown_base_aval]
1435
+ )
1436
+
1437
+ btn_limpar_aval.click(
1438
+ lambda: ("", [], gr.update(choices=[], value=None)),
1439
+ outputs=[resultado_aval_html, estado_avaliacoes, dropdown_base_aval]
1440
+ )
1441
+
1442
+ excluir_aval_trigger_viz.change(
1443
+ excluir_avaliacao_viz,
1444
+ inputs=[excluir_aval_trigger_viz, estado_avaliacoes, dropdown_base_aval],
1445
+ outputs=[resultado_aval_html, estado_avaliacoes, dropdown_base_aval, excluir_aval_trigger_viz]
1446
+ )
1447
+
1448
+ dropdown_base_aval.change(
1449
+ atualizar_base_avaliacao_viz,
1450
+ inputs=[estado_avaliacoes, dropdown_base_aval],
1451
+ outputs=[resultado_aval_html]
1452
+ )
1453
+
1454
+ btn_exportar_aval.click(
1455
+ exportar_avaliacoes_excel_viz,
1456
+ inputs=[estado_avaliacoes],
1457
+ outputs=[download_aval_file]
1458
+ )
1459
+
1460
+ # Força Plotly a recalcular tamanho quando a tab Gráficos é selecionada
1461
+ tab_graficos.select(
1462
+ fn=None,
1463
+ inputs=None,
1464
+ outputs=None,
1465
+ js="() => { setTimeout(() => window.dispatchEvent(new Event('resize')), 100) }"
1466
+ )
1467
+
1468
+
1469
+ # ============================================================
1470
+ # EXECUÇÃO
1471
+ # ============================================================
1472
+ if __name__ == "__main__":
1473
+ custom_css = carregar_css()
1474
+ with gr.Blocks(css=custom_css) as app:
1475
+ gr.Markdown(description)
1476
+ criar_aba()
1477
+ app.launch()
backend/app/main.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import FileResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+
10
+ from app.api import elaboracao, health, session, visualizacao
11
+
12
+
13
+ app = FastAPI(
14
+ title="MESA Frame API",
15
+ version="1.0.0",
16
+ description="Backend FastAPI para o app MESA (Elaboracao + Visualizacao)",
17
+ )
18
+
19
+ app.add_middleware(
20
+ CORSMiddleware,
21
+ allow_origins=["*"],
22
+ allow_credentials=True,
23
+ allow_methods=["*"],
24
+ allow_headers=["*"],
25
+ )
26
+
27
+ app.include_router(health.router)
28
+ app.include_router(session.router)
29
+ app.include_router(elaboracao.router)
30
+ app.include_router(visualizacao.router)
31
+
32
+
33
+ def _mount_frontend_if_exists() -> None:
34
+ frontend_dist = Path(__file__).resolve().parents[2] / "frontend" / "dist"
35
+ index_file = frontend_dist / "index.html"
36
+ if not index_file.exists():
37
+ return
38
+
39
+ app.mount("/assets", StaticFiles(directory=frontend_dist / "assets"), name="assets")
40
+
41
+ @app.get("/{full_path:path}", include_in_schema=False)
42
+ def serve_spa(full_path: str) -> FileResponse:
43
+ return FileResponse(index_file)
44
+
45
+
46
+ _mount_frontend_if_exists()
backend/app/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Models package
backend/app/models/session.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import pandas as pd
8
+
9
+
10
+ @dataclass
11
+ class SessionState:
12
+ session_id: str
13
+ workdir: Path
14
+
15
+ uploaded_file_path: str | None = None
16
+ uploaded_filename: str | None = None
17
+ available_sheets: list[str] = field(default_factory=list)
18
+
19
+ df_original: pd.DataFrame | None = None
20
+ df_filtrado: pd.DataFrame | None = None
21
+
22
+ coluna_y: str | None = None
23
+ colunas_x: list[str] = field(default_factory=list)
24
+ dicotomicas: list[str] = field(default_factory=list)
25
+ codigo_alocado: list[str] = field(default_factory=list)
26
+ percentuais: list[str] = field(default_factory=list)
27
+
28
+ transformacao_y: str = "(x)"
29
+ transformacoes_x: dict[str, str] = field(default_factory=dict)
30
+
31
+ resultados_busca: list[dict[str, Any]] = field(default_factory=list)
32
+ resultado_modelo: dict[str, Any] | None = None
33
+
34
+ tabela_estatisticas: pd.DataFrame | None = None
35
+ tabela_metricas_estado: pd.DataFrame | None = None
36
+
37
+ outliers_anteriores: list[int] = field(default_factory=list)
38
+ iteracao: int = 1
39
+
40
+ avaliacoes_elaboracao: list[dict[str, Any]] = field(default_factory=list)
41
+
42
+ geo_falhas_df: pd.DataFrame | None = None
43
+ geo_col_cdlog: str | None = None
44
+ geo_col_num: str | None = None
45
+
46
+ pacote_visualizacao: dict[str, Any] | None = None
47
+ dados_visualizacao: pd.DataFrame | None = None
48
+ avaliacoes_visualizacao: list[dict[str, Any]] = field(default_factory=list)
49
+
50
+ elaborador: dict[str, Any] | None = None
51
+
52
+ def reset_modelo(self) -> None:
53
+ self.resultados_busca = []
54
+ self.resultado_modelo = None
55
+ self.tabela_estatisticas = None
56
+ self.tabela_metricas_estado = None
57
+ self.avaliacoes_elaboracao = []
58
+ self.transformacao_y = "(x)"
59
+ self.transformacoes_x = {}
60
+
61
+ def reset_visualizacao(self) -> None:
62
+ self.pacote_visualizacao = None
63
+ self.dados_visualizacao = None
64
+ self.avaliacoes_visualizacao = []
backend/app/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Service layer package
backend/app/services/elaboracao_service.py ADDED
@@ -0,0 +1,1124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import asdict
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ from fastapi import HTTPException
12
+
13
+ from app.core.elaboracao import charts, geocodificacao
14
+ from app.core.elaboracao.core import (
15
+ TRANSFORMACOES,
16
+ ajustar_modelo,
17
+ buscar_melhores_transformacoes,
18
+ calcular_estatisticas_variaveis,
19
+ carregar_arquivo,
20
+ carregar_dai,
21
+ detectar_abas_excel,
22
+ detectar_codigo_alocado,
23
+ detectar_dicotomicas,
24
+ detectar_percentuais,
25
+ exportar_avaliacoes_excel,
26
+ exportar_base_csv,
27
+ exportar_modelo_dai,
28
+ identificar_coluna_y_padrao,
29
+ obter_colunas_numericas,
30
+ avaliar_imovel,
31
+ testar_micronumerosidade,
32
+ verificar_multicolinearidade,
33
+ )
34
+ from app.core.elaboracao.formatadores import (
35
+ formatar_avaliacao_html,
36
+ formatar_aviso_multicolinearidade,
37
+ formatar_busca_html,
38
+ formatar_diagnosticos_html,
39
+ formatar_micronumerosidade_html,
40
+ formatar_outliers_anteriores_html,
41
+ )
42
+ from app.models.session import SessionState
43
+ from app.services.serializers import dataframe_to_payload, figure_to_payload, sanitize_value
44
+
45
+ _AVALIADORES_PATH = Path(__file__).resolve().parent.parent / "core" / "elaboracao" / "avaliadores.json"
46
+ _AVALIADORES_CACHE: list[dict[str, Any]] | None = None
47
+
48
+
49
+ def list_avaliadores() -> list[dict[str, Any]]:
50
+ global _AVALIADORES_CACHE
51
+ if _AVALIADORES_CACHE is not None:
52
+ return _AVALIADORES_CACHE
53
+
54
+ try:
55
+ raw = json.loads(_AVALIADORES_PATH.read_text(encoding="utf-8"))
56
+ avaliadores = raw.get("avaliadores", [])
57
+ if isinstance(avaliadores, list):
58
+ _AVALIADORES_CACHE = [sanitize_value(item) for item in avaliadores if isinstance(item, dict)]
59
+ else:
60
+ _AVALIADORES_CACHE = []
61
+ except Exception:
62
+ _AVALIADORES_CACHE = []
63
+
64
+ return _AVALIADORES_CACHE
65
+
66
+
67
+ def _clean_int_list(values: list[Any] | None) -> list[int]:
68
+ if not values:
69
+ return []
70
+ out: list[int] = []
71
+ for value in values:
72
+ try:
73
+ out.append(int(value))
74
+ except Exception:
75
+ continue
76
+ return sorted(set(out))
77
+
78
+
79
+ def _parse_indices_text(text: str | None) -> list[int]:
80
+ if not text:
81
+ return []
82
+ items = [piece.strip() for piece in text.split(",") if piece.strip()]
83
+ out: list[int] = []
84
+ for item in items:
85
+ try:
86
+ out.append(int(item))
87
+ except Exception:
88
+ continue
89
+ return sorted(set(out))
90
+
91
+
92
+ def _selection_context(session: SessionState) -> dict[str, Any]:
93
+ return {
94
+ "coluna_y": session.coluna_y,
95
+ "colunas_x": list(session.colunas_x),
96
+ "dicotomicas": list(session.dicotomicas),
97
+ "codigo_alocado": list(session.codigo_alocado),
98
+ "percentuais": list(session.percentuais),
99
+ "outliers_anteriores": list(session.outliers_anteriores),
100
+ }
101
+
102
+
103
+ def classificar_tipos_variaveis_x(session: SessionState, colunas_x: list[str] | None) -> dict[str, Any]:
104
+ df = session.df_original if session.df_original is not None else session.df_filtrado
105
+ if df is None:
106
+ raise HTTPException(status_code=400, detail="Carregue uma base antes de classificar as variaveis X")
107
+
108
+ coluna_y = str(session.coluna_y) if session.coluna_y else None
109
+ colunas_validas: list[str] = []
110
+ for col in colunas_x or []:
111
+ nome = str(col)
112
+ if nome not in df.columns:
113
+ continue
114
+ if coluna_y and nome == coluna_y:
115
+ continue
116
+ colunas_validas.append(nome)
117
+
118
+ if not colunas_validas:
119
+ session.dicotomicas = []
120
+ session.codigo_alocado = []
121
+ session.percentuais = []
122
+ return {
123
+ "dicotomicas": [],
124
+ "codigo_alocado": [],
125
+ "percentuais": [],
126
+ }
127
+
128
+ dicotomicas = detectar_dicotomicas(df, colunas_validas)
129
+ codigo_alocado = detectar_codigo_alocado(df, colunas_validas)
130
+ percentuais = detectar_percentuais(df, colunas_validas)
131
+
132
+ session.dicotomicas = list(dicotomicas)
133
+ session.codigo_alocado = list(codigo_alocado)
134
+ session.percentuais = list(percentuais)
135
+
136
+ return {
137
+ "dicotomicas": sanitize_value(dicotomicas),
138
+ "codigo_alocado": sanitize_value(codigo_alocado),
139
+ "percentuais": sanitize_value(percentuais),
140
+ }
141
+
142
+
143
+ def _build_coords_payload(df: pd.DataFrame, tem_coords: bool) -> dict[str, Any]:
144
+ todas_colunas = [str(c) for c in df.columns]
145
+ if tem_coords:
146
+ return {
147
+ "tem_coords": True,
148
+ "aviso_html": "",
149
+ "colunas_disponiveis": [],
150
+ "cdlog_auto": None,
151
+ "num_auto": None,
152
+ }
153
+
154
+ cdlog_auto, num_auto = geocodificacao.auto_detectar_colunas_geo(df)
155
+ aviso_html = (
156
+ '<div style="background:#f8d7da;border:1px solid #f5c2c7;border-radius:8px;'
157
+ f'padding:10px 14px;margin-bottom:8px"><strong>Colunas lat/lon nao encontradas</strong> — {len(df)} registro(s).'
158
+ "</div>"
159
+ )
160
+ return {
161
+ "tem_coords": False,
162
+ "aviso_html": aviso_html,
163
+ "colunas_disponiveis": todas_colunas,
164
+ "cdlog_auto": cdlog_auto,
165
+ "num_auto": num_auto,
166
+ }
167
+
168
+
169
+ def _set_dataframe_base(session: SessionState, df: pd.DataFrame, clear_models: bool = True) -> dict[str, Any]:
170
+ tem_coords, col_lat, col_lon = geocodificacao.verificar_coords(df)
171
+ if tem_coords and col_lat and col_lon:
172
+ df = geocodificacao.padronizar_coords(df, col_lat, col_lon)
173
+
174
+ session.df_original = df.copy()
175
+ session.df_filtrado = df.copy()
176
+ session.geo_falhas_df = None
177
+ session.geo_col_cdlog = None
178
+ session.geo_col_num = None
179
+
180
+ if clear_models:
181
+ session.reset_modelo()
182
+ session.coluna_y = None
183
+ session.colunas_x = []
184
+ session.dicotomicas = []
185
+ session.codigo_alocado = []
186
+ session.percentuais = []
187
+ session.outliers_anteriores = []
188
+ session.iteracao = 1
189
+
190
+ colunas_numericas = [str(c) for c in obter_colunas_numericas(df)]
191
+ coluna_y_padrao = identificar_coluna_y_padrao(df)
192
+ mapa_html = charts.criar_mapa(df)
193
+
194
+ return {
195
+ "dados": dataframe_to_payload(df, decimals=4),
196
+ "mapa_html": mapa_html,
197
+ "colunas_numericas": colunas_numericas,
198
+ "coluna_y_padrao": coluna_y_padrao,
199
+ "coords": _build_coords_payload(df, tem_coords),
200
+ }
201
+
202
+
203
+ def save_uploaded_file(session: SessionState, filename: str, content: bytes) -> str:
204
+ safe_name = os.path.basename(filename)
205
+ destino = session.workdir / safe_name
206
+ destino.write_bytes(content)
207
+ session.uploaded_file_path = str(destino)
208
+ session.uploaded_filename = safe_name
209
+ return str(destino)
210
+
211
+
212
+ def load_uploaded_file(session: SessionState, selected_sheet: str | None = None) -> dict[str, Any]:
213
+ if not session.uploaded_file_path:
214
+ raise HTTPException(status_code=400, detail="Nenhum arquivo enviado")
215
+
216
+ caminho = session.uploaded_file_path
217
+ ext = Path(caminho).suffix.lower()
218
+
219
+ if ext == ".dai":
220
+ return load_dai_for_elaboracao(session, caminho)
221
+
222
+ abas, msg_abas, sucesso_abas = detectar_abas_excel(caminho)
223
+ if sucesso_abas and len(abas) > 1 and selected_sheet is None:
224
+ session.available_sheets = abas
225
+ return {
226
+ "status": msg_abas,
227
+ "requires_sheet": True,
228
+ "sheets": abas,
229
+ "sheet_selected": abas[0],
230
+ }
231
+
232
+ df, msg, sucesso = carregar_arquivo(caminho, selected_sheet)
233
+ if not sucesso or df is None:
234
+ raise HTTPException(status_code=400, detail=msg)
235
+
236
+ base = _set_dataframe_base(session, df, clear_models=True)
237
+ return {
238
+ "status": msg,
239
+ "requires_sheet": False,
240
+ "sheets": [],
241
+ **base,
242
+ "contexto": _selection_context(session),
243
+ }
244
+
245
+
246
+ def load_dai_for_elaboracao(session: SessionState, caminho_arquivo: str) -> dict[str, Any]:
247
+ (
248
+ df,
249
+ coluna_y,
250
+ colunas_x,
251
+ transformacao_y,
252
+ transformacoes_x,
253
+ dicotomicas,
254
+ codigo_alocado,
255
+ percentuais,
256
+ msg,
257
+ sucesso,
258
+ elaborador,
259
+ outliers_excluidos,
260
+ ) = carregar_dai(caminho_arquivo)
261
+
262
+ if not sucesso or df is None:
263
+ raise HTTPException(status_code=400, detail=msg)
264
+
265
+ session.elaborador = elaborador
266
+ session.outliers_anteriores = _clean_int_list(outliers_excluidos)
267
+
268
+ base = _set_dataframe_base(session, df, clear_models=True)
269
+
270
+ selection_payload = apply_selection(
271
+ session,
272
+ coluna_y=coluna_y,
273
+ colunas_x=[str(c) for c in colunas_x],
274
+ dicotomicas=[str(c) for c in (dicotomicas or [])],
275
+ codigo_alocado=[str(c) for c in (codigo_alocado or [])],
276
+ percentuais=[str(c) for c in (percentuais or [])],
277
+ outliers_anteriores=session.outliers_anteriores,
278
+ grau_min_coef=1,
279
+ grau_min_f=0,
280
+ )
281
+
282
+ fit_payload = fit_model(
283
+ session,
284
+ transformacao_y=transformacao_y,
285
+ transformacoes_x={str(k): str(v) for k, v in (transformacoes_x or {}).items()},
286
+ dicotomicas=[str(c) for c in (dicotomicas or [])],
287
+ codigo_alocado=[str(c) for c in (codigo_alocado or [])],
288
+ percentuais=[str(c) for c in (percentuais or [])],
289
+ )
290
+
291
+ html_outliers = ""
292
+ if session.outliers_anteriores:
293
+ lista = ", ".join(str(v) for v in session.outliers_anteriores)
294
+ html_outliers = formatar_outliers_anteriores_html(len(session.outliers_anteriores), lista)
295
+
296
+ return {
297
+ "status": msg,
298
+ "tipo": "dai",
299
+ **base,
300
+ "selection": selection_payload,
301
+ "fit": fit_payload,
302
+ "outliers_html": html_outliers,
303
+ "contexto": _selection_context(session),
304
+ "elaborador": sanitize_value(elaborador),
305
+ }
306
+
307
+
308
+ def apply_selection(
309
+ session: SessionState,
310
+ coluna_y: str,
311
+ colunas_x: list[str],
312
+ dicotomicas: list[str] | None = None,
313
+ codigo_alocado: list[str] | None = None,
314
+ percentuais: list[str] | None = None,
315
+ outliers_anteriores: list[int] | None = None,
316
+ grau_min_coef: int = 1,
317
+ grau_min_f: int = 0,
318
+ ) -> dict[str, Any]:
319
+ df = session.df_original
320
+ if df is None:
321
+ raise HTTPException(status_code=400, detail="Carregue um arquivo primeiro")
322
+
323
+ if coluna_y not in df.columns:
324
+ raise HTTPException(status_code=400, detail="Variavel Y invalida")
325
+
326
+ colunas_x_validas = [c for c in colunas_x if c in df.columns and c != coluna_y]
327
+ if not colunas_x_validas:
328
+ raise HTTPException(status_code=400, detail="Selecione ao menos uma variavel X")
329
+
330
+ outliers = _clean_int_list(outliers_anteriores if outliers_anteriores is not None else session.outliers_anteriores)
331
+ df_filtrado = df.copy()
332
+ if outliers:
333
+ df_filtrado = df_filtrado.drop(index=outliers, errors="ignore")
334
+
335
+ session.df_filtrado = df_filtrado
336
+ session.coluna_y = coluna_y
337
+ session.colunas_x = colunas_x_validas
338
+
339
+ dicotomicas = [c for c in (dicotomicas or []) if c in colunas_x_validas]
340
+ codigo_alocado = [c for c in (codigo_alocado or []) if c in colunas_x_validas]
341
+ percentuais = [c for c in (percentuais or []) if c in colunas_x_validas]
342
+
343
+ session.dicotomicas = dicotomicas
344
+ session.codigo_alocado = codigo_alocado
345
+ session.percentuais = percentuais
346
+ session.outliers_anteriores = outliers
347
+ session.iteracao = max(1, session.iteracao)
348
+ session.resultado_modelo = None
349
+ session.tabela_metricas_estado = None
350
+ session.avaliacoes_elaboracao = []
351
+
352
+ estatisticas = calcular_estatisticas_variaveis(df_filtrado, coluna_y, colunas=[coluna_y] + colunas_x_validas)
353
+ estatisticas = estatisticas.round(4)
354
+ session.tabela_estatisticas = estatisticas
355
+
356
+ resultado_micro = testar_micronumerosidade(
357
+ df_filtrado,
358
+ colunas_x_validas,
359
+ dicotomicas=dicotomicas,
360
+ codigo_alocado=codigo_alocado,
361
+ )
362
+ micro_html = formatar_micronumerosidade_html(resultado_micro)
363
+
364
+ try:
365
+ fig_dispersao = charts.criar_graficos_dispersao(df_filtrado[colunas_x_validas], df_filtrado[coluna_y])
366
+ except Exception:
367
+ fig_dispersao = None
368
+
369
+ busca_payload = search_transformacoes(session, grau_min_coef=grau_min_coef, grau_min_f=grau_min_f)
370
+
371
+ resultado_multi = verificar_multicolinearidade(df_filtrado, colunas_x_validas)
372
+ aviso_multi_html, aviso_multi_visivel = formatar_aviso_multicolinearidade(resultado_multi)
373
+
374
+ n_out = len(outliers)
375
+ resumo_outliers = f"Excluidos: {n_out} | A excluir: 0 | A reincluir: 0 | Total: {n_out}"
376
+ outliers_html = formatar_outliers_anteriores_html(
377
+ n_out,
378
+ ", ".join(str(i) for i in outliers) if outliers else "",
379
+ )
380
+
381
+ transform_fields = []
382
+ existing = session.transformacoes_x if session.transformacoes_x else {}
383
+ for col in colunas_x_validas:
384
+ locked = col in dicotomicas or col in percentuais
385
+ transform_fields.append(
386
+ {
387
+ "coluna": col,
388
+ "locked": locked,
389
+ "valor": existing.get(col, "(x)"),
390
+ "choices": TRANSFORMACOES,
391
+ }
392
+ )
393
+
394
+ return {
395
+ "estatisticas": dataframe_to_payload(estatisticas, decimals=4),
396
+ "micronumerosidade_html": micro_html,
397
+ "grafico_dispersao": figure_to_payload(fig_dispersao),
398
+ "busca": busca_payload,
399
+ "resumo_outliers": resumo_outliers,
400
+ "outliers_html": outliers_html,
401
+ "aviso_multicolinearidade": {
402
+ "html": aviso_multi_html,
403
+ "visible": aviso_multi_visivel,
404
+ },
405
+ "transformacao_y": session.transformacao_y,
406
+ "transform_fields": transform_fields,
407
+ "mapa_html": charts.criar_mapa(df_filtrado),
408
+ "contexto": _selection_context(session),
409
+ }
410
+
411
+
412
+ def search_transformacoes(session: SessionState, grau_min_coef: int = 1, grau_min_f: int = 0) -> dict[str, Any]:
413
+ df = session.df_filtrado if session.df_filtrado is not None else session.df_original
414
+ if df is None or not session.coluna_y or not session.colunas_x:
415
+ raise HTTPException(status_code=400, detail="Selecione variaveis antes de buscar transformacoes")
416
+
417
+ transformacoes_fixas: dict[str, str] = {}
418
+ for col in (session.dicotomicas or []) + (session.percentuais or []):
419
+ transformacoes_fixas[col] = "(x)"
420
+
421
+ resultados = buscar_melhores_transformacoes(
422
+ df,
423
+ session.coluna_y,
424
+ session.colunas_x,
425
+ transformacoes_fixas=transformacoes_fixas,
426
+ top_n=5,
427
+ grau_min_coef=int(grau_min_coef),
428
+ grau_min_f=int(grau_min_f),
429
+ )
430
+
431
+ session.resultados_busca = sanitize_value(resultados)
432
+
433
+ if not resultados:
434
+ html = (
435
+ "<p style='color: orange;'><b>Aviso:</b> Nenhuma combinacao encontrada com os criterios atuais.</p>"
436
+ )
437
+ return {
438
+ "html": html,
439
+ "resultados": [],
440
+ "grau_coef": int(grau_min_coef),
441
+ "grau_f": int(grau_min_f),
442
+ }
443
+
444
+ html = formatar_busca_html(resultados)
445
+
446
+ grau_coef_usado = min(
447
+ min(resultado.get("graus_coef", {}).values(), default=0) for resultado in resultados
448
+ )
449
+ grau_f_usado = min(resultado.get("grau_f", 0) for resultado in resultados)
450
+
451
+ return {
452
+ "html": html,
453
+ "resultados": sanitize_value(resultados),
454
+ "grau_coef": int(grau_coef_usado),
455
+ "grau_f": int(grau_f_usado),
456
+ }
457
+
458
+
459
+ def adotar_sugestao(session: SessionState, indice: int) -> dict[str, Any]:
460
+ if not session.resultados_busca:
461
+ raise HTTPException(status_code=400, detail="Nao ha sugestoes carregadas")
462
+
463
+ if indice < 0 or indice >= len(session.resultados_busca):
464
+ raise HTTPException(status_code=400, detail="Indice de sugestao invalido")
465
+
466
+ sugestao = session.resultados_busca[indice]
467
+ session.transformacao_y = str(sugestao.get("transformacao_y", "(x)"))
468
+ transf = sugestao.get("transformacoes_x", {}) or {}
469
+ session.transformacoes_x = {str(k): str(v) for k, v in transf.items()}
470
+
471
+ return {
472
+ "transformacao_y": session.transformacao_y,
473
+ "transformacoes_x": session.transformacoes_x,
474
+ }
475
+
476
+
477
+ def fit_model(
478
+ session: SessionState,
479
+ transformacao_y: str,
480
+ transformacoes_x: dict[str, str] | None,
481
+ dicotomicas: list[str] | None = None,
482
+ codigo_alocado: list[str] | None = None,
483
+ percentuais: list[str] | None = None,
484
+ ) -> dict[str, Any]:
485
+ if session.df_filtrado is None:
486
+ raise HTTPException(status_code=400, detail="Aplique a selecao antes de ajustar")
487
+ if not session.coluna_y or not session.colunas_x:
488
+ raise HTTPException(status_code=400, detail="Variaveis Y/X nao definidas")
489
+
490
+ session.transformacao_y = transformacao_y or "(x)"
491
+
492
+ transf_map: dict[str, str] = {}
493
+ incoming = transformacoes_x or {}
494
+ for col in session.colunas_x:
495
+ transf_map[col] = str(incoming.get(col, "(x)"))
496
+ session.transformacoes_x = transf_map
497
+
498
+ session.dicotomicas = [c for c in (dicotomicas or session.dicotomicas) if c in session.colunas_x]
499
+ session.codigo_alocado = [c for c in (codigo_alocado or session.codigo_alocado) if c in session.colunas_x]
500
+ session.percentuais = [c for c in (percentuais or session.percentuais) if c in session.colunas_x]
501
+
502
+ resultado = ajustar_modelo(
503
+ session.df_filtrado,
504
+ session.coluna_y,
505
+ session.colunas_x,
506
+ session.transformacao_y,
507
+ session.transformacoes_x,
508
+ )
509
+ if resultado is None:
510
+ raise HTTPException(status_code=400, detail="Nao foi possivel ajustar o modelo")
511
+
512
+ resultado["dicotomicas"] = list(session.dicotomicas)
513
+ resultado["codigo_alocado"] = list(session.codigo_alocado)
514
+ resultado["percentuais"] = list(session.percentuais)
515
+
516
+ session.resultado_modelo = resultado
517
+ session.avaliacoes_elaboracao = []
518
+
519
+ diagnosticos_html = formatar_diagnosticos_html(resultado["diagnosticos"])
520
+
521
+ try:
522
+ fig_dispersao_transf = charts.criar_graficos_dispersao(
523
+ resultado["X_transformado"],
524
+ resultado["y_transformado"],
525
+ )
526
+ except Exception:
527
+ fig_dispersao_transf = None
528
+
529
+ fig_corr = None
530
+ try:
531
+ df_corr = resultado["X_transformado"].copy()
532
+ df_corr[session.coluna_y] = resultado["y_transformado"]
533
+ fig_corr = charts.criar_matriz_correlacao(df_corr, [session.coluna_y] + list(session.colunas_x))
534
+ except Exception:
535
+ fig_corr = None
536
+
537
+ graficos = charts.criar_painel_diagnostico(resultado)
538
+
539
+ tabela_metricas = resultado["tabela_obs_calc"].copy()
540
+ tabela_metricas_estado = tabela_metricas.set_index("Índice")
541
+
542
+ indices_usados = resultado.get("indices_usados", [])
543
+ if indices_usados:
544
+ df_base = session.df_filtrado.loc[indices_usados]
545
+ else:
546
+ df_base = session.df_filtrado
547
+
548
+ colunas_novas = {
549
+ col: df_base[col].values
550
+ for col in df_base.columns
551
+ if col not in tabela_metricas_estado.columns
552
+ }
553
+ if colunas_novas:
554
+ tabela_metricas_estado = pd.concat(
555
+ [
556
+ tabela_metricas_estado,
557
+ pd.DataFrame(colunas_novas, index=tabela_metricas_estado.index),
558
+ ],
559
+ axis=1,
560
+ )
561
+
562
+ session.tabela_metricas_estado = tabela_metricas_estado
563
+
564
+ colunas_originais = [c for c in session.df_filtrado.columns if c not in {"Observado", "Calculado", "Resíduo", "Resíduo Pad.", "Resíduo Stud.", "Cook"}]
565
+ variaveis_filtro = ["Resíduo Pad.", "Resíduo Stud.", "Cook"] + [str(c) for c in colunas_originais]
566
+
567
+ n_out = len(session.outliers_anteriores)
568
+ resumo = f"Excluidos: {n_out} | A excluir: 0 | A reincluir: 0 | Total: {n_out}"
569
+
570
+ return {
571
+ "diagnosticos_html": diagnosticos_html,
572
+ "tabela_coef": dataframe_to_payload(resultado["tabela_coef"], decimals=4),
573
+ "tabela_obs_calc": dataframe_to_payload(resultado["tabela_obs_calc"], decimals=4),
574
+ "grafico_dispersao_modelo": figure_to_payload(fig_dispersao_transf),
575
+ "grafico_obs_calc": figure_to_payload(graficos.get("obs_calc")),
576
+ "grafico_residuos": figure_to_payload(graficos.get("residuos")),
577
+ "grafico_histograma": figure_to_payload(graficos.get("histograma")),
578
+ "grafico_cook": figure_to_payload(graficos.get("cook")),
579
+ "grafico_correlacao": figure_to_payload(fig_corr),
580
+ "tabela_metricas": dataframe_to_payload(tabela_metricas, decimals=4),
581
+ "variaveis_filtro": variaveis_filtro,
582
+ "resumo_outliers": resumo,
583
+ "avaliacao_campos": build_campos_avaliacao(session),
584
+ "contexto": _selection_context(session),
585
+ }
586
+
587
+
588
+ def gerar_grafico_dispersao_modelo(session: SessionState, tipo: str) -> dict[str, Any]:
589
+ if not session.resultado_modelo:
590
+ raise HTTPException(status_code=400, detail="Ajuste um modelo primeiro")
591
+
592
+ try:
593
+ if "Residuo" in tipo or "Resíduo" in tipo:
594
+ tabela = session.resultado_modelo.get("tabela_obs_calc")
595
+ residuos = tabela["Resíduo Pad."].values if tabela is not None and "Resíduo Pad." in tabela.columns else None
596
+ fig = charts.criar_graficos_dispersao_residuos(session.resultado_modelo["X_transformado"], residuos)
597
+ else:
598
+ fig = charts.criar_graficos_dispersao(
599
+ session.resultado_modelo["X_transformado"],
600
+ session.resultado_modelo["y_transformado"],
601
+ )
602
+ except Exception:
603
+ fig = None
604
+
605
+ return {"grafico": figure_to_payload(fig)}
606
+
607
+
608
+ def apply_outlier_filters(session: SessionState, filtros: list[dict[str, Any]]) -> dict[str, Any]:
609
+ metricas = session.tabela_metricas_estado
610
+ if metricas is None:
611
+ raise HTTPException(status_code=400, detail="Ajuste o modelo para gerar metricas")
612
+
613
+ if not filtros:
614
+ return {"indices": [], "texto": ""}
615
+
616
+ indices_outliers: set[int] = set()
617
+
618
+ for filtro in filtros:
619
+ var = str(filtro.get("variavel", "")).strip()
620
+ op = str(filtro.get("operador", "")).strip()
621
+ val = filtro.get("valor")
622
+
623
+ if not var or var not in metricas.columns:
624
+ continue
625
+ if val is None:
626
+ continue
627
+
628
+ try:
629
+ val_num = float(val)
630
+ except Exception:
631
+ continue
632
+
633
+ if op == "<=":
634
+ mask = metricas[var] <= val_num
635
+ elif op == ">=":
636
+ mask = metricas[var] >= val_num
637
+ elif op == "<":
638
+ mask = metricas[var] < val_num
639
+ elif op == ">":
640
+ mask = metricas[var] > val_num
641
+ elif op == "=":
642
+ mask = metricas[var] == val_num
643
+ else:
644
+ continue
645
+
646
+ matched = metricas[mask].index.tolist()
647
+ for idx in matched:
648
+ try:
649
+ indices_outliers.add(int(idx))
650
+ except Exception:
651
+ continue
652
+
653
+ indices = sorted(indices_outliers)
654
+ texto = ", ".join(str(i) for i in indices)
655
+ return {"indices": indices, "texto": texto}
656
+
657
+
658
+ def resumir_outliers(outliers_anteriores: list[int], outliers_texto: str | None, reincluir_texto: str | None) -> str:
659
+ anteriores = _clean_int_list(outliers_anteriores)
660
+ novos = _parse_indices_text(outliers_texto)
661
+ reincluir = _parse_indices_text(reincluir_texto)
662
+
663
+ anteriores_atualizados = [i for i in anteriores if i not in reincluir]
664
+ total = sorted(set(anteriores_atualizados + novos))
665
+ return (
666
+ f"Excluidos: {len(anteriores)} | A excluir: {len(novos)} | "
667
+ f"A reincluir: {len(reincluir)} | Total: {len(total)}"
668
+ )
669
+
670
+
671
+ def reiniciar_iteracao(
672
+ session: SessionState,
673
+ outliers_texto: str | None,
674
+ reincluir_texto: str | None,
675
+ grau_min_coef: int = 3,
676
+ grau_min_f: int = 3,
677
+ ) -> dict[str, Any]:
678
+ if session.df_original is None:
679
+ raise HTTPException(status_code=400, detail="Carregue dados primeiro")
680
+ if not session.coluna_y or not session.colunas_x:
681
+ raise HTTPException(status_code=400, detail="Selecione variaveis primeiro")
682
+
683
+ novos = _parse_indices_text(outliers_texto)
684
+ reincluir = _parse_indices_text(reincluir_texto)
685
+
686
+ anteriores_atualizados = [i for i in session.outliers_anteriores if i not in reincluir]
687
+ outliers_combinados = sorted(set(anteriores_atualizados + novos))
688
+
689
+ session.outliers_anteriores = outliers_combinados
690
+ session.iteracao = (session.iteracao or 1) + 1
691
+
692
+ selection = apply_selection(
693
+ session,
694
+ coluna_y=session.coluna_y,
695
+ colunas_x=session.colunas_x,
696
+ dicotomicas=session.dicotomicas,
697
+ codigo_alocado=session.codigo_alocado,
698
+ percentuais=session.percentuais,
699
+ outliers_anteriores=outliers_combinados,
700
+ grau_min_coef=int(grau_min_coef),
701
+ grau_min_f=int(grau_min_f),
702
+ )
703
+
704
+ html_outliers = formatar_outliers_anteriores_html(
705
+ len(outliers_combinados),
706
+ ", ".join(str(i) for i in outliers_combinados) if outliers_combinados else "",
707
+ )
708
+
709
+ selection.update(
710
+ {
711
+ "outliers_anteriores": outliers_combinados,
712
+ "iteracao": session.iteracao,
713
+ "outliers_html": html_outliers,
714
+ "resumo_outliers": resumir_outliers(outliers_combinados, "", ""),
715
+ }
716
+ )
717
+
718
+ return selection
719
+
720
+
721
+ def build_campos_avaliacao(session: SessionState) -> list[dict[str, Any]]:
722
+ if session.resultado_modelo is None or session.tabela_estatisticas is None:
723
+ return []
724
+
725
+ colunas_x = list(session.resultado_modelo.get("colunas_x", []))
726
+
727
+ est = session.tabela_estatisticas
728
+ if "Variável" in est.columns:
729
+ est_idx = est.set_index("Variável")
730
+ else:
731
+ est_idx = est
732
+
733
+ campos: list[dict[str, Any]] = []
734
+ for col in colunas_x:
735
+ placeholder = ""
736
+ if col in session.dicotomicas:
737
+ placeholder = "0 ou 1"
738
+ tipo = "dicotomica"
739
+ elif col in session.codigo_alocado and col in est_idx.index:
740
+ min_v = est_idx.loc[col, "Mínimo"]
741
+ max_v = est_idx.loc[col, "Máximo"]
742
+ placeholder = f"cod. {int(min_v)} a {int(max_v)}"
743
+ tipo = "codigo_alocado"
744
+ elif col in session.percentuais:
745
+ placeholder = "0 a 1"
746
+ tipo = "percentual"
747
+ elif col in est_idx.index:
748
+ min_v = est_idx.loc[col, "Mínimo"]
749
+ max_v = est_idx.loc[col, "Máximo"]
750
+ placeholder = f"{min_v} — {max_v}"
751
+ tipo = "numerica"
752
+ else:
753
+ tipo = "numerica"
754
+
755
+ campos.append(
756
+ {
757
+ "coluna": col,
758
+ "placeholder": placeholder,
759
+ "tipo": tipo,
760
+ }
761
+ )
762
+
763
+ return sanitize_value(campos)
764
+
765
+
766
+ def calcular_avaliacao_elaboracao(
767
+ session: SessionState,
768
+ valores_x: dict[str, Any],
769
+ indice_base: str | None,
770
+ ) -> dict[str, Any]:
771
+ if session.resultado_modelo is None:
772
+ raise HTTPException(status_code=400, detail="Ajuste um modelo primeiro")
773
+ if session.tabela_estatisticas is None:
774
+ raise HTTPException(status_code=400, detail="Estatisticas indisponiveis")
775
+
776
+ colunas_x = list(session.resultado_modelo.get("colunas_x", []))
777
+ if not colunas_x:
778
+ raise HTTPException(status_code=400, detail="Modelo sem variaveis")
779
+
780
+ entradas: dict[str, float] = {}
781
+ for col in colunas_x:
782
+ if col not in valores_x:
783
+ raise HTTPException(status_code=400, detail=f"Valor ausente para {col}")
784
+ try:
785
+ entradas[col] = float(valores_x[col])
786
+ except Exception as exc:
787
+ raise HTTPException(status_code=400, detail=f"Valor invalido para {col}") from exc
788
+
789
+ est = session.tabela_estatisticas
790
+ if "Variável" in est.columns:
791
+ est_idx = est.set_index("Variável")
792
+ else:
793
+ est_idx = est
794
+
795
+ for col in colunas_x:
796
+ valor = entradas[col]
797
+ if col in session.dicotomicas and valor not in (0, 0.0, 1, 1.0):
798
+ raise HTTPException(status_code=400, detail=f"{col} aceita apenas 0 ou 1")
799
+ if col in session.codigo_alocado and col in est_idx.index:
800
+ min_v = float(est_idx.loc[col, "Mínimo"])
801
+ max_v = float(est_idx.loc[col, "Máximo"])
802
+ if float(valor) != int(float(valor)) or valor < min_v or valor > max_v:
803
+ raise HTTPException(status_code=400, detail=f"{col} aceita inteiros de {int(min_v)} a {int(max_v)}")
804
+ if col in session.percentuais and (valor < 0 or valor > 1):
805
+ raise HTTPException(status_code=400, detail=f"{col} aceita valores entre 0 e 1")
806
+
807
+ resultado = avaliar_imovel(
808
+ modelo_sm=session.resultado_modelo["modelo_sm"],
809
+ valores_x=entradas,
810
+ colunas_x=colunas_x,
811
+ transformacoes_x=session.resultado_modelo.get("transformacoes_x", {}),
812
+ transformacao_y=session.resultado_modelo.get("transformacao_y", "(x)"),
813
+ estatisticas_df=session.tabela_estatisticas,
814
+ dicotomicas=session.dicotomicas,
815
+ codigo_alocado=session.codigo_alocado,
816
+ percentuais=session.percentuais,
817
+ )
818
+ if resultado is None:
819
+ raise HTTPException(status_code=400, detail="Erro ao calcular avaliacao")
820
+
821
+ session.avaliacoes_elaboracao.append(resultado)
822
+ idx_base = int(indice_base) - 1 if indice_base else 0
823
+ html = formatar_avaliacao_html(session.avaliacoes_elaboracao, indice_base=idx_base, elem_id_excluir="excluir-aval-elab")
824
+
825
+ choices = [str(i + 1) for i in range(len(session.avaliacoes_elaboracao))]
826
+ base = indice_base if indice_base else "1"
827
+
828
+ return {
829
+ "resultado_html": html,
830
+ "avaliacoes": sanitize_value(session.avaliacoes_elaboracao),
831
+ "base_choices": choices,
832
+ "base_value": base,
833
+ }
834
+
835
+
836
+ def limpar_avaliacoes_elaboracao(session: SessionState) -> dict[str, Any]:
837
+ session.avaliacoes_elaboracao = []
838
+ return {
839
+ "resultado_html": "",
840
+ "avaliacoes": [],
841
+ "base_choices": [],
842
+ "base_value": None,
843
+ }
844
+
845
+
846
+ def excluir_avaliacao_elaboracao(session: SessionState, indice_str: str | None, indice_base_str: str | None) -> dict[str, Any]:
847
+ if not indice_str or not session.avaliacoes_elaboracao:
848
+ return {
849
+ "resultado_html": None,
850
+ "avaliacoes": sanitize_value(session.avaliacoes_elaboracao),
851
+ "base_choices": [str(i + 1) for i in range(len(session.avaliacoes_elaboracao))],
852
+ "base_value": indice_base_str,
853
+ }
854
+
855
+ try:
856
+ idx = int(indice_str.strip()) - 1
857
+ except Exception:
858
+ return {
859
+ "resultado_html": None,
860
+ "avaliacoes": sanitize_value(session.avaliacoes_elaboracao),
861
+ "base_choices": [str(i + 1) for i in range(len(session.avaliacoes_elaboracao))],
862
+ "base_value": indice_base_str,
863
+ }
864
+
865
+ if idx < 0 or idx >= len(session.avaliacoes_elaboracao):
866
+ return {
867
+ "resultado_html": None,
868
+ "avaliacoes": sanitize_value(session.avaliacoes_elaboracao),
869
+ "base_choices": [str(i + 1) for i in range(len(session.avaliacoes_elaboracao))],
870
+ "base_value": indice_base_str,
871
+ }
872
+
873
+ nova_lista = [a for i, a in enumerate(session.avaliacoes_elaboracao) if i != idx]
874
+ session.avaliacoes_elaboracao = nova_lista
875
+
876
+ if not nova_lista:
877
+ return {
878
+ "resultado_html": "",
879
+ "avaliacoes": [],
880
+ "base_choices": [],
881
+ "base_value": None,
882
+ }
883
+
884
+ base = int(indice_base_str) - 1 if indice_base_str else 0
885
+ if base >= len(nova_lista):
886
+ base = len(nova_lista) - 1
887
+ if base < 0:
888
+ base = 0
889
+
890
+ html = formatar_avaliacao_html(nova_lista, indice_base=base, elem_id_excluir="excluir-aval-elab")
891
+ choices = [str(i + 1) for i in range(len(nova_lista))]
892
+
893
+ return {
894
+ "resultado_html": html,
895
+ "avaliacoes": sanitize_value(nova_lista),
896
+ "base_choices": choices,
897
+ "base_value": str(base + 1),
898
+ }
899
+
900
+
901
+ def atualizar_base_avaliacao_elaboracao(session: SessionState, indice_base_str: str | None) -> dict[str, Any]:
902
+ if not session.avaliacoes_elaboracao:
903
+ return {"resultado_html": ""}
904
+ indice = int(indice_base_str) - 1 if indice_base_str else 0
905
+ html = formatar_avaliacao_html(session.avaliacoes_elaboracao, indice_base=indice, elem_id_excluir="excluir-aval-elab")
906
+ return {"resultado_html": html}
907
+
908
+
909
+ def exportar_avaliacoes_elaboracao(session: SessionState) -> str:
910
+ caminho = exportar_avaliacoes_excel(session.avaliacoes_elaboracao)
911
+ if not caminho:
912
+ raise HTTPException(status_code=400, detail="Sem avaliacoes para exportar")
913
+ return caminho
914
+
915
+
916
+ def exportar_modelo(session: SessionState, nome_arquivo: str, elaborador: dict[str, Any] | None = None) -> tuple[str, str]:
917
+ if session.resultado_modelo is None:
918
+ raise HTTPException(status_code=400, detail="Ajuste um modelo antes de exportar")
919
+ if session.df_filtrado is None or session.df_original is None:
920
+ raise HTTPException(status_code=400, detail="Dados nao disponiveis para exportacao")
921
+
922
+ if not nome_arquivo or not nome_arquivo.strip():
923
+ raise HTTPException(status_code=400, detail="Informe o nome do arquivo")
924
+
925
+ caminho, msg = exportar_modelo_dai(
926
+ session.resultado_modelo,
927
+ session.df_filtrado,
928
+ session.df_original,
929
+ session.tabela_estatisticas,
930
+ nome_arquivo.strip(),
931
+ elaborador=elaborador,
932
+ outliers_excluidos=session.outliers_anteriores,
933
+ )
934
+
935
+ if not caminho:
936
+ raise HTTPException(status_code=400, detail=msg)
937
+
938
+ return caminho, msg
939
+
940
+
941
+ def exportar_base(session: SessionState, usar_filtrado: bool = True) -> str:
942
+ df = session.df_filtrado if usar_filtrado else session.df_original
943
+ caminho = exportar_base_csv(df)
944
+ if not caminho:
945
+ raise HTTPException(status_code=400, detail="Base indisponivel")
946
+ return caminho
947
+
948
+
949
+ def atualizar_mapa(session: SessionState, var_mapa: str | None) -> dict[str, Any]:
950
+ df = session.df_filtrado if session.df_filtrado is not None else session.df_original
951
+ if df is None:
952
+ raise HTTPException(status_code=400, detail="Carregue dados primeiro")
953
+
954
+ tamanho_col = None if not var_mapa or var_mapa == "Visualizacao Padrao" or var_mapa == "Visualização Padrão" else var_mapa
955
+ mapa_html = charts.criar_mapa(df, tamanho_col=tamanho_col)
956
+ return {"mapa_html": mapa_html}
957
+
958
+
959
+ def mapear_coordenadas_manualmente(session: SessionState, col_lat: str, col_lon: str) -> dict[str, Any]:
960
+ df = session.df_original
961
+ if df is None:
962
+ raise HTTPException(status_code=400, detail="Carregue dados primeiro")
963
+ if not col_lat or not col_lon:
964
+ raise HTTPException(status_code=400, detail="Selecione latitude e longitude")
965
+
966
+ n_total = len(df)
967
+ if n_total == 0:
968
+ raise HTTPException(status_code=400, detail="DataFrame vazio")
969
+
970
+ lat_num = pd.to_numeric(df[col_lat], errors="coerce")
971
+ lon_num = pd.to_numeric(df[col_lon], errors="coerce")
972
+ lat_vals = lat_num.dropna()
973
+ lon_vals = lon_num.dropna()
974
+
975
+ lat_conv = len(lat_vals) / n_total
976
+ lon_conv = len(lon_vals) / n_total
977
+
978
+ erros: list[str] = []
979
+ if lat_conv < 0.5:
980
+ erros.append(f"{col_lat}: apenas {lat_conv:.0%} numericos")
981
+ if lon_conv < 0.5:
982
+ erros.append(f"{col_lon}: apenas {lon_conv:.0%} numericos")
983
+
984
+ if not erros:
985
+ lat_in_range = ((lat_vals >= -90) & (lat_vals <= 90)).mean()
986
+ lon_in_range = ((lon_vals >= -180) & (lon_vals <= 180)).mean()
987
+ lat_in_lon_range = ((lat_vals >= -180) & (lat_vals <= 180)).mean()
988
+ lon_in_lat_range = ((lon_vals >= -90) & (lon_vals <= 90)).mean()
989
+
990
+ inversao = lat_in_range < 0.5 and lat_in_lon_range > 0.8 and lon_in_lat_range > 0.8
991
+ if inversao:
992
+ erros.append("As colunas parecem invertidas")
993
+ else:
994
+ if lat_in_range < 0.8:
995
+ erros.append(f"{col_lat}: baixa aderencia ao intervalo de latitude")
996
+ if lon_in_range < 0.8:
997
+ erros.append(f"{col_lon}: baixa aderencia ao intervalo de longitude")
998
+
999
+ if not erros:
1000
+ lat_tem_decimais = (lat_vals % 1 != 0).mean() if len(lat_vals) > 0 else 0.0
1001
+ lon_tem_decimais = (lon_vals % 1 != 0).mean() if len(lon_vals) > 0 else 0.0
1002
+ if lat_tem_decimais < 0.1:
1003
+ erros.append(f"{col_lat}: poucos valores com casas decimais")
1004
+ if lon_tem_decimais < 0.1:
1005
+ erros.append(f"{col_lon}: poucos valores com casas decimais")
1006
+
1007
+ if erros:
1008
+ raise HTTPException(status_code=400, detail="; ".join(erros))
1009
+
1010
+ df_novo = geocodificacao.padronizar_coords(df, col_lat, col_lon)
1011
+ session.df_original = df_novo
1012
+
1013
+ df_filtrado = df_novo.copy()
1014
+ if session.outliers_anteriores:
1015
+ df_filtrado = df_filtrado.drop(index=session.outliers_anteriores, errors="ignore")
1016
+ session.df_filtrado = df_filtrado
1017
+
1018
+ return {
1019
+ "status": "Coordenadas mapeadas com sucesso",
1020
+ "mapa_html": charts.criar_mapa(df_filtrado),
1021
+ "dados": dataframe_to_payload(df_filtrado, decimals=4),
1022
+ "coords": _build_coords_payload(df_novo, True),
1023
+ }
1024
+
1025
+
1026
+ def geocodificar(session: SessionState, col_cdlog: str, col_num: str, auto_200: bool = False) -> dict[str, Any]:
1027
+ if session.df_original is None:
1028
+ raise HTTPException(status_code=400, detail="Carregue dados primeiro")
1029
+
1030
+ try:
1031
+ df_resultado, df_falhas, ajustados = geocodificacao.geocodificar(session.df_original, col_cdlog, col_num, auto_200=auto_200)
1032
+ except Exception as exc:
1033
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1034
+
1035
+ session.df_original = df_resultado
1036
+ session.df_filtrado = df_resultado.drop(index=session.outliers_anteriores, errors="ignore")
1037
+ session.geo_falhas_df = df_falhas
1038
+ session.geo_col_cdlog = col_cdlog
1039
+ session.geo_col_num = col_num
1040
+
1041
+ status_html = geocodificacao.formatar_status_geocodificacao(df_resultado, df_falhas, ajustados)
1042
+ falhas_html, df_correcoes = geocodificacao.preparar_display_falhas(df_falhas)
1043
+
1044
+ return {
1045
+ "status_html": status_html,
1046
+ "falhas_html": falhas_html,
1047
+ "falhas_para_correcao": dataframe_to_payload(df_correcoes, decimals=None),
1048
+ "mapa_html": charts.criar_mapa(session.df_filtrado),
1049
+ "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
1050
+ "coords": _build_coords_payload(session.df_original, True),
1051
+ }
1052
+
1053
+
1054
+ def aplicar_correcoes_geocodificacao(
1055
+ session: SessionState,
1056
+ correcoes: list[dict[str, Any]],
1057
+ auto_200: bool = False,
1058
+ ) -> dict[str, Any]:
1059
+ if session.df_original is None:
1060
+ raise HTTPException(status_code=400, detail="Carregue dados primeiro")
1061
+ if session.geo_falhas_df is None:
1062
+ raise HTTPException(status_code=400, detail="Nao ha falhas de geocodificacao pendentes")
1063
+ if not session.geo_col_cdlog or not session.geo_col_num:
1064
+ raise HTTPException(status_code=400, detail="Contexto de geocodificacao ausente")
1065
+
1066
+ df_falhas = session.geo_falhas_df.copy()
1067
+ mapa_correcao = {
1068
+ int(item.get("linha")): str(item.get("numero_corrigido", "")).strip()
1069
+ for item in correcoes
1070
+ if item.get("linha") is not None
1071
+ }
1072
+
1073
+ if "numero_corrigido" not in df_falhas.columns:
1074
+ df_falhas["numero_corrigido"] = ""
1075
+
1076
+ for idx, row in df_falhas.iterrows():
1077
+ linha = int(row.get("_idx"))
1078
+ valor = mapa_correcao.get(linha, "")
1079
+ df_falhas.at[idx, "numero_corrigido"] = valor
1080
+
1081
+ try:
1082
+ df_resultado, df_falhas_novas, ajustados, manuais = geocodificacao.aplicar_correcoes_e_regeodificar(
1083
+ session.df_original,
1084
+ df_falhas,
1085
+ session.geo_col_cdlog,
1086
+ session.geo_col_num,
1087
+ auto_200=auto_200,
1088
+ )
1089
+ except Exception as exc:
1090
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1091
+
1092
+ session.df_original = df_resultado
1093
+ session.df_filtrado = df_resultado.drop(index=session.outliers_anteriores, errors="ignore")
1094
+ session.geo_falhas_df = df_falhas_novas
1095
+
1096
+ status_html = geocodificacao.formatar_status_geocodificacao(df_resultado, df_falhas_novas, ajustados, manuais=manuais)
1097
+ falhas_html, df_correcoes = geocodificacao.preparar_display_falhas(df_falhas_novas)
1098
+
1099
+ return {
1100
+ "status_html": status_html,
1101
+ "falhas_html": falhas_html,
1102
+ "falhas_para_correcao": dataframe_to_payload(df_correcoes, decimals=None),
1103
+ "mapa_html": charts.criar_mapa(session.df_filtrado),
1104
+ "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
1105
+ "coords": _build_coords_payload(session.df_original, True),
1106
+ }
1107
+
1108
+
1109
+ def limpar_historico_outliers(session: SessionState) -> dict[str, Any]:
1110
+ if session.df_original is None:
1111
+ raise HTTPException(status_code=400, detail="Carregue dados primeiro")
1112
+
1113
+ session.outliers_anteriores = []
1114
+ session.iteracao = 1
1115
+ session.df_filtrado = session.df_original.copy()
1116
+ session.reset_modelo()
1117
+
1118
+ return {
1119
+ "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
1120
+ "mapa_html": charts.criar_mapa(session.df_filtrado),
1121
+ "outliers_html": "",
1122
+ "resumo_outliers": "Excluidos: 0 | A excluir: 0 | A reincluir: 0 | Total: 0",
1123
+ "contexto": _selection_context(session),
1124
+ }
backend/app/services/serializers.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+
11
+ def sanitize_value(value: Any) -> Any:
12
+ if isinstance(value, np.ndarray):
13
+ return [sanitize_value(item) for item in value.tolist()]
14
+ if isinstance(value, np.generic):
15
+ return sanitize_value(value.item())
16
+ if isinstance(value, (np.floating, float)):
17
+ if math.isnan(value) or math.isinf(value):
18
+ return None
19
+ return float(value)
20
+ if isinstance(value, (np.integer, int)):
21
+ return int(value)
22
+ if isinstance(value, (np.bool_, bool)):
23
+ return bool(value)
24
+ if isinstance(value, pd.Timestamp):
25
+ return value.isoformat()
26
+ if isinstance(value, pd.Timedelta):
27
+ return str(value)
28
+ if isinstance(value, pd.Series):
29
+ return [sanitize_value(item) for item in value.tolist()]
30
+ if isinstance(value, pd.Index):
31
+ return [sanitize_value(item) for item in value.tolist()]
32
+ if isinstance(value, pd.DataFrame):
33
+ return sanitize_value(value.to_dict(orient="records"))
34
+ if isinstance(value, Path):
35
+ return str(value)
36
+ if value is None:
37
+ return None
38
+ if isinstance(value, str):
39
+ return value
40
+ if isinstance(value, dict):
41
+ return {str(k): sanitize_value(v) for k, v in value.items()}
42
+ if isinstance(value, (list, tuple, set)):
43
+ return [sanitize_value(v) for v in value]
44
+ try:
45
+ if pd.isna(value):
46
+ return None
47
+ except Exception:
48
+ pass
49
+ return value
50
+
51
+
52
+ def dataframe_to_payload(df: pd.DataFrame | None, decimals: int | None = None, max_rows: int = 5000) -> dict[str, Any] | None:
53
+ if df is None:
54
+ return None
55
+
56
+ df_work = df.copy()
57
+ if decimals is not None:
58
+ numeric_cols = df_work.select_dtypes(include=[np.number]).columns
59
+ df_work[numeric_cols] = df_work[numeric_cols].round(decimals)
60
+
61
+ total_rows = len(df_work)
62
+ truncated = total_rows > max_rows
63
+ if truncated:
64
+ df_work = df_work.head(max_rows)
65
+
66
+ rows: list[dict[str, Any]] = []
67
+ for idx, row in df_work.iterrows():
68
+ payload_row = {"_index": sanitize_value(idx)}
69
+ for col, value in row.items():
70
+ payload_row[str(col)] = sanitize_value(value)
71
+ rows.append(payload_row)
72
+
73
+ columns = ["_index"] + [str(c) for c in df_work.columns]
74
+ return {
75
+ "columns": columns,
76
+ "rows": rows,
77
+ "total_rows": total_rows,
78
+ "returned_rows": len(rows),
79
+ "truncated": truncated,
80
+ }
81
+
82
+
83
+ def figure_to_payload(fig: Any) -> dict[str, Any] | None:
84
+ if fig is None:
85
+ return None
86
+ try:
87
+ return sanitize_value(fig.to_plotly_json())
88
+ except Exception:
89
+ return None
backend/app/services/session_store.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import tempfile
5
+ import uuid
6
+ from pathlib import Path
7
+
8
+ from fastapi import HTTPException
9
+
10
+ from app.models.session import SessionState
11
+
12
+
13
+ class SessionStore:
14
+ def __init__(self) -> None:
15
+ self._sessions: dict[str, SessionState] = {}
16
+
17
+ def create(self) -> SessionState:
18
+ session_id = uuid.uuid4().hex
19
+ workdir = Path(tempfile.mkdtemp(prefix=f"mesa_{session_id[:8]}_"))
20
+ state = SessionState(session_id=session_id, workdir=workdir)
21
+ self._sessions[session_id] = state
22
+ return state
23
+
24
+ def get(self, session_id: str) -> SessionState:
25
+ state = self._sessions.get(session_id)
26
+ if state is None:
27
+ raise HTTPException(status_code=404, detail="Sessao nao encontrada")
28
+ return state
29
+
30
+ def delete(self, session_id: str) -> None:
31
+ state = self._sessions.pop(session_id, None)
32
+ if state is None:
33
+ return
34
+ shutil.rmtree(state.workdir, ignore_errors=True)
35
+
36
+
37
+ session_store = SessionStore()
backend/app/services/visualizacao_service.py ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ from fastapi import HTTPException
9
+ from joblib import load
10
+
11
+ from app.core.visualizacao import app as viz_app
12
+ from app.core.elaboracao.core import _migrar_pacote_v1_para_v2, avaliar_imovel, exportar_avaliacoes_excel
13
+ from app.core.elaboracao.formatadores import formatar_avaliacao_html
14
+ from app.models.session import SessionState
15
+ from app.services.serializers import dataframe_to_payload, figure_to_payload, sanitize_value
16
+
17
+
18
+ CHAVES_ESPERADAS = ["versao", "dados", "transformacoes", "modelo"]
19
+
20
+
21
+ def carregar_modelo(session: SessionState, caminho_arquivo: str) -> dict[str, Any]:
22
+ try:
23
+ pacote = load(caminho_arquivo)
24
+ except Exception as exc:
25
+ raise HTTPException(status_code=400, detail=f"Falha ao ler arquivo .dai: {exc}") from exc
26
+
27
+ if not isinstance(pacote, dict):
28
+ raise HTTPException(status_code=400, detail="Arquivo .dai invalido")
29
+
30
+ if "versao" not in pacote:
31
+ pacote = _migrar_pacote_v1_para_v2(pacote)
32
+
33
+ faltantes = [k for k in CHAVES_ESPERADAS if k not in pacote]
34
+ if faltantes:
35
+ raise HTTPException(status_code=400, detail=f"Pacote incompleto: {faltantes}")
36
+
37
+ session.pacote_visualizacao = pacote
38
+ session.dados_visualizacao = None
39
+ session.avaliacoes_visualizacao = []
40
+
41
+ status = f"Modelo carregado: {os.path.basename(caminho_arquivo)}"
42
+ badge_html = viz_app._formatar_badge_completo(pacote)
43
+
44
+ return {
45
+ "status": status,
46
+ "badge_html": badge_html,
47
+ }
48
+
49
+
50
+ def _tabela_estatisticas(pacote: dict[str, Any]) -> pd.DataFrame:
51
+ estat = pd.DataFrame(pacote["dados"]["estatisticas"])
52
+ if not isinstance(estat.index, pd.RangeIndex):
53
+ estat.insert(0, "Variável", estat.index.astype(str))
54
+ estat = estat.reset_index(drop=True)
55
+ return estat
56
+
57
+
58
+ def _extrair_modelo_info(pacote: dict[str, Any]) -> dict[str, Any]:
59
+ info_transf = pacote["transformacoes"]["info"]
60
+ nome_y, transf_y = info_transf[0].split(": ", 1)
61
+ transformacao_y = transf_y.strip().replace("(y)", "(x)")
62
+
63
+ colunas_x: list[str] = []
64
+ transformacoes_x: dict[str, str] = {}
65
+ for item in info_transf[1:]:
66
+ nome_x, transf_x = item.split(": ", 1)
67
+ nome_x = nome_x.strip()
68
+ colunas_x.append(nome_x)
69
+ transformacoes_x[nome_x] = transf_x.strip()
70
+
71
+ dicotomicas = [str(v) for v in (pacote["transformacoes"].get("dicotomicas", []) or [])]
72
+ codigo_alocado = [str(v) for v in (pacote["transformacoes"].get("codigo_alocado", []) or [])]
73
+ percentuais = [str(v) for v in (pacote["transformacoes"].get("percentuais", []) or [])]
74
+
75
+ return {
76
+ "nome_y": nome_y.strip(),
77
+ "transformacao_y": transformacao_y,
78
+ "colunas_x": colunas_x,
79
+ "transformacoes_x": transformacoes_x,
80
+ "dicotomicas": dicotomicas,
81
+ "codigo_alocado": codigo_alocado,
82
+ "percentuais": percentuais,
83
+ }
84
+
85
+
86
+ def exibir_modelo(session: SessionState) -> dict[str, Any]:
87
+ pacote = session.pacote_visualizacao
88
+ if pacote is None:
89
+ raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
90
+
91
+ dados = pacote["dados"]["df"].reset_index()
92
+ for col in dados.columns:
93
+ if str(col).lower() in ["lat", "lon"]:
94
+ dados[col] = dados[col].round(6)
95
+ elif pd.api.types.is_numeric_dtype(dados[col]):
96
+ dados[col] = dados[col].round(2)
97
+
98
+ estat = _tabela_estatisticas(pacote).round(2)
99
+
100
+ escalas_html = viz_app.formatar_escalas_html(pacote["transformacoes"]["info"])
101
+
102
+ X = pacote["transformacoes"]["X"].reset_index()
103
+ y = pacote["transformacoes"]["y"].reset_index()
104
+ if "index" in y.columns and "index" in X.columns:
105
+ y = y.drop(columns=["index"])
106
+ df_xy = pd.concat([X, y], axis=1)
107
+ df_xy = df_xy.loc[:, ~df_xy.columns.duplicated()].round(2)
108
+
109
+ resumo_html = viz_app.formatar_resumo_html(viz_app.reorganizar_modelos_resumos(pacote["modelo"]["diagnosticos"]))
110
+
111
+ tab_coef = pd.DataFrame(pacote["modelo"]["coeficientes"])
112
+ if not isinstance(tab_coef.index, pd.RangeIndex):
113
+ tab_coef.insert(0, "Variável", tab_coef.index.astype(str))
114
+ tab_coef = tab_coef.reset_index(drop=True)
115
+ mask = tab_coef["Variável"].astype(str).str.lower().isin(["intercept", "const", "(intercept)"])
116
+ if mask.any():
117
+ tab_coef = pd.concat([tab_coef[mask], tab_coef[~mask]], ignore_index=True)
118
+ tab_coef = tab_coef.round(2)
119
+
120
+ tab_obs_calc = pacote["modelo"]["obs_calc"].reset_index().round(2)
121
+
122
+ figs = viz_app.gerar_todos_graficos(pacote)
123
+
124
+ info = _extrair_modelo_info(pacote)
125
+ mapa_html = viz_app.criar_mapa(dados, col_y=info["nome_y"])
126
+
127
+ colunas_numericas = [
128
+ str(col)
129
+ for col in dados.select_dtypes(include=[np.number]).columns
130
+ if str(col).lower() not in ["lat", "lon", "latitude", "longitude", "index"]
131
+ ]
132
+ choices_mapa = ["Visualização Padrão"] + colunas_numericas
133
+
134
+ session.dados_visualizacao = dados
135
+
136
+ return {
137
+ "dados": dataframe_to_payload(dados, decimals=2),
138
+ "estatisticas": dataframe_to_payload(estat, decimals=2),
139
+ "escalas_html": escalas_html,
140
+ "dados_transformados": dataframe_to_payload(df_xy, decimals=2),
141
+ "resumo_html": resumo_html,
142
+ "coeficientes": dataframe_to_payload(tab_coef, decimals=2),
143
+ "obs_calc": dataframe_to_payload(tab_obs_calc, decimals=2),
144
+ "grafico_obs_calc": figure_to_payload(figs.get("obs_calc")),
145
+ "grafico_residuos": figure_to_payload(figs.get("residuos")),
146
+ "grafico_histograma": figure_to_payload(figs.get("hist")),
147
+ "grafico_cook": figure_to_payload(figs.get("cook")),
148
+ "grafico_correlacao": figure_to_payload(figs.get("corr")),
149
+ "mapa_html": mapa_html,
150
+ "mapa_choices": choices_mapa,
151
+ "campos_avaliacao": campos_avaliacao(session),
152
+ "meta_modelo": sanitize_value(info),
153
+ }
154
+
155
+
156
+ def atualizar_mapa(session: SessionState, variavel_mapa: str | None) -> dict[str, Any]:
157
+ pacote = session.pacote_visualizacao
158
+ dados = session.dados_visualizacao
159
+ if pacote is None or dados is None or dados.empty:
160
+ raise HTTPException(status_code=400, detail="Exiba o modelo antes de atualizar o mapa")
161
+
162
+ info = _extrair_modelo_info(pacote)
163
+ tamanho_col = None
164
+ if variavel_mapa and variavel_mapa != "Visualização Padrão":
165
+ tamanho_col = variavel_mapa
166
+
167
+ mapa_html = viz_app.criar_mapa(dados, tamanho_col=tamanho_col, col_y=info["nome_y"])
168
+ return {"mapa_html": mapa_html}
169
+
170
+
171
+ def campos_avaliacao(session: SessionState) -> list[dict[str, Any]]:
172
+ pacote = session.pacote_visualizacao
173
+ if pacote is None:
174
+ return []
175
+
176
+ info = _extrair_modelo_info(pacote)
177
+ colunas_x = info["colunas_x"]
178
+ dicotomicas = info["dicotomicas"]
179
+ codigo_alocado = info["codigo_alocado"]
180
+ percentuais = info["percentuais"]
181
+
182
+ estat = pacote["dados"]["estatisticas"]
183
+ if isinstance(estat, pd.DataFrame):
184
+ estat_df = estat.copy()
185
+ else:
186
+ estat_df = pd.DataFrame(estat)
187
+
188
+ if "Variável" in estat_df.columns:
189
+ est_idx = estat_df.set_index("Variável")
190
+ elif not isinstance(estat_df.index, pd.RangeIndex):
191
+ est_idx = estat_df
192
+ else:
193
+ est_idx = pd.DataFrame()
194
+
195
+ if not est_idx.empty:
196
+ est_idx.index = est_idx.index.map(str)
197
+
198
+ campos: list[dict[str, Any]] = []
199
+ for col in colunas_x:
200
+ placeholder = ""
201
+ if col in dicotomicas:
202
+ placeholder = "0 ou 1"
203
+ tipo = "dicotomica"
204
+ elif col in codigo_alocado and col in est_idx.index:
205
+ min_v = est_idx.loc[col, "Mínimo"]
206
+ max_v = est_idx.loc[col, "Máximo"]
207
+ placeholder = f"cod. {int(min_v)} a {int(max_v)}"
208
+ tipo = "codigo_alocado"
209
+ elif col in percentuais:
210
+ placeholder = "0 a 1"
211
+ tipo = "percentual"
212
+ elif col in est_idx.index:
213
+ min_v = est_idx.loc[col, "Mínimo"]
214
+ max_v = est_idx.loc[col, "Máximo"]
215
+ placeholder = f"{min_v} — {max_v}"
216
+ tipo = "numerica"
217
+ else:
218
+ tipo = "numerica"
219
+
220
+ campos.append({"coluna": col, "placeholder": placeholder, "tipo": tipo})
221
+
222
+ return sanitize_value(campos)
223
+
224
+
225
+ def calcular_avaliacao(session: SessionState, valores_x: dict[str, Any], indice_base: str | None) -> dict[str, Any]:
226
+ pacote = session.pacote_visualizacao
227
+ if pacote is None:
228
+ raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
229
+
230
+ info = _extrair_modelo_info(pacote)
231
+ colunas_x = info["colunas_x"]
232
+
233
+ entradas: dict[str, float] = {}
234
+ for col in colunas_x:
235
+ if col not in valores_x:
236
+ raise HTTPException(status_code=400, detail=f"Valor ausente para {col}")
237
+ try:
238
+ entradas[col] = float(valores_x[col])
239
+ except Exception as exc:
240
+ raise HTTPException(status_code=400, detail=f"Valor invalido para {col}") from exc
241
+
242
+ estatisticas_df = pacote["dados"]["estatisticas"]
243
+ if isinstance(estatisticas_df, pd.DataFrame):
244
+ est_df = estatisticas_df
245
+ else:
246
+ est_df = pd.DataFrame(estatisticas_df)
247
+
248
+ if "Variável" in est_df.columns:
249
+ est_idx = est_df.set_index("Variável")
250
+ else:
251
+ est_idx = est_df
252
+
253
+ for col in colunas_x:
254
+ valor = entradas[col]
255
+ if col in info["dicotomicas"] and valor not in (0, 0.0, 1, 1.0):
256
+ raise HTTPException(status_code=400, detail=f"{col} aceita apenas 0 ou 1")
257
+ if col in info["codigo_alocado"] and col in est_idx.index:
258
+ min_v = float(est_idx.loc[col, "Mínimo"])
259
+ max_v = float(est_idx.loc[col, "Máximo"])
260
+ if float(valor) != int(float(valor)) or valor < min_v or valor > max_v:
261
+ raise HTTPException(status_code=400, detail=f"{col} aceita inteiros de {int(min_v)} a {int(max_v)}")
262
+ if col in info["percentuais"] and (valor < 0 or valor > 1):
263
+ raise HTTPException(status_code=400, detail=f"{col} aceita valores entre 0 e 1")
264
+
265
+ resultado = avaliar_imovel(
266
+ modelo_sm=pacote["modelo"]["sm"],
267
+ valores_x=entradas,
268
+ colunas_x=colunas_x,
269
+ transformacoes_x=info["transformacoes_x"],
270
+ transformacao_y=info["transformacao_y"],
271
+ estatisticas_df=est_df,
272
+ dicotomicas=info["dicotomicas"],
273
+ codigo_alocado=info["codigo_alocado"],
274
+ percentuais=info["percentuais"],
275
+ )
276
+
277
+ if resultado is None:
278
+ raise HTTPException(status_code=400, detail="Erro ao calcular avaliacao")
279
+
280
+ session.avaliacoes_visualizacao.append(resultado)
281
+
282
+ indice = int(indice_base) - 1 if indice_base else 0
283
+ html = formatar_avaliacao_html(session.avaliacoes_visualizacao, indice_base=indice, elem_id_excluir="excluir-aval-viz")
284
+ choices = [str(i + 1) for i in range(len(session.avaliacoes_visualizacao))]
285
+ base_value = indice_base if indice_base else "1"
286
+
287
+ return {
288
+ "resultado_html": html,
289
+ "avaliacoes": sanitize_value(session.avaliacoes_visualizacao),
290
+ "base_choices": choices,
291
+ "base_value": base_value,
292
+ }
293
+
294
+
295
+ def limpar_avaliacoes(session: SessionState) -> dict[str, Any]:
296
+ session.avaliacoes_visualizacao = []
297
+ return {
298
+ "resultado_html": "",
299
+ "avaliacoes": [],
300
+ "base_choices": [],
301
+ "base_value": None,
302
+ }
303
+
304
+
305
+ def excluir_avaliacao(session: SessionState, indice_str: str | None, indice_base_str: str | None) -> dict[str, Any]:
306
+ if not indice_str or not session.avaliacoes_visualizacao:
307
+ return {
308
+ "resultado_html": None,
309
+ "avaliacoes": sanitize_value(session.avaliacoes_visualizacao),
310
+ "base_choices": [str(i + 1) for i in range(len(session.avaliacoes_visualizacao))],
311
+ "base_value": indice_base_str,
312
+ }
313
+
314
+ try:
315
+ idx = int(indice_str.strip()) - 1
316
+ except Exception:
317
+ return {
318
+ "resultado_html": None,
319
+ "avaliacoes": sanitize_value(session.avaliacoes_visualizacao),
320
+ "base_choices": [str(i + 1) for i in range(len(session.avaliacoes_visualizacao))],
321
+ "base_value": indice_base_str,
322
+ }
323
+
324
+ if idx < 0 or idx >= len(session.avaliacoes_visualizacao):
325
+ return {
326
+ "resultado_html": None,
327
+ "avaliacoes": sanitize_value(session.avaliacoes_visualizacao),
328
+ "base_choices": [str(i + 1) for i in range(len(session.avaliacoes_visualizacao))],
329
+ "base_value": indice_base_str,
330
+ }
331
+
332
+ nova_lista = [a for i, a in enumerate(session.avaliacoes_visualizacao) if i != idx]
333
+ session.avaliacoes_visualizacao = nova_lista
334
+
335
+ if not nova_lista:
336
+ return {
337
+ "resultado_html": "",
338
+ "avaliacoes": [],
339
+ "base_choices": [],
340
+ "base_value": None,
341
+ }
342
+
343
+ base = int(indice_base_str) - 1 if indice_base_str else 0
344
+ if base >= len(nova_lista):
345
+ base = len(nova_lista) - 1
346
+ if base < 0:
347
+ base = 0
348
+
349
+ html = formatar_avaliacao_html(nova_lista, indice_base=base, elem_id_excluir="excluir-aval-viz")
350
+ choices = [str(i + 1) for i in range(len(nova_lista))]
351
+
352
+ return {
353
+ "resultado_html": html,
354
+ "avaliacoes": sanitize_value(nova_lista),
355
+ "base_choices": choices,
356
+ "base_value": str(base + 1),
357
+ }
358
+
359
+
360
+ def atualizar_base(session: SessionState, indice_base_str: str | None) -> dict[str, Any]:
361
+ if not session.avaliacoes_visualizacao:
362
+ return {"resultado_html": ""}
363
+ indice = int(indice_base_str) - 1 if indice_base_str else 0
364
+ html = formatar_avaliacao_html(session.avaliacoes_visualizacao, indice_base=indice, elem_id_excluir="excluir-aval-viz")
365
+ return {"resultado_html": html}
366
+
367
+
368
+ def exportar_avaliacoes(session: SessionState) -> str:
369
+ caminho = exportar_avaliacoes_excel(session.avaliacoes_visualizacao)
370
+ if not caminho:
371
+ raise HTTPException(status_code=400, detail="Sem avaliacoes para exportar")
372
+ return caminho
373
+
374
+
375
+ def limpar_tudo_visualizacao(session: SessionState) -> dict[str, Any]:
376
+ session.reset_visualizacao()
377
+ return {
378
+ "status": "",
379
+ "badge_html": "",
380
+ }
backend/requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.111.0
2
+ uvicorn[standard]>=0.30.0
3
+ python-multipart>=0.0.9
4
+ pydantic>=2.7.0
5
+ pandas
6
+ numpy
7
+ statsmodels
8
+ scipy
9
+ plotly
10
+ folium
11
+ branca
12
+ joblib
13
+ openpyxl
14
+ geopandas
15
+ fiona
16
+ gradio>=4.0
backend/run_backend.sh ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend/index.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="pt-BR">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>MESA Frame</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.jsx"></script>
11
+ </body>
12
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "mesa-frame-frontend",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "plotly.js-dist-min": "^2.35.2",
13
+ "react": "^18.3.1",
14
+ "react-dom": "^18.3.1",
15
+ "react-plotly.js": "^2.6.0"
16
+ },
17
+ "devDependencies": {
18
+ "@vitejs/plugin-react": "^4.3.1",
19
+ "vite": "^5.4.8"
20
+ }
21
+ }
frontend/public/logo_mesa.png ADDED

Git LFS Details

  • SHA256: 2f4481917b3f14c1deed9a0e55fe59f84ee9c9b6a3dd4e453543f1bf8b0a8614
  • Pointer size: 131 Bytes
  • Size of remote file: 237 kB
frontend/src/App.jsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react'
2
+ import { api } from './api'
3
+ import ElaboracaoTab from './components/ElaboracaoTab'
4
+ import VisualizacaoTab from './components/VisualizacaoTab'
5
+
6
+ const TABS = [
7
+ { key: 'Pesquisa', label: 'Pesquisa', hint: 'Módulo em desenvolvimento' },
8
+ { key: 'Elaboração/Edição', label: 'Elaboração/Edição', hint: 'Fluxo completo de modelagem' },
9
+ { key: 'Visualização/Avaliação', label: 'Visualização/Avaliação', hint: 'Leitura e avaliação de modelos .dai' },
10
+ ]
11
+
12
+ export default function App() {
13
+ const [activeTab, setActiveTab] = useState(TABS[0].key)
14
+ const [sessionId, setSessionId] = useState('')
15
+ const [bootError, setBootError] = useState('')
16
+
17
+ useEffect(() => {
18
+ let mounted = true
19
+
20
+ api.createSession()
21
+ .then((resp) => {
22
+ if (mounted) setSessionId(resp.session_id)
23
+ })
24
+ .catch((err) => {
25
+ if (mounted) setBootError(err.message)
26
+ })
27
+
28
+ return () => {
29
+ mounted = false
30
+ }
31
+ }, [])
32
+
33
+ return (
34
+ <div className="app-shell">
35
+ <header className="app-header app-header-logo-only">
36
+ <div className="brand-mark" aria-hidden="true">
37
+ <img src="/logo_mesa.png" alt="MESA" />
38
+ </div>
39
+ </header>
40
+
41
+ <nav className="tabs" aria-label="Navegação principal">
42
+ {TABS.map((tab) => {
43
+ const active = tab.key === activeTab
44
+ return (
45
+ <button
46
+ key={tab.key}
47
+ className={active ? 'tab-pill active' : 'tab-pill'}
48
+ onClick={() => setActiveTab(tab.key)}
49
+ type="button"
50
+ >
51
+ <strong>{tab.label}</strong>
52
+ <small>{tab.hint}</small>
53
+ </button>
54
+ )
55
+ })}
56
+ </nav>
57
+
58
+ {bootError ? <div className="error-line">Falha ao criar sessão: {bootError}</div> : null}
59
+
60
+ {activeTab === 'Pesquisa' ? (
61
+ <section className="workflow-section placeholder-section" style={{ '--section-order': 1 }}>
62
+ <header className="section-head">
63
+ <span className="section-index">1</span>
64
+ <div className="section-title-wrap">
65
+ <h3>Pesquisa</h3>
66
+ <p>Este módulo segue em desenvolvimento no app original e foi mantido com o mesmo status.</p>
67
+ </div>
68
+ </header>
69
+ <div className="section-body">
70
+ <div className="empty-box">Aba disponível para expansão futura.</div>
71
+ </div>
72
+ </section>
73
+ ) : null}
74
+
75
+ {activeTab === 'Elaboração/Edição' ? <ElaboracaoTab sessionId={sessionId} /> : null}
76
+ {activeTab === 'Visualização/Avaliação' ? <VisualizacaoTab sessionId={sessionId} /> : null}
77
+ </div>
78
+ )
79
+ }
frontend/src/api.js ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000'
2
+
3
+ async function handleResponse(response) {
4
+ if (!response.ok) {
5
+ let detail = 'Erro inesperado'
6
+ try {
7
+ const data = await response.json()
8
+ detail = data.detail || detail
9
+ } catch {
10
+ detail = response.statusText || detail
11
+ }
12
+ throw new Error(detail)
13
+ }
14
+ return response
15
+ }
16
+
17
+ async function postJson(path, body) {
18
+ const response = await fetch(`${API_BASE}${path}`, {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify(body),
22
+ })
23
+ await handleResponse(response)
24
+ return response.json()
25
+ }
26
+
27
+ async function postForm(path, formData) {
28
+ const response = await fetch(`${API_BASE}${path}`, {
29
+ method: 'POST',
30
+ body: formData,
31
+ })
32
+ await handleResponse(response)
33
+ return response.json()
34
+ }
35
+
36
+ async function getJson(path) {
37
+ const response = await fetch(`${API_BASE}${path}`)
38
+ await handleResponse(response)
39
+ return response.json()
40
+ }
41
+
42
+ async function getBlob(path) {
43
+ const response = await fetch(`${API_BASE}${path}`)
44
+ await handleResponse(response)
45
+ return response.blob()
46
+ }
47
+
48
+ export function downloadBlob(blob, fileName) {
49
+ const url = URL.createObjectURL(blob)
50
+ const a = document.createElement('a')
51
+ a.href = url
52
+ a.download = fileName
53
+ document.body.appendChild(a)
54
+ a.click()
55
+ a.remove()
56
+ URL.revokeObjectURL(url)
57
+ }
58
+
59
+ export const api = {
60
+ createSession: () => postJson('/api/sessions', {}),
61
+
62
+ uploadElaboracaoFile(sessionId, file) {
63
+ const form = new FormData()
64
+ form.append('session_id', sessionId)
65
+ form.append('file', file)
66
+ return postForm('/api/elaboracao/upload', form)
67
+ },
68
+
69
+ confirmSheet: (sessionId, sheetName) => postJson('/api/elaboracao/confirm-sheet', { session_id: sessionId, sheet_name: sheetName }),
70
+ mapCoords: (sessionId, colLat, colLon) => postJson('/api/elaboracao/map-coords', { session_id: sessionId, col_lat: colLat, col_lon: colLon }),
71
+ geocodificar: (sessionId, colCdlog, colNum, auto200) => postJson('/api/elaboracao/geocodificar', { session_id: sessionId, col_cdlog: colCdlog, col_num: colNum, auto_200: auto200 }),
72
+ geocodificarCorrecoes: (sessionId, correcoes, auto200) => postJson('/api/elaboracao/geocodificar-correcoes', { session_id: sessionId, correcoes, auto_200: auto200 }),
73
+
74
+ applySelection(payload) {
75
+ return postJson('/api/elaboracao/apply-selection', payload)
76
+ },
77
+ classifyElaboracaoX: (sessionId, colunasX) => postJson('/api/elaboracao/classify-x', { session_id: sessionId, colunas_x: colunasX }),
78
+
79
+ searchTransformations: (sessionId, grauCoef, grauF) => postJson('/api/elaboracao/search-transformations', {
80
+ session_id: sessionId,
81
+ grau_min_coef: grauCoef,
82
+ grau_min_f: grauF,
83
+ }),
84
+
85
+ adoptSuggestion: (sessionId, indice) => postJson('/api/elaboracao/adopt-suggestion', { session_id: sessionId, indice }),
86
+
87
+ fitModel(payload) {
88
+ return postJson('/api/elaboracao/fit-model', payload)
89
+ },
90
+
91
+ updateModelDispersao: (sessionId, tipo) => postJson('/api/elaboracao/model-dispersao', { session_id: sessionId, tipo }),
92
+
93
+ applyOutlierFilters: (sessionId, filtros) => postJson('/api/elaboracao/outliers/apply-filters', { session_id: sessionId, filtros }),
94
+ restartOutlierIteration: (sessionId, outliersTexto, reincluirTexto, grauCoef, grauF) => postJson('/api/elaboracao/outliers/restart', {
95
+ session_id: sessionId,
96
+ outliers_texto: outliersTexto,
97
+ reincluir_texto: reincluirTexto,
98
+ grau_min_coef: grauCoef,
99
+ grau_min_f: grauF,
100
+ }),
101
+ outlierSummary: (sessionId, outliersTexto, reincluirTexto) => postJson('/api/elaboracao/outliers/summary', {
102
+ session_id: sessionId,
103
+ outliers_texto: outliersTexto,
104
+ reincluir_texto: reincluirTexto,
105
+ }),
106
+ clearOutlierHistory: (sessionId) => postJson('/api/elaboracao/outliers/clear-history', { session_id: sessionId }),
107
+
108
+ evaluationFieldsElab: (sessionId) => postJson('/api/elaboracao/evaluation/fields', { session_id: sessionId }),
109
+ evaluationCalculateElab: (sessionId, valoresX, indiceBase) => postJson('/api/elaboracao/evaluation/calculate', {
110
+ session_id: sessionId,
111
+ valores_x: valoresX,
112
+ indice_base: indiceBase,
113
+ }),
114
+ evaluationClearElab: (sessionId) => postJson('/api/elaboracao/evaluation/clear', { session_id: sessionId }),
115
+ evaluationDeleteElab: (sessionId, indice, indiceBase) => postJson('/api/elaboracao/evaluation/delete', {
116
+ session_id: sessionId,
117
+ indice,
118
+ indice_base: indiceBase,
119
+ }),
120
+ getAvaliadores: () => getJson('/api/elaboracao/avaliadores'),
121
+ evaluationBaseElab: (sessionId, indiceBase) => postJson('/api/elaboracao/evaluation/base', {
122
+ session_id: sessionId,
123
+ indice_base: indiceBase,
124
+ }),
125
+ exportEvaluationElab: async (sessionId) => getBlob(`/api/elaboracao/evaluation/export?session_id=${encodeURIComponent(sessionId)}`),
126
+ exportModel: async (sessionId, nomeArquivo, elaborador) => {
127
+ const response = await fetch(`${API_BASE}/api/elaboracao/export-model`, {
128
+ method: 'POST',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ body: JSON.stringify({ session_id: sessionId, nome_arquivo: nomeArquivo, elaborador }),
131
+ })
132
+ await handleResponse(response)
133
+ return response.blob()
134
+ },
135
+ exportBase: (sessionId, filtered = true) => getBlob(`/api/elaboracao/export-base?session_id=${encodeURIComponent(sessionId)}&filtered=${String(filtered)}`),
136
+ updateElaboracaoMap: (sessionId, variavelMapa) => postJson('/api/elaboracao/map/update', { session_id: sessionId, variavel_mapa: variavelMapa }),
137
+ getContext: (sessionId) => getJson(`/api/elaboracao/context?session_id=${encodeURIComponent(sessionId)}`),
138
+
139
+ uploadVisualizacaoFile(sessionId, file) {
140
+ const form = new FormData()
141
+ form.append('session_id', sessionId)
142
+ form.append('file', file)
143
+ return postForm('/api/visualizacao/upload', form)
144
+ },
145
+ exibirVisualizacao: (sessionId) => postJson('/api/visualizacao/exibir', { session_id: sessionId }),
146
+ updateVisualizacaoMap: (sessionId, variavelMapa) => postJson('/api/visualizacao/map/update', { session_id: sessionId, variavel_mapa: variavelMapa }),
147
+ evaluationFieldsViz: (sessionId) => postJson('/api/visualizacao/evaluation/fields', { session_id: sessionId }),
148
+ evaluationCalculateViz: (sessionId, valoresX, indiceBase) => postJson('/api/visualizacao/evaluation/calculate', {
149
+ session_id: sessionId,
150
+ valores_x: valoresX,
151
+ indice_base: indiceBase,
152
+ }),
153
+ evaluationClearViz: (sessionId) => postJson('/api/visualizacao/evaluation/clear', { session_id: sessionId }),
154
+ evaluationDeleteViz: (sessionId, indice, indiceBase) => postJson('/api/visualizacao/evaluation/delete', {
155
+ session_id: sessionId,
156
+ indice,
157
+ indice_base: indiceBase,
158
+ }),
159
+ evaluationBaseViz: (sessionId, indiceBase) => postJson('/api/visualizacao/evaluation/base', {
160
+ session_id: sessionId,
161
+ indice_base: indiceBase,
162
+ }),
163
+ exportEvaluationViz: (sessionId) => getBlob(`/api/visualizacao/evaluation/export?session_id=${encodeURIComponent(sessionId)}`),
164
+ clearVisualizacao: (sessionId) => postJson('/api/visualizacao/clear', { session_id: sessionId }),
165
+ }
frontend/src/components/DataTable.jsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+
3
+ export default function DataTable({ table, maxHeight = 320 }) {
4
+ if (!table || !table.columns || !table.rows) {
5
+ return <div className="empty-box">Sem dados.</div>
6
+ }
7
+
8
+ return (
9
+ <div className="table-wrapper" style={{ maxHeight }}>
10
+ <table>
11
+ <thead>
12
+ <tr>
13
+ {table.columns.map((col) => (
14
+ <th key={col}>{col}</th>
15
+ ))}
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ {table.rows.map((row, i) => (
20
+ <tr key={i}>
21
+ {table.columns.map((col) => (
22
+ <td key={`${i}-${col}`}>{String(row[col] ?? '')}</td>
23
+ ))}
24
+ </tr>
25
+ ))}
26
+ </tbody>
27
+ </table>
28
+ {table.truncated ? (
29
+ <div className="table-hint">Mostrando {table.returned_rows} de {table.total_rows} linhas.</div>
30
+ ) : null}
31
+ </div>
32
+ )
33
+ }
frontend/src/components/ElaboracaoTab.jsx ADDED
@@ -0,0 +1,1331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { api, downloadBlob } from '../api'
3
+ import DataTable from './DataTable'
4
+ import MapFrame from './MapFrame'
5
+ import PlotFigure from './PlotFigure'
6
+ import SectionBlock from './SectionBlock'
7
+
8
+ const OPERADORES = ['<=', '>=', '<', '>', '=']
9
+ const GRAUS_COEF = [
10
+ { value: 0, label: 'Sem enquadramento' },
11
+ { value: 1, label: 'Grau I (p<=30%)' },
12
+ { value: 2, label: 'Grau II (p<=20%)' },
13
+ { value: 3, label: 'Grau III (p<=10%)' },
14
+ ]
15
+ const GRAUS_F = [
16
+ { value: 0, label: 'Sem enquadramento' },
17
+ { value: 1, label: 'Grau I (alpha=5%)' },
18
+ { value: 2, label: 'Grau II (alpha=2%)' },
19
+ { value: 3, label: 'Grau III (alpha=1%)' },
20
+ ]
21
+ const GRAU_LABEL_CURTO = {
22
+ 0: 'Sem enq.',
23
+ 1: 'Grau I',
24
+ 2: 'Grau II',
25
+ 3: 'Grau III',
26
+ }
27
+
28
+ function grauBadgeClass(value) {
29
+ const grau = Number(value)
30
+ if (grau >= 3) return 'grau-badge grau-3'
31
+ if (grau === 2) return 'grau-badge grau-2'
32
+ if (grau === 1) return 'grau-badge grau-1'
33
+ return 'grau-badge grau-0'
34
+ }
35
+
36
+ function defaultFiltros() {
37
+ return [
38
+ { variavel: 'Resíduo Pad.', operador: '<=', valor: -2 },
39
+ { variavel: 'Resíduo Pad.', operador: '>=', valor: 2 },
40
+ ]
41
+ }
42
+
43
+ function joinSelection(values) {
44
+ return values.join(', ')
45
+ }
46
+
47
+ function formatConselhoRegistro(elaborador) {
48
+ if (!elaborador) return ''
49
+ const conselho = String(elaborador.conselho || '').trim()
50
+ const estado = String(elaborador.estado_conselho || '').trim()
51
+ const numero = String(elaborador.numero_conselho || '').trim()
52
+ if (conselho && estado && numero) return `${conselho}/${estado} ${numero}`
53
+ return [conselho, numero, estado].filter(Boolean).join(' ')
54
+ }
55
+
56
+ function formatTransformacaoBadge(transformacao) {
57
+ const valor = String(transformacao || '').trim()
58
+ if (!valor || valor === '(x)' || valor === '(y)' || valor === 'x' || valor === 'y') return ''
59
+ return valor
60
+ }
61
+
62
+ export default function ElaboracaoTab({ sessionId }) {
63
+ const [loading, setLoading] = useState(false)
64
+ const [error, setError] = useState('')
65
+ const [status, setStatus] = useState('')
66
+
67
+ const [uploadedFile, setUploadedFile] = useState(null)
68
+ const [requiresSheet, setRequiresSheet] = useState(false)
69
+ const [sheetOptions, setSheetOptions] = useState([])
70
+ const [selectedSheet, setSelectedSheet] = useState('')
71
+ const [elaborador, setElaborador] = useState(null)
72
+
73
+ const [dados, setDados] = useState(null)
74
+ const [mapaHtml, setMapaHtml] = useState('')
75
+ const [mapaVariavel, setMapaVariavel] = useState('Visualização Padrão')
76
+
77
+ const [coordsInfo, setCoordsInfo] = useState(null)
78
+ const [manualLat, setManualLat] = useState('')
79
+ const [manualLon, setManualLon] = useState('')
80
+ const [geoCdlog, setGeoCdlog] = useState('')
81
+ const [geoNum, setGeoNum] = useState('')
82
+ const [geoAuto200, setGeoAuto200] = useState(false)
83
+ const [geoStatusHtml, setGeoStatusHtml] = useState('')
84
+ const [geoFalhasHtml, setGeoFalhasHtml] = useState('')
85
+ const [geoCorrecoes, setGeoCorrecoes] = useState([])
86
+ const [manualMapError, setManualMapError] = useState('')
87
+ const [geoProcessError, setGeoProcessError] = useState('')
88
+ const [coordsMode, setCoordsMode] = useState('menu')
89
+
90
+ const [colunasNumericas, setColunasNumericas] = useState([])
91
+ const [colunaY, setColunaY] = useState('')
92
+ const [colunasX, setColunasX] = useState([])
93
+ const [dicotomicas, setDicotomicas] = useState([])
94
+ const [codigoAlocado, setCodigoAlocado] = useState([])
95
+ const [percentuais, setPercentuais] = useState([])
96
+
97
+ const [outliersAnteriores, setOutliersAnteriores] = useState([])
98
+ const [iteracao, setIteracao] = useState(1)
99
+
100
+ const [selection, setSelection] = useState(null)
101
+ const [grauCoef, setGrauCoef] = useState(1)
102
+ const [grauF, setGrauF] = useState(0)
103
+
104
+ const [transformacaoY, setTransformacaoY] = useState('(x)')
105
+ const [transformacoesX, setTransformacoesX] = useState({})
106
+
107
+ const [fit, setFit] = useState(null)
108
+ const [tipoDispersao, setTipoDispersao] = useState('Variáveis Independentes Transformadas X Variável Dependente Transformada')
109
+
110
+ const [filtros, setFiltros] = useState(defaultFiltros())
111
+ const [outliersTexto, setOutliersTexto] = useState('')
112
+ const [reincluirTexto, setReincluirTexto] = useState('')
113
+ const [resumoOutliers, setResumoOutliers] = useState('Excluidos: 0 | A excluir: 0 | A reincluir: 0 | Total: 0')
114
+ const [outliersHtml, setOutliersHtml] = useState('')
115
+
116
+ const [camposAvaliacao, setCamposAvaliacao] = useState([])
117
+ const [valoresAvaliacao, setValoresAvaliacao] = useState({})
118
+ const [resultadoAvaliacaoHtml, setResultadoAvaliacaoHtml] = useState('')
119
+ const [baseChoices, setBaseChoices] = useState([])
120
+ const [baseValue, setBaseValue] = useState('')
121
+ const [deleteAvalIndex, setDeleteAvalIndex] = useState('')
122
+
123
+ const [nomeArquivoExport, setNomeArquivoExport] = useState('modelo_mesa')
124
+ const [avaliadores, setAvaliadores] = useState([])
125
+ const [avaliadorSelecionado, setAvaliadorSelecionado] = useState('')
126
+ const [tipoFonteDados, setTipoFonteDados] = useState('')
127
+ const marcarTodasXRef = useRef(null)
128
+ const classificarXReqRef = useRef(0)
129
+
130
+ const mapaChoices = useMemo(() => ['Visualização Padrão', ...colunasNumericas], [colunasNumericas])
131
+ const colunasXDisponiveis = useMemo(
132
+ () => colunasNumericas.filter((coluna) => coluna !== colunaY),
133
+ [colunasNumericas, colunaY],
134
+ )
135
+ const todasXMarcadas = useMemo(
136
+ () => colunasXDisponiveis.length > 0 && colunasXDisponiveis.every((coluna) => colunasX.includes(coluna)),
137
+ [colunasXDisponiveis, colunasX],
138
+ )
139
+ const algumaXMarcada = useMemo(
140
+ () => colunasX.some((coluna) => colunasXDisponiveis.includes(coluna)),
141
+ [colunasX, colunasXDisponiveis],
142
+ )
143
+ const conselhoRegistro = useMemo(() => formatConselhoRegistro(elaborador), [elaborador])
144
+ const elaboradorMeta = useMemo(() => {
145
+ if (!elaborador) return []
146
+ return [elaborador.cargo, conselhoRegistro, elaborador.matricula_sem_digito ? `Matricula ${elaborador.matricula_sem_digito}` : '', elaborador.lotacao].filter(Boolean)
147
+ }, [elaborador, conselhoRegistro])
148
+ const transformacaoYBadge = useMemo(() => formatTransformacaoBadge(transformacaoY), [transformacaoY])
149
+ const variaveisIndependentesBadge = useMemo(() => {
150
+ return colunasX.map((coluna) => ({
151
+ coluna,
152
+ transformacao: formatTransformacaoBadge(transformacoesX[coluna]),
153
+ }))
154
+ }, [colunasX, transformacoesX])
155
+ const showCoordsPanel = Boolean(
156
+ coordsInfo && (
157
+ !coordsInfo.tem_coords ||
158
+ coordsMode === 'geocodificar' ||
159
+ geoStatusHtml ||
160
+ geoFalhasHtml ||
161
+ geoCorrecoes.length > 0 ||
162
+ manualMapError ||
163
+ geoProcessError
164
+ ),
165
+ )
166
+
167
+ useEffect(() => {
168
+ if (coordsInfo && !coordsInfo.tem_coords) {
169
+ setCoordsMode('menu')
170
+ }
171
+ }, [coordsInfo])
172
+
173
+ useEffect(() => {
174
+ if (marcarTodasXRef.current) {
175
+ marcarTodasXRef.current.indeterminate = algumaXMarcada && !todasXMarcadas
176
+ }
177
+ }, [algumaXMarcada, todasXMarcadas])
178
+
179
+ useEffect(() => {
180
+ if (!sessionId || tipoFonteDados === 'dai') return undefined
181
+
182
+ const selecionadas = colunasXDisponiveis.filter((coluna) => colunasX.includes(coluna))
183
+ if (selecionadas.length === 0) {
184
+ setDicotomicas([])
185
+ setCodigoAlocado([])
186
+ setPercentuais([])
187
+ return undefined
188
+ }
189
+
190
+ classificarXReqRef.current += 1
191
+ const requestId = classificarXReqRef.current
192
+ let ativo = true
193
+
194
+ api.classifyElaboracaoX(sessionId, selecionadas)
195
+ .then((resp) => {
196
+ if (!ativo || requestId !== classificarXReqRef.current) return
197
+ setDicotomicas(resp.dicotomicas || [])
198
+ setCodigoAlocado(resp.codigo_alocado || [])
199
+ setPercentuais(resp.percentuais || [])
200
+ })
201
+ .catch(() => {
202
+ if (!ativo || requestId !== classificarXReqRef.current) return
203
+ setDicotomicas((prev) => prev.filter((item) => selecionadas.includes(item)))
204
+ setCodigoAlocado((prev) => prev.filter((item) => selecionadas.includes(item)))
205
+ setPercentuais((prev) => prev.filter((item) => selecionadas.includes(item)))
206
+ })
207
+
208
+ return () => {
209
+ ativo = false
210
+ }
211
+ }, [sessionId, tipoFonteDados, colunasX, colunasXDisponiveis])
212
+
213
+ useEffect(() => {
214
+ let ativo = true
215
+ if (!sessionId) return () => {
216
+ ativo = false
217
+ }
218
+
219
+ async function carregarAvaliadores() {
220
+ try {
221
+ const resp = await api.getAvaliadores()
222
+ if (!ativo) return
223
+ setAvaliadores(resp.avaliadores || [])
224
+ } catch {
225
+ if (!ativo) return
226
+ setAvaliadores([])
227
+ }
228
+ }
229
+
230
+ carregarAvaliadores()
231
+ return () => {
232
+ ativo = false
233
+ }
234
+ }, [sessionId])
235
+
236
+ async function withBusy(fn) {
237
+ setLoading(true)
238
+ setError('')
239
+ try {
240
+ await fn()
241
+ } catch (err) {
242
+ setError(err.message)
243
+ } finally {
244
+ setLoading(false)
245
+ }
246
+ }
247
+
248
+ function parseCorrecoes(table) {
249
+ if (!table?.rows) return []
250
+ return table.rows.map((row) => {
251
+ const linha = row['Nº Linha'] ?? row['No Linha'] ?? row['linha'] ?? row['_index']
252
+ return {
253
+ linha: Number(linha),
254
+ numero_corrigido: row['Nº Corrigido'] ?? row['No Corrigido'] ?? '',
255
+ }
256
+ })
257
+ }
258
+
259
+ function applyBaseResponse(resp, options = {}) {
260
+ const resetXSelection = Boolean(options.resetXSelection)
261
+ if (resp.status) setStatus(resp.status)
262
+ if (resp.dados) setDados(resp.dados)
263
+ if (resp.mapa_html) setMapaHtml(resp.mapa_html)
264
+ if (resp.colunas_numericas) setColunasNumericas(resp.colunas_numericas)
265
+ if (resp.coluna_y_padrao) setColunaY(resp.coluna_y_padrao)
266
+
267
+ if (resp.contexto && !resetXSelection) {
268
+ setColunaY(resp.contexto.coluna_y || resp.coluna_y_padrao || '')
269
+ setColunasX(resp.contexto.colunas_x || [])
270
+ setDicotomicas(resp.contexto.dicotomicas || [])
271
+ setCodigoAlocado(resp.contexto.codigo_alocado || [])
272
+ setPercentuais(resp.contexto.percentuais || [])
273
+ setOutliersAnteriores(resp.contexto.outliers_anteriores || [])
274
+ setIteracao(resp.contexto.iteracao || 1)
275
+ } else if (resetXSelection) {
276
+ setColunasX([])
277
+ setDicotomicas([])
278
+ setCodigoAlocado([])
279
+ setPercentuais([])
280
+ setTransformacaoY('(x)')
281
+ setTransformacoesX({})
282
+ setOutliersAnteriores([])
283
+ setIteracao(1)
284
+ setSelection(null)
285
+ setFit(null)
286
+ setCamposAvaliacao([])
287
+ setResultadoAvaliacaoHtml('')
288
+ setOutliersHtml('')
289
+ }
290
+
291
+ if (resp.coords) {
292
+ setCoordsInfo(resp.coords)
293
+ setManualLat(resp.coords.colunas_disponiveis?.[0] || '')
294
+ setManualLon(resp.coords.colunas_disponiveis?.[1] || '')
295
+ setGeoCdlog(resp.coords.cdlog_auto || '')
296
+ setGeoNum(resp.coords.num_auto || '')
297
+ }
298
+
299
+ if (resp.selection && !resetXSelection) {
300
+ applySelectionResponse(resp.selection)
301
+ }
302
+
303
+ if (resp.fit && !resetXSelection) {
304
+ applyFitResponse(resp.fit)
305
+ }
306
+ }
307
+
308
+ function applySelectionResponse(resp) {
309
+ setSelection(resp)
310
+ if (resp.transformacao_y) {
311
+ setTransformacaoY(resp.transformacao_y)
312
+ }
313
+
314
+ const map = {}
315
+ ;(resp.transform_fields || []).forEach((field) => {
316
+ map[field.coluna] = field.valor || '(x)'
317
+ })
318
+ setTransformacoesX(map)
319
+
320
+ if (resp.busca) {
321
+ setGrauCoef(resp.busca.grau_coef ?? 1)
322
+ setGrauF(resp.busca.grau_f ?? 0)
323
+ }
324
+
325
+ if (resp.resumo_outliers) {
326
+ setResumoOutliers(resp.resumo_outliers)
327
+ }
328
+ if (typeof resp.outliers_html !== 'undefined') {
329
+ setOutliersHtml(resp.outliers_html || '')
330
+ }
331
+
332
+ if (resp.mapa_html) {
333
+ setMapaHtml(resp.mapa_html)
334
+ }
335
+ }
336
+
337
+ function applyFitResponse(resp) {
338
+ setFit(resp)
339
+ setResumoOutliers(resp.resumo_outliers || resumoOutliers)
340
+ setCamposAvaliacao(resp.avaliacao_campos || [])
341
+ const init = {}
342
+ ;(resp.avaliacao_campos || []).forEach((campo) => {
343
+ init[campo.coluna] = ''
344
+ })
345
+ setValoresAvaliacao(init)
346
+ setResultadoAvaliacaoHtml('')
347
+ setBaseChoices([])
348
+ setBaseValue('')
349
+ }
350
+
351
+ async function onUploadClick() {
352
+ if (!uploadedFile || !sessionId) return
353
+ await withBusy(async () => {
354
+ const nomeArquivo = String(uploadedFile?.name || '').toLowerCase()
355
+ const uploadEhDai = nomeArquivo.endsWith('.dai')
356
+ setTipoFonteDados(uploadEhDai ? 'dai' : 'tabular')
357
+ const resp = await api.uploadElaboracaoFile(sessionId, uploadedFile)
358
+ setManualMapError('')
359
+ setGeoProcessError('')
360
+ setGeoStatusHtml('')
361
+ setGeoFalhasHtml('')
362
+ setGeoCorrecoes([])
363
+ setElaborador(resp.elaborador || null)
364
+ setAvaliadorSelecionado(resp.elaborador?.nome_completo || '')
365
+ setRequiresSheet(Boolean(resp.requires_sheet))
366
+ setSheetOptions(resp.sheets || [])
367
+ setSelectedSheet(resp.sheet_selected || '')
368
+ if (!resp.requires_sheet) {
369
+ const origemResp = String(resp.tipo || '').toLowerCase()
370
+ setTipoFonteDados(origemResp === 'dai' ? 'dai' : 'tabular')
371
+ const resetXSelection = String(resp.tipo || '').toLowerCase() !== 'dai'
372
+ applyBaseResponse(resp, { resetXSelection })
373
+ } else if (resp.status) {
374
+ setTipoFonteDados('tabular')
375
+ setSelection(null)
376
+ setFit(null)
377
+ setColunasX([])
378
+ setDicotomicas([])
379
+ setCodigoAlocado([])
380
+ setPercentuais([])
381
+ setTransformacaoY('(x)')
382
+ setTransformacoesX({})
383
+ setCamposAvaliacao([])
384
+ setResultadoAvaliacaoHtml('')
385
+ setStatus(resp.status)
386
+ }
387
+ })
388
+ }
389
+
390
+ async function onConfirmSheet() {
391
+ if (!selectedSheet || !sessionId) return
392
+ await withBusy(async () => {
393
+ const resp = await api.confirmSheet(sessionId, selectedSheet)
394
+ setTipoFonteDados('tabular')
395
+ setManualMapError('')
396
+ setGeoProcessError('')
397
+ setGeoStatusHtml('')
398
+ setGeoFalhasHtml('')
399
+ setGeoCorrecoes([])
400
+ setElaborador(resp.elaborador || null)
401
+ setAvaliadorSelecionado(resp.elaborador?.nome_completo || '')
402
+ setRequiresSheet(false)
403
+ applyBaseResponse(resp, { resetXSelection: true })
404
+ })
405
+ }
406
+
407
+ async function onMapCoords() {
408
+ if (!manualLat || !manualLon || !sessionId) return
409
+ setLoading(true)
410
+ setError('')
411
+ setManualMapError('')
412
+ setGeoProcessError('')
413
+ try {
414
+ const resp = await api.mapCoords(sessionId, manualLat, manualLon)
415
+ setStatus(resp.status)
416
+ setMapaHtml(resp.mapa_html)
417
+ setDados(resp.dados)
418
+ setCoordsInfo(resp.coords)
419
+ setGeoStatusHtml('')
420
+ setGeoFalhasHtml('')
421
+ setGeoCorrecoes([])
422
+ } catch (err) {
423
+ setManualMapError(err.message || 'Falha ao mapear coordenadas.')
424
+ } finally {
425
+ setLoading(false)
426
+ }
427
+ }
428
+
429
+ async function onGeocodificar() {
430
+ if (!geoCdlog || !geoNum || !sessionId) return
431
+ setLoading(true)
432
+ setError('')
433
+ setManualMapError('')
434
+ setGeoProcessError('')
435
+ try {
436
+ const resp = await api.geocodificar(sessionId, geoCdlog, geoNum, geoAuto200)
437
+ setGeoStatusHtml(resp.status_html || '')
438
+ setGeoFalhasHtml(resp.falhas_html || '')
439
+ setGeoCorrecoes(parseCorrecoes(resp.falhas_para_correcao))
440
+ setMapaHtml(resp.mapa_html || '')
441
+ setDados(resp.dados || null)
442
+ setCoordsInfo(resp.coords || null)
443
+ setCoordsMode('geocodificar')
444
+ } catch (err) {
445
+ setGeoProcessError(err.message || 'Falha ao geocodificar.')
446
+ } finally {
447
+ setLoading(false)
448
+ }
449
+ }
450
+
451
+ async function onAplicarCorrecoesGeo() {
452
+ if (!sessionId) return
453
+ setLoading(true)
454
+ setError('')
455
+ setManualMapError('')
456
+ setGeoProcessError('')
457
+ try {
458
+ const resp = await api.geocodificarCorrecoes(sessionId, geoCorrecoes, geoAuto200)
459
+ setGeoStatusHtml(resp.status_html || '')
460
+ setGeoFalhasHtml(resp.falhas_html || '')
461
+ setGeoCorrecoes(parseCorrecoes(resp.falhas_para_correcao))
462
+ setMapaHtml(resp.mapa_html || '')
463
+ setDados(resp.dados || null)
464
+ setCoordsInfo(resp.coords || null)
465
+ setCoordsMode('geocodificar')
466
+ } catch (err) {
467
+ setGeoProcessError(err.message || 'Falha ao aplicar correções.')
468
+ } finally {
469
+ setLoading(false)
470
+ }
471
+ }
472
+
473
+ async function onApplySelection() {
474
+ if (!sessionId || !colunaY || colunasX.length === 0) return
475
+ await withBusy(async () => {
476
+ const resp = await api.applySelection({
477
+ session_id: sessionId,
478
+ coluna_y: colunaY,
479
+ colunas_x: colunasX,
480
+ dicotomicas: dicotomicas,
481
+ codigo_alocado: codigoAlocado,
482
+ percentuais: percentuais,
483
+ outliers_anteriores: outliersAnteriores,
484
+ grau_min_coef: grauCoef,
485
+ grau_min_f: grauF,
486
+ })
487
+ applySelectionResponse(resp)
488
+ setFit(null)
489
+ setCamposAvaliacao([])
490
+ setResultadoAvaliacaoHtml('')
491
+ })
492
+ }
493
+
494
+ async function onSearchTransform() {
495
+ if (!sessionId) return
496
+ await withBusy(async () => {
497
+ const busca = await api.searchTransformations(sessionId, grauCoef, grauF)
498
+ setSelection((prev) => ({ ...prev, busca }))
499
+ setGrauCoef(busca.grau_coef ?? grauCoef)
500
+ setGrauF(busca.grau_f ?? grauF)
501
+ })
502
+ }
503
+
504
+ async function onAdoptSuggestion(idx) {
505
+ if (!sessionId) return
506
+ await withBusy(async () => {
507
+ const resp = await api.adoptSuggestion(sessionId, idx)
508
+ setTransformacaoY(resp.transformacao_y || '(x)')
509
+ setTransformacoesX(resp.transformacoes_x || {})
510
+ })
511
+ }
512
+
513
+ async function onFitModel() {
514
+ if (!sessionId) return
515
+ await withBusy(async () => {
516
+ const resp = await api.fitModel({
517
+ session_id: sessionId,
518
+ transformacao_y: transformacaoY,
519
+ transformacoes_x: transformacoesX,
520
+ dicotomicas,
521
+ codigo_alocado: codigoAlocado,
522
+ percentuais,
523
+ })
524
+ applyFitResponse(resp)
525
+ })
526
+ }
527
+
528
+ async function onTipoDispersaoChange(nextTipo) {
529
+ setTipoDispersao(nextTipo)
530
+ if (!sessionId) return
531
+ await withBusy(async () => {
532
+ const resp = await api.updateModelDispersao(sessionId, nextTipo)
533
+ setFit((prev) => ({ ...prev, grafico_dispersao_modelo: resp.grafico }))
534
+ })
535
+ }
536
+
537
+ async function onApplyOutlierFilters() {
538
+ if (!sessionId) return
539
+ await withBusy(async () => {
540
+ const filtrosValidos = filtros.filter((f) => f.variavel && f.operador)
541
+ const resp = await api.applyOutlierFilters(sessionId, filtrosValidos)
542
+ setOutliersTexto(resp.texto || '')
543
+ })
544
+ }
545
+
546
+ async function onSummaryOutliers() {
547
+ if (!sessionId) return
548
+ await withBusy(async () => {
549
+ const resp = await api.outlierSummary(sessionId, outliersTexto, reincluirTexto)
550
+ setResumoOutliers(resp.resumo)
551
+ })
552
+ }
553
+
554
+ async function onRestartIteration() {
555
+ if (!sessionId) return
556
+ await withBusy(async () => {
557
+ const resp = await api.restartOutlierIteration(sessionId, outliersTexto, reincluirTexto, grauCoef, grauF)
558
+ applySelectionResponse(resp)
559
+ setOutliersAnteriores(resp.outliers_anteriores || [])
560
+ setIteracao(resp.iteracao || iteracao)
561
+ setOutliersTexto('')
562
+ setReincluirTexto('')
563
+ setFit(null)
564
+ setResultadoAvaliacaoHtml('')
565
+ setResumoOutliers(resp.resumo_outliers || resumoOutliers)
566
+ if (typeof window !== 'undefined') {
567
+ window.scrollTo({ top: 0, behavior: 'smooth' })
568
+ }
569
+ })
570
+ }
571
+
572
+ async function onClearHistory() {
573
+ if (!sessionId) return
574
+ await withBusy(async () => {
575
+ const resp = await api.clearOutlierHistory(sessionId)
576
+ setDados(resp.dados)
577
+ setMapaHtml(resp.mapa_html)
578
+ setResumoOutliers(resp.resumo_outliers || 'Excluidos: 0 | A excluir: 0 | A reincluir: 0 | Total: 0')
579
+ setOutliersAnteriores([])
580
+ setIteracao(1)
581
+ setSelection(null)
582
+ setFit(null)
583
+ setCamposAvaliacao([])
584
+ setResultadoAvaliacaoHtml('')
585
+ setOutliersHtml(resp.outliers_html || '')
586
+ })
587
+ }
588
+
589
+ async function onCalculateAvaliacao() {
590
+ if (!sessionId) return
591
+ await withBusy(async () => {
592
+ const resp = await api.evaluationCalculateElab(sessionId, valoresAvaliacao, baseValue || null)
593
+ setResultadoAvaliacaoHtml(resp.resultado_html || '')
594
+ setBaseChoices(resp.base_choices || [])
595
+ setBaseValue(resp.base_value || '')
596
+ })
597
+ }
598
+
599
+ async function onClearAvaliacao() {
600
+ if (!sessionId) return
601
+ await withBusy(async () => {
602
+ const resp = await api.evaluationClearElab(sessionId)
603
+ setResultadoAvaliacaoHtml(resp.resultado_html || '')
604
+ setBaseChoices(resp.base_choices || [])
605
+ setBaseValue(resp.base_value || '')
606
+ })
607
+ }
608
+
609
+ async function onDeleteAvaliacao() {
610
+ if (!sessionId) return
611
+ await withBusy(async () => {
612
+ const resp = await api.evaluationDeleteElab(sessionId, deleteAvalIndex, baseValue || null)
613
+ setResultadoAvaliacaoHtml(resp.resultado_html || '')
614
+ setBaseChoices(resp.base_choices || [])
615
+ setBaseValue(resp.base_value || '')
616
+ setDeleteAvalIndex('')
617
+ })
618
+ }
619
+
620
+ async function onBaseChange(value) {
621
+ setBaseValue(value)
622
+ if (!sessionId) return
623
+ await withBusy(async () => {
624
+ const resp = await api.evaluationBaseElab(sessionId, value)
625
+ setResultadoAvaliacaoHtml(resp.resultado_html || '')
626
+ })
627
+ }
628
+
629
+ async function onExportAvaliacoes() {
630
+ if (!sessionId) return
631
+ await withBusy(async () => {
632
+ const blob = await api.exportEvaluationElab(sessionId)
633
+ downloadBlob(blob, 'avaliacoes_mesa.xlsx')
634
+ })
635
+ }
636
+
637
+ async function onExportModel() {
638
+ if (!sessionId || !nomeArquivoExport) return
639
+ await withBusy(async () => {
640
+ const avaliadorEscolhido = avaliadores.find((item) => item.nome_completo === avaliadorSelecionado) || null
641
+ const blob = await api.exportModel(sessionId, nomeArquivoExport, avaliadorEscolhido || elaborador || null)
642
+ downloadBlob(blob, `${nomeArquivoExport.replace(/\.dai$/i, '')}.dai`)
643
+ })
644
+ }
645
+
646
+ async function onExportBase() {
647
+ if (!sessionId) return
648
+ await withBusy(async () => {
649
+ const blob = await api.exportBase(sessionId, true)
650
+ downloadBlob(blob, 'base_tratada.csv')
651
+ })
652
+ }
653
+
654
+ async function onMapVarChange(value) {
655
+ setMapaVariavel(value)
656
+ if (!sessionId) return
657
+ await withBusy(async () => {
658
+ const resp = await api.updateElaboracaoMap(sessionId, value)
659
+ setMapaHtml(resp.mapa_html || '')
660
+ })
661
+ }
662
+
663
+ function toggleSelection(setter, value) {
664
+ setter((prev) => {
665
+ if (prev.includes(value)) return prev.filter((item) => item !== value)
666
+ return [...prev, value]
667
+ })
668
+ }
669
+
670
+ function onToggleColunaX(coluna) {
671
+ const novaLista = colunasX.includes(coluna)
672
+ ? colunasX.filter((item) => item !== coluna)
673
+ : [...colunasX, coluna]
674
+ setColunasX(novaLista)
675
+ setDicotomicas((prev) => prev.filter((item) => novaLista.includes(item)))
676
+ setCodigoAlocado((prev) => prev.filter((item) => novaLista.includes(item)))
677
+ setPercentuais((prev) => prev.filter((item) => novaLista.includes(item)))
678
+ }
679
+
680
+ function onToggleTodasX(event) {
681
+ const checked = event.target.checked
682
+ if (checked) {
683
+ setColunasX(colunasXDisponiveis)
684
+ setDicotomicas((prev) => prev.filter((item) => colunasXDisponiveis.includes(item)))
685
+ setCodigoAlocado((prev) => prev.filter((item) => colunasXDisponiveis.includes(item)))
686
+ setPercentuais((prev) => prev.filter((item) => colunasXDisponiveis.includes(item)))
687
+ return
688
+ }
689
+ setColunasX([])
690
+ setDicotomicas([])
691
+ setCodigoAlocado([])
692
+ setPercentuais([])
693
+ }
694
+
695
+ return (
696
+ <div className="tab-content">
697
+ {status ? <div className="status-line">{status}</div> : null}
698
+
699
+ <SectionBlock step="1" title="Importar Dados" subtitle="Upload de CSV, Excel ou .dai com recuperação do fluxo.">
700
+ <div className="section1-groups">
701
+ <div className="subpanel section1-group">
702
+ <h4>Carregar modelo</h4>
703
+ <div className="row">
704
+ <input type="file" onChange={(e) => setUploadedFile(e.target.files?.[0] ?? null)} />
705
+ <button onClick={onUploadClick} disabled={!uploadedFile || loading || !sessionId}>Carregar arquivo</button>
706
+ </div>
707
+
708
+ {requiresSheet ? (
709
+ <div className="row">
710
+ <select value={selectedSheet} onChange={(e) => setSelectedSheet(e.target.value)}>
711
+ {sheetOptions.map((sheet) => (
712
+ <option key={sheet} value={sheet}>{sheet}</option>
713
+ ))}
714
+ </select>
715
+ <button onClick={onConfirmSheet} disabled={loading || !selectedSheet}>Confirmar aba</button>
716
+ </div>
717
+ ) : null}
718
+ </div>
719
+
720
+ <div className="subpanel section1-group">
721
+ <h4>Informações do modelo</h4>
722
+ {elaborador?.nome_completo ? (
723
+ <div className="modelo-cabecalho-grid">
724
+ <div className="elaborador-badge">
725
+ <div className="elaborador-badge-title">Modelo elaborado por:</div>
726
+ <div className="elaborador-badge-name">{elaborador.nome_completo}</div>
727
+ {elaboradorMeta.length > 0 ? (
728
+ <div className="elaborador-badge-meta">{elaboradorMeta.join(' | ')}</div>
729
+ ) : null}
730
+ </div>
731
+
732
+ {colunaY || variaveisIndependentesBadge.length > 0 ? (
733
+ <div className="modelo-variaveis-box">
734
+ <div className="elaborador-badge-title">Variáveis do modelo carregado</div>
735
+ {colunaY ? (
736
+ <div className="variavel-badge-line">
737
+ <span className="variavel-badge-label">Dependente:</span>
738
+ <span className="variavel-chip variavel-chip-y">
739
+ {colunaY}
740
+ {transformacaoYBadge ? <span className="variavel-chip-transform">{` ${transformacaoYBadge}`}</span> : null}
741
+ </span>
742
+ </div>
743
+ ) : null}
744
+ {variaveisIndependentesBadge.length > 0 ? (
745
+ <div className="variavel-badge-line">
746
+ <span className="variavel-badge-label">Independentes:</span>
747
+ <div className="variavel-chip-wrap">
748
+ {variaveisIndependentesBadge.map((item) => (
749
+ <span key={`ind-${item.coluna}`} className="variavel-chip">
750
+ {item.coluna}
751
+ {item.transformacao ? <span className="variavel-chip-transform">{` ${item.transformacao}`}</span> : null}
752
+ </span>
753
+ ))}
754
+ </div>
755
+ </div>
756
+ ) : null}
757
+ </div>
758
+ ) : null}
759
+ </div>
760
+ ) : (
761
+ <div className="section1-empty-hint">Carregue um modelo .dai para visualizar os badges do elaborador.</div>
762
+ )}
763
+ </div>
764
+
765
+ <div className="subpanel section1-group">
766
+ <h4>Resolver coordenadas</h4>
767
+ {showCoordsPanel && coordsMode !== 'skipped' ? (
768
+ <>
769
+ {coordsInfo?.aviso_html ? <div dangerouslySetInnerHTML={{ __html: coordsInfo.aviso_html }} /> : null}
770
+
771
+ {coordsMode === 'menu' ? (
772
+ <div className="coords-choice-row">
773
+ <button
774
+ onClick={() => {
775
+ setManualMapError('')
776
+ setGeoProcessError('')
777
+ setCoordsMode('mapear')
778
+ }}
779
+ disabled={loading}
780
+ >
781
+ Mapear colunas existentes para lat/lon
782
+ </button>
783
+ <span className="coords-choice-separator">ou</span>
784
+ <button
785
+ onClick={() => {
786
+ setManualMapError('')
787
+ setGeoProcessError('')
788
+ setCoordsMode('geocodificar')
789
+ }}
790
+ disabled={loading}
791
+ >
792
+ Geocodificar automaticamente
793
+ </button>
794
+ <span className="coords-choice-separator">ou</span>
795
+ <button
796
+ onClick={() => {
797
+ setManualMapError('')
798
+ setGeoProcessError('')
799
+ setCoordsMode('confirmar-sem-coords')
800
+ }}
801
+ disabled={loading}
802
+ >
803
+ Prosseguir sem mapear coordenadas
804
+ </button>
805
+ </div>
806
+ ) : null}
807
+
808
+ {coordsMode === 'confirmar-sem-coords' ? (
809
+ <div className="subpanel warning">
810
+ <p>
811
+ Funcionalidades dependentes de geolocalização podem apresentar resultados incompletos sem coordenadas.
812
+ Deseja prosseguir mesmo assim?
813
+ </p>
814
+ <div className="row">
815
+ <button
816
+ onClick={() => {
817
+ setCoordsMode('skipped')
818
+ setStatus('Seção 1 liberada sem coordenadas completas.')
819
+ }}
820
+ disabled={loading}
821
+ >
822
+ Confirmar e prosseguir
823
+ </button>
824
+ <button
825
+ onClick={() => {
826
+ setManualMapError('')
827
+ setGeoProcessError('')
828
+ setCoordsMode('menu')
829
+ }}
830
+ disabled={loading}
831
+ >
832
+ Voltar
833
+ </button>
834
+ </div>
835
+ </div>
836
+ ) : null}
837
+
838
+ {coordsMode === 'mapear' ? (
839
+ <div className="subpanel">
840
+ <div className="row">
841
+ <button
842
+ onClick={() => {
843
+ setManualMapError('')
844
+ setCoordsMode('menu')
845
+ }}
846
+ disabled={loading}
847
+ >
848
+ Voltar
849
+ </button>
850
+ </div>
851
+ <h4>Mapeamento manual</h4>
852
+ <div className="row">
853
+ <select value={manualLat} onChange={(e) => setManualLat(e.target.value)}>
854
+ <option value="">Coluna Latitude</option>
855
+ {(coordsInfo?.colunas_disponiveis || []).map((col) => (
856
+ <option key={`lat-${col}`} value={col}>{col}</option>
857
+ ))}
858
+ </select>
859
+ <select value={manualLon} onChange={(e) => setManualLon(e.target.value)}>
860
+ <option value="">Coluna Longitude</option>
861
+ {(coordsInfo?.colunas_disponiveis || []).map((col) => (
862
+ <option key={`lon-${col}`} value={col}>{col}</option>
863
+ ))}
864
+ </select>
865
+ <button onClick={onMapCoords} disabled={loading || !manualLat || !manualLon}>Confirmar mapeamento</button>
866
+ </div>
867
+ {manualMapError ? <div className="error-line inline-error">{manualMapError}</div> : null}
868
+ </div>
869
+ ) : null}
870
+
871
+ {coordsMode === 'geocodificar' ? (
872
+ <div className="subpanel">
873
+ <div className="row">
874
+ <button
875
+ onClick={() => {
876
+ setGeoStatusHtml('')
877
+ setGeoFalhasHtml('')
878
+ setGeoCorrecoes([])
879
+ setGeoProcessError('')
880
+ setCoordsMode('menu')
881
+ }}
882
+ disabled={loading}
883
+ >
884
+ Voltar
885
+ </button>
886
+ </div>
887
+ <h4>Geocodificação por eixo</h4>
888
+ <div className="row">
889
+ <select value={geoCdlog} onChange={(e) => setGeoCdlog(e.target.value)}>
890
+ <option value="">Coluna CDLOG/CTM</option>
891
+ {(coordsInfo?.colunas_disponiveis || []).map((col) => (
892
+ <option key={`cdlog-${col}`} value={col}>{col}</option>
893
+ ))}
894
+ </select>
895
+ <select value={geoNum} onChange={(e) => setGeoNum(e.target.value)}>
896
+ <option value="">Coluna Número</option>
897
+ {(coordsInfo?.colunas_disponiveis || []).map((col) => (
898
+ <option key={`num-${col}`} value={col}>{col}</option>
899
+ ))}
900
+ </select>
901
+ <label>
902
+ <input type="checkbox" checked={geoAuto200} onChange={(e) => setGeoAuto200(e.target.checked)} />
903
+ Auto ajustar {'<='} 200
904
+ </label>
905
+ <button onClick={onGeocodificar} disabled={loading || !geoCdlog || !geoNum}>Geocodificar</button>
906
+ </div>
907
+ {geoProcessError ? <div className="error-line inline-error">{geoProcessError}</div> : null}
908
+ {geoStatusHtml ? <div dangerouslySetInnerHTML={{ __html: geoStatusHtml }} /> : null}
909
+ {geoFalhasHtml ? <div dangerouslySetInnerHTML={{ __html: geoFalhasHtml }} /> : null}
910
+ {geoCorrecoes.length > 0 ? (
911
+ <div>
912
+ <h5>Correções manuais</h5>
913
+ <div className="geo-correcoes">
914
+ {geoCorrecoes.map((item, idx) => (
915
+ <div className="row" key={`cor-${item.linha}-${idx}`}>
916
+ <span>Linha {item.linha}</span>
917
+ <input
918
+ type="text"
919
+ value={item.numero_corrigido || ''}
920
+ onChange={(e) => {
921
+ const next = [...geoCorrecoes]
922
+ next[idx] = { ...next[idx], numero_corrigido: e.target.value }
923
+ setGeoCorrecoes(next)
924
+ }}
925
+ placeholder="Número corrigido"
926
+ />
927
+ </div>
928
+ ))}
929
+ </div>
930
+ <button onClick={onAplicarCorrecoesGeo} disabled={loading}>Aplicar correções</button>
931
+ </div>
932
+ ) : null}
933
+ </div>
934
+ ) : null}
935
+ </>
936
+ ) : (
937
+ <div className="section1-empty-hint">Coordenadas já disponíveis ou etapa concluída.</div>
938
+ )}
939
+ </div>
940
+ </div>
941
+ </SectionBlock>
942
+
943
+ <SectionBlock
944
+ step="2"
945
+ title="Visualizar Dados"
946
+ subtitle="Mapa interativo e tabela prévia da base carregada."
947
+ aside={(
948
+ <div className="row compact">
949
+ <label>Variável no mapa</label>
950
+ <select value={mapaVariavel} onChange={(e) => onMapVarChange(e.target.value)}>
951
+ {mapaChoices.map((choice) => (
952
+ <option key={choice} value={choice}>{choice}</option>
953
+ ))}
954
+ </select>
955
+ </div>
956
+ )}
957
+ >
958
+ <div className="pane">
959
+ <MapFrame html={mapaHtml} />
960
+ </div>
961
+ <div className="stack-block">
962
+ <h4>Dados de mercado</h4>
963
+ <DataTable table={dados} maxHeight={540} />
964
+ </div>
965
+ </SectionBlock>
966
+
967
+ <SectionBlock step="3" title="Selecionar Variável Dependente" subtitle="Defina a variável dependente (Y).">
968
+ <div className="row">
969
+ <label>Variável Dependente (Y)</label>
970
+ <select value={colunaY} onChange={(e) => setColunaY(e.target.value)}>
971
+ <option value="">Selecione</option>
972
+ {colunasNumericas.map((col) => (
973
+ <option key={col} value={col}>{col}</option>
974
+ ))}
975
+ </select>
976
+ </div>
977
+ </SectionBlock>
978
+
979
+ <SectionBlock step="4" title="Selecionar Variáveis Independentes" subtitle="Escolha regressoras e grupos de tipologia.">
980
+ <div className="compact-option-group">
981
+ <h4>Variáveis Independentes (X)</h4>
982
+ <div className="checkbox-inline-wrap checkbox-inline-wrap-tools">
983
+ <label className="compact-checkbox compact-checkbox-toggle-all">
984
+ <input
985
+ ref={marcarTodasXRef}
986
+ type="checkbox"
987
+ checked={todasXMarcadas}
988
+ onChange={onToggleTodasX}
989
+ disabled={colunasXDisponiveis.length === 0}
990
+ />
991
+ {todasXMarcadas ? 'Desmarcar todas' : 'Marcar todas'}
992
+ </label>
993
+ <span className="compact-selection-count">
994
+ {colunasX.length}/{colunasXDisponiveis.length} selecionadas
995
+ </span>
996
+ </div>
997
+ <div className="checkbox-inline-wrap">
998
+ {colunasXDisponiveis.map((col) => (
999
+ <label key={`x-${col}`} className="compact-checkbox">
1000
+ <input
1001
+ type="checkbox"
1002
+ checked={colunasX.includes(col)}
1003
+ onChange={() => onToggleColunaX(col)}
1004
+ />
1005
+ {col}
1006
+ </label>
1007
+ ))}
1008
+ </div>
1009
+ </div>
1010
+
1011
+ <div className="compact-option-group">
1012
+ <h4>Variáveis Dicotômicas (0/1)</h4>
1013
+ <div className="checkbox-inline-wrap">
1014
+ {colunasX.map((col) => (
1015
+ <label key={`d-${col}`} className="compact-checkbox">
1016
+ <input type="checkbox" checked={dicotomicas.includes(col)} onChange={() => toggleSelection(setDicotomicas, col)} />
1017
+ {col}
1018
+ </label>
1019
+ ))}
1020
+ </div>
1021
+ </div>
1022
+
1023
+ <div className="compact-option-group">
1024
+ <h4>Variáveis de Código Alocado/Ajustado</h4>
1025
+ <div className="checkbox-inline-wrap">
1026
+ {colunasX.map((col) => (
1027
+ <label key={`c-${col}`} className="compact-checkbox">
1028
+ <input type="checkbox" checked={codigoAlocado.includes(col)} onChange={() => toggleSelection(setCodigoAlocado, col)} />
1029
+ {col}
1030
+ </label>
1031
+ ))}
1032
+ </div>
1033
+ </div>
1034
+
1035
+ <div className="compact-option-group">
1036
+ <h4>Variáveis Percentuais (0 a 1)</h4>
1037
+ <div className="checkbox-inline-wrap">
1038
+ {colunasX.map((col) => (
1039
+ <label key={`p-${col}`} className="compact-checkbox">
1040
+ <input type="checkbox" checked={percentuais.includes(col)} onChange={() => toggleSelection(setPercentuais, col)} />
1041
+ {col}
1042
+ </label>
1043
+ ))}
1044
+ </div>
1045
+ </div>
1046
+
1047
+ <div className="row">
1048
+ <button onClick={onApplySelection} disabled={loading || !colunaY || colunasX.length === 0}>Aplicar seleção</button>
1049
+ </div>
1050
+ </SectionBlock>
1051
+
1052
+ {selection ? (
1053
+ <>
1054
+ <SectionBlock step="5" title="Estatísticas das Variáveis Selecionadas" subtitle="Resumo estatístico para Y e regressoras.">
1055
+ <DataTable table={selection.estatisticas} />
1056
+ </SectionBlock>
1057
+
1058
+ <SectionBlock step="6" title="Teste de Micronumerosidade" subtitle="Validação de amostra mínima para variáveis selecionadas.">
1059
+ <div dangerouslySetInnerHTML={{ __html: selection.micronumerosidade_html || '' }} />
1060
+ {selection.aviso_multicolinearidade?.visible ? (
1061
+ <div dangerouslySetInnerHTML={{ __html: selection.aviso_multicolinearidade.html }} />
1062
+ ) : null}
1063
+ </SectionBlock>
1064
+
1065
+ <SectionBlock step="7" title="Gráficos de Dispersão das Variáveis Independentes" subtitle="Leitura visual entre X e Y no conjunto filtrado.">
1066
+ <PlotFigure
1067
+ figure={selection.grafico_dispersao}
1068
+ title="Dispersão (dados filtrados)"
1069
+ forceHideLegend
1070
+ className="plot-stretch"
1071
+ />
1072
+ </SectionBlock>
1073
+
1074
+ <SectionBlock step="8" title="Transformações Sugeridas" subtitle="Busca automática de combinações por R² e enquadramento.">
1075
+ <div className="row">
1076
+ <label>Grau mínimo dos coeficientes</label>
1077
+ <select value={grauCoef} onChange={(e) => setGrauCoef(Number(e.target.value))}>
1078
+ {GRAUS_COEF.map((item) => (
1079
+ <option key={`coef-${item.value}`} value={item.value}>{item.label}</option>
1080
+ ))}
1081
+ </select>
1082
+ <label>Grau mínimo do teste F</label>
1083
+ <select value={grauF} onChange={(e) => setGrauF(Number(e.target.value))}>
1084
+ {GRAUS_F.map((item) => (
1085
+ <option key={`f-${item.value}`} value={item.value}>{item.label}</option>
1086
+ ))}
1087
+ </select>
1088
+ <button onClick={onSearchTransform} disabled={loading}>Buscar transformações</button>
1089
+ </div>
1090
+ {(selection.busca?.resultados || []).length > 0 ? (
1091
+ <div className="transform-suggestions-grid">
1092
+ {(selection.busca?.resultados || []).map((item, idx) => (
1093
+ <div key={`sug-${item.rank || idx + 1}`} className="transform-suggestion-card">
1094
+ <div className="transform-suggestion-head">
1095
+ <span className="transform-suggestion-rank">#{item.rank || idx + 1}</span>
1096
+ <span className="transform-suggestion-r2">R² = {Number(item.r2 ?? 0).toFixed(4)}</span>
1097
+ </div>
1098
+ <div className="transform-suggestion-line"><strong>Y:</strong> {item.transformacao_y}</div>
1099
+ <div className="transform-suggestion-list">
1100
+ {Object.entries(item.transformacoes_x || {}).map(([coluna, transf]) => {
1101
+ const grau = Number(item.graus_coef?.[coluna] ?? 0)
1102
+ return (
1103
+ <div key={`scol-${item.rank || idx}-${coluna}`} className="transform-suggestion-item">
1104
+ <span className="transform-suggestion-col">{coluna}</span>
1105
+ <span className="transform-suggestion-fn">{transf}</span>
1106
+ <span className={grauBadgeClass(grau)}>{GRAU_LABEL_CURTO[grau] || 'Sem enq.'}</span>
1107
+ </div>
1108
+ )
1109
+ })}
1110
+ </div>
1111
+ <div className="transform-suggestion-foot">
1112
+ <span className={grauBadgeClass(Number(item.grau_f ?? 0))}>
1113
+ Teste F: {GRAU_LABEL_CURTO[Number(item.grau_f ?? 0)] || 'Sem enq.'}
1114
+ </span>
1115
+ </div>
1116
+ <button onClick={() => onAdoptSuggestion(idx)} disabled={loading}>
1117
+ Adotar sugestão #{item.rank || idx + 1}
1118
+ </button>
1119
+ </div>
1120
+ ))}
1121
+ </div>
1122
+ ) : (
1123
+ <div dangerouslySetInnerHTML={{ __html: selection.busca?.html || '' }} />
1124
+ )}
1125
+ </SectionBlock>
1126
+
1127
+ <SectionBlock step="9" title="Aplicação das Transformações" subtitle="Configuração manual para ajuste do modelo.">
1128
+ <div className="row">
1129
+ <label>Transformação de Y</label>
1130
+ <select value={transformacaoY} onChange={(e) => setTransformacaoY(e.target.value)}>
1131
+ {['(x)', '1/(x)', 'ln(x)', 'exp(x)', '(x)^2', 'raiz(x)', '1/raiz(x)'].map((item) => (
1132
+ <option key={`y-${item}`} value={item}>{item}</option>
1133
+ ))}
1134
+ </select>
1135
+ </div>
1136
+
1137
+ <div className="transform-grid">
1138
+ {(selection.transform_fields || []).map((field) => (
1139
+ <div key={`tf-${field.coluna}`} className="transform-card">
1140
+ <span>{field.coluna}</span>
1141
+ <select
1142
+ value={transformacoesX[field.coluna] || '(x)'}
1143
+ onChange={(e) => setTransformacoesX((prev) => ({ ...prev, [field.coluna]: e.target.value }))}
1144
+ disabled={field.locked}
1145
+ >
1146
+ {(field.choices || []).map((choice) => (
1147
+ <option key={`${field.coluna}-${choice}`} value={choice}>{choice}</option>
1148
+ ))}
1149
+ </select>
1150
+ </div>
1151
+ ))}
1152
+ </div>
1153
+
1154
+ <button onClick={onFitModel} disabled={loading}>Aplicar transformações e ajustar modelo</button>
1155
+ </SectionBlock>
1156
+ </>
1157
+ ) : null}
1158
+
1159
+ {fit ? (
1160
+ <>
1161
+ <SectionBlock step="10" title="Gráficos de Dispersão (Variáveis Transformadas)" subtitle="Dispersão com variáveis já transformadas.">
1162
+ <div className="row">
1163
+ <label>Tipo de dispersão</label>
1164
+ <select value={tipoDispersao} onChange={(e) => onTipoDispersaoChange(e.target.value)}>
1165
+ <option value="Variáveis Independentes Transformadas X Variável Dependente Transformada">X transformado x Y transformado</option>
1166
+ <option value="Variáveis Independentes Transformadas X Resíduo Padronizado">X transformado x Resíduo</option>
1167
+ </select>
1168
+ </div>
1169
+ <PlotFigure figure={fit.grafico_dispersao_modelo} title="Dispersão do modelo" forceHideLegend className="plot-stretch" />
1170
+ </SectionBlock>
1171
+
1172
+ <SectionBlock step="11" title="Diagnóstico de Modelo" subtitle="Resumo diagnóstico e tabelas principais do ajuste.">
1173
+ <div dangerouslySetInnerHTML={{ __html: fit.diagnosticos_html || '' }} />
1174
+ <div className="two-col diagnostic-tables">
1175
+ <div className="pane">
1176
+ <h4>Tabela de Coeficientes</h4>
1177
+ <DataTable table={fit.tabela_coef} maxHeight={460} />
1178
+ </div>
1179
+ <div className="pane">
1180
+ <h4>Valores Observados x Calculados</h4>
1181
+ <DataTable table={fit.tabela_obs_calc} maxHeight={460} />
1182
+ </div>
1183
+ </div>
1184
+ </SectionBlock>
1185
+
1186
+ <SectionBlock step="12" title="Gráficos de Diagnóstico do Modelo" subtitle="Obs x calc, resíduos, histograma, Cook e correlação.">
1187
+ <div className="plot-grid-2-fixed">
1188
+ <PlotFigure figure={fit.grafico_obs_calc} title="Obs x Calc" />
1189
+ <PlotFigure figure={fit.grafico_residuos} title="Resíduos" />
1190
+ <PlotFigure figure={fit.grafico_histograma} title="Histograma" />
1191
+ <PlotFigure figure={fit.grafico_cook} title="Cook" forceHideLegend />
1192
+ </div>
1193
+ <div className="plot-full-width">
1194
+ <PlotFigure figure={fit.grafico_correlacao} title="Matriz de correlação" className="plot-correlation-card" />
1195
+ </div>
1196
+ </SectionBlock>
1197
+
1198
+ <SectionBlock step="13" title="Analisar Outliers" subtitle="Métricas para identificação de observações influentes.">
1199
+ <DataTable table={fit.tabela_metricas} maxHeight={320} />
1200
+ </SectionBlock>
1201
+
1202
+ <SectionBlock step="14" title="Exclusão ou Reinclusão de Outliers" subtitle="Filtre índices, revise e atualize o modelo.">
1203
+ {outliersAnteriores.length > 0 && outliersHtml ? (
1204
+ <div className="outliers-html-box" dangerouslySetInnerHTML={{ __html: outliersHtml }} />
1205
+ ) : null}
1206
+ <div className="outlier-subheader">Filtrar outliers</div>
1207
+ <div className="outlier-dica">Outliers = linhas que satisfazem qualquer filtro (lógica OR / união).</div>
1208
+ <div className="filtros-stack">
1209
+ {filtros.map((filtro, idx) => (
1210
+ <div key={`filtro-${idx}`} className="filtro-row-react">
1211
+ <select
1212
+ value={filtro.variavel}
1213
+ onChange={(e) => {
1214
+ const next = [...filtros]
1215
+ next[idx] = { ...next[idx], variavel: e.target.value }
1216
+ setFiltros(next)
1217
+ }}
1218
+ >
1219
+ {(fit.variaveis_filtro || []).map((varItem) => (
1220
+ <option key={`vf-${idx}-${varItem}`} value={varItem}>{varItem}</option>
1221
+ ))}
1222
+ </select>
1223
+ <select
1224
+ value={filtro.operador}
1225
+ onChange={(e) => {
1226
+ const next = [...filtros]
1227
+ next[idx] = { ...next[idx], operador: e.target.value }
1228
+ setFiltros(next)
1229
+ }}
1230
+ >
1231
+ {OPERADORES.map((op) => (
1232
+ <option key={`op-${idx}-${op}`} value={op}>{op}</option>
1233
+ ))}
1234
+ </select>
1235
+ <input
1236
+ type="number"
1237
+ value={filtro.valor}
1238
+ onChange={(e) => {
1239
+ const next = [...filtros]
1240
+ next[idx] = { ...next[idx], valor: Number(e.target.value) }
1241
+ setFiltros(next)
1242
+ }}
1243
+ />
1244
+ </div>
1245
+ ))}
1246
+ </div>
1247
+ <div className="outlier-actions-row">
1248
+ <button onClick={onApplyOutlierFilters} disabled={loading}>Aplicar filtros</button>
1249
+ </div>
1250
+
1251
+ <div className="outlier-divider"><span className="arrow">→</span></div>
1252
+ <div className="outlier-subheader">Excluir ou reincluir índices</div>
1253
+ <div className="outlier-inputs-grid">
1254
+ <div className="outlier-input-card">
1255
+ <label>A excluir</label>
1256
+ <input type="text" value={outliersTexto} onChange={(e) => setOutliersTexto(e.target.value)} placeholder="ex: 5, 12, 30" />
1257
+ </div>
1258
+ <div className="outlier-input-card">
1259
+ <label>A reincluir</label>
1260
+ <input type="text" value={reincluirTexto} onChange={(e) => setReincluirTexto(e.target.value)} placeholder="ex: 5" />
1261
+ </div>
1262
+ </div>
1263
+
1264
+ <div className="outlier-actions-row">
1265
+ <button onClick={onRestartIteration} disabled={loading} className="btn-reiniciar-iteracao">
1266
+ Atualizar Modelo (Excluir/Reincluir Outliers)
1267
+ </button>
1268
+ </div>
1269
+ <div className="resumo-outliers-box">Iteração: {iteracao} | {resumoOutliers}</div>
1270
+ <div className="resumo-outliers-box">Outliers anteriores: {joinSelection(outliersAnteriores) || '-'}</div>
1271
+ </SectionBlock>
1272
+
1273
+ <SectionBlock step="15" title="Avaliação de Imóvel" subtitle="Cálculo individual e comparação entre avaliações.">
1274
+ <div className="avaliacao-grid">
1275
+ {camposAvaliacao.map((campo) => (
1276
+ <div key={`aval-${campo.coluna}`} className="avaliacao-card">
1277
+ <label>{campo.coluna}</label>
1278
+ <input
1279
+ type="number"
1280
+ value={valoresAvaliacao[campo.coluna] ?? ''}
1281
+ placeholder={campo.placeholder || ''}
1282
+ onChange={(e) => setValoresAvaliacao((prev) => ({ ...prev, [campo.coluna]: e.target.value }))}
1283
+ />
1284
+ </div>
1285
+ ))}
1286
+ </div>
1287
+ <div className="row-wrap avaliacao-actions-row">
1288
+ <button onClick={onCalculateAvaliacao} disabled={loading}>Calcular</button>
1289
+ <button onClick={onClearAvaliacao} disabled={loading}>Limpar</button>
1290
+ <button onClick={onExportAvaliacoes} disabled={loading}>Exportar avaliações (Excel)</button>
1291
+ </div>
1292
+ <div className="row avaliacao-base-row">
1293
+ <label>Base comparação</label>
1294
+ <select value={baseValue || ''} onChange={(e) => onBaseChange(e.target.value)}>
1295
+ <option value="">Selecione</option>
1296
+ {baseChoices.map((choice) => (
1297
+ <option key={`base-${choice}`} value={choice}>{choice}</option>
1298
+ ))}
1299
+ </select>
1300
+ <label>Excluir avaliação #</label>
1301
+ <input type="text" value={deleteAvalIndex} onChange={(e) => setDeleteAvalIndex(e.target.value)} />
1302
+ <button onClick={onDeleteAvaliacao} disabled={loading}>Excluir</button>
1303
+ </div>
1304
+ <div className="avaliacao-resultado-box" dangerouslySetInnerHTML={{ __html: resultadoAvaliacaoHtml }} />
1305
+ </SectionBlock>
1306
+
1307
+ <SectionBlock step="16" title="Exportar Modelo" subtitle="Geração do pacote .dai e download da base tratada.">
1308
+ <div className="row">
1309
+ <label>Nome do arquivo (.dai)</label>
1310
+ <input type="text" value={nomeArquivoExport} onChange={(e) => setNomeArquivoExport(e.target.value)} />
1311
+ <label>Avaliador</label>
1312
+ <select value={avaliadorSelecionado} onChange={(e) => setAvaliadorSelecionado(e.target.value)}>
1313
+ <option value="">Manter elaborador do modelo (se houver)</option>
1314
+ {avaliadores.map((item) => (
1315
+ <option key={`aval-exp-${item.nome_completo}`} value={item.nome_completo}>
1316
+ {item.nome_completo}
1317
+ </option>
1318
+ ))}
1319
+ </select>
1320
+ <button onClick={onExportModel} disabled={loading || !nomeArquivoExport}>Exportar modelo</button>
1321
+ <button onClick={onExportBase} disabled={loading}>Exportar base CSV</button>
1322
+ </div>
1323
+ </SectionBlock>
1324
+ </>
1325
+ ) : null}
1326
+
1327
+ {loading ? <div className="status-line">Processando...</div> : null}
1328
+ {error ? <div className="error-line">{error}</div> : null}
1329
+ </div>
1330
+ )
1331
+ }
frontend/src/components/MapFrame.jsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+
3
+ export default function MapFrame({ html }) {
4
+ if (!html) {
5
+ return <div className="empty-box">Mapa indisponivel.</div>
6
+ }
7
+
8
+ return (
9
+ <iframe
10
+ title="mapa"
11
+ className="map-frame"
12
+ srcDoc={html}
13
+ sandbox="allow-scripts allow-same-origin allow-popups"
14
+ />
15
+ )
16
+ }
frontend/src/components/PlotFigure.jsx ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import Plot from 'react-plotly.js'
3
+ import Plotly from 'plotly.js-dist-min'
4
+
5
+ export default function PlotFigure({ figure, title, forceHideLegend = false, className = '' }) {
6
+ if (!figure) {
7
+ return <div className="empty-box">Grafico indisponivel.</div>
8
+ }
9
+
10
+ const data = (figure.data || []).map((trace) => {
11
+ if (!forceHideLegend) return trace
12
+ return { ...trace, showlegend: false }
13
+ })
14
+
15
+ const baseLayout = figure.layout || {}
16
+ const { width: _ignoreWidth, ...layoutNoWidth } = baseLayout
17
+ const safeAnnotations = Array.isArray(baseLayout.annotations)
18
+ ? baseLayout.annotations.map((annotation) => {
19
+ const { ax, ay, axref, ayref, arrowhead, arrowsize, arrowwidth, arrowcolor, standoff, ...clean } = annotation || {}
20
+ return { ...clean, showarrow: false }
21
+ })
22
+ : baseLayout.annotations
23
+ const layout = {
24
+ ...layoutNoWidth,
25
+ autosize: true,
26
+ annotations: safeAnnotations,
27
+ margin: baseLayout.margin || { t: 40, r: 20, b: 50, l: 50 },
28
+ }
29
+ if (forceHideLegend) {
30
+ layout.showlegend = false
31
+ }
32
+ const plotHeight = layout.height ? `${layout.height}px` : '100%'
33
+ const cardClassName = `plot-card ${className}`.trim()
34
+
35
+ return (
36
+ <div className={cardClassName}>
37
+ {title ? <h4>{title}</h4> : null}
38
+ <Plot
39
+ data={data}
40
+ layout={layout}
41
+ config={{ responsive: true, displaylogo: false }}
42
+ style={{ width: '100%', height: plotHeight, minHeight: '320px' }}
43
+ useResizeHandler
44
+ plotly={Plotly}
45
+ />
46
+ </div>
47
+ )
48
+ }
frontend/src/components/SectionBlock.jsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+
3
+ export default function SectionBlock({
4
+ step,
5
+ title,
6
+ subtitle,
7
+ aside,
8
+ children,
9
+ }) {
10
+ return (
11
+ <section className="workflow-section" style={{ '--section-order': Number(step) || 1 }}>
12
+ <header className="section-head">
13
+ <span className="section-index">{step}</span>
14
+ <div className="section-title-wrap">
15
+ <h3>{title}</h3>
16
+ {subtitle ? <p>{subtitle}</p> : null}
17
+ </div>
18
+ {aside ? <div className="section-head-aside">{aside}</div> : null}
19
+ </header>
20
+ <div className="section-body">{children}</div>
21
+ </section>
22
+ )
23
+ }
frontend/src/components/VisualizacaoTab.jsx ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react'
2
+ import { api, downloadBlob } from '../api'
3
+ import DataTable from './DataTable'
4
+ import MapFrame from './MapFrame'
5
+ import PlotFigure from './PlotFigure'
6
+ import SectionBlock from './SectionBlock'
7
+
8
+ const INNER_TABS = [
9
+ { key: 'mapa', label: 'Mapa' },
10
+ { key: 'dados', label: 'Dados' },
11
+ { key: 'transformacoes', label: 'Transformações' },
12
+ { key: 'resumo', label: 'Resumo' },
13
+ { key: 'coeficientes', label: 'Coeficientes' },
14
+ { key: 'obs_calc', label: 'Obs x Calc' },
15
+ { key: 'graficos', label: 'Gráficos' },
16
+ { key: 'avaliacao', label: 'Avaliação' },
17
+ { key: 'avaliacao_massa', label: 'Avaliação em Massa' },
18
+ ]
19
+
20
+ export default function VisualizacaoTab({ sessionId }) {
21
+ const [loading, setLoading] = useState(false)
22
+ const [error, setError] = useState('')
23
+ const [status, setStatus] = useState('')
24
+ const [badgeHtml, setBadgeHtml] = useState('')
25
+
26
+ const [uploadedFile, setUploadedFile] = useState(null)
27
+
28
+ const [dados, setDados] = useState(null)
29
+ const [estatisticas, setEstatisticas] = useState(null)
30
+ const [escalasHtml, setEscalasHtml] = useState('')
31
+ const [dadosTransformados, setDadosTransformados] = useState(null)
32
+ const [resumoHtml, setResumoHtml] = useState('')
33
+ const [coeficientes, setCoeficientes] = useState(null)
34
+ const [obsCalc, setObsCalc] = useState(null)
35
+
36
+ const [plotObsCalc, setPlotObsCalc] = useState(null)
37
+ const [plotResiduos, setPlotResiduos] = useState(null)
38
+ const [plotHistograma, setPlotHistograma] = useState(null)
39
+ const [plotCook, setPlotCook] = useState(null)
40
+ const [plotCorr, setPlotCorr] = useState(null)
41
+
42
+ const [mapaHtml, setMapaHtml] = useState('')
43
+ const [mapaChoices, setMapaChoices] = useState(['Visualização Padrão'])
44
+ const [mapaVar, setMapaVar] = useState('Visualização Padrão')
45
+
46
+ const [camposAvaliacao, setCamposAvaliacao] = useState([])
47
+ const [valoresAvaliacao, setValoresAvaliacao] = useState({})
48
+ const [resultadoAvaliacaoHtml, setResultadoAvaliacaoHtml] = useState('')
49
+ const [baseChoices, setBaseChoices] = useState([])
50
+ const [baseValue, setBaseValue] = useState('')
51
+ const [deleteAvalIndex, setDeleteAvalIndex] = useState('')
52
+
53
+ const [activeInnerTab, setActiveInnerTab] = useState('mapa')
54
+
55
+ const statusFluxo = [
56
+ { label: 'Modelo carregado', done: Boolean(status) },
57
+ { label: 'Dados exibidos', done: Boolean(dados) },
58
+ { label: 'Gráficos prontos', done: Boolean(plotObsCalc) },
59
+ { label: 'Avaliação ativa', done: camposAvaliacao.length > 0 },
60
+ ]
61
+
62
+ function resetConteudoVisualizacao() {
63
+ setDados(null)
64
+ setEstatisticas(null)
65
+ setEscalasHtml('')
66
+ setDadosTransformados(null)
67
+ setResumoHtml('')
68
+ setCoeficientes(null)
69
+ setObsCalc(null)
70
+
71
+ setPlotObsCalc(null)
72
+ setPlotResiduos(null)
73
+ setPlotHistograma(null)
74
+ setPlotCook(null)
75
+ setPlotCorr(null)
76
+
77
+ setMapaHtml('')
78
+ setMapaChoices(['Visualização Padrão'])
79
+ setMapaVar('Visualização Padrão')
80
+
81
+ setCamposAvaliacao([])
82
+ setValoresAvaliacao({})
83
+ setResultadoAvaliacaoHtml('')
84
+ setBaseChoices([])
85
+ setBaseValue('')
86
+ setDeleteAvalIndex('')
87
+
88
+ setActiveInnerTab('mapa')
89
+ }
90
+
91
+ function applyExibicaoResponse(resp) {
92
+ setDados(resp.dados || null)
93
+ setEstatisticas(resp.estatisticas || null)
94
+ setEscalasHtml(resp.escalas_html || '')
95
+ setDadosTransformados(resp.dados_transformados || null)
96
+ setResumoHtml(resp.resumo_html || '')
97
+ setCoeficientes(resp.coeficientes || null)
98
+ setObsCalc(resp.obs_calc || null)
99
+
100
+ setPlotObsCalc(resp.grafico_obs_calc || null)
101
+ setPlotResiduos(resp.grafico_residuos || null)
102
+ setPlotHistograma(resp.grafico_histograma || null)
103
+ setPlotCook(resp.grafico_cook || null)
104
+ setPlotCorr(resp.grafico_correlacao || null)
105
+
106
+ setMapaHtml(resp.mapa_html || '')
107
+ setMapaChoices(resp.mapa_choices || ['Visualização Padrão'])
108
+ setMapaVar('Visualização Padrão')
109
+
110
+ setCamposAvaliacao(resp.campos_avaliacao || [])
111
+ const values = {}
112
+ ;(resp.campos_avaliacao || []).forEach((campo) => {
113
+ values[campo.coluna] = ''
114
+ })
115
+ setValoresAvaliacao(values)
116
+ setResultadoAvaliacaoHtml('')
117
+ setBaseChoices([])
118
+ setBaseValue('')
119
+ }
120
+
121
+ async function withBusy(fn) {
122
+ setLoading(true)
123
+ setError('')
124
+ try {
125
+ await fn()
126
+ } catch (err) {
127
+ setError(err.message)
128
+ } finally {
129
+ setLoading(false)
130
+ }
131
+ }
132
+
133
+ async function onUploadModel() {
134
+ if (!sessionId || !uploadedFile) return
135
+ await withBusy(async () => {
136
+ resetConteudoVisualizacao()
137
+ const uploadResp = await api.uploadVisualizacaoFile(sessionId, uploadedFile)
138
+ setStatus(uploadResp.status || '')
139
+ setBadgeHtml(uploadResp.badge_html || '')
140
+
141
+ const exibirResp = await api.exibirVisualizacao(sessionId)
142
+ applyExibicaoResponse(exibirResp)
143
+ })
144
+ }
145
+
146
+ async function onMapChange(value) {
147
+ setMapaVar(value)
148
+ if (!sessionId) return
149
+ await withBusy(async () => {
150
+ const resp = await api.updateVisualizacaoMap(sessionId, value)
151
+ setMapaHtml(resp.mapa_html || '')
152
+ })
153
+ }
154
+
155
+ async function onCalcularAvaliacao() {
156
+ if (!sessionId) return
157
+ await withBusy(async () => {
158
+ const resp = await api.evaluationCalculateViz(sessionId, valoresAvaliacao, baseValue || null)
159
+ setResultadoAvaliacaoHtml(resp.resultado_html || '')
160
+ setBaseChoices(resp.base_choices || [])
161
+ setBaseValue(resp.base_value || '')
162
+ })
163
+ }
164
+
165
+ async function onClearAvaliacao() {
166
+ if (!sessionId) return
167
+ await withBusy(async () => {
168
+ const resp = await api.evaluationClearViz(sessionId)
169
+ setResultadoAvaliacaoHtml(resp.resultado_html || '')
170
+ setBaseChoices(resp.base_choices || [])
171
+ setBaseValue(resp.base_value || '')
172
+ })
173
+ }
174
+
175
+ async function onDeleteAvaliacao() {
176
+ if (!sessionId) return
177
+ await withBusy(async () => {
178
+ const resp = await api.evaluationDeleteViz(sessionId, deleteAvalIndex, baseValue || null)
179
+ setResultadoAvaliacaoHtml(resp.resultado_html || '')
180
+ setBaseChoices(resp.base_choices || [])
181
+ setBaseValue(resp.base_value || '')
182
+ setDeleteAvalIndex('')
183
+ })
184
+ }
185
+
186
+ async function onBaseChange(value) {
187
+ setBaseValue(value)
188
+ if (!sessionId) return
189
+ await withBusy(async () => {
190
+ const resp = await api.evaluationBaseViz(sessionId, value)
191
+ setResultadoAvaliacaoHtml(resp.resultado_html || '')
192
+ })
193
+ }
194
+
195
+ async function onExportAvaliacoes() {
196
+ if (!sessionId) return
197
+ await withBusy(async () => {
198
+ const blob = await api.exportEvaluationViz(sessionId)
199
+ downloadBlob(blob, 'avaliacoes_visualizacao.xlsx')
200
+ })
201
+ }
202
+
203
+ return (
204
+ <div className="tab-content">
205
+ <div className="status-strip">
206
+ {statusFluxo.map((item) => (
207
+ <span key={item.label} className={item.done ? 'status-pill done' : 'status-pill'}>
208
+ {item.label}
209
+ </span>
210
+ ))}
211
+ </div>
212
+
213
+ <SectionBlock step="1" title="Carregar Modelo .dai" subtitle="Carregue o arquivo e o conteúdo será exibido automaticamente.">
214
+ <div className="row">
215
+ <input type="file" onChange={(e) => setUploadedFile(e.target.files?.[0] ?? null)} />
216
+ <button onClick={onUploadModel} disabled={!uploadedFile || loading || !sessionId}>Carregar Modelo</button>
217
+ </div>
218
+ {status ? <div className="status-line">{status}</div> : null}
219
+ {badgeHtml ? <div dangerouslySetInnerHTML={{ __html: badgeHtml }} /> : null}
220
+ </SectionBlock>
221
+
222
+ {dados ? (
223
+ <SectionBlock step="2" title="Conteúdo do Modelo" subtitle="Carregue o modelo no topo e navegue pelas abas internas abaixo.">
224
+ <div className="inner-tabs" role="tablist" aria-label="Abas internas de visualização">
225
+ {INNER_TABS.map((tab) => (
226
+ <button
227
+ key={tab.key}
228
+ type="button"
229
+ className={activeInnerTab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
230
+ onClick={() => setActiveInnerTab(tab.key)}
231
+ >
232
+ {tab.label}
233
+ </button>
234
+ ))}
235
+ </div>
236
+
237
+ <div className="inner-tab-panel">
238
+ {activeInnerTab === 'mapa' ? (
239
+ <>
240
+ <div className="row compact">
241
+ <label>Variável no mapa</label>
242
+ <select value={mapaVar} onChange={(e) => onMapChange(e.target.value)}>
243
+ {mapaChoices.map((choice) => (
244
+ <option key={choice} value={choice}>{choice}</option>
245
+ ))}
246
+ </select>
247
+ </div>
248
+ <MapFrame html={mapaHtml} />
249
+ </>
250
+ ) : null}
251
+
252
+ {activeInnerTab === 'dados' ? (
253
+ <div className="two-col">
254
+ <div className="pane">
255
+ <h4>Dados</h4>
256
+ <DataTable table={dados} maxHeight={520} />
257
+ </div>
258
+ <div className="pane">
259
+ <h4>Estatísticas</h4>
260
+ <DataTable table={estatisticas} maxHeight={520} />
261
+ </div>
262
+ </div>
263
+ ) : null}
264
+
265
+ {activeInnerTab === 'transformacoes' ? (
266
+ <>
267
+ <div dangerouslySetInnerHTML={{ __html: escalasHtml }} />
268
+ <DataTable table={dadosTransformados} />
269
+ </>
270
+ ) : null}
271
+
272
+ {activeInnerTab === 'resumo' ? (
273
+ <div dangerouslySetInnerHTML={{ __html: resumoHtml }} />
274
+ ) : null}
275
+
276
+ {activeInnerTab === 'coeficientes' ? (
277
+ <DataTable table={coeficientes} maxHeight={620} />
278
+ ) : null}
279
+
280
+ {activeInnerTab === 'obs_calc' ? (
281
+ <DataTable table={obsCalc} maxHeight={620} />
282
+ ) : null}
283
+
284
+ {activeInnerTab === 'graficos' ? (
285
+ <>
286
+ <div className="plot-grid-2-fixed">
287
+ <PlotFigure figure={plotObsCalc} title="Obs x Calc" />
288
+ <PlotFigure figure={plotResiduos} title="Resíduos" />
289
+ <PlotFigure figure={plotHistograma} title="Histograma" />
290
+ <PlotFigure figure={plotCook} title="Cook" forceHideLegend />
291
+ </div>
292
+ <div className="plot-full-width">
293
+ <PlotFigure figure={plotCorr} title="Correlação" className="plot-correlation-card" />
294
+ </div>
295
+ </>
296
+ ) : null}
297
+
298
+ {activeInnerTab === 'avaliacao' ? (
299
+ <>
300
+ <div className="avaliacao-grid">
301
+ {camposAvaliacao.map((campo) => (
302
+ <div key={`campo-${campo.coluna}`} className="avaliacao-card">
303
+ <label>{campo.coluna}</label>
304
+ <input
305
+ type="number"
306
+ value={valoresAvaliacao[campo.coluna] ?? ''}
307
+ placeholder={campo.placeholder || ''}
308
+ onChange={(e) => setValoresAvaliacao((prev) => ({ ...prev, [campo.coluna]: e.target.value }))}
309
+ />
310
+ </div>
311
+ ))}
312
+ </div>
313
+
314
+ <div className="row-wrap avaliacao-actions-row">
315
+ <button onClick={onCalcularAvaliacao} disabled={loading}>Calcular</button>
316
+ <button onClick={onClearAvaliacao} disabled={loading}>Limpar</button>
317
+ <button onClick={onExportAvaliacoes} disabled={loading}>Exportar avaliações</button>
318
+ </div>
319
+
320
+ <div className="row avaliacao-base-row">
321
+ <label>Base comparação</label>
322
+ <select value={baseValue || ''} onChange={(e) => onBaseChange(e.target.value)}>
323
+ <option value="">Selecione</option>
324
+ {baseChoices.map((choice) => (
325
+ <option key={`base-${choice}`} value={choice}>{choice}</option>
326
+ ))}
327
+ </select>
328
+
329
+ <label>Excluir avaliação #</label>
330
+ <input type="text" value={deleteAvalIndex} onChange={(e) => setDeleteAvalIndex(e.target.value)} />
331
+ <button onClick={onDeleteAvaliacao} disabled={loading}>Excluir</button>
332
+ </div>
333
+
334
+ <div className="avaliacao-resultado-box" dangerouslySetInnerHTML={{ __html: resultadoAvaliacaoHtml }} />
335
+ </>
336
+ ) : null}
337
+
338
+ {activeInnerTab === 'avaliacao_massa' ? (
339
+ <div className="empty-box">Módulo em desenvolvimento.</div>
340
+ ) : null}
341
+ </div>
342
+ </SectionBlock>
343
+ ) : (
344
+ <div className="empty-box">Carregue um modelo para navegar nas abas internas da Visualização/Avaliação.</div>
345
+ )}
346
+
347
+ {loading ? <div className="status-line">Processando...</div> : null}
348
+ {error ? <div className="error-line">{error}</div> : null}
349
+ </div>
350
+ )
351
+ }