Skip to contents

Suggested methods text

The text below describes the exposure definition of add_lmed_v20261002() for the methods section of an article. Copy it as it stands. The paper adds which approach and which variant it analysed. Every number in it is measured from the function or read from its workbooks when this vignette builds. A hidden chunk runs the function on the cases the prose names: the gap rule, the three-year rule, injected oestrogen, injected progesterone and a clash. It stops the build if the function disagrees. The rules of each approach are checked in sections 4 and 5. The sections after this one give the evidence for each sentence.

The text covers the exposure definition only. For the target trial emulation design, the weights and the outcome model, use the manuscript methods of swereg: vignette("tte-methods", package = "swereg"), Section 2.

Assessment of MHT exposure. MHT exposure was derived from dispensations in the Swedish Prescribed Drug Register (Socialstyrelsen 2024), using the R package mht (version 26.10.5, function add_lmed_v20261002()). Each dispensed product name was classified by a product table shipped with the package into one of 33 categories, defined by drug group, route of administration and progestogen type, or as not MHT. Products classified as not MHT did not contribute exposure.

The duration of each dispensation was the dispensed amount in defined daily doses, read as days of supply. The levonorgestrel intrauterine systems (Jaydess, Kyleena, Levosert, Levosertone, Mirena) and the implant (Jadelle) were given fixed durations of 1680 days, or 1008 days for Jaydess. Sequentially dosed progestogens were converted to whole 28-day treatment months, using a product-specific minimum supply per month. A dispensation that did not reach a product-specific minimum number of months was discarded. The minimum supply and number of months were defined as sufficient for endometrial protection against the proliferative effects of oestrogen, based on Swedish national guidelines for MHT (SFOG 2019) and expert opinion. Dispensations with no date or no positive duration were discarded.

Each dispensation covered the days from its dispensing date to the end of its supply, and was mapped to ISO weeks. A week was exposed to a category if a supply of that category covered at least one of its days. Overlapping supplies of one category were combined, and supply remaining at a refill was not carried forward. Within one treatment level, a gap of up to 4 weeks between two exposed weeks was treated as continued use (Nielsen et al. 2008). A gap of 5 weeks or more ended the treatment episode. A gap between two different levels was not bridged.

Each person-week was assigned one treatment level under five definitions. Approach 1 compared oral or transdermal MHT with no such treatment. Approach 2 classified oral or transdermal oestrogen by route. Approach 3 classified it by the progestogen used with it: none, progesterone or dydrogesterone, or another progestogen. Injected progesterone was classified with the other progestogens. Approach 4 combined the route of approach 2 with the progestogen type of approach 3, but did not count injected progesterone. Approach 5 combined both routes, and separated oestrogen used with a levonorgestrel intrauterine system from oestrogen with another progestogen. In every approach, tibolone was a separate level. A progestogen counted as MHT only in a week with an oral or transdermal oestrogen. Weeks with none of these levels formed the reference level. These include weeks with only local oestrogen, injected oestrogen, a progestogen or an androgen. Under approaches 4 and 5 they also include weeks of oestrogen with injected progesterone. When two levels were in force in the same week, the week was assigned the level started most recently. Levels started in the same week were classified as a separate level of clashing prescriptions, which lasted until the end of that treatment episode.

After a treatment episode ended, the subsequent untreated weeks were classified as former use if the episode had lasted at least 156 weeks (3 years of 52 weeks), and were excluded otherwise. Two variants were derived for each approach. In the single-episode variant, all person-time from a second initiation onward was excluded. In the multiple-episode variant, women who reinitiated MHT re-entered the level they initiated, and the rule above was applied to each episode. Women with any dispensation of a product that the product table flags (possible gender-affirming therapy; possible gender-affirming therapy or IVF; private prescribing without monitoring) were excluded.

References:

The text leaves these to the study, because the function does not do them:

  • The new-user requirement. The function builds no treatment history before a person’s first observed week, and does not remove prevalent users. A supply dispensed before that week reads as starting in it. State the washout, and the register coverage it relies on.
  • Which approach, and which variant. rd_approach<N>_single is the single-episode variant, and rd_approach<N>_multiple is the multiple-episode variant.
  • The exclusion of flagged women. The function sets ri_mht_excluded_product and removes nobody. The sentence on flagged products is true only when the study excludes on that column.
  • Any limit on follow-up after initiation or after stopping. The function sets none.

Overview

add_lmed_v20261002() takes a table of dispensed prescriptions and a person-week skeleton. It writes menopausal hormone therapy (MHT) exposure columns and two person-level exclusion columns onto the skeleton. Exposure here means dispensed supply, assumed used. The function reads the dispensed quantity and, for two strength-keyed rules, the strength. It reads no adherence and no indication. It runs six steps, in this order:

  1. It looks each product name up in a shipped product table. The table gives the product’s MHT category, and whether the study removes its holders.
  2. It turns each prescription into a closed interval of ISO weeks.
  3. It marks each person-week with the categories in force that week.
  4. It resolves the categories into one treatment level per week, under each of five approaches.
  5. It derives the rd_approach* exposure variables from those levels.
  6. It flags every week of a person whose supplied prescriptions include a product the table marks for removal.

The product table is product_table_20261002.xlsx. The duration rules and the approach rules come from the codebook dataDictionary20261002.xlsx. Both workbooks are dated 2026-10-02 and ship with the package. The function reads no other workbook. The tables below are read from the two files when the vignette builds.

Approach 5 is new in this function. It pools the oestrogen routes of approach 4 and gives the hormonal IUD (E1) its own level. Every helper that reads a workbook, directly or through another helper, has a 2026-10-02 copy. The exposure layer also has a copy, because it writes the two rd_approach5 columns. Every other helper is an older dated one, called unchanged. Section 1 states the changes against add_lmed_v20260924(). Section 3 verifies on the fixture that the two functions agree in every column that both write. The vignette of that frozen version, lmed-v20260924.Rmd, is kept in the archive/ folder of the source repository.

The function’s arguments, the columns it owns and the conditions it rejects are in ?add_lmed_v20261002 and ?add_lmed_v20260924.

product_table <- system.file("2023-mht", "product_table_20261002.xlsx", package = "mht")
codebook <- system.file("2023-mht", "dataDictionary20261002.xlsx", package = "mht")

# Every product row of a product table, from every sheet but README.
read_product_rows <- function(path) {
  sheets <- setdiff(readxl::excel_sheets(path), "README")
  return(rbindlist(
    lapply(sheets, function(s) {
      setDT(suppressMessages(readxl::read_excel(path, sheet = s, col_types = "text")))
    }),
    use.names = TRUE,
    fill = TRUE
  ))
}
setdiff(readxl::excel_sheets(product_table), "README")
#> [1] "G"     "other"
ptab <- read_product_rows(product_table)
sheet_atc <- lapply(c(G = "G", other = "other"), function(s) {
  readxl::read_excel(product_table, sheet = s, col_types = "text")$atc
})
lengths(sheet_atc)
#>     G other 
#>   182  2425
stopifnot(
  all(sheet_atc$G %like% "^G0[23]"), !any(sheet_atc$other %like% "^G0[23]"),
  sum(lengths(sheet_atc)) == nrow(ptab)
)
groups <- setDT(readxl::read_excel(codebook, sheet = "MHT_groups", col_types = "text"))
groups <- groups[!is.na(Preparatnamn)]

1. Products and categories

How a product name is looked up

The function reduces the register’s product name to its ASCII letters, then lowercases it. Estradot 25, Estradot® and Estradot all become estradot. It then looks that key up in the product table, on an exact match. Extra digits, spaces and symbols disappear in the reduction, so they still match. Extra letters do not: estradotmatrix would match no key. The table holds one row per raw register name, so several rows can share one key. Rows that share a key MUST agree on the category, on the exclusion flag and on its reason. The function errors where they do not.

key_of(c("Estradot 25", "Estradot®", "Estradot", "Estradot Matrix"))
#> [1] "estradot"       "estradot"       "estradot"       "estradotmatrix"
stopifnot(
  all(key_of(c("Estradot 25", "Estradot®", "Estradot")) == "estradot"),
  "estradot" %chin% ptab$produkt_clean,
  !key_of("Estradot Matrix") %chin% ptab$produkt_clean
)
spellings <- lapply(c("Estradot 25", "Estradot®", "Estradot"), function(p) run_toy(rx(p, 1, 10)))
stopifnot(
  all(spellings[[1]]$A1[1:10]),
  identical(spellings[[1]], spellings[[2]]),
  identical(spellings[[1]], spellings[[3]])
)
msg <- error_of(run_toy(rx("Estradot Matrix", 1, 10)))
msg
#> [1] "1 delivered products have no row in the product table: Estradot Matrix. A product with no row would read as no MHT, so the woman would enter as an unexposed control. Add each one to the table, as a category or as notmht, before the run."
stopifnot(startsWith(msg, "1 delivered products have no row"))

The chunk also runs the function on each spelling. The three runs give the same columns, and Estradot Matrix stops the run.

A product name with no row is an error. The function checks the whole prescription table before it restricts to the people in the skeleton. A product held only by people outside the cohort therefore still needs a row. A product nobody has ruled on would otherwise read as no MHT, and its holder would enter as an unexposed control. The run stops with an error that names up to five such products. This table is frozen. A missing row goes into a new dated table, which a new dated function reads.

The chunk below passes six unknown product names, all held by person 99, who is not in the skeleton. The function stops, names five of the six, and writes no column.

outside <- data.table(
  lopnr = 99L, produkt = paste("Unknown product", 1:6), edatum = monday, fddd = 28, lnmn = NA_character_
)
skel <- toy_skeleton()
msg <- error_of(add_lmed_v20261002(skel, rbind(rx("Divigel", 1, 10), outside), verbose = FALSE))
msg
#> [1] "6 delivered products have no row in the product table: Unknown product 1 | Unknown product 2 | Unknown product 3 | Unknown product 4 | Unknown product 5. A product with no row would read as no MHT, so the woman would enter as an unexposed control. Add each one to the table, as a category or as notmht, before the run."
stopifnot(
  startsWith(msg, "6 delivered products have no row"),
  lengths(regmatches(msg, gregexpr("Unknown product", msg))) == 5L,
  identical(names(skel), c("id", "isoyearweek", "week"))
)

notmht in the table is a recorded decision that the product is not MHT. A prescription of such a product contributes nothing, and no warning names it.

The reader validates four things. A category MUST be one the person-week grid materialises, one the 2026-08-28 classifier returns, or notmht. The exclusion cell MUST read TRUE or FALSE. The key MUST equal the raw name reduced, because the function recomputes it. Rows sharing a key MUST agree, as above. No other cell is checked. The chunk below breaks one row of the table in each of the first three ways, and passes each broken table to the function’s validator. The intact table passes.

validate <- mht:::lmed_validate_product_table_v20260902
i <- which(ptab$classification == "A1")[1]
broken <- list(
  category = copy(ptab)[i, classification := "Al"],
  flag = copy(ptab)[i, exclude_entire_person := "yes"],
  key = copy(ptab)[i, produkt_clean := "estradot"]
)
invalid <- vapply(broken, function(x) error_of(validate(x)), "")
invalid
#>                                                                                                                                                                                                          category 
#> "1 table rows carry a classification that is neither a materialised category nor notmht: Al. A category the person-week grid never materialises reaches no approach rule, so the person would read as untreated." 
#>                                                                                                                                                                                                              flag 
#>                                                                       "the exclude_entire_person column carries YES. Only TRUE and FALSE are read. Any other value would silently keep the person in the cohort." 
#>                                                                                                                                                                                                               key 
#>                                                                                                                                  "1 table rows carry a produkt_clean that is not produkt_raw normalised: Climara"
stopifnot(
  grepl("neither a materialised category nor notmht", invalid[["category"]]),
  grepl("exclude_entire_person column carries YES", invalid[["flag"]]),
  grepl("not produkt_raw normalised", invalid[["key"]]),
  is.na(error_of(validate(copy(ptab))))
)

The categories

The table names 33 categories, each with at least one product. A category is one letter for the drug group and one number for a subgroup: the route, the progestogen or the regimen. The table below lists every product that reaches each category. The meaning column is the product table’s own English translation of the codebook’s group and route cells, shown without the table’s parenthetical notes. The route is the codebook’s. The lookup reads the product name only, and never checks the route dispensed.

legend <- ptab[classification != "notmht",
  .(
    meaning = paste(unique(sub(" \\(.*\\)$", "", classification_meaning)), collapse = " | "),
    products = paste(unique(produkt_raw), collapse = ", ")
  ),
  keyby = .(category = classification)
]
legend <- legend[order(substr(category, 1, 1), as.integer(substring(category, 2)))]
knitr::kable(legend)
category meaning products
A1 oestrogens, transdermal Climara, Climara®, Dermestril, Divigel®, Estradot, Estradot 25, Estradot®, Estramon 100, Estramon 25, Estramon 75, Estrogel, Evopad, Evorel, Evorel 25, Evorel 50, Evorel®, FemSeven, Lenzetto, Oesclim®
A2 oestrogens, oral Femanest®, Progynon®
A3 oestrogens, vaginal, local effect Oestring®, Vagidonna, Vagifem, Vagifem®, Vagirux
A4 oestrogens, vaginal, local effect Blissel, Estrokad, Ovesterin, Ovesterin®, Gynoflor, Intrarosa
A5 oestrogens, oral Oestriol Aspen, Oestriol Imi Pharma®, Oestriol Mylan
A6 oestrogens, oral Climopax Mono, Premarina®, Presomen
A7 oestrogens, injection Delestrogen, Estradiol Valerate, Neofollin
B1 progestogens in combination with oestrogens, transdermal CombiPatch, Estalis, Estalis®, Evorel® Micronor, Estalis Sekvens, Estalis® Sekvens
B2 progestogens in combination with oestrogens, oral Activelle, Activelle®, Cliovelle, Eviana, Femanor®, Kliogest®, Noresmea
B3 progestogens in combination with oestrogens, oral Duova, Indivina, Indivina®, Premelle, Premelle®, Premelle® Sekvens
B4 progestogens in combination with oestrogens, oral Femostonconti
B5 progestogens in combination with oestrogens, oral Climodien, Climodien®, Lafamme
B6 progestogens in combination with oestrogens, oral Angemin®
B7 progestogens in combination with oestrogens, transdermal Sequidot®
B8 progestogens in combination with oestrogens, oral Femasekvens®, Novofem®, Trisekvens, Trisekvens®
B9 progestogens in combination with oestrogens, oral Divina® Plus, Trivina®
B10 progestogens in combination with oestrogens, oral Presomen 28 compositum
B11 progestogens in combination with oestrogens, oral Femoston
B12 progestogens in combination with oestrogens, oral Cyclabil®
C1 progestogens, vaginal progesterone Crinone, Crinone®, Cyclogest, Extempore ATC-kod G03DA04 Progesteron, Gepretix, Lugesteron 100 mg -kapseli, pehmeä, Lugesteron 200 mg -kapseli, pehmeä, Lutinus, Progesteron, Progesteron MIC APL, Progesteron Streuli, Utrogest, Utrogestan, Utrogestan 100mg Capsules
C2 progestogens, intramuscular Prolutex, Prolutex 25 mg Injektionslösung
C3 progestogens, oral Azalia, Ceranor, Cerazette®, Desogestrel Orifarm, Desogestrel STADA, Desogestrel Sandoz, Desogestrel Teva, Gestrina, Velavel, Vinelle, Zarelle, Slinda, Dienogest Besins, Dienosis, Endovelle, Visanne
C4 progestogens, oral Gestapuran®, Provera, Provera®, Primolut®-Nor, Orgametril, Orgametril®
C5 progestogens, oral Duphaston, Duphaston®
D1 hormonal contraceptives for systemic use; progestogens, intramuscular Depo-Progevera, Depo-Provera, Depo-Provera®
D2 hormonal contraceptives for systemic use; progestogens, subcutaneous implant Implanon®, Nexplanon, Nexplanon®
D3 hormonal contraceptives for systemic use; progestogens, subcutaneous implant Jadelle®
E1 intrauterine contraceptives, when used in combination with G03C, IUD, progestogen Jaydess, Kyleena, Levosert, Levosertone, Mirena
F1 tibolone, oral Boltin, Livial, Livial®, Tibelia, Tibocina, Tibolon Aristo, Tibolon Mylan, Tibolon Orifarm
G1 oestrogens with bazedoxifene, oral DUAVIVE
H1 androgens Intrinsa, Nebido, Nebido®, Testavan, Testim, Testogel, Testogel®, Testosteron Depot Panpharma, Testosteron-Depot Jenapharm, Testoviron Depot, Testoviron-Depot-250, Testoviron®-Depot, Tostran, Tostrex, Undestor® Testocaps®
I1 hormonal contraceptives for systemic use; progestogens, oral Mini-Pe®
I2 hormonal contraceptives for systemic use; progestogens, oral Exlutena®
stopifnot(nrow(legend) == 33L, !anyNA(legend$products))

