[
  {
    "path": ".gitignore",
    "content": "*.txt\n*.mps\n*.sol\n*.cmd\n*.code-workspace\n**/__pycache__/\noutput/*\narchive/*\nsolver/*\n*/.ipynb_checkpoints/*\n*.log\n*.sol\n*.mps\n*.opt\n*.csv\n*.png\n_test*.*\n.cache/\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.12.7\n    hooks:\n      - id: ruff-format\n        args: [--line-length=150]\n\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v4.5.0\n    hooks:\n      - id: trailing-whitespace\n        args: [--markdown-linebreak-ext=md]\n      - id: check-json\n      - id: fix-byte-order-marker\n      - id: mixed-line-ending\n        args: [--fix=lf]\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n\n\n* License\n\nThis project is dual-licensed under the Apache License 2.0 and a Commercial License.\n\n* Apache License 2.0\n\nYou may use this project under the terms of the Apache License 2.0.\n\n* Commercial License\n\nFor commercial use, please contact info@fploptimized.com to obtain a commercial license.\n\n* Contributor License Agreement\n\nBy contributing to this project, you agree that your contributions can be licensed under both the Apache License 2.0 and the Commercial License.\n"
  },
  {
    "path": "README.md",
    "content": "# FPL Optimization Tools\n\nThis repository provides a set of tools for solving deterministic **Fantasy Premier League (FPL)** optimization problems.\nThe Python code uses **`pandas`** for data management, **`sasoptpy`** for building the optimization model, and **HiGHS** via **`highspy`** to solve the model.\n\nIt allows users to:\n\n- Automatically select the best FPL squad based on the given projection data and solver settings.\n- Customize squad constraints, formation rules, transfer strategies, and more.\n- Modify data sources and parameters to suit personal models or preferences.\n\n## 🔧 Installation\n\n### 1. Install `uv`\n\n`uv` handles **both Python installation and dependency management**, so you **do not need to install Python separately**.\n\n**Windows (PowerShell)**\n\nOpen PowerShell and run:\n\n```powershell\npowershell -ExecutionPolicy ByPass -c \"irm https://astral.sh/uv/install.ps1 | iex\"\n```\n\n**macOS / Linux**\n\n```bash\nwget -qO- https://astral.sh/uv/install.sh | sh\n```\n\nRestart your terminal after installation, then verify:\n\n```bash\nuv --version\n```\n\n---\n\n### 2. Install Git\n\n**Windows**\n\nDownload from [git-scm.com](https://git-scm.com/download/win) and accept all default installation options.\n\n**macOS**\n\nGit is usually pre-installed. If not, run:\n\n```bash\nbrew install git\n```\n\n### 3. Clone the Repository\n\nOpen a terminal (search for *Command Prompt* in Windows) and run:\n\n```bash\ncd Documents\ngit clone https://github.com/solioanalytics/open-fpl-solver.git\ncd open-fpl-solver\n```\n\n### 4. Install Dependencies (and Python)\n```bash\nuv sync\n```\n\n## 🚀 Running the Optimizer\n\n### 1. Add Projection Data\n\nPlace your projections file (e.g., `solio.csv`) in the `data/` folder.\n\n### 2. Configure Data Source\n\nIf you are not using the default data source, update the `datasource` field in `data/user_settings.json` to match your CSV file name.\n\nExample: if you are using a file named `projections.csv`, the settings file should contain:\n\n```json\n\"datasource\": \"projections\"\n```\n\n### 3. Edit Settings\n\nEdit any desired settings in `comprehensive_settings.json` or `user_settings.json`.\n\n- The majority of useful settings for most people will be in `user_settings.json`.\n- `comprehensive_settings.json` provides a wider range of options that will be used as defaults unless altered in `user_settings.json`.\n\nDetails of what each setting does can be found in the `.md` file in the `/data/` folder.\n\n### 4. Run the Solver\n\n```bash\ncd run\nuv run python solve.py\n```\n\n## 🎥 Videos\n\nThere is a YouTube playlist [here](https://www.youtube.com/playlist?list=PLrIyJJU8_viOags1yudB_wyafRuTNs1Ed) by Sertalp, showing the early stages of this tool, explaining how it was built, and discussing ideas around optimization with a focus on FPL.\n\n## 🌍 Browser-based optimization\n\nThere is also a browser-based version of the optimizer that doesn't require the download or installation of anything to your device, and works on mobile. It is hosted in a google colab notebook that can be found [here](https://colab.research.google.com/drive/1fwYcG28zpIOJf7R8yx31bDL_kJG1JRLu). Simply follow the instructions on that page to run the optimizer.\n\n## 🛠️ Issues\n\nIf you have issues, feel free to open an issue on GitHub and I will get back to you as soon as possible.\nAlternatively, you can email me at **chris.musson@hotmail.com**.\n"
  },
  {
    "path": "data/README.md",
    "content": "## Setting Explanations\n\nThis file documents all configurable settings for the FPL solver. Settings are organized by complexity:\n\n- **[User-Friendly Settings](#user-friendly-settings)** – Essential options for most users (see `user_settings.json`)\n- **[Advanced Settings](#advanced-settings)** – Fine-tuning options (see `comprehensive_settings.json`)\n- **[Complete Reference](#complete-reference)** – Full alphabetical listing of all settings\n\n---\n\n## User-Friendly Settings\n\nThese are the core settings most users will interact with. You'll find them in `user_settings.json`.\n\n### Planning Horizon\n- `horizon`: length of the planning horizon (number of gameweeks to optimize)\n  - Example: `\"horizon\": 4` (plan 4 gameweeks ahead)\n\n### Decay & Valuation\n- `decay_base`: value assigned to decay rate of expected points (discounts future GWs)\n  - Example: `\"decay_base\": 0.9` (10% discount per GW)\n- `ft_value_list`: values of rolling free transfers in different states\n  - Example: `\"ft_value_list\": {\"2\": 2, \"3\": 1.6, \"4\": 1.3, \"5\": 1.1}` assigns value 2.0 for rolling from 1FT to 2FTs, 1.6 for rolling from 2FTs to 3FTs, etc.\n\n### Data Source & Team\n- `datasource`: specifies which projection CSV to use or `\"mixed\"` for multiple sources\n  - Example: `\"datasource\": \"fplreview\"` or `\"solio\"`\n- `team_data`: how to provide team data (`\"id\"` for team_id, `\"json\"` for inline JSON, or default uses `team.json`)\n  - Example: `\"team_data\": \"id\"`\n- `team_id`: your FPL team ID (requires `team_data: \"id\"`)\n  - Example: `\"team_id\": 2211381`\n\n### Player Pool Filtering\n- `xmin_lb`: drop players below this many expected minutes across the horizon\n  - Example: `\"xmin_lb\": 300` (drop players with &lt;300 expected minutes)\n- `ev_per_price_cutoff`: drop players below this percentile of expected value per price\n  - Example: `\"ev_per_price_cutoff\": 30` (drop bottom 30%)\n- `keep_top_ev_percent`: force the filtering to always keep the top n% of players by total expected value, even if they would have been otherwise filtered out by other steps\n  - Example: `\"keep_top_ev_percent\": 5` (avoid filtering out players in the top 5% by total EV)\n\n### Player Constraints\n- `banned`: list of player IDs to exclude from entire horizon\n  - Example: `\"banned\": []`\n- `locked`: list of player IDs to always keep in squad\n  - Example: `\"locked\": [430]` (always include player 430 (Haaland))\n\n### Transfer Constraints\n- `no_transfer_last_gws`: number of gameweeks at end where transfers are banned\n  - Example: `\"no_transfer_last_gws\": 0`\n\n### Chips\n- `use_wc`: list of gameweeks to use Wildcard\n  - Example: `\"use_wc\": []` or `\"use_wc\": [15]`\n- `use_bb`: list of gameweeks to use Bench Boost\n  - Example: `\"use_bb\": [25]`\n- `use_fh`: list of gameweeks to use Free Hit\n  - Example: `\"use_fh\": []`\n- `use_tc`: list of gameweeks to use Triple Captain\n  - Example: `\"use_tc\": []`\n\n### Output\n- `verbose`: whether to print solver progress to screen\n  - Example: `\"verbose\": true`\n\n---\n\n## Advanced Settings\n\nThese settings provide fine-grained control over the optimization. Most users won't need to adjust these. See `comprehensive_settings.json` for default values.\n\n### Scoring & Objective Function\n- `ft_value`: value (in points) assigned to having one extra free transfer\n  - Example: `\"ft_value\": 1.5`\n- `bench_weights`: weights for each bench position's expected points (0=subGK, 1=sub1, 2=sub2, 3=sub3)\n  - Example: `\"bench_weights\": {\"0\": 0.03, \"1\": 0.21, \"2\": 0.06, \"3\": 0.002}`\n- `vcap_weight`: weight for vice-captain points in the objective function\n  - Example: `\"vcap_weight\": 0.1`\n- `itb_value`: value (in points) assigned to having 1.0 extra budget in the bank\n  - Example: `\"itb_value\": 0.08`\n- `itb_loss_per_transfer`: reduction in ITB value per scheduled transfer in future (tries to give some budget flexibility for future gameweeks)\n  - Example: `\"itb_loss_per_transfer\": 0.05`\n- `ft_use_penalty`: penalty applied when a free transfer is used (prevents trivial scheduled transfers)\n  - Example: `\"ft_use_penalty\": 0.2`\n\n### Transfer & Hit Management\n- `no_future_transfer`: if `true`, disable planning transfers beyond current gameweek\n  - Example: `\"no_future_transfer\": false`\n- `no_transfer_by_position`: list of positions to ban transfers in/out. Valid: `[\"G\", \"D\", \"M\", \"F\"]`\n  - Example: `\"no_transfer_by_position\": [\"G\", \"D\"]`\n- `force_ft_state_lb`: list of `[GW, minimum_FTs]` pairs to force minimum FTs in specific gameweeks\n  - Example: `\"force_ft_state_lb\": [[4, 3], [7, 2]]` ensures at least 3 FTs in GW4 and 2 FTs in GW7\n- `force_ft_state_ub`: list of `[GW, maximum_FTs]` pairs to force maximum FTs in specific gameweeks\n  - Example: `\"force_ft_state_ub\": [[4, 4], [7, 3]]` ensures at most 4 FTs in GW4 and 3 FTs in GW7\n- `num_transfers`: fixed number of transfers for this gameweek (optional override)\n  - Example: `\"num_transfers\": null`\n- `hit_limit`: maximum total hits allowed across entire horizon\n  - Example: `\"hit_limit\": null` (no limit)\n- `weekly_hit_limit`: maximum hits allowed in a single gameweek\n  - Example: `\"weekly_hit_limit\": 0`\n- `hit_cost`: points deducted per hit (default 4)\n  - Example: `\"hit_cost\": 4`\n- `future_transfer_limit`: upper bound on total transfers made in future gameweeks\n  - Example: `\"future_transfer_limit\": 5`\n- `no_transfer_gws`: list of gameweek numbers where transfers are not allowed\n  - Example: `\"no_transfer_gws\": []`\n- `transfer_itb_buffer`: minimum ITB (in the bank) to maintain if any transfer is planned (for robustness)\n  - Example: `\"transfer_itb_buffer\": null` or `0.1` to leave £0.1m in bank\n- `booked_transfers`: pre-scheduled transfers for future gameweeks\n  - Format: `[{\"gw\": 5, \"transfer_in\": 427}, {\"gw\": 7, \"transfer_out\": 427}]` (buy player 427 on GW5, sell on GW7)\n- `only_booked_transfers`: if `true`, next GW can only use booked transfers\n  - Example: `\"only_booked_transfers\": false`\n- `no_trs_except_wc`: if `true`, prevent transfers except via Wildcard\n  - Example: `\"no_trs_except_wc\": false`\n\n### Player Management (Advanced)\n- `banned_next_gw`: list of player IDs to ban from next gameweek, or `[ID, GW]` to ban for specific GW\n  - Example: `\"banned_next_gw\": [100, [200, 32]]` bans player ID 100 next GW, and player ID 200 for GW32 only\n- `locked_next_gw`: list of player IDs to force into next gameweek's squad (supports per-GW like `banned_next_gw`)\n  - Example: `\"locked_next_gw\": []`\n- `keep`: list of player IDs that will not be kept throughout the player filtering process, even if they would otherwise be filtered out.\n  - Example: `\"keep\": []`\n- `price_changes`: list of `[player_ID, price_change]` pairs to simulate price changes (in £0.1m increments)\n  - Example: `\"price_changes\": [[311, 1], [351, -1]]` simulates player 311 up £0.1m, player 351 down £0.1m\n- `pick_prices`: force players at specific price points by position\n  - Example: `\"pick_prices\": {\"G\": \"\", \"D\": \"\", \"M\": \"8\", \"F\": \"11.5,11.5\"}`\n\n### Randomization\n- `randomized`: if `true`, add random noise to expected values for varied solutions\n  - Example: `\"randomized\": false`\n- `randomization_seed`: seed for reproducible random noise (null = different seed each time)\n  - Example: `\"randomization_seed\": null`\n- `randomization_strength`: multiplier for the random noise (default 1.0)\n  - Example: `\"randomization_strength\": 1.0`\n\n### Chip Management\n- `chip_limits`: maximum count for each chip type (note: this does not need to be edited if using `use_fh`, `use_wc` etc. )\n  - Example: `\"chip_limits\": {\"bb\": 0, \"wc\": 0, \"fh\": 0, \"tc\": 0}`\n- `no_chip_gws`: list of gameweeks where no chips can be used\n  - Example: `\"no_chip_gws\": []`\n- `allowed_chip_gws`: dictionary of chip types to lists of allowed gameweeks\n  - Example: `\"allowed_chip_gws\": {\"wc\": [25, 27], \"fh\": [30, 31]}`\n- `forced_chip_gws`: dictionary of chip types to lists of gameweeks where chip MUST be used\n  - Example: `\"forced_chip_gws\": {\"wc\": [], \"bb\": [], \"fh\": [], \"tc\": []}`\n- `preseason`: special flag for GW1 solving where team data is not important\n  - Example: `\"preseason\": false`\n\n### Lineup Constraints\n- `no_opposing_play`: controls opposing-play logic\n  - `true` = no two players can play each other in same GW\n  - `false` = no constraint\n  - `\"penalty\"` = penalize each opposing-play instance\n  - Example: `\"no_opposing_play\": false`\n- `opposing_play_group`: scope of opposing-play constraint\n  - `\"all\"` = no opposing players at all\n  - `\"position\"` = only offense vs defense (not M/F vs each other or D/G vs each other)\n  - Example: `\"opposing_play_group\": \"position\"`\n- `opposing_play_penalty`: penalty deducted per opposing-play when using `\"penalty\"` mode\n  - Example: `\"opposing_play_penalty\": 0.5`\n- `max_defenders_per_team`: maximum defenders + goalkeepers from single team (default 3)\n  - Example: `\"max_defenders_per_team\": 3`\n- `double_defense_pick`: forces solver to use either 0 or 2+ defenders/GKs from each team\n  - Example: `\"double_defense_pick\": false`\n- `no_gk_rotation_after`: gameweek after which to lock to single goalkeeper (no rotation)\n  - Example: `\"no_gk_rotation_after\": null`\n\n### Solution Variants\n- `num_iterations`: number of alternative solutions to generate\n  - Example: `\"num_iterations\": 1`\n- `iteration_criteria`: rule for what makes each iteration \"different\"\n  - Options: `\"this_gw_transfer_in\"`, `\"this_gw_transfer_out\"`, `\"this_gw_transfer_in_out\"`, `\"chip_gws\"`, `\"target_gws_transfer_in\"`, `\"this_gw_lineup\"`\n  - Example: `\"iteration_criteria\": \"this_gw_transfer_in_out\"`\n- `iteration_difference`: number of players that must differ (only for `\"this_gw_lineup\"` criteria)\n  - Example: `\"iteration_difference\": 1`\n- `iteration_target`: list of gameweeks to target for iteration changes (used with `\"target_gws_transfer_in\"`)\n  - Example: `\"iteration_target\": []`\n\n### Data Sources\n- `data_weights`: weight percentage for each data source when using `\"datasource\": \"mixed\"`\n  - Example: `\"data_weights\": {\"solio\": 1, \"review\": 1}`\n- `export_data`: option to export mixed data as CSV\n  - Example: `\"export_data\": \"mixed.csv\"`\n- `report_decay_base`: list of decay bases to compute and report for the solve\n  - Example: `\"report_decay_base\": [0.85, 1.0, 1.017]`\n\n### Team Data Options\n- `team_json`: supply team JSON inline (requires `\"team_data\": \"json\"`)\n  - Can be run as: `uv run python solve.py --team_json '{\"picks\": [{...}]}'`\n  - Example: `\"team_json\": null`\n- `override_next_gw`: override the starting gameweek for planning horizon\n  - Example: `\"override_next_gw\": null`\n\n### Solver Behavior\n- `secs`: time limit for solver in seconds\n  - Example: `\"secs\": 600` (10 minutes)\n- `gap`: relative optimality gap (0.0–1.0). Solver stops when within this gap of optimal. Set to 0 for proven optimality\n  - Example: `\"gap\": 0` (solve to optimality)\n- `delete_tmp`: if `true`, delete temporary solver files after solving\n  - Example: `\"delete_tmp\": true`\n- `single_solve`: internal flag for single solve mode\n  - Example: `\"single_solve\": true`\n- `solver`: which solver to use (e.g., `\"highs\"`)\n  - Example: `\"solver\": \"highs\"`\n\n### Output & Exports\n- `export_image`: if `true`, generate and export lineup visualizations\n  - Example: `\"export_image\": false`\n- `solve_name`: name for the solve (used in output filenames)\n  - Example: `\"solve_name\": \"regular\"`\n- `print_result_table`: if `true`, print result table to console\n  - Example: `\"print_result_table\": true`\n- `print_decay_metrics`: if `true`, print decay metric analysis to console\n  - Example: `\"print_decay_metrics\": false`\n- `print_transfer_chip_summary`: if `true`, print transfer/chip summary per gameweek\n  - Example: `\"print_transfer_chip_summary\": true`\n- `print_squads`: if `true`, print full lineup and bench for each gameweek\n  - Example: `\"print_squads\": true`\n- `dataframe_format`: [tabulate](https://pypi.org/project/tabulate/) format for printing tables\n  - Examples: `\"plain\"`, `\"rounded_grid\"`, `\"fancy_outline\"`\n- `hide_transfers`: if `true`, hide transfer details in result table\n  - Example: `\"hide_transfers\": false`\n- `solutions_file`: if provided (filepath ending in `.csv`), save all solutions to this file\n  - Example: `\"solutions_file\": \"\"`\n- `save_squads`: if `true` and `solutions_file` is set, include lineup/bench info per gameweek\n  - Example: `\"save_squads\": true`\n- `solutions_file_player_type`: whether solutions file contains player `\"id\"` or `\"name\"`\n  - Example: `\"solutions_file_player_type\": \"name\"`\n\n### Binary Files (Advanced)\n- `binary_file_weights`: configure binary file names and weights\n  - Example: `\"binary_file_weights\": {\"binary_1.csv\": 0.6, \"binary_2.csv\": 0.3, \"binary_3.csv\": 0.1}`\n- `generate_binary_files`: if `true`, generate binary files based on fixture settings\n  - Example: `\"generate_binary_files\": false`\n\n---\n\n## Complete Reference\n\nFor a complete listing of all settings and their default values, see [`comprehensive_settings.json`](comprehensive_settings.json).\n| Setting | Type | User-Friendly? |\n|----------------|------|----------------|\n| [`allowed_chip_gws`](#chip-management) | dict | ❌ |\n| [`banned`](#player-constraints) | list | ✅ |\n| [`banned_next_gw`](#player-management-advanced) | list | ❌ |\n| [`bench_weights`](#scoring--objective-function) | dict | ❌ |\n| [`binary_file_weights`](#binary-files-advanced) | dict | ❌ |\n| [`booked_transfers`](#transfer--hit-management) | list | ❌ |\n| [`chip_limits`](#chip-management) | dict | ❌ |\n| [`data_weights`](#data-sources) | dict | ❌ |\n| [`datasource`](#data-source--team) | string | ✅ |\n| [`dataframe_format`](#output--exports) | string | ❌ |\n| [`decay_base`](#decay--valuation) | float | ✅ |\n| [`delete_tmp`](#solver-behavior) | bool | ❌ |\n| [`double_defense_pick`](#lineup-constraints) | bool | ❌ |\n| [`ev_per_price_cutoff`](#player-pool-filtering) | int | ✅ |\n| [`export_data`](#data-sources) | string | ❌ |\n| [`export_image`](#output--exports) | bool | ❌ |\n| [`force_ft_state_lb`](#transfer--hit-management) | list | ❌ |\n| [`force_ft_state_ub`](#transfer--hit-management) | list | ❌ |\n| [`forced_chip_gws`](#chip-management) | dict | ❌ |\n| [`ft_use_penalty`](#scoring--objective-function) | float | ❌ |\n| [`ft_value`](#scoring--objective-function) | float | ❌ |\n| [`ft_value_list`](#decay--valuation) | dict | ✅ |\n| [`future_transfer_limit`](#transfer--hit-management) | int | ❌ |\n| [`gap`](#solver-behavior) | float | ❌ |\n| [`generate_binary_files`](#binary-files-advanced) | bool | ❌ |\n| [`hide_transfers`](#output--exports) | bool | ❌ |\n| [`hit_cost`](#transfer--hit-management) | int | ❌ |\n| [`hit_limit`](#transfer--hit-management) | int | ❌ |\n| [`horizon`](#planning-horizon) | int | ✅ |\n| [`itb_loss_per_transfer`](#scoring--objective-function) | float | ❌ |\n| [`itb_value`](#scoring--objective-function) | float | ❌ |\n| [`iteration_criteria`](#solution-variants) | string | ❌ |\n| [`iteration_difference`](#solution-variants) | int | ❌ |\n| [`iteration_target`](#solution-variants) | list | ❌ |\n| [`keep`](#player-management-advanced) | list | ❌ |\n| [`keep_top_ev_percent`](#player-pool-filtering) | int | ✅ |\n| [`locked`](#player-constraints) | list | ✅ |\n| [`locked_next_gw`](#player-management-advanced) | list | ❌ |\n| [`max_defenders_per_team`](#lineup-constraints) | int | ❌ |\n| [`no_chip_gws`](#chip-management) | list | ❌ |\n| [`no_future_transfer`](#transfer--hit-management) | bool | ❌ |\n| [`no_gk_rotation_after`](#lineup-constraints) | int | ❌ |\n| [`no_opposing_play`](#lineup-constraints) | bool/str | ❌ |\n| [`no_transfer_by_position`](#transfer--hit-management) | list | ❌ |\n| [`no_transfer_gws`](#transfer--hit-management) | list | ❌ |\n| [`no_transfer_last_gws`](#transfer-constraints) | int | ✅ |\n| [`no_trs_except_wc`](#transfer--hit-management) | bool | ❌ |\n| [`num_iterations`](#solution-variants) | int | ❌ |\n| [`num_transfers`](#transfer--hit-management) | int | ❌ |\n| [`only_booked_transfers`](#transfer--hit-management) | bool | ❌ |\n| [`opposing_play_group`](#lineup-constraints) | string | ❌ |\n| [`opposing_play_penalty`](#lineup-constraints) | float | ❌ |\n| [`override_next_gw`](#team-data-options) | int | ❌ |\n| [`pick_prices`](#player-management-advanced) | dict | ❌ |\n| [`preseason`](#chip-management) | bool | ❌ |\n| [`price_changes`](#player-management-advanced) | list | ❌ |\n| [`print_decay_metrics`](#output--exports) | bool | ❌ |\n| [`print_result_table`](#output--exports) | bool | ❌ |\n| [`print_squads`](#output--exports) | bool | ❌ |\n| [`print_transfer_chip_summary`](#output--exports) | bool | ❌ |\n| [`randomization_seed`](#randomization) | int/null | ❌ |\n| [`randomization_strength`](#randomization) | float | ❌ |\n| [`randomized`](#randomization) | bool | ❌ |\n| [`report_decay_base`](#data-sources) | list | ❌ |\n| [`secs`](#solver-behavior) | int | ❌ |\n| [`single_solve`](#solver-behavior) | bool | ❌ |\n| [`solver`](#solver-behavior) | string | ❌ |\n| [`solutions_file`](#output--exports) | string | ❌ |\n| [`solutions_file_player_type`](#output--exports) | string | ❌ |\n| [`solve_name`](#output--exports) | string | ❌ |\n| [`team_data`](#data-source--team) | string | ✅ |\n| [`team_id`](#data-source--team) | int | ✅ |\n| [`team_json`](#team-data-options) | object | ❌ |\n| [`transfer_itb_buffer`](#transfer--hit-management) | float | ❌ |\n| [`use_bb`](#chips) | list | ✅ |\n| [`use_fh`](#chips) | list | ✅ |\n| [`use_tc`](#chips) | list | ✅ |\n| [`use_wc`](#chips) | list | ✅ |\n| [`vcap_weight`](#scoring--objective-function) | float | ❌ |\n| [`verbose`](#output) | bool | ✅ |\n| [`xmin_lb`](#player-pool-filtering) | int | ✅ |\n"
  },
  {
    "path": "data/binary_fixtures.md",
    "content": "## Binary Files\n\nThis file contains instructions for generating binary files for when there are fixture uncertainties, for use with `simulations.py`.\n\n### Instructions\n\n1. Set the fixtures in your data source so there are no blank or double gameweeks, i.e. each team should have the fixtures as originally scheduled in their respective GW. Download that CSV and save it as `data/original.csv`.\n\n2. Configure the settings in your `data/user_settings.json` file.\n\n    **(a) binary_fixture_settings** - Create the settings that describe which fixtures are being moved in each binary file. For each team, the dictionary contains `{a:b}` pairs, where `a` represents the gameweek the expected points will be taken from, and `b` represents the gameweek where the expected points will be put into. In the example configuration provided below and looking at `binary1.csv`, expected points will be moved from GW33 to GW34 for Bournemounth, Man Utd, Man City, and Aston Villa, and from GW36 to GW34 for Newcastle and Ipswich. Note that the team names here should match with the team names in your data source.\n\n    **(b) binary_file_weights** - Set the weighting for each possible binary file. In the example below, 60% of simulations will use `binary1.csv`, 30% will use `binary2.csv`, and 10% will use `binary3.csv`.\n\n    **(c) generate_binary_files** - Set this to `true` if you want to generate the binary files. This is only a setting because if you run multiple sets of simulations, there is sometimes no need to regenerate the files, so this setting can just be set to `false`.\n\n3. Run `simulations.py` and when it asks whether you want to use binaries, respond with `y`.\n\n### Example JSON Settings\n\n```json\n{\n  \"generate_binary_files\": true,\n  \"binary_file_weights\": {\n    \"binary1.csv\": 0.6,\n    \"binary2.csv\": 0.3,\n    \"binary3.csv\": 0.1\n  },\n  \"binary_fixture_settings\": {\n    \"binary1.csv\": {\n      \"Bournemouth\": { \"33\": \"34\" },\n      \"Man Utd\": { \"33\": \"34\" },\n      \"Man City\": { \"33\": \"34\" },\n      \"Aston Villa\": { \"33\": \"34\" },\n      \"Newcastle\": { \"36\": \"34\" },\n      \"Ipswich\": { \"36\": \"34\" }\n    },\n    \"binary2.csv\": {\n      \"Bournemouth\": { \"33\": \"34\", \"36\": \"37\" },\n      \"Man Utd\": { \"33\": \"34\" },\n      \"Man City\": { \"33\": \"34\", \"36\": \"37\" },\n      \"Aston Villa\": { \"33\": \"34\" },\n      \"Newcastle\": { \"36\": \"34\" },\n      \"Ipswich\": { \"36\": \"34\" }\n    },\n    \"binary3.csv\": {\n      \"Man City\": { \"33\": \"34\" },\n      \"Aston Villa\": { \"33\": \"34\" },\n      \"Newcastle\": { \"36\": \"34\" },\n      \"Ipswich\": { \"36\": \"34\" },\n      \"Arsenal\": { \"36\": \"34\" },\n      \"Crystal Palace\": { \"36\": \"34\" }\n    }\n  }\n}\n```\n"
  },
  {
    "path": "data/comprehensive_settings.json",
    "content": "{\n    \"horizon\": 8,\n    \"decay_base\": 0.9,\n    \"ft_value\": 1.5,\n    \"ft_value_list\": {\n        \"2\": 2,\n        \"3\": 1.6,\n        \"4\": 1.3,\n        \"5\": 1.1\n    },\n    \"bench_weights\": {\n        \"0\": 0.03,\n        \"1\": 0.21,\n        \"2\": 0.06,\n        \"3\": 0.002\n    },\n    \"vcap_weight\": 0.1,\n    \"ft_use_penalty\": 0.2,\n    \"itb_value\": 0.08,\n    \"itb_loss_per_transfer\": 0,\n    \"no_future_transfer\": false,\n    \"no_transfer_last_gws\": 2,\n    \"no_transfer_by_position\": [],\n    \"force_ft_state_lb\": [],\n    \"force_ft_state_ub\": [],\n    \"randomized\": false,\n    \"randomization_seed\": null,\n    \"randomization_strength\": 1.0,\n    \"xmin_lb\": 300,\n    \"ev_per_price_cutoff\": 30,\n    \"keep_top_ev_percent\": 5,\n    \"banned\": [],\n    \"banned_next_gw\": [],\n    \"locked\": [],\n    \"locked_next_gw\": [],\n    \"price_changes\": [],\n    \"keep\": [],\n    \"delete_tmp\": true,\n    \"single_solve\": true,\n    \"solver\": \"highs\",\n    \"secs\": 600,\n    \"gap\": 0,\n    \"num_transfers\": null,\n    \"hit_limit\": null,\n    \"weekly_hit_limit\": 0,\n    \"hit_cost\": 4,\n    \"use_wc\": [],\n    \"use_bb\": [],\n    \"use_fh\": [],\n    \"use_tc\": [],\n    \"chip_limits\": {\n        \"bb\": 0,\n        \"wc\": 0,\n        \"fh\": 0,\n        \"tc\": 0\n    },\n    \"no_chip_gws\": [],\n    \"allowed_chip_gws\": {\n        \"bb\": [],\n        \"wc\": [],\n        \"fh\": [],\n        \"tc\": []\n    },\n    \"forced_chip_gws\": {\n        \"bb\": [],\n        \"wc\": [],\n        \"fh\": [],\n        \"tc\": []\n    },\n    \"future_transfer_limit\": null,\n    \"no_transfer_gws\": [],\n    \"booked_transfers\": [],\n    \"only_booked_transfers\": false,\n    \"no_trs_except_wc\": false,\n    \"preseason\": false,\n    \"no_opposing_play\": false,\n    \"opposing_play_group\": \"position\",\n    \"opposing_play_penalty\": 0.5,\n    \"pick_prices\": {\n        \"G\": \"\",\n        \"D\": \"\",\n        \"M\": \"\",\n        \"F\": \"\"\n    },\n    \"no_gk_rotation_after\": null,\n    \"max_defenders_per_team\": 3,\n    \"double_defense_pick\": false,\n    \"transfer_itb_buffer\": null,\n    \"num_iterations\": 1,\n    \"iteration_criteria\": \"this_gw_transfer_in_out\",\n    \"iteration_difference\": 1,\n    \"iteration_target\": [],\n    \"report_decay_base\": [\n        0.85,\n        1.0,\n        1.017\n    ],\n    \"datasource\": \"solio\",\n    \"data_weights\": {\n        \"solio\": 1,\n        \"review\": 1\n    },\n    \"export_data\": \"mixed.csv\",\n    \"team_data\": \"json\",\n    \"team_id\": null,\n    \"team_json\": null,\n    \"export_image\": false,\n    \"solve_name\": \"regular\",\n    \"override_next_gw\": null,\n    \"generate_binary_files\": false,\n    \"binary_file_weights\": {},\n    \"binary_fixture_settings\": {},\n    \"verbose\": true,\n    \"print_result_table\": true,\n    \"print_decay_metrics\": false,\n    \"print_transfer_chip_summary\": true,\n    \"print_squads\": true,\n    \"dataframe_format\": \"plain\",\n    \"hide_transfers\": false,\n    \"solutions_file\": \"\",\n    \"save_squads\": true,\n    \"solutions_file_player_type\": \"name\"\n}"
  },
  {
    "path": "data/images/.gitkeep",
    "content": ""
  },
  {
    "path": "data/results/.gitkeep",
    "content": ""
  },
  {
    "path": "data/team.json.sample",
    "content": "{\n    \"picks\": [\n        {\n            \"element\": 307,\n            \"position\": 1,\n            \"selling_price\": 55,\n            \"multiplier\": 1,\n            \"purchase_price\": 55,\n            \"is_captain\": false,\n            \"is_vice_captain\": false\n        },\n        {\n            \"element\": 284,\n            \"position\": 2,\n            \"selling_price\": 70,\n            \"multiplier\": 1,\n            \"purchase_price\": 70,\n            \"is_captain\": false,\n            \"is_vice_captain\": false\n        },\n        {\n            \"element\": 8,\n            \"position\": 3,\n            \"selling_price\": 50,\n            \"multiplier\": 1,\n            \"purchase_price\": 50,\n            \"is_captain\": false,\n            \"is_vice_captain\": false\n        },\n        {\n            \"element\": 429,\n            \"position\": 4,\n            \"selling_price\": 50,\n            \"multiplier\": 1,\n            \"purchase_price\": 50,\n            \"is_captain\": false,\n            \"is_vice_captain\": false\n        },\n        {\n            \"element\": 309,\n            \"position\": 5,\n            \"selling_price\": 60,\n            \"multiplier\": 1,\n            \"purchase_price\": 60,\n            \"is_captain\": false,\n            \"is_vice_captain\": true\n        },\n        {\n            \"element\": 33,\n            \"position\": 6,\n            \"selling_price\": 50,\n            \"multiplier\": 1,\n            \"purchase_price\": 50,\n            \"is_captain\": false,\n            \"is_vice_captain\": false\n        },\n        {\n            \"element\": 301,\n            \"position\": 7,\n            \"selling_price\": 120,\n            \"multiplier\": 1,\n            \"purchase_price\": 120,\n            \"is_captain\": false,\n            \"is_vice_captain\": false\n        },\n        {\n            \"element\": 142,\n            \"position\": 8,\n            \"selling_price\": 80,\n            \"multiplier\": 2,\n            \"purchase_price\": 80,\n            \"is_captain\": true,\n            \"is_vice_captain\": false\n        },\n        {\n            \"element\": 383,\n            \"position\": 9,\n            \"selling_price\": 45,\n            \"multiplier\": 1,\n            \"purchase_price\": 45,\n            \"is_captain\": false,\n            \"is_vice_captain\": false\n        },\n        {\n            \"element\": 66,\n            \"position\": 10,\n            \"selling_price\": 60,\n            \"multiplier\": 1,\n            \"purchase_price\": 60,\n            \"is_captain\": false,\n            \"is_vice_captain\": false\n        },\n        {\n            \"element\": 427,\n            \"position\": 11,\n            \"selling_price\": 115,\n            \"multiplier\": 1,\n            \"purchase_price\": 115,\n            \"is_captain\": false,\n            \"is_vice_captain\": false\n        },\n        {\n            \"element\": 289,\n            \"position\": 12,\n            \"selling_price\": 40,\n            \"multiplier\": 0,\n            \"purchase_price\": 40,\n            \"is_captain\": false,\n            \"is_vice_captain\": false\n        },\n        {\n            \"element\": 13,\n            \"position\": 13,\n            \"selling_price\": 80,\n            \"multiplier\": 0,\n            \"purchase_price\": 80,\n            \"is_captain\": false,\n            \"is_vice_captain\": false\n        },\n        {\n            \"element\": 116,\n            \"position\": 14,\n            \"selling_price\": 55,\n            \"multiplier\": 0,\n            \"purchase_price\": 55,\n            \"is_captain\": false,\n            \"is_vice_captain\": false\n        },\n        {\n            \"element\": 476,\n            \"position\": 15,\n            \"selling_price\": 70,\n            \"multiplier\": 0,\n            \"purchase_price\": 70,\n            \"is_captain\": false,\n            \"is_vice_captain\": false\n        }\n    ],\n    \"chips\": [\n        {\n            \"status_for_entry\": \"available\",\n            \"played_by_entry\": [],\n            \"name\": \"bboost\",\n            \"number\": 1,\n            \"start_event\": 1,\n            \"stop_event\": 38,\n            \"chip_type\": \"team\"\n        },\n        {\n            \"status_for_entry\": \"available\",\n            \"played_by_entry\": [],\n            \"name\": \"3xc\",\n            \"number\": 1,\n            \"start_event\": 1,\n            \"stop_event\": 38,\n            \"chip_type\": \"team\"\n        }\n    ],\n    \"transfers\": {\n        \"cost\": 4,\n        \"status\": \"unlimited\",\n        \"limit\": null,\n        \"made\": 0,\n        \"bank\": 0,\n        \"value\": 1000\n    }\n}\n"
  },
  {
    "path": "data/user_settings.json",
    "content": "{\n    \"datasource\": \"solio\",\n    \"team_data\": \"id\",\n    \"team_id\": null,\n    \"horizon\": 8,\n    \"no_transfer_last_gws\": 2,\n    \"decay_base\": 0.9,\n    \"ft_value_list\": {\n        \"2\": 2,\n        \"3\": 1.6,\n        \"4\": 1.3,\n        \"5\": 1.1\n    },\n    \"xmin_lb\": 300,\n    \"ev_per_price_cutoff\": 30,\n    \"keep_top_ev_percent\": 5,\n    \"banned\": [],\n    \"locked\": [],\n    \"use_wc\": [],\n    \"use_bb\": [],\n    \"use_fh\": [],\n    \"use_tc\": [],\n    \"verbose\": true\n}\n"
  },
  {
    "path": "dev/data_parser.py",
    "content": "import csv\nimport os\nimport sys\nfrom unicodedata import combining, normalize\n\nimport numpy as np\nimport pandas as pd\nimport requests\nfrom fuzzywuzzy import fuzz\n\nfrom paths import DATA_DIR\nfrom utils import cached_request\n\n\ndef read_data(options, source=None):\n    source = options.get(\"datasource\")\n    weights = options.get(\"data_weights\")\n    list_of_files = [x for x in os.listdir(DATA_DIR) if x.endswith(\".csv\")]\n\n    if not source:\n        try:\n            latest_file = max(list_of_files, key=os.path.getctime)\n            print(f\"No source specified, using most recent projection file: {latest_file}\")\n            return pd.read_csv(latest_file)\n        except Exception:\n            print(\"Cannot find projection data in /data/. Upload it to /data/ and make sure it is a .csv file\")\n            sys.exit(0)\n\n    if source == \"mixed\":\n        return read_mixed(options, weights)\n\n    if f\"{source}.csv\" not in list_of_files:\n        raise FileNotFoundError(f\"Data file {source}.csv not found in /data/. Please upload it there and try again.\")\n\n    for reader in [read_mikkel, read_solio, read_fplreview]:\n        try:\n            return reader(options)\n        except Exception:\n            # print(f\"{reader.__name__} failed: {e}\")\n            continue\n\n    raise RuntimeError(\"All data readers failed.\")\n\n\ndef read_solio(options):\n    # TODO: implement more complex solio data parsing when additional data is added to csv\n    filepath = options.get(\"data_path\", DATA_DIR / f\"{options['datasource']}.csv\")\n    return pd.read_csv(filepath, encoding=\"utf-8\")\n\n\ndef read_fplreview(options):\n    filepath = options.get(\"data_path\", DATA_DIR / f\"{options['datasource']}.csv\")\n    return pd.read_csv(filepath, encoding=\"utf-8\")\n\n\ndef read_mikkel(options):\n    output_file = \"mikkel_cleaned.csv\"\n    input_file = options.get(\"data_path\", DATA_DIR / f\"{options['datasource']}.csv\")\n    convert_mikkel_to_review(input_file, output_file=output_file)\n    return pd.read_csv(DATA_DIR / f\"{output_file}\", encoding=\"utf-8\")\n\n\ndef read_mixed(options, weights):\n    # Get each source separately and mix with given weights\n    all_data = []\n    for name, weight in weights.items():\n        if weight == 0:\n            continue\n        options[\"datasource\"] = name\n        df = read_data(options)\n        # drop players without data\n        first_gw_col = None\n        for col in df.columns:\n            if \"_Pts\" in col:\n                first_gw_col = col\n                break\n        # drop missing ones\n        df = df[~df[first_gw_col].isnull()].copy()\n        for col in df.columns:\n            if \"_Pts\" in col:\n                df[col.split(\"_\")[0] + \"_weight\"] = weight\n        all_data.append(df)\n\n    for i, d in enumerate(all_data):\n        # d[\"ID\"] = d[\"ID\"].astype(np.int64)\n\n        for col in d.columns:\n            if \"_xMins\" in col:\n                d[col] = pd.to_numeric(d[col], errors=\"coerce\").fillna(0).astype(int)\n\n        all_data[i] = d\n\n    # Update EV by weight\n    new_data = []\n    # for d, w in zip(data, data_weights):\n    for d in all_data:\n        pts_columns = [i for i in d if \"_Pts\" in i]\n        min_columns = [i for i in d if \"_xMins\" in i]\n        weights_cols = [i.split(\"_\")[0] + \"_weight\" for i in pts_columns]\n        # d[pts_columns] = d[pts_columns].multiply(d[weights_cols], axis='index')\n        d[pts_columns] = pd.DataFrame(d[pts_columns].values * d[weights_cols].values, columns=d[pts_columns].columns, index=d[pts_columns].index)\n        weights_cols = [i.split(\"_\")[0] + \"_weight\" for i in min_columns]\n        d[min_columns] = pd.DataFrame(d[min_columns].values * d[weights_cols].values, columns=d[min_columns].columns, index=d[min_columns].index)\n        new_data.append(d.copy())\n\n    combined_data = pd.concat(new_data, ignore_index=True)\n    combined_data = combined_data.copy()\n    combined_data[\"real_id\"] = combined_data[\"ID\"]\n    combined_data = combined_data.reset_index(drop=True)\n\n    key_dict = {}\n    for i in combined_data.columns.to_list():\n        if \"_weight\" in i:  # weight column\n            key_dict[i] = \"sum\"\n        elif \"_xMins\" in i:\n            key_dict[i] = \"sum\"\n        elif \"_Pts\" in i:\n            key_dict[i] = \"sum\"\n        else:\n            key_dict[i] = \"first\"\n\n    # key_dict = {i: 'first' if (\"_x\" not in i and \"_P\" not in i) else 'median' for i in main_keys}\n    grouped_data = combined_data.groupby(\"real_id\").agg(key_dict)\n    final_data = grouped_data[grouped_data[\"ID\"] != 0].copy()\n    # adjust by weight sum for each player\n    for c in final_data.columns:\n        if \"_Pts\" in c or \"_xMins\" in c:\n            gw = c.split(\"_\")[0]\n            final_data[c] = final_data[c] / final_data[gw + \"_weight\"]\n\n    # Find missing players and add them\n    fpl_data = cached_request(\"https://fantasy.premierleague.com/api/bootstrap-static/\")\n    players = fpl_data[\"elements\"]\n    existing_ids = final_data[\"ID\"].tolist()\n    element_type_dict = {1: \"G\", 2: \"D\", 3: \"M\", 4: \"F\"}\n    teams = fpl_data[\"teams\"]\n    team_code_dict = {i[\"code\"]: i for i in teams}\n    missing_players = []\n    for p in players:\n        if p[\"id\"] in existing_ids:\n            continue\n        missing_players.append(\n            {\n                \"fpl_id\": p[\"id\"],\n                \"ID\": p[\"id\"],\n                \"real_id\": p[\"id\"],\n                \"team\": \"\",\n                \"Name\": p[\"web_name\"],\n                \"Pos\": element_type_dict[p[\"element_type\"]],\n                \"Value\": p[\"now_cost\"] / 10,\n                \"Team\": team_code_dict[p[\"team_code\"]][\"name\"],\n                \"Missing\": 1,\n            }\n        )\n\n    final_data = pd.concat([final_data, pd.DataFrame(missing_players)]).fillna(0)\n    final_data.to_csv(DATA_DIR / \"mixed.csv\", index=False, encoding=\"utf-8\", float_format=\"%.2f\")\n\n    return final_data\n\n\n# To remove accents in names\ndef fix_name_dialect(name):\n    new_name = \"\".join([c for c in normalize(\"NFKD\", name) if not combining(c)])\n    return new_name.replace(\"Ø\", \"O\").replace(\"ø\", \"o\").replace(\"ã\", \"a\")\n\n\ndef get_best_score(r):\n    return max(r[\"wn_score\"], r[\"cn_score\"])\n\n\n# To add FPL ID column to Mikkel's data and clean empty rows\ndef fix_mikkel(file_address):\n    for enc in [\"utf-8\", \"latin-1\"]:\n        try:\n            with open(file_address, encoding=enc, errors=\"replace\") as f:\n                # Use csv.Sniffer to detect delimiter, either , or ;\n                dialect = csv.Sniffer().sniff(f.readline(), delimiters=\",;\")\n                df = pd.read_csv(file_address, encoding=enc, sep=dialect.delimiter)\n            break\n        except Exception:\n            continue\n\n    fpl_data = cached_request(\"https://fantasy.premierleague.com/api/bootstrap-static/\")\n    players = fpl_data[\"elements\"]\n    mikkel_team_dict = {\n        \"BHA\": \"BRI\",\n        \"CRY\": \"CPL\",\n        \"NFO\": \"NOT\",\n        \"WHU\": \"WHM\",\n    }\n    teams = fpl_data[\"teams\"]\n    for t in teams:\n        t[\"mikkel_short\"] = mikkel_team_dict.get(t[\"short_name\"], t[\"short_name\"])\n\n    df = df.rename(columns={x: x.strip() for x in df.columns})\n    df[\"BCV_clean\"] = df[\"BCV\"].astype(str).str.replace(r\"\\((.*)\\)\", \"-\\\\1\", regex=True).astype(str).str.strip()\n    df[\"BCV_numeric\"] = pd.to_numeric(df[\"BCV_clean\"], errors=\"coerce\")\n    df = df.loc[df[\"BCV_numeric\"] != -1]\n    df_cleaned = df.loc[~((df[\"Player\"] == \"0\") | (df[\"No.\"].isnull()) | (df[\"BCV_numeric\"].isnull()))].copy()\n    df_cleaned[\"Clean_Name\"] = df_cleaned[\"Player\"].apply(fix_name_dialect)\n    # df_cleaned[\"Team\"] = df_cleaned[\"Team\"].map(mikkel_team_dict, na_action=\"ignore\")\n    df_cleaned[\"Position\"] = df_cleaned[\"Position\"].replace({\"GK\": \"G\"})\n    df_cleaned = df_cleaned.dropna(subset=[\"Team\"])\n\n    element_type_dict = {1: \"G\", 2: \"D\", 3: \"M\", 4: \"F\"}\n    team_code_dict = {i[\"code\"]: i for i in teams}\n    player_names = [\n        {\n            \"id\": e[\"id\"],\n            \"web_name\": e[\"web_name\"],\n            \"combined\": e[\"first_name\"] + \" \" + e[\"second_name\"],\n            \"team\": team_code_dict[e[\"team_code\"]][\"mikkel_short\"],\n            \"position\": element_type_dict[e[\"element_type\"]],\n        }\n        for e in players\n    ]\n    for target in player_names:\n        target[\"wn\"] = fix_name_dialect(target[\"web_name\"])\n        target[\"cn\"] = fix_name_dialect(target[\"combined\"])\n\n    entries = []\n    for player in df_cleaned.iloc:\n        possible_matches = [i for i in player_names if i[\"team\"] == player[\"Team\"] and i[\"position\"] == player[\"Position\"]]\n        for target in possible_matches:\n            p = player[\"Clean_Name\"]\n            target[\"wn_score\"] = fuzz.token_set_ratio(p, target[\"wn\"])\n            target[\"cn_score\"] = fuzz.token_set_ratio(p, target[\"cn\"])\n\n        best_match = max(possible_matches, key=get_best_score)\n        entries.append({\"player_input\": player[\"Player\"], \"team_input\": player[\"Team\"], \"position_input\": player[\"Position\"], **best_match})\n\n    entries_df = pd.DataFrame(entries)\n    entries_df[\"score\"] = entries_df[[\"wn_score\", \"cn_score\"]].max(axis=1)\n    entries_df[\"name_team\"] = entries_df[\"player_input\"] + \" @ \" + entries_df[\"team_input\"]\n    entry_dict = entries_df.set_index(\"name_team\")[\"id\"].to_dict()\n    fpl_name_dict = entries_df.set_index(\"id\")[\"web_name\"].to_dict()\n    score_dict = entries_df.set_index(\"name_team\")[\"score\"].to_dict()\n    df_cleaned[\"name_team\"] = df_cleaned[\"Player\"] + \" @ \" + df_cleaned[\"Team\"]\n    df_cleaned[\"FPL ID\"] = df_cleaned[\"name_team\"].map(entry_dict)\n    df_cleaned[\"fpl_name\"] = df_cleaned[\"FPL ID\"].map(fpl_name_dict)\n    df_cleaned[\"score\"] = df_cleaned[\"name_team\"].map(score_dict)\n\n    # Check for duplicate IDs\n    duplicate_rows = df_cleaned[\"FPL ID\"].duplicated(keep=False)\n    if len(df_cleaned[duplicate_rows]) > 0:\n        print(\"WARNING: There are players with duplicate IDs, lowest name match accuracy (score) will be dropped\")\n        print(df_cleaned[duplicate_rows][[\"Player\", \"fpl_name\", \"score\"]].head())\n    df_cleaned = df_cleaned.sort_values(by=[\"score\"], ascending=False)\n    df_cleaned = df_cleaned.loc[~df_cleaned[\"FPL ID\"].duplicated(keep=\"first\")].sort_index()\n\n    existing_ids = df_cleaned[\"FPL ID\"].tolist()\n    missing_players = []\n    for p in players:\n        if p[\"id\"] in existing_ids:\n            continue\n        missing_players.append(\n            {\n                \"Position\": element_type_dict[p[\"element_type\"]],\n                \"Player\": p[\"web_name\"],\n                \"Price\": p[\"now_cost\"] / 10,\n                \"FPL ID\": p[\"id\"],\n                \"Weighted minutes\": 0,\n                \"Missing\": 1,\n            }\n        )\n\n    return pd.concat([df_cleaned, pd.DataFrame(missing_players)]).fillna(0)\n\n\n# To convert cleaned Mikkel data into Review format\ndef convert_mikkel_to_review(target, output_file):\n    # Read and add ID column\n    df = fix_mikkel(target)\n\n    static_url = \"https://fantasy.premierleague.com/api/bootstrap-static/\"\n    fpl_data = cached_request(static_url)\n    teams = fpl_data[\"teams\"]\n\n    new_names = {i: i.strip() for i in df.columns}\n    df = df.rename(columns=new_names)\n    df[\"Price\"] = pd.to_numeric(df[\"Price\"], errors=\"coerce\")\n    df[\"Weighted minutes\"] = df[\"Weighted minutes\"].fillna(90)\n    df[\"ID\"] = df[\"FPL ID\"].astype(int)\n\n    pos_fix = {\"GK\": \"G\"}\n    df[\"Pos\"] = df[\"Position\"]\n    df[\"Pos\"] = df[\"Pos\"].map(pos_fix).fillna(df[\"Pos\"])\n    df.loc[df[\"Pos\"].isin([\"G\", \"D\"]), \"Weighted minutes\"] = \"90\"\n\n    gws = []\n    for i in df.columns:\n        try:\n            int(i)\n            df[f\"{i}_Pts\"] = df[i].str.strip().replace({\"-\": 0}).astype(float)\n            df[f\"{i}_xMins\"] = df[\"Weighted minutes\"].str.strip().replace({\"-\": 0}).astype(float).replace({np.nan: 0})\n            gws.append(i)\n        except Exception:\n            continue\n    df[\"Name\"] = df[\"Player\"]\n    df[\"Value\"] = df[\"Price\"]\n\n    df_final = df[[\"ID\", \"Name\", \"Pos\", \"Value\"] + [f\"{gw}_{tag}\" for gw in gws for tag in [\"Pts\", \"xMins\"]]].copy()\n    elements_data = fpl_data[\"elements\"]\n    player_ids = [i[\"id\"] for i in elements_data]\n    player_names = {i[\"id\"]: i[\"web_name\"] for i in elements_data}\n    player_pos = {i[\"id\"]: i[\"element_type\"] for i in elements_data}\n    player_price = {i[\"id\"]: i[\"now_cost\"] / 10 for i in elements_data}\n    pos_no = {1: \"G\", 2: \"D\", 3: \"M\", 4: \"F\"}\n    values = []\n    existing_players = df_final[\"ID\"].to_list()\n    for i in player_ids:\n        if i not in existing_players:\n            entry = {\n                \"ID\": i,\n                \"Name\": player_names[i],\n                \"Pos\": pos_no[player_pos[i]],\n                \"Value\": player_price[i],\n                **{f\"{gw}_{tag}\": 0 for gw in gws for tag in [\"Pts\", \"xMins\"]},\n            }\n            values.append(entry)\n\n    team_data = teams\n    team_dict = {i[\"code\"]: i[\"name\"] for i in team_data}\n    player_teams = {i[\"id\"]: team_dict[i[\"team_code\"]] for i in elements_data}\n    # Add missing players\n    # df_final = pd.concat([df_final, pd.DataFrame(values, columns=df_final.columns)], ignore_index=True)\n    df_final[\"Team\"] = df_final[\"ID\"].map(player_teams)\n    df_final[\"fpl_id\"] = df_final[\"ID\"]\n    df_final[\"Name\"] = df_final[\"ID\"].replace(player_names)\n\n    df_final = df_final.set_index(\"fpl_id\")\n    df_final.to_csv(DATA_DIR / output_file, index=False, encoding=\"utf-8\", float_format=\"%.2f\")\n\n\n# convert_mikkel_to_review(\"../data/TransferAlgorithm.csv\")\n"
  },
  {
    "path": "dev/solver.py",
    "content": "import os\nimport subprocess\nimport threading\nimport time\nimport warnings\nfrom collections import Counter\nfrom pathlib import Path\n\nimport highspy\nimport numpy as np\nimport pandas as pd\nimport sasoptpy as so\n\nfrom dev.data_parser import read_data\nfrom utils import cached_request, get_random_id\n\nwarnings.filterwarnings(\"ignore\", category=FutureWarning, module=\"sasoptpy\")\n\n\nBINARY_THRESHOLD = 0.5  # threshold value for evaluating binary variables\nBASE_URL = \"https://fantasy.premierleague.com/api\"\nIS_COLAB = \"COLAB_GPU\" in os.environ\nSQUAD_SIZE = 15\nLINEUP_SIZE = 11\nMAX_GAMEWEEK = 38\nMAX_PLAYERS_PER_TEAM = 3\nAFCON_GW = 16\n\n\ndef generate_team_json(team_id, options):\n    static_url = f\"{BASE_URL}/bootstrap-static/\"\n    static = cached_request(static_url)\n    element_to_type_dict = {x[\"id\"]: x[\"element_type\"] for x in static[\"elements\"]}\n    next_gw = next(x for x in static[\"events\"] if x[\"is_next\"])[\"id\"]\n\n    start_prices = {x[\"id\"]: x[\"now_cost\"] - x[\"cost_change_start\"] for x in static[\"elements\"]}\n\n    transfers_url = f\"{BASE_URL}/entry/{team_id}/transfers/\"\n    transfers = cached_request(transfers_url)[::-1]\n\n    history_url = f\"{BASE_URL}/entry/{team_id}/history/\"\n    history = cached_request(history_url)\n    chips = history[\"chips\"]\n    fh_gws = [x[\"event\"] for x in chips if x[\"name\"] == \"freehit\"]\n    wc_gws = [x[\"event\"] for x in chips if x[\"name\"] == \"wildcard\"]\n\n    # find out the first gameweek that the user played in - don't assume gw1\n    first_gw = history[\"current\"][0][\"event\"]\n    first_gw_url = f\"{BASE_URL}/entry/{team_id}/event/{first_gw}/picks/\"\n    first_gw_data = cached_request(first_gw_url)\n\n    # squad will remain an ID:puchase_price map throughout iteration over transfers\n    # once they have been iterated through, can then add on the current selling price\n    squad = {x[\"element\"]: start_prices[x[\"element\"]] for x in first_gw_data[\"picks\"]}\n\n    itb = 1000 - sum(squad.values())\n    for t in transfers:\n        if t[\"event\"] in fh_gws:\n            continue\n        itb += t[\"element_out_cost\"]\n        itb -= t[\"element_in_cost\"]\n        if t[\"element_in\"]:\n            squad[t[\"element_in\"]] = t[\"element_in_cost\"]\n        if t[\"element_out\"]:\n            del squad[t[\"element_out\"]]\n\n    fts = calculate_fts(transfers, first_gw, next_gw, fh_gws, wc_gws)\n    my_data = {\"chips\": chips, \"picks\": [], \"team_id\": team_id, \"transfers\": {\"bank\": itb, \"limit\": fts, \"made\": 0}}\n    for player_id, purchase_price in squad.items():\n        now_cost = next(x for x in static[\"elements\"] if x[\"id\"] == player_id)[\"now_cost\"]\n\n        diff = now_cost - purchase_price\n        if diff > 0:\n            selling_price = purchase_price + diff // 2\n        else:\n            selling_price = now_cost\n\n        my_data[\"picks\"].append(\n            {\"element\": player_id, \"purchase_price\": purchase_price, \"selling_price\": selling_price, \"element_type\": element_to_type_dict[player_id]}\n        )\n\n    return my_data\n\n\ndef calculate_fts(transfers, first_gw, next_gw, fh_gws, wc_gws):\n    n_transfers = dict.fromkeys(range(2, next_gw), 0)\n    for t in transfers:\n        n_transfers[t[\"event\"]] += 1\n    fts = dict.fromkeys(range(first_gw + 1, next_gw + 1), 0)\n    fts[first_gw + 1] = 1\n    for i in range(first_gw + 2, next_gw + 1):\n        if i == AFCON_GW:\n            fts[i] = 5\n            continue\n        if (i - 1) in fh_gws:\n            fts[i] = fts[i - 1]\n            continue\n        if i - 1 in wc_gws:\n            fts[i] = fts[i - 1]\n            continue\n        fts[i] = fts[i - 1]\n        fts[i] -= n_transfers[i - 1]\n        fts[i] = max(fts[i], 0)\n        fts[i] += 1\n        fts[i] = min(fts[i], 5)\n    return fts[next_gw]\n\n\ndef prep_data(my_data, options):\n    fpl_data = cached_request(\"https://fantasy.premierleague.com/api/bootstrap-static/\")\n    valid_ids = [x[\"id\"] for x in fpl_data[\"elements\"]]\n\n    for pid, change in options.get(\"price_changes\", []):\n        if pid not in valid_ids:\n            continue\n        player = next(x for x in fpl_data[\"elements\"] if x[\"id\"] == pid)\n        player[\"now_cost\"] += change\n\n    if options.get(\"override_next_gw\", None):\n        gw = int(options[\"override_next_gw\"])\n    else:\n        gw = 0\n        for e in fpl_data[\"events\"]:\n            if e[\"is_next\"]:\n                gw = e[\"id\"]\n                break\n\n    horizon = options.get(\"horizon\", 3)\n\n    element_data = pd.DataFrame(fpl_data[\"elements\"])\n    team_data = pd.DataFrame(fpl_data[\"teams\"])\n    elements_team = pd.merge(element_data, team_data, left_on=\"team\", right_on=\"id\")\n\n    element_to_team = {x[\"id\"]: x[\"team\"] for x in fpl_data[\"elements\"]}  # dict mapping element to team id\n    max_players_from_team = Counter([element_to_team[x[\"element\"]] for x in my_data[\"picks\"]]).most_common(1)[0][1] if my_data[\"picks\"] else 3\n    data = read_data(options)\n\n    merged_data = pd.merge(elements_team, data, left_on=\"id_x\", right_on=\"ID\")\n    merged_data.set_index([\"id_x\"], inplace=True)\n\n    # Drop duplicates\n    merged_data = merged_data.drop_duplicates(subset=[\"ID\"], keep=\"first\")\n\n    # Check if data exists\n    for week in range(gw, min(39, gw + horizon)):\n        if f\"{week}_Pts\" not in data.keys():\n            raise ValueError(f\"{week}_Pts is not inside prediction data - change your horizon or update your prediction data\")\n\n    original_keys = merged_data.columns.to_list()\n    keys = [k for k in original_keys if \"_Pts\" in k]\n    min_keys = [k for k in original_keys if \"_xMins\" in k]\n    merged_data[\"total_ev\"] = merged_data[keys].sum(axis=1)\n    merged_data[\"total_min\"] = merged_data[min_keys].sum(axis=1)\n\n    merged_data.sort_values(by=[\"total_ev\"], ascending=[False], inplace=True)\n\n    locked_next_gw = [int(i[0]) if isinstance(i, list) else int(i) for i in options.get(\"locked_next_gw\", [])]\n    safe_players_due_price = []\n    for pos, vals in options.get(\"pick_prices\", {}).items():\n        if vals is None or vals == \"\":\n            continue\n        price_vals = [float(i) for i in vals.split(\",\")]\n        pp = merged_data[(merged_data[\"Pos\"] == pos) & ((merged_data[\"now_cost\"] / 10).isin(price_vals))][\"ID\"].to_list()\n        safe_players_due_price += pp\n\n    # Filter players by total EV\n    cutoff = merged_data[\"total_ev\"].quantile((100 - options.get(\"keep_top_ev_percent\", 10)) / 100)\n    safe_players_due_ev = merged_data[(merged_data[\"total_ev\"] > cutoff)][\"ID\"].tolist()\n\n    initial_squad = [int(i[\"element\"]) for i in my_data[\"picks\"]]\n    safe_players = initial_squad + options.get(\"locked\", []) + options.get(\"keep\", []) + locked_next_gw + safe_players_due_price + safe_players_due_ev\n\n    for bt in options.get(\"booked_transfers\", []):\n        if bt.get(\"transfer_in\"):\n            safe_players.append(bt[\"transfer_in\"])\n        if bt.get(\"transfer_out\"):\n            safe_players.append(bt[\"transfer_out\"])\n\n    # Filter players by xMin\n    xmin_lb = options.get(\"xmin_lb\", 100)\n    num_players_before = len(merged_data)\n    merged_data = merged_data[(merged_data[\"total_min\"] >= xmin_lb) | (merged_data[\"ID\"].isin(safe_players))].copy()\n\n    # Filter by ev per price\n    ev_per_price_cutoff = options.get(\"ev_per_price_cutoff\", 0)\n    if ev_per_price_cutoff != 0:\n        cutoff = (merged_data[\"total_ev\"] / merged_data[\"now_cost\"]).quantile(ev_per_price_cutoff / 100)\n        merged_data = merged_data[(merged_data[\"total_ev\"] / merged_data[\"now_cost\"] > cutoff) | (merged_data[\"ID\"].isin(safe_players))].copy()\n\n    num_players_after = len(merged_data)\n    print(f\"Filtered player pool from {num_players_before} to {num_players_after} players\")\n\n    if options.get(\"randomized\", False):\n        rng = np.random.default_rng(seed=options.get(\"randomization_seed\"))\n        gws = list(range(gw, min(39, gw + horizon)))\n        for w in gws:\n            noise = merged_data[f\"{w}_Pts\"] * (92 - merged_data[f\"{w}_xMins\"]) / 134 * rng.standard_normal(size=len(merged_data))\n            merged_data[f\"{w}_Pts\"] = merged_data[f\"{w}_Pts\"] + noise * options.get(\"randomization_strength\", 1)\n\n    type_data = pd.DataFrame(fpl_data[\"element_types\"]).set_index([\"id\"])\n\n    buy_price = (merged_data[\"now_cost\"] / 10).to_dict()\n    sell_price = {i[\"element\"]: i[\"selling_price\"] / 10 for i in my_data[\"picks\"]}\n    price_modified_players = []\n\n    preseason = options.get(\"preseason\", False)\n    if not preseason:\n        for i in my_data[\"picks\"]:\n            if buy_price[i[\"element\"]] != sell_price[i[\"element\"]]:\n                price_modified_players.append(i[\"element\"])\n                print(f\"Added player {i['element']} to list, buy price {buy_price[i['element']]} sell price {sell_price[i['element']]}\")\n\n    itb = my_data[\"transfers\"][\"bank\"] / 10\n    ft_base = None\n    if my_data[\"transfers\"][\"limit\"] is None:\n        ft = 1\n        ft_base = 1\n    else:\n        ft = my_data[\"transfers\"][\"limit\"] - my_data[\"transfers\"][\"made\"]\n        ft_base = my_data[\"transfers\"][\"limit\"]\n\n    ft = max(ft, 0)\n\n    # If wildcard is active, then you have: \"status_for_entry\": \"active\" under my_data['chips']\n    # can only pass the check when using \"team_data\": \"json\"\n    for c in my_data[\"chips\"]:\n        if c[\"name\"] == \"wildcard\" and c.get(\"status_for_entry\", \"\") == \"active\":\n            options[\"use_wc\"] = [gw]\n            if options[\"chip_limits\"][\"wc\"] == 0:\n                options[\"chip_limits\"][\"wc\"] = 1\n            break\n\n    # Fixture info\n    team_code_dict = team_data.set_index(\"id\")[\"name\"].to_dict()\n    fixture_data = cached_request(\"https://fantasy.premierleague.com/api/fixtures/\")\n    fixtures = [{\"gw\": f[\"event\"], \"home\": team_code_dict[f[\"team_h\"]], \"away\": team_code_dict[f[\"team_a\"]]} for f in fixture_data]\n\n    return {\n        \"merged_data\": merged_data,\n        \"team_data\": team_data,\n        \"my_data\": my_data,\n        \"type_data\": type_data,\n        \"next_gw\": gw,\n        \"initial_squad\": initial_squad,\n        \"sell_price\": sell_price,\n        \"buy_price\": buy_price,\n        \"price_modified_players\": price_modified_players,\n        \"itb\": itb,\n        \"ft\": ft,\n        \"ft_base\": ft_base,\n        \"fixtures\": fixtures,\n        \"max_players_from_team\": max_players_from_team,\n    }\n\n\ndef solve_multi_period_fpl(data, options):\n    \"\"\"\n    Solves multi-objective FPL problem with transfers\n\n    Parameters\n    ----------\n    data: dict\n        Pre-processed data for the problem definition\n    options: dict\n        User controlled values for the problem instance\n    \"\"\"\n\n    print(\n        \"This solver is free for personal, educational, or non-commercial use under the \"\n        \"Apache License 2.0. Commercial entities must obtain a Commercial License before \"\n        \"accessing, viewing, or using the code for any commercial purposes. Unauthorized \"\n        \"access or use by commercial entities without a valid commercial license is strictly \"\n        \"prohibited. To obtain a commercial license, please contact us at \"\n        \"info@fploptimized.com.\"\n    )\n\n    try:\n        commit_hash = subprocess.check_output([\"git\", \"rev-parse\", \"--short\", \"HEAD\"], stderr=subprocess.DEVNULL).decode(\"utf-8\").strip()\n        commit_count = subprocess.check_output([\"git\", \"rev-list\", \"--count\", \"HEAD\"], stderr=subprocess.DEVNULL).decode(\"utf-8\").strip()\n        version = f\"{commit_count} - {commit_hash}\"\n        print(f\"Version: {version}\")\n    except Exception:\n        pass\n\n    # Arguments\n    problem_id = get_random_id(5)\n    horizon = options.get(\"horizon\", 3)\n    objective = options.get(\"objective\", \"decay\")\n    decay_base = options.get(\"decay_base\", 0.84)\n    bench_weights = options.get(\"bench_weights\", {0: 0.03, 1: 0.21, 2: 0.06, 3: 0.002})\n    bench_weights = {int(key): value for (key, value) in bench_weights.items()}\n    # wc_limit = options.get('wc_limit', 0)\n    ft_value = options.get(\"ft_value\", 1.5)\n    ft_value_list = options.get(\"ft_value_list\", {})\n    # ft_gw_value = {}\n    ft_use_penalty = options.get(\"ft_use_penalty\", None)\n    itb_value = options.get(\"itb_value\", 0.08)\n    initial_ft = max(0, data.get(\"ft\", 1))\n    ft_base = data.get(\"ft_base\", 1)\n    chip_limits = options.get(\"chip_limits\", {})\n    allowed_chip_gws = options.get(\"allowed_chip_gws\", {})\n    forced_chip_gws = options.get(\"forced_chip_gws\", {})\n    booked_transfers = options.get(\"booked_transfers\", [])\n    preseason = options.get(\"preseason\", False)\n    itb_loss_per_transfer = options.get(\"itb_loss_per_transfer\", None)\n    if itb_loss_per_transfer is None:\n        itb_loss_per_transfer = 0\n    weekly_hit_limit = options.get(\"weekly_hit_limit\", None)\n\n    # Data\n    problem_name = f\"mp_h{horizon}_regular\" if objective == \"regular\" else f\"mp_h{horizon}_o{objective[0]}_d{decay_base}\"\n    merged_data = data[\"merged_data\"]\n    team_data = data[\"team_data\"]\n    type_data = data[\"type_data\"]\n    next_gw = data[\"next_gw\"]\n    initial_squad = data[\"initial_squad\"]\n    itb = data[\"itb\"]\n    fixtures = data[\"fixtures\"]\n    if preseason:\n        itb = 100\n        threshold_gw = 2\n    else:\n        threshold_gw = next_gw\n\n    # Sets\n    players = merged_data.index.to_list()\n    el_types = type_data.index.to_list()\n    teams = team_data[\"name\"].to_list()\n    last_gw = next_gw + horizon - 1\n    if last_gw > MAX_GAMEWEEK:\n        last_gw = MAX_GAMEWEEK\n        horizon = MAX_GAMEWEEK + 1 - next_gw\n    gws = list(range(next_gw, last_gw + 1))\n    all_gw = [next_gw - 1, *gws]\n    order = [0, 1, 2, 3]\n    price_modified_players = data[\"price_modified_players\"]\n    ft_states = [0, 1, 2, 3, 4, 5]\n\n    # Model\n    model = so.Model(name=problem_name)\n\n    # Variables\n    squad = model.add_variables(players, all_gw, name=\"squad\", vartype=so.binary)\n    squad_fh = model.add_variables(players, gws, name=\"squad_fh\", vartype=so.binary)\n    lineup = model.add_variables(players, gws, name=\"lineup\", vartype=so.binary)\n    captain = model.add_variables(players, gws, name=\"captain\", vartype=so.binary)\n    vicecap = model.add_variables(players, gws, name=\"vicecap\", vartype=so.binary)\n    bench = model.add_variables(players, gws, order, name=\"bench\", vartype=so.binary)\n    transfer_in = model.add_variables(players, gws, name=\"transfer_in\", vartype=so.binary)\n    transfer_out_first = model.add_variables(price_modified_players, gws, name=\"tr_out_first\", vartype=so.binary)\n    transfer_out_regular = model.add_variables(players, gws, name=\"tr_out_reg\", vartype=so.binary)\n    transfer_out = {\n        (p, w): transfer_out_regular[p, w] + (transfer_out_first[p, w] if p in price_modified_players else 0) for p in players for w in gws\n    }\n    in_the_bank = model.add_variables(all_gw, name=\"itb\", vartype=so.continuous, lb=0)\n    fts = model.add_variables(all_gw, name=\"ft\", vartype=so.integer, lb=0, ub=5)\n    ft_above_ub = model.add_variables(gws, name=\"ft_above\", vartype=so.binary)\n    ft_below_lb = model.add_variables(gws, name=\"ft_below\", vartype=so.binary)\n    fts_state = model.add_variables(gws, ft_states, name=\"ft_state\", vartype=so.binary)\n    penalized_transfers = model.add_variables(gws, name=\"pt\", vartype=so.integer, lb=0)\n    aux = model.add_variables(gws, name=\"aux\", vartype=so.binary)\n    transfer_count = model.add_variables(gws, name=\"trc\", vartype=so.integer, lb=0, ub=SQUAD_SIZE)\n\n    use_wc = model.add_variables(gws, name=\"use_wc\", vartype=so.binary)\n    use_bb = model.add_variables(gws, name=\"use_bb\", vartype=so.binary)\n    use_fh = model.add_variables(gws, name=\"use_fh\", vartype=so.binary)\n    use_tc = model.add_variables(players, gws, name=\"use_tc\", vartype=so.binary)\n\n    # Dictionaries\n    lineup_type_count = {(t, w): so.expr_sum(lineup[p, w] for p in players if merged_data.loc[p, \"element_type\"] == t) for t in el_types for w in gws}\n    squad_type_count = {(t, w): so.expr_sum(squad[p, w] for p in players if merged_data.loc[p, \"element_type\"] == t) for t in el_types for w in gws}\n    squad_fh_type_count = {\n        (t, w): so.expr_sum(squad_fh[p, w] for p in players if merged_data.loc[p, \"element_type\"] == t) for t in el_types for w in gws\n    }\n    player_type = merged_data[\"element_type\"].to_dict()\n    # player_price = (merged_data['now_cost'] / 10).to_dict()\n    sell_price = data[\"sell_price\"]\n    buy_price = data[\"buy_price\"]\n    sold_amount = {\n        w: (\n            so.expr_sum(sell_price[p] * transfer_out_first[p, w] for p in price_modified_players)\n            + so.expr_sum(buy_price[p] * transfer_out_regular[p, w] for p in players)\n        )\n        for w in gws\n    }\n    fh_sell_price = {p: sell_price[p] if p in price_modified_players else buy_price[p] for p in players}\n    bought_amount = {w: so.expr_sum(buy_price[p] * transfer_in[p, w] for p in players) for w in gws}\n    points_player_week = {(p, w): merged_data.loc[p, f\"{w}_Pts\"] for p in players for w in gws}\n    minutes_player_week = {(p, w): merged_data.loc[p, f\"{w}_xMins\"] for p in players for w in gws}\n    player_team = {p: merged_data.loc[p, \"name\"] for p in players}\n    squad_count = {w: so.expr_sum(squad[p, w] for p in players) for w in gws}\n    squad_fh_count = {w: so.expr_sum(squad_fh[p, w] for p in players) for w in gws}\n    num_transfers = {w: so.expr_sum(transfer_out[p, w] for p in players) for w in gws}\n    transfer_diff = {w: num_transfers[w] - fts[w] - SQUAD_SIZE * use_wc[w] for w in gws}\n    use_tc_gw = {w: so.expr_sum(use_tc[p, w] for p in players) for w in gws}\n\n    # Initial conditions\n    model.add_constraints((squad[p, next_gw - 1] == 1 for p in initial_squad), name=\"initial_squad_players\")\n    model.add_constraints((squad[p, next_gw - 1] == 0 for p in players if p not in initial_squad), name=\"initial_squad_others\")\n    model.add_constraint(in_the_bank[next_gw - 1] == itb, name=\"initial_itb\")\n    model.add_constraint(fts[next_gw] == initial_ft * (1 - use_wc[next_gw]) + ft_base * use_wc[next_gw], name=\"initial_ft\")\n    model.add_constraints((fts[w] >= 1 for w in gws if w > next_gw), name=\"future_ft_limit\")\n\n    # Constraints\n    model.add_constraints((squad_count[w] == SQUAD_SIZE for w in gws), name=\"squad_count\")\n    model.add_constraints((squad_fh_count[w] == SQUAD_SIZE * use_fh[w] for w in gws), name=\"squad_fh_count\")\n    model.add_constraints(\n        (so.expr_sum(lineup[p, w] for p in players) == LINEUP_SIZE + (SQUAD_SIZE - LINEUP_SIZE) * use_bb[w] for w in gws), name=\"lineup_count\"\n    )\n    model.add_constraints((so.expr_sum(bench[p, w, 0] for p in players if player_type[p] == 1) == 1 - use_bb[w] for w in gws), name=\"bench_gk\")\n    model.add_constraints((so.expr_sum(bench[p, w, o] for p in players) == 1 - use_bb[w] for w in gws for o in [1, 2, 3]), name=\"bench_count\")\n    model.add_constraints((so.expr_sum(captain[p, w] for p in players) == 1 for w in gws), name=\"captain_count\")\n    model.add_constraints((so.expr_sum(vicecap[p, w] for p in players) == 1 for w in gws), name=\"vicecap_count\")\n    model.add_constraints((lineup[p, w] <= squad[p, w] + use_fh[w] for p in players for w in gws), name=\"lineup_squad_rel\")\n    model.add_constraints((bench[p, w, o] <= squad[p, w] + use_fh[w] for p in players for w in gws for o in order), name=\"bench_squad_rel\")\n    model.add_constraints((lineup[p, w] <= squad_fh[p, w] + 1 - use_fh[w] for p in players for w in gws), name=\"lineup_squad_fh_rel\")\n    model.add_constraints((bench[p, w, o] <= squad_fh[p, w] + 1 - use_fh[w] for p in players for w in gws for o in order), name=\"bench_squad_fh_rel\")\n    model.add_constraints((captain[p, w] <= lineup[p, w] for p in players for w in gws), name=\"captain_lineup_rel\")\n    model.add_constraints((vicecap[p, w] <= lineup[p, w] for p in players for w in gws), name=\"vicecap_lineup_rel\")\n    model.add_constraints((captain[p, w] + vicecap[p, w] <= 1 for p in players for w in gws), name=\"cap_vc_rel\")\n    model.add_constraints((lineup[p, w] + so.expr_sum(bench[p, w, o] for o in order) <= 1 for p in players for w in gws), name=\"lineup_bench_rel\")\n    model.add_constraints((lineup_type_count[t, w] >= type_data.loc[t, \"squad_min_play\"] for t in el_types for w in gws), name=\"valid_formation_lb\")\n    model.add_constraints(\n        (lineup_type_count[t, w] <= type_data.loc[t, \"squad_max_play\"] + use_bb[w] for t in el_types for w in gws), name=\"valid_formation_ub\"\n    )\n    model.add_constraints((squad_type_count[t, w] == type_data.loc[t, \"squad_select\"] for t in el_types for w in gws), name=\"valid_squad\")\n    model.add_constraints(\n        (squad_fh_type_count[t, w] == type_data.loc[t, \"squad_select\"] * use_fh[w] for t in el_types for w in gws), name=\"valid_squad_fh\"\n    )\n\n    # special case where user's current squad has too many players from the same team\n    # only works for 4 players from same team at the moment\n    if data[\"max_players_from_team\"] > MAX_PLAYERS_PER_TEAM:\n        no_transfer = model.add_variables(gws, vartype=so.binary, name=\"no_transfer\")\n        model.add_constraints((transfer_count[w] <= SQUAD_SIZE * (1 - no_transfer[w]) for w in gws), name=\"no_transfer_1\")\n        model.add_constraints((transfer_count[w] >= 1 - SQUAD_SIZE * no_transfer[w] for w in gws), name=\"no_transfer_2\")\n\n        model.add_constraints(\n            (so.expr_sum(squad[p, w] for p in players if player_team[p] == t) <= MAX_PLAYERS_PER_TEAM + no_transfer[w] for t in teams for w in gws),\n            name=\"team_limit\",\n        )\n\n    else:  # normal case where user has a valid squad\n        model.add_constraints(\n            (so.expr_sum(squad[p, w] for p in players if player_team[p] == t) <= MAX_PLAYERS_PER_TEAM for t in teams for w in all_gw),\n            name=\"team_limit\",\n        )\n\n    model.add_constraints(\n        (so.expr_sum(squad_fh[p, w] for p in players if player_team[p] == t) <= MAX_PLAYERS_PER_TEAM * use_fh[w] for t in teams for w in gws),\n        name=\"team_limit_fh\",\n    )\n    ## Transfer constraints\n    model.add_constraints(\n        (squad[p, w] == squad[p, w - 1] + transfer_in[p, w] - transfer_out[p, w] for p in players for w in gws), name=\"squad_transfer_rel\"\n    )\n    model.add_constraints(\n        (\n            in_the_bank[w]\n            == in_the_bank[w - 1] + sold_amount[w] - bought_amount[w] - (transfer_count[w] * itb_loss_per_transfer if w > next_gw else 0)\n            for w in gws\n        ),\n        name=\"cont_budget\",\n    )\n    model.add_constraints(\n        (\n            so.expr_sum(fh_sell_price[p] * squad[p, w - 1] for p in players) + in_the_bank[w - 1]\n            >= so.expr_sum(fh_sell_price[p] * squad_fh[p, w] for p in players)\n            for w in gws\n        ),\n        name=\"fh_budget\",\n    )\n    model.add_constraints((transfer_in[p, w] <= 1 - use_fh[w] for p in players for w in gws), name=\"no_tr_in_fh\")\n    model.add_constraints((transfer_out[p, w] <= 1 - use_fh[w] for p in players for w in gws), name=\"no_tr_out_fh\")\n\n    ## Free transfer constraints\n    # 2024-2025 variation: min 1 / max 5 / roll over WC & FH\n    # raw_gw_ft = {w: fts[w] - transfer_count[w] + 1 - use_wc[w] - use_fh[w] for w in gws}\n\n    # 2056-26 afcon variation: always have 5 ft in gw16 no matter what\n    raw_gw_ft = {w: fts[w] - transfer_count[w] + (5 if w == AFCON_GW - 1 else 1) - use_wc[w] - use_fh[w] for w in gws}\n    m = 20  # big m for bounding constraints, picked 20 because nobody will ever get to 20 ft in a solve\n\n    # FT_BELOW_LB AND FT_ABOVE_UB LOGIC\n\n    # ft_above_ub[w] == 1  <=>  raw_gw_ft[w] > 5\n    model.add_constraints((raw_gw_ft[w] >= 6 - m * (1 - ft_above_ub[w]) for w in gws), name=\"ft_above_ub_lb\")\n    model.add_constraints((raw_gw_ft[w] <= 5 + m * ft_above_ub[w] for w in gws), name=\"ft_above_ub_ub\")\n\n    # ft_below_lb[w] == 1  <=>  raw_gw_ft[w] <= 0\n    model.add_constraints((raw_gw_ft[w] <= 0 + m * (1 - ft_below_lb[w]) for w in gws), name=\"ft_below_lb_ub\")\n    model.add_constraints((raw_gw_ft[w] >= 1 - m * ft_below_lb[w] for w in gws), name=\"ft_below_lb_lb\")\n\n    # FREE TRANSFER LOGIC\n\n    # raw_gw_ft[w] > 5 => fts[w+1] = 5\n    model.add_constraints((fts[w + 1] <= 5 + m * (1 - ft_above_ub[w]) for w in gws if w + 1 in gws), name=\"ft_cap_upper_ub\")\n    model.add_constraints((fts[w + 1] >= 5 - m * (1 - ft_above_ub[w]) for w in gws if w + 1 in gws), name=\"ft_cap_upper_lb\")\n\n    # raw_gw_ft[w] < 0 => fts[w+1] = 1\n    model.add_constraints((fts[w + 1] <= 1 + m * (1 - ft_below_lb[w]) for w in gws if w + 1 in gws), name=\"ft_cap_lower_ub\")\n    model.add_constraints((fts[w + 1] >= 1 - m * (1 - ft_below_lb[w]) for w in gws if w + 1 in gws), name=\"ft_cap_lower_lb\")\n\n    # 0 <= raw_gw_ft <= 5 => fts[w+1] = raw_gw_ft[w]\n    model.add_constraints((fts[w + 1] - raw_gw_ft[w] <= m * (ft_above_ub[w] + ft_below_lb[w]) for w in gws if w + 1 in gws), name=\"ft_inrange_ub\")\n    model.add_constraints((raw_gw_ft[w] - fts[w + 1] <= m * (ft_above_ub[w] + ft_below_lb[w]) for w in gws if w + 1 in gws), name=\"ft_inrange_lb\")\n\n    model.add_constraints((fts[w] == so.expr_sum(fts_state[w, s] * s for s in ft_states) for w in gws), name=\"ftsc1\")\n    model.add_constraints((so.expr_sum(fts_state[w, s] for s in ft_states) == 1 for w in gws), name=\"ftsc2\")\n\n    if preseason and threshold_gw in gws:\n        model.add_constraint(fts[threshold_gw] == 1, name=\"ps_initial_ft\")\n    model.add_constraints((penalized_transfers[w] >= transfer_diff[w] for w in gws), name=\"pen_transfer_rel\")\n\n    ## Chip constraints\n    model.add_constraints((use_wc[w] + use_fh[w] + use_bb[w] + use_tc_gw[w] <= 1 for w in gws), name=\"single_chip\")\n    model.add_constraints((aux[w] <= 1 - use_wc[w - 1] for w in gws if w > next_gw), name=\"ft_after_wc\")\n    model.add_constraints((aux[w] <= 1 - use_fh[w - 1] for w in gws if w > next_gw), name=\"ft_after_fh\")\n    model.add_constraints((use_tc[p, w] <= captain[p, w] for p in players for w in gws), name=\"tc_cap_rel\")\n\n    wc = options.get(\"use_wc\", [])\n    if len(wc) > 0:\n        model.add_constraints((use_wc[w] == 1 for w in wc), name=\"force_wc\")\n        chip_limits[\"wc\"] = len(wc)\n\n    bb = options.get(\"use_bb\", [])\n    if len(bb) > 0:\n        model.add_constraints((use_bb[w] == 1 for w in bb), name=\"force_bb\")\n        chip_limits[\"bb\"] = len(bb)\n\n    fh = options.get(\"use_fh\", [])\n    if len(fh) > 0:\n        model.add_constraints((use_fh[w] == 1 for w in fh), name=\"force_fh\")\n        chip_limits[\"fh\"] = len(fh)\n\n    tc = options.get(\"use_tc\", [])\n    if len(tc) > 0:\n        model.add_constraints((use_tc_gw[w] == 1 for w in tc), name=\"force_tc\")\n        chip_limits[\"tc\"] = len(tc)\n\n    if len(allowed_chip_gws.get(\"wc\", [])) > 0:\n        gws_banned = [w for w in gws if w not in allowed_chip_gws[\"wc\"]]\n        model.add_constraints((use_wc[w] == 0 for w in gws_banned), name=\"banned_wc_gws\")\n        chip_limits[\"wc\"] = 1\n    if len(allowed_chip_gws.get(\"fh\", [])) > 0:\n        gws_banned = [w for w in gws if w not in allowed_chip_gws[\"fh\"]]\n        model.add_constraints((use_fh[w] == 0 for w in gws_banned), name=\"banned_fh_gws\")\n        chip_limits[\"fh\"] = 1\n    if len(allowed_chip_gws.get(\"bb\", [])) > 0:\n        gws_banned = [w for w in gws if w not in allowed_chip_gws[\"bb\"]]\n        model.add_constraints((use_bb[w] == 0 for w in gws_banned), name=\"banned_bb_gws\")\n        chip_limits[\"bb\"] = 1\n    if len(allowed_chip_gws.get(\"tc\", [])) > 0:\n        gws_banned = [w for w in gws if w not in allowed_chip_gws[\"tc\"]]\n        model.add_constraints((use_tc_gw[w] == 0 for w in gws_banned), name=\"banned_tc_gws\")\n        chip_limits[\"tc\"] = 1\n\n    if len(forced_chip_gws.get(\"wc\", [])) > 0:\n        model.add_constraint(so.expr_sum(use_wc[w] for w in forced_chip_gws[\"wc\"]) == 1, name=\"force_wc_gw\")\n        chip_limits[\"wc\"] = 1\n    if len(forced_chip_gws.get(\"fh\", [])) > 0:\n        model.add_constraint(so.expr_sum(use_fh[w] for w in forced_chip_gws[\"fh\"]) == 1, name=\"force_fh_gw\")\n        chip_limits[\"fh\"] = 1\n    if len(forced_chip_gws.get(\"bb\", [])) > 0:\n        model.add_constraint(so.expr_sum(use_bb[w] for w in forced_chip_gws[\"bb\"]) == 1, name=\"force_bb_gw\")\n        chip_limits[\"bb\"] = 1\n    if len(forced_chip_gws.get(\"tc\", [])) > 0:\n        model.add_constraint(so.expr_sum(use_tc_gw[w] for w in forced_chip_gws[\"tc\"]) == 1, name=\"force_tc_gw\")\n        chip_limits[\"tc\"] = 1\n\n    model.add_constraint(so.expr_sum(use_wc[w] for w in gws) <= chip_limits.get(\"wc\", 0), name=\"use_wc_limit\")\n    model.add_constraint(so.expr_sum(use_bb[w] for w in gws) <= chip_limits.get(\"bb\", 0), name=\"use_bb_limit\")\n    model.add_constraint(so.expr_sum(use_fh[w] for w in gws) <= chip_limits.get(\"fh\", 0), name=\"use_fh_limit\")\n    model.add_constraint(so.expr_sum(use_tc_gw[w] for w in gws) <= chip_limits.get(\"tc\", 0), name=\"use_tc_limit\")\n    model.add_constraints((squad_fh[p, w] <= use_fh[w] for p in players for w in gws), name=\"fh_squad_logic\")\n\n    ## Multiple-sell fix\n    model.add_constraints(\n        (transfer_out_first[p, w] + transfer_out_regular[p, w] <= 1 for p in price_modified_players for w in gws), name=\"multi_sell_1\"\n    )\n    model.add_constraints(\n        (\n            horizon * so.expr_sum(transfer_out_first[p, w] for w in gws if w <= wbar)\n            >= so.expr_sum(transfer_out_regular[p, w] for w in gws if w >= wbar)\n            for p in price_modified_players\n            for wbar in gws\n        ),\n        name=\"multi_sell_2\",\n    )\n    model.add_constraints((so.expr_sum(transfer_out_first[p, w] for w in gws) <= 1 for p in price_modified_players), name=\"multi_sell_3\")\n\n    ## Transfer in/out fix\n    model.add_constraints((transfer_in[p, w] + transfer_out[p, w] <= 1 for p in players for w in gws), name=\"tr_in_out_limit\")\n\n    ## Tr Count Constraints\n    ft_penalty = dict.fromkeys(gws, 0)\n    model.add_constraints((transfer_count[w] >= num_transfers[w] - SQUAD_SIZE * use_wc[w] for w in gws), name=\"trc_lb\")\n    model.add_constraints((transfer_count[w] <= num_transfers[w] for w in gws), name=\"trc_ub1\")\n    model.add_constraints((transfer_count[w] <= SQUAD_SIZE * (1 - use_wc[w]) for w in gws), name=\"trc_ub2\")\n    if ft_use_penalty is not None:\n        ft_penalty = {w: ft_use_penalty * transfer_count[w] for w in gws}\n\n    ## Optional constraints\n    if options.get(\"banned\", None):\n        print(\"OC - Banned\")\n        banned_players = options[\"banned\"]\n        model.add_constraints((so.expr_sum(squad[p, w] for w in gws) == 0 for p in banned_players if p in players), name=\"ban_player\")\n        model.add_constraints((so.expr_sum(squad_fh[p, w] for w in gws) == 0 for p in banned_players if p in players), name=\"ban_player_fh\")\n\n    if options.get(\"banned_next_gw\", None):\n        print(\"OC - Banned Next GW\")\n        banned_in_gw = [(x, gws[0]) if isinstance(x, int) else tuple(x) for x in options[\"banned_next_gw\"]]\n        model.add_constraints((squad[p0, p1] == 0 for (p0, p1) in banned_in_gw if p0 in players), name=\"ban_player_specified_gw\")\n        model.add_constraints((squad_fh[p0, p1] == 0 for (p0, p1) in banned_in_gw if p0 in players), name=\"ban_player_specified_gw_fh\")\n\n    if options.get(\"locked\", None):\n        print(\"OC - Locked\")\n        locked_players = options[\"locked\"]\n        model.add_constraints((squad[p, w] + squad_fh[p, w] == 1 for p in locked_players for w in gws), name=\"lock_player\")\n\n    if options.get(\"locked_next_gw\", None):\n        print(\"OC - Locked Next GW\")\n        locked_in_gw = [(x, gws[0]) if isinstance(x, int) else tuple(x) for x in options[\"locked_next_gw\"]]\n        model.add_constraints((squad[p0, p1] == 1 for (p0, p1) in locked_in_gw), name=\"lock_player_specified_gw\")\n\n    if options.get(\"no_future_transfer\", None):\n        print(\"OC - No Future Tr\")\n        model.add_constraint(\n            so.expr_sum(transfer_in[p, w] for p in players for w in gws if w > next_gw and w != options.get(\"use_wc\")) == 0, name=\"no_future_transfer\"\n        )\n\n    if options.get(\"no_transfer_last_gws\", None):\n        print(\"OC - No TR last GWs\")\n        no_tr_gws = options[\"no_transfer_last_gws\"]\n        if horizon > no_tr_gws:\n            model.add_constraints(\n                (so.expr_sum(transfer_in[p, w] for p in players) <= SQUAD_SIZE * use_wc[w] for w in gws if w > last_gw - no_tr_gws), name=\"tr_ban_gws\"\n            )\n\n    if options.get(\"num_transfers\", None) is not None:\n        print(\"OC - Num Transfers\")\n        model.add_constraint(so.expr_sum(transfer_in[p, next_gw] for p in players) == options[\"num_transfers\"], name=\"tr_limit\")\n\n    if options.get(\"hit_limit\", None):\n        print(\"OC - Hit Limit\")\n        model.add_constraint(so.expr_sum(penalized_transfers[w] for w in gws) <= int(options[\"hit_limit\"]), name=\"horizon_hit_limit\")\n\n    if options.get(\"weekly_hit_limit\") is not None:\n        weekly_hit_limit = int(options.get(\"weekly_hit_limit\"))\n        model.add_constraints((penalized_transfers[w] <= weekly_hit_limit for w in gws), name=\"gw_hit_lim\")\n\n    # if options.get(\"ft_custom_value\", None) is not None:\n    #     ft_custom_value = {int(key): value for (key, value) in options.get('ft_custom_value', {}).items()}\n    #     ft_gw_value = {**{gw: ft_value for gw in gws}, **ft_custom_value}\n\n    if options.get(\"future_transfer_limit\", None):\n        print(\"OC - Future TR Limit\")\n        model.add_constraint(\n            so.expr_sum(transfer_in[p, w] for p in players for w in gws if w > next_gw and w not in options.get(\"use_wc\", []))\n            <= options[\"future_transfer_limit\"],\n            name=\"future_tr_limit\",\n        )\n\n    if options.get(\"no_transfer_gws\", None):\n        print(\"OC - No TR GWs\")\n        if len(options[\"no_transfer_gws\"]) > 0:\n            model.add_constraint(so.expr_sum(transfer_in[p, w] for p in players for w in options[\"no_transfer_gws\"]) == 0, name=\"banned_gws_for_tr\")\n\n    if options.get(\"no_transfer_by_position\", None):\n        print(\"OC - No TR by position\")\n        if len(options[\"no_transfer_by_position\"]) > 0:\n            # ignore w=1 as you must transfer in a full squad\n            model.add_constraints(\n                (\n                    transfer_in[p, w] <= use_wc[w]\n                    for p in players\n                    for w in gws\n                    if w > 1\n                    if merged_data.loc[p, \"Pos\"] in options[\"no_transfer_by_position\"]\n                ),\n                name=\"no_tr_by_pos\",\n            )\n\n    max_defs_per_team = options.get(\"max_defenders_per_team\", 3)\n    if max_defs_per_team < MAX_PLAYERS_PER_TEAM:  # only add constraints if necessary\n        model.add_constraints(\n            (\n                so.expr_sum(squad[p, w] for p in players if player_team[p] == t and merged_data.loc[p, \"Pos\"] in {\"G\", \"D\"}) <= max_defs_per_team\n                for t in teams\n                for w in gws\n            ),\n            name=\"defenders_per_team_limit\",\n        )\n        model.add_constraints(\n            (\n                so.expr_sum(squad_fh[p, w] for p in players if player_team[p] == t and merged_data.loc[p, \"Pos\"] in {\"G\", \"D\"})\n                <= max_defs_per_team * use_fh[w]\n                for t in teams\n                for w in gws\n            ),\n            name=\"defenders_per_team_limit_fh\",\n        )\n\n    for booked_transfer in booked_transfers:\n        print(\"OC - Booked TRs\")\n        transfer_gw = booked_transfer.get(\"gw\", None)\n\n        if transfer_gw is None:\n            continue\n\n        player_in = booked_transfer.get(\"transfer_in\", None)\n        player_out = booked_transfer.get(\"transfer_out\", None)\n\n        if player_in is not None:\n            model.add_constraint(transfer_in[player_in, transfer_gw] == 1, name=f\"booked_transfer_in_{transfer_gw}_{player_in}\")\n        if player_out is not None:\n            model.add_constraint(transfer_out[player_out, transfer_gw] == 1, name=f\"booked_transfer_out_{transfer_gw}_{player_out}\")\n\n    cp_penalty = {}\n    if options.get(\"no_opposing_play\") is True:\n        print(\"OC - No Opposing Play\")\n        gw_opp_teams = {\n            w: [(f[\"home\"], f[\"away\"]) for f in fixtures if f[\"gw\"] == w] + [(f[\"away\"], f[\"home\"]) for f in fixtures if f[\"gw\"] == w] for w in gws\n        }\n        for gw in gws:\n            [i for i in fixtures if i[\"gw\"] == gw]\n            if options.get(\"opposing_play_group\", \"all\") == \"all\":\n                opposing_players = [(p1, p2) for p1 in players for p2 in players if (player_team[p1], player_team[p2]) in gw_opp_teams[gw]]\n                model.add_constraints((lineup[p1, gw] + lineup[p2, gw] <= 1 for (p1, p2) in opposing_players), name=f\"no_opp_{gw}\")\n            elif options.get(\"opposing_play_group\") == \"position\":\n                opposing_positions = [\n                    (1, 3),\n                    (1, 4),\n                    (2, 3),\n                    (2, 4),\n                    (3, 1),\n                    (4, 1),\n                    (3, 2),\n                    (4, 2),\n                ]  # gk vs mid, gk vs fwd, def vs mid, def vs fwd\n                opposing_players = [\n                    (p1, p2)\n                    for p1 in players\n                    for p2 in players\n                    if (player_team[p1], player_team[p2]) in gw_opp_teams[gw] and (player_type[p1], player_type[p2]) in opposing_positions\n                ]\n                model.add_constraints((lineup[p1, gw] + lineup[p2, gw] <= 1 for (p1, p2) in opposing_players), name=f\"no_opp_{gw}\")\n    elif options.get(\"no_opposing_play\") == \"penalty\":\n        print(\"OC - Penalty Opposing Play\")\n        gw_opp_teams = {\n            w: [(f[\"home\"], f[\"away\"]) for f in fixtures if f[\"gw\"] == w] + [(f[\"away\"], f[\"home\"]) for f in fixtures if f[\"gw\"] == w] for w in gws\n        }\n        if options.get(\"opposing_play_group\") == \"all\":\n            cp_list = [\n                (p1, p2, w)\n                for p1 in players\n                for p2 in players\n                for w in gws\n                if (player_team[p1], player_team[p2]) in gw_opp_teams[w] and minutes_player_week[p1, w] > 0 and minutes_player_week[p2, w] > 0\n            ]\n        elif options.get(\"opposing_play_group\", \"position\") == \"position\":\n            opposing_positions = [(1, 3), (1, 4), (2, 3), (2, 4), (3, 1), (4, 1), (3, 2), (4, 2)]\n            cp_list = [\n                (p1, p2, w)\n                for p1 in players\n                for p2 in players\n                for w in gws\n                if (player_team[p1], player_team[p2]) in gw_opp_teams[w]\n                and minutes_player_week[p1, w] > 0\n                and minutes_player_week[p2, w] > 0\n                and (player_type[p1], player_type[p2]) in opposing_positions\n            ]\n        cp_pen_var = model.add_variables(cp_list, name=\"cp_v\", vartype=so.binary)\n        opposing_play_penalty = options.get(\"opposing_play_penalty\", 0.5)\n        cp_penalty = {w: opposing_play_penalty * so.expr_sum(cp_pen_var[p1, p2, w1] for (p1, p2, w1) in cp_list if w1 == w) for w in gws}\n        model.add_constraints((lineup[p1, w] + lineup[p2, w] <= 1 + cp_pen_var[p1, p2, w] for (p1, p2, w) in cp_list), name=\"cp1\")\n        model.add_constraints((cp_pen_var[p1, p2, w] <= lineup[p1, w] for (p1, p2, w) in cp_list), name=\"cp2\")\n        model.add_constraints((cp_pen_var[p1, p2, w] <= lineup[p2, w] for (p1, p2, w) in cp_list), name=\"cp3\")\n\n    if options.get(\"double_defense_pick\") is True:\n        print(\"OC - Double Defense Pick\")\n        team_players = {t: [p for p in players if player_team[p] == t] for t in teams}\n        gk_df_players = {t: [p for p in team_players[t] if player_type[p] in [1, 2]] for t in teams}\n        weekly_sum = {(t, w): so.expr_sum(lineup[p, w] for p in gk_df_players[t]) for t in teams for w in gws}\n        def_aux = model.add_variables(teams, gws, vartype=so.binary, name=\"daux\")\n        model.add_constraints((weekly_sum[t, w] <= 3 * def_aux[t, w] for t in teams for w in gws), name=\"dauxc1\")\n        model.add_constraints((weekly_sum[t, w] >= 2 - 3 * (1 - def_aux[t, w]) for t in teams for w in gws), name=\"dauxc2\")\n\n    if options.get(\"transfer_itb_buffer\"):\n        buffer_amount = float(options[\"transfer_itb_buffer\"])\n        gw_with_tr = model.add_variables(gws, name=\"gw_with_tr\", vartype=so.binary)\n        model.add_constraints((SQUAD_SIZE * gw_with_tr[w] >= num_transfers[w] for w in gws), name=\"gw_with_tr_lb\")\n        model.add_constraints((gw_with_tr[w] <= num_transfers[w] for w in gws), name=\"gw_with_tr_ub\")\n        model.add_constraints((in_the_bank[w] >= buffer_amount * gw_with_tr[w] for w in gws), name=\"buffer_con\")\n\n    if options.get(\"pick_prices\", None) not in [None, {\"G\": \"\", \"D\": \"\", \"M\": \"\", \"F\": \"\"}]:\n        print(\"OC - Pick Prices\")\n        buffer = 0.2\n        price_choices = options[\"pick_prices\"]\n        for pos, val in price_choices.items():\n            if val == \"\":\n                continue\n            price_points = [float(i) for i in val.split(\",\")]\n            value_dict = {i: price_points.count(i) for i in set(price_points)}\n            con_iter = 0\n            for key, count in value_dict.items():\n                target_players = [\n                    p for p in players if merged_data.loc[p, \"Pos\"] == pos and buy_price[p] >= key - buffer and buy_price[p] <= key + buffer\n                ]\n                model.add_constraints((so.expr_sum(squad[p, w] for p in target_players) >= count for w in gws), name=f\"price_point_{pos}_{con_iter}\")\n                con_iter += 1\n\n    if options.get(\"no_gk_rotation_after\", None):\n        print(\"OC - No GK rotation\")\n        target_gw = int(options[\"no_gk_rotation_after\"])\n        players_gk = [p for p in players if player_type[p] == 1]\n        model.add_constraints(\n            (lineup[p, w] >= lineup[p, target_gw] - use_fh[w] for p in players_gk for w in gws if w > target_gw), name=\"fixed_lineup_gk\"\n        )\n\n    if len(options.get(\"no_chip_gws\", [])) > 0:\n        print(\"OC - No Chip GWs\")\n        no_chip_gws = options[\"no_chip_gws\"]\n        model.add_constraint(so.expr_sum(use_bb[w] + use_wc[w] + use_fh[w] for w in no_chip_gws) == 0, name=\"no_chip_gws\")\n\n    if options.get(\"only_booked_transfers\") is True:\n        print(\"OC - Only Booked Transfers\")\n        forced_in = []\n        forced_out = []\n        for bt in options.get(\"booked_transfers\", []):\n            if bt[\"gw\"] == next_gw:\n                if bt.get(\"transfer_in\") is not None:\n                    forced_in.append(bt[\"transfer_in\"])\n                if bt.get(\"transfer_out\") is not None:\n                    forced_out.append(bt[\"transfer_out\"])\n\n        in_players = {(p): 1 if p in forced_in else 0 for p in players}\n        out_players = {(p): 1 if p in forced_out else 0 for p in players}\n        model.add_constraints((transfer_in[p, next_gw] == in_players[p] for p in players), name=\"fix_tgw_tr_in\")\n        model.add_constraints((transfer_out[p, next_gw] == out_players[p] for p in players), name=\"fix_tgw_tr_out\")\n\n    # if options.get('have_2ft_in_gws', None) is not None:\n    #     for gw in options['have_2ft_in_gws']:\n    #         model.add_constraint(fts[gw] == 2, name=f'have_2ft_{gw}')\n\n    if options.get(\"force_ft_state_lb\", None):\n        print(\"OC - Force FT LB\")\n        for gw, ft_pos in options[\"force_ft_state_lb\"]:\n            model.add_constraint(fts[gw] >= ft_pos, name=f\"cft_lb_{gw}\")\n\n    if options.get(\"force_ft_state_ub\", None):\n        print(\"OC - Force FT UB\")\n        for gw, ft_pos in options[\"force_ft_state_ub\"]:\n            model.add_constraint(fts[gw] <= ft_pos, name=f\"cft_ub_{gw}\")\n\n    if options.get(\"no_trs_except_wc\", False) is True:\n        print(\"OC - No TRS except WC\")\n        model.add_constraints((num_transfers[w] <= SQUAD_SIZE * use_wc[w] for w in gws), name=\"wc_trs_only\")\n\n    # FT gain\n    ft_state_value = {}\n    for s in ft_states:\n        ft_state_value[s] = ft_state_value.get(s - 1, 0) + ft_value_list.get(str(s), ft_value)\n    # print(f\"Using FT state values of {ft_state_value}\")\n    print(f\"Using FT values of {ft_value_list}\")\n    gw_ft_value = {w: so.expr_sum(ft_state_value[s] * fts_state[w, s] for s in ft_states) for w in gws}\n    gw_ft_gain = {w: gw_ft_value[w] - gw_ft_value.get(w - 1, 0) for w in gws}\n\n    # Objectives\n    hit_cost = options.get(\"hit_cost\", 4)\n    vcap_weight = options.get(\"vcap_weight\", 0.1)\n    gw_xp = {\n        w: so.expr_sum(\n            points_player_week[p, w]\n            * (\n                lineup[p, w]\n                + captain[p, w]\n                + vcap_weight * vicecap[p, w]\n                + use_tc[p, w]\n                + so.expr_sum(bench_weights[o] * bench[p, w, o] for o in order)\n            )\n            for p in players\n        )\n        for w in gws\n    }\n\n    gw_total = {\n        w: gw_xp[w] - hit_cost * penalized_transfers[w] + gw_ft_gain[w] - ft_penalty[w] + itb_value * in_the_bank[w] - cp_penalty.get(w, 0)\n        for w in gws\n    }\n\n    if objective == \"regular\":\n        total_xp = so.expr_sum(gw_total[w] for w in gws)\n        model.set_objective(-total_xp, sense=\"N\", name=\"total_regular_xp\")\n    else:\n        decay_objective = so.expr_sum(gw_total[w] * pow(decay_base, w - next_gw) for w in gws)\n        model.set_objective(-decay_objective, sense=\"N\", name=\"total_decay_xp\")\n\n    report_decay_base = options.get(\"report_decay_base\", [])\n    decay_metrics = {i: so.expr_sum(gw_total[w] * pow(i, w - next_gw) for w in gws) for i in report_decay_base}\n\n    num_iterations = options.get(\"num_iterations\", 1)\n    iteration_criteria = options.get(\"iteration_criteria\", \"this_gw_transfer_in\")\n\n    # fix for multiple iterations when free-hitting next gameweek\n    if iteration_criteria in {\"this_gw_transfer_in\", \"this_gw_transfer_in_out\"} and next_gw in options.get(\"use_fh\", []):\n        iteration_criteria = \"this_gw_lineup\"\n    solutions = []\n\n    for iteration in range(num_iterations):\n        mps_file_name = f\"tmp/{problem_name}_{problem_id}_{iteration}.mps\"\n        sol_file_name = f\"tmp/{problem_name}_{problem_id}_{iteration}_sol.txt\"\n        opt_file_name = f\"tmp/{problem_name}_{problem_id}_{iteration}.opt\"\n\n        tmp_folder = Path() / \"tmp\"\n        tmp_folder.mkdir(exist_ok=True, parents=True)\n        model.export_mps(mps_file_name)\n        print(f\"Exported model with name: {problem_name}_{problem_id}_{iteration}\")\n\n        if options.get(\"export_debug\", False):\n            with open(\"debug.sas\", \"w\") as file:\n                file.write(model.to_optmodel())\n\n        # use_cmd = options.get(\"use_cmd\", False)\n        solver = options.get(\"solver\", \"highs\")\n\n        if solver.lower() == \"highs\":\n            # Use highspy Python interface instead of command line\n            secs = options.get(\"secs\", 20 * 60)\n            presolve = options.get(\"presolve\", \"on\")\n            gap = options.get(\"gap\", 0)\n            random_seed = options.get(\"random_seed\", 0)\n            verbose = options.get(\"verbose\", False)\n\n            solver_instance = highspy.Highs()\n            solver_instance.readModel(str(mps_file_name))\n            solver_instance.setOptionValue(\"parallel\", \"on\")\n            solver_instance.setOptionValue(\"random_seed\", random_seed)\n            solver_instance.setOptionValue(\"presolve\", presolve)\n            solver_instance.setOptionValue(\"time_limit\", secs)\n            solver_instance.setOptionValue(\"mip_rel_gap\", gap)\n            solver_instance.setOptionValue(\"log_to_console\", verbose)\n\n            solver_instance.run()\n            solution = solver_instance.getSolution()\n            values = list(solution.col_value)\n            for idx, v in enumerate(model.get_variables()):\n                v.set_value(values[idx])\n\n        elif solver == \"gurobi\":\n            use_cmd = options.get(\"use_cmd\", False)\n            gap = options.get(\"gap\", 0)\n            sol_file_name = sol_file_name.replace(\"_sol\", \"\").replace(\"txt\", \"sol\")\n            command = f\"gurobi_cl MIPGap={gap} ResultFile={sol_file_name} {mps_file_name}\"\n\n            if use_cmd:\n                os.system(command)\n            else:\n\n                def print_output(process):\n                    while True:\n                        output = process.stdout.readline()\n                        if \"Solving report\" in output:\n                            time.sleep(2)\n                            process.kill()\n                        elif output == \"\" and process.poll() is not None:\n                            break\n                        elif output:\n                            print(output.strip())\n\n                process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)\n                output_thread = threading.Thread(target=print_output, args=(process,))\n                output_thread.start()\n                output_thread.join()\n\n            # Parsing\n            with open(sol_file_name) as f:\n                for v in model.get_variables():\n                    v.set_value(0)\n                for line in f:\n                    if line[0] == \"#\":\n                        continue\n                    if line == \"\":\n                        break\n                    words = line.split()\n                    v = model.get_variable(words[0])\n                    try:\n                        if v.get_type() == so.INT:\n                            v.set_value(round(float(words[1])))\n                        elif v.get_type() == so.BIN:\n                            v.set_value(round(float(words[1])))\n                        elif v.get_type() == so.CONT:\n                            v.set_value(round(float(words[1]), 3))\n                    except Exception:\n                        print(\"Error\", words[0], line)\n\n        # DataFrame generation\n        picks = []\n        for w in gws:\n            for p in players:\n                if squad[p, w].get_value() + squad_fh[p, w].get_value() + transfer_out[p, w].get_value() > BINARY_THRESHOLD:\n                    lp = merged_data.loc[p]\n                    is_captain = 1 if captain[p, w].get_value() > BINARY_THRESHOLD else 0\n                    is_squad = (\n                        1\n                        if (use_fh[w].get_value() < BINARY_THRESHOLD and squad[p, w].get_value() > BINARY_THRESHOLD)\n                        or (use_fh[w].get_value() > BINARY_THRESHOLD and squad_fh[p, w].get_value() > BINARY_THRESHOLD)\n                        else 0\n                    )\n                    is_lineup = 1 if lineup[p, w].get_value() > BINARY_THRESHOLD else 0\n                    is_vice = 1 if vicecap[p, w].get_value() > BINARY_THRESHOLD else 0\n                    is_tc = 1 if use_tc[p, w].get_value() > BINARY_THRESHOLD else 0\n                    is_transfer_in = 1 if transfer_in[p, w].get_value() > BINARY_THRESHOLD else 0\n                    is_transfer_out = 1 if transfer_out[p, w].get_value() > BINARY_THRESHOLD else 0\n                    bench_value = -1\n                    for o in order:\n                        if bench[p, w, o].get_value() > BINARY_THRESHOLD:\n                            bench_value = o\n                    position = type_data.loc[lp[\"element_type\"], \"singular_name_short\"]\n                    player_buy_price = 0 if not is_transfer_in else buy_price[p]\n                    player_sell_price = (\n                        0\n                        if not is_transfer_out\n                        else (\n                            sell_price[p] if p in price_modified_players and transfer_out_first[p, w].get_value() > BINARY_THRESHOLD else buy_price[p]\n                        )\n                    )\n                    multiplier = 1 * (is_lineup == 1) + 1 * (is_captain == 1) + 1 * (is_tc == 1)\n                    xp_cont = points_player_week[p, w] * multiplier\n\n                    # chip\n                    if use_wc[w].get_value() > BINARY_THRESHOLD:\n                        chip_text = \"WC\"\n                    elif use_fh[w].get_value() > BINARY_THRESHOLD:\n                        chip_text = \"FH\"\n                    elif use_bb[w].get_value() > BINARY_THRESHOLD:\n                        chip_text = \"BB\"\n                    elif use_tc[p, w].get_value() > BINARY_THRESHOLD:\n                        chip_text = \"TC\"\n                    else:\n                        chip_text = \"\"\n\n                    picks.append(\n                        {\n                            \"id\": p,\n                            \"week\": w,\n                            \"name\": lp[\"web_name\"],\n                            \"pos\": position,\n                            \"type\": lp[\"element_type\"],\n                            \"team\": lp[\"name\"],\n                            \"buy_price\": player_buy_price,\n                            \"sell_price\": player_sell_price,\n                            \"xP\": round(points_player_week[p, w], 2),\n                            \"xMin\": minutes_player_week[p, w],\n                            \"squad\": is_squad,\n                            \"lineup\": is_lineup,\n                            \"bench\": bench_value,\n                            \"captain\": is_captain,\n                            \"vicecaptain\": is_vice,\n                            \"transfer_in\": is_transfer_in,\n                            \"transfer_out\": is_transfer_out,\n                            \"multiplier\": multiplier,\n                            \"xp_cont\": xp_cont,\n                            \"chip\": chip_text,\n                            \"iter\": iteration,\n                            \"ft\": fts[w].get_value(),\n                            \"transfer_count\": num_transfers[w].get_value(),\n                        }\n                    )\n\n        picks_df = pd.DataFrame(picks).sort_values(by=[\"week\", \"lineup\", \"type\", \"xP\"], ascending=[True, False, True, True])\n        total_xp = so.expr_sum((lineup[p, w] + captain[p, w]) * points_player_week[p, w] for p in players for w in gws).get_value()\n\n        picks_df.sort_values(by=[\"week\", \"squad\", \"lineup\", \"bench\", \"type\"], ascending=[True, False, False, True, True], inplace=True)\n\n        # Writing summary\n        summary_of_actions = \"\"\n        move_summary = {\"chip\": [], \"buy\": [], \"sell\": []}\n\n        # collect statistics\n        statistics = {}\n\n        for w in all_gw:\n            if w == all_gw[0]:\n                statistics[int(w)] = {\"itb\": in_the_bank[w].get_value(), \"ft\": fts[w].get_value()}\n                continue\n            summary_of_actions += f\"** GW {w}:\\n\"\n            chip_decision = (\n                (\"WC\" if use_wc[w].get_value() > BINARY_THRESHOLD else \"\")\n                + (\"FH\" if use_fh[w].get_value() > BINARY_THRESHOLD else \"\")\n                + (\"BB\" if use_bb[w].get_value() > BINARY_THRESHOLD else \"\")\n                + (\"TC\" if use_tc_gw[w].get_value() > BINARY_THRESHOLD else \"\")\n            )\n            if chip_decision != \"\":\n                summary_of_actions += \"CHIP \" + chip_decision + \"\\n\"\n                move_summary[\"chip\"].append(chip_decision + str(w))\n            summary_of_actions += (\n                f\"ITB={round(in_the_bank[w - 1].get_value(), 1)}->{round(in_the_bank[w].get_value(), 1)}, \"\n                f\"FT={round(fts[w].get_value())}, \"\n                f\"PT={round(penalized_transfers[w].get_value())}, \"\n                f\"NT={round(num_transfers[w].get_value())}\\n\"\n            )\n            for p in players:\n                if transfer_in[p, w].get_value() > BINARY_THRESHOLD:\n                    summary_of_actions += f\"Buy {p} - {merged_data['web_name'][p]}\\n\"\n                    if w == next_gw:\n                        move_summary[\"buy\"].append(merged_data[\"web_name\"][p])\n\n            for p in players:\n                if transfer_out[p, w].get_value() > BINARY_THRESHOLD:\n                    summary_of_actions += f\"Sell {p} - {merged_data['web_name'][p]}\\n\"\n                    if w == next_gw:\n                        move_summary[\"sell\"].append(merged_data[\"web_name\"][p])\n\n            picks_df[picks_df[\"week\"] == w]\n            lineup_players = picks_df[(picks_df[\"week\"] == w) & (picks_df[\"lineup\"] == 1)]\n            bench_players = picks_df[(picks_df[\"week\"] == w) & (picks_df[\"bench\"] >= 0)]\n\n            # captain_name = picks_df[(picks_df['week'] == w) & (picks_df['captain'] == 1)].iloc[0]['name']\n            # vicecap_name = picks_df[(picks_df['week'] == w) & (picks_df['vicecaptain'] == 1)].iloc[0]['name']\n\n            summary_of_actions += \"\\nLineup: \\n\"\n\n            def get_display(row):\n                return f\"{row['name']} ({row['xP']}{', C' if row['captain'] == 1 else ''}{', V' if row['vicecaptain'] == 1 else ''})\"\n\n            for typ in [1, 2, 3, 4]:\n                type_players = lineup_players[lineup_players[\"type\"] == typ]\n                entries = type_players.apply(get_display, axis=1)\n                summary_of_actions += \"\\t\" + \", \".join(entries.tolist()) + \"\\n\"\n            summary_of_actions += \"Bench: \\n\\t\" + \", \".join(bench_players.apply(get_display, axis=1)) + \"\\n\"\n            summary_of_actions += \"Lineup xPts: \" + str(round(lineup_players[\"xp_cont\"].sum(), 2)) + \"\\n\"\n            if w != max(gws):\n                summary_of_actions += \"\\n\\n\"\n\n            statistics[int(w)] = {\n                \"itb\": in_the_bank[w].get_value(),\n                \"ft\": fts[w].get_value(),\n                \"pt\": penalized_transfers[w].get_value(),\n                \"nt\": num_transfers[w].get_value(),\n                \"xP\": lineup_players[\"xp_cont\"].sum(),\n                \"obj\": round(gw_total[w].get_value(), 2),\n                \"chip\": chip_decision if chip_decision != \"\" else None,\n            }\n\n        if options.get(\"delete_tmp\", True):\n            time.sleep(0.1)\n            try:\n                try:\n                    os.unlink(mps_file_name)\n                except Exception:\n                    pass\n                try:\n                    os.unlink(sol_file_name)\n                except Exception:\n                    pass\n                try:\n                    os.unlink(opt_file_name)\n                except Exception:\n                    pass\n            except Exception:\n                print(\"Could not delete temporary files\")\n\n        def format_decisions(items):\n            return \", \".join(items) if items else \"-\"\n\n        buy_decisions = format_decisions(move_summary[\"buy\"])\n        sell_decisions = format_decisions(move_summary[\"sell\"])\n        chip_decisions = format_decisions(move_summary[\"chip\"])\n\n        if options.get(\"hide_transfers\"):\n            buy_decisions = sell_decisions = \"-\"\n\n        # Add current solution to a list, and add a new cut\n        solutions.append(\n            {\n                \"iter\": iteration,\n                \"model\": model,\n                \"picks\": picks_df,\n                \"total_xp\": total_xp,\n                \"summary\": summary_of_actions,\n                \"statistics\": statistics,\n                \"buy\": buy_decisions,\n                \"sell\": sell_decisions,\n                \"chip\": chip_decisions,\n                \"score\": -model.get_objective_value(),\n                \"decay_metrics\": {key: value.get_value() for key, value in decay_metrics.items()},\n            }\n        )\n\n        if num_iterations == 1:\n            return solutions\n\n        iter_diff = options.get(\"iteration_difference\", 1)\n\n        if iteration_criteria == \"this_gw_transfer_in\":\n            actions = so.expr_sum(\n                1 - transfer_in[p, next_gw] for p in players if transfer_in[p, next_gw].get_value() > BINARY_THRESHOLD\n            ) + so.expr_sum(transfer_in[p, next_gw] for p in players if transfer_in[p, next_gw].get_value() < BINARY_THRESHOLD)\n            model.add_constraint(actions >= 1, name=f\"cutoff_{iteration}\")\n\n        elif iteration_criteria == \"this_gw_transfer_out\":\n            actions = so.expr_sum(\n                1 - transfer_out[p, next_gw] for p in players if transfer_out[p, next_gw].get_value() > BINARY_THRESHOLD\n            ) + so.expr_sum(transfer_out[p, next_gw] for p in players if transfer_out[p, next_gw].get_value() < BINARY_THRESHOLD)\n            model.add_constraint(actions >= 1, name=f\"cutoff_{iteration}\")\n\n        elif iteration_criteria == \"this_gw_transfer_in_out\":\n            actions = (\n                so.expr_sum(1 - transfer_in[p, next_gw] for p in players if transfer_in[p, next_gw].get_value() > BINARY_THRESHOLD)\n                + so.expr_sum(transfer_in[p, next_gw] for p in players if transfer_in[p, next_gw].get_value() < BINARY_THRESHOLD)\n                + so.expr_sum(1 - transfer_out[p, next_gw] for p in players if transfer_out[p, next_gw].get_value() > BINARY_THRESHOLD)\n                + so.expr_sum(transfer_out[p, next_gw] for p in players if transfer_out[p, next_gw].get_value() < BINARY_THRESHOLD)\n            )\n            model.add_constraint(actions >= 1, name=f\"cutoff_{iteration}\")\n\n        elif iteration_criteria == \"chip_gws\":\n            actions = (\n                so.expr_sum(1 - use_wc[w] for w in gws if use_wc[w].get_value() > BINARY_THRESHOLD)\n                + so.expr_sum(use_wc[w] for w in gws if use_wc[w].get_value() < BINARY_THRESHOLD)\n                + so.expr_sum(1 - use_bb[w] for w in gws if use_bb[w].get_value() > BINARY_THRESHOLD)\n                + so.expr_sum(use_bb[w] for w in gws if use_bb[w].get_value() < BINARY_THRESHOLD)\n                + so.expr_sum(1 - use_fh[w] for w in gws if use_fh[w].get_value() > BINARY_THRESHOLD)\n                + so.expr_sum(use_fh[w] for w in gws if use_fh[w].get_value() < BINARY_THRESHOLD)\n            )\n            model.add_constraint(actions >= 1, name=f\"cutoff_{iteration}\")\n\n        elif iteration_criteria == \"target_gws_transfer_in\":\n            target_gws = options.get(\"iteration_target\", [next_gw])\n            transferred_players = [[p, w] for p in players for w in target_gws if transfer_in[p, w].get_value() > BINARY_THRESHOLD]\n            remaining_players = [[p, w] for p in players for w in target_gws if transfer_in[p, w].get_value() < BINARY_THRESHOLD]\n\n            actions = so.expr_sum(1 - transfer_in[p, w] for [p, w] in transferred_players) + so.expr_sum(\n                transfer_in[p, w] for [p, w] in remaining_players\n            )\n            model.add_constraint(actions >= 1, name=f\"cutoff_{iteration}\")\n\n        elif iteration_criteria == \"this_gw_lineup\":\n            selected_lineup = [p for p in players if lineup[p, next_gw].get_value() > BINARY_THRESHOLD]\n            model.add_constraint(\n                so.expr_sum(lineup[p, next_gw] for p in selected_lineup) <= len(selected_lineup) - iter_diff, name=f\"cutoff_{iteration}\"\n            )\n\n    return solutions\n"
  },
  {
    "path": "dev/visualization.py",
    "content": "import os\n\nimport matplotlib.path as mpath\nimport matplotlib.pyplot as plt\nimport pandas as pd\nfrom matplotlib import patches\n\nfrom paths import DATA_DIR\n\nHIT_COST = 4\n\n# Spacing and sizing\nBOX_HEIGHT = 0.9\nBOX_WIDTH = 9\nPLAYER_SPACING = 1.2\nPLAYER_NAME_FONT_SIZE = 11\nSTATS_FONT_SIZE = 9\nGAMEWEEK_SPACING = 14\nPOSITION_BORDER_WIDTH = 0.12\nCAPTAIN_BORDER_WIDTH = 0.2\nCHIP_BACKGROUND_ZORDERS = {\n    \"FH\": -1.0,\n    \"WC\": -5.0,\n    \"BB\": -5.0,\n    \"TC\": -5.0,\n}\n\n# color scheme\nCAPTAIN_COLOR = \"#ffd700\"\nVICE_CAPTAIN_COLOR = \"#c0c0c0\"\nBG_COLOR = \"#0f0f0f\"\nCELL_BG_COLOR = \"#1e1e1e\"\nBENCH_BG_COLOR = \"#2a2a2a\"\nTEXT_COLOR = \"#ffffff\"\nSTATS_COLOR = \"#b0b0b0\"\nCHIP_BACKGROUND_COLOR = \"#1a1a1a\"\n\n# Position constants\nPOSITIONS = [\"GKP\", \"DEF\", \"MID\", \"FWD\"]\nPOSITION_COLORS = {\"GKP\": \"#8b5cf6\", \"DEF\": \"#3b82f6\", \"MID\": \"#f59e0b\", \"FWD\": \"#ef4444\"}\nBASE_Y = 16\n\n\ndef calculate_bezier(x_start, x_end, y_start, y_end):\n    \"\"\"\n    Calculates a bezier curve using the 4 given points.\n    These are used to draw the lines signifying transfers between gameweeks.\n    \"\"\"\n    x_control1 = x_start + (x_end - x_start) * 0.3\n    x_control2 = x_start + (x_end - x_start) * 0.7\n    y_control1 = y_start + (y_end - y_start) * 0.02\n    y_control2 = y_start + (y_end - y_start) * 0.98\n\n    path_data = [\n        ((x_start, y_start), mpath.Path.MOVETO),\n        ((x_control1, y_control1), mpath.Path.CURVE4),\n        ((x_control2, y_control2), mpath.Path.CURVE4),\n        ((x_end, y_end), mpath.Path.CURVE4),\n    ]\n\n    return patches.PathPatch(\n        mpath.Path(*zip(*path_data, strict=True)),\n        facecolor=\"none\",\n        edgecolor=\"#60a5fa\",\n        alpha=0.8,\n        linewidth=1.5,\n        zorder=-3.0,\n    )\n\n\ndef calculate_player_cells(gw_idx, player_idx, player):\n    y_pos = BASE_Y - player_idx * PLAYER_SPACING\n    data = []\n\n    # base cell\n    data.append(\n        patches.Rectangle(\n            (gw_idx * GAMEWEEK_SPACING - BOX_WIDTH / 2, y_pos - BOX_HEIGHT / 2),\n            BOX_WIDTH,\n            BOX_HEIGHT,\n            facecolor=CELL_BG_COLOR if player[\"lineup\"] else BENCH_BG_COLOR,\n            edgecolor=\"none\",\n        )\n    )\n\n    # position border\n    data.append(\n        patches.Rectangle(\n            (gw_idx * GAMEWEEK_SPACING - BOX_WIDTH / 2, y_pos - BOX_HEIGHT / 2 - POSITION_BORDER_WIDTH),\n            BOX_WIDTH,\n            POSITION_BORDER_WIDTH,\n            facecolor=POSITION_COLORS[player[\"pos\"]],\n            edgecolor=\"none\",\n        )\n    )\n\n    # captain border\n    if player[\"captain\"] == 1 and gw_idx > 0:\n        data.append(\n            patches.Rectangle(\n                (gw_idx * GAMEWEEK_SPACING - BOX_WIDTH / 2, y_pos - BOX_HEIGHT / 2),\n                CAPTAIN_BORDER_WIDTH,\n                BOX_HEIGHT,\n                facecolor=CAPTAIN_COLOR,\n                edgecolor=\"none\",\n            )\n        )\n\n    # vice captain border\n    elif player[\"vicecaptain\"] == 1 and gw_idx > 0:\n        data.append(\n            patches.Rectangle(\n                (gw_idx * GAMEWEEK_SPACING - BOX_WIDTH / 2, y_pos - BOX_HEIGHT / 2),\n                CAPTAIN_BORDER_WIDTH,\n                BOX_HEIGHT,\n                facecolor=VICE_CAPTAIN_COLOR,\n                edgecolor=\"none\",\n            )\n        )\n\n    return data\n\n\ndef _setup_figure_and_data(picks, current_squad):\n    \"\"\"Setup the matplotlib figure and prepare data for visualization.\"\"\"\n    df = pd.DataFrame(picks)\n    df_squad = df[df[\"squad\"] == 1]\n    df_base = df[df[\"week\"] == min(df[\"week\"])]\n    gameweeks = sorted(df_squad[\"week\"].unique())\n\n    # Handle preseason scenario (earliest gameweek is 1)\n    if min(gameweeks) == 1:\n        base_week = None  # No base team in preseason\n    else:\n        base_week = min(gameweeks) - 1\n\n    fh_week = df.loc[df[\"chip\"] == \"FH\"].iloc[0][\"week\"] if len(df.loc[df[\"chip\"] == \"FH\"]) > 0 else None\n\n    fig, ax = plt.subplots(figsize=(26, 14))\n    ax.set_facecolor(BG_COLOR)\n    fig.patch.set_facecolor(BG_COLOR)\n\n    return fig, ax, df, df_squad, df_base, gameweeks, base_week, fh_week\n\n\ndef _get_week_players(week, base_week, df_base, df_squad, current_squad):\n    \"\"\"Get players for a specific gameweek.\"\"\"\n    if base_week is not None and week == base_week:\n        gw_players = df_base[df_base[\"id\"].isin(current_squad)]\n        gw_players.loc[:, \"lineup\"] = 1\n    else:\n        gw_players = df_squad[df_squad[\"week\"] == week]\n    return gw_players\n\n\ndef _add_week_header(ax, gw_idx, week, base_week, gw_players):\n    \"\"\"Add gameweek header and chip information.\"\"\"\n    if base_week is not None and week == base_week:\n        ax.text(gw_idx * GAMEWEEK_SPACING, BASE_Y + 1.2, \"Base\", color=TEXT_COLOR, fontsize=13, ha=\"center\", weight=\"bold\")\n    else:\n        ax.text(gw_idx * GAMEWEEK_SPACING, BASE_Y + 1.2, f\"GW{week}\", color=TEXT_COLOR, fontsize=13, ha=\"center\", weight=\"bold\")\n        if \"chip\" in gw_players.columns and not gw_players[\"chip\"].isna().all():\n            try:\n                chip = gw_players.loc[gw_players[\"chip\"] != \"\"][\"chip\"].iloc[0]\n            except Exception:\n                chip = gw_players[\"chip\"].iloc[0]\n            if pd.notna(chip):\n                ax.text(gw_idx * GAMEWEEK_SPACING, BASE_Y + 0.8, chip, color=\"#fbbf24\", fontsize=11, ha=\"center\", weight=\"bold\")\n\n\ndef _add_player_cells(ax, gw_idx, gw_players, week, player_indexes):\n    \"\"\"Add player cells for starting XI and bench.\"\"\"\n    starting_xi = gw_players[gw_players[\"lineup\"] == 1].sort_values([\"type\", \"xP\"], ascending=[True, False]).reset_index()\n    bench = gw_players[gw_players[\"lineup\"] == 0].sort_values([\"type\", \"xP\"], ascending=[True, False]).reset_index()\n    bench.index = bench.index + 11\n\n    player_indexes[week] = {}\n\n    # Starting XI\n    for player_idx, player in starting_xi.iterrows():\n        y_pos = BASE_Y - player_idx * PLAYER_SPACING\n        player_indexes[week][player[\"id\"]] = (y_pos, player[\"pos\"])\n\n        cells = calculate_player_cells(gw_idx, player_idx, player)\n        for cell in cells:\n            ax.add_patch(cell)\n        text_pos = (gw_idx * GAMEWEEK_SPACING, y_pos + 0.2)\n        ax.text(*text_pos, player[\"name\"], color=TEXT_COLOR, ha=\"center\", va=\"center\", fontsize=PLAYER_NAME_FONT_SIZE, weight=\"medium\")\n\n        # Check if this is not the base week by looking at the data structure\n        if \"xP\" in player and \"xMin\" in player:\n            stats_text = f\"{player['xP']:.1f} xPts • {int(player['xMin'])} xMin\"\n            ax.text(gw_idx * GAMEWEEK_SPACING, y_pos - 0.25, stats_text, color=STATS_COLOR, ha=\"center\", va=\"center\", fontsize=STATS_FONT_SIZE)\n\n    # Bench\n    for player_idx, player in bench.iterrows():\n        y_pos = BASE_Y - player_idx * PLAYER_SPACING\n        player_indexes[week][player[\"id\"]] = (BASE_Y - player_idx * PLAYER_SPACING, player[\"pos\"])\n        cells = calculate_player_cells(gw_idx, player_idx, player)\n        for cell in cells:\n            ax.add_patch(cell)\n        text_pos = (gw_idx * GAMEWEEK_SPACING, y_pos + 0.2)\n        ax.text(*text_pos, player[\"name\"], color=TEXT_COLOR, ha=\"center\", va=\"center\", fontsize=PLAYER_NAME_FONT_SIZE, weight=\"medium\")\n\n        stats_text = f\"{player['xP']:.1f} xPts • {int(player['xMin'])} xMin\"\n        ax.text(gw_idx * GAMEWEEK_SPACING, y_pos - 0.25, stats_text, color=STATS_COLOR, ha=\"center\", va=\"center\", fontsize=STATS_FONT_SIZE)\n\n\ndef _add_transfers(ax, gw_idx, week, picks, player_indexes):\n    \"\"\"Add transfer lines between gameweeks.\"\"\"\n    # Calculate fh_week from picks data\n    fh_week = picks.loc[picks[\"chip\"] == \"FH\"].iloc[0][\"week\"] if len(picks.loc[picks[\"chip\"] == \"FH\"]) > 0 else None\n\n    # Get previous week from player_indexes keys\n    prev_weeks = [w for w in player_indexes.keys() if w < week]\n    prev_week_int = max(prev_weeks) if prev_weeks else week - 1\n\n    transfers_in = picks.loc[(picks[\"week\"] == week) & (picks[\"transfer_in\"] == 1)]\n    transfers_out = picks.loc[(picks[\"week\"] == week) & (picks[\"transfer_out\"] == 1)]\n\n    for pos in POSITIONS:\n        players_out = transfers_out.loc[transfers_out[\"pos\"] == pos].to_dict(orient=\"records\")\n        players_in = transfers_in.loc[transfers_in[\"pos\"] == pos].to_dict(orient=\"records\")\n\n        if week == 1:\n            # don't draw any lines\n            continue\n\n        for player_out, player_in in zip(players_out, players_in, strict=True):\n            skip_fh = int(prev_week_int == fh_week) if fh_week else 0\n            x_start = (gw_idx - 1 - skip_fh) * GAMEWEEK_SPACING + BOX_WIDTH / 2\n            x_end = gw_idx * GAMEWEEK_SPACING - BOX_WIDTH / 2\n            y_start = player_indexes[prev_week_int - skip_fh][player_out[\"id\"]][0]\n            y_end = player_indexes[week][player_in[\"id\"]][0]\n            ax.add_patch(calculate_bezier(x_start, x_end, y_start, y_end))\n\n\ndef _add_gameweek_statistics(ax, gw_idx, week, statistics, player_idx):\n    \"\"\"Add gameweek statistics below the squad.\"\"\"\n    # Determine base week from statistics keys\n    base_week = min(statistics.keys()) if statistics else week\n\n    if week == base_week:\n        return\n\n    stats_y = BASE_Y - (player_idx + 0.5) * PLAYER_SPACING\n    ax.text(\n        gw_idx * GAMEWEEK_SPACING,\n        stats_y - 0.5,\n        f\"{statistics[int(week)]['xP']:.2f} xPts\",\n        color=TEXT_COLOR,\n        fontsize=11,\n        ha=\"center\",\n        weight=\"medium\",\n    )\n\n    if week > 1:\n        itb_text = f\"{statistics[week - 1]['itb']:.1f} → {statistics[week]['itb']:.1f}\"\n    else:\n        itb_text = f\"{statistics[week]['itb']:.1f}\"\n    ax.text(\n        gw_idx * GAMEWEEK_SPACING,\n        stats_y - 0.9,\n        f\"ITB: {itb_text}\",\n        color=STATS_COLOR,\n        fontsize=9,\n        ha=\"center\",\n    )\n\n    if week > 1 and statistics[week][\"chip\"] not in [\"FH\", \"WC\"]:\n        fts_available = round(statistics[week][\"ft\"])\n        transfer_str = f\"FTs: {round(statistics[week]['nt'])}/{fts_available}\"\n        if statistics[week][\"pt\"] > 0:\n            transfer_str += f\" (-{statistics[week]['pt'] * HIT_COST})\"\n        ax.text(gw_idx * GAMEWEEK_SPACING, stats_y - 1.3, transfer_str, color=STATS_COLOR, fontsize=9, ha=\"center\")\n\n\ndef _add_chip_backgrounds(ax, df, base_week, bottom_limit, top_limit):\n    \"\"\"Add background rectangles for chip gameweeks.\"\"\"\n    chip_weeks = dict(df.loc[df[\"chip\"] != \"\"][[\"week\", \"chip\"]].drop_duplicates().values)\n\n    for gw, chip in chip_weeks.items():\n        # Handle preseason scenario (no base week)\n        if base_week is not None:\n            x_center = (gw - base_week) * GAMEWEEK_SPACING\n        else:\n            x_center = (gw - 1) * GAMEWEEK_SPACING  # Use GW1 as reference in preseason\n\n        rect = patches.FancyBboxPatch(\n            (x_center - GAMEWEEK_SPACING / 2, bottom_limit),\n            GAMEWEEK_SPACING,\n            top_limit - bottom_limit,\n            edgecolor=\"none\",\n            facecolor=CHIP_BACKGROUND_COLOR,\n            zorder=CHIP_BACKGROUND_ZORDERS[chip],\n            boxstyle=patches.BoxStyle(\"Round\", pad=-0.3, rounding_size=2),\n            alpha=0.85,\n        )\n        ax.add_patch(rect)\n\n\ndef create_squad_timeline(current_squad, statistics, picks, filename):\n    \"\"\"Create a timeline visualization of squad changes across gameweeks.\"\"\"\n    fig, ax, df, df_squad, df_base, gameweeks, base_week, fh_week = _setup_figure_and_data(picks, current_squad)\n\n    player_indexes = {}\n    # Handle preseason scenario (no base week)\n    if base_week is not None:\n        display_weeks = [base_week, *gameweeks]\n    else:\n        display_weeks = gameweeks\n\n    for gw_idx, week in enumerate(display_weeks):\n        gw_players = _get_week_players(week, base_week, df_base, df_squad, current_squad)\n        _add_week_header(ax, gw_idx, week, base_week, gw_players)\n        _add_player_cells(ax, gw_idx, gw_players, week, player_indexes)\n        _add_transfers(ax, gw_idx, week, picks, player_indexes)\n        _add_gameweek_statistics(ax, gw_idx, week, statistics, len(gw_players) - 1)\n\n    # Set plot limits and styling\n    total_width = (len(display_weeks) - 1) * GAMEWEEK_SPACING + BOX_WIDTH\n    ax.set_xlim(-6, total_width + 2)\n    bottom_limit = BASE_Y - (len(gw_players) + 1.5) * PLAYER_SPACING\n    top_limit = BASE_Y + 2.8\n    ax.set_ylim(bottom_limit, top_limit)\n    ax.axis(\"off\")\n\n    plt.title(filename, color=TEXT_COLOR, fontsize=14, weight=\"bold\", pad=20)\n    _add_chip_backgrounds(ax, df, base_week, bottom_limit, top_limit)\n\n    # Ensure the images directory exists\n    os.makedirs(DATA_DIR / \"images\", exist_ok=True)\n    plt.savefig(DATA_DIR / \"images\" / f\"{filename}.png\", bbox_inches=\"tight\", facecolor=BG_COLOR)\n    plt.close()\n"
  },
  {
    "path": "paths.py",
    "content": "from pathlib import Path\n\nPROJECT_ROOT = Path(__file__).parent\n\nDATA_DIR = PROJECT_ROOT / \"data\"\nRUN_DIR = PROJECT_ROOT / \"run\"\nDEV_DIR = PROJECT_ROOT / \"dev\"\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"fpl-optimization-tools\"\nversion = \"0.1.0\"\ndescription = \"Fantasy Premier League optimization tools\"\nauthors = [\n  {name = \"Sertalp Bilal\"}\n]\nmaintainers = [\n  {name = \"Chris Musson\", email = \"chris.musson@hotmail.com\"}\n]\nreadme = \"README.md\"\nrequires-python = \">=3.12,<3.14\"\ndependencies = [\n    \"pandas\",\n    \"numpy\",\n    \"sasoptpy>=1.0.5a0\",\n    \"fuzzywuzzy\",\n    \"python-Levenshtein\",\n    \"matplotlib\",\n    \"highspy>=1.11.0\",\n    \"tabulate>=0.9.0\",\n]\n\n[project.optional-dependencies]\ndev = [\n    \"pytest\",\n    \"ruff\",\n]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src\", \"run\", \"tests\", \"dev\"]\n\n[tool.ruff]\nline-length = 150\ntarget-version = \"py313\"\n\n[tool.ruff.lint]\nselect = [\"E\", \"F\", \"I\", \"N\", \"W\", \"B\", \"A\", \"C4\", \"UP\", \"PL\", \"RUF\"]\nfixable = [\"E501\"]\n\n[tool.ruff.lint.per-file-ignores]\n\"__init__.py\" = [\"F401\"]\n\"tests/**/*\" = [\"PLR2004\"]\n\"run/solve.py\" = [\"PLR0915\", \"PLR0912\"]\n\"dev/solver.py\" = [\"PLR0915\", \"PLR0912\"]\n\n[tool.ruff.lint.isort]\nknown-first-party = [\"src\", \"run\", \"tests\"]\n"
  },
  {
    "path": "run/binary_file_generator.py",
    "content": "import pandas as pd\n\nfrom paths import DATA_DIR\n\n\ndef generate_binary_files(file_path, fixtures_json):\n    # Iterate through each binary file entry in config\n    for file_name, fixtures in fixtures_json.items():\n        # Load original fixture CSV file\n        df = pd.read_csv(file_path)\n\n        for team, binary_fix in fixtures.items():\n            # Apply changes only to rows where the Team column matches the specified team\n            team_mask = df[\"Team\"] == team\n\n            for orig_gw, new_gw in binary_fix.items():\n                new_gw_pts_col = f\"{new_gw}_Pts\"\n                new_gw_xmins_col = f\"{new_gw}_xMins\"\n                orig_gw_pts_col = f\"{orig_gw}_Pts\"\n                orig_gw_xmins_col = f\"{orig_gw}_xMins\"\n\n                if not new_gw:\n                    df.loc[team_mask, orig_gw_pts_col] = 0\n                    df.loc[team_mask, orig_gw_xmins_col] = 0\n\n                # Ensure relevant columns exist in the dataframe\n                if all(col in df.columns for col in [new_gw_pts_col, new_gw_xmins_col, orig_gw_pts_col, orig_gw_xmins_col]):\n                    # Convert columns to numeric values for the rows we are updating\n                    df.loc[team_mask, new_gw_pts_col] = pd.to_numeric(df.loc[team_mask, new_gw_pts_col], errors=\"coerce\")\n                    df.loc[team_mask, new_gw_xmins_col] = pd.to_numeric(df.loc[team_mask, new_gw_xmins_col], errors=\"coerce\")\n                    df.loc[team_mask, orig_gw_pts_col] = pd.to_numeric(df.loc[team_mask, orig_gw_pts_col], errors=\"coerce\")\n                    df.loc[team_mask, orig_gw_xmins_col] = pd.to_numeric(df.loc[team_mask, orig_gw_xmins_col], errors=\"coerce\")\n\n                    # Update target GW xPts by adding xPts from original GW\n                    df.loc[team_mask, new_gw_pts_col] += df.loc[team_mask, orig_gw_pts_col]\n\n                    # Use target GW xMins\n                    df.loc[team_mask, new_gw_xmins_col] = df.loc[team_mask, new_gw_xmins_col]\n\n                    # Zero out key_value_Pts and key_value_xMins\n                    df.loc[team_mask, [orig_gw_pts_col, orig_gw_xmins_col]] = 0\n\n        # Save the updated CSV file\n        df.to_csv(DATA_DIR / file_name, index=False)\n        print(f\"Generated: {file_name}\")\n"
  },
  {
    "path": "run/run_parallel.py",
    "content": "import os\nfrom concurrent.futures import ProcessPoolExecutor\n\nimport pandas as pd\nfrom solve import solve_regular\n\nfrom utils import get_dict_combinations\n\n\ndef run_parallel_solves(chip_combinations, max_workers=None):\n    if not max_workers:\n        max_workers = os.cpu_count() - 2\n\n    # these are added just to reduce the output, you can remove them or put any settings you want here\n    options = {\n        \"verbose\": False,\n        \"print_result_table\": False,\n        \"print_decay_metrics\": False,\n        \"print_transfer_chip_summary\": False,\n        \"print_squads\": False,\n    }\n\n    args = []\n    for combination in chip_combinations:\n        args.append({**options, **combination})\n\n    # Use ProcessPoolExecutor to run commands in parallel\n    with ProcessPoolExecutor(max_workers=max_workers) as executor:\n        results = list(executor.map(solve_regular, args))\n\n    df = pd.concat(results).sort_values(by=\"score\", ascending=False).reset_index(drop=True)\n    df = df.drop(\"iter\", axis=1)\n    print(df)\n\n    # you can save the results to a csv file if you want to, by uncommenting the line below\n    df.to_csv(\"chip_solve.csv\", encoding=\"utf-8\", index=False)\n\n\nif __name__ == \"__main__\":\n    # edit the gameweeks you want to have chips available in here.\n    # in this example it means it will run solves for 11 chips combinations:\n    # no chips, bb1, bb2, fh2, fh3, fh4, bb1fh2, bb1fh3, bb1fh4, bb2fh3, bb2fh4\n    # note that this is the 3 bb options multiplied by the 4 fh options, minus the invalid combination bb2fh2\n    chip_gameweeks = {\n        \"use_bb\": [None, 1, 2],\n        \"use_wc\": [],\n        \"use_fh\": [None, 2, 3, 4],\n        \"use_tc\": [],\n    }\n\n    combinations = get_dict_combinations(chip_gameweeks)\n    run_parallel_solves(combinations)\n"
  },
  {
    "path": "run/sensitivity.py",
    "content": "import argparse\nfrom collections import Counter\nfrom pathlib import Path\n\nimport pandas as pd\n\nfrom paths import DATA_DIR\n\nITER_SCORING = {0: 10, 1: 9, 2: 8}\n\n\ndef get_user_inputs(options=None):\n    \"\"\"Get user inputs for sensitivity analysis.\"\"\"\n    if options is None:\n        options = {}\n\n    gw = options.get(\"gw\")\n    situation = options.get(\"situation\")\n\n    if gw is not None and situation is not None:\n        all_gws = \"n\"\n    else:\n        all_gws = options.get(\"all_gws\")\n        if all_gws is None:\n            all_gws = input(\"Do you want to display a summary of buys and sells for all GWs? (y/n) \").strip().lower()\n\n    print()\n    return all_gws, gw, situation\n\n\ndef process_all_gameweeks():\n    \"\"\"Process and display results for all gameweeks.\"\"\"\n    directory = DATA_DIR / \"results\"\n\n    buys = []\n    sells = []\n    move = []\n    no_plans = 0\n    gameweeks = set()\n\n    # Read CSV files and process plans\n    for filename in Path(directory).glob(\"*.csv\"):\n        plan = pd.read_csv(filename)\n        plan = plan.loc[(plan[\"squad\"] == 1) | (plan[\"transfer_out\"] == 1)]\n        plan = plan.sort_values(by=[\"week\", \"iter\", \"pos\", \"id\"])\n        iteration = plan.iloc[0][\"iter\"] if not plan.empty else 0\n        gameweeks.update(plan[\"week\"].unique())\n\n        for week in gameweeks:\n            if plan[(plan[\"week\"] == week) & (plan[\"transfer_in\"] == 1)][\"name\"].to_list() == []:\n                buys.append({\"move\": \"No transfer\", \"iter\": iteration, \"week\": week})\n                sells.append({\"move\": \"No transfer\", \"iter\": iteration, \"week\": week})\n                move.append({\"move\": \"No transfer\", \"iter\": iteration, \"week\": week})\n            else:\n                buy_list = plan[(plan[\"week\"] == week) & (plan[\"transfer_in\"] == 1)][\"name\"].to_list()\n                for buy in buy_list:\n                    buys.append({\"move\": buy, \"iter\": iteration, \"week\": week})\n\n                sell_list = plan[(plan[\"week\"] == week) & (plan[\"transfer_out\"] == 1)][\"name\"].to_list()\n                for sell in sell_list:\n                    sells.append({\"move\": sell, \"iter\": iteration, \"week\": week})\n                    move.append(\n                        {\n                            \"move\": sell + \" -> \" + \", \".join(buy_list),\n                            \"iter\": iteration,\n                            \"week\": week,\n                        }\n                    )\n\n        no_plans += 1\n\n    return buys, sells, move, no_plans\n\n\ndef print_pivot_tables_all_gws(buy_df, sell_df, no_plans):\n    \"\"\"Print pivot tables for all gameweeks analysis.\"\"\"\n    show_top_n = input(\"Show top N results (y/n)? \").strip().lower()\n\n    if show_top_n == \"y\":\n        top_n = int(input(\"What do you want to use as N? \"))\n\n    def print_pivots(df, title):\n        print(f\"{title}:\")\n\n        # Create the pivot table with counts\n        df_counts = df.pivot_table(index=\"move\", columns=\"week\", aggfunc=\"size\", fill_value=0)\n\n        # Calculate percentages\n        df_percentages = df_counts.divide(no_plans).multiply(100)\n\n        # Map percentages to display with 0 decimals, hide 0%\n        df_percentages = df_percentages.map(lambda x: f\"{x:.0f}%\" if x != 0 else \"\")\n\n        # Store numeric version for sorting and total calculations\n        df_percentages_numeric = df_counts.divide(no_plans).multiply(100)\n\n        # Add 'Total' column summing over all weeks\n        df_percentages[\"Total\"] = df_percentages_numeric.sum(axis=1)\n        df_percentages[\"Total\"] = pd.to_numeric(df_percentages[\"Total\"])\n\n        # Sort by 'Total' column\n        df_percentages.sort_values(by=\"Total\", ascending=False, inplace=True)\n\n        # Apply the top N filter if requested\n        if show_top_n == \"y\":\n            df_percentages = df_percentages.head(top_n)\n\n        # Format 'Total' column as percentages\n        df_percentages[\"Total\"] = df_percentages[\"Total\"].map(lambda x: f\"{x:.0f}%\")\n\n        # Print the result\n        print(df_percentages)\n        print()\n\n    # Display the results\n    print()\n    print(f\"Number of plans: {no_plans}\")\n    print()\n\n    # Print Buy and Sell pivots\n    print_pivots(buy_df, \"Buy\")\n    print_pivots(sell_df, \"Sell\")\n\n\ndef process_single_gameweek(gw, situation):\n    \"\"\"Process and display results for a single gameweek.\"\"\"\n    directory = DATA_DIR / \"results\"\n\n    if situation == \"n\":\n        return process_regular_transfers(gw, directory)\n    elif situation == \"y\":\n        return process_wildcard_transfers(gw, directory)\n    else:\n        print(\"Invalid input, please enter 'y' for a wildcard or 'n' for a regular transfer plan.\")\n        return None\n\n\ndef process_regular_transfers(gw, directory):\n    \"\"\"Process regular transfer analysis for a specific gameweek.\"\"\"\n    print(f\"Processing for GW {gw} without wildcard.\")\n\n    buys = []\n    sells = []\n    move = []\n    no_plans = 0\n\n    for filename in Path(directory).glob(\"*.csv\"):\n        plan = pd.read_csv(filename)\n        plan = plan.loc[(plan[\"squad\"] == 1) | (plan[\"transfer_out\"] == 1)]\n        plan = plan.sort_values(by=[\"week\", \"iter\", \"pos\", \"id\"])\n        try:\n            iteration = plan.iloc[0][\"iter\"]\n        except Exception:\n            iteration = 0\n\n        if plan[(plan[\"week\"] == gw) & (plan[\"transfer_in\"] == 1)][\"name\"].to_list() == []:\n            buys.append({\"move\": \"No transfer\", \"iter\": iteration})\n            sells.append({\"move\": \"No transfer\", \"iter\": iteration})\n            move.append({\"move\": \"No transfer\", \"iter\": iteration})\n        else:\n            buy_list = plan[(plan[\"week\"] == gw) & (plan[\"transfer_in\"] == 1)][\"name\"].to_list()\n            buy = \", \".join(buy_list)\n            buys.append({\"move\": buy, \"iter\": iteration})\n            sell_list = plan[(plan[\"week\"] == gw) & (plan[\"transfer_out\"] == 1)][\"name\"].to_list()\n            sell = \", \".join(sell_list)\n            sells.append({\"move\": sell, \"iter\": iteration})\n            move.append({\"move\": sell + \" -> \" + buy, \"iter\": iteration})\n        no_plans += 1\n\n    print()\n    print(f\"Number of plans: {no_plans}\")\n    print()\n\n    return create_regular_transfer_pivots(buys, sells, move, no_plans)\n\n\ndef create_regular_transfer_pivots(buys, sells, move, no_plans):\n    \"\"\"Create and display pivot tables for regular transfers.\"\"\"\n    buy_df = pd.DataFrame(buys)\n    buy_pivot = buy_df.pivot_table(index=\"move\", columns=\"iter\", aggfunc=\"size\", fill_value=0)\n    iters = sorted(buy_df[\"iter\"].unique())\n    buy_pivot[\"PSB\"] = buy_pivot.loc[:, iters].sum(axis=1) / buy_pivot.sum().sum()\n    buy_pivot[\"PSB\"] = buy_pivot[\"PSB\"].apply(lambda x: f\"{x:.0%}\")\n    buy_pivot[\"Score\"] = buy_pivot.apply(lambda r: sum(r[i] * ITER_SCORING.get(i, 0) for i in iters), axis=1)\n    buy_pivot.sort_values(by=\"Score\", ascending=False, inplace=True)\n\n    sell_df = pd.DataFrame(sells)\n    sell_pivot = sell_df.pivot_table(index=\"move\", columns=\"iter\", aggfunc=\"size\", fill_value=0)\n    iters = sorted(sell_df[\"iter\"].unique())\n    sell_pivot[\"PSB\"] = sell_pivot.loc[:, iters].sum(axis=1) / sell_pivot.sum().sum()\n    sell_pivot[\"PSB\"] = sell_pivot[\"PSB\"].apply(lambda x: f\"{x:.0%}\")\n    sell_pivot[\"Score\"] = sell_pivot.apply(lambda r: sum(r[i] * ITER_SCORING.get(i, 0) for i in iters), axis=1)\n    sell_pivot.sort_values(by=\"Score\", ascending=False, inplace=True)\n\n    move_df = pd.DataFrame(move)\n    move_pivot = move_df.pivot_table(index=\"move\", columns=\"iter\", aggfunc=\"size\", fill_value=0)\n    iters = sorted(move_df[\"iter\"].unique())\n    move_pivot[\"PSB\"] = move_pivot.loc[:, iters].sum(axis=1) / move_pivot.sum().sum()\n    move_pivot[\"PSB\"] = move_pivot[\"PSB\"].apply(lambda x: f\"{x:.0%}\")\n    move_pivot[\"Score\"] = move_pivot.apply(lambda r: sum(r[i] * ITER_SCORING.get(i, 0) for i in iters), axis=1)\n    move_pivot.sort_values(by=\"Score\", ascending=False, inplace=True)\n\n    # Set the display options for wider column width\n    pd.set_option(\"display.max_colwidth\", None)\n\n    # Ask once for filtering choice at the beginning\n    show_top_n = input(\"Show top N results (y/n)? \").strip().lower()\n\n    if show_top_n == \"y\":\n        top_n = int(input(\"What do you want to use as N? \"))\n        print()\n\n    # Apply the top N filter if requested\n    if show_top_n == \"y\":\n        buy_pivot = buy_pivot.head(top_n)\n        sell_pivot = sell_pivot.head(top_n)\n        move_pivot = move_pivot.head(top_n)\n\n    print(\"Buy:\")\n    print(buy_pivot)\n    print()\n    print(\"Sell:\")\n    print(sell_pivot)\n    print()\n    print(\"Move:\")\n    print(move_pivot)\n    print()\n\n    return {\"buy_pivot\": buy_pivot, \"sell_pivot\": sell_pivot, \"move_pivot\": move_pivot}\n\n\ndef process_wildcard_transfers(gw, directory):\n    \"\"\"Process wildcard transfer analysis for a specific gameweek.\"\"\"\n    print(f\"Processing for GW {gw} with wildcard.\")\n\n    goalkeepers = []\n    defenders = []\n    midfielders = []\n    forwards = []\n    no_plans = 0\n\n    for filename in Path(directory).glob(\"*.csv\"):\n        plan = pd.read_csv(filename)\n        plan = plan.loc[(plan[\"squad\"] == 1) | (plan[\"transfer_out\"] == 1)]\n\n        # Goalkeepers list of tuples (name, lineup status)\n        goalkeepers += (\n            plan[(plan[\"week\"] == gw) & (plan[\"pos\"] == \"GKP\") & (plan[\"transfer_out\"] != 1)][[\"name\", \"lineup\"]]\n            .apply(lambda x: (x[\"name\"], 1 if x[\"lineup\"] == 1 else 0), axis=1)\n            .to_list()\n        )\n        # Defenders list of tuples (name, lineup status)\n        defenders += (\n            plan[(plan[\"week\"] == gw) & (plan[\"pos\"] == \"DEF\") & (plan[\"transfer_out\"] != 1)][[\"name\", \"lineup\"]]\n            .apply(lambda x: (x[\"name\"], 1 if x[\"lineup\"] == 1 else 0), axis=1)\n            .to_list()\n        )\n        # Midfielders list of tuples (name, lineup status)\n        midfielders += (\n            plan[(plan[\"week\"] == gw) & (plan[\"pos\"] == \"MID\") & (plan[\"transfer_out\"] != 1)][[\"name\", \"lineup\"]]\n            .apply(lambda x: (x[\"name\"], 1 if x[\"lineup\"] == 1 else 0), axis=1)\n            .to_list()\n        )\n        # Forwards list of tuples (name, lineup status)\n        forwards += (\n            plan[(plan[\"week\"] == gw) & (plan[\"pos\"] == \"FWD\") & (plan[\"transfer_out\"] != 1)][[\"name\", \"lineup\"]]\n            .apply(lambda x: (x[\"name\"], 1 if x[\"lineup\"] == 1 else 0), axis=1)\n            .to_list()\n        )\n        no_plans += 1\n\n    print()\n    print(f\"Number of plans: {no_plans}\")\n    print()\n\n    return create_wildcard_pivots(goalkeepers, defenders, midfielders, forwards, no_plans)\n\n\ndef calculate_counts(player_list):\n    \"\"\"Calculate total counts and lineup counts for players.\"\"\"\n    total_count = Counter([name for name, lineup in player_list])\n    lineup_count = Counter([name for name, lineup in player_list if lineup == 1])\n    # Convert to DataFrame\n    total_df = pd.DataFrame(total_count.items(), columns=[\"player\", \"PSB\"])\n    lineup_df = pd.DataFrame(lineup_count.items(), columns=[\"player\", \"Lineup\"])\n    # Merge both DataFrames on player name\n    merged_df = pd.merge(total_df, lineup_df, on=\"player\", how=\"left\").fillna(0)\n    return merged_df\n\n\ndef calculate_percentage(df, no_plans):\n    \"\"\"Convert counts to percentages and sort by PSB.\"\"\"\n    # Sort by PSB before converting to percentages\n    df = df.sort_values(by=\"PSB\", ascending=False).reset_index(drop=True)\n    df[\"#_PSB\"] = df[\"PSB\"].astype(int)\n    df[\"#_Lineup\"] = df[\"Lineup\"].astype(int)\n    # Convert to percentage\n    df[\"PSB\"] = [\"{:.0%}\".format(df[\"PSB\"][x] / no_plans) for x in range(df.shape[0])]\n    df[\"Lineup\"] = [\"{:.0%}\".format(df[\"Lineup\"][x] / no_plans) for x in range(df.shape[0])]\n    return df\n\n\ndef print_dataframe(df, title, use_color=False, psb_threshold=0.05):\n    \"\"\"Print DataFrame with aligned columns and optional color grading.\"\"\"\n    print(f\"{title}:\")\n    # Sort the DataFrame by PSB_count in descending order\n    df = df.sort_values(by=\"#_PSB\", ascending=False).reset_index(drop=True)\n    # Define the max length for each column for proper alignment\n    max_name_len = df[\"player\"].str.len().max()\n    max_psb_len = 8\n    max_lineup_len = 8\n    max_psb_count_len = max(8, df[\"#_PSB\"].astype(str).str.len().max())\n    max_lineup_count_len = max(8, df[\"#_Lineup\"].astype(str).str.len().max())\n    # Print the headers first with fixed width formatting\n    header_format = (\n        f\"{'player':<{max_name_len}} \"\n        f\"{'PSB':<{max_psb_len}} \"\n        f\"{'Lineup':<{max_lineup_len}} \"\n        f\"{'#_PSB':<{max_psb_count_len}} \"\n        f\"{'#_Lineup':<{max_lineup_count_len}}\"\n    )\n    print(header_format)\n    # Ensure PSB and Lineup are strings and handle any non-string values\n    df[\"PSB\"] = df[\"PSB\"].astype(str)\n    df[\"Lineup\"] = df[\"Lineup\"].astype(str)\n    # Normalize values for PSB and Lineup to range [0, 1]\n    try:\n        df[\"PSB_normalized\"] = df[\"PSB\"].str.extract(r\"(\\d+)%\")[0].astype(float) / 100\n        df[\"Lineup_normalized\"] = df[\"Lineup\"].str.extract(r\"(\\d+)%\")[0].astype(float) / 100\n    except Exception as e:\n        print(f\"Error normalizing data: {e}\")\n        return\n    # Filter out players with PSB less than 5%\n    df = df[df[\"PSB_normalized\"] >= psb_threshold]\n    # Calculate the maximum normalized values for the current DataFrame\n    max_normalized_psb = df[\"PSB_normalized\"].max() if not df.empty else 1\n    max_normalized_lineup = df[\"Lineup_normalized\"].max() if not df.empty else 1\n    # Print each row with calculated widths and optional color grading\n    for _, row in df.iterrows():\n        if use_color:\n            # Calculate brightness for PSB based on its maximum value\n            brightness_psb = int(200 * (row[\"PSB_normalized\"] / max_normalized_psb)) if max_normalized_psb > 0 else 200\n            # Calculate brightness for Lineup based on its maximum value\n            brightness_lineup = int(200 * (row[\"Lineup_normalized\"] / max_normalized_lineup)) if max_normalized_lineup > 0 else 200\n            # Define colors for both PSB and Lineup\n            color_psb = f\"\\033[38;2;0;{brightness_psb};{255 - brightness_psb}m\"  # Blue to Green gradient for PSB\n            color_lineup = f\"\\033[38;2;0;{brightness_lineup};{255 - brightness_lineup}m\"  # Blue to Green gradient for Lineup\n        else:\n            # No color formatting if use_color is False\n            color_psb = color_lineup = \"\"\n        # Print each row with or without color\n        player_part = f\"{row['player']:<{max_name_len}}\"\n        psb_part = f\"{color_psb}{row['PSB']:<{max_psb_len}}\\033[0m\"\n        lineup_part = f\"{color_lineup}{row['Lineup']:<{max_lineup_len}}\\033[0m\"\n        psb_count_part = f\"{color_psb}{row['#_PSB']:<{max_psb_count_len}}\\033[0m\"\n        lineup_count_part = f\"{color_lineup}{row['#_Lineup']:<{max_lineup_count_len}}\\033[0m\"\n\n        print(f\"{player_part} {psb_part} {lineup_part} {psb_count_part} {lineup_count_part}\")\n    print()  # Add an empty line for separation between tables\n\n\ndef create_wildcard_pivots(goalkeepers, defenders, midfielders, forwards, no_plans):\n    \"\"\"Create and display pivot tables for wildcard transfers.\"\"\"\n    # Calculate for each position\n    keepers = calculate_counts(goalkeepers)\n    defs = calculate_counts(defenders)\n    mids = calculate_counts(midfielders)\n    fwds = calculate_counts(forwards)\n\n    # Calculate percentages and sort for each position\n    keepers = calculate_percentage(keepers, no_plans)\n    defs = calculate_percentage(defs, no_plans)\n    mids = calculate_percentage(mids, no_plans)\n    fwds = calculate_percentage(fwds, no_plans)\n\n    # Print sorted DataFrames for each position with proper alignment\n    print_dataframe(keepers, \"Goalkeepers\")\n    print_dataframe(defs, \"Defenders\")\n    print_dataframe(mids, \"Midfielders\")\n    print_dataframe(fwds, \"Forwards\")\n\n    return {\"keepers\": keepers, \"defs\": defs, \"mids\": mids, \"fwds\": fwds}\n\n\ndef read_sensitivity(options=None):\n    \"\"\"Main function to read and process sensitivity analysis.\"\"\"\n    all_gws, gw, situation = get_user_inputs(options)\n\n    # If all_gws is 'y', gw and wildcard are not needed\n    if all_gws == \"y\":\n        print(\"Processing all gameweeks.\")\n        buys, sells, move, no_plans = process_all_gameweeks()\n        buy_df = pd.DataFrame(buys)\n        sell_df = pd.DataFrame(sells)\n        print_pivot_tables_all_gws(buy_df, sell_df, no_plans)\n\n    # If all_gws is 'n', use gw and situation or ask for them\n    else:\n        if gw is None:\n            gw = int(input(\"What GW are you assessing? \"))\n        if situation is None:\n            situation = input(\"Is this a wildcard or preseason (GW1) solve? (y/n) \").strip().lower()\n\n        return process_single_gameweek(gw, situation)\n\n\nif __name__ == \"__main__\":\n    import argparse\n\n    try:\n        parser = argparse.ArgumentParser(description=\"Summarize sensitivity analysis results\")\n        parser.add_argument(\n            \"--all_gws\",\n            choices=[\"Y\", \"y\", \"N\", \"n\"],\n            help=\"'y' if you want to display all gameweeks, 'n' otherwise\",\n        )\n        parser.add_argument(\"--gw\", type=int, help=\"Numeric value for 'gw'\")\n        parser.add_argument(\n            \"--wildcard\",\n            choices=[\"Y\", \"y\", \"N\", \"n\"],\n            help=\"'y' if using wildcard, 'n' otherwise\",\n        )\n        args = parser.parse_args()\n\n        # Prepare options for read_sensitivity based on arguments\n        options = {}\n\n        # Handle command-line arguments\n        if args.all_gws:\n            options[\"all_gws\"] = args.all_gws.strip().lower()\n\n        if args.gw is not None:\n            options[\"gw\"] = args.gw\n\n        if args.wildcard:\n            options[\"situation\"] = args.wildcard.strip().lower()\n\n        # If no command-line arguments are provided, prompt for input\n        if not any(vars(args).values()):\n            print(\"No command-line arguments provided, prompting for input... use command line arguments if you want to skip questions\")\n            print()\n            read_sensitivity()  # Prompt for input\n        elif \"all_gws\" in options or (\"gw\" in options and \"situation\" in options):\n            read_sensitivity(options)\n        else:\n            print(\"Error: You must specify either --all_gws or both --gw and --wildcard.\")\n\n    except Exception as e:\n        print(f\"Error occurred: {e}\")\n        print(\"Falling back to user input mode.\")\n\n        # Fallback to prompt user for input\n        read_sensitivity()\n"
  },
  {
    "path": "run/simulations.py",
    "content": "import argparse\nimport json\nimport time\nfrom concurrent.futures import ProcessPoolExecutor\n\nfrom binary_file_generator import generate_binary_files\nfrom solve import solve_regular\n\nfrom paths import DATA_DIR\nfrom utils import load_settings\n\n\ndef get_user_input():\n    print(\"Remember to delete results folder before running simulations\")\n    runs = int(input(\"How many simulations would you like to run? \"))\n    processes = int(input(\"How many processes you want to run in parallel? \"))\n    use_binaries = input(\"Use binaries (y or n)? \")\n    return runs, processes, use_binaries\n\n\ndef get_options_from_args(options):\n    runs = options.get(\"count\", 1)\n    processes = options.get(\"processes\", 1)\n    use_binaries = options.get(\"use_binaries\", \"n\")\n    return runs, processes, use_binaries\n\n\ndef setup_binary_files():\n    settings = load_settings()\n    source = settings.get(\"datasource\", {})\n    if settings.get(\"generate_binary_files\"):\n        print(\"Generating binary files\")\n        binary_fixture_settings = settings.get(\"binary_fixture_settings\", {})\n        if not binary_fixture_settings:\n            raise ValueError(\"Your `binary_fixture_settings` setting is empty!\")\n        file_path = DATA_DIR / f\"{source}.csv\"\n        generate_binary_files(file_path, binary_fixture_settings)\n    return settings\n\n\ndef run_simulations_with_binaries(runs, processes, options):\n    \"\"\"Run simulations using binary files\"\"\"\n    print(\"Using binary config for simulations\")\n    settings = setup_binary_files()\n\n    weights = settings.get(\"binary_file_weights\", {})\n    total_weights = sum(weights.values())\n\n    for binary, weight in weights.items():\n        scaled_weight = weight / total_weights\n        print(f\"Binary file {binary} weight scaled from {weight} to {scaled_weight:.2f}\")\n        weighted_runs = round(scaled_weight * runs)\n\n        print(f\"Running {weighted_runs} simulations for binary file {binary}\")\n\n        start = time.time()\n\n        runtime_options = options.get(\"runtime_options\", {})\n        all_jobs = [{\"run_no\": str(i + 1), \"randomized\": True, \"datasource\": binary.rstrip(\".csv\"), **runtime_options} for i in range(weighted_runs)]\n        with ProcessPoolExecutor(max_workers=processes) as executor:\n            list(executor.map(solve_regular, all_jobs))\n        print(f\"\\nTotal time taken is {(time.time() - start) / 60:.2f} minutes\")\n\n\ndef run_simulations_standard(runs, processes, options):\n    start = time.time()\n    runtime_options = options.get(\"runtime_options\", {})\n    all_jobs = [{\"run_no\": str(i + 1), \"randomized\": True, **runtime_options} for i in range(runs)]\n    with ProcessPoolExecutor(max_workers=processes) as executor:\n        list(executor.map(solve_regular, all_jobs))\n    print(f\"\\nTotal time taken is {(time.time() - start) / 60:.2f} minutes\")\n\n\ndef run_sensitivity(options=None):\n    if options is None or \"count\" not in options:\n        runs, processes, use_binaries = get_user_input()\n    else:\n        runs, processes, use_binaries = get_options_from_args(options)\n\n    # if use_binaries is set, loop through binary_files dict in settings\n    # and set number of sim run for each binary based on provided weights\n    if use_binaries.lower() == \"y\":\n        run_simulations_with_binaries(runs, processes, options)\n    else:\n        run_simulations_standard(runs, processes, options)\n\n\ndef parse_unknown_arguments(unknown):\n    \"\"\"Parse unknown command line arguments and convert them to runtime options\"\"\"\n    runtime_options = {}\n    i = 0\n    while i < len(unknown):\n        if unknown[i].startswith(\"--\"):\n            key = unknown[i][2:]  # Remove -- prefix\n            if i + 1 < len(unknown) and not unknown[i + 1].startswith(\"--\"):\n                value = unknown[i + 1]\n                if value.isdigit():\n                    runtime_options[key] = int(value)\n                else:\n                    try:\n                        runtime_options[key] = float(value)\n                    except ValueError:\n                        if value[0] in \"[{\":\n                            try:\n                                runtime_options[key] = json.loads(value)\n                            except json.JSONDecodeError:\n                                runtime_options[key] = json.loads(value.replace(\"'\", '\"'))\n                        else:\n                            runtime_options[key] = value\n                i += 2\n            else:\n                runtime_options[key] = True\n                i += 1\n        else:\n            i += 1\n    return runtime_options\n\n\nif __name__ == \"__main__\":\n    try:\n        parser = argparse.ArgumentParser(description=\"Run sensitivity analysis\")\n        parser.add_argument(\"--no\", type=int, help=\"Number of runs\")\n        parser.add_argument(\"--parallel\", type=int, help=\"Number of parallel runs\")\n        parser.add_argument(\"--use_binaries\", type=str, help=\"Do you want to use binaries? (y/n)\")\n\n        # Parse known arguments first\n        args, unknown = parser.parse_known_args()\n\n        options = {}\n        if args.no:\n            options[\"count\"] = args.no\n        if args.parallel:\n            options[\"processes\"] = args.parallel\n        if args.use_binaries:\n            options[\"use_binaries\"] = args.use_binaries\n\n        options[\"runtime_options\"] = parse_unknown_arguments(unknown)\n\n    except Exception:\n        options = None\n\n    # Clear command line arguments to prevent them from being passed to solve_regular\n    import sys\n\n    sys.argv = [sys.argv[0]]\n\n    run_sensitivity(options)\n"
  },
  {
    "path": "run/solve.py",
    "content": "import argparse\nimport csv\nimport datetime\nimport json\nimport os\nimport subprocess\nimport sys\nimport textwrap\nimport time\n\nimport pandas as pd\nimport requests\nfrom tabulate import tabulate\n\nfrom dev.solver import generate_team_json, prep_data, solve_multi_period_fpl\nfrom dev.visualization import create_squad_timeline\nfrom paths import DATA_DIR\nfrom utils import cached_request, get_random_id, load_config_files, load_settings\n\nIS_COLAB = \"COLAB_GPU\" in os.environ\nBINARY_THRESHOLD = 0.5\n\n\ndef is_latest_version():\n    try:\n        # Get the current branch name\n        branch = subprocess.check_output([\"git\", \"rev-parse\", \"--abbrev-ref\", \"HEAD\"], stderr=subprocess.DEVNULL, text=True).strip()\n\n        # Fetch the latest updates from the remote\n        subprocess.run([\"git\", \"fetch\"], check=True, stderr=subprocess.DEVNULL)\n\n        # Check if there are commits in the remote branch not in the local branch\n        updates = subprocess.check_output([\"git\", \"rev-list\", f\"HEAD..origin/{branch}\"], stderr=subprocess.DEVNULL, text=True).strip()\n\n        if updates:\n            print(\"Your repository is not up-to-date. Please pull the latest changes.\")\n            return False\n        else:\n            print(\"Your repository is up-to-date.\")\n            return True\n    except subprocess.CalledProcessError:\n        print(\"Error: Could not check the repository status.\")\n        return False\n\n\ndef solve_regular(runtime_options=None):\n    # if not IS_COLAB:\n    #     print(\"Checking for updates...\")\n    #     is_latest_version()\n\n    # Create a base parser first for the --config argument\n    # remaining_args is all the command line args that aren't --config\n    base_parser = argparse.ArgumentParser(add_help=False)\n    base_parser.add_argument(\"--config\", type=str, help=\"Path to one or more configuration files (semicolon-delimited)\")\n    base_args, remaining_args = base_parser.parse_known_args()\n\n    # Load base configuration file\n    options = load_settings()\n\n    # Load and merge additional configuration files if specified\n    if base_args.config:\n        config_options = load_config_files(base_args.config)\n        options.update(config_options)  # Override base config with additional configs\n\n    # Create the full parser with all configuration options\n    parser = argparse.ArgumentParser(parents=[base_parser])\n    for key, value in options.items():\n        if value is None or isinstance(value, list | dict):\n            parser.add_argument(f\"--{key}\", default=value)\n            continue\n        parser.add_argument(f\"--{key}\", type=type(value), default=value)\n\n    # Parse remaining arguments, which will take highest priority\n    args = vars(parser.parse_args(remaining_args))\n\n    # this code block is to look at command line arguments (read as a string) and determine what type\n    # they should be when there is no default argument type set by the code above\n    for key, value in args.items():\n        if key not in options:\n            continue\n        if value == options[key]:  # skip anything that hasn't been edited by command line argument\n            continue\n\n        if options[key] is None or isinstance(options[key], list | dict):\n            if value.isdigit():\n                args[key] = int(value)\n                continue\n\n            try:\n                args[key] = float(value)\n                continue\n            except ValueError:\n                pass\n\n            if value[0] in \"[{\":\n                try:\n                    args[key] = json.loads(value)\n                    continue\n                except json.JSONDecodeError:\n                    args[key] = json.loads(value.replace(\"'\", '\"'))\n                    continue\n                finally:\n                    pass\n\n            print(f\"Problem with CL argument: {key}. Original value: {options[key]}, New value: {value}\")\n\n    cli_options = {k: v for k, v in args.items() if v is not None and k != \"config\"}\n\n    # Update options with CLI arguments (highest priority)\n    options.update(cli_options)\n\n    if runtime_options is not None:\n        options.update(runtime_options)\n\n    if options.get(\"preseason\"):\n        my_data = {\"picks\": [], \"chips\": [], \"transfers\": {\"limit\": None, \"cost\": 4, \"bank\": 1000, \"value\": 0}}\n    elif options.get(\"team_data\", \"json\").lower() == \"id\":\n        team_id = options.get(\"team_id\", None)\n        if team_id is None:\n            print(\"You must supply your team_id in data/user_settings.json\")\n            sys.exit(0)\n        my_data = generate_team_json(team_id, options)\n    elif options.get(\"team_json\"):\n        my_data = json.loads(options[\"team_json\"])\n    else:\n        try:\n            with open(DATA_DIR / \"team.json\") as f:\n                my_data = json.load(f)\n        except FileNotFoundError:\n            msg = \"\"\"\n            team.json file not found in the data folder.\n\n            You must either:\n                1. Download your team data from https://fantasy.premierleague.com/api/my-team/YOUR-TEAM-ID/ and either\n                    a) save it inside the data folder with the filename 'team.json' or\n                    b) supply it to the \"team_json\" option in user_settings.json\n                2. Set \"team_data\" in user_settings to \"ID\", and set the \"team_id\" value to your team's ID\n            \"\"\"\n            print(textwrap.dedent(msg))\n            sys.exit(0)\n\n    if price_changes := options.get(\"price_changes\", []):\n        my_squad_ids = [x[\"element\"] for x in my_data[\"picks\"]]\n        fpl_data = cached_request(\"https://fantasy.premierleague.com/api/bootstrap-static/\")\n        elements = fpl_data[\"elements\"]\n        current_prices = {x[\"id\"]: x[\"now_cost\"] for x in elements if x[\"id\"] in my_squad_ids}\n        for pid, change in price_changes:\n            if pid not in my_squad_ids:\n                continue\n            new_price = current_prices[pid] + change\n            player = next(x for x in my_data[\"picks\"] if x[\"element\"] == pid)\n            if player[\"purchase_price\"] >= new_price:\n                player[\"selling_price\"] = new_price\n            else:\n                player[\"selling_price\"] = player[\"purchase_price\"] + (new_price - player[\"purchase_price\"]) // 2\n\n    data = prep_data(my_data, options)\n\n    response = solve_multi_period_fpl(data, options)\n    run_id = get_random_id(5)\n    options[\"run_id\"] = run_id\n\n    for i, result in enumerate(response):\n        if options.get(\"print_squads\"):\n            print(f\"\\n\\nSolution {i + 1}\")\n            print(textwrap.indent(result[\"summary\"], \"    \"))\n            total_xp = sum(gw_stats.get(\"xP\", 0) for _, gw_stats in result[\"statistics\"].items())\n            print(f\"Total xPts over the horizon: {total_xp:.2f}\\n\")\n        iteration = result[\"iter\"]\n        time_now = datetime.datetime.now()\n        stamp = time_now.strftime(\"%Y-%m-%d_%H-%M-%S\")\n        source = options.get(\"datasource\")\n        filename = f\"{source}_{stamp}_{run_id}_{iteration}\"\n\n        if not os.path.exists(DATA_DIR / \"results/\"):\n            os.mkdir(DATA_DIR / \"results/\")\n        result[\"picks\"].to_csv(DATA_DIR / \"results\" / f\"{filename}.csv\", index=False)\n\n        if options.get(\"export_image\", 0) and not IS_COLAB:\n            create_squad_timeline(\n                current_squad=data[\"initial_squad\"],\n                statistics=result[\"statistics\"],\n                picks=result[\"picks\"],\n                filename=filename,\n            )\n\n    result_table = pd.DataFrame(response)\n    result_table = result_table.sort_values(by=\"score\", ascending=False)\n    result_table = result_table[[\"iter\", \"sell\", \"buy\", \"chip\", \"score\"]]\n\n    dataframe_format = options.get(\"dataframe_format\", \"plain\")\n\n    if options.get(\"print_decay_metrics\"):\n        # print decay metrics\n        if len(options.get(\"report_decay_base\", [])) > 0:\n            try:\n                print(\"\\nDecay Metrics\")\n                metrics_df = pd.DataFrame([{\"iter\": result[\"iter\"], **result[\"decay_metrics\"]} for result in response])\n                print(tabulate(metrics_df, headers=\"keys\", tablefmt=dataframe_format, showindex=False, floatfmt=\".2f\"))\n            except Exception:\n                pass\n\n    if options.get(\"print_transfer_chip_summary\"):\n        print(\"\\n\\n\\nTransfer Overview\")\n        for result in response:\n            print_transfer_chip_summary(result, options)\n\n    if options.get(\"print_result_table\"):\n        # print result table\n        print(f\"\\n\\nResult{'s' if len(response) > 1 else ''}\")\n        print(tabulate(result_table, headers=\"keys\", tablefmt=dataframe_format, showindex=False, floatfmt=\".2f\"))\n        print(\"\\n\\n\")\n\n    if solutions_file := options.get(\"solutions_file\"):\n        for result in response:\n            write_line_to_file(solutions_file, result, options)\n\n    return result_table\n\n\ndef print_transfer_chip_summary(result, options):\n    picks = result[\"picks\"]\n    gws = picks[\"week\"].unique()\n    print(f\"\\nSolution {result['iter'] + 1}\")\n    for gw in sorted(gws):\n        chip_text = \"\"\n        line_text = \"\"\n        chip = picks.loc[(picks[\"week\"] == gw) & (picks[\"chip\"] != \"\")]\n        if not chip.empty:\n            chip_text = chip.iloc[0][\"chip\"]\n            line_text += f\"({chip_text}) \"\n        sell_text = \", \".join(picks[(picks[\"week\"] == gw) & (picks[\"transfer_out\"] == 1)][\"name\"].to_list())\n        buy_text = \", \".join(picks[(picks[\"week\"] == gw) & (picks[\"transfer_in\"] == 1)][\"name\"].to_list())\n\n        if sell_text != \"\" or buy_text != \"\":\n            line_text += sell_text + \" -> \" + buy_text\n        elif chip_text == \"FH\":\n            line_text += \"\"\n        else:\n            line_text += \"Roll\"\n        print(f\"\\tGW{gw}: {line_text}\")\n\n\ndef write_line_to_file(filename, result, options):\n    t = time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime())\n    gw = min(result[\"picks\"][\"week\"])\n    score = round(result[\"score\"], 3)\n    picks = result[\"picks\"]\n\n    run_id = options[\"run_id\"]\n    iteration = result[\"iter\"]\n    team_id = options.get(\"team_id\")\n    datasource = options.get(\"datasource\")\n    chips = [\",\".join(map(str, options.get(x, []))) for x in [\"use_wc\", \"use_bb\", \"use_fh\", \"use_tc\"]]\n\n    squad = picks.loc[(picks[\"week\"] == gw) & ((picks[\"lineup\"] == 1) | (picks[\"bench\"] >= 0))].sort_values(\n        by=[\"lineup\", \"bench\", \"type\"], ascending=[False, True, True]\n    )\n    sells = picks.loc[(picks[\"week\"] == gw) & (picks[\"transfer_out\"] == 1)]\n    buys = picks.loc[(picks[\"week\"] == gw) & (picks[\"transfer_in\"] == 1)]\n    cap = picks.loc[(picks[\"week\"] == gw) & (picks[\"captain\"] > BINARY_THRESHOLD)].iloc[0]\n    vcap = picks.loc[(picks[\"week\"] == gw) & (picks[\"vicecaptain\"] > BINARY_THRESHOLD)].iloc[0]\n\n    if options.get(\"solutions_file_player_type\", \"name\") == \"name\":\n        squad = squad[\"name\"].to_list()\n        sell_text = \",\".join(sells[\"name\"].to_list())\n        buy_text = \",\".join(buys[\"name\"].to_list())\n        cap = cap[\"name\"]\n        vcap = vcap[\"name\"]\n\n    else:\n        squad = squad[\"id\"].astype(int).to_list()\n        sell_text = \", \".join(sells[\"id\"].astype(str).to_list())\n        buy_text = \", \".join(buys[\"id\"].astype(str).to_list())\n        cap = cap[\"id\"].astype(int)\n        vcap = vcap[\"id\"].astype(int)\n\n    headers = [\n        \"run_id\",\n        \"iter\",\n        \"user_id\",\n        \"datasource\",\n        \"wc\",\n        \"bb\",\n        \"fh\",\n        \"tc\",\n        *[f\"p{i}\" for i in range(1, 16)],\n        \"cap\",\n        \"vcap\",\n        \"sell\",\n        \"buy\",\n        \"score\",\n        \"datetime\",\n    ]\n\n    data = [run_id, iteration, team_id, datasource, *chips, *squad, cap, vcap, sell_text, buy_text, score, t]\n    if options.get(\"save_squads\", False):\n        headers.append(\"summary\")\n        data.append(result[\"summary\"])\n\n    if not os.path.exists(filename):\n        with open(filename, \"w\", newline=\"\") as f:\n            writer = csv.writer(f)\n            writer.writerow(headers)\n\n    with open(filename, \"a\", newline=\"\", encoding=\"utf-8\") as f:\n        writer = csv.writer(f)\n        writer.writerow(data)\n\n    # Link to FPL.Team\n    # get_fplteam_link(options, response)\n\n\ndef get_fplteam_link(options, response):\n    print(\"\\nYou can see the solutions on a planner using the following FPL.Team links:\")\n    team_id = options.get(\"team_id\", 1)\n    if options.get(\"team_id\") is None:\n        print(\"(Do not forget to add your team ID to user_settings.json file to get a custom link.)\")\n    url_base = f\"https://fpl.team/plan/{team_id}/?\"\n    for result in response:\n        result_url = url_base\n        picks = result[\"picks\"]\n        gws = picks[\"week\"].unique()\n        for gw in gws:\n            lineup_players = \",\".join(picks[(picks[\"week\"] == gw) & (picks[\"lineup\"] > BINARY_THRESHOLD)][\"id\"].astype(str).to_list())\n            bench_players = \",\".join(picks[(picks[\"week\"] == gw) & (picks[\"bench\"] > -BINARY_THRESHOLD)][\"id\"].astype(str).to_list())\n            cap = picks[(picks[\"week\"] == gw) & (picks[\"captain\"] > BINARY_THRESHOLD)].iloc[0][\"id\"]\n            vcap = picks[(picks[\"week\"] == gw) & (picks[\"vicecaptain\"] > BINARY_THRESHOLD)].iloc[0][\"id\"]\n            chip = picks[picks[\"week\"] == gw].iloc[0][\"chip\"]\n            sold_players = (\n                picks[(picks[\"week\"] == gw) & (picks[\"transfer_out\"] > BINARY_THRESHOLD)].sort_values(by=\"type\")[\"id\"].astype(str).to_list()\n            )\n            bought_players = (\n                picks[(picks[\"week\"] == gw) & (picks[\"transfer_in\"] > BINARY_THRESHOLD)].sort_values(by=\"type\")[\"id\"].astype(str).to_list()\n            )\n\n            if gw == 1:\n                sold_players = []\n                bought_players = []\n\n            tr_string = \";\".join([f\"{i},{j}\" for (i, j) in zip(sold_players, bought_players, strict=False)])\n\n            if tr_string == \"\":\n                tr_string = \";\"\n\n            sub_text = \"\"\n            if gw == 1:\n                sub_text = \";\"\n            else:\n                prev_lineup = (\n                    picks[(picks[\"week\"] == gw - 1) & (picks[\"lineup\"] > BINARY_THRESHOLD)].sort_values(by=\"type\")[\"id\"].astype(str).to_list()\n                )\n                now_bench = picks[(picks[\"week\"] == gw) & (picks[\"bench\"] > -BINARY_THRESHOLD)].sort_values(by=\"type\")[\"id\"].astype(str).to_list()\n                lineup_to_bench = [i for i in prev_lineup if i in now_bench]\n                prev_bench = (\n                    picks[(picks[\"week\"] == gw - 1) & (picks[\"bench\"] > -BINARY_THRESHOLD)].sort_values(by=\"type\")[\"id\"].astype(str).to_list()\n                )\n                now_lineup = picks[(picks[\"week\"] == gw) & (picks[\"lineup\"] > BINARY_THRESHOLD)].sort_values(by=\"type\")[\"id\"].astype(str).to_list()\n                bench_to_lineup = [i for i in prev_bench if i in now_lineup]\n                sub_text = \";\".join([f\"{i},{j}\" for (i, j) in zip(lineup_to_bench, bench_to_lineup, strict=False)])\n\n                if sub_text == \"\":\n                    sub_text = \";\"\n\n            gw_params = (\n                f\"lineup{gw}={lineup_players}&bench{gw}={bench_players}&cap{gw}={cap}&vcap{gw}={vcap}\"\n                f\"&chip{gw}={chip}&transfers{gw}={tr_string}&subs{gw}={sub_text}&opt=true\"\n            )\n            result_url += (\"\" if gw == gws[0] else \"&\") + gw_params\n        print(f\"Solution {result['iter'] + 1}: {result_url}\")\n\n\nif __name__ == \"__main__\":\n    solve_regular()\n"
  },
  {
    "path": "run/tmp/.gitignore",
    "content": "*.txt\n"
  },
  {
    "path": "run/tmp/.gitkeep",
    "content": ""
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_options_parsing.py",
    "content": "import argparse\nimport json\nimport sys\nimport tempfile\nfrom pathlib import Path\n\nimport pytest\n\nfrom run.solve_regular import load_config_files\n\n\n@pytest.fixture\ndef temp_config_files():\n    \"\"\"Create temporary config files for testing.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        base_config = {\"solve_name\": \"test\", \"horizon\": 5, \"iterations\": 100}\n        base_path = Path(tmpdir) / \"base_config.json\"\n        with open(base_path, \"w\") as f:\n            json.dump(base_config, f)\n\n        override_config = {\"iterations\": 200, \"export_image\": True}\n        override_path = Path(tmpdir) / \"override_config.json\"\n        with open(override_path, \"w\") as f:\n            json.dump(override_config, f)\n\n        yield {\"base_path\": str(base_path), \"override_path\": str(override_path)}\n\n\ndef test_load_single_config(temp_config_files):\n    \"\"\"Test loading a single configuration file.\"\"\"\n    config = load_config_files(temp_config_files[\"base_path\"])\n    assert config[\"solve_name\"] == \"test\"\n    assert config[\"horizon\"] == 5\n    assert config[\"iterations\"] == 100\n\n\ndef test_load_multiple_configs(temp_config_files):\n    \"\"\"Test loading multiple configuration files with overrides.\"\"\"\n    config_paths = f\"{temp_config_files['base_path']};{temp_config_files['override_path']}\"\n    config = load_config_files(config_paths)\n\n    assert config[\"solve_name\"] == \"test\"\n    assert config[\"horizon\"] == 5\n\n    assert config[\"iterations\"] == 200\n    assert config[\"export_image\"]\n\n\ndef test_load_nonexistent_config():\n    \"\"\"Test handling of nonexistent configuration file.\"\"\"\n    config = load_config_files(\"nonexistent.json\")\n    assert config == {}\n\n\ndef test_load_invalid_json(tmp_path):\n    \"\"\"Test handling of invalid JSON configuration file.\"\"\"\n    invalid_config = tmp_path / \"invalid.json\"\n    invalid_config.write_text(\"{invalid json\")\n    config = load_config_files(str(invalid_config))\n    assert config == {}\n\n\ndef test_empty_config_path():\n    \"\"\"Test handling of empty configuration path.\"\"\"\n    config = load_config_files(\"\")\n    assert config == {}\n\n\ndef test_semicolon_only_config_path():\n    \"\"\"Test handling of config path with only semicolons.\"\"\"\n    config = load_config_files(\";;\")\n    assert config == {}\n\n\n@pytest.fixture\ndef mock_argv(monkeypatch):\n    \"\"\"Fixture to temporarily replace sys.argv\"\"\"\n\n    def _mock_argv(args):\n        monkeypatch.setattr(sys.argv, args)\n\n    return _mock_argv\n\n\ndef create_arg_parser():\n    \"\"\"Helper function to create argument parser similar to solve_regular.py\"\"\"\n    base_parser = argparse.ArgumentParser(add_help=False)\n    base_parser.add_argument(\"--config\", type=str, help=\"Path to one or more configuration files (semicolon-delimited)\")\n    return base_parser\n\n\ndef test_cli_no_config_argument():\n    \"\"\"Test CLI parsing with no config argument.\"\"\"\n    parser = create_arg_parser()\n    args = parser.parse_known_args([\"--other-arg\", \"value\"])[0]\n    assert args.config is None\n\n\ndef test_cli_single_config():\n    \"\"\"Test CLI parsing with single config path.\"\"\"\n    parser = create_arg_parser()\n    args = parser.parse_known_args([\"--config\", \"path/to/config.json\"])[0]\n    assert args.config == \"path/to/config.json\"\n\n\ndef test_cli_multiple_configs():\n    \"\"\"Test CLI parsing with multiple semicolon-separated config paths.\"\"\"\n    parser = create_arg_parser()\n    args = parser.parse_known_args([\"--config\", \"config1.json;config2.json\"])[0]\n    assert args.config == \"config1.json;config2.json\"\n\n\ndef test_config_priority_order(temp_config_files, monkeypatch):\n    \"\"\"Test that configuration priority is respected: base -> override configs -> CLI args\"\"\"\n\n    base_parser = argparse.ArgumentParser(add_help=False)\n    base_parser.add_argument(\"--config\", type=str)\n\n    base_config = {\"solve_name\": \"base\", \"horizon\": 5, \"iterations\": 100}\n    override_config = {\"horizon\": 7, \"export_image\": True}\n\n    with tempfile.TemporaryDirectory() as tmpdir:\n        base_path = Path(tmpdir) / \"base.json\"\n        with open(base_path, \"w\") as f:\n            json.dump(base_config, f)\n\n        override_path = Path(tmpdir) / \"override.json\"\n        with open(override_path, \"w\") as f:\n            json.dump(override_config, f)\n\n        test_args = [\"script.py\", \"--config\", f\"{base_path};{override_path}\", \"--horizon\", \"10\"]\n        monkeypatch.setattr(sys, \"argv\", test_args)\n\n        base_args, remaining = base_parser.parse_known_args()\n\n        options = base_config.copy()\n        if base_args.config:\n            config_options = load_config_files(base_args.config)\n            options.update(config_options)\n\n        parser = argparse.ArgumentParser(parents=[base_parser])\n        for key, value in options.items():\n            if not isinstance(value, list | dict):\n                parser.add_argument(f\"--{key}\", type=type(value), default=value)\n\n        args = parser.parse_args(remaining)\n        cli_options = {k: v for k, v in vars(args).items() if v is not None and k != \"config\"}\n        options.update(cli_options)\n\n        assert options[\"solve_name\"] == \"base\"\n        assert options[\"horizon\"] == 10\n        assert options[\"iterations\"] == 100\n        assert options[\"export_image\"]\n\n\ndef test_partial_cli_override(temp_config_files, monkeypatch):\n    \"\"\"Test that CLI arguments only override specified values\"\"\"\n    base_parser = argparse.ArgumentParser(add_help=False)\n    base_parser.add_argument(\"--config\", type=str)\n\n    with tempfile.TemporaryDirectory() as tmpdir:\n        config = {\"solve_name\": \"test\", \"horizon\": 5, \"iterations\": 100}\n        config_path = Path(tmpdir) / \"config.json\"\n        with open(config_path, \"w\") as f:\n            json.dump(config, f)\n\n        test_args = [\"script.py\", \"--config\", str(config_path), \"--horizon\", \"7\"]\n        monkeypatch.setattr(sys, \"argv\", test_args)\n\n        base_args, remaining = base_parser.parse_known_args()\n        options = load_config_files(base_args.config)\n\n        parser = argparse.ArgumentParser(parents=[base_parser])\n        for key, value in options.items():\n            if not isinstance(value, list | dict):\n                parser.add_argument(f\"--{key}\", type=type(value), default=value)\n\n        args = parser.parse_args(remaining)\n        cli_options = {k: v for k, v in vars(args).items() if v is not None and k != \"config\"}\n        options.update(cli_options)\n\n        assert options[\"solve_name\"] == \"test\"\n        assert options[\"horizon\"] == 7\n        assert options[\"iterations\"] == 100  # Unchanged from config\n"
  },
  {
    "path": "utils.py",
    "content": "import json\nimport random\nimport string\nimport time\nfrom itertools import product\nfrom pathlib import Path\n\nimport requests\n\nfrom paths import DATA_DIR\n\n# Cache configuration\nCACHE_DIR = Path(__file__).parent / \".cache\"\nCACHE_FILE = CACHE_DIR / \"http_cache.json\"\nCACHE_EXPIRATION = 300\n\n\ndef load_settings():\n    with open(DATA_DIR / \"comprehensive_settings.json\") as f:\n        options = json.load(f)\n    with open(DATA_DIR / \"user_settings.json\") as f:\n        options = {**options, **json.load(f)}\n\n    return options\n\n\ndef get_random_id(n):\n    return \"\".join(random.choice(string.ascii_letters + string.digits) for _ in range(n))\n\n\ndef xmin_to_prob(xmin, sub_on=0.5, sub_off=0.3):\n    start = min(max((xmin - 25 * sub_on) / (90 * (1 - sub_off) + 65 * sub_off - 25 * sub_on), 0.001), 0.999)\n    return start + (1 - start) * sub_on\n\n\ndef get_dict_combinations(my_dict):\n    keys = my_dict.keys()\n    for key in keys:\n        if my_dict[key] is None or len(my_dict[key]) == 0:\n            my_dict[key] = [None]\n    all_combs = [dict(zip(my_dict.keys(), values, strict=False)) for values in product(*my_dict.values())]\n    feasible_combs = []\n    for comb in all_combs:\n        c_values = [i for i in comb.values() if i is not None]\n        if len(c_values) == len(set(c_values)):\n            feasible_combs.append({k: [v] for k, v in comb.items() if v is not None})\n        # else we have a duplicate\n    return feasible_combs\n\n\ndef load_config_files(config_paths):\n    \"\"\"\n    Load and merge multiple configuration files.\n    Files are merged in order, with later files overriding earlier ones.\n    \"\"\"\n    merged_config = {}\n    if not config_paths:\n        return merged_config\n\n    paths = config_paths.split(\";\")\n    for path in paths:\n        stripped_path = path.strip()\n        if not path:\n            continue\n        try:\n            with open(stripped_path) as f:\n                config = json.load(f)\n                merged_config.update(config)\n        except FileNotFoundError:\n            print(f\"Warning: Configuration file {stripped_path} not found\")\n        except json.JSONDecodeError:\n            print(f\"Warning: Configuration file {stripped_path} is not valid JSON\")\n\n    return merged_config\n\n\ndef cached_request(url):\n    \"\"\"\n    Fetch data from URL with caching support.\n    Returns cached data if available and not expired (< 24 hours old).\n    Otherwise fetches fresh data, updates cache, and returns the data.\n\n    Args:\n        url: The URL to fetch data from\n\n    Returns:\n        dict: JSON response from the URL\n    \"\"\"\n    # Create cache directory if it doesn't exist\n    CACHE_DIR.mkdir(exist_ok=True)\n\n    # Load existing cache\n    cache = {}\n    if CACHE_FILE.exists():\n        try:\n            with open(CACHE_FILE) as f:\n                cache = json.load(f)\n        except (json.JSONDecodeError, IOError):\n            # If cache is corrupted, start fresh\n            cache = {}\n\n    # Check if URL is in cache and not expired\n    current_time = time.time()\n    if url in cache:\n        cached_entry = cache[url]\n        timestamp = cached_entry.get(\"timestamp\", 0)\n        if current_time - timestamp < CACHE_EXPIRATION:\n            # Cache is still valid\n            return cached_entry[\"data\"]\n\n    # Cache miss or expired - fetch fresh data\n    try:\n        response = requests.get(url)\n        response.raise_for_status()\n        data = response.json()\n\n        # Update cache\n        cache[url] = {\n            \"data\": data,\n            \"timestamp\": current_time\n        }\n\n        # Save cache to file\n        with open(CACHE_FILE, \"w\") as f:\n            json.dump(cache, f, indent=2)\n\n        return data\n\n    except requests.RequestException as e:\n        # If network request fails and we have expired cache, return it anyway\n        if url in cache:\n            print(f\"Warning: Failed to fetch {url}, using expired cache. Error: {e}\")\n            return cache[url][\"data\"]\n        raise\n"
  }
]