资讯详情

Django实现城市PM2.5可视化分析:从数据库到ECharts完整实践

📅 2026/10/28 8:43:36 | 华诺云谱 👁 阅读
Django实现城市PM2.5可视化分析:从数据库到ECharts完整实践
简介面向Python与Django学习者的城市PM2.5空气质量数据可视化分析完整项目以高分毕业设计形式呈现适合作为毕业设计、课程设计或实战练手参考。项目基于Django框架构建完整实现了空气质量数据的采集、存储、分析与可视化展示覆盖模型、视图、模板及路由等核心模块可帮助理解Web开发与数据分析的融合流程。压缩包共64个文件涵盖Python源码、CSV数据文件、SQL数据库、HTML模板以及配置文件等整体约12.38MB结构清晰便于查阅。资源内置北京、上海、广州、成都、沈阳等城市六年PM2.5数据并附带多维度分析脚本如月份变化、与温湿度及风向关系、不同季度逐年变化等可直接运行体验。目前已有305人学习下载对需要快速上手Django项目或完成数据可视化作业的读者具有较高参考价值。1. 用Django做城市PM2.5可视化分析为什么这种“高分毕设”值得照着做城市空气质量的毕业设计十个里至少有八个会选PM2.5可视化分析原因是题目具体、数据公开、画面直观但很多作品最终的产出只是一张画好的ECharts折线图画完图就不知道下面该做什么。基于PythonDjango的城市PM2.5空气质量数据可视化分析源码数据库恰恰是把这个方向做完整的代表它把数据入库、Django后端查询、可视化图表、管理后台和数据库文件串在一起解决的是“数据从哪里来、表怎么建、接口怎么出、图怎么联动”整条链路。适合正在做毕设想快速落地的人也适合拿现成骨架做二次开发的从业者。下面这套做法就是我按这类项目最常见的技术栈拆出来的完整落地路径。2. 先把数据链路立住PM2.5数据从哪来、存到哪、怎么建模型2.1 数据来源与文件结构拿到“数据库”后先确认这四件事这类压缩包解压后常见交付物是一个Django工程、一个数据库文件、若干CSV数据文件。数据库文件以SQLite的.db为主也有带MySQL导出.sql的CSV里放的是城市监测站的逐日或逐时数据。上手第一步不是直接python manage.py runserver而是先确认四件事settings里数据库指向什么、模型和库是否对应、CSV覆盖了哪些城市哪个时间段、依赖装没装齐。确认模型和库是否对应的命令是python manage.py migrate --check和python manage.py inspectdb。前者看迁移状态后者反向输出建表语句一对比就能看出差异。依赖一般写在requirements.txt执行pip install -r requirements.txt就能装齐。如果连Django环境都还没搭好先按官方教程把最小工程跑起来再继续后面排错才不会分不清是代码问题还是环境问题。字段设计直接决定接口能不能省事。PM2.5日平均数据通常包含下面几列我一般拿到CSV先打开看表头确认字段名和单位再建模字段类型示例说明city字符串北京城市名monitor_date日期2024-01-15监测日期pm25浮点85.4PM2.5浓度μg/m³pm10浮点120.2PM10浓度so2浮点12.0二氧化硫no2浮点44.0二氧化氮co浮点1.2一氧化碳o3浮点90.0臭氧aqi整数115空气质量指数日平均粒度用DateField就够了小时级数据再考虑DateTimeField。单位必须统一浓度保留一位小数aqi用整数。缺测的数据留空值不要填00会被前端当成真实污染水平。2.2 用Django ORM建表唯一约束、索引和迁移有了字段清单建模就是直接照着写模型类。下面是这类项目里比较标准的一张表# quality/models.py from django.db import models class AirQuality(models.Model): city models.CharField(城市, max_length50) monitor_date models.DateField(监测日期) 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) class Meta: db_table air_quality ordering [-monitor_date] constraints [ models.UniqueConstraint( fields[city, monitor_date], nameuniq_city_date ), ] indexes [ models.Index(fields[city, monitor_date]), ] def __str__(self): return f{self.city} {self.monitor_date} PM2.5{self.pm25}所有浓度字段都用FloatFieldaqi用IntegerField因为aqi本身是整数分段计算出来的存浮点反而会在前端图表里出现小数。nullTrue, blankTrue用来容纳监测站的缺测记录。UniqueConstraint是防重复导入的关键没有唯一约束时同一份CSV跑两次导入表里会出现完全相同的两行趋势图直接画出“锯齿”。加到数据库约束层面比在业务代码里判断更可靠。联合索引建在city和monitor_date上因为绝大多数页面查询都带这两个条件。数据量小的时候看不出差距但当数据到几十万行、页面加载明显变慢时这个索引能让查询快一个数量级。索引不是越多越好组合索引建一个就够单列再各建一个纯属浪费空间。模型写好后执行迁移python manage.py startapp quality python manage.py makemigrations quality python manage.py migratestartapp是Django创建app的标准动作之后记得在settings的INSTALLED_APPS里把quality加进去。makemigrations只生成迁移文件migrate才是真正建表。如果项目里已经带了一份数据库文件建议先inspectdb反向生成模型把自己这份模型和它对比找出差异而不是直接覆盖。2.3 从CSV批量导入增量导入与重复数据不重样数据从CSV落库这一步最容易出问题的是重复导入。我习惯写成一个Django管理命令而不是在shell里一行行执行这样以后每次拿到新数据都能复用# quality/management/commands/import_air.py import csv from datetime import datetime from django.core.management.base import BaseCommand from quality.models import AirQuality class Command(BaseCommand): help 从CSV导入PM2.5日平均数据支持增量更新 def add_arguments(self, parser): parser.add_argument(--csv, requiredTrue, helpCSV文件路径) parser.add_argument(--city, requiredTrue, help城市名) def handle(self, *args, **options): csv_path options[csv] city options[city] rows [] with open(csv_path, encodingutf-8-sig) as f: reader csv.DictReader(f) for line in reader: rows.append({ city: city, monitor_date: datetime.strptime( line[date].strip(), %Y-%m-%d ).date(), pm25: float(line[pm25]) if line[pm25] else None, pm10: float(line[pm10]) if line[pm10] else None, so2: float(line[so2]) if line[so2] else None, no2: float(line[no2]) if line[no2] else None, co: float(line[co]) if line[co] else None, o3: float(line[o3]) if line[o3] else None, aqi: int(float(line[aqi])) if line[aqi] else None, }) existing set( AirQuality.objects.filter(citycity) .values_list(monitor_date, flatTrue) ) new_rows [ row for row in rows if row[monitor_date] not in existing ] if new_rows: AirQuality.objects.bulk_create( [AirQuality(**row) for row in new_rows] ) self.stdout.write(self.style.SUCCESS( f共 {len(rows)} 行新增 {len(new_rows)} 行跳过 {len(rows) - len(new_rows)} 行 ))逻辑说明先一次性读出CSV所有行然后查出这个城市已在库里存在的日期集合过滤出真正的新数据最后用bulk_create批量写入。先查后插的做法牺牲一点内存但能避开UniqueConstraint冲突报错。bulk_create只在最后执行一次INSERT比在循环里逐条save快很多导入几十万行也不会让开发机卡死。参数说明--csv传文件路径--city传城市名城市名会写进每一条记录里一张CSV只对应一个城市。实际执行python manage.py import_air --csv data/beijing.csv --city 北京 python manage.py import_air --csv data/shanghai.csv --city 上海另外提醒一句CSV文件最好让数据提供方统一输出UTF-8编码。代码里用了encodingutf-8-sig是为了兼容Excel另存的CSV文件这种文件开头带BOM不加这个参数第一列城市名会变成\ufeff北京后续所有按城市过滤的查询全部失效。3. 搭建Django后端查询链路视图、序列化和管理后台3.1 先设计接口趋势、对比、汇总三种查询怎么写前端要画图后端就得先把数据查询接口定义好。我一般按页面需要拆成三个接口时间趋势查询、同一天多城市对比、汇总统计。先看时间趋势# quality/views.py from django.http import JsonResponse from django.views.decorators.http import require_GET from quality.models import AirQuality require_GET def api_trend(request): city request.GET.get(city, 北京) start request.GET.get(start, 2024-01-01) end request.GET.get(end, 2024-12-31) qs ( AirQuality.objects .filter(citycity, monitor_date__range[start, end]) .order_by(monitor_date) .values(monitor_date, pm25, pm10, aqi) ) return JsonResponse({city: city, data: list(qs)})monitor_date__range是闭区间查询start和end都会被包含在内。order_by(monitor_date)按日期升序排保证折线图从左往右画不跳点。values直接返回字典列表前端拿到的JSON结构就是[{…}, {…}]比Django的queryset对象序列化省事得多。多城市对比接口用getlist接收多个参数require_GET def api_compare(request): date request.GET.get(date, 2024-01-15) cities request.GET.getlist(cities) if not cities: cities [北京, 上海, 广州] rows ( AirQuality.objects .filter(monitor_datedate, city__incities) .values(city, pm25, aqi) ) return JsonResponse({date: date, data: list(rows)})前端URL写成/api/compare?date2024-01-15cities北京cities上海Django的getlist(cities)就能取到完整的城市列表。Django里执行查询删除对象也很常用如果发现某天数据污染可以用AirQuality.objects.filter(city北京, pm25__isnullTrue).delete()做条件删除这条删除会先查出受影响行数再删比直接裸写SQL多一道保护。3.2 序列化与边界DateField、NaN和JsonResponse的三个小坑直接JsonResponse({data: list(qs)})有一个隐患Django的DateField是date对象json.dumps遇到它不会自动转字符串会把接口跑挂。正确写法是给JsonResponse指定编码器from django.core.serializers.json import DjangoJSONEncoder return JsonResponse({city: city, data: list(qs)}, encoderDjangoJSONEncoder)DjangoJSONEncoder会把date和datetime自动转成YYYY-MM-DD和ISO时间字符串。接下来要处理NaN数据库缺测返回空值时Python这边可能是None但如果算过平均、导入时做过计算NaN就可能混进结果。JSON标准里没有NaN有些前端库解析到NaN会整个图表白屏。稳妥做法是接口出口处统一清洗def clean_nan(rows): for row in rows: for key, value in row.items(): if isinstance(value, float) and value ! value: row[key] None return rowsvalue ! value这个写法专门用来判断NaN因为NaN不等于自身。这个方法可以放在所有接口return之前先跑一遍一行调用省掉无数前端排查时间。3.3 管理后台与基础增删改查几分钟多出一个可演示模块Django自带的admin就是这个项目现成的“数据库增删改查”界面不需要自己写HTML。配置一下就能在答辩现场演示新增、修改、删除一条PM2.5记录# quality/admin.py from django.contrib import admin from quality.models import AirQuality admin.register(AirQuality) class AirQualityAdmin(admin.ModelAdmin): list_display (city, monitor_date, pm25, aqi) list_filter (city, monitor_date) search_fields (city,) date_hierarchy monitor_date list_per_page 20list_display控制列表页显示的列list_filter右侧生成城市和日期筛选date_hierarchy会在列表顶部生成日期层级导航点年份缩到月份、点月份缩到日期。list_per_page防止记录太多把页面撑垮。如果想限制非法数据比如AQI超过500、pm25为负数可以加一个ModelForm校验。但毕业设计阶段admin自带的能力足够撑起“数据管理”这个功能点。从零创建项目时记得用django-admin startproject airflow和python manage.py startapp quality建立工程再把quality挂到admin.py里注册登录/admin就能看到这张表。4. 前端可视化与页面联动把Django接口变成能讲解的图表4.1 Django模板与ECharts本地化页面骨架和静态资源前端部分我习惯把ECharts下载到本地static目录而不是依赖公网CDN这样答辩现场的离线环境也不会白屏。先建好模板文件!-- quality/templates/quality/chart.html -- {% load static %} !DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title城市PM2.5空气质量分析/title style #trendChart { width: 100%; height: 480px; } #barChart { width: 100%; height: 360px; } /style /head body div select idselCity option value北京北京/option option value上海上海/option option value广州广州/option /select input typedate idstart value2024-01-01 input typedate idend value2024-12-31 button idbtnQuery查询/button /div div idtrendChart/div div idbarChart/div script src{% static js/echarts.min.js %}/script script src{% static js/chart.js %}/script /body /html{% static %}这个模板标签由{% load static %}引入文件放在app内的static/js目录下。ECharts版本用5.x就行功能稳定官方示例也基本能直接套用。页面里放一个折线图容器和一个柱状图容器下面的JS用id分别初始化。4.2 fetch下拉联动城市切换、日期区间与图表刷新图表脚本的核心就是调用上一章写的接口再把数据映射到ECharts的series。这里必须注意中文城市名在URL里的编码问题// quality/static/js/chart.js const trendChart echarts.init(document.getElementById(trendChart)); const barChart echarts.init(document.getElementById(barChart)); async function loadTrend(city, start, end) { const url /api/trend?city${encodeURIComponent(city)}start${start}end${end}; const response await fetch(url); const json await response.json(); const dates json.data.map(item item.monitor_date); const pm25 json.data.map(item item.pm25 ?? null); const aqi json.data.map(item item.aqi ?? null); trendChart.setOption({ tooltip: { trigger: axis }, legend: { data: [PM2.5, AQI] }, xAxis: { type: category, data: dates, boundaryGap: false }, yAxis: { type: value, name: μg/m³ }, series: [ { name: PM2.5, type: line, data: pm25, smooth: true }, { name: AQI, type: line, data: aqi, smooth: true } ] }, true); } document.getElementById(btnQuery).addEventListener(click, () { const city document.getElementById(selCity).value; const start document.getElementById(start).value; const end document.getElementById(end).value; loadTrend(city, start, end); });encodeURIComponent(city)是必须的否则“北京”两个字在URL里会变成非法字符。item.pm25 ?? null用来处理接口返回的空值ES6空值合并运算符比||更合适因为0不会被替换成null。setOption第二个参数传true表示完全覆盖旧的配置项这是图表刷新的关键不传true时上次的城市数据会残留点和线叠在一起乱成一团。4.3 横向对比与超标告警让图表演示时有结论可讲同一个页面再加一个城市对比柱状图答辩时先展示趋势再切换日期对比城市演示节奏就完整了async function loadCompare(date, cities) { let url /api/compare?date${date}; cities.forEach(city { url cities${encodeURIComponent(city)}; }); const response await fetch(url); const json await response.json(); const cityNames json.data.map(row row.city); const pm25Values json.data.map(row row.pm25); barChart.setOption({ tooltip: { trigger: axis }, xAxis: { type: category, data: cityNames }, yAxis: { type: value, name: PM2.5 }, series: [{ type: bar, data: pm25Values, itemStyle: { color: param param.value 75 ? #c0392b : #2ecc71 } }] }, true); }颜色函数按PM2.5日均浓度阈值动态变色超过75就标红低于等于75显示绿色。75这个数字对应PM2.5的24小时平均浓度二级标准答辩时一句话就能讲清楚“这张图在说什么”。图表不只是在画数据城市超标的红色柱子会让老师一眼看出分析结论。5. 常见问题与避坑从SQLite到图表渲染的4个踩坑记录5.1 换库翻车SQLite换MySQL后字段消失或查询报错现象本地SQLite跑得好好的把.db文件直接丢到服务器MySQL里migrate报错或者查询提示某个字段不存在。原因SQLite是文件型数据库MySQL是服务型两者的字段类型、自增主键、事务行为都不一致直接把SQLite文件导入MySQL表结构经常对不上。解决不要把.db文件当搬家工具正确流程是用Django的导出导入命令python manage.py migrate # 在MySQL上重新建表 python manage.py dumpdata quality.AirQuality --output air_quality.json # 切换settings指向MySQL后 python manage.py loaddata air_quality.jsondumpdata把业务数据导成JSONloaddata再把数据灌进新库。loaddata遇到唯一约束冲突会失败所以导入之前先确认表是空的。迁移完成后抽查几天的数据行数和原库对比一下一致才算搬完。这种项目里的数据量通常在几十万行以内完全没必要折腾专门的数据库同步工具。5.2 日期边界与UTC时区当天数据总差一天现象前端选了2024年2月29日趋势图却返回空列表或者查询“今天”的数据图里少最后一天。原因模型字段如果混用了DateTimeFieldDjango在USE_TZTrue时会做UTC和本地时间的转换前端传的2024-02-29被北京时间0点转成UTC后日期边界就对不上了。解决日平均数据统一用DateField视图里把所有日期参数先做格式化再传from datetime import datetime def parse_date_param(request, name): raw request.GET.get(name, ) try: return datetime.strptime(raw, %Y-%m-%d).date() except ValueError: return None如果接口必须返回DateTimeField就在序列化前统一转成本地时区字符串。毕业设计项目我一般直接在settings里USE_TZ False让数据库、后端、前端处在同一个时区逻辑少一类“时间怎么差8小时”的玄学问题。5.3 中文乱码与BOM城市名变成“\ufeff北京”现象导入后列表页显示的城市名带上不可见字符查询city北京查不出来。原因CSV文件是用Excel另存的文件头带BOM字节Python按普通utf-8打开时把BOM读成了\ufeff拼进第一个字段。解决导入代码打开文件时指定encodingutf-8-sig这个编码专门用来跳过BOM。MySQL库在建库时也要指定字符集否则表默认latin1中文全变问号CREATE DATABASE air_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;前端页面加上meta charsetUTF-8接口返回的中文就不会因为静态页编码问题变成乱码。整个链路里“HTML声明、Python读取、数据库存储”三处编码保持一致这类问题基本绝迹。5.4 图表黑匣子点击查询后页面没反应现象按钮点了图表没变化浏览器控制台也不报错像进了黑匣子。原因多数情况是接口返回了异常数据或者URL构造不对。排查顺序有讲究先按F12打开Network面板选中Fetch/XHR再点一次查询看请求返回什么。如果状态码500去终端看Django的堆栈日志如果返回200但图表没动把响应里的JSON展开看字段。前端脚本里我习惯先console.log(json)一行const response await fetch(url); const json await response.json(); console.log(json);翻车经验里十次有八次是item.field的字段名拼错还有两次是接口没走到。先看数据再接住错误不要在setOption里瞎猜图纸为什么不变。ECharts的setOption(option, true)强制覆盖配置也能排除旧配置残留的干扰。6. 再多做半步AQI补算、定时更新与答辩前的自检清单6.1 数据库里没有AQI字段时用IAQI公式自己补算很多公开CSV只给了污染物浓度没有AQI字段。手写一个PM2.5的单因子IAQI计算器比到处找别人封装好的库更可控。注意这是简化实现完整AQI需要取各项污染物IAQI的最大值def calc_pm25_iaqi(pm25): breakpoints [ (0, 35, 0, 50), (35, 75, 50, 100), (75, 115, 100, 150), (115, 150, 150, 200), (150, 250, 200, 300), (250, 350, 300, 400), (350, 500, 400, 500), ] for c_low, c_high, i_low, i_high in breakpoints: if pm25 c_high: return round((i_high - i_low) / (c_high - c_low) * (pm25 - c_low) i_low) return 500这个函数是按IAQI分段线性插值实现的输入一个PM2.5浓度返回对应的分指数。放在数据导入命令里统一补算就不用在数据库里逐行UPDATE。6.2 让数据自动更新每天的crontab导入项目交付以后数据不会自己长出来。开发机或服务器上配置一个定时任务每天拉取新CSV再执行导入命令。Linux下直接写crontab0 8 * * * cd /opt/air_project /usr/bin/python3 manage.py import_air --csv /tmp/pm25_today.csv --city 北京 /tmp/air_import.log 21这句的意思是每天早上8点进去项目目录执行导入命令标准输出和错误输出都追加到日志文件方便第二天早上检查有没有缺行。Windows开发环境没有crontab就用任务计划程序触发器和调度逻辑一样。导入命令本身是幂等的重复执行不会插出重复记录所以定时任务设计成“先下载数据到固定路径再导入”就能长期跑。6.3 答辩前一定要做的自检顺序交付前我会把数据库删掉重来一遍验证从零复现先rm掉开发机的数据库文件再makemigrations、migrate、执行两次CSV导入确认第二次导入显示“跳过N行”。接着用curl打接口检查时间边界和城市参数最后开无痕浏览器走一遍页面确认图表能渲染。这个删库重建的习惯救过我两次一次是发现自己漏了migration文件一次是发现导入脚本依赖了本地绝对路径。数据图和页面都正常后再开始打包。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