The letter groups the drug. A is oestrogen alone. B is oestrogen with a progestogen in one product. C is a progestogen alone. D, E and I are contraceptive progestogens. F is tibolone, G is a tissue-selective oestrogen complex and H is an androgen. The codebook counts A3, A4 and A5 as local treatment, although A5 is a peroral oestriol. No approach rule treats one of the three as MHT exposure.

Products that are not MHT

The table’s G sheet covers the ATC groups G02 and G03. These are its notmht rows:

ptab[classification == "notmht" & atc %like% "^G0[23]", .(atc, produkt_raw)]
#>         atc                                  produkt_raw
#>      <char>                                       <char>
#>  1: G03AC03                                  Follistrel®
#>  2: G03AC03                                    Norplant®
#>  3: G03BA03                            Andriol Testocaps
#>  4: G03BA03                                       Atmos®
#>  5: G03BA03                                      Striant
#>  6: G03BA03                            Taro-Testosterone
#>  7: G03BA03                   Testosteron Medical Valley
#>  8: G03BA03                         Testosteron Paranova
#>  9: G03BA03                         Testosterone Orifarm
#> 10: G03BA03                            Testosterone Teva
#> 11: G03BB02                                    Andractim
#> 12: G03CA01                          Etinylestradiol APL
#> 13: G03CA57                                     Premarin
#> 14: G03DA02 Extempore ATC-kod G03DA02 Medroxiprogesteron
#> 15: G03DA03                               Proluton Depot
#> 16: G03DA04                                      Amelgen
#> 17: G03XA01                                     Danatrol
#> 18: G03XA01               Danatrol Sanofi-Aventis France
#> 19: G03XA01                                      Danazol
#> 20: G03XA01                                        Danol
#> 21: G03XB01                                      Mifecur
#> 22: G03XB01                                     Mifegyne
#> 23: G03XB01                     Mifepristone Linepharma®
#> 24: G03XB02                                        Esmya
#> 25: G03XB51                                      Medabon
#> 26: G03XC01                                      Evista®
#> 27: G03XC01                                Raloxifen Ebb
#> 28: G03XC01                              Raloxifen STADA
#> 29: G03XC01                              Raloxifene Teva
#>         atc                                  produkt_raw
#>      <char>                                       <char>

The other sheet holds every row whose ATC code lies outside G02 and G03. Every row of it is notmht:

other <- ptab[!atc %like% "^G0[23]"]
nrow(other)
#> [1] 2425
other[, .N, keyby = .(classification, exclude_entire_person)]
#> Key: <classification, exclude_entire_person>
#>    classification exclude_entire_person     N
#>            <char>                <char> <int>
#> 1:         notmht                 FALSE  2425
stopifnot(all(other$classification == "notmht"), all(other$exclude_entire_person == "FALSE"))

What changed against the 2026-09-24 function

The function adds approach 5 and changes nothing else. The product table is a byte copy of product_table_20260924.xlsx. The chunk compares the MD5 checksums of the two files.

table_before <- system.file("2023-mht", "product_table_20260924.xlsx", package = "mht")
unname(tools::md5sum(c(product_table, table_before)))
#> [1] "e98c90bd7c31413d1635718ac6a5f95e" "e98c90bd7c31413d1635718ac6a5f95e"
stopifnot(identical(unname(tools::md5sum(product_table)), unname(tools::md5sum(table_before))))

The codebook is dataDictionary20260924.xlsx with 47 approach 5 rules added to the post_grouping sheet, after the 135 rules of approaches 1 to 4. Every other sheet is unchanged, and so is every rule of approaches 1 to 4. The chunk reads the rules with the function’s own reader.

codebook_before <- system.file("2023-mht", "dataDictionary20260924.xlsx", package = "mht")
stopifnot(identical(readxl::excel_sheets(codebook), readxl::excel_sheets(codebook_before)))
for (s in setdiff(readxl::excel_sheets(codebook), "post_grouping")) {
  stopifnot(identical(
    suppressMessages(readxl::read_excel(codebook, sheet = s, col_types = "text")),
    suppressMessages(readxl::read_excel(codebook_before, sheet = s, col_types = "text"))
  ))
}
rules <- mht:::lmed_read_approach_rules_v20261002()
rules_before <- mht:::lmed_read_approach_rules_v20260924()
approach_of <- vapply(rules, function(r) as.character(r$approach), "")
table(approach_of)
#> approach_of
#>  1  2  3  4  5 
#> 19 19 50 47 47
raw_pg <- function(path) {
  x <- setDT(suppressMessages(readxl::read_excel(path, sheet = "post_grouping", col_types = "text")))
  return(x[!is.na(approach)])
}
pg_now <- raw_pg(codebook)
pg_old <- raw_pg(codebook_before)
stopifnot(
  identical(pg_now[seq_len(nrow(pg_old))], pg_old),
  all(pg_now[-seq_len(nrow(pg_old)), approach] == "5")
)
stopifnot(
  length(rules_before) == 135L,
  length(rules) == 182L,
  sum(approach_of == "5") == 47L,
  identical(rules[approach_of != "5"], rules_before)
)

The function writes three columns that add_lmed_v20260924() does not: approach5, rd_approach5_single and rd_approach5_multiple. Section 3 checks that every other column is identical.

Categories that never change exposure

Three categories can be classified and still move no exposure variable: A7, G1 and H1. A fourth, D4, is a column that no product reaches. A3, A4 and A5 are read only by the inert reference-level rules of section 4, so they move nothing either. Approaches 4 and 5 read no category that approaches 1 to 3 do not already read.

pg <- setDT(suppressMessages(
  readxl::read_excel(codebook, sheet = "post_grouping", col_types = "text")
))
pg <- pg[!is.na(approach)]
ex_cols <- grep("^doesnotinclude", names(pg), value = TRUE)
read_by <- function(x) cat_order(na.omit(unlist(x[, c("includes1", "includes2", ex_cols), with = FALSE])))
read <- read_by(pg)
read
#>  [1] "A1"  "A2"  "A3"  "A4"  "A5"  "A6"  "B1"  "B2"  "B3"  "B4"  "B5"  "B6" 
#> [13] "B7"  "B8"  "B9"  "B10" "B11" "B12" "C1"  "C2"  "C3"  "C4"  "C5"  "D1" 
#> [25] "D2"  "D3"  "E1"  "F1"  "I1"  "I2"
stopifnot(
  identical(read, read_by(pg[!approach %chin% c("4", "5")])),
  all(read_by(pg[approach %chin% c("4", "5")]) %in% read)
)
  • A7 is the injected oestrogens. The grid carries an A7 column, and no approach rule names it. Section 4 shows a week of A7 alone.
  • G1 is Duavive alone, and Duavive counts as no MHT. The table gives it G1, and the person-week grid has no G1 column, so no rule can read it.
  • H1 is the androgens. The grid carries an H1 column, and no approach rule names it.
  • D4 appears nowhere in the codebook. The grid carries a D4 column that is FALSE in every week, and no rule names it.

Products whose holders the study removes

The table flags a product with exclude_entire_person. The flag is separate from the category: a flagged product keeps its category, and its category column still marks each week it covers. Every A7 product is flagged, so every woman with an A7 dispensing is flagged. Section 6 states what the function does with the flag.

ptab[exclude_entire_person == "TRUE", .(produkt_raw, classification, exclusion_reason)]
#>                              produkt_raw classification
#>                                   <char>         <char>
#> 1:                           Delestrogen             A7
#> 2:                    Estradiol Valerate             A7
#> 3:                             Neofollin             A7
#> 4:                             Cyclogest             C1
#> 5: Extempore ATC-kod G03DA04 Progesteron             C1
#> 6:    Lugesteron 100 mg -kapseli, pehmeä             C1
#> 7:    Lugesteron 200 mg -kapseli, pehmeä             C1
#> 8:                              Prolutex             C2
#> 9:       Prolutex 25 mg Injektionslösung             C2
#>                            exclusion_reason
#>                                      <char>
#> 1:        possible gender-affirming therapy
#> 2:        possible gender-affirming therapy
#> 3:        possible gender-affirming therapy
#> 4:   private prescribing without monitoring
#> 5:   private prescribing without monitoring
#> 6:   private prescribing without monitoring
#> 7:   private prescribing without monitoring
#> 8: possible gender-affirming therapy or IVF
#> 9: possible gender-affirming therapy or IVF
stopifnot(all(ptab[classification == "A7", exclude_entire_person] == "TRUE"))

2. From a prescription to an interval

The layer reads five columns of the prescription table: the person identifier, produkt, edatum, fddd, and lnmn where a strength-keyed rule needs it. It reads fddd as a number of days, rounded to a whole day with round(), unless a codebook rule says otherwise.

Four steps run, in this order:

  1. Look the product up, as in section 1.
  2. Overwrite fddd with the codebook’s fixed duration, for the products that carry one.
  3. Drop every prescription with no category, no dispensing date, or no positive duration.
  4. Read the strength from lnmn, and apply the whole-months rules.

Step 2 runs before step 3 because an implant carries no useful dispensed quantity, and the fixed duration supplies one. Step 4 runs after step 3 because a prescription with no duration has no interval at any strength. If step 4 ran first, a missing lnmn on such a row would be an error, although step 3 drops the row.

The duration rules come from the codebook’s MHT_groups sheet, and they are matched by prefix, not by exact key. A rule applies where its codebook name is a prefix of the reduced product name, and the longest such name wins. Utrogestan 100mg Capsules takes the Utrogestan rule. An alias in the duration layer gives Utrogest and Gepretix the Utrogestan rules. Their categories come from the table, where all three are C1. The category lookup and the duration rule are chosen independently, and nothing checks that the rule’s codebook subgroup agrees with the table’s category.

The interval is closed. A prescription dispensed on edatum for d days covers edatum to edatum + d - 1. Both ends convert to an ISO week with cstime::date_to_isoyearweek_c(). Each prescription gets its own interval. Overlapping intervals of one category are unioned, and supply left over at a refill is not carried forward.

The chunk below shows the week boundaries. One day of Divigel on a Sunday marks one week, and two days from that Sunday mark two. A 10-week supply refilled in week 9 for 4 weeks covers weeks 1 to 12, the union of the two intervals. A 10-week supply dispensed 4 weeks before the skeleton starts covers weeks 1 to 6 of it, and the episode starts in week 1.

on_sunday <- function(fddd) data.table(lopnr = 1L, produkt = "Divigel", edatum = monday + 6L, fddd = fddd, lnmn = NA_character_)
one_day <- run_toy(on_sunday(1))
two_days <- run_toy(on_sunday(2))
refill <- run_toy(rbind(rx("Divigel", 1, 10), rx("Divigel", 9, 4)))
early <- run_toy(rx("Divigel", -3, 10))
stopifnot(
  identical(which(one_day$A1), 1L),
  identical(which(two_days$A1), 1:2),
  identical(which(refill$A1), 1:12),
  identical(which(early$A1), 1:6),
  identical(early$approach1[1:7], rep(c("systemic_mht", "local_or_none_mht"), c(6L, 1L)))
)

Fixed durations

knitr::kable(groups[!is.na(FDDD), .(Preparatnamn, Subgrupp, FDDD)])
Preparatnamn Subgrupp FDDD
Jadelle D3 1680
Jaydess E1 1008
Kyleena E1 1680
Levosert E1 1680
Levosertone E1 1680
Mirena E1 1680
stopifnot(nrow(groups[!is.na(FDDD)]) == 6L, groups[Preparatnamn == "Jadelle", FDDD] == "1680")

1680 days is five years of twelve 28-day months, which is 240 weeks. 1008 days is three years of the same months, which is 144 weeks. Jaydess is the one product whose fixed duration differs from the rest of its category. The function reads no removal or replacement date, so a device’s interval runs its full length or ends with follow-up. The chunk dispenses each hormonal IUD (E1) once, with fddd = 1, and counts the weeks its E1 column marks.

iud <- ptab[classification == "E1", unique(produkt_raw)]
iud_days <- groups[Preparatnamn %in% iud, .(produkt = Preparatnamn, days = as.numeric(FDDD))]
iud_days[, weeks := vapply(produkt, function(p) sum(run_toy(rx(p, 1, fddd = 1), 250L)$E1), integer(1))]
iud_days
#>        produkt  days weeks
#>         <char> <num> <int>
#> 1:     Jaydess  1008   144
#> 2:     Kyleena  1680   240
#> 3:    Levosert  1680   240
#> 4: Levosertone  1680   240
#> 5:      Mirena  1680   240
stopifnot(
  setequal(iud_days$produkt, iud),
  all(iud_days$weeks == iud_days$days / 7),
  identical(iud_days[days == 1008, produkt], "Jaydess"),
  setequal(iud_days[days == 1680, produkt], setdiff(iud, "Jaydess"))
)

Whole months for sparse progestogens

A sequential progestogen is taken on some days of each month. Its fddd counts the days of supply, and a sparse supply covers more calendar time than it has days. For each such product the codebook gives minimum_monthly_dose, the days of supply that make one treatment month, and minimum_months, the months a prescription must reach.

