A Three REST resources
Since August 2026 the source data are published as REST resources rather than reached only through a PostgreSQL connection. They are public: no key, no account, no VPN. That is what makes the datings reproducible by someone outside the project.
sql/v_ips_dated_stamps.sql delivers, and the
model is computed from them. Currently 1391 records.
py/ips_model.py is measured against.
Currently 40 rows and 41 columns.
datemin and datemax per stamp. This was
tried first and produced an empty model in silence, which is why it is written
down rather than discovered again.
What the payloads actually look like
Both REST resources answer with ColdFusion's query serialisation: a
COLUMNS list and a DATA list of positional rows — not a list
of objects. Column names are upper case; everything else in the project uses lower case.
{ "records": 1391,
"success": true,
"data": {
"COLUMNS": ["THE_ID", "THE_SITE", "THE_FINDSPOT", …, "DATEMIN", "DATEMAX"],
"DATA": [ [1000136, "London", "New Fresh Wharf: quay", …, 200, 260],
… ] } }
load_statistics() in py/ips_rest.py accepts either, plus a
plain JSON array, and reports what it received when it can make sense of none of them.
The pipe-delimited variant also renders SQL NULL as an empty field —
indistinguishable from an empty string — and omitted two columns the database query
produces, the_id and q_repetition. A comparison that assumes
the two column sets match will report dozens of differences that are not there.
B Fetching them
Nothing beyond a browser is needed to look; the pipeline uses
urllib from the standard library, so there is no dependency and no
credential anywhere in the code.
# look at it curl -s https://www1.rgzm.de/rest/samianresearch/datedsites | head -c 400 # save both payloads where the pipeline expects them curl -s -o data/source/datedsites.json \ https://www1.rgzm.de/rest/samianresearch/datedsites curl -s -o data/source/datedsitesstatistics.csv \ https://www1.rgzm.de/rest/samianresearch/datedsitesstatistics
# or from Python, through the project's own reader import sys; sys.path.insert(0, "py") import ips_rest from pathlib import Path paths, origin, notes = ips_rest.resolve(Path("data/source")) rows = ips_rest.load_datedsites_json(paths["datedsites"]) print(origin, len(rows)) # -> live 1391
resolve() tries the
endpoints and falls back to the copies in data/source/ only when they
cannot be reached. That order is deliberate: a stale export is the failure this project
has actually suffered — the whole corpus was once rebuilt from a file carrying the
wrong t0, and the Böckleareal findspots went missing for a week without
anyone noticing. Partial success counts as failure: if only one of the two payloads
arrives, both are read from the archive, because checking a fresh stamp list against a
stale reference would make the disagreement look like a defect in the model.
Reproducibility is preserved rather than traded away. What was fetched is written
into data/source/ and stamped into data/SNAPSHOT.json with the
date, byte sizes and SHA-256 of each payload, the record counts and the model
parameters in force. A build from the archive alone produces the same figures — it just
says which of the two it did. Cite the snapshot as the data state rather than asserting
a fixed number of findspots: Samian Research is a live database.
| Invocation | Reads |
|---|---|
| python py/main.py | the endpoints, falling back to data/source/ |
| python py/main.py --offline | data/source/ only, no network attempt — what CI runs |
| python py/main.py --no-rest | the CSV in data/ as it stands; reproduces an older build exactly |
| python py/main.py --csv data/other.csv | a named export instead |
C The figures
Five sheets, all written by python py/main.py into
img/, each as SVG and as JPG at 300 dpi. They are generated from the same
rows as the interactive plot, so a figure cannot disagree with the table beside it.
q_interval, the whiskers carry
q_start and q_end in their own colours, the thin line
behind is the full range of the contributing potters, and the bar in the left margin
is the second quality axis, q_repetition. The encoding is identical to
the web plot; only the setting is different — typography, a quieter grid, a BC/AD
axis, a value table.
SVG, 376 kB ·
JPG, 300 dpi, 1.8 MB
D Embedding them elsewhere
The figures on this page are not copies. Each
<img> points straight at the file in the repository, so a rebuild that
changes a figure changes what this page shows, and there is no second version to keep in
step.
<img src="https://raw.githubusercontent.com/leiza-rse/IPSDatedSites/main/img/plot_v2_modern.svg" alt="Findspot datings from samian potters' stamps" loading="lazy">
Replace the file name with any of the five. raw.githubusercontent.com
serves .svg as image/svg+xml and .jpg as
image/jpeg, so both work in an ordinary <img> tag; the
path pattern is …/main/img/name.svg|jpg, and pinning a
commit hash instead of main freezes the figure at a known state.
| Use | Take | Why |
|---|---|---|
| web page, this application | .svg | text stays selectable and the figure stays sharp at any zoom |
| print, a submitted manuscript | .jpg | 300 dpi, and no dependency on how a renderer treats fonts in SVG |
| a slide | .jpg | the large SVGs carry tens of thousands of elements; some presentation software will not thank you |
SOURCE_DATE_EPOCH and a fixed
svg.hashsalt are set before matplotlib is imported, so a rebuild that
changes nothing produces an identical file. Without that, matplotlib writes a fresh
timestamp and freshly randomised element identifiers every run, every figure appears
modified in git for ever, and a real change to a published figure goes
unnoticed among the noise. It also means a figure you embedded and one rebuilt six
months later can be compared with cmp.
img/. They are build output and
the next run overwrites them. Everything the figures show — margins, row height, sort
order, colour ramp — is read from the graph and the model parameters, so a change
belongs in py/ips_render.py or in the parameters, not in the SVG.
Rebuilding them
python py/main.py # everything, figures included python py/main.py --skip-plots # everything except the figures python py/main.py --img-out /tmp/figs # write them somewhere else python py/make_calibration_panels.py # the two v3 panel sheets alone
The panel sheets read the CSV directly rather than the graph, because the calibration
argument has to be checkable against the source table without a graph in between. The
termini are not restated in the plotting code either: they are read from
CALIBRATION_REFERENCES in py/ips_rdf_export.py, which is also
what the RDF export publishes as lado:calibratedAgainst. One list, one place
to correct.