├── icon.ico ├── icon.png ├── requirements.txt ├── BUILD.txt ├── LICENSE ├── .gitignore ├── omo.py ├── README.md └── app.py /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/perv-asive/omo-trainer/HEAD/icon.ico -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/perv-asive/omo-trainer/HEAD/icon.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | altgraph==0.15 2 | appdirs==1.4.3 3 | future==0.16.0 4 | macholib==1.9 5 | pefile==2017.11.5 6 | PyInstaller==3.3.1 7 | pypiwin32==223 8 | pywin32==223 9 | -------------------------------------------------------------------------------- /BUILD.txt: -------------------------------------------------------------------------------- 1 | To build .exe, activate the venv, then: 2 | 3 | pyinstaller --clean -F -w app.py 4 | 5 | Use the option --icon icon.ico for the app icon if desired, but not sure if it works yet. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 perv-asive 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # PyCharm 107 | 108 | .idea/ 109 | -------------------------------------------------------------------------------- /omo.py: -------------------------------------------------------------------------------- 1 | __author__ = 'PERVasive' 2 | 3 | import collections 4 | import statistics 5 | import random 6 | from math import log2 7 | 8 | # h is the half life of water consumed before it gets absorbed, in minutes 9 | h = float(45) 10 | # default_capacity is 500 mL, the accepted figure for human bladder size 11 | # this is known to be low for Omo players, but it is better to err low 12 | default_capacity = 500 13 | # after asking permission, we cannot ask again until bladder has increased 14 | # by capacity/fullness_quantum. This method is balanced between large and small bladders. 15 | fullness_quantum = 5.0 16 | 17 | 18 | class Permission(collections.namedtuple('Permission', ['time', 'permission'])): 19 | pass 20 | 21 | 22 | class Drink(collections.namedtuple('Drink', ['time', 'amount'])): 23 | def unabsorbed(self, t): 24 | if t > self.time: 25 | return (2 ** ((self.time - t) / h)) * self.amount 26 | else: 27 | return self.amount 28 | 29 | 30 | class Release(collections.namedtuple('Release', ['time', 'amount', 'permission'])): 31 | pass 32 | 33 | 34 | class Drinker(object): 35 | def __init__(self): 36 | random.seed() 37 | self._history = [] 38 | self.old_accidents = [] 39 | self._permission = Permission(None, False) 40 | 41 | @property 42 | def history(self): 43 | return self._history[:] 44 | 45 | @history.setter 46 | def history(self, value): 47 | self._history = sorted(value, key=lambda el: el.time) 48 | 49 | @property 50 | def drinks(self): 51 | return [el for el in self._history if isinstance(el, Drink)] 52 | 53 | @property 54 | def releases(self): 55 | return [el for el in self._history if isinstance(el, Release)] 56 | 57 | @property 58 | def accidents(self): 59 | return [el for el in self._history if isinstance(el, Release) and not el.permission] 60 | 61 | @property 62 | def capacity(self): 63 | all_accidents = [el.amount for el in self.accidents] + self.old_accidents 64 | if all_accidents: 65 | new_cap = statistics.mean(all_accidents) 66 | return new_cap if new_cap else default_capacity 67 | else: 68 | return default_capacity 69 | 70 | @property 71 | def eta(self): 72 | excess_latent_water = \ 73 | sum(el.amount for el in self.drinks) - sum(el.amount for el in self.releases) - self.capacity 74 | if excess_latent_water > 0: 75 | start_time = min(el.time for el in self.drinks) 76 | # Inverse function of sum(unabsorbed), must be solved by hand algebraically 77 | # Result will change if additional drinks after ETA is reached 78 | return start_time + \ 79 | h*log2(sum(el.amount*2**((el.time - start_time)/h) for el in self.drinks)/excess_latent_water) 80 | else: 81 | return None 82 | 83 | def absorbed(self, t): 84 | return sum(el.amount - el.unabsorbed(t) for el in self.drinks) 85 | 86 | def bladder(self, t): 87 | return self.absorbed(t) - sum(el.amount for el in 88 | self.releases if el.time <= t) 89 | 90 | def add_drink(self, t, amount): 91 | self.history += [Drink(t, amount)] 92 | 93 | def add_release(self, t, permission): 94 | self.history += [Release(t, self.bladder(t), permission)] 95 | 96 | def desperation(self, t): 97 | # Normalize holding over capacity down to 1.0 98 | # So that permission is always possible 99 | fullness = self.bladder(t)/float(self.capacity) 100 | return 1.0 if fullness > 1.0 else fullness 101 | 102 | def roll_allowed(self, t): 103 | if not self._permission.time: 104 | return True 105 | else: 106 | return self.absorbed(t) - self.absorbed(self._permission.time) > self.capacity/fullness_quantum 107 | 108 | def roll_for_permission(self, t): 109 | # 10% chance of guaranteed yes or no 110 | roll = random.random()*1.2 - 0.1 111 | answer = roll > self.desperation(t) 112 | self._permission = Permission(t, answer) 113 | return answer 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## The Short Version 2 | 3 | Omo Trainer keeps track of the fluids you drink, models your pee desperation over time, and allows or denies potty breaks. It also keeps track of your accidents to learn your personal bladder capacity. 4 | 5 | ## How to Play 6 | 7 | To begin: 8 | 9 | 1. Be generally well hydrated. 10 | 2. Don't drink significant fluids for 45 minutes or so before starting. 11 | 3. Empty your bladder immediately before starting. 12 | 4. Open the Omo Trainer app and leave it open while you go about your business. 13 | 14 | The rules: 15 | 16 | 1. **You may not pee unless Omo Trainer gives you permission.** If you want to pee, you must ask Omo Trainer for permission using the "May I pee?" button. If you are permitted to pee, press the "Go pee." button immediately after emptying your bladder. 17 | 2. **Whenever you drink something, enter it in Omo Trainer**; move the slider to the approximate amount of fluids then press the "Drink" button once. 18 | 3. **If you have an accident, press "I can't hold it!"** This will reset the desperation meter and teach Omo Trainer about your bladder capacity. 19 | 20 | ## The Long Version 21 | 22 | ### The Dice Game 23 | 24 | If you'd prefer for the potty permission game to remain a mystery, don't read this! 25 | 26 | Omo Trainer decides potty permission based on a simple dice game: if your dice roll higher than your current desperation rating, you are allowed to go pee. Omo Trainer uses its estimate of your current bladder contents and average bladder capacity to calculate your desperation. 27 | 28 | The elegant thing about this game is that the higher your desperation, the lower the chance you will be allowed to pee. That means being denied permission once increases the chance that you will be denied permission again. Most of the time if you ask permission when you first need to go, you will be allowed to pee, but occasionally you will be forced to hold it to the point where an accident is a real possibility. 29 | 30 | The idea is to play this game while going about your normal day, to make things more exciting. 31 | 32 | You might notice that after asking permission, you can't ask again for some time. This delay is not a fixed amount of time, but rather depends on how much your desperation has increased since the last time you asked. That way, having a large bladder does not give you more chances to ask permission. 33 | 34 | ###Bladder Capacity 35 | 36 | Whenever you press "I can't hold it!" Omo Trainer records the amount it estimates you were holding and saves it between sessions. Omo Trainer uses the average of your accident amounts as your bladder capacity. 37 | 38 | Since the only way that Omo Trainer learns about your bladder capacity is when you have an accident, it is self-correcting for Omo Trainer to underestimate your bladder capacity. After all, then you will get permission to pee less, and will have more accidents, so Omo Trainer will learn. If Omo Trainer overestimates your capacity, however, you will get permission to pee more, and will have fewer accidents, preventing Omo Trainer from learning. This is why Omo Trainer begins by assuming a small bladder capacity of 500 mL. 39 | 40 | If you are dehydrated to begin with, a significant percentage of fluids will not reach your bladder, leading Omo Trainer to significantly overestimate your desperation and bladder capacity. Therefore hydrate well before playing. 41 | 42 | ###The Bladder Model 43 | 44 | Omo Trainer uses an exponential decay model for bladder filling. This is based on observing that since the volume of bodily fluids must remain constant, the rate at which the kidneys produce urine should be proportional to the amount of excess water in the body. 45 | 46 | The exponential decay model has been calibrated for a half-life equivalent to a urine production rate of 750 mL/hr. This is a reasonable estimate for a hydrated adult drinking a glass of water every 15-30 minutes. It makes sense to choose this rate to be on the high side because players are likely to drink a lot and it is better to err on the side of denying pee permission. 47 | 48 | To the scientists: please note that this model is intentionally simplistic. A more realistic mathematical model would require the app to numerically solve differential equations. The complexity would not be worth it, and it isn't really feasible anyway to expect users to enter accurate data about electrolyte balance. -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import omo 2 | import time 3 | import tkinter as tk 4 | import tkinter.ttk as ttk 5 | import appdirs 6 | import csv 7 | import os 8 | import math 9 | 10 | save_dir = appdirs.user_data_dir('Omo Trainer', 'PERVasive') 11 | accident_log = os.path.join(save_dir, 'accidents.csv') 12 | 13 | 14 | def now(): 15 | return time.time()/60.0 16 | 17 | 18 | class App(object): 19 | def __init__(self): 20 | self.root = tk.Tk() 21 | self.root.title("Omo Trainer") 22 | try: 23 | img = tk.Image("photo", file="icon.png") 24 | self.root.call('wm', 'iconphoto', self.root._w, img) 25 | except tk.TclError: 26 | # If the icon is missing, just go on with the default icon 27 | pass 28 | 29 | self.drinker = omo.Drinker() 30 | 31 | self.load_data() 32 | 33 | # Initialize GUI property variables 34 | self.desperation = tk.DoubleVar() 35 | 36 | self.drink_amount = tk.IntVar() 37 | self.drink_amount.set(300) 38 | 39 | self.bladder_text = tk.StringVar() 40 | self.drink_text = tk.StringVar() 41 | self.permission_text = tk.StringVar() 42 | self.eta_text = tk.StringVar() 43 | 44 | self.create_widgets() 45 | self.create_menus() 46 | 47 | self.poll() 48 | 49 | def create_widgets(self): 50 | self.mainframe = ttk.Frame(self.root, padding="3 3 12 12") 51 | self.mainframe.grid(column=0, row=0, sticky=(tk.N, tk.W, tk.E, tk.S)) 52 | self.mainframe.columnconfigure(0, weight=1) 53 | self.mainframe.rowconfigure(0, weight=1) 54 | 55 | self.bladder_bar = ttk.Progressbar(self.mainframe, orient=tk.VERTICAL, 56 | variable=self.desperation, maximum=1, mode='determinate') 57 | self.bladder_bar.grid(column=0, row=0, rowspan=2, sticky=(tk.N, tk.S)) 58 | 59 | self.bladder_display = ttk.Label(self.mainframe, textvariable=self.bladder_text) 60 | self.bladder_display.grid(column=0, row=2, sticky=(tk.S, tk.W)) 61 | 62 | self.eta_display = ttk.Label(self.mainframe, textvariable=self.eta_text) 63 | self.eta_display.grid(column=1, row=2, columnspan=2, sticky=(tk.S, tk.W)) 64 | 65 | self.drink_slider = ttk.Scale(self.mainframe, orient=tk.HORIZONTAL, length=200, 66 | variable=self.drink_amount, command=self._quantize_drink, from_=100, to=750) 67 | self.drink_slider.grid(column=1, row=0, columnspan=2, sticky=(tk.W, tk.E)) 68 | 69 | self.drink_display = ttk.Label(self.mainframe, textvariable=self.drink_text) 70 | self.drink_display.grid(column=3, row=0, sticky=(tk.E)) 71 | self._quantize_drink() 72 | 73 | self.drink_button = ttk.Button(self.mainframe, text="Drink", command=self.drink) 74 | self.drink_button.grid(column=4, row=0, sticky=(tk.E)) 75 | 76 | self.permission_text.set("May I pee?") 77 | self.permission_button = ttk.Button(self.mainframe, textvariable=self.permission_text, 78 | command=self.ask_permission) 79 | self.permission_button.grid(column=1, row=1, sticky=(tk.W)) 80 | 81 | self.pee_button = ttk.Button(self.mainframe, text="Go pee.", command=self.pee) 82 | self.pee_button.grid(column=2, row=1, sticky=(tk.W)) 83 | self.pee_button.state(['disabled']) 84 | 85 | self.accident_button = ttk.Button(self.mainframe, text="I can't hold it!", command=self.accident) 86 | self.accident_button.grid(column=4, row=1, sticky=(tk.E)) 87 | 88 | for child in self.mainframe.winfo_children(): 89 | child.grid_configure(padx=5, pady=5) 90 | 91 | def create_menus(self): 92 | self.menubar = tk.Menu(self.root, tearoff=0) 93 | self.menu_main = tk.Menu(self.menubar, tearoff=0) 94 | self.root.config(menu=self.menubar) 95 | self.menubar.add_cascade(menu=self.menu_main, label='Menu') 96 | self.menu_main.add_command(label='Reset Capacity Log', command=self.reset_capacity) 97 | 98 | def _quantize_drink(self, *args): 99 | value = self.drink_slider.get() 100 | quantized_val = int(round(value/50)*50) 101 | self.drink_amount.set(quantized_val) 102 | self.drink_text.set(str(quantized_val) + " mL") 103 | 104 | def _on_click(self, butt): 105 | """Briefly disables a button to avoid accidental double clicking""" 106 | butt.state(['disabled']) 107 | self.root.after(1000, lambda: butt.state(['!disabled'])) 108 | 109 | def drink(self): 110 | self.drinker.add_drink(now(), self.drink_amount.get()) 111 | self._on_click(self.drink_button) 112 | 113 | def accident(self): 114 | self.drinker.add_release(now(), False) 115 | self._on_click(self.accident_button) 116 | 117 | def ask_permission(self): 118 | if self.drinker.roll_for_permission(now()): 119 | self.permission_text.set("You may pee.") 120 | self.permission_button.state(['disabled']) 121 | self.pee_button.state(['!disabled']) 122 | else: 123 | self.permission_text.set("You may not pee.") 124 | self.permission_button.state(['disabled']) 125 | 126 | def pee(self): 127 | self.drinker.add_release(now(), True) 128 | self.permission_text.set("May I pee?") 129 | self.pee_button.state(['disabled']) 130 | 131 | def poll(self): 132 | t = now() 133 | self.desperation.set(self.drinker.desperation(t)) 134 | self.bladder_text.set(str(round(self.drinker.bladder(t))) + " mL/" + str(round(self.drinker.capacity)) + " mL") 135 | if self.drinker.eta: 136 | eta = math.ceil(self.drinker.eta - t) 137 | if eta > 1: 138 | self.eta_text.set("Potty emergency in: " + str(eta) + " minutes") 139 | elif eta == 1: 140 | self.eta_text.set("Potty emergency in: " + str(eta) + " minute") 141 | else: 142 | self.eta_text.set("Potty emergency now!") 143 | else: 144 | self.eta_text.set("") 145 | if self.permission_button.instate(['disabled']) and self.drinker.roll_allowed(t): 146 | self.permission_button.state(['!disabled']) 147 | self.pee_button.state(['disabled']) 148 | self.permission_text.set("May I pee?") 149 | self.root.after(500, self.poll) 150 | 151 | def load_data(self): 152 | if os.path.exists(accident_log): 153 | with open(accident_log, 'r', newline='') as f: 154 | reader = csv.reader(f) 155 | self.drinker.old_accidents = [float(row[0]) for row in reader] 156 | 157 | def save_data(self): 158 | os.makedirs(os.path.dirname(accident_log), exist_ok=True) 159 | with open(accident_log, 'a', newline='') as f: 160 | writer = csv.writer(f) 161 | writer.writerows([accident.amount] for accident in self.drinker.accidents) 162 | 163 | def reset_capacity(self): 164 | if os.path.exists(accident_log): 165 | os.remove(accident_log) 166 | self.drinker.old_accidents = [] 167 | 168 | 169 | if __name__ == "__main__": 170 | app = App() 171 | app.root.mainloop() 172 | app.save_data() 173 | 174 | --------------------------------------------------------------------------------