- Python 67.2%
- Emacs Lisp 13.1%
- JavaScript 11.4%
- CSS 4.1%
- HTML 3.1%
- Other 1.1%
gunicorn's access log prints REMOTE_ADDR, which behind a proxy is the proxy (or Docker's bridge gateway), so logs showed the container's local address. The rate limiter already read X-Forwarded-For, but the logs did not. - mayan/api.py: at the start of each request, set REMOTE_ADDR to the real client address (from X-Forwarded-For, trusting MAYAN_PROXY_HOPS proxies) and keep the socket address in environ["mayan.proxy_addr"]. gunicorn logs from the same environ dict, so access-log lines and 429s now show the visitor. Applies even when rate limiting is disabled. - mayan/ratelimit.py: split out client_ip() (plain IP, no port) and group_key() (IPv6 grouped by /64). An unparseable forwarded entry now falls back to the socket address, so junk never reaches logs or keys. Policy.check() takes the address; MAYAN_PROXY_HOPS is read even when limiting is off. - README: logs show the real client (a 172.x line means no usable X-Forwarded-For, e.g. a direct health check), an HAProxy backend example (`option forwardfor` is enough), a note that X-Real-IP / X-Client-IP are not used, and to set MAYAN_PROXY_HOPS=2 if another layer sits in front of HAProxy. - tests: 7 new (87 total) for the two-header-line shape HAProxy produces, REMOTE_ADDR rewriting, no-header fallback, trusted-proxy 0, and hops with limiting disabled. Checked in the Docker image: a client-forged leading header is ignored, "ip:port" entries log as the bare IP, and a request without the header logs the gateway address. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cs15HErdrGNzMQCYWProJg |
||
|---|---|---|
| docs | ||
| mayan | ||
| references | ||
| tests | ||
| web | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| compose.yaml | ||
| Dockerfile | ||
| LICENSE | ||
| overview.md | ||
| README.md | ||
| requirements-prod.txt | ||
| requirements.txt | ||
mayan-cal
Convert dates to the Maya calendar (Long Count, Tzolk'in, Haab') and show where
the Sun, Moon and planets actually are that day, next to the cycle lengths the
Maya almanacs used for them. It comes as a date-style command line tool, a
JSON API and a small website.
$ python -m mayan 2012-12-21
13.0.0.0.0 4 Ahau 3 Kankin
The site at 13.0.0.0.0 (21 Dec 2012), on desktop and at phone width.
The calendar part is exact integer arithmetic. The sky part uses real positions from Skyfield and the JPL DE440s ephemeris. See overview.md for the background: what each cycle is, what the sources do and do not support, and which almanac periods are debated.
Requirements
- Python 3.11 or newer.
- The calendar and CLI need only the standard library.
- Astronomy (
--astro,--ascii,astro=1) needspip install -r requirements.txt(Skyfield). The first use downloads a ~32 MB ephemeris into~/.cache/mayan-cal(override withMAYAN_DATA_DIR). It covers 1849–2150.
python3 -m venv venv && . venv/bin/activate
pip install -r requirements.txt
Command line
python -m mayan [WHEN ...] [options]
WHEN can be any of:
| Form | Example |
|---|---|
| now (default) | now, today |
| ISO date | 2012-12-21 |
| ISO date and time | 2012-12-21T12:00:00Z |
| Unix time | @1356048000 |
| Long Count | 13.0.0.0.0 |
| Option | Meaning |
|---|---|
-j, --json |
JSON output, one object per WHEN |
-f, --format TEMPLATE |
e.g. '{long_count} {tzolkin} {haab}'; fields: days, long_count, baktun, katun, tun, uinal, kin, tzolkin, tzolkin_number, tzolkin_name, haab, haab_day, haab_month, calendar_round, gregorian, weekday, text |
-c, --correlation |
gmt (default), thompson, spinden, hochleitner, or an integer offset |
-z, --tz |
timezone for the day boundary (default: local; utc or an IANA name) |
-a, --astro |
add real positions of the Sun, Moon and planets and their cycle progress |
--ascii |
also draw a top-down ASCII map of the orbits (implies --astro) |
Exit status is 2 for bad input and 1 for an astronomy failure (missing ephemeris, date out of range, a Long Count with no Gregorian date).
$ python -m mayan -z utc -f '{weekday} {gregorian}: {long_count}' 2012-12-21 @0
Friday 2012-12-21: 13.0.0.0.0
Thursday 1970-01-01: 12.17.16.7.5
$ python -m mayan -a 2012-12-21
Website and API
python -m mayan.server # http://127.0.0.1:8000
python -m mayan.server --host 0.0.0.0 --port 9000
The site shows the Long Count, the Tzolk'in and Haab' wheels, a top-down orbit
map and a bar per body for how far it is through its cycle. You can step by
meaningful intervals (20 days, 260, 584 for Venus, 780 for Mars, ...) or press
Play. The date and correlation are kept in the URL hash. Clicking the "attested" or "debated" tag next to
a body opens a dialog with what supports that period, with citations (the text
comes from /api/v1/info, field body_notes).
mayan.server is a development server. For production use the container
below, or any WSGI server: gunicorn mayan.api:app.
API
GET /api/v1/date
| Parameter | Default | Meaning |
|---|---|---|
when |
now |
same forms as the CLI (max 100 characters) |
tz |
utc |
timezone for the day boundary |
correlation |
gmt |
as above |
astro |
0 |
1 adds a celestial object |
$ curl 'localhost:8000/api/v1/date?when=2012-12-21&astro=1'
{"api_version": 1, "long_count": "13.0.0.0.0",
"tzolkin": {"number": 4, "name": "Ahau", "text": "4 Ahau"},
"haab": {"day": 3, "month": "Kankin", "text": "3 Kankin"},
"gregorian": "2012-12-21", "celestial": {...}, ...}
GET /api/v1/info returns reference data: Tzolk'in and Haab' names, Long
Count unit sizes, correlations, and each body's Maya period with a
confidence of attested or debated.
Errors are JSON {"error": "..."}: 400 for bad input, 404, 405 (GET and HEAD
only), 429 when rate limited (with a Retry-After header), 503 when astronomy is
unavailable. Responses allow any origin
(Access-Control-Allow-Origin: *) because the data is read-only.
Docker
docker compose up -d --build # http://127.0.0.1:8000
The image uses gunicorn, has the ephemeris baked in (no download at runtime), runs as an unprivileged user, and the compose service uses a read-only root filesystem with all capabilities dropped and a 512 MB memory limit.
| Variable | Default | Meaning |
|---|---|---|
MAYAN_BIND |
127.0.0.1 |
host address to bind; 0.0.0.0 exposes it directly |
MAYAN_PORT |
8000 |
host port |
WEB_CONCURRENCY |
2 |
gunicorn worker processes |
Put them in a .env file next to compose.yaml (Compose reads it
automatically; it is git-ignored). Start from cp .env.example .env. Check the
result with docker compose config. A variable set in your shell overrides
.env.
If port 8000 is taken: set MAYAN_PORT=8765 in .env, or run
MAYAN_PORT=8765 docker compose up -d.
Before exposing it to the public, put a TLS-terminating reverse proxy (Caddy,
nginx, a cloud load balancer) in front and check MAYAN_PROXY_HOPS (below).
Rate limiting
The API has a built-in token-bucket limit per client IP (IPv6 clients are
grouped by /64). Each /api/ request costs 1 token and an astro=1 request
costs 2, since it runs an ephemeris calculation. A client that runs out gets
429 with a Retry-After header. Static files are not limited.
| Variable | Default | Meaning |
|---|---|---|
MAYAN_RATE_LIMIT |
1 |
0 turns limiting off |
MAYAN_RATE_BURST |
120 |
bucket size: requests allowed at once |
MAYAN_RATE_PER_MINUTE |
120 |
refill rate |
MAYAN_ASTRO_COST |
2 |
tokens for an astro=1 request |
MAYAN_PROXY_HOPS |
1 |
number of reverse proxies in front of the container (0 if clients connect directly) |
Two things to get right:
- The real client IP comes from
X-Forwarded-For. The defaultMAYAN_PROXY_HOPS=1uses the last entry, which your proxy appended, and ignores anything a client put earlier in the header. Your proxy must set or append the header (Caddy does by default; nginx needsproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;). If the header is missing, the socket address is used, which behind a proxy is the proxy itself, so everyone would share one bucket. Use2for two chained proxies, never more than you really run, or clients can forge the header and dodge the limit. If clients connect directly, set0so the header is ignored. Entries written asip:portor[ipv6]:portare handled. - Logs show the real client too. The app sets
REMOTE_ADDRto that client address, so gunicorn's access log (and429s) show the visitor, not the proxy or Docker's bridge gateway. A log line with a172.xaddress means the request had no usableX-Forwarded-For(for example a direct health check). The socket address is kept inenviron["mayan.proxy_addr"].
For HAProxy, option forwardfor in the backend is all that is needed:
backend mayan
mode http
option forwardfor
server app 127.0.0.1:8000 check
Other headers such as X-Real-IP or X-Client-IP are not used. If something
else (a CDN, another load balancer) sits in front of HAProxy, the entry HAProxy
appends is that layer's address, so set MAYAN_PROXY_HOPS=2.
- Limits are per gunicorn worker, kept in memory, so the effective limit is
about
WEB_CONCURRENCYtimes the numbers above, and they reset on restart. For a strict or shared limit, also limit at the proxy.
The website's Play button steps once a second, which stays within the default limit. If the limit is hit, the page shows the server's message and stops.
Notes on accuracy
- Default correlation is Goodman–Martinez–Thompson (JDN 584,283), the same
default as Emacs
cal-mayan.el. The Long Count is the same number under any correlation; only its link to the Gregorian calendar changes. - Gregorian conversion works for years 1–9999 (Python's
date). Long Counts outside that still give a Tzolk'in and Haab' but no Gregorian date or sky. - Date-only inputs use noon in the chosen timezone for the astronomy.
- "Day N of the cycle" for each body is its geometric synodic phase times the Maya almanac period. It is approximate: orbits are not circular and the Dresden Codex tables start at heliacal risings, not conjunctions. Mercury, Jupiter and Saturn are marked debated because scholars disagree that the Maya tracked those cycles.
- The almanac periods were checked on 5 Nov 2026 against a compiled reference table (Van Stone / FAMSI) and one paper abstract, not against the codex or Bricker & Bricker (2011). Venus 584, Mars 780, the 405-lunation 11,960-day eclipse span and the lunar ratios are confirmed. Mercury 116, Jupiter 399 and Saturn 378 are only tentatively supported, and the 177/148-day eclipse steps come from a preprint and a popular site. Details and links are in overview.md and references/almanac-periods.md.
Development
python -m unittest discover # 87 tests
Astronomy and API astronomy tests are skipped when Skyfield or the cached ephemeris is missing; the tests never download anything.
Layout:
| Path | Role |
|---|---|
mayan/core.py |
integer Long Count / Tzolk'in / Haab' arithmetic |
mayan/convert.py |
input parsing and the JSON result; shared by CLI and API |
mayan/astro.py |
Skyfield positions and Maya cycle data |
mayan/render.py |
text table and ASCII orbit map |
mayan/cli.py, mayan/api.py, mayan/server.py |
front ends |
mayan/ratelimit.py |
per-client token-bucket limiter used by the API |
web/ |
the website (vanilla JS and SVG, no build step) |
references/ |
source material (see below) |
References and licenses
references/cal-mayan.el: from GNU Emacs 25.2, https://raw.githubusercontent.com/jkitchin/emacs-win/refs/heads/master/share/emacs/25.2/lisp/calendar/cal-mayan.el. GPLv3, copyright the Free Software Foundation. The offsets and epoch values were checked against it; no code was copied.references/DresdenCodexCorrectReadingSequence.jpg: image of the Dresden Codex, from https://en.wikipedia.org/wiki/Dresden_Codex.overview.mdbegan as the output of a small local LLM and was then reviewed and corrected by hand; it lists items still marked as debated.
The project itself is under the MIT license (see LICENSE); the files in
references/ keep their own terms.
