DGA meteorológica
Precipitación y temperatura diarias de la Dirección General de Aguas.
Ajustes
Estación, año hidrológico, umbral, variable y periodo.
import {
metaVariable, filasDeColumna, analizar, hidro, parseFecha, nombreMesInicio,
plotDiario, plotMensual, plotAnual, plotClima, plotExcedencia, tablaExcedencia, plotHeat, plotCompletitudSerie,
htmlEstadisticos, paresViento, plotFrecuenciaSector,
plotDireccionMensual, rosaViento, ESTACIONES_ANO, fmt, plotDuracion, fmtFecha,
marcoFigura, datosMensual, amplitudAnual, claveFecha, htmlTabla,
resumenDiario, lluviaPorAnio, htmlNumeros,
armarReferencias, lineasCerca, pieNorma, marcasNorma
} from "../_js/portal.js"
import {
serieMaximos, tendenciaMaximos, movilMaximos, ajusteMaximos,
plotTendenciaMax, plotMovilMax, plotHistMax, filasPrueba
} from "../_js/maximos.js"
catalogo = FileAttachment("estaciones.csv").csv({typed: false})
limitesNorma = (fuente === "lt-sensores" || fuente === "lt-satelital")
? FileAttachment("antecedentes/normativa_limites.csv").csv({typed: false})
: []
qurl = new URLSearchParams(document.location.search)
grupoSat = grupoForzado || qurl.get("grupo") || ""
varsMeteoSat = new Set([
"Precipitacion", "PrecipitacionAcumulada",
"TemperaturaAire", "TemperaturaAireAspirada",
"HumedadRelativa", "HumedadRelativaAspirada",
"VelocidadViento", "VelocidadVientoMax", "DireccionViento",
"RadiacionIncidente", "RadiacionReflejada"
])
function estacionEnGrupo(d) {
if (fuente === "camels" && String(d.id).endsWith("_derechos")) return false
if (fuente !== "lt-satelital" || (grupoSat !== "meteo" && grupoSat !== "resto")) return true
const vars = String(d.variables || "").split("|").filter(Boolean)
const met = vars.some((v) => varsMeteoSat.has(v))
const otra = vars.some((v) => !varsMeteoSat.has(v))
return grupoSat === "meteo" ? met : otra
}
sensorImposible = (col, v) => {
if (fuente !== "lt-sensores" || v == null || !Number.isFinite(+v)) return false
const x = +v
if (col === "Clorofila" || col === "Ficocianina" || col === "MateriaOrganica" || col === "Conductividad" || col === "Turbidez") return x < 0
if (col === "Oxigeno") return x < 0
if (col === "OxigenoSat") return x < 0 || x > 200
if (col === "pH") return x < 0 || x > 14
return false
}
deFuente = catalogo.filter((d) => d.fuente_id === fuente && estacionEnGrupo(d))
ids = deFuente.map((d) => d.id)
pre = qurl.get("est")
preMes = Math.min(12, Math.max(1, +(qurl.get("mes") || 4)))
preUmb = Math.min(100, Math.max(50, +(qurl.get("umbral") || 80)))
preMin = Math.min(12, Math.max(1, +(qurl.get("meses") || 10)))
viewof idEst = Inputs.select(ids, {
label: "Estación",
value: ids.includes(pre) ? pre : ids[0],
format: (id) => {
const f = deFuente.find((d) => d.id === id);
if (!f) return id;
const repetido = deFuente.filter((d) => d.nombre === f.nombre).length > 1;
return repetido ? `${f.nombre} (${id})` : f.nombre;
}
})
viewof mesInicio = Inputs.range([1, 12], {
label: "Mes de inicio del año hidrológico",
value: preMes,
step: 1
})
viewof umbralPct = Inputs.range([50, 100], {
label: "Umbral de mes válido [%]",
value: preUmb,
step: 5
})
viewof minMeses = Inputs.range([1, 12], {
label: "Meses para aceptar el año",
value: preMin,
step: 1
})ficha = deFuente.find((d) => d.id === idEst)
crudo = await d3.csv(`${carpeta}/${fuente}/${idEst}.csv`)
columnas = crudo.columns.filter((c) => {
if (c === "fecha" || c === "DireccionViento") return false
if (fuente === "camels" && c.startsWith("Derechos")) return false
if (fuente === "camels" && grupoForzado === "meteo" && (c === "Caudal" || c === "Caudal_mm")) return false
if (fuente === "camels" && grupoForzado !== "meteo" && c !== "Caudal" && c !== "Caudal_mm") return false
if (fuente !== "lt-satelital" || (grupoSat !== "meteo" && grupoSat !== "resto")) return true
return grupoSat === "meteo" ? varsMeteoSat.has(c) : !varsMeteoSat.has(c)
})
inicios = [...new Set(crudo.map((r) => hidro(parseFecha(r.fecha), mesInicio).inicio))]
.filter((x) => Number.isFinite(x))
.sort((a, b) => a - b)
preVar = qurl.get("var")
viewof variable = Inputs.select(columnas, {
label: "Variable",
format: (c) => metaVariable(c).nombre,
value: columnas.includes(preVar) ? preVar : (columnas.includes("Precipitacion") ? "Precipitacion" : columnas[0])
})
viewof desde = Inputs.select(inicios, {
label: "Periodo desde",
format: (y) => `${y}-${mesInicio === 1 ? y : y + 1}`,
value: inicios.includes(+qurl.get("desde")) ? +qurl.get("desde") : inicios[0]
})
viewof hasta = Inputs.select(inicios, {
label: "Periodo hasta",
format: (y) => `${y}-${mesInicio === 1 ? y : y + 1}`,
value: inicios.includes(+qurl.get("hasta")) ? +qurl.get("hasta") : inicios[inicios.length - 1]
})Características de la estación
Coordenadas, periodo de registro, completitud global y resumen del periodo elegido.
function completitudObservada(rows, col) {
const dias = new Set()
let t0 = null
let t1 = null
for (const r of rows) {
const t = parseFecha(r.fecha)
const crudoV = r[col]
if (!t || crudoV === "" || crudoV == null || !Number.isFinite(+crudoV)) continue
const v = +crudoV
if ([-99, 99, -999, 999, -9999, 9999].includes(v) || Math.abs(v) >= 1e10) continue
if (sensorImposible(col, v)) continue
dias.add(claveFecha(t))
if (!t0 || t < t0) t0 = t
if (!t1 || t > t1) t1 = t
}
if (!dias.size || !t0) return null
const span = Math.round((t1 - t0) / 86400000) + 1
return span > 0 ? dias.size / span : null
}
guardada = String(ficha.completitud ?? "").trim()
comp = guardada !== "" && Number.isFinite(+guardada) ? +guardada : completitudObservada(crudo, variable)
compTxt = comp == null ? "—" : fmt(comp * 100, 1) + " %"html`<div class="portal-datos">
<div><dt>Estación</dt><dd>${ficha.nombre}</dd></div>
<div><dt>Coordenadas</dt><dd>${ficha.lat ? `${fmt(+ficha.lat, 3)}, ${fmt(+ficha.lon, 3)}` : "Sin coordenada"}</dd></div>
<div><dt>Periodo de registro</dt><dd>${fmtFecha(parseFecha(ficha.desde))} – ${fmtFecha(parseFecha(ficha.hasta))}</dd></div>
<div><dt>Completitud global</dt><dd>${compTxt}</dd></div>
<div><dt>Año hidrológico</dt><dd>Empieza en ${nombreMesInicio(mesInicio)}</dd></div>
</div>
<p class="portal-origen">${ficha.fuente} · <a href=${`../comparador/${variable.startsWith("Caudal") ? "caudal" : (carpeta === "lago" ? "lago" : "meteo")}.html?s1=${fuente}:${idEst}:${variable}`}>Comparar esta serie</a></p>`CENTINELAS = [-99, 99, -999, 999, -9999, 9999]
filasPeriodo = filasDeColumna(crudo, variable).filter((f) => {
const h = hidro(f.t, mesInicio);
return h.inicio >= desde && h.inicio <= hasta;
})
nCentinelas = filasPeriodo.filter((f) => f.v != null && (CENTINELAS.includes(+f.v) || Math.abs(+f.v) >= 1e10)).length
nSensor = filasPeriodo.filter((f) => sensorImposible(variable, f.v)).length
filas = filasPeriodo.map((f) => {
if (f.v != null && (CENTINELAS.includes(+f.v) || Math.abs(+f.v) >= 1e10)) return { t: f.t, v: null }
if (sensorImposible(variable, f.v)) return { t: f.t, v: null }
return f
})
meta = metaVariable(variable)
a = analizar(filas, {
mesInicio,
umbral: umbralPct / 100,
minMeses,
desde,
hasta,
agg: meta.agg
})
r = resumenDiario(filas)
panelNumeros = {
let minF = null;
let maxF = null;
let lluvia = 0;
for (const p of a.puntos) {
if (!minF || p.v < minF.v) minF = p;
if (!maxF || p.v > maxF.v) maxF = p;
if (meta.agg === "sum" && p.v >= 1) lluvia += 1;
}
const caja = document.createElement("div");
caja.className = "portal-resumen";
const h = document.createElement("h3");
h.textContent = "Resumen del periodo elegido";
const conValor = filas.filter((f) => f.v != null)
const diasUnicos = new Set(conValor.map((f) => claveFecha(f.t))).size
caja.append(h, htmlNumeros(r, meta, {
minFecha: minF ? fmtFecha(minF.t) : "",
maxFecha: maxF ? fmtFecha(maxF.t) : "",
centinelas: nCentinelas,
lluvia: meta.agg === "sum" ? lluvia : null,
mediaAnual: meta.agg === "sum" ? a.mediaAnual : null,
pieN: conValor.length > diasUnicos * 1.2 ? "lecturas" : "días con dato",
descartados: nSensor
}));
return caja;
}Estadísticos de la serie
Por mes del año hidrológico y del conjunto anual. Dev.st es la desviación estándar.
{
const caja = document.createElement("div");
caja.className = "portal-resumen";
caja.append(htmlEstadisticos(a, idEst + "_" + variable));
return caja;
}aExtremos = {
const deMaximoDiario = fuente === "lt-caudal" && variable === "Caudal" && columnas.includes("CaudalMax");
if (!deMaximoDiario) return a;
const filasMax = filasDeColumna(crudo, "CaudalMax").filter((f) => {
const h = hidro(f.t, mesInicio);
return h.inicio >= desde && h.inicio <= hasta;
}).map((f) => (
f.v != null && (CENTINELAS.includes(+f.v) || Math.abs(+f.v) >= 1e10) ? { t: f.t, v: null } : f
));
return analizar(filasMax, {
mesInicio,
umbral: umbralPct / 100,
minMeses,
desde,
hasta,
agg: metaVariable("CaudalMax").agg
});
}Figuras
Serie diaria, mensual, anual, climatología, excedencia y completitud. Algunas variables agregan una figura propia, a veces con su tabla.
esCaudal = variable === "Caudal" || variable === "Caudal_mm" || variable === "CaudalMin" || variable === "CaudalMax"
esLluvia = variable.startsWith("Precipitacion") && variable !== "PrecipitacionAcumulada"
tieneViento = (variable === "VelocidadViento" || variable === "DireccionViento" || variable === "VelocidadViento2m")
&& crudo.columns.includes("DireccionViento") && crudo.columns.includes("VelocidadViento")
vistas = (() => {
if (variable.startsWith("Derechos")) return ["Anual"];
const base = ["Diaria", "Mensual", "Anual", "Estacional", "Completitud"];
if (!variable.startsWith("Derechos") && !String(variable).includes("Direccion")) {
base.splice(base.indexOf("Estacional") + 1, 0, "Excedencia");
}
const enDatosMeteo = grupoForzado === "meteo" || carpeta === "meteo";
const serieExt = serieMaximos(aExtremos);
const tendencia = enDatosMeteo && serieExt.suficiente && !String(variable).includes("Direccion") && !variable.startsWith("Derechos");
const distribucion = serieExt.suficiente && (
(enDatosMeteo && esLluvia) ||
(fuente === "dga-caudal" && variable === "Caudal") ||
(fuente === "lt-caudal" && (variable === "Caudal" || variable === "CaudalMax"))
);
if (tendencia || distribucion) {
const extra = [];
if (tendencia) extra.push("Tendencia", "Media móvil");
if (distribucion) extra.push("Distribución");
const pos = base.indexOf("Excedencia");
base.splice(pos >= 0 ? pos + 1 : base.indexOf("Completitud"), 0, ...extra);
}
if (tipoSerie === "nivel") base.push("Amplitud");
if (esCaudal) base.push("Duración");
if (esLluvia && (fuente !== "lt-satelital" || enDatosMeteo)) base.push("Días de lluvia");
if (fuente === "lt-satelital") {
if (variable === "Precipitacion") base.push("Frecuencia de lluvia");
if (variable === "TemperaturaAire" || variable === "TemperaturaAireAspirada") base.push("Ciclo de la temperatura");
if (variable === "HumedadRelativa" || variable === "HumedadRelativaAspirada") base.push("Ciclo de la humedad");
if (variable === "VelocidadViento" || variable === "VelocidadVientoMax") base.push("Ciclo del viento");
if (variable === "RadiacionIncidente" || variable === "RadiacionReflejada") base.push("Ciclo de la radiación");
}
if (tieneViento) base.push("Sectores", "Dirección", "Rosa");
if (fuente === "lt-caudal") base.push("Caudal horario", "Altura horaria", "Altura–caudal", "Ciclo horario");
if (fuente === "lt-sensores") base.push("Variables del sitio");
return base;
})()
pedida = new URLSearchParams(location.search).get("vista")
function etiquetaVista(v) {
if (v === "Diaria") return "Serie diaria"
if (v === "Estacional") return "Climatología estacional"
if (v === "Excedencia") return "Curvas de excedencia"
return v
}
viewof vista = Inputs.radio(vistas, {
label: "Vista",
value: vistas.includes(pedida) ? pedida : "Anual",
format: etiquetaVista
})viewof ventanaAnios = {
const max = Math.max(1, hasta - desde + 1);
const raw = qurl.get("ventana");
const completo = raw === "completo" || raw === "Periodo completo";
const nPedido = Number.isFinite(+raw) && +raw > 0 ? +raw : 5;
const nIni = Math.min(max, Math.max(1, nPedido));
const modo = Inputs.radio(["Periodo completo", "Últimos años"], {
label: "Ventana",
value: completo ? "Periodo completo" : "Últimos años"
});
const n = Inputs.range([1, max], {
label: "Cantidad de últimos años",
value: nIni,
step: 1
});
const caja = document.createElement("div");
caja.className = "portal-ventana";
caja.style.display = (vista === "Diaria" || vista === "Mensual") ? "block" : "none";
const filaN = document.createElement("div");
filaN.style.display = completo ? "none" : "block";
filaN.append(n);
caja.append(modo, filaN);
const publicar = () => {
const ultimos = modo.value === "Últimos años";
filaN.style.display = ultimos ? "block" : "none";
caja.value = ultimos ? +n.value : null;
caja.dispatchEvent(new Event("input", { bubbles: true }));
};
modo.addEventListener("input", publicar);
n.addEventListener("input", publicar);
caja.value = completo ? null : nIni;
return caja;
}{
const u = new URL(location.href);
const set = (k, v) => { if (v == null || v === "") u.searchParams.delete(k); else u.searchParams.set(k, String(v)); };
set("est", idEst);
set("var", variable);
set("mes", mesInicio);
set("umbral", umbralPct);
set("meses", minMeses);
set("vista", vista);
set("desde", desde);
set("hasta", hasta);
set("ventana", ventanaAnios == null ? "completo" : ventanaAnios);
for (const k of ["f_criterio", "f_modo", "f_min", "f_max", "f_salto_pos", "f_salto_neg", "f_rn"]) u.searchParams.delete(k);
if (u.search !== location.search) history.replaceState(null, "", u);
return html``;
}viewof rosaCual = {
const sel = Inputs.select(Object.keys(ESTACIONES_ANO), { label: "Rosa de viento", value: "Año" });
const caja = document.createElement("div");
caja.style.display = ["Velocidad", "Sectores", "Dirección", "Rosa"].includes(vista) ? "block" : "none";
caja.append(sel);
const publicar = () => {
caja.value = sel.value;
caja.dispatchEvent(new Event("input", { bubbles: true }));
};
sel.addEventListener("input", publicar);
caja.value = sel.value;
return caja;
}{
const origen = `datos/${carpeta}/${fuente}/${idEst}.csv`;
const archivo = `${idEst}_${variable}_${vista}`;
const normaDe = (valores) => {
if (fuente !== "lt-sensores" && fuente !== "lt-satelital") return null;
const ys = (valores || []).map(Number).filter((v) => Number.isFinite(v));
if (!ys.length || !limitesNorma) return null;
const refs = armarReferencias(limitesNorma, meta.nombre, meta.unidad, ficha.nombre);
if (!refs.nsca.length && !refs.smith) return null;
const cerca = lineasCerca(refs, Math.min(...ys), Math.max(...ys));
const orden = ys.slice().sort((a, b) => a - b);
const iMed = (orden.length - 1) / 2;
const mediana = (orden[Math.floor(iMed)] + orden[Math.ceil(iMed)]) / 2;
return { refs, cerca, mediana };
};
const figura = (nodo, opts, n) => {
const fig = marcoFigura(nodo, opts);
const pie = n ? pieNorma({ refs: n.refs, cerca: n.cerca, mediana: n.mediana, unidad: meta.unidad }) : null;
if (pie) fig.append(pie);
return fig;
};
if (vista === "Diaria") {
const fin = a.puntos.length ? a.puntos[a.puntos.length - 1].t : null;
const desdeDiaria = ventanaAnios == null || !fin
? null
: new Date(fin.getFullYear() - ventanaAnios, fin.getMonth(), fin.getDate());
const ys = a.puntos
.filter((d) => d.v != null && (!desdeDiaria || d.t >= desdeDiaria))
.map((d) => d.v);
const n = normaDe(ys);
return figura(plotDiario(Plot, a, meta, {
barras: variable.startsWith("Precipitacion"),
desde: desdeDiaria,
cerca: n && n.cerca
}), {
titulo: `${meta.nombre} diaria [${meta.unidad}]`,
que: (variable.startsWith("Precipitacion")
? "Cada día con dato sale desde cero. Si el periodo es largo, cada día es una raya; si es corto, una barra."
: "Línea diaria de los días con dato.") + (n ? " Las líneas y la tabla son los umbrales de este parámetro." : ""),
origen, archivo
}, n);
}
if (vista === "Mensual") {
const full = datosMensual(a);
const fin = full.length ? full[full.length - 1].t : null;
const rango = (ventanaAnios == null || !fin)
? null
: [new Date(fin.getFullYear() - ventanaAnios, fin.getMonth(), 15), fin];
const dataM = rango ? full.filter((d) => d.t >= rango[0] && d.t <= rango[1]) : full;
const n = normaDe(dataM.map((d) => d.valor));
return figura(plotMensual(Plot, a, meta, rango, { cerca: n && n.cerca }), {
titulo: `${meta.nombre} mensual [${meta.unidad}]`,
que: "Agregado del mes. El gris no alcanza el umbral de días con dato. Con más de 240 meses las barras van sin separación." + (n ? " Las líneas y la tabla son los umbrales de este parámetro." : ""),
origen, archivo
}, n);
}
if (vista === "Anual" && tipoSerie === "nivel") {
const amp = amplitudAnual(a.puntos).sort((u, v) => (u.label < v.label ? -1 : u.label > v.label ? 1 : 0));
const media = amp.map((d) => ({ ...d, serie: "Media" }));
const minimo = amp.map((d) => ({ ...d, serie: "Mínimo" }));
const maximo = amp.map((d) => ({ ...d, serie: "Máximo" }));
const fig = Plot.plot({
width: 900,
height: 420,
marginBottom: 88,
marginLeft: 48,
y: { grid: true, label: meta.unidad ? `Nivel [${meta.unidad}]` : "Nivel" },
x: { label: "Año hidrológico", tickRotate: 90 },
color: {
legend: true,
domain: ["Mínimo", "Media", "Máximo"],
range: ["#1a5276", "#1B7F4E", "#C62828"]
},
marks: [
Plot.ruleX(amp, { x: "label", y1: "min", y2: "max", stroke: "#bdbdbd" }),
Plot.lineY(media, { x: "label", y: "media", z: "serie", stroke: "serie", strokeWidth: 2 }),
Plot.dot(minimo, { x: "label", y: "min", fill: "serie", r: 2.5 }),
Plot.dot(maximo, { x: "label", y: "max", fill: "serie", r: 2.5 }),
Plot.tip(amp, Plot.pointerX({
x: "label",
y: "media",
title: (d) => `Año hidrológico ${d.label}: media ${fmt(d.media)}, mín ${fmt(d.min)}, máx ${fmt(d.max)}`
}))
]
});
return marcoFigura(fig, {
titulo: `${meta.nombre}: media, mínimo y máximo anual [${meta.unidad}]`,
que: "Por año hidrológico, a partir de los días con dato del periodo elegido.",
origen, archivo
});
}
if (vista === "Anual") {
const n = normaDe(a.anual.map((d) => d.valor));
return figura(plotAnual(Plot, a, meta, { cerca: n && n.cerca }), {
titulo: `${meta.nombre} por año hidrológico [${meta.unidad}]`,
que: n
? "Año válido en azul y el incompleto en gris. La línea gris es la media de los años válidos. Las demás líneas y la tabla son los umbrales de este parámetro."
: "Año válido en azul y el incompleto en gris. La línea punteada es la media de los años válidos.",
origen, archivo
}, n);
}
if (vista === "Amplitud") {
const amp = amplitudAnual(a.puntos);
const fig = Plot.plot({
width: 900,
height: 360,
marginBottom: 48,
y: { grid: true, label: `Amplitud [${meta.unidad}]` },
x: { label: "Año hidrológico", tickRotate: -60 },
marks: [
Plot.barY(amp, { x: "label", y: "amplitud", fill: "#1a5276" }),
Plot.tip(amp, Plot.pointerX({
x: "label",
y: "amplitud",
title: (d) => `Año hidrológico ${d.label}: ${fmt(d.amplitud)} ${meta.unidad}`
}))
]
});
return marcoFigura(fig, {
titulo: `Amplitud anual de ${meta.nombre} [${meta.unidad}]`,
que: "Diferencia entre el máximo y el mínimo diarios de cada año hidrológico.",
origen, archivo
});
}
if (vista === "Estacional") {
const ys = [];
for (const d of a.clima) {
for (const k of ["media", "p10", "p90"]) if (Number.isFinite(+d[k])) ys.push(+d[k]);
}
const n = normaDe(ys);
return figura(plotClima(Plot, a, meta, { cerca: n && n.cerca }), {
titulo: `Climatología de ${meta.nombre} [${meta.unidad}]`,
que: "Media de los meses válidos y banda P10–P90. Los meses siguen el año hidrológico, no el año calendario. No hay barras." + (n ? " Las líneas y la tabla son los umbrales de este parámetro." : ""),
origen, archivo
}, n);
}
if (vista === "Excedencia") {
const tab = tablaExcedencia(a);
const cols = [
{ key: "prob", label: "Excedencia" },
...tab.columnasMes.map((m) => ({ key: m, label: m }))
];
const filas = tab.filas.map((f) => {
const o = { prob: f.prob };
for (const m of tab.columnasMes) o[m] = f[m] == null ? "" : fmt(f[m]);
return o;
});
const n = { prob: "n (meses válidos)" };
for (const m of tab.columnasMes) n[m] = String(tab.nfila[m] ?? 0);
filas.push(n);
const caja = document.createElement("div");
caja.append(
marcoFigura(plotExcedencia(Plot, a, meta), {
titulo: `Variación estacional de ${meta.nombre} [${meta.unidad}]`,
que: "Cuantiles de los meses válidos con la posición de gráfica de Weibull, k/(n+1). Cada curva es una probabilidad de excedencia: 5 % es un valor alto y 95 % uno bajo. El eje de meses es el año hidrológico, no el año calendario.",
origen,
archivo: `${idEst}_${variable}_excedencia`
}),
htmlTabla(cols, filas, `${idEst}_${variable}_excedencia.csv`)
);
return caja;
}
if (vista === "Tendencia" || vista === "Media móvil" || vista === "Distribución") {
const serie = serieMaximos(aExtremos);
if (vista === "Tendencia") {
const ten = tendenciaMaximos(serie.puntos);
const lado = document.createElement("div");
const h = document.createElement("h3");
h.textContent = "Pruebas sobre la serie";
lado.append(h, htmlTabla(
[
{ key: "test", label: "Test" },
{ key: "estadistico", label: "Estadístico" },
{ key: "p", label: "p-valor" }
],
filasPrueba(ten, meta.unidad),
`${idEst}_tendencia_maximos.csv`
));
const caja = document.createElement("div");
caja.className = "portal-lado";
caja.append(marcoFigura(plotTendenciaMax(Plot, serie.puntos, ten, null, meta.unidad), {
titulo: `Máximo diario por año hidrológico [${meta.unidad}]`,
que: "Cada punto es el máximo diario de un año hidrológico válido, unido al siguiente. La recta de mínimos cuadrados relaciona ese máximo con el año. La de Sen toma como pendiente la mediana de las pendientes entre pares de años, y como intercepto la mediana de los residuos. El número del eje es el año en que empieza el año hidrológico, no el año calendario.",
origen,
archivo: `${idEst}_tendencia_maximos`
}), lado);
return caja;
}
if (vista === "Media móvil") {
const mov = movilMaximos(serie.puntos);
return marcoFigura(plotMovilMax(Plot, mov, meta.unidad), {
titulo: `Media móvil de 5 años del máximo diario [${meta.unidad}]`,
que: "Cada punto promedia el máximo de ese año hidrológico y el de los cuatro anteriores. Los cuatro primeros años quedan vacíos, porque la ventana todavía no junta cinco valores. Las dos rectas se calculan con esta serie suavizada. La línea vertical punteada marca el año de Pettitt: aquel en que más se separan la media de lo anterior y la media de lo posterior. Junto a la marca va el p-valor.",
origen,
archivo: `${idEst}_media_movil_maximos`
});
}
const xs = serie.puntos.map((d) => d.max);
const aj = ajusteMaximos(xs);
const colsRet = [
{ key: "periodo", label: "T [años]" },
...aj.orden.map((nombre) => ({ key: nombre, label: nombre, num: true }))
];
const caja = document.createElement("div");
const hKs = document.createElement("h3");
hKs.textContent = "Ajuste de Kolmogorov–Smirnov";
const hRet = document.createElement("h3");
hRet.textContent = "Periodos de retorno";
caja.append(
marcoFigura(plotHistMax(Plot, xs, aj, meta.unidad), {
titulo: "Máximos diarios y curvas ajustadas",
que: "Histograma en densidad. Encima van las siete curvas: GEV, Gumbel, exponencial, normal y lognormal por máxima verosimilitud; gamma por máxima verosimilitud; Pearson III por momentos (media, varianza y asimetría). El histograma no elige un ganador.",
origen,
archivo: `${idEst}_hist_maximos`
}),
hKs,
htmlTabla(
[
{ key: "distribucion", label: "Distribución" },
{ key: "ks", label: "Kolmogorov–Smirnov" },
{ key: "p", label: "p-valor" }
],
aj.filasKs,
`${idEst}_ks_maximos.csv`
),
hRet,
htmlTabla(colsRet, aj.filasRetorno, `${idEst}_retorno_maximos.csv`)
);
return caja;
}
if (vista === "Completitud") {
const c = plotCompletitudSerie(Plot, a);
const caja = document.createElement("div");
caja.append(
marcoFigura(plotHeat(Plot, a), {
titulo: "Completitud mensual",
que: "Fracción de días con dato en cada mes del año hidrológico.",
origen, archivo: archivo + "_calor"
}),
marcoFigura(c.linea, {
titulo: "Fracción mensual en el tiempo",
que: "La misma fracción, como serie.",
origen, archivo: archivo + "_fraccion"
}),
marcoFigura(c.anio, {
titulo: "Meses válidos por año",
que: "Cuántos meses del año superan el umbral. La línea roja es el mínimo exigido.",
origen, archivo: archivo + "_meses"
})
);
return caja;
}
if (vista === "Duración") {
const fig = plotDuracion(Plot, a.puntos, meta);
const caja = document.createElement("div");
caja.append(marcoFigura(fig, {
titulo: `Curva de duración [${meta.unidad}]`,
que: "Eje vertical logarítmico. Solo entran los días con dato positivo.",
origen,
archivo: `${idEst}_duracion`
}));
return caja;
}
if (vista === "Días de lluvia") {
const anios = lluviaPorAnio(a.puntos);
const tope = Math.max(1, ...anios.map((d) => d.dias), 0);
const fig = Plot.plot({
width: 900,
height: 320,
marginBottom: 70,
x: { label: "Año hidrológico", tickRotate: 90 },
y: { grid: true, label: "Días con precipitación ≥ 1 mm", domain: [0, tope] },
marks: [
Plot.barY(anios, { x: "label", y: "dias", fill: "#1a5276" }),
Plot.tip(anios, Plot.pointerX({
x: "label",
y: "dias",
title: (d) => `Año hidrológico ${d.label}: ${d.dias} días, máximo ${fmt(d.max)} el ${fmtFecha(d.fecha)}`
}))
]
});
const tabla = htmlTabla(
[
{ key: "label", label: "Año hidrológico" },
{ key: "dias", label: "Días ≥ 1 mm", num: true, dec: 0 },
{ key: "max", label: "Máximo diario [mm]", num: true },
{ key: "fecha", label: "Fecha del máximo" }
],
anios.map((d) => ({ label: d.label, dias: d.dias, max: d.max, fecha: fmtFecha(d.fecha) })),
`${idEst}_lluvia_anual.csv`
);
const caja = document.createElement("div");
caja.append(marcoFigura(fig, {
titulo: "Días con precipitación de al menos 1 mm",
que: "Conteo por año hidrológico. La tabla trae el máximo diario de ese año y su fecha.",
origen,
archivo: `${idEst}_dias_lluvia`
}), tabla);
return caja;
}
if (["Velocidad", "Sectores", "Dirección", "Rosa"].includes(vista)) {
const porDia = new Map(filas.map((f) => [claveFecha(f.t), f]));
const crudoV = crudo.map((r) => {
if (variable !== "VelocidadViento" && variable !== "DireccionViento") return r;
const t = parseFecha(r.fecha);
const f = t ? porDia.get(claveFecha(t)) : null;
if (!f || f.v != null) return r;
return { ...r, [variable]: "" };
});
const meses = ESTACIONES_ANO[rosaCual];
const pares = paresViento(crudoV, desde, hasta, mesInicio, meses);
if (!pares.length) return html`<p>No hay pares de dirección y velocidad en el periodo.</p>`;
if (vista === "Velocidad") {
const vel = analizar(filasDeColumna(crudoV, "VelocidadViento").filter((f) => {
const h = hidro(f.t, mesInicio);
return h.inicio >= desde && h.inicio <= hasta;
}), { mesInicio, umbral: umbralPct / 100, minMeses, desde, hasta, agg: "mean" });
return marcoFigura(plotAnual(Plot, vel, metaVariable("VelocidadViento")), {
titulo: "Velocidad del viento [m/s]",
que: "Media de los meses válidos de cada año hidrológico.",
origen, archivo: `${idEst}_viento_anual`
});
}
if (vista === "Sectores") return marcoFigura(plotFrecuenciaSector(Plot, pares), {
titulo: "Frecuencia por sector",
que: "Cuenta de días con dirección y velocidad, por octante.",
origen, archivo: `${idEst}_viento_sector`
});
if (vista === "Dirección") return marcoFigura(plotDireccionMensual(Plot, pares, mesInicio, umbralPct / 100), {
titulo: "Dirección media vectorial",
que: "Media circular de la dirección en los meses que cumplen el umbral.",
origen, archivo: `${idEst}_viento_direccion`
});
return marcoFigura(rosaViento(d3, pares, `Rosa ${rosaCual}`), {
titulo: `Rosa de viento, ${rosaCual}`,
que: "Frecuencia conjunta de dirección y velocidad.",
origen, archivo: `${idEst}_rosa_${rosaCual}`
});
}
return html``;
}