Exercise 2 — Six SQL queries, twice

Guided practice5 min
Time
25-30 min
You need
the kit, the venv active, data/diagnostics.sqlite built by python data/make_dataset.py
Deliverable
the line All six queries give the same answer in SQL and in pandas.

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

Goal

NorthPeak keeps its clean data in a SQLite file. Your colleagues ask questions in SQL. You prefer pandas. Today you answer six questions both ways and prove the answers match with assert. Each query adds one SQL word. By the end you have used COUNT, WHERE, ORDER BY, LIMIT, GROUP BY, AVG, SUM and JOIN.

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 week02/six_queries.py. Start it with this block. The function sql runs one query and returns a DataFrame.

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")


def sql(query):
    return pd.read_sql_query(query, con)
The data you will touch

data/diagnostics.sqlite holds three tables. They are the clean CSV files, loaded as is.

TableRowsKey columns
machines40machine_id, machine_type, site, install_year, rated_power_kw
readings14,600reading_id, machine_id, date, six sensor columns, fault_next_7d
incidents155incident_id, machine_id, date, category, severity, downtime_hours, repair_cost_cad, technician, description

machine_id appears in all three tables. It is the column you join on.

pd.read_sql_query("SELECT * FROM machines LIMIT 3", con) prints:

text
  machine_id machine_type     site  install_year  rated_power_kw
0       M001         pump  Toronto          2022            45.0
1       M002         pump  Montreal         2016            45.0
2       M003         pump  Toronto          2013            45.0

Query 1 — COUNT

The simplest query: how many readings? In pandas, len().

python
q1 = sql("SELECT COUNT(*) AS n FROM readings")
print(q1)
p1 = len(readings)
print("pandas:", p1)
assert q1["n"][0] == p1
text
       n
0  14600
pandas: 14600

AS n names the result column. Without it the column is called COUNT(*), which is hard to type.

Query 2 — WHERE, ORDER BY, LIMIT

The hot days of M001: temperature above 60, oldest first, five rows.

python
q2 = sql("""
    SELECT date, temperature_c
    FROM readings
    WHERE machine_id = 'M001' AND temperature_c > 60
    ORDER BY date
    LIMIT 5
""")
print(q2)
mask = (readings["machine_id"] == "M001") & (readings["temperature_c"] > 60)
p2 = readings.loc[mask, ["date", "temperature_c"]].sort_values("date").head(5)
print(p2.to_string(index=False))
assert q2["date"].tolist() == p2["date"].tolist()
text
         date  temperature_c
0  2025-04-23           61.1
1  2025-05-02           62.7
2  2025-05-05           65.7
3  2025-05-07           63.0
4  2025-05-20           60.3

The pandas side prints the same five rows. Notice the & between the two conditions, and the brackets around each one. In SQL it is AND with no brackets.

Query 3 — GROUP BY and COUNT

Machines per site. In pandas this is value_counts().

python
q3 = sql("SELECT site, COUNT(*) AS n FROM machines GROUP BY site ORDER BY n DESC")
print(q3)
p3 = machines["site"].value_counts()
print(p3)
assert dict(zip(q3["site"], q3["n"])) == p3.to_dict()
text
          site   n
0     Montreal  20
1      Toronto  13
2  Quebec City   7

The assert turns both results into dictionaries. Order does not matter in a dictionary, so the check is fair.

Query 4 — GROUP BY and AVG

The five machines with the highest mean temperature.

python
q4 = sql("""
    SELECT machine_id, ROUND(AVG(temperature_c), 1) AS mean_temp
    FROM readings
    GROUP BY machine_id
    ORDER BY mean_temp DESC
    LIMIT 5
""")
print(q4)
p4 = readings.groupby("machine_id")["temperature_c"].mean().round(1).sort_values(ascending=False).head(5)
print(p4)
assert q4["machine_id"].tolist() == p4.index.tolist()
text
  machine_id  mean_temp
0       M012       61.3
1       M018       59.7
2       M019       59.6
3       M016       59.2
4       M011       58.8

All five are compressors, M011 to M020. Compressors run hot by design.

Query 5 — JOIN

Mean power by machine type. Power is in readings, type is in machines. The pandas twin of JOIN is merge.

python
q5 = sql("""
    SELECT m.machine_type, ROUND(AVG(r.power_kw), 1) AS mean_power
    FROM readings r
    JOIN machines m ON r.machine_id = m.machine_id
    GROUP BY m.machine_type
    ORDER BY mean_power DESC
""")
print(q5)
merged = readings.merge(machines, on="machine_id")
p5 = merged.groupby("machine_type")["power_kw"].mean().round(1).sort_values(ascending=False)
print(p5)
assert q5["mean_power"].tolist() == p5.tolist()
text
  machine_type  mean_power
0   compressor        45.8
1      chiller        36.9
2         pump        25.9
3     conveyor        12.6

r and m are short names for the two tables. r.machine_id = m.machine_id says which rows go together. merged has 14,600 rows and the columns of both tables.

Query 6 — Two aggregates at once

Incidents and total repair cost by category.

python
q6 = sql("""
    SELECT category, COUNT(*) AS n, ROUND(SUM(repair_cost_cad), 2) AS cost
    FROM incidents
    GROUP BY category
    ORDER BY n DESC
""")
print(q6)
p6 = (
    incidents.groupby("category")
    .agg(n=("incident_id", "count"), cost=("repair_cost_cad", "sum"))
    .round(2)
    .sort_values("n", ascending=False)
)
print(p6)
assert q6["n"].tolist() == p6["n"].tolist()
assert q6["cost"].tolist() == p6["cost"].tolist()
con.close()
print("All six queries give the same answer in SQL and in pandas.")
text
       category   n      cost
0  bearing_wear  48  60257.29
1   overheating  39  54811.67
2          leak  28  22957.77
3    electrical  23  31467.09
4  sensor_fault  17  10439.98
All six queries give the same answer in SQL and in pandas.

Bearing wear is the most frequent category and the most expensive: 48 incidents, 60,257 dollars. Electrical is rarer but costs more per incident than leaks.

Check yourself

  1. Which SQL word is the twin of merge, and which column did you join on?
  2. What does AS n change in the result?
  3. Why does the assert of Query 3 convert both sides to dictionaries?
  4. Which machine type has the highest mean power, and what is the value?
Answers
  1. JOIN. Both tables share machine_id, so ON r.machine_id = m.machine_id.
  2. It names the result column n. Without it the column is called COUNT(*).
  3. SQL and value_counts() may order the sites differently. A dictionary compares site to count, whatever the order.
  4. Compressors, 45.8 kW. Then chillers 36.9, pumps 25.9, conveyors 12.6.

Bonus (optional)

Write a seventh query: the mean downtime_hours and the mean repair_cost_cad by severity, in SQL and in pandas. Then a harder one: the number of incidents per site, which needs a JOIN between incidents and machines. Montreal should have 66.

Full solutionweek02/exercise_2_solution.py in the kit
python
"""Week 2, Exercise 2 - Six SQL queries, and the same six in pandas.

Run from the kit root, with the venv active:

    python week02/exercise_2_solution.py

Every query runs twice: once in SQL on data/diagnostics.sqlite with sqlite3,
once in pandas on the CSV files of data/clean/. The script checks that the
two answers are the same.
"""

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")


def sql(query):
    """Run one SELECT and return a DataFrame."""
    return pd.read_sql_query(query, con)


# Query 1 - COUNT
print("== Query 1: how many readings? ==")
q1 = sql("SELECT COUNT(*) AS n FROM readings")
print(q1)
p1 = len(readings)
print("pandas:", p1)
assert q1["n"][0] == p1
print()

