Skip to content

Commit 8219cb2

Browse files
committed
add a statistics page plotting model count over time
1 parent 8016c9b commit 8219cb2

5 files changed

Lines changed: 278 additions & 9 deletions

File tree

biggr_models/handlers/home_handlers.py

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,102 @@
1-
"""Handler for the BiGGr front page."""
1+
"""Handlers for the BiGGr front page and statistics page."""
22

33
from biggr_models.handlers import utils
4-
from biggr_models.queries.summary_queries import get_summary_counts
4+
from biggr_models.queries.summary_queries import (
5+
get_summary_counts,
6+
get_summary_history,
7+
)
8+
9+
10+
def build_model_chart(points, width=720, height=320, pad_left=64, pad=32):
11+
"""Turn [{"date": ..., "count": ...}, ...] into SVG geometry for one line.
12+
13+
Returns None when there are fewer than two points, so the template can show
14+
the cold-start message instead of an axis with nothing on it.
15+
16+
The y axis starts at zero: scaling to the minimum would turn a small
17+
absolute change into a dramatic climb, which is the kind of misleading
18+
chart this page exists to avoid.
19+
"""
20+
if not points or len(points) < 2:
21+
return None
22+
23+
counts = [p["count"] for p in points]
24+
dates = [p["date"] for p in points]
25+
26+
plot_w = width - pad_left - pad
27+
plot_h = height - 2 * pad
28+
29+
top = _nice_ceiling(max(counts))
30+
span = (dates[-1] - dates[0]).total_seconds() or 1
31+
32+
def x_of(date):
33+
return pad_left + plot_w * (date - dates[0]).total_seconds() / span
34+
35+
def y_of(count):
36+
return pad + plot_h * (1 - count / top)
37+
38+
coords = [(x_of(d), y_of(c)) for d, c in zip(dates, counts)]
39+
40+
return {
41+
"width": width,
42+
"height": height,
43+
"plot_left": pad_left,
44+
"plot_right": pad_left + plot_w,
45+
"plot_top": pad,
46+
"plot_bottom": pad + plot_h,
47+
"polyline": " ".join(f"{x:.1f},{y:.1f}" for x, y in coords),
48+
# A filled area under the line reads as "accumulated total" far more
49+
# clearly than a bare stroke.
50+
"area": "{} {} {}".format(
51+
f"{coords[0][0]:.1f},{pad + plot_h:.1f}",
52+
" ".join(f"{x:.1f},{y:.1f}" for x, y in coords),
53+
f"{coords[-1][0]:.1f},{pad + plot_h:.1f}",
54+
),
55+
"dots": [
56+
{"x": x, "y": y, "count": c, "date": d}
57+
for (x, y), c, d in zip(coords, counts, dates)
58+
],
59+
"y_ticks": [
60+
{"value": v, "y": y_of(v)} for v in _tick_values(top)
61+
],
62+
"x_ticks": [
63+
{"label": dates[0].strftime("%b %Y"), "x": x_of(dates[0])},
64+
{"label": dates[-1].strftime("%b %Y"), "x": x_of(dates[-1])},
65+
],
66+
"first": {"count": counts[0], "date": dates[0]},
67+
"last": {"count": counts[-1], "date": dates[-1]},
68+
"added": counts[-1] - counts[0],
69+
}
70+
71+
72+
def _nice_ceiling(value, divisions=4):
73+
"""Round up to an axis maximum that divides into readable ticks.
74+
75+
Rounds the tick *step* rather than the maximum, so the ticks land on round
76+
numbers while the top stays close to the data: 4881 gives 1250-steps and a
77+
top of 5000, not 8000, which would leave the line hugging the floor.
78+
"""
79+
if value <= 0:
80+
return divisions
81+
# Try every round step across the plausible magnitudes and keep the
82+
# smallest top that still covers the data, so the line fills the plot.
83+
candidates = []
84+
magnitude = 1
85+
while magnitude <= max(value, 1):
86+
for multiple in (1, 1.25, 2, 2.5, 5):
87+
step = magnitude * multiple
88+
top = step * divisions
89+
# Keep only steps that give whole-number tick labels.
90+
if top >= value and float(step).is_integer():
91+
candidates.append(int(top))
92+
magnitude *= 10
93+
return min(candidates) if candidates else int(value)
94+
95+
96+
def _tick_values(top, count=4):
97+
"""Evenly spaced y-axis values from 0 to top, inclusive."""
98+
step = top / count
99+
return [int(round(step * i)) for i in range(count + 1)]
5100

