项目概述

DataImputer 是一个基于 Web 的Water Supply Systems数据修补系统。采用图傅里叶变换原理,针对供水管网的流量、压力时间序列进行数据修补工作。方法论部分请参考论文:

ZHOU X, MAN Y, LIU S, et al. 2024. Leveraging multi-level correlations for imputing monitoring data in water supply systems using graph signal sampling theory. Water Research X [J], 25: 100274.

项目地址为:https://group.zhouxiao.tech/imputation.html

主要功能为允许用户上传含有缺失值的供水管网流量、压力数据文件,进行自动化的数据修补工作。系统能够对缺失的数据进行插补,并在处理完成后生成图表以可视化结果,支持下载处理后的文件。该系统设计成全自动化流程,简化用户操作,提高数据预处理效率。

系统架构

项目实现过程中,核心包括前端页面设计、后端数据处理逻辑、跨域处理以及服务器配置优化。主要技术栈为:

  • 前端页面和交互逻辑主要由 HTML、CSS、JavaScript 和 jQuery 组成;
  • 后端由 Flask 提供 API 支持,包括文件上传、数据处理、图表生成和文件下载;
  • 数据处理基于 Pandas 和 NumPy 实现;
  • 图表使用 pyecharts 生成;
  • 通过 AJAX 技术进行前后端通信。

前端设计

前端核心框架结构为:

<div id="app">
    <!-- 上部显示区域 -->
    <div id="display-area">
        <div id="chart-container">
            <!-- Pyecharts生成的图表会在此显示 -->
            <iframe id="chart-frame" src="https://impute.zhouxiao.tech/welcome"></iframe>
        </div>
    </div>
    <div id="blank"></div>

    <!-- 中部按钮区 -->
    <div id="control-buttons" data-filename="">
        <button id="upload-btn">1. 上传文件</button>
        <input type="file" id="file-input" style="display: none;">
        <button id="run-btn" disabled>2. 运行算法</button>
        <button id="save-btn" disabled>3. 保存文件</button>
    </div>

    <!-- 底部消息框 -->
    <div id="message-box">
        <ul id="message-list"></ul>
    </div>

</div>

主要分为三部分:

  • 上部显示区域:用于显示可交互图表;
  • 操作区域:主要是三个按钮(1. 上传文件、2. 运行算法、3. 保存文件);
  • 底部消息框:为用户展示前后端反馈消息。

主要样式如下所示:

成品展示
成品展示

前端部分技术不需要详细阐述,有问题直接 F12 控制台查看源代码。

有一个设计点需要注意:考虑到用户上传的时间序列可能比较长,且大部分研究者日常工作都会使用PC进行访问本网站,因此<div id="display-area">的宽度要尽量宽,而不是局限于父类元素的100%,这里采用的是绝对定位的方法将其与父元素居中的同时,宽度可以达到:

@media (min-width: 800px) {
    #display-area {
        width: 90vw;
    }
}

这样就完美地同时顾及了页面设计美学和用户体验。

JS工具函数

COOKIE设置相关函数

首先介绍几个主要的COOKIE工具函数,首先是设置Cookie和读取Cookie的函数:

// 设置 Cookie 的函数
function setCookie(name, value, hours) {
    const date = new Date();
    date.setTime(date.getTime() + (hours * 60 * 60 * 1000)); // 设置过期时间
    document.cookie = `${name}=${value}; expires=${date.toUTCString()}; path=/`;
}

// 获取 Cookie 的函数
function getCookie(name) {
    const cookieArr = document.cookie.split("; ");
    for (let i = 0; i < cookieArr.length; i++) {
        const cookiePair = cookieArr[i].split("=");
        if (name === cookiePair[0]) {
            return decodeURIComponent(cookiePair[1]);
        }
    }
    return null;
}

然后是每次刷新页面或者第一次进入页面的恢复函数:

function restorePreviousUpload() {
    const savedFilename = getCookie('uploadedFilename');

    if (savedFilename) {
        $('#control-buttons').attr('data-filename', savedFilename);

        // 发送 AJAX 请求,让后端检查是否存在该文件并处理
        $.ajax({
            url: 'https://impute.zhouxiao.tech/upload',  // 与后端的上传和检查接口一致
            type: 'POST',
            data: { filename: savedFilename },  // 仅发送文件名,后端将根据文件名查找文件
            success: function (response) {
                const message = `
                    ${response.message}. 
                    Start: [${response.start_time}],  
                    End: [${response.end_time}], 
                    Time interval: [${response.frequency}], 
                    Nan Info: [${response.missing_summary}].
                `;
                displayMessage(message, "SUCCESS");

                // 显示图表并启用“运行算法”按钮
                $('#chart-frame').attr('srcdoc', response.chart_html);
                $('#run-btn').prop('disabled', false);
            },
            error: function (error) {
                displayMessage(`无法找到文件 ${savedFilename}, 请重新上传.`, "ERROR");
                $('#run-btn').prop('disabled', true);  // 禁用“运行算法”按钮
            }
        });
    }
}

