csrlite 0.2.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {csrlite-0.2.1/src/csrlite.egg-info → csrlite-0.3.2}/PKG-INFO +2 -2
  2. {csrlite-0.2.1 → csrlite-0.3.2}/pyproject.toml +2 -2
  3. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/__init__.py +20 -0
  4. csrlite-0.3.2/src/csrlite/cm/cm_listing.py +497 -0
  5. csrlite-0.3.2/src/csrlite/cm/cm_summary.py +327 -0
  6. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/common/rtf.py +35 -6
  7. csrlite-0.3.2/src/csrlite/pd/pd_listing.py +461 -0
  8. {csrlite-0.2.1 → csrlite-0.3.2/src/csrlite.egg-info}/PKG-INFO +2 -2
  9. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite.egg-info/SOURCES.txt +4 -1
  10. {csrlite-0.2.1 → csrlite-0.3.2}/MANIFEST.in +0 -0
  11. {csrlite-0.2.1 → csrlite-0.3.2}/README.md +0 -0
  12. {csrlite-0.2.1 → csrlite-0.3.2}/setup.cfg +0 -0
  13. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/ae/__init__.py +0 -0
  14. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/ae/ae_listing.py +0 -0
  15. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/ae/ae_specific.py +0 -0
  16. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/ae/ae_summary.py +0 -0
  17. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/ae/ae_utils.py +0 -0
  18. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/common/config.py +0 -0
  19. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/common/count.py +0 -0
  20. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/common/parse.py +0 -0
  21. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/common/plan.py +0 -0
  22. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/common/utils.py +0 -0
  23. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/common/yaml_loader.py +0 -0
  24. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/disposition/__init__.py +0 -0
  25. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/disposition/disposition.py +0 -0
  26. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite/ie/ie.py +0 -0
  27. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite.egg-info/dependency_links.txt +0 -0
  28. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite.egg-info/requires.txt +0 -0
  29. {csrlite-0.2.1 → csrlite-0.3.2}/src/csrlite.egg-info/top_level.txt +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: csrlite
3
- Version: 0.2.1
3
+ Version: 0.3.2
4
4
  Summary: A hierarchical YAML-based framework for generating Tables, Listings, and Figures in clinical trials
5
- Author-email: Clinical Biostatistics Team <biostat@example.com>, Ming-Chun Chen <hellomingchun@gmail.com>
5
+ Author-email: Yilong Zhang <elong0527@gmail.com>, Ming-Chun Chen <hellomingchun@gmail.com>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/elong0527/csrlite
8
8
  Project-URL: Documentation, https://elong0527.github.io/csrlite
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "csrlite"
7
- version = "0.2.1"
7
+ version = "0.3.2"
8
8
  description = "A hierarchical YAML-based framework for generating Tables, Listings, and Figures in clinical trials"
9
9
  authors = [
10
- {name = "Clinical Biostatistics Team", email = "biostat@example.com"},
10
+ {name = "Yilong Zhang", email = "elong0527@gmail.com"},
11
11
  {name = "Ming-Chun Chen", email = "hellomingchun@gmail.com"}
12
12
  ]
13
13
  license = {text = "MIT"}
@@ -13,6 +13,14 @@ from .ae.ae_summary import ( # AE summary functions
13
13
  ae_summary,
14
14
  study_plan_to_ae_summary,
15
15
  )
16
+ from .cm.cm_listing import ( # CM listing functions
17
+ cm_listing,
18
+ study_plan_to_cm_listing,
19
+ )
20
+ from .cm.cm_summary import (
21
+ cm_summary,
22
+ study_plan_to_cm_summary,
23
+ )
16
24
  from .common.config import config
