bhsim 1.0.0__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.0.dist-info/METADATA +328 -0
- bhsim-1.0.0.dist-info/RECORD +11 -0
- bhsim-1.0.0.dist-info/WHEEL +5 -0
- bhsim-1.0.0.dist-info/entry_points.txt +2 -0
- bhsim-1.0.0.dist-info/licenses/LICENSE +21 -0
- bhsim-1.0.0.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,328 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bhsim
|
|
3
|
+
Version: 1.0.0
|
|
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
|
+
<p align="center">
|
|
22
|
+
<img src="assets/bhsim-banner.png" alt="BHSim - Python library for synthetic behavioral health data" width="850">
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
**Synthetic behavioral health data for SQL, analytics, and application testing.**
|
|
26
|
+
|
|
27
|
+
[](https://pypi.org/project/bhsim/)
|
|
28
|
+

|
|
29
|
+
[](LICENSE)
|
|
30
|
+
[](https://github.com/rahulbyrapuneni/bhsim/actions)
|
|
31
|
+
|
|
32
|
+
**[Install from PyPI](https://pypi.org/project/bhsim/) · [Source code](https://github.com/rahulbyrapuneni/bhsim) · [Report an issue](https://github.com/rahulbyrapuneni/bhsim/issues)**
|
|
33
|
+
|
|
34
|
+
BHSim is an open-source Python library that generates **fictional, interconnected behavioral health records**. It gives analysts, developers, students, and educators a ready-to-query dataset without asking them to find or handle real patient information.
|
|
35
|
+
|
|
36
|
+
In a few lines of Python, you can create a SQLite database containing individuals, programs, enrollments, employees, appointments, productivity records, and screening assessments. Use it to practice SQL joins, prototype Power BI dashboards, develop analytics features, or test software that expects relational healthcare-style data.
|
|
37
|
+
|
|
38
|
+
> **Release status:** `1.0.0rc3` is a public **release candidate**. Install this specific version using the command below. BHSim is intended for development, demonstrations, and education—not clinical decisions, official quality reporting, or billing.
|
|
39
|
+
|
|
40
|
+
## Why BHSim?
|
|
41
|
+
|
|
42
|
+
Sample spreadsheets are useful for learning individual SQL queries, but real analytics often depends on **relationships between records**. An individual may participate in several programs, a staff member may serve multiple programs, and each appointment must connect to the right people and enrollment history.
|
|
43
|
+
|
|
44
|
+
BHSim generates those relationships for you, so you can spend more time learning, querying, building, and testing—and less time assembling fake data by hand.
|
|
45
|
+
|
|
46
|
+
**Common uses:**
|
|
47
|
+
|
|
48
|
+
- **SQL practice:** Join visits with individuals, employees, programs, and enrollments.
|
|
49
|
+
- **BI dashboards:** Explore attendance, no-shows, program activity, staff workload, and screening trends.
|
|
50
|
+
- **Application testing:** Test database queries, ETL pipelines, APIs, and analytics features with fictional records.
|
|
51
|
+
- **Teaching and portfolio projects:** Demonstrate healthcare-style reporting without sharing protected health information (PHI).
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
## Quick start
|
|
55
|
+
|
|
56
|
+
**Requirements:** Python 3.10 or newer.
|
|
57
|
+
|
|
58
|
+
BHSim is available on PyPI. No database server, paid API,
|
|
59
|
+
or real patient information is required.
|
|
60
|
+
|
|
61
|
+
### Install BHSim
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install bhsim==1.0.0rc3
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
BHSim is currently available as a public release candidate.
|
|
68
|
+
|
|
69
|
+
### Generate your first dataset
|
|
70
|
+
|
|
71
|
+
Create a file named `example.py`:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from bhsim import BHSimulator
|
|
75
|
+
|
|
76
|
+
sim = BHSimulator(
|
|
77
|
+
clients=1000,
|
|
78
|
+
employees=80,
|
|
79
|
+
history_months=12,
|
|
80
|
+
future_days=30,
|
|
81
|
+
seed=42
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
sim.generate("behavioral_health.db")
|
|
85
|
+
|
|
86
|
+
print(sim.validate())
|
|
87
|
+
|
|
88
|
+
sim.to_csv("output")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Run:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python example.py
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
This generates a SQLite database containing connected synthetic
|
|
98
|
+
behavioral health records and exports CSV files for analysis.
|
|
99
|
+
|
|
100
|
+
You can explore the data with SQLite, Python, Excel, or Power BI.
|
|
101
|
+
|
|
102
|
+
You will get:
|
|
103
|
+
|
|
104
|
+
- **`behavioral_health.db`** — a SQLite database with related synthetic tables.
|
|
105
|
+
- **`output/`** — CSV exports you can load into spreadsheets or BI tools.
|
|
106
|
+
- **Validation results** — a report checking the dataset's core consistency rules. A passing result includes `"valid": true` and an empty `issues` list.
|
|
107
|
+
|
|
108
|
+
**Note:** BHSim does not overwrite an existing database file. Use a new filename if you want to generate another dataset.
|
|
109
|
+
|
|
110
|
+
### Prefer a command-line workflow?
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
python -m bhsim generate demo.db --clients 100 --seed 42
|
|
114
|
+
python -m bhsim validate demo.db
|
|
115
|
+
python -m bhsim export-csv demo.db output
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
You can also use the installed `bhsim` command when it is available on your system's PATH.
|
|
119
|
+
|
|
120
|
+
## What's inside the database?
|
|
121
|
+
|
|
122
|
+
| Area | Tables | What you'll find |
|
|
123
|
+
| --- | --- | --- |
|
|
124
|
+
| Individuals and programs | `individuals`, `programs`, `individual_programs` | Fictional people, MH/SA/IDD programs, admissions, discharges, and re-enrollments |
|
|
125
|
+
| Workforce | `employees`, `employee_programs`, `employee_supervision` | Staff members, program assignments, and reporting relationships |
|
|
126
|
+
| Appointments | `visits`, `visit_types`, `locations` | Scheduled and completed visits, no-shows, cancellations, rescheduling, and service times |
|
|
127
|
+
| Productivity | `productivity_rules`, `visit_productivity` | Illustrative productivity rules and visit-linked units |
|
|
128
|
+
| Screening assessments | `assessment_instruments`, `assessment_questions`, `answer_options`, `assessments`, `assessment_responses`, `assessment_scores` | PHQ-9 and GAD-7 items, answer options, responses, and scores |
|
|
129
|
+
| Generation details | `simulation_metadata` | Information about the simulation run |
|
|
130
|
+
|
|
131
|
+
There is also an **`assessment_responses_readable`** SQL view that brings together assessment questions and selected answers.
|
|
132
|
+
|
|
133
|
+
Records use primary and foreign keys. IDs are fixed-width, numeric-looking **text** values—not sequential row numbers. Names can repeat, so use IDs rather than names for joins.
|
|
134
|
+
|
|
135
|
+
## Explore your data with SQL
|
|
136
|
+
|
|
137
|
+
Open `behavioral_health.db` in a SQLite-compatible tool such as [SQLime](https://sqlime.org/), DB Browser for SQLite, or Python's built-in `sqlite3` module.
|
|
138
|
+
|
|
139
|
+
### 1. Appointments by program
|
|
140
|
+
|
|
141
|
+
```sql
|
|
142
|
+
SELECT
|
|
143
|
+
p.program_code,
|
|
144
|
+
p.description AS program_name,
|
|
145
|
+
COUNT(*) AS total_appointments,
|
|
146
|
+
SUM(CASE WHEN v.visit_status = 'COMPLETED' THEN 1 ELSE 0 END) AS completed,
|
|
147
|
+
SUM(CASE WHEN v.visit_status = 'NO_SHOW' THEN 1 ELSE 0 END) AS no_shows
|
|
148
|
+
FROM visits AS v
|
|
149
|
+
JOIN programs AS p
|
|
150
|
+
ON p.program_id = v.program_id
|
|
151
|
+
GROUP BY p.program_code, p.description
|
|
152
|
+
ORDER BY total_appointments DESC;
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**What it tells you:** Which programs have the most scheduled activity, completed appointments, and no-shows.
|
|
156
|
+
|
|
157
|
+
### 2. No-show rate by program
|
|
158
|
+
|
|
159
|
+
```sql
|
|
160
|
+
SELECT
|
|
161
|
+
p.program_code,
|
|
162
|
+
COUNT(*) AS eligible_appointments,
|
|
163
|
+
SUM(CASE WHEN v.visit_status = 'NO_SHOW' THEN 1 ELSE 0 END) AS no_shows,
|
|
164
|
+
ROUND(
|
|
165
|
+
100.0 * SUM(CASE WHEN v.visit_status = 'NO_SHOW' THEN 1 ELSE 0 END)
|
|
166
|
+
/ COUNT(*),
|
|
167
|
+
2
|
|
168
|
+
) AS no_show_percentage
|
|
169
|
+
FROM visits AS v
|
|
170
|
+
JOIN programs AS p
|
|
171
|
+
ON p.program_id = v.program_id
|
|
172
|
+
WHERE v.visit_status IN ('COMPLETED', 'NO_SHOW')
|
|
173
|
+
GROUP BY p.program_code
|
|
174
|
+
ORDER BY no_show_percentage DESC;
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**What it tells you:** The percentage of eligible appointments marked as no-shows. This example uses **completed + no-show** visits as the denominator; actual organizations may define their reporting measures differently.
|
|
178
|
+
|
|
179
|
+
### 3. Visit history with individual and employee names
|
|
180
|
+
|
|
181
|
+
```sql
|
|
182
|
+
SELECT
|
|
183
|
+
i.individual_id,
|
|
184
|
+
i.full_name AS individual_name,
|
|
185
|
+
p.program_code,
|
|
186
|
+
v.visit_status,
|
|
187
|
+
v.scheduled_start,
|
|
188
|
+
e.full_name AS employee_name
|
|
189
|
+
FROM visits AS v
|
|
190
|
+
JOIN individuals AS i ON i.individual_id = v.individual_id
|
|
191
|
+
JOIN programs AS p ON p.program_id = v.program_id
|
|
192
|
+
JOIN employees AS e ON e.employee_id = v.employee_id
|
|
193
|
+
ORDER BY v.scheduled_start DESC
|
|
194
|
+
LIMIT 20;
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**What it tells you:** How appointments connect to the individual receiving services, the program, and the assigned employee.
|
|
198
|
+
|
|
199
|
+
### 4. Explore PHQ-9 and GAD-7 responses
|
|
200
|
+
|
|
201
|
+
```sql
|
|
202
|
+
SELECT
|
|
203
|
+
instrument_code,
|
|
204
|
+
question_text,
|
|
205
|
+
selected_answer,
|
|
206
|
+
score_value
|
|
207
|
+
FROM assessment_responses_readable
|
|
208
|
+
LIMIT 20;
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**What it tells you:** Readable questions, answer choices, and simulated response scores, without writing several joins yourself.
|
|
212
|
+
|
|
213
|
+
PHQ-9 has nine scored items (complete total **0–27**), while GAD-7 has seven (complete total **0–21**). Incomplete submissions do not have complete total scores. These are simulated screening responses, **not diagnoses or clinically validated trajectories**.
|
|
214
|
+
|
|
215
|
+
## Customize your simulation
|
|
216
|
+
|
|
217
|
+
| Python argument | Example | Meaning |
|
|
218
|
+
| --- | --- | --- |
|
|
219
|
+
| `clients` | `1000` | Number of fictional individuals |
|
|
220
|
+
| `employees` | `80` | Number of fictional staff members |
|
|
221
|
+
| `history_months` | `12` | Historical period to simulate |
|
|
222
|
+
| `future_days` | `30` | Number of days of future appointments |
|
|
223
|
+
| `reference_date` | `"2026-10-10"` | Reference date in `YYYY-MM-DD` format |
|
|
224
|
+
| `seed` | `42` | Random seed for repeatable results |
|
|
225
|
+
|
|
226
|
+
Using the same version, settings, and seed produces a reproducible simulation. Change the seed to produce a different fictional dataset.
|
|
227
|
+
|
|
228
|
+
Some operational assumptions—including program hours, probabilities, and productivity calculations—are predefined in this release rather than exposed as user settings.
|
|
229
|
+
|
|
230
|
+
## Check data quality
|
|
231
|
+
|
|
232
|
+
BHSim includes built-in validation:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
python -m bhsim validate demo.db
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
You can also independently inspect a SQLite database with a few simple queries.
|
|
239
|
+
|
|
240
|
+
**Check broken foreign-key relationships** (expected: no rows):
|
|
241
|
+
|
|
242
|
+
```sql
|
|
243
|
+
PRAGMA foreign_key_check;
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Check completed visits with missing or invalid service times** (expected: `0`):
|
|
247
|
+
|
|
248
|
+
```sql
|
|
249
|
+
SELECT COUNT(*) AS invalid_completed_visits
|
|
250
|
+
FROM visits
|
|
251
|
+
WHERE visit_status = 'COMPLETED'
|
|
252
|
+
AND (
|
|
253
|
+
actual_start IS NULL
|
|
254
|
+
OR actual_end IS NULL
|
|
255
|
+
OR actual_end <= actual_start
|
|
256
|
+
);
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Check visits without a matching enrollment** (expected: `0` under the current simulation rules):
|
|
260
|
+
|
|
261
|
+
```sql
|
|
262
|
+
SELECT COUNT(*) AS visits_outside_enrollment
|
|
263
|
+
FROM visits AS v
|
|
264
|
+
WHERE NOT EXISTS (
|
|
265
|
+
SELECT 1
|
|
266
|
+
FROM individual_programs AS ip
|
|
267
|
+
WHERE ip.individual_id = v.individual_id
|
|
268
|
+
AND ip.program_id = v.program_id
|
|
269
|
+
AND DATE(v.scheduled_start) >= DATE(ip.start_date)
|
|
270
|
+
AND (
|
|
271
|
+
ip.end_date IS NULL
|
|
272
|
+
OR DATE(v.scheduled_start) <= DATE(ip.end_date)
|
|
273
|
+
)
|
|
274
|
+
);
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
These checks validate **structural and business-rule consistency**. They do not demonstrate that the generated rates or outcomes represent real behavioral health populations.
|
|
278
|
+
|
|
279
|
+
## Use BHSim with Power BI
|
|
280
|
+
|
|
281
|
+
1. Generate the data and export it to CSV using `sim.to_csv("output")`.
|
|
282
|
+
2. In Power BI Desktop, select **Get data → Text/CSV** and load the files you need.
|
|
283
|
+
3. Use the ID columns to create relationships between individuals, visits, programs, and employees.
|
|
284
|
+
4. Build visuals for program activity, appointment statuses, staff productivity, or assessment trends.
|
|
285
|
+
|
|
286
|
+
You can also access SQLite directly through a compatible connector or ODBC configuration, if you prefer not to export CSV files.
|
|
287
|
+
|
|
288
|
+
## Develop and contribute
|
|
289
|
+
|
|
290
|
+
Clone the repository and install development dependencies:
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
git clone https://github.com/rahulbyrapuneni/bhsim.git
|
|
294
|
+
cd bhsim
|
|
295
|
+
python -m pip install -e ".[dev]"
|
|
296
|
+
python -m pytest -q
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The main library lives in `src/bhsim/`. An **optional** Streamlit demo is available in the repository:
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
python -m pip install -e ".[ui]"
|
|
303
|
+
python -m streamlit run app.py
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The demo is not required to install or use BHSim as a Python library.
|
|
307
|
+
|
|
308
|
+
Suggestions, bug reports, documentation improvements, and pull requests are welcome through [GitHub Issues](https://github.com/rahulbyrapuneni/bhsim/issues). Please **never submit real patient information or PHI** in issues, sample files, or tests.
|
|
309
|
+
|
|
310
|
+
## Important limitations
|
|
311
|
+
|
|
312
|
+
- **Synthetic, not sourced from patients:** BHSim generates fictional records and does not retrieve information from electronic health records.
|
|
313
|
+
- **Not a clinical model:** Demographic distributions, attendance behavior, utilization, and assessment trends are illustrative—not validated predictions of real populations.
|
|
314
|
+
- **Not for care decisions:** PHQ-9 and GAD-7 outputs must not be used for diagnosis, clinical decision support, or treatment recommendations.
|
|
315
|
+
- **Not billing guidance:** Productivity rules and units are examples, not payer-approved reimbursement logic.
|
|
316
|
+
- **Simplified workflows:** The current scheduling and program-enrollment rules do not cover every real-world exception or organization-specific workflow.
|
|
317
|
+
- **Schema versions matter:** Existing databases are not migrated automatically. Current releases use `full_name`; older generated datasets may contain `synthetic_name`.
|
|
318
|
+
- **Assessment rights:** Questionnaire wording and instrument-related rights belong to their respective owners. Confirm any applicable usage or redistribution requirements, particularly for clinical or commercial uses.
|
|
319
|
+
|
|
320
|
+
## License and credits
|
|
321
|
+
|
|
322
|
+
BHSim is open-source software released under the [MIT License](LICENSE). Third-party assessment rights and attribution are separate from the software license.
|
|
323
|
+
|
|
324
|
+
**Creator and maintainer:** [Rahul Byrapuneni](https://github.com/rahulbyrapuneni)
|
|
325
|
+
|
|
326
|
+
Created with behavioral health analytics domain direction and AI-assisted software development.
|
|
327
|
+
|
|
328
|
+
**Links:** [PyPI](https://pypi.org/project/bhsim/) · [GitHub](https://github.com/rahulbyrapuneni/bhsim) · [Issues](https://github.com/rahulbyrapuneni/bhsim/issues)
|
|
@@ -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.0.dist-info/licenses/LICENSE,sha256=C1x8kG3OtLgkkkGUZOfXl3Jv-E2Dzbmyksy9thtM6No,1073
|
|
7
|
+
bhsim-1.0.0.dist-info/METADATA,sha256=bCZr1TPCKx8D2-KwzdgBHulwUHzlLqJ4xEJcBmsR_98,13519
|
|
8
|
+
bhsim-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
bhsim-1.0.0.dist-info/entry_points.txt,sha256=B1n4QT4SebBFXsYjSFiJNA4lyTl1U69QbRxv9XuqsW0,41
|
|
10
|
+
bhsim-1.0.0.dist-info/top_level.txt,sha256=1aymJiF_xzkmcrhqC_rAuyDMReT9muKAFghbXq2lzCo,6
|
|
11
|
+
bhsim-1.0.0.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
|