multimodel improvements

This commit is contained in:
terraputix
2026-08-02 22:24:51 +02:00
parent bde815c35a
commit ea9eb17d75
19 changed files with 2493 additions and 826 deletions
@@ -0,0 +1,130 @@
import { findModel, models as modelOptions } from '../../options';
import type { ModelSeriesData } from '$lib/services/weather';
const MODEL_COLORS = [
'#0072b2',
'#d55e00',
'#009e73',
'#cc79a7',
'#e69f00',
'#56b4e9',
'#6f4ead',
'#8c564b',
'#e11d48',
'#65a30d',
'#0891b2',
'#c026d3',
'#2563eb',
'#ea580c',
'#059669',
'#9333ea'
] as const;
/**
* High-contrast categorical colour. Within a comparison, selection order owns
* the palette slot so adjacent series never receive accidentally similar hues.
*/
export function modelColor(modelId: string, modelOrder?: readonly string[]): string {
const selectedIndex = modelOrder?.indexOf(modelId) ?? -1;
if (selectedIndex >= 0) return MODEL_COLORS[selectedIndex % MODEL_COLORS.length]!;
const canonicalIndex = modelOptions.findIndex((model) => model.value === modelId);
if (canonicalIndex >= 0) return MODEL_COLORS[canonicalIndex % MODEL_COLORS.length]!;
let hash = 2166136261;
for (let i = 0; i < modelId.length; i++) {
hash ^= modelId.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return MODEL_COLORS[Math.abs(hash) % MODEL_COLORS.length]!;
}
export function modelLabel(modelId: string): string {
return findModel(modelId)?.label ?? modelId.replace(/_/g, ' ');
}
export function sanitizeList(
values: string[] | null,
allowed: ReadonlySet<string>
): string[] | null {
if (!values) return null;
const clean = [...new Set(values.filter((value) => allowed.has(value)))];
return clean.length > 0 ? clean : null;
}
export function isWindDirection(variable: string): boolean {
return variable.startsWith('wind_direction_');
}
export function validDirectionValues(values: number[] | undefined): (number | null)[] {
return (values ?? []).map((value) =>
Number.isFinite(value) && value >= 0 && value <= 360 ? value : null
);
}
export interface PrecipitationAgreementPoint {
wetCount: number;
availableCount: number;
median: number;
min: number;
max: number;
}
/** Per-timestamp precipitation occurrence agreement and amount spread. */
export function precipitationAgreement(
models: ModelSeriesData[],
variable: string,
timeLength: number,
wetThreshold: number
): (PrecipitationAgreementPoint | null)[] {
const points: (PrecipitationAgreementPoint | null)[] = new Array(timeLength).fill(null);
for (let index = 0; index < timeLength; index++) {
const values: number[] = [];
for (const model of models) {
const value = model.variables[variable]?.[index];
if (value === undefined || !Number.isFinite(value) || value < 0) continue;
values.push(value);
}
if (values.length === 0) continue;
values.sort((left, right) => left - right);
const middle = Math.floor(values.length / 2);
const median =
values.length % 2 === 0 ? (values[middle - 1] + values[middle]) / 2 : values[middle];
points[index] = {
wetCount: values.filter((value) => value > wetThreshold).length,
availableCount: values.length,
median,
min: values[0],
max: values[values.length - 1]
};
}
return points;
}
export function cardinalDirection(value: number): string {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
return directions[Math.round((((value % 360) + 360) % 360) / 45) % 8];
}
/** Arithmetic model mean for scalar variables; missing consensus stays missing. */
export function modelMean(
models: ModelSeriesData[],
variable: string,
timeLength: number
): (number | null)[] {
const totals = new Array<number>(timeLength).fill(0);
const counts = new Array<number>(timeLength).fill(0);
for (const model of models) {
const values = model.variables[variable] ?? [];
for (let i = 0; i < Math.min(values.length, timeLength); i++) {
const value = values[i];
if (!Number.isFinite(value)) continue;
totals[i] += value;
counts[i]++;
}
}
return totals.map((total, i) =>
counts[i] > 0 ? Math.round((total / counts[i]) * 10) / 10 : null
);
}