"""How a share of a whole is written, for every table that writes one.
One rule in one place, so every info-page table that renders a count
as a percentage of a total says the same thing the same way: the
genomic-score page's Coverage table (denominator: a chromosome's
length) and Alleles tables (denominator: an allele total), and the
reference-genome page's nucleotide and di-nucleotide tables
(denominator: the bases, or the pairs, counted).
:func:`percentage_of` is the rule itself, and it is deliberately
SCALAR -- one (count, total) pair to one string. What varies between
the callers is the contract around a MISSING denominator, and that is
theirs to keep. Coverage resolves a denominator per row and degrades
only that row, as do the Alleles table's per-class share columns,
whose denominator is each chromosome's own allele count.
:func:`percentages_over` is the whole-map answer -- no total, no
percentages at all -- which the substitution matrix, the complex table
and the reference-genome tables ask.
Every caller hands over the INTEGERS. The boundaries are decided on
them, so a share stored already multiplied out cannot be written by
this rule; the reference-genome page derives its shares from the
stored counts for that reason.
"""
from __future__ import annotations
from collections.abc import Mapping
[docs]
def percentage_of(count: int, total: int) -> str:
"""``count`` as a percentage of ``total``, floored and capped.
The result is text for an HTML page and can carry markup-significant
characters -- the floor and the ceiling begin with ``<`` and ``>``
-- so a template rendering it must escape, which the ``.jinja`` HTML
templates do and the Markdown ones deliberately do not.
``total`` must be positive; a share of nothing is not a percentage,
and what to show instead is the caller's question rather than this
one's (see the module docstring).
Two answers a bare ``"%.2f%%"`` gets wrong:
* A nonzero count too small to survive two decimals renders
``<0.01%``, never ``0.00%``. On a real score ``complex`` is 881
alleles out of 727,413,443 while ``other`` is genuinely empty --
and telling those two apart is the whole reason a percentage is
shown at all.
* A count that falls SHORT of the total but rounds up to it renders
``>99.99%``, never ``100.00%`` -- the floor reflected (gain#990).
On that same score the substitutions are all but 881 of the
alleles, and a column reading ``substitution 100.00%`` beside
``complex <0.01%`` says the resource is entirely one class in the
act of showing that it is not.
Both boundaries are decided on the INTEGERS, never on the rendered
float: a count that IS the total renders ``100.00%`` and a count of
zero renders ``0.00%``, because only a share that is not the whole
is written as short of it, and only a share that exists at all is
written as too small to see.
"""
rendered = f"{100.0 * count / total:.2f}%"
if count and rendered == "0.00%":
return "<0.01%"
if count < total and rendered == "100.00%":
return ">99.99%"
return rendered
[docs]
def percentages_over[K](
counts: Mapping[K, int], total: int,
) -> dict[K, str] | None:
"""Each count as a percentage of ``total``, ``None`` without one.
Every cell is written by :func:`percentage_of`. What this adds is
the MAP contract around a missing denominator: a zero total has no
percentage, and the answer is ``None`` for the WHOLE map rather
than per cell, because the denominator is a property of the table.
The caller decides what a table without one shows -- the Alleles
section drops the column, the reference-genome page leaves the
cells empty.
"""
if total <= 0:
return None
return {
key: percentage_of(count, total)
for key, count in counts.items()
}