conference_footprint

compute the CO2 footprint of an academic conference
git clone https://a3nm.net/git/conference_footprint/
Log | Files | Refs

commit 6580ad1171d5caa4394e01fc1f555fe86d23de1b
parent 29e5f70295bb3e79236f4bc029fabf6145306675
Author: Antoine Amarilli <a3nm@a3nm.net>
Date:   Fri, 25 Sep 2026 18:01:20 +0200

2026

Diffstat:
2026/README.md | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2026/co2.py | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2026/commands.txt | 7+++++++
2026/generate_trips.py | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2026/geocode.py | 31+++++++++++++++++++++++++++++++
2026/locations_with_latlon_additions.txt | 6++++++
2026/trips_anonymized.csv | 236+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 599 insertions(+), 0 deletions(-)

diff --git a/2026/README.md b/2026/README.md @@ -0,0 +1,99 @@ +This file explains how the carbon footprint of Highlights'26 was computed. + +## Data collection + +We collected information about the travel plans of participants via the +registration form, containing the following fields: + +- Arriving from… +- Arriving by… +- Leaving to... +- Leaving by… + +Unfortunately, this year, the form was only completed with these fields after +participants had registered. So participants were asked during the conference to complete the extra fields, but not everyone did so. The missing fields were extrapolated as explained below. + +There are 155 participants, of which 37 are local and 118 are nonlocal. + +For those participants that did not provide complete travel information (36 of +them), I assumed that they were travelling to and from the institution to which +they were affiliated. Further, based on Highlights'25 data, I assumed that trips +of <=600km were done by train, and trips of >600km were done by plane. I checked +that the computation was not too sensitive to this choice. + +We prepared a file with the following tab-separated fields: +- field 0 is the origin +- field 1 is the transportation mode +- field 2 is the destination +- field 3 is the transportation mode + +(These files are not versioned because they can be considered private +information.) + +We run: + + ./generate_trips.py 48.198964 16.369916 0.2 + +Where the arguments are the latitude and longitude of the conference venue, and +0.2 is the noise to add. This generates a file trips_anonymized.csv containing, +for each trip leg, the mode ("plane", "train", "bus/coach"), the distance (in +km, rounded, with noise), and the information about extended stays. A file +trips.csv is also produced for debugging (with the data without noise and with +personal information). A file map.geojson is also produced with the map of +participants and transportation modes and private information (to be used as an +image only). + +The file trips_anonymized.csv can then be fed to co2.py which computes the +carbon footprint (see below). This gives (from the anonymized data): + +total CO2e emissions (tons): 33.435452 +for mode train: CO2e emissions (tons): 2.391939 +for mode plane: CO2e emissions (tons): 30.958393 +for mode bus/coach: CO2e emissions (tons): 0.085120 +for distances <2000 km, plane is used for 107/222 trips +for distances >=2000 km, plane is used for 14/14 trips +flights of over 2000 km account for 7.821959 CO2e emissions (tons) i.e. 23.394207 percent of total for 14/236 total legs +distance by plane: 152714 +num by mode train: 108 +num by mode plane: 121 +num by mode bus/coach: 7 +dist by mode train: 64647 +dist by mode plane: 152714 +dist by mode bus/coach: 3040 + +Hence, the total CO2 footprint is 33 tons CO2e (it is the same with the +non-anonymized file). Around 93% of emissions are due to plane travel. + +The average footprint per onsite non-local participant (118) is around +283 kgCO2e. The average footprint per onsite participant (155) is around +216 kgCO2e. (These figures are computed from the anonymized data.) + +### Carbon footprint (unchanged from 2024) + +Like in 2022, we compute the CO2 fotprint following the +[labos1point5](https://labos1point5.org/ges-1point5) data, which is adapted from +the French agency [Ademe](https://www.ademe.fr/). We use the values from 2022 +without updating them to ensure that the methodology is comparable. + +- For train, we count **37 gCO2e/pkm** (international train). This is pessimistic in France, very + pessimistic for TGV, but similar to the 41 gCO2e/pm for national (UK) rail + given by [Our World in + Data](https://ourworldindata.org/travel-carbon-footprint). +- Plane is counted following + [labos1point5](https://labos1point5.org/ges-1point5), including the effect + of contrails: + - 258 gCO2e/pkm for less than 1000km + - 187 gCO2e/pkm between 1001km and 3500km + - 152 gCO2e/pkm above 3500km. This value is consistent to the 150 gCO2e/pkm + value for long-haul flight given by [Our World in + Data](https://ourworldindata.org/travel-carbon-footprint) (also including + contrails) +- For bus/coach, we count 28 gCO2e/pkm as the coach value given by [Our World in + Data](https://ourworldindata.org/travel-carbon-footprint) as there is no + value in labos1point5. + +## Trends relative to 2025 + +The footprint is slightly more than the one estimated in 2025 (29 tCO2e), but in +the low range of the footprints since 2022 (27-42 tCO2e). + diff --git a/2026/co2.py b/2026/co2.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 + +# From a list of trip legs (mode, distance_in_km), compute the total CO2 +# footprint and statistics + +import sys +from collections import defaultdict + +LONG_THRESH = 2000 + +def co2(distance, mode): + if mode == "train": + g_km_person = 37 + if mode == "bus/coach": + g_km_person = 28 + if mode == "plane": + if distance <= 1000: + g_km_person = 258 + elif 1000< distance <= 3500: + g_km_person = 187 + elif 3500< distance: + g_km_person = 152 + return distance * g_km_person + +co2_by_mode = defaultdict(lambda: 0) +co2_total = 0 +co2_total_long_plane = 0 +num_total_long_plane = 0 +num_total_long_nonplane = 0 +num_total = 0 +num_short_plane = 0 +num_short = 0 +dist_plane = 0 +km_by_mode = defaultdict(lambda: 0) +num_by_mode = defaultdict(lambda: 0) + +with open("footprints.txt", 'w') as fp: + for l in sys.stdin.readlines(): + f = l.strip().split(",") + mode = f[0] + dist = float(f[1]) + co2v = co2(dist, mode) + co2_by_mode[mode] += co2v + print(co2v, file=fp) + co2_total += co2v + num_total += 1 + num_by_mode[mode] += 1 + km_by_mode[mode] += dist + if mode == "plane": + dist_plane += dist + if dist >= LONG_THRESH: + if mode == "plane": + co2_total_long_plane += co2v + num_total_long_plane += 1 + else: + num_total_long_nonplane += 1 + if dist < LONG_THRESH: + num_short += 1 + if mode == "plane": + num_short_plane += 1 + +assert (num_short + num_total_long_plane + num_total_long_nonplane == num_total) + +print("total CO2e emissions (tons): %f" % (co2_total/1000000)) +for m in co2_by_mode.keys(): + print("for mode %s: CO2e emissions (tons): %f" % (m, co2_by_mode[m]/1000000)) + +print ("for distances <%d km, plane is used for %d/%d trips" % + (LONG_THRESH, num_short_plane, num_short)) +print ("for distances >=%d km, plane is used for %d/%d trips" % + (LONG_THRESH, num_total_long_plane, num_total_long_plane+num_total_long_nonplane)) + +print( "flights of over %d km account for %f CO2e emissions (tons) i.e. %f percent of total for %d/%d total legs" + % (LONG_THRESH, co2_total_long_plane/1000000, 100*co2_total_long_plane/co2_total, + num_total_long_plane, num_total)) +print("distance by plane: %d" % dist_plane) + +for k in num_by_mode.keys(): + print("num by mode %s: %d" % (k, num_by_mode[k])) +for k in km_by_mode.keys(): + print("dist by mode %s: %d" % (k, km_by_mode[k])) diff --git a/2026/commands.txt b/2026/commands.txt @@ -0,0 +1,7 @@ +sed 1d Highlights26_full_table_2026-09-12_remove_newlines.csv | cut -d ';' -f1,5,7-11,13,17-20,23,24 > registration_data.csv +./compute_places.py < registration_data.csv | sort | uniq > locations.txt +./geocode.py < locations.txt > locations_with_latlon.txt +cat <(cat locations_with_latlon.txt | grep -vi oxford) locations_with_latlon_additions.txt > locations_all.txt +./complete_participants.py < registration_data.csv > location_mode.csv +./generate_trips.py 48.198964 16.369916 0.2 +./co2.py < trips_anonymized.csv diff --git a/2026/generate_trips.py b/2026/generate_trips.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 + +# Process registration data to generate the list of trip legs + +import csv +import sys +import json +from geopy.distance import geodesic +from collections import defaultdict +from random import uniform + +# place of the conference +origin = (sys.argv[1], sys.argv[2]) +noise = float(sys.argv[3]) # how much multiplicative noise to add to distances + +# read locations +location = {} +with open("locations_all.txt", 'r') as floc: + for l in floc.readlines(): + f = l.strip().split(' ') + lat = f[0] + lon = f[1] + loc = ' '.join(f[2:]) + location[loc] = (lat, lon) + +FNAME = "location_mode.csv" + +modes = ["train", "plane", "bus/coach", "other", ""] +places = defaultdict(lambda : [0, 0, ""]) + +def complete_mode(dist): + # from highlights 2025 data, from 600km onwards most trips are by plane + if dist > 600: + return "plane" + else: + return "train" + +# compute trips +with open("trips_anonymized.csv", 'w') as fout: + with open("trips.csv", 'w') as fout2: + with open(FNAME, 'r') as ftrip: + reader = csv.reader(ftrip, delimiter="\t") + for r in reader: + #university = r[5] + # first = r[2].replace(',', '') + # last = r[3].replace(',', '') + # assert(r[6] == "I'm coming to Bordeaux") + # assert(r[8] == "External Participant") + # typ = r[7].replace(',', '') + if (len(r) < 4): + print (r) + assert(False) + from_place = r[0].strip() + from_mode = r[1].replace(',', '').lower() + to_place = r[2].strip() + to_mode = r[3].replace(',', '').lower() + #annotation = ' '.join((first, last, university, typ)) + annotation = '' + if from_mode == "bus": + from_mode = "bus/coach" + if to_mode == "bus": + to_mode = "bus/coach" + if not (from_mode in modes): + print (from_mode) + assert(False) + assert (from_mode in modes) + assert (to_mode in modes) + from_coord = location[from_place] + to_coord = location[to_place] + + from_dist = geodesic(origin, from_coord).kilometers + to_dist = geodesic(origin, to_coord).kilometers + from_dist_anon = round(uniform(from_dist * (1-noise), from_dist * (1+noise))) + to_dist_anon = round(uniform(to_dist * (1-noise), to_dist * (1+noise))) + if from_mode == '' or from_mode == 'other': + from_mode = complete_mode(from_dist) + if to_mode == '' or to_mode == 'other': + to_mode = complete_mode(to_dist) + + places[from_coord][1] += 1 + places[to_coord][1] += 1 + places[from_coord][2] += annotation + "\n" + places[to_coord][2] += annotation + "\n" + if from_mode == "plane": + places[from_coord][0] += 1 + if to_mode == "plane": + places[to_coord][0] += 1 + + + print(','.join(( + from_mode.lower(), + str(from_dist), + from_place.replace(',', ''), + *from_coord, annotation)), file=fout2) + print(','.join(( + to_mode.lower(), + str(to_dist), + to_place.replace(',', ''), + *to_coord, annotation)), file=fout2) + print(','.join(( + from_mode.lower(), + str(from_dist_anon), + )), file=fout) + print(','.join(( + to_mode.lower(), + str(to_dist_anon), + )), file=fout) + + ## OUTPUT GEOJSON + + features = [] + for k in places.keys(): + red = int(255.*places[k][0]/places[k][1]) + green = 0 + blue = int(255.*(places[k][1]-places[k][0])/places[k][1]) + color = '#%02X%02X%02X' % (red, green, blue) + feature = { + "type": "Feature", + "properties": { + "name":places[k][2], + "_umap_options": {"color": color} + }, + "geometry": { + "type": "Point", + "coordinates": [ + k[1], k[0] + ] + } + } + features.append(feature) + +output = { + "type": "FeatureCollection", + "features": features +} + +with open("map.geojson", 'w') as f: + print (json.dumps(output), file=f) + diff --git a/2026/geocode.py b/2026/geocode.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 + +# read human-readable locations on stdin, produce same list with added GPS +# coordinates on STDOUT + +from geopy.geocoders import GeoNames +import os +import sys +from time import sleep + +USER="a3nm" # user on geonames.org, serves as API key +geolocator = GeoNames(username=USER) + +def searchGeonames(place): + # chatgpt + global geolocator + location = geolocator.geocode(place, exactly_one=True) + if (location == None): + print("ERROR: %s" % place, file=sys.stderr) + return None, None + else: + return (location.latitude, location.longitude) + +for l in sys.stdin.readlines(): + l = l.strip() + origin_lat, origin_lng = searchGeonames(l) + if origin_lat is not None and origin_lng is not None: + print(origin_lat, origin_lng, l) + + sleep(1) + diff --git a/2026/locations_with_latlon_additions.txt b/2026/locations_with_latlon_additions.txt @@ -0,0 +1,6 @@ +50.878703 4.700090 Leuven, Belguim +51.50853 -0.12574 London (through Salzburg, by train) +32.761502 35.019765 Yokneam Ilit, Israel +51.7535 -1.2590 Oxford, United Kingdom +31.2499 34.7865 Israel +53.41058 -2.97794 Liverpool (CONCUR) diff --git a/2026/trips_anonymized.csv b/2026/trips_anonymized.csv @@ -0,0 +1,236 @@ +train,902 +train,608 +train,501 +train,551 +train,658 +train,726 +train,612 +train,543 +train,718 +train,688 +train,894 +train,872 +plane,1123 +plane,1069 +plane,729 +plane,1200 +train,295 +train,118 +plane,690 +plane,745 +plane,1478 +train,1004 +train,439 +train,407 +plane,2467 +plane,2610 +train,610 +train,459 +bus/coach,348 +bus/coach,308 +plane,898 +plane,928 +plane,935 +plane,840 +plane,1018 +plane,700 +plane,1204 +plane,1190 +train,419 +train,398 +train,895 +train,779 +plane,563 +plane,618 +train,647 +train,210 +plane,1099 +plane,1073 +train,552 +plane,1107 +train,630 +train,493 +plane,1714 +plane,1605 +train,528 +train,483 +bus/coach,841 +plane,1015 +plane,532 +plane,586 +train,439 +train,531 +plane,1195 +plane,841 +plane,581 +plane,469 +train,589 +train,748 +train,631 +train,515 +plane,571 +plane,654 +plane,1119 +plane,806 +train,640 +train,703 +plane,741 +plane,862 +train,521 +train,539 +plane,2681 +plane,2455 +train,580 +train,589 +train,101 +train,94 +train,538 +train,568 +plane,590 +plane,692 +plane,701 +train,863 +train,571 +train,519 +plane,1101 +plane,1184 +plane,690 +plane,536 +plane,1585 +plane,1551 +plane,518 +plane,540 +train,662 +train,664 +plane,1172 +plane,1210 +plane,518 +plane,620 +plane,1327 +plane,1457 +train,453 +train,655 +train,567 +train,294 +train,507 +train,438 +train,1003 +train,1003 +plane,632 +plane,613 +plane,2704 +plane,2229 +plane,2767 +plane,2653 +train,915 +train,884 +plane,1196 +plane,891 +train,121 +train,116 +plane,924 +plane,991 +train,532 +plane,1307 +plane,664 +plane,593 +plane,1459 +plane,1688 +plane,825 +plane,737 +train,629 +train,466 +train,426 +train,483 +train,1095 +train,898 +plane,744 +plane,947 +train,534 +train,445 +train,629 +plane,1869 +train,1212 +train,1205 +plane,1117 +plane,1509 +plane,906 +plane,844 +plane,1172 +plane,1192 +bus/coach,621 +bus/coach,682 +plane,2636 +plane,1942 +train,120 +train,124 +train,560 +train,658 +train,703 +train,826 +plane,1372 +train,853 +plane,1460 +train,671 +train,568 +train,624 +plane,1277 +plane,1117 +plane,1279 +plane,1213 +train,396 +train,344 +plane,658 +plane,681 +plane,1889 +plane,2254 +plane,495 +plane,868 +train,99 +train,132 +plane,1050 +plane,862 +plane,1237 +plane,1095 +plane,972 +plane,942 +plane,1267 +plane,1172 +plane,1602 +plane,1485 +train,1102 +plane,873 +plane,885 +plane,1144 +plane,2609 +plane,2436 +bus/coach,125 +train,112 +train,347 +train,288 +train,575 +train,691 +train,643 +train,637 +plane,538 +train,503 +plane,1152 +plane,1163 +plane,1165 +plane,967 +plane,1206 +plane,983 +train,546 +train,664 +bus/coach,115 +train,1018 +plane,956 +train,823 +train,882 +train,839 +train,1318 +train,1516 +plane,7716 +plane,6220 +train,291 +train,420