2026/9/23 1:56:32

Django+ECharts城市PM2.5空气质量可视化:从数据库到图表实战

Django+ECharts城市PM2.5空气质量可视化:从数据库到图表实战 简介面向毕业设计的PythonDjango城市PM2.5空气质量数据可视化分析项目基于Django框架开发整合北京、上海、广州、成都、沈阳五个城市六年的PM2.5历史数据覆盖数据清洗、导入、查询、可视化展示等完整流程适合计算机相关专业学生作为毕业设计参考或二次开发起点。压缩包内共64个文件包含24个CSV数据文件如时间序列逐年数据、不同季度逐年数据、一天中不同时段以及PM2.5与温度、相对湿度、气压、风向、露点等要素的关系数据、17个Python源码文件含Django应用、视图、模型、数据导入与通用脚本、3个HTML页面模板、1个MySQL数据库文件db129.sql及说明文档整体大小12.38MB目录结构清晰便于按模块查阅。读者可直接获取完整源码与数据库快速搭建运行环境查看不同维度的可视化分析结果同时学习如何用Python处理CSV数据并写入MySQL掌握Django中数据模型、视图与模板的协作方式为课程设计或毕业设计提供一套可落地的完整方案。该项目已有406人浏览/学习适合需要完成空气质量数据分析类题目的同学参考借鉴。1. 城市PM2.5空气质量数据可视化拿到这套源码包后先别急着runserver凌晨一点的PM2.5曲线突然掉到0连续三小时仪表盘上出现一道直上直下的断崖——这是很多空气质量可视化项目第一次跑通时见到的经典翻车现场。PythonDjango城市PM2.5空气质量数据可视化分析源码数据库毕业设计这个标题对外是一份交付物对内其实就三件事把PM2.5小时数据存进数据库、用Django把数据取出来算成图表接口、再让可视化页面把趋势和分布讲清楚。它不碰传感器硬件也不做浓度预测落点是完整的“存取—清洗—展示”闭环。适合计算机、大数据、环境相关专业做毕设或课设的人也适合想从Django项目实战新手过渡到数据可视化方向的人。2. 技术选型Django的MTV模式为什么适合做空气质量可视化系统2.1 先看Django的MTV分工模型、模板、视图各管哪一段空气质量可视化系统的本质是“频繁查询历史数据、按时间维度聚合、再交给前端画图”。Django的MTV模式正好把这三段拆开了Model管表结构和查询Template管页面骨架View管业务逻辑和上下文传递。很多人问Django之MTV模式的MTV到底有什么用它和MVC的区别在于Controller的角色由框架加View一起承担对毕业设计这种规模的项目这种约定反而省事不需要自己写路由分发和控制器基类。数据流是固定的一条链路浏览器发起请求urls.py匹配路由views里的函数查询ORM拿到数据集模板渲染出HTML或者返回JSONECharts拿到数据绘图。Django的ORM把SQL语句包了一层写代码的时候不用手拼字符串换数据库也不用改查询逻辑这对项目后期切换SQLite到MySQL非常关键。这里要做一个选型判断常见做法有两种方案代码量可交互性调试体验适合场景Django模板直出 ECharts少一个视图函数搞定一般切换站点要刷新页面改完刷新即见课设、快速演示Django提供JSON接口 前端异步渲染中等多一个接口函数强可做联动筛选接口和页面分开调毕设、想继续扩展成前后端分离我一般建议毕设选第二种。接口和页面分开之后前端报错和后端报错能一眼区分答辩的时候演示也流畅。2.2 可视化选型为什么是ECharts而不是后端出图有一类做法是用Matplotlib把趋势图保存成PNG再在模板里img标签引用。这种方案的问题很明显图是死的没法切换站点、放大时间区间数据更新后还得重新生成图片文件。Plotly可以在服务端生成交互式HTML但每次请求都重新生成整个页面数据量大时响应变慢。ECharts数据可视化的思路是“前端拿JSON画图”后端只负责算好数据前端负责交互。它内置折线图、柱状图、饼图、地图还有dataZoom缩放、tooltip悬浮提示这些现成组件不需要自己写SVG或者Canvas逻辑。这套模式和企业级数据可视化的常规架构是一致的只是规模更小。如果你以后接触更复杂的可视化大屏会发现前端图表库加后端接口这条路完全能平移过去。有人会问pyecharts行不行。pyecharts适合生成离线报告用Python代码配置图表渲染成HTML文件。但网页上要做站点切换、日期联动这些动态交互还是直接用ECharts原生库更灵活数据变化后调用setOption就能更新图表不用重新加载页面。2.3 创建项目与app最小可运行命令环境搭建是Python新手第一个坎先把步骤固定下来mkdir pm25-project cd pm25-project python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate pip install django django-admin startproject air_quality . python manage.py startapp monitor这里做的每一件事都有明确目的。venv虚拟环境把项目依赖隔离在本地目录防止污染全局Python环境也方便后面生成requirements.txt。django-admin startproject创建项目骨架注意结尾的英文句点表示在当前目录生成manage.py不要漏掉。python manage.py startapp monitor创建业务模块名字建议用单数名词不要带横线。创建完app之后第一件要做的事是打开air_quality/settings.py在INSTALLED_APPS列表里加上monitor。这一步漏掉的话后面makemigrations会提示“No changes detected”这是新手最常踩的坑之一。Django版本建议用与源码包匹配的版本如果是旧项目用了url()函数写路由用Django 4.x跑会直接报错需要在urls.py里改成path()或re_path()这个兼容问题后面避坑章节还会展开。3. 数据库设计与ORM建模把PM2.5小时数据存成可查询的表3.1 三张核心表站点表、小时数据表、日统计表数据库设计决定了后面所有查询是否顺畅。空气质量数据有三个自然维度在哪个站点测的、什么时候测的、测出来多少。拆表的时候按这个逻辑拆不要把所有信息塞进一张大宽表。MonitorStation站点表存静态信息包括站点名称、所属区域、经纬度。AirRecord小时数据表是主表每条记录对应一个站点某一小时的污染物浓度。DailySummary日统计表按天聚合存当天平均PM2.5、最大AQI这些派生指标。前两张表必须有第三张视数据量决定——如果图表的“日均对比”在页面加载时临时聚合几十万行以内SQLite都能在一秒内算完日统计表可以先不做等查询确实变慢了再物化。from django.db import models class MonitorStation(models.Model): name models.CharField(站点名称, max_length64, uniqueTrue) district models.CharField(所属区域, max_length32) longitude models.FloatField(经度) latitude models.FloatField(纬度) class Meta: db_table monitor_station ordering [id] def __str__(self): return self.name class AirRecord(models.Model): station models.ForeignKey( MonitorStation, on_deletemodels.CASCADE, related_namerecords, verbose_name监测站点 ) record_time models.DateTimeField(监测时间, db_indexTrue) pm25 models.FloatField(PM2.5浓度, nullTrue, blankTrue) pm10 models.FloatField(PM10浓度, nullTrue, blankTrue) so2 models.FloatField(SO2浓度, nullTrue, blankTrue) no2 models.FloatField(NO2浓度, nullTrue, blankTrue) co models.FloatField(CO浓度, nullTrue, blankTrue) o3 models.FloatField(O3浓度, nullTrue, blankTrue) aqi models.IntegerField(AQI指数, nullTrue, blankTrue) level models.CharField(空气质量等级, max_length16, blankTrue) class Meta: db_table air_record constraints [ models.UniqueConstraint( fields[station, record_time], nameuniq_station_record_time ) ] ordering [record_time] def __str__(self): return f{self.station.name} {self.record_time} PM2.5{self.pm25}字段类型的选择有一些门道。pm25用FloatField而不是IntegerField因为部分监测设备返回的读数带小数用整型会在入库时被截断后续做日均值计算误差会累积。record_time必须加db_indexTrue后面所有按时间范围查的SQL都靠这个索引加速不加的话数据量到几万行就开始卡页面。level存字符串“良”“轻度污染”这类中文前端拿到直接显示不需要再做一次AQI数值到等级的映射。UniqueConstraint是防脏数据的第一道防线保证同一个站点同一个时间点最多只能有一条记录。造数脚本、爬虫脚本重复执行时如果没做去重很容易插入重复行这个约束能从数据库层面直接拦住。要特别留意on_deletemodels.CASCADE这个参数站点被删除时它下面的所有监测记录会级联删除这条在第5章会专门讲怎么避免误删。3.2 SQLite起步MySQL切换两种数据库的配置差异这类源码包默认带的是SQLitedb.sqlite3就一个文件整个项目copy走就能跑零配置。对毕业设计来说SQLite数据库应付几十万行数据完全够用查询带索引之后响应时间基本在毫秒级。它真正弱的地方是并发写入多个进程同时写同一个库文件时会锁库但开发环境和单机部署根本碰不到这个瓶颈。如果学校要求必须用MySQL切换成本很低只需要改settings.py里的DATABASES配置# SQLite 配置 DATABASES { default: { ENGINE: django.db.backends.sqlite3, NAME: BASE_DIR / db.sqlite3, } } # MySQL 配置 DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: pm25_db, USER: root, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, }, } }切换后要注意两点。一是连接MySQL需要装驱动pip install pymysql并且在项目__init__.py里写一句pymysql.install_as_MySQLdb()。二是开发阶段可以让Django用migrate自动建表但生产库要做结构变更时务必先备份再执行迁移ORM的迁移工具并不擅长处理已有大量数据的表结构调整。3.3 执行迁移与造数migrate之后先塞一小时数据验证模型建好之后跑三行命令python manage.py makemigrations monitor python manage.py migrate python manage.py shell -c from monitor.models import MonitorStation s MonitorStation.objects.create( name奥体中心, district建邺区, longitude118.73, latitude32.02 ) print(站点ID:, s.id) makemigrations根据模型的变更生成迁移文件migrate把迁移应用到数据库。执行完这两步后可以用python manage.py shell进入Django的交互环境直接操作ORM上面示例创建了一个测试站点。如果migrate执行后提示No migrations to apply说明monitor没有注册进INSTALLED_APPS回2.3节检查。真正批量灌数据时不要用循环逐条save()那样一万条数据要卡几十秒。用bulk_create批量写入from django.utils import timezone from datetime import timedelta from monitor.models import MonitorStation, AirRecord station MonitorStation.objects.first() records [] base_time timezone.now().replace(minute0, second0, microsecond0) for i in range(24 * 30): # 30天小时数据 t base_time - timedelta(hoursi) records.append(AirRecord( stationstation, record_timet, pm2530 (i % 10) * 2, aqi50 (i % 8) * 3, )) AirRecord.objects.bulk_create(records)bulk_create把多行INSERT拼成一条SQL发到数据库写入时间从逐条的几十秒压缩到一两秒。参数上要注意bulk_create不会自动触发模型里的save()方法所以依赖save()做字段预处理的逻辑在这里不生效数据要提前在Python侧准备好。删除数据时的ORM行为也要提前知道。AirRecord.objects.filter(stationstation).delete()会把该站点所有记录清空但如果你删的是MonitorStation对象因为外键on_deletemodels.CASCADE该站点底下的所有AirRecord也会被连带删除。操作之前先在Django执行查询-删除对象的动作前用count()确认命中行数这个习惯能避免很多后悔药都买不回来的事故。4. 从数据库到图表用Django视图与ECharts输出空气质量曲线4.1 视图层一个返回最近24小时曲线的JSON接口可视化页面的核心是一个JSON接口前端通过fetch拿到数据喂给图表。先在monitor/views.py里写视图函数import json from datetime import timedelta from django.http import JsonResponse from django.utils import timezone from .models import AirRecord def api_recent_hours(request, station_id): 返回指定站点最近24小时的PM2.5序列 now timezone.now() start now - timedelta(hours24) records ( AirRecord.objects .filter(station_idstation_id, record_time__range[start, now]) .order_by(record_time) .values(record_time, pm25) ) data [] for item in records: data.append({ time: item[record_time].strftime(%Y-%m-%d %H:%M), pm25: item[pm25], }) return JsonResponse({station_id: station_id, data: data}, json_dumps_params{ensure_ascii: False})过滤逻辑里record_time__range[start, now]是SQL的BETWEEN操作一次查询取出整个时间窗口的数据而不是在Python里循环24次分别查每小时。timezone.now()取的是当前时区时间配合settings里TIME_ZONE的配置后面第5章会单独讲时区不一致导致的偏移问题。strftime把datetime对象格式化成字符串因为这个JSON是要直接交给前端时间轴的数字对象没法被ECharts直接显示。JsonResponse默认会把中文转成\uXXXX的Unicode转义序列接口返回的站点名、等级字段看起来是一串乱码调试时非常烦人。json_dumps_params{ensure_ascii: False}可以关掉这个转义让接口直接返回可读的中文这是很多人忽略的一个参数。前端拿到数据后需要留意一个边界如果某个小时pm25字段是None它会被JSON序列化成nullnull在ECharts里默认表现为断点而不是0这符合真实情况——“没有数据”和“浓度是0”是两件完全不同的事前端图表里断点至少能提醒你数据源有空缺。4.2 路由与模板页把接口接进页面在monitor/urls.py里配置路由from django.urls import path from . import views urlpatterns [ path(api/recent/int:station_id/, views.api_recent_hours, nameapi_recent), path(, views.dashboard, namedashboard), ]根路径dashboard渲染出可视化主页面。写一个最简模板!DOCTYPE html html head meta charsetutf-8 title城市PM2.5空气质量可视化/title script src{% static js/echarts.min.js %}/script /head body div idchart stylewidth: 100%; height: 480px;/div script fetch(/api/recent/1/) .then(res res.json()) .then(result { const chart echarts.init(document.getElementById(chart)); chart.setOption({ tooltip: { trigger: axis }, grid: { left: 60, right: 30, top: 40, bottom: 60 }, xAxis: { type: category, data: result.data.map(item item.time) }, yAxis: { type: value, name: μg/m³ }, dataZoom: [{ type: inside }, { type: slider }], series: [{ name: PM2.5, type: line, smooth: true, data: result.data.map(item item.pm25), areaStyle: { opacity: 0.2 }, markLine: { data: [{ yAxis: 75 }], title: 二级标准限值 } }] }); }); /script /body /html这段HTML里{% static js/echarts.min.js %}依赖模板的static标签使用之前要在settings.py确认STATIC_URL/static/并且模板文件里第一行加上{% load static %}。开发期runserver会自动找static目录下的文件页面空白时先打开浏览器开发者工具的Network面板看echarts.min.js是不是404这是最常见的图表不显示原因。ECharts配置里smooth: true让折线变得圆滑视觉效果比硬折线好markLine在75这个位置画一条横线这是AQI二级标准的日均限值有这条线观众才能一眼看出哪些时段超标。dataZoom加了一个内置缩放和一个底部滑动条图表数据量大的时候可以用鼠标滚轮缩放查看细节这个交互组件是答辩演示时的加分项。4.3 多站点对比与AQI等级分布三张图表的接口设计单站点曲线完成后再补两个接口就能把可视化页面做成完整看板。一个返回所有站点当天平均PM2.5用于柱状图对比一个返回AQI等级分布统计用于饼图展示占比。接口设计有一个原则聚合计算尽量落在数据库层不在Python里循环累加。Django的ORM提供了annotate聚合方法from django.db.models import Avg from .models import AirRecord def api_daily_summary(request): today timezone.localdate() summary ( AirRecord.objects .filter(record_time__datetoday) .values(station__name) .annotate(avg_pm25Avg(pm25)) .order_by(-avg_pm25) ) return JsonResponse(list(summary), safeFalse, json_dumps_params{ensure_ascii: False})values(station__name)让SQL按站点名称分组annotate(avg_pm25Avg(pm25))在数据库层面算出平均值这比把几千条记录全部load到Python里自己算快了不止一个量级。返回前用list(summary)把QuerySet转成列表因为JsonResponse默认不接受QuerySet作为数据对象。三张图表的接口清单整理如下接口路径入参返回字段对应图表/api/recent/station_id/站点IDtime、pm25单站点24小时折线图/api/daily_summary/无站点名、avg_pm25多站点日均柱状图/api/level_distribution/无等级、数量AQI等级占比饼图这三个接口覆盖了时间趋势、空间对比、等级分布三个分析维度是空气质量可视化最核心的图表组合。如果还想加地图热力用ECharts的geo组件配站点经纬度就能实现但需要额外准备地图GeoJSON资源毕业设计没有硬性要求不必强上。5. 避坑手册PM2.5项目里最常翻车的5个问题与排查这类项目90%的故障不在算法而在时间字段、静态文件和数据库迁移。下面几条是反复踩出来的血泪经验按现象到原因到解决的顺序写。5.1 全线掉零凌晨曲线为什么齐齐变成0现象折线图每天0点到6点整段掉到0其他时段正常。原因造数脚本只在白天生成数据凌晨时段数据库里根本没有记录前端拿到null值或者后端把空值填充成了0。更隐蔽的情况是数据源本身在这个时段没有上传而不是浓度真的为0。把“没有数据”和“浓度为0”混为一谈会让观众误判空气质量极好。解决先确认数据库里这个时段是否真的没有记录用Django shell执行AirRecord.objects.filter(record_time__hour__lt6).count()查出来的数字是0说明是缺数据不是浓度低。解决方式有两种一种是补数把数据源缺失时间段用前后两小时均值插值填充另一种更诚实让前端对null值显示断点而不是连成0线。5.2 接口有数据图表页面空白现象浏览器Network面板里能看到接口返回了一大段JSON数据格式看起来都对但页面上的图表容器是空的。原因三个方向去查。一是ECharts初始化时DOM还没渲染完脚本在body底部执行一般没问题但在head里引入且立即初始化就会拿到空容器。二是series.data的类型不对ECharts要求data是一个数组有人会把从接口拿到的对象直接塞进去导致渲染失败。三是JSON序列化时出现NaN前端解析后ECharts无法处理但接口层面看起来还是200。解决把echarts.init放进window.onload或DOMContentLoaded回调里。数据结构打印到浏览器控制台确认是数组。后端如果算出NaN值在构造data时先做判断pm25 is None就append(None)让null正常走JSON序列化。5.3 整条曲线向右平移8小时现象曲线形状完全正确但波峰和波谷整体往后偏了8小时和当地真实监测数据的趋势对不上。原因settings.py里TIME_ZONE设置为UTC且USE_TZTrue存进数据库的datetime被转成了UTC时间存储前端拿到的时间字符串比本地时间早8小时图画出来自然偏移。这是Django时区机制在作怪只做国内项目时很多人会在这上面浪费半天。解决如果系统只服务国内城市最省心的配置是把TIME_ZONE设为Asia/Shanghai同时把USE_TZ改为False让Django存储和输出都使用本地时间不介入UTC转换。如果为了兼容其他UTC应用必须保留USE_TZTrue那就在接口返回前用timezone.localtime()转换一次或者让前端用时间戳而不是格式化字符串。5.4 源码包自带的数据库文件报错现象拿到别人的Django项目源码包后直接运行python manage.py runserver页面打开时报no such table或者database disk image is malformed。原因zip压缩再解压的过程中SQLite数据库文件可能没被完整搬运或者源码包里的db.sqlite3是用旧版本Django迁移生成的和当前的models定义对不上。源代码和数据库文件是两套独立的东西数据库文件损坏并不意味着代码有问题。解决不要试图修复那个db文件。把这个第三方数据库文件删掉重新执行python manage.py makemigrations和python manage.py migrate从干净的库结构重建再通过造数脚本灌入数据。对毕业设计来说能用代码重建的信息都不值得从旧库里抢救。5.5 runserver正常部署到云服务器后接口404现象本地python manage.py runserver一切正常用windows10生产环境配合waitress和nginx部署之后页面能打开但接口全部404静态文件也加载不出来。原因部署环境的常见问题集中在三处。一是nginx没有把/api/开头的请求转发给waitress监听的端口导致请求落在nginx静态文件处理逻辑上直接返回404。二是ALLOWED_HOSTS没有把服务器域名或IP加进去Django会拒绝处理非白名单的Host头。三是STATIC_ROOT和STATIC_URL配置与nginx的location /static/不匹配CSS和JS文件找不到。解决先在nginx配置里确认location /api/的proxy_pass指向waitress监听的地址然后看Django项目里ALLOWED_HOSTS是否包含服务器公网IP最后执行python manage.py collectstatic把静态文件集中收集到STATIC_ROOT目录确保nginx的root指向正确位置。部署问题看日志最快tail -f /var/log/nginx/error.log能直接定位是转发失败还是文件不存在。6. 进阶技巧自定义management命令让演示数据永远在线答辩前夜最怕的一件事是数据库被误删、时间区间没数据、图表一片空白。与其依赖一个不稳定的数据库文件不如写一条Django management命令一键生成完整演示数据。在monitor/management/commands/目录下新建seed_air.pyimport random from datetime import timedelta from django.core.management.base import BaseCommand from django.utils import timezone from monitor.models import MonitorStation, AirRecord class Command(BaseCommand): help 生成指定天数的小时级PM2.5模拟数据 def add_arguments(self, parser): parser.add_argument(--days, typeint, default30) parser.add_argument(--seed, typeint, default42) def handle(self, *args, **options): random.seed(options[seed]) days options[days] hours days * 24 stations list(MonitorStation.objects.all()) if not stations: self.stdout.write(self.style.ERROR(请先创建站点)) return AirRecord.objects.all().delete() base_time timezone.now().replace(minute0, second0, microsecond0) records [] for station in stations: for i in range(hours): t base_time - timedelta(hoursi) # 加入24小时周期和噪声模拟早晚高峰 base_pm 40 20 * ((i % 24) // 6) records.append(AirRecord( stationstation, record_timet, pm25max(0, base_pm random.uniform(-10, 15)), )) AirRecord.objects.bulk_create(records) self.stdout.write(self.style.SUCCESS( f已生成 {len(records)} 条模拟记录 ))执行方式很简单python manage.py seed_air --days 30 --seed 42按固定随机种子生成30天数据。add_arguments定义了命令行参数days控制数据长度seed固定随机种子保证每次生成的数据形状一样演示时不会出现上一次还好好的、这次数据突然全变负数的尴尬。base_pm的算法里加了一个按小时区间变化的函数模拟早晚高峰PM2.5偏高、凌晨偏低的真实规律这样画出来的曲线有起伏有说服力。另一个值得写的命令是数据质量校验检查每个站点每天的记录数是否达到24条、PM2.5是否有负值或超过500的极端异常值把问题集中输出到控制台。这两个命令合起来就形成了一个可重复的演示流程删库、重建、造数、校验。我现在的习惯是拿到任何一套Django可视化项目第一件事永远是打开models.py看时间字段和外键的定义方式然后再确认数据库能不能随时推倒重建。这两条路都通了后面所有页面都是在稳定的地基上做东西。你如果时间紧张至少把“重建数据库加造数脚本”这条路打通答辩现场才不至于对着空白的图表发呆。希望帮到你。本文还有配套的精品资源点击获取