knitr::kable(groups[
  !is.na(minimum_monthly_dose),
  .(Preparatnamn, Subgrupp, minimum_monthly_dose, minimum_months, strength_mg_min, strength_mg_max)
])
Preparatnamn Subgrupp minimum_monthly_dose minimum_months strength_mg_min strength_mg_max
Crinone C1 12 1 NA NA
Cyclogest C1 3.33 3 NA NA
Lutinus C1 8 3 NA NA
Utrogestan C1 28 1 NA 150
Utrogestan C1 12 1 150 NA
Progesteron MIC APL C1 3.33 3 NA NA
Desogestrel C3 4 3 NA NA
Cerazette C3 4 3 NA NA
Azalia C3 4 3 NA NA
Gestrina C3 4 3 NA NA
Velavel C3 4 3 NA NA
Vinelle C3 4 3 NA NA
Zarelle C3 4 3 NA NA
Primolut-Nor C4 4.66 3 NA NA
Provera C4 4 3 NA NA
Orgametril C4 4 3 NA NA
Gestapuran C4 4 3 NA NA
Duphaston C5 12 1 NA NA

The rule rewrites fddd in three steps:

  1. months = floor(fddd / minimum_monthly_dose).
  2. If months < minimum_months, the duration is zero and the prescription is dropped.
  3. Otherwise fddd = months * 28.

The function uses a 28-day month, which is four whole weeks. The result rounds down.

Two Utrogestan rules are keyed on strength. strength_mg_min is inclusive and strength_mg_max is exclusive, so the pair NA, 150 and 150, NA covers every strength once. The strength comes from lnmn, the register’s full product name, which carries the dosage form and the strength. The strength is the last number in lnmn followed by mg, with a comma read as a decimal point. A number followed by mg/ is a concentration and does not count. As soon as a strength-keyed product survives step 3, lnmn is required. A surviving row of it with no readable strength is an error.

The chunk below drives the duration layer with one row per case. The layer is an internal function, so the chunk calls it directly, with the product table that the function reads.

u <- groups[Preparatnamn == "Utrogestan"]
u[, .(Preparatnamn, minimum_monthly_dose, strength_mg_min, strength_mg_max)]
#>    Preparatnamn minimum_monthly_dose strength_mg_min strength_mg_max
#>          <char>               <char>          <char>          <char>
#> 1:   Utrogestan                   28            <NA>             150
#> 2:   Utrogestan                   12             150            <NA>
stopifnot(
  nrow(u) == 2L,
  nrow(u[is.na(strength_mg_min) & strength_mg_max == "150"]) == 1L,
  nrow(u[strength_mg_min == "150" & is.na(strength_mg_max)]) == 1L
)
cases <- data.table(
  produkt = c("Divigel", "Cerazette", "Provera", "Provera", "Utrogestan", "Utrogestan", "Utrogestan",
              "Utrogestan 100mg Capsules", "Utrogest", "Gepretix", "Mirena", "Jaydess",
              "Divigel", "Divigel", "Mirena"),
  lnmn = c(NA, NA, NA, NA, "Utrogestan, kapsel, mjuk 100 mg", "Utrogestan, kapsel, mjuk 150 mg",
           "Utrogestan, kapsel, mjuk 200 mg", "Utrogestan, kapsel, mjuk 100 mg",
           "Utrogest, kapsel, mjuk 100 mg", "Gepretix, kapsel, mjuk 100 mg", NA, NA, NA, NA, NA),
  edatum = as.Date("2020-01-06"),
  fddd = c(90, 28, 28, 8, 30, 30, 30, 30, 30, 30, 1, 1, NA, 90, NA)
)
cases[, lopnr := seq_len(.N)]
cases[14, edatum := NA]
warned <- character(0)
intervals <- withCallingHandlers(
  mht:::lmed_durations_v20261002(cases, mht:::lmed_read_product_table_v20261002(), verbose = FALSE),
  warning = function(w) {
    warned <<- c(warned, conditionMessage(w))
    invokeRestart("muffleWarning")
  }
)
intervals[, .(case = lopnr, produkt, lnmn, duration_days, start_isoyearweek, stop_isoyearweek)]
#>      case                   produkt                            lnmn
#>     <int>                    <char>                          <char>
#>  1:     1                   Divigel                            <NA>
#>  2:     2                 Cerazette                            <NA>
#>  3:     3                   Provera                            <NA>
#>  4:     5                Utrogestan Utrogestan, kapsel, mjuk 100 mg
#>  5:     6                Utrogestan Utrogestan, kapsel, mjuk 150 mg
#>  6:     7                Utrogestan Utrogestan, kapsel, mjuk 200 mg
#>  7:     8 Utrogestan 100mg Capsules Utrogestan, kapsel, mjuk 100 mg
#>  8:     9                  Utrogest   Utrogest, kapsel, mjuk 100 mg
#>  9:    10                  Gepretix   Gepretix, kapsel, mjuk 100 mg
#> 10:    11                    Mirena                            <NA>
#> 11:    12                   Jaydess                            <NA>
#> 12:    15                    Mirena                            <NA>
#>     duration_days start_isoyearweek stop_isoyearweek
#>             <num>            <char>           <char>
#>  1:            90           2020-02          2020-14
#>  2:           196           2020-02          2020-29
#>  3:           196           2020-02          2020-29
#>  4:            28           2020-02          2020-05
#>  5:            56           2020-02          2020-09
#>  6:            56           2020-02          2020-09
#>  7:            28           2020-02          2020-05
#>  8:            28           2020-02          2020-05
#>  9:            28           2020-02          2020-05
#> 10:          1680           2020-02          2024-32
#> 11:          1008           2020-02          2022-40
#> 12:          1680           2020-02          2024-32
warned
#> [1] "3 of 15 classified prescriptions dropped: 1 with no duration, 1 with a duration that is no positive number of days, 1 with no dispensing date"
days <- intervals[match(seq_len(nrow(cases)), lopnr), duration_days]
stopifnot(
  identical(as.numeric(days), c(90, 196, 196, NA, 28, 56, 56, 28, 28, 28, 1680, 1008, NA, NA, 1680)),
  identical(warned, paste(
    "3 of 15 classified prescriptions dropped: 1 with no duration,",
    "1 with a duration that is no positive number of days, 1 with no dispensing date"
  ))
)

Divigel has no rule and keeps 90 days. Cerazette and Provera at 28 days of supply reach seven months, which is 196 days. Provera at 8 days of supply reaches two months. The rule needs three, so the prescription is dropped and the warning reports it. Utrogestan 100 mg needs 28 days of supply per month and gets one month. Utrogestan 150 mg and 200 mg take the other rule, which needs 12, and get two. The names Utrogestan 100mg Capsules, Utrogest and Gepretix take the Utrogestan rules. Mirena and Jaydess take their fixed durations regardless of fddd, and so does a Mirena with no fddd. A Divigel with no fddd and a Divigel with no dispensing date are dropped, and the warning counts them.

Dropped prescriptions

A prescription of a notmht product reaches no category and is dropped silently. Every other drop is counted in one aggregate warning, under three causes:

  • no dispensing date
  • no duration
  • a duration that is no positive number of days, which includes a whole-months result of zero

Outside the six fixed-duration products, a missing fddd is never imputed.

3. Person-weeks

The skeleton MUST hold each person’s ISO weeks once each, with no gap between the first and the last. Gap bridging and the three-year rule both count rows, and a row is one elapsed week only under that condition. The function errors otherwise, before it writes anything. The chunk below drops week 5 from a toy skeleton.

gapped <- toy_skeleton()[-5]
error_of(add_lmed_v20261002(gapped, rx("Divigel", 1, 10), verbose = FALSE))
#> [1] "the check fails for 1 of 1 persons. Each person must hold each ISO week once, consecutively. id 1 has 39 rows, 39 distinct weeks, spanning 40 weeks"
stopifnot(
  grepl("id 1 has 39 rows, 39 distinct weeks, spanning 40 weeks",
        error_of(add_lmed_v20261002(gapped, rx("Divigel", 1, 10), verbose = FALSE)), fixed = TRUE),
  identical(names(gapped), c("id", "isoyearweek", "week"))
)

Each of the 33 grid categories gets one logical column. G1 is discarded before this step, and D4 is a column that no product reaches. A week is TRUE for a category where any interval of that category includes at least one day of the week. One day of supply on a Sunday marks that week. The same day plus the Monday marks two weeks. An episode already running at a person’s first week is clipped: it reads as starting in that week. The function counts observed weeks and reads no week before the first one.

The synthetic fixtures show the result. fake_lmed_2026 holds one Utrogestan user, so lnmn is set for those rows first. It also carries one product name, Paracetamol, that has no row in the product table, so the chunk drops that row. The function would stop on it otherwise. Person 18 held only that row, so the chunk gives her one Estradiol Valerate dispensing instead.

skeleton <- copy(fake_skeleton_mht)
lmed <- copy(fake_lmed_2026)
lmed[, lnmn := NA_character_]
lmed[produkt == "Utrogestan", lnmn := UTROGESTAN]
lmed <- lmed[produkt != "Paracetamol"]
lmed <- rbind(lmed, data.table(
  lopnr = 18L,
  produkt = "Estradiol Valerate",
  edatum = as.Date("2016-05-23"),
  fddd = 28,
  lnmn = NA_character_
))
add_lmed_v20261002(skeleton, lmed, verbose = FALSE)
#> Warning: 1 of 128 classified prescriptions dropped: 0 with no duration, 1 with
#> a duration that is no positive number of days, 0 with no dispensing date
categories <- grep("^[A-I][0-9]+$", names(skeleton), value = TRUE)
categories
#>  [1] "A1"  "A2"  "A3"  "A4"  "A5"  "A6"  "A7"  "B1"  "B2"  "B3"  "B4"  "B5" 
#> [13] "B6"  "B7"  "B8"  "B9"  "B10" "B11" "B12" "C1"  "C2"  "C3"  "C4"  "C5" 
#> [25] "D1"  "D2"  "D3"  "D4"  "E1"  "F1"  "H1"  "I1"  "I2"
skeleton[, lapply(.SD, sum), .SDcols = categories][, .SD, .SDcols = function(x) x > 0]
#>       A1    A2    A3    A4    A5    A6    A7    B1    B2    B3    B4    B5
#>    <int> <int> <int> <int> <int> <int> <int> <int> <int> <int> <int> <int>
#> 1:   724   104   127     5     5     4     8     4     5     5     5     5
#>       B6    B7    B8    B9   B11    C1    C3    C4    D2    D3    E1    F1
#>    <int> <int> <int> <int> <int> <int> <int> <int> <int> <int> <int> <int>
#> 1:     4     5     5     4     5    76   178    29     4   240   385     5
#>       H1    I1    I2
#>    <int> <int> <int>
#> 1:     5     5     5

The grid materialises 33 categories. Three of them, A7, D4 and H1, are read by no approach rule:

setdiff(categories, read)
#> [1] "A7" "D4" "H1"
stopifnot(length(categories) == 33L, identical(setdiff(categories, read), c("A7", "D4", "H1")))

Agreement with the 2026-09-24 function

The chunk below runs add_lmed_v20260924() on the same input. The 2026-10-02 function writes the three approach 5 columns in addition. Every other column is identical, for every person.

older <- copy(fake_skeleton_mht)
add_lmed_v20260924(older, lmed, verbose = FALSE)
#> Warning: 1 of 128 classified prescriptions dropped: 0 with no duration, 1 with
#> a duration that is no positive number of days, 0 with no dispensing date
added <- setdiff(names(skeleton), names(older))
added
#> [1] "approach5"             "rd_approach5_single"   "rd_approach5_multiple"
stopifnot(
  identical(added, c("approach5", "rd_approach5_single", "rd_approach5_multiple")),
  identical(setdiff(names(skeleton), added), names(older)),
  identical(skeleton[, names(older), with = FALSE], older)
)

The skeleton must be weekly

swereg::create_skeleton() writes one annual row per person per year before the weekly rows. An annual row holds is_isoyear = TRUE and an isoyearweek that is no ISO week:

id isoyearweek is_isoyear
1 1946-** TRUE
1 2015-02 FALSE

The function stops on the annual row, before it writes anything:

annual <- rbind(data.table(id = 1L, isoyearweek = "1946-**", week = 0L), toy_skeleton())
msg <- error_of(add_lmed_v20261002(annual, rx("Divigel", 1, 10), verbose = FALSE))
msg
#> [1] "skeleton$isoyearweek holds a value that is no ISO week: 1946-**"
stopifnot(grepl("no ISO week: 1946-**", msg, fixed = TRUE), identical(names(annual), c("id", "isoyearweek", "week")))

Pass the weekly rows only, and fill the annual rows in the caller:

weekly <- skeleton[is_isoyear == FALSE]
add_lmed_v20261002(weekly, lmed)

The subset is a new data.table, so the function writes its columns there.

4. From categories to a treatment level

The post_grouping sheet defines five approaches. Each approach is a set of rules. A rule names one treatment variable, the categories a week must carry, and the categories it must not carry. The rule lights its variable in every week that carries all of the first set and none of the second. Several rules can name one variable, and each lights it on its own.

The tables below compress the rules that name at least one category. alone lists the categories that light the variable on their own. pairs lists the combinations that light it, written as oestrogens + partners. Every oestrogen listed pairs with every partner listed. The chunk asserts that the pairs are exactly that cross, with no pair missing and none repeated. excludes lists the categories a week must not carry. Every rule of one variable carries the same excludes, and the chunk asserts that too.

pg[, excludes := apply(.SD, 1, function(r) paste(cat_order(r[!is.na(r)]), collapse = " ")),
  .SDcols = ex_cols]
rule_table <- function(a) {
  x <- pg[approach == a & !is.na(includes1)]
  out <- x[, {
    alone <- includes1[is.na(includes2)]
    p1 <- includes1[!is.na(includes2)]
    p2 <- includes2[!is.na(includes2)]
    seen <- paste(p1, p2)
    cross <- as.vector(outer(unique(p1), unique(p2), paste))
    stopifnot(anyDuplicated(seen) == 0L, setequal(seen, cross))
    stopifnot(length(unique(excludes)) == 1L)
    .(
      rules = .N,
      alone = paste(cat_order(alone), collapse = " "),
      pairs = if (length(p1) == 0L) "" else
        paste(paste(cat_order(p1), collapse = "/"), "+", paste(cat_order(p2), collapse = "/")),
      excludes = paste(unique(excludes[nzchar(excludes)]), collapse = " | ")
    )
  }, keyby = variable]
  return(out[order(variable == "local_or_none_mht", variable)])
}
# The categories that light variable `v` of approach `a` alone, and the two
# sides of its pair rules.
alone_in <- function(a, v) cat_order(pg[approach == a & variable == v & !is.na(includes1) & is.na(includes2), includes1])
pair_in <- function(a, v, col) cat_order(na.omit(pg[approach == a & variable == v & !is.na(includes2)][[col]]))
excludes_in <- function(a, v) unique(pg[approach == a & variable == v & !is.na(includes1), excludes])
# The combination products by route, and the partner categories, from the product table and the grid.
b_route <- unique(ptab[classification %like% "^B", .(category = classification, route)])
stopifnot(!anyDuplicated(b_route$category))
b_oral <- cat_order(b_route[route == "oral", category])
b_patch <- cat_order(b_route[route == "transdermal", category])
partners_all <- setdiff(grep("^[CDEI]", categories, value = TRUE), "D4")
b_oral
#>  [1] "B2"  "B3"  "B4"  "B5"  "B6"  "B8"  "B9"  "B10" "B11" "B12"
b_patch
#> [1] "B1" "B7"
partners_all
#>  [1] "C1" "C2" "C3" "C4" "C5" "D1" "D2" "D3" "E1" "I1" "I2"
stopifnot(setequal(c(b_oral, b_patch), b_route$category), length(b_patch) == 2L, length(b_oral) == 10L)

