aemet-api
Version:
Cliente TypeScript para la API de AEMET (Agencia Estatal de Meteorología)
564 lines (563 loc) • 25.3 kB
JavaScript
/**
* Clase principal de la API de AEMET
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.Aemet = void 0;
const constants_1 = require("./lib/constants");
const utils_1 = require("./lib/utils");
/**
* Cliente para la API de AEMET (Agencia Estatal de Meteorología)
*/
class Aemet {
/**
* Inicializa un nuevo cliente de AEMET
* @param apiKey - Clave API para acceder a la API de AEMET
* @param options - Opciones adicionales
*/
constructor(apiKey, options = {}) {
this.provinciaIndex = {};
if (!apiKey) {
throw new Error('La API key es obligatoria');
}
this.apiKey = apiKey;
this.baseUrl = options.baseUrl || constants_1.DEFAULT_BASE_URL;
this.timeout = options.timeout || constants_1.DEFAULT_TIMEOUT;
}
/**
* Obtener la predicción simplificada para un municipio
* @param municipalityCode - Código INE del municipio (5 dígitos)
* @returns Predicción simplificada para los próximos 3 días
*/
async getSimpleForecast(municipalityCode) {
if (!municipalityCode || municipalityCode.length !== 5) {
throw new Error('El código de municipio debe tener 5 dígitos');
}
try {
const url = `${this.baseUrl}${constants_1.ENDPOINTS.FORECAST_MUNICIPALITY}${municipalityCode}`;
const data = await (0, utils_1.fetchAemetData)(url, this.apiKey, this.timeout);
// Obtenemos el nombre del municipio y la provincia
const name = data?.nombre || '';
const province = data?.provincia || '';
// Obtenemos las predicciones para hoy, mañana y pasado mañana
const today = this.extractDayForecast(data, 0);
const tomorrow = this.extractDayForecast(data, 1);
const next2 = this.extractDayForecast(data, 2);
return {
name,
province,
today,
tomorrow,
next2
};
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Error al obtener la predicción: ${error.message}`);
}
throw new Error('Error desconocido al obtener la predicción');
}
}
/**
* Obtener la predicción completa para un municipio
* @param municipalityCode - Código INE del municipio (5 dígitos)
* @returns Predicción completa con los datos crudos
*/
async getForecast(municipalityCode) {
if (!municipalityCode || municipalityCode.length !== 5) {
throw new Error('El código de municipio debe tener 5 dígitos');
}
try {
const url = `${this.baseUrl}${constants_1.ENDPOINTS.FORECAST_MUNICIPALITY}${municipalityCode}`;
const data = await (0, utils_1.fetchAemetData)(url, this.apiKey, this.timeout);
// Obtenemos el nombre del municipio y la provincia
const name = data?.nombre || '';
const province = data?.provincia || '';
// Obtenemos las predicciones para hoy, mañana y pasado mañana
const today = this.extractDayForecast(data, 0);
const tomorrow = this.extractDayForecast(data, 1);
const next2 = this.extractDayForecast(data, 2);
// Incluimos el forecast completo original
const forecast = data?.prediccion?.dia || [];
return {
name,
province,
today,
tomorrow,
next2,
forecast
};
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Error al obtener la predicción completa: ${error.message}`);
}
throw new Error('Error desconocido al obtener la predicción completa');
}
}
/**
* Obtener la lista de municipios disponibles
* @returns Lista de municipios con sus códigos
*/
async getMunicipalities() {
try {
const url = `${this.baseUrl}${constants_1.ENDPOINTS.MUNICIPALITIES}`;
return await (0, utils_1.fetchAemetData)(url, this.apiKey, this.timeout);
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Error al obtener los municipios: ${error.message}`);
}
throw new Error('Error desconocido al obtener los municipios');
}
}
/**
* Obtener la lista de provincias disponibles
* @returns Lista de provincias
*/
async getProvinces() {
try {
const url = `${this.baseUrl}${constants_1.ENDPOINTS.PROVINCES}`;
return await (0, utils_1.fetchAemetData)(url, this.apiKey, this.timeout);
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Error al obtener las provincias: ${error.message}`);
}
throw new Error('Error desconocido al obtener las provincias');
}
}
/**
* Obtener avisos meteorológicos para el día actual
* @returns Avisos meteorológicos para hoy
*/
async getAlertsToday() {
try {
const url = `${this.baseUrl}${constants_1.ENDPOINTS.ALERTS_TODAY}`;
return await (0, utils_1.fetchAemetData)(url, this.apiKey, this.timeout);
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Error al obtener los avisos para hoy: ${error.message}`);
}
throw new Error('Error desconocido al obtener los avisos para hoy');
}
}
/**
* Obtener avisos meteorológicos para el día siguiente
* @returns Avisos meteorológicos para mañana
*/
async getAlertsTomorrow() {
try {
const url = `${this.baseUrl}${constants_1.ENDPOINTS.ALERTS_TOMORROW}`;
return await (0, utils_1.fetchAemetData)(url, this.apiKey, this.timeout);
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Error al obtener los avisos para mañana: ${error.message}`);
}
throw new Error('Error desconocido al obtener los avisos para mañana');
}
}
/**
* Método privado para extraer la predicción de un día específico
* @param data - Datos de la predicción completa
* @param dayOffset - Desplazamiento del día (0 para hoy, 1 para mañana, etc.)
* @returns Predicción simplificada para el día
*/
extractDayForecast(data, dayOffset) {
try {
// Si no hay datos de predicción, devolvemos un objeto vacío
if (!data || !data.prediccion || !data.prediccion.dia || !data.prediccion.dia.length) {
throw new Error('No hay datos de predicción disponibles');
}
const dayData = (0, utils_1.getDayForecast)(data.prediccion.dia, dayOffset);
if (!dayData) {
throw new Error(`No hay datos para el día con offset ${dayOffset}`);
}
// Obtenemos el estado del cielo predominante
const skyState = this.getPredominantSkyState(dayData.estadoCielo);
// Obtenemos las temperaturas mínima y máxima
let minTemp = null;
let maxTemp = null;
if (dayData.temperatura) {
minTemp = dayData.temperatura.minima;
maxTemp = dayData.temperatura.maxima;
}
return {
value: skyState,
descripcion: (0, utils_1.getSkyStateDescription)(skyState),
tmp: {
min: minTemp !== null ? Number.parseInt(minTemp, 10) : 0,
max: maxTemp !== null ? Number.parseInt(maxTemp, 10) : 0
}
};
}
catch (error) {
if (error instanceof Error) {
console.error(`Error al extraer la predicción del día: ${error.message}`);
}
else {
console.error('Error desconocido al extraer la predicción del día');
}
// Devolvemos un objeto con valores por defecto
return {
value: '11',
descripcion: 'Desconocido',
tmp: {
min: 0,
max: 0
}
};
}
}
/**
* Método privado para obtener el estado del cielo predominante durante el día
* @param skyStates - Array de estados del cielo
* @returns Valor del estado del cielo predominante
*/
getPredominantSkyState(skyStates) {
if (!skyStates || !Array.isArray(skyStates) || skyStates.length === 0) {
return '11'; // Por defecto "Despejado"
}
// Nos centramos en los estados del cielo durante horas diurnas (período de 12pm a 6pm)
const daytimeStates = skyStates.filter(state => {
const periodo = state.periodo ? parseInt(state.periodo, 10) : NaN;
return !isNaN(periodo) && periodo >= 12 && periodo <= 18;
});
// Si hay estados diurnos, tomamos el más frecuente
if (daytimeStates.length > 0) {
// Contamos las ocurrencias de cada estado
const stateCount = {};
daytimeStates.forEach(state => {
if (state.value) {
stateCount[state.value] = (stateCount[state.value] || 0) + 1;
}
});
// Encontramos el estado más frecuente
let mostFrequent = '11';
let maxCount = 0;
Object.entries(stateCount).forEach(([value, count]) => {
if (count > maxCount) {
mostFrequent = value;
maxCount = count;
}
});
return mostFrequent;
}
// Si no hay estados diurnos, tomamos el primero disponible con valor
for (const state of skyStates) {
if (state.value) {
return state.value;
}
}
return '11'; // Valor por defecto si no hay ningún estado con valor
}
/**
* Obtener todas las estaciones meteorológicas disponibles
* @returns Lista de estaciones meteorológicas
*/
async getWeatherStations() {
try {
const url = `${this.baseUrl}${constants_1.ENDPOINTS.CLIMATE_STATIONS}`;
const data = await (0, utils_1.fetchAemetData)(url, this.apiKey, this.timeout);
if (!Array.isArray(data)) {
throw new Error('Formato de datos inesperado. Se esperaba un array de estaciones.');
}
return data.map((station) => {
// Normalizar coordenadas desde formatos como "394924N" a números decimales
const latitudNormalizada = this.normalizeCoordinate(station.latitud);
const longitudNormalizada = this.normalizeCoordinate(station.longitud);
return {
indicativo: station.indicativo || '',
nombre: station.nombre || '',
provincia: this.normalizeProvince(station.provincia) || '',
altitud: parseFloat(station.altitud) || 0,
geoposicion: {
latitud: latitudNormalizada,
longitud: longitudNormalizada
}
};
});
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Error al obtener las estaciones: ${error.message}`);
}
throw new Error('Error desconocido al obtener las estaciones');
}
}
/**
* Normaliza coordenadas desde formato AEMET (ej: "394924N") a decimales
* @param coord - Coordenada en formato AEMET
* @returns - Coordenada en formato decimal
*/
normalizeCoordinate(coord) {
if (!coord || typeof coord !== 'string') {
return 0;
}
try {
// Formato esperado: "GGMMSS[N|S|E|W]" (grados, minutos, segundos, dirección)
const direction = coord.slice(-1);
const numeric = coord.slice(0, -1);
if (numeric.length !== 6) {
return 0;
}
const degrees = parseInt(numeric.slice(0, 2), 10);
const minutes = parseInt(numeric.slice(2, 4), 10);
const seconds = parseInt(numeric.slice(4, 6), 10);
// Convertir a grados decimales
let decimal = degrees + (minutes / 60) + (seconds / 3600);
// Ajustar signo según dirección
if (direction === 'S' || direction === 'W') {
decimal = -decimal;
}
return decimal;
}
catch (e) {
console.warn('Error al normalizar coordenada:', coord, e);
return 0;
}
}
/**
* Busca estaciones por nombre o provincia
* @param query - Texto a buscar en el nombre o provincia de la estación
* @returns Lista de estaciones filtradas
*/
async searchWeatherStations(query) {
try {
const stations = await this.getWeatherStations();
const normalizedQuery = query.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
return stations.filter(station => {
const normalizedName = station.nombre.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const normalizedProvince = station.provincia.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
return normalizedName.includes(normalizedQuery) || normalizedProvince.includes(normalizedQuery);
});
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Error al buscar estaciones: ${error.message}`);
}
throw new Error('Error desconocido al buscar estaciones');
}
}
/**
* Obtener valores climatológicos diarios para una estación y período específicos
* @param params - Parámetros para obtener valores climatológicos
* @returns Valores climatológicos diarios
*/
async getClimateValues(params) {
if (!params.startDate || !params.endDate) {
throw new Error('Se requieren los parámetros: stationId, startDate y endDate');
}
// Validamos el formato de fechas
// Ahora aceptamos tanto formato AAAA-MM-DD como AAAA-MM-DDTHH:MM:SSUTC
const simpleDateRegex = /^\d{4}-\d{2}-\d{2}$/;
const fullDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}UTC$/;
let fechaIni = params.startDate;
let fechaFin = params.endDate;
// Si la fecha está en formato simple, la convertimos al formato completo
if (simpleDateRegex.test(params.startDate)) {
fechaIni = `${params.startDate}T00:00:00UTC`;
}
else if (!fullDateRegex.test(params.startDate)) {
throw new Error('Formato de fecha incorrecto. Debe ser AAAA-MM-DD o AAAA-MM-DDTHH:MM:SSUTC');
}
if (simpleDateRegex.test(params.endDate)) {
fechaFin = `${params.endDate}T23:59:59UTC`;
}
else if (!fullDateRegex.test(params.endDate)) {
throw new Error('Formato de fecha incorrecto. Debe ser AAAA-MM-DD o AAAA-MM-DDTHH:MM:SSUTC');
}
// Verificamos que las fechas no sean futuras
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
// Extraemos la parte de la fecha para la comparación
const startDateParts = params.startDate.split('T')[0].split('-');
const endDateParts = params.endDate.split('T')[0].split('-');
const startDate = new Date(parseInt(startDateParts[0]), parseInt(startDateParts[1]) - 1, parseInt(startDateParts[2]));
const endDate = new Date(parseInt(endDateParts[0]), parseInt(endDateParts[1]) - 1, parseInt(endDateParts[2]));
if (startDate > currentDate || endDate > currentDate) {
throw new Error('No se pueden solicitar datos climatológicos para fechas futuras');
}
try {
// La API requiere los parámetros en la ruta de URL, no como query params
const url = `${this.baseUrl}${constants_1.ENDPOINTS.CLIMATE_VALUES_DAILY}`;
// Construir la URL con el formato: /fechaini/{fechaIni}/fechafin/{fechaFin}/estacion/idema={idema}
const queryUrl = `${url}fechaini/${encodeURIComponent(fechaIni)}/fechafin/${encodeURIComponent(fechaFin)}/todasestaciones`;
const data = await (0, utils_1.fetchAemetData)(queryUrl, this.apiKey, this.timeout);
if (!Array.isArray(data) || data.length === 0) {
throw new Error('No se encontraron datos para la estación y período especificados');
}
// Extraemos la información de la estación del primer registro
const station = {
indicativo: data[0].indicativo || '',
nombre: data[0].nombre || '',
provincia: this.normalizeProvince(data[0].provincia) || '',
altitud: parseFloat(data[0].altitud) || 0
};
// Procesamos los valores climatológicos
const values = data.map((item) => ({
fecha: item.fecha || '',
indicativo: item.indicativo || '',
nombre: item.nombre || '',
provincia: this.normalizeProvince(item.provincia) || '',
altitud: parseFloat(item.altitud) || 0,
tmax: item.tmax !== undefined ? parseFloat(item.tmax) : undefined,
horatmax: item.horatmax || undefined,
tmin: item.tmin !== undefined ? parseFloat(item.tmin) : undefined,
horatmin: item.horatmin || undefined,
tm: item.tm !== undefined ? parseFloat(item.tm) : undefined,
prec: item.prec !== undefined ? parseFloat(item.prec) : undefined,
presMax: item.presMax !== undefined ? parseFloat(item.presMax) : undefined,
presMin: item.presMin !== undefined ? parseFloat(item.presMin) : undefined,
velmedia: item.velmedia !== undefined ? parseFloat(item.velmedia) : undefined,
racha: item.racha !== undefined ? parseFloat(item.racha) : undefined,
dir: item.dir !== undefined ? parseFloat(item.dir) : undefined,
inso: item.inso !== undefined ? parseFloat(item.inso) : undefined,
nieve: item.nieve !== undefined ? parseInt(item.nieve, 10) : undefined
}));
return {
station,
values
};
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Error al obtener valores climatológicos: ${error.message}`);
}
throw new Error('Error desconocido al obtener valores climatológicos');
}
}
/**
* Obtener resumen climatológico para una estación y período
* @param params - Parámetros para obtener valores climatológicos
* @provincia - Nombre de la provincia autónoma para filtrar estaciones
* @returns Resumen estadístico de los valores climatológicos
*/
async getClimateSummaryByProvincia(params, provincia) {
try {
const data = await this.getClimateValues(params);
const provinciaBuscada = this.normalizeProvince(provincia);
const valoresProvincia = data.values.filter(item => this.normalizeProvince(item.provincia) === provinciaBuscada);
if (valoresProvincia.length === 0) {
throw new Error(`No hay datos disponibles para ${provincia}`);
}
const estacionesUnicas = [];
valoresProvincia.forEach(item => {
if (!estacionesUnicas.some(e => e.indicativo === item.indicativo)) {
estacionesUnicas.push({
indicativo: item.indicativo,
nombre: item.nombre,
provincia: item.provincia,
altitud: item.altitud,
geoposicion: {
latitud: 0,
longitud: 0
}
});
}
});
// Función auxiliar para cálculos seguros
const safeStats = (values, fallback = 0) => {
const filtered = values.filter((v) => v !== undefined);
return {
max: filtered.length ? Math.max(...filtered) : fallback,
min: filtered.length ? Math.min(...filtered) : fallback,
avg: filtered.length ? this.calculateAverage(filtered) : fallback,
total: filtered.length ? this.calculateSum(filtered) : fallback
};
};
// Cálculos para todos los parámetros
const temperatura = {
maxima: safeStats(valoresProvincia.map(v => v.tmax)).max,
minima: safeStats(valoresProvincia.map(v => v.tmin)).min,
media: safeStats(valoresProvincia.map(v => v.tm)).avg
};
const precipitacion = {
total: safeStats(valoresProvincia.map(v => v.prec)).total,
maxDiaria: safeStats(valoresProvincia.map(v => v.prec)).max,
diasConLluvia: valoresProvincia.filter(v => (v.prec || 0) > 0).length
};
const presionAtmosferica = {
maxima: safeStats(valoresProvincia.map(v => v.presMax)).max,
minima: safeStats(valoresProvincia.map(v => v.presMin)).min,
mediaMax: safeStats(valoresProvincia.map(v => v.presMax)).avg,
mediaMin: safeStats(valoresProvincia.map(v => v.presMin)).avg
};
const viento = {
velocidadMedia: safeStats(valoresProvincia.map(v => v.velmedia)).avg,
rachaMaxima: safeStats(valoresProvincia.map(v => v.racha)).max,
direccionPredominante: this.calculateWindDirectionAverage(valoresProvincia.map(v => v.dir).filter((dir) => dir !== undefined))
};
const radiacionSolar = {
total: safeStats(valoresProvincia.map(v => v.inso)).total,
mediaDiaria: safeStats(valoresProvincia.map(v => v.inso)).avg
};
const nieve = {
acumuladoTotal: safeStats(valoresProvincia.map(v => v.nieve)).total,
diasConNieve: valoresProvincia.filter(v => (v.nieve || 0) > 0).length
};
const resumen = {
provincia: provinciaBuscada,
estaciones: estacionesUnicas,
periodo: {
inicio: params.startDate,
fin: params.endDate,
dias: [...new Set(valoresProvincia.map(v => v.fecha))].length
},
temperatura,
precipitacion,
presionAtmosferica,
viento,
radiacionSolar,
nieve,
// Podemos añadir más parámetros aquí según sea necesario
};
return resumen;
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Error al generar el resumen: ${error.message}`);
}
throw new Error('Error desconocido al generar el resumen');
}
}
// Método auxiliar para dirección del viento (cálculo circular)
calculateWindDirectionAverage(direcciones) {
if (direcciones.length === 0)
return 0;
const sumSines = direcciones.reduce((acc, dir) => acc + Math.sin(dir * Math.PI / 180), 0);
const sumCosines = direcciones.reduce((acc, dir) => acc + Math.cos(dir * Math.PI / 180), 0);
const avgAngle = Math.atan2(sumSines / direcciones.length, sumCosines / direcciones.length);
return (avgAngle * 180 / Math.PI + 360) % 360;
}
/**
* Método auxiliar para calcular la media de un array de números
* @param values - Array de valores numéricos
* @returns Media aritmética o 0 si no hay valores
*/
calculateAverage(values) {
if (!values || values.length === 0)
return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
/**
* Método auxiliar para calcular la suma de un array de números
* @param values - Array de valores numéricos
* @returns Suma total o 0 si no hay valores
*/
calculateSum(values) {
if (!values || values.length === 0)
return 0;
return values.reduce((sum, value) => sum + value, 0);
}
normalizeProvince(provincia) {
if (!provincia)
return '';
return provincia in constants_1.PROVINCE_MAPPING
? constants_1.PROVINCE_MAPPING[provincia]
: provincia;
}
}
exports.Aemet = Aemet;
;