Module:Climate year
More actions
This is a documentation subpage for Climate year. It contains usage information, categories, and other content that is not part of the original template.
This module draws the year-at-a-glance strip of {{Year at a glance}}: twelve columns, one per month, with rows for day and night, temperature, water and light, and the field cycle. Each value cell is coloured on one scale shared by every page, so two places can be compared by eye. It shows native values only. The method and datasets are explained at Help:Climate matches.
Rows
| Group | Row | What it is |
|---|---|---|
| Day and night | First light, Last light | Civil twilight (the sun 6° below the horizon) on the 15th of the month, local clock time |
| Sunrise, Sunset | The sun's upper edge on the horizon, with refraction, on the 15th, local clock time | |
| Daylight | Hours and minutes from sunrise to sunset on the 15th | |
| Change | Minutes of daylight gained (+) or lost (−) per day around the 15th | |
| Computed from the latitude and longitude with the NOAA solar-position formulas and a flat horizon, so in a deep valley the sun clears the ridges later than shown. | ||
| Temperature | Avg daily high, Avg daily low | The 1981–2010 mean of each day's maximum and minimum air temperature, °C. Single days go well past them. |
| Swing | High minus low, °C | |
| Water and light | Rain | Mean precipitation for the month, mm |
| VPD | Mean vapour-pressure deficit, kPa | |
| Sunlight | Shortwave radiation converted to an approximate daily light integral (× 2.04), mol/m²/day | |
| Field cycle | Cycle | Planting, growing and harvest, each month drawn as three thirds (early, mid, late) |
Where the values come from
- Accession: its own point, read from Module:Climate profile/accessions (the point sampled, the computed Köppen class and elevation, and the normals of its climate cell). The field cycle comes from the eight cycle fields of
{{Accession}}. - Appellation: the climate cells holding the accessions linked to it (
Has appellation), each cell counted once however many accessions share it, averaged; when none of its accessions has coordinates, its whole area from Module:Climate profile/normals. The field cycle combines the cycles of its linked accessions; when none records one, those of the accessions in its growing area (Has growing area). The note under the strip says which was used. - Growing area: its whole area. The field cycle combines the cycles of the accessions in it.
- Day length is computed at the accession's point, or at the place's centre.
When several accessions are combined, each third of a month shows the furthest stage any of them records there: harvest before planting, planting before growing. Growing fills the time from the end of planting to the start of harvest of one cycle. A cycle with a harvest but no planting shows the harvest alone.
Clock time
Times are local clock time for the country in the page's country field: its standard offset from UTC, plus summer time in the months whose 15th falls inside it (the European rule, the United States rule, the Australian, Chilean and Egyptian rules). A country spanning several zones (Russia, Mongolia, the Democratic Republic of the Congo, Indonesia, Papua New Guinea, Brazil, Australia, the United States, Canada, Mexico) is resolved from the point's longitude and latitude; near a zone border away from the wiki's current points that is approximate. Morocco shows UTC+1; the change to UTC+0 during Ramadan is not shown. A country missing from the table falls back to the nominal offset of the longitude, and the strip says so.
Functions
{{#invoke:Climate year|strip}}: the strip, from the arguments of the calling template ({{Year at a glance}}). Empty for a page with no computed climate.{{#invoke:Climate year|has}}:yeswhen the page would show a strip.
From Lua: _sample(args, frame) resolves everything a strip needs, _render(sample) draws it, _sunEvent and _dayLength compute the sun, _clock(countries, lat, lon) gives the clock, _parseMonth, _band and _cycleText read the field cycle.
Markup
Each value cell carries data-v (the unrounded value) and three style properties, --lw-yr-t, --lw-yr-c and --lw-yr-h, from which Template:Year at a glance/styles.css builds the colour for the light and the dark theme. Each row carries data-row and, where it has one, data-unit; each month heading data-month; the strip data-place.
-- Module:Climate year
-- The year-at-a-glance strip on accession, appellation and growing-area pages: twelve months of day length
-- (computed from the sun's position), monthly climate normals (Module:Climate profile) and the recorded field cycle.
-- Called through {{Year at a glance}}. Native values only. Functions, rules and tests: Module:Climate year/doc.
local p = {}
local CP = require('Module:Climate profile')
local HELP = 'Help:Climate matches'
local STYLES = 'Template:Year at a glance/styles.css'
local MONTHS = { 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' }
local MONTH_FULL = { 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September',
'October', 'November', 'December' }
-- Day of the year of the 1st of each month in a common (non-leap) year; the strip uses the 15th.
local DOY1 = { 1, 32, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335 }
local MINUS = '−'
local function trim(s)
if s == nil then return nil end
s = mw.text.trim(tostring(s))
return s ~= '' and s or nil
end
-- A page name as a template argument may carry link brackets or a pipe: [[Ketama|the Ketama area]] → Ketama.
local function bare(s)
s = trim(s)
if not s then return nil end
s = s:gsub('^%[%[', ''):gsub('%]%]$', '')
s = s:gsub('|.*$', '')
return trim(s)
end
local function round(x, d)
local m = 10 ^ (d or 0)
return math.floor(x * m + 0.5) / m
end
-- string.format with a true minus sign.
local function fnum(fmt, x)
local s = string.format(fmt, x)
if s:sub(1, 1) == '-' then
s = s:sub(2)
if s:match('^[0.]+$') then return s end -- "-0" reads as 0
return MINUS .. s
end
return s
end
---------------------------------------------------------------------------------------------------------------
-- The sun. NOAA solar-position formulas (Spencer's series for the declination and the equation of time),
-- flat horizon. Sunrise and sunset at a zenith of 90.833° (refraction and the sun's disc), first and last light
-- at civil twilight (96°).
local SUNRISE, CIVIL = 90.833, 96
-- Minutes after local midnight of a sun event on day `doy`, or nil plus 'always' (the sun never goes that far
-- below the horizon that day) or 'never' (it never gets that high).
function p._sunEvent(lat, lon, doy, tzmin, zenith, rising)
local g = 2 * math.pi / 365 * (doy - 1 + 0.5)
local eqt = 229.18 * (0.000075 + 0.001868 * math.cos(g) - 0.032077 * math.sin(g)
- 0.014615 * math.cos(2 * g) - 0.040849 * math.sin(2 * g))
local decl = 0.006918 - 0.399912 * math.cos(g) + 0.070257 * math.sin(g) - 0.006758 * math.cos(2 * g)
+ 0.000907 * math.sin(2 * g) - 0.002697 * math.cos(3 * g) + 0.00148 * math.sin(3 * g)
local la = math.rad(lat)
local cosha = math.cos(math.rad(zenith)) / (math.cos(la) * math.cos(decl)) - math.tan(la) * math.tan(decl)
if cosha < -1 then return nil, 'always' end
if cosha > 1 then return nil, 'never' end
local ha = math.deg(math.acos(cosha))
if not rising then ha = -ha end
return 720 - 4 * (lon + ha) - eqt + tzmin
end
-- Minutes of daylight (sunrise to sunset) on day `doy`.
function p._dayLength(lat, lon, doy)
local rise, why = p._sunEvent(lat, lon, doy, 0, SUNRISE, true)
if not rise then return why == 'always' and 1440 or 0 end
return p._sunEvent(lat, lon, doy, 0, SUNRISE, false) - rise
end
local function clock(mins)
mins = round(mins) % 1440
return string.format('%02d:%02d', math.floor(mins / 60), mins % 60)
end
local function duration(mins)
mins = round(mins)
return string.format('%dh%02d', math.floor(mins / 60), mins % 60)
end
---------------------------------------------------------------------------------------------------------------
-- Local clock time (ruling M3: local clock time per country). Standard offset in hours, the summer-time rule
-- and, for a country spanning several zones, a function of the point. A summer-time rule lists the months whose
-- 15th falls inside summer time.
local DST = {
eu = { 4, 5, 6, 7, 8, 9, 10 }, -- last Sunday of March to last Sunday of October
us = { 3, 4, 5, 6, 7, 8, 9, 10 }, -- second Sunday of March to first Sunday of November
au = { 10, 11, 12, 1, 2, 3 }, -- first Sunday of October to first Sunday of April
cl = { 9, 10, 11, 12, 1, 2, 3 }, -- first Saturday of September to first Saturday of April
eg = { 5, 6, 7, 8, 9, 10 }, -- last Friday of April to last Thursday of October
}
local function band(lon, cuts)
-- cuts: { {maxLon, offset}, …, {nil, offset} }
for _, c in ipairs(cuts) do
if c[1] == nil or lon < c[1] then return c[2] end
end
end
local TZ = {
['Afghanistan'] = 4.5, ['Albania'] = { 1, 'eu' }, ['Algeria'] = 1, ['Angola'] = 1, ['Armenia'] = 4,
['Austria'] = { 1, 'eu' }, ['Azerbaijan'] = 4, ['Bangladesh'] = 6, ['Belize'] = -6, ['Benin'] = 1,
['Bhutan'] = 6, ['Bolivia'] = -4, ['Botswana'] = 2, ['Bulgaria'] = { 2, 'eu' }, ['Burkina Faso'] = 0,
['Burundi'] = 2, ['Cambodia'] = 7, ['Cameroon'] = 1, ['Central African Republic'] = 1, ['Chad'] = 1,
['China'] = 8, ['Colombia'] = -5, ['Costa Rica'] = -6, ['Croatia'] = { 1, 'eu' }, ['Cuba'] = -5,
['Cyprus'] = { 2, 'eu' }, ['Czech Republic'] = { 1, 'eu' }, ['Denmark'] = { 1, 'eu' },
['Dominican Republic'] = -4, ['Ecuador'] = -5, ['Egypt'] = { 2, 'eg' }, ['El Salvador'] = -6,
['Eritrea'] = 3, ['Eswatini'] = 2, ['Ethiopia'] = 3, ['Fiji'] = 12, ['Finland'] = { 2, 'eu' },
['France'] = { 1, 'eu' }, ['Gabon'] = 1, ['Gambia'] = 0, ['Georgia (country)'] = 4, ['Georgia'] = 4,
['Germany'] = { 1, 'eu' }, ['Ghana'] = 0, ['Greece'] = { 2, 'eu' }, ['Guatemala'] = -6, ['Guinea'] = 0,
['Guyana'] = -4, ['Haiti'] = -5, ['Honduras'] = -6, ['Hungary'] = { 1, 'eu' }, ['India'] = 5.5,
['Iran'] = 3.5, ['Iraq'] = 3, ['Italy'] = { 1, 'eu' }, ['Ivory Coast'] = 0, ['Jamaica'] = -5,
['Japan'] = 9, ['Jordan'] = 3, ['Kazakhstan'] = 5, ['Kenya'] = 3, ['Korea'] = 9, ['South Korea'] = 9,
['North Korea'] = 9, ['Kyrgyzstan'] = 6, ['Laos'] = 7, ['Lebanon'] = { 2, 'eu' }, ['Lesotho'] = 2,
['Liberia'] = 0, ['Libya'] = 2, ['Madagascar'] = 3, ['Malawi'] = 2, ['Malaysia'] = 8, ['Mali'] = 0,
['Mauritius'] = 4, ['Morocco'] = 1, ['Mozambique'] = 2, ['Myanmar'] = 6.5, ['Namibia'] = 2,
['Nepal'] = 5.75, ['Netherlands'] = { 1, 'eu' }, ['New Caledonia'] = 11, ['Nicaragua'] = -6, ['Niger'] = 1,
['Nigeria'] = 1, ['Oman'] = 4, ['Pakistan'] = 5, ['Panama'] = -5, ['Paraguay'] = -3, ['Peru'] = -5,
['Philippines'] = 8, ['Poland'] = { 1, 'eu' }, ['Portugal'] = { 0, 'eu' }, ['Republic of the Congo'] = 1,
['Réunion'] = 4, ['Romania'] = { 2, 'eu' }, ['Rwanda'] = 2, ['São Tomé and Príncipe'] = 0,
['Saudi Arabia'] = 3, ['Senegal'] = 0, ['Serbia'] = { 1, 'eu' }, ['Sierra Leone'] = 0, ['Somalia'] = 3,
['South Africa'] = 2, ['South Sudan'] = 2, ['Spain'] = { 1, 'eu' }, ['Sri Lanka'] = 5.5, ['Sudan'] = 2,
['Sweden'] = { 1, 'eu' }, ['Switzerland'] = { 1, 'eu' }, ['Syria'] = 3, ['Taiwan'] = 8, ['Tajikistan'] = 5,
['Tanzania'] = 3, ['Thailand'] = 7, ['Togo'] = 0, ['Trinidad and Tobago'] = -4, ['Tunisia'] = 1,
['Turkey'] = 3, ['Turkmenistan'] = 5, ['Uganda'] = 3, ['Ukraine'] = { 2, 'eu' },
['United Kingdom'] = { 0, 'eu' }, ['Uruguay'] = -3, ['Uzbekistan'] = 5, ['Venezuela'] = -4,
['Vietnam'] = 7, ['Yemen'] = 3, ['Zambia'] = 2, ['Zimbabwe'] = 2,
-- Countries spanning several zones: by longitude (and latitude where a border runs east–west). The cuts are
-- drawn so that every point on the wiki on 9 November 2026 falls in its province's zone; near a zone border
-- elsewhere they are approximate.
['Russia'] = function(lat, lon)
return band(lon, { { 23, 2 }, { 48, 3 }, { 55, 4 }, { 70, 5 }, { 78, 6 }, { 99, 7 }, { 112, 8 },
{ 130, 9 }, { 141.5, 10 }, { 160, 11 }, { nil, 12 } })
end,
['Mongolia'] = function(lat, lon) return lon < 99 and 7 or 8 end,
['Democratic Republic of the Congo'] = function(lat, lon)
return lon < ((lat > -2.5) and 24 or 20.5) and 1 or 2
end,
['Indonesia'] = function(lat, lon) return band(lon, { { 114.4, 7 }, { 127, 8 }, { nil, 9 } }) end,
['Papua New Guinea'] = function(lat, lon) return lon > 154 and 11 or 10 end,
['Brazil'] = function(lat, lon) return band(lon, { { -67.3, -5 }, { -54, -4 }, { nil, -3 } }) end,
['Chile'] = function(lat, lon) return { -4, 'cl' } end,
['Australia'] = function(lat, lon)
if lon < 129 then return 8 end -- Western Australia
if lon < 138 then return lat < -26 and { 9.5, 'au' } or 9.5 end -- South Australia / Northern Territory
if lon < 141 then return lat < -26 and { 9.5, 'au' } or 10 end -- South Australia / Queensland
if lat > -28.2 or (lat > -29 and lon < 151) then return 10 end -- Queensland: no summer time
return { 10, 'au' } -- New South Wales, Victoria, Tasmania
end,
['United States'] = function(lat, lon)
return { band(lon, { { -114.5, -8 }, { -101, -7 }, { -86.5, -6 }, { nil, -5 } }), 'us' }
end,
['Canada'] = function(lat, lon)
return { band(lon, { { -120, -8 }, { -110, -7 }, { -90, -6 }, { -64, -5 }, { -59.5, -4 }, { nil, -3.5 } }), 'us' }
end,
['Mexico'] = function(lat, lon)
if lon < -114.8 then return { -8, 'us' } end
if lon < -106 then return -7 end
if lon > -89.3 and lat < 22 then return -5 end
return -6
end,
}
p.TZ = TZ
local NOTES = { ['Morocco'] = 'UTC+0 during Ramadan, not shown' }
local function offsetText(h)
if h == 0 then return 'UTC' end
local sign = h > 0 and '+' or MINUS
h = math.abs(h)
local whole = math.floor(h)
local mins = round((h - whole) * 60)
return 'UTC' .. sign .. whole .. (mins > 0 and string.format(':%02d', mins) or '')
end
local function monthsText(list)
return MONTHS[list[1]] .. '–' .. MONTHS[list[#list]]
end
-- The clock of a place: { offsets = {12 numbers, hours}, label = 'UTC+1, UTC+2 Apr–Oct', country = … } or nil
-- when no listed country is known.
function p._clock(countries, lat, lon)
for _, c in ipairs(countries) do
local z = TZ[c]
if type(z) == 'function' then z = z(lat, lon) end
if z ~= nil then
local std, rule = z, nil
if type(z) == 'table' then std, rule = z[1], z[2] end
local offs = {}
for m = 1, 12 do offs[m] = std end
local label = offsetText(std)
if rule then
for _, m in ipairs(DST[rule]) do offs[m] = std + 1 end
label = label .. ', ' .. offsetText(std + 1) .. ' ' .. monthsText(DST[rule])
end
if NOTES[c] then label = label .. ' (' .. NOTES[c] .. ')' end
return { offsets = offs, label = label, country = c }
end
end
return nil
end
-- Countries as written in an infobox: "India", "[[India]]", "Kazakhstan, Kyrgyzstan".
function p._countries(s)
local out = {}
s = trim(s)
if not s then return out end
for part in mw.text.gsplit(s, '[,;]') do
local c = bare(part)
if c then out[#out + 1] = c end
end
return out
end
---------------------------------------------------------------------------------------------------------------
-- The field cycle. A cycle field holds a month name, optionally with Early, Mid or Late; those are drawn as
-- thirds of a month. The year is 36 thirds, 0 = early January.
local MONTH_NUM = {}
for i, m in ipairs(MONTH_FULL) do
MONTH_NUM[m:lower()] = i
MONTH_NUM[m:sub(1, 3):lower()] = i
end
MONTH_NUM['sept'] = 9
local PART = { early = 0, mid = 1, middle = 1, late = 2 }
-- { m = 1…12, part = 0|1|2|nil } or nil.
function p._parseMonth(s)
s = trim(s)
if not s then return nil end
s = mw.ustring.lower(s):gsub('%.', ''):gsub('%-', ' ')
local words = mw.text.split(mw.text.trim(s), '%s+')
local part, month
if #words == 1 then
month = MONTH_NUM[words[1]]
elseif #words == 2 then
part, month = PART[words[1]], MONTH_NUM[words[2]]
if part == nil then return nil end
end
if not month then return nil end
return { m = month, part = part }
end
local function third(v, isEnd)
local base = (v.m - 1) * 3
if v.part then return base + v.part end
return isEnd and base + 2 or base
end
local CYCLE_FIELDS = { 'earliest_planting', 'latest_planting', 'harvest_start', 'harvest_end' }
local RANK = { [''] = 0, grow = 1, plant = 2, harvest = 3 }
-- Mark thirds a…b (inclusive, wrapping round the year) with state s where s outranks what is there.
local function mark(state, a, b, s)
local k = a
for _ = 1, 36 do
if RANK[s] > RANK[state[k % 36]] then state[k % 36] = s end
if k % 36 == b % 36 then break end
k = k + 1
end
end
-- One cycle (a table with the four fields, raw text) into `state`. Returns true when it marked anything.
local function addCycle(state, c)
local p1, p2 = p._parseMonth(c.earliest_planting), p._parseMonth(c.latest_planting)
local h1, h2 = p._parseMonth(c.harvest_start), p._parseMonth(c.harvest_end)
local pa, pb, ha, hb
if p1 or p2 then pa, pb = third(p1 or p2, false), third(p2 or p1, true) end
if h1 or h2 then ha, hb = third(h1 or h2, false), third(h2 or h1, true) end
if pa and ha then
local a, b = (pb + 1) % 36, (ha - 1) % 36
if a ~= ha then mark(state, a, b, 'grow') end
end
if pa then mark(state, pa, pb, 'plant') end
if ha then mark(state, ha, hb, 'harvest') end
return (pa or ha) ~= nil
end
-- 36 states ('' / 'grow' / 'plant' / 'harvest') from a list of cycles; harvest outranks planting, planting
-- outranks growing, so several accessions combine into the months any of them records.
function p._band(cycles)
local state = {}
for k = 0, 35 do state[k] = '' end
local any = false
for _, c in ipairs(cycles) do
if addCycle(state, c) then any = true end
end
return state, any
end
-- The cycles of one accession from its template arguments (cycle_1_earliest_planting …).
function p._argCycles(args)
local out = {}
for n = 1, 2 do
local c, has = {}, false
for _, f in ipairs(CYCLE_FIELDS) do
c[f] = trim(args['cycle_' .. n .. '_' .. f])
if c[f] then has = true end
end
if has then out[#out + 1] = c end
end
return out
end
local function lowerFirst(s)
return (s:gsub('^(%a)(%a*)', function(a, b)
if b:match('^%l') or b == '' then return a:lower() .. b end
return a .. b
end))
end
local function rangeText(a, b)
a, b = trim(a), trim(b)
if a and b and a ~= b then
local a2 = PART[(a:match('^(%a+)%s') or ''):lower()] and lowerFirst(a) or a
local b2 = PART[(b:match('^(%a+)%s') or ''):lower()] and lowerFirst(b) or b
return a2 .. '–' .. b2
end
local s = a or b
return PART[(s:match('^(%a+)%s') or ''):lower()] and lowerFirst(s) or s
end
-- "planting March–May, harvest September–December"
function p._cycleText(c)
local parts = {}
if c.earliest_planting or c.latest_planting then
parts[#parts + 1] = 'planting ' .. rangeText(c.earliest_planting, c.latest_planting)
end
if c.harvest_start or c.harvest_end then
parts[#parts + 1] = 'harvest ' .. rangeText(c.harvest_start, c.harvest_end)
end
return table.concat(parts, ', ')
end
local function cyclesText(cycles)
if #cycles == 1 then return p._cycleText(cycles[1]) end
local t = {}
for i, c in ipairs(cycles) do
t[#t + 1] = (i == 1 and 'first cycle ' or 'second cycle ') .. p._cycleText(c)
end
return table.concat(t, '; ')
end
local function signature(cycles)
local t = {}
for _, c in ipairs(cycles) do
for _, f in ipairs(CYCLE_FIELDS) do t[#t + 1] = c[f] or '' end
end
return table.concat(t, '|')
end
local function plantingRecorded(cycles)
for _, c in ipairs(cycles) do
if c.earliest_planting or c.latest_planting then return true end
end
return false
end
---------------------------------------------------------------------------------------------------------------
-- Linked accessions (rulings M2, M4): one SMW query per page, run through the parser.
local ASK_LABELS = {}
for n = 1, 2 do
for i, f in ipairs(CYCLE_FIELDS) do ASK_LABELS['c' .. n .. 'f' .. i] = { n, f } end
end
local function askText(prop, title)
local q = { '{{#ask:[[Category:Accessions]][[', prop, '::', title, ']]' }
for n = 1, 2 do
for i, f in ipairs(CYCLE_FIELDS) do
q[#q + 1] = '|?Has cycle ' .. n .. ' ' .. f:gsub('_', ' ') .. '=c' .. n .. 'f' .. i
end
end
q[#q + 1] = '|format=plainlist|headers=plain|link=none|sep=@@R@@|propsep=@@P@@|valuesep=@@V@@'
.. '|limit=500|searchlabel=|default=|sort=|order=}}'
return table.concat(q)
end
p._askText = askText
-- Parse the plainlist output into { { title = …, cycles = {…} }, … }, sorted by title.
function p._parseAsk(out)
local rows = {}
out = trim(out)
if not out then return rows end
for row in mw.text.gsplit(out, '@@R@@', true) do
row = trim(row)
if row then
local title, props = row:match('^(.-) %((c[12]f[1-4]: .*)%)$')
title = title or row
local cyc = { {}, {} }
if props then
for pv in mw.text.gsplit(props, '@@P@@', true) do
local lab, val = pv:match('^(c[12]f[1-4]): (.*)$')
if lab then
val = mw.text.split(val, '@@V@@', true)[1]
local nf = ASK_LABELS[lab]
cyc[nf[1]][nf[2]] = trim(mw.text.decode(val))
end
end
end
local cycles = {}
for n = 1, 2 do
if next(cyc[n]) then cycles[#cycles + 1] = cyc[n] end
end
rows[#rows + 1] = { title = trim(mw.text.decode(title)), cycles = cycles }
end
end
table.sort(rows, function(a, b) return a.title < b.title end)
return rows
end
-- The accessions linked to a page through `prop`. `stub` (tests) maps "prop::title" to the plainlist text.
local function linked(frame, prop, title, stub)
if not title then return {} end
local out
if stub then
out = stub[prop .. '::' .. title] or ''
else
out = frame:preprocess(askText(prop, title))
end
return p._parseAsk(out)
end
---------------------------------------------------------------------------------------------------------------
-- Climate sampling (ruling M4, M5): an appellation is sampled at the distinct climate cells holding its linked
-- accessions, each cell counted once; with none, its whole area. Accessions use their own cell; growing areas
-- their whole area.
local VARS = { 'tmax', 'tmin', 'pr', 'vpd', 'rsds' }
local function cellKey(n)
local t = {}
for _, v in ipairs(VARS) do
for m = 1, 12 do t[#t + 1] = tostring(n[v][m]) end
end
return table.concat(t, ',')
end
-- { normals = {tmax = {…}, …}, cells = n, accessions = n, elo = m, ehi = m } or nil.
function p._cellSample(rows)
local seen, cells, nacc = {}, {}, 0
for _, r in ipairs(rows) do
local a = CP._accession(r.title)
if a then
nacc = nacc + 1
local k = cellKey(a)
if not seen[k] then
seen[k] = true
cells[#cells + 1] = a
end
end
end
if #cells == 0 then return nil end
local nm = {}
for _, v in ipairs(VARS) do
nm[v] = {}
for m = 1, 12 do
local s = 0
for _, c in ipairs(cells) do s = s + c[v][m] end
-- three decimals, so that the order of the sum never tips a displayed rounding
nm[v][m] = round(s / #cells, 3)
end
end
local lo, hi
for _, c in ipairs(cells) do
lo = (lo == nil or c.e < lo) and c.e or lo
hi = (hi == nil or c.e > hi) and c.e or hi
end
return { normals = nm, cells = #cells, accessions = nacc, elo = lo, ehi = hi }
end
---------------------------------------------------------------------------------------------------------------
-- Cells. One absolute colour scale across every place. A cell gives its intensity as how far the lightness moves
-- (--lw-yr-t, 0 to 0.2), its chroma and its hue; the stylesheet builds the oklch colour from a light base that
-- darkens, or a dark base that lightens in dark mode.
local function clamp(t) return math.max(0, math.min(1, t)) end
local function cell(text, hue, chroma, t, title, v)
t = clamp(t)
local c = round(chroma * (0.25 + 0.75 * t), 3)
local td = mw.html.create('td')
:attr('style', string.format('--lw-yr-t:%.3f;--lw-yr-c:%s;--lw-yr-h:%s', 0.2 * t, tostring(c), tostring(hue)))
:attr('title', title)
:wikitext(text)
if v ~= nil then td:attr('data-v', tostring(v)) end
return td
end
local function blank(title)
return mw.html.create('td'):addClass('lw-yr__na'):attr('title', title):wikitext('—')
end
local function tempCell(v, label)
local t = (v + 15) / 50
local hue = 255 - 230 * clamp(t)
local strength = math.abs(t - 0.5) * 2
return cell(fnum('%.0f', v), round(hue), 0.16, 0.25 + 0.75 * strength,
label .. ' ' .. fnum('%.1f', v) .. ' °C', round(v, 1))
end
---------------------------------------------------------------------------------------------------------------
-- The strip.
local function fmtInt(n)
return mw.language.getContentLanguage():formatNum(math.floor(n + 0.5))
end
local function count(n, one, many)
if n == 1 then return 'one ' .. one end
return n .. ' ' .. many
end
-- Builds every row. `s` = { lat, lon, normals, clock, band } → table of { key, label, unit, cls, cells }.
function p._rows(s)
local R = {}
local function row(key, label, unit, cls)
local r = { key = key, label = label, unit = unit, cls = cls, cells = {} }
R[#R + 1] = r
return r
end
local first, rise, set, last = row('first', 'First light'), row('sunrise', 'Sunrise'), row('sunset', 'Sunset'),
row('last', 'Last light')
local len = row('daylight', 'Daylight', 'h', 'strong')
local chg = row('change', 'Change', 'min/day')
local hi, lo, sw = row('tmax', 'Avg daily high', '°C', 'strong'), row('tmin', 'Avg daily low', '°C', 'strong'),
row('swing', 'Swing', '°C')
local pr, vpd, dli = row('pr', 'Rain', 'mm', 'strong'), row('vpd', 'VPD', 'kPa'), row('dli', 'Sunlight', 'mol/m²/d')
local lat, lon, nm = s.lat, s.lon, s.normals
for m = 1, 12 do
local doy = DOY1[m] + 14
local tz = s.clock.offsets[m] * 60
local mon = MONTH_FULL[m] .. ' 15'
local L = p._dayLength(lat, lon, doy)
local lt = (L / 60 - 10) / 4.5
local fl, flw = p._sunEvent(lat, lon, doy, tz, CIVIL, true)
local sr, srw = p._sunEvent(lat, lon, doy, tz, SUNRISE, true)
local ss = p._sunEvent(lat, lon, doy, tz, SUNRISE, false)
local ll = p._sunEvent(lat, lon, doy, tz, CIVIL, false)
if fl then
first.cells[m] = cell(clock(fl), 75, 0.05, lt * 0.6, mon .. ': first light (civil twilight) ' .. clock(fl), clock(fl))
last.cells[m] = cell(clock(ll), 75, 0.05, lt * 0.6, mon .. ': last light (civil twilight) ' .. clock(ll), clock(ll))
else
local why = flw == 'always' and 'twilight lasts all night' or 'the sun stays more than 6° below the horizon'
first.cells[m] = blank(mon .. ': ' .. why)
last.cells[m] = blank(mon .. ': ' .. why)
end
if sr then
rise.cells[m] = cell(clock(sr), 85, 0.09, lt, mon .. ': sunrise ' .. clock(sr), clock(sr))
set.cells[m] = cell(clock(ss), 85, 0.09, lt, mon .. ': sunset ' .. clock(ss), clock(ss))
else
local why = srw == 'always' and 'the sun does not set' or 'the sun does not rise'
rise.cells[m] = blank(mon .. ': ' .. why)
set.cells[m] = blank(mon .. ': ' .. why)
end
len.cells[m] = cell(duration(L), 90, 0.14, lt, mon .. ': ' .. string.format('%.2f', L / 60) .. ' hours of daylight',
round(L / 60, 3))
local c = (p._dayLength(lat, lon, doy + 1) - p._dayLength(lat, lon, doy - 1)) / 2
local sign = c > 0.05 and '+' or (c < -0.05 and MINUS or '±')
chg.cells[m] = cell(sign .. string.format('%.1f', math.abs(c)), c > 0 and 150 or 300, 0.12, math.abs(c) / 2.5,
mon .. ': day length changing ' .. (c >= 0 and '+' or MINUS) .. string.format('%.2f', math.abs(c)) .. ' min per day',
round(c, 2))
local h, l = nm.tmax[m], nm.tmin[m]
hi.cells[m] = tempCell(h, MONTH_FULL[m] .. ' average daily high')
lo.cells[m] = tempCell(l, MONTH_FULL[m] .. ' average daily low')
sw.cells[m] = cell(fnum('%.0f', h - l), 25, 0.06, (h - l - 4) / 14,
MONTH_FULL[m] .. ': day–night swing ' .. fnum('%.1f', h - l) .. ' °C', round(h - l, 1))
local r = nm.pr[m]
pr.cells[m] = cell(string.format('%.0f', r), 240, 0.15, math.log10(math.max(r, 1)) / math.log10(500),
MONTH_FULL[m] .. ': ' .. string.format('%.1f', r) .. ' mm of rain', round(r, 1))
local v = nm.vpd[m] / 1000
vpd.cells[m] = cell(string.format('%.1f', v), 345, 0.13, v / 2.0,
MONTH_FULL[m] .. ': VPD ' .. string.format('%.2f', v) .. ' kPa', round(v, 3))
local swr = nm.rsds[m]
local d = swr * 2.04
dli.cells[m] = cell(string.format('%.0f', d), 100, 0.15, (d - 15) / 45,
MONTH_FULL[m] .. ': ≈' .. string.format('%.0f', d) .. ' mol/m²/day (shortwave '
.. string.format('%.1f', swr) .. ' MJ/m²/day × 2.04)', round(d, 1))
end
return R
end
local GROUPS = { first = 'Day and night', tmax = 'Temperature', pr = 'Water and light' }
local BAND_NAMES = { plant = 'planting', grow = 'growing', harvest = 'harvest', [''] = 'not recorded' }
local function bandRow(state)
local tr = mw.html.create('tr'):addClass('lw-yr__cyc'):attr('data-row', 'cycle')
tr:tag('th'):attr('scope', 'row'):wikitext('Cycle')
for m = 1, 12 do
local td = tr:tag('td'):addClass('lw-yr__band')
local names = {}
for k = 0, 2 do
local s = state[(m - 1) * 3 + k]
td:tag('span'):addClass('lw-yr__b'):addClass('lw-yr__b--' .. (s ~= '' and s or 'none'))
names[#names + 1] = BAND_NAMES[s]
end
td:attr('title', MONTH_FULL[m] .. ' (early, mid, late): ' .. table.concat(names, ', '))
td:attr('data-v', table.concat(names, ';'))
end
return tr
end
local function keyList()
local ul = mw.html.create('ul'):addClass('lw-yr__key')
for _, k in ipairs({ { 'plant', 'Planting' }, { 'grow', 'Growing' }, { 'harvest', 'Harvest' },
{ 'none', 'Not recorded / off season' } }) do
ul:tag('li'):tag('span'):addClass('lw-yr__b'):addClass('lw-yr__b--' .. k[1]):done():wikitext(k[2])
end
return ul
end
local function latlon(lat, lon)
return string.format('%.2f° %s, %.2f° %s', math.abs(lat), lat >= 0 and 'N' or 'S', math.abs(lon),
lon >= 0 and 'E' or 'W')
end
-- Renders the strip from a resolved sample:
-- { key, kind, lat, lon, normals, clock, koppen, elevation (text), band, any (bool), note (text), collapsed }
function p._render(s)
local root = mw.html.create('div'):addClass('lw-yr'):attr('data-place', s.key)
if s.collapsed then root:addClass('mw-collapsible') end
local bar = root:tag('div'):addClass('lw-yr__bar')
bar:tag('span'):addClass('lw-yr__title'):wikitext('Year at a glance')
bar:tag('span'):addClass('lw-yr__computed'):wikitext(' (computed)')
local body = root:tag('div'):addClass('lw-yr__body')
if s.collapsed then body:addClass('mw-collapsible-content') end
local facts = body:tag('dl'):addClass('lw-yr__facts')
local function fact(dt, dd)
local d = facts:tag('div')
d:tag('dt'):wikitext(dt)
d:tag('dd'):wikitext(dd)
end
fact('Point', latlon(s.lat, s.lon))
fact('Elevation used', s.elevation)
if s.koppen then fact('Köppen class', s.koppen) end
fact('Clock', s.clock.label)
local scroll = body:tag('div'):addClass('lw-yr__scroll')
local tbl = scroll:tag('table'):addClass('lw-yr__table')
local head = tbl:tag('tr'):addClass('lw-yr__head')
head:tag('th'):attr('scope', 'col'):addClass('lw-yr__corner'):tag('span'):addClass('lw-yr__sr'):wikitext('Month')
for m = 1, 12 do head:tag('th'):attr('scope', 'col'):attr('data-month', m):wikitext(MONTHS[m]) end
for _, r in ipairs(p._rows(s)) do
if GROUPS[r.key] then
tbl:tag('tr'):addClass('lw-yr__grp'):tag('th'):attr('colspan', 13):attr('scope', 'rowgroup'):wikitext(GROUPS[r.key])
end
local tr = tbl:tag('tr'):attr('data-row', r.key)
if r.cls then tr:addClass('lw-yr__' .. r.cls) end
if r.unit then tr:attr('data-unit', r.unit) end
local th = tr:tag('th'):attr('scope', 'row'):wikitext(r.label)
if r.unit then th:tag('span'):addClass('lw-yr__u'):wikitext(r.unit) end
for m = 1, 12 do tr:node(r.cells[m]) end
end
tbl:tag('tr'):addClass('lw-yr__grp'):tag('th'):attr('colspan', 13):attr('scope', 'rowgroup'):wikitext('Field cycle')
tbl:node(bandRow(s.band))
body:node(keyList())
body:tag('p'):addClass('lw-yr__note'):wikitext(s.note)
body:tag('p'):addClass('lw-yr__src'):wikitext('Day and night computed from the sun\'s position on the 15th of each month; '
.. 'climate normals 1981–2010 from CHELSA V2.1 · [[' .. HELP .. '|About these figures]]')
return tostring(root)
end
-- Resolve everything a page's strip needs. `a` = arguments (key, kind, country, growing_area, cycle fields,
-- collapsed); `frame` runs the SMW queries; `stub` replaces them in tests. nil when there is nothing to show.
function p._sample(a, frame, stub)
local key = bare(a.key)
if not key then return nil end
local kind = mw.ustring.lower(trim(a.kind) or 'accession')
local s = { key = key, kind = kind, collapsed = trim(a.collapsed) == 'yes' }
local cycles, note
if kind == 'accession' then
local acc = CP._accession(key)
if not acc then return nil end
s.lat, s.lon, s.normals = acc.lat, acc.lon, acc
s.koppen = acc.k ~= '' and acc.k or nil
s.elevation = fmtInt(acc.e) .. ' m <span class="lw-yr__muted">computed, '
.. ((CP._meta().elevation or {})[acc.es] or acc.es) .. ', at this point</span>'
cycles = p._argCycles(a)
if #cycles > 0 then
note = 'From this accession\'s page: ' .. cyclesText(cycles) .. '.'
if not plantingRecorded(cycles) then note = note .. ' No planting is recorded.' end
else
note = 'No field cycle is recorded on this accession\'s page.'
end
else
local e = CP._profile(key)
if not e then return nil end
local nm = CP._normals(key)
if not nm then return nil end
s.lat, s.lon = e.lat, e.lon
s.koppen = e.k ~= '' and e.k or nil
local where = kind == 'appellation' and 'this appellation' or 'this growing area'
local prop = kind == 'appellation' and 'Has appellation' or 'Has growing area'
local rows = linked(frame, prop, key, stub)
-- climate
local cs = kind == 'appellation' and p._cellSample(rows) or nil
local dem = (CP._meta().elevation or {})[e.es] or e.es
if cs then
s.normals = cs.normals
local range = cs.elo == cs.ehi and (fmtInt(cs.elo) .. ' m') or (fmtInt(cs.elo) .. '–' .. fmtInt(cs.ehi) .. ' m')
s.elevation = range .. ' <span class="lw-yr__muted">computed, '
.. (cs.cells == 1 and ('the climate cell holding its ' .. count(cs.accessions, 'accession', 'accessions'))
or ('the ' .. cs.cells .. ' climate cells holding its ' .. count(cs.accessions, 'accession', 'accessions')
.. ', averaged'))
.. '</span>'
else
s.normals = nm
local range = e.e10 == e.e90 and (fmtInt(e.e) .. ' m')
or (fmtInt(e.e) .. ' m (' .. fmtInt(e.e10) .. '–' .. fmtInt(e.e90) .. ' m)')
s.elevation = range .. ' <span class="lw-yr__muted">computed, ' .. dem .. ', its whole area</span>'
end
-- cycle (M2)
local function withCycles(rs)
local out, sigs, nsig = {}, {}, 0
for _, r in ipairs(rs) do
if #r.cycles > 0 then
out[#out + 1] = r
local g = signature(r.cycles)
if not sigs[g] then sigs[g] = true; nsig = nsig + 1 end
end
end
return out, nsig
end
local function describe(rs, nsig, intro)
local all = {}
for _, r in ipairs(rs) do
for _, c in ipairs(r.cycles) do all[#all + 1] = c end
end
local t
if nsig == 1 then
local agree = #rs == 1 and ': ' or (#rs == 2 and '; both give ' or ('; all ' .. #rs .. ' give '))
t = intro .. agree .. cyclesText(rs[1].cycles) .. '.'
else
t = intro .. '. Their cycles differ; the band shows every part of a month any of them records.'
end
if not plantingRecorded(all) then
t = t .. (#rs == 1 and ' It records no planting.' or (#rs == 2 and ' Neither records planting.'
or ' None records planting.'))
end
return all, t
end
local withc, nsig = withCycles(rows)
local nacc = #rows
if #withc > 0 then
cycles, note = describe(withc, nsig, 'From the ' .. count(#withc, 'accession', 'accessions') .. ' in ' .. where
.. ' that ' .. (#withc == 1 and 'records' or 'record') .. ' a cycle')
elseif kind == 'appellation' and bare(a.growing_area) then
local ga = bare(a.growing_area)
local grows = linked(frame, 'Has growing area', ga, stub)
local gwith, gsig = withCycles(grows)
local lead = (nacc == 0 and 'No accession is linked to this appellation.'
or ('None of the ' .. count(nacc, 'accession', 'accessions') .. ' linked to this appellation records a cycle.'))
if #gwith > 0 then
local intro = ' The band uses the ' .. count(#gwith, 'accession', 'accessions') .. ' in the [[' .. ga .. ']] growing area that '
.. (#gwith == 1 and 'records' or 'record') .. ' one'
cycles, note = describe(gwith, gsig, intro)
note = lead .. note
else
cycles, note = {}, lead .. ' No accession in the [[' .. ga .. ']] growing area records one either.'
end
else
cycles = {}
note = nacc == 0 and ('No accession is linked to ' .. where .. ', so no field cycle is shown.')
or ('None of the ' .. count(nacc, 'accession', 'accessions') .. ' in ' .. where .. ' records a field cycle.')
end
end
s.band, s.any = p._band(cycles)
s.note = note
local countries = p._countries(a.country)
s.clock = p._clock(countries, s.lat, s.lon)
if not s.clock then
local h = round(s.lon / 15)
local offs = {}
for m = 1, 12 do offs[m] = h end
s.clock = { offsets = offs, label = offsetText(h) .. ' (nominal, from longitude)' }
end
return s
end
-- {{Year at a glance}}: arguments come from the template call.
function p.strip(frame)
local parent = frame:getParent()
local a = {}
for k, v in pairs(parent and parent.args or {}) do a[k] = v end
for k, v in pairs(frame.args) do a[k] = v end
if not trim(a.key) then a.key = mw.title.getCurrentTitle().text end
local s = p._sample(a, frame)
if not s then return '' end
return frame:extensionTag('templatestyles', '', { src = STYLES }) .. p._render(s)
end
-- "yes" when the page would show a strip (for a heading or a wrapper), else empty.
function p.has(frame)
local parent = frame:getParent()
local args = parent and parent.args or {}
local key = bare(frame.args.key) or bare(args.key) or mw.title.getCurrentTitle().text
local kind = mw.ustring.lower(trim(frame.args.kind) or trim(args.kind) or 'accession')
if kind == 'accession' then return CP._accession(key) and 'yes' or '' end
return (CP._profile(key) and CP._normals(key)) and 'yes' or ''
end
return p