Exercise 1 — Three tables, two tools, same numbers

Guided practice5 min
Time
25-30 min
You need
the kit, the venv active, data/diagnostics.sqlite built
Deliverable
the three lines of Step 6 starting with by site, by site and type, three tables

The lab kit of the course: https://github.com/hrhouma2/aiopsatlas-ml-data-diagnostics-labs-en

Goal

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.

Setup: the commands (PowerShell, then bash)
powershell
cd aiopsatlas-ml-data-diagnostics-labs-en
.\.venv\Scripts\Activate.ps1
bash
cd aiopsatlas-ml-data-diagnostics-labs-en
source .venv/bin/activate

Create week03/three_tables.py and start it with this block.

python
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")
The data you will touch

Three tables, one shared key. incidents and readings also share date.

text
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_7d

The first incident, with its machine:

text
incident_id machine_id        date     category severity  repair_cost_cad | machine_type     site
   INC-0001       M001  2025-02-20  overheating     high          3465.33 |         pump  Toronto

To join incidents to readings you need two columns: the same machine and the same day. One column would glue each incident to 365 readings.

Step 1 — SQL, two tables, one group key

Incidents and total cost by site. site is in machines, so you need a JOIN.

python
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)
text
          site   n      cost
0     Montreal  66  82516.26
1      Toronto  56  56539.32
2  Quebec City  33  40878.22

Montreal has half the machines and 66 of the 155 incidents. The three counts add up to 155. Check that every time.

Step 2 — SQL, two group keys

Add machine_type to the GROUP BY. Twelve rows: three sites times four types.

python
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)
text
           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.41

The chillers of Montreal alone cost 44,102 dollars. That is a quarter of the whole year. The manager will want to know why.

Step 3 — SQL, three tables

Add the reading of the incident day. The second JOIN uses two conditions: same machine and same date.

python
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)
text
           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.5

The 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.

Step 4 — pandas, two tables

merge is the pandas JOIN. Then groupby with agg to get two numbers per group.

python
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()
)
text
merged rows: 155
          site   n      cost
0     Montreal  66  82516.26
1      Toronto  56  56539.32
2  Quebec City  33  40878.22

Same 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.

Step 5 — pandas, three tables

Merge again with readings, on two columns this time.

python
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)
text
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.6

Look 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.

Step 6 — Prove it

Sort both results the same way and compare with assert. For the temperatures, allow a gap of 0.1 for the rounding case.

python
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()
text
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.

Check yourself

  1. Why does the join with readings need two columns, not one?
  2. How many rows does two have, and why is that the right number?
  3. Which site and type cost the most, and how much?
  4. Why did SQL print 38.5 and pandas 38.6 for the same two incidents?
Answers
  1. One machine has 365 readings. Only the reading of the incident day is wanted, so you match on machine_id and date.
    1. One row per incident. A join that changes the count has a wrong key or a missing ON.
  2. Montreal chillers: 30 incidents, 44,102.65 dollars.
  3. The exact mean is 38.55. Rounding a value ending in 5 to one decimal is a choice. SQLite went down, Python went up.

Bonus (optional)

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.

Full solutionweek03/exercise_1_solution.py in the kit
python
"""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.

Stuck? Common errors

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.