Approach 1: any systemic MHT

knitr::kable(rule_table("1"))
variable rules alone pairs excludes
systemic_mht 15 A1 A2 A6 B1 B2 B3 B4 B5 B6 B7 B8 B9 B10 B11 B12
tibolone 1 F1
local_or_none_mht 3 A3 A4 A5 A1 A2 A6 B1 B2 B3 B4 B5 B6 B7 B8 B9 B10 B11 B12
stopifnot(
  identical(alone_in("1", "systemic_mht"), cat_order(c("A1", "A2", "A6", b_oral, b_patch))),
  all(is.na(pg[approach == "1", includes2])),
  identical(alone_in("1", "tibolone"), "F1")
)

A1, A2, A6 and every B product are one level, systemic_mht. Tibolone is its own level. No rule of approach 1 reads a progestogen or a contraceptive. A woman on oestrogen with a separate progestogen is systemic_mht. A woman on a progestogen alone is not exposed.

Approach 2: route of the oestrogen

knitr::kable(rule_table("2"))
variable rules alone pairs excludes
peroral_estrogen 12 A2 A6 B2 B3 B4 B5 B6 B8 B9 B10 B11 B12
tibolone 1 F1
transdermal_estrogen 3 A1 B1 B7
local_or_none_mht 3 A3 A4 A5 B1 B2 B3 B4 B5 B6 B7 B8 B9 B10 B11 B12 C1 C2 C3 C4 C5 D1 D2 D3 E1 I1 I2
stopifnot(
  identical(alone_in("2", "transdermal_estrogen"), cat_order(c("A1", b_patch))),
  identical(alone_in("2", "peroral_estrogen"), cat_order(c("A2", "A6", b_oral))),
  all(is.na(pg[approach == "2", includes2])),
  identical(alone_in("2", "tibolone"), "F1")
)

Approach 2 splits systemic oestrogen by route. No rule reads injected oestrogen (A7), so it lights no route. The combined products follow the route of their oestrogen: the two patches, B1 and B7, are transdermal, and the ten tablets are peroral. Tibolone is again its own level.

Approach 3: the progestogen

knitr::kable(rule_table("3"))
variable rules alone pairs excludes
estrogen_only 3 A1 A2 A6 B1 B2 B3 B4 B5 B6 B7 B8 B9 B10 B11 B12 C1 C2 C3 C4 C5 D1 D2 D3 E1 I1 I2
estrogen_progesterone_bioidentical 8 B4 B11 A1/A2/A6 + C1/C5
estrogen_progesterone_synthetic 37 B1 B2 B3 B5 B6 B7 B8 B9 B10 B12 A1/A2/A6 + C2/C3/C4/D1/D2/D3/E1/I1/I2
tibolone 1 F1
bioidentical <- c("B4", "B11")
stopifnot(
  identical(alone_in("3", "estrogen_only"), c("A1", "A2", "A6")),
  identical(excludes_in("3", "estrogen_only"), paste(cat_order(c(b_oral, b_patch, partners_all)), collapse = " ")),
  identical(alone_in("3", "estrogen_progesterone_bioidentical"), bioidentical),
  identical(pair_in("3", "estrogen_progesterone_bioidentical", "includes1"), c("A1", "A2", "A6")),
  identical(pair_in("3", "estrogen_progesterone_bioidentical", "includes2"), c("C1", "C5")),
  identical(alone_in("3", "estrogen_progesterone_synthetic"), setdiff(cat_order(c(b_oral, b_patch)), bioidentical)),
  identical(pair_in("3", "estrogen_progesterone_synthetic", "includes2"), setdiff(partners_all, c("C1", "C5"))),
  identical(alone_in("3", "tibolone"), "F1")
)

Approach 3 splits systemic oestrogen by the progestogen taken with it. estrogen_only is A1, A2 or A6 with no combined product, no progestogen and no contraceptive in the same week. The two estrogen_progesterone levels split the progestogen. The codebook’s bioidentical level holds progesterone (C1), of every route the table gives it, and dydrogesterone (C5, and the combined products B4 and B11). Its synthetic level holds every other progestogen, including injected progesterone (C2), whether in a combined product or dispensed separately. The two names are the codebook’s labels. Tibolone is its own level. A progestogen or a contraceptive alone lights nothing.

Approach 3 also declares local_or_none_mht with an empty rule. That rule lights nothing.

Approach 4: route of the oestrogen by type of progestogen

knitr::kable(rule_table("4"))
variable rules alone pairs excludes
estrogen_only 3 A1 A2 A6 B1 B2 B3 B4 B5 B6 B7 B8 B9 B10 B11 B12 C1 C2 C3 C4 C5 D1 D2 D3 E1 I1 I2
oral_estrogen_other_progestogen 24 B2 B3 B5 B6 B8 B9 B10 B12 A2/A6 + C3/C4/D1/D2/D3/E1/I1/I2
oral_estrogen_progesterone 6 B4 B11 A2/A6 + C1/C5
tibolone 1 F1
transdermal_estrogen_other_progestogen 10 B1 B7 A1 + C3/C4/D1/D2/D3/E1/I1/I2
transdermal_estrogen_progesterone 2 A1 + C1/C5
four <- pg[approach == "4" & !is.na(includes1)]
progesterone <- pair_in("4", "oral_estrogen_progesterone", "includes2")
other_pg <- pair_in("4", "oral_estrogen_other_progestogen", "includes2")
stopifnot(
  identical(progesterone, c("C1", "C5")),
  identical(other_pg, setdiff(partners_all, c("C1", "C2", "C5"))),
  identical(pair_in("4", "transdermal_estrogen_progesterone", "includes2"), progesterone),
  identical(pair_in("4", "transdermal_estrogen_other_progestogen", "includes2"), other_pg),
  identical(pair_in("4", "oral_estrogen_progesterone", "includes1"), c("A2", "A6")),
  identical(pair_in("4", "oral_estrogen_other_progestogen", "includes1"), c("A2", "A6")),
  identical(pair_in("4", "transdermal_estrogen_progesterone", "includes1"), "A1"),
  identical(pair_in("4", "transdermal_estrogen_other_progestogen", "includes1"), "A1"),
  identical(alone_in("4", "oral_estrogen_progesterone"), bioidentical),
  identical(alone_in("4", "oral_estrogen_other_progestogen"), setdiff(b_oral, bioidentical)),
  identical(alone_in("4", "transdermal_estrogen_other_progestogen"), b_patch),
  length(alone_in("4", "transdermal_estrogen_progesterone")) == 0L,
  identical(alone_in("4", "estrogen_only"), c("A1", "A2", "A6")),
  identical(excludes_in("4", "estrogen_only"), excludes_in("3", "estrogen_only")),
  identical(alone_in("4", "tibolone"), "F1"),
  identical(pg[approach == "4" & is.na(includes1), variable], "local_or_none_mht"),
  identical(
    setdiff(cat_order(na.omit(pg[approach == "3", includes2])), c(progesterone, other_pg)),
    "C2"
  )
)

Approach 4 crosses the route of approach 2 with the progestogen of approach 3, with one difference: no approach 4 pair reads injected progesterone (C2). The chunk checks that difference against the approach 3 rules. Its levels:

  • oral_estrogen_progesterone: Femoston (B11) or Femostonconti (B4), the two oral combinations of dydrogesterone and oestrogen. Or an oral oestrogen (A2 or A6) with progesterone (C1) or dydrogesterone (C5).
  • oral_estrogen_other_progestogen: an oral combination product of any other category. Or A2 or A6 with an other progestogen.
  • transdermal_estrogen_progesterone: a transdermal oestrogen (A1) with C1 or C5.
  • transdermal_estrogen_other_progestogen: a transdermal combination product (B1 or B7), or A1 with an other progestogen.
  • estrogen_only: A1, A2 or A6, in a week with no category of the excludes column above.
  • tibolone: tibolone (F1).

Approach 4 declares local_or_none_mht with an empty rule, as approach 3 does. That rule lights nothing.

The progesterone levels read C1 and C5, dispensed separately or in B4 and B11. Every other progestogen that approach 4 pairs with an oestrogen is “other”. The table lists them from the two workbooks: the categories from the rules, and the routes and products from the product table.

knitr::kable(ptab[classification %in% other_pg, .(
  route = paste(unique(route), collapse = ", "),
  meaning = paste(unique(sub(" \\(.*\\)$", "", classification_meaning)), collapse = " | "),
  products = paste(unique(produkt_raw), collapse = ", ")
), keyby = .(category = classification)][match(other_pg, category)])
category route meaning products
C3 oral progestogens, oral Azalia, Ceranor, Cerazette®, Desogestrel Orifarm, Desogestrel STADA, Desogestrel Sandoz, Desogestrel Teva, Gestrina, Velavel, Vinelle, Zarelle, Slinda, Dienogest Besins, Dienosis, Endovelle, Visanne
C4 oral progestogens, oral Gestapuran®, Provera, Provera®, Primolut®-Nor, Orgametril, Orgametril®
D1 injection hormonal contraceptives for systemic use; progestogens, intramuscular Depo-Progevera, Depo-Provera, Depo-Provera®
D2 implant hormonal contraceptives for systemic use; progestogens, subcutaneous implant Implanon®, Nexplanon, Nexplanon®
D3 implant hormonal contraceptives for systemic use; progestogens, subcutaneous implant Jadelle®
E1 iud intrauterine contraceptives, when used in combination with G03C, IUD, progestogen Jaydess, Kyleena, Levosert, Levosertone, Mirena
I1 oral hormonal contraceptives for systemic use; progestogens, oral Mini-Pe®
I2 oral hormonal contraceptives for systemic use; progestogens, oral Exlutena®

Approach 4 reads no rule that needs A3, A4, A5, A7, C2, G1 or H1. A week of these alone is local_or_none_mht. Injected progesterone (C2, Prolutex) is among the exclusions of estrogen_only and in no pair, so A1 with C2 lights no approach 4 level. Approach 3 reads the same week as estrogen_progesterone_synthetic.

The next table lists, for each category that approach 4 reads, its routes, its ATC codes, its products and the levels whose rules name it. The chunk checks the routes and the product categories that the prose above states.

read4 <- cat_order(c(four$includes1, na.omit(four$includes2)))
setdiff(legend$category, read4)
#> [1] "A3" "A4" "A5" "A7" "C2" "G1" "H1"
stopifnot(identical(setdiff(legend$category, read4), c("A3", "A4", "A5", "A7", "C2", "G1", "H1")))
in_rules <- melt(four[, .(variable, includes1, includes2)], id.vars = "variable", na.rm = TRUE)
#> Duplicate column names found in molten data.table. Setting unique names using 'make.names'
in_rules <- in_rules[, .(levels = paste(unique(variable), collapse = ", ")), keyby = .(category = value)]
cat4 <- ptab[classification %in% read4, .(
  route = paste(unique(route), collapse = ", "),
  atc = paste(sort(unique(atc)), collapse = " "),
  products = paste(unique(trimws(gsub("®", "", produkt_raw))), collapse = ", ")
), keyby = .(category = classification)][in_rules, on = "category"][match(read4, category)]
knitr::kable(cat4)
category route atc products levels
A1 transdermal G03CA03 Climara, Dermestril, Divigel, Estradot, Estradot 25, Estramon 100, Estramon 25, Estramon 75, Estrogel, Evopad, Evorel, Evorel 25, Evorel 50, FemSeven, Lenzetto, Oesclim transdermal_estrogen_progesterone, transdermal_estrogen_other_progestogen, estrogen_only
A2 oral G03CA03 Femanest, Progynon oral_estrogen_progesterone, oral_estrogen_other_progestogen, estrogen_only
A6 oral G03CA57 Climopax Mono, Premarina, Presomen oral_estrogen_progesterone, oral_estrogen_other_progestogen, estrogen_only
B1 transdermal G03FA01 G03FB05 CombiPatch, Estalis, Evorel Micronor, Estalis Sekvens transdermal_estrogen_other_progestogen
B2 oral G03FA01 Activelle, Cliovelle, Eviana, Femanor, Kliogest, Noresmea oral_estrogen_other_progestogen
B3 oral G03FA12 G03FB06 Duova, Indivina, Premelle, Premelle Sekvens oral_estrogen_other_progestogen
B4 oral G03FA14 Femostonconti oral_estrogen_progesterone
B5 oral G03FA15 Climodien, Lafamme oral_estrogen_other_progestogen
B6 oral G03FA17 Angemin oral_estrogen_other_progestogen
B7 transdermal G03FB05 Sequidot transdermal_estrogen_other_progestogen
B8 oral G03FB05 Femasekvens, Novofem, Trisekvens oral_estrogen_other_progestogen
B9 oral G03FB06 Divina Plus, Trivina oral_estrogen_other_progestogen
B10 oral G03FB07 Presomen 28 compositum oral_estrogen_other_progestogen
B11 oral G03FB08 Femoston oral_estrogen_progesterone
B12 oral G03FB09 Cyclabil oral_estrogen_other_progestogen
C1 vaginal, other, oral, injection, oral / vaginal G03DA04 Crinone, Cyclogest, Extempore ATC-kod G03DA04 Progesteron, Gepretix, Lugesteron 100 mg -kapseli, pehmeä, Lugesteron 200 mg -kapseli, pehmeä, Lutinus, Progesteron, Progesteron MIC APL, Progesteron Streuli, Utrogest, Utrogestan, Utrogestan 100mg Capsules oral_estrogen_progesterone, transdermal_estrogen_progesterone
C3 oral G03AC09 G03AC10 G03DB08 Azalia, Ceranor, Cerazette, Desogestrel Orifarm, Desogestrel STADA, Desogestrel Sandoz, Desogestrel Teva, Gestrina, Velavel, Vinelle, Zarelle, Slinda, Dienogest Besins, Dienosis, Endovelle, Visanne oral_estrogen_other_progestogen, transdermal_estrogen_other_progestogen
C4 oral G03DA02 G03DC02 G03DC03 Gestapuran, Provera, Primolut-Nor, Orgametril oral_estrogen_other_progestogen, transdermal_estrogen_other_progestogen
C5 oral G03DB01 Duphaston oral_estrogen_progesterone, transdermal_estrogen_progesterone
D1 injection G03AC06 Depo-Progevera, Depo-Provera oral_estrogen_other_progestogen, transdermal_estrogen_other_progestogen
D2 implant G03AC08 Implanon, Nexplanon oral_estrogen_other_progestogen, transdermal_estrogen_other_progestogen
D3 implant G03AC03 Jadelle oral_estrogen_other_progestogen, transdermal_estrogen_other_progestogen
E1 iud G02BA03 Jaydess, Kyleena, Levosert, Levosertone, Mirena oral_estrogen_other_progestogen, transdermal_estrogen_other_progestogen
F1 oral G03CX01 Boltin, Livial, Tibelia, Tibocina, Tibolon Aristo, Tibolon Mylan, Tibolon Orifarm tibolone
I1 oral G03AC01 Mini-Pe oral_estrogen_other_progestogen, transdermal_estrogen_other_progestogen
I2 oral G03AC02 Exlutena oral_estrogen_other_progestogen, transdermal_estrogen_other_progestogen
route_of <- function(cats) unique(ptab[classification %in% cats, route])
cat_of <- function(p) unique(ptab[produkt_clean == key_of(p), classification])
stopifnot(
  nrow(cat4) == 26L, !anyNA(cat4$products),
  identical(route_of(c("A2", "A6", "B2", "B3", "B4", "B5", "B6", "B8", "B9", "B10", "B11", "B12")), "oral"),
  identical(route_of(c("A1", "B1", "B7")), "transdermal"),
  identical(route_of("C2"), "injection"),
  identical(vapply(c("Femoston", "Femostonconti", "Cerazette", "Slinda", "Provera", "Primolut-Nor",
                     "Depo-Provera", "Nexplanon", "Jadelle", "Prolutex"), cat_of, ""),
            c(Femoston = "B11", Femostonconti = "B4", Cerazette = "C3", Slinda = "C3", Provera = "C4",
              `Primolut-Nor` = "C4", `Depo-Provera` = "D1", Nexplanon = "D2", Jadelle = "D3", Prolutex = "C2"))
)

