A scientific figure exists to make one claim visible. Everything else is decoration.
| File | Source | Coverage | Used for |
|---|---|---|---|
| iowa_streamflow_daily.csv | USGS NWIS, site 05454500, Iowa River at Iowa City | Daily, 2015–2025 | River panel, flow-duration curve |
| iowa_city_weather_daily.csv | Iowa Environmental Mesonet, station IOW | Daily, 2019–2025 | Rain panel, scatter, histogram |
| synthetic_temperature_grid.csv | Course-generated, seed 4100 | 50 × 50 grid, 10 × 8 km | Colormap choices |
| synthetic_elevation_grid.csv | Course-generated, seed 4100 | 50 × 50 grid, 10 × 8 km | Gridded field map |
Decide what the reader should see before writing any plotting code.
| Question | One-sentence claim | Figure |
|---|---|---|
| How did the river respond to the May 2019 storms? | The Iowa River peaked at 12,600 cfs on 29 May, one day after 42.7 mm of rain. | Two-panel rain and river |
| How rare are flows like spring 2019? | Flows above 6,520 cfs occur on only 10% of days in 2015–2025. | Flow-duration curve, log axis |
| Where is the site warmer than average? | A north–south band near x = 4 km runs about 1 °C above the site mean. | Anomaly map, diverging colormap |
Every visible element is an object you can name and control.
pyplot state machine
plt.plot(s["date"], s["discharge_cfs"])
plt.ylabel("Discharge (cfs)")
plt.title("Iowa River, 2019")
plt.show()
Acts on whichever axes is "current". Fine for a quick look; ambiguous once there are two panels.
Explicit figure and axes · use this
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(s["date"], s["discharge_cfs"])
ax.set_ylabel("Discharge (cfs)")
ax.set_title("Iowa River, 2019")
Every call names its target. The same code scales to two, four or nine panels without changes.
fig, (ax_rain, ax_flow) = plt.subplots(
2, 1,
sharex=True,
figsize=(10, 6),
gridspec_kw={"height_ratios": [1, 2]},
layout="constrained",
)One bad figure, five problems, three steps to fix it.
fig, ax = plt.subplots(figsize=(3, 2))
ax.plot(stream["date"], stream["discharge_cfs"])
ax.plot(weather["date"],
weather["precipitation_mm"])
plt.show()
Same 2019 data as the rest of this section. Work in pairs for two minutes.
Discharge (cfs). A figure without units cannot be checked.
Text legible from the back row, or in a two-column PDF.
Whenever two lines share a panel. No guessing.
Title or caption says what to see, not just what it is.
This checklist is the figure rubric for every remaining lab.
w = weather[weather["date"].dt.year == 2019]
s = stream[stream["date"].dt.year == 2019]
fig, (ax_rain, ax_flow) = plt.subplots(
2, 1, sharex=True, figsize=(10, 6),
gridspec_kw={"height_ratios": [1, 2]})
ax_rain.bar(w["date"], w["precipitation_mm"],
width=1)
ax_flow.plot(s["date"], s["discharge_cfs"])
Fixes problem 2. Rain is daily totals, so it is drawn as bars; flow is continuous, so it is a line.
ax_rain.invert_yaxis()
ax_rain.set_ylabel("Rain (mm/day)")
ax_flow.set_ylabel("Discharge (cfs)")
ax_flow.set_xlabel("Date, 2019")
ax_flow.xaxis.set_major_formatter(
mdates.DateFormatter("%b"))
ax_flow.yaxis.set_major_formatter(
StrMethodFormatter("{x:,.0f}"))
Fixes problems 1 and 4. Month names replace ISO dates; thousands separators make 12,000 readable at a glance.
flood = (pd.Timestamp("2019-05-17"),
pd.Timestamp("2019-07-04"))
for ax in (ax_rain, ax_flow):
ax.axvspan(*flood, color="0.92", zorder=0)
peak = s.loc[s["discharge_cfs"].idxmax()]
q = peak["discharge_cfs"]
ax_flow.annotate(f"Peak {q:,.0f} cfs, 29 May",
xy=(peak["date"], q), xytext=(20, 0),
textcoords="offset points",
arrowprops={"arrowstyle": "-"})
Fixes problem 5. The band marks 17 May to 4 July, when flow rose and stayed above 9,000 cfs.
figures/grouplab01_rain_river_2019.png
?
Your two lines are distinguishable only by color. Name two situations where the figure fails, and the fix.
When values span orders of magnitude, a linear axis hides most of the data.
Linear axis
Log axis
Iowa River at Iowa City, 2015–2025: 89 to 14,800 cfs, a 166-fold range. On the linear axis, everything below flood flow flattens.
from earthcourse.hydro import flow_duration_curve fdc = flow_duration_curve(stream) # 4,018 days fig, ax = plt.subplots(figsize=(8, 5)) ax.semilogy(fdc["exceedance_probability"] * 100, fdc["discharge_cfs"]) ax.set_xlabel("Percent of days flow is exceeded") ax.set_ylabel("Discharge (cfs)") ax.grid(True, which="both", alpha=0.25)
| Statistic | Flow (cfs) | Meaning |
|---|---|---|
| Q10 | 6,520 | High flow: exceeded on 10% of days |
| Q50 | 1,850 | Median daily flow |
| Q90 | 258 | Low flow: exceeded on 90% of days |
The 2019 peak, 12,600 cfs, sits above Q10 by a factor of two.
A colormap is an axis. Choose it the way you choose a scale.
jet
jet, in grayscale
viridis
viridis, in grayscale
| Type | Use when the data are | Example in our data | matplotlib | Map |
|---|---|---|---|---|
| Sequential | Magnitudes from low to high | Elevation (m), temperature (°C) | viridis, YlGnBu | |
| Diverging | Departures from a meaningful center | Temperature anomaly from the site mean | RdBu_r, centered on 0 | |
| Qualitative | Unordered categories | Station, land-cover class | tab10 | |
| Avoid | Never for quantitative data | It invents boundaries | jet, rainbow |
grid = temp.pivot(index="y_km", columns="x_km",
values="temperature_c")
anom = grid - grid.values.mean()
lim = np.abs(anom.values).max()
fig, ax = plt.subplots(figsize=(7, 5))
im = ax.pcolormesh(
grid.columns, grid.index, anom,
cmap="RdBu_r", vmin=-lim, vmax=lim)
fig.colorbar(im, ax=ax, label="Anomaly (°C)")
ax.set_aspect("equal")
ax.set_xlabel("x (km)")
ax.set_ylabel("y (km)")
The question decides the plot type.
| Question | Plot | Watch for |
|---|---|---|
| How does it change in time? | Line (ax.plot) | Too many lines = spaghetti |
| How do two variables relate? | Scatter (ax.scatter) | Overplotting: use alpha |
| How is it distributed? | Histogram (ax.hist) | Bin count changes the story |
| What does the field look like? | imshow / contourf | Colorbar with units |
| Orders of magnitude? | semilogy | Log-scale ticks readable |
Daily discharge, USGS 05454500
2,546 days, IEM station IOW
Daily mean, 2019–2025
Synthetic elevation, 204–259 m
Flow-duration curve, 2015–2025
| Function | Draws | Use it to show | Example in our data |
|---|---|---|---|
| ax.plot(x, y) | Lines | Change over time | Daily discharge, 2019 |
| ax.scatter(x, y) | Points | How two variables relate | Dew point vs. air temperature |
| ax.bar(x, height) | Bars | Totals per group | Rain total per month, 2019 |
| ax.hist(values, bins=30) | Counts in bins | How one variable is spread | Daily mean temperature |
| ax.boxplot(groups) | Boxes and whiskers | Distributions side by side | Discharge by month |
| ax.fill_between(x, low, high) | Shaded band | A range or uncertainty | Flow range by day of year |
| ax.errorbar(x, y, yerr=sd) | Points with bars | A mean and its spread | Temperature by month, ±1 SD |
| ax.imshow(grid) | Cells by array index | A field on a regular grid | Elevation, 50 × 50 cells |
| ax.pcolormesh(x, y, grid) | Cells at x, y | A field in real coordinates | Temperature grid, km axes |
| ax.contourf(x, y, grid) | Filled contours | Levels of a smooth surface | Elevation contours |
| ax.semilogy(x, y) | Line, log y axis | Values across orders of magnitude | Flow-duration curve |
All eleven are methods of ax from fig, ax = plt.subplots(). pandas shortcuts such as df.plot() and df.hist() call the same functions.
The caption carries the claim; the saved file is the deliverable.
Daily discharge of the Iowa River at Iowa City for 2019, with daily precipitation inverted above.
USGS station 05454500 and Iowa Environmental Mesonet data; highlighted band marks the spring flood, 17 May to 4 July.
The river peaked at 12,600 cfs on 29 May, one day after 42.7 mm of rain; flow stayed above 9,000 cfs into early July.
?
A caption reads: "Figure 1. Discharge versus time." Which of the three parts is present, and what is missing?
Flow rose from about 2,000 to 12,600 cfs over two weeks of repeated storms.
214 mm fell between 17 and 29 May on already wet soils, so most rain became runoff.
Rain is from one station, IOW; the Iowa River drains a much larger basin upstream.
Group Lab 1 grades all three, not just the picture.
from earthcourse.plotting import save_figure
saved_path = save_figure(
fig,
PROJECT_ROOT / "figures"
/ "grouplab01_rain_river_2019.png",
)
print(saved_path)Group labs begin this week.
Data validation: date ranges, missing values, units confirmed before plotting.
Statistics: the numbers the figure will carry (peaks, means, reference lines).
Figure and caption: design, critique, 200-word interpretation.
Groups of 2-3; roles posted before Wednesday; everyone signs a contribution statement.
?
Find one published figure this week, from any paper, that fails the checklist. Bring it Friday; the best bad example earns bragging rights.