# Query 2 - WHERE + ORDER BY + LIMIT
print("== Query 2: hot days of M001 ==")
q2 = sql(
    """
    SELECT date, temperature_c
    FROM readings
    WHERE machine_id = 'M001' AND temperature_c > 60
    ORDER BY date
    LIMIT 5
    """
)
print(q2)
mask = (readings["machine_id"] == "M001") & (readings["temperature_c"] > 60)
p2 = readings.loc[mask, ["date", "temperature_c"]].sort_values("date").head(5)
print(p2.to_string(index=False))
assert q2["date"].tolist() == p2["date"].tolist()
print()

# Query 3 - GROUP BY + COUNT
print("== Query 3: machines per site ==")
q3 = sql("SELECT site, COUNT(*) AS n FROM machines GROUP BY site ORDER BY n DESC")
print(q3)
p3 = machines["site"].value_counts()
print(p3)
assert dict(zip(q3["site"], q3["n"])) == p3.to_dict()
print()

# Query 4 - GROUP BY + AVG
print("== Query 4: the five hottest machines ==")
q4 = sql(
    """
    SELECT machine_id, ROUND(AVG(temperature_c), 1) AS mean_temp
    FROM readings
    GROUP BY machine_id
    ORDER BY mean_temp DESC
    LIMIT 5
    """
)
print(q4)
p4 = readings.groupby("machine_id")["temperature_c"].mean().round(1).sort_values(ascending=False).head(5)
print(p4)
assert q4["machine_id"].tolist() == p4.index.tolist()
print()

# Query 5 - JOIN
print("== Query 5: mean power by machine type ==")
q5 = sql(
    """
    SELECT m.machine_type, ROUND(AVG(r.power_kw), 1) AS mean_power
    FROM readings r
    JOIN machines m ON r.machine_id = m.machine_id
    GROUP BY m.machine_type
    ORDER BY mean_power DESC
    """
)
print(q5)
merged = readings.merge(machines, on="machine_id")
p5 = merged.groupby("machine_type")["power_kw"].mean().round(1).sort_values(ascending=False)
print(p5)
assert q5["mean_power"].tolist() == p5.tolist()
print()

# Query 6 - GROUP BY with two aggregates
print("== Query 6: incidents and cost by category ==")
q6 = sql(
    """
    SELECT category, COUNT(*) AS n, ROUND(SUM(repair_cost_cad), 2) AS cost
    FROM incidents
    GROUP BY category
    ORDER BY n DESC
    """
)
print(q6)
p6 = (
    incidents.groupby("category")
    .agg(n=("incident_id", "count"), cost=("repair_cost_cad", "sum"))
    .round(2)
    .sort_values("n", ascending=False)
)
print(p6)
assert q6["n"].tolist() == p6["n"].tolist()
assert q6["cost"].tolist() == p6["cost"].tolist()
print()

con.close()
print("All six queries give the same answer in SQL and in pandas.")

Run it with python week02/exercise_2_solution.py. The last line of the output is the deliverable.

Stuck? Common errors

All systems — sqlite3.OperationalError: no such table: readings. The path is wrong, so SQLite created a new empty database. Delete the empty file it created and check you are in the kit root. sqlite3.connect never complains about a missing file.

All systems — sqlite3.OperationalError: no such column: chiller. You wrote text with double quotes. Use single quotes: 'chiller'.

All systems — AssertionError on Query 5. Your merge may have dropped rows. Print len(merged); it must be 14,600. Check that on="machine_id" is spelled right.

All systems — ValueError: The truth value of a Series is ambiguous. You wrote and between two conditions. pandas needs &, and brackets around each condition.

Windows only — sqlite3.OperationalError: unable to open database file. The file is open in another program, for example a database viewer. Close it and run again.

Linux and macOS only — sqlite3 module missing. Rare. Your Python was built without SQLite. Install python3 from your package manager, not from source, and rebuild the venv.