A separate progestogen lights an approach 4 level only in a week with an oestrogen. The chunk dispenses one product of each partner category alone, and then with Progynon (A2). Each supply covers weeks 1 to 8 or more, and the table shows the level in weeks 1 to 8.

partners <- data.table(
  produkt = c("Utrogestan", "Duphaston", "Cerazette", "Slinda", "Provera", "Depo-Provera", "Nexplanon", "Mirena"),
  fddd = c(56, 24, 12, 84, 12, 84, 84, 1),
  lnmn = c(UTROGESTAN, NA, NA, NA, NA, NA, NA, NA)
)
partners[, category := vapply(produkt, cat_of, "")]
stopifnot(all(partners$category %in% c(progesterone, other_pg)))
paired <- partners[, {
  alone <- run_toy(rx(produkt, 1, lnmn = lnmn, fddd = fddd))
  with_a2 <- run_toy(rbind(rx(produkt, 1, lnmn = lnmn, fddd = fddd), rx("Progynon", 1, 8)))
  stopifnot(all(alone[[category]][1:8]))
  .(alone = toString(unique(level_in(alone, 1:8))), with_progynon = toString(unique(level_in(with_a2, 1:8))))
}, by = .(produkt, category)]
knitr::kable(paired)
produkt category alone with_progynon
Utrogestan C1 local_or_none_mht oral_estrogen_progesterone
Duphaston C5 local_or_none_mht oral_estrogen_progesterone
Cerazette C3 local_or_none_mht oral_estrogen_other_progestogen
Slinda C3 local_or_none_mht oral_estrogen_other_progestogen
Provera C4 local_or_none_mht oral_estrogen_other_progestogen
Depo-Provera D1 local_or_none_mht oral_estrogen_other_progestogen
Nexplanon D2 local_or_none_mht oral_estrogen_other_progestogen
Mirena E1 local_or_none_mht oral_estrogen_other_progestogen
stopifnot(
  all(paired$alone == "local_or_none_mht"),
  identical(paired$with_progynon, fifelse(paired$category %in% progesterone,
    "oral_estrogen_progesterone", "oral_estrogen_other_progestogen"))
)

Approach 5: oestrogen with an IUD against another progestogen

Approach 5 pools the two routes of approach 4 and gives the hormonal IUD (E1) its own level. Its 47 rules follow the 135 rules of approaches 1 to 4 in the post_grouping sheet. The table and the checks below read them from dataDictionary20261002.xlsx.

knitr::kable(rule_table("5"))
variable rules alone pairs excludes
estrogen_iud 3 A1/A2/A6 + E1
estrogen_only 3 A1 A2 A6 B1 B2 B3 B4 B5 B6 B7 B8 B9 B10 B11 B12 C1 C2 C3 C4 C5 D1 D2 D3 E1 I1 I2
estrogen_other_progestogen 31 B1 B2 B3 B5 B6 B7 B8 B9 B10 B12 A1/A2/A6 + C3/C4/D1/D2/D3/I1/I2
estrogen_progesterone 8 B4 B11 A1/A2/A6 + C1/C5
tibolone 1 F1
five <- pg[approach == "5"]
oestrogens <- c("A1", "A2", "A6")
stopifnot(
  nrow(five) == 47L,
  identical(alone_in("5", "estrogen_progesterone"), bioidentical),
  identical(pair_in("5", "estrogen_progesterone", "includes1"), oestrogens),
  identical(pair_in("5", "estrogen_progesterone", "includes2"), progesterone),
  identical(alone_in("5", "estrogen_other_progestogen"), setdiff(cat_order(c(b_oral, b_patch)), bioidentical)),
  identical(pair_in("5", "estrogen_other_progestogen", "includes1"), oestrogens),
  identical(pair_in("5", "estrogen_other_progestogen", "includes2"), setdiff(other_pg, "E1")),
  length(alone_in("5", "estrogen_iud")) == 0L,
  identical(pair_in("5", "estrogen_iud", "includes1"), oestrogens),
  identical(pair_in("5", "estrogen_iud", "includes2"), "E1")
)

Its levels, where an oestrogen is A1, A2 or A6:

  • estrogen_progesterone: Femoston (B11) or Femostonconti (B4), or an oestrogen with progesterone (C1) or dydrogesterone (C5).
  • estrogen_other_progestogen: any other combination product, or an oestrogen with an other progestogen of approach 4 that is not E1.
  • estrogen_iud: an oestrogen with a hormonal IUD (E1).
  • estrogen_only, tibolone and local_or_none_mht: the approach 4 rules, copied cell for cell.

The chunk below compares the approach 5 rules with the approach 4 rules. It compares a rule by its includes and excludes cells, and it checks four set equalities:

  1. The estrogen_progesterone rules are the 8 progesterone rules of approach 4, oral and transdermal.
  2. The estrogen_other_progestogen and estrogen_iud rules together are the 34 other-progestogen rules of approach 4.
  3. The estrogen_iud rules are the 3 approach 4 other-progestogen rules that read E1.
  4. No approach 5 rule has A3, A4, A5, A7, C2, G1 or H1 in its includes.
rule_key <- function(x) paste(x$includes1, x$includes2, x$excludes)
rules_of <- function(a, v) rule_key(pg[approach == a & variable %chin% v])
progesterone4 <- c("oral_estrogen_progesterone", "transdermal_estrogen_progesterone")
other4 <- c("oral_estrogen_other_progestogen", "transdermal_estrogen_other_progestogen")
iud4 <- four[variable %chin% other4 & (includes1 %chin% "E1" | includes2 %chin% "E1")]
copied <- c("estrogen_only", "tibolone", "local_or_none_mht")
stopifnot(anyDuplicated(rule_key(five)) == 0L)
# 1
stopifnot(
  setequal(rules_of("5", "estrogen_progesterone"), rules_of("4", progesterone4)),
  length(rules_of("5", "estrogen_progesterone")) == 8L
)
# 2
stopifnot(
  setequal(rules_of("5", c("estrogen_other_progestogen", "estrogen_iud")), rules_of("4", other4)),
  length(rules_of("5", c("estrogen_other_progestogen", "estrogen_iud"))) == 34L
)
# 3
stopifnot(setequal(rules_of("5", "estrogen_iud"), rule_key(iud4)), nrow(iud4) == 3L)
# 4
stopifnot(!any(c("A3", "A4", "A5", "A7", "C2", "G1", "H1") %chin% c(five$includes1, five$includes2)))
# The copied rules
stopifnot(identical(
  five[variable %chin% copied, !"approach"],
  pg[approach == "4" & variable %chin% copied, !"approach"]
))

The products below have these categories in the product table. Both C2 products, Prolutex among them, are flagged for removal, as “Products whose holders the study removes” in section 1 states.

named <- c("Divigel", "Progynon", "Mirena", "Jaydess", "Provera", "Mini-Pe", "Activelle", "Estalis",
           "Utrogestan", "Femoston", "Femostonconti", "Prolutex")
vapply(named, cat_of, "")
#>       Divigel      Progynon        Mirena       Jaydess       Provera 
#>          "A1"          "A2"          "E1"          "E1"          "C4" 
#>       Mini-Pe     Activelle       Estalis    Utrogestan      Femoston 
#>          "I1"          "B2"          "B1"          "C1"         "B11" 
#> Femostonconti      Prolutex 
#>          "B4"          "C2"
stopifnot(
  identical(cat_of("Divigel"), "A1"),
  identical(cat_of("Progynon"), "A2"),
  identical(cat_of("Mirena"), "E1"),
  identical(cat_of("Jaydess"), "E1"),
  identical(cat_of("Provera"), "C4"),
  identical(cat_of("Mini-Pe"), "I1"),
  identical(cat_of("Activelle"), "B2"),
  identical(cat_of("Estalis"), "B1"),
  identical(cat_of("Utrogestan"), "C1"),
  identical(cat_of("Femoston"), "B11"),
  identical(cat_of("Femostonconti"), "B4"),
  identical(cat_of("Prolutex"), "C2"),
  nrow(ptab[classification == "C2"]) == 2L,
  all(ptab[classification == "C2", exclude_entire_person] == "TRUE")
)

The chunk below runs each of these products for weeks 1 to 8. A separate progestogen runs with Divigel. A combination product or an oestrogen runs alone.

a5_cases <- data.table(
  produkt = c("Divigel", "Progynon", "Activelle", "Estalis", "Femoston", "Femostonconti",
              "Utrogestan", "Provera", "Mini-Pe", "Mirena", "Jaydess", "Prolutex"),
  fddd = c(56, 56, 56, 56, 56, 56, 56, 12, 56, 1, 1, 56),
  lnmn = c(rep(NA, 6), UTROGESTAN, rep(NA, 5)),
  with_divigel = rep(c(FALSE, TRUE), c(6L, 6L))
)
a5_levels <- a5_cases[, {
  input <- rx(produkt, 1, lnmn = lnmn, fddd = fddd)
  if (with_divigel) input <- rbind(input, rx("Divigel", 1, 8))
  skel <- suppressWarnings(run_toy(input))
  stopifnot(all(skel[[cat_of(produkt)]][1:8]))
  .(approach5 = toString(unique(level_in(skel, 1:8, "approach5"))))
}, by = .(produkt, category = vapply(produkt, cat_of, ""), with_divigel)]
knitr::kable(a5_levels)
produkt category with_divigel approach5
Divigel A1 FALSE estrogen_only
Progynon A2 FALSE estrogen_only
Activelle B2 FALSE estrogen_other_progestogen
Estalis B1 FALSE estrogen_other_progestogen
Femoston B11 FALSE estrogen_progesterone
Femostonconti B4 FALSE estrogen_progesterone
Utrogestan C1 TRUE estrogen_progesterone
Provera C4 TRUE estrogen_other_progestogen
Mini-Pe I1 TRUE estrogen_other_progestogen
Mirena E1 TRUE estrogen_iud
Jaydess E1 TRUE estrogen_iud
Prolutex C2 TRUE local_or_none_mht
stopifnot(identical(a5_levels$approach5, rep(
  c("estrogen_only", "estrogen_other_progestogen", "estrogen_progesterone", "estrogen_other_progestogen",
    "estrogen_iud", "local_or_none_mht"),
  c(2L, 2L, 3L, 2L, 2L, 1L)
)))

Divigel with Prolutex lights no approach 5 level, as under approach 4. No approach 5 rule has C2 in its includes, and the estrogen_only rule excludes it.

A hormonal IUD dispensed during an oestrogen episode switches the level in the week of the dispensing. The woman below takes Divigel for weeks 1 to 20, and collects Mirena in week 5.

iud_start <- run_toy(rbind(rx("Divigel", 1, 20), rx("Mirena", 5, fddd = 1)))
episodes(iud_start, "approach5")
#>       id  from    to weeks             level
#>    <int> <int> <int> <int>            <char>
#> 1:     1     1     4     4     estrogen_only
#> 2:     1     5    20    16      estrogen_iud
#> 3:     1    21    40    20 local_or_none_mht
episodes(iud_start, "rd_approach5_single")
#>       id  from    to weeks         level
#>    <int> <int> <int> <int>        <char>
#> 1:     1     1     4     4 estrogen_only
#> 2:     1     5    20    16  estrogen_iud
#> 3:     1    21    40    20       exclude
stopifnot(
  identical(which(iud_start$E1), 5:40),
  identical(iud_start$approach5, rep(c("estrogen_only", "estrogen_iud", "local_or_none_mht"), c(4L, 16L, 20L))),
  identical(iud_start$approach4,
            rep(c("estrogen_only", "transdermal_estrogen_other_progestogen", "local_or_none_mht"), c(4L, 16L, 20L))),
  identical(iud_start$rd_approach5_single, rep(c("estrogen_only", "estrogen_iud", "exclude"), c(4L, 16L, 20L)))
)

From week 5 the estrogen_only rule is unlit, because it excludes E1, and estrogen_iud is lit. Approach 4 reads these weeks as transdermal_estrogen_other_progestogen. The move is between two active levels, so it creates no previous week, and the single variant keeps her. Her treated episode ends with the Divigel in week 20, after 20 weeks, so her later weeks are exclude.

The IUD interval is fixed, as section 2 shows: 240 weeks for Mirena and 144 weeks for Jaydess. The woman below collects one IUD in week 1 and takes Divigel for weeks 150 to 157. The chunk runs the case once with Mirena and once with Jaydess.

window <- lapply(c(Mirena = "Mirena", Jaydess = "Jaydess"), function(p) {
  return(run_toy(rbind(rx(p, 1, fddd = 1), rx("Divigel", 150, 8)), 200L))
})
vapply(window, function(skel) toString(unique(level_in(skel, 150:157, "approach5"))), "")
#>          Mirena         Jaydess 
#>  "estrogen_iud" "estrogen_only"
stopifnot(
  identical(which(window$Mirena$E1), 1:200),
  identical(which(window$Jaydess$E1), 1:144),
  all(level_in(window$Mirena, 150:157, "approach5") == "estrogen_iud"),
  all(level_in(window$Jaydess, 150:157, "approach5") == "estrogen_only")
)

