Repository: CDCgov/ixa-epi-isolation
Pull request #88: Use real rate functions from isolation guidance. Closes #86.
Author: login: ChiragKumar9
Created: 2025-03-26T16:36:53Z | Merged: 2025-05-15T18:39:23Z

- Adds some example data that mirrors the outputs we get from our Stan model of isolation guidance.
- Adds an R script that parses that data and turns it into an ixa-epi ready format (we keep that file in the repo to allow `cargo run` to use it and not fail on GitHub actions).
- Adds a `scale` parameter to the empirical rate functions that scales the relative hazard rates into an absolute rate of infection.

Also, here's what the SIR curve looks like when we use these new rate functions (scale = 1.0): 
![image](https://github.com/user-attachments/assets/4be86d1f-26ee-4d31-ab5d-1ad078d00171)

Compared to what it looked like before (this is post addition of settings with each setting experiencing 25% of a person's infectiousness with alpha = 0.0):
![image](https://github.com/user-attachments/assets/67bd1081-d0a6-4738-a35a-087fc4cb79d2)

We expect people to be more infectious since the rate of infection is higher with the triangle model with scale = 1.0 (the triangle's mean magnitude is about 7 and it's mean duration is about 15 days).

Linked issue #86: Use real rate function data from isolation guidance
- [x] R script to make empirical rate function file in the right format of what we want ixa-epi from our Stan model output
- [x] Add `scale` parameter to empirical rate function that scales the hazard rates (which are not normalized by-person) to have epi-relevant meaning (parameter is calibrated)
- [x] What is the impact on the SIR curve?
Linked issue comments: user: login: ChiragKumar9
created_at: 2025-03-26T15:30:49Z
body: Experiencing a challenge with floating point numerical error. Current infectiousness is greater than forecasted infectiousness because of numerical problems, not actually because that is the case...
author_association: CONTRIBUTOR

Diff:
diff --git a/.gitignore b/.gitignore
index dc172f21..fa5d4d67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,6 +39,9 @@
 # !your_data_directory/
 !input/people_test.csv
 !tests/data/*
+!input/isolation_guidance_stan_parameters.csv
+!input/library_empirical_rate_fns.csv
+!input/library_symptom_parameters.csv
 
 #####
 # Python
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 37ed42de..48275191 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -61,4 +61,4 @@ repos:
         args: ["--verbose", "--"]
     -   id: clippy
         # Use 'all-targets' to run on all code, including tests and examples
-        args: ["--all-targets", "--", "-D", "warnings", "-W", "clippy::pedantic", "-A", "clippy::module-name-repetitions"]
+        args: ["--all-targets", "--", "-D", "warnings", "-W", "clippy::pedantic", "-A", "clippy::module-name-repetitions", "-A", "clippy::match-wildcard-for-single-variants"]
diff --git a/input/input.json b/input/input.json
index 8c4228b8..f0c5bdb4 100644
--- a/input/input.json
+++ b/input/input.json
@@ -2,12 +2,13 @@
   "epi_isolation.GlobalParams": {
     "max_time": 200.0,
     "seed": 123,
-    "infectiousness_rate_fn": {"Constant": {"rate": 1.0, "duration": 5.0}},
+    "infectiousness_rate_fn": {"EmpiricalFromFile": {"file": "input/library_empirical_rate_fns.csv", "scale": 1.0}},
     "initial_infections": 1,
     "settings_properties": {"Home": {"alpha": 0.0, "itinerary_specification": {"Constant": {"ratio": 0.25}}},
                             "Workplace": {"alpha": 0.0, "itinerary_specification": {"Constant": {"ratio": 0.25}}},
                             "School": {"alpha": 0.0, "itinerary_specification": {"Constant": {"ratio": 0.25}}},
                             "CensusTract": {"alpha": 0.0, "itinerary_specification": {"Constant": {"ratio": 0.25}}}},
+    "symptom_progression_library" : {"EmpiricalFromFile": {"file": "input/library_symptom_parameters.csv"}},
     "report_period": 1.0,
     "synth_population_file": "input/people_test.csv",
     "transmission_report_name": "transmission_report.csv"
diff --git a/input/isolation_guidance_stan_parameters.csv b/input/isolation_guidance_stan_parameters.csv
new file mode 100644
index 00000000..1578e6ea
--- /dev/null
+++ b/input/isolation_guidance_stan_parameters.csv
@@ -0,0 +1,11 @@
+symp_type_cat,tp,dp,wp,wr,wr_mean,wr_std,si_shape,si_beta_0_exponentiated,si_beta_wr
+3,6.204420750497397,3.1630641670385176,6.633023339439888,6.55263936768077,12.149713373050606,2.116218607947531,2.3028596741731664,2.558915464086227,0.4399615055635713
+2,-2.030748096916528,7.399489284774774,0.7107455552101054,38.403511108391385,14.022952029403083,0.37521917513372194,2.238139598412739,3.9365453312922414,0.14432199503488788
+1,0.4402085393263524,7.217162437510317,0.741798057532761,9.941617348312501,13.751053447816854,1.0039733428501316,2.189415992800881,7.1895887694327305,0.6126644959879458
+1,0.9782227660038114,5.997938176632097,15.79758184728197,8.995954325289285,11.482600345998852,1.9800937720174296,0.5264434166789742,7.554141761269349,0.04979525356894429
+2,6.151210293194424,9.145173796599742,4.404285471506528,7.347357810074719,13.250025216652709,1.3878340189075895,0.9983501184461243,6.716746750556041,-1.546203580146347
+2,1.7330279421204746,8.285443184988655,2.5853651609986184,9.42937796008658,13.75575071341198,1.1409154790829943,1.1620010371534175,6.714865789506733,0.8581134738275983
+1,2.975086592717668,9.260087955808597,5.127189057091407,10.632774870880674,15.339755751842631,1.241713492086739,1.8393366362948504,7.980013806093956,0.4935845478357588
+3,4.026607658819095,5.1169170644528545,3.4206836189896244,4.609317405567327,11.241595559645454,2.1894745344353073,2.378730542718536,1.4447959837098463,-0.18122335558643662
+2,0.2080856094414429,4.012690354002111,4.342214056541452,16.98579195970987,13.764091087463505,3.2475494455167135,3.938441892843904,6.685088099295831,0.284575867768635
+2,-0.48810700603308915,12.554031691297997,4.654338087569759,7.462926548497697,15.142297621605527,1.5775996476430956,3.1836607306665154,5.3612661132365265,0.2755282629419131
diff --git a/input/library_empirical_rate_fns.csv b/input/library_empirical_rate_fns.csv
new file mode 100644
index 00000000..5b1b4956
--- /dev/null
+++ b/input/library_empirical_rate_fns.csv
@@ -0,0 +1,784 @@
+id,time,value
+1,1.6,0.06069773508300921
+1,1.8,0.1560709609913509
+1,2,0.25144418689969256
+1,2.2,0.34681741280803435
+1,2.4,0.44219063871637615
+1,2.6,0.5375638646247177
+1,2.8,0.6329370905330595
+1,3,0.7283103164414011
+1,3.2,0.8236835423497428
+1,3.4,0.9190567682580846
+1,3.6,1.0144299941664263
+1,3.8,1.109803220074768
+1,4,1.2051764459831094
+1,4.2,1.300549671891451
+1,4.4,1.395922897799793
+1,4.6,1.4912961237081348
+1,4.8,1.5866693496164765
+1,5,1.6820425755248178
+1,5.2,1.7774158014331596
+1,5.4,1.8727890273415015
+1,5.6,1.9681622532498433
+1,5.8,2.063535479158185
+1,6,2.1589087050665263
+1,6.2,2.254281930974868
+1,6.4,2.34965515688321
+1,6.6,2.4450283827915515
+1,6.8,2.5404016086998937
+1,7,2.635774834608235
+1,7.2,2.7311480605165768
+1,7.4,2.8265212864249185
+1,7.6,2.9218945123332603
+1,7.8,3.017267738241602
+1,8,3.1126409641499433
+1,8.2,3.1175627232586183
+1,8.4,3.02101951404007
+1,8.6,2.9244763048215217
+1,8.8,2.8279330956029725
+1,9,2.7313898863844237
+1,9.2,2.6348466771658745
+1,9.4,2.538303467947326
+1,9.6,2.441760258728777
+1,9.8,2.3452170495102287
+1,10,2.24867384029168
+1,10.2,2.1521306310731303
+1,10.4,2.0555874218545824
+1,10.6,1.959044212636033
+1,10.8,1.8625010034174845
+1,11,1.7659577941989362
+1,11.2,1.669414584980387
+1,11.4,1.5728713757618384
+1,11.6,1.4763281665432892
+1,11.8,1.379784957324741
+1,12,1.2832417481061924
+1,12.2,1.1866985388876432
+1,12.4,1.090155329669095
+1,12.6,0.9936121204505457
+1,12.8,0.8970689112319974
+1,13,0.8005257020134486
+1,13.2,0.7039824927948994
+1,13.4,0.6074392835763511
+1,13.6,0.5108960743578019
+1,13.8,0.4143528651392536
+1,14,0.3178096559207053
+1,14.2,0.22126644670215612
+1,14.4,0.12472323748360736
+1,14.6,0.028180028265058166
+2,1.2,0.7761586587544327
+2,1.4,2.8583353900847883
+2,1.6,4.940512121415139
+2,1.8,7.022688852745491
+2,2,7.367927362708121
+2,2.2,7.329391879512899
+2,2.4,7.290856396317676
+2,2.6,7.252320913122454
+2,2.8,7.213785429927231
+2,3,7.175249946732009
+2,3.2,7.136714463536786
+2,3.4,7.098178980341564
+2,3.6,7.059643497146341
+2,3.8,7.021108013951119
+2,4,6.982572530755897
+2,4.2,6.944037047560674
+2,4.4,6.905501564365452
+2,4.6,6.866966081170229
+2,4.8,6.828430597975007
+2,5,6.789895114779784
+2,5.2,6.751359631584562
+2,5.4,6.712824148389339
+2,5.6,6.674288665194117
+2,5.8,6.635753181998894
+2,6,6.597217698803672
+2,6.2,6.558682215608449
+2,6.4,6.520146732413227
+2,6.6,6.481611249218005
+2,6.8,6.443075766022782
+2,7,6.40454028282756
+2,7.2,6.3660047996323375
+2,7.4,6.327469316437115
+2,7.6,6.2889338332418925
+2,7.8,6.25039835004667
+2,8,6.2118628668514475
+2,8.2,6.1733273836562255
+2,8.4,6.134791900461003
+2,8.6,6.0962564172657805
+2,8.8,6.057720934070558
+2,9,6.0191854508753355
+2,9.2,5.9806499676801135
+2,9.4,5.9421144844848905
+2,9.6,5.903579001289668
+2,9.8,5.865043518094446
+2,10,5.8265080348992235
+2,10.2,5.787972551704001
+2,10.4,5.749437068508779
+2,10.6,5.710901585313556
+2,10.8,5.672366102118334
+2,11,5.6338306189231115
+2,11.2,5.595295135727889
+2,11.4,5.556759652532667
+2,11.6,5.518224169337444
+2,11.8,5.479688686142222
+2,12,5.441153202946999
+2,12.2,5.402617719751776
+2,12.4,5.364082236556554
+2,12.6,5.325546753361332
+2,12.8,5.28701127016611
+2,13,5.248475786970887
+2,13.2,5.209940303775664
+2,13.4,5.171404820580442
+2,13.6,5.13286933738522
+2,13.8,5.094333854189997
+2,14,5.055798370994775
+2,14.2,5.017262887799552
+2,14.4,4.97872740460433
+2,14.6,4.940191921409107
+2,14.8,4.901656438213885
+2,15,4.863120955018662
+2,15.2,4.82458547182344
+2,15.4,4.786049988628217
+2,15.6,4.747514505432995
+2,15.8,4.708979022237772
+2,16,4.67044353904255
+2,16.2,4.631908055847328
+2,16.4,4.593372572652105
+2,16.6,4.554837089456882
+2,16.8,4.51630160626166
+2,17,4.477766123066438
+2,17.2,4.439230639871216
+2,17.4,4.400695156675992
+2,17.6,4.36215967348077
+2,17.8,4.323624190285548
+2,18,4.285088707090326
+2,18.2,4.246553223895104
+2,18.4,4.20801774069988
+2,18.6,4.169482257504658
+2,18.8,4.130946774309436
+2,19,4.092411291114214
+2,19.2,4.05387580791899
+2,19.4,4.015340324723768
+2,19.6,3.9768048415285455
+2,19.8,3.9382693583333235
+2,20,3.899733875138101
+2,20.2,3.861198391942878
+2,20.4,3.8226629087476556
+2,20.6,3.7841274255524335
+2,20.8,3.745591942357211
+2,21,3.707056459161989
+2,21.2,3.668520975966766
+2,21.4,3.6299854927715436
+2,21.6,3.591450009576321
+2,21.8,3.552914526381099
+2,22,3.5143790431858766
+2,22.2,3.4758435599906536
+2,22.4,3.437308076795431
+2,22.6,3.398772593600209
+2,22.8,3.360237110404987
+2,23,3.321701627209764
+2,23.2,3.283166144014541
+2,23.4,3.244630660819319
+2,23.6,3.206095177624097
+2,23.8,3.167559694428874
+2,24,3.129024211233652
+2,24.2,3.0904887280384292
+2,24.4,3.051953244843207
+2,24.6,3.0134177616479842
+2,24.8,2.974882278452762
+2,25,2.93634679525754
+2,25.2,2.8978113120623172
+2,25.4,2.8592758288670943
+2,25.6,2.8207403456718723
+2,25.8,2.78220486247665
+2,26,2.7436693792814273
+2,26.2,2.7051338960862044
+2,26.4,2.6665984128909823
+2,26.6,2.6280629296957603
+2,26.8,2.5895274465005373
+2,27,2.5509919633053153
+2,27.2,2.5124564801100924
+2,27.4,2.4739209969148703
+2,27.6,2.4353855137196474
+2,27.8,2.3968500305244254
+2,28,2.3583145473292033
+3,1.6,0.9313766962312353
+3,1.8,2.8772330824058714
+3,2,4.823089468580508
+3,2.2,6.768945854755146
+3,2.4,7.105415398961415
+3,2.6,6.960224487168067
+3,2.8,6.815033575374718
+3,3,6.66984266358137
+3,3.2,6.524651751788022
+3,3.4,6.379460839994673
+3,3.6,6.234269928201324
+3,3.8,6.089079016407975
+3,4,5.9438881046146275
+3,4.2,5.798697192821279
+3,4.4,5.65350628102793
+3,4.6,5.508315369234582
+3,4.8,5.363124457441233
+3,5,5.2179335456478855
+3,5.2,5.072742633854537
+3,5.4,4.927551722061188
+3,5.6,4.782360810267839
+3,5.8,4.6371698984744905
+3,6,4.491978986681143
+3,6.2,4.346788074887794
+3,6.4,4.201597163094446
+3,6.6,4.056406251301096
+3,6.8,3.911215339507748
+3,7,3.7660244277144
+3,7.2,3.6208335159210514
+3,7.4,3.4756426041277027
+3,7.6,3.3304516923343543
+3,7.8,3.1852607805410056
+3,8,3.0400698687476577
+3,8.2,2.894878956954308
+3,8.4,2.74968804516096
+3,8.6,2.6044971333676123
+3,8.8,2.4593062215742627
+3,9,2.314115309780915
+3,9.2,2.168924397987566
+3,9.4,2.0237334861942173
+3,9.6,1.8785425744008686
+3,9.8,1.7333516626075207
+3,10,1.588160750814172
+3,10.2,1.4429698390208223
+3,10.4,1.2977789272274745
+3,10.6,1.1525880154341257
+3,10.8,1.007397103640777
+3,11,0.8622061918474291
+3,11.2,0.7170152800540803
+3,11.4,0.5718243682607316
+3,11.6,0.4266334564673828
+3,11.8,0.28144254467403496
+3,12,0.1362516328806871
+4,1.8,0.03446160329753117
+4,2,0.11039649301185846
+4,2.2,0.18633138272618574
+4,2.4,0.2622662724405137
+4,2.6,0.338201162154841
+4,2.8,0.41413605186916896
+4,3,0.49007094158349623
+4,3.2,0.5660058312978236
+4,3.4,0.6419407210121515
+4,3.6,0.7178756107264788
+4,3.8,0.7938105004408067
+4,4,0.8697453901551341
+4,4.2,0.9456802798694613
+4,4.4,1.0216151695837894
+4,4.6,1.0975500592981173
+4,4.8,1.1734849490124446
+4,5,1.2494198387267719
+4,5.2,1.3253547284410991
+4,5.4,1.401289618155427
+4,5.6,1.477224507869755
+4,5.8,1.5531593975840823
+4,6,1.6290942872984095
+4,6.2,1.7050291770127368
+4,6.4,1.780964066727065
+4,6.6,1.856898956441393
+4,6.8,1.9328338461557202
+4,7,2.0087687358700475
+4,7.2,2.0847036255843747
+4,7.4,2.160638515298703
+4,7.6,2.2365734050130306
+4,7.8,2.312508294727358
+4,8,2.388443184441685
+4,8.2,2.4643780741560133
+4,8.4,2.5403129638703406
+4,8.6,2.616247853584668
+4,8.8,2.6921827432989955
+4,9,2.768117633013323
+4,9.2,2.844052522727651
+4,9.4,2.9199874124419782
+4,9.6,2.9959223021563064
+4,9.8,3.0718571918706337
+4,10,3.147792081584961
+4,10.2,3.2237269712992886
+4,10.4,3.299661861013616
+4,10.6,3.375596750727944
+4,10.8,3.4515316404422713
+4,11,3.5274665301565986
+4,11.2,3.6034014198709263
+4,11.4,3.679336309585254
+4,11.6,3.7552711992995818
+4,11.8,3.831206089013909
+4,12,3.9071409787282363
+4,12.2,3.9830758684425644
+4,12.4,4.059010758156892
+4,12.6,4.13494564787122
+4,12.8,4.210880537585547
+4,13,4.286815427299874
+4,13.2,4.362750317014202
+4,13.4,4.43868520672853
+4,13.6,4.514620096442857
+4,13.8,4.590554986157184
+4,14,4.666489875871512
+4,14.2,4.74242476558584
+4,14.4,4.818359655300167
+4,14.6,4.894294545014495
+4,14.8,4.9702294347288225
+4,15,5.04616432444315
+4,15.2,5.122099214157478
+4,15.4,5.198034103871805
+4,15.6,5.273968993586133
+4,15.8,5.349903883300461
+4,16,5.425838773014788
+4,16.2,5.501773662729115
+4,16.4,5.577708552443443
+4,16.6,5.653643442157771
+4,16.8,5.729578331872098
+4,17,5.805513221586425
+4,17.2,5.881448111300752
+4,17.4,5.9573830010150814
+4,17.6,5.935808700539215
+4,17.8,5.802461243232306
+4,18,5.669113785925397
+4,18.2,5.535766328618489
+4,18.4,5.4024188713115775
+4,18.6,5.269071414004668
+4,18.8,5.13572395669776
+4,19,5.00237649939085
+4,19.2,4.869029042083939
+4,19.4,4.73568158477703
+4,19.6,4.6023341274701215
+4,19.8,4.468986670163212
+4,20,4.335639212856304
+4,20.2,4.202291755549393
+4,20.4,4.068944298242483
+4,20.6,3.9355968409355744
+4,20.8,3.8022493836286655
+4,21,3.6689019263217566
+4,21.2,3.5355544690148455
+4,21.4,3.4022070117079366
+4,21.6,3.2688595544010277
+4,21.8,3.135512097094119
+4,22,3.00216463978721
+4,22.2,2.868817182480299
+4,22.4,2.73546972517339
+4,22.6,2.602122267866481
+4,22.8,2.468774810559572
+4,23,2.3354273532526633
+4,23.2,2.202079895945752
+4,23.4,2.0687324386388433
+4,23.6,1.935384981331934
+4,23.8,1.8020375240250255
+4,24,1.6686900667181161
+4,24.2,1.535342609411205
+4,24.4,1.4019951521042966
+4,24.6,1.2686476947973873
+4,24.8,1.1353002374904788
+4,25,1.0019527801835695
+4,25.2,0.8686053228766584
+4,25.4,0.7352578655697499
+4,25.6,0.6019104082628415
+4,25.8,0.46856295095593214
+4,26,0.3352154936490237
+4,26.2,0.2018680363421126
+4,26.4,0.06852057903520326
+5,5.8,0.035289304173951395
+5,6,0.4505745465027833
+5,6.2,0.865859788831617
+5,6.4,1.2811450311604506
+5,6.6,1.6964302734892844
+5,6.8,2.1117155158181182
+5,7,2.52700075814695
+5,7.2,2.9422860004757836
+5,7.4,3.3575712428046174
+5,7.6,3.772856485133451
+5,7.8,4.188141727462285
+5,8,4.6034269697911165
+5,8.2,5.0187122121199526
+5,8.4,5.433997454448784
+5,8.6,5.849282696777616
+5,8.8,6.264567939106452
+5,9,6.679853181435283
+5,9.2,7.095138423764119
+5,9.4,7.510423666092951
+5,9.6,7.925708908421787
+5,9.8,8.340994150750618
+5,10,8.756279393079451
+5,10.2,9.129354124964047
+5,10.4,8.880416369841797
+5,10.6,8.631478614719542
+5,10.8,8.382540859597292
+5,11,8.13360310447504
+5,11.2,7.884665349352786
+5,11.4,7.635727594230534
+5,11.6,7.386789839108281
+5,11.8,7.137852083986029
+5,12,6.888914328863779
+5,12.2,6.639976573741523
+5,12.4,6.391038818619272
+5,12.6,6.14210106349702
+5,12.8,5.893163308374769
+5,13,5.644225553252518
+5,13.2,5.395287798130262
+5,13.4,5.14635004300801
+5,13.6,4.897412287885759
+5,13.8,4.648474532763507
+5,14,4.399536777641256
+5,14.2,4.150599022519
+5,14.4,3.901661267396748
+5,14.6,3.6527235122744965
+5,14.8,3.403785757152246
+5,15,3.1548480020299943
+5,15.2,2.9059102469077382
+5,15.4,2.6569724917854867
+5,15.6,2.408034736663235
+5,15.8,2.1590969815409835
+5,16,1.9101592264187328
+5,16.2,1.6612214712964812
+5,16.4,1.4122837161742252
+5,16.6,1.1633459610519736
+5,16.8,0.9144082059297229
+5,17,0.6654704508074705
+5,17.2,0.4165326956852198
+5,17.4,0.16759494056296376
+6,3,0.08355098866237107
+6,3.2,0.7245005388441599
+6,3.4,1.3654500890259487
+6,3.6,2.006399639207736
+6,3.8,2.6473491893895247
+6,4,3.288298739571312
+6,4.2,3.929248289753101
+6,4.4,4.57019783993489
+6,4.6,5.211147390116678
+6,4.8,5.852096940298467
+6,5,6.4930464904802525
+6,5.2,7.1339960406620415
+6,5.4,7.77494559084383
+6,5.6,8.249675614344048
+6,5.8,8.073938821947774
+6,6,7.898202029551502
+6,6.2,7.7224652371552285
+6,6.4,7.546728444758955
+6,6.6,7.370991652362681
+6,6.8,7.195254859966408
+6,7,7.019518067570135
+6,7.2,6.843781275173861
+6,7.4,6.668044482777588
+6,7.6,6.492307690381315
+6,7.8,6.3165708979850415
+6,8,6.140834105588768
+6,8.2,5.965097313192494
+6,8.4,5.789360520796221
+6,8.6,5.613623728399949
+6,8.8,5.437886936003674
+6,9,5.262150143607402
+6,9.2,5.086413351211127
+6,9.4,4.910676558814854
+6,9.6,4.734939766418581
+6,9.8,4.559202974022307
+6,10,4.383466181626035
+6,10.2,4.207729389229761
+6,10.4,4.031992596833487
+6,10.6,3.8562558044372137
+6,10.8,3.680519012040941
+6,11,3.5047822196446683
+6,11.2,3.329045427248394
+6,11.4,3.153308634852121
+6,11.6,2.9775718424558466
+6,11.8,2.801835050059574
+6,12,2.626098257663301
+6,12.2,2.4503614652670267
+6,12.4,2.274624672870754
+6,12.6,2.0988878804744795
+6,12.8,1.9231510880782068
+6,13,1.747414295681934
+6,13.2,1.5716775032856605
+6,13.4,1.3959407108893878
+6,13.6,1.2202039184931124
+6,13.8,1.0444671260968397
+6,14,0.868730333700567
+6,14.2,0.6929935413042925
+6,14.4,0.5172567489080198
+6,14.6,0.34151995651174527
+6,14.8,0.16578316411547256
+7,2.4,0.3061989217232148
+7,2.6,0.6674139209244231
+7,2.8,1.0286289201256322
+7,3,1.3898439193268406
+7,3.2,1.7510589185280496
+7,3.4,2.112273917729259
+7,3.6,2.4734889169304672
+7,3.8,2.8347039161316765
+7,4,3.1959189153328844
+7,4.2,3.5571339145340937
+7,4.4,3.918348913735303
+7,4.6,4.279563912936512
+7,4.8,4.640778912137721
+7,5,5.0019939113389285
+7,5.2,5.363208910540138
+7,5.4,5.724423909741347
+7,5.6,6.085638908942556
+7,5.8,6.4468539081437655
+7,6,6.808068907344973
+7,6.2,7.169283906546182
+7,6.4,7.530498905747391
+7,6.6,7.8917139049486
+7,6.8,8.25292890414981
+7,7,8.614143903351017
+7,7.2,8.975358902552227
+7,7.4,9.223205964998874
+7,7.6,9.049025883726417
+7,7.8,8.874845802453962
+7,8,8.700665721181506
+7,8.2,8.52648563990905
+7,8.4,8.352305558636594
+7,8.6,8.17812547736414
+7,8.8,8.003945396091684
+7,9,7.8297653148192285
+7,9.2,7.6555852335467725
+7,9.4,7.481405152274317
+7,9.6,7.307225071001861
+7,9.8,7.133044989729406
+7,10,6.958864908456951
+7,10.2,6.784684827184495
+7,10.4,6.610504745912039
+7,10.6,6.436324664639583
+7,10.8,6.262144583367128
+7,11,6.087964502094673
+7,11.2,5.913784420822217
+7,11.4,5.739604339549762
+7,11.6,5.5654242582773055
+7,11.8,5.39124417700485
+7,12,5.217064095732395
+7,12.2,5.042884014459939
+7,12.4,4.868703933187483
+7,12.6,4.694523851915028
+7,12.8,4.520343770642573
+7,13,4.346163689370118
+7,13.2,4.17198360809766
+7,13.4,3.997803526825205
+7,13.6,3.82362344555275
+7,13.8,3.6494433642802955
+7,14,3.4752632830078403
+7,14.2,3.3010832017353824
+7,14.4,3.126903120462927
+7,14.6,2.952723039190472
+7,14.8,2.7785429579180176
+7,15,2.6043628766455624
+7,15.2,2.4301827953731046
+7,15.4,2.2560027141006493
+7,15.6,2.081822632828194
+7,15.8,1.9076425515557398
+7,16,1.7334624702832846
+7,16.2,1.5592823890108294
+7,16.4,1.3851023077383715
+7,16.6,1.2109222264659163
+7,16.8,1.036742145193461
+7,17,0.8625620639210076
+7,17.2,0.6883819826485524
+7,17.4,0.5142019013760937
+7,17.6,0.34002182010363846
+7,17.8,0.16584173883118325
+8,4.2,0.18167705006530635
+8,4.4,0.48085216442237483
+8,4.6,0.7800272787794433
+8,4.8,1.0792023931365118
+8,5,1.378377507493579
+8,5.2,1.6775526218506476
+8,5.4,1.976727736207716
+8,5.6,2.2759028505647847
+8,5.8,2.575077964921853
+8,6,2.8742530792789203
+8,6.2,3.1734281936359885
+8,6.4,3.472603307993057
+8,6.6,3.771778422350126
+8,6.8,4.0709535367071945
+8,7,4.370128651064261
+8,7.2,4.66930376542133
+8,7.4,4.968478879778398
+8,7.6,5.00505161871043
+8,7.8,4.7830266803422274
+8,8,4.561001741974025
+8,8.2,4.338976803605821
+8,8.4,4.116951865237619
+8,8.6,3.8949269268694167
+8,8.8,3.6729019885012124
+8,9,3.45087705013301
+8,9.2,3.228852111764806
+8,9.4,3.006827173396604
+8,9.6,2.7848022350284
+8,9.8,2.5627772966601974
+8,10,2.3407523582919953
+8,10.2,2.118727419923791
+8,10.4,1.896702481555589
+8,10.6,1.6746775431873848
+8,10.8,1.4526526048191828
+8,11,1.2306276664509808
+8,11.2,1.008602728082776
+8,11.4,0.786577789714574
+8,11.6,0.5645528513463693
+8,11.8,0.3425279129781673
+8,12,0.1205029746099644
+9,1.8,0.054095811174320274
+9,2,0.23891813000759307
+9,2.2,0.42374044884086587
+9,2.4,0.6085627676741386
+9,2.6,0.793385086507411
+9,2.8,0.9782074053406838
+9,3,1.1630297241739562
+9,3.2,1.347852043007229
+9,3.4,1.5326743618405019
+9,3.6,1.7174966806737741
+9,3.8,1.9023189995070469
+9,4,2.0871413183403194
+9,4.2,2.271963637173592
+9,4.4,2.456785956006865
+9,4.6,2.6416082748401375
+9,4.8,2.8264305936734107
+9,5,3.0112529125066825
+9,5.2,3.196075231339955
+9,5.4,3.380897550173228
+9,5.6,3.5657198690065006
+9,5.8,3.7505421878397738
+9,6,3.9353645066730456
+9,6.2,3.9852101699340765
+9,6.4,3.9379625601255945
+9,6.6,3.8907149503171126
+9,6.8,3.8434673405086306
+9,7,3.7962197307001486
+9,7.2,3.7489721208916666
+9,7.4,3.7017245110831847
+9,7.6,3.6544769012747027
+9,7.8,3.6072292914662207
+9,8,3.5599816816577388
+9,8.2,3.5127340718492563
+9,8.4,3.465486462040775
+9,8.6,3.418238852232293
+9,8.8,3.370991242423811
+9,9,3.323743632615329
+9,9.2,3.2764960228068465
+9,9.4,3.229248412998365
+9,9.6,3.1820008031898825
+9,9.8,3.1347531933814006
+9,10,3.087505583572919
+9,10.2,3.0402579737644366
+9,10.4,2.993010363955955
+9,10.6,2.9457627541474727
+9,10.8,2.898515144338991
+9,11,2.851267534530509
+9,11.2,2.804019924722027
+9,11.4,2.7567723149135452
+9,11.6,2.7095247051050633
+9,11.8,2.662277095296581
+9,12,2.6150294854880993
+9,12.2,2.567781875679617
+9,12.4,2.5205342658711354
+9,12.6,2.473286656062653
+9,12.8,2.4260390462541714
+9,13,2.3787914364456895
+9,13.2,2.3315438266372075
+9,13.4,2.2842962168287255
+9,13.6,2.2370486070202436
+9,13.8,2.1898009972117616
+9,14,2.1425533874032796
+9,14.2,2.0953057775947976
+9,14.4,2.0480581677863157
+9,14.6,2.0008105579778337
+9,14.8,1.9535629481693517
+9,15,1.9063153383608702
+9,15.2,1.8590677285523878
+9,15.4,1.8118201187439062
+9,15.6,1.7645725089354238
+9,15.8,1.7173248991269419
+9,16,1.6700772893184603
+9,16.2,1.6228296795099784
+9,16.4,1.575582069701496
+9,16.6,1.528334459893014
+9,16.8,1.481086850084532
+9,17,1.4338392402760505
+9,17.2,1.3865916304675685
+9,17.4,1.339344020659086
+9,17.6,1.2920964108506041
+9,17.8,1.2448488010421226
+9,18,1.1976011912336406
+9,18.2,1.1503535814251586
+9,18.4,1.1031059716166762
+9,18.6,1.0558583618081943
+9,18.8,1.0086107519997127
+9,19,0.9613631421912308
+9,19.2,0.9141155323827479
+9,19.4,0.8668679225742664
+9,19.6,0.8196203127657844
+9,19.8,0.7723727029573029
+9,20,0.7251250931488209
+9,20.2,0.6778774833403385
+9,20.4,0.6306298735318565
+9,20.6,0.5833822637233745
+9,20.8,0.536134653914893
+9,21,0.48888704410641104
+9,21.2,0.4416394342979286
+9,21.4,0.39439182448944665
+9,21.6,0.3471442146809647
+9,21.8,0.29989660487248315
+9,22,0.2526489950640012
+9,22.2,0.20540138525551876
+9,22.4,0.1581537754470368
+9,22.6,0.11090616563855482
+9,22.8,0.0636585558300733
+9,23,0.01641094602159132
+10,0.8,0.18560054341396032
+10,1,0.725055626181832
+10,1.2,1.2645107089497036
+10,1.4,1.8039657917175753
+10,1.6,2.3434208744854446
+10,1.8,2.8828759572533165
+10,2,3.422331040021188
+10,2.2,3.96178612278906
+10,2.4,4.501241205556932
+10,2.6,5.040696288324802
+10,2.8,5.580151371092674
+10,3,6.119606453860544
+10,3.2,6.659061536628416
+10,3.4,7.198516619396288
+10,3.6,7.737971702164158
+10,3.8,8.277426784932029
+10,4,8.8168818676999
+10,4.2,9.356336950467771
+10,4.4,9.895792033235644
+10,4.6,10.435247116003515
+10,4.8,10.974702198771386
+10,5,11.514157281539257
+10,5.2,12.053612364307128
+10,5.4,12.529686603119881
+10,5.6,12.19324937277289
+10,5.8,11.856812142425898
+10,6,11.520374912078907
+10,6.2,11.183937681731916
+10,6.4,10.847500451384924
+10,6.6,10.511063221037931
+10,6.8,10.17462599069094
+10,7,9.83818876034395
+10,7.2,9.501751529996957
+10,7.4,9.165314299649966
+10,7.6,8.828877069302973
+10,7.8,8.492439838955981
+10,8,8.15600260860899
+10,8.2,7.819565378261998
+10,8.4,7.483128147915007
+10,8.6,7.146690917568017
+10,8.8,6.810253687221023
+10,9,6.473816456874032
+10,9.2,6.137379226527039
+10,9.4,5.800941996180049
+10,9.6,5.4645047658330554
+10,9.8,5.128067535486065
+10,10,4.7916303051390745
+10,10.2,4.455193074792081
+10,10.4,4.11875584444509
+10,10.6,3.782318614098097
+10,10.8,3.4458813837511055
+10,11,3.109444153404116
+10,11.2,2.773006923057123
+10,11.4,2.4365696927101315
+10,11.6,2.1001324623631383
+10,11.8,1.7636952320161488
+10,12,1.4272580016691574
+10,12.2,1.0908207713221643
+10,12.4,0.7543835409751729
+10,12.6,0.4179463106281798
+10,12.8,0.0815090802811902
diff --git a/input/library_symptom_parameters.csv b/input/library_symptom_parameters.csv
new file mode 100644
index 00000000..88d4d348
--- /dev/null
+++ b/input/library_symptom_parameters.csv
@@ -0,0 +1,51 @@
+id,progression_type,parameter_name,parameter_value
+1,SymptomData,Symptom category,3
+1,SymptomData,Incubation period,1.9013179458890146
+1,SymptomData,Weibull shape,2.3028596741731664
+1,SymptomData,Weibull scale,1.7810565334641089
+1,SymptomData,Weibull upper bound,28
+2,SymptomData,Symptom category,2
+2,SymptomData,Incubation period,3.8669410315852906
+2,SymptomData,Weibull shape,2.238139598412739
+2,SymptomData,Weibull scale,3.3938860750104456
+2,SymptomData,Weibull upper bound,28
+3,SymptomData,Symptom category,1
+3,SymptomData,Incubation period,1.8058602852734242
+3,SymptomData,Weibull shape,2.189415992800881
+3,SymptomData,Weibull scale,1.2329108475984496e-21
+3,SymptomData,Weibull upper bound,28
+4,SymptomData,Symptom category,1
+4,SymptomData,Incubation period,16.52859289085587
+4,SymptomData,Weibull shape,0.5264434166789742
+4,SymptomData,Weibull scale,7.420944179649009
+4,SymptomData,Weibull upper bound,28
+5,SymptomData,Symptom category,2
+5,SymptomData,Incubation period,4.036079966069042
+5,SymptomData,Weibull shape,0.9983501184461244
+5,SymptomData,Weibull scale,108.46685958857132
+5,SymptomData,Weibull upper bound,28
+6,SymptomData,Symptom category,2
+6,SymptomData,Incubation period,3.8262662157426632
+6,SymptomData,Weibull shape,1.1620010371534175
+6,SymptomData,Weibull scale,0.574806143537459
+6,SymptomData,Weibull upper bound,28
+7,SymptomData,Symptom category,1
+7,SymptomData,Incubation period,4.382564143761171
+7,SymptomData,Weibull shape,1.8393366362948504
+7,SymptomData,Weibull scale,3.4602302144120416
+7,SymptomData,Weibull upper bound,28
+8,SymptomData,Symptom category,3
+8,SymptomData,Incubation period,3.4726239801448964
+8,SymptomData,Weibull shape,2.378730542718536
+8,SymptomData,Weibull scale,1.7756007581813682
+8,SymptomData,Weibull upper bound,28
+9,SymptomData,Symptom category,2
+9,SymptomData,Incubation period,5.875590266737582
+9,SymptomData,Weibull shape,3.938441892843904
+9,SymptomData,Weibull scale,7.033541854129597
+9,SymptomData,Weibull upper bound,28
+10,SymptomData,Symptom category,2
+10,SymptomData,Incubation period,5.873634714632399
+10,SymptomData,Weibull shape,3.183660730666515
+10,SymptomData,Weibull scale,3.49590408094402
+10,SymptomData,Weibull upper bound,28
diff --git a/scripts/functions.R b/scripts/functions.R
new file mode 100644
index 00000000..fa1c01db
--- /dev/null
+++ b/scripts/functions.R
@@ -0,0 +1,49 @@
+#' triangle_vl
+#' viral load at a given time based on triangular VL function
+#'
+#' @param t times at which viral load should be estimated
+#' @param dp peak value of viral load -- assumes that peak VL > LOD
+#' @param tp time of peak vira load
+#' @param wp proliferation time (time from logVL = 0 up to peak)
+#' @param wr clearance time (time from peak back down to logVL = 0)
+#'
+#' @return triangular viral load at each time t
+#' @export
+#'
+#' @examples triangle_vl()
+triangle_vl <- function(t, dp, tp, wp, wr) {
+  log_vl <- rep(NA, length(t))
+
+  log_vl <- dplyr::if_else(t <= tp,
+    (dp / wp) * (t - (tp - wp)),
+    dp - (dp / wr) * (t - tp)
+  )
+
+  return(log_vl)
+}
+
+calculate_weibull_scale <- function(
+    si_beta_0_exponentiated, si_beta_wr, wr, wr_mean, wr_sd) {
+  # We need wr_raw for the discrete Weibull scale parameter.
+  # wr_raw is the individual-level standard normal deviation of
+  # an individual's clearance time from the pooled mean.
+  # recall that wr = exp(log_wr_mean + log_wr_sd * wr_raw) * wr_midpoint_prior
+  # We output wr_mean = exp(log_wr_mean) * wr_midpoint_prior and
+  # wr_sd = exp(log_wr_sd) from the Stan model.
+  # Substituting gives us
+  # = wr_raw = (ln(wr / wr_midpoint_prior) - log_wr_mean) / log_wr_sd
+  # = (ln(wr / wr_midpoint_prior) - ln(wr_mean / wr_midpoint_prior)) / log_wr_sd
+  # = ln((wr / wr_midpoint_prior) / (wr_mean / wr_midpoint_prior)) / log_wr_sd
+  # = ln((wr / wr_midpoint_prior) * (wr_midpoint_prior / wr_mean)) / log_wr_sd
+  # = ln(wr / wr_mean) / log_wr_sd
+  log_wr_sd <- log(wr_sd)
+  wr_raw <- log(wr / wr_mean) / log_wr_sd
+  # Recall that the discrete Weibull takes two arguments --
+  # a shape parameter (pooled/fit by symptom category), and
+  # a scale parameter (individual level).
+  # The scale parameter is
+  # = exp(si_beta_0 + si_beta_wr * wr_raw)
+  # = exp(si_beta_0) * exp(si_beta_wr * wr_raw).
+  si_scale <- si_beta_0_exponentiated * exp(si_beta_wr * wr_raw)
+  return(si_scale)
+}
diff --git a/scripts/generate_isolation_guidance_inputs.R b/scripts/generate_isolation_guidance_inputs.R
new file mode 100644
index 00000000..7f3cf5eb
--- /dev/null
+++ b/scripts/generate_isolation_guidance_inputs.R
@@ -0,0 +1,182 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time from infectiousness onset to symptom onset is the
+# disease incubation period.
+log_pre_infectiousness_mean <- 0.5
+log_pre_infectiousness_std_dev <- 1
+dt <- 0.2
+max_time <- 28
+
+posterior_sampled_path <- file.path(
+  "input",
+  "isolation_guidance_stan_parameters.csv"
+)
+
+sampled_params <- readr::read_csv(
+  file = posterior_sampled_path
+)
+
+# Our isolation guidance parameters provide the viral load with respect to
+# time since _symptom onset_. We need to convert times to be with respect to
+# _infection_ onset because in our ABM we need to start forecasting
+# infectiousness the moment the agent becomes infectious rather than when they
+# start showing symptoms.
+# For this reason, we need to estimate the time of infection.
+# We do this by assuming that the actual infection event happens sometime
+# before whenever we assume infectiousness starts (logVL > 0).
+# This time is calibratable and is biologically the disease latent period.
+# The time from the start of the infection to symptom onset is the incubation
+# period (this is -infection_start_time), so we can interpret these parameters
+# and relationships among them epidemiologically.
+# The core assumption here is really that infectiousness starts at logVL > 0.
+# If that is not true, when we do model calibration of the pre infectiousness
+# period, we should see the posterior for the pre infectiousness period run up
+# against 0, and we can adjust the assumption that logVL > 0 marks the start of
+# infectiousness accordingly, making the start of infectiousness at a lower
+# logVL instead.
+lognorm_with_constraints <- function(minimum, mu, sd) {
+  # Generate a log-normal random variable with a minimum value
+  repeat {
+    l <- rlnorm(1, meanlog = mu, sdlog = sd)
+    if (l > minimum) {
+      return(l)
+    }
+  }
+}
+
+sampled_params <- sampled_params |>
+  dplyr::mutate(minimum_latent_period = tp - wp) |>
+  dplyr::mutate(
+    latent_period =
+      purrr::map_dbl(
+        minimum_latent_period,
+        lognorm_with_constraints,
+        log_pre_infectiousness_mean,
+        log_pre_infectiousness_std_dev
+      )
+  ) |>
+  # wp is the proliferation period -- time from logVL = 0 to peak logVL
+  # tp is the time of peak logVL wrt time since symptom onset
+  # so wp - tp is the time from logVL = 0 to symptom onset, and -(wp - tp)
+  # is the time at which infectiousness starts. We want to have the
+  # infection start before that, so we subtract some time.
+  dplyr::mutate(infection_start_time = tp - wp - latent_period)
+
+stopifnot(
+  all(sampled_params$infection_start_time <= 0)
+)
+
+# Our triangle_vl function from our isolation guidance work (copied and put in
+# `functions.R` for now) is defined in terms of the time since symptom onset.
+# This function is defined in terms of the time since infection.
+triangle_vl_wrt_infected_time <- function(
+    tp, dp, wp, wr, infection_start_time, max_time, dt) {
+  curve <- pmax(triangle_vl(
+    seq(infection_start_time, infection_start_time + max_time, dt),
+    dp, tp, wp, wr
+  ), 0)
+  # The triangle vl curves tell us the rate of transmission over time, so we
+  # treat them alone as the hazard rate.
+  # TODO (kzs9): allow for adding a viral_load_to_infectiousness function here
+  # which transforms the viral load to an infectiousness function using our
+  # isolation guidance assumptions for the different functional forms of
+  # infectiousness.
+  # When we do that, we also need to figure out a more mature way to handle
+  # the start of infectiousness. Do we stick with saying that infectiousness
+  # starts at logVL > 0 even though our other curves are no longer just the
+  # the logVL? Or, do we pick some threshold based on the specific viral load
+  # to infectiousness function we are using?
+  list(curve)
+}
+
+# For every parameter set, make a vector of the viral load over time trajectory.
+trajectories <- purrr::pmap_vec(
+  list(
+    tp = sampled_params$tp,
+    dp = sampled_params$dp,
+    wp = sampled_params$wp,
+    wr = sampled_params$wr,
+    infection_start_time = sampled_params$infection_start_time,
+    max_time = max_time,
+    dt = dt
+  ), triangle_vl_wrt_infected_time,
+  .progress = TRUE
+)
+
+# Prepare the data for long format.
+# Turn the list of vectors into a matrix.
+trajectories <- as_tibble(trajectories, .name_repair = "minimal") |>
+  sjmisc::rotate_df()
+times <- seq(0, max_time, dt) # Add the times for each value
+names(trajectories) <- times # As column names
+
+trajectories <- trajectories |>
+  dplyr::mutate(id = row_number()) |>
+  pivot_longer(cols = -c("id"), names_to = "time", values_to = "value") |>
+  # We can save space by removing zeros since the Rust code assumes that the
+  # rate is 0 for values outside the time series.
+  dplyr::filter(value != 0)
+
+write_csv(trajectories,
+  file.path(
+    "..", "ixa-epi-isolation", "input",
+    "library_empirical_rate_fns.csv"
+  ),
+  col_names = TRUE
+)
+
+sampled_params <- sampled_params |>
+  dplyr::mutate(
+    si_scale =
+      calculate_weibull_scale(
+        sampled_params$si_beta_0_exponentiated,
+        sampled_params$si_beta_wr,
+        sampled_params$wr,
+        sampled_params$wr_mean,
+        sampled_params$wr_std
+      )
+  )
+
+symptom_parameters <- sampled_params |>
+  dplyr::mutate(id = row_number()) |>
+  # Same max si as is used in isolation guidance modeling
+  dplyr::mutate(incubation_period = -infection_start_time, max_si = 28.0) |>
+  dplyr::select(
+    id, symp_type_cat,
+    incubation_period, si_shape, si_scale, max_si
+  )
+
+# Get the dataframe into the right format for ixa epi's progression
+# library reader
+names(symptom_parameters) <- c(
+  "id", "Symptom category", "Incubation period", "Weibull shape",
+  "Weibull scale",
+  "Weibull upper bound"
+)
+
+symptom_parameters <- symptom_parameters |>
+  tidyr::pivot_longer(
+    cols = -c("id"),
+    names_to = "parameter_name",
+    values_to = "parameter_value"
+  ) |>
+  dplyr::mutate(progression_type = "SymptomData") |>
+  dplyr::select(id, progression_type, parameter_name, parameter_value)
+
+write_csv(symptom_parameters,
+  file.path(
+    "..", "ixa-epi-isolation", "input",
+    "library_symptom_parameters.csv"
+  ),
+  col_names = TRUE
+)
diff --git a/scripts/plot_outputs.R b/scripts/plot_outputs.R
index 4f084e0f..459f85e2 100644
--- a/scripts/plot_outputs.R
+++ b/scripts/plot_outputs.R
@@ -9,7 +9,7 @@ ggplot2::theme_set(ggplot2::theme_classic())
 ## Read person properties reports
 ## ===============================#
 
-infections <- readr::read_csv(file.path(
+person_property_report <- readr::read_csv(file.path(
   "output",
   "person_property_count.csv"
 ))
@@ -19,10 +19,19 @@ infections <- readr::read_csv(file.path(
 ## ===============================#
 
 # Infectious curves
-infections |>
+person_property_report |>
   group_by(t, InfectionStatus) |>
   summarise(count = sum(count), .groups = "drop") |>
   ggplot(aes(x = t, y = count)) +
   geom_line(aes(color = InfectionStatus)) +
   xlab("Day") +
   ylab("Number of people")
+
+# Symptom curves
+person_property_report |>
+  group_by(t, Symptoms) |>
+  summarise(count = sum(count), .groups = "drop") |>
+  ggplot(aes(x = t, y = count)) +
+  geom_line(aes(color = Symptoms)) +
+  xlab("Day") +
+  ylab("Number of people")
diff --git a/src/infectiousness_manager.rs b/src/infectiousness_manager.rs
index c578a4f0..61994721 100644
--- a/src/infectiousness_manager.rs
+++ b/src/infectiousness_manager.rs
@@ -125,7 +125,8 @@ pub fn evaluate_forecast(
     let current_infectiousness = total_rate_fn.rate(elapsed_t);
 
     assert!(
-        (current_infectiousness <= forecasted_total_infectiousness),
+        // 1e-10 is a small enough tolerance for floating point comparison.
+        (current_infectiousness <= forecasted_total_infectiousness + 1e-10),
         "Person {person_id}: Forecasted infectiousness must always be greater than or equal to current infectiousness. Current: {current_infectiousness}, Forecasted: {forecasted_total_infectiousness}"
     );
 
@@ -136,7 +137,7 @@ pub fn evaluate_forecast(
             ForecastRng,
             current_infectiousness / forecasted_total_infectiousness,
         ) {
-            trace!("Person{person_id}: Forecast rejected");
+            trace!("Person {person_id}: Forecast rejected");
 
             return false;
         }
@@ -376,6 +377,7 @@ mod test {
         let p1 = context.add_person(()).unwrap();
         set_homogeneous_mixing_itinerary(&mut context, p1).unwrap();
         context.infect_person(p1, None);
+        // We need to add another person so that our total infectiousness is 1.
         let p2 = context.add_person(()).unwrap();
         set_homogeneous_mixing_itinerary(&mut context, p2).unwrap();
 
@@ -383,6 +385,19 @@ mod test {
         evaluate_forecast(&mut context, p1, invalid_forecast);
     }
 
+    #[test]
+    fn test_evaluate_still_succeeds_when_forecast_slightly_bigger() {
+        let mut context = setup_context();
+        let p1 = context.add_person(()).unwrap();
+        set_homogeneous_mixing_itinerary(&mut context, p1).unwrap();
+        context.infect_person(p1, None);
+        let p2 = context.add_person(()).unwrap();
+        set_homogeneous_mixing_itinerary(&mut context, p2).unwrap();
+
+        let still_valid_forecast = 1.0 - 9e-11;
+        assert!(evaluate_forecast(&mut context, p1, still_valid_forecast));
+    }
+
     #[test]
     fn test_infected_by() {
         let mut context = setup_context();
diff --git a/src/main.rs b/src/main.rs
index 2bcfc2b9..45edd51f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15,6 +15,7 @@ use ixa::runner::run_with_args;
 use ixa::{ContextPeopleExt, ContextRandomExt, ContextReportExt};
 use parameters::{ContextParametersExt, Params};
 use population_loader::Age;
+use symptom_progression::Symptoms;
 
 // You must run this with a parameters file:
 // cargo run -- --config input/input.json
@@ -39,12 +40,12 @@ fn main() {
             context.shutdown();
         });
 
-        // Report the number of people by age, census tract, and infectious status
+        // Report the number of people by age, census tract, symptoms, and infectious status
         // every report_period.
         context.add_periodic_report(
             "person_property_count",
             report_period,
-            (Age, InfectionStatus),
+            (Age, Symptoms, InfectionStatus),
         )?;
 
         settings::init(context);
diff --git a/src/parameters.rs b/src/parameters.rs
index 07da9cea..110ccfe5 100644
--- a/src/parameters.rs
+++ b/src/parameters.rs
@@ -7,8 +7,19 @@ use crate::settings::SettingProperties;
 
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
 pub enum RateFnType {
+    /// A constant rate of infectiousness (constant hazard -> exponential waiting times) for a given
+    /// duration.
     Constant { rate: f64, duration: f64 },
-    EmpiricalFromFile { file: PathBuf },
+    /// A library of empirical rate functions read in from a file.
+    EmpiricalFromFile {
+        /// The path to the library of empirical rates with columns, `id`, `time`, and `value`.
+        file: PathBuf,
+        /// Empirical rate functions are specified as hazard rates. However, the specified hazard
+        /// rates are relative rather than absolute (unlike the constant rate of infectiousness
+        /// which has an absolute rate of infection). We need a scale factor (that is often
+        /// calibrated) to convert the relative hazard rates to absolute rates of infection.
+        scale: f64,
+    },
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -63,6 +74,28 @@ fn validate_inputs(parameters: &Params) -> Result<(), IxaError> {
             "The report writing period must be non-negative.".to_string(),
         ));
     }
+    match parameters.infectiousness_rate_fn {
+        RateFnType::Constant { rate, duration } => {
+            if rate < 0.0 {
+                return Err(IxaError::IxaError(
+                    "The infectiousness rate must be non-negative.".to_string(),
+                ));
+            }
+            if duration < 0.0 {
+                return Err(IxaError::IxaError(
+                    "The infectiousness duration must be non-negative.".to_string(),
+                ));
+            }
+        }
+        RateFnType::EmpiricalFromFile { scale, .. } => {
+            if scale < 0.0 {
+                return Err(IxaError::IxaError(
+                    "The empirical rate function infectiousness scale must be non-negative."
+                        .to_string(),
+                ));
+            }
+        }
+    }
 
     // If all the itinerary ratios are None, we can't validate them.
     // If some of them are zero and the rest are none, we still shouldn't fail.
diff --git a/src/rate_fns/rate_fn_storage.rs b/src/rate_fns/rate_fn_storage.rs
index defb7148..8d42805c 100644
--- a/src/rate_fns/rate_fn_storage.rs
+++ b/src/rate_fns/rate_fn_storage.rs
@@ -3,7 +3,7 @@ use std::path::PathBuf;
 use ixa::{define_data_plugin, define_rng, Context, ContextRandomExt, IxaError};
 use serde::{Deserialize, Serialize};
 
-use crate::parameters::{ContextParametersExt, RateFnType};
+use crate::parameters::{ContextParametersExt, Params, RateFnType};
 
 use super::{rate_fn::InfectiousnessRateFn, ConstantRate, EmpiricalRate};
 
@@ -23,15 +23,15 @@ define_data_plugin!(
 define_rng!(InfectiousnessRateRng);
 
 pub trait InfectiousnessRateExt {
-    fn add_rate_fn(&mut self, dist: Box<dyn InfectiousnessRateFn>) -> RateFnId;
+    fn add_rate_fn(&mut self, dist: impl InfectiousnessRateFn + 'static) -> RateFnId;
     fn get_random_rate_fn(&self) -> RateFnId;
     fn get_rate_fn(&self, index: RateFnId) -> &dyn InfectiousnessRateFn;
 }
 
 impl InfectiousnessRateExt for Context {
-    fn add_rate_fn(&mut self, dist: Box<dyn InfectiousnessRateFn>) -> RateFnId {
+    fn add_rate_fn(&mut self, dist: impl InfectiousnessRateFn + 'static) -> RateFnId {
         let container = self.get_data_container_mut(RateFnPlugin);
-        container.rates.push(dist);
+        container.rates.push(Box::new(dist));
         RateFnId(container.rates.len() - 1)
     }
 
@@ -62,9 +62,9 @@ pub fn load_rate_fns(context: &mut Context) -> Result<(), IxaError> {
 
     match rate_of_infection {
         RateFnType::Constant { rate, duration } => {
-            context.add_rate_fn(Box::new(ConstantRate::new(rate, duration)?));
+            context.add_rate_fn(ConstantRate::new(rate, duration)?);
         }
-        RateFnType::EmpiricalFromFile { file } => {
+        RateFnType::EmpiricalFromFile { file, .. } => {
             add_rate_fns_from_file(context, file)?;
         }
     }
@@ -79,34 +79,42 @@ pub struct EmpiricalRateFnRecord {
 }
 
 fn add_rate_fns_from_file(context: &mut Context, file: PathBuf) -> Result<(), IxaError> {
+    let Params {
+        infectiousness_rate_fn,
+        ..
+    } = context.get_params();
+    let &RateFnType::EmpiricalFromFile { scale, .. } = infectiousness_rate_fn else {
+        unreachable!("This function should only be called for empirical rate functions");
+    };
     let mut reader = csv::Reader::from_path(file)?;
     let mut reader = reader.deserialize::<EmpiricalRateFnRecord>();
     // Pop out the first record so we can initialize the vectors
     let record = reader.next().unwrap()?;
     let mut last_id = record.id;
     let mut times = vec![record.time];
-    let mut values = vec![record.value];
+    let mut values = vec![record.value * scale];
     for record in reader {
         let record = record?;
-        // For now, assume only empirical rate functions
+        // For now, assume that we are only reading in empirical rate functions, so the code
+        // below is pegged to the input format for empirical rate functions.
         if record.id == last_id {
             // Add to the current rate function
             times.push(record.time);
-            values.push(record.value);
+            values.push(record.value * scale);
         } else {
             // Take the last values of times and values and make them into a rate function
             if !times.is_empty() {
-                let fcn = Box::new(EmpiricalRate::new(times, values)?);
+                let fcn = EmpiricalRate::new(times, values)?;
                 context.add_rate_fn(fcn);
                 last_id = record.id;
             }
             // Start the new values off
             times = vec![record.time];
-            values = vec![record.value];
+            values = vec![record.value * scale];
         }
     }
     // Add the last rate function in the CSV
-    let fcn = Box::new(EmpiricalRate::new(times, values)?);
+    let fcn = EmpiricalRate::new(times, values)?;
     context.add_rate_fn(fcn);
     Ok(())
 }
@@ -149,7 +157,7 @@ mod tests {
         let mut context = init_context();
 
         let rate_fn = TestRateFn {};
-        context.add_rate_fn(Box::new(rate_fn));
+        context.add_rate_fn(rate_fn);
 
         let i = context.get_random_rate_fn();
         assert!(i.0 == 0);
@@ -187,24 +195,41 @@ mod tests {
 
     #[test]
     fn test_read_rate_function_file_multiple_functions() {
+        let scale = 2.0;
         let mut context = Context::new();
-        let file = PathBuf::from("./tests/data/two_rate_fns.csv");
-        add_rate_fns_from_file(&mut context, file).unwrap();
+        let parameters = Params {
+            initial_infections: 3,
+            max_time: 100.0,
+            seed: 0,
+            infectiousness_rate_fn: RateFnType::EmpiricalFromFile {
+                file: PathBuf::from("./tests/data/two_rate_fns.csv"),
+                scale,
+            },
+            symptom_progression_library: None,
+            report_period: 1.0,
+            synth_population_file: PathBuf::from("."),
+            transmission_report_name: None,
+            settings_properties: HashMap::new(),
+        };
+        context
+            .set_global_property_value(GlobalParams, parameters)
+            .unwrap();
+        load_rate_fns(&mut context).unwrap();
         let rate_fns = context.get_data_container(RateFnPlugin).unwrap();
         // Make sure we load two rate functions as expected
         assert_eq!(rate_fns.rates.len(), 2);
         // Check that rate function 1 is what we expect it to be
         let rate_fn = rate_fns.rates[0].as_ref();
-        assert_almost_eq!(rate_fn.rate(0.0), 1.0, 0.0);
-        assert_almost_eq!(rate_fn.rate(1.0), 2.0, 0.0);
-        assert_almost_eq!(rate_fn.rate(2.0), 3.0, 0.0);
+        assert_almost_eq!(rate_fn.rate(0.0), 1.0 * scale, 0.0);
+        assert_almost_eq!(rate_fn.rate(1.0), 2.0 * scale, 0.0);
+        assert_almost_eq!(rate_fn.rate(2.0), 3.0 * scale, 0.0);
         assert_almost_eq!(rate_fn.infection_duration(), 2.0, 0.0);
-        assert_almost_eq!(rate_fn.cum_rate(2.0), 4.0, 0.0);
+        assert_almost_eq!(rate_fn.cum_rate(2.0), 4.0 * scale, 0.0);
         // Check that rate function 2 is what we expect it to be
         let rate_fn = rate_fns.rates[1].as_ref();
-        assert_almost_eq!(rate_fn.rate(0.0), 2.0, 0.0);
-        assert_almost_eq!(rate_fn.rate(3.0), 2.0, 0.0);
+        assert_almost_eq!(rate_fn.rate(0.0), 2.0 * scale, 0.0);
+        assert_almost_eq!(rate_fn.rate(3.0), 2.0 * scale, 0.0);
         assert_almost_eq!(rate_fn.infection_duration(), 3.0, 0.0);
-        assert_almost_eq!(rate_fn.cum_rate(3.0), 6.0, 0.0);
+        assert_almost_eq!(rate_fn.cum_rate(3.0), 6.0 * scale, 0.0);
     }
 }


Reviews:
user: login: ChiragKumar9
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T16:48:47Z
user: login: confunguido
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-01T19:18:01Z
user: login: confunguido
body: lgtm
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-01T19:19:41Z
user: login: eqmooring
body: Thanks, Chirag. I am approving because I think the math / logic is fine. But I know I made a bunch of comments. Let me know if you want to discuss any of them.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T08:44:46Z
user: login: ChiragKumar9
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T13:07:58Z
user: login: ChiragKumar9
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T13:09:37Z
user: login: ChiragKumar9
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T13:33:05Z
user: login: ChiragKumar9
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:23:44Z
user: login: ChiragKumar9
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:25:23Z

Review comments:
diff_hunk: @@ -114,10 +118,13 @@ pub fn evaluate_forecast(
     let total_rate_fn = ScaledRateFn::new(rate_fn, total_multiplier, 0.0);
 
     let elapsed_t = context.get_elapsed_infection_time(person_id);
-    let current_infectiousness = total_rate_fn.rate(elapsed_t);
+    let current_infectiousness = match context.get_params().infectiousness_rate_fn {
+        RateFnType::EmpiricalFromFile { scale, .. } => total_rate_fn.rate(elapsed_t) * scale,
+        _ => total_rate_fn.rate(elapsed_t),
+    };
 
     assert!(
-        (f64::abs(current_infectiousness - forecasted_total_infectiousness) <= f64::EPSILON),
+        (f64::abs(current_infectiousness - forecasted_total_infectiousness) <= 2.0 * f64::EPSILON),
path: src/infectiousness_manager.rs
user: login: ChiragKumar9
body: This is where I've been running into the floating point arithmetic issues. I believe this is a numerical error. I have the change the float here to be `500.0 * f64::EPSILON` to get the model to run. Clearly, that's bad. I don't really know how to fix this. I noticed that the difference gets bigger more and more throughout an individual's infection -- I think that's because `ScaledRateFn.rate()` calls the base rate function but also multiplies by `self.scale` so the error just grows.
created_at: 2025-03-26T16:48:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,144 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion for basically adding a positive value (that we calibrate)
path: scripts/generate_isolation_guidance_inputs.R
user: login: confunguido
body: Could you clean up this comment and just describe what it does? 
created_at: 2025-05-01T19:18:01Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,163 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time to symptom onset is the incubation period.
+pre_infectiousness_mean <- 0.5
+pre_infectiousness_std_dev <- 1
+dt <- 0.2
+max_time <- 28
+
+posterior_sampled_path <- file.path(
+  "input",
+  "isolation_guidance_stan_parameters.csv"
+)
+
+sampled_params <- readr::read_csv(
+  file = posterior_sampled_path
+)
+
+# Our isolation guidance parameters provide the viral load _with respect to_
+# time since symptom. We need to convert times to be with respect to _infection_
+# onset. For this reason, we need to estimate the time of infection.
+# We do this by assuming that infection happens sometime before whenever we
+# assume infectiousness starts (logVL > 0), a calibratable amount of time.
+# Note that this time is the disease latent period, and the time from the start
+# of the infection to symptom onset is the incubation period
+# (this is -infection_start_time), so we can interpret these parameters
+# epidemiologically.
+# There core assumption here is really that infectiousness starts at logVL > 0.
+# If that is not true, we should see the pre infectiousness period run up
+# against 0 in the calibration, and we can adjust accordingly.
+lognorm_with_constraints <- function(minimum, mu, sd) {
+  # Generate a log-normal random variable with a minimum value
+  repeat {
+    x <- exp(rnorm(1, mean = log(mu), sd = sd))
+    if (x > minimum) {
+      return(x)
+    }
+  }
+}
+
+sampled_params <- sampled_params |>
+  dplyr::mutate(minimum_incubation_period = tp - wp) |>
+  dplyr::mutate(
+    pre_infectiousness_period =
+      purrr::map_dbl(
+        minimum_incubation_period,
+        lognorm_with_constraints,
+        pre_infectiousness_mean,
+        pre_infectiousness_std_dev
+      )
+  ) |>
+  # wp is the proliferation period -- time from logVL = 0 to peak logVL
+  # tp is the time of peak logVL wrt time since symptom onset
+  # so wp - tp is the time from logVL = 0 to symptom onset, and -(wp - tp)
+  # is the time at which infectiousness starts. We want to have the
+  # infection start before that, so we subtract some time.
+  dplyr::mutate(infection_start_time = tp - wp - pre_infectiousness_period)
+
+# Our triangle_vl function from our isolation guidance work (copied and put in
+# `functions.R` for now) is defined in terms of the time since symptom onset.
+# This function is defined in terms of the time since infection.
+triangle_vl_wrt_infected_time <- function(
+    tp, dp, wp, wr, infection_start_time, max_time, dt) {
+  curve <- pmax(triangle_vl(
+    seq(infection_start_time, infection_start_time + max_time, dt),
+    dp, tp, wp, wr
+  ), 0)
+  list(unnormalized_pdf_to_hazard(curve, dt))
+}
+
+# For every parameter set, make a vector of the viral load over time trajectory.
+trajectories <- purrr::pmap_vec(
+  list(
+    tp = sampled_params$tp,
+    dp = sampled_params$dp,
+    wp = sampled_params$wp,
+    wr = sampled_params$wr,
+    infection_start_time = sampled_params$infection_start_time,
+    max_time = max_time,
+    dt = dt
+  ), triangle_vl_wrt_infected_time,
+  .progress = TRUE
+)
+
+# Prepare the data for long format.
+# Turn the list of vectors into a matrix.
+trajectories <- as_tibble(trajectories, .name_repair = "minimal") |>
+  sjmisc::rotate_df()
+times <- seq(dt / 2, max_time, dt) # Add the times for each value
path: scripts/generate_isolation_guidance_inputs.R
user: login: eqmooring
body: Unclear what 2 is doing here.
created_at: 2025-05-06T06:18:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,7 +102,10 @@ pub fn get_forecast(context: &Context, person_id: PersonId) -> Option<Forecast>
     let t = total_rate_fn.inverse_cum_rate(e)?;
 
     let next_time = context.get_current_time() + t;
-    let forecasted_total_infectiousness = total_rate_fn.rate(t);
+    let forecasted_total_infectiousness = match context.get_params().infectiousness_rate_fn {
path: src/infectiousness_manager.rs
user: login: eqmooring
body: This is fine, but it seems like the code here and in line 130 are repeated. I wonder if there would be a way to multiply by the scale factor "upstream", e.g., as soon as the empirical rate function is read from the file.
created_at: 2025-05-14T19:35:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,8 +7,19 @@ use crate::settings::SettingProperties;
 
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
 pub enum RateFnType {
+    /// A constant rate of infectiousness (constant hazard -> exponential waiting times) for a given
+    /// duration.
     Constant { rate: f64, duration: f64 },
-    EmpiricalFromFile { file: PathBuf },
+    /// A library of empirical rate functions read in from a file.
+    EmpiricalFromFile {
+        /// The path to the library of empirical rates with columns, `id`, `time`, and `value`.
+        file: PathBuf,
+        /// Empirical rate functions are specified as hazard rates. However, the specified hazard
+        /// rates are relative rather than absolute (unlike the constant rate of infectiousness
+        /// which has an absolute rate of infection). We need a scale factor (that is often
path: src/parameters.rs
user: login: eqmooring
body: I am good with this for now. I personally lean toward seeing the scale factor as something that more naturally aligns with the specification of settings (along with alpha), since I am not sure of a physical interpretation of absolute infectiousness independent of a particular physical context (e.g., ventilation rates) in which transmission happens.
created_at: 2025-05-14T19:44:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -383,6 +391,18 @@ mod test {
         evaluate_forecast(&mut context, p1, invalid_forecast);
     }
 
+    #[test]
+    fn test_evaluate_fails_when_forecast_slightly_bigger() {
path: src/infectiousness_manager.rs
user: login: eqmooring
body: I think I am getting confused by this test, or at least by its name... `evaluate_forecast()` should *not* throw an error in this test, correct, because `still_valid_forecast` is within the tolerance subtracted above? 
created_at: 2025-05-14T20:13:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,169 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time to symptom onset is the incubation period.
+pre_infectiousness_mean <- 0.5
+pre_infectiousness_std_dev <- 1
+dt <- 0.2
+max_time <- 28
+
+posterior_sampled_path <- file.path(
+  "input",
+  "isolation_guidance_stan_parameters.csv"
+)
+
+sampled_params <- readr::read_csv(
+  file = posterior_sampled_path
+)
+
+# Our isolation guidance parameters provide the viral load _with respect to_
+# time since symptom. We need to convert times to be with respect to _infection_
+# onset. For this reason, we need to estimate the time of infection.
+# We do this by assuming that infection happens sometime before whenever we
+# assume infectiousness starts (logVL > 0), a calibratable amount of time.
+# Note that this time is the disease latent period, and the time from the start
+# of the infection to symptom onset is the incubation period
+# (this is -infection_start_time), so we can interpret these parameters
+# epidemiologically.
+# There core assumption here is really that infectiousness starts at logVL > 0.
+# If that is not true, we should see the pre infectiousness period run up
+# against 0 in the calibration, and we can adjust accordingly.
+lognorm_with_constraints <- function(minimum, mu, sd) {
+  # Generate a log-normal random variable with a minimum value
+  repeat {
+    x <- exp(rnorm(1, mean = log(mu), sd = sd))
path: scripts/generate_isolation_guidance_inputs.R
user: login: eqmooring
body: Minor comment: Is there a reason you are doing this rather than using `rlnorm()`? At least from the R documentation and Wikipedia, I think that the more common parameterization is for mu to be on the log scale so that the median of the resulting distribution is exp(mu) rather than mu.
created_at: 2025-05-15T06:53:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,169 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time to symptom onset is the incubation period.
+pre_infectiousness_mean <- 0.5
+pre_infectiousness_std_dev <- 1
+dt <- 0.2
+max_time <- 28
+
+posterior_sampled_path <- file.path(
+  "input",
+  "isolation_guidance_stan_parameters.csv"
+)
+
+sampled_params <- readr::read_csv(
+  file = posterior_sampled_path
+)
+
+# Our isolation guidance parameters provide the viral load _with respect to_
+# time since symptom. We need to convert times to be with respect to _infection_
+# onset. For this reason, we need to estimate the time of infection.
+# We do this by assuming that infection happens sometime before whenever we
+# assume infectiousness starts (logVL > 0), a calibratable amount of time.
+# Note that this time is the disease latent period, and the time from the start
+# of the infection to symptom onset is the incubation period
+# (this is -infection_start_time), so we can interpret these parameters
+# epidemiologically.
+# There core assumption here is really that infectiousness starts at logVL > 0.
+# If that is not true, we should see the pre infectiousness period run up
+# against 0 in the calibration, and we can adjust accordingly.
+lognorm_with_constraints <- function(minimum, mu, sd) {
+  # Generate a log-normal random variable with a minimum value
+  repeat {
+    x <- exp(rnorm(1, mean = log(mu), sd = sd))
+    if (x > minimum) {
+      return(x)
+    }
+  }
+}
+
+sampled_params <- sampled_params |>
+  dplyr::mutate(minimum_incubation_period = tp - wp) |>
+  dplyr::mutate(
+    pre_infectiousness_period =
+      purrr::map_dbl(
+        minimum_incubation_period,
+        lognorm_with_constraints,
+        pre_infectiousness_mean,
+        pre_infectiousness_std_dev
+      )
+  ) |>
+  # wp is the proliferation period -- time from logVL = 0 to peak logVL
+  # tp is the time of peak logVL wrt time since symptom onset
+  # so wp - tp is the time from logVL = 0 to symptom onset, and -(wp - tp)
+  # is the time at which infectiousness starts. We want to have the
+  # infection start before that, so we subtract some time.
+  dplyr::mutate(infection_start_time = tp - wp - pre_infectiousness_period)
+
+# Our triangle_vl function from our isolation guidance work (copied and put in
+# `functions.R` for now) is defined in terms of the time since symptom onset.
+# This function is defined in terms of the time since infection.
+triangle_vl_wrt_infected_time <- function(
+    tp, dp, wp, wr, infection_start_time, max_time, dt) {
+  curve <- pmax(triangle_vl(
+    seq(infection_start_time, infection_start_time + max_time, dt),
+    dp, tp, wp, wr
+  ), 0)
+  # The triangle vl curves tell us the rate of transmission over time, so we
+  # treat them alone as the hazard rate.
+  # TODO (kzs9): allow for adding a viral_load_to_infectiousness function here
+  # which transforms the viral load to an infectiousness function using our
+  # isolation guidance assumptions for the different functional forms of
+  # infectiousness.
+  list(curve)
+}
+
+# For every parameter set, make a vector of the viral load over time trajectory.
+trajectories <- purrr::pmap_vec(
+  list(
+    tp = sampled_params$tp,
+    dp = sampled_params$dp,
+    wp = sampled_params$wp,
+    wr = sampled_params$wr,
+    infection_start_time = sampled_params$infection_start_time,
+    max_time = max_time,
+    dt = dt
+  ), triangle_vl_wrt_infected_time,
+  .progress = TRUE
+)
+
+# Prepare the data for long format.
+# Turn the list of vectors into a matrix.
+trajectories <- as_tibble(trajectories, .name_repair = "minimal") |>
+  sjmisc::rotate_df()
+times <- seq(0, max_time, dt) # Add the times for each value
+names(trajectories) <- times # As column names
+
+trajectories <- trajectories |>
+  dplyr::mutate(id = row_number()) |>
+  pivot_longer(cols = -c("id"), names_to = "time", values_to = "value") |>
+  # Due to numerical instability, it is possible to have the rate go to infinity
+  # at the end of a timeseries -- we need to remove these.
+  dplyr::filter(is.finite(value)) |>
path: scripts/generate_isolation_guidance_inputs.R
user: login: eqmooring
body: Is this still necessary given the changed approach to hazards?
created_at: 2025-05-15T07:31:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,169 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time to symptom onset is the incubation period.
+pre_infectiousness_mean <- 0.5
+pre_infectiousness_std_dev <- 1
+dt <- 0.2
+max_time <- 28
+
+posterior_sampled_path <- file.path(
+  "input",
+  "isolation_guidance_stan_parameters.csv"
+)
+
+sampled_params <- readr::read_csv(
+  file = posterior_sampled_path
+)
+
+# Our isolation guidance parameters provide the viral load _with respect to_
+# time since symptom. We need to convert times to be with respect to _infection_
+# onset. For this reason, we need to estimate the time of infection.
+# We do this by assuming that infection happens sometime before whenever we
+# assume infectiousness starts (logVL > 0), a calibratable amount of time.
+# Note that this time is the disease latent period, and the time from the start
+# of the infection to symptom onset is the incubation period
+# (this is -infection_start_time), so we can interpret these parameters
+# epidemiologically.
+# There core assumption here is really that infectiousness starts at logVL > 0.
+# If that is not true, we should see the pre infectiousness period run up
+# against 0 in the calibration, and we can adjust accordingly.
+lognorm_with_constraints <- function(minimum, mu, sd) {
+  # Generate a log-normal random variable with a minimum value
+  repeat {
+    x <- exp(rnorm(1, mean = log(mu), sd = sd))
+    if (x > minimum) {
+      return(x)
+    }
+  }
+}
+
+sampled_params <- sampled_params |>
+  dplyr::mutate(minimum_incubation_period = tp - wp) |>
path: scripts/generate_isolation_guidance_inputs.R
user: login: eqmooring
body: I believe it would be more accurate to call this the `minimum_latent_period`.
created_at: 2025-05-15T08:14:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,169 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time to symptom onset is the incubation period.
+pre_infectiousness_mean <- 0.5
+pre_infectiousness_std_dev <- 1
+dt <- 0.2
+max_time <- 28
+
+posterior_sampled_path <- file.path(
+  "input",
+  "isolation_guidance_stan_parameters.csv"
+)
+
+sampled_params <- readr::read_csv(
+  file = posterior_sampled_path
+)
+
+# Our isolation guidance parameters provide the viral load _with respect to_
+# time since symptom. We need to convert times to be with respect to _infection_
+# onset. For this reason, we need to estimate the time of infection.
+# We do this by assuming that infection happens sometime before whenever we
+# assume infectiousness starts (logVL > 0), a calibratable amount of time.
+# Note that this time is the disease latent period, and the time from the start
+# of the infection to symptom onset is the incubation period
+# (this is -infection_start_time), so we can interpret these parameters
+# epidemiologically.
+# There core assumption here is really that infectiousness starts at logVL > 0.
+# If that is not true, we should see the pre infectiousness period run up
+# against 0 in the calibration, and we can adjust accordingly.
+lognorm_with_constraints <- function(minimum, mu, sd) {
+  # Generate a log-normal random variable with a minimum value
+  repeat {
+    x <- exp(rnorm(1, mean = log(mu), sd = sd))
+    if (x > minimum) {
+      return(x)
+    }
+  }
+}
+
+sampled_params <- sampled_params |>
+  dplyr::mutate(minimum_incubation_period = tp - wp) |>
+  dplyr::mutate(
+    pre_infectiousness_period =
path: scripts/generate_isolation_guidance_inputs.R
user: login: eqmooring
body: I may be misunderstanding, but would it be clearer to just call this the `latent_period`?
created_at: 2025-05-15T08:16:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,169 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time to symptom onset is the incubation period.
path: scripts/generate_isolation_guidance_inputs.R
user: login: eqmooring
body: Do you mean?

```suggestion
# plus the time from infectiousness onset to symptom onset is the incubation period.
```
created_at: 2025-05-15T08:18:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,169 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time to symptom onset is the incubation period.
+pre_infectiousness_mean <- 0.5
+pre_infectiousness_std_dev <- 1
+dt <- 0.2
+max_time <- 28
+
+posterior_sampled_path <- file.path(
+  "input",
+  "isolation_guidance_stan_parameters.csv"
+)
+
+sampled_params <- readr::read_csv(
+  file = posterior_sampled_path
+)
+
+# Our isolation guidance parameters provide the viral load _with respect to_
+# time since symptom. We need to convert times to be with respect to _infection_
+# onset. For this reason, we need to estimate the time of infection.
+# We do this by assuming that infection happens sometime before whenever we
+# assume infectiousness starts (logVL > 0), a calibratable amount of time.
+# Note that this time is the disease latent period, and the time from the start
+# of the infection to symptom onset is the incubation period
+# (this is -infection_start_time), so we can interpret these parameters
+# epidemiologically.
+# There core assumption here is really that infectiousness starts at logVL > 0.
+# If that is not true, we should see the pre infectiousness period run up
+# against 0 in the calibration, and we can adjust accordingly.
+lognorm_with_constraints <- function(minimum, mu, sd) {
+  # Generate a log-normal random variable with a minimum value
+  repeat {
+    x <- exp(rnorm(1, mean = log(mu), sd = sd))
+    if (x > minimum) {
+      return(x)
+    }
+  }
+}
+
+sampled_params <- sampled_params |>
+  dplyr::mutate(minimum_incubation_period = tp - wp) |>
+  dplyr::mutate(
+    pre_infectiousness_period =
+      purrr::map_dbl(
+        minimum_incubation_period,
+        lognorm_with_constraints,
+        pre_infectiousness_mean,
+        pre_infectiousness_std_dev
+      )
+  ) |>
+  # wp is the proliferation period -- time from logVL = 0 to peak logVL
+  # tp is the time of peak logVL wrt time since symptom onset
+  # so wp - tp is the time from logVL = 0 to symptom onset, and -(wp - tp)
+  # is the time at which infectiousness starts. We want to have the
+  # infection start before that, so we subtract some time.
+  dplyr::mutate(infection_start_time = tp - wp - pre_infectiousness_period)
+
+# Our triangle_vl function from our isolation guidance work (copied and put in
+# `functions.R` for now) is defined in terms of the time since symptom onset.
+# This function is defined in terms of the time since infection.
+triangle_vl_wrt_infected_time <- function(
+    tp, dp, wp, wr, infection_start_time, max_time, dt) {
+  curve <- pmax(triangle_vl(
+    seq(infection_start_time, infection_start_time + max_time, dt),
+    dp, tp, wp, wr
+  ), 0)
+  # The triangle vl curves tell us the rate of transmission over time, so we
+  # treat them alone as the hazard rate.
+  # TODO (kzs9): allow for adding a viral_load_to_infectiousness function here
+  # which transforms the viral load to an infectiousness function using our
+  # isolation guidance assumptions for the different functional forms of
+  # infectiousness.
+  list(curve)
+}
+
+# For every parameter set, make a vector of the viral load over time trajectory.
+trajectories <- purrr::pmap_vec(
path: scripts/generate_isolation_guidance_inputs.R
user: login: eqmooring
body: I think this is fine, but as discussed, it could be simplified to just three rows for each rate function. The times (I think) would be `c(tp - wp, wp, tp + wr) - infection_start_time` and the values would be `c(0, dp, 0)`
created_at: 2025-05-15T08:25:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,169 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time to symptom onset is the incubation period.
+pre_infectiousness_mean <- 0.5
+pre_infectiousness_std_dev <- 1
+dt <- 0.2
+max_time <- 28
+
+posterior_sampled_path <- file.path(
+  "input",
+  "isolation_guidance_stan_parameters.csv"
+)
+
+sampled_params <- readr::read_csv(
+  file = posterior_sampled_path
+)
+
+# Our isolation guidance parameters provide the viral load _with respect to_
+# time since symptom. We need to convert times to be with respect to _infection_
+# onset. For this reason, we need to estimate the time of infection.
+# We do this by assuming that infection happens sometime before whenever we
+# assume infectiousness starts (logVL > 0), a calibratable amount of time.
+# Note that this time is the disease latent period, and the time from the start
+# of the infection to symptom onset is the incubation period
+# (this is -infection_start_time), so we can interpret these parameters
+# epidemiologically.
+# There core assumption here is really that infectiousness starts at logVL > 0.
+# If that is not true, we should see the pre infectiousness period run up
+# against 0 in the calibration, and we can adjust accordingly.
+lognorm_with_constraints <- function(minimum, mu, sd) {
+  # Generate a log-normal random variable with a minimum value
+  repeat {
+    x <- exp(rnorm(1, mean = log(mu), sd = sd))
+    if (x > minimum) {
+      return(x)
+    }
+  }
+}
+
+sampled_params <- sampled_params |>
+  dplyr::mutate(minimum_incubation_period = tp - wp) |>
+  dplyr::mutate(
+    pre_infectiousness_period =
+      purrr::map_dbl(
+        minimum_incubation_period,
+        lognorm_with_constraints,
+        pre_infectiousness_mean,
+        pre_infectiousness_std_dev
+      )
+  ) |>
+  # wp is the proliferation period -- time from logVL = 0 to peak logVL
+  # tp is the time of peak logVL wrt time since symptom onset
+  # so wp - tp is the time from logVL = 0 to symptom onset, and -(wp - tp)
+  # is the time at which infectiousness starts. We want to have the
+  # infection start before that, so we subtract some time.
+  dplyr::mutate(infection_start_time = tp - wp - pre_infectiousness_period)
path: scripts/generate_isolation_guidance_inputs.R
user: login: eqmooring
body: Maybe do a `stopifnot()` to check that all `infection_start_time` values are negative?
created_at: 2025-05-15T08:26:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,169 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time to symptom onset is the incubation period.
+pre_infectiousness_mean <- 0.5
+pre_infectiousness_std_dev <- 1
+dt <- 0.2
+max_time <- 28
+
+posterior_sampled_path <- file.path(
+  "input",
+  "isolation_guidance_stan_parameters.csv"
+)
+
+sampled_params <- readr::read_csv(
+  file = posterior_sampled_path
+)
+
+# Our isolation guidance parameters provide the viral load _with respect to_
+# time since symptom. We need to convert times to be with respect to _infection_
+# onset. For this reason, we need to estimate the time of infection.
+# We do this by assuming that infection happens sometime before whenever we
+# assume infectiousness starts (logVL > 0), a calibratable amount of time.
+# Note that this time is the disease latent period, and the time from the start
+# of the infection to symptom onset is the incubation period
+# (this is -infection_start_time), so we can interpret these parameters
+# epidemiologically.
+# There core assumption here is really that infectiousness starts at logVL > 0.
+# If that is not true, we should see the pre infectiousness period run up
path: scripts/generate_isolation_guidance_inputs.R
user: login: eqmooring
body: Minor comment: Consider revising documentation to make clear that calibration is not actually performed here.
created_at: 2025-05-15T08:28:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,169 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time to symptom onset is the incubation period.
+pre_infectiousness_mean <- 0.5
+pre_infectiousness_std_dev <- 1
+dt <- 0.2
+max_time <- 28
+
+posterior_sampled_path <- file.path(
+  "input",
+  "isolation_guidance_stan_parameters.csv"
+)
+
+sampled_params <- readr::read_csv(
+  file = posterior_sampled_path
+)
+
+# Our isolation guidance parameters provide the viral load _with respect to_
+# time since symptom. We need to convert times to be with respect to _infection_
+# onset. For this reason, we need to estimate the time of infection.
+# We do this by assuming that infection happens sometime before whenever we
+# assume infectiousness starts (logVL > 0), a calibratable amount of time.
+# Note that this time is the disease latent period, and the time from the start
+# of the infection to symptom onset is the incubation period
+# (this is -infection_start_time), so we can interpret these parameters
+# epidemiologically.
+# There core assumption here is really that infectiousness starts at logVL > 0.
+# If that is not true, we should see the pre infectiousness period run up
+# against 0 in the calibration, and we can adjust accordingly.
+lognorm_with_constraints <- function(minimum, mu, sd) {
+  # Generate a log-normal random variable with a minimum value
+  repeat {
+    x <- exp(rnorm(1, mean = log(mu), sd = sd))
+    if (x > minimum) {
+      return(x)
+    }
+  }
+}
+
+sampled_params <- sampled_params |>
+  dplyr::mutate(minimum_incubation_period = tp - wp) |>
+  dplyr::mutate(
+    pre_infectiousness_period =
+      purrr::map_dbl(
+        minimum_incubation_period,
+        lognorm_with_constraints,
+        pre_infectiousness_mean,
+        pre_infectiousness_std_dev
+      )
+  ) |>
+  # wp is the proliferation period -- time from logVL = 0 to peak logVL
+  # tp is the time of peak logVL wrt time since symptom onset
+  # so wp - tp is the time from logVL = 0 to symptom onset, and -(wp - tp)
+  # is the time at which infectiousness starts. We want to have the
+  # infection start before that, so we subtract some time.
+  dplyr::mutate(infection_start_time = tp - wp - pre_infectiousness_period)
+
+# Our triangle_vl function from our isolation guidance work (copied and put in
+# `functions.R` for now) is defined in terms of the time since symptom onset.
+# This function is defined in terms of the time since infection.
+triangle_vl_wrt_infected_time <- function(
+    tp, dp, wp, wr, infection_start_time, max_time, dt) {
+  curve <- pmax(triangle_vl(
+    seq(infection_start_time, infection_start_time + max_time, dt),
+    dp, tp, wp, wr
+  ), 0)
+  # The triangle vl curves tell us the rate of transmission over time, so we
+  # treat them alone as the hazard rate.
+  # TODO (kzs9): allow for adding a viral_load_to_infectiousness function here
+  # which transforms the viral load to an infectiousness function using our
+  # isolation guidance assumptions for the different functional forms of
+  # infectiousness.
path: scripts/generate_isolation_guidance_inputs.R
user: login: eqmooring
body: Yes, and to expand on this, it's important to be clear that the current code not only assumes that infectiousness scales with log viral load but that the critical baseline threshold is 0 log VL /mL.
created_at: 2025-05-15T08:31:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,49 @@
+#' triangle_vl
+#' viral load at a given time based on triangular VL function
+#'
+#' @param t times at which viral load should be estimated
+#' @param dp peak value of viral load -- assumes that peak VL > LOD
+#' @param tp time of peak vira load
+#' @param wp proliferation time (time from logVL = 0 up to peak)
+#' @param wr clearance time (time from peak back down to logVL = 0)
+#'
+#' @return triangular viral load at each time t
+#' @export
+#'
+#' @examples triangle_vl()
+triangle_vl <- function(t, dp, tp, wp, wr) {
+  log_vl <- rep(NA, length(t))
+
+  log_vl <- dplyr::if_else(t <= tp,
+    (dp / wp) * (t - (tp - wp)),
+    dp - (dp / wr) * (t - tp)
+  )
+
+  return(log_vl)
+}
+
+calculate_weibull_scale <- function(
path: scripts/functions.R
user: login: eqmooring
body: The algebra looks correct to me, though I think we could think about (as an issue, not necessarily in this PR) tests that compare distribution of time to symptom improvement in this model versus what we simulated in stan.
created_at: 2025-05-15T08:40:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,169 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time to symptom onset is the incubation period.
+pre_infectiousness_mean <- 0.5
path: scripts/generate_isolation_guidance_inputs.R
user: login: eqmooring
body: Are these just semi-arbitrary choices? Just looking at the incubation periods in `input/library_symptom_parameters.csv`, some look extremely short while others look quite long. I wonder if you could turn down the standard deviation. But, as we've discussed, we never set out to estimate incubation periods in our analysis, so I see this issue as out-of-scope for now.
created_at: 2025-05-15T08:43:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,49 @@
+#' triangle_vl
+#' viral load at a given time based on triangular VL function
+#'
+#' @param t times at which viral load should be estimated
+#' @param dp peak value of viral load -- assumes that peak VL > LOD
+#' @param tp time of peak vira load
+#' @param wp proliferation time (time from logVL = 0 up to peak)
+#' @param wr clearance time (time from peak back down to logVL = 0)
+#'
+#' @return triangular viral load at each time t
+#' @export
+#'
+#' @examples triangle_vl()
+triangle_vl <- function(t, dp, tp, wp, wr) {
+  log_vl <- rep(NA, length(t))
+
+  log_vl <- dplyr::if_else(t <= tp,
+    (dp / wp) * (t - (tp - wp)),
+    dp - (dp / wr) * (t - tp)
+  )
+
+  return(log_vl)
+}
+
+calculate_weibull_scale <- function(
path: scripts/functions.R
user: login: ChiragKumar9
body: Agreed. See #128.
created_at: 2025-05-15T13:07:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,163 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time to symptom onset is the incubation period.
+pre_infectiousness_mean <- 0.5
+pre_infectiousness_std_dev <- 1
+dt <- 0.2
+max_time <- 28
+
+posterior_sampled_path <- file.path(
+  "input",
+  "isolation_guidance_stan_parameters.csv"
+)
+
+sampled_params <- readr::read_csv(
+  file = posterior_sampled_path
+)
+
+# Our isolation guidance parameters provide the viral load _with respect to_
+# time since symptom. We need to convert times to be with respect to _infection_
+# onset. For this reason, we need to estimate the time of infection.
+# We do this by assuming that infection happens sometime before whenever we
+# assume infectiousness starts (logVL > 0), a calibratable amount of time.
+# Note that this time is the disease latent period, and the time from the start
+# of the infection to symptom onset is the incubation period
+# (this is -infection_start_time), so we can interpret these parameters
+# epidemiologically.
+# There core assumption here is really that infectiousness starts at logVL > 0.
+# If that is not true, we should see the pre infectiousness period run up
+# against 0 in the calibration, and we can adjust accordingly.
+lognorm_with_constraints <- function(minimum, mu, sd) {
+  # Generate a log-normal random variable with a minimum value
+  repeat {
+    x <- exp(rnorm(1, mean = log(mu), sd = sd))
+    if (x > minimum) {
+      return(x)
+    }
+  }
+}
+
+sampled_params <- sampled_params |>
+  dplyr::mutate(minimum_incubation_period = tp - wp) |>
+  dplyr::mutate(
+    pre_infectiousness_period =
+      purrr::map_dbl(
+        minimum_incubation_period,
+        lognorm_with_constraints,
+        pre_infectiousness_mean,
+        pre_infectiousness_std_dev
+      )
+  ) |>
+  # wp is the proliferation period -- time from logVL = 0 to peak logVL
+  # tp is the time of peak logVL wrt time since symptom onset
+  # so wp - tp is the time from logVL = 0 to symptom onset, and -(wp - tp)
+  # is the time at which infectiousness starts. We want to have the
+  # infection start before that, so we subtract some time.
+  dplyr::mutate(infection_start_time = tp - wp - pre_infectiousness_period)
+
+# Our triangle_vl function from our isolation guidance work (copied and put in
+# `functions.R` for now) is defined in terms of the time since symptom onset.
+# This function is defined in terms of the time since infection.
+triangle_vl_wrt_infected_time <- function(
+    tp, dp, wp, wr, infection_start_time, max_time, dt) {
+  curve <- pmax(triangle_vl(
+    seq(infection_start_time, infection_start_time + max_time, dt),
+    dp, tp, wp, wr
+  ), 0)
+  list(unnormalized_pdf_to_hazard(curve, dt))
+}
+
+# For every parameter set, make a vector of the viral load over time trajectory.
+trajectories <- purrr::pmap_vec(
+  list(
+    tp = sampled_params$tp,
+    dp = sampled_params$dp,
+    wp = sampled_params$wp,
+    wr = sampled_params$wr,
+    infection_start_time = sampled_params$infection_start_time,
+    max_time = max_time,
+    dt = dt
+  ), triangle_vl_wrt_infected_time,
+  .progress = TRUE
+)
+
+# Prepare the data for long format.
+# Turn the list of vectors into a matrix.
+trajectories <- as_tibble(trajectories, .name_repair = "minimal") |>
+  sjmisc::rotate_df()
+times <- seq(dt / 2, max_time, dt) # Add the times for each value
path: scripts/generate_isolation_guidance_inputs.R
user: login: ChiragKumar9
body: Old code. No longer relevant now that we feed the triangles in directly rather than taking their hazard (which required a derivative, so the $dt/2$ was indicating that it was a midpoint derivative).
created_at: 2025-05-15T13:09:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,8 +7,19 @@ use crate::settings::SettingProperties;
 
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
 pub enum RateFnType {
+    /// A constant rate of infectiousness (constant hazard -> exponential waiting times) for a given
+    /// duration.
     Constant { rate: f64, duration: f64 },
-    EmpiricalFromFile { file: PathBuf },
+    /// A library of empirical rate functions read in from a file.
+    EmpiricalFromFile {
+        /// The path to the library of empirical rates with columns, `id`, `time`, and `value`.
+        file: PathBuf,
+        /// Empirical rate functions are specified as hazard rates. However, the specified hazard
+        /// rates are relative rather than absolute (unlike the constant rate of infectiousness
+        /// which has an absolute rate of infection). We need a scale factor (that is often
path: src/parameters.rs
user: login: ChiragKumar9
body: Fascinating. See #142.
created_at: 2025-05-15T13:33:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,7 +102,10 @@ pub fn get_forecast(context: &Context, person_id: PersonId) -> Option<Forecast>
     let t = total_rate_fn.inverse_cum_rate(e)?;
 
     let next_time = context.get_current_time() + t;
-    let forecasted_total_infectiousness = total_rate_fn.rate(t);
+    let forecasted_total_infectiousness = match context.get_params().infectiousness_rate_fn {
path: src/infectiousness_manager.rs
user: login: ChiragKumar9
body: This is a great idea. Addressed in 2b37602.
created_at: 2025-05-15T14:23:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,169 @@
+## ===============================================#
+## Setup -----------------
+## ===============================================#
+library(tidyverse)
+library(sjmisc)
+source("scripts/functions.R")
+set.seed(108)
+
+# Jason's suggestion: add some time before infectiousness starts
+# to get the time of infection. We can calibrate this time and use
+# it to check our assumption that infectiousness starts at logVL > 0.
+# The time we add is the disease latent period, and the latent period
+# plus the time to symptom onset is the incubation period.
+pre_infectiousness_mean <- 0.5
+pre_infectiousness_std_dev <- 1
+dt <- 0.2
+max_time <- 28
+
+posterior_sampled_path <- file.path(
+  "input",
+  "isolation_guidance_stan_parameters.csv"
+)
+
+sampled_params <- readr::read_csv(
+  file = posterior_sampled_path
+)
+
+# Our isolation guidance parameters provide the viral load _with respect to_
+# time since symptom. We need to convert times to be with respect to _infection_
+# onset. For this reason, we need to estimate the time of infection.
+# We do this by assuming that infection happens sometime before whenever we
+# assume infectiousness starts (logVL > 0), a calibratable amount of time.
+# Note that this time is the disease latent period, and the time from the start
+# of the infection to symptom onset is the incubation period
+# (this is -infection_start_time), so we can interpret these parameters
+# epidemiologically.
+# There core assumption here is really that infectiousness starts at logVL > 0.
+# If that is not true, we should see the pre infectiousness period run up
+# against 0 in the calibration, and we can adjust accordingly.
+lognorm_with_constraints <- function(minimum, mu, sd) {
+  # Generate a log-normal random variable with a minimum value
+  repeat {
+    x <- exp(rnorm(1, mean = log(mu), sd = sd))
+    if (x > minimum) {
+      return(x)
+    }
+  }
+}
+
+sampled_params <- sampled_params |>
+  dplyr::mutate(minimum_incubation_period = tp - wp) |>
+  dplyr::mutate(
+    pre_infectiousness_period =
+      purrr::map_dbl(
+        minimum_incubation_period,
+        lognorm_with_constraints,
+        pre_infectiousness_mean,
+        pre_infectiousness_std_dev
+      )
+  ) |>
+  # wp is the proliferation period -- time from logVL = 0 to peak logVL
+  # tp is the time of peak logVL wrt time since symptom onset
+  # so wp - tp is the time from logVL = 0 to symptom onset, and -(wp - tp)
+  # is the time at which infectiousness starts. We want to have the
+  # infection start before that, so we subtract some time.
+  dplyr::mutate(infection_start_time = tp - wp - pre_infectiousness_period)
+
+# Our triangle_vl function from our isolation guidance work (copied and put in
+# `functions.R` for now) is defined in terms of the time since symptom onset.
+# This function is defined in terms of the time since infection.
+triangle_vl_wrt_infected_time <- function(
+    tp, dp, wp, wr, infection_start_time, max_time, dt) {
+  curve <- pmax(triangle_vl(
+    seq(infection_start_time, infection_start_time + max_time, dt),
+    dp, tp, wp, wr
+  ), 0)
+  # The triangle vl curves tell us the rate of transmission over time, so we
+  # treat them alone as the hazard rate.
+  # TODO (kzs9): allow for adding a viral_load_to_infectiousness function here
+  # which transforms the viral load to an infectiousness function using our
+  # isolation guidance assumptions for the different functional forms of
+  # infectiousness.
+  list(curve)
+}
+
+# For every parameter set, make a vector of the viral load over time trajectory.
+trajectories <- purrr::pmap_vec(
path: scripts/generate_isolation_guidance_inputs.R
user: login: ChiragKumar9
body: Yep, for posterity, the reason we can simplify in this case is because our ixa-epi empirical rate function code linearly interpolates between specified rate function values, and the triangle viral load function is linear as it is. Because we will have to use the full timeseries when we have nonlinear hazard rates, for now I am keeping the explicit input structure.
created_at: 2025-05-15T14:25:23Z
author_association: CONTRIBUTOR

PR comments:
user: login: ChiragKumar9
created_at: 2025-04-25T19:32:20Z
body: Marking as ready for review because the math, proposal, implementation, etc. are stabilized. But, this PR is currently gated. We have to fix the numerical errors in the use of the empirical rate functions (probably something in the quadratic inversion?). Second, we have to land #110 first because that proposes the file input scheme for a progression library. However, once we have merged that PR, all we need to do is specify the symptom progression file output from this PR in the input file for our ixa-epi model, so nothing will actually change code-wise.
author_association: CONTRIBUTOR
user: login: confunguido
created_at: 2025-05-01T19:20:12Z
body: It looks fine to me but I think cargo is failing. 
author_association: COLLABORATOR
user: login: ChiragKumar9
created_at: 2025-05-01T21:07:28Z
body: > It looks fine to me but I think cargo is failing.

Yeah, I may ask for your help on this (if you want to go down a rabbit hole with me!)
author_association: CONTRIBUTOR