diff --git a/README.md b/README.md index 670e102..254059d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/python/spec-kit/CHANGELOG.md b/python/spec-kit/CHANGELOG.md index b740881..b236892 100644 --- a/python/spec-kit/CHANGELOG.md +++ b/python/spec-kit/CHANGELOG.md @@ -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 diff --git a/python/spec-kit/EXPORTS.md b/python/spec-kit/EXPORTS.md index 1a04281..a612db9 100644 --- a/python/spec-kit/EXPORTS.md +++ b/python/spec-kit/EXPORTS.md @@ -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" diff --git a/python/spec-kit/src/dfo_spec_kit/units/__init__.py b/python/spec-kit/src/dfo_spec_kit/units/__init__.py index cf92f62..e3477bc 100644 --- a/python/spec-kit/src/dfo_spec_kit/units/__init__.py +++ b/python/spec-kit/src/dfo_spec_kit/units/__init__.py @@ -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 @@ -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.""" @@ -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.""" @@ -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( @@ -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: @@ -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}") @@ -333,6 +366,7 @@ def complete_prices(unit: Unit, *, measurement_qualifier: str | None) -> Unit: "Other", "Percent", "Prices", + "Rate", "Unit", "UnpricedMoney", "complete_prices", diff --git a/python/spec-kit/src/dfo_spec_kit/units/units.json b/python/spec-kit/src/dfo_spec_kit/units/units.json index 6707c01..60103fd 100644 --- a/python/spec-kit/src/dfo_spec_kit/units/units.json +++ b/python/spec-kit/src/dfo_spec_kit/units/units.json @@ -9,6 +9,7 @@ "AFN", "ALL", "AMD", + "ANG", "AOA", "ARS", "AUD", @@ -17,6 +18,7 @@ "BAM", "BBD", "BDT", + "BGN", "BHD", "BIF", "BMD", @@ -180,7 +182,8 @@ "YER", "ZAR", "ZMW", - "ZWG" + "ZWG", + "ZWL" ], "units": [ { @@ -231,6 +234,62 @@ "prices": null, "evidence": "Base Data Commons twin of CurrencyINR; the ref does not state prices." }, + { + "ref": "Per100000Females", + "kind": "rate", + "count_base": 100000, + "counted": "females", + "evidence": "Graph label 'Per 100k females', on series such as Count_Death_IntentionalSelfHarm_Female_AsFractionOf_Count_Person_Female." + }, + { + "ref": "Per100000Males", + "kind": "rate", + "count_base": 100000, + "counted": "males", + "evidence": "Graph label 'Per 100k males', on series such as Count_CriminalActivities_MurderAndNonNegligentManslaughter_Male_AsFractionOf_Count_Person_Male." + }, + { + "ref": "Per100000Persons", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "Graph label 'Per 100k people', on series such as Count_Death_IntentionalSelfHarm_AsFractionOf_Count_Person." + }, + { + "ref": "Per1000FemaleLiveBirths", + "kind": "rate", + "count_base": 1000, + "counted": "female live births", + "evidence": "Graph label 'Per 1000 female live births', on series such as Count_Death_0Years_Female_AsFractionOf_Count_BirthEvent_LiveBirth_Female." + }, + { + "ref": "Per1000LiveBirths", + "kind": "rate", + "count_base": 1000, + "counted": "live births", + "evidence": "Graph label 'Per 1000 live births', on series such as Count_Death_0Years_AsFractionOf_Count_BirthEvent_LiveBirth." + }, + { + "ref": "Per1000MaleLiveBirths", + "kind": "rate", + "count_base": 1000, + "counted": "male live births", + "evidence": "Graph label 'Per 1000 male live births', on series such as Count_Death_0Years_Male_AsFractionOf_Count_BirthEvent_LiveBirth_Male." + }, + { + "ref": "Per1000Persons", + "kind": "rate", + "count_base": 1000, + "counted": "people", + "evidence": "Graph label 'Per 1000 people', on series such as Count_BirthEvent_LiveBirth_AsFractionOf_Count_Person." + }, + { + "ref": "Per100Persons", + "kind": "rate", + "count_base": 100, + "counted": "people", + "evidence": "Graph label 'Per 100 Persons', on series such as Count_Person_COVID19." + }, { "ref": "Percent", "kind": "percent", @@ -264,6 +323,62 @@ "kind": "percent", "evidence": "SDG unit: per cent." }, + { + "ref": "SDG_PER_1000000_POP", + "kind": "rate", + "count_base": 1000000, + "counted": "people", + "evidence": "Graph label 'Per million population', on series such as sdg/GB_POP_SCIERD." + }, + { + "ref": "SDG_PER_100000_EMP", + "kind": "rate", + "count_base": 100000, + "counted": "employees", + "evidence": "Graph label 'Per 100,000 employees', on series such as sdg/SL_EMP_FTLINJUR." + }, + { + "ref": "SDG_PER_100000_LIVE_BIRTHS", + "kind": "rate", + "count_base": 100000, + "counted": "live births", + "evidence": "Graph label 'Per 100,000 live births', on series such as sdg/SH_STA_MORT.SEX--F." + }, + { + "ref": "SDG_PER_100000_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "Graph label 'Per 100,000 population', on series such as sdg/SM_POP_REFG_OR." + }, + { + "ref": "SDG_PER_10000_POP", + "kind": "rate", + "count_base": 10000, + "counted": "people", + "evidence": "Graph label 'Per 10,000 population', on series such as sdg/SH_MED_DEN.OCCUPATION--ISCO08_2261." + }, + { + "ref": "SDG_PER_1000_LIVE_BIRTHS", + "kind": "rate", + "count_base": 1000, + "counted": "live births", + "evidence": "Graph label 'Per 1,000 live births', on series such as sdg/SH_DYN_IMRT.AGE--Y0__SEX--F." + }, + { + "ref": "SDG_PER_1000_POP", + "kind": "rate", + "count_base": 1000, + "counted": "people", + "evidence": "Graph label 'Per 1,000 population', on series such as sdg/SP_DYN_ADKL.AGE--Y10T14__SEX--F." + }, + { + "ref": "SDG_PER_1000_UNINFECTED_POP", + "kind": "rate", + "count_base": 1000, + "counted": "uninfected people", + "evidence": "Graph label 'Per 1,000 uninfected population', on series such as sdg/SH_HIV_INCD.SEX--F." + }, { "ref": "USDollar", "kind": "money", @@ -363,6 +478,307 @@ "base_year": null, "chained": false, "evidence": "UN unit 'United States dollars at constant prices'; _K marks constant prices, and neither ref nor label states the base year." + }, + { + "ref": "undata/UNIT_MEASURE-RATE_DEATH_PER_100000_COUNT", + "kind": "rate", + "count_base": 100000, + "counted": null, + "evidence": "UN label 'Death rate per 100,000', on series such as undata/who/AIR_DEATH_R_AS.AIR_POL_TYPE--HH__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATE_DEATH_PER_1000_COUNT", + "kind": "rate", + "count_base": 1000, + "counted": null, + "evidence": "UN label 'Death rate per 1,000', on series such as undata/who/LT_NMX.AGE--Y45T49__SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATE_MORTALITY_PER_100000_COUNT", + "kind": "rate", + "count_base": 100000, + "counted": null, + "evidence": "UN label 'Mortality rate per 100,000', on series such as undata/undphdro/GII_mmr.SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATE_MORTALITY_PER_1000_COUNT_LIVEBIRTHS", + "kind": "rate", + "count_base": 1000, + "counted": "live births", + "evidence": "UN label 'Mortality rate per 1,000 live births', on series such as undata/who/MORT_U5_CAUSE_RATE.AGE--Y0T4__GHE_CAUSE--PRETERM_LBW." + }, + { + "ref": "undata/UNIT_MEASURE-RATE_MORTALITY_PER_1000_COUNT_POP_AGECOHORT", + "kind": "rate", + "count_base": 1000, + "counted": "people in the age cohort", + "evidence": "UN label 'Mortality rate per 1,000 age-specific cohort', on series such as undata/who/MORT_CHILD_ADO_RATE.AGE--Y15T19__SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_BIRTHS_PER_1000_COUNT_POP_F", + "kind": "rate", + "count_base": 1000, + "counted": "women", + "evidence": "UN label 'Births per 1,000 women', on series such as undata/undphdro/GII_abr.AGE--Y15T19__SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_BIRTHS_PER_1000_COUNT_POP_F_Y15T19", + "kind": "rate", + "count_base": 1000, + "counted": "women aged 15-19 years", + "evidence": "UN label 'Births per 1,000 women aged 15-19 years', on series such as undata/who/FERT_ADO.AGE--Y15T19__EDUCATION_LEVEL--AGG_1__SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_CASES_PER_100000_COUNT_LIVEBIRTHS", + "kind": "rate", + "count_base": 100000, + "counted": "live births", + "evidence": "UN label 'Cases per 100,000 live births', on series such as undata/who/CSYPH_RATE." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_CASES_PER_COUNT_1000000_COUNT_POP", + "kind": "rate", + "count_base": 1000000, + "counted": "people", + "evidence": "UN label 'Cases per million persons', on series such as undata/who/NTD_CASE_R.NTD_DISEASE--LEPROSY." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_CASES_PER_COUNT_1000_COUNT_POP", + "kind": "rate", + "count_base": 1000, + "counted": "people", + "evidence": "UN label 'Cases per 1,000 persons', on series such as undata/who/MAL_EST_INCID." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DALY_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'DALYs per 100,000 persons', on series such as undata/who/AIR_DALY_R_AS.AIR_POL_TYPE--HH__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_100000_COUNT_BIRTHS", + "kind": "rate", + "count_base": 100000, + "counted": "births", + "evidence": "UN label 'Deaths per 100,000 births', on series such as undata/unicef/MNCH_MMR.SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_100000_COUNT_PERSONS_INCARCERATED", + "kind": "rate", + "count_base": 100000, + "counted": "incarcerated people", + "evidence": "UN label 'Number of deaths per 100,000 incarcerated persons', on series such as undata/who/PRISON_DEATH_RATE.GHE_CAUSE--COVID19." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'Deaths per 100,000 persons', on series such as undata/who/AIR_DEATH_R.AIR_POL_TYPE--HH__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_LIVEBIRTHS", + "kind": "rate", + "count_base": 1000, + "counted": "live births", + "evidence": "UN label 'Deaths per 1,000 live births', on series such as undata/unicef/CME_MR.AGE--M0." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP", + "kind": "rate", + "count_base": 1000, + "counted": "people", + "evidence": "UN label 'Deaths per 1,000 persons', on series such as undata/unaids/AIDS_MORT.SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP_M1", + "kind": "rate", + "count_base": 1000, + "counted": "children aged 1 month", + "evidence": "UN label 'Deaths per 1,000 children aged 1 month', on series such as undata/unicef/CME_MR.AGE--M1T59." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP_Y1", + "kind": "rate", + "count_base": 1000, + "counted": "children aged 1", + "evidence": "UN label 'Deaths per 1,000 children aged 1', on series such as undata/unicef/CME_MR.AGE--Y1T4__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP_Y10", + "kind": "rate", + "count_base": 1000, + "counted": "children aged 10", + "evidence": "UN label 'Deaths per 1,000 children aged 10', on series such as undata/unicef/CME_MR.AGE--Y10T19." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP_Y15", + "kind": "rate", + "count_base": 1000, + "counted": "youths aged 15", + "evidence": "UN label 'Deaths per 1,000 youths aged 15', on series such as undata/unicef/CME_MR.AGE--Y15T19." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP_Y20", + "kind": "rate", + "count_base": 1000, + "counted": "people aged 20", + "evidence": "UN label 'Deaths per 1,000 children aged 20', on series such as undata/unicef/CME_MR.AGE--Y20T24__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP_Y5", + "kind": "rate", + "count_base": 1000, + "counted": "children aged 5", + "evidence": "UN label 'Deaths per 1,000 children aged 5', on series such as undata/unicef/CME_MR.AGE--Y5T9__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_FTE_PER_1000_COUNT_PERSONS_PRISONER", + "kind": "rate", + "count_base": 1000, + "counted": "prisoners", + "evidence": "UN label 'Number of full-time-equivalent staff per 1,000 prisoners', on series such as undata/who/PRISON_HWF_RATIO.HWF_TYPE--MED_DOCTOR." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_M_PER_100_COUNT_F", + "kind": "rate", + "count_base": 100, + "counted": "females", + "evidence": "UN label 'Males per 100 females', on series such as undata/unfpa/SEX_RATIO_CHILD.NATIVITY--NATIVE_BORN." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_NEWCASES_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'New cases per 100,000 persons', on series such as undata/who/ORAL_CANCER_INC_R.SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_NEWCASES_PER_1000_COUNT_POP_UNINFECTED", + "kind": "rate", + "count_base": 1000, + "counted": "uninfected people", + "evidence": "UN label 'New cases per 1,000 uninfected persons', on series such as undata/unaids/HIV_INC.SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PERSONS_PRISONER_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'Prisoners per 100,000 persons', on series such as undata/who/PRISON_POP_R." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_1000000_COUNT_POP", + "kind": "rate", + "count_base": 1000000, + "counted": "people", + "evidence": "UN label 'Count per million persons', on series such as undata/sdg/GB_POP_SCIERD." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_100000_COUNT_EMPLOYEES", + "kind": "rate", + "count_base": 100000, + "counted": "employees", + "evidence": "UN label 'Count per 100,000 employees', on series such as undata/sdg/SL_EMP_INJUR.AGE--Y_GE15." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_100000_COUNT_LIVEBIRTHS", + "kind": "rate", + "count_base": 100000, + "counted": "live births", + "evidence": "UN label 'Count per 100,000 live births', on series such as undata/sdg/SH_STA_MORT.SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'Count per 100,000 persons', on series such as undata/sdg/FB_ATM_TOTL.AGE--Y_GE15." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_10000_COUNT_POP", + "kind": "rate", + "count_base": 10000, + "counted": "people", + "evidence": "UN label 'Count per 10,000 persons', on series such as undata/sdg/SH_MED_DEN.OCCUPATION--ISCO08_2262." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_1000_COUNT_", + "kind": "rate", + "count_base": 1000, + "counted": null, + "evidence": "UN label 'Count per 1,000', on series such as undata/unicef/MG_RFGS_CNTRY_ASYLM_PER1000." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_1000_COUNT_LIVEBIRTHS", + "kind": "rate", + "count_base": 1000, + "counted": "live births", + "evidence": "UN label 'Count per 1,000 live births', on series such as undata/sdg/SH_DYN_MORT.AGE--Y0T4__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_1000_COUNT_POP", + "kind": "rate", + "count_base": 1000, + "counted": "people", + "evidence": "UN label 'Count per 1,000 persons', on series such as undata/sdg/SH_STA_MALR." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_1000_COUNT_POP_F_Y15T49", + "kind": "rate", + "count_base": 1000, + "counted": "women aged 15-49 years", + "evidence": "UN label 'Count per 1,000 women aged 15-49 years', on series such as undata/who/ABORTION_RATE." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_1000_COUNT_POP_UNINFECTED", + "kind": "rate", + "count_base": 1000, + "counted": "uninfected people", + "evidence": "UN label 'Count per 1,000 uninfected population', on series such as undata/sdg/SH_HIV_INCD." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_100_COUNT_LIVEBIRTHS", + "kind": "rate", + "count_base": 100, + "counted": "live births", + "evidence": "UN label 'Count per 100 live births', on series such as undata/who/PRETERMBIRTH_R." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_100_COUNT_POP", + "kind": "rate", + "count_base": 100, + "counted": "people", + "evidence": "UN label 'Count per 100 persons', on series such as undata/itu/I992B.INTERNET_SPEED--MBPS2T10." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_5000000_COUNT_POP", + "kind": "rate", + "count_base": 5000000, + "counted": "people", + "evidence": "UN label 'Count per 5 million persons', on series such as undata/who/TB_LAB_CUL_5M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_STILLBIRTHS_PER_1000_COUNT_BIRTHS", + "kind": "rate", + "count_base": 1000, + "counted": "total births", + "evidence": "UN label 'Stillbirths per 1,000 total births', on series such as undata/unicef/CME_SBR." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_YLD_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'YLDs per 100,000 persons', on series such as undata/who/FB_YLD_RATE.AGE--Y_GE5__FB_HAZARD--FBH_II_B_3." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_YLL_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'YLLs per 100,000 persons', on series such as undata/who/GHE_YLL_RATE.AGE--Y60T69__GHE_CAUSE--GHE2019_II__SEX--F." } ] } diff --git a/python/spec-kit/tests/test_units.py b/python/spec-kit/tests/test_units.py index 87fdff3..2664a2b 100644 --- a/python/spec-kit/tests/test_units.py +++ b/python/spec-kit/tests/test_units.py @@ -14,6 +14,7 @@ Money, Other, Percent, + Rate, Unit, UnpricedMoney, _row_to_unit, @@ -76,6 +77,20 @@ def test_vector(vector: dict) -> None: assert completed.as_json() == vector["expected"] +_CURRENCY_CODES = json.loads(_CANONICAL_PATH.read_text(encoding="utf-8"))[ + "currency_codes" +] + + +@pytest.mark.parametrize("code", sorted({*_CURRENCY_CODES, "ANG", "BGN", "ZWL"})) +def test_every_listed_currency_code_alone_is_money_in_it(code: str) -> None: + """A bare code the registry lists reads as money in that currency, the withdrawn codes + Data Commons still serves (`ANG`, `BGN`, `ZWL`) among them.""" + unit = parse_unit(code) + assert isinstance(unit, UnpricedMoney) + assert unit.currency == code + + def test_every_registry_row_parses_to_its_declared_kind() -> None: rows = json.loads(_CANONICAL_PATH.read_text(encoding="utf-8"))["units"] for row in rows: @@ -89,6 +104,8 @@ def test_every_registry_row_parses_to_its_declared_kind() -> None: assert isinstance(unit, Percent), row["ref"] case "count": assert isinstance(unit, Count), row["ref"] + case "rate": + assert isinstance(unit, Rate), row["ref"] case _: pytest.fail(row["ref"]) @@ -222,6 +239,28 @@ def test_money_refuses_an_invalid_value(kwargs: dict) -> None: {**_MONEY_ROW, "evidence": 5}, {**_MONEY_ROW, "evidence": ""}, {"ref": "test/count", "kind": "count", "evidence": ""}, + {"ref": "test/rate", "kind": "rate", "count_base": 1000, "evidence": "x"}, + { + "ref": "test/rate", + "kind": "rate", + "count_base": 1, + "counted": "people", + "evidence": "x", + }, + { + "ref": "test/rate", + "kind": "rate", + "count_base": True, + "counted": "people", + "evidence": "x", + }, + { + "ref": "test/rate", + "kind": "rate", + "count_base": 1000, + "counted": "", + "evidence": "x", + }, ], ) def test_registry_row_refuses_members_its_kind_lacks(row: dict) -> None: diff --git a/registries/README.md b/registries/README.md index d9e5842..e7cd2b9 100644 --- a/registries/README.md +++ b/registries/README.md @@ -4,4 +4,4 @@ This file is the canonical copy. `python/spec-kit/src/dfo_spec_kit/registry/codes.json` is a byte-identical package mirror, kept in sync by the same validator, so a Python consumer can load the registry from an installed wheel via `dfo_spec_kit.registry` without a filesystem path back to this directory. Edit this file, not the mirror; `uv run tools/validate_registry.py` fails loudly if they diverge. -`units.json` declares how unit refs read as typed units, for `dfo_spec_kit.units`. `currency_prefixes` lists the prefixes (`""` and `Currency`) that, followed by a currency code, make a ref money in that currency with prices unstated. After `Currency` a code is any three upper-case ASCII letters. After the empty prefix the code must be one of `currency_codes`, the ISO 4217 active codes without `XTS` (testing) and `XXX` (no currency), so a bare `TEU` or `PPM` reads as other. `units` lists every other typed ref, sorted by `ref`. Money rows carry `currency`, `prices` (`current`, `constant`, or `null` when the ref leaves them unstated) and `evidence`; a row with stated prices also carries `base_year` (`null` when unstated, always `null` for current prices) and `chained` (`true` for a chain-linked series, which only constant prices can be). Percent and count rows carry `ref`, `kind` and `evidence` only. `evidence` says where the reading comes from. A ref that scales its values (`SDG_CU_USD_M`, `SDG_NUM_TH`, `PeopleInThousands`) has no row and reads as other, as does any ref that matches no prefix and no row. No row may also match a prefix rule. `schemas/registry/unit-registry.schema.json` validates the file, and `uv run tools/validate_unit_registry.py` also checks the sort order, uniqueness, the prefix rule and the package mirror `python/spec-kit/src/dfo_spec_kit/units/units.json`; edit this file and copy it there. +`units.json` declares how unit refs read as typed units, for `dfo_spec_kit.units`. `currency_prefixes` lists the prefixes (`""` and `Currency`) that, followed by a currency code, make a ref money in that currency with prices unstated. After `Currency` a code is any three upper-case ASCII letters. After the empty prefix the code must be one of `currency_codes`, the ISO 4217 active codes without `XTS` (testing) and `XXX` (no currency), plus the withdrawn `ANG`, `BGN` and `ZWL`, which Data Commons still types as `Currency` for older series, so a bare `TEU` or `PPM` reads as other. `units` lists every other typed ref, sorted by `ref`. Money rows carry `currency`, `prices` (`current`, `constant`, or `null` when the ref leaves them unstated) and `evidence`; a row with stated prices also carries `base_year` (`null` when unstated, always `null` for current prices) and `chained` (`true` for a chain-linked series, which only constant prices can be). Percent and count rows carry `ref`, `kind` and `evidence` only. Rate rows state a value per a count of something: `count_base` is that count (`1000` for "per 1,000 people"), and `counted` is what it counts as a plural noun phrase (`people`, `live births`), `null` when the ref states none. The graph holds neither as a field, so each rate row declares them from the ref and its label. `evidence` says where the reading comes from. A ref that scales its values (`SDG_CU_USD_M`, `SDG_NUM_TH`, `PeopleInThousands`) has no row and reads as other, as does any ref that matches no prefix and no row. No row may also match a prefix rule. `schemas/registry/unit-registry.schema.json` validates the file, and `uv run tools/validate_unit_registry.py` also checks the sort order, uniqueness, the prefix rule and the package mirror `python/spec-kit/src/dfo_spec_kit/units/units.json`; edit this file and copy it there. diff --git a/registries/units.json b/registries/units.json index 6707c01..60103fd 100644 --- a/registries/units.json +++ b/registries/units.json @@ -9,6 +9,7 @@ "AFN", "ALL", "AMD", + "ANG", "AOA", "ARS", "AUD", @@ -17,6 +18,7 @@ "BAM", "BBD", "BDT", + "BGN", "BHD", "BIF", "BMD", @@ -180,7 +182,8 @@ "YER", "ZAR", "ZMW", - "ZWG" + "ZWG", + "ZWL" ], "units": [ { @@ -231,6 +234,62 @@ "prices": null, "evidence": "Base Data Commons twin of CurrencyINR; the ref does not state prices." }, + { + "ref": "Per100000Females", + "kind": "rate", + "count_base": 100000, + "counted": "females", + "evidence": "Graph label 'Per 100k females', on series such as Count_Death_IntentionalSelfHarm_Female_AsFractionOf_Count_Person_Female." + }, + { + "ref": "Per100000Males", + "kind": "rate", + "count_base": 100000, + "counted": "males", + "evidence": "Graph label 'Per 100k males', on series such as Count_CriminalActivities_MurderAndNonNegligentManslaughter_Male_AsFractionOf_Count_Person_Male." + }, + { + "ref": "Per100000Persons", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "Graph label 'Per 100k people', on series such as Count_Death_IntentionalSelfHarm_AsFractionOf_Count_Person." + }, + { + "ref": "Per1000FemaleLiveBirths", + "kind": "rate", + "count_base": 1000, + "counted": "female live births", + "evidence": "Graph label 'Per 1000 female live births', on series such as Count_Death_0Years_Female_AsFractionOf_Count_BirthEvent_LiveBirth_Female." + }, + { + "ref": "Per1000LiveBirths", + "kind": "rate", + "count_base": 1000, + "counted": "live births", + "evidence": "Graph label 'Per 1000 live births', on series such as Count_Death_0Years_AsFractionOf_Count_BirthEvent_LiveBirth." + }, + { + "ref": "Per1000MaleLiveBirths", + "kind": "rate", + "count_base": 1000, + "counted": "male live births", + "evidence": "Graph label 'Per 1000 male live births', on series such as Count_Death_0Years_Male_AsFractionOf_Count_BirthEvent_LiveBirth_Male." + }, + { + "ref": "Per1000Persons", + "kind": "rate", + "count_base": 1000, + "counted": "people", + "evidence": "Graph label 'Per 1000 people', on series such as Count_BirthEvent_LiveBirth_AsFractionOf_Count_Person." + }, + { + "ref": "Per100Persons", + "kind": "rate", + "count_base": 100, + "counted": "people", + "evidence": "Graph label 'Per 100 Persons', on series such as Count_Person_COVID19." + }, { "ref": "Percent", "kind": "percent", @@ -264,6 +323,62 @@ "kind": "percent", "evidence": "SDG unit: per cent." }, + { + "ref": "SDG_PER_1000000_POP", + "kind": "rate", + "count_base": 1000000, + "counted": "people", + "evidence": "Graph label 'Per million population', on series such as sdg/GB_POP_SCIERD." + }, + { + "ref": "SDG_PER_100000_EMP", + "kind": "rate", + "count_base": 100000, + "counted": "employees", + "evidence": "Graph label 'Per 100,000 employees', on series such as sdg/SL_EMP_FTLINJUR." + }, + { + "ref": "SDG_PER_100000_LIVE_BIRTHS", + "kind": "rate", + "count_base": 100000, + "counted": "live births", + "evidence": "Graph label 'Per 100,000 live births', on series such as sdg/SH_STA_MORT.SEX--F." + }, + { + "ref": "SDG_PER_100000_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "Graph label 'Per 100,000 population', on series such as sdg/SM_POP_REFG_OR." + }, + { + "ref": "SDG_PER_10000_POP", + "kind": "rate", + "count_base": 10000, + "counted": "people", + "evidence": "Graph label 'Per 10,000 population', on series such as sdg/SH_MED_DEN.OCCUPATION--ISCO08_2261." + }, + { + "ref": "SDG_PER_1000_LIVE_BIRTHS", + "kind": "rate", + "count_base": 1000, + "counted": "live births", + "evidence": "Graph label 'Per 1,000 live births', on series such as sdg/SH_DYN_IMRT.AGE--Y0__SEX--F." + }, + { + "ref": "SDG_PER_1000_POP", + "kind": "rate", + "count_base": 1000, + "counted": "people", + "evidence": "Graph label 'Per 1,000 population', on series such as sdg/SP_DYN_ADKL.AGE--Y10T14__SEX--F." + }, + { + "ref": "SDG_PER_1000_UNINFECTED_POP", + "kind": "rate", + "count_base": 1000, + "counted": "uninfected people", + "evidence": "Graph label 'Per 1,000 uninfected population', on series such as sdg/SH_HIV_INCD.SEX--F." + }, { "ref": "USDollar", "kind": "money", @@ -363,6 +478,307 @@ "base_year": null, "chained": false, "evidence": "UN unit 'United States dollars at constant prices'; _K marks constant prices, and neither ref nor label states the base year." + }, + { + "ref": "undata/UNIT_MEASURE-RATE_DEATH_PER_100000_COUNT", + "kind": "rate", + "count_base": 100000, + "counted": null, + "evidence": "UN label 'Death rate per 100,000', on series such as undata/who/AIR_DEATH_R_AS.AIR_POL_TYPE--HH__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATE_DEATH_PER_1000_COUNT", + "kind": "rate", + "count_base": 1000, + "counted": null, + "evidence": "UN label 'Death rate per 1,000', on series such as undata/who/LT_NMX.AGE--Y45T49__SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATE_MORTALITY_PER_100000_COUNT", + "kind": "rate", + "count_base": 100000, + "counted": null, + "evidence": "UN label 'Mortality rate per 100,000', on series such as undata/undphdro/GII_mmr.SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATE_MORTALITY_PER_1000_COUNT_LIVEBIRTHS", + "kind": "rate", + "count_base": 1000, + "counted": "live births", + "evidence": "UN label 'Mortality rate per 1,000 live births', on series such as undata/who/MORT_U5_CAUSE_RATE.AGE--Y0T4__GHE_CAUSE--PRETERM_LBW." + }, + { + "ref": "undata/UNIT_MEASURE-RATE_MORTALITY_PER_1000_COUNT_POP_AGECOHORT", + "kind": "rate", + "count_base": 1000, + "counted": "people in the age cohort", + "evidence": "UN label 'Mortality rate per 1,000 age-specific cohort', on series such as undata/who/MORT_CHILD_ADO_RATE.AGE--Y15T19__SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_BIRTHS_PER_1000_COUNT_POP_F", + "kind": "rate", + "count_base": 1000, + "counted": "women", + "evidence": "UN label 'Births per 1,000 women', on series such as undata/undphdro/GII_abr.AGE--Y15T19__SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_BIRTHS_PER_1000_COUNT_POP_F_Y15T19", + "kind": "rate", + "count_base": 1000, + "counted": "women aged 15-19 years", + "evidence": "UN label 'Births per 1,000 women aged 15-19 years', on series such as undata/who/FERT_ADO.AGE--Y15T19__EDUCATION_LEVEL--AGG_1__SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_CASES_PER_100000_COUNT_LIVEBIRTHS", + "kind": "rate", + "count_base": 100000, + "counted": "live births", + "evidence": "UN label 'Cases per 100,000 live births', on series such as undata/who/CSYPH_RATE." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_CASES_PER_COUNT_1000000_COUNT_POP", + "kind": "rate", + "count_base": 1000000, + "counted": "people", + "evidence": "UN label 'Cases per million persons', on series such as undata/who/NTD_CASE_R.NTD_DISEASE--LEPROSY." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_CASES_PER_COUNT_1000_COUNT_POP", + "kind": "rate", + "count_base": 1000, + "counted": "people", + "evidence": "UN label 'Cases per 1,000 persons', on series such as undata/who/MAL_EST_INCID." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DALY_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'DALYs per 100,000 persons', on series such as undata/who/AIR_DALY_R_AS.AIR_POL_TYPE--HH__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_100000_COUNT_BIRTHS", + "kind": "rate", + "count_base": 100000, + "counted": "births", + "evidence": "UN label 'Deaths per 100,000 births', on series such as undata/unicef/MNCH_MMR.SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_100000_COUNT_PERSONS_INCARCERATED", + "kind": "rate", + "count_base": 100000, + "counted": "incarcerated people", + "evidence": "UN label 'Number of deaths per 100,000 incarcerated persons', on series such as undata/who/PRISON_DEATH_RATE.GHE_CAUSE--COVID19." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'Deaths per 100,000 persons', on series such as undata/who/AIR_DEATH_R.AIR_POL_TYPE--HH__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_LIVEBIRTHS", + "kind": "rate", + "count_base": 1000, + "counted": "live births", + "evidence": "UN label 'Deaths per 1,000 live births', on series such as undata/unicef/CME_MR.AGE--M0." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP", + "kind": "rate", + "count_base": 1000, + "counted": "people", + "evidence": "UN label 'Deaths per 1,000 persons', on series such as undata/unaids/AIDS_MORT.SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP_M1", + "kind": "rate", + "count_base": 1000, + "counted": "children aged 1 month", + "evidence": "UN label 'Deaths per 1,000 children aged 1 month', on series such as undata/unicef/CME_MR.AGE--M1T59." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP_Y1", + "kind": "rate", + "count_base": 1000, + "counted": "children aged 1", + "evidence": "UN label 'Deaths per 1,000 children aged 1', on series such as undata/unicef/CME_MR.AGE--Y1T4__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP_Y10", + "kind": "rate", + "count_base": 1000, + "counted": "children aged 10", + "evidence": "UN label 'Deaths per 1,000 children aged 10', on series such as undata/unicef/CME_MR.AGE--Y10T19." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP_Y15", + "kind": "rate", + "count_base": 1000, + "counted": "youths aged 15", + "evidence": "UN label 'Deaths per 1,000 youths aged 15', on series such as undata/unicef/CME_MR.AGE--Y15T19." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP_Y20", + "kind": "rate", + "count_base": 1000, + "counted": "people aged 20", + "evidence": "UN label 'Deaths per 1,000 children aged 20', on series such as undata/unicef/CME_MR.AGE--Y20T24__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_DEATHS_PER_1000_COUNT_POP_Y5", + "kind": "rate", + "count_base": 1000, + "counted": "children aged 5", + "evidence": "UN label 'Deaths per 1,000 children aged 5', on series such as undata/unicef/CME_MR.AGE--Y5T9__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_FTE_PER_1000_COUNT_PERSONS_PRISONER", + "kind": "rate", + "count_base": 1000, + "counted": "prisoners", + "evidence": "UN label 'Number of full-time-equivalent staff per 1,000 prisoners', on series such as undata/who/PRISON_HWF_RATIO.HWF_TYPE--MED_DOCTOR." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_M_PER_100_COUNT_F", + "kind": "rate", + "count_base": 100, + "counted": "females", + "evidence": "UN label 'Males per 100 females', on series such as undata/unfpa/SEX_RATIO_CHILD.NATIVITY--NATIVE_BORN." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_NEWCASES_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'New cases per 100,000 persons', on series such as undata/who/ORAL_CANCER_INC_R.SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_NEWCASES_PER_1000_COUNT_POP_UNINFECTED", + "kind": "rate", + "count_base": 1000, + "counted": "uninfected people", + "evidence": "UN label 'New cases per 1,000 uninfected persons', on series such as undata/unaids/HIV_INC.SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PERSONS_PRISONER_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'Prisoners per 100,000 persons', on series such as undata/who/PRISON_POP_R." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_1000000_COUNT_POP", + "kind": "rate", + "count_base": 1000000, + "counted": "people", + "evidence": "UN label 'Count per million persons', on series such as undata/sdg/GB_POP_SCIERD." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_100000_COUNT_EMPLOYEES", + "kind": "rate", + "count_base": 100000, + "counted": "employees", + "evidence": "UN label 'Count per 100,000 employees', on series such as undata/sdg/SL_EMP_INJUR.AGE--Y_GE15." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_100000_COUNT_LIVEBIRTHS", + "kind": "rate", + "count_base": 100000, + "counted": "live births", + "evidence": "UN label 'Count per 100,000 live births', on series such as undata/sdg/SH_STA_MORT.SEX--F." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'Count per 100,000 persons', on series such as undata/sdg/FB_ATM_TOTL.AGE--Y_GE15." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_10000_COUNT_POP", + "kind": "rate", + "count_base": 10000, + "counted": "people", + "evidence": "UN label 'Count per 10,000 persons', on series such as undata/sdg/SH_MED_DEN.OCCUPATION--ISCO08_2262." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_1000_COUNT_", + "kind": "rate", + "count_base": 1000, + "counted": null, + "evidence": "UN label 'Count per 1,000', on series such as undata/unicef/MG_RFGS_CNTRY_ASYLM_PER1000." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_1000_COUNT_LIVEBIRTHS", + "kind": "rate", + "count_base": 1000, + "counted": "live births", + "evidence": "UN label 'Count per 1,000 live births', on series such as undata/sdg/SH_DYN_MORT.AGE--Y0T4__SEX--M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_1000_COUNT_POP", + "kind": "rate", + "count_base": 1000, + "counted": "people", + "evidence": "UN label 'Count per 1,000 persons', on series such as undata/sdg/SH_STA_MALR." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_1000_COUNT_POP_F_Y15T49", + "kind": "rate", + "count_base": 1000, + "counted": "women aged 15-49 years", + "evidence": "UN label 'Count per 1,000 women aged 15-49 years', on series such as undata/who/ABORTION_RATE." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_1000_COUNT_POP_UNINFECTED", + "kind": "rate", + "count_base": 1000, + "counted": "uninfected people", + "evidence": "UN label 'Count per 1,000 uninfected population', on series such as undata/sdg/SH_HIV_INCD." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_100_COUNT_LIVEBIRTHS", + "kind": "rate", + "count_base": 100, + "counted": "live births", + "evidence": "UN label 'Count per 100 live births', on series such as undata/who/PRETERMBIRTH_R." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_100_COUNT_POP", + "kind": "rate", + "count_base": 100, + "counted": "people", + "evidence": "UN label 'Count per 100 persons', on series such as undata/itu/I992B.INTERNET_SPEED--MBPS2T10." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_PER_5000000_COUNT_POP", + "kind": "rate", + "count_base": 5000000, + "counted": "people", + "evidence": "UN label 'Count per 5 million persons', on series such as undata/who/TB_LAB_CUL_5M." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_STILLBIRTHS_PER_1000_COUNT_BIRTHS", + "kind": "rate", + "count_base": 1000, + "counted": "total births", + "evidence": "UN label 'Stillbirths per 1,000 total births', on series such as undata/unicef/CME_SBR." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_YLD_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'YLDs per 100,000 persons', on series such as undata/who/FB_YLD_RATE.AGE--Y_GE5__FB_HAZARD--FBH_II_B_3." + }, + { + "ref": "undata/UNIT_MEASURE-RATIO_COUNT_YLL_PER_100000_COUNT_POP", + "kind": "rate", + "count_base": 100000, + "counted": "people", + "evidence": "UN label 'YLLs per 100,000 persons', on series such as undata/who/GHE_YLL_RATE.AGE--Y60T69__GHE_CAUSE--GHE2019_II__SEX--F." } ] } diff --git a/schemas/registry/unit-registry.schema.json b/schemas/registry/unit-registry.schema.json index d4873d8..288cf98 100644 --- a/schemas/registry/unit-registry.schema.json +++ b/schemas/registry/unit-registry.schema.json @@ -176,6 +176,41 @@ "$ref": "#/$defs/evidence" } } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ref", + "kind", + "count_base", + "counted", + "evidence" + ], + "properties": { + "ref": { + "$ref": "#/$defs/ref" + }, + "kind": { + "const": "rate" + }, + "count_base": { + "description": "How many of what is counted one value is per: 1000 for a value per 1,000 people.", + "type": "integer", + "minimum": 2 + }, + "counted": { + "description": "What the base counts, as a plural noun phrase the ref states, null when the ref states none.", + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "evidence": { + "$ref": "#/$defs/evidence" + } + } } ] }, diff --git a/schemas/vectors/README.md b/schemas/vectors/README.md index 269a572..dc5615d 100644 --- a/schemas/vectors/README.md +++ b/schemas/vectors/README.md @@ -259,15 +259,15 @@ The dense-slots invariant names two distinct claims, one graph-dependent and one ## Unit vectors -`vectors/units/` holds the conformance suite for the typed unit, `dfo_spec_kit.units`, which reads a unit ref into `money`, `unpriced_money`, `percent`, `count` or `other` by the rules in `registries/units.json`. Each vector is one JSON file named `.json`, validated against `schemas/vectors/unit-vector.schema.json` by `tools/validate_unit_vectors.py`, and `python/spec-kit/tests/test_units.py` runs every file through the module. +`vectors/units/` holds the conformance suite for the typed unit, `dfo_spec_kit.units`, which reads a unit ref into `money`, `unpriced_money`, `percent`, `count`, `rate` or `other` by the rules in `registries/units.json`. Each vector is one JSON file named `.json`, validated against `schemas/vectors/unit-vector.schema.json` by `tools/validate_unit_vectors.py`, and `python/spec-kit/tests/test_units.py` runs every file through the module. A vector carries `format_version` (`1`), `name`, `description` and a `classification`, which decides the rest of the record: -- `parse`: `ref`, and `expected`, the typed value as a JSON object with every member present. Money is `{kind, currency, prices, base_year, chained}`, unpriced money `{kind, currency}`, per cent and a count `{kind}`, and other `{kind, ref}`. +- `parse`: `ref`, and `expected`, the typed value as a JSON object with every member present. Money is `{kind, currency, prices, base_year, chained}`, unpriced money `{kind, currency}`, per cent and a count `{kind}`, a rate `{kind, count_base, counted}`, and other `{kind, ref}`. - `compare`: `refs`, two refs; `measurement_qualifier`, a dcid or `null`, which completes both refs' unstated prices first; and `outcome`. `equal` and `different` say whether the two values compare equal. `incomparable` says the comparison raises because a value is unpriced money or is constant-price money with no `base_year`, since two unknown base years may differ. - `complete`: `ref`, `measurement_qualifier` (a dcid or `null`), and `expected`, the typed value after `complete_prices` gives unpriced money the qualifier's price basis. -The suite uses real refs, typed and untyped. `Per1000Persons`, `Per100000Persons`, `IndexPoint`, `PPPInternationalDollar`, `SDG_CU_USD_M`, `CurrencyPerUSDollar` and `Currencyusd` are real refs that no registry row or prefix rule types, and the `other` vectors that use them prove they read as `other`. Adding a registry row for one of them changes those vectors. `TEU`, twenty-foot equivalent units, stands for a bare three-capital ref that is not a currency code. `test/unknownUnit` stands for a ref no import uses, and `test/unknownQualifier` for a qualifier that states no price basis. +The suite uses real refs, typed and untyped. `PersonPerSquareKilometer`, `KilogramPerHectare`, `MilligramsPer24Hours`, `IndexPoint`, `PPPInternationalDollar`, `SDG_CU_USD_M`, `CurrencyPerUSDollar` and `Currencyusd` are real refs that no registry row or prefix rule types, and the `other` vectors that use them prove they read as `other`. Adding a registry row for one of them changes those vectors. `TEU`, twenty-foot equivalent units, stands for a bare three-capital ref that is not a currency code. `test/unknownUnit` stands for a ref no import uses, and `test/unknownQualifier` for a qualifier that states no price basis. | Name | Classification | Pins | | ---------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- | @@ -279,6 +279,10 @@ The suite uses real refs, typed and untyped. `Per1000Persons`, `Per100000Persons | `compare-other-different-refs-differ` | compare | Two other units with different refs differ. | | `compare-other-same-ref-equal` | compare | An other unit equals the same ref. | | `compare-percent-spellings-equal` | compare | Two per cent refs are one unit. | +| `compare-rate-bases-differ` | compare | Rates over different bases are different units. | +| `compare-rate-counted-differ` | compare | Rates of one base over different counted things are different units. | +| `compare-rate-spellings-equal` | compare | Two refs of one base and one counted thing are one unit. | +| `compare-rate-un-sdg-equal` | compare | A UN and an SDG ref of one base and one counted thing are one unit. | | `compare-scaled-ref-differs-from-unscaled` | compare | A ref that scales its values is other, so millions of dollars never equal dollars. | | `compare-unknown-base-year-incomparable-with-itself` | compare | Constant prices in an unstated base year cannot be compared, even with the same ref. | | `compare-unknown-base-years-incomparable` | compare | Two constant-price units with unstated base years cannot be compared, so they never compare equal. | @@ -300,9 +304,13 @@ The suite uses real refs, typed and untyped. `Per1000Persons`, `Per100000Persons | `parse-fixed-base-constant-usd` | parse | A fixed-base constant-dollar ref carries constant prices, its base year and chained false. | | `parse-lowercase-currency-suffix-is-other` | parse | A currency code is upper-case; a lower-case suffix is not money. | | `parse-named-currency-twin` | parse | A declared named currency parses to the same unpriced money as its Currency-prefixed twin. | -| `parse-named-denominator-is-other` | parse | A ratio with a named denominator stays its own ref. | +| `parse-hourly-per-is-other` | parse | A per-N of hours is a dose over time, so it states no count base. | +| `parse-named-denominator-is-other` | parse | A ratio that states no count base stays its own ref. | | `parse-percent` | parse | Per cent is one value with no fields. | | `parse-ppp-is-other` | parse | A purchasing-power-parity unit is not money in any currency. | +| `parse-rate-million-population` | parse | A base the ref writes in digits and the graph label in a word reads as the integer. | +| `parse-rate-per-1000-people` | parse | A rate carries its count base and what the base counts. | +| `parse-rate-un-counted-unstated` | parse | A rate whose ref states a base and not what it counts carries a null counted. | | `parse-scaled-sdg-count-is-other` | parse | An SDG count ref in thousands scales its values, so it is other. | | `parse-scaled-sdg-money-is-other` | parse | An SDG money ref in millions scales its values, so it is other. | | `parse-sdg-current-usd` | parse | An SDG ref that states current prices is comparable money. | diff --git a/schemas/vectors/unit-vector.schema.json b/schemas/vectors/unit-vector.schema.json index ba2ec76..58f6cb3 100644 --- a/schemas/vectors/unit-vector.schema.json +++ b/schemas/vectors/unit-vector.schema.json @@ -203,6 +203,31 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "count_base", + "counted" + ], + "properties": { + "kind": { + "const": "rate" + }, + "count_base": { + "type": "integer", + "minimum": 2 + }, + "counted": { + "type": [ + "string", + "null" + ], + "minLength": 1 + } + } + }, { "type": "object", "additionalProperties": false, diff --git a/tools/tests/test_validate_unit_registry.py b/tools/tests/test_validate_unit_registry.py index 0cc8097..5e0d825 100644 --- a/tools/tests/test_validate_unit_registry.py +++ b/tools/tests/test_validate_unit_registry.py @@ -59,6 +59,10 @@ def mutate(registry: dict[str, Any]) -> None: pytest.param(_set("Percent", currency="USD"), id="percent-extra-member"), pytest.param(_set("SDG_NUMBER", prices=None), id="count-extra-member"), pytest.param(_set("SDG_CU_USD", method=None), id="money-retired-member"), + pytest.param(_set("Per1000Persons", count_base=1), id="rate-base-of-one"), + pytest.param(_set("Per1000Persons", count_base="1000"), id="string-base"), + pytest.param(_set("Per1000Persons", counted=""), id="rate-empty-counted"), + pytest.param(_set("Percent", count_base=100), id="percent-carries-base"), pytest.param(_set("USDollar", chained=False), id="unpriced-carries-chained"), pytest.param(_set("SDG_CU_USD", base_year=2015), id="current-base-year"), pytest.param(_set("SDG_CU_USD", chained=True), id="current-chained"), diff --git a/vectors/units/compare-other-different-refs-differ.json b/vectors/units/compare-other-different-refs-differ.json index 4d97a5a..5be5115 100644 --- a/vectors/units/compare-other-different-refs-differ.json +++ b/vectors/units/compare-other-different-refs-differ.json @@ -4,8 +4,8 @@ "description": "Two other units with different refs differ.", "classification": "compare", "refs": [ - "Per1000Persons", - "Per100000Persons" + "PersonPerSquareKilometer", + "KilogramPerHectare" ], "measurement_qualifier": null, "outcome": "different" diff --git a/vectors/units/compare-rate-bases-differ.json b/vectors/units/compare-rate-bases-differ.json new file mode 100644 index 0000000..5f61895 --- /dev/null +++ b/vectors/units/compare-rate-bases-differ.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "name": "compare-rate-bases-differ", + "description": "Rates over different bases are different units.", + "classification": "compare", + "refs": [ + "Per1000Persons", + "Per100000Persons" + ], + "measurement_qualifier": null, + "outcome": "different" +} diff --git a/vectors/units/compare-rate-counted-differ.json b/vectors/units/compare-rate-counted-differ.json new file mode 100644 index 0000000..1045264 --- /dev/null +++ b/vectors/units/compare-rate-counted-differ.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "name": "compare-rate-counted-differ", + "description": "Rates of one base over different counted things are different units.", + "classification": "compare", + "refs": [ + "SDG_PER_1000_POP", + "SDG_PER_1000_LIVE_BIRTHS" + ], + "measurement_qualifier": null, + "outcome": "different" +} diff --git a/vectors/units/compare-rate-spellings-equal.json b/vectors/units/compare-rate-spellings-equal.json new file mode 100644 index 0000000..8b149c9 --- /dev/null +++ b/vectors/units/compare-rate-spellings-equal.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "name": "compare-rate-spellings-equal", + "description": "Two refs of one base and one counted thing are one unit.", + "classification": "compare", + "refs": [ + "Per1000Persons", + "SDG_PER_1000_POP" + ], + "measurement_qualifier": null, + "outcome": "equal" +} diff --git a/vectors/units/compare-rate-un-sdg-equal.json b/vectors/units/compare-rate-un-sdg-equal.json new file mode 100644 index 0000000..24d7ecb --- /dev/null +++ b/vectors/units/compare-rate-un-sdg-equal.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "name": "compare-rate-un-sdg-equal", + "description": "A UN and an SDG ref of one base and one counted thing are one unit.", + "classification": "compare", + "refs": [ + "undata/UNIT_MEASURE-RATIO_COUNT_PER_100000_COUNT_POP", + "SDG_PER_100000_POP" + ], + "measurement_qualifier": null, + "outcome": "equal" +} diff --git a/vectors/units/parse-hourly-per-is-other.json b/vectors/units/parse-hourly-per-is-other.json new file mode 100644 index 0000000..6d41c29 --- /dev/null +++ b/vectors/units/parse-hourly-per-is-other.json @@ -0,0 +1,11 @@ +{ + "format_version": 1, + "name": "parse-hourly-per-is-other", + "description": "A per-N of hours is a dose over time, so it states no count base.", + "classification": "parse", + "ref": "MilligramsPer24Hours", + "expected": { + "kind": "other", + "ref": "MilligramsPer24Hours" + } +} diff --git a/vectors/units/parse-named-denominator-is-other.json b/vectors/units/parse-named-denominator-is-other.json index bb68db9..f1c925a 100644 --- a/vectors/units/parse-named-denominator-is-other.json +++ b/vectors/units/parse-named-denominator-is-other.json @@ -1,11 +1,11 @@ { "format_version": 1, "name": "parse-named-denominator-is-other", - "description": "A ratio with a named denominator stays its own ref.", + "description": "A ratio that states no count base stays its own ref.", "classification": "parse", - "ref": "Per1000Persons", + "ref": "PersonPerSquareKilometer", "expected": { "kind": "other", - "ref": "Per1000Persons" + "ref": "PersonPerSquareKilometer" } } diff --git a/vectors/units/parse-rate-million-population.json b/vectors/units/parse-rate-million-population.json new file mode 100644 index 0000000..bec7099 --- /dev/null +++ b/vectors/units/parse-rate-million-population.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "name": "parse-rate-million-population", + "description": "A base the ref writes in digits and the graph label in a word reads as the integer.", + "classification": "parse", + "ref": "SDG_PER_1000000_POP", + "expected": { + "kind": "rate", + "count_base": 1000000, + "counted": "people" + } +} diff --git a/vectors/units/parse-rate-per-1000-people.json b/vectors/units/parse-rate-per-1000-people.json new file mode 100644 index 0000000..a141436 --- /dev/null +++ b/vectors/units/parse-rate-per-1000-people.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "name": "parse-rate-per-1000-people", + "description": "A rate carries its count base and what the base counts.", + "classification": "parse", + "ref": "Per1000Persons", + "expected": { + "kind": "rate", + "count_base": 1000, + "counted": "people" + } +} diff --git a/vectors/units/parse-rate-un-counted-unstated.json b/vectors/units/parse-rate-un-counted-unstated.json new file mode 100644 index 0000000..ec68044 --- /dev/null +++ b/vectors/units/parse-rate-un-counted-unstated.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "name": "parse-rate-un-counted-unstated", + "description": "A rate whose ref states a base and not what it counts carries a null counted.", + "classification": "parse", + "ref": "undata/UNIT_MEASURE-RATE_DEATH_PER_1000_COUNT", + "expected": { + "kind": "rate", + "count_base": 1000, + "counted": null + } +}