An IUD week with an other progestogen lights two levels, estrogen_iud and estrogen_other_progestogen. The level started most recently is reported, as “One level per week: the most recent start wins” below states. The woman below collects Mirena in week 1 and takes Divigel for weeks 1 to 20. She adds Provera in week 9, which covers weeks 9 to 20.

iud_c4 <- run_toy(rbind(rx("Mirena", 1, fddd = 1), rx("Divigel", 1, 20), rx("Provera", 9, fddd = 12)))
episodes(iud_c4, "approach5")
#>       id  from    to weeks                      level
#>    <int> <int> <int> <int>                     <char>
#> 1:     1     1     8     8               estrogen_iud
#> 2:     1     9    20    12 estrogen_other_progestogen
#> 3:     1    21    40    20          local_or_none_mht
stopifnot(
  identical(which(iud_c4$C4), 9:20),
  identical(iud_c4$approach5,
            rep(c("estrogen_iud", "estrogen_other_progestogen", "local_or_none_mht"), c(8L, 12L, 20L)))
)

These properties of approach 5 follow from the code:

  • Each IUD dispensing covers 1680 days, or 1008 days for Jaydess, whatever its fddd. The interval starts on the dispensing date, because the function reads no insertion date.
  • The register shows no removal. A device removed early still counts until its interval ends. An oestrogen in that interval reads as estrogen_iud.
  • A device dispensed during an oestrogen episode moves her to estrogen_iud from that week, as the Divigel and Mirena case shows. The episode continues, with no previous week.
  • The function reads dispensed prescriptions only. An IUD inserted from a clinic’s own stock is not dispensed, so the register holds no row for it. Her oestrogen then reads as if she had no IUD.

The reference level

local_or_none_mht is the value a week takes when no treatment variable is lit. The rules that name it, in every approach, are inert. The resolver never reads the column they light, and it deletes that column with the rest of its working columns. Such a week holds a local oestrogen, an injected oestrogen, a progestogen alone, a contraceptive, an androgen, Duavive, or nothing. Under approaches 4 and 5 it also holds a week of A1, A2 or A6 with injected progesterone (C2).

A week of injected oestrogen alone

The woman below takes one A7 product for 10 weeks, and nothing else. The chunk runs each of the three A7 products in turn. Each time, the A7 column marks the 10 weeks, and every approach reads local_or_none_mht in every week.

a7_alone <- rbindlist(lapply(ptab[classification == "A7", produkt_raw], function(p) {
  skel <- run_toy(rx(p, 1, 10))
  return(data.table(
    produkt = p,
    a7_weeks = sum(skel$A7),
    approach1 = toString(unique(skel$approach1)),
    approach2 = toString(unique(skel$approach2)),
    approach3 = toString(unique(skel$approach3)),
    approach4 = toString(unique(skel$approach4)),
    approach5 = toString(unique(skel$approach5))
  ))
}))
a7_alone
#>               produkt a7_weeks         approach1         approach2
#>                <char>    <int>            <char>            <char>
#> 1:        Delestrogen       10 local_or_none_mht local_or_none_mht
#> 2: Estradiol Valerate       10 local_or_none_mht local_or_none_mht
#> 3:          Neofollin       10 local_or_none_mht local_or_none_mht
#>            approach3         approach4         approach5
#>               <char>            <char>            <char>
#> 1: local_or_none_mht local_or_none_mht local_or_none_mht
#> 2: local_or_none_mht local_or_none_mht local_or_none_mht
#> 3: local_or_none_mht local_or_none_mht local_or_none_mht
stopifnot(
  nrow(a7_alone) == 3L,
  all(a7_alone$a7_weeks == 10L),
  all(unlist(a7_alone[, .(approach1, approach2, approach3, approach4, approach5)]) == "local_or_none_mht")
)

Bridging a gap

Before the resolver compares treatment variables, it bridges short gaps in each of them separately. For one variable, a run of up to four weeks in which it is unlit, between two weeks in which it is lit, becomes lit. A leading or a trailing run is not a gap, so bridging never moves a start or a stop. The category columns are not bridged: bridging happens inside the approach step.

A duration is an estimate from the dispensed quantity, as section 2 shows. A woman who collects her next pack a few weeks late shows a short recorded gap. So does a woman who uses a pack more slowly than the estimate. She may still have been treated in that gap. The function therefore treats a run of up to four weeks as continued treatment. It treats a run of five weeks or more as a stop.

gap4 <- run_toy(rbind(rx("Divigel", 1, 10), rx("Divigel", 15, 6)))
episodes(gap4, "approach1")
#>       id  from    to weeks             level
#>    <int> <int> <int> <int>            <char>
#> 1:     1     1    20    20      systemic_mht
#> 2:     1    21    40    20 local_or_none_mht
gap5 <- run_toy(rbind(rx("Divigel", 1, 10), rx("Divigel", 16, 5)))
episodes(gap5, "approach1")
#>       id  from    to weeks             level
#>    <int> <int> <int> <int>            <char>
#> 1:     1     1    10    10      systemic_mht
#> 2:     1    11    15     5 local_or_none_mht
#> 3:     1    16    20     5      systemic_mht
#> 4:     1    21    40    20 local_or_none_mht
stopifnot(
  !any(gap4$A1[11:14]), all(gap4$approach1[1:20] == "systemic_mht"),
  !any(gap5$A1[11:15]), all(gap5$approach1[11:15] == "local_or_none_mht")
)

A four-week gap is bridged, although the A1 column stays FALSE in those weeks. A five-week gap is not, and the woman holds two episodes.

One level per week: the most recent start wins

A week can light two treatment variables. Concomitance is judged at ISO-week resolution, so two products dispensed on different days of one week count as concomitant. The resolver gives each variable a run length, which counts the weeks since it last turned on, and reports the variable with the shortest run.

The chunk below dispenses Progynon on the Monday and Divigel on the Friday of week 1. The two start in the same ISO week, so approach 2 reads a clash, as described at the end of this section.

friday <- run_toy(rbind(rx("Progynon", 1, 10), data.table(
  lopnr = 1L, produkt = "Divigel", edatum = monday + 4L, fddd = 70, lnmn = NA_character_
)))
episodes(friday, "approach2")
#>       id  from    to weeks                 level
#>    <int> <int> <int> <int>                <char>
#> 1:     1     1    11    11 clashingprescriptions
#> 2:     1    12    40    29     local_or_none_mht
stopifnot(friday$approach2[1] == "clashingprescriptions")

The woman below takes Progynon (A2) for 30 weeks and adds Divigel (A1) from week 11 for 20 weeks.

skel <- run_toy(rbind(rx("Progynon", 1, 30), rx("Divigel", 11, 20)))
episodes(skel, "approach1")
#>       id  from    to weeks             level
#>    <int> <int> <int> <int>            <char>
#> 1:     1     1    30    30      systemic_mht
#> 2:     1    31    40    10 local_or_none_mht
episodes(skel, "approach2")
#>       id  from    to weeks                level
#>    <int> <int> <int> <int>               <char>
#> 1:     1     1    10    10     peroral_estrogen
#> 2:     1    11    30    20 transdermal_estrogen
#> 3:     1    31    40    10    local_or_none_mht
episodes(skel, "approach3")
#>       id  from    to weeks             level
#>    <int> <int> <int> <int>            <char>
#> 1:     1     1    30    30     estrogen_only
#> 2:     1    31    40    10 local_or_none_mht
episodes(skel, "approach4")
#>       id  from    to weeks             level
#>    <int> <int> <int> <int>            <char>
#> 1:     1     1    30    30     estrogen_only
#> 2:     1    31    40    10 local_or_none_mht

Approach 1 sees one level throughout, because A1 and A2 are both systemic_mht. Approach 2 reports peroral_estrogen for ten weeks and then transdermal_estrogen, the more recent start, for twenty. Approaches 3 and 4 see estrogen_only throughout, because approach 4 splits by route only in the presence of a progestogen.

An exclusion works the same way. The woman below takes Divigel for 20 weeks and Utrogestan 100 mg from week 9 for 8 weeks.

skel <- run_toy(rbind(rx("Divigel", 1, 20), rx("Utrogestan", 9, 8, UTROGESTAN)))
episodes(skel, "approach3")
#>       id  from    to weeks                              level
#>    <int> <int> <int> <int>                             <char>
#> 1:     1     1     8     8                      estrogen_only
#> 2:     1     9    16     8 estrogen_progesterone_bioidentical
#> 3:     1    17    20     4                      estrogen_only
#> 4:     1    21    40    20                  local_or_none_mht
episodes(skel, "approach4")
#>       id  from    to weeks                             level
#>    <int> <int> <int> <int>                            <char>
#> 1:     1     1     8     8                     estrogen_only
#> 2:     1     9    16     8 transdermal_estrogen_progesterone
#> 3:     1    17    20     4                     estrogen_only
#> 4:     1    21    40    20                 local_or_none_mht

While C1 is in force, estrogen_only is not lit, because its rule excludes C1. Approach 3 lights estrogen_progesterone_bioidentical and approach 4 lights transdermal_estrogen_progesterone, both by the pair A1 + C1. When the progesterone ends, estrogen_only lights again. Its eight-week gap is over four weeks, so it is not bridged, and the run restarts.

Approach 4 has no separate level for a week that holds both progestogen types. Such a week lights two levels, and the rule above reports the one started most recently. A woman who changes progestogen therefore moves to the new level in the week she starts it, while the old supply still lasts. The woman below takes Progynon for weeks 1 to 20 and Provera (C4) for weeks 1 to 20. She adds Utrogestan 100 mg for weeks 5 to 12.

utro <- run_toy(rbind(
  rx("Progynon", 1, 20),
  rx("Provera", 1, fddd = 20),
  rx("Utrogestan", 5, 8, UTROGESTAN)
))
episodes(utro, "approach4")
#>       id  from    to weeks                           level
#>    <int> <int> <int> <int>                          <char>
#> 1:     1     1     4     4 oral_estrogen_other_progestogen
#> 2:     1     5    12     8      oral_estrogen_progesterone
#> 3:     1    13    20     8 oral_estrogen_other_progestogen
#> 4:     1    21    40    20               local_or_none_mht
stopifnot(
  all(utro[5:12, C1 & C4]),
  all(level_in(utro, 1:4) == "oral_estrogen_other_progestogen"),
  all(level_in(utro, 5:12) == "oral_estrogen_progesterone"),
  all(level_in(utro, 13:20) == "oral_estrogen_other_progestogen"),
  level_in(utro, 21) == "local_or_none_mht"
)

Weeks 5 to 12 light both levels. oral_estrogen_progesterone started in week 5, later than the other level, so it is reported. When the Utrogestan supply ends, only oral_estrogen_other_progestogen is lit, and she returns to it in week 13.

No rule pairs a combination product with a separate progestogen. A progestogen taken after a combination product, with no oestrogen, is not MHT, and it lights nothing. The woman below takes Femoston (B11) for weeks 1 to 12. In week 5 she collects Provera alone, which covers weeks 5 to 16. In the second case she collects Progynon as well, for weeks 5 to 16.

to_provera <- run_toy(rbind(rx("Femoston", 1, 12), rx("Provera", 5, fddd = 12)))
episodes(to_provera, "approach4")
#>       id  from    to weeks                      level
#>    <int> <int> <int> <int>                     <char>
#> 1:     1     1    12    12 oral_estrogen_progesterone
#> 2:     1    13    40    28          local_or_none_mht
to_pair <- run_toy(rbind(rx("Femoston", 1, 12), rx("Progynon", 5, 12), rx("Provera", 5, fddd = 12)))
episodes(to_pair, "approach4")
#>       id  from    to weeks                           level
#>    <int> <int> <int> <int>                          <char>
#> 1:     1     1     4     4      oral_estrogen_progesterone
#> 2:     1     5    16    12 oral_estrogen_other_progestogen
#> 3:     1    17    40    24               local_or_none_mht
stopifnot(
  all(to_provera$C4[5:16]),
  all(level_in(to_provera, 1:12) == "oral_estrogen_progesterone"),
  all(level_in(to_provera, 13:40) == "local_or_none_mht"),
  all(level_in(to_pair, 1:4) == "oral_estrogen_progesterone"),
  all(level_in(to_pair, 5:16) == "oral_estrogen_other_progestogen"),
  level_in(to_pair, 17) == "local_or_none_mht"
)

With Provera alone she stays oral_estrogen_progesterone until the Femoston supply ends in week 12, and is untreated from week 13. With Progynon as well, the pair A2 + C4 starts in week 5, so she moves to oral_estrogen_other_progestogen in week 5, while Femoston still lasts.

A hormonal IUD covers 144 or 240 weeks, as section 2 shows, and it pairs with any A1, A2 or A6 supply in that window. The woman below collects Mirena (E1) in week 1. She takes Divigel for weeks 200 to 207, then again for weeks 245 to 252.

mirena <- run_toy(rbind(rx("Mirena", 1, fddd = 1), rx("Divigel", 200, 8), rx("Divigel", 245, 8)), 260L)
episodes(mirena, "approach4")
#>       id  from    to weeks                                  level
#>    <int> <int> <int> <int>                                 <char>
#> 1:     1     1   199   199                      local_or_none_mht
#> 2:     1   200   207     8 transdermal_estrogen_other_progestogen
#> 3:     1   208   244    37                      local_or_none_mht
#> 4:     1   245   252     8                          estrogen_only
#> 5:     1   253   260     8                      local_or_none_mht
stopifnot(
  all(mirena$E1[1:240]), !any(mirena$E1[241:260]),
  all(level_in(mirena, 200:207) == "transdermal_estrogen_other_progestogen"),
  all(level_in(mirena, 245:252) == "estrogen_only")
)

In weeks 200 to 207 the Mirena supply still lasts, so Divigel reads as transdermal_estrogen_other_progestogen. The Mirena supply ends in week 240, so the second Divigel supply reads as estrogen_only.

Two starts in one week: a clash

Two treatment variables that start in the same week have equal run lengths, and neither wins. The week is marked clashingprescriptions. The mark carries forward to the last treated week of that episode, and no further. The first untreated week after it is an ordinary stop.

skel <- run_toy(rbind(rx("Progynon", 1, 30), rx("Divigel", 1, 20)))
episodes(skel, "approach2")
#>       id  from    to weeks                 level
#>    <int> <int> <int> <int>                <char>
#> 1:     1     1    30    30 clashingprescriptions
#> 2:     1    31    40    10     local_or_none_mht

The clash ends there for two reasons. A clash carried to the end of follow-up would keep a woman who stopped and restarted out of the comparison permanently. A clash ended with the overlap would read her as a new user in the week the overlap ends, and she was treated throughout.

Under approach 4, a woman who starts both progestogen types in one week clashes. The woman below starts Divigel, Utrogestan 100 mg and Provera on the same day, each for weeks 1 to 12.

