Skip to content

Commit 1dd34f0

Browse files
committed
basic E2E for autogenerating JupyterNotebooks and associating them with runs on Portal
Signed-off-by: Lance-Drane <[email protected]>
1 parent ed8e4b3 commit 1dd34f0

File tree

9 files changed

+321
-12
lines changed

9 files changed

+321
-12
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# temporary files
2+
*.tmp
23
*.tmp.*
34

45

examples-proposed/004-time-loop/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,5 @@ pip install -e .
1919
To run the code, run:
2020

2121
```bash
22-
ips.py --config=sim.conf --platform=platform.conf
22+
./run.sh
2323
```

examples-proposed/004-time-loop/mymodule/components.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ def step(self, timestamp=0.0):
2929
# TODO - perhaps monitor timestep does not need to be called every step, but only every 20 steps?
3030
self.services.call(monitor, 'step', t)
3131

32+
# create notebook here
33+
NOTEBOOK_NAME = 'full_state.ipynb'
34+
jupyter_files = self.services.get_staged_jupyterhub_files()
35+
self.services.create_jupyterhub_notebook(jupyter_files, NOTEBOOK_NAME)
36+
# NOTE: depending on the names of the files, you may have to use a custom mapping function to get the tag
37+
# You MUST store the tag somewhere in the file name
38+
tags = jupyter_files
39+
self.services.portal_register_jupyter_notebook(NOTEBOOK_NAME, tags)
40+
3241
self.services.call(worker, 'finalize', 0)
3342

3443

@@ -77,8 +86,11 @@ def step(self, timestamp=0.0, **keywords):
7786
self.services.stage_state()
7887

7988
state_file = self.services.get_config_param('STATE_FILES')
80-
with open(state_file, 'r') as f:
81-
data = json.load(f)
89+
with open(state_file, 'rb') as f:
90+
data = f.read()
8291

92+
# example of updating Jupyter state
93+
_jupyterhub_state_file = self.services.jupyterhub_make_state(state_file, timestamp)
94+
# if you wanted to create a notebook per timestep, call send_portal_data with _jupyterhub_state_file as the argument.
8395
print('SEND PORTAL DATA', timestamp, data, file=stderr)
84-
# self.services.send_portal_data(timestamp, data)
96+
self.services.send_portal_data(timestamp, data)
+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
rm -rf sim
2-
PYTHONPATH=$PWD ips.py --config=sim.conf --platform=platform.conf --log=ips.log #--debug --verbose
2+
PYTHONPATH=$PWD PSCRATCH=${PSCRATCH:-/tmp} ips.py --config=sim.conf --platform=platform.conf --log=ips.log #--debug --verbose

examples-proposed/004-time-loop/sim.conf

+14-3
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,18 @@ TAG = tag
1212
USER_W3_DIR = $PWD/www
1313
USER_W3_BASEURL =
1414

15-
PORTAL_URL = http://lb.ipsportal.development.svc.spin.nersc.org
15+
PORTAL_URL = https://lb.ipsportal.development.svc.spin.nersc.org
16+
17+
# OPTIONAL
18+
# The BASE DIRECTORY of your machine's JupyterHub web server directory. This is used strictly for moving files around on the machine itself.
19+
# This MUST be an absolute directory
20+
# if executing portal on a web server also running JupyterHub, you can set this value to manually add your file to this location.
21+
# NOTE: "/ipsframework/runs/${RUNID}/${TIMESTEP}.ipynb" will automatically be prepended to the file, guaranteeing that files will NEVER be overwritten.
22+
JUPYTERHUB_DIR = ${PSCRATCH}
23+
# OPTIONAL
24+
# if PORTAL_URL is defined + JUPYTERHUB_DIR is defined + this value is defined, the IPSFramework can send a direct link to the JupyterHub file.
25+
# In the framework, we don't make any assumptions about how JupyterHub's base URL or root server path is configured; you'll generally want to provide the full URL up to the JupyterHub base directory.
26+
JUPYTERHUB_URL = https://jupyter.nersc.gov/user/${USER}/perlmutter-login-node-base/lab/tree${PSCRATCH}
1627

