import { htmlTabla, bloqueTabla, marcoFigura, kge } from "../_js/portal.js"
COL = ({dga: "#111111", sim: "#1a5276", cmp: "#c0392b"})
anio = (f) => f instanceof Date ? f.getUTCFullYear() : +String(f).slice(0, 4)
mesNum = (f) => f instanceof Date ? f.getUTCMonth() + 1 : +String(f).slice(5, 7)
dia = (f) => {
if (f instanceof Date) return new Date(f.getUTCFullYear(), f.getUTCMonth(), f.getUTCDate())
const p = String(f).slice(0, 10).split("-").map(Number)
return new Date(p[0], p[1] - 1, p[2])
}
tildePeriodo = (s) => String(s).replace("Calibracion", "Calibración").replace("Validacion", "Validación")
fmtEs = (x, d) => (+x).toLocaleString("es-CL", {minimumFractionDigits: d, maximumFractionDigits: d})
cat = FileAttachment("../datos/series/swat_catalogo.csv").csv({typed: true})
series = FileAttachment("../datos/series/swat_series.csv").csv({typed: true})
metricas = FileAttachment("../datos/series/swat_metricas.csv").csv({typed: true})
param = FileAttachment("../datos/series/swat_parametros.csv").csv({typed: true})
firmas = FileAttachment("../datos/series/swat_firmas.csv").csv({typed: true})
delsa = FileAttachment("../datos/series/swat_delsa.csv").csv({typed: true})
nieve = FileAttachment("../datos/series/swat_nieve_pet.csv").csv({typed: true})
ensemble = FileAttachment("../datos/series/swat_ensemble_lhs200.csv").csv({typed: true})
ids = cat.map(d => d.id)
nombres = new Map(cat.map(d => [d.id, d.nombre]))
figuras = ([
{id: "hidrograma", label: "Hidrograma"},
{id: "scatter", label: "Dispersión obs. vs sim."},
{id: "residuos", label: "Residuos en el tiempo"},
{id: "fdc", label: "Curva de duración"},
{id: "climatologia", label: "Climatología mensual"},
{id: "volumen", label: "Volumen acumulado"},
{id: "residuos_mes", label: "Residuos por mes"},
{id: "kge_comp", label: "Componentes del KGE"},
{id: "ensemble_kge", label: "Ensemble LHS200: KGE por corrida"},
{id: "ensemble_pareto", label: "Ensemble LHS200: NSE cali vs val"},
{id: "delsa", label: "Sensibilidad DELSA"},
{id: "barrido_nieve", label: "Barrido nieve/PET (descartado)"}
])
figIds = figuras.map(d => d.id)
figLabels = new Map(figuras.map(d => [d.id, d.label]))Resultados SWAT+
Tres corridas en cha001: ganador diario LHS200 (run 75), base mensual days_mult1 y referencia mensual meteo25 (run 43). Elige la corrida y el tipo de figura; los gráficos se arman en la página. Negro = DGA; azul = corrida; naranja = comparación (solo hidrograma).
viewof corrida = Inputs.select(ids, {
label: "Corrida",
format: d => nombres.get(d),
value: "lhs200"
})
viewof figura = Inputs.select(figIds, {
label: "Figura",
format: d => figLabels.get(d),
value: "hidrograma"
})ficha = cat.find(d => d.id === corrida)
html`<p><strong>${ficha.veredicto}</strong>. ${ficha.frase} Paso ${ficha.paso}. ${ficha.nota_param}</p>`usaAnios = ["hidrograma", "scatter", "residuos", "fdc", "climatologia", "volumen", "residuos_mes", "kge_comp"].includes(figura)
anios = [...new Set(series.filter(d => d.id === corrida).map(d => anio(d.fecha)))].sort((a, b) => a - b)
viewof desde = Inputs.select(anios, {
label: "Desde",
format: d => String(d),
value: anios.includes(2015) ? 2015 : anios[0]
})
viewof hasta = Inputs.select(anios, {
label: "Hasta",
format: d => String(d),
value: anios.includes(2017) ? 2017 : anios[anios.length - 1]
})
viewof otra = Inputs.select(["(ninguna)", ...ids], {
label: "Comparar con (hidrograma)",
format: d => d === "(ninguna)" ? d : nombres.get(d),
value: "(ninguna)"
})trozo = (id) => series.filter(d =>
d.id === id &&
anio(d.fecha) >= desde &&
anio(d.fecha) <= hasta &&
d.obs != null &&
d.sim != null &&
isFinite(+d.obs) &&
isFinite(+d.sim)
).map(d => ({...d, obs: +d.obs, sim: +d.sim}))
base = trozo(corrida)
extra = otra === "(ninguna)" || otra === corrida || figura !== "hidrograma" ? [] : trozo(otra)
nom = nombres.get(corrida)
rangoTxt = `${desde}–${hasta}`
fdcPts = (filas, campo) => {
const v = filas.map(d => d[campo]).filter(x => x != null && isFinite(x)).sort((a, b) => b - a)
return v.map((q, i) => ({p: 100 * (i + 1) / (v.length + 1), q}))
}
climaMensual = () => {
const g = new Map()
for (const d of base) {
const m = mesNum(d.fecha)
let x = g.get(m)
if (!x) {
x = {mes: m, obs: [], sim: []}
g.set(m, x)
}
x.obs.push(d.obs)
x.sim.push(d.sim)
}
return [...g.values()]
.sort((a, b) => a.mes - b.mes)
.map(d => ({
mes: d.mes,
obs: d3.mean(d.obs),
sim: d3.mean(d.sim)
}))
}
residuosMes = () => {
const g = new Map()
for (const d of base) {
const m = mesNum(d.fecha)
let xs = g.get(m)
if (!xs) {
xs = []
g.set(m, xs)
}
xs.push(d.sim - d.obs)
}
return [...g.entries()]
.sort((a, b) => a[0] - b[0])
.map(([m, xs]) => ({mes: m, residuo: d3.mean(xs)}))
}
volAcum = () => {
let o = 0
let s = 0
return base.map(d => {
o += d.obs
s += d.sim
return {fecha: d.fecha, obs: o, sim: s}
})
}
kgePorPeriodo = () => {
const periodos = [...new Set(base.map(d => d.periodo))]
return periodos.map(p => {
const filas = base.filter(d => d.periodo === p)
const m = kge(filas.map(d => d.obs), filas.map(d => d.sim))
return [
{periodo: tildePeriodo(p), componente: "r", valor: m.r},
{periodo: tildePeriodo(p), componente: "α", valor: m.alpha},
{periodo: tildePeriodo(p), componente: "β", valor: m.beta},
{periodo: tildePeriodo(p), componente: "KGE", valor: m.kge}
]
}).flat().filter(d => d.valor != null && isFinite(d.valor))
}panelFigura = {
if (!base.length && usaAnios) {
return html`<p>No hay pares observado–simulado en el rango elegido.</p>`
}
if (figura === "hidrograma") {
const dominio = ["DGA", nom, ...(extra.length ? [nombres.get(otra)] : [])]
const rango = [COL.dga, COL.sim, ...(extra.length ? [COL.cmp] : [])]
return marcoFigura(
Plot.plot({
width: 860,
height: 380,
y: {grid: true, label: "Caudal (m³/s)"},
color: {legend: true, domain: dominio, range: rango},
marks: [
Plot.lineY(base.map(d => ({...d, serie: "DGA"})), {x: d => dia(d.fecha), y: "obs", stroke: "serie"}),
Plot.lineY(base.map(d => ({...d, serie: nom})), {x: d => dia(d.fecha), y: "sim", stroke: "serie"}),
Plot.lineY(extra.map(d => ({...d, serie: nombres.get(otra)})), {x: d => dia(d.fecha), y: "sim", stroke: "serie"})
]
}),
{titulo: `Hidrograma cha001 · ${nom} (${rangoTxt})`, archivo: `hidrograma_${corrida}`}
)
}
if (figura === "scatter") {
const mx = d3.max(base, d => Math.max(d.obs, d.sim))
return marcoFigura(
Plot.plot({
width: 520,
height: 520,
x: {grid: true, label: "Observado (m³/s)", domain: [0, mx]},
y: {grid: true, label: "Simulado (m³/s)", domain: [0, mx]},
marks: [
Plot.dot(base, {x: "obs", y: "sim", fill: COL.sim, fillOpacity: 0.35, r: 2.2}),
Plot.line([{x: 0, y: 0}, {x: mx, y: mx}], {x: "x", y: "y", stroke: "#666", strokeDasharray: "4,3"})
]
}),
{titulo: `Dispersión obs. vs sim. · ${nom} (${rangoTxt})`, archivo: `scatter_${corrida}`}
)
}
if (figura === "residuos") {
const resid = base.map(d => ({fecha: d.fecha, residuo: d.sim - d.obs}))
return marcoFigura(
Plot.plot({
width: 860,
height: 280,
y: {grid: true, label: "Simulado - observado (m³/s)"},
marks: [
Plot.ruleY([0], {stroke: "#666"}),
Plot.lineY(resid, {x: d => dia(d.fecha), y: "residuo", stroke: COL.sim})
]
}),
{titulo: `Residuos · ${nom} (${rangoTxt})`, archivo: `residuos_${corrida}`}
)
}
if (figura === "fdc") {
return marcoFigura(
Plot.plot({
width: 700,
height: 320,
x: {label: "Probabilidad de excedencia (%)"},
y: {grid: true, label: "Caudal (m³/s)"},
color: {legend: true, domain: ["DGA", "SWAT+"], range: [COL.dga, COL.sim]},
marks: [
Plot.line(fdcPts(base, "obs").map(d => ({...d, serie: "DGA"})), {x: "p", y: "q", stroke: "serie"}),
Plot.line(fdcPts(base, "sim").map(d => ({...d, serie: "SWAT+"})), {x: "p", y: "q", stroke: "serie"})
]
}),
{titulo: `Curva de duración · ${nom} (${rangoTxt})`, archivo: `fdc_${corrida}`}
)
}
if (figura === "climatologia") {
const clima = climaMensual()
return marcoFigura(
Plot.plot({
width: 720,
height: 300,
x: {label: "Mes", ticks: clima.map(d => d.mes)},
y: {grid: true, label: "Caudal medio (m³/s)"},
color: {legend: true, domain: ["DGA", "SWAT+"], range: [COL.dga, COL.sim]},
marks: [
Plot.line(clima.map(d => ({...d, serie: "DGA"})), {x: "mes", y: "obs", stroke: "serie", marker: true}),
Plot.line(clima.map(d => ({...d, serie: "SWAT+"})), {x: "mes", y: "sim", stroke: "serie", marker: true})
]
}),
{titulo: `Climatología mensual · ${nom} (${rangoTxt})`, archivo: `clima_${corrida}`}
)
}
if (figura === "volumen") {
const vol = volAcum()
return marcoFigura(
Plot.plot({
width: 860,
height: 320,
y: {grid: true, label: "Volumen acumulado (m³/s · día)"},
color: {legend: true, domain: ["DGA", "SWAT+"], range: [COL.dga, COL.sim]},
marks: [
Plot.lineY(vol.map(d => ({...d, serie: "DGA"})), {x: d => dia(d.fecha), y: "obs", stroke: "serie"}),
Plot.lineY(vol.map(d => ({...d, serie: "SWAT+"})), {x: d => dia(d.fecha), y: "sim", stroke: "serie"})
]
}),
{titulo: `Volumen acumulado · ${nom} (${rangoTxt})`, archivo: `volumen_${corrida}`}
)
}
if (figura === "residuos_mes") {
const rm = residuosMes()
return marcoFigura(
Plot.plot({
width: 720,
height: 300,
x: {label: "Mes", ticks: rm.map(d => d.mes)},
y: {grid: true, label: "Residuo medio (m³/s)"},
marks: [
Plot.ruleY([0], {stroke: "#666"}),
Plot.barY(rm, {x: "mes", y: "residuo", fill: COL.sim})
]
}),
{titulo: `Residuos medios por mes · ${nom} (${rangoTxt})`, archivo: `residuos_mes_${corrida}`}
)
}
if (figura === "kge_comp") {
const filas = kgePorPeriodo().map(d => ({...d, clave: `${d.periodo} · ${d.componente}`}))
return marcoFigura(
Plot.plot({
width: 860,
height: 320,
x: {label: null},
y: {grid: true, label: "Valor"},
color: {legend: true},
marks: [
Plot.barY(filas, {x: "clave", y: "valor", fill: "periodo", tip: true})
]
}),
{titulo: `Componentes del KGE · ${nom} (${rangoTxt})`, archivo: `kge_${corrida}`}
)
}
if (figura === "ensemble_kge") {
const wrap = document.createElement("div")
if (corrida !== "lhs200") {
const p = document.createElement("p")
p.textContent = "El ensemble LHS200 corresponde a la búsqueda del ganador; se muestra igual como contexto."
wrap.append(p)
}
wrap.append(marcoFigura(
Plot.plot({
width: 860,
height: 320,
x: {label: "Corrida LHS"},
y: {grid: true, label: "KGE calibración"},
marks: [
Plot.dot(ensemble, {
x: "run",
y: "KGE_cali",
fill: d => +d.run === 75 ? COL.cmp : COL.sim,
tip: true,
title: d => `Run ${d.run}: KGE ${(+d.KGE_cali).toLocaleString("es-CL", {maximumFractionDigits: 3})}`
}),
Plot.ruleY([0.803683388492296], {stroke: "#922b21", strokeWidth: 2})
]
}),
{titulo: "Ensemble LHS200: KGE de calibración (rojo = run 75)", archivo: "ensemble_kge"}
))
return wrap
}
if (figura === "ensemble_pareto") {
return marcoFigura(
Plot.plot({
width: 560,
height: 520,
x: {grid: true, label: "NSE calibración"},
y: {grid: true, label: "NSE validación"},
marks: [
Plot.dot(ensemble, {
x: "NSE_cali",
y: "NSE_valid",
fill: d => +d.run === 75 ? COL.cmp : COL.sim,
fillOpacity: 0.7,
tip: true,
title: d => `Run ${d.run}`
})
]
}),
{titulo: "Ensemble LHS200: NSE calibración vs validación", archivo: "ensemble_pareto"}
)
}
if (figura === "delsa") {
return marcoFigura(
Plot.plot({
width: 860,
height: 480,
x: {grid: true, label: "DELSA de primer orden, valor absoluto"},
y: {label: null},
marks: [
Plot.barX(delsa, {
y: "parametro",
x: "abs_DELSA",
fill: COL.sim,
sort: {y: "x", reverse: true},
tip: true,
title: d => `${d.parametro}: ${(+d.abs_DELSA).toLocaleString("es-CL", {maximumFractionDigits: 4})}`
})
]
}),
{titulo: "Sensibilidad DELSA (KGE diario, LHS200)", archivo: "delsa"}
)
}
if (figura === "barrido_nieve") {
return marcoFigura(
Plot.plot({
width: 860,
height: 280,
y: {grid: true, label: "KGE de calibración"},
marks: [
Plot.dot(nieve, {
x: "id",
y: "KGE_cali",
fill: "#5d6d7e",
tip: true,
title: d => `Corrida ${d.id}: ${(+d.KGE_cali).toLocaleString("es-CL", {maximumFractionDigits: 3})}`
}),
Plot.ruleY([0.803683388492296], {stroke: "#922b21", strokeWidth: 2})
]
}),
{titulo: "Barrido nieve/PET (descartado): KGE vs ganador (0,804)", archivo: "barrido_nieve"}
)
}
return html`<p>Figura no disponible.</p>`
}Tablas
esc = ficha.paso
mets = metricas.filter(d => d.id === corrida && d.escala === esc).map(d => ({
periodo: tildePeriodo(d.periodo),
nse: fmtEs(d.nse, 3),
kge: fmtEs(d.kge, 3),
pbias: fmtEs(d.pbias, 1),
rmse: fmtEs(d.rmse, 1),
n: fmtEs(d.n, 0)
}))
bloqueTabla(
`Métricas ${esc} · ${nom}`,
"Escala alineada al paso de la corrida (diario o mensual).",
htmlTabla(
[
{key: "periodo", label: "Periodo", num: false},
{key: "nse", label: "NSE", num: false},
{key: "kge", label: "KGE", num: false},
{key: "pbias", label: "PBIAS [%]", num: false},
{key: "rmse", label: "RMSE [m³/s]", num: false},
{key: "n", label: "n", num: false}
],
mets,
`metricas_${corrida}`
)
)corrida === "lhs200"
? bloqueTabla(
"Vector de calibración LHS run 75",
"Cambio relative = fracción sobre el valor base; replace = valor absoluto.",
htmlTabla(
[
{key: "parametro", label: "Parámetro", num: false},
{key: "cambio", label: "Tipo", num: false},
{key: "valor", label: "Valor", num: false},
{key: "minimo", label: "Mín.", num: false},
{key: "maximo", label: "Máx.", num: false},
{key: "borde", label: "Borde", num: false}
],
param.map(d => ({
parametro: d.parametro,
cambio: d.cambio,
valor: (+d.valor).toLocaleString("es-CL", {maximumFractionDigits: 4}),
minimo: (+d.minimo).toLocaleString("es-CL", {maximumFractionDigits: 3}),
maximo: (+d.maximo).toLocaleString("es-CL", {maximumFractionDigits: 3}),
borde: d.borde
})),
"parametros_run75"
)
)
: html`<p>El vector de parámetros mostrado es solo el del ganador LHS200.</p>`corrida === "lhs200"
? bloqueTabla(
"Firmas FDC del ganador (calibración y validación)",
"PBIAS por tramos; α y β son componentes del KGE.",
htmlTabla(
[
{key: "tramo", label: "Periodo", num: false},
{key: "n", label: "n", num: false},
{key: "pbias", label: "PBIAS [%]", num: false},
{key: "pbias_bajos", label: "PBIAS bajos", num: false},
{key: "pbias_medios", label: "PBIAS medios", num: false},
{key: "pbias_altos", label: "PBIAS altos", num: false},
{key: "r", label: "r", num: false},
{key: "alpha", label: "α", num: false},
{key: "beta", label: "β", num: false}
],
firmas.map(d => ({
tramo: d.tramo === "cali" ? "Calibración" : d.tramo === "val" ? "Validación" : d.tramo,
n: (+d.n).toLocaleString("es-CL", {maximumFractionDigits: 0}),
pbias: (+d.pbias).toLocaleString("es-CL", {minimumFractionDigits: 1, maximumFractionDigits: 1}),
pbias_bajos: (+d.pbias_bajos).toLocaleString("es-CL", {minimumFractionDigits: 1, maximumFractionDigits: 1}),
pbias_medios: (+d.pbias_medios).toLocaleString("es-CL", {minimumFractionDigits: 1, maximumFractionDigits: 1}),
pbias_altos: (+d.pbias_altos).toLocaleString("es-CL", {minimumFractionDigits: 1, maximumFractionDigits: 1}),
r: (+d.r).toLocaleString("es-CL", {minimumFractionDigits: 3, maximumFractionDigits: 3}),
alpha: (+d.alpha).toLocaleString("es-CL", {minimumFractionDigits: 3, maximumFractionDigits: 3}),
beta: (+d.beta).toLocaleString("es-CL", {minimumFractionDigits: 3, maximumFractionDigits: 3})
})),
"firmas_fdc"
)
)
: html``Figuras interactivas a partir de las series y del ensemble LHS200. Barrido nieve/PET solo como contraste descartado.