# SPDX-License-Identifier: MIT # body — state the rules of blood compatibility and body size, and calculate # the body's numbers. # # THIS MODULE IS NOT MEDICAL ADVICE. It states rules taught in every # introduction to the subject and arithmetic with published formulas. Blood # compatibility in practice is decided by laboratory testing, a body mass # index is a measure for populations that its own namer judged inappropriate # for individual evaluation, and the energy and heart-rate formulas below are # estimates that may deviate significantly from a person's actual value. It # gives no dose, no diagnosis and no treatment. Ask a clinician. # # Two halves that share nothing but the subject. # # STRUCTURE. You supply two kinds of record as facts. # # Transfusions (`body.Transfusion`), each a donor's blood group and a # recipient's, written as "A+", "O-", "AB+" and so on. The rules conclude, for # each of the three antigens A, B and D, whether the donor's red cells carry # it and the recipient's do not; and from that whether red cells of that donor # would react (the A or B antigen is lacked, the ABO rule) and whether they # put the recipient at risk of sensitisation (the D antigen is lacked). A # pair that fails is marked and kept, never dropped: # # import body # body.transfusion("t1", "O-", "AB+", "ward") # body.transfusion("t2", "AB+", "O-", "ward") # print(body.verdict("t1"), body.verdict("t2")) # n = why_not(body.Transfusion.t1.reacts) # # People (`Person`), by their mass in kilograms and height in metres. The # rules conclude the World Health Organization's body-mass bands from the # index, mass over the square of the height: # # body.person("p1", 70.0, 1.75) # print(body.band("p1"), body.bmi("p1")) # # A height of zero draws no index: the computed field has no answer, so no # band is concluded, and `why_not` says the conclusion computes a division # that has none. # # Each conclusion answers `why`, and `why_not` names the premise that failed. # A corrected fact withdraws what rested on it: a group rewritten moves the # verdict. Where two sources disagree about a fact, such as a recipient's # group, both values are reasoned about, both verdicts are held, `unsettled()` # names what they disagree about, and no read refuses but `bmi()`, which reads # one value and refuses a cell holding two. # # CALCULATION. Value scripts that return a value and write nothing: body mass # index, the Mifflin-St Jeor resting energy, the Mosteller body surface area, # and the maximum heart rate by two formulas. # # What the rules and scripts do not cover: plasma and platelet compatibility, # the Kell and other blood systems, a rare group, children's growth, and any # dose. # # Sources, fetched 2026-11-08: # - Karl Landsteiner discovered the ABO system in 1901; group A has A # antigens on the red cells and anti-B antibodies in its serum, group B # the reverse, and group O neither antigen and both antibodies # (https://en.wikipedia.org/wiki/ABO_blood_group_system). # - "Rh positive" and "Rh negative" refer to the Rh(D) antigen only # (https://en.wikipedia.org/wiki/Rh_blood_group_system). # - Type O red cells can be given to any ABO group, AB people can receive # from any group, and O-negative and AB-positive are called universal # donor and recipient, terms the page calls only generally true; a # D-negative patient given D-positive blood may become sensitised to the D # antigen, which puts a female patient at risk for hemolytic disease of the # newborn (https://en.wikipedia.org/wiki/Blood_type). # - Body mass index is the mass in kilograms over the square of the height # in metres. The WHO treats under 18.5 as underweight, 25 or more as # overweight and 30 or more as obese. Ancel Keys named the index in 1972 # and judged it suitable for population studies but inappropriate for # individual evaluation (https://en.wikipedia.org/wiki/Body_mass_index). # - Mifflin-St Jeor, 1990: 10 x mass (kg) + 6.25 x height (cm) - 5 x age # + s, with s +5 for males and -161 for females # (https://en.wikipedia.org/wiki/Basal_metabolic_rate). # - Mosteller, 1987: the body surface area in square metres is the square # root of the height in centimetres times the mass in kilograms over 3600 # (https://en.wikipedia.org/wiki/Body_surface_area). # - Maximum heart rate by Haskell and Fox (1971), 220 minus the age, with a # standard deviation of 12 to 15 beats a minute, and by Tanaka, Monahan and # Seals (2001), 208 minus 0.7 times the age, with about 10; the page says # they may deviate significantly from the actual value # (https://en.wikipedia.org/wiki/Heart_rate). # # Floats are IEEE doubles. `math.sqrt` is correctly rounded, so a result # agrees with CPython's. import math # ── Transfusions as facts ──────────────────────────────────────────────────── class Transfusion: # A donor group given to a recipient group, by a name you choose. Each # group is held as its three antigens, 1 for present and 0 for absent: # `da`, `db`, `dd` for the donor's A, B and D, and `ra`, `rb`, `rd` for the # recipient's; `transfusion()` writes them from the text. The rules # conclude `lacks_a`, `lacks_b` and `lacks_d`, an antigen the donor's red # cells carry and the recipient's do not; `reacts`, red cells that would # react, from a lacked A or B; and `sensitises`, a lacked D, which the # source describes as a risk of sensitisation and not an immediate # reaction. da: int db: int dd: int ra: int rb: int rd: int lacks_a: bool lacks_b: bool lacks_d: bool reacts: bool sensitises: bool @rule def lacks_a(): for t in Transfusion: if t.da > t.ra: t.lacks_a = True @rule def lacks_b(): for t in Transfusion: if t.db > t.rb: t.lacks_b = True @rule def lacks_d(): for t in Transfusion: if t.dd > t.rd: t.lacks_d = True @rule def reacts_for_a(): for t in Transfusion: if t.lacks_a == True: t.reacts = True @rule def reacts_for_b(): for t in Transfusion: if t.lacks_b == True: t.reacts = True @rule def sensitises_for_d(): for t in Transfusion: if t.lacks_d == True: t.sensitises = True # ── People as facts ────────────────────────────────────────────────────────── class Person: # A person, by a name you choose, and `kg` and `metres`. `bmi` is the mass # over the square of the height, divided by the height twice so that it # has a value wherever it is a real, and has no value for a height of zero. # `underweight` is an index under 18.5, `overweight` 25 or more (as the # WHO has it, the obese included) and `obese` 30 or more; the rule # `normal` is 18.5 up to but not including 25. kg: real metres: real bmi: real = kg / metres / metres underweight: bool = bmi < 18.5 overweight: bool = bmi >= 25.0 obese: bool = bmi >= 30.0 normal: bool @rule def normal_weight(): for p in Person: if p.bmi >= 18.5 and p.bmi < 25.0: p.normal = True # ── Writing facts ──────────────────────────────────────────────────────────── def antigens(group: string) -> list: # The A, B and D antigens of a blood group written "A+", "B-", "AB+" or # "O-" as [a, b, d], each 1 or 0. Anything else refuses. n = len(group) if n < 2 or n > 3: raise ValueError("a blood group is written A+, B-, AB+ or O-") sign = group[n - 1:n] kind = group[0:n - 1] if sign != "+" and sign != "-": raise ValueError("a blood group ends in + or -") d = 0 if sign == "+": d = 1 if kind == "A": return [1, 0, d] if kind == "B": return [0, 1, d] if kind == "AB": return [1, 1, d] if kind == "O": return [0, 0, d] raise ValueError("a blood group is A, B, AB or O, then + or -") def transfusion(name: string, donor: string, recipient: string, source: string): # A donor's group given to a recipient's, and the source that says so. # Write it again with another source and another group to hold the rival # value beside the first. a = antigens(donor) b = antigens(recipient) update(Transfusion(name).da, a[0], source) update(Transfusion(name).db, a[1], source) update(Transfusion(name).dd, a[2], source) update(Transfusion(name).ra, b[0], source) update(Transfusion(name).rb, b[1], source) update(Transfusion(name).rd, b[2], source) def person(name: string, kilograms: real, height_metres: real): update(Person(name).kg, kilograms, "people") update(Person(name).metres, height_metres, "people") # ── Reading what the rules concluded ───────────────────────────────────────── # # The readers that return names read every value in force, so a fact two # sources disagree about is reported with both values. `bmi` returns one # number and reads it with `current()`, which refuses a cell holding two # values rather than choose one: a person whose `kg` or `metres` two sources # disagree about has two indexes, and `unsettled()` names the fact. def holds(values: claim_list, name: string) -> bool: for v in values: parts = v.path.split(".") if parts[len(parts) - 2] == name and v.value == True: return True return False def verdict(name: string) -> list: # What the rules conclude for a transfusion: "reacts" where a lacked A or B # antigen makes the red cells react, "sensitises" where a lacked D antigen # risks sensitisation, both, or empty for neither. This is a statement of # a taught rule, not advice. out = [] a = list_values(Transfusion.reacts) b = list_values(Transfusion.sensitises) if holds(a, name): out = out + ["reacts"] if holds(b, name): out = out + ["sensitises"] return out def lacks(name: string) -> list: # The antigens of the donor the recipient lacks, from "A", "B" and "D". out = [] a = list_values(Transfusion.lacks_a) b = list_values(Transfusion.lacks_b) c = list_values(Transfusion.lacks_d) if holds(a, name): out = out + ["A"] if holds(b, name): out = out + ["B"] if holds(c, name): out = out + ["D"] return out def bmi(name: string) -> float: # A person's body mass index; 0.0 where none is drawn, as for a height of # zero or before the facts are written. c = current(Person.name.bmi) match c: case active_claim: match c.value: case real as x: return x case default: pass case empty: pass return 0.0 def band(name: string) -> string: # The narrowest WHO band the rules conclude for a person: "underweight", # "normal", "overweight" or "obese"; "" where no index is drawn. The bands # are for populations and not a judgement of a person. a = list_values(Person.obese) b = list_values(Person.overweight) c = list_values(Person.underweight) d = list_values(Person.normal) if holds(a, name): return "obese" if holds(b, name): return "overweight" if holds(c, name): return "underweight" if holds(d, name): return "normal" return "" def unsettled() -> list: # Each transfusion or person that holds two different values for a fact # you write, as "transfusion x ra" and so on, sorted. out = [] for f in ["da", "db", "dd", "ra", "rb", "rd", "kg", "metres"]: if f == "da": vs = list_values(Transfusion.da) label = "transfusion " elif f == "db": vs = list_values(Transfusion.db) label = "transfusion " elif f == "dd": vs = list_values(Transfusion.dd) label = "transfusion " elif f == "ra": vs = list_values(Transfusion.ra) label = "transfusion " elif f == "rb": vs = list_values(Transfusion.rb) label = "transfusion " elif f == "rd": vs = list_values(Transfusion.rd) label = "transfusion " elif f == "kg": vs = list_values(Person.kg) label = "person " else: vs = list_values(Person.metres) label = "person " seen = {} for v in vs: parts = v.path.split(".") s = label + parts[len(parts) - 2] + " " + parts[len(parts) - 1] x = str(v.value) if s in seen and seen[s] != x and s not in out: out = out + [s] seen[s] = x return sorted(out) # ── Calculation: the body's numbers ────────────────────────────────────────── def body_mass_index(kilograms: float, height_metres: float) -> float: # The mass in kilograms over the square of the height in metres, divided # by the height twice, as `Person.bmi` is, so that it has a value wherever # it is a real. A height of zero refuses with a division by zero. return kilograms / height_metres / height_metres def mifflin_st_jeor(kilograms: float, height_cm: float, age_years: float, male: bool) -> float: # The resting energy in kilocalories a day by Mifflin and St Jeor (1990): # 10 x kg + 6.25 x cm - 5 x age, then +5 for a male and -161 for a female. # It is an estimate for adults. base = 10.0 * kilograms + 6.25 * height_cm - 5.0 * age_years if male: return base + 5.0 return base - 161.0 def body_surface_mosteller(kilograms: float, height_cm: float) -> float: # The body surface in square metres by Mosteller (1987), the square root # of height in centimetres times mass in kilograms over 3600; where that # product passes the largest real or is under 1e-300, near the smallest, # the product of the two roots over 60. p = height_cm * kilograms if math.isinf(p) or (abs(p) < 1e-300 and height_cm != 0.0 and kilograms != 0.0): return math.sqrt(height_cm) * math.sqrt(kilograms) / 60.0 return math.sqrt(p / 3600.0) def max_heart_rate_haskell_fox(age_years: float) -> float: # The maximum heart rate in beats a minute by Haskell and Fox (1971), # 220 minus the age; its standard deviation is 12 to 15. return 220.0 - age_years def max_heart_rate_tanaka(age_years: float) -> float: # The maximum heart rate in beats a minute by Tanaka, Monahan and Seals # (2001), 208 minus 0.7 times the age; its standard deviation is about 10. return 208.0 - 0.7 * age_years