该函数的主要作用是实现了:当用户不小心刷新或者关闭了页面,不需要重新进行文件上传,而是直接读取COOKIE中的文件名传递给Server端进行处理。因此Server端文件是每六个小时清理一次,如果距离用户最近一次上传文件不到6个小时,那么不需要其重新上传,可以直接读取 Server 端保存的文件。

文件时间戳重命名函数

function generateUniqueFilename(originalName) {
    const timestamp = Date.now();  // 生成时间戳
    const extension = originalName.split('.').pop();  // 获取文件扩展名
    return `file_${timestamp}.${extension}`;
}

消息通知函数

该函数的作用是添加一条信息到底部消息框用以告诉用户必要的信息,根据不同的样式将消息以不同颜色进行区分:

  • level = "INFO":灰色;
  • level = "SUCCESS":绿色;
  • level = "WARNING":黄色;
  • level = "ERROR":红色。
function displayMessage(message, level = "INFO") {
    const $messageList = $('#message-list'); // 使用jQuery选择器
    const timestamp = new Date().toLocaleTimeString();

    // 根据等级确定消息前缀和样式
    let prefix;
    let color;
    switch (level.toUpperCase()) {
        case 'ERROR':
            prefix = "[ERROR]";
            color = "red";
            break;
        case 'WARNING':
            prefix = "[WARNING]";
            color = "orange";
            break;
        case 'SUCCESS':
            prefix = "[SUCCESS]";
            color = "green";
            break;
        case 'INFO':
        default:
            prefix = "[INFO]";
            color = "#ccc";
            break;
    }
    prefix += " - ";
    // 移除之前消息的加粗样式
    $messageList.find('li').css("font-weight", "normal");

    // 创建并添加新消息项,并设置加粗样式
    const $newMessageItem = $(`<li style="color: ${color}; font-weight: bold;">${timestamp} ${prefix} ${message}</li>`);
    $messageList.append($newMessageItem);

    // 平滑滚动至底部
    $('#message-box').animate({ scrollTop: $messageList.prop("scrollHeight") }, 300);
}

文件输入

由于HTML默认的文件输入框很难看,因此大部分开发者都会将其直接隐藏,通过JS来实现点击按钮上传文件:

// 点击按钮触发文件输入框
$('#upload-btn').on('click', function () {
    $('#file-input').click();
});

上传文件与GDI运行时间统计

为了缓解1G1核服务器在网速和运行速度带给用户的压力,在用户上传文件或者 Server 端运行计算服务期间,给用户来点动画和消息提示——让用户知道我在努力工作,只是服务器配置太低,运行速度慢!!!

// 动态图标显示,带耗时
function updateLastMessageIcon(status, startTime) {
    const $lastMessage = $('#message-list').find('li').last();
    const icons = {
        start: `<img src="https://image.manyacan.com/202411062009861.svg" class="gdi-icon gdi-loading-icon" alt="Loading...">`,  
        success: `<img src="https://image.manyacan.com/202411062009080.svg" class="gdi-icon" alt="Complete">`, 
        error: `<img src="https://image.manyacan.com/202411062018649.svg" class="gdi-icon" alt="Error">`
    };
    
    const iconHtml = icons[status.toLowerCase()];
    if (status.toLowerCase() === 'start') {
        $lastMessage.find('.gdi-icon').remove();
        $lastMessage.append(`<span class="elapsed-time">已耗时 [0分0秒].</span>`);
        $lastMessage.append(iconHtml);
        startTimer($lastMessage, startTime);
    } else {
        clearInterval(timerInterval);
        const totalTime = getElapsedTime(startTime);
        const $loadingMessage = $('#message-list').find('li:has(.gdi-loading-icon)');
        
        // 移除之前的start图标和耗时显示
        $loadingMessage.find('.gdi-icon').remove();
        $loadingMessage.find('.elapsed-time').remove();
        
        // 添加结束耗时文本和新的图标
        $loadingMessage.append(`<span class="elapsed-time">耗时 [${totalTime}] 已结束.</span>`);
        $loadingMessage.append(iconHtml);
    }
}

// 启动动态计时器
function startTimer($lastMessage, startTime) {
    timerInterval = setInterval(() => {
        const elapsedTime = getElapsedTime(startTime);
        $lastMessage.find('.elapsed-time').text(`已耗时 [${elapsedTime}].`);
    }, 1000);
}

// 获取耗时
function getElapsedTime(startTime) {
    const now = Date.now();
    const elapsed = Math.floor((now - startTime) / 1000);
    const minutes = Math.floor(elapsed / 60);
    const seconds = elapsed % 60;
    return `${minutes}分${seconds}秒`;
}

算法参数有效性检查

在提交表单到 Server 前,对算法参数进行检查:

function validateParameters() {
    const fillMethod = $('#fill-method').val();  // 获取填充方式
    const nStd = parseFloat($('#n-std').val());  // 获取N倍标准差
    const f_p = parseFloat($('#sor-0').val());   // 低频项数量
    const sigma_v = parseFloat($('#sor-1').val()); // 重构权重

    if (['li', 'cv', 'svr', 'gru'].includes(fillMethod)) {
        displayMessage(`填充方式 '${fillMethod}' 暂未上线,请选择其他填充方式。`, "ERROR");
        return false;
    }
    if (isNaN(nStd) || nStd > 10) {
        displayMessage(`N倍标准差应为数值,且不能大于 10,请重新输入。`, "ERROR");
        return false;
    }
    if (isNaN(f_p) || f_p <= 0) {
        displayMessage(`参数 \\(F_p\\) 不能为空且必须大于 0,请重新输入。`, "ERROR");
        return false;
    }

    if (isNaN(sigma_v) || sigma_v <= 0) {
        displayMessage(`参数 \\(\\sigma_v\\) 不能为空且必须大于 0,请重新输入。`, "ERROR");
        return false;
    }

    // 所有检查通过
    return true;
}

一键复制系统日志

$('#copy-log-btn').on('click', function () {
    let logText = '';
    $('#message-list li').each(function () {
        logText += ' ➤    '   + $(this).text() + '\n'; // 添加箭头符号
    });

    // 创建临时 textarea 用于复制
    const tempInput = $('<textarea>');
    $('body').append(tempInput);
    tempInput.val(logText).select();
    document.execCommand('copy');
    tempInput.remove();

    // 显示提示信息
    VOID.alert("日志信息已复制到剪贴板!");
});

操作区域

操作区域的第一个按钮是1. 上传文件,其点击事件的主要逻辑为:

  1. 向底部消息框输入一条消息,告诉用户正在上传文件,displayMessage()函数实现;
  2. 以当前时间戳创建一个文件名,对上传文件进行重命名并构建上传表单;
  3. 与 Server 进行AJAX通信:

    1. 如果上传并处理文件成功,返回message举例为:3:12:42 PM [SUCCESS] - Found the file on GDI Server. Start: [2020-03-01 00:00:00], End: [2020-09-30 23:45:00], Time interval: [0 days 00:15:00], Nan Info: [Volume: 7795, Volume_02: 7795, Volume_03: 7795],主要介绍了数据的时间序列范围、采样频率以及各类数据的缺失情况。同时,response.chart_html为绘制的可视化JS交互图形,需要将其写入到$('#chart-frame')中,然后并开始2. 运行算法按钮。需要注意的是,此时还要将文件名写入到COOKIE中,方便用户后续操作。
    2. 如果上传失败或者 Server 对文件检测估过程中出现问题,那么就会返回错误信息,具体的错误视情况而定。
$('#file-input').on('change', function (event) {
    const startTime = Date.now(); // 记录任务开始时间
    $('#save-btn').prop('disabled', true);
    const file = event.target.files[0];
    if (file) {
        displayMessage("正在向服务器端发送AJAX请求, ");
        updateLastMessageIcon('start', startTime);
        
        const uniqueFilename = generateUniqueFilename(file.name);
        $('#control-buttons').attr('data-filename', uniqueFilename);

        const formData = new FormData();
        formData.append('file', new File([file], uniqueFilename));
        formData.append('filename', uniqueFilename);

        $.ajax({
            url: 'https://impute.zhouxiao.tech/upload',
            type: 'POST',
            data: formData,
            processData: false,
            contentType: false,
            success: function (response) {
                updateLastMessageIcon('success', startTime); // 传递开始时间
                const message = `
                    ${response.message}, 
                    Start: [${response.start_time}],  
                    End: [${response.end_time}], 
                    Time interval: [${response.frequency}], 
                    Nan Info: [${response.missing_summary}].
                `;
                displayMessage(message, "SUCCESS");
                $('#chart-frame').attr('srcdoc', response.chart_html);
                $('#run-btn').prop('disabled', false);
                setCookie('uploadedFilename', uniqueFilename, 6);
            },
            error: function (xhr) {
                updateLastMessageIcon('error', startTime); // 传递开始时间
                let errorMessage;
                if (xhr.status === 413) {
                    errorMessage = "文件大小超出限制,请选择小于50MB的文件。";
                } else if (xhr.responseJSON && xhr.responseJSON.error) {
                    errorMessage = `文件上传失败: ${xhr.responseJSON.error}`;
                } else {
                    errorMessage = `文件上传失败: 未知错误。`;
                }
                displayMessage(errorMessage, "ERROR");
            }
        });
    }
});

