Previsão de surf calculada por pico, não por praia.
a preencher — badge de CI. Tarefa: docs/readme-gaps.md
Como rodar · Garantias · Medições · Limitações
Cada pico tem a própria página, onde a nota é aberta nos seis fatores que a produziram e a geometria do fundo é desenhada: o arco de direções que aquele fundo enxerga, a linha de costa em que ele está, e de onde a ondulação está chegando.
Um site de previsão de ondas informa como está a praia. Quem entra no mar sabe que isso não basta: dois picos separados por trezentos metros da mesma praia, no mesmo dia, com a mesma ondulação, produzem ondas diferentes — um funciona e o outro não. A diferença está no fundo de cada um, na direção para onde ele olha e em quanta água ele pede.
A dificuldade é que essa diferença não existe no dado de entrada. A previsão pública de ondulação é calculada numa malha de pontos espaçados a cada 8 km, e Guarajuba tem 7,1 km de ponta a ponta — todos os picos daquela praia leem o mesmo ponto, com os mesmos números dentro. O sistema busca uma vez por praia e pontua todos os picos dela a partir dessa única resposta. Qualquer diferença que a interface mostre entre dois picos vizinhos, portanto, não pode ter vindo do dado: veio de um modelo físico de como cada fundo transforma aquela ondulação. Essa é a premissa, e a arquitetura está montada de modo que ela não possa ser violada em silêncio.
A nota nunca aparece sozinha. O modelo não emite um número opaco: emite os seis fatores que o produziram, cada um com a grandeza medida ao lado e uma frase gerada de forma determinística a partir dela — "o período está bem na linha: 8,9 s de pico contra os 9,0 s que o fundo pede, e essa estimativa carrega ±2,7 s". Duas coisas decorrem disso. A primeira é que o número é auditável: quem não acreditar consegue refazer a conta com o que está na tela. A segunda é que o documento abre pelas garantias, e não pela lista de funcionalidades, porque num sistema que afirma coisas sobre o mar o que importa não é o que ele calcula — é o que ele nunca faz.
Cada linha é uma propriedade que o modelo mantém para qualquer entrada, verificada sobre 1000 casos gerados por execução, não sobre exemplos escolhidos.
| Garantia | Comando que prova |
|---|---|
| A nota nunca sai de [0, 1], para qualquer mar | npx vitest run -t "always lands in" |
| Uma porteira fechada zera a nota, por melhor que esteja o resto | npx vitest run -t "force the score to zero when any of them closes" |
| Uma leitura ausente pontua no piso, nunca no topo | npx vitest run -t "score at the floor rather than on the plateau" |
| Acrescentar ondulação ao mar nunca fecha uma porteira que estava aberta | npx vitest run -t "never closes a gate that was open" |
| Maré fora da faixa nunca pontua acima da maré no meio dela | npx vitest run -t "never scores better outside the band" |
| O vento nunca melhora a nota ao girar do terral para o maral | npx vitest run -t "never improves as it swings from offshore toward onshore" |
| Entradas idênticas produzem sempre a mesma nota | npx vitest run -t "is a pure function of its arguments" |
| A maré prevista reproduz a tábua oficial da Marinha do Brasil | npx vitest run -t "reproduces every published high and low water" |
As duas últimas propriedades da lista de garantias do modelo — a de leitura ausente e a de porteira — nasceram de defeitos reais encontrados em revisão, e foram confirmadas falhando quando a correção é revertida. Um teste de regressão que passa com o defeito presente não prova nada.
O núcleo é isolado por regra de lint, não por convenção: dentro de src/core são erro os
imports de React, de Next, da camada de aplicação, e os usos de fetch, window,
Math.random e Date.now. É isso que permite ao mesmo código pontuar no navegador, no
servidor e dentro de um teste de propriedade — e que torna "o modelo é determinístico" uma
afirmação verificável em vez de uma promessa.
Node 20.9 ou superior. Sem chave de API e sem banco de dados.
npm install
npm run dev # http://localhost:3000npm test # 50 testes: 35 propriedades × 1000 casos, mais 15 por exemplo
npm run typecheck
npm run lint
npm run buildVerificações que dependem do app respondendo:
npm run build && npm start &
npm run audit:a11y # axe-core sobre o HTML servido, rota por rotaScripts que produzem os números deste documento:
npm run calibrate:tide # ajusta a transferência de maré para cada praia
npm run calibrate:swell # mede o viés de período e valida contra a boia
npm run backtest # reconstrói as sessões registradas e correlacionaflowchart TD
OM["Open-Meteo<br/>ondulação · vento"] --> LIB
TICON["TICON-4<br/>constantes harmônicas"] --> CORE
subgraph LIB["src/lib — E/S e apresentação"]
CLIENT["cliente HTTP<br/>validação por schema"]
NARR["narrativa pt-BR"]
end
subgraph CORE["src/core — domínio puro"]
WAVE["transformação de onda<br/>dispersão · empolamento · refração · quebra"]
TIDE["maré harmônica"]
SPOTS["registro de picos<br/>geometria + proveniência"]
SCORE["modelo de score<br/>2 porteiras + 4 moduladores"]
end
LIB --> CORE
CORE --> APP["src/app — Next.js"]
CORE --> SCRIPTS["scripts/<br/>backtest · calibração"]
Uma praia é a unidade de busca: uma requisição, um nó de grade, uma transferência de maré. Essa fronteira é estrutural. O argumento de "mesma célula, respostas diferentes" vale dentro de uma praia, onde os picos comprovadamente compartilham o nó, e deixa de valer entre praias separadas por dezenas de quilômetros — que recebem nós distintos e o dizem na tela:
| Praia | Picos | Nó servido | Distância | Normal da praia |
|---|---|---|---|---|
| Stella Maris, Salvador | Padang | −12,9583, −38,2917 | 4,4 km | 136° |
| Guarajuba, Camaçari | Scar Reef, Genipabu, Praia dos Corais, Praia do Surf | −12,7083, −38,0417 | 7,2 km | 121–134° |
| Praia do Forte, Mata de São João | Casinha | −12,5417, −37,8750 | 12,4 km | 119° |
Cada trem de ondulação percorre a mesma sequência em src/core/ocean/linear-wave.ts: relação
de dispersão para o comprimento de onda em cada profundidade, empolamento conforme a
velocidade de grupo cai, refração pela lei de Snell — que espalha a mesma energia num
comprimento maior de crista, e por isso um swell oblíquo sempre quebra menor que um de frente
—, e a profundidade de quebra resolvida numericamente. O número de Iribarren classifica se a
onda derrama, tuba ou infla, que é onde o declive do fundo separa um banco de areia de uma
laje.
A maré não é buscada: é calculada por soma de constituintes harmônicos com correções nodais sobre as constantes medidas do marégrafo de Salvador. Isso importa duas vezes — o site nunca depende de uma API de maré continuar de pé, e o backtest reconstrói a maré de uma sessão de 2022 com a mesma exatidão com que prevê a de terça que vem.
Todo parâmetro físico de todo pico carrega, obrigatoriamente, como foi obtido. SpotProvenance
é um mapeamento exaustivo sobre SpotPhysics, então um pico não compila sem declarar a origem
de cada número — medido, derivado, campo ou estimado — e a interface mostra esse rótulo
ao lado do valor.
Stack: Next.js 16.2.10 (App Router) · React 19.2.7 · TypeScript 6.0.3 em modo estrito com
noUncheckedIndexedAccess · Tailwind CSS 4.3.3 · Vitest 4.1.10 com fast-check 4.9.0 · Zod
4.4.3 · Drizzle ORM 0.45.2 sobre Postgres, opcional.
Buscar previsão por pico, e não por praia. É o que um serviço comercial faz e produziria seis conjuntos de números em vez de três. Rejeitado porque seria uma diferença falsa: a grade tem 8 km e devolveria o mesmo nó para picos da mesma praia de qualquer modo. Buscar por pico daria à interface aparência de precisão que o dado não tem, e destruiria a única afirmação que o projeto pode sustentar — a de que a diferença entre picos vizinhos veio inteiramente do modelo.
Usar uma biblioteca de maré pronta. @neaps/tide-predictor existe, é MIT e resolve o
problema. Rejeitado como dependência de runtime porque a maré precisa viver dentro do núcleo
puro, sem E/S, para que o backtest rode o caminho idêntico ao do site. A biblioteca ficou como
oráculo de teste: um ano de níveis é comparado contra ela a cada execução da suíte, o que
é mais valioso do que usá-la — duas implementações independentes da mesma teoria concordando
descarta uma classe inteira de erro algébrico.
Trocar a fonte de ondulação pelo modelo que publica período de pico. O DWD GWAM publica período médio e de pico juntos, o que eliminaria uma conversão estimada. Rejeitado porque o arquivo histórico dele só alcança janeiro de 2026, e o backtest precisa rodar sobre sessões mais antigas. Trocar ganharia precisão numa variável e quebraria a única validação real que o projeto tem.
Calibrar a altura de onda contra a boia. A boia SIMCosta BA-1 mede altura significativa e está a caminho dos picos. Rejeitado: ela fica fundeada dentro da boca da Baía de Todos os Santos, onde lê 0,44 m de média contra 1,13 m no nó do modelo. Essa diferença é abrigo da baía, não erro de modelo, e transportá-la para costa aberta cortaria toda onda em dois terços. A boia calibra período, que é fixado pelo campo de vento gerador e sofre pouco com abrigo, e não altura.
Regressão para converter período médio em período de pico. Testada e descartada: o R² ficou em 0,13. Com uma relação tão fraca, inverter a reta para recuperar um limiar amplifica ruído em vez de removê-lo. Ficou o deslocamento mediano medido, com a dispersão viajando junto.
CSP com nonce. O caminho canônico exige ler o nonce dos cabeçalhos da requisição dentro do
layout raiz, e chamar headers() ali tira o site inteiro da geração estática. Treze páginas
estáticas viraram dinâmicas seria um preço alto para endurecer um script inline que não contém
entrada de usuário. A política mantém 'unsafe-inline' em script-src e proíbe script de
qualquer outra origem, plugins, reescrita de <base> e enquadramento.
Números de exatidão contra referências externas, não de desempenho. Cada linha declara o método e o comando que a reproduz.
| O que foi medido | Resultado | Método |
|---|---|---|
| Maré prevista contra a Tábua das Marés 2026 da Marinha do Brasil, Porto de Salvador | erro médio de 21 mm (máximo 58 mm) e 5,4 min (máximo 16,8 min) | 36 preamares e baixa-mares publicadas, em janeiro e julho · npm test |
| Transferência do marégrafo para a costa aberta, ajustada por praia | resíduo de 23,5 cm → 3,5 cm | 92 dias de nível do mar costeiro modelado, busca de defasagem e mínimos quadrados na escala · npm run calibrate:tide |
| Viés de período de pico, contra a boia de ondas SIMCosta BA-1 | −2,04 s → +0,21 s, RMSE 3,26 s → 2,56 s | 8.280 horas casadas, jun/2024 a mai/2025 · npm run calibrate:swell |
| Altura significativa do modelo contra a mesma boia, removido o abrigo | r = 0,77, RMSE 19 cm | mesmas 8.280 horas · npm run calibrate:swell |
| Discordância de altura de swell entre dois modelos independentes no mesmo nó | 23–30 cm RMS | 92 dias, MFWAM contra GWAM · npm run calibrate:swell |
| Correlação entre a nota do modelo e a nota humana | a preencher — o dataset de sessões está vazio. Tarefa: docs/readme-gaps.md |
npm run backtest |
A concordância de maré é significativa porque as constantes vêm do TICON-4, uma análise moderna de 19 anos sobre um registro diferente do reprocessamento de 71 componentes da própria Marinha. Duas análises independentes caindo a 2 cm uma da outra é evidência sobre o mundo, não dois programas compartilhando um defeito.
O datum vertical segue o Anexo J da NORMAM-501, que define o Nível de Redução para regime semidiurno como a soma das quatro amplitudes semidiurnas principais abaixo do nível médio. Calculado a partir das constantes desta estação, dá 1,338 m — contra os 1,34 m que a Marinha imprime na tábua de 2026.
Duas coisas que estas medições não estabelecem, ditas junto com elas. A transferência de maré foi ajustada contra um modelo costeiro, não contra um marégrafo, então os 3,5 cm medem concordância entre dois modelos; o que sustenta a corrente é a outra ponta, as constantes de Salvador batendo com a tábua oficial. E contas certas não são previsão certa: enquanto o dataset de sessões estiver vazio, o modelo não provou que acerta a onda.
50 testes em quatro arquivos. 35 deles são propriedades verificadas sobre 1000 entradas geradas por execução — 35.000 casos por rodada da suíte.
| Camada | O que cobre |
|---|---|
src/core/ocean/linear-wave.test.ts |
14 propriedades da física: comprimento de onda nunca excede o de água profunda, refração é sempre perda, a onda quebra na altura que a profundidade permite, swell oblíquo nunca quebra maior que um de frente |
src/core/ocean/tide.test.ts |
8 verificações do motor de maré: velocidade de cada constituinte reconstruída dos próprios números de Doodson, um ano de níveis contra implementação independente, extremos contra a tábua da Marinha |
src/core/score/model.test.ts |
21 propriedades e 1 caso determinístico sobre as invariantes do score, incluindo os dois defeitos encontrados em revisão |
src/server/rate-limit.test.ts |
6 casos do limitador de tentativas: o limite exato, isolamento entre chamadores, reabertura da janela |
Uma verificação que não é de código: npm run audit:a11y roda axe-core contra o HTML que o
servidor devolve em cada rota, lendo a lista de rotas do registro de picos. Estado atual: 10
rotas, nenhuma violação. O script declara os próprios limites — contraste, landmark e presença
de h1 voltam indecidíveis porque jsdom não faz layout nem cascata, e isso é reportado em
separado em vez de contado como aprovação. O contraste é verificado numericamente contra os
tokens de cor: os três níveis de tinta passam 4,5:1 sobre as três superfícies nos dois temas,
pior caso 4,50:1.
- Quase todo parâmetro de pico é estimado, não medido. As normais de praia vêm de azimute calculado sobre coordenadas do OpenStreetMap; janela de swell, faixa de maré e período mínimo são pontos de partida defensáveis que ainda não foram conferidos contra uma sessão. Cada um declara isso na própria página do pico.
- O modelo não aprende com os dados. É físico e explícito; nada aqui é ajustado por regressão sobre resultados.
- A batimetria é desconhecida. O declive do fundo entra como um número por pico. Refração real sobre um recife irregular concentra energia de um jeito que um declive médio não representa.
- O nó de grade fica longe do pico — 4,4 km em Stella Maris, 7,2 km em Guarajuba e 12,4 km na Casinha, onde o modelo marinho não tem célula molhada mais perto. Quanto maior a distância, mais o dado de entrada descreve mar aberto e menos aquele pedaço de costa.
- O promontório do Farol Garcia d'Ávila sombreia parcialmente a Casinha pelo quadrante sul. Essa atenuação não é modelada, então swell de sul ali provavelmente pontua otimista.
- Maré meteorológica não entra. Empilhamento por vento e pressão não faz parte da soma harmônica.
- Não cobre nenhuma praia além dessas três. A geometria de cada pico é escrita à mão, e é esse o ativo — não generaliza por cópia.
- Uso recreativo. Não serve para navegação.
| Fonte | Uso | Licença |
|---|---|---|
| Open-Meteo Marine e Forecast | Ondulação, vento, temperatura do ar e do mar. Previsão e arquivo histórico, que alcança ~2 de outubro de 2021. | CC BY 4.0, uso não comercial |
| TICON-4, marégrafo Salvador USCGS | Constantes harmônicas para a previsão de maré | CC BY 4.0 |
| Marinha do Brasil / CHM, Tábua das Marés 2026 | Alvo de validação apenas | Pública |
| SIMCosta BA-1, boia de ondas UFBA/FURG | Alvo de validação apenas | API aberta |
| OpenStreetMap | Coordenadas e azimutes de linha de costa | ODbL |
A atribuição exigida pela licença do Open-Meteo é renderizada ao lado do dado, não apenas aqui.
Copyright © 2026 Igor Bahia. Todos os direitos reservados. Ver LICENSE.
O código-fonte é publicado para leitura e avaliação, não para reuso. Isso cobre o código deste projeto e não altera os termos do material de terceiros listado acima, que segue sob a própria licença e mantém as próprias obrigações.

