Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Each contract answers one question that two or more services must answer identic

- Which dataset is this request asking for? A dataset spec is reduced to a canonical form and hashed into a `spec_id`, so every service mints the same id for the same request.
- Has this table of observations changed? A materialised frame is serialised to canonical bytes and hashed into a `content_version`.
- How do units compare? A unit ref reads as a typed value (money with a currency and price basis, a percent, a count), and refuses comparisons that would mix price bases.
- How do units compare? A unit ref reads as a typed value (money with a currency and price basis, a percent, a count, a rate per a stated base), and refuses comparisons that would mix price bases.
- What does a card or chart look like on the wire? A catalogue of JSON schemas describes each presentation component.
- How is a result documented? Step records and descriptions say what was done to a result, and provenance documents carry transformations, changelogs, and methodology.
- What do the DFO clients expect from the API? The client contract holds the served OpenAPI document, the response members the clients act on, the next step for each refusal code, and recorded scenarios the clients replay. The API's CI checks its app against the same files.
Expand Down
6 changes: 6 additions & 0 deletions python/spec-kit/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `dfo_spec_kit.units.Rate` (`count_base`, `counted`), the typed value of a
ref that states a value per a count of something. `registries/units.json`
gains the `rate` kind, whose rows declare the base as an integer and what it
counts as a plural noun phrase, or `null` where the ref states none. Every
per-N unit with a series in the graph has a row, so `Per1000Persons` and
`SDG_PER_1000_POP` parse to one `Rate` and no longer to `Other`.
- Codes `share_id_taken` (409) and `share_import_not_permitted` (403) on
`presentation.shares` in `registries/codes.json` and its bundled mirror,
for the share import route. `share_id_taken` covers an id that holds
Expand Down
7 changes: 7 additions & 0 deletions python/spec-kit/EXPORTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,13 @@ kind = "model"
stability = "provisional"
reason = "bundled-data"

[[export]]
name = "Rate"
module = "dfo_spec_kit.units"
kind = "model"
stability = "provisional"
reason = "bundled-data"

[[export]]
name = "Other"
module = "dfo_spec_kit.units"
Expand Down
48 changes: 41 additions & 7 deletions python/spec-kit/src/dfo_spec_kit/units/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A unit ref is the dcid a facet names as its unit (`CurrencyUSD`, `USDollar`,
`Percent`, `undata/UNIT_MEASURE-CR_USD_K`). `parse_unit` reads one into a
`Money`, `UnpricedMoney`, `Percent`, `Count` or `Other`. Frames keep the
`Money`, `UnpricedMoney`, `Percent`, `Count`, `Rate` or `Other`. Frames keep the
native ref text in their cells, and a reader types it with this module.

The rules come from `registries/units.json` (canonical) through its packaged
Expand Down Expand Up @@ -164,11 +164,7 @@ def as_json(self) -> dict[str, object]:

@dataclass(frozen=True, slots=True)
class Percent:
"""Per cent of the variable's own denominator, so a stored 5 is 5 per cent.

A ratio with a named denominator in its ref (`Per1000Persons`) parses as
`Other`, so two ratios of different denominators never compare equal.
"""
"""Per cent of the variable's own denominator, so a stored 5 is 5 per cent."""

def as_json(self) -> dict[str, object]:
"""The value as a JSON object."""
Expand All @@ -184,6 +180,39 @@ def as_json(self) -> dict[str, object]:
return {"kind": "count"}


@dataclass(frozen=True, slots=True)
class Rate:
"""A number per a stated count of something, so a stored 12 under a
`count_base` of 1000 and `counted` of `people` is 12 per 1,000 people.

`count_base` is that count. `counted` is what it counts, a plural noun
phrase, `None` when the ref states none. Two refs of one base and one
counted thing are one value.
"""

count_base: int
counted: str | None

def __post_init__(self) -> None:
# bool is an int subclass, and True would equal and hash as 1.
if type(self.count_base) is not int or self.count_base < 2:
raise ValueError(f"count_base must be an int above 1: {self.count_base!r}")
if self.counted is not None and (
not isinstance(self.counted, str) or not self.counted
):
raise ValueError(
f"counted must be a non-empty string or None: {self.counted!r}"
)

def as_json(self) -> dict[str, object]:
"""The value as a JSON object, every member present."""
return {
"kind": "rate",
"count_base": self.count_base,
"counted": self.counted,
}


@dataclass(frozen=True, slots=True)
class Other:
"""A ref with no typed reading, equal only to the same ref."""
Expand All @@ -195,7 +224,7 @@ def as_json(self) -> dict[str, object]:
return {"kind": "other", "ref": self.ref}


type Unit = Money | UnpricedMoney | Percent | Count | Other
type Unit = Money | UnpricedMoney | Percent | Count | Rate | Other


def _currency_of(
Expand All @@ -222,6 +251,7 @@ def _currency_of(
)
_UNPRICED_MONEY_MEMBERS = frozenset(("ref", "kind", "currency", "prices", "evidence"))
_BARE_MEMBERS = frozenset(("ref", "kind", "evidence"))
_RATE_MEMBERS = frozenset(("ref", "kind", "count_base", "counted", "evidence"))


def _require_members(row: dict[str, Any], members: frozenset[str]) -> None:
Expand Down Expand Up @@ -257,6 +287,9 @@ def _row_to_unit(row: dict[str, Any]) -> Unit:
case "count":
_require_members(row, _BARE_MEMBERS)
return Count()
case "rate":
_require_members(row, _RATE_MEMBERS)
return Rate(count_base=row["count_base"], counted=row["counted"])
case kind:
raise ValueError(f"unit row {row.get('ref')!r} has unknown kind {kind!r}")

Expand Down Expand Up @@ -333,6 +366,7 @@ def complete_prices(unit: Unit, *, measurement_qualifier: str | None) -> Unit:
"Other",
"Percent",
"Prices",
"Rate",
"Unit",
"UnpricedMoney",
"complete_prices",
Expand Down
Loading
Loading