diff --git a/Cargo.lock b/Cargo.lock index be513b4..14bbbfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.15" @@ -51,6 +66,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "autocfg" version = "1.3.0" @@ -63,12 +87,33 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytemuck" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773d90827bc3feecfb67fab12e24de0749aad83c74b9504ecde46237b5cd24e2" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cc" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72db2f7947ecee9b03b510377e8bb9077afa27176fdbff55c51027e976fdcc48" +dependencies = [ + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -76,10 +121,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "clap" -version = "4.5.15" +name = "chrono" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d8838454fda655dafd3accb2b6e2bea645b9e4078abe84a22ceb947235c5cc" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "clap" +version = "4.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed6719fffa43d0d87e5fd8caeab59be1554fb028cd30edc88fc4369b17971019" dependencies = [ "clap_builder", ] @@ -108,6 +168,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crossbeam-deque" version = "0.8.5" @@ -133,6 +199,17 @@ version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +[[package]] +name = "distimate" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2e266cc5215f2a854deadfe97b5fb1047ab35121fd4fafa958e3760945790bf" +dependencies = [ + "rand", + "statrs", + "thiserror", +] + [[package]] name = "either" version = "1.13.0" @@ -178,6 +255,29 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indexmap" version = "2.4.0" @@ -201,10 +301,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] -name = "libc" -version = "0.2.155" +name = "js-sys" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.158" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" [[package]] name = "libm" @@ -219,10 +328,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] -name = "mcps" -version = "0.1.1" +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "matrixmultiply" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9380b911e3e96d10c1f415da0876389aaf1b56759054eeb0de7df940c456ba1a" dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "mcps" +version = "0.2.0" +dependencies = [ + "chrono", "clap", + "distimate", "rand", "rand_distr", "rayon", @@ -230,6 +357,7 @@ dependencies = [ "serde_json", "serde_yaml", "tempfile", + "workdays", ] [[package]] @@ -238,6 +366,63 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "nalgebra" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "rand", + "rand_distr", + "simba", + "typenum", +] + +[[package]] +name = "nalgebra-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -254,6 +439,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -321,6 +512,12 @@ dependencies = [ "rand", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.10.0" @@ -361,19 +558,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] -name = "serde" -version = "1.0.207" +name = "safe_arch" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2" +checksum = "c3460605018fdc9612bce72735cba0d27efbcd9904780d44c7e3a9948f96148a" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "serde" +version = "1.0.208" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff085d2cb684faa248efb494c39b68e522822ac0de72ccf08109abde717cfb2" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.207" +version = "1.0.208" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e" +checksum = "24008e81ff7613ed8e5ba0cfaf24e2c2f1e5b8a0495711e44fcd4882fca62bcf" dependencies = [ "proc-macro2", "quote", @@ -382,9 +588,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.124" +version = "1.0.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66ad62847a56b3dba58cc891acd13884b9c61138d330c0d7b6181713d4fce38d" +checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed" dependencies = [ "itoa", "memchr", @@ -405,6 +611,37 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + +[[package]] +name = "statrs" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f697a07e4606a0a25c044de247e583a330dbb1731d11bc7350b81f48ad567255" +dependencies = [ + "approx", + "nalgebra", + "num-traits", + "rand", +] + [[package]] name = "strsim" version = "0.11.1" @@ -413,9 +650,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.74" +version = "2.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" +checksum = "f6af063034fc1935ede7be0122941bafa9bacb949334d090b77ca98b5817c7d9" dependencies = [ "proc-macro2", "quote", @@ -435,6 +672,32 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "thiserror" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -459,6 +722,80 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasm-bindgen" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" + +[[package]] +name = "wide" +version = "0.7.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b828f995bf1e9622031f8009f8481a85406ce1f4d4588ff746d872043e855690" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -541,6 +878,18 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "workdays" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3bc4b29b64419a2983662ca6902adf5313df2a061e8f6104b2dd603765b5aa" +dependencies = [ + "chrono", + "serde", + "serde_json", + "serde_yaml", +] + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/Cargo.toml b/Cargo.toml index 6608b70..d1a4e39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mcps" -version = "0.1.1" +version = "0.2.0" authors = ["Stephen Waits "] edition = "2021" description = "A command-line tool for running Monte Carlo simulations on project schedules." @@ -22,13 +22,16 @@ name = "mcps" path = "src/main.rs" [dependencies] +chrono = "0.4.38" clap = "4.5.15" +distimate = "0.2.0" rand = "0.8.5" rand_distr = "0.4.3" rayon = "1.10.0" serde = { version = "1.0.207", features = ["derive"] } serde_json = "1.0.124" serde_yaml = "0.9.34" +workdays = "0.1.0" [dev-dependencies] tempfile = "3.12.0" diff --git a/README.md b/README.md index f3fdb5d..6ac9f62 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ Monte Carlo Project Scheduler (mcps) is a command-line tool that runs Monte Carlo simulations on project schedules to estimate completion times and effort required. It helps project managers and engineers gain a better understanding of potential project risks and timelines by providing a probabilistic analysis of -schedule durations and total work effort. +project durations and total work effort. ## Features - **Simulate Project Schedules**: Run multiple simulations to estimate the probability distribution of project completion times and total work effort. -- **Customizable Inputs**: Accepts project schedules in YAML or JSON formats, +- **Customizable Inputs**: Accepts project definitions in YAML or JSON formats, with options to override certain parameters like the number of workers or the number of iterations. - **Visual Output**: Generates an ASCII-based cumulative distribution function @@ -34,6 +34,9 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh cargo install mcps ``` +Note, if you're upgrading, use `cargo install --force mcps` to force +installation of the latest version.. + ### Option 2: Clone and build the repository locally ```bash @@ -46,45 +49,79 @@ The binary will be available at `target/release/mcps`. ## Usage -Once you have built the tool, you can use it by providing a project schedule +Once you have built the tool, you can use it by providing a project definition file in YAML or JSON format. ### Basic Usage ```bash -mcps example.yaml -i 100000 -w 10 +mcps project.yaml -i 100000 -w 10 ``` -This command runs the Monte Carlo simulation on the [`example.yaml`](./example.yaml) schedule file -with 100,000 iterations and overrides the number of workers to 10. +This command runs the Monte Carlo simulation on the +[`project.yaml`](./assets/project.yaml) project file with 100,000 iterations +and overrides the number of workers to 10. ### Command-Line Options -- `-i, --iterations `: Specify the number of iterations to run. Must be at least 100. Default is 50,000. -- `-w, --workers `: Override the number of workers specified in the schedule file. -- `-v, --version`: Print version information. -- `-h, --help`: Print help information. +- `-i, --iterations `: Specify the number of iterations to run. Must + be at least 100. Default is 50,000. +- `-n, --workers `: Override `num_workers` specified in project file +- `-b, --begin `: Override `start_date` specified in project file +- `-s, --schedule `: Work schedule config file (.yaml or .json) +- `-h, --help`: Print help +- `-V, --version`: Print version -### Schedule File Format +### Project Definition File Format -The tool accepts schedule files in YAML or JSON format. Below is an example of the YAML format: +`mcps` accepts projects in YAML or JSON format. Below is an example of the +YAML format: ```yaml num_workers: 5 # for the purpose of scheduling simulation -estimate_confidence: 0.80 # confidence actuals will fall inside estimate bounds +start_date: 2024-08-01 # [optional] project start date tasks: - id: DesignPhase - min_time: 1.5 # 1.5 days - max_time: 3.5 # 3.5 days + estimate: + min: 1.5 # 1.5 days + likely: 2.2 # 2.2 days (best guess at actual time) + max: 3.5 # 3.5 days dependencies: [] - id: ImplementationPhase - min_time: 2.25 # 2.25 days - max_time: 4.75 # 4.75 days + estimate: + min: 2.25 # 2.25 days + likely: 3.9 # 3.9 days (best guess at actual time) + max: 4.75 # 4.75 days dependencies: [DesignPhase] ``` -This repo includes examples in [JSON](./example.json) and -[YAML](./example.yaml). +This repo includes examples in [JSON](./assets/project.json) and +[YAML](./assets/project.yaml). + +### Work Schedule Configuration File Format + +This file is optional. It allows you to configure the days of the week you work +along with any specific work holidays. + +If you don't provide a schedule file, the tool will default to a 5-day work week +with no scheduled holidays. + +`mcps` accepts schedules in YAML or JSON format. Below is an example of the +YAML format: + +work_days: + +```yaml +work_days: + - Monday + - Tuesday + - Wednesday +holidays: + - 2023-12-25 +``` + +This repo includes examples in [JSON](./assets/schedule.json) and +[YAML](./assets/schedule.yaml). ### Example Output @@ -111,4 +148,5 @@ always welcome! ### License -This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details. +This project is licensed under the MIT License. See the [LICENSE](./LICENSE) +file for details. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..7049eb0 --- /dev/null +++ b/TODO.md @@ -0,0 +1,40 @@ +# TODO + +- update output with workdays results +- add CSV output of distributions +- add CLI parameter to specify which workdays, holidays, calendar config file to use +- switch to BetaPERT distribution: + +```rust +use rand::Rng; + +fn beta_pert_sample(min: f64, likely: f64, max: f64) -> f64 { + let alpha1 = 1.0 + 4.0 * (likely - min) / (max - min); + let alpha2 = 1.0 + 4.0 * (max - likely) / (max - min); + + let mut rng = rand::thread_rng(); + let u: f64 = rng.gen(); // Generate a uniform random number between 0 and 1 + + // Inverse transform sampling + let x = inverse_beta_cdf(u, alpha1, alpha2); + + // Scale and shift to the desired range + min + (max - min) * x +} + +fn inverse_beta_cdf(p: f64, alpha1: f64, alpha2: f64) -> f64 { + // This is a simple approximation of the inverse beta CDF + // For more accurate results, consider using a numerical method or a statistics library + let x = p.powf(1.0 / alpha1) / (p.powf(1.0 / alpha1) + (1.0 - p).powf(1.0 / alpha2)); + x +} + +fn main() { + let min = 10.0; + let likely = 20.0; + let max = 40.0; + + let sample = beta_pert_sample(min, likely, max); + println!("Random sample from BetaPERT({}, {}, {}): {}", min, likely, max, sample); +} +``` diff --git a/assets/output.png b/assets/output.png index 68ddc3f..0c87025 100644 Binary files a/assets/output.png and b/assets/output.png differ diff --git a/example.json b/assets/project.json similarity index 54% rename from example.json rename to assets/project.json index 7783bb1..76cfaab 100644 --- a/example.json +++ b/assets/project.json @@ -1,49 +1,67 @@ { "num_workers": 8, - "estimate_confidence": 0.8, + "start_date": "2024-08-01", "tasks": [ { "id": "RequirementsGathering", - "min_time": 10.0, - "max_time": 30.0, + "estimate": { + "min": 10.0, + "likely": 25.0, + "max": 30.0 + }, "dependencies": [] }, { "id": "SystemDesign", - "min_time": 15.0, - "max_time": 45.0, + "estimate": { + "min": 10.0, + "likely": 25.0, + "max": 30.0 + }, "dependencies": [ "RequirementsGathering" ] }, { "id": "DatabaseDesign", - "min_time": 10.0, - "max_time": 35.0, + "estimate": { + "min": 10.0, + "likely": 25.0, + "max": 30.0 + }, "dependencies": [ "SystemDesign" ] }, { "id": "UIUXDesign", - "min_time": 20.0, - "max_time": 50.0, + "estimate": { + "min": 10.0, + "likely": 25.0, + "max": 30.0 + }, "dependencies": [ "RequirementsGathering" ] }, { "id": "FrontendDevelopment", - "min_time": 25.0, - "max_time": 60.0, + "estimate": { + "min": 10.0, + "likely": 25.0, + "max": 30.0 + }, "dependencies": [ "UIUXDesign" ] }, { "id": "BackendDevelopment", - "min_time": 30.0, - "max_time": 80.0, + "estimate": { + "min": 10.0, + "likely": 25.0, + "max": 30.0 + }, "dependencies": [ "SystemDesign", "DatabaseDesign" @@ -51,16 +69,22 @@ }, { "id": "APIDevelopment", - "min_time": 20.0, - "max_time": 60.0, + "estimate": { + "min": 10.0, + "likely": 25.0, + "max": 30.0 + }, "dependencies": [ "BackendDevelopment" ] }, { "id": "IntegrationTesting", - "min_time": 15.0, - "max_time": 45.0, + "estimate": { + "min": 10.0, + "likely": 25.0, + "max": 30.0 + }, "dependencies": [ "FrontendDevelopment", "BackendDevelopment", @@ -69,32 +93,44 @@ }, { "id": "UserAcceptanceTesting", - "min_time": 10.0, - "max_time": 30.0, + "estimate": { + "min": 10.0, + "likely": 25.0, + "max": 30.0 + }, "dependencies": [ "IntegrationTesting" ] }, { "id": "PerformanceOptimization", - "min_time": 10.0, - "max_time": 40.0, + "estimate": { + "min": 10.0, + "likely": 25.0, + "max": 30.0 + }, "dependencies": [ "IntegrationTesting" ] }, { "id": "SecurityAudit", - "min_time": 10.0, - "max_time": 30.0, + "estimate": { + "min": 10.0, + "likely": 25.0, + "max": 30.0 + }, "dependencies": [ "IntegrationTesting" ] }, { "id": "FinalDeployment", - "min_time": 5.0, - "max_time": 15.0, + "estimate": { + "min": 10.0, + "likely": 25.0, + "max": 30.0 + }, "dependencies": [ "UserAcceptanceTesting", "PerformanceOptimization", @@ -103,8 +139,11 @@ }, { "id": "ProjectRetrospective", - "min_time": 3.0, - "max_time": 10.0, + "estimate": { + "min": 10.0, + "likely": 25.0, + "max": 30.0 + }, "dependencies": [ "FinalDeployment" ] diff --git a/example.yaml b/assets/project.yaml similarity index 59% rename from example.yaml rename to assets/project.yaml index 1ae2917..99480a0 100644 --- a/example.yaml +++ b/assets/project.yaml @@ -1,129 +1,177 @@ num_workers: 5 -estimate_confidence: 0.80 +start_date: 2024-08-01 tasks: # Project Initiation and Planning - id: project_kickoff - min_time: 2 - max_time: 3 + estimate: + min: 2 + likely: 2.8 + max: 3 dependencies: [] - id: requirements_gathering - min_time: 10 - max_time: 15 + estimate: + min: 10 + likely: 13 + max: 15 dependencies: [project_kickoff] - id: system_architecture_design - min_time: 15 - max_time: 20 + estimate: + min: 15 + likely: 18 + max: 20 dependencies: [requirements_gathering] - id: database_design - min_time: 5 - max_time: 10 + estimate: + min: 5 + likely: 8 + max: 10 dependencies: [system_architecture_design] # Frontend Development - id: ui_ux_design - min_time: 20 - max_time: 30 + estimate: + min: 20 + likely: 24 + max: 30 dependencies: [requirements_gathering] - id: frontend_setup - min_time: 3 - max_time: 5 + estimate: + min: 3 + likely: 4.2 + max: 5 dependencies: [ui_ux_design] - id: user_authentication_frontend - min_time: 5 - max_time: 10 + estimate: + min: 5 + likely: 6.1 + max: 10 dependencies: [frontend_setup] - id: product_catalog_frontend - min_time: 15 - max_time: 25 + estimate: + min: 15 + likely: 17 + max: 25 dependencies: [frontend_setup] - id: shopping_cart_frontend - min_time: 10 - max_time: 15 + estimate: + min: 10 + likely: 12 + max: 15 dependencies: [frontend_setup] - id: checkout_process_frontend - min_time: 15 - max_time: 20 + estimate: + min: 15 + likely: 17 + max: 20 dependencies: [shopping_cart_frontend] - id: user_profile_frontend - min_time: 5 - max_time: 10 + estimate: + min: 5 + likely: 6 + max: 10 dependencies: [user_authentication_frontend] - id: search_functionality_frontend - min_time: 10 - max_time: 15 + estimate: + min: 10 + likely: 12 + max: 15 dependencies: [product_catalog_frontend] # Backend Development - id: backend_setup - min_time: 5 - max_time: 10 + estimate: + min: 5 + likely: 7 + max: 10 dependencies: [system_architecture_design, database_design] - id: user_authentication_backend - min_time: 10 - max_time: 15 + estimate: + min: 10 + likely: 12 + max: 15 dependencies: [backend_setup] - id: product_catalog_backend - min_time: 20 - max_time: 30 + estimate: + min: 20 + likely: 25 + max: 30 dependencies: [backend_setup] - id: shopping_cart_backend - min_time: 15 - max_time: 20 + estimate: + min: 15 + likely: 18 + max: 20 dependencies: [backend_setup] - id: checkout_process_backend - min_time: 20 - max_time: 25 + estimate: + min: 20 + likely: 23 + max: 25 dependencies: [shopping_cart_backend] - id: user_profile_backend - min_time: 10 - max_time: 15 + estimate: + min: 10 + likely: 12 + max: 15 dependencies: [user_authentication_backend] - id: search_functionality_backend - min_time: 15 - max_time: 20 + estimate: + min: 15 + likely: 18 + max: 20 dependencies: [product_catalog_backend] - id: payment_gateway_integration - min_time: 10 - max_time: 15 + estimate: + min: 10 + likely: 13 + max: 15 dependencies: [checkout_process_backend] # API Development and Integration - id: api_design - min_time: 5 - max_time: 10 + estimate: + min: 5 + likely: 7.5 + max: 10 dependencies: [system_architecture_design] - id: api_development - min_time: 25 - max_time: 35 + estimate: + min: 25 + likely: 32 + max: 35 dependencies: [api_design, backend_setup] - id: api_documentation - min_time: 5 - max_time: 10 + estimate: + min: 5 + likely: 7 + max: 10 dependencies: [api_development] # Testing - id: unit_testing - min_time: 20 - max_time: 30 + estimate: + min: 20 + likely: 25 + max: 30 dependencies: [ user_authentication_backend, @@ -135,55 +183,75 @@ tasks: ] - id: integration_testing - min_time: 15 - max_time: 20 + estimate: + min: 15 + likely: 16 + max: 20 dependencies: [unit_testing, api_development] - id: user_acceptance_testing - min_time: 10 - max_time: 15 + estimate: + min: 10 + likely: 12 + max: 15 dependencies: [integration_testing] # Security and Performance - id: security_audit - min_time: 10 - max_time: 15 + estimate: + min: 10 + likely: 12 + max: 15 dependencies: [integration_testing] - id: performance_optimization - min_time: 15 - max_time: 20 + estimate: + min: 15 + likely: 17 + max: 20 dependencies: [integration_testing] # Deployment and Launch - id: deployment_planning - min_time: 5 - max_time: 10 + estimate: + min: 5 + likely: 7 + max: 10 dependencies: [system_architecture_design] - id: staging_environment_setup - min_time: 5 - max_time: 10 + estimate: + min: 5 + likely: 7 + max: 10 dependencies: [deployment_planning] - id: production_environment_setup - min_time: 10 - max_time: 15 + estimate: + min: 10 + likely: 12 + max: 15 dependencies: [staging_environment_setup] - id: data_migration - min_time: 10 - max_time: 20 + estimate: + min: 10 + likely: 12 + max: 20 dependencies: [production_environment_setup] - id: user_training - min_time: 5 - max_time: 10 + estimate: + min: 5 + likely: 7 + max: 10 dependencies: [user_acceptance_testing] - id: final_deployment - min_time: 5 - max_time: 10 + estimate: + min: 5 + likely: 7 + max: 10 dependencies: [ user_acceptance_testing, @@ -195,11 +263,15 @@ tasks: # Post-launch - id: post_launch_support - min_time: 10 - max_time: 15 + estimate: + min: 10 + likely: 12 + max: 15 dependencies: [final_deployment] - id: project_retrospective - min_time: 2 - max_time: 3 + estimate: + min: 2 + likely: 2.8 + max: 3 dependencies: [post_launch_support] diff --git a/assets/schedule.json b/assets/schedule.json new file mode 100644 index 0000000..d2bccf9 --- /dev/null +++ b/assets/schedule.json @@ -0,0 +1,10 @@ +{ + "work_days": [ + "Monday", + "Tuesday", + "Wednesday" + ], + "holidays": [ + "2023-12-25" + ] +} diff --git a/assets/schedule.yaml b/assets/schedule.yaml new file mode 100644 index 0000000..6ca0420 --- /dev/null +++ b/assets/schedule.yaml @@ -0,0 +1,6 @@ +work_days: + - Monday + - Tuesday + - Wednesday +holidays: + - 2023-12-25 diff --git a/src/main.rs b/src/main.rs index e9f5c28..04cfeac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,52 +1,17 @@ -use mcps::{schedule::Schedule, simulation::run_multiple_simulations}; +use chrono::{NaiveDate, Utc}; +use mcps::{schedule::Project, simulation::run_multiple_simulations}; use clap::{Arg, Command}; +use workdays::WorkCalendar; -use std::time::Duration; +use std::{str::FromStr, time::Duration}; const AFTER_HELP_TEXT: &str = "\ -Examples of supported file formats: +Find example project definition and schedule config files in the repository: - YAML Format: + https://github.com/swaits/mcps/blob/main/examples/ - num_workers: 5 - estimate_confidence: 0.80 - tasks: - - id: DesignPhase - min_time: 1.5 # 1.5 days - max_time: 3.5 # 3.5 days - dependencies: [] - - id: ImplementationPhase - min_time: 2.25 # 2.25 days - max_time: 4.75 # 4.75 days - dependencies: [DesignPhase] - - JSON Format: - - { - \"num_workers\": 5, - \"estimate_confidence\": 0.80, - \"tasks\": [ - { - \"id\": \"DesignPhase\", - \"min_time\": 1.5, - \"max_time\": 3.5, - \"dependencies\": [] - }, - { - \"id\": \"ImplementationPhase\", - \"min_time\": 2.25, - \"max_time\": 4.75, - \"dependencies\": [\"DesignPhase\"] - } - ] - } - - Note: `estimate_confidence` is the confidence (0.0,1.0) that the - actual values fall in the task estimate ranges. For example, - 0.8 means you believe that the actual time it will take to - complete a task falls in your [min_time,max_time] estimate - range for that task 80% of the time."; +"; fn main() -> Result<(), Box> { let matches = Command::new("Monte Carlo Project Scheduler") @@ -55,7 +20,7 @@ fn main() -> Result<(), Box> { .about("Runs Monte Carlo simulations on project schedules") .arg( Arg::new("filename") - .help("Path to the schedule file (.yaml or .json).") + .help("Path to the project file (.yaml or .json)") .required(true) .index(1), ) @@ -68,15 +33,29 @@ fn main() -> Result<(), Box> { ) .arg( Arg::new("workers") - .short('w') + .short('n') .long("workers") - .help("Override `num_workers` specified in schedule file") + .help("Override `num_workers` specified in project file") .value_name("num_workers"), ) + .arg( + Arg::new("begin") + .short('b') + .long("begin") + .help("Override `start_date` specified in project file") + .value_name("YYYY-MM-DD"), + ) + .arg( + Arg::new("schedule") + .short('s') + .long("schedule") + .help("Work schedule config file (.yaml or .json)") + .value_name("filename"), + ) .after_help(AFTER_HELP_TEXT) .get_matches(); - let schedule_path = matches.get_one::("filename").unwrap(); + let project_path = matches.get_one::("filename").unwrap(); let num_simulations: usize = matches.get_one::("iterations").unwrap().parse()?; // Ensure iterations is >= 100 @@ -84,8 +63,20 @@ fn main() -> Result<(), Box> { return Err("Iterations must be at least 100.".into()); } - // Load the schedule - let mut schedule = Schedule::from_file(schedule_path)?; + // Load the project + let mut project = Project::from_file(project_path)?; + + // Load work schedule if it exists + let calendar: WorkCalendar = match matches.get_one::("schedule") { + Some(filename) => WorkCalendar::from_str(&std::fs::read_to_string(filename)?)?, + None => WorkCalendar::new(), + }; + + // Determine the start date (command line > project file > TODAY) + let start_date = matches.get_one::("begin") + .and_then(|date| NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()) + .or(project.start_date) + .unwrap_or_else(|| Utc::now().date_naive()); // Check if workers are overridden by command-line argument if let Some(workers_str) = matches.get_one::("workers") { @@ -93,34 +84,39 @@ fn main() -> Result<(), Box> { if workers < 1 { return Err("Invalid number of workers, must be 1 or more".into()); } - schedule.num_workers = workers; + project.num_workers = workers; } // Monte Carlo simulation - let (project_durations, effort_times) = run_multiple_simulations(&schedule, num_simulations); + let (project_durations, effort_times) = run_multiple_simulations(&project, num_simulations); // Results output print_ascii_cdf( &project_durations, format!( - "Project Completion Time with {} Worker{} (by probability)", - schedule.num_workers, - if schedule.num_workers == 1 { "" } else { "s" } + "Completion Time ({} Worker{}, starting {})", + project.num_workers, + if project.num_workers == 1 { "" } else { "s" }, + start_date, ) .as_str(), - "Duration", + &start_date, + &calendar, ); + println!(); + print_ascii_cdf( &effort_times, - "Total Work Effort (by probability)", - "Effort", + format!("Total Work Effort (1 worker, starting {})", start_date).as_str(), + &start_date, + &calendar, ); Ok(()) } -fn print_ascii_cdf(data: &[Duration], title: &str, units: &str) { +fn print_ascii_cdf(data: &[Duration], title: &str, start: &NaiveDate, calendar: &WorkCalendar) { let mut sorted_data = data.to_vec(); sorted_data.sort_unstable(); let min = sorted_data[0]; @@ -139,9 +135,9 @@ fn print_ascii_cdf(data: &[Duration], title: &str, units: &str) { padding_right = width - padding - title.len() ); - println!("────┬────────────────────────────────────────────────────────────┬──────────"); - println!("%ile│{}│{:>10}", centered_title, units); - println!("────┼────────────────────────────────────────────────────────────┼──────────"); + println!("────┬────────────────────────────────────────────────────────────┬──────────┬──────────┬──────────"); + println!("%ile│{}│ Workdays │ Schedule │ Complete ", centered_title); + println!("────┼────────────────────────────────────────────────────────────┼──────────┼──────────┼──────────"); let bar_width = 60; @@ -179,6 +175,9 @@ fn print_ascii_cdf(data: &[Duration], title: &str, units: &str) { let offset = (bar_width - (max_bar_position - min_bar_position)) / 2; for (i, (bar_position, days)) in bar_positions.iter().enumerate() { + let (end_date, calendar_duration) = + calendar.compute_end_date(*start, *days as i64).unwrap(); + let shifted_bar_position = bar_position + offset; let (fg, bg) = if i % 2 == 0 { @@ -191,7 +190,7 @@ fn print_ascii_cdf(data: &[Duration], title: &str, units: &str) { 0..=50 => "\x1b[31m", // Red 55..=75 => "\x1b[33m", // Orange 80..=90 => "\x1b[32m", // Green - 95..=100 => "\x1b[31m", // Red + 95..=100 => "\x1b[33m", // Orange _ => "\x1b[0m", // Default (shouldn't happen) }; let reset_code = "\x1b[0m"; @@ -205,7 +204,7 @@ fn print_ascii_cdf(data: &[Duration], title: &str, units: &str) { .collect(); println!( - "{}{:>4}{}│{}{}{}│{}{:5.0} days{}", + "{}{:>4}{}│{}{}{}│{}{:5.0} days{}│{}{:5.0} days{}│{}{}{}", color_code, format!("p{}", 100 - i * 5), reset_code, @@ -214,8 +213,14 @@ fn print_ascii_cdf(data: &[Duration], title: &str, units: &str) { reset_code, color_code, days, + reset_code, + color_code, + calendar_duration.num_days(), + reset_code, + color_code, + end_date, reset_code ); } - println!("────┴────────────────────────────────────────────────────────────┴──────────"); + println!("────┴────────────────────────────────────────────────────────────┴──────────┴──────────┴──────────"); } diff --git a/src/schedule.rs b/src/schedule.rs index 47ac688..8da22ef 100644 --- a/src/schedule.rs +++ b/src/schedule.rs @@ -1,25 +1,27 @@ use crate::task::Task; +use chrono::NaiveDate; + use std::collections::{HashMap, HashSet}; use std::time::Duration; #[derive(Debug)] -pub struct Schedule { +pub struct Project { pub tasks: Vec, pub num_workers: usize, - pub estimate_confidence: f64, + pub start_date: Option, } -impl Schedule { +impl Project { pub fn new( tasks: Vec, num_workers: usize, - estimate_confidence: f64, + start_date: Option, ) -> Result { - let schedule = Schedule { + let schedule = Project { tasks, num_workers, - estimate_confidence, + start_date, }; schedule.validate()?; Ok(schedule) @@ -94,11 +96,12 @@ impl Schedule { mod tests { use super::*; - fn create_task(id: &str, min: u64, max: u64, deps: Vec<&str>) -> Task { + fn create_task(id: &str, min: u64, likely: u64, max: u64, deps: Vec<&str>) -> Task { Task { id: id.to_string(), dependencies: deps.into_iter().map(String::from).collect(), min_time: Duration::from_secs(min), + likely_time: Duration::from_secs(likely), max_time: Duration::from_secs(max), } } @@ -106,25 +109,25 @@ mod tests { #[test] fn test_valid_schedule() { let tasks = vec![ - create_task("A", 1, 3, vec![]), - create_task("B", 2, 4, vec!["A"]), - create_task("C", 3, 5, vec!["A"]), + create_task("A", 1, 2, 3, vec![]), + create_task("B", 2, 3, 4, vec!["A"]), + create_task("C", 3, 4, 5, vec!["A"]), ]; - let schedule = Schedule::new(tasks, 2, 0.8); + let schedule = Project::new(tasks, 2, None); assert!(schedule.is_ok()); } #[test] fn test_empty_task_list() { - let schedule = Schedule::new(vec![], 2, 0.8); + let schedule = Project::new(vec![], 2, None); assert!(schedule.is_err()); assert_eq!(schedule.unwrap_err(), "Empty task list"); } #[test] fn test_invalid_task_duration() { - let tasks = vec![create_task("A", 3, 1, vec![])]; - let schedule = Schedule::new(tasks, 2, 0.8); + let tasks = vec![create_task("A", 3, 2, 1, vec![])]; + let schedule = Project::new(tasks, 2, None); assert!(schedule.is_err()); assert_eq!( schedule.unwrap_err(), @@ -134,8 +137,8 @@ mod tests { #[test] fn test_zero_duration() { - let tasks = vec![create_task("A", 0, 1, vec![])]; - let schedule = Schedule::new(tasks, 2, 0.8); + let tasks = vec![create_task("A", 0, 1, 2, vec![])]; + let schedule = Project::new(tasks, 2, None); assert!(schedule.is_err()); assert_eq!(schedule.unwrap_err(), "Invalid task duration for task A"); } @@ -143,10 +146,10 @@ mod tests { #[test] fn test_missing_dependency() { let tasks = vec![ - create_task("A", 1, 3, vec![]), - create_task("B", 2, 4, vec!["C"]), + create_task("A", 1, 2, 3, vec![]), + create_task("B", 2, 3, 4, vec!["C"]), ]; - let schedule = Schedule::new(tasks, 2, 0.8); + let schedule = Project::new(tasks, 2, None); assert!(schedule.is_err()); assert_eq!(schedule.unwrap_err(), "Missing dependency C for task B"); } @@ -154,10 +157,10 @@ mod tests { #[test] fn test_cyclic_dependency() { let tasks = vec![ - create_task("A", 1, 3, vec!["B"]), - create_task("B", 2, 4, vec!["A"]), + create_task("A", 1, 2, 3, vec!["B"]), + create_task("B", 2, 3, 4, vec!["A"]), ]; - let schedule = Schedule::new(tasks, 2, 0.8); + let schedule = Project::new(tasks, 2, None); assert!(schedule.is_err()); assert!(schedule .unwrap_err() @@ -167,28 +170,28 @@ mod tests { #[test] fn test_complex_valid_schedule() { let tasks = vec![ - create_task("A", 1, 3, vec![]), - create_task("B", 2, 4, vec!["A"]), - create_task("C", 3, 5, vec!["A"]), - create_task("D", 2, 6, vec!["B", "C"]), - create_task("E", 1, 2, vec!["A"]), - create_task("F", 2, 4, vec!["D", "E"]), + create_task("A", 1, 2, 3, vec![]), + create_task("B", 2, 3, 4, vec!["A"]), + create_task("C", 3, 4, 5, vec!["A"]), + create_task("D", 2, 4, 6, vec!["B", "C"]), + create_task("E", 1, 2, 4, vec!["A"]), + create_task("F", 2, 3, 4, vec!["D", "E"]), ]; - let schedule = Schedule::new(tasks, 3, 0.9); + let schedule = Project::new(tasks, 3, None); assert!(schedule.is_ok()); } #[test] fn test_complex_cyclic_dependency() { let tasks = vec![ - create_task("A", 1, 3, vec!["F"]), - create_task("B", 2, 4, vec!["A"]), - create_task("C", 3, 5, vec!["B"]), - create_task("D", 2, 6, vec!["C"]), - create_task("E", 1, 2, vec!["D"]), - create_task("F", 2, 4, vec!["E"]), + create_task("A", 1, 2, 3, vec!["F"]), + create_task("B", 2, 3, 4, vec!["A"]), + create_task("C", 3, 4, 5, vec!["B"]), + create_task("D", 2, 3, 6, vec!["C"]), + create_task("E", 1, 2, 3, vec!["D"]), + create_task("F", 2, 3, 4, vec!["E"]), ]; - let schedule = Schedule::new(tasks, 3, 0.9); + let schedule = Project::new(tasks, 3, None); assert!( schedule.is_err(), "Expected cyclic dependency error, but got Ok" @@ -204,8 +207,8 @@ mod tests { #[test] fn test_self_dependency() { - let tasks = vec![create_task("A", 1, 3, vec!["A"])]; - let schedule = Schedule::new(tasks, 1, 0.8); + let tasks = vec![create_task("A", 1, 2, 3, vec!["A"])]; + let schedule = Project::new(tasks, 1, None); assert!( schedule.is_err(), "Expected self-dependency error, but got Ok" diff --git a/src/schedule_loader.rs b/src/schedule_loader.rs index 5a7b540..5a95ef6 100644 --- a/src/schedule_loader.rs +++ b/src/schedule_loader.rs @@ -1,5 +1,6 @@ -use crate::{schedule::Schedule, task::Task}; +use crate::{schedule::Project, task::Task}; +use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use std::{fs::File, io::Read, path::Path, time::Duration}; @@ -7,15 +8,23 @@ use std::{fs::File, io::Read, path::Path, time::Duration}; #[derive(Debug, Deserialize, Serialize)] struct ScheduleInput { num_workers: usize, - estimate_confidence: f64, + start_date: Option, tasks: Vec, } +#[derive(Debug, Deserialize, Serialize)] +struct EstimateInput { + min: f64, + max: f64, + likely: f64, +} + #[derive(Debug, Deserialize, Serialize)] struct TaskInput { id: String, - min_time: f64, - max_time: f64, + name: Option, + description: Option, + estimate: EstimateInput, dependencies: Vec, } @@ -24,7 +33,7 @@ enum FileFormat { Json, } -impl Schedule { +impl Project { pub fn from_file>(path: P) -> Result> { let mut file = File::open(&path)?; let mut contents = String::new(); @@ -41,13 +50,14 @@ impl Schedule { .into_iter() .map(|t| Task { id: t.id, - min_time: Duration::from_secs_f64(t.min_time * 24.0 * 60.0 * 60.0), - max_time: Duration::from_secs_f64(t.max_time * 24.0 * 60.0 * 60.0), + min_time: Duration::from_secs_f64(t.estimate.min * 24.0 * 60.0 * 60.0), + likely_time: Duration::from_secs_f64(t.estimate.likely * 24.0 * 60.0 * 60.0), + max_time: Duration::from_secs_f64(t.estimate.max * 24.0 * 60.0 * 60.0), dependencies: t.dependencies, }) .collect(); - let schedule = Schedule::new(tasks, input.num_workers, input.estimate_confidence)?; + let schedule = Project::new(tasks, input.num_workers, input.start_date)?; schedule.validate()?; @@ -89,22 +99,24 @@ mod tests { fn test_load_from_yaml() { let yaml_content = r#" num_workers: 3 -estimate_confidence: 0.9 tasks: - id: A - min_time: 1 - max_time: 3 + estimate: + min: 1 + likely: 2.4 + max: 3 dependencies: [] - id: B - min_time: 2 - max_time: 4 + estimate: + min: 2 + likely: 2.4 + max: 4 dependencies: [A] "#; let (_temp_file, path) = create_temp_file(yaml_content, "yaml"); - let schedule = Schedule::from_file(path).unwrap(); + let schedule = Project::from_file(path).unwrap(); assert_eq!(schedule.tasks.len(), 2); assert_eq!(schedule.num_workers, 3); - assert_eq!(schedule.estimate_confidence, 0.9); } #[test] @@ -112,35 +124,39 @@ tasks: let json_content = r#" { "num_workers": 3, - "estimate_confidence": 0.9, "tasks": [ { "id": "A", - "min_time": 1, - "max_time": 3, + "estimate": { + "min": 1, + "likely": 1.8, + "max": 3 + }, "dependencies": [] }, { "id": "B", - "min_time": 2, - "max_time": 4, + "estimate": { + "min": 2, + "likely": 1.8, + "max": 4 + }, "dependencies": ["A"] } ] } "#; let (_temp_file, path) = create_temp_file(json_content, "json"); - let schedule = Schedule::from_file(path).unwrap(); + let schedule = Project::from_file(path).unwrap(); assert_eq!(schedule.tasks.len(), 2); assert_eq!(schedule.num_workers, 3); - assert_eq!(schedule.estimate_confidence, 0.9); } #[test] fn test_unsupported_format() { let content = "irrelevant content"; let (_temp_file, path) = create_temp_file(content, "txt"); - let result = Schedule::from_file(path); + let result = Project::from_file(path); assert!(result.is_err()); assert!(result .unwrap_err() @@ -152,15 +168,16 @@ tasks: fn test_invalid_schedule() { let yaml_content = r#" num_workers: 3 -estimate_confidence: 0.9 tasks: - id: A - min_time: 3 - max_time: 1 # Invalid: min_time > max_time + estimate: + min: 3 + likely: 2 + max: 1 # Invalid: min_time > max_time dependencies: [] "#; let (_temp_file, path) = create_temp_file(yaml_content, "yaml"); - let result = Schedule::from_file(path); + let result = Project::from_file(path); assert!(result.is_err()); assert!(result .unwrap_err() @@ -172,15 +189,16 @@ tasks: fn test_load_with_floating_point_times_yaml() { let yaml_content = r#" num_workers: 3 -estimate_confidence: 0.9 tasks: - id: A - min_time: 1.5 # 1.5 days - max_time: 3.5 # 3.5 days + estimate: + min: 1.5 # 1.5 days + likely: 2 # 2.0 days + max: 3.5 # 3.5 days dependencies: [] "#; let (_temp_file, path) = create_temp_file(yaml_content, "yaml"); - let schedule = Schedule::from_file(path).unwrap(); + let schedule = Project::from_file(path).unwrap(); // Verify that there is one task and it has the correct times assert_eq!(schedule.tasks.len(), 1); @@ -199,19 +217,21 @@ tasks: let json_content = r#" { "num_workers": 3, - "estimate_confidence": 0.9, "tasks": [ { "id": "A", - "min_time": 1.5, - "max_time": 3.5, + "estimate": { + "min": 1.5, + "likely": 3, + "max": 3.5 + }, "dependencies": [] } ] } "#; let (_temp_file, path) = create_temp_file(json_content, "json"); - let schedule = Schedule::from_file(path).unwrap(); + let schedule = Project::from_file(path).unwrap(); // Verify that there is one task and it has the correct times assert_eq!(schedule.tasks.len(), 1); diff --git a/src/simulation.rs b/src/simulation.rs index 4989565..2440ca9 100644 --- a/src/simulation.rs +++ b/src/simulation.rs @@ -1,7 +1,7 @@ -use crate::schedule::Schedule; +use crate::schedule::Project; +use distimate::Pert; use rand::prelude::*; -use rand_distr::{Distribution, Normal}; use rayon::prelude::*; use std::{ @@ -9,13 +9,14 @@ use std::{ time::Duration, }; +#[derive(Debug)] pub struct SimulationResult { pub total_project_duration: Duration, pub total_effort_time: Duration, } pub fn run_multiple_simulations( - schedule: &Schedule, + schedule: &Project, num_simulations: usize, ) -> (Vec, Vec) { (0..num_simulations) @@ -27,7 +28,7 @@ pub fn run_multiple_simulations( .unzip() } -fn run_simulation(schedule: &Schedule) -> SimulationResult { +fn run_simulation(schedule: &Project) -> SimulationResult { let mut rng = thread_rng(); // Simulate task times @@ -35,12 +36,7 @@ fn run_simulation(schedule: &Schedule) -> SimulationResult { .tasks .iter() .map(|task| { - let task_time = simulate_task_time( - &mut rng, - task.min_time, - task.max_time, - schedule.estimate_confidence, - ); + let task_time = simulate_task_time(&mut rng, task.min_time, task.max_time); (&task.id, task_time) }) .collect(); @@ -117,21 +113,13 @@ fn run_simulation(schedule: &Schedule) -> SimulationResult { } } -fn simulate_task_time( - rng: &mut impl Rng, - min_time: Duration, - max_time: Duration, - estimate_confidence: f64, -) -> Duration { +fn simulate_task_time(rng: &mut impl Rng, min_time: Duration, max_time: Duration) -> Duration { let min_secs = min_time.as_secs_f64(); let max_secs = max_time.as_secs_f64(); let mean = (min_secs + max_secs) / 2.0; - let z_score = (1.0 - (1.0 - estimate_confidence) / 2.0).sqrt() * 2.0; - let std_dev = (max_secs - min_secs) / (2.0 * z_score); - - let normal = Normal::new(mean, std_dev).unwrap(); - let sampled_secs = normal.sample(rng).clamp(min_secs, max_secs); + let pert = Pert::new(min_secs, mean, max_secs).unwrap(); + let sampled_secs = pert.sample(rng); Duration::from_secs_f64(sampled_secs) } @@ -146,10 +134,9 @@ mod tests { let mut rng = thread_rng(); let min_time = Duration::from_secs(5); let max_time = Duration::from_secs(15); - let estimate_confidence = 0.8; for _ in 0..1000 { - let time = simulate_task_time(&mut rng, min_time, max_time, estimate_confidence); + let time = simulate_task_time(&mut rng, min_time, max_time); assert!( time >= min_time && time <= max_time, "Simulated time should be within range" @@ -159,8 +146,14 @@ mod tests { #[test] fn test_single_task_simulation() { - let task = Task::new("A", vec![], Duration::from_secs(5), Duration::from_secs(15)); - let schedule = Schedule::new(vec![task], 1, 0.8).unwrap(); + let task = Task::new( + "A", + vec![], + Duration::from_secs(5), + Duration::from_secs(10), + Duration::from_secs(15), + ); + let schedule = Project::new(vec![task], 1, None).unwrap(); let result = run_simulation(&schedule); @@ -178,11 +171,29 @@ mod tests { #[test] fn test_multiple_independent_tasks() { let tasks = vec![ - Task::new("A", vec![], Duration::from_secs(5), Duration::from_secs(10)), - Task::new("B", vec![], Duration::from_secs(7), Duration::from_secs(12)), - Task::new("C", vec![], Duration::from_secs(3), Duration::from_secs(8)), + Task::new( + "A", + vec![], + Duration::from_secs(5), + Duration::from_secs(6), + Duration::from_secs(10), + ), + Task::new( + "B", + vec![], + Duration::from_secs(7), + Duration::from_secs(10), + Duration::from_secs(12), + ), + Task::new( + "C", + vec![], + Duration::from_secs(3), + Duration::from_secs(6), + Duration::from_secs(8), + ), ]; - let schedule = Schedule::new(tasks, 2, 0.8).unwrap(); + let schedule = Project::new(tasks, 2, None).unwrap(); let result = run_simulation(&schedule); @@ -199,32 +210,42 @@ mod tests { #[test] fn test_tasks_with_dependencies() { let tasks = vec![ - Task::new("A", vec![], Duration::from_secs(5), Duration::from_secs(10)), + Task::new( + "A", + vec![], + Duration::from_secs(5), + Duration::from_secs(8), + Duration::from_secs(10), + ), Task::new( "B", vec!["A".to_string()], Duration::from_secs(7), + Duration::from_secs(9), Duration::from_secs(12), ), Task::new( "C", vec!["A".to_string()], Duration::from_secs(3), + Duration::from_secs(6), Duration::from_secs(8), ), Task::new( "D", vec!["B".to_string(), "C".to_string()], Duration::from_secs(4), + Duration::from_secs(7), Duration::from_secs(9), ), ]; - let schedule = Schedule::new(tasks, 2, 0.8).unwrap(); + let schedule = Project::new(tasks, 2, None).unwrap(); let result = run_simulation(&schedule); + println!("{:?}", result); assert!( - result.total_project_duration >= Duration::from_secs(14), + result.total_project_duration >= Duration::from_secs(13), "Project duration should be at least the minimum critical path duration" ); } @@ -232,15 +253,22 @@ mod tests { #[test] fn test_multiple_simulations_consistency() { let tasks = vec![ - Task::new("A", vec![], Duration::from_secs(5), Duration::from_secs(10)), + Task::new( + "A", + vec![], + Duration::from_secs(5), + Duration::from_secs(8), + Duration::from_secs(10), + ), Task::new( "B", vec!["A".to_string()], Duration::from_secs(7), + Duration::from_secs(10), Duration::from_secs(12), ), ]; - let schedule = Schedule::new(tasks, 1, 0.8).unwrap(); + let schedule = Project::new(tasks, 1, None).unwrap(); let (durations, efforts) = run_multiple_simulations(&schedule, 1000); diff --git a/src/task.rs b/src/task.rs index e97d895..71572e8 100644 --- a/src/task.rs +++ b/src/task.rs @@ -5,6 +5,7 @@ pub struct Task { pub id: String, pub dependencies: Vec, pub min_time: Duration, + pub likely_time: Duration, pub max_time: Duration, } @@ -13,12 +14,14 @@ impl Task { id: &str, dependencies: Vec, min_time: Duration, + likely_time: Duration, max_time: Duration, ) -> Self { Task { id: id.to_string(), dependencies, min_time, + likely_time, max_time, } } @@ -51,6 +54,7 @@ mod tests { "Task1", vec!["Dep1".to_string(), "Dep2".to_string()], Duration::from_secs(1), + Duration::from_secs(3), Duration::from_secs(5), ); @@ -66,6 +70,7 @@ mod tests { "Task2", vec![], Duration::from_secs(2), + Duration::from_secs(7), Duration::from_secs(10), ); @@ -82,6 +87,7 @@ mod tests { vec!["Dep3".to_string()], Duration::from_secs(5), Duration::from_secs(5), + Duration::from_secs(5), ); assert_eq!(task.id, "Task3"); @@ -95,6 +101,7 @@ mod tests { "Task4", vec!["Dep4".to_string()], Duration::from_secs(3), + Duration::from_secs(5), Duration::from_secs(7), ); let task2 = task1.clone(); diff --git a/tests/effort_is_correct.rs b/tests/effort_is_correct.rs index 2149f49..d2a4e8a 100644 --- a/tests/effort_is_correct.rs +++ b/tests/effort_is_correct.rs @@ -1,5 +1,5 @@ use mcps::{ - schedule::Schedule, + schedule::Project, simulation::run_multiple_simulations, task::{days_to_duration, Task}, }; @@ -12,7 +12,6 @@ fn test_average_effort_calculation() { let min_days = 5.0; let max_days = 15.0; let expected_avg_days = 10.0; - let estimate_confidence = 0.8; let num_simulations = 10000; let num_workers = 1; // Using 1 worker to simplify effort calculation @@ -23,13 +22,13 @@ fn test_average_effort_calculation() { &format!("Task{}", i), vec![], days_to_duration(min_days), + days_to_duration(0.5 * (min_days + max_days)), days_to_duration(max_days), ) }) .collect(); - let schedule = - Schedule::new(tasks, num_workers, estimate_confidence).expect("Failed to create schedule"); + let schedule = Project::new(tasks, num_workers, None).expect("Failed to create schedule"); let (_, effort_times) = run_multiple_simulations(&schedule, num_simulations); diff --git a/tests/invalid_schedules.rs b/tests/invalid_schedules.rs index 646286a..5e05886 100644 --- a/tests/invalid_schedules.rs +++ b/tests/invalid_schedules.rs @@ -1,10 +1,10 @@ use mcps::{ - schedule::Schedule, + schedule::Project, task::{days_to_duration, Task}, }; fn create_schedule_and_expect_error(tasks: Vec, expected_error: &str) { - let result = Schedule::new(tasks, 1, 0.8); + let result = Project::new(tasks, 1, None); assert!( result.is_err(), @@ -27,12 +27,14 @@ fn test_cyclic_dependency() { "A", vec!["B".to_string()], days_to_duration(1.0), + days_to_duration(1.5), days_to_duration(2.0), ), Task::new( "B", vec!["A".to_string()], days_to_duration(1.0), + days_to_duration(1.5), days_to_duration(2.0), ), ]; @@ -47,12 +49,14 @@ fn test_missing_dependency() { "A", vec!["C".to_string()], days_to_duration(1.0), + days_to_duration(1.5), days_to_duration(2.0), ), Task::new( "B", vec!["A".to_string()], days_to_duration(1.0), + days_to_duration(1.5), days_to_duration(2.0), ), ]; @@ -66,6 +70,7 @@ fn test_negative_duration() { "A", vec![], days_to_duration(0.0), + days_to_duration(0.5), days_to_duration(1.0), )]; @@ -79,6 +84,7 @@ fn test_min_greater_than_max() { vec![], days_to_duration(3.0), days_to_duration(2.0), + days_to_duration(2.0), )]; create_schedule_and_expect_error(tasks, "Minimum duration greater than maximum"); diff --git a/tests/no_dependencies_schedule.rs b/tests/no_dependencies_schedule.rs index bcbc59f..2135c00 100644 --- a/tests/no_dependencies_schedule.rs +++ b/tests/no_dependencies_schedule.rs @@ -1,5 +1,5 @@ use mcps::{ - schedule::Schedule, + schedule::Project, simulation::run_multiple_simulations, task::{days_to_duration, Task}, }; @@ -13,6 +13,7 @@ fn create_independent_tasks(num_tasks: usize) -> Vec { &format!("Task_{}", i), vec![], days_to_duration(1.0), + days_to_duration(2.0), days_to_duration(3.0), ) }) @@ -23,14 +24,13 @@ fn create_independent_tasks(num_tasks: usize) -> Vec { fn test_no_dependencies_schedule() { let num_tasks = 32; let tasks = create_independent_tasks(num_tasks); - let estimate_confidence = 0.8; let num_simulations = 1_000; let mut prev_avg_effort = 0.0; for num_workers in [1, 2, 4, 8] { - let schedule = Schedule::new(tasks.clone(), num_workers, estimate_confidence) - .expect("Failed to create schedule"); + let schedule = + Project::new(tasks.clone(), num_workers, None).expect("Failed to create schedule"); let (project_durations, effort_times) = run_multiple_simulations(&schedule, num_simulations); diff --git a/tests/simulation_tests.rs b/tests/simulation_tests.rs index bc52dd2..49d8b0b 100644 --- a/tests/simulation_tests.rs +++ b/tests/simulation_tests.rs @@ -1,5 +1,5 @@ use mcps::{ - schedule::Schedule, + schedule::Project, simulation::run_multiple_simulations, task::{days_to_duration, Task}, }; @@ -9,47 +9,57 @@ use std::time::Duration; #[test] fn test_simulation_with_different_worker_counts() { let tasks = vec![ - Task::new("A", vec![], days_to_duration(1.0), days_to_duration(3.0)), + Task::new( + "A", + vec![], + days_to_duration(1.0), + days_to_duration(2.0), + days_to_duration(3.0), + ), Task::new( "B", vec!["A".to_string()], days_to_duration(2.0), + days_to_duration(3.0), days_to_duration(4.0), ), Task::new( "C", vec!["A".to_string()], days_to_duration(3.0), + days_to_duration(4.0), days_to_duration(5.0), ), Task::new( "D", vec!["B".to_string(), "C".to_string()], days_to_duration(2.0), + days_to_duration(4.0), days_to_duration(6.0), ), Task::new( "E", vec!["B".to_string()], days_to_duration(1.0), + days_to_duration(1.5), days_to_duration(2.0), ), Task::new( "F", vec!["D".to_string(), "E".to_string()], days_to_duration(2.0), + days_to_duration(3.0), days_to_duration(4.0), ), ]; - let estimate_confidence = 0.8; let num_simulations = 1000; let mut all_efforts = Vec::new(); for num_workers in [1, 2, 4, 8, 16, 32] { - let schedule = Schedule::new(tasks.clone(), num_workers, estimate_confidence) - .expect("Failed to create schedule"); + let schedule = + Project::new(tasks.clone(), num_workers, None).expect("Failed to create schedule"); let (project_durations, effort_times) = run_multiple_simulations(&schedule, num_simulations);