Module:BassaridiaOutbreaks

From MicrasWiki
Revision as of 19:05, 28 September 2025 by 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...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigationJump to search

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