1728
STATE_FILES = state.json
1829
STATE_WORK_DIR = $SIM_ROOT/state
@@ -79,5 +90,5 @@ STATE_WORK_DIR = $SIM_ROOT/state
7990
[TIME_LOOP]
8091
MODE = REGULAR
8192
START = 1
82-
FINISH = 100
83-
NSTEP = 100
93+
FINISH = 30
94+
NSTEP = 30

ipsframework/jupyter.py

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""
2+
This module is designed to help generate JupyterNotebooks to be used with IPS Portal analysis.
3+
Some parts of the script will need direction from users on the Framework side to generate.
4+
5+
Note that this module is currently biased towards working with NERSC (jupyter.nersc.gov), so will attempt to import specific libraries.
6+
7+
To see available libraries on NERSC, run:
8+
!pip list
9+
10+
...in a shell on Jupyter NERSC.
11+
"""
12+
13+
from os.path import sep
14+
from typing import List
15+
16+
import nbformat as nbf
17+
18+
19+
def _get_multi_state_file_notebook_code(state_file_paths: List[str]) -> str:
20+
"""TODO this is currently just an example."""
21+
return f"""FILES = [{','.join([f"'data{sep}{file}'" for file in state_file_paths])}]
22+
mapping = {{}}
23+
for file in FILES:
24+
with open(file, 'rb') as f:
25+
mapping[file] = f.read()
26+
print(mapping)
27+
"""
28+
29+
30+
def _get_nb_v4(code: str):
31+
"""Returns an nbf.v4 object"""
32+
nb = nbf.v4.new_notebook()
33+
text = '# AUTOGENERATED from IPS Framework'
34+
35+
nb['cells'] = [nbf.v4.new_markdown_cell(text), nbf.v4.new_code_cell(code)]
36+
return nb
37+
38+
39+
def create_multi_state_file_notebook(state_file_paths: List[str], notebook_path: str):
40+
"""
41+
TODO
42+
43+
Writes notebook which will try to load multiple state files
44+
45+
for now, will just store all data in a dictionary of filepath str to raw bytes
46+
"""
47+
48+
code = _get_multi_state_file_notebook_code(state_file_paths)
49+
nb = _get_nb_v4(code)
50+
51+
with open(notebook_path, 'w') as f:
52+
nbf.write(nb, f)

ipsframework/portalBridge.py

+86-3
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,38 @@ def send_post_data(conn: Connection, stop: EventType, url: str):
9898
break
9999

100100

101+
def send_put_jupyter_url(conn: Connection, stop: EventType, url: str):
102+
fail_count = 0
103+
104+
http = urllib3.PoolManager(retries=urllib3.util.Retry(3, backoff_factor=0.25))
105+
106+
while True:
107+
if conn.poll(0.1):
108+
next_val: dict[str, Any] = conn.recv()
109+
# TODO - consider using multipart/form-data instead
110+
try:
111+
resp = http.request(
112+
'PUT',
113+
url,
114+
body=json.dumps({'url': next_val['url'], 'tags': next_val['tags'], 'portal_runid': next_val['portal_runid']}).encode(),
115+
headers={
116+
'Content-Type': 'application/json',
117+
},
118+
)
119+
except urllib3.exceptions.MaxRetryError as e:
120+
fail_count += 1
121+
conn.send((999, str(e)))
122+
else:
123+
conn.send((resp.status, resp.data.decode()))
124+
fail_count = 0
125+
126+
if fail_count >= 3:
127+
conn.send((-1, 'Too many consecutive failed connections'))
128+
break
129+
elif stop.is_set():
130+
break
131+
132+
101133
class PortalBridge(Component):
102134
"""
103135
Framework component to communicate with the SWIM web portal.
@@ -142,6 +174,10 @@ def __init__(self, services, config):
142174
self.data_childProcess = None
143175
self.data_childProcessStop = None
144176
self.data_parent_conn = None
177+
self.dataurl_first_event = True
178+
self.dataurl_childProcess = None
179+
self.dataurl_childProcessStop = None
180+
self.dataurl_parent_conn = None
145181
self.mpo = None
146182
self.mpo_name_counter = defaultdict(lambda: 0)
147183
self.counter = 0
@@ -232,6 +268,10 @@ def process_event(self, topicName, theEvent):
232268
self.send_data(sim_data, portal_data)
233269
return
234270