clash <- run_toy(rbind(rx("Divigel", 1, 12), rx("Utrogestan", 1, 12, UTROGESTAN), rx("Provera", 1, fddd = 12)))
episodes(clash, "approach4")
#>       id  from    to weeks                 level
#>    <int> <int> <int> <int>                <char>
#> 1:     1     1    12    12 clashingprescriptions
#> 2:     1    13    40    28     local_or_none_mht
stopifnot(
  all(level_in(clash, 1:12) == "clashingprescriptions"),
  level_in(clash, 13) == "local_or_none_mht"
)

5. The exposure variables

The exposure layer turns each approach column into two exposure columns, rd_approach<N>_single and rd_approach<N>_multiple, for N from 1 to 5. Each holds the approach’s levels, clashingprescriptions among them, plus two more: previous and exclude. Five steps run per approach and per variant:

  1. Mark the move from an active level to local_or_none_mht as previous. Every later untreated week is also previous.
  2. Find the first week that moves from previous back to an active level. That is the re-initiation.
  3. In the single variant only, set every week from the re-initiation onward to exclude.
  4. Rewrite previous to exclude where the treated episode before it ran under 156 weeks, which is three years of 52.
  5. Write the result.

clashingprescriptions counts as an active level in every step. A woman who leaves a clash for no treatment is a former user like any other. A week of A7 alone is local_or_none_mht, so these steps count it as untreated.

The single variant allows one treated episode in the observed follow-up. Everything from a second start onward leaves the analysis. A start before the first observed week is not seen, so the first observed episode counts as the first. The multiple variant lets her re-enter her new level, and excludes person-time only through step 4.

The steps read only whether a level is active, so they treat every approach alike. The same woman can still differ between approaches. A week that approach 3 counts as treated, such as A1 with C2, is untreated under approaches 4 and 5. An episode can therefore end earlier under those two approaches.

Former user or excluded

Two women in the fixture stop and never restart. Person 1 was treated for 193 weeks, person 2 for 73.

episodes(skeleton[id %in% c(1, 2)], "rd_approach3_single")
#>       id    from      to weeks         level
#>    <int>  <char>  <char> <int>        <char>
#> 1:     1 2015-02 2018-37   193 estrogen_only
#> 2:     1 2018-38 2020-52   119      previous
#> 3:     2 2015-02 2016-21    73 estrogen_only
#> 4:     2 2016-22 2020-52   239       exclude
episodes(skeleton[id %in% c(1, 2)], "rd_approach4_single")
#>       id    from      to weeks         level
#>    <int>  <char>  <char> <int>        <char>
#> 1:     1 2015-02 2018-37   193 estrogen_only
#> 2:     1 2018-38 2020-52   119      previous
#> 3:     2 2015-02 2016-21    73 estrogen_only
#> 4:     2 2016-22 2020-52   239       exclude

Person 1 becomes previous, a former user. Person 2 becomes exclude, because 73 weeks is under 156.

The chunk below tests the cutoff. Each woman starts Divigel in week 5, so weeks 1 to 4 come before her first treatment. One takes it for 155 weeks and the other for 156.

short <- run_toy(rx("Divigel", 5, 155), 200L)
long <- run_toy(rx("Divigel", 5, 156), 200L)
episodes(short, "rd_approach1_single")
#>       id  from    to weeks             level
#>    <int> <int> <int> <int>            <char>
#> 1:     1     1     4     4 local_or_none_mht
#> 2:     1     5   159   155      systemic_mht
#> 3:     1   160   200    41           exclude
episodes(long, "rd_approach1_single")
#>       id  from    to weeks             level
#>    <int> <int> <int> <int>            <char>
#> 1:     1     1     4     4 local_or_none_mht
#> 2:     1     5   160   156      systemic_mht
#> 3:     1   161   200    40          previous
stopifnot(
  all(short$rd_approach1_single[1:4] == "local_or_none_mht"),
  all(short$rd_approach1_single[160:200] == "exclude"),
  all(long$rd_approach1_single[161:200] == "previous")
)

155 weeks is under 156, so the untreated weeks after it are exclude. 156 weeks is not, so they are previous. Every week before a woman’s first treatment stays local_or_none_mht.

Re-initiation, single against multiple

Person 6 was treated for 193 weeks, stopped for 21, and restarted for 50.

episodes(skeleton[id == 6], "rd_approach3_single")
#>       id    from      to weeks         level
#>    <int>  <char>  <char> <int>        <char>
#> 1:     6 2015-02 2018-37   193 estrogen_only
#> 2:     6 2018-38 2019-06    21      previous
#> 3:     6 2019-07 2020-52    98       exclude
episodes(skeleton[id == 6], "rd_approach3_multiple")
#>       id    from      to weeks         level
#>    <int>  <char>  <char> <int>        <char>
#> 1:     6 2015-02 2018-37   193 estrogen_only
#> 2:     6 2018-38 2019-06    21      previous
#> 3:     6 2019-07 2020-04    50 estrogen_only
#> 4:     6 2020-05 2020-52    48       exclude
episodes(skeleton[id == 6], "rd_approach4_multiple")
#>       id    from      to weeks         level
#>    <int>  <char>  <char> <int>        <char>
#> 1:     6 2015-02 2018-37   193 estrogen_only
#> 2:     6 2018-38 2019-06    21      previous
#> 3:     6 2019-07 2020-04    50 estrogen_only
#> 4:     6 2020-05 2020-52    48       exclude

In the single variant, the 21 untreated weeks are previous, because the first episode reached 156 weeks, and every week from the restart is exclude. In the multiple variant she re-enters estrogen_only for 50 weeks. Her final untreated weeks are exclude, because that second episode ran under 156 weeks.

A move to injected oestrogen is a stop

The woman below takes Divigel for 10 weeks, then Neofollin for 10 weeks.

skel <- run_toy(rbind(rx("Divigel", 1, 10), rx("Neofollin", 11, 10)))
episodes(skel, "approach1")
#>       id  from    to weeks             level
#>    <int> <int> <int> <int>            <char>
#> 1:     1     1    10    10      systemic_mht
#> 2:     1    11    40    30 local_or_none_mht
episodes(skel, "rd_approach1_single")
#>       id  from    to weeks        level
#>    <int> <int> <int> <int>       <char>
#> 1:     1     1    10    10 systemic_mht
#> 2:     1    11    40    30      exclude
stopifnot(
  identical(skel$approach1, rep(c("systemic_mht", "local_or_none_mht"), c(10L, 30L))),
  identical(skel$rd_approach1_single, rep(c("systemic_mht", "exclude"), c(10L, 30L))),
  identical(skel$rd_approach4_single, rep(c("estrogen_only", "exclude"), c(10L, 30L)))
)

Her treated episode ends with the Divigel, in week 10. It ran 10 weeks, under 156, so every later week is exclude. Approach 4 gives the same result.

A route switch with a short gap

The four-week bridge of section 4, “Bridging a gap”, fills a gap only within one treatment level. The woman below takes Progynon (A2) and Provera (C4) for weeks 1 to 12. After a 3-week gap she takes Divigel (A1) and Provera for weeks 16 to 40, and then stops. Provera takes the whole-months rule of section 2. Her three Provera prescriptions carry fddd values of 12, 16 and 12, and cover weeks 1 to 12, 16 to 31 and 29 to 40.

route_gap <- run_toy(rbind(
  rx("Progynon", 1, 12), rx("Provera", 1, fddd = 12),
  rx("Divigel", 16, 25), rx("Provera", 16, fddd = 16), rx("Provera", 29, fddd = 12)
), 52L)
episodes(route_gap, "approach2")
#>       id  from    to weeks                level
#>    <int> <int> <int> <int>               <char>
#> 1:     1     1    12    12     peroral_estrogen
#> 2:     1    13    15     3    local_or_none_mht
#> 3:     1    16    40    25 transdermal_estrogen
#> 4:     1    41    52    12    local_or_none_mht
episodes(route_gap, "approach3")
#>       id  from    to weeks                           level
#>    <int> <int> <int> <int>                          <char>
#> 1:     1     1    40    40 estrogen_progesterone_synthetic
#> 2:     1    41    52    12               local_or_none_mht
episodes(route_gap, "approach4")
#>       id  from    to weeks                                  level
#>    <int> <int> <int> <int>                                 <char>
#> 1:     1     1    12    12        oral_estrogen_other_progestogen
#> 2:     1    13    15     3                      local_or_none_mht
#> 3:     1    16    40    25 transdermal_estrogen_other_progestogen
#> 4:     1    41    52    12                      local_or_none_mht
episodes(route_gap, "approach5")
#>       id  from    to weeks                      level
#>    <int> <int> <int> <int>                     <char>
#> 1:     1     1    40    40 estrogen_other_progestogen
#> 2:     1    41    52    12          local_or_none_mht
episodes(route_gap, "rd_approach2_single")
#>       id  from    to weeks            level
#>    <int> <int> <int> <int>           <char>
#> 1:     1     1    12    12 peroral_estrogen
#> 2:     1    13    52    40          exclude
episodes(route_gap, "rd_approach3_single")
#>       id  from    to weeks                           level
#>    <int> <int> <int> <int>                          <char>
#> 1:     1     1    40    40 estrogen_progesterone_synthetic
#> 2:     1    41    52    12                         exclude
episodes(route_gap, "rd_approach4_single")
#>       id  from    to weeks                           level
#>    <int> <int> <int> <int>                          <char>
#> 1:     1     1    12    12 oral_estrogen_other_progestogen
#> 2:     1    13    52    40                         exclude
episodes(route_gap, "rd_approach4_multiple")
#>       id  from    to weeks                                  level
#>    <int> <int> <int> <int>                                 <char>
#> 1:     1     1    12    12        oral_estrogen_other_progestogen
#> 2:     1    13    15     3                                exclude
#> 3:     1    16    40    25 transdermal_estrogen_other_progestogen
#> 4:     1    41    52    12                                exclude
runs <- function(levels, n) rep(levels, n)
oral4 <- "oral_estrogen_other_progestogen"
td4 <- "transdermal_estrogen_other_progestogen"
none <- "local_or_none_mht"
provera_weeks <- function(start_week, fddd) which(run_toy(rx("Provera", start_week, fddd = fddd), 52L)$C4)
stopifnot(
  identical(provera_weeks(1, 12), 1:12),
  identical(provera_weeks(16, 16), 16:31),
  identical(provera_weeks(29, 12), 29:40),
  identical(which(route_gap$A2), 1:12),
  identical(which(route_gap$A1), 16:40),
  identical(which(route_gap$C4), c(1:12, 16:40)),
  identical(route_gap$approach3, runs(c("estrogen_progesterone_synthetic", none), c(40L, 12L))),
  identical(route_gap$approach2, runs(c("peroral_estrogen", none, "transdermal_estrogen", none), c(12L, 3L, 25L, 12L))),
  identical(route_gap$approach4, runs(c(oral4, none, td4, none), c(12L, 3L, 25L, 12L))),
  identical(route_gap$approach5, runs(c("estrogen_other_progestogen", none), c(40L, 12L))),
  identical(route_gap$rd_approach3_single, runs(c("estrogen_progesterone_synthetic", "exclude"), c(40L, 12L))),
  identical(route_gap$rd_approach2_single, runs(c("peroral_estrogen", "exclude"), c(12L, 40L))),
  identical(route_gap$rd_approach4_single, runs(c(oral4, "exclude"), c(12L, 40L))),
  identical(route_gap$rd_approach2_multiple, runs(c("peroral_estrogen", "exclude", "transdermal_estrogen", "exclude"), c(12L, 3L, 25L, 12L))),
  identical(route_gap$rd_approach4_multiple, runs(c(oral4, "exclude", td4, "exclude"), c(12L, 3L, 25L, 12L)))
)

Approach 3 puts oral and transdermal oestrogen with a synthetic progestogen in one level, estrogen_progesterone_synthetic. The gap in weeks 13 to 15 lies within that level, so the bridge fills it. One episode runs from week 1 to week 40. Approach 5 pools the routes too, and its level estrogen_other_progestogen runs from week 1 to week 40.

Approaches 2 and 4 split by route, so each route has its own level. The oral level stops in week 12 and the transdermal level starts in week 16. Neither level has a gap, so the bridge fills nothing, and weeks 13 to 15 are untreated. The oral episode ran 12 weeks, under 156, so weeks 13 to 15 are exclude. After an episode of 156 weeks or more they would be previous, as in “Former user or excluded”. In the single variant every week from the restart in week 16 is exclude. In the multiple variant she re-enters at the transdermal level in week 16, and stays there to week 40. Her weeks 41 to 52 are exclude in every variant shown, because no episode reached 156 weeks.

This is the rule as implemented, and it is the same in approach 2 and in approach 4. Whether a short gap across routes ends an episode is a methods choice for the analysis. The function does not make that choice.

The woman below makes the same switch after a longer first episode. She takes Progynon and Provera for weeks 1 to 160. After a 3-week gap she takes Divigel and Provera for weeks 164 to 187, and then stops. Her first episode holds four Provera prescriptions, each with fddd = 40. They cover weeks 1 to 40, 41 to 80, 81 to 120 and 121 to 160. Her Provera prescription in week 164 carries fddd = 24 and covers weeks 164 to 187.

long_switch <- function(gap) {
  restart <- 161L + gap
  return(run_toy(rbind(
    rx("Progynon", 1, 160),
    rx("Provera", 1, fddd = 40), rx("Provera", 41, fddd = 40),
    rx("Provera", 81, fddd = 40), rx("Provera", 121, fddd = 40),
    rx("Divigel", restart, 24), rx("Provera", restart, fddd = 24)
  ), 220L))
}
long3 <- long_switch(3L)
episodes(long3, "approach3")
#>       id  from    to weeks                           level
#>    <int> <int> <int> <int>                          <char>
#> 1:     1     1   187   187 estrogen_progesterone_synthetic
#> 2:     1   188   220    33               local_or_none_mht
episodes(long3, "approach4")
#>       id  from    to weeks                                  level
#>    <int> <int> <int> <int>                                 <char>
#> 1:     1     1   160   160        oral_estrogen_other_progestogen
#> 2:     1   161   163     3                      local_or_none_mht
#> 3:     1   164   187    24 transdermal_estrogen_other_progestogen
#> 4:     1   188   220    33                      local_or_none_mht
episodes(long3, "rd_approach4_single")
#>       id  from    to weeks                           level
#>    <int> <int> <int> <int>                          <char>
#> 1:     1     1   160   160 oral_estrogen_other_progestogen
#> 2:     1   161   163     3                        previous
#> 3:     1   164   220    57                         exclude
episodes(long3, "rd_approach4_multiple")
#>       id  from    to weeks                                  level
#>    <int> <int> <int> <int>                                 <char>
#> 1:     1     1   160   160        oral_estrogen_other_progestogen
#> 2:     1   161   163     3                               previous
#> 3:     1   164   187    24 transdermal_estrogen_other_progestogen
#> 4:     1   188   220    33                                exclude
syn3 <- "estrogen_progesterone_synthetic"
provera_weeks_220 <- function(start_week, fddd) which(run_toy(rx("Provera", start_week, fddd = fddd), 220L)$C4)
stopifnot(
  identical(provera_weeks_220(1, 40), 1:40),
  identical(provera_weeks_220(41, 40), 41:80),
  identical(provera_weeks_220(81, 40), 81:120),
  identical(provera_weeks_220(121, 40), 121:160),
  identical(provera_weeks_220(164, 24), 164:187),
  identical(which(long3$A2), 1:160),
  identical(which(long3$A1), 164:187),
  identical(which(long3$C4), c(1:160, 164:187)),
  identical(long3$approach4, runs(c(oral4, none, td4, none), c(160L, 3L, 24L, 33L))),
  identical(long3$approach2, runs(c("peroral_estrogen", none, "transdermal_estrogen", none), c(160L, 3L, 24L, 33L))),
  identical(long3$approach3, runs(c(syn3, none), c(187L, 33L))),
  identical(long3$rd_approach3_single, runs(c(syn3, "previous"), c(187L, 33L))),
  identical(long3$rd_approach4_single, runs(c(oral4, "previous", "exclude"), c(160L, 3L, 57L))),
  identical(long3$rd_approach2_single, runs(c("peroral_estrogen", "previous", "exclude"), c(160L, 3L, 57L))),
  identical(long3$rd_approach4_multiple, runs(c(oral4, "previous", td4, "exclude"), c(160L, 3L, 24L, 33L))),
  identical(long3$rd_approach2_multiple, runs(c("peroral_estrogen", "previous", "transdermal_estrogen", "exclude"), c(160L, 3L, 24L, 33L)))
)