6101

7102
class HomeHandler(utils.BaseHandler):
@@ -13,4 +108,22 @@ def get(self):
13108
counts = utils.do_safe_query(get_summary_counts)
14109
# Wrap in a dict so the result stays truthy even when no counts are
15110
# available; return_result skips the context entirely on a falsy result.
16-
self.return_result({"counts": counts})
111+
self.return_result({"counts": counts})
112+
113+
114+
class StatisticsHandler(utils.BaseHandler):
115+
"""Database growth over time."""
116+
117+
template = utils.env.get_template("statistics.html")
118+
119+
def get(self):
120+
history = utils.do_safe_query(get_summary_history)
121+
model_points = history.get("models", [])
122+
self.return_result(
123+
{
124+
"history": history,
125+
"model_points": model_points,
126+
"chart": build_model_chart(model_points),
127+
"breadcrumbs": [("Home", "/"), ("Statistics", "/statistics")],
128+
}
129+
)

biggr_models/queries/summary_queries.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
"""Read precomputed whole-database counts for the front page.
1+
"""Read precomputed whole-database counts for the front page and statistics.
22
33
The counts are written by the cobradb ETL (cobradb.summary_loading), which runs
4-
as the final step of bin/load_db. This module only reads them, so the front page
5-
costs one indexed query instead of a COUNT(*) per card on every request.
4+
as the final step of bin/load_db. Every run appends a snapshot, so the table is
5+
both the current totals and the history of how the database has grown. Reading
6+
it costs one indexed query instead of a COUNT(*) per card on every request.
67
"""
78

89
import logging
9-
from typing import Dict
10+
from typing import Dict, List
1011

1112
from sqlalchemy import select
1213
from sqlalchemy.exc import SQLAlchemyError
@@ -33,7 +34,9 @@ def get_summary_counts(session) -> Dict[str, int]:
3334
"""
3435
try:
3536
rows = session.execute(
36-
select(DatabaseSummaryCount.entity_type, DatabaseSummaryCount.count)
37+
select(
38+
DatabaseSummaryCount.entity_type, DatabaseSummaryCount.count
39+
).order_by(DatabaseSummaryCount.date_time)
3740
).all()
3841
except SQLAlchemyError as e:
3942
# do_safe_query only maps NotFoundError and ValueError, so an error here
@@ -43,4 +46,34 @@ def get_summary_counts(session) -> Dict[str, int]:
4346
logging.warning("Could not read database summary counts: %s", e)
4447
return {}
4548

46-
return {entity_type: count for entity_type, count in rows}
49+
# The table holds one row per entity per ETL run. Ordering by date_time
50+
# means the newest row is assigned last, so the dict ends up holding the
51+
# current totals rather than an arbitrary older snapshot.
52+
return {entity_type: count for entity_type, count in rows}
53+
54+
55+
def get_summary_history(session) -> Dict[str, List[Dict]]:
56+
"""Return {entity_type: [{"date": ..., "count": ...}, ...]} oldest first.
57+
58+
Never raises; an empty dict lets the statistics page explain that no
59+
history has been collected yet instead of returning a 500.
60+
"""
61+
try:
62+
rows = session.execute(
63+
select(
64+
DatabaseSummaryCount.entity_type,
65+
DatabaseSummaryCount.date_time,
66+
DatabaseSummaryCount.count,
67+
).order_by(DatabaseSummaryCount.date_time)
68+
).all()
69+
except SQLAlchemyError as e:
70+
session.rollback()
71+
logging.warning("Could not read database summary history: %s", e)
72+
return {}
73+
74+
history: Dict[str, List[Dict]] = {}
75+
for entity_type, date_time, count in rows:
76+
history.setdefault(entity_type, []).append(
77+
{"date": date_time, "count": count}
78+
)
79+
return history