271+
if portal_data['eventtype'] == 'PORTAL_REGISTER_NOTEBOOK':
272+
self.send_notebook_url(sim_data, portal_data)
273+
return
274+
235275
if portal_data['eventtype'] == 'IPS_SET_MONITOR_URL':
236276
sim_data.monitor_url = portal_data['vizurl']
237277
elif sim_data.monitor_url:
@@ -345,7 +385,7 @@ def send_data(self, sim_data, event_data):
345385
if self.data_first_event: # First time, launch sendPost.py daemon
346386
self.data_parent_conn, child_conn = Pipe()
347387
self.data_childProcessStop = Event()
348-
self.data_childProcess = Process(target=send_post_data, args=(child_conn, self.childProcessStop, self.portal_url + '/api/data'))
388+
self.data_childProcess = Process(target=send_post_data, args=(child_conn, self.data_childProcessStop, self.portal_url + '/api/data'))
349389
self.data_childProcess.start()
350390
self.data_first_event = False
351391

@@ -357,9 +397,9 @@ def send_data(self, sim_data, event_data):
357397
self.check_data_send_post_responses()
358398

359399
def check_data_send_post_responses(self):
360-
while self.parent_conn.poll():
400+
while self.data_parent_conn.poll():
361401
try:
362-
code, msg = self.parent_conn.recv()
402+
code, msg = self.data_parent_conn.recv()
363403
except (EOFError, OSError):
364404
break
365405

@@ -380,6 +420,49 @@ def check_data_send_post_responses(self):
380420
else:
381421
self.services.error('Portal Error: %d %s', code, msg)
382422

423+
def send_notebook_url(self, sim_data, event_data):
424+
"""
425+
Send notebook contents
426+
"""
427+
if self.portal_url:
428+
if self.dataurl_first_event: # First time, launch sendPost.py daemon
429+
self.dataurl_parent_conn, child_conn = Pipe()
430+
self.dataurl_childProcessStop = Event()
431+
self.dataurl_childProcess = Process(
432+
target=send_put_jupyter_url, args=(child_conn, self.dataurl_childProcessStop, self.portal_url + '/api/data/add_url')
433+
)
434+
self.dataurl_childProcess.start()
435+
self.dataurl_first_event = False
436+
437+
try:
438+
self.dataurl_parent_conn.send(event_data)
439+
except OSError:
440+
pass
441+
442+
while self.dataurl_parent_conn.poll():
443+
try:
444+
code, msg = self.dataurl_parent_conn.recv()
445+
except (EOFError, OSError):
446+
break
447+
448+
print('PUT RESPONSE', code, msg)
449+
try:
450+
data = json.loads(msg)
451+
if 'runid' in data:
452+
self.services.info('Run Portal URL = %s/%s', self.portal_url, data.get('runid'))
453+
454+
msg = json.dumps(data)
455+
except (TypeError, json.decoder.JSONDecodeError):
456+
pass
457+
if code == -1:
458+
# disable portal, stop trying to send more data
459+
self.portal_url = None
460+
self.services.error('Disabling portal because: %s', msg)
461+
elif code < 400:
462+
self.services.debug('Portal Response: %d %s', code, msg)
463+
else:
464+
self.services.error('Portal Error: %d %s', code, msg)
465+
383466
def send_mpo_data(self, event_data, sim_data): # pragma: no cover
384467
def md5(fname):
385468
"Courtesy of stackoverflow 3431825"

0 commit comments

Comments
 (0)