Approaches 2 and 4 again do not bridge the gap, so weeks 161 to 163 are untreated. These weeks are previous, a former user, because her first episode reached 156 weeks. In the single variant every week from the restart in week 164 is exclude. In the multiple variant she re-enters at the transdermal level for weeks 164 to 187. Her weeks 188 to 220 are exclude, because the second episode ran 24 weeks, under 156.

Approach 3 bridges the gap, and one episode runs from week 1 to week 187. That episode reached 156 weeks, so her weeks 188 to 220 are previous.

The table below states what an untreated run between two treated weeks becomes. The gap-table chunk under it checks each row.

Gap Within one level Across levels (route switch in approach 2 or 4)
up to 4 weeks bridged, no gap untreated: previous after an episode of 156+ weeks, else exclude
5 weeks or more untreated, as across levels untreated, as for a gap of up to 4 weeks

After an untreated gap, the single variant is exclude from the restart. The multiple variant re-enters at the new level. A switch with no gap, where the two supplies overlap, moves her straight to the new level with no untreated week. The switch chunk of “One level per week: the most recent start wins” shows that case.

# Row "up to 4 weeks", within one level: bridged. Chunks `bridge` (gap4), `route-gap` and `route-gap-long` (approach 3).
stopifnot(all(gap4$approach1[11:14] == "systemic_mht"), all(long3$approach3[161:163] == syn3))
# Row "up to 4 weeks", across levels: `exclude` after 12 weeks (`route-gap`), `previous` after 160 weeks (`route-gap-long`).
stopifnot(
  all(route_gap$rd_approach4_multiple[13:15] == "exclude"),
  all(long3$rd_approach4_multiple[161:163] == "previous")
)
# Row "5 weeks or more", within one level: untreated. Chunk `bridge` (gap5, a 10-week episode, so `exclude`),
# and the long case with a 5-week gap under approach 3 (a 160-week episode, so `previous`).
long5 <- long_switch(5L)
stopifnot(
  identical(provera_weeks_220(166, 24), 166:189),
  all(gap5$rd_approach1_single[11:15] == "exclude"),
  identical(long5$approach3, runs(c(syn3, none, syn3, none), c(160L, 5L, 24L, 31L))),
  identical(long5$rd_approach3_multiple, runs(c(syn3, "previous", syn3, "exclude"), c(160L, 5L, 24L, 31L)))
)
# Row "5 weeks or more", across levels: untreated. The short case with a 5-week gap (`exclude`),
# and the long case with a 5-week gap under approach 4 (`previous`).
short5 <- run_toy(rbind(
  rx("Progynon", 1, 12), rx("Provera", 1, fddd = 12),
  rx("Divigel", 18, 12), rx("Provera", 18, fddd = 12)
), 52L)
stopifnot(
  identical(short5$rd_approach4_multiple, runs(c(oral4, "exclude", td4, "exclude"), c(12L, 5L, 12L, 23L))),
  identical(long5$rd_approach4_multiple, runs(c(oral4, "previous", td4, "exclude"), c(160L, 5L, 24L, 31L)))
)
# No gap: the switch moves her straight to the new level (chunk `switch`, Progynon 1 to 30, Divigel from 11).
overlap <- run_toy(rbind(rx("Progynon", 1, 30), rx("Divigel", 11, 20)))
stopifnot(identical(
  overlap$rd_approach2_multiple,
  runs(c("peroral_estrogen", "transdermal_estrogen", "exclude"), c(10L, 20L, 10L))
))

Approach 3b

rd_approach3b_single and rd_approach3b_multiple are the approach 3 columns with the two estrogen_progesterone levels collapsed into one. A switch between active levels never creates previous, so the relabel gives what a full second pass would give. Approaches 4 and 5 have no such collapsed variant.

episodes(skeleton[id == 16], "rd_approach3_single")
#>       id    from      to weeks                           level
#>    <int>  <char>  <char> <int>                          <char>
#> 1:    16 2015-02 2016-21    73 estrogen_progesterone_synthetic
#> 2:    16 2016-22 2020-52   239                         exclude
episodes(skeleton[id == 16], "rd_approach3b_single")
#>       id    from      to weeks                 level
#>    <int>  <char>  <char> <int>                <char>
#> 1:    16 2015-02 2016-21    73 estrogen_progesterone
#> 2:    16 2016-22 2020-52   239               exclude

The chunk below checks that claim. A woman takes Femoston (B11, the bioidentical level) for 10 weeks, then Cyclabil (B12, the synthetic level) for 10 weeks. The chunk collapses her approach3 column, and every fixture person’s, into the approach3b level, and runs the exposure layer again on the result. Its rd_approach3 columns equal the rd_approach3b columns the function wrote.

switcher <- run_toy(rbind(rx("Femoston", 1, 10), rx("Cyclabil", 11, 10)))
episodes(switcher, "rd_approach3_single")
#>       id  from    to weeks                              level
#>    <int> <int> <int> <int>                             <char>
#> 1:     1     1    10    10 estrogen_progesterone_bioidentical
#> 2:     1    11    20    10    estrogen_progesterone_synthetic
#> 3:     1    21    40    20                            exclude
episodes(switcher, "rd_approach3b_single")
#>       id  from    to weeks                 level
#>    <int> <int> <int> <int>                <char>
#> 1:     1     1    20    20 estrogen_progesterone
#> 2:     1    21    40    20               exclude
second_pass <- function(skel) {
  collapsed <- setdiff(unique(skel$rd_approach3b_single), unique(skel$rd_approach3_single))
  x <- copy(skel)
  x[approach3 %like% "^estrogen_progesterone", approach3 := collapsed]
  mht:::create_exposure_variables_v20261002(x)
  return(identical(x$rd_approach3_single, skel$rd_approach3b_single) &&
    identical(x$rd_approach3_multiple, skel$rd_approach3b_multiple))
}
stopifnot(
  identical(unique(switcher$approach3[1:20]), c("estrogen_progesterone_bioidentical", "estrogen_progesterone_synthetic")),
  second_pass(switcher),
  second_pass(skeleton)
)

6. The exclusion flags

ri_mht_excluded_product is TRUE in every week of a person whose supplied prescriptions include a flagged product, and FALSE otherwise. ri_mht_excluded_reason carries the table’s reason, or NA. Where one person holds products flagged for different reasons, the reasons join in sorted order, separated by ;.

The flag is set from holding the product. It does not depend on that prescription having a usable duration or a dispensing date, and it does not depend on the week. A flagged prescription dispensed before or after follow-up also flags every observed week. The prescriptions the caller passes therefore define the window of ascertainment. The ri_ prefix means person level and time invariant.

The function removes nobody. A flagged woman keeps every exposure column, computed as for anyone else. A study removes her only through an exclusion that it names, and that exclusion reads ri_mht_excluded_product.

skeleton[, .N, keyby = .(ri_mht_excluded_product, ri_mht_excluded_reason)]
#> Key: <ri_mht_excluded_product, ri_mht_excluded_reason>
#>    ri_mht_excluded_product            ri_mht_excluded_reason     N
#>                     <lgcl>                            <char> <int>
#> 1:                   FALSE                              <NA>  4992
#> 2:                    TRUE possible gender-affirming therapy   624
flagged <- skeleton[ri_mht_excluded_product == TRUE, unique(id)]
lmed[lopnr %in% flagged, .(lopnr, produkt, edatum, fddd)]
#>    lopnr            produkt     edatum  fddd
#>    <int>             <char>     <Date> <num>
#> 1:     8            Divigel 2015-01-05    28
#> 2:     8           Progynon 2015-03-31    28
#> 3:     8            Vagifem 2015-06-20    28
#> 4:     8            Blissel 2015-09-11    28
#> 5:     8      Oestriolaspen 2015-12-08    28
#> 6:     8          Premarina 2016-02-29    28
#> 7:     8          Neofollin 2016-05-23    28
#> 8:    18 Estradiol Valerate 2016-05-23    28
episodes(skeleton[id %in% flagged], "approach1")
#>       id    from      to weeks             level
#>    <int>  <char>  <char> <int>            <char>
#> 1:     8 2015-02 2015-05     4      systemic_mht
#> 2:     8 2015-06 2015-13     8 local_or_none_mht
#> 3:     8 2015-14 2015-18     5      systemic_mht
#> 4:     8 2015-19 2016-08    43 local_or_none_mht
#> 5:     8 2016-09 2016-12     4      systemic_mht
#> 6:     8 2016-13 2020-52   248 local_or_none_mht
#> 7:    18 2015-02 2020-52   312 local_or_none_mht
stopifnot(
  identical(flagged, c(8L, 18L)),
  nrow(skeleton[id %in% flagged]) == 2L * 312L,
  skeleton[id %in% flagged, all(ri_mht_excluded_product)],
  skeleton[id %in% flagged, all(ri_mht_excluded_reason == "possible gender-affirming therapy")],
  skeleton[id == 8L, any(approach1 == "systemic_mht")],
  all(unlist(skeleton[id == 18L, .(approach1, approach2, approach3, approach4, approach5)]) == "local_or_none_mht")
)

Person 8 holds one Neofollin prescription among six others. Person 18 holds one Estradiol Valerate prescription and nothing else. Every one of their 312 weeks is flagged. Person 8’s approach columns still show her other products. Person 18 is local_or_none_mht in every week of every approach.

The chunk below covers the other cases. The first woman holds Neofollin and Prolutex, which the table flags for different reasons. The second holds one Neofollin prescription with no date and no quantity. The third holds one dispensed in week 100, after her 40 observed weeks.

reason_of <- function(p) ptab[produkt_clean == key_of(p), unique(exclusion_reason)]
two <- run_toy(rbind(rx("Neofollin", 1, 4), rx("Prolutex", 1, 4)))
undated <- suppressWarnings(run_toy(data.table(
  lopnr = 1L, produkt = "Neofollin", edatum = as.Date(NA), fddd = NA_real_, lnmn = NA_character_
)))
late <- run_toy(rx("Neofollin", 100, 4))
unique(two$ri_mht_excluded_reason)
#> [1] "possible gender-affirming therapy; possible gender-affirming therapy or IVF"
stopifnot(
  identical(unique(two$ri_mht_excluded_reason), paste(sort(c(reason_of("Neofollin"), reason_of("Prolutex"))), collapse = "; ")),
  all(undated$ri_mht_excluded_product), !any(undated$A7),
  all(late$ri_mht_excluded_product), !any(late$A7)
)

No approach rule reads A7, so a study that keeps a flagged woman sees no exposure from her A7 products. Her exposure columns equal those that the function writes when the input holds no A7 row at all. The chunk removes every A7 row, runs the function again, and compares the two results.

no_a7 <- copy(fake_skeleton_mht)
suppressWarnings(add_lmed_v20261002(
  no_a7,
  lmed[!key_of(produkt) %chin% ptab[classification == "A7", produkt_clean]],
  verbose = FALSE
))
exposure <- grep("^(rd_)?approach", names(skeleton), value = TRUE)
exposure
#>  [1] "approach1"              "approach2"              "approach3"             
#>  [4] "approach4"              "approach5"              "rd_approach1_single"   
#>  [7] "rd_approach1_multiple"  "rd_approach2_single"    "rd_approach2_multiple" 
#> [10] "rd_approach3_single"    "rd_approach3_multiple"  "rd_approach4_single"   
#> [13] "rd_approach4_multiple"  "rd_approach5_single"    "rd_approach5_multiple" 
#> [16] "rd_approach3b_single"   "rd_approach3b_multiple"
stopifnot(length(exposure) == 17L, identical(skeleton[, ..exposure], no_a7[, ..exposure]))

7. What the function does not do

The chunk below records every readxl::read_excel() call during one run of the function. It also lists the function’s arguments: none of them carries a diagnosis or a covariate.

calls <- new.env()
calls$seen <- character(0)
trace("read_excel", where = asNamespace("readxl"), print = FALSE, tracer = bquote(
  assign("seen", c(get("seen", envir = .(calls)), paste(basename(path), sheet)), envir = .(calls))
))
#> Tracing function "read_excel" in package "namespace:readxl"
#> [1] "read_excel"
invisible(run_toy(rx("Divigel", 1, 10)))
untrace("read_excel", where = asNamespace("readxl"))
#> Untracing function "read_excel" in package "namespace:readxl"
sort(unique(calls$seen))
#> [1] "dataDictionary20261002.xlsx MHT_groups"   
#> [2] "dataDictionary20261002.xlsx post_grouping"
#> [3] "product_table_20261002.xlsx G"            
#> [4] "product_table_20261002.xlsx other"
names(formals(add_lmed_v20261002))
#> [1] "skeleton"  "lmed"      "id_name"   "create_rd" "verbose"
stopifnot(setequal(unique(calls$seen), c(
  "dataDictionary20261002.xlsx MHT_groups", "dataDictionary20261002.xlsx post_grouping",
  "product_table_20261002.xlsx G", "product_table_20261002.xlsx other"
)))
  • It removes no person. The exclusion flag is the caller’s to act on.
  • It reads no diagnosis and no covariate. The codebook’s diagnosis-based exclusion is not applied. The table instead flags products whose holders the study removes.
  • It builds no history before a woman’s first observed week. A prescription dispensed before that week still covers the weeks of it inside follow-up, and the episode reads as starting in the first observed week.
  • It imputes no duration, outside the six fixed-duration products. Any other prescription with no usable fddd contributes nothing.
  • It gives approach 4 no separate level for a week that holds both progestogen types. The level started most recently is reported, as in section 4.
  • It gives approach 5 no separate level for a week that holds E1 and another progestogen. The level started most recently is reported, as in section 4.
  • It does not read the Rules, FDDD or covariates sheets of the codebook, or the README sheet of the product table.