Skip to content

Repository files navigation

Refract

Previsão de surf calculada por pico, não por praia.

A home do Refract: a manchete "A previsão comum diz como está a praia. Refract diz como está o pico", e abaixo o painel de leitura marcando "escuro agora, melhor às 06:00" com nota 9,4 em Genipabu, a frase que explica a nota e o diagrama da geometria do pico à direita.

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.

A página do pico Scar Reef, com a nota do momento, a frase que a explica e o diagrama da janela de swell mostrando o setor de mar aberto que o pico alcança, a linha de costa e as cristas chegando na direção da ondulação.


Visão geral

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.

Garantias

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.

Como rodar

Node 20.9 ou superior. Sem chave de API e sem banco de dados.

npm install
npm run dev        # http://localhost:3000
npm test           # 50 testes: 35 propriedades × 1000 casos, mais 15 por exemplo
npm run typecheck
npm run lint
npm run build

Verificações que dependem do app respondendo:

npm run build && npm start &
npm run audit:a11y   # axe-core sobre o HTML servido, rota por rota

Scripts 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 correlaciona

Arquitetura

flowchart 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"]
Loading

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.

Alternativas consideradas

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.

Medições

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.

Testes

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.

Limitações

  • 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.

Fontes de dados

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.

Licença

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.

Releases

Packages

Contributors

Languages