Module:BassaridiaOutbreaks: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
Created page with "--------------------------------------------------------- -- 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..."
 
NewZimiaGov (talk | contribs)
No edit summary
Line 3: Line 3:
-- Daily outbreak tracker for Bassaridia Vaeringheim.
-- Daily outbreak tracker for Bassaridia Vaeringheim.
-- Public functions:
-- Public functions:
--  1) outbreaksAll(frame)      -> single big table for ALL cities
--  outbreaksAll(frame)      -> table for ALL cities
--  2) outbreaksForCity(frame)  -> single table for a city
--  outbreaksForCity(frame)  -> table for a single city
--  3) diseaseGlossary(frame)  -> legend of disease names & controls
--  diseaseGlossary(frame)  -> legend
--
--
-- Deterministic per-day values via math.randomseed(year*1000 + yday).
-- City rows are further stabilized by seeding (dailySeed + cityIndex).
---------------------------------------------------------
---------------------------------------------------------


Line 14: Line 12:


---------------------------------------------------------
---------------------------------------------------------
-- 0. Date helpers (PSSC-compatible) & seeding
-- Date helpers & deterministic daily seed
---------------------------------------------------------
---------------------------------------------------------
local function getCurrentDateInfo()
local function getCurrentDateInfo()
Line 20: Line 18:
     local secondsInDay= 86400
     local secondsInDay= 86400
     local daysPerYear = 183
     local daysPerYear = 183
     local now        = os.time()
     local now        = os.time()
     local totalDays  = math.floor((now - startDate) / secondsInDay)
     local totalDays  = math.floor((now - startDate) / secondsInDay)
Line 26: Line 23:
     local psscYear    = math.floor(fraction)
     local psscYear    = math.floor(fraction)
     local dayOfYear  = math.floor((fraction - psscYear) * daysPerYear) + 1
     local dayOfYear  = math.floor((fraction - psscYear) * daysPerYear) + 1
     return { psscYear = psscYear, dayOfYear = dayOfYear }
     return { psscYear = psscYear, dayOfYear = dayOfYear }
end
end
Line 36: Line 32:


---------------------------------------------------------
---------------------------------------------------------
-- 1. Canon diseases (names & quick controls)
-- Config helpers (read once per invoke)
--   Names mirror national public-health page terms.
--  freq: chance any city has an outbreak (0..1), default 0.80
--   You can tweak weights/controls without touching code logic.
--  max : cap concurrent diseases per city (1..3), default 2
---------------------------------------------------------
local function getConfig(frame)
    local freq = tonumber(frame and frame.args and frame.args.freq) or 0.80
    if freq < 0 then freq = 0 elseif freq > 1 then freq = 1 end
 
    local maxd = tonumber(frame and frame.args and frame.args.max) or 2
    if maxd < 1 then maxd = 1 elseif maxd > 3 then maxd = 3 end
 
    return { freq = freq, max = maxd }
end
 
---------------------------------------------------------
-- Disease catalog (names, weights, baseline R)
---------------------------------------------------------
---------------------------------------------------------
local diseases = {
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="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="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="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="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="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="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="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="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="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="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="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="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="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"},
   {name="Agnian Lockjaw",         code="AL",  w=3,  r=0.95, controls="tetanus boosters, injury clinics"},
}
}


-- Weighted picker
local function pickNDiseases(n, rnd)
local function pickNDiseases(n, rnd)
    -- build cumulative weights
     local total = 0
     local total = 0
     for _,d in ipairs(diseases) do total = total + d.w end
     for _,d in ipairs(diseases) do total = total + d.w end
Line 73: Line 79:
             if roll <= acc then
             if roll <= acc then
                 if not used[i] then
                 if not used[i] then
                     table.insert(chosen, d)
                     table.insert(chosen, d); used[i] = true
                    used[i] = true
                 end
                 end
                 break
                 break
Line 84: Line 89:


