The lab kit of the course: https://github.com/hrhouma2/aiopsatlas-ml-data-diagnostics-labs-en
The plant manager wants one table: incidents and repair cost by site and by machine type. Then a harder one: the casing temperature of the machine on the day of each incident. That needs three tables. You build both in SQL, then in pandas, and prove with assert that the numbers match. Along the way you meet a small surprise about rounding.
cd aiopsatlas-ml-data-diagnostics-labs-en
.\.venv\Scripts\Activate.ps1cd aiopsatlas-ml-data-diagnostics-labs-en
source .venv/bin/activateCreate week03/three_tables.py and start it with this block.
import sqlite3
import pandas as pd
pd.set_option("display.width", 120)
con = sqlite3.connect("data/diagnostics.sqlite")
machines = pd.read_csv("data/clean/machines.csv")
readings = pd.read_csv("data/clean/readings.csv")
incidents = pd.read_csv("data/clean/incidents.csv")Three tables, one shared key. incidents and readings also share date.
machines machine_id, machine_type, site, install_year, rated_power_kw 40 rows
incidents incident_id, machine_id, date, category, severity, downtime_hours, 155 rows
repair_cost_cad, technician, description
readings reading_id, machine_id, date, load_pct, ambient_c, temperature_c, 14,600 rows
vibration_mm_s, pressure_bar, power_kw, fault_next_7dThe first incident, with its machine:
incident_id machine_id date category severity repair_cost_cad | machine_type site
INC-0001 M001 2025-02-20 overheating high 3465.33 | pump TorontoTo join incidents to readings you need two columns: the same machine and the same day. One column would glue each incident to 365 readings.
Incidents and total cost by site. site is in machines, so you need a JOIN.
sql_site = pd.read_sql_query("""
SELECT m.site, COUNT(*) AS n, ROUND(SUM(i.repair_cost_cad), 2) AS cost
FROM incidents i
JOIN machines m ON i.machine_id = m.machine_id
GROUP BY m.site
ORDER BY n DESC
""", con)
print(sql_site) site n cost
0 Montreal 66 82516.26
1 Toronto 56 56539.32
2 Quebec City 33 40878.22Montreal has half the machines and 66 of the 155 incidents. The three counts add up to 155. Check that every time.
Add machine_type to the GROUP BY. Twelve rows: three sites times four types.
sql_site_type = pd.read_sql_query("""
SELECT m.site, m.machine_type, COUNT(*) AS n, ROUND(SUM(i.repair_cost_cad), 2) AS cost
FROM incidents i
JOIN machines m ON i.machine_id = m.machine_id
GROUP BY m.site, m.machine_type
ORDER BY n DESC
""", con)
print(sql_site_type) site machine_type n cost
0 Montreal chiller 30 44102.65
1 Toronto pump 23 30401.04
2 Quebec City compressor 19 27411.38
3 Toronto conveyor 15 14239.96
...
10 Quebec City chiller 5 4390.39
11 Quebec City pump 2 1928.41The chillers of Montreal alone cost 44,102 dollars. That is a quarter of the whole year. The manager will want to know why.
Add the reading of the incident day. The second JOIN uses two conditions: same machine and same date.
sql_three = pd.read_sql_query("""
SELECT m.site, m.machine_type, COUNT(*) AS n, ROUND(AVG(r.temperature_c), 1) AS temp_that_day
FROM incidents i
JOIN machines m ON i.machine_id = m.machine_id
JOIN readings r ON r.machine_id = i.machine_id AND r.date = i.date
GROUP BY m.site, m.machine_type
ORDER BY n DESC
""", con)
print(sql_three) site machine_type n temp_that_day
0 Montreal chiller 30 36.5
1 Toronto pump 23 49.9
2 Quebec City compressor 19 58.2
...
6 Toronto compressor 11 64.9
...
11 Quebec City pump 2 38.5The counts are the same as in Step 2. Good: the second join did not add or lose rows. Compressors run at 55 to 65 degrees on the day they fail. Montreal chillers at 36.5.
merge is the pandas JOIN. Then groupby with agg to get two numbers per group.
two = incidents.merge(machines, on="machine_id")
print("merged rows:", len(two))
pd_site = (
two.groupby("site")
.agg(n=("incident_id", "count"), cost=("repair_cost_cad", "sum"))
.round(2)
.sort_values("n", ascending=False)
.reset_index()
)
print(pd_site)
pd_site_type = (
two.groupby(["site", "machine_type"])
.agg(n=("incident_id", "count"), cost=("repair_cost_cad", "sum"))
.round(2)
.sort_values("n", ascending=False)
.reset_index()
)merged rows: 155
site n cost
0 Montreal 66 82516.26
1 Toronto 56 56539.32
2 Quebec City 33 40878.22Same three lines as Step 1. agg(n=("incident_id", "count")) reads: a column n that counts incident_id. reset_index() turns the group keys back into columns, like the SQL result.
Merge again with readings, on two columns this time.
three = two.merge(readings, on=["machine_id", "date"])
print("merged rows:", len(three))
pd_three = (
three.groupby(["site", "machine_type"])
.agg(n=("incident_id", "count"), temp_that_day=("temperature_c", "mean"))
.round(1)
.sort_values("n", ascending=False)
.reset_index()
)
print(pd_three)merged rows: 155
site machine_type n temp_that_day
0 Montreal chiller 30 36.5
1 Toronto pump 23 49.9
2 Quebec City compressor 19 58.2
...
11 Quebec City pump 2 38.6Look at the last line. SQL said 38.5. pandas says 38.6. Same two incidents, same two temperatures, 49.7 and 27.4. The exact mean is 38.55. SQLite rounds it down, Python rounds it up. Both are correct. Rounding a value that ends in 5 is a choice, and the two tools chose differently.
Sort both results the same way and compare with assert. For the temperatures, allow a gap of 0.1 for the rounding case.
assert sql_site["n"].tolist() == pd_site["n"].tolist()
assert sql_site["cost"].tolist() == pd_site["cost"].tolist()
print("by site : same counts, same costs")
sql_st = sql_site_type.set_index(["site", "machine_type"]).sort_index()
pd_st = pd_site_type.set_index(["site", "machine_type"]).sort_index()
assert sql_st["n"].tolist() == pd_st["n"].tolist()
assert sql_st["cost"].tolist() == pd_st["cost"].tolist()
print("by site and type : same counts, same costs")
sql_t = sql_three.set_index(["site", "machine_type"]).sort_index()
pd_t = pd_three.set_index(["site", "machine_type"]).sort_index()
assert sql_t["n"].tolist() == pd_t["n"].tolist()
gap = (sql_t["temp_that_day"] - pd_t["temp_that_day"]).abs().max()
assert gap < 0.15, gap
print(f"three tables : same counts, mean temperatures within {gap:.1f} (rounding of .x5)")
con.close()by site : same counts, same costs
by site and type : same counts, same costs
three tables : same counts, mean temperatures within 0.1 (rounding of .x5)set_index plus sort_index puts both tables in the same order before the comparison. Without it, tolist() would compare rows in different orders and fail.
readings need two columns, not one?two have, and why is that the right number?machine_id and date.ON.Add severity to the three-table query. For each severity, give the count and the mean vibration_mm_s on the incident day. Do it in SQL and in pandas. High-severity incidents should show the highest vibration.
week03/exercise_1_solution.py in the kit"""Week 3, Exercise 1 - Three-table join, in SQL and in pandas.
Run from the kit root, with the venv active:
python week03/exercise_1_solution.py
Joins incidents, machines and readings on data/diagnostics.sqlite, then does
the same joins with pandas merge on the CSV files, and checks that the
numbers are identical:
1. incidents JOIN machines: count and cost by site;
2. the same by site and machine type;
3. + readings of the incident day: mean temperature that day;
4. pandas merge, two tables;
5. pandas merge, three tables;
6. assert: SQL == pandas.
"""
import sqlite3
import pandas as pd
pd.set_option("display.width", 120)
con = sqlite3.connect("data/diagnostics.sqlite")
machines = pd.read_csv("data/clean/machines.csv")
readings = pd.read_csv("data/clean/readings.csv")
incidents = pd.read_csv("data/clean/incidents.csv")
# Step 1 - two tables: incidents by site
print("== Step 1: SQL, incidents by site ==")
sql_site = pd.read_sql_query(
"""
SELECT m.site, COUNT(*) AS n, ROUND(SUM(i.repair_cost_cad), 2) AS cost
FROM incidents i
JOIN machines m ON i.machine_id = m.machine_id
GROUP BY m.site
ORDER BY n DESC
""",
con,
)
print(sql_site)
print()
# Step 2 - two tables, two group keys
print("== Step 2: SQL, incidents by site and type ==")
sql_site_type = pd.read_sql_query(
"""
SELECT m.site, m.machine_type, COUNT(*) AS n, ROUND(SUM(i.repair_cost_cad), 2) AS cost
FROM incidents i
JOIN machines m ON i.machine_id = m.machine_id
GROUP BY m.site, m.machine_type
ORDER BY n DESC
""",
con,
)
print(sql_site_type)
print()
# Step 3 - three tables: add the reading of the incident day
print("== Step 3: SQL, three tables ==")
sql_three = pd.read_sql_query(
"""
SELECT m.site, m.machine_type, COUNT(*) AS n, ROUND(AVG(r.temperature_c), 1) AS temp_that_day
FROM incidents i
JOIN machines m ON i.machine_id = m.machine_id
JOIN readings r ON r.machine_id = i.machine_id AND r.date = i.date
GROUP BY m.site, m.machine_type
ORDER BY n DESC
""",
con,
)
print(sql_three)
print()
# Step 4 - pandas, two tables
print("== Step 4: pandas, incidents by site ==")
two = incidents.merge(machines, on="machine_id")
print("merged rows:", len(two))
pd_site = (
two.groupby("site")
.agg(n=("incident_id", "count"), cost=("repair_cost_cad", "sum"))
.round(2)
.sort_values("n", ascending=False)
.reset_index()
)
print(pd_site)
pd_site_type = (
two.groupby(["site", "machine_type"])
.agg(n=("incident_id", "count"), cost=("repair_cost_cad", "sum"))
.round(2)
.sort_values("n", ascending=False)
.reset_index()
)
print()
# Step 5 - pandas, three tables
print("== Step 5: pandas, three tables ==")
three = two.merge(readings, on=["machine_id", "date"])
print("merged rows:", len(three))
pd_three = (
three.groupby(["site", "machine_type"])
.agg(n=("incident_id", "count"), temp_that_day=("temperature_c", "mean"))
.round(1)
.sort_values("n", ascending=False)
.reset_index()
)
print(pd_three)
print()
# Step 6 - same numbers?
print("== Step 6: SQL == pandas ==")
assert sql_site["n"].tolist() == pd_site["n"].tolist()
assert sql_site["cost"].tolist() == pd_site["cost"].tolist()
print("by site : same counts, same costs")
sql_st = sql_site_type.set_index(["site", "machine_type"]).sort_index()
pd_st = pd_site_type.set_index(["site", "machine_type"]).sort_index()
assert sql_st["n"].tolist() == pd_st["n"].tolist()
assert sql_st["cost"].tolist() == pd_st["cost"].tolist()
print("by site and type : same counts, same costs")
sql_t = sql_three.set_index(["site", "machine_type"]).sort_index()
pd_t = pd_three.set_index(["site", "machine_type"]).sort_index()
assert sql_t["n"].tolist() == pd_t["n"].tolist()
# SQLite and Python can round a value ending in .x5 in different directions.
gap = (sql_t["temp_that_day"] - pd_t["temp_that_day"]).abs().max()
assert gap < 0.15, gap
print(f"three tables : same counts, mean temperatures within {gap:.1f} (rounding of .x5)")
print("total incidents :", int(sql_site["n"].sum()), "| total cost:", round(sql_site["cost"].sum(), 2))
con.close()Run it with python week03/exercise_1_solution.py. The last line prints total incidents : 155 | total cost: 179933.8.
All systems — merged rows: 6200 or 56575. A join without a proper key. For two, check on="machine_id". For three, you need both columns: on=["machine_id", "date"].
All systems — AssertionError in Step 6 on cost. You forgot .round(2) on the pandas side, or ROUND(..., 2) on the SQL side. Both must round the same way.
All systems — KeyError: 'site' after groupby. You forgot .reset_index(). The group keys are in the index, not in the columns.
All systems — AssertionError on the temperatures with a gap of 0.1. That is the rounding case of Step 5. Compare with a tolerance, as in Step 6, not with ==.
Windows only — sqlite3.OperationalError: unable to open database file. The file is open in another program. Close it and run again.
Linux and macOS only — python: command not found. Use python3 to create the venv. Once active, python works.