bhsim 1.0.0rc2__py3-none-any.whl
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.
- bhsim/__init__.py +5 -0
- bhsim/__main__.py +4 -0
- bhsim/cli.py +26 -0
- bhsim/engine.py +589 -0
- bhsim/simulator.py +34 -0
- bhsim-1.0.0rc2.dist-info/METADATA +267 -0
- bhsim-1.0.0rc2.dist-info/RECORD +11 -0
- bhsim-1.0.0rc2.dist-info/WHEEL +5 -0
- bhsim-1.0.0rc2.dist-info/entry_points.txt +2 -0
- bhsim-1.0.0rc2.dist-info/licenses/LICENSE +21 -0
- bhsim-1.0.0rc2.dist-info/top_level.txt +1 -0
bhsim/__init__.py
ADDED
bhsim/__main__.py
ADDED
bhsim/cli.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
from .engine import Config, generate, validate, export_csv
|
|
4
|
+
|
|
5
|
+
def main():
|
|
6
|
+
parser=argparse.ArgumentParser(description='BHSim synthetic behavioral health data generator')
|
|
7
|
+
sub=parser.add_subparsers(dest='command',required=True)
|
|
8
|
+
gen=sub.add_parser('generate')
|
|
9
|
+
gen.add_argument('path')
|
|
10
|
+
gen.add_argument('--clients',type=int,default=1000)
|
|
11
|
+
gen.add_argument('--employees',type=int,default=80)
|
|
12
|
+
gen.add_argument('--months',type=int,default=12)
|
|
13
|
+
gen.add_argument('--future-days',type=int,default=30)
|
|
14
|
+
gen.add_argument('--reference-date',default='2026-10-10')
|
|
15
|
+
gen.add_argument('--seed',type=int,default=42)
|
|
16
|
+
valid=sub.add_parser('validate');valid.add_argument('path')
|
|
17
|
+
exp=sub.add_parser('export-csv');exp.add_argument('path');exp.add_argument('directory')
|
|
18
|
+
a=parser.parse_args()
|
|
19
|
+
if a.command=='generate':
|
|
20
|
+
result=generate(a.path,Config(a.clients,a.employees,a.months,a.future_days,a.reference_date,a.seed))
|
|
21
|
+
elif a.command=='validate':result=validate(a.path)
|
|
22
|
+
else:result=export_csv(a.path,a.directory)
|
|
23
|
+
print(json.dumps(result,indent=2))
|
|
24
|
+
if a.command=='validate' and not result['valid']:raise SystemExit(1)
|
|
25
|
+
|
|
26
|
+
if __name__=='__main__': main()
|
bhsim/engine.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""Deterministic synthetic behavioral-health data generation; no PHI inputs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
from contextlib import closing as sqlite_closing
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import random
|
|
9
|
+
import sqlite3
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from datetime import date, datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Config:
|
|
17
|
+
clients: int = 1000
|
|
18
|
+
employees: int = 80
|
|
19
|
+
months: int = 12
|
|
20
|
+
future_days: int = 30
|
|
21
|
+
reference_date: str = "2026-10-10"
|
|
22
|
+
seed: int = 42
|
|
23
|
+
|
|
24
|
+
def check(self):
|
|
25
|
+
if min(self.clients, self.employees, self.months) < 1 or self.future_days < 0:
|
|
26
|
+
raise ValueError("clients, employees and months must be positive; future_days cannot be negative")
|
|
27
|
+
date.fromisoformat(self.reference_date)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
PROGRAMS = [
|
|
31
|
+
(1, "MH-OP", "Mental Health Outpatient", "MH", .18),
|
|
32
|
+
(2, "MH-CS", "Mental Health Case Management", "MH", .13),
|
|
33
|
+
(3, "MH-CR", "Crisis Services", "MH", .06),
|
|
34
|
+
(4, "SA-OP", "Substance Use Outpatient", "SA", .24),
|
|
35
|
+
(5, "SA-IOP", "Intensive Outpatient", "SA", .21),
|
|
36
|
+
(6, "SA-RC", "Recovery Support", "SA", .16),
|
|
37
|
+
(7, "IDD-CS", "IDD Community Support", "IDD", .11),
|
|
38
|
+
(8, "IDD-CC", "IDD Care Coordination", "IDD", .09),
|
|
39
|
+
]
|
|
40
|
+
VISIT_TYPES = [(1,"Individual Therapy",60,20),(2,"Case Management",45,17),(3,"Medication Management",30,12),(4,"Crisis Intervention",90,35),(5,"Group Therapy",60,16),(6,"Assessment",45,15)]
|
|
41
|
+
# ---IDENTIFIER FORMATS---
|
|
42
|
+
ID_LENGTHS = {'individual': 7, 'program': 4, 'enrollment': 9, 'employee': 6, 'assignment': 9, 'supervision': 9, 'location': 5, 'visit': 10, 'visit_type': 5, 'rule': 7, 'productivity': 10, 'instrument': 5, 'question': 7, 'assessment': 10, 'response': 12, 'score': 10, 'option': 6}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def make_id(kind: str, number: int, seed: int = 42) -> str:
|
|
46
|
+
"""Seeded, collision-free numeric permutation within each ID's fixed width.
|
|
47
|
+
|
|
48
|
+
Different entity kinds have independent namespaces. Unlike zero-padding a
|
|
49
|
+
counter, neighboring source numbers produce unrelated-looking identifiers.
|
|
50
|
+
IDs are stable for the same seed and source number, not cryptographic secrets.
|
|
51
|
+
"""
|
|
52
|
+
length = ID_LENGTHS[kind]
|
|
53
|
+
if number < 1 or number >= 10 ** length:
|
|
54
|
+
raise ValueError(f"{kind} ID {number} exceeds the {length}-character capacity")
|
|
55
|
+
right_digits = length // 2
|
|
56
|
+
left_base = 10 ** (length - right_digits)
|
|
57
|
+
right_base = 10 ** right_digits
|
|
58
|
+
left, right = divmod(number, right_base)
|
|
59
|
+
key = f"BHSim|{seed}|{kind}|".encode("ascii")
|
|
60
|
+
# Alternating modular additions are a bijection: distinct inputs cannot
|
|
61
|
+
# generate the same output for a given identifier kind and seed.
|
|
62
|
+
for round_number in range(4):
|
|
63
|
+
digest = hashlib.blake2b(key + bytes([round_number]) + str(right).encode("ascii"), digest_size=8).digest()
|
|
64
|
+
left = (left + int.from_bytes(digest, "big")) % left_base
|
|
65
|
+
digest = hashlib.blake2b(key + bytes([round_number + 4]) + str(left).encode("ascii"), digest_size=8).digest()
|
|
66
|
+
right = (right + int.from_bytes(digest, "big")) % right_base
|
|
67
|
+
return f"{left:0{length - right_digits}d}{right:0{right_digits}d}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
FIRST = [
|
|
71
|
+
"Alex", "Aisha", "Amara", "Amelia", "Andre", "Anika", "Arjun", "Avery", "Benjamin", "Blake",
|
|
72
|
+
"Brianna", "Caleb", "Camila", "Cameron", "Carlos", "Casey", "Charlotte", "Chloe", "Daniel", "David",
|
|
73
|
+
"Dev", "Diana", "Diego", "Elena", "Elias", "Emily", "Emma", "Ethan", "Eva", "Fatima",
|
|
74
|
+
"Gabriel", "Grace", "Hannah", "Harper", "Henry", "Isabella", "Ivy", "Jack", "Jade", "James",
|
|
75
|
+
"Jasmine", "Jayden", "Jordan", "Jose", "Joshua", "Julian", "Kai", "Kiran", "Layla", "Leo",
|
|
76
|
+
"Liam", "Lily", "Logan", "Lucas", "Luis", "Maya", "Meera", "Mia", "Miguel", "Mila",
|
|
77
|
+
"Morgan", "Naomi", "Nikhil", "Noah", "Nora", "Olivia", "Omar", "Priya", "Quinn", "Rafael",
|
|
78
|
+
"Rahul", "Riley", "Riya", "Ryan", "Sam", "Sana", "Sara", "Sofia", "Sophia", "Taylor",
|
|
79
|
+
"Theo", "Victoria", "Vivian", "William", "Yara", "Zara"
|
|
80
|
+
]
|
|
81
|
+
LAST = [
|
|
82
|
+
"Ahmed", "Ali", "Anderson", "Baker", "Banerjee", "Bennett", "Brooks", "Brown", "Campbell", "Carter",
|
|
83
|
+
"Chen", "Clark", "Collins", "Cooper", "Davis", "Diaz", "Edwards", "Evans", "Flores", "Garcia",
|
|
84
|
+
"Gonzalez", "Green", "Gupta", "Hall", "Harris", "Hernandez", "Hill", "Jackson", "Jain", "Johnson",
|
|
85
|
+
"Jones", "Kapoor", "Khan", "Kim", "Kumar", "Lee", "Lewis", "Lopez", "Martin", "Martinez",
|
|
86
|
+
"Miller", "Mitchell", "Moore", "Morgan", "Murphy", "Nguyen", "Ortiz", "Parker", "Patel", "Perez",
|
|
87
|
+
"Price", "Ramirez", "Rao", "Reed", "Rivera", "Roberts", "Robinson", "Rodriguez", "Ross", "Scott",
|
|
88
|
+
"Shah", "Sharma", "Singh", "Smith", "Stewart", "Taylor", "Thomas", "Thompson", "Turner", "Walker",
|
|
89
|
+
"Wang", "White", "Williams", "Wilson", "Wright", "Young"
|
|
90
|
+
]
|
|
91
|
+
SCHEMA = """
|
|
92
|
+
PRAGMA foreign_keys=ON;
|
|
93
|
+
CREATE TABLE individuals (individual_id TEXT PRIMARY KEY, full_name TEXT NOT NULL, date_of_birth TEXT NOT NULL, sex TEXT NOT NULL);
|
|
94
|
+
CREATE TABLE programs (program_id TEXT PRIMARY KEY, program_code TEXT UNIQUE NOT NULL, description TEXT NOT NULL, program_group TEXT NOT NULL, no_show_probability REAL NOT NULL);
|
|
95
|
+
CREATE TABLE employees (employee_id TEXT PRIMARY KEY, full_name TEXT NOT NULL, supervisor_id TEXT REFERENCES employees(employee_id));
|
|
96
|
+
CREATE TABLE visit_types (visit_type_id TEXT PRIMARY KEY, description TEXT NOT NULL, typical_minutes INTEGER NOT NULL, variation_minutes INTEGER NOT NULL);
|
|
97
|
+
CREATE TABLE locations (location_id TEXT PRIMARY KEY, location_name TEXT NOT NULL);
|
|
98
|
+
CREATE TABLE individual_programs (enrollment_id TEXT PRIMARY KEY, individual_id TEXT NOT NULL REFERENCES individuals(individual_id), program_id TEXT NOT NULL REFERENCES programs(program_id), start_date TEXT NOT NULL, end_date TEXT, CHECK(end_date IS NULL OR end_date >= start_date));
|
|
99
|
+
CREATE TABLE employee_programs (assignment_id TEXT PRIMARY KEY, employee_id TEXT NOT NULL REFERENCES employees(employee_id), program_id TEXT NOT NULL REFERENCES programs(program_id), start_date TEXT NOT NULL, end_date TEXT);
|
|
100
|
+
CREATE TABLE productivity_rules (rule_id TEXT PRIMARY KEY, program_id TEXT UNIQUE NOT NULL REFERENCES programs(program_id), interval_minutes INTEGER NOT NULL CHECK(interval_minutes>0), rule_version TEXT NOT NULL);
|
|
101
|
+
CREATE TABLE visits (visit_id TEXT PRIMARY KEY, individual_id TEXT NOT NULL REFERENCES individuals(individual_id), program_id TEXT NOT NULL REFERENCES programs(program_id), employee_id TEXT NOT NULL REFERENCES employees(employee_id), visit_type_id TEXT NOT NULL REFERENCES visit_types(visit_type_id), location_id TEXT NOT NULL REFERENCES locations(location_id), visit_status TEXT NOT NULL CHECK(visit_status IN ('COMPLETED','NO_SHOW','CANCELED','RESCHEDULED','SCHEDULED')), scheduled_start TEXT NOT NULL, scheduled_duration_minutes INTEGER NOT NULL, actual_start TEXT, actual_end TEXT, actual_duration_minutes INTEGER, documented_at TEXT, rescheduled_to_visit_id TEXT REFERENCES visits(visit_id), productivity_units INTEGER NOT NULL DEFAULT 0);
|
|
102
|
+
CREATE TABLE assessment_instruments (instrument_id TEXT PRIMARY KEY, instrument_code TEXT UNIQUE NOT NULL, instrument_version TEXT NOT NULL, item_count INTEGER NOT NULL, scoring_method TEXT NOT NULL);
|
|
103
|
+
CREATE TABLE assessments (assessment_id TEXT PRIMARY KEY, individual_id TEXT NOT NULL REFERENCES individuals(individual_id), program_id TEXT REFERENCES programs(program_id), visit_id TEXT REFERENCES visits(visit_id), instrument_id TEXT NOT NULL REFERENCES assessment_instruments(instrument_id), assessed_at TEXT NOT NULL, status TEXT NOT NULL, revision_number INTEGER NOT NULL DEFAULT 1);
|
|
104
|
+
CREATE TABLE assessment_responses (response_id TEXT PRIMARY KEY, question_id TEXT NOT NULL REFERENCES assessment_questions(question_id), assessment_id TEXT NOT NULL REFERENCES assessments(assessment_id), item_number INTEGER NOT NULL, option_id TEXT NOT NULL REFERENCES answer_options(option_id), response_value INTEGER NOT NULL CHECK(response_value BETWEEN 0 AND 3), UNIQUE (assessment_id,item_number));
|
|
105
|
+
CREATE TABLE assessment_scores (score_id TEXT PRIMARY KEY, assessment_id TEXT UNIQUE NOT NULL REFERENCES assessments(assessment_id), total_score INTEGER NOT NULL, scoring_version TEXT NOT NULL);
|
|
106
|
+
CREATE TABLE assessment_questions (question_id TEXT PRIMARY KEY, instrument_id TEXT NOT NULL REFERENCES assessment_instruments(instrument_id), item_number INTEGER NOT NULL, question_text TEXT NOT NULL, UNIQUE(instrument_id,item_number));
|
|
107
|
+
CREATE TABLE answer_options (option_id TEXT PRIMARY KEY, instrument_id TEXT NOT NULL REFERENCES assessment_instruments(instrument_id), option_text TEXT NOT NULL, score_value INTEGER NOT NULL CHECK(score_value BETWEEN 0 AND 3), UNIQUE(instrument_id,score_value));
|
|
108
|
+
CREATE VIEW assessment_responses_readable AS SELECT a.assessment_id, a.individual_id, i.instrument_code, a.assessed_at, q.item_number, q.question_text, o.option_text AS selected_answer, o.score_value FROM assessment_responses r JOIN assessments a ON a.assessment_id=r.assessment_id JOIN assessment_instruments i ON i.instrument_id=a.instrument_id JOIN assessment_questions q ON q.question_id=r.question_id JOIN answer_options o ON o.option_id=r.option_id;
|
|
109
|
+
CREATE TABLE employee_supervision (supervision_id TEXT PRIMARY KEY, employee_id TEXT NOT NULL REFERENCES employees(employee_id), supervisor_id TEXT NOT NULL REFERENCES employees(employee_id), start_date TEXT NOT NULL, end_date TEXT, CHECK(employee_id != supervisor_id));
|
|
110
|
+
CREATE TABLE visit_productivity (productivity_id TEXT PRIMARY KEY, visit_id TEXT UNIQUE NOT NULL REFERENCES visits(visit_id), rule_id TEXT REFERENCES productivity_rules(rule_id), productivity_units INTEGER NOT NULL);
|
|
111
|
+
CREATE TABLE simulation_metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
112
|
+
CREATE INDEX idx_visits_individual_date ON visits(individual_id,scheduled_start);
|
|
113
|
+
CREATE INDEX idx_visits_program_date ON visits(program_id,scheduled_start);
|
|
114
|
+
CREATE INDEX idx_assessments_individual_date ON assessments(individual_id,assessed_at);
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
ID_COLUMNS = {'individuals': ['individual_id'], 'programs': ['program_id'], 'employees': ['employee_id', 'supervisor_id'], 'locations': ['location_id'], 'visit_types': ['visit_type_id'], 'individual_programs': ['enrollment_id', 'individual_id', 'program_id'], 'employee_programs': ['assignment_id', 'employee_id', 'program_id'], 'employee_supervision': ['supervision_id', 'employee_id', 'supervisor_id'], 'productivity_rules': ['rule_id', 'program_id'], 'visits': ['visit_id', 'individual_id', 'program_id', 'employee_id', 'visit_type_id', 'location_id', 'rescheduled_to_visit_id'], 'visit_productivity': ['productivity_id', 'visit_id', 'rule_id'], 'assessment_instruments': ['instrument_id'], 'assessment_questions': ['question_id', 'instrument_id'], 'assessments': ['assessment_id', 'individual_id', 'program_id', 'visit_id', 'instrument_id'], 'assessment_responses': ['response_id', 'question_id', 'assessment_id', 'option_id'], 'answer_options': ['option_id', 'instrument_id'], 'assessment_scores': ['score_id', 'assessment_id']}
|
|
119
|
+
ID_KINDS = {'individual_id': 'individual', 'program_id': 'program', 'employee_id': 'employee', 'supervisor_id': 'employee', 'location_id': 'location', 'visit_type_id': 'visit_type', 'enrollment_id': 'enrollment', 'assignment_id': 'assignment', 'supervision_id': 'supervision', 'rule_id': 'rule', 'visit_id': 'visit', 'rescheduled_to_visit_id': 'visit', 'productivity_id': 'productivity', 'instrument_id': 'instrument', 'question_id': 'question', 'assessment_id': 'assessment', 'response_id': 'response', 'score_id': 'score', 'option_id': 'option'}
|
|
120
|
+
|
|
121
|
+
def _pick_datetime(rng: random.Random, earliest: date, latest: date) -> datetime:
|
|
122
|
+
day = earliest + timedelta(days=rng.randint(0, max((latest-earliest).days, 0)))
|
|
123
|
+
return datetime(day.year,day.month,day.day,rng.randint(8,17),rng.choice([0,15,30,45]))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---PROGRAM OPERATING HOURS---
|
|
128
|
+
# Weekday: Monday=0, Sunday=6. Hours are local wall-clock hours.
|
|
129
|
+
PROGRAM_HOURS = {
|
|
130
|
+
1: (set(range(5)), 8, 18),
|
|
131
|
+
2: (set(range(5)), 8, 18),
|
|
132
|
+
3: (set(range(7)), 0, 24), # Crisis: 24/7
|
|
133
|
+
4: (set(range(6)), 8, 19),
|
|
134
|
+
5: (set(range(6)), 8, 19),
|
|
135
|
+
6: (set(range(6)), 8, 19),
|
|
136
|
+
7: (set(range(5)), 8, 17),
|
|
137
|
+
8: (set(range(5)), 8, 17),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _program_slot(rng, program_number, earliest, latest, duration, after=None):
|
|
142
|
+
"""Sample a valid slot (end-exclusive) inside program operating hours."""
|
|
143
|
+
days, opening, closing = PROGRAM_HOURS[program_number]
|
|
144
|
+
start_day = max(earliest, after.date() if after else earliest)
|
|
145
|
+
eligible = []
|
|
146
|
+
day = start_day
|
|
147
|
+
while day <= latest:
|
|
148
|
+
if day.weekday() in days:
|
|
149
|
+
base = datetime.combine(day, datetime.min.time())
|
|
150
|
+
minimum = base + timedelta(hours=opening)
|
|
151
|
+
maximum = base + timedelta(hours=closing, minutes=-duration)
|
|
152
|
+
if after is not None:
|
|
153
|
+
minimum = max(minimum, after + timedelta(minutes=15))
|
|
154
|
+
# Quarter-hour start times. Ceiling to next quarter.
|
|
155
|
+
minimum += timedelta(minutes=(-minimum.minute) % 15, seconds=-minimum.second, microseconds=-minimum.microsecond)
|
|
156
|
+
if minimum <= maximum:
|
|
157
|
+
eligible.append((minimum, maximum))
|
|
158
|
+
day += timedelta(days=1)
|
|
159
|
+
if not eligible:
|
|
160
|
+
raise ValueError(f"No operating slot for program {program_number} between {start_day} and {latest}")
|
|
161
|
+
first, last = rng.choice(eligible)
|
|
162
|
+
return first + timedelta(minutes=15 * rng.randrange(int((last-first).total_seconds()//900)+1))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _visit_within_hours(visit, program_index):
|
|
166
|
+
days, opening, closing = PROGRAM_HOURS[program_index[visit[2]]]
|
|
167
|
+
start = datetime.fromisoformat(visit[9] or visit[7])
|
|
168
|
+
length = (datetime.fromisoformat(visit[10])-start).total_seconds()/60 if visit[10] else visit[8]
|
|
169
|
+
end = start + timedelta(minutes=length)
|
|
170
|
+
return (start.weekday() in days and start.date()==end.date()
|
|
171
|
+
and start.hour*60+start.minute >= opening*60
|
|
172
|
+
and end.hour*60+end.minute <= closing*60)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _resolve_completed_staff_overlaps(visits, by_program, program_index):
|
|
176
|
+
"""Prefer an assigned, available employee for overlapping completed visits."""
|
|
177
|
+
from collections import defaultdict
|
|
178
|
+
busy = defaultdict(list)
|
|
179
|
+
for visit in sorted((v for v in visits if v[6] == "COMPLETED"), key=lambda v: (v[9], v[0])):
|
|
180
|
+
begin = datetime.fromisoformat(visit[9])
|
|
181
|
+
end = datetime.fromisoformat(visit[10])
|
|
182
|
+
eligible = by_program[program_index[visit[2]]]
|
|
183
|
+
alternatives = [visit[3]] + [emp for emp in eligible if emp != visit[3]]
|
|
184
|
+
for employee in alternatives:
|
|
185
|
+
if all(end <= left or begin >= right for left, right in busy[employee]):
|
|
186
|
+
visit[3] = employee
|
|
187
|
+
busy[employee].append((begin, end))
|
|
188
|
+
break
|
|
189
|
+
else:
|
|
190
|
+
# No available colleague: adjust only this encounter to a free slot
|
|
191
|
+
# after its planned time. Do not alter visit date or client identity.
|
|
192
|
+
days, opening, closing = PROGRAM_HOURS[program_index[visit[2]]]
|
|
193
|
+
base = datetime.combine(begin.date(), datetime.min.time())
|
|
194
|
+
service_minutes = int((end - begin).total_seconds() / 60)
|
|
195
|
+
earliest = base + timedelta(hours=opening)
|
|
196
|
+
latest = base + timedelta(hours=closing, minutes=-service_minutes)
|
|
197
|
+
possible = []
|
|
198
|
+
trial = earliest
|
|
199
|
+
while trial <= latest:
|
|
200
|
+
possible.append(trial)
|
|
201
|
+
trial += timedelta(minutes=15)
|
|
202
|
+
for trial in sorted(possible, key=lambda x: abs((x-begin).total_seconds())):
|
|
203
|
+
finish = trial + (end-begin)
|
|
204
|
+
if all(finish <= left or trial >= right for left,right in busy[visit[3]]):
|
|
205
|
+
delta = trial-begin
|
|
206
|
+
visit[7] = max(earliest, datetime.fromisoformat(visit[7])+delta).isoformat(timespec="minutes")
|
|
207
|
+
visit[9] = trial.isoformat(timespec="minutes")
|
|
208
|
+
visit[10] = finish.isoformat(timespec="minutes")
|
|
209
|
+
visit[12] = (datetime.fromisoformat(visit[12])+delta).isoformat(timespec="minutes")
|
|
210
|
+
busy[visit[3]].append((trial,finish))
|
|
211
|
+
break
|
|
212
|
+
else:
|
|
213
|
+
raise ValueError("Insufficient same-day staff capacity for completed visits")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def generate(path: str | Path, config: Config | None = None) -> dict:
|
|
218
|
+
"""Generate a new SQLite database. Existing destinations are refused."""
|
|
219
|
+
config = config or Config()
|
|
220
|
+
config.check()
|
|
221
|
+
target = Path(path)
|
|
222
|
+
if target.exists():
|
|
223
|
+
raise FileExistsError(f"Refusing to overwrite {target}")
|
|
224
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
rng = random.Random(config.seed)
|
|
226
|
+
make_id = lambda kind, number: globals()["make_id"](kind, number, config.seed)
|
|
227
|
+
program_index = {make_id("program", row[0]): row[0] for row in PROGRAMS}
|
|
228
|
+
today = date.fromisoformat(config.reference_date)
|
|
229
|
+
start = today - timedelta(days=round(config.months * 30.44))
|
|
230
|
+
counts = {}
|
|
231
|
+
try:
|
|
232
|
+
with sqlite_closing(sqlite3.connect(target)) as con, con:
|
|
233
|
+
con.executescript(SCHEMA)
|
|
234
|
+
con.executemany("INSERT INTO programs VALUES (?,?,?,?,?)",[(make_id("program",p[0]),*p[1:]) for p in PROGRAMS])
|
|
235
|
+
con.executemany("INSERT INTO visit_types VALUES (?,?,?,?)", [(make_id("visit_type",v[0]),*v[1:]) for v in VISIT_TYPES])
|
|
236
|
+
con.executemany("INSERT INTO locations VALUES (?,?)", [(make_id("location",i),name) for i,name in [(1,"North Center"),(2,"South Center"),(3,"Telehealth")]])
|
|
237
|
+
con.executemany("INSERT INTO productivity_rules VALUES (?,?,?,?)",[(make_id("rule",p[0]),make_id("program",p[0]),15,"demo-v1") for p in PROGRAMS])
|
|
238
|
+
individuals=[]
|
|
239
|
+
for i in range(1, config.clients+1):
|
|
240
|
+
dob = today - timedelta(days=rng.randint(16*365,80*365))
|
|
241
|
+
individuals.append((make_id("individual",i), f"{rng.choice(FIRST)} {rng.choice(LAST)}",dob.isoformat(),rng.choice(["F","M","X"])))
|
|
242
|
+
con.executemany("INSERT INTO individuals VALUES (?,?,?,?)",individuals)
|
|
243
|
+
employees=[]
|
|
244
|
+
for i in range(1,config.employees+1):
|
|
245
|
+
employees.append((make_id("employee",i),f"{rng.choice(FIRST)} {rng.choice(LAST)}",make_id("employee",rng.randint(1,i-1)) if i>1 and rng.random()<.8 else None))
|
|
246
|
+
con.executemany("INSERT INTO employees VALUES (?,?,?)",employees)
|
|
247
|
+
employee_assignments=[]
|
|
248
|
+
by_program={p[0]:[] for p in PROGRAMS}
|
|
249
|
+
for e in range(1,config.employees+1):
|
|
250
|
+
selected=rng.sample([p[0] for p in PROGRAMS],k=rng.randint(1, min(3,len(PROGRAMS))))
|
|
251
|
+
for p in selected:
|
|
252
|
+
employee_assignments.append((make_id("assignment",len(employee_assignments)+1),make_id("employee",e),make_id("program",p),start.isoformat(),None))
|
|
253
|
+
by_program[p].append(make_id("employee",e))
|
|
254
|
+
# Guarantee that each program has at least one assigned worker.
|
|
255
|
+
for p,staff in by_program.items():
|
|
256
|
+
if not staff:
|
|
257
|
+
employee_assignments.append((make_id("assignment",len(employee_assignments)+1),make_id("employee",1),make_id("program",p),start.isoformat(),None))
|
|
258
|
+
by_program[p].append(make_id("employee",1))
|
|
259
|
+
con.executemany("INSERT INTO employee_programs VALUES (?,?,?,?,?)",employee_assignments)
|
|
260
|
+
supervisors=[]
|
|
261
|
+
for e,_,supervisor in employees:
|
|
262
|
+
if supervisor:
|
|
263
|
+
supervisors.append((make_id("supervision",len(supervisors)+1),e,supervisor,start.isoformat(),None))
|
|
264
|
+
con.executemany("INSERT INTO employee_supervision VALUES (?,?,?,?,?)",supervisors)
|
|
265
|
+
# ---CLIENT ENROLLMENT LIFECYCLES---
|
|
266
|
+
enrollments = []
|
|
267
|
+
client_programs = {}
|
|
268
|
+
enrollment_episodes = {}
|
|
269
|
+
history_days = (today - start).days
|
|
270
|
+
for i in range(1, config.clients + 1):
|
|
271
|
+
chosen = rng.sample([p[0] for p in PROGRAMS if p[0] != 3], k=rng.randint(1, 3))
|
|
272
|
+
client_programs[i] = [make_id("program", p) for p in chosen]
|
|
273
|
+
enrollment_episodes[i] = []
|
|
274
|
+
for program_number in chosen:
|
|
275
|
+
program_id = make_id("program", program_number)
|
|
276
|
+
# Leave at least four weeks of potential encounter history.
|
|
277
|
+
admission = start + timedelta(days=rng.randint(0, max(0, history_days - 35)))
|
|
278
|
+
remaining = (today - admission).days
|
|
279
|
+
discharge = None
|
|
280
|
+
if remaining >= 100 and rng.random() < 0.55:
|
|
281
|
+
discharge = admission + timedelta(days=rng.randint(42, remaining - 30))
|
|
282
|
+
episodes = [(admission, discharge)]
|
|
283
|
+
if discharge is not None and (today - discharge).days >= 50 and rng.random() < 0.40:
|
|
284
|
+
gap = rng.randint(15, (today - discharge).days - 25)
|
|
285
|
+
episodes.append((discharge + timedelta(days=gap), None))
|
|
286
|
+
for episode_start, episode_end in episodes:
|
|
287
|
+
enrollments.append((
|
|
288
|
+
make_id("enrollment", len(enrollments) + 1),
|
|
289
|
+
make_id("individual", i), program_id,
|
|
290
|
+
episode_start.isoformat(),
|
|
291
|
+
episode_end.isoformat() if episode_end else None,
|
|
292
|
+
))
|
|
293
|
+
enrollment_episodes[i].append((program_id, episode_start, episode_end))
|
|
294
|
+
con.executemany("INSERT INTO individual_programs VALUES (?,?,?,?,?)", enrollments)
|
|
295
|
+
visits=[]
|
|
296
|
+
completed_by_client={}
|
|
297
|
+
visit_id=0
|
|
298
|
+
visit_episode_end = {}
|
|
299
|
+
for i in range(1,config.clients+1):
|
|
300
|
+
for _ in range(rng.randint(3, min(25,max(4,config.months*2)))):
|
|
301
|
+
# ---SCHEDULE WITHIN ONE VALID ENROLLMENT EPISODE---
|
|
302
|
+
eligible = [
|
|
303
|
+
episode for episode in enrollment_episodes[i]
|
|
304
|
+
if (min(episode[2] or today, today) - episode[1]).days >= 21
|
|
305
|
+
]
|
|
306
|
+
if not eligible:
|
|
307
|
+
continue
|
|
308
|
+
p, episode_start, episode_end = rng.choice(eligible)
|
|
309
|
+
vt = rng.choice(VISIT_TYPES)
|
|
310
|
+
last_day = min(episode_end or today, today)
|
|
311
|
+
# Historical visits are at least 14 days before the episode ends
|
|
312
|
+
# so that replacements can occur within the same enrollment.
|
|
313
|
+
duration_buffer = max(vt[2] + 90, 180)
|
|
314
|
+
scheduled = _program_slot(
|
|
315
|
+
rng, program_index[p], episode_start,
|
|
316
|
+
last_day - timedelta(days=14), duration_buffer,
|
|
317
|
+
)
|
|
318
|
+
r=rng.random()
|
|
319
|
+
probability=PROGRAMS[program_index[p]-1][4]
|
|
320
|
+
status="NO_SHOW" if r<probability else "CANCELED" if r<probability+.08 else "RESCHEDULED" if r<probability+.11 else "COMPLETED"
|
|
321
|
+
duration=max(10,round(rng.gauss(vt[2],vt[3])))
|
|
322
|
+
if status == "COMPLETED":
|
|
323
|
+
actual_start = scheduled + timedelta(minutes=rng.randint(-5, 12))
|
|
324
|
+
_, opening, closing = PROGRAM_HOURS[program_index[p]]
|
|
325
|
+
open_time = datetime.combine(scheduled.date(), datetime.min.time()) + timedelta(hours=opening)
|
|
326
|
+
close_time = datetime.combine(scheduled.date(), datetime.min.time()) + timedelta(hours=closing)
|
|
327
|
+
actual_start = max(open_time, actual_start)
|
|
328
|
+
duration = min(duration, int((close_time - actual_start).total_seconds() // 60))
|
|
329
|
+
else:
|
|
330
|
+
actual_start = None
|
|
331
|
+
actual_end=actual_start+timedelta(minutes=duration) if actual_start else None
|
|
332
|
+
visit_id+=1
|
|
333
|
+
visits.append([make_id("visit",visit_id),make_id("individual",i),p,rng.choice(by_program[program_index[p]]),make_id("visit_type",vt[0]),make_id("location",rng.randint(1,3)),status,scheduled.isoformat(timespec="minutes"),vt[2],actual_start.isoformat(timespec="minutes") if actual_start else None,actual_end.isoformat(timespec="minutes") if actual_end else None,duration if actual_start else None,(actual_end+timedelta(days=rng.randint(0,3))).isoformat(timespec="minutes") if actual_end else None,None,(duration+7)//15 if actual_start else 0])
|
|
334
|
+
visit_episode_end[visits[-1][0]] = episode_end
|
|
335
|
+
if status=="COMPLETED":
|
|
336
|
+
completed_by_client.setdefault(i,[]).append(visits[-1])
|
|
337
|
+
for _ in range(rng.randint(0,max(1,config.future_days//20))):
|
|
338
|
+
active = [p for p, admission, discharge in enrollment_episodes[i]
|
|
339
|
+
if admission <= today and discharge is None]
|
|
340
|
+
if not active:
|
|
341
|
+
continue
|
|
342
|
+
p = rng.choice(active)
|
|
343
|
+
vt = rng.choice(VISIT_TYPES)
|
|
344
|
+
sched=_program_slot(rng,program_index[p],today+timedelta(days=1),today+timedelta(days=max(1,config.future_days)),vt[2]) if config.future_days else None
|
|
345
|
+
if sched is None: continue
|
|
346
|
+
visit_id+=1
|
|
347
|
+
visits.append([make_id("visit",visit_id),make_id("individual",i),p,rng.choice(by_program[program_index[p]]),make_id("visit_type",vt[0]),make_id("location",rng.randint(1,3)),"SCHEDULED",sched.isoformat(timespec="minutes"),vt[2],None,None,None,None,None,0])
|
|
348
|
+
# ---RESCHEDULED APPOINTMENTS---
|
|
349
|
+
original_rescheduled = [v for v in visits if v[6] == "RESCHEDULED"]
|
|
350
|
+
for original in original_rescheduled:
|
|
351
|
+
original_date = datetime.fromisoformat(original[7])
|
|
352
|
+
visit_duration = next(vt[2] for vt in VISIT_TYPES if make_id("visit_type", vt[0]) == original[4])
|
|
353
|
+
sched = _program_slot(
|
|
354
|
+
rng, program_index[original[2]],
|
|
355
|
+
original_date.date() + timedelta(days=1),
|
|
356
|
+
min(original_date.date() + timedelta(days=14), visit_episode_end[original[0]] or (today + timedelta(days=14))),
|
|
357
|
+
max(visit_duration + 90, 180),
|
|
358
|
+
after=original_date,
|
|
359
|
+
)
|
|
360
|
+
visit_id += 1
|
|
361
|
+
if sched.date() > today:
|
|
362
|
+
status = "SCHEDULED"
|
|
363
|
+
else:
|
|
364
|
+
probability = PROGRAMS[program_index[original[2]] - 1][4]
|
|
365
|
+
outcome = rng.random()
|
|
366
|
+
status = (
|
|
367
|
+
"NO_SHOW" if outcome < probability
|
|
368
|
+
else "CANCELED" if outcome < probability + 0.08
|
|
369
|
+
else "COMPLETED"
|
|
370
|
+
)
|
|
371
|
+
actual_start = None
|
|
372
|
+
actual_end = None
|
|
373
|
+
actual_duration = None
|
|
374
|
+
documented_at = None
|
|
375
|
+
units = 0
|
|
376
|
+
if status == "COMPLETED":
|
|
377
|
+
visit_type_number = next(
|
|
378
|
+
vt for vt in VISIT_TYPES
|
|
379
|
+
if make_id("visit_type", vt[0]) == original[4]
|
|
380
|
+
)
|
|
381
|
+
actual_duration = max(10, round(rng.gauss(visit_type_number[2], visit_type_number[3])))
|
|
382
|
+
actual_start = sched + timedelta(minutes=rng.randint(-5, 12))
|
|
383
|
+
_, opening, closing = PROGRAM_HOURS[program_index[original[2]]]
|
|
384
|
+
open_time = datetime.combine(sched.date(), datetime.min.time()) + timedelta(hours=opening)
|
|
385
|
+
close_time = datetime.combine(sched.date(), datetime.min.time()) + timedelta(hours=closing)
|
|
386
|
+
actual_start = max(open_time, actual_start)
|
|
387
|
+
actual_duration = min(actual_duration, int((close_time - actual_start).total_seconds() // 60))
|
|
388
|
+
actual_end = actual_start + timedelta(minutes=actual_duration)
|
|
389
|
+
documented_at = actual_end + timedelta(days=rng.randint(0, 3))
|
|
390
|
+
units = (actual_duration + 7) // 15
|
|
391
|
+
replacement = [
|
|
392
|
+
make_id("visit", visit_id), original[1], original[2],
|
|
393
|
+
original[3], original[4], original[5], status,
|
|
394
|
+
sched.isoformat(timespec="minutes"), original[8],
|
|
395
|
+
actual_start.isoformat(timespec="minutes") if actual_start else None,
|
|
396
|
+
actual_end.isoformat(timespec="minutes") if actual_end else None,
|
|
397
|
+
actual_duration,
|
|
398
|
+
documented_at.isoformat(timespec="minutes") if documented_at else None,
|
|
399
|
+
None, units,
|
|
400
|
+
]
|
|
401
|
+
original[13] = replacement[0]
|
|
402
|
+
visits.append(replacement)
|
|
403
|
+
_resolve_completed_staff_overlaps(visits, by_program, program_index)
|
|
404
|
+
# Insert all rows before assigning self-referential reschedule links.
|
|
405
|
+
links=[(v[13],v[0]) for v in visits if v[13] is not None]
|
|
406
|
+
for v in visits:
|
|
407
|
+
v[13]=None
|
|
408
|
+
con.executemany("INSERT INTO visits VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",visits)
|
|
409
|
+
con.executemany("UPDATE visits SET rescheduled_to_visit_id=? WHERE visit_id=?",links)
|
|
410
|
+
# ---ASSESSMENT INSTRUMENT CATALOG---
|
|
411
|
+
instruments = [(1, "PHQ9", "v1", 9, "SUM_COMPLETE_ITEMS"),
|
|
412
|
+
(2, "GAD7", "v1", 7, "SUM_COMPLETE_ITEMS")]
|
|
413
|
+
con.executemany("INSERT INTO assessment_instruments VALUES (?,?,?,?,?)",
|
|
414
|
+
[(make_id("instrument", n), code, version, count, method)
|
|
415
|
+
for n, code, version, count, method in instruments])
|
|
416
|
+
|
|
417
|
+
phq9 = [
|
|
418
|
+
"Little interest or pleasure in doing things",
|
|
419
|
+
"Feeling down, depressed, or hopeless",
|
|
420
|
+
"Trouble falling or staying asleep, or sleeping too much",
|
|
421
|
+
"Feeling tired or having little energy",
|
|
422
|
+
"Poor appetite or overeating",
|
|
423
|
+
"Feeling bad about yourself, or that you are a failure or have let yourself or your family down",
|
|
424
|
+
"Trouble concentrating on things, such as reading the newspaper or watching television",
|
|
425
|
+
"Moving or speaking so slowly that other people could have noticed, or the opposite—being fidgety or restless",
|
|
426
|
+
"Thoughts that you would be better off dead or of hurting yourself in some way",
|
|
427
|
+
]
|
|
428
|
+
gad7 = [
|
|
429
|
+
"Feeling nervous, anxious, or on edge",
|
|
430
|
+
"Not being able to stop or control worrying",
|
|
431
|
+
"Worrying too much about different things",
|
|
432
|
+
"Trouble relaxing",
|
|
433
|
+
"Being so restless that it is hard to sit still",
|
|
434
|
+
"Becoming easily annoyed or irritable",
|
|
435
|
+
"Feeling afraid as if something awful might happen",
|
|
436
|
+
]
|
|
437
|
+
option_texts = ["Not at all", "Several days", "More than half the days", "Nearly every day"]
|
|
438
|
+
question_lookup, option_lookup = {}, {}
|
|
439
|
+
question_rows, option_rows = [], []
|
|
440
|
+
for n, texts in [(1, phq9), (2, gad7)]:
|
|
441
|
+
for item, prompt in enumerate(texts, 1):
|
|
442
|
+
qid = make_id("question", len(question_rows) + 1)
|
|
443
|
+
question_rows.append((qid, make_id("instrument", n), item, prompt))
|
|
444
|
+
question_lookup[(n, item)] = qid
|
|
445
|
+
for score, text in enumerate(option_texts):
|
|
446
|
+
oid = make_id("option", len(option_rows) + 1)
|
|
447
|
+
option_rows.append((oid, make_id("instrument", n), text, score))
|
|
448
|
+
option_lookup[(n, score)] = oid
|
|
449
|
+
con.executemany("INSERT INTO assessment_questions VALUES (?,?,?,?)", question_rows)
|
|
450
|
+
con.executemany("INSERT INTO answer_options VALUES (?,?,?,?)", option_rows)
|
|
451
|
+
|
|
452
|
+
# ---LONGITUDINAL SYNTHETIC SCREENING---
|
|
453
|
+
assessments, responses, scores = [], [], []
|
|
454
|
+
assessment_id = response_id = score_id = 0
|
|
455
|
+
for i in range(1, config.clients + 1):
|
|
456
|
+
client_visits = sorted(completed_by_client.get(i, []), key=lambda v: v[9])
|
|
457
|
+
if not client_visits:
|
|
458
|
+
continue
|
|
459
|
+
for n, item_count in [(1, 9), (2, 7)]:
|
|
460
|
+
if n == 2 and rng.random() < 0.25:
|
|
461
|
+
continue
|
|
462
|
+
severity = rng.randint(3, item_count * 3 - 3)
|
|
463
|
+
direction = rng.choice([-1, -1, 0, 0, 1])
|
|
464
|
+
target_count = min(len(client_visits), rng.randint(1, 4))
|
|
465
|
+
positions = sorted(rng.sample(range(len(client_visits)), target_count))
|
|
466
|
+
for order, position in enumerate(positions):
|
|
467
|
+
v = client_visits[position]
|
|
468
|
+
assessment_id += 1
|
|
469
|
+
aid = make_id("assessment", assessment_id)
|
|
470
|
+
level = max(0, min(item_count * 3, severity + 2 * direction * order + rng.randint(-3, 3)))
|
|
471
|
+
values = [0] * item_count
|
|
472
|
+
for _ in range(level):
|
|
473
|
+
available = [j for j, val in enumerate(values) if val < 3]
|
|
474
|
+
values[rng.choice(available)] += 1
|
|
475
|
+
complete = rng.random() >= 0.08
|
|
476
|
+
answered = item_count if complete else rng.randint(1, item_count - 1)
|
|
477
|
+
actual_begin = datetime.fromisoformat(v[9])
|
|
478
|
+
actual_end = datetime.fromisoformat(v[10])
|
|
479
|
+
assessed_at = actual_begin + (actual_end - actual_begin) / 2
|
|
480
|
+
linked_visit = v[0] if rng.random() < 0.85 else None
|
|
481
|
+
assessments.append((aid, make_id("individual", i), v[2], linked_visit,
|
|
482
|
+
make_id("instrument", n), assessed_at.isoformat(timespec="minutes"),
|
|
483
|
+
"COMPLETE" if complete else "INCOMPLETE", 1))
|
|
484
|
+
for item, value in enumerate(values[:answered], 1):
|
|
485
|
+
response_id += 1
|
|
486
|
+
responses.append((make_id("response", response_id),
|
|
487
|
+
question_lookup[(n, item)], aid, item, option_lookup[(n, value)], value))
|
|
488
|
+
if complete:
|
|
489
|
+
score_id += 1
|
|
490
|
+
scores.append((make_id("score", score_id), aid, sum(values),
|
|
491
|
+
"PHQ9_SUM_V1" if n == 1 else "GAD7_SUM_V1"))
|
|
492
|
+
con.executemany("INSERT INTO assessments VALUES (?,?,?,?,?,?,?,?)", assessments)
|
|
493
|
+
con.executemany("INSERT INTO assessment_responses VALUES (?,?,?,?,?,?)", responses)
|
|
494
|
+
con.executemany("INSERT INTO assessment_scores VALUES (?,?,?,?)", scores)
|
|
495
|
+
con.executemany("INSERT INTO visit_productivity VALUES (?,?,?,?)",[
|
|
496
|
+
(make_id("productivity",n),v[0],make_id("rule",program_index[v[2]]),v[14])
|
|
497
|
+
for n,v in enumerate(visits,1)])
|
|
498
|
+
con.executemany("INSERT INTO simulation_metadata VALUES (?,?)",[("config",json.dumps(asdict(config),sort_keys=True)),("engine_version","1.0.0rc2"),("synthetic_only","true"),("instrument_scope","PHQ-9 and GAD-7; complete-case summation only; synthetic screening data"),("id_lengths",json.dumps(ID_LENGTHS,sort_keys=True)),("id_scheme","seeded-numeric-permutation-v1")])
|
|
499
|
+
counts={t:con.execute(f'SELECT COUNT(*) FROM {t}').fetchone()[0] for t in ["individuals","programs","employees","individual_programs","employee_programs","visits","assessments","assessment_responses"]}
|
|
500
|
+
except Exception:
|
|
501
|
+
try:
|
|
502
|
+
target.unlink(missing_ok=True)
|
|
503
|
+
except PermissionError:
|
|
504
|
+
pass # Preserve the original generator error on Windows.
|
|
505
|
+
raise
|
|
506
|
+
return counts
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def validate(path: str | Path) -> dict:
|
|
510
|
+
"""Independently check relational and temporal invariants in the output."""
|
|
511
|
+
issues=[]
|
|
512
|
+
with sqlite_closing(sqlite3.connect(path)) as con:
|
|
513
|
+
con.execute("PRAGMA foreign_keys=ON")
|
|
514
|
+
fk=con.execute("PRAGMA foreign_key_check").fetchall()
|
|
515
|
+
if fk: issues.append(f"Foreign key failures: {len(fk)}")
|
|
516
|
+
# Check every primary and foreign ID for fixed-width numeric TEXT storage.
|
|
517
|
+
for table, cols in ID_COLUMNS.items():
|
|
518
|
+
for col in cols:
|
|
519
|
+
kind=ID_KINDS[col]
|
|
520
|
+
n=con.execute(f"SELECT COUNT(*) FROM {table} WHERE {col} IS NOT NULL AND (typeof({col})!='text' OR length({col})!=? OR {col} GLOB '*[^0-9]*')", (ID_LENGTHS[kind],)).fetchone()[0]
|
|
521
|
+
if n: issues.append(f"invalid_id_format {table}.{col}: {n}")
|
|
522
|
+
checks={
|
|
523
|
+
"incorrect_productivity": "SELECT COUNT(*) FROM visits WHERE productivity_units != CASE WHEN visit_status='COMPLETED' THEN (actual_duration_minutes+7)/15 ELSE 0 END",
|
|
524
|
+
"invalid_actual_data": "SELECT COUNT(*) FROM visits WHERE (visit_status='COMPLETED' AND (actual_start IS NULL OR actual_end IS NULL OR actual_duration_minutes<=0)) OR (visit_status!='COMPLETED' AND (actual_start IS NOT NULL OR actual_end IS NOT NULL OR actual_duration_minutes IS NOT NULL))",
|
|
525
|
+
"invalid_reschedule": "SELECT COUNT(*) FROM visits a LEFT JOIN visits b ON a.rescheduled_to_visit_id=b.visit_id WHERE (a.visit_status='RESCHEDULED' AND (b.visit_id IS NULL OR a.individual_id!=b.individual_id OR a.visit_id=b.visit_id)) OR (a.visit_status!='RESCHEDULED' AND a.rescheduled_to_visit_id IS NOT NULL)",
|
|
526
|
+
"invalid_employee_program": "SELECT COUNT(*) FROM visits v WHERE NOT EXISTS (SELECT 1 FROM employee_programs ep WHERE ep.employee_id=v.employee_id AND ep.program_id=v.program_id AND date(v.scheduled_start) >= ep.start_date AND (ep.end_date IS NULL OR date(v.scheduled_start)<=ep.end_date))",
|
|
527
|
+
"incorrect_assessment_score": "SELECT COUNT(*) FROM assessment_scores s JOIN assessments a ON s.assessment_id=a.assessment_id JOIN assessment_instruments i ON a.instrument_id=i.instrument_id WHERE s.total_score != (SELECT SUM(response_value) FROM assessment_responses r WHERE r.assessment_id=s.assessment_id) OR (SELECT COUNT(*) FROM assessment_responses r WHERE r.assessment_id=s.assessment_id)!=i.item_count OR a.status!='COMPLETE'",
|
|
528
|
+
"assessment_option_mismatch": "SELECT COUNT(*) FROM assessment_responses r JOIN assessments a ON r.assessment_id=a.assessment_id JOIN answer_options o ON o.option_id=r.option_id WHERE a.instrument_id!=o.instrument_id OR o.score_value!=r.response_value",
|
|
529
|
+
"assessment_response_question_mismatch": "SELECT COUNT(*) FROM assessment_responses r JOIN assessments a ON r.assessment_id=a.assessment_id JOIN assessment_questions q ON q.question_id=r.question_id WHERE a.instrument_id!=q.instrument_id OR q.item_number!=r.item_number",
|
|
530
|
+
"invalid_question_count": "SELECT COUNT(*) FROM assessments a JOIN assessment_instruments i ON i.instrument_id=a.instrument_id WHERE (a.status='COMPLETE' AND (SELECT COUNT(*) FROM assessment_responses r WHERE r.assessment_id=a.assessment_id)!=i.item_count) OR (a.status='INCOMPLETE' AND (SELECT COUNT(*) FROM assessment_responses r WHERE r.assessment_id=a.assessment_id)>=i.item_count)",
|
|
531
|
+
"incomplete_assessment_scored": "SELECT COUNT(*) FROM assessments a JOIN assessment_scores s ON a.assessment_id=s.assessment_id WHERE a.status!='COMPLETE'",
|
|
532
|
+
"missing_complete_scores": "SELECT COUNT(*) FROM assessments a LEFT JOIN assessment_scores s ON a.assessment_id=s.assessment_id WHERE a.status='COMPLETE' AND s.score_id IS NULL",
|
|
533
|
+
"assessment_visit_mismatch": "SELECT COUNT(*) FROM assessments a JOIN visits v ON a.visit_id=v.visit_id WHERE a.individual_id!=v.individual_id OR a.program_id!=v.program_id OR v.visit_status!='COMPLETED'",
|
|
534
|
+
"future_completed": "SELECT COUNT(*) FROM visits WHERE visit_status='COMPLETED' AND date(scheduled_start)>(SELECT json_extract(value,'$.reference_date') FROM simulation_metadata WHERE key='config')",
|
|
535
|
+
"visits_outside_enrollment": "SELECT COUNT(*) FROM visits v WHERE NOT EXISTS (SELECT 1 FROM individual_programs ip WHERE ip.individual_id=v.individual_id AND ip.program_id=v.program_id AND date(v.scheduled_start)>=ip.start_date AND (ip.end_date IS NULL OR date(v.scheduled_start)<=ip.end_date))",
|
|
536
|
+
"overlapping_enrollment_episodes": "SELECT COUNT(*) FROM individual_programs a JOIN individual_programs b ON a.individual_id=b.individual_id AND a.program_id=b.program_id AND a.enrollment_id < b.enrollment_id AND a.start_date <= COALESCE(b.end_date,'9999-12-31') AND b.start_date <= COALESCE(a.end_date,'9999-12-31')",
|
|
537
|
+
}
|
|
538
|
+
for name,query in checks.items():
|
|
539
|
+
try:
|
|
540
|
+
n=con.execute(query).fetchone()[0]
|
|
541
|
+
if n: issues.append(f"{name}: {n}")
|
|
542
|
+
except sqlite3.DatabaseError as e:
|
|
543
|
+
issues.append(f"{name}: validation query failed: {e}")
|
|
544
|
+
# ---VALIDATE OPERATING HOURS AND STAFF CONFLICTS---
|
|
545
|
+
with sqlite_closing(sqlite3.connect(path)) as con:
|
|
546
|
+
lookup = {row[1]: row[0] for row in PROGRAMS}
|
|
547
|
+
pmap = {pid: lookup[code] for pid, code in con.execute("SELECT program_id,program_code FROM programs")}
|
|
548
|
+
violations = 0
|
|
549
|
+
for pid, start_text, duration, actual_start, actual_end in con.execute(
|
|
550
|
+
"SELECT program_id,scheduled_start,scheduled_duration_minutes,actual_start,actual_end FROM visits"
|
|
551
|
+
):
|
|
552
|
+
days, opening, closing = PROGRAM_HOURS[pmap[pid]]
|
|
553
|
+
sched = datetime.fromisoformat(start_text)
|
|
554
|
+
day_start = datetime.combine(sched.date(), datetime.min.time())
|
|
555
|
+
opens = day_start + timedelta(hours=opening)
|
|
556
|
+
closes = day_start + timedelta(hours=closing)
|
|
557
|
+
invalid = sched.weekday() not in days or sched < opens or sched + timedelta(minutes=duration) > closes
|
|
558
|
+
if actual_start and actual_end:
|
|
559
|
+
begin = datetime.fromisoformat(actual_start)
|
|
560
|
+
end = datetime.fromisoformat(actual_end)
|
|
561
|
+
invalid |= begin < opens or end > closes or end <= begin
|
|
562
|
+
violations += bool(invalid)
|
|
563
|
+
if violations:
|
|
564
|
+
issues.append(f"program_operating_hours_violations: {violations}")
|
|
565
|
+
overlaps = con.execute("""SELECT COUNT(*) FROM visits a JOIN visits b
|
|
566
|
+
ON a.employee_id=b.employee_id AND a.visit_id < b.visit_id
|
|
567
|
+
WHERE a.visit_status='COMPLETED' AND b.visit_status='COMPLETED'
|
|
568
|
+
AND a.actual_start < b.actual_end AND b.actual_start < a.actual_end""").fetchone()[0]
|
|
569
|
+
if overlaps:
|
|
570
|
+
issues.append(f"employee_service_overlaps: {overlaps}")
|
|
571
|
+
return {"valid": not issues,"issues":issues}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def export_csv(path: str | Path, directory: str | Path) -> list[str]:
|
|
575
|
+
destination=Path(directory)
|
|
576
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
577
|
+
output=[]
|
|
578
|
+
with sqlite_closing(sqlite3.connect(path)) as con:
|
|
579
|
+
tables=[r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")]
|
|
580
|
+
for table in tables:
|
|
581
|
+
filepath=destination/(table+".csv")
|
|
582
|
+
cursor=con.execute(f'SELECT * FROM "{table}"')
|
|
583
|
+
with filepath.open("w",newline="",encoding="utf-8") as f:
|
|
584
|
+
writer=csv.writer(f)
|
|
585
|
+
writer.writerow([d[0] for d in cursor.description])
|
|
586
|
+
for chunk in iter(lambda:cursor.fetchmany(10000),[]):
|
|
587
|
+
writer.writerows(chunk)
|
|
588
|
+
output.append(str(filepath))
|
|
589
|
+
return output
|
bhsim/simulator.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Friendly Python entry point for the BHSim generator."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .engine import Config, generate, validate, export_csv
|
|
4
|
+
|
|
5
|
+
class BHSimulator:
|
|
6
|
+
"""Generate linked synthetic behavioral health records (no PHI input)."""
|
|
7
|
+
def __init__(self, clients=1000, employees=80, history_months=12,
|
|
8
|
+
future_days=30, reference_date="2026-10-10", seed=42):
|
|
9
|
+
self.config = Config(clients=clients, employees=employees, months=history_months,
|
|
10
|
+
future_days=future_days, reference_date=reference_date, seed=seed)
|
|
11
|
+
self.database_path = None
|
|
12
|
+
|
|
13
|
+
def generate(self, path="behavioral_health.db"):
|
|
14
|
+
self.database_path = Path(path)
|
|
15
|
+
return generate(self.database_path, self.config)
|
|
16
|
+
|
|
17
|
+
def to_sqlite(self, path=None):
|
|
18
|
+
if self.database_path is None:
|
|
19
|
+
raise RuntimeError("Call generate() first")
|
|
20
|
+
if path is not None and Path(path).resolve() != self.database_path.resolve():
|
|
21
|
+
import shutil
|
|
22
|
+
shutil.copyfile(self.database_path, path)
|
|
23
|
+
return Path(path)
|
|
24
|
+
return self.database_path
|
|
25
|
+
|
|
26
|
+
def to_csv(self, directory="output"):
|
|
27
|
+
if self.database_path is None:
|
|
28
|
+
raise RuntimeError("Call generate() first")
|
|
29
|
+
return export_csv(self.database_path, directory)
|
|
30
|
+
|
|
31
|
+
def validate(self):
|
|
32
|
+
if self.database_path is None:
|
|
33
|
+
raise RuntimeError("Call generate() first")
|
|
34
|
+
return validate(self.database_path)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bhsim
|
|
3
|
+
Version: 1.0.0rc2
|
|
4
|
+
Summary: Privacy-first synthetic behavioral health operations simulator
|
|
5
|
+
Author: Rahul Byrapuneni
|
|
6
|
+
Maintainer: Rahul Byrapuneni
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: synthetic-data,behavioral-health,sqlite,power-bi
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Provides-Extra: ui
|
|
15
|
+
Requires-Dist: streamlit>=1.35; extra == "ui"
|
|
16
|
+
Requires-Dist: pandas>=2.0; extra == "ui"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# BHSim
|
|
20
|
+
|
|
21
|
+
### Synthetic behavioral health data, ready for SQL and analytics
|
|
22
|
+
|
|
23
|
+
BHSim is an open-source Python library that creates **fictional, connected behavioral health records** for SQL practice, BI dashboards, software testing, and analytics prototypes.
|
|
24
|
+
|
|
25
|
+
Instead of hand-building spreadsheets or borrowing sensitive patient data, you can generate a small SQLite database with clients, programs, staff, appointments, enrollments, and screening assessments—and start querying it right away.
|
|
26
|
+
|
|
27
|
+
**Created and maintained by Rahul Byrapuneni.**
|
|
28
|
+
|
|
29
|
+
> **Important:** BHSim generates synthetic test data, not real patient records. Its scheduling, attendance, assessment trajectories, and productivity assumptions are examples for development and learning—not clinical predictions, diagnostic advice, official reporting definitions, or billing guidance.
|
|
30
|
+
|
|
31
|
+
## What can BHSim generate?
|
|
32
|
+
|
|
33
|
+
- **Individuals:** Fictional names, dates of birth, demographic fields, and unique numeric identifiers.
|
|
34
|
+
- **Programs:** Mental health (MH), substance use (SA), and intellectual/developmental disabilities (IDD) services.
|
|
35
|
+
- **Enrollments:** Admissions, discharges, multiple concurrent programs, and re-enrollment histories.
|
|
36
|
+
- **Employees:** Staff records, program assignments, and supervisor relationships.
|
|
37
|
+
- **Visits:** Completed, no-show, canceled, rescheduled, and upcoming appointments, with scheduled and actual service times.
|
|
38
|
+
- **Productivity:** Example service-unit calculations linked to visits and program rules.
|
|
39
|
+
- **Assessments:** PHQ-9 and GAD-7 with readable question text, answer choices, responses, and total scores for complete assessments.
|
|
40
|
+
|
|
41
|
+
The records are linked through primary and foreign keys, so you can practice realistic joins without needing access to an EHR.
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
**Requirements:** Python 3.10 or newer. No database server, paid API, or patient data is needed.
|
|
46
|
+
|
|
47
|
+
BHSim is currently available from its GitHub repository. To install it directly:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python -m pip install git+https://github.com/rahulbyrapuneni/bhsim.git
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> PyPI publication is planned, but **`pip install bhsim` is not yet the documented installation method**.
|
|
54
|
+
|
|
55
|
+
Create a file named `example.py`:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from bhsim import BHSimulator
|
|
59
|
+
|
|
60
|
+
# ---SET UP A SYNTHETIC ORGANIZATION---
|
|
61
|
+
sim = BHSimulator(
|
|
62
|
+
clients=1000,
|
|
63
|
+
employees=80,
|
|
64
|
+
history_months=12,
|
|
65
|
+
future_days=30,
|
|
66
|
+
seed=42,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# ---GENERATE, CHECK, AND EXPORT---
|
|
70
|
+
sim.generate("behavioral_health.db")
|
|
71
|
+
print(sim.validate())
|
|
72
|
+
sim.to_csv("output")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Run:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python example.py
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
You'll get `behavioral_health.db` plus a folder of CSV files, one for each table. A successful validation returns a result with `"valid": true` and an empty `issues` list.
|
|
82
|
+
|
|
83
|
+
**Already have an output database?** BHSim deliberately refuses to overwrite it. Choose a new filename or move the old one before generating another dataset.
|
|
84
|
+
|
|
85
|
+
### Prefer the command line?
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python -m bhsim generate demo.db --clients 100 --seed 42
|
|
89
|
+
python -m bhsim validate demo.db
|
|
90
|
+
python -m bhsim export-csv demo.db output
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The installed `bhsim` command can be used instead of `python -m bhsim` when it is available on your PATH.
|
|
94
|
+
|
|
95
|
+
## Explore the data with SQL
|
|
96
|
+
|
|
97
|
+
You can open the generated `.db` file with a SQLite-compatible editor, including [SQLime](https://sqlime.org/), or use Python's built-in `sqlite3` library.
|
|
98
|
+
|
|
99
|
+
### Appointments by program
|
|
100
|
+
|
|
101
|
+
```sql
|
|
102
|
+
SELECT
|
|
103
|
+
p.program_code,
|
|
104
|
+
p.description AS program_name,
|
|
105
|
+
COUNT(*) AS appointments,
|
|
106
|
+
SUM(CASE WHEN v.visit_status = 'COMPLETED' THEN 1 ELSE 0 END) AS completed,
|
|
107
|
+
SUM(CASE WHEN v.visit_status = 'NO_SHOW' THEN 1 ELSE 0 END) AS no_shows
|
|
108
|
+
FROM visits AS v
|
|
109
|
+
JOIN programs AS p ON p.program_id = v.program_id
|
|
110
|
+
GROUP BY p.program_code, p.description
|
|
111
|
+
ORDER BY appointments DESC;
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### No-show rate by program
|
|
115
|
+
|
|
116
|
+
```sql
|
|
117
|
+
SELECT
|
|
118
|
+
p.program_code,
|
|
119
|
+
COUNT(*) AS eligible_appointments,
|
|
120
|
+
SUM(CASE WHEN v.visit_status = 'NO_SHOW' THEN 1 ELSE 0 END) AS no_shows,
|
|
121
|
+
ROUND(
|
|
122
|
+
100.0 * SUM(CASE WHEN v.visit_status = 'NO_SHOW' THEN 1 ELSE 0 END)
|
|
123
|
+
/ COUNT(*), 2
|
|
124
|
+
) AS no_show_pct
|
|
125
|
+
FROM visits AS v
|
|
126
|
+
JOIN programs AS p ON p.program_id = v.program_id
|
|
127
|
+
WHERE v.visit_status IN ('COMPLETED', 'NO_SHOW')
|
|
128
|
+
GROUP BY p.program_code
|
|
129
|
+
ORDER BY no_show_pct DESC;
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
This example uses **completed + no-show** visits as its denominator. Organizations may define reportable no-show measures differently.
|
|
133
|
+
|
|
134
|
+
### Individual visit history
|
|
135
|
+
|
|
136
|
+
```sql
|
|
137
|
+
SELECT
|
|
138
|
+
i.individual_id,
|
|
139
|
+
i.full_name,
|
|
140
|
+
p.program_code,
|
|
141
|
+
v.visit_status,
|
|
142
|
+
v.scheduled_start,
|
|
143
|
+
e.full_name AS employee_name
|
|
144
|
+
FROM visits AS v
|
|
145
|
+
JOIN individuals AS i ON i.individual_id = v.individual_id
|
|
146
|
+
JOIN programs AS p ON p.program_id = v.program_id
|
|
147
|
+
JOIN employees AS e ON e.employee_id = v.employee_id
|
|
148
|
+
ORDER BY v.scheduled_start DESC
|
|
149
|
+
LIMIT 20;
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Read actual assessment questions and selected answers
|
|
153
|
+
|
|
154
|
+
BHSim keeps question text and answer choices in lookup tables instead of repeating them in every response. The built-in view joins those tables for you:
|
|
155
|
+
|
|
156
|
+
```sql
|
|
157
|
+
SELECT
|
|
158
|
+
instrument_code,
|
|
159
|
+
question_text,
|
|
160
|
+
selected_answer,
|
|
161
|
+
score_value
|
|
162
|
+
FROM assessment_responses_readable
|
|
163
|
+
LIMIT 20;
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
PHQ-9 contains nine scored symptom items (complete total: **0–27**); GAD-7 contains seven (complete total: **0–21**). Incomplete assessments do not receive a complete total. Scores in BHSim are simulated screening data, **not diagnoses**.
|
|
167
|
+
|
|
168
|
+
## How the tables fit together
|
|
169
|
+
|
|
170
|
+
| Area | Tables | What they represent |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| People and programs | `individuals`, `programs`, `individual_programs` | Individuals and their enrollment episodes |
|
|
173
|
+
| Workforce | `employees`, `employee_programs`, `employee_supervision` | Staff assignments and reporting relationships |
|
|
174
|
+
| Services | `visits`, `visit_types`, `locations` | Scheduled appointments and documented services |
|
|
175
|
+
| Productivity | `productivity_rules`, `visit_productivity` | Example rules and calculated units |
|
|
176
|
+
| Assessments | `assessment_instruments`, `assessment_questions`, `answer_options`, `assessments`, `assessment_responses`, `assessment_scores` | Forms, questions, selected answers, and scored submissions |
|
|
177
|
+
| Supporting data | `simulation_metadata` | Configuration and generation metadata |
|
|
178
|
+
|
|
179
|
+
There is also an `assessment_responses_readable` **SQL view** for simpler queries.
|
|
180
|
+
|
|
181
|
+
Identifiers are numeric-looking **TEXT** values, with fixed lengths defined by entity type. They are deliberately nonsequential and are joined by ID, not by name. Names may repeat, just as they can in real records.
|
|
182
|
+
|
|
183
|
+
## Customize a run
|
|
184
|
+
|
|
185
|
+
These are the main settings available through the Python API:
|
|
186
|
+
|
|
187
|
+
| Parameter | Example | Meaning |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| `clients` | `1000` | Number of fictional individuals |
|
|
190
|
+
| `employees` | `80` | Number of fictional staff records |
|
|
191
|
+
| `history_months` | `12` | Historical period to simulate |
|
|
192
|
+
| `future_days` | `30` | Days of upcoming appointments |
|
|
193
|
+
| `reference_date` | `"2026-10-10"` | Simulation's reference date, in `YYYY-MM-DD` format |
|
|
194
|
+
| `seed` | `42` | Controls the pseudorandom sequence for reproducibility |
|
|
195
|
+
|
|
196
|
+
**What is a seed?** With the same BHSim code, settings, and seed, generation is reproducible. Change the seed to get a different fictional dataset. The number `42` has no special meaning.
|
|
197
|
+
|
|
198
|
+
Some program-specific probabilities, operating hours, and productivity assumptions are currently defined internally rather than exposed as configurable API options.
|
|
199
|
+
|
|
200
|
+
## Data checks
|
|
201
|
+
|
|
202
|
+
BHSim includes a validator for core integrity rules:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
python -m bhsim validate demo.db
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
You can also run these independent SQLite checks:
|
|
209
|
+
|
|
210
|
+
```sql
|
|
211
|
+
-- Broken foreign keys: expect no rows
|
|
212
|
+
PRAGMA foreign_key_check;
|
|
213
|
+
|
|
214
|
+
-- Completed visits without valid service timestamps: expect zero
|
|
215
|
+
SELECT COUNT(*) AS invalid_completed_visits
|
|
216
|
+
FROM visits
|
|
217
|
+
WHERE visit_status = 'COMPLETED'
|
|
218
|
+
AND (actual_start IS NULL OR actual_end IS NULL
|
|
219
|
+
OR actual_end <= actual_start);
|
|
220
|
+
|
|
221
|
+
-- Visits without a matching program enrollment: expect zero
|
|
222
|
+
SELECT COUNT(*) AS visits_outside_enrollment
|
|
223
|
+
FROM visits AS v
|
|
224
|
+
WHERE NOT EXISTS (
|
|
225
|
+
SELECT 1
|
|
226
|
+
FROM individual_programs AS ip
|
|
227
|
+
WHERE ip.individual_id = v.individual_id
|
|
228
|
+
AND ip.program_id = v.program_id
|
|
229
|
+
AND DATE(v.scheduled_start) >= DATE(ip.start_date)
|
|
230
|
+
AND (ip.end_date IS NULL OR DATE(v.scheduled_start) <= DATE(ip.end_date))
|
|
231
|
+
);
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Validation checks **consistency**, not whether the generated statistics match real healthcare populations.
|
|
235
|
+
|
|
236
|
+
## Contributing and developing locally
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
git clone https://github.com/rahulbyrapuneni/bhsim.git
|
|
240
|
+
cd bhsim
|
|
241
|
+
python -m pip install -e ".[dev]"
|
|
242
|
+
python -m pytest -q
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The library's main code lives under `src/bhsim/`. `app.py` is an **optional Streamlit demonstration**; it is not needed for library installation or use. To run the demo, install the optional UI dependencies with `python -m pip install -e ".[ui]"`, then use `python -m streamlit run app.py`.
|
|
246
|
+
|
|
247
|
+
Contributions that improve documentation, validation, reproducibility, or realistic *synthetic* workflows are welcome. Please don't submit real patient records or other protected health information in issues, examples, or tests.
|
|
248
|
+
|
|
249
|
+
## Limitations and responsible use
|
|
250
|
+
|
|
251
|
+
- All records are fictional. BHSim does **not** access EHRs or require patient data.
|
|
252
|
+
- Generated distributions are illustrative; the library has not been validated as a model of real-world clinical outcomes, demographics, or utilization.
|
|
253
|
+
- PHQ-9 and GAD-7 are screening instruments. Simulated scores are not diagnoses, treatment recommendations, or a substitute for clinical judgment.
|
|
254
|
+
- Visit eligibility currently assumes a matching enrollment episode; un-enrolled crisis-service exceptions are not generated in this release.
|
|
255
|
+
- Scheduling checks cover completed-service conflicts, not every possible appointment-booking workflow.
|
|
256
|
+
- Productivity units use demonstration rules and should not be treated as reimbursement or billing guidance.
|
|
257
|
+
- Existing databases are not automatically migrated when the schema changes. Newer releases use `full_name` in place of the older `synthetic_name` column.
|
|
258
|
+
- Questionnaire wording and names belong to their respective instrument originators. Preserve attribution and confirm the relevant usage terms before redistributing or using the instruments clinically.
|
|
259
|
+
|
|
260
|
+
## License and author
|
|
261
|
+
|
|
262
|
+
BHSim is released under the [MIT License](LICENSE). See the repository's `LICENSE` file for its full terms. Instrument attribution and third-party rights are separate from the project's software license.
|
|
263
|
+
|
|
264
|
+
**Creator & maintainer:** [Rahul Byrapuneni](https://github.com/rahulbyrapuneni)
|
|
265
|
+
**Repository:** [github.com/rahulbyrapuneni/bhsim](https://github.com/rahulbyrapuneni/bhsim)
|
|
266
|
+
|
|
267
|
+
Developed using AI-assisted software engineering, with behavioral health domain direction and product design led by the creator.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
bhsim/__init__.py,sha256=H_EnRLfDAF7A9wFFxHGV7b7O-DNdj6xcFAZeRhm_nsw,250
|
|
2
|
+
bhsim/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
|
|
3
|
+
bhsim/cli.py,sha256=tqXpklCqLATY7dIpA2gck_q3fwj2TZWcS2GBvKG-T3U,1227
|
|
4
|
+
bhsim/engine.py,sha256=85aUMuab3kdQ4QW4FXwsnNs26kKqf04nD4rhBf9LObE,43551
|
|
5
|
+
bhsim/simulator.py,sha256=G2UEELzPV9iWcXa-dODjKVKCqeH3a5XA4_5mdwokrVw,1475
|
|
6
|
+
bhsim-1.0.0rc2.dist-info/licenses/LICENSE,sha256=C1x8kG3OtLgkkkGUZOfXl3Jv-E2Dzbmyksy9thtM6No,1073
|
|
7
|
+
bhsim-1.0.0rc2.dist-info/METADATA,sha256=Y3_sOtmijAtaYkYBgj_Rvq64raCQVm_gAKS-f0lP_mQ,11282
|
|
8
|
+
bhsim-1.0.0rc2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
bhsim-1.0.0rc2.dist-info/entry_points.txt,sha256=B1n4QT4SebBFXsYjSFiJNA4lyTl1U69QbRxv9XuqsW0,41
|
|
10
|
+
bhsim-1.0.0rc2.dist-info/top_level.txt,sha256=1aymJiF_xzkmcrhqC_rAuyDMReT9muKAFghbXq2lzCo,6
|
|
11
|
+
bhsim-1.0.0rc2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rahul Byrapuneni
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bhsim
|