当上传文件并且文件通过检测时,2. 运行算法 按钮将被开启。

$('#run-btn').on('click', function () {
    if (!validateParameters()) {
        return;
    }
    const startTime = Date.now(); // 记录任务开始时间
    $('#display-area').addClass('border-animating');

    const params = {
        fill_method: $('#fill-method').val(),
        n_std: $('#n-std').val(),
        use_sor: $('input[name="use-sor"]:checked').val(),
        sor_0: $('#sor-0').val(),
        sor_1: $('#sor-1').val(),
        filename: $('#control-buttons').attr('data-filename')
    };

    // 开始任务请求
    $.ajax({
        url: 'https://impute.zhouxiao.tech/process',
        type: 'POST',
        data: params,
        success: function (response) {
            const taskId = response.task_id;
            displayMessage(`Task ID: [${taskId}], GDI Server is running, `);
            updateLastMessageIcon('start', startTime); // 传递开始时间
            pollTaskStatus(taskId, startTime);
        },
        error: function () {
            updateLastMessageIcon('error');
            displayMessage("任务启动失败", "ERROR");
        }
    });
});

轮询任务状态:

function pollTaskStatus(taskId, startTime) {
    const intervalId = setInterval(function () {
        $.get(`https://impute.zhouxiao.tech/check_task/${taskId}`, function (response) {
            const status = response.status;
            if (status === "completed") {
                clearInterval(intervalId);
                updateLastMessageIcon('success', startTime);
                $('#chart-frame').attr('srcdoc', response.result.chart_html);
                $('#save-btn').prop('disabled', false).attr('data-url', response.result.download_url.replace(/^http:/, 'https:'));
                $('#display-area').removeClass('border-animating'); // 完成
                displayMessage('任务完成.', "SUCCESS");
            } else if (status === "failed") {
                clearInterval(intervalId);
                updateLastMessageIcon('error', startTime);
                $('#display-area').removeClass('border-animating');
                displayMessage(`任务失败: [${response.result}].`, "ERROR");
            } else {
                console.log('GDI Running...')
            }
        }).fail(function () {
            displayMessage("无法获取任务状态,可能任务已失效", "ERROR");
            clearInterval(intervalId);
        });
    }, 4000);
}

当GDI算法运行完毕结束,3. 文件下载  按钮将被开启。

// 下载打包文件
document.getElementById('save-btn').addEventListener('click', function () {
    const url = this.getAttribute('data-url');
    window.location.href = url;
});

环境配置

Python项目管理

创建虚拟环境:

cd /www/wwwroot/DataImputer
python3 -m venv venv

项目启动命令:

/www/wwwroot/DataImputer/venv/bin/python /www/wwwroot/DataImputer/app.py

激活虚拟环境:

source /www/wwwroot/DataImputer/venv/bin/activate

开启Flask Web服务:

python app.py

APACHE环境配置

Server端下载文件夹的访问

APACHE默认禁止用户端下载服务器端文件,用户浏览器直接访问服务器端文件显示502 Bad Gateway,开启网站下载文件夹的访问:

# 下载设置
location /Downloads/ {
      alias /www/wwwroot/DataImputer/Downloads/;  # 请替换为实际文件夹路径
      autoindex off;  # 可选:允许目录浏览
}

Flask获取网站HTTPS域名

后端Flask中默认使用

from flask import request

print(request.host_url)

获取到的是服务器 IP + 端口号,例如:https://126.1.0.1:86/。直接把IP地址暴露在互联网是一种裸奔行为,为了文明起见,最好还是将其更换为网站域名:

屏蔽APACHE配置文件的第65行:

# proxy_set_header Host 127.0.0.1:$server_port;

更换为:

proxy_set_header Host $host;