feat: implement automated data scraping and history generation pipeline for PVL reservoir levels

This commit is contained in:
David Fencl
2026-06-05 22:58:21 +02:00
parent 5411bd16ff
commit 8d1fb5b28e
25 changed files with 60588 additions and 419 deletions
+6
View File
@@ -63,6 +63,11 @@ const lakes = lakesConfig.map(lake => {
if (volume === 0) volume = lake.maxVolume || 0;
}
let storageDiff = 0;
if (lake.storageLevel && currentLevel > 0) {
storageDiff = Number((currentLevel - lake.storageLevel).toFixed(2));
}
return {
id: lake.id,
name: lake.text.replace('VD ', '').split('-')[0].trim(),
@@ -70,6 +75,7 @@ const lakes = lakesConfig.map(lake => {
priority: lake.priority || false,
level: currentLevel.toFixed(2),
capacity: capacity,
storageDiff: storageDiff,
inflow: inflow.toFixed(1),
outflow: currentFlow.toFixed(1),
volume: volume,
+64
View File
@@ -0,0 +1,64 @@
import fs from 'fs';
import path from 'path';
const DATA_DIR = path.resolve(process.cwd(), 'public/data');
if (!fs.existsSync(DATA_DIR)) {
console.error("Data directory not found.");
process.exit(1);
}
const files = fs.readdirSync(DATA_DIR).filter(f => f.endsWith('.json') && f !== 'lakes_index.json');
files.forEach(file => {
const filePath = path.join(DATA_DIR, file);
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
if (data.length === 0) return;
// The oldest record currently in DB
const oldest = data[0];
const oldestTime = new Date(oldest.timestamp).getTime();
const newRecords: any[] = [];
let currentLevel = oldest.level;
let currentFlow = oldest.flow;
let currentInflow = oldest.inflow || (Math.random() * 10 + 5);
// Generate 720 records (30 days * 24 hours) going BACKWARDS
for (let i = 1; i <= 720; i++) {
const d = new Date(oldestTime - i * 60 * 60 * 1000);
// random walk for level
currentLevel = currentLevel + (Math.random() - 0.5) * 0.05;
// random walk for outflow and inflow
currentFlow = Math.max(0, currentFlow + (Math.random() - 0.5) * 2);
currentInflow = Math.max(0, currentInflow + (Math.random() - 0.5) * 2);
// Temperature: daily sine wave (colder at night, warmer in day) + noise
const hour = d.getHours();
const tempBase = 18; // base 18C
const tempDay = Math.sin(((hour - 6) / 24) * Math.PI * 2) * 8; // cold morning, warm afternoon
const randomTemp = tempBase + tempDay + (Math.random() - 0.5) * 2;
// Precipitation: rare spikes
const randomPrecip = Math.random() > 0.95 ? Math.random() * 15 : 0;
newRecords.push({
timestamp: d.toISOString(),
level: currentLevel,
flow: currentFlow,
inflow: currentInflow,
volume: oldest.volume, // volume changes too slow, keep constant for mock
temperature: randomTemp,
precipitation: randomPrecip
});
}
// Combine: newRecords are older, so reverse them to make chronological (oldest first), then add real data
const allRecords = [...newRecords.reverse(), ...data];
fs.writeFileSync(filePath, JSON.stringify(allRecords, null, 2));
console.log(`Generated 30 days of realistic mock history for ${file}`);
});
+16 -12
View File
@@ -3,19 +3,23 @@ export interface LakeConfig {
text: string;
priority?: boolean;
coords: [number, number];
maxVolume?: number;
minLevel?: number;
maxLevel?: number;
storageLevel?: number;
}
export const lakesConfig: LakeConfig[] = [
{ id: "VLL1|1", text: "VD Lipno 1 - Vltava", priority: true, coords: [48.6322, 14.2215], maxVolume: 306.0, minLevel: 715.00, maxLevel: 725.60 },
{ id: "VLL2|1", text: "VD Lipno II - Vltava", coords: [48.6250, 14.3180], maxVolume: 1.5, minLevel: 510.0, maxLevel: 511.5 },
{ id: "VLHN|1", text: "VD Hněvkovice - Vltava", priority: true, coords: [49.1830, 14.4440], maxVolume: 21.1, minLevel: 365.0, maxLevel: 370.5 },
{ id: "VLKO|1", text: "VD Kořensko - Vltava", coords: [49.2550, 14.3980], maxVolume: 2.8, minLevel: 352.0, maxLevel: 353.5 },
{ id: "VLOR|2", text: "VD Orlík - Vltava", priority: true, coords: [49.6060, 14.1700], maxVolume: 716.5, minLevel: 330.00, maxLevel: 354.00 },
{ id: "UHKA|2", text: "VD Kamýk - Vltava", coords: [49.6360, 14.2540], maxVolume: 12.8, minLevel: 283.0, maxLevel: 285.6 },
{ id: "VLSL|2", text: "VD Slapy - Vltava", priority: true, coords: [49.8220, 14.4360], maxVolume: 269.3, minLevel: 265.50, maxLevel: 271.10 },
{ id: "VLST|2", text: "VD Štěchovice - Vltava", coords: [49.8450, 14.4120], maxVolume: 11.2, minLevel: 217.0, maxLevel: 219.5 },
{ id: "VRSN|2", text: "VD Vrané - Vltava", coords: [49.9340, 14.3850], maxVolume: 11.1, minLevel: 198.5, maxLevel: 200.5 },
{ id: "SVKR|2", text: "VD Švihov - Želivka", priority: true, coords: [49.7180, 15.1060], maxVolume: 266.6, minLevel: 343.0, maxLevel: 377.0 },
{ id: "MARI|1", text: "VD Římov - Malše", priority: true, coords: [48.8470, 14.4870], maxVolume: 33.8, minLevel: 458.0, maxLevel: 471.0 },
{ id: "MZHR|3", text: "VD Hracholusky - Mže", priority: true, coords: [49.7890, 13.1550], maxVolume: 56.7, minLevel: 360.0, maxLevel: 373.0 }
{ id: "VLL1|1", text: "VD Lipno 1 - Vltava", priority: true, coords: [48.6322, 14.2215], maxVolume: 306.0, minLevel: 715.00, maxLevel: 725.60, storageLevel: 724.9 },
{ id: "VLL2|1", text: "VD Lipno II - Vltava", coords: [48.6250, 14.3180], maxVolume: 1.5, minLevel: 510.0, maxLevel: 511.5, storageLevel: 511.5 },
{ id: "VLHN|1", text: "VD Hněvkovice - Vltava", priority: true, coords: [49.1830, 14.4440], maxVolume: 21.1, minLevel: 365.0, maxLevel: 370.5, storageLevel: 370.1 },
{ id: "VLKO|1", text: "VD Kořensko - Vltava", coords: [49.2550, 14.3980], maxVolume: 2.8, minLevel: 352.0, maxLevel: 353.5, storageLevel: 352.6 },
{ id: "VLOR|2", text: "VD Orlík - Vltava", priority: true, coords: [49.6060, 14.1700], maxVolume: 716.5, minLevel: 330.00, maxLevel: 354.00, storageLevel: 349.9 },
{ id: "UHKA|2", text: "VD Kamýk - Vltava", coords: [49.6360, 14.2540], maxVolume: 12.8, minLevel: 283.0, maxLevel: 285.6, storageLevel: 285.6 },
{ id: "VLSL|2", text: "VD Slapy - Vltava", priority: true, coords: [49.8220, 14.4360], maxVolume: 269.3, minLevel: 265.50, maxLevel: 271.10, storageLevel: 270.6 },
{ id: "VLST|2", text: "VD Štěchovice - Vltava", coords: [49.8450, 14.4120], maxVolume: 11.2, minLevel: 217.0, maxLevel: 219.5, storageLevel: 219.4 },
{ id: "VRSN|2", text: "VD Vrané - Vltava", coords: [49.9340, 14.3850], maxVolume: 11.1, minLevel: 198.5, maxLevel: 200.5, storageLevel: 200.5 },
{ id: "SVKR|2", text: "VD Švihov - Želivka", priority: true, coords: [49.7180, 15.1060], maxVolume: 266.6, minLevel: 343.0, maxLevel: 377.0, storageLevel: 377.0 },
{ id: "MARI|1", text: "VD Římov - Malše", priority: true, coords: [48.8470, 14.4870], maxVolume: 33.8, minLevel: 458.0, maxLevel: 471.0, storageLevel: 470.65 },
{ id: "MZHR|3", text: "VD Hracholusky - Mže", priority: true, coords: [49.7890, 13.1550], maxVolume: 56.7, minLevel: 360.0, maxLevel: 373.0, storageLevel: 354.1 }
];
+33
View File
@@ -9,6 +9,10 @@ interface DataRecord {
timestamp: string;
level: number;
flow: number;
inflow?: number;
volume?: number;
temperature?: number | null;
precipitation?: number | null;
}
// Parse date from DD.MM.YYYY HH:MM to ISO
@@ -46,6 +50,8 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
let currentInflow = 0;
let currentVolume = 0;
let currentTemp: number | null = null;
let currentPrecip: number | null = null;
$('table').each((i, tbl) => {
const text = $(tbl).text();
@@ -55,6 +61,14 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
const valStr = $(r).find('td').eq(1).text().trim().replace(/\s/g, '').replace(',', '.');
if (label.includes('Přítok')) currentInflow = parseFloat(valStr) || 0;
if (label.includes('Objem')) currentVolume = parseFloat(valStr) || 0;
if (label.includes('Teplota')) {
const v = parseFloat(valStr);
if (!isNaN(v)) currentTemp = v;
}
if (label.includes('Srážky')) {
const v = parseFloat(valStr);
if (!isNaN(v)) currentPrecip = v;
}
});
}
});
@@ -97,6 +111,8 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
// Apply current values to the latest record
records[0].inflow = currentInflow;
records[0].volume = currentVolume;
if (currentTemp !== null) records[0].temperature = currentTemp;
if (currentPrecip !== null) records[0].precipitation = currentPrecip;
}
let existingData: DataRecord[] = [];
@@ -113,6 +129,23 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
// Propagate previous values if missing (user requested)
let lastKnownTemp: number | null = null;
let lastKnownPrecip: number | null = null;
mergedData.forEach(item => {
if (item.temperature !== undefined && item.temperature !== null) {
lastKnownTemp = item.temperature;
} else if (lastKnownTemp !== null) {
item.temperature = lastKnownTemp;
}
if (item.precipitation !== undefined && item.precipitation !== null) {
lastKnownPrecip = item.precipitation;
} else if (lastKnownPrecip !== null) {
item.precipitation = lastKnownPrecip;
}
});
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
fs.writeFileSync(DATA_FILE, JSON.stringify(mergedData, null, 2), 'utf-8');
+25
View File
@@ -0,0 +1,25 @@
import { execSync } from 'child_process';
const args = process.argv.slice(2);
const minutes = parseInt(args[0], 10) || 10;
const intervalMs = minutes * 60 * 1000;
console.log(`\n⏱️ HLADINATOR Watcher spuštěn!`);
console.log(`Budu automaticky stahovat nová data každých ${minutes} minut.\n`);
function runUpdate() {
const now = new Date().toLocaleTimeString('cs-CZ');
console.log(`[${now}] 🔄 Spouštím npm run data:update...`);
try {
execSync('npm run data:update', { stdio: 'inherit' });
console.log(`[${new Date().toLocaleTimeString('cs-CZ')}] ✅ Úspěšně hotovo. Další kontrola za ${minutes} minut...\n`);
} catch (error: any) {
console.error(`[${new Date().toLocaleTimeString('cs-CZ')}] ❌ Chyba při aktualizaci:`, error.message);
}
}
// Spustit ihned po zapnutí
runUpdate();
// A pak periodicky v zadaném intervalu
setInterval(runUpdate, intervalMs);