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 sharing9d11d14d
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_after in data/holidays/th-2027.json before booking anything non-refundable.
  • Sources conflict and the conflict is recorded, not hidden. Two published 2027 calendars disagreed on three points; th-2027.json carries a conflicts block 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 is bank_private (banks and companies, not government offices). Set your own scopes in config/profile.json.
  • The card bridge reports its own staleness. app-rules.json is a build artifact with an as_of; src/cards.py prints 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.

  1. 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.
  2. 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.
  3. 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'.
  4. 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.
  5. Nepal rows touching Langtang / Syabrubesi / Rasuwa read at least 'caution' (FCDO flood damage plus a US Level 4 carve-out on that corridor).
  6. 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'.
  7. Visa lead time takes the TOP of any range — the number to plan against is the slow case.
  8. 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.
  9. 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.
  10. A bracketed qualifier becomes `area` ("China (Tibet)" -> China + Tibet) unless it names the sovereign ("Faroe Islands (Denmark)") or the approach ("Italy (crossing from Switzerland)").
  11. 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.
  12. 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.
  13. 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.
  14. 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).
  15. 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.
  16. Continent is derived from region, never stored separately: Thailand | Asia | Middle East & Africa | Europe (incl. Russia) | Oceania | Americas.
  17. 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.
  18. 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.
  19. 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 fileLast changedSize
trips/roadtrip-atlas-2027/data/costed.json2026-09-164,864 KB
trips/roadtrip-atlas-2027/data/solo-safety-2026.json2026-09-16345 KB
trips/roadtrip-atlas-2027/data/solo-board.csv2026-09-16581 KB
../songkran-escape-2027/board.csv2026-09-04251 KB
data/images.json2026-09-12354 KB
src/holidays_th.py2026-09-125 KB
data/holidays/th-2026.json2026-09-1267 KB
data/holidays/th-2027.json2026-09-1287 KB
data/holidays/th-2028.json2026-09-1262 KB

Dataset built 2026-09-16 · holiday years 2026, 2027, 2028.