Jump to a section
Search the atlas's pages and board presets.
Method
How every figure on the board was built, what it includes, and the ±15% it is honest about. Costs are planning estimates from September 2026 unit prices — not quotes, and no live 2027 fare exists this far out.
How the numbers were built
Six regional research agents collected unit prices only — airfare band, car per day by class, fuel per litre, room per night, food per person per day, visa fee, per-vehicle extras. No agent computed a trip total, because an earlier pass that did produced totals that could not be compared and, in eight cases, per-person costs that rose with party size. Each region was then audited by an adversarial checker against primary sources, producing 64 corrected unit prices and 10 trips cut outright. Those corrections live in app/corrections.py as a patch applied at build time, so the ranking is re-derivable and a bad correction is findable.
Costs are planning estimates from September 2026 prices, not quotes. Treat the ranking as reliable and any single number as ±15%. The weakest input everywhere is airfare: no live 2027 fare is obtainable this far out.
Trip length is a range, not a number
Every trip carries days_min / days_ideal / days_max — the shortest version still worth
doing, the length it is best at, and the point past which you are padding. Picking N days
filters to the trips that can take N days and re-prices each one at exactly that length through
the same cost model, so the flight, the visa and the activities stay put while the nights, the
food and the car scale. That is why a longer trip costs more in total and less per day:
| Iceland Ring Road, 4 sharing | 9d | 11d | 14d |
|---|---|---|---|
| all-in per person | ฿89,400 | ฿100,300 | ฿116,600 |
| per night | ฿14,900 | ฿12,538 | ฿10,600 |
It is also not all self-drive any more. The board carries ten trip types, and the cost model
prices each on the right fields: a motorbike seats two and six riders buy three machines, so the
per-head curve is almost flat where a car's collapses; a bicycle is one per rider and burns no
fuel whatever the distance; a rail, bus or island trip has no vehicle at all and buys its
movement per person in tickets and ferries. costmodel.type_mismatch() refuses to let a row
declare one type and be priced on another — it caught four rail trips being charged a phantom
hire car on top of their fares.
Two rules keep that honest. The flying sets a floor (transit_floor): a 24-hour-each-way
destination cannot be an 8-day trip, because four of those days are already in the air — a rule,
not forty hand-patched rows. And a trip too long for the selector keeps its real floor rather
than being clamped into it: 18 trips need more than 15 days and simply never appear, because
claiming a 16-day minimum fits in 15 is the same overstatement the verification pass exists to
catch.
A party of one
A party of one is new. The cost model only ever costed parties of two to twelve; party 1 was
added by widening one range (costmodel.SIZES) so the same model prices it, not by inventing a
solo model. Two consequences are worth stating:
- A package price is a floor, not a solo price. Where a trek is sold as a fixed package per person, the curve is flat and party 1 pays the package. A real solo booking often carries a single supplement the pack never researched, so treat a flat trek curve as the least it can cost.
- A shared car is the whole solo premium. On a self-drive row the solo figure carries the entire hire vehicle and fuel bill alone, which is exactly why the curve falls so steeply at two and three. Nothing else in the model changes with party size except the room.
How the leave optimizer thinks
A span is a run of calendar days you ask off. Its cost is only the working days inside it — weekends and public holidays are free. Its payoff is the full run of consecutive days off you end up with, which extends outward through any adjacent weekend or holiday.
The multiplier is payoff ÷ cost. Three leave days placed against the 2026 New Year holidays return nine consecutive days off — a 3.0× multiplier. Placed one week earlier, the same three days return five. That difference is the entire product.
Every span whose first and last day are working days is enumerated (any other span wastes leave on a day already free), deduplicated to the cheapest way of reaching each off-block, and ranked.
Data honesty
- 2026 is Cabinet-confirmed. 2027 is provisional and the tool says so on every run.
The Thai Cabinet had not published the 2027 calendar as of 2026-08-17; lunar holy days are
astronomical estimates that can move by a day, and the Cabinet routinely adds bridge
holidays that do not exist yet (it added 2026-01-02 for exactly this reason). Re-check
after
recheck_afterindata/holidays/th-2027.jsonbefore booking anything non-refundable. - Sources conflict and the conflict is recorded, not hidden. Two published 2027 calendars
disagreed on three points;
th-2027.jsoncarries aconflictsblock naming each one, which source won, and the weekday computation that settled it. - Scope is modelled. The BOT's 2026-10-16 special holiday closes Bangkok banks only —
it is in the data, marked
bank_bangkok, and excluded from leave math by default. Labour Day isbank_private(banks and companies, not government offices). Set your own scopes inconfig/profile.json. - The card bridge reports its own staleness.
app-rules.jsonis a build artifact with anas_of;src/cards.pyprints its age on every run and warns past 45 days.
Every derivation this build applied
Written by the generator on each run, so the list can never fall behind the code. Any number on the board traces back to one of these 19 rules.
- Prices are never recomputed here: every baht is read from trips/roadtrip-atlas-2027/data/costed.json, which the Python cost model wrote. curve[days][0] is one traveller, [11] is twelve.
- Songkran rows are a fixed week (Sat 10 – Sun 18 Apr 2027) at a fixed per-person price: party size and trip length do not reprice them.
- Safety verdict: the row's CANONICAL countries (§1.2) are looked up in solo-safety-2026.json first — the table is indexed under its own spelling, its parenthetical-free name and every canonical alias, so "Turkiye" and "Türkiye (Turkey)" are one country — then the raw survey string, then the worst verdict among the parts of a compound. Duplicate entries for one country resolve to the worst verdict; a compound entry only answers for a part that has no row of its own. No match stays 'unknown'.
- Thailand rows read 'safe' (home country, no national advisory) except where the trip NAME or its ROUTE names, as a whole word, one of the 2026 advisory bands — the 50 km Cambodia border strip (Sa Kaeo, Buriram, Si Saket, Surin, Ubon, Chanthaburi, mainland Trat incl. the Ko Chang piers) or the deep south (Yala, Pattani, Narathiwat, the named Songkhla districts and the Hat Yai–Padang Besar line) — which read 'caution'. The survey's batch title is NOT searched (it is shared by seven unrelated trips), and Mu Ko Surin / the Surin Islands are cut out of the text before the scan so the Andaman park does not match the border province.
- Nepal rows touching Langtang / Syabrubesi / Rasuwa read at least 'caution' (FCDO flood damage plus a US Level 4 carve-out on that corridor).
- Visa kind: 'free' when the notes say visa-free or the fee is 0 without a 'visa required' note; then e-visa, then on arrival; 'required' whenever a fee is charged; else 'unknown'.
- Visa lead time takes the TOP of any range — the number to plan against is the slow case.
- Geography (SPEC §1.2) is derived from the COUNTRY string only. The survey's own `region` is a research-batch title ("Europe at 13-15 days"), not a place, so it is kept as `batch` and never faceted.
- Country names are normalised to one spelling each (USA -> United States, Turkey/Turkiye -> Türkiye, Korea -> South Korea, Czech Republic -> Czechia, UAE -> United Arab Emirates, Hong Kong SAR -> Hong Kong). A compound country is split on / & + , and " and "; every part is kept in `countries`, so a France/Italy/Switzerland row is findable under all three, and `country` is the first.
- A bracketed qualifier becomes `area` ("China (Tibet)" -> China + Tibet) unless it names the sovereign ("Faroe Islands (Denmark)") or the approach ("Italy (crossing from Switzerland)").
- Region is one of 22 canonical values keyed by country, with two overrides: a row touching the Alps goes to Europe · Alps whatever its countries, and a Thai row is placed into one of six sub-regions by a majority vote of place-name keywords over its name, batch title and route.
- In that Thai vote a keyword in the trip NAME counts three times one in the route, because a route lists everything passed on the way: Phetchaburi's Tham Khao Luang cave and the Nan river at Phitsanulok would otherwise pull two Central & West weekends into the Gulf and the North.
- The Alps override yields when the Alps are a minority leg — a five-country Western Balkans drive that starts in the Julian Alps is a Balkans trip — so it only re-files a row where at least half its countries are in the Alpine arc.
- The Alps override needs a European country: read literally it would file the Japanese Alps, New Zealand's Southern Alps, the Transylvanian Alps and Norway's Lyngen Alps in Europe. Named Alpine places (Chamonix, Aletsch, Dolomites, Alta Via…) fire anywhere in Europe; the bare words Alps/Alpine only inside the Alpine arc (CH, AT, LI, FR, IT, DE, SI).
- A multi-country row outside Thailand and the Alps takes the region of the majority of its countries; a tie goes to the first country written, which is the one the survey led with.
- Continent is derived from region, never stored separately: Thailand | Asia | Middle East & Africa | Europe (incl. Russia) | Oceania | Americas.
- packageFlat is read from data/solo-board.csv (app/price_solo.py), the only artefact that carries the survey's trek.package_thb_per_person flag. A package price is a FLOOR for a solo traveller: it does not fall with party size.
- Only a holiday whose scope is in holidays_th.DEFAULT_SCOPES {national, bank_private} sets `off`; government and Bangkok-bank-only days are shown but cost leave. Two rows on one date are merged, never overwritten.
- score() is build_pack.score() ported verbatim (tier×20 + capped value − unbounded over-budget penalty + ease), rounded the way Python rounds (correctly, on the exact binary value) rather than by multiplying by ten first — which moved 90 of 711 rows 0.1 above the pack's own board.
What this dataset contains
- Trips
- 846
- Road-trip atlas
- 711
- Songkran 2027
- 135
- With a price curve
- 711
- With a written plan
- 752
- With photos
- 164
- Cut, with reasons
- 77
- Countries verified
- 119
The files this was built from
| Source file | Last changed | Size |
|---|---|---|
| trips/roadtrip-atlas-2027/data/costed.json | 2026-09-16 | 4,864 KB |
| trips/roadtrip-atlas-2027/data/solo-safety-2026.json | 2026-09-16 | 345 KB |
| trips/roadtrip-atlas-2027/data/solo-board.csv | 2026-09-16 | 581 KB |
| ../songkran-escape-2027/board.csv | 2026-09-04 | 251 KB |
| data/images.json | 2026-09-12 | 354 KB |
| src/holidays_th.py | 2026-09-12 | 5 KB |
| data/holidays/th-2026.json | 2026-09-12 | 67 KB |
| data/holidays/th-2027.json | 2026-09-12 | 87 KB |
| data/holidays/th-2028.json | 2026-09-12 | 62 KB |
Dataset built 2026-09-16 · holiday years 2026, 2027, 2028.