Skip to main content
← Back to Software

pygrid

Time-Varying Data & Registries

Person-period denominator grids and zero-filled event attachment

. net install pygrid, from(...)
View on GitHub →

Version 1.0.0 | 2026-08-12

Stata 16+ MIT License Status

pygrid turns inclusive person-level observation windows into exact person-period denominators. Its companion command pyattach attaches event counts, sums, indicators, maxima, and rates while retaining zero-event periods by default.

Installation

net install pygrid, from("https://raw.githubusercontent.com/tpcopeland/Stata-Tools/main/pygrid")

The package requires Stata 16 or later and has no runtime dependencies.

Identifiers may be numeric or fixed-width strings. All date variables and numeric date bounds must contain integer Stata daily dates; datetime and fractional values are rejected.

Syntax

pygrid [if] [in], id(varname) start(varname) end(varname) axis(calendar|anniversary|fixed) [options]
pyattach using filename, id(varname) date(varname) count(name) [options]

Use help pygrid and help pyattach for the complete option and stored-result contracts.

pygrid options

Option Default Purpose
id(varname) required Numeric or fixed-width string person or episode identifier.
start(varname) required Inclusive daily window start.
end(varname) required Inclusive daily window end.
axis(rule) required Calendar, anniversary, or fixed periods.
origin(varname) none Anniversary origin or calendar relative-period origin.
width(#) 1 Period width.
unit(unit) year Day, month, or year periods.
first(#) none First retained period number.
last(#) none Last retained period number.
partial(rule) keep Keep, drop, or flag partial periods.
clamp(# #) none Intersect windows with study bounds.
coverage(#|varname) none Truncate windows at a coverage start.
generate(name) period Period-number output name.
relgen(name) rel_period Relative-period output name when origin() is used.
startgen(name) period_start Observed-period start name.
stopgen(name) period_stop Observed-period stop name.
pytime(name) person_years Person-time output name.
pyunit(unit) year Store person-time in years or days.
noinclusive off Exclude each interval's terminal day from person-time and event attachment.
keep(varlist) none Copy source variables to period rows.
saveas(filename) none Save a grid while preserving the data in memory.
replace off Replace an existing saveas() target.
noisily off Display a build report.

pyattach options

Option Default Purpose
id(varname) required Numeric or fixed-width string event identifier.
date(varname) required Integer Stata daily event date.
count(name) none Count attached event rows.
sum(varname [name]) none Sum an event measure.
any(name) none Indicate one or more attached events.
max(varname [name]) none Take an event-measure maximum.
if(expression) none Restrict the numerator in the using data.
rate(name) none Divide count() by grid person-time.
nozerofill off Leave no-event rows missing.
orphans(policy) error Error, report, or save unmatched eligible events.
noisily off Display an attachment report.

Demo

The complete runnable tutorial is demo/demo_pygrid.do. It creates its own cohort and event data, rebuilds the package from the local source, and writes reproducible console assets to pygrid/demo/. Run it from the repository root:

stata-mp -b do pygrid/demo/demo_pygrid.do

Start with the first two sections for the usual denominator-to-rate workflow. The later sections show how period definitions, observation restrictions, output naming, and source-data preservation fit together. By default, person-time is inclusive: (period_stop - period_start + 1)/365.25. In survival-time notation, [start, stop] therefore maps to the half-open interval [start, stop + 1).

1. Build a calendar-year denominator

Each source row becomes one row per observed calendar year. keep(cohort) carries source metadata onto every period, while noisily reports the number of people, generated rows, partial periods, and total person-time. Notice that the first and last calendar years are retained with their exact observed time.

Observed Stata output (click to expand)
. noisily pygrid, id(id) start(window_start) end(window_end)
>     axis(calendar) keep(cohort) noisily
pygrid:            3 persons,            7 period rows
  empty windows:            0   partial periods:            3
  person-time:        5.07598   axis: calendar   convention: inclusive
. format person_years %9.3f
. noisily list id cohort period period_start period_stop person_years,
>     sepby(id) noobs abbreviate(16)
  +---------------------------------------------------------------------+
  |  id     cohort   period   period_start   period_stop   person_years |
  |---------------------------------------------------------------------|
  | 101   Clinic A     2019      15jun2019     31dec2019          0.548 |
  | 101   Clinic A     2020      01jan2020     31dec2020          1.002 |
  | 101   Clinic A     2021      01jan2021     31dec2021          0.999 |
  | 101   Clinic A     2022      01jan2022     20mar2022          0.216 |
  |---------------------------------------------------------------------|
  | 102   Clinic B     2020      01jan2020     31dec2020          1.002 |
  | 102   Clinic B     2021      01jan2021     31dec2021          0.999 |
  |---------------------------------------------------------------------|
  | 103   Clinic A     2021      10sep2021     31dec2021          0.309 |
  +---------------------------------------------------------------------+
. noisily return list
scalars:
         r(period_max) =  2022
         r(period_min) =  2019
              r(pymax) =  1.002053388090349
              r(pymin) =  .216290212183436
            r(pytotal) =  5.075975359342916
          r(N_partial) =  3
        r(N_uncovered) =  0
     r(N_empty_window) =  0
             r(N_rows) =  7
          r(N_persons) =  3

macros:
       r(pyconvention) : "inclusive"
               r(unit) : "year"
              r(width) : "1"
               r(axis) : "calendar"

2. Attach events and retain zero-event time

pyattach adds a count, cost sum, any-event indicator, maximum severity, and event rate without changing the seven-row denominator. The demo's if() filters only event rows. It deliberately includes two eligible events outside the grid so orphans(report) can show the diagnostic; without an explicit policy, orphans are errors. Periods with no event remain present with zero-filled measures.

Observed Stata output (click to expand)
. noisily pyattach using "`events'", id(person_id) date(event_date)
>     count(n_events) sum(cost total_cost) any(any_event)
>     max(severity max_severity) rate(events_per_py)
>     if(qualifying == 1) orphans(report) noisily
pyattach:            2 orphan event row(s);            1 have id() absent from the grid
pyattach:            7 eligible events,            5 attached
  orphans:            2   zero-event grid rows:            3
  overall event rate:        .985032
. format person_years %9.3f total_cost %9.0fc events_per_py %9.2f
. noisily list id cohort period period_start period_stop person_years
>     n_events events_per_py,
>     sepby(id) noobs abbreviate(16)
  +------------------------------------------------------------------------------------------------+
  |  id     cohort   period   period_start   period_stop   person_years   n_events   events_per_py |
  |------------------------------------------------------------------------------------------------|
  | 101   Clinic A     2019      15jun2019     31dec2019          0.548          1         1.82625 |
  | 101   Clinic A     2020      01jan2020     31dec2020          1.002          2       1.9959016 |
  | 101   Clinic A     2021      01jan2021     31dec2021          0.999          0               0 |
  | 101   Clinic A     2022      01jan2022     20mar2022          0.216          1       4.6234177 |
  |------------------------------------------------------------------------------------------------|
  | 102   Clinic B     2020      01jan2020     31dec2020          1.002          0               0 |
  | 102   Clinic B     2021      01jan2021     31dec2021          0.999          1       1.0006849 |
  |------------------------------------------------------------------------------------------------|
  | 103   Clinic A     2021      10sep2021     31dec2021          0.309          0               0 |
  +------------------------------------------------------------------------------------------------+
. noisily list id period total_cost any_event max_severity,
>     sepby(id) noobs abbreviate(16)
  +------------------------------------------------------+
  |  id   period   total_cost   any_event   max_severity |
  |------------------------------------------------------|
  | 101     2019          125           1              2 |
  | 101     2020          290           1              3 |
  | 101     2021            0           0              0 |
  | 101     2022           60           1              2 |
  |------------------------------------------------------|
  | 102     2020            0           0              0 |
  | 102     2021          175           1              4 |
  |------------------------------------------------------|
  | 103     2021            0           0              0 |
  +------------------------------------------------------+
. noisily return list
scalars:
       r(rate_overall) =  .9850323624595468
             r(events) =  5
       r(N_zerofilled) =  3
     r(N_orphan_nokey) =  1
           r(N_orphan) =  2
         r(N_attached) =  5
         r(N_eligible) =  7
            r(N_using) =  8

3. Choose calendar, anniversary, or fixed periods

The first section used common calendar boundaries. Anniversary periods instead start on each person's origin(); partial(flag) retains shorter edge periods and marks them. A fixed axis produces one row per observed window. The fixed example also demonstrates custom output names and pyunit(day).

Observed Stata output (click to expand)
. noisily pygrid, id(id) start(index_date) end(followup_date)
>     axis(anniversary) origin(index_date) partial(flag) keep(cohort) noisily
pygrid:            2 persons,            7 period rows
  empty windows:            0   partial periods:            2
  person-time:        5.76591   axis: anniversary   convention: inclusive
. format person_years %9.3f
. noisily list id cohort period rel_period period_start period_stop
>     person_years _partial, sepby(id) noobs abbreviate(16)
  +---------------------------------------------------------------------------------------------+
  |  id     cohort   period   rel_period   period_start   period_stop   person_years   _partial |
  |---------------------------------------------------------------------------------------------|
  | 201   Clinic A        1            0      15jun2020     14jun2021          0.999          0 |
  | 201   Clinic A        2            1      15jun2021     14jun2022          0.999          0 |
  | 201   Clinic A        3            2      15jun2022     14jun2023          0.999          0 |
  | 201   Clinic A        4            3      15jun2023     20sep2023          0.268          1 |
  |---------------------------------------------------------------------------------------------|
  | 202   Clinic B        1            0      01oct2019     29sep2020          0.999          0 |
  | 202   Clinic B        2            1      30sep2020     29sep2021          0.999          0 |
  | 202   Clinic B        3            2      30sep2021     31mar2022          0.501          1 |
  +---------------------------------------------------------------------------------------------+
. noisily return list
scalars:
         r(period_max) =  4
         r(period_min) =  1
              r(pymax) =  .999315537303217
              r(pymin) =  .2683093771389459
            r(pytotal) =  5.765913757700205
          r(N_partial) =  2
        r(N_uncovered) =  0
     r(N_empty_window) =  0
             r(N_rows) =  7
          r(N_persons) =  2

macros:
       r(pyconvention) : "inclusive"
               r(unit) : "year"
              r(width) : "1"
               r(axis) : "anniversary"

Fixed windows and person-time in days

. use "`anniversary_source'", clear
. noisily pygrid, id(id) start(index_date) end(followup_date)
>     axis(fixed) generate(window) startgen(observed_start)
>     stopgen(observed_stop) pytime(days_at_risk) pyunit(day)
>     keep(cohort) noisily
pygrid:            2 persons,            2 period rows
  empty windows:            0   partial periods:            0
  person-time:           2106   axis: fixed   convention: inclusive
. format observed_start observed_stop %td
. noisily list id cohort window observed_start observed_stop days_at_risk,
>     noobs abbreviate(16)
  +-------------------------------------------------------------------------+
  |  id     cohort   window   observed_start   observed_stop   days_at_risk |
  |-------------------------------------------------------------------------|
  | 201   Clinic A        1        15jun2020       20sep2023           1193 |
  | 202   Clinic B        1        01oct2019       31mar2022            913 |
  +-------------------------------------------------------------------------+
. noisily return list
scalars:
         r(period_max) =  1
         r(period_min) =  1
              r(pymax) =  1193
              r(pymin) =  913
            r(pytotal) =  2106
          r(N_partial) =  0
        r(N_uncovered) =  0
     r(N_empty_window) =  0
             r(N_rows) =  2
          r(N_persons) =  2

macros:
       r(pyconvention) : "inclusive"
               r(unit) : "year"
              r(width) : "1"
               r(axis) : "fixed"

4. Apply study and data-coverage restrictions

The final example intersects source windows with clamp(), delays eligible follow-up with row-specific coverage(), creates calendar periods relative to origin(), and flags partial periods. Person 303 contributes no time after the study bounds and is counted in r(N_empty_window). With saveas(), the six-row grid is written to a new dataset while the three-row source data stay in memory.

Observed Stata output (click to expand)
. noisily pygrid, id(id) start(window_start) end(window_end)
>     axis(calendar) origin(index_date) coverage(coverage_start)
>     clamp(`study_start' `study_stop') relgen(study_year)
>     partial(flag) keep(cohort) saveas("`controlled_grid'") replace noisily
pygrid:            2 persons,            6 period rows
  empty windows:            1   partial periods:            3
  person-time:        4.79671   axis: calendar   convention: inclusive
. noisily return list
scalars:
         r(period_max) =  2022
         r(period_min) =  2020
              r(pymax) =  .999315537303217
              r(pymin) =  .4955509924709103
            r(pytotal) =  4.796714579055442
          r(N_partial) =  3
        r(N_uncovered) =  1
     r(N_empty_window) =  1
             r(N_rows) =  6
          r(N_persons) =  2

macros:
       r(pyconvention) : "inclusive"
               r(unit) : "year"
              r(width) : "1"
               r(axis) : "calendar"
. noisily display as text "Source rows still in memory after saveas(): " as result _N
Source rows still in memory after saveas(): 3
. use "`controlled_grid'", clear
. format person_years %9.3f
. noisily list id cohort period study_year period_start period_stop
>     person_years _partial, sepby(id) noobs abbreviate(16)
  +---------------------------------------------------------------------------------------------+
  |  id     cohort   period   study_year   period_start   period_stop   person_years   _partial |
  |---------------------------------------------------------------------------------------------|
  | 301   Clinic A     2020            0      01jul2020     31dec2020          0.504          1 |
  | 301   Clinic A     2021            1      01jan2021     31dec2021          0.999          0 |
  | 301   Clinic A     2022            2      01jan2022     30jun2022          0.496          1 |
  |---------------------------------------------------------------------------------------------|
  | 302   Clinic B     2020            0      15mar2020     31dec2020          0.799          1 |
  | 302   Clinic B     2021            1      01jan2021     31dec2021          0.999          0 |
  | 302   Clinic B     2022            2      01jan2022     31dec2022          0.999          0 |
  +---------------------------------------------------------------------------------------------+

The checked-in .log files are plain-text Stata output; logdoc also generates the reusable markdown for getting started, event attachment, period axes, and window controls.

Stored results

Command Results
pygrid scalars r(N_persons), r(N_rows), r(N_empty_window), r(N_uncovered), r(N_partial), r(pytotal), r(pymin), r(pymax), r(period_min), r(period_max)
pygrid macros r(axis), r(width), r(unit), r(pyconvention)
pyattach scalars r(N_using), r(N_eligible), r(N_attached), r(N_orphan), r(N_orphan_nokey), r(N_zerofilled), r(events), r(rate_overall)

Version history

  • 1.0.0 (12 August 2026): Initial implementation of calendar, anniversary, and fixed grids; exact person-time; coverage and partial-period rules; zero-filled event attachment; orphan policies; and rate construction.

QA

See qa/README.md for the suite inventory, coverage, cross-validation, benchmark, and run commands.

Author

Timothy P Copeland, Karolinska Institutet

License

MIT License