---------------------------------------------------------
---------------------------------------------------------
-- 2. City roster (same links as your weather module)
-- Cities (same link formatting as your weather module)
---------------------------------------------------------
---------------------------------------------------------
local cityData = {
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#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#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#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#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#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#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#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#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#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#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#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#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#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#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#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#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#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#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#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#Skýrophos Skýrophos]"},
  {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aegirheim Aegirheim]"},
    {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Bjornopolis Bjornopolis]"},
  {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Norsolyra Norsolyra]"},
    {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aegirheim Aegirheim]"},
  {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thorsalon Thorsalon]"},
    {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Norsolyra Norsolyra]"},
  {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Pelagia Pelagia]"},
    {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thorsalon Thorsalon]"},
  {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#Pelagia Pelagia]"},
  {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ephyra Ephyra]"},
    {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Myrene Myrene]"},
  {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Halicarn Halicarn]"},
    {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thyrea Thyrea]"},
  {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#Ephyra Ephyra]"},
  {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#Halicarn Halicarn]"},
  {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#Keybir-Aviv Keybir-Aviv]"},
  {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Lewisburg Lewisburg]"},
    {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#Thermosalem Thermosalem]"},
    {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Diamandis Diamandis]"},
  {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#Jogi Jogi]"},
  {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Sufriya Sufriya]"},
    {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Lewisburg Lewisburg]"},
  {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#Thermosalem Thermosalem]"},
  {city="[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Riddersborg Riddersborg]"},
    {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)
local function plainName(link)
     return (link:match("%s+([^%]]+)%]$")) or link
     return (link:match("%s+([^%]]+)%]$")) or link
Line 141: Line 139:


---------------------------------------------------------
---------------------------------------------------------
-- 3. Severity, colors, and tiny helpers
-- Severity, colors, arrows
---------------------------------------------------------
---------------------------------------------------------
local function severityFor(casesPer100k)
local function severityFor(casesPer100k)
Line 162: Line 160:


---------------------------------------------------------
---------------------------------------------------------
-- 4. City outbreak synthesis (deterministic daily)
-- Outbreak count logic (REDUCED ACTIVITY)
--   For each city, 0–3 active diseases are picked.
-- 1) With probability = cfg.freq, a city has an outbreak.
--    We synthesize 24h cases, 7-day trend, Rt, positivity, advisory.
-- 2) If outbreak happens, choose 1–3 diseases with
--    weights heavily favoring single-disease rows.
--    Default max=2 to further reduce noise.
---------------------------------------------------------
---------------------------------------------------------
local function synthCityRow(rnd, cityLink)
local function chooseNActive(rnd, cfg)
     -- how many diseases today?
     -- No outbreak in this city today?
     local nActive = 0
     if rnd() > cfg.freq then return 0 end
    local r = rnd()
 
    if    r < 0.12 then nActive = 0
     -- Base weights for 1,2,3 diseases
     elseif r < 0.55 then nActive = 1
     local w = {0.70, 0.25, 0.05}
     elseif r < 0.88 then nActive = 2
    else nActive = 3 end


     local picked = pickNDiseases(nActive, rnd)
    -- Respect max cap
    local max = cfg.max
    if max == 1 then
        w = {1.0, 0.0, 0.0}
    elseif max == 2 then
        -- renormalize 1 & 2
        local s = (0.70 + 0.25)
        w = {0.70/s, 0.25/s, 0.0}
    end
 
    local roll = rnd()
    if roll <= w[1] then return 1
    elseif roll <= w[1] + w[2] then return 2
    else return 3 end
end
 
---------------------------------------------------------
-- City synthesis (deterministic per day + per city)
---------------------------------------------------------
local function synthCityRow(rnd, cityLink, cfg)
    local nActive = chooseNActive(rnd, cfg)
     local picked = pickNDiseases(nActive, rnd)


    -- Combine signals into overall metrics
     local names, short = {}, {}
     local names, short = {}, {}
     local totalCases = 0
     local totalCases, totalPopBase = 0, 0
    local totalPopBase = 0
     local maxSeverity, maxColor = "NONE", severityColors.NONE
     local maxSeverity = "NONE"
     local advices = {}
    local maxColor    = severityColors.NONE
     local advices     = {}


     for _,d in ipairs(picked) do
     for _,d in ipairs(picked) do
        -- Pseudopop base lets us derive cases per 100k without real populations
         local popBase = 100000   -- nominal
         local popBase = 100000 -- nominal per disease
         local base    = (d.r * 10)
         local base    = (d.r * 10) -- crude baseline
         local jitter  = math.floor(rnd()*base*3 + 0.5)
         local jitter  = math.floor(rnd()*base*3 + 0.5)
         local cases  = math.max(0, math.floor(base + jitter))
         local cases  = math.max(0, math.floor(base + jitter))


         totalCases   = totalCases + cases
         totalCases   = totalCases + cases
         totalPopBase = totalPopBase + popBase
         totalPopBase = totalPopBase + popBase


         local per100k = (cases / popBase) * 100000
         local per100k = (cases / popBase) * 100000
Line 209: Line 224:
     end
     end


    -- Aggregate stats
     local per100k = (totalPopBase>0) and (totalCases / totalPopBase) * 100000 or 0
     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 Rt      = (#picked>0) and (1.0 + rnd()*0.8) or (0.80 + rnd()*0.15)
     local trend  = (rnd()*0.6 - 0.3)  -- -0.3..+0.3 (neg=improving)
     local trend  = (rnd()*0.6 - 0.3)
     local posRate = (#picked>0) and (15 + math.floor(rnd()*25)) or (5 + math.floor(rnd()*5))
     local posRate = (#picked>0) and (15 + math.floor(rnd()*25)) or (4 + math.floor(rnd()*4))


     local diseasesCell = (#picked>0) and table.concat(names, ", ") or "—"
     local diseasesCell = (#picked>0) and table.concat(names, ", ") or "—"
Line 220: Line 234:


     return {
     return {
         cityLink     = cityLink,
         cityLink   = cityLink,
         diseases     = diseasesCell,
         diseases   = diseasesCell,
         codes         = codesCell,
         codes     = codesCell,
         cases24       = totalCases,
         cases24   = totalCases,
         per100k       = math.floor(per100k + 0.5),
         per100k   = math.floor(per100k + 0.5),
         Rt           = tonumber(string.format("%.2f", Rt)),
         Rt         = tonumber(string.format("%.2f", Rt)),
         trend         = trend,
         trend     = trend,
         trendIcon     = arrow(trend),
         trendIcon = arrow(trend),
         positivity   = posRate,
         positivity = posRate,
         severity     = maxSeverity,
         severity   = maxSeverity,
         color         = maxColor,
         color     = maxColor,
         advisory     = advisory
         advisory   = advisory
     }
     }
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 5. Render helpers
-- Rendering
---------------------------------------------------------
---------------------------------------------------------
local function renderHeaderAll(pssc)
local function renderHeaderAll(pssc)
Line 255: Line 269:
end
end


local function renderFooter()
local function renderFooter() return "|}\n" end
    return "|}\n"
end


---------------------------------------------------------
---------------------------------------------------------
-- 6A. ALL CITIES
-- Public: ALL CITIES
---------------------------------------------------------
---------------------------------------------------------
function p.outbreaksAll(frame)
function p.outbreaksAll(frame)
    local cfg  = getConfig(frame)
     local seed = getDailySeed()
     local seed = getDailySeed()
     math.randomseed(seed)
     math.randomseed(seed)
Line 270: Line 283:


     for i,entry in ipairs(cityData) do
     for i,entry in ipairs(cityData) do
        -- stabilize row randomness per city
         math.randomseed(seed + i*97)
         math.randomseed(seed + i*97)
         local function rnd() return math.random() end
         local function rnd() return math.random() end
         local row = synthCityRow(rnd, entry.city)
         local row = synthCityRow(rnd, entry.city, cfg)
         table.insert(out, renderRowAll(row))
         table.insert(out, renderRowAll(row))
     end
     end
Line 282: Line 294:


---------------------------------------------------------
---------------------------------------------------------
-- 6B. SINGLE CITY
-- Public: SINGLE CITY
---------------------------------------------------------
---------------------------------------------------------
function p.outbreaksForCity(frame)
function p.outbreaksForCity(frame)
Line 289: Line 301:
         return "Error: Please specify a city. E.g. {{#invoke:BassaridiaOutbreaks|outbreaksForCity|city=Vaeringheim}}"
         return "Error: Please specify a city. E.g. {{#invoke:BassaridiaOutbreaks|outbreaksForCity|city=Vaeringheim}}"
     end
     end
 
    local cfg  = getConfig(frame)
     local seed = getDailySeed()
     local seed = getDailySeed()
    math.randomseed(seed)


     local idx, link
     local idx, link
     for i,entry in ipairs(cityData) do
     for i,entry in ipairs(cityData) do
         local plain = plainName(entry.city)
         local plain = plainName(entry.city)
         if entry.city == q or plain == q then
         if entry.city == q or plain == q then idx = i; link = entry.city; break end
            idx = i; link = entry.city; break
        end
     end
     end
     if not idx then return "Error: City '"..q.."' not found." end
     if not idx then return "Error: City '"..q.."' not found." end
Line 304: Line 313:
     math.randomseed(seed + idx*97)
     math.randomseed(seed + idx*97)
     local function rnd() return math.random() end
     local function rnd() return math.random() end
     local row = synthCityRow(rnd, link)
     local row = synthCityRow(rnd, link, cfg)


     local out = {}
     local out = {}
Line 315: Line 324:


---------------------------------------------------------
---------------------------------------------------------
-- 6C. GLOSSARY
-- Public: Glossary
---------------------------------------------------------
---------------------------------------------------------
function p.diseaseGlossary(frame)
function p.diseaseGlossary(frame)
Line 329: Line 338:
end
end


---------------------------------------------------------
return p
return p

Revision as of 19:10, 28 September 2025

Documentation for this module may be created at Module:BassaridiaOutbreaks/doc

---------------------------------------------------------
-- Module: BassaridiaOutbreaks
-- Daily outbreak tracker for Bassaridia Vaeringheim.
-- Public functions:
--   outbreaksAll(frame)      -> table for ALL cities
--   outbreaksForCity(frame)  -> table for a single city
--   diseaseGlossary(frame)   -> legend
--
---------------------------------------------------------

local p = {}

---------------------------------------------------------
-- Date helpers & deterministic daily seed
---------------------------------------------------------
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

---------------------------------------------------------
-- Config helpers (read once per invoke)
--   freq: chance any city has an outbreak (0..1), default 0.80
--   max : cap concurrent diseases per city (1..3), default 2
---------------------------------------------------------
local function getConfig(frame)
    local freq = tonumber(frame and frame.args and frame.args.freq) or 0.80
    if freq < 0 then freq = 0 elseif freq > 1 then freq = 1 end

    local maxd = tonumber(frame and frame.args and frame.args.max) or 2
    if maxd < 1 then maxd = 1 elseif maxd > 3 then maxd = 3 end

    return { freq = freq, max = maxd }
end

---------------------------------------------------------
-- Disease catalog (names, weights, baseline R)
---------------------------------------------------------
local diseases = {
  {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"},
}

local function pickNDiseases(n, rnd)
    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

---------------------------------------------------------
-- Cities (same link formatting 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]"},
}

local function plainName(link)
    return (link:match("%s+([^%]]+)%]$")) or link
end

---------------------------------------------------------
-- Severity, colors, arrows
---------------------------------------------------------
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

---------------------------------------------------------
-- Outbreak count logic (REDUCED ACTIVITY)
-- 1) With probability = cfg.freq, a city has an outbreak.
-- 2) If outbreak happens, choose 1–3 diseases with
--    weights heavily favoring single-disease rows.
--    Default max=2 to further reduce noise.
---------------------------------------------------------
local function chooseNActive(rnd, cfg)
    -- No outbreak in this city today?
    if rnd() > cfg.freq then return 0 end

    -- Base weights for 1,2,3 diseases
    local w = {0.70, 0.25, 0.05}

    -- Respect max cap
    local max = cfg.max
    if max == 1 then
        w = {1.0, 0.0, 0.0}
    elseif max == 2 then
        -- renormalize 1 & 2
        local s = (0.70 + 0.25)
        w = {0.70/s, 0.25/s, 0.0}
    end

    local roll = rnd()
    if roll <= w[1] then return 1
    elseif roll <= w[1] + w[2] then return 2
    else return 3 end
end

---------------------------------------------------------
-- City synthesis (deterministic per day + per city)
---------------------------------------------------------
local function synthCityRow(rnd, cityLink, cfg)
    local nActive = chooseNActive(rnd, cfg)
    local picked  = pickNDiseases(nActive, rnd)

    local names, short = {}, {}
    local totalCases, totalPopBase = 0, 0
    local maxSeverity, maxColor = "NONE", severityColors.NONE
    local advices = {}

    for _,d in ipairs(picked) do
        local popBase = 100000   -- nominal
        local base    = (d.r * 10)
        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

    local per100k = (totalPopBase>0) and (totalCases / totalPopBase) * 100000 or 0
    local Rt      = (#picked>0) and (1.0 + rnd()*0.8) or (0.80 + rnd()*0.15)
    local trend   = (rnd()*0.6 - 0.3)
    local posRate = (#picked>0) and (15 + math.floor(rnd()*25)) or (4 + math.floor(rnd()*4))

    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

---------------------------------------------------------
-- Rendering
---------------------------------------------------------
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

---------------------------------------------------------
-- Public: ALL CITIES
---------------------------------------------------------
function p.outbreaksAll(frame)
    local cfg  = getConfig(frame)
    local seed = getDailySeed()
    math.randomseed(seed)

    local d = getCurrentDateInfo()
    local out = renderHeaderAll(d)

    for i,entry in ipairs(cityData) do
        math.randomseed(seed + i*97)
        local function rnd() return math.random() end
        local row = synthCityRow(rnd, entry.city, cfg)
        table.insert(out, renderRowAll(row))
    end

    table.insert(out, renderFooter())
    return table.concat(out)
end

---------------------------------------------------------
-- Public: 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 cfg  = getConfig(frame)
    local seed = getDailySeed()

    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, cfg)

    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

---------------------------------------------------------
-- Public: 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