biggr_models/routes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def get_routes():
4949
routes = [
5050
(r"/", home_handlers.HomeHandler),
5151
(r"/about/?", utils.TemplateHandler, {"template_name": "about.html"}),
52+
(r"/statistics/?", home_handlers.StatisticsHandler),
5253
(
5354
r"/api/%s/objects/?$" % api_v,
5455
object_handlers.ObjectHandler,

biggr_models/templates/general_navbar.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ <h5 class="offcanvas-title" id="offcanvasNavbar2Label">BiGGr</h5> <button type="
2929
<li class="nav-item"> <a class="nav-link{% if homepage %} active" aria-current="page"{% else %}"{% endif %} href="/">Home</a> </li>
3030
<li class="nav-item"> <a class="nav-link{% if aboutpage %} active" aria-current="page"{% else %}"{% endif %} href="/about">About</a> </li>
3131
<li class="nav-item"> <a class="nav-link{% if dataaccesspage %} active" aria-current="page"{% else %}"{% endif %} href="/data_access">Data Access</a> </li>
32+
<li class="nav-item"> <a class="nav-link{% if statisticspage %} active" aria-current="page"{% else %}"{% endif %} href="/statistics">Statistics</a> </li>
3233
</ul>
3334
<div>
3435
<div class="input-group d-flex mt-3 mt-lg-0"><input id="navbar_search_input" name="search_query"
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
{% extends "general_main.html" %}
2+
{% block title %}BiGGr Statistics{% endblock %}
3+
{% set statisticspage = True %}
4+
{% block body %}
5+
<div class="row">
6+
<div class="col-12 col-lg-10 offset-lg-1">
7+
8+
<h1 class="fs-3 mb-1">Database growth</h1>
9+
<p class="text-body-secondary mb-4">
10+
The number of metabolic models in BiGGr, recorded at each database update.
11+
</p>
12+
13+
<div class="card mb-4">
14+
<div class="card-body">
15+
{% if chart %}
16+
<div class="d-flex flex-wrap align-items-baseline gap-4 mb-3">
17+
<div>
18+
<div class="fs-2 fw-semibold lh-1">{{ "{:,}".format(chart.last.count) }}</div>
19+
<div class="small text-body-secondary">models today</div>
20+
</div>
21+
<div>
22+
<div class="fs-4 fw-semibold lh-1 text-success">
23+
{% if chart.added >= 0 %}+{% endif %}{{ "{:,}".format(chart.added) }}
24+
</div>
25+
<div class="small text-body-secondary">
26+
since {{ chart.first.date.strftime("%b %Y") }}
27+
</div>
28+
</div>
29+
</div>
30+
31+
<div style="overflow-x: auto;">
32+
<svg viewBox="0 0 {{ chart.width }} {{ chart.height }}"
33+
width="100%" height="{{ chart.height }}"
34+
role="img" aria-label="Number of models over time"
35+
style="min-width: 480px; display: block;">
36+
37+
{# horizontal gridlines + y-axis labels #}
38+
{% for tick in chart.y_ticks %}
39+
<line x1="{{ chart.plot_left }}" y1="{{ "%.1f"|format(tick.y) }}"
40+
x2="{{ chart.plot_right }}" y2="{{ "%.1f"|format(tick.y) }}"
41+
stroke="currentColor" stroke-opacity="0.12" stroke-width="1"/>
42+
<text x="{{ chart.plot_left - 10 }}" y="{{ "%.1f"|format(tick.y + 4) }}"
43+
text-anchor="end" font-size="12"
44+
fill="currentColor" fill-opacity="0.65">{{ "{:,}".format(tick.value) }}</text>
45+
{% endfor %}
46+
47+
{# filled area reads as an accumulated total #}
48+
<polygon points="{{ chart.area }}"
49+
fill="var(--bs-primary)" fill-opacity="0.12"/>
50+
51+
<polyline points="{{ chart.polyline }}"
52+
fill="none" stroke="var(--bs-primary)"
53+
stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
54+
55+
{# one dot per ETL run, with the exact figure on hover #}
56+
{% for dot in chart.dots %}
57+
<circle cx="{{ "%.1f"|format(dot.x) }}" cy="{{ "%.1f"|format(dot.y) }}" r="4"
58+
fill="var(--bs-body-bg)" stroke="var(--bs-primary)" stroke-width="2"
59+
data-bs-toggle="tooltip"
60+
data-bs-title="{{ dot.date.strftime('%d %b %Y') }}: {{ '{:,}'.format(dot.count) }} models"/>
61+
{% endfor %}
62+
63+
{# x-axis baseline + date labels #}
64+
<line x1="{{ chart.plot_left }}" y1="{{ chart.plot_bottom }}"
65+
x2="{{ chart.plot_right }}" y2="{{ chart.plot_bottom }}"
66+
stroke="currentColor" stroke-opacity="0.3" stroke-width="1"/>
67+
{% for tick in chart.x_ticks %}
68+
<text x="{{ "%.1f"|format(tick.x) }}" y="{{ chart.plot_bottom + 20 }}"
69+
text-anchor="{% if loop.first %}start{% else %}end{% endif %}"
70+
font-size="12" fill="currentColor" fill-opacity="0.65">{{ tick.label }}</text>
71+
{% endfor %}
72+
</svg>
73+
</div>
74+
75+
{% else %}
76+
{# Cold start: the table is filled one snapshot per ETL run, so a new
77+
deployment has nothing to plot until the second run. #}
78+
<div class="text-center py-4">
79+
{% if model_points %}
80+
<div class="fs-2 fw-semibold lh-1">{{ "{:,}".format(model_points[-1].count) }}</div>
81+
<div class="small text-body-secondary mb-3">models</div>
82+
<p class="text-body-secondary mb-0">
83+
Growth is recorded at each database update. The chart appears once
84+
there are at least two updates to compare.
85+
</p>
86+
{% else %}
87+
<p class="text-body-secondary mb-0">
88+
No growth data has been recorded yet. It is collected at the end of
89+
each database update.
90+
</p>
91+
{% endif %}
92+
</div>
93+
{% endif %}
94+
</div>
95+
</div>
96+
97+
{% if history %}
98+
<h2 class="fs-5 mb-3">Current contents</h2>
99+
<div class="row g-3 mb-4">
100+
{% for label, key in [("Collections", "collections"), ("Models", "models"),
101+
("Metabolites", "metabolites"), ("Reactions", "reactions"),
102+
("Genomes", "genomes"), ("Compartments", "compartments")] %}
103+
{% if history.get(key) %}
104+
<div class="col-6 col-md-4 col-lg-2">
105+
<div class="border rounded p-3 h-100">
106+
<div class="fs-4 fw-semibold lh-1">{{ "{:,}".format(history[key][-1].count) }}</div>
107+
<div class="small text-body-secondary">{{ label }}</div>
108+
</div>
109+
</div>
110+
{% endif %}
111+
{% endfor %}
112+
</div>
113+
114+
<p class="small text-body-secondary">
115+
Last updated {{ history["models"][-1].date.strftime("%d %B %Y") if history.get("models") }}.
116+
</p>
117+
{% endif %}
118+
119+
</div>
120+
</div>
121+
{% endblock %}

0 commit comments

Comments
 (0)