UNPKG

aemet-api

Version:

Cliente TypeScript para la API de AEMET (Agencia Estatal de Meteorología)

1,033 lines 65 kB
"use strict"; /** * Clase principal de la API de AEMET */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.Aemet = void 0; const constants_1 = require("./lib/constants"); const utils_1 = require("./lib/utils"); const tar = __importStar(require("tar")); const fs = __importStar(require("fs-extra")); const os = __importStar(require("os")); const path = __importStar(require("path")); const xml2js = __importStar(require("xml2js")); /** * 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 = {}; this.municipios = []; 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); // Extraemos el número de intentos si existe const intentos = data?.intentos; return { name, province, today, tomorrow, next2, intentos }; } 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 || []; // Extraemos el número de intentos si existe const intentos = data?.intentos; return { name, province, today, tomorrow, next2, forecast, intentos }; } 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 completos de la predicción * @param dayOffset - Offset del día (0: hoy, 1: mañana, 2: pasado mañana) * @returns Predicción simplificada para el día especificado */ 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 = 0; let maxTemp = 0; if (dayData.temperatura) { // Aseguramos que los valores sean números minTemp = typeof dayData.temperatura.minima === 'string' ? parseFloat(dayData.temperatura.minima) : (typeof dayData.temperatura.minima === 'number' ? dayData.temperatura.minima : 0); maxTemp = typeof dayData.temperatura.maxima === 'string' ? parseFloat(dayData.temperatura.maxima) : (typeof dayData.temperatura.maxima === 'number' ? dayData.temperatura.maxima : 0); } return { value: skyState, descripcion: (0, utils_1.getSkyStateDescription)(skyState), tmp: { min: minTemp, max: maxTemp } }; } catch (error) { if (error instanceof Error) { console.error(`Error: No se pudo 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 { // Para el caso de los tests donde ya vienen como números decimales if (coord.includes('.')) { return parseFloat(coord); } // 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 parseFloat(coord) || 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 convertir coordenada:', coord); 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: startDate y endDate'); } // Si se ha proporcionado un código de municipio, usar el endpoint de predicción horaria if (params.municipalityCode) { if (params.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_HOURLY}${params.municipalityCode}`; const data = await (0, utils_1.fetchAemetData)(url, this.apiKey, this.timeout); // Verificar si los datos son un array y tomar el primer elemento const municipioData = Array.isArray(data) ? data[0] : data; if (!municipioData || !municipioData.nombre || !municipioData.provincia || !municipioData.prediccion) { throw new Error('Error en la API de AEMET'); } // Creamos una estación virtual para el municipio const station = { indicativo: params.municipalityCode, nombre: municipioData.nombre || 'Municipio', provincia: municipioData.provincia || 'Desconocida', altitud: 0 }; // Adaptamos los datos al formato de respuesta esperado const values = []; // Si hay predicciones, procesamos cada día if (municipioData.prediccion?.dia && Array.isArray(municipioData.prediccion.dia)) { for (const dia of municipioData.prediccion.dia) { // Para cada día, procesamos cada periodo horario if (dia.estadoCielo && Array.isArray(dia.estadoCielo)) { for (const estado of dia.estadoCielo) { if (!estado.periodo) continue; // Recopilamos todos los datos para este periodo const precipitacion = dia.precipitacion?.find((p) => p.periodo === estado.periodo)?.value || 0; const temperatura = dia.temperatura?.find((t) => t.periodo === estado.periodo)?.value; const viento = dia.vientoAndRachaMax?.find((v) => v.periodo === estado.periodo); values.push({ fecha: `${dia.fecha.split('T')[0]}T${estado.periodo.padStart(2, '0')}:00:00`, indicativo: params.municipalityCode, nombre: municipioData.nombre || 'Municipio', provincia: municipioData.provincia || 'Desconocida', altitud: 0, tmax: parseFloat(temperatura) || undefined, tmin: parseFloat(temperatura) || undefined, tm: parseFloat(temperatura) || undefined, prec: parseFloat(precipitacion) || undefined, velmedia: viento?.velocidad?.[0] ? parseFloat(viento.velocidad[0]) : undefined, racha: viento?.value ? parseFloat(viento.value) : undefined, dir: viento?.direccion?.[0] === 'N' ? 0 : viento?.direccion?.[0] === 'NE' ? 45 : viento?.direccion?.[0] === 'E' ? 90 : viento?.direccion?.[0] === 'SE' ? 135 : viento?.direccion?.[0] === 'S' ? 180 : viento?.direccion?.[0] === 'SO' ? 225 : viento?.direccion?.[0] === 'O' ? 270 : viento?.direccion?.[0] === 'NO' ? 315 : undefined }); } } } } // Extraemos el número de intentos si existe const intentos = data?.intentos || (Array.isArray(data) && data[0]?.intentos); return { station, values, rawData: municipioData, intentos }; } catch (error) { console.error('Error al obtener la predicción horaria:', error); throw new Error('Error en la API de AEMET'); } } // Si no se proporcionó un código de municipio, seguir con la lógica original // 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 })); // Extraemos el número de intentos si existe const intentos = data[0]?.intentos; return { station, values, intentos }; } 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; } /** * Obtener datos meteorológicos para una ubicación y hora específicas * @param latitud - Latitud de la ubicación * @param longitud - Longitud de la ubicación * @param municipalityCode - Código opcional del municipio (5 dígitos) * @returns Datos meteorológicos más cercanos a la ubicación y hora especificadas */ async getWeatherByCoordinates(latitud, longitud, municipalityCode) { try { // Si se proporciona un código de municipio, lo usamos directamente const codigoMunicipio = municipalityCode || await this.getMunicipioCercano(latitud, longitud); if (!codigoMunicipio || codigoMunicipio.length !== 5) { throw new Error('Error en la API de AEMET'); } // Obtenemos los datos de predicción horaria const url = `${this.baseUrl}${constants_1.ENDPOINTS.FORECAST_MUNICIPALITY_HOURLY}${codigoMunicipio}`; const data = await (0, utils_1.fetchAemetData)(url, this.apiKey, this.timeout); // Verificar si los datos son un array y tomar el primer elemento const municipioData = Array.isArray(data) ? data[0] : data; if (!municipioData || !municipioData.nombre || !municipioData.provincia) { throw new Error('Error en la API de AEMET'); } // Obtenemos el periodo más cercano a la hora actual const periodoMasCercano = this.getPeriodoMasCercano(data); // Calculamos la distancia si no se proporcionó un código de municipio const distancia = municipalityCode ? 0 : await this.calcularDistanciaMunicipio(latitud, longitud, codigoMunicipio); // Extraemos el número de intentos si existe const intentos = data?.intentos || (Array.isArray(data) && data[0]?.intentos); return { municipalityCode: codigoMunicipio, name: municipioData.nombre || '', province: municipioData.provincia || '', weatherData: periodoMasCercano, distancia, intentos }; } catch (error) { console.error('Error al obtener datos meteorológicos por coordenadas'); throw new Error('Error en la API de AEMET'); } } /** * Obtiene el código del municipio más cercano a las coordenadas dadas * @param latitud - Latitud del punto * @param longitud - Longitud del punto * @returns Código del municipio más cercano */ async getMunicipioCercano(latitud, longitud) { try { // Obtener todos los municipios si no están cargados if (!this.municipios || this.municipios.length === 0) { this.municipios = await this.getMunicipalities(); } // Verificar que se hayan cargado municipios if (!Array.isArray(this.municipios) || this.municipios.length === 0) { throw new Error('Error en la API de AEMET'); } let municipioMasCercano = null; let distanciaMinima = Infinity; // Calcular la distancia a cada municipio for (const municipio of this.municipios) { // Verificar que el municipio tiene coordenadas válidas if (!municipio.latitud_dec || !municipio.longitud_dec) continue; const latitudMunicipio = parseFloat(municipio.latitud_dec); const longitudMunicipio = parseFloat(municipio.longitud_dec); // Verificar que las coordenadas son números válidos if (isNaN(latitudMunicipio) || isNaN(longitudMunicipio)) continue; const distancia = this.calcularDistancia(latitud, longitud, latitudMunicipio, longitudMunicipio); if (distancia < distanciaMinima) { distanciaMinima = distancia; municipioMasCercano = municipio; } } if (!municipioMasCercano) { throw new Error('Error en la API de AEMET'); } // Devolver el código del municipio sin el prefijo 'id' return municipioMasCercano.id.replace('id', ''); } catch (error) { console.error('Error al buscar municipio cercano a coordenadas'); throw new Error('Error en la API de AEMET'); } } /** * Calcula la distancia entre un punto y un municipio * @param latitud - Latitud del punto * @param longitud - Longitud del punto * @param codigoMunicipio - Código del municipio * @returns Distancia en kilómetros */ async calcularDistanciaMunicipio(latitud, longitud, codigoMunicipio) { try { // Obtener todos los municipios si no están cargados if (!this.municipios || this.municipios.length === 0) { this.municipios = await this.getMunicipalities(); } // Verificar que se hayan cargado municipios if (!Array.isArray(this.municipios) || this.municipios.length === 0) { throw new Error('Error en la API de AEMET'); } // Buscar el municipio por su código const municipio = this.municipios.find(m => m.id.replace('id', '') === codigoMunicipio); if (!municipio) { throw new Error('Error en la API de AEMET'); } // Verificar que el municipio tiene coordenadas válidas if (!municipio.latitud_dec || !municipio.longitud_dec) { throw new Error('Error en la API de AEMET'); } // Usar las coordenadas decimales del municipio const latitudMunicipio = parseFloat(municipio.latitud_dec); const longitudMunicipio = parseFloat(municipio.longitud_dec); // Verificar que las coordenadas son números válidos if (isNaN(latitudMunicipio) || isNaN(longitudMunicipio)) { throw new Error('Error en la API de AEMET'); } // Calcular la distancia entre los puntos return this.calcularDistancia(latitud, longitud, latitudMunicipio, longitudMunicipio); } catch (error) { console.error('Error al calcular la distancia al municipio'); throw new Error('Error en la API de AEMET'); } } /** * Calcular la distancia entre dos puntos geográficos usando la fórmula de Haversine * @param lat1 - Latitud del primer punto * @param lon1 - Longitud del primer punto * @param lat2 - Latitud del segundo punto * @param lon2 - Longitud del segundo punto * @returns Distancia en kilómetros */ calcularDistancia(lat1, lon1, lat2, lon2) { const R = 6371; // Radio de la Tierra en kilómetros const dLat = this.toRad(lat2 - lat1); const dLon = this.toRad(lon2 - lon1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } /** * Convertir grados a radianes * @param grados - Ángulo en grados * @returns Ángulo en radianes */ toRad(grados) { return grados * (Math.PI / 180); } /** * Obtiene los datos del periodo más cercano a la hora actual * @param data - Datos de predicción horaria * @returns Datos del periodo más cercano */ getPeriodoMasCercano(data) { try { // Verificar si los datos son un array y tomar el primer elemento const municipioData = Array.isArray(data) ? data[0] : data; if (!municipioData?.prediccion?.dia || !Array.isArray(municipioData.prediccion.dia)) { throw new Error('Error en la API de AEMET'); } const horaActual = new Date().getHours(); let periodoMasCercano = null; let diferenciaMinimaHoras = Infinity; // Buscamos en todos los días disponibles for (const dia of municipioData.prediccion.dia) { // Verificamos si el día tiene datos de estado del cielo if (!dia.estadoCielo || !Array.isArray(dia.estadoCielo)) continue; // Buscamos el periodo más cercano a la hora actual for (const estado of dia.estadoCielo) { if (!estado.periodo) continue; const horaEstado = parseInt(estado.periodo, 10); if (isNaN(horaEstado)) continue; // Calculamos la diferencia de horas const diferencia = Math.abs(horaEstado - horaActual); // Si encontramos un periodo más cercano, actualizamos if (diferencia < diferenciaMinimaHoras) { diferenciaMinimaHoras = diferencia; // Recopilamos todos los datos para este periodo const precipitacion = dia.precipitacion?.find((p) => p.periodo === estado.periodo)?.value || 0; const probPrec = dia.probPrecipitacion?.find((p) => p.periodo.includes(estado.periodo.padStart(2, '0')))?.value || 0; const probTorm = dia.probTormenta?.find((p) => p.periodo.includes(estado.periodo.padStart(2, '0')))?.value || 0; const nieve = dia.nieve?.find((n) => n.periodo === estado.periodo)?.value || 0; const probNieve = dia.probNieve?.find((n) => n.periodo.includes(estado.periodo.padStart(2, '0')))?.value || 0; const temperatura = dia.temperatura?.find((t) => t.periodo === estado.periodo)?.value; const sensTermica = dia.sensTermica?.find((s) => s.periodo === estado.periodo)?.value; const humedad = dia.humedadRelativa?.find((h) => h.periodo === estado.periodo)?.value; const viento = dia.vientoAndRachaMax?.find((v) => v.periodo === estado.periodo); // Construir una fecha correcta con la hora del periodo const fechaBase = dia.fecha.split('T')[0]; const fechaConHora = `${fechaBase}T${estado.periodo.padStart(2, '0')}:00:00`; periodoMasCercano = { fecha: fechaConHora, periodo: estado.periodo, estadoCielo: { value: estado.value, descripcion: estado.descripcion }, precipitacion: parseFloat(precipitacion), probPrecipitacion: parseInt(probPrec, 10), probTormenta: parseInt(probTorm, 10), nieve: parseFloat(nieve), probNieve: parseInt(probNieve, 10), temperatura: parseFloat(temperatura), sensTermica: parseFloat(sensTermica), humedadRelativa: parseInt(humedad, 10), viento: { direccion: viento?.direccion?.[0] || '', velocidad: parseInt(viento?.velocidad?.[0] || '0', 10), rachaMax: viento?.value ? parseInt(viento.value, 10) : undefined } }; } } } if (!periodoMasCercano) { throw new Error('Error en la API de AEMET'); } return periodoMasCercano; } catch (error) { console.error('Error al obtener el periodo más cercano para predicción'); throw new Error('Error en la API de AEMET'); } } /** * Obtener un resumen climatológico para una estación y período específicos * @param params - Parámetros para obtener valores climatológicos * @returns Resumen climatológico del período */ async getClimateSummary(params) { try { // Primero obtenemos los valores climatológicos const climateData = await this.getClimateValues(params); // Si no hay datos, lanzamos un error if (!climateData.values || climateData.values.length === 0) { throw new Error('No hay datos climatológicos disponibles para el período especificado'); } // Extraemos los valores para calcular estadísticas const temperatures = climateData.values.map(v => v.tm).filter(v => v !== undefined); const maxTemperatures = climateData.values.map(v => v.tmax).filter(v => v !== undefined); const minTemperatures = climateData.values.map(v => v.tmin).filter(v => v !== undefined); const precipitations = climateData.values.map(v => v.prec).filter(v => v !== undefined); const windSpeeds = climateData.values.map(v => v.velmedia).filter(v => v !== undefined); const windGusts = climateData.values.map(v => v.racha).filter(v => v !== undefined); // Calculamos las estadísticas const avgTemperature = this.calculateAverage(temperatures); const maxTemp = Math.max(...maxTemperatures); const maxTempDay = climateData.values.find(v => v.tmax === maxTemp); const minTemp = Math.min(...minTemperatures); const minTempDay = climateData.values.find(v => v.tmin === minTemp); const totalPrecipitation = this.calculateSum(precipitations); const daysWithPrecipitation = precipitations.filter(p => p > 0).length; const avgWindSpeed = this.calculateAverage(windSpeeds); const maxWindSpeed = Math.max(...windGusts); const maxWindDay = climateData.values.find(v => v.racha === maxWindSpeed); return { station: climateData.station, period: { startDate: params.startDate, endDate: params.endDate, totalDays: climateData.values.length }, temperature: { avg: avgTemperature, max: { value: maxTemp, date: maxTempDay?.fecha }, min: { value: minTemp, date: minTempDay?.fecha } }, precipitation: { total: totalPrecipitation, daysWithPrecipitation, avg: totalPrecipitation / climateData.values.length }, wind: { avgSpeed: avgWindSpeed, maxSpeed: { value: maxWindSpeed, date: maxWindDay?.fecha } }, intentos: climateData.intentos }; } catch (error) { if (error instanceof Error) { throw new Error(`Error al generar resumen climatológico: ${error.message}`); } throw new Error('Error desconocido al generar resumen climatológico'); } } /** * Obtener las alertas meteorológicas en formato GeoJSON * @returns Colección de alertas en formato GeoJSON */ async getAlertsGeoJSON() { try { // Crear un directorio temporal para extraer los archivos const tempDir = path.join(os.tmpdir(), `aemet-alerts-${Date.now()}`); const tarFilePath = path.join(tempDir, 'alertas.tar'); await fs.ensureDir(tempDir); // 1. Descargar el archivo .tar desde el endpoint de AEMET usando la nueva función específica para archivos binarios const url = `${this.baseUrl}${constants_1.ENDPOINTS.ALERTS_CAP_AREA_ESP}`; // Usar el nuevo método para descargar archivos binarios con un timeout mayor (30 segundos) const { data: tarBuffer, intentos } = await (0, utils_1.fetchAemetBinaryFile)(url, this.apiKey, 30000); // Guardar el archivo descargado await fs.writeFile(tarFilePath, tarBuffer); console.log(`Procesando archivo de alertas...`); // 2. Extraer los archivos XML del archivo .tar try { await tar.extract({ file: tarFilePath, cwd: tempDir, onentry: () => { // Log eliminado para reducir la salida } }); } catch (extractError) { console.error('Error al extraer el archivo tar'); // A veces el archivo tar puede tener un formato ligeramente diferente // Intentamos leer el archivo como raw buffer y escribir su contenido console.log('Intentando método alternativo...'); const rawContent = await fs.readFile(tarFi