Skip to main content
← Back to Software

pygrid

Cohorts, Registries & Time-Varying Data

Exact person-time denominators, zero-event cells kept

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

pygrid hex logo

Version 1.0.2 | 2026-10-26

version Stata 16+ MIT license Part of Stata-Tools

Quick Start · Installation · Options · Demo · Stata-Tools · Report a bug

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.

Quick Start

After installing, split three people's follow-up into calendar years and attach their qualifying events, keeping the years with no events:

set linesize 120

* Event rows: one per event, with a cost, a severity, and a qualifying flag
clear
input long person_id str9 event_text double cost byte severity byte qualifying
101 "31dec2019" 125 2 1
101 "01jan2020"  80 1 1
101 "29feb2020" 210 3 1
101 "01jan2021" 999 5 0
101 "20mar2022"  60 2 1
102 "15jul2021" 175 4 1
103 "01jan2022"  90 2 1
999 "01jun2020" 300 5 1
end
generate double event_date = daily(event_text, "DMY")
drop event_text
format event_date %td
tempfile events
save "`events'"

* Follow-up windows: one row per person
clear
input long id str9 entry_text str9 exit_text str8 cohort
101 "15jun2019" "20mar2022" "Clinic A"
102 "01jan2020" "31dec2021" "Clinic B"
103 "10sep2021" "31dec2021" "Clinic A"
end
generate double window_start = daily(entry_text, "DMY")
generate double window_end = daily(exit_text, "DMY")
drop entry_text exit_text
format window_start window_end %td

* One row per person and calendar year, then attach the qualifying events
pygrid, id(id) start(window_start) end(window_end) axis(calendar) keep(cohort) 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
format person_years %9.3f
list id cohort period period_start period_stop person_years n_events events_per_py, sepby(id) noobs abbreviate(16)
pygrid: 3 persons, 7 period rows
  empty windows: 0   partial periods: 3
  person-time: 5.07598   axis: calendar   convention: inclusive
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
  +------------------------------------------------------------------------------------------------+
  |  id     cohort   period   period_start   period_stop   person_years   n_events   events_per_py |
  |------------------------------------------------------------------------------------------------|
  | 101   Clinic A     2019      15jun2019     31dec2019          0.548          1            1.83 |
  | 101   Clinic A     2020      01jan2020     31dec2020          1.002          2            2.00 |
  | 101   Clinic A     2021      01jan2021     31dec2021          0.999          0            0.00 |
  | 101   Clinic A     2022      01jan2022     20mar2022          0.216          1            4.62 |
  |------------------------------------------------------------------------------------------------|
  | 102   Clinic B     2020      01jan2020     31dec2020          1.002          0            0.00 |
  | 102   Clinic B     2021      01jan2021     31dec2021          0.999          1            1.00 |
  |------------------------------------------------------------------------------------------------|
  | 103   Clinic A     2021      10sep2021     31dec2021          0.309          0            0.00 |
  +------------------------------------------------------------------------------------------------+

Seven person-year rows carry exact observed time, including the partial first and last years. Five qualifying events attach; the excluded qualifying == 0 row is not counted, the two orphans (person 999, who is not in the grid, and person 103's event after follow-up ended) are reported, and three rows keep zero events.

Output copied from demo/console_getting_started.md and demo/console_event_attachment.md, written by demo/demo_pygrid.do. set linesize 120 matches the demo; at the default width list wraps the rows into blocks. The demo also formats total_cost and events_per_py in the same format call, which Stata ignores (one format per call), so the shown output is unchanged.

Features

  • Exact person-time. Each row's time is inclusive by default, (period_stop - period_start + 1)/365.25, and partial first and last periods are kept with their exact observed time.
  • Zero-event periods stay in. pyattach adds counts, sums, any-event indicators, maxima, and rates without changing the number of denominator rows, and fills no-event rows with zeros unless nozerofill is given.
  • Calendar, anniversary, or fixed periods. axis() sets the rule, width() and unit() the period length, origin() the anniversary or relative-period origin, and partial() keeps, drops, or flags short edge periods.
  • Study bounds and data coverage. clamp() intersects every window with study dates and coverage() delays follow-up to a coverage start; people left with no time are counted in r(N_empty_window).
  • No silently lost events. Eligible events that land in no grid row are an error by default; orphans() can report or save them instead.
  • A grid that knows it was edited. Every grid carries an integrity stamp; pyattach accepts reordered rows and added measure columns but refuses a grid whose structural values were changed.

Requirements

  • Stata 16 or later
  • No runtime dependencies
  • Integer Stata daily dates in every date variable and numeric date bound

Installation

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

Commands

Command Description
pygrid Build a person-period denominator grid with exact person-time
pyattach Attach zero-filled event measures to a pygrid denominator

How It Works

one row per person window          pygrid                      pyattach using events
id  start  end  [keep() vars]  -->  id  period  period_start  -->  ... plus count(), sum(),
                                    period_stop  person_years      any(), max(), rate();
                                                                   zero where no event

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.

Source windows must have end() on or after start(). Calendar month blocks are anchored at January 1960, so odd widths remain stable across years.

Every generated grid carries an integrity stamp over its structural metadata and data. Row reordering and added event-measure columns remain valid, but editing structural values or metadata requires rebuilding the grid before calling pyattach; grids created before version 1.0.1 must also be rebuilt.

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
. format total_cost %9.0fc
. format 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.83 |
  | 101   Clinic A     2020      01jan2020     31dec2020          1.002          2            2.00 |
  | 101   Clinic A     2021      01jan2021     31dec2021          0.999          0            0.00 |
  | 101   Clinic A     2022      01jan2022     20mar2022          0.216          1            4.62 |
  |------------------------------------------------------------------------------------------------|
  | 102   Clinic B     2020      01jan2020     31dec2020          1.002          0            0.00 |
  | 102   Clinic B     2021      01jan2021     31dec2021          0.999          1            1.00 |
  |------------------------------------------------------------------------------------------------|
  | 103   Clinic A     2021      10sep2021     31dec2021          0.309          0            0.00 |
  +------------------------------------------------------------------------------------------------+
. 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.

Command Reference

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

pyattach needs at least one measure: count(), sum(), any(), or max(). Use help pygrid and help pyattach for the complete option and stored-result contracts.

Options

pygrid

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. Unused with axis(fixed).
unit(unit) year Day, month, or year periods. Unused with axis(fixed).
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

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.

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)

QA

QA suites and how to run them are documented in qa/README.md.

Version History

  • 1.0.2 (13 October 2026): Compact pygrid and pyattach count lines (counts and person-time no longer pad to 12 and 14 columns). Documentation: with axis(fixed), width() and unit() are unused and r(width)/r(unit) carry no period size; pyunit(year) is a 365.25-day divisor, so calendar years can be 0.999 or 1.002 person-years; the README synopsis no longer lists count() as required (at least one of count(), sum(), any(), max() is).
  • 1.0.1 (30 August 2026): Rejects reversed source windows, adds tamper-detecting grid integrity stamps while preserving row-reordering support, pins and documents the January 1960 month-block origin, and strengthens rollback QA against asymmetric dataset comparisons.
  • 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.

Author

Timothy P Copeland, Karolinska Institutet

Questions and bug reports: GitHub issues. Please include the first line of which pygrid and your Stata version.

To cite: Copeland TP. pygrid: Person-period denominators with zero-filled events. Stata-Tools, https://github.com/tpcopeland/Stata-Tools/tree/main/pygrid. Please give the version you used (which pygrid prints it).

License

MIT (see LICENSE)