Skip to main content
Back to module
Python Fundamentals · Mini Project

Build a grade or sensor-log analyser

Reading48mLesson 1 of 3 in this module

Parse messy text records, skip bad lines, group by key and report summary statistics.

Why it matters

This lesson pulls the whole course together. Turning raw text records into a clear summary is a task you will meet in almost every technical job: an engineer summarising sensor logs, a lab technician checking instrument output, a teacher totalling grades, a data analyst cleaning an export. The same five steps appear every time: read, parse, validate, group, report.

The problem

A building's sensors write one line per reading:

# date,sensor,value
2026-10-01,temp-a,21.5
2026-10-01,temp-b,19.0
2026-10-02,temp-a,22.5

2026-10-02,temp-b,n/a
2026-10-03,temp-a,20.0
2026-10-03,humid,55
garbage line

Real logs are messy. This one has a comment line, a blank line, a reading of n/a and a broken line. We want a report like:

humid: n=1 min=55.0 max=55.0 mean=55.0
temp-a: n=3 min=20.0 max=22.5 mean=21.3
temp-b: n=1 min=19.0 max=19.0 mean=19.0

The same design works for grades: replace "sensor" with "student" or "assignment" and "value" with "score".

Step 1: parse one line

Start with the smallest piece. A function that takes one line and returns a clean record, or None if the line is not usable:

def parse_line(line):
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    parts = line.split(",")
    if len(parts) != 3:
        return None
    day, sensor, text = parts
    try:
        value = float(text)
    except ValueError:
        return None
    return (day, sensor, value)

Every way a line can be bad is handled explicitly: blank, comment, wrong number of fields, value that is not a number. Returning None for "skip this" keeps the decision in one place.

Test it before moving on:

assert parse_line("2026-10-01,temp-a,21.5") == ("2026-10-01", "temp-a", 21.5)
assert parse_line("# header") is None
assert parse_line("2026-10-02,temp-b,n/a") is None
assert parse_line("garbage line") is None

Step 2: load every line

def load_records(text):
    records = []
    for line in text.splitlines():
        record = parse_line(line)
        if record is not None:
            records.append(record)
    return records

text.splitlines() splits a block of text into lines. If the data were in a file, you would loop over the file object instead, exactly as in the files lesson. Keeping load_records working on a string makes it easy to test, and a one-line wrapper can read a file into that string.

Step 3: group by sensor

This is the dictionary-of-lists pattern from the dictionaries lesson:

def group_by_sensor(records):
    groups = {}
    for day, sensor, value in records:
        groups.setdefault(sensor, []).append(value)
    return groups

groups.setdefault(sensor, []) returns the existing list for that sensor, or creates an empty one first. It is a compact version of the if sensor not in groups: check.

Step 4: summarise each group

import statistics

def summarise(records):
    summary = {}
    for sensor, values in group_by_sensor(records).items():
        summary[sensor] = {
            "count": len(values),
            "min": min(values),
            "max": max(values),
            "mean": statistics.mean(values),
        }
    return summary

The result is a dictionary of dictionaries, easy to test and easy to turn into any output format.

Step 5: format the report

def format_report(summary):
    lines = []
    for sensor in sorted(summary):
        s = summary[sensor]
        lines.append(
            f"{sensor}: n={s['count']} min={s['min']:.1f} "
            f"max={s['max']:.1f} mean={s['mean']:.1f}"
        )
    return "\n".join(lines)

Sorting the sensor names makes the output predictable, which matters for testing and for readers comparing two reports. "\n".join(lines) glues the lines together with newlines between them.

Putting it together

report = format_report(summarise(load_records(LOG_TEXT)))
print(report)

Each function does one job, and the final line reads almost like the plan: load, summarise, format. When something is wrong, you can test each stage separately to see where the data stops looking right.

Extending it

Once the core works, good next steps are:

  • Count what was skipped. Report "3 lines skipped" so bad data does not vanish silently.
  • Flag out-of-range values. Reuse your reading_status function from the conditions lesson.
  • Write the report to a file with with open("report.txt", "w").
  • Group by day as well as sensor, using a tuple (day, sensor) as the dictionary key.
  • Grades version: a CSV of student,assignment,score, with a letter grade per student from their mean.

Common mistakes

  • Writing everything in one big loop. It works for the first version, then becomes impossible to change. Split it into functions.
  • Silently dropping data. Skipping bad lines is right, but count them so you know how much was lost.
  • Testing only the happy path. Include a blank line, a comment, a non-number and a short line in your test data.
  • Unpredictable output order. Sort keys before printing if a person or a test will compare the output.

Quick recap

  • Read, parse, validate, group, report: the shape of most data-summary programs.
  • Parse one line in a function that returns a record or None.
  • Group with a dictionary of lists, then summarise each group.
  • Format the report last, in a predictable order.
  • Test each function on its own with small, deliberately messy inputs.

Try it

The Do tab gives you the log above and the five function names. Implement them so the checks pass, then print the report.

Also in this lesson: