Module:BassaridiaOutbreaks
From MicrasWiki
Documentation for this module may be created at Module:BassaridiaOutbreaks/doc
---------------------------------------------------------
-- Module: BassaridiaOutbreaks
-- Daily outbreak tracker for Bassaridia Vaeringheim.
-- Public functions:
-- 1) outbreaksAll(frame) -> single big table for ALL cities
-- 2) outbreaksForCity(frame) -> single table for a city
-- 3) diseaseGlossary(frame) -> legend of disease names & controls
--
-- Deterministic per-day values via math.randomseed(year*1000 + yday).
-- City rows are further stabilized by seeding (dailySeed + cityIndex).
---------------------------------------------------------
local p = {}
---------------------------------------------------------
-- 0. Date helpers (PSSC-compatible) & seeding
---------------------------------------------------------
local function getCurrentDateInfo()
local startDate = os.time({year=1999, month=8, day=6})
local secondsInDay= 86400
local daysPerYear = 183
local now = os.time()
local totalDays = math.floor((now - startDate) / secondsInDay)
local fraction = totalDays / daysPerYear
local psscYear = math.floor(fraction)
local dayOfYear = math.floor((fraction - psscYear) * daysPerYear) + 1
return { psscYear = psscYear, dayOfYear = dayOfYear }
end
local function getDailySeed()
local d = os.date("*t")
return (d.year * 1000) + d.yday
end
---------------------------------------------------------
-- 1. Canon diseases (names & quick controls)
-- Names mirror national public-health page terms.
-- You can tweak weights/controls without touching code logic.
---------------------------------------------------------
local diseases = {
-- name short weight baseR desc / controls
{name="Thalassan Rot", code="TR", w=10, r=1.20, controls="canal flush, rat-proofing, food-handler checks"},
{name="Festival Blush", code="FB", w=8, r=1.25, controls="ring vaccination, crowd-spacing, dorm rotation"},
{name="Pilgrim’s Flux", code="PF", w=9, r=1.15, controls="boil notices, cistern audit, ORS distribution"},
{name="Stygian Dust", code="SD", w=5, r=1.05, controls="respirators, mist carts, guide clinic checks"},
{name="Haifan Salt-Sickness", code="HS", w=6, r=1.10, controls="indoor rotations, antifungal clinics, dehumidify"},
{name="Norsolyrian Drain", code="ND", w=6, r=1.18, controls="lined ditches, praziquantel, snail abatement"},
{name="Somniumpolis Lung", code="SL", w=5, r=1.07, controls="dust suppression, respirator fit-checks"},
{name="Haifan Crimson Fever", code="HCF", w=3, r=1.30, controls="larval control, dusk patrols, vector abatement"},
{name="Strip Yellowing", code="SY", w=10, r=1.10, controls="single-use ladles, vendor relicensing, recall"},
{name="Eye of Styx", code="ES", w=4, r=1.00, controls="eye-wash stations, visor compliance"},
{name="Cato’s Curse", code="CC", w=4, r=1.05, controls="PPE audits, solvent exposure controls"},
{name="White Pestilence", code="WP", w=2, r=1.40, controls="tiered quarantine, convoy assays, cordons"},
{name="Breath of Nephele", code="BN", w=9, r=1.12, controls="ventilation drills, mask catechism, spacing"},
{name="Agnian Lockjaw", code="AL", w=3, r=0.95, controls="tetanus boosters, injury clinics"},
}
-- Weighted picker
local function pickNDiseases(n, rnd)
-- build cumulative weights
local total = 0
for _,d in ipairs(diseases) do total = total + d.w end
local chosen, used = {}, {}
local tries = 0
while #chosen < n and tries < 50 do
tries = tries + 1
local roll = rnd() * total
local acc = 0
for i,d in ipairs(diseases) do
acc = acc + d.w
if roll <= acc then
if not used[i] then
table.insert(chosen, d)
used[i] = true
end
break
end
end
end
return chosen
end
---------------------------------------------------------
-- 2. City roster (same links as your weather module)
---------------------------------------------------------
local cityData = {
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Vaeringheim Vaeringheim]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Luminaria Luminaria]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Serena Serena]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Pyralis Pyralis]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Symphonara Symphonara]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aurelia Aurelia]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Somniumpolis Somniumpolis]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Nexa Nexa]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Lunalis_Sancta Lunalis Sancta]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Sylvapolis Sylvapolis]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Saluria Saluria]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aetherium Aetherium]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ferrum_Citadel Ferrum Citadel]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Acheron Acheron]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Erythros Erythros]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Catonis_Atrium Catonis Atrium]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Delphica Delphica]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Koinonía Koinonía]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aureum Aureum]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Skýrophos Skýrophos]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Bjornopolis Bjornopolis]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aegirheim Aegirheim]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Norsolyra Norsolyra]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thorsalon Thorsalon]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Pelagia Pelagia]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Myrene Myrene]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thyrea Thyrea]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ephyra Ephyra]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Halicarn Halicarn]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Keybir-Aviv Keybir-Aviv]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Tel-Amin Tel-Amin]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Diamandis Diamandis]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Jogi Jogi]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Lewisburg Lewisburg]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thermosalem Thermosalem]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Akróstadium Akróstadium]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Sufriya Sufriya]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Lykopolis Lykopolis]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ardclach Ardclach]"},
{city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Riddersborg Riddersborg]"},
}
-- Plain city name extractor for single-city lookups
local function plainName(link)
return (link:match("%s+([^%]]+)%]$")) or link
end
---------------------------------------------------------
-- 3. Severity, colors, and tiny helpers
---------------------------------------------------------
local function severityFor(casesPer100k)
if casesPer100k >= 60 then return "CRITICAL"
elseif casesPer100k >= 25 then return "HIGH"
elseif casesPer100k >= 10 then return "MEDIUM"
elseif casesPer100k >= 1 then return "LOW"
else return "NONE" end
end
local severityColors = {
CRITICAL="#ffcccc", HIGH="#ffe0b2", MEDIUM="#fff9c4", LOW="#e0f2f1", NONE="#f2f2f2"
}
local function arrow(trend)
if trend > 0.1 then return "▲"
elseif trend < -0.1 then return "▼"
else return "▬" end
end
---------------------------------------------------------
-- 4. City outbreak synthesis (deterministic daily)
-- For each city, 0–3 active diseases are picked.
-- We synthesize 24h cases, 7-day trend, Rt, positivity, advisory.
---------------------------------------------------------
local function synthCityRow(rnd, cityLink)
-- how many diseases today?
local nActive = 0
local r = rnd()
if r < 0.12 then nActive = 0
elseif r < 0.55 then nActive = 1
elseif r < 0.88 then nActive = 2
else nActive = 3 end
local picked = pickNDiseases(nActive, rnd)
-- Combine signals into overall metrics
local names, short = {}, {}
local totalCases = 0
local totalPopBase = 0
local maxSeverity = "NONE"
local maxColor = severityColors.NONE
local advices = {}
for _,d in ipairs(picked) do
-- Pseudopop base lets us derive cases per 100k without real populations
local popBase = 100000 -- nominal per disease
local base = (d.r * 10) -- crude baseline
local jitter = math.floor(rnd()*base*3 + 0.5)
local cases = math.max(0, math.floor(base + jitter))
totalCases = totalCases + cases
totalPopBase = totalPopBase + popBase
local per100k = (cases / popBase) * 100000
local sev = severityFor(per100k)
if sev == "CRITICAL" or (sev=="HIGH" and maxSeverity~="CRITICAL")
or (sev=="MEDIUM" and (maxSeverity=="LOW" or maxSeverity=="NONE"))
or (sev=="LOW" and maxSeverity=="NONE") then
maxSeverity = sev
maxColor = severityColors[sev]
end
table.insert(names, d.name)
table.insert(short, d.code)
table.insert(advices, string.format("%s: %s", d.code, d.controls))
end
-- Aggregate stats
local per100k = (totalPopBase>0) and (totalCases / totalPopBase) * 100000 or 0
local Rt = (#picked>0) and (1.0 + rnd()*0.8) or 0.85 + rnd()*0.1 -- rises with activity
local trend = (rnd()*0.6 - 0.3) -- -0.3..+0.3 (neg=improving)
local posRate = (#picked>0) and (15 + math.floor(rnd()*25)) or (5 + math.floor(rnd()*5))
local diseasesCell = (#picked>0) and table.concat(names, ", ") or "—"
local codesCell = (#picked>0) and table.concat(short, "/") or "—"
local advisory = (#picked>0) and table.concat(advices, " • ") or "No active advisories"
return {
cityLink = cityLink,
diseases = diseasesCell,
codes = codesCell,
cases24 = totalCases,
per100k = math.floor(per100k + 0.5),
Rt = tonumber(string.format("%.2f", Rt)),
trend = trend,
trendIcon = arrow(trend),
positivity = posRate,
severity = maxSeverity,
color = maxColor,
advisory = advisory
}
end
---------------------------------------------------------
-- 5. Render helpers
---------------------------------------------------------
local function renderHeaderAll(pssc)
local out = {}
table.insert(out, "== Daily Outbreak Report ==\n")
table.insert(out, string.format("''(Day %d of Year %d PSSC)''\n\n", pssc.dayOfYear, pssc.psscYear))
table.insert(out, '{| class="wikitable sortable" style="width:100%; text-align:left;"\n')
table.insert(out, "! City !! Active Diseases !! Codes !! 24h Cases !! /100k !! R<sub>t</sub> !! 7d Trend !! Positivity (%) !! Severity !! Advisory\n")
return out
end
local function renderRowAll(row)
return string.format(
"|-\n| %s || %s || %s || %d || %d || %.2f || %s %.0f%% || %d || style=\"background:%s\" | %s || %s\n",
row.cityLink, row.diseases, row.codes, row.cases24, row.per100k, row.Rt,
row.trendIcon, math.abs(row.trend*100), row.positivity, row.color, row.severity, row.advisory
)
end
local function renderFooter()
return "|}\n"
end
---------------------------------------------------------
-- 6A. ALL CITIES
---------------------------------------------------------
function p.outbreaksAll(frame)
local seed = getDailySeed()
math.randomseed(seed)
local d = getCurrentDateInfo()
local out = renderHeaderAll(d)
for i,entry in ipairs(cityData) do
-- stabilize row randomness per city
math.randomseed(seed + i*97)
local function rnd() return math.random() end
local row = synthCityRow(rnd, entry.city)
table.insert(out, renderRowAll(row))
end
table.insert(out, renderFooter())
return table.concat(out)
end
---------------------------------------------------------
-- 6B. SINGLE CITY
---------------------------------------------------------
function p.outbreaksForCity(frame)
local q = frame.args.city
if not q or q == "" then
return "Error: Please specify a city. E.g. {{#invoke:BassaridiaOutbreaks|outbreaksForCity|city=Vaeringheim}}"
end
local seed = getDailySeed()
math.randomseed(seed)
local idx, link
for i,entry in ipairs(cityData) do
local plain = plainName(entry.city)
if entry.city == q or plain == q then
idx = i; link = entry.city; break
end
end
if not idx then return "Error: City '"..q.."' not found." end
math.randomseed(seed + idx*97)
local function rnd() return math.random() end
local row = synthCityRow(rnd, link)
local out = {}
table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"\n')
table.insert(out, "! City !! Active Diseases !! Codes !! 24h Cases !! /100k !! R<sub>t</sub> !! 7d Trend !! Positivity (%) !! Severity !! Advisory\n")
table.insert(out, renderRowAll(row))
table.insert(out, renderFooter())
return table.concat(out)
end
---------------------------------------------------------
-- 6C. GLOSSARY
---------------------------------------------------------
function p.diseaseGlossary(frame)
local out = {}
table.insert(out, "=== Disease Glossary & Standard Controls ===\n")
table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"\n')
table.insert(out, "! Code !! Disease !! Typical Controls\n")
for _,d in ipairs(diseases) do
table.insert(out, string.format("|-\n| %s || %s || %s\n", d.code, d.name, d.controls))
end
table.insert(out, "|}\n")
return table.concat(out)
end
---------------------------------------------------------
return p