17
25
  from .common.count import (
18
26
  count_subject,
@@ -33,6 +41,10 @@ from .ie.ie import (
33
41
  study_plan_to_ie_listing,
34
42
  study_plan_to_ie_summary,
35
43
  )
44
+ from .pd.pd_listing import (
45
+ pd_listing,
46
+ study_plan_to_pd_listing,
47
+ )
36
48
 
37
49
  # Configure logging
38
50
  logging.basicConfig(
@@ -54,6 +66,11 @@ __all__ = [
54
66
  "study_plan_to_ae_summary",
55
67
  "study_plan_to_ae_specific",
56
68
  "study_plan_to_ae_listing",
69
+ # CM analysis
70
+ "cm_listing",
71
+ "study_plan_to_cm_listing",
72
+ "cm_summary",
73
+ "study_plan_to_cm_summary",
57
74
  # Disposition analysis
58
75
  "study_plan_to_disposition_summary",
59
76
  # Count functions
@@ -68,4 +85,7 @@ __all__ = [
68
85
  "ie_rtf",
69
86
  "study_plan_to_ie_summary",
70
87
  "study_plan_to_ie_listing",
88
+ # PD analysis
89
+ "pd_listing",
90
+ "study_plan_to_pd_listing",
71
91
  ]
@@ -0,0 +1,497 @@
1
+ # pyre-strict
2
+ """
3
+ Concomitant Medications (CM) Listing Functions
4
+
5
+ This module provides functions for generating detailed CM listings showing individual
6
+ medication records with key details like medication name, dose, route, etc.
7
+
8
+ The two-step pipeline:
9
+ - cm_listing_ard: Filter, select, sort, and rename columns (returns display-ready data)
10
+ - cm_listing_rtf: Generate formatted RTF output
11
+ - cm_listing: Complete pipeline wrapper
12
+ - study_plan_to_cm_listing: Batch generation from StudyPlan
13
+
14
+ Uses Polars native SQL capabilities for data manipulation and parse.py utilities
15
+ for StudyPlan parsing.
16
+ """
17
+
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import polars as pl
22
+ from rtflite import RTFBody, RTFColumnHeader, RTFDocument, RTFFootnote, RTFPage, RTFSource, RTFTitle
23
+
24
+ from ..common.parse import StudyPlanParser
25
+ from ..common.plan import StudyPlan
26
+ from ..common.utils import apply_common_filters
27
+
28
+
29
+ def cm_listing_ard(
30
+ population: pl.DataFrame,
31
+ observation: pl.DataFrame,
32
+ population_filter: str | None,
33
+ observation_filter: str | None,
34
+ parameter_filter: str | None,
35
+ id: tuple[str, str],
36
+ population_columns: list[tuple[str, str]] | None = None,
37
+ observation_columns: list[tuple[str, str]] | None = None,
38
+ sort_columns: list[str] | None = None,
39
+ page_by: list[str] | None = None,
40
+ ) -> pl.DataFrame:
41
+ """
42
+ Generate Analysis Results Data (ARD) for CM listing.
43
+
44
+ Filters and joins population and observation data, then selects relevant columns.
45
+
46
+ Args:
47
+ population: Population DataFrame (subject-level data, e.g., ADSL)
48
+ observation: Observation DataFrame (event data, e.g., ADCM)
49
+ population_filter: SQL WHERE clause for population (can be None)
50
+ observation_filter: SQL WHERE clause for observation (can be None)
51
+ parameter_filter: SQL WHERE clause for parameter filtering (can be None)
52
+ id: Tuple (variable_name, label) for ID column
53
+ population_columns: List of tuples (variable_name, label) from population
54
+ observation_columns: List of tuples (variable_name, label) from observation
55
+ sort_columns: List of column names to sort by. If None, sorts by id column.
56
+
57
+ Returns:
58
+ pl.DataFrame: Filtered and joined records with selected columns
59
+ """
60
+ id_var_name, id_var_label = id
61
+
62
+ # Apply common filters
63
+ population_filtered, observation_to_filter = apply_common_filters(
64
+ population=population,
65
+ observation=observation,
66
+ population_filter=population_filter,
67
+ observation_filter=observation_filter,
68
+ parameter_filter=parameter_filter,
69
+ )
70
+
71
+ assert observation_to_filter is not None
72
+
73
+ # Filter observation to include only subjects in filtered population
74
+ observation_filtered = observation_to_filter.filter(
75
+ pl.col(id_var_name).is_in(population_filtered[id_var_name].to_list())
76
+ )
77
+
78
+ # Determine which observation columns to select
79
+ if observation_columns is None:
80
+ # Default: select id column only
81
+ obs_cols = [id_var_name]
82
+ else:
83
+ # Extract variable names from tuples
84
+ obs_col_names = [var_name for var_name, _ in observation_columns]
85
+ # Ensure id is included
86
+ obs_cols = [id_var_name] + [col for col in obs_col_names if col != id_var_name]
87
+
88
+ # Select available observation columns
89
+ obs_cols_available = [col for col in obs_cols if col in observation_filtered.columns]
90
+ result = observation_filtered.select(obs_cols_available)
91
+
92
+ # Join with population to add population columns
93
+ if population_columns is not None:
94
+ # Extract variable names from tuples
95
+ pop_col_names = [var_name for var_name, _ in population_columns]
96
+ # Select id + requested population columns
97
+ pop_cols = [id_var_name] + [col for col in pop_col_names if col != id_var_name]
98
+ pop_cols_available = [col for col in pop_cols if col in population_filtered.columns]
99
+ population_subset = population_filtered.select(pop_cols_available)
100
+
101
+ # Left join to preserve all observation records
102
+ result = result.join(population_subset, on=id_var_name, how="left")
103
+
104
+ # Create __index__ column for pagination
105
+ # Default to using the id column as the index
106
+ if id_var_name in result.columns:
107
+ result = result.with_columns(
108
+ (pl.lit(f"{id_var_label} = ") + pl.col(id_var_name).cast(pl.Utf8)).alias("__index__")
109
+ )
110
+
111
+ # Use page_by columns if provided and they exist
112
+ existing_page_by_cols = [col for col in page_by if col in result.columns] if page_by else []
113
+
114
+ if existing_page_by_cols:
115
+ # Create a mapping from column name to label
116
+ column_labels = {id_var_name: id_var_label}
117
+ if population_columns:
118
+ for var_name, var_label in population_columns:
119
+ column_labels[var_name] = var_label
120
+
121
+ # Ensure the order of labels matches the order of columns in page_by
122
+ index_expressions = []
123
+ for col_name in existing_page_by_cols:
124
+ label = column_labels.get(col_name, col_name)
125
+ index_expressions.append(pl.lit(f"{label} = ") + pl.col(col_name).cast(pl.Utf8))
126
+
127
+ result = result.with_columns(
128
+ pl.concat_str(index_expressions, separator=", ").alias("__index__")
129
+ )
130
+
131
+ page_by_remove = [col for col in (page_by or []) if col != id_var_name]
132
+ result = result.drop(page_by_remove)
133
+
134
+ if "__index__" in result.columns:
135
+ # Get all columns except __index__
136
+ other_columns = [col for col in result.columns if col != "__index__"]
137
+ # Reorder to have __index__ first
138
+ result = result.select(["__index__"] + other_columns)
139
+
140
+ # Sort by specified columns or default to id column
141
+ if sort_columns is None:
142
+ # Default: sort by id column if it exists in result
143
+ if id_var_name in result.columns:
144
+ result = result.sort(id_var_name)
145
+ else:
146
+ # Sort by specified columns that exist in result
147
+ cols_to_sort = [col for col in sort_columns if col in result.columns]
148
+ if cols_to_sort:
149
+ result = result.sort(cols_to_sort)
150
+
151
+ return result
152
+
153
+
154
+ def cm_listing_rtf(
155
+ df: pl.DataFrame,
156
+ column_labels: dict[str, str],
157
+ title: list[str],
158
+ footnote: list[str] | None,
159
+ source: list[str] | None,
160
+ col_rel_width: list[float] | None = None,
161
+ group_by: list[str] | None = None,
162
+ page_by: list[str] | None = None,
163
+ orientation: str = "landscape",
164
+ ) -> RTFDocument:
165
+ """
166
+ Generate RTF table from CM listing display DataFrame.
167
+
168
+ Creates a formatted RTF table with column headers and optional section grouping/pagination.
169
+
170
+ Args:
171
+ df: Display DataFrame from cm_listing_ard
172
+ column_labels: Dictionary mapping column names to display labels
173
+ title: Title(s) for the table as list of strings
174
+ footnote: Optional footnote(s) as list of strings
175
+ source: Optional source note(s) as list of strings
176
+ col_rel_width: Optional list of relative column widths. If None, auto-calculated
177
+ as equal widths for all columns
178
+ group_by: Optional list of column names to group by for section headers within pages.
179
+ Should only contain population columns (e.g., ["TRT01A", "USUBJID"])
180
+ page_by: Optional list of column names to trigger new pages when values change.
181
+ Should only contain population columns (e.g., ["TRT01A"])
182
+ orientation: Page orientation ("portrait" or "landscape"), default is "landscape"
183
+
184
+ Returns:
185
+ RTFDocument: RTF document object that can be written to file
186
+ """
187
+ # Calculate number of columns
188
+ n_cols = len(df.columns)
189
+
190
+ # Build column headers using labels
191
+ col_header = [column_labels.get(col, col) for col in df.columns]
192
+
193
+ # Calculate column widths
194
+ if col_rel_width is None:
195
+ col_widths = [1.0] * n_cols
196
+ else:
197
+ col_widths = col_rel_width
198
+
199
+ # Normalize title, footnote, source to lists
200
+ title_list = title
201
+ footnote_list: list[str] = footnote or []
202
+ source_list: list[str] = source or []
203
+
204
+ # Build RTF document
205
+ rtf_components: dict[str, Any] = {
206
+ "df": df,
207
+ "rtf_page": RTFPage(orientation=orientation),
208
+ "rtf_title": RTFTitle(text=title_list),
209
+ "rtf_column_header": [
210
+ RTFColumnHeader(
211
+ text=col_header[1:],
212
+ col_rel_width=col_widths[1:],
213
+ text_justification=["l"] + ["c"] * (n_cols - 1),
214
+ ),
215
+ ],
216
+ "rtf_body": RTFBody(
217
+ col_rel_width=col_widths,
218
+ text_justification=["l"] * n_cols,
219
+ border_left=["single"],
220
+ border_top=["single"] + [""] * (n_cols - 1),
221
+ border_bottom=["single"] + [""] * (n_cols - 1),
222
+ group_by=group_by,
223
+ page_by=page_by,
224
+ ),
225
+ }
226
+
227
+ # Add optional footnote
228
+ if footnote_list:
229
+ rtf_components["rtf_footnote"] = RTFFootnote(text=footnote_list)
230
+
231
+ # Add optional source
232
+ if source_list:
233
+ rtf_components["rtf_source"] = RTFSource(text=source_list)
234
+
235
+ # Create RTF document
236
+ doc = RTFDocument(**rtf_components)
237
+
238
+ return doc
239
+
240
+
241
+ def cm_listing(
242
+ population: pl.DataFrame,
243
+ observation: pl.DataFrame,
244
+ population_filter: str | None,
245
+ observation_filter: str | None,
246
+ parameter_filter: str | None,
247
+ id: tuple[str, str],
248
+ title: list[str],
249
+ footnote: list[str] | None,
250
+ source: list[str] | None,
251
+ output_file: str,
252
+ population_columns: list[tuple[str, str]] | None = None,
253
+ observation_columns: list[tuple[str, str]] | None = None,
254
+ sort_columns: list[str] | None = None,
255
+ group_by: list[str] | None = None,
256
+ page_by: list[str] | None = None,
257
+ col_rel_width: list[float] | None = None,
258
+ orientation: str = "landscape",
259
+ ) -> str:
260
+ """
261
+ Complete CM listing pipeline wrapper.
262
+
263
+ This function orchestrates the two-step pipeline:
264
+ 1. cm_listing_ard: Filter, join, select, and sort columns
265
+ 2. cm_listing_rtf: Generate RTF output with optional grouping/pagination
266
+
267
+ Args:
268
+ population: Population DataFrame (subject-level data, e.g., ADSL)
269
+ observation: Observation DataFrame (event data, e.g., ADCM)
270
+ population_filter: SQL WHERE clause for population (can be None)
271
+ observation_filter: SQL WHERE clause for observation (can be None)
272
+ parameter_filter: SQL WHERE clause for parameter filtering (can be None)
273
+ id: Tuple (variable_name, label) for ID column
274
+ title: Title for RTF output as list of strings
275
+ footnote: Optional footnote for RTF output as list of strings
276
+ source: Optional source for RTF output as list of strings
277
+ output_file: File path to write RTF output
278
+ population_columns: Optional list of tuples (variable_name, label) from population
279
+ observation_columns: Optional list of tuples (variable_name, label) from observation
280
+ sort_columns: Optional list of column names to sort by. If None, sorts by id column.
281
+ group_by: Optional list of column names to group by for section headers
282
+ (population columns only)
283
+ page_by: Optional list of column names to trigger new pages (population columns only)
284
+ col_rel_width: Optional column widths for RTF output
285
+ orientation: Page orientation ("portrait" or "landscape"), default is "landscape"
286
+
287
+ Returns:
288
+ str: Path to the generated RTF file
289
+ """
290
+ # Step 1: Generate ARD (includes filtering, joining, and selecting)
291
+ df = cm_listing_ard(
292
+ population=population,
293
+ observation=observation,
294
+ population_filter=population_filter,
295
+ observation_filter=observation_filter,
296
+ parameter_filter=parameter_filter,
297
+ id=id,
298
+ population_columns=population_columns,
299
+ observation_columns=observation_columns,
300
+ sort_columns=sort_columns,
301
+ page_by=page_by,
302
+ )
303
+
304
+ # Build column labels from tuples
305
+ id_var_name, id_var_label = id
306
+ column_labels = {id_var_name: id_var_label}
307
+
308
+ # Add observation column labels
309
+ if observation_columns is not None:
310
+ for var_name, var_label in observation_columns:
311
+ column_labels[var_name] = var_label
312
+
313
+ # Add population column labels
314
+ if population_columns is not None:
315
+ for var_name, var_label in population_columns:
316
+ column_labels[var_name] = var_label
317
+
318
+ # Set __index__ header to empty string
319
+ column_labels["__index__"] = ""
320
+
321
+ # Step 2: Generate RTF and write to file
322
+ rtf_doc = cm_listing_rtf(
323
+ df=df,
324
+ column_labels=column_labels,
325
+ title=title,
326
+ footnote=footnote,
327
+ source=source,
328
+ col_rel_width=col_rel_width,
329
+ group_by=group_by,
330
+ page_by=["__index__"],
331
+ orientation=orientation,
332
+ )
333
+ rtf_doc.write_rtf(output_file)
334
+
335
+ return output_file
336
+
337
+
338
+ def study_plan_to_cm_listing(
339
+ study_plan: StudyPlan,
340
+ ) -> list[str]:
341
+ """
342
+ Generate CM listing RTF outputs for all analyses defined in StudyPlan.
343
+
344
+ This function reads the expanded plan from StudyPlan and generates
345
+ an RTF listing for each cm_listing analysis specification automatically.
346
+
347
+ Args:
348
+ study_plan: StudyPlan object with loaded datasets and analysis specifications
349
+
350
+ Returns:
351
+ list[str]: List of paths to generated RTF files
352
+ """
353
+
354
+ # Meta data
355
+ analysis = "cm_listing"
356
+ output_dir = study_plan.output_dir
357
+ # Auto-adjust column widths based on typical content
358
+ col_rel_width = [1.0] * 12 # Estimate based on number of columns
359
+ footnote = None
360
+ source = None
361
+
362
+ population_df_name = "adsl"
363
+ observation_df_name = "adcm"
364
+
365
+ id = ("USUBJID", "Subject ID")
366
+ # Column configuration with labels - easy to customize
367
+ # Population columns (demographics) - group variable will be added dynamically
368
+ population_columns_base = [
369
+ ("AGE", "Age"),
370
+ ("SEX", "Sex"),
371
+ ("RACE", "Race"),
372
+ ]
373
+
374
+ # Observation columns (event details)
375
+ # Adjusted based on available columns in adcm.parquet
376
+ observation_columns_base = [
377
+ ("CMSEQ", "Sequence"),
378
+ ("CMCAT", "Category"),
379
+ ("ASTDT", "Start Date"),
380
+ ("AENDT", "End Date"),
381
+ ("CMINDC", "Indication"),
382
+ ("CMTRT", "Medication"),
383
+ ("CMDECOD", "Standardized Name"),
384
+ ("CMDOSE", "Dose"),
385
+ ("CMDOSU", "Unit"),
386
+ ("CMROUTE", "Route"),
387
+ ("CMFREQ", "Frequency"),
388
+ ("CMENDPT", "End Point"),
389
+ ]
390
+
391
+ # Sorting configuration
392
+ sort_columns = ["TRT01A", "USUBJID", "ASTDT", "CMSEQ"]
393
+ page_by = ["USUBJID", "SEX", "RACE", "AGE", "TRT01A"]
394
+ group_by = ["USUBJID"]
395
+
396
+ # Create output directory if it doesn't exist
397
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
398
+
399
+ # Initialize parser
400
+ parser = StudyPlanParser(study_plan)
401
+
402
+ # Get expanded plan DataFrame
403
+ plan_df = study_plan.get_plan_df()
404
+
405
+ # Filter for CM listing analyses
406
+ cm_plans = plan_df.filter(pl.col("analysis") == analysis)
407
+
408
+ rtf_files = []
409
+
410
+ # Generate RTF for each analysis
411
+ for row in cm_plans.iter_rows(named=True):
412
+ population = row["population"]
413
+ observation = row.get("observation")
414
+ parameter = row.get("parameter")
415
+ group = row.get("group")
416
+
417
+ # Validate group is specified
418
+ if group is None:
419
+ raise ValueError(
420
+ f"Group not specified in YAML for analysis: population={population}, "
421
+ f"observation={observation}, parameter={parameter}. "
422
+ "Please add group to your YAML plan."
423
+ )
424
+
425
+ # Get datasets using parser
426
+ population_df, observation_df = parser.get_datasets(population_df_name, observation_df_name)
427
+
428
+ # Get filters using parser
429
+ population_filter = parser.get_population_filter(population)
430
+ obs_filter = parser.get_observation_filter(observation)
431
+
432
+ # Get group variable name from YAML
433
+ group_var_name, group_labels = parser.get_group_info(group)
434
+
435
+ # Determine group variable label
436
+ group_var_label = group_labels[0] if group_labels else "Treatment"
437
+
438
+ # Build columns dynamically from base configuration with labels
439
+ population_columns = population_columns_base + [(group_var_name, group_var_label)]
440
+ observation_columns = observation_columns_base
441
+
442
+ # Dynamic sort and page configuration
443
+ sort_columns = [group_var_name, "USUBJID", "ASTDT", "CMSEQ"]
444
+ page_by = ["USUBJID", "SEX", "RACE", "AGE", group_var_name]
445
+
446
+ # Get parameter filter if parameter is specified
447
+ parameter_filter = None
448
+ if parameter:
449
+ # Assuming similar parameter structure, though CM might just use subsets
450
+ param_names, param_filters, param_labels, _ = parser.get_parameter_info(parameter)
451
+ # For cm_listing, use the first filter
452
+ parameter_filter = param_filters[0] if param_filters else None
453
+
454
+ # Build title
455
+ title_parts = ["Listing of Concomitant Medications"]
456
+
457
+ if observation:
458
+ obs_kw = study_plan.keywords.observations.get(observation)
459
+ if obs_kw and obs_kw.label:
460
+ title_parts.append(obs_kw.label)
461
+
462
+ pop_kw = study_plan.keywords.populations.get(population)
463
+ if pop_kw and pop_kw.label:
464
+ title_parts.append(pop_kw.label)
465
+
466
+ # Build output filename
467
+ filename = f"{analysis}_{population}"
468
+ if observation:
469
+ filename += f"_{observation}"
470
+ if parameter:
471
+ filename += f"_{parameter.replace(';', '_')}"
472
+ filename += ".rtf"
473
+ output_file = str(Path(output_dir) / filename)
474
+
475
+ # Generate RTF
476
+ rtf_path = cm_listing(
477
+ population=population_df,
478
+ observation=observation_df,
479
+ population_filter=population_filter,
480
+ observation_filter=obs_filter,
481
+ parameter_filter=parameter_filter,
482
+ id=id,
483
+ title=title_parts,
484
+ footnote=footnote,
485
+ source=source,
486
+ output_file=output_file,
487
+ population_columns=population_columns,
488
+ observation_columns=observation_columns,
489
+ sort_columns=sort_columns,
490
+ col_rel_width=col_rel_width,
491
+ group_by=group_by,
492
+ page_by=page_by,
493
+ )
494
+
495
+ rtf_files.append(rtf_path)
496
+
497
+ return rtf_files