phaserEM 0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phaser/__init__.py +0 -0
- phaser/__main__.py +5 -0
- phaser/engines/common/__init__.py +0 -0
- phaser/engines/common/noise_models.py +113 -0
- phaser/engines/common/output.py +189 -0
- phaser/engines/common/position_correction.py +62 -0
- phaser/engines/common/regularizers.py +403 -0
- phaser/engines/common/simulation.py +270 -0
- phaser/engines/conventional/__init__.py +0 -0
- phaser/engines/conventional/run.py +142 -0
- phaser/engines/conventional/solvers.py +476 -0
- phaser/engines/gradient/run.py +451 -0
- phaser/engines/gradient/solvers.py +139 -0
- phaser/execute.py +371 -0
- phaser/hooks/__init__.py +158 -0
- phaser/hooks/hook.py +159 -0
- phaser/hooks/io/empad.py +88 -0
- phaser/hooks/object.py +25 -0
- phaser/hooks/preprocessing.py +133 -0
- phaser/hooks/probe.py +24 -0
- phaser/hooks/regularization.py +97 -0
- phaser/hooks/scan.py +27 -0
- phaser/hooks/schedule.py +75 -0
- phaser/hooks/solver.py +169 -0
- phaser/io/__init__.py +0 -0
- phaser/io/empad.py +212 -0
- phaser/main.py +92 -0
- phaser/plan.py +184 -0
- phaser/py.typed +0 -0
- phaser/state.py +249 -0
- phaser/types.py +305 -0
- phaser/utils/__init__.py +0 -0
- phaser/utils/_cuda_kernels.py +213 -0
- phaser/utils/_jax_kernels.py +98 -0
- phaser/utils/analysis.py +263 -0
- phaser/utils/image.py +201 -0
- phaser/utils/io.py +402 -0
- phaser/utils/misc.py +295 -0
- phaser/utils/num.py +800 -0
- phaser/utils/object.py +578 -0
- phaser/utils/optics.py +377 -0
- phaser/utils/physics.py +88 -0
- phaser/utils/plotting.py +699 -0
- phaser/utils/scan.py +60 -0
- phaser/web/__init__.py +0 -0
- phaser/web/dist/03510a839ccb97b0da9f.module.wasm +0 -0
- phaser/web/dist/9573273f862f4f5d9644.module.wasm +0 -0
- phaser/web/dist/bundle-dashboard.js +712 -0
- phaser/web/dist/bundle-manager.js +210 -0
- phaser/web/dist/bundle-vendors-node_modules_wasm-array_wasm_array_js.js +106 -0
- phaser/web/dist/style.css +152 -0
- phaser/web/notebook.py +269 -0
- phaser/web/routes.py +211 -0
- phaser/web/server.py +540 -0
- phaser/web/slurm.py +180 -0
- phaser/web/templates/base.html +13 -0
- phaser/web/templates/dashboard.html +14 -0
- phaser/web/templates/manager.html +19 -0
- phaser/web/types.py +255 -0
- phaser/web/util.py +116 -0
- phaser/web/worker.py +200 -0
- phaserem-0.1.dist-info/METADATA +121 -0
- phaserem-0.1.dist-info/RECORD +67 -0
- phaserem-0.1.dist-info/WHEEL +5 -0
- phaserem-0.1.dist-info/entry_points.txt +2 -0
- phaserem-0.1.dist-info/licenses/LICENSE.txt +373 -0
- phaserem-0.1.dist-info/top_level.txt +1 -0
phaser/web/slurm.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import weakref
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import importlib.resources
|
|
8
|
+
import typing as t
|
|
9
|
+
|
|
10
|
+
from .server import WorkerID, Worker
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SlurmID: t.TypeAlias = int
|
|
14
|
+
SlurmState: t.TypeAlias = t.Literal[
|
|
15
|
+
'BOOT_FAIL', 'CANCELLED', 'COMPLETED', 'CONFIGURING', 'COMPLETING',
|
|
16
|
+
'DEADLINE', 'FAILED', 'NODE_FAIL', 'OUT_OF_MEMORY', 'PENDING',
|
|
17
|
+
'PREEMPTED', 'RUNNING', 'RESV_DEL_HOLD', 'REQUEUE_FED', 'REQUEUE_HOLD',
|
|
18
|
+
'REQUEUED', 'RESIZING', 'REVOKED', 'SIGNALING', 'SPECIAL_EXIT',
|
|
19
|
+
'STAGE_OUT', 'STOPPED', 'SUSPENDED', 'TIMEOUT',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SlurmJobInfo(t.TypedDict):
|
|
24
|
+
job_id: SlurmID
|
|
25
|
+
job_state: SlurmState
|
|
26
|
+
name: str
|
|
27
|
+
nodes: str
|
|
28
|
+
user_name: str
|
|
29
|
+
|
|
30
|
+
# path to stdout and stderr
|
|
31
|
+
standard_output: str
|
|
32
|
+
standard_error: str
|
|
33
|
+
|
|
34
|
+
submit_time: int
|
|
35
|
+
start_time: int
|
|
36
|
+
eligible_time: int
|
|
37
|
+
end_time: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SqueueResult(t.TypedDict):
|
|
41
|
+
meta: t.Dict[str, t.Any]
|
|
42
|
+
jobs: t.List[SlurmJobInfo]
|
|
43
|
+
warnings: t.List[t.Any]
|
|
44
|
+
errors: t.List[t.Any]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SlurmWorker(Worker):
|
|
48
|
+
def __init__(self, worker_id: WorkerID, slurm_job_id: SlurmID):
|
|
49
|
+
self.slurm_job_id: SlurmID = slurm_job_id
|
|
50
|
+
super().__init__(worker_id)
|
|
51
|
+
|
|
52
|
+
async def cancel(self):
|
|
53
|
+
from .server import server
|
|
54
|
+
if self.status == 'queued':
|
|
55
|
+
await server.slurm_manager.cancel_queued_worker(self.slurm_job_id)
|
|
56
|
+
|
|
57
|
+
await self.set_status('stopping')
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SlurmManager:
|
|
61
|
+
def __init__(self):
|
|
62
|
+
self.logger = logging.getLogger(__name__)
|
|
63
|
+
self._slurm_exists: t.Optional[bool] = None
|
|
64
|
+
self._slurm_workers: weakref.WeakValueDictionary[SlurmID, SlurmWorker] = weakref.WeakValueDictionary()
|
|
65
|
+
|
|
66
|
+
self._poll_task: t.Optional[asyncio.Task[None]] = None
|
|
67
|
+
self._new_worker_event: asyncio.Event = asyncio.Event()
|
|
68
|
+
|
|
69
|
+
async def make_worker(self, worker_id: WorkerID, url: str) -> SlurmWorker:
|
|
70
|
+
await self.check_slurm_exists()
|
|
71
|
+
|
|
72
|
+
job_name = f"phaser_{worker_id}"
|
|
73
|
+
|
|
74
|
+
from .. import web
|
|
75
|
+
with importlib.resources.path(web, 'slurm_worker.sh') as script_path:
|
|
76
|
+
slurm_args = "--qos=high --time=4-0 --partition=xeon-g6-volta --cpus-per-task=20 --gres=gpu:volta:1 --signal=SIGINT@120"
|
|
77
|
+
#slurm_args = "--qos=high --time=01:00:00 --partition=debug-cpu --cpus-per-task=20"
|
|
78
|
+
shell = os.environ.get('SHELL', '/bin/bash')
|
|
79
|
+
proc = await asyncio.create_subprocess_exec(
|
|
80
|
+
shell, "-c", f"sbatch --job-name={job_name} {slurm_args} \"{script_path.absolute()}\" \"{url}\"",
|
|
81
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
82
|
+
)
|
|
83
|
+
stdout, stderr = await proc.communicate()
|
|
84
|
+
|
|
85
|
+
if proc.returncode != 0:
|
|
86
|
+
raise RuntimeError(f"Failed to start slurm worker, stderr: '{stderr}'")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
match = re.fullmatch(r'Submitted batch job (\d+)', stdout.decode().strip())
|
|
90
|
+
job_id: SlurmID = int(match[1]) # type: ignore
|
|
91
|
+
except (ValueError, TypeError):
|
|
92
|
+
raise ValueError(f"Failed to parse slurm return: '{stdout.decode()}'")
|
|
93
|
+
|
|
94
|
+
worker = SlurmWorker(worker_id, job_id)
|
|
95
|
+
self._slurm_workers[job_id] = worker
|
|
96
|
+
|
|
97
|
+
if self._poll_task is None:
|
|
98
|
+
self._poll_task = asyncio.create_task(self._poll_slurm())
|
|
99
|
+
else:
|
|
100
|
+
self._new_worker_event.set()
|
|
101
|
+
|
|
102
|
+
return worker
|
|
103
|
+
|
|
104
|
+
async def cancel_queued_worker(self, slurm_job_id: SlurmID):
|
|
105
|
+
proc = await asyncio.create_subprocess_shell(
|
|
106
|
+
f"scancel --state=PENDING {slurm_job_id}",
|
|
107
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
108
|
+
)
|
|
109
|
+
stdout, stderr = await proc.communicate()
|
|
110
|
+
|
|
111
|
+
if proc.returncode != 0:
|
|
112
|
+
self.logger.warning(f"Failed to cancel slurm job: '{stderr.decode()}'")
|
|
113
|
+
|
|
114
|
+
async def _poll_slurm(self):
|
|
115
|
+
while True:
|
|
116
|
+
while True:
|
|
117
|
+
try:
|
|
118
|
+
await self._poll_slurm_status()
|
|
119
|
+
except Exception as e:
|
|
120
|
+
self.logger.warning(f"Failed to poll slurm worker statuses: {e}")
|
|
121
|
+
|
|
122
|
+
if any(worker.state == 'queued' for worker in self._slurm_workers.values()):
|
|
123
|
+
# fast wait cycle
|
|
124
|
+
await asyncio.sleep(5.0)
|
|
125
|
+
elif any(worker.state != 'stopped' for worker in self._slurm_workers.values()):
|
|
126
|
+
# if there's any workers to wait for, slow wait cycle
|
|
127
|
+
await asyncio.sleep(30.0)
|
|
128
|
+
else:
|
|
129
|
+
# no workers waiting, wait until one is started
|
|
130
|
+
self._new_worker_event.clear()
|
|
131
|
+
break
|
|
132
|
+
await self._new_worker_event.wait()
|
|
133
|
+
|
|
134
|
+
async def _poll_slurm_status(self):
|
|
135
|
+
jobs = ",".join(str(worker.slurm_job_id) for worker in self._slurm_workers.values())
|
|
136
|
+
|
|
137
|
+
proc = await asyncio.create_subprocess_shell(
|
|
138
|
+
f"squeue --job={jobs} --states=all --json",
|
|
139
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
140
|
+
)
|
|
141
|
+
stdout, stderr = await proc.communicate()
|
|
142
|
+
|
|
143
|
+
if proc.returncode != 0:
|
|
144
|
+
self.logger.warning(f"Failed to poll slurm worker statuses, stderr: '{stderr}'")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
d = t.cast(SqueueResult, json.loads(stdout))
|
|
148
|
+
|
|
149
|
+
for job in d["jobs"]:
|
|
150
|
+
try:
|
|
151
|
+
worker = self._slurm_workers[job["job_id"]]
|
|
152
|
+
except KeyError:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
if job['job_state'] in ('CONFIGURING', 'RUNNING'):
|
|
156
|
+
if worker.status == 'queued':
|
|
157
|
+
await worker.set_status('starting')
|
|
158
|
+
elif job['job_state'] != 'PENDING':
|
|
159
|
+
if worker.status != 'stopped':
|
|
160
|
+
# TODO grab some exit information here
|
|
161
|
+
await worker.set_status('stopped')
|
|
162
|
+
|
|
163
|
+
async def check_slurm_exists(self):
|
|
164
|
+
if self._slurm_exists is None:
|
|
165
|
+
proc = await asyncio.create_subprocess_shell("sbatch --version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
|
166
|
+
stdout, stderr = await proc.communicate()
|
|
167
|
+
|
|
168
|
+
if proc.returncode == 0:
|
|
169
|
+
self._slurm_exists = True
|
|
170
|
+
self.logger.info(f"Slurm found, version '{stdout.decode().strip()}'")
|
|
171
|
+
else:
|
|
172
|
+
self._slurm_exists = False
|
|
173
|
+
self.logger.warning(f"Slurm not found, stderr '{stderr.decode()}'")
|
|
174
|
+
|
|
175
|
+
if not self._slurm_exists:
|
|
176
|
+
raise RuntimeError("Slurm not found, is it installed and on PATH?")
|
|
177
|
+
|
|
178
|
+
async def finalize(self):
|
|
179
|
+
if self._poll_task is not None:
|
|
180
|
+
self._poll_task.cancel()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>{% block title %}Phaser{% endblock %}</title>
|
|
6
|
+
<link href="{{ url_for('static', filename='style.css') }}" rel='stylesheet'>
|
|
7
|
+
{% block head %}{% endblock %}
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
{% block body %}{% endblock %}
|
|
11
|
+
<noscript>This page contains webassembly and javascript content, please enable javascript in your browser.</noscript>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}Phaser frontend{% endblock %}
|
|
3
|
+
|
|
4
|
+
{% block head %}
|
|
5
|
+
<script type="text/javascript" src="{{ url_for('static', filename='bundle-dashboard.js') }}" defer></script>
|
|
6
|
+
{% endblock %}
|
|
7
|
+
|
|
8
|
+
{% block body %}
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<!--<div class="section-header">Probe</div>
|
|
11
|
+
<div class="section">
|
|
12
|
+
<div style="width: 100%; margin: auto;" class="plot-cont" id="probe-plot"></div>
|
|
13
|
+
</div>-->
|
|
14
|
+
{% endblock %}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}Phaser frontend{% endblock %}
|
|
3
|
+
|
|
4
|
+
{% block head %}
|
|
5
|
+
<script type="text/javascript" src="{{ url_for('static', filename='bundle-manager.js') }}" defer></script>
|
|
6
|
+
{% endblock %}
|
|
7
|
+
|
|
8
|
+
{% block body %}
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<!--<div class="section-header">Start new reconstruction</div>
|
|
11
|
+
<div class="section">
|
|
12
|
+
<button id="start">Start reconstruction</button>
|
|
13
|
+
<button id="start-fake">Start fake reconstruction</button>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="section-header">Current reconstructions</div>
|
|
16
|
+
<div class="section">
|
|
17
|
+
<table id="recons-table"></table>
|
|
18
|
+
</div>-->
|
|
19
|
+
{% endblock %}
|
phaser/web/types.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging
|
|
3
|
+
import typing as t
|
|
4
|
+
|
|
5
|
+
import pane
|
|
6
|
+
from pane.annotations import Tagged
|
|
7
|
+
from typing_extensions import Self
|
|
8
|
+
|
|
9
|
+
from phaser.types import Cancelled
|
|
10
|
+
from .util import ReconsStateConverter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
JobID: t.TypeAlias = str
|
|
14
|
+
WorkerID: t.TypeAlias = str
|
|
15
|
+
|
|
16
|
+
WorkerStatus: t.TypeAlias = t.Literal['queued', 'starting', 'reloading', 'idle', 'running', 'stopping', 'stopped', 'unknown']
|
|
17
|
+
"""
|
|
18
|
+
Enum indicating the state of the connection, as described by either the server or client.
|
|
19
|
+
|
|
20
|
+
- 'queued': Worker is queued/scheduled to start
|
|
21
|
+
- 'starting': Worker is actively being started
|
|
22
|
+
- 'reloading': Worker is being reloaded
|
|
23
|
+
- 'idle': No job is running
|
|
24
|
+
- 'running': Job is running
|
|
25
|
+
- 'stopping': Worker is being stopped
|
|
26
|
+
- 'stopped': Worker has been stopped (normally or abnormally)
|
|
27
|
+
|
|
28
|
+
- 'unknown': Job hasn't talked in a while, unknown state
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
JobStatus: t.TypeAlias = t.Literal['queued', 'starting', 'running', 'stopping', 'stopped']
|
|
32
|
+
"""
|
|
33
|
+
Enum indicating the state of a reconstruction job
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
Result: t.TypeAlias = t.Literal['finished', 'errored', 'cancelled', 'interrupted']
|
|
37
|
+
"""
|
|
38
|
+
Enum indicating a job or worker result.
|
|
39
|
+
|
|
40
|
+
- 'finished': Finished normally
|
|
41
|
+
- 'errored': Encounted an error running
|
|
42
|
+
- 'cancelled': Cancelled/shutdown by the user (from server side)
|
|
43
|
+
- 'interrupted': Cancelled/shutdown by the client/OS
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
Signal: t.TypeAlias = t.Literal['shutdown', 'cancel', 'reload']
|
|
47
|
+
"""
|
|
48
|
+
Signal sent to a job or worker.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
class ValidationError(Exception):
|
|
52
|
+
def __init__(self, msg: str):
|
|
53
|
+
self.msg: str = msg
|
|
54
|
+
|
|
55
|
+
class SignalException(Cancelled):
|
|
56
|
+
def __init__(self, signal: Signal, urgent: bool = False):
|
|
57
|
+
self.signal: Signal = signal
|
|
58
|
+
self.urgent: bool = urgent
|
|
59
|
+
|
|
60
|
+
# server -> client messages
|
|
61
|
+
|
|
62
|
+
class WorkerState(pane.PaneBase):
|
|
63
|
+
worker_id: WorkerID
|
|
64
|
+
status: WorkerStatus
|
|
65
|
+
links: t.Dict[str, str] = pane.field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
class WorkerUpdate(pane.PaneBase):
|
|
68
|
+
worker_id: WorkerID
|
|
69
|
+
status: WorkerStatus
|
|
70
|
+
|
|
71
|
+
msg: t.Literal['status_change'] = 'status_change'
|
|
72
|
+
|
|
73
|
+
class JobState(pane.PaneBase):
|
|
74
|
+
job_id: JobID
|
|
75
|
+
status: JobStatus
|
|
76
|
+
links: t.Dict[str, str] = pane.field(default_factory=dict)
|
|
77
|
+
state: t.Dict[str, t.Any] = pane.field(converter=ReconsStateConverter(), default_factory=dict)
|
|
78
|
+
worker_id: t.Optional[WorkerID] = None
|
|
79
|
+
|
|
80
|
+
def strip_state(self) -> Self:
|
|
81
|
+
return type(self)(
|
|
82
|
+
self.job_id, self.status, self.links
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
class LogRecord(pane.PaneBase):
|
|
86
|
+
i: int
|
|
87
|
+
|
|
88
|
+
timestamp: datetime.datetime
|
|
89
|
+
|
|
90
|
+
log: str
|
|
91
|
+
|
|
92
|
+
logger_name: str
|
|
93
|
+
log_level: int
|
|
94
|
+
|
|
95
|
+
line_number: int
|
|
96
|
+
func_name: t.Optional[str] = None
|
|
97
|
+
stack_info: t.Optional[str] = None
|
|
98
|
+
|
|
99
|
+
class JobStatusChange(pane.PaneBase):
|
|
100
|
+
status: JobStatus
|
|
101
|
+
job_id: JobID
|
|
102
|
+
|
|
103
|
+
msg: t.Literal['status_change'] = 'status_change'
|
|
104
|
+
|
|
105
|
+
class JobUpdate(pane.PaneBase):
|
|
106
|
+
state: t.Dict[str, t.Any] = pane.field(converter=ReconsStateConverter())
|
|
107
|
+
job_id: JobID
|
|
108
|
+
|
|
109
|
+
msg: t.Literal['job_update'] = 'job_update'
|
|
110
|
+
|
|
111
|
+
class LogUpdate(pane.PaneBase):
|
|
112
|
+
new_logs: t.List[LogRecord]
|
|
113
|
+
|
|
114
|
+
msg: t.Literal['log'] = 'log'
|
|
115
|
+
|
|
116
|
+
class JobStopped(pane.PaneBase):
|
|
117
|
+
result: Result
|
|
118
|
+
error: t.Optional[str] = None
|
|
119
|
+
|
|
120
|
+
msg: t.Literal['job_stopped'] = 'job_stopped'
|
|
121
|
+
|
|
122
|
+
JobMessage: t.TypeAlias = t.Annotated[t.Union[
|
|
123
|
+
JobStatusChange, JobUpdate, LogUpdate, JobStopped
|
|
124
|
+
], Tagged('msg')]
|
|
125
|
+
|
|
126
|
+
class DashboardConnected(pane.PaneBase):
|
|
127
|
+
state: JobState
|
|
128
|
+
msg: t.Literal['connected'] = 'connected'
|
|
129
|
+
|
|
130
|
+
DashboardMessage: t.TypeAlias = t.Annotated[t.Union[
|
|
131
|
+
JobStatusChange, JobUpdate, JobStopped, DashboardConnected
|
|
132
|
+
], Tagged('msg')]
|
|
133
|
+
|
|
134
|
+
class WorkersUpdate(pane.PaneBase):
|
|
135
|
+
event: t.Optional[WorkerUpdate]
|
|
136
|
+
state: t.List[WorkerState]
|
|
137
|
+
|
|
138
|
+
msg: t.Literal['workers_update'] = 'workers_update'
|
|
139
|
+
|
|
140
|
+
class JobsUpdate(pane.PaneBase):
|
|
141
|
+
event: JobMessage
|
|
142
|
+
state: t.List[JobState]
|
|
143
|
+
|
|
144
|
+
msg: t.Literal['jobs_update'] = 'jobs_update'
|
|
145
|
+
|
|
146
|
+
class ManagerConnected(pane.PaneBase):
|
|
147
|
+
workers: t.List[WorkerState]
|
|
148
|
+
jobs: t.List[JobState]
|
|
149
|
+
|
|
150
|
+
msg: t.Literal['connected'] = 'connected'
|
|
151
|
+
|
|
152
|
+
ManagerMessage: t.TypeAlias = t.Annotated[t.Union[
|
|
153
|
+
JobsUpdate, WorkersUpdate, ManagerConnected
|
|
154
|
+
], Tagged('msg')]
|
|
155
|
+
|
|
156
|
+
# worker -> server messages
|
|
157
|
+
|
|
158
|
+
class ConnectMessage(pane.PaneBase):
|
|
159
|
+
"""Message sent when a worker starts up"""
|
|
160
|
+
msg: t.Literal['connect'] = 'connect'
|
|
161
|
+
|
|
162
|
+
class PollMessage(pane.PaneBase):
|
|
163
|
+
"""Message polling the server for a job"""
|
|
164
|
+
msg: t.Literal['poll'] = 'poll'
|
|
165
|
+
|
|
166
|
+
class PingMessage(pane.PaneBase):
|
|
167
|
+
"""Message pinging the server, potentially receiving a cancellation"""
|
|
168
|
+
msg: t.Literal['ping'] = 'ping'
|
|
169
|
+
|
|
170
|
+
class UpdateMessage(pane.PaneBase):
|
|
171
|
+
"""Message containing some state update"""
|
|
172
|
+
state: t.Dict[str, t.Any] = pane.field(converter=ReconsStateConverter())
|
|
173
|
+
job_id: JobID
|
|
174
|
+
msg: t.Literal['job_update'] = 'job_update'
|
|
175
|
+
|
|
176
|
+
class LogMessage(pane.PaneBase):
|
|
177
|
+
"""Message corresponding to a log entry"""
|
|
178
|
+
job_id: t.Optional[JobID]
|
|
179
|
+
|
|
180
|
+
timestamp: datetime.datetime
|
|
181
|
+
|
|
182
|
+
log: str
|
|
183
|
+
|
|
184
|
+
logger_name: str
|
|
185
|
+
log_level: int
|
|
186
|
+
|
|
187
|
+
line_number: int
|
|
188
|
+
func_name: t.Optional[str] = None
|
|
189
|
+
stack_info: t.Optional[str] = None
|
|
190
|
+
|
|
191
|
+
msg: t.Literal['log'] = 'log'
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def from_logrecord(cls, job_id: t.Optional[JobID], record: logging.LogRecord) -> Self:
|
|
195
|
+
timestamp = datetime.datetime.fromtimestamp(record.created)
|
|
196
|
+
return cls(
|
|
197
|
+
timestamp=timestamp,
|
|
198
|
+
log=record.getMessage(),
|
|
199
|
+
job_id=job_id,
|
|
200
|
+
logger_name=record.name,
|
|
201
|
+
log_level=record.levelno,
|
|
202
|
+
line_number=record.lineno,
|
|
203
|
+
func_name=record.funcName,
|
|
204
|
+
stack_info=record.stack_info,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def into_record(self, i: int) -> LogRecord:
|
|
208
|
+
return LogRecord.make_unchecked(
|
|
209
|
+
i, self.timestamp, self.log, self.logger_name, self.log_level,
|
|
210
|
+
self.line_number, self.func_name, self.stack_info
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
class JobResultMessage(pane.PaneBase):
|
|
214
|
+
"""Message indicating job has stopped (normally or abnormally)"""
|
|
215
|
+
job_id: JobID
|
|
216
|
+
result: Result
|
|
217
|
+
error: t.Optional[str] = None
|
|
218
|
+
|
|
219
|
+
msg: t.Literal['job_result'] = 'job_result'
|
|
220
|
+
|
|
221
|
+
class WorkerShutdownMessage(pane.PaneBase):
|
|
222
|
+
"""Message indicating a worker has been stopped"""
|
|
223
|
+
result: Result
|
|
224
|
+
error: t.Optional[str] = None
|
|
225
|
+
detail: t.Optional[str] = None
|
|
226
|
+
|
|
227
|
+
msg: t.Literal['shutdown'] = 'shutdown'
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# server -> worker responses
|
|
231
|
+
|
|
232
|
+
class JobResponse(pane.PaneBase):
|
|
233
|
+
"""Response containing a job to run"""
|
|
234
|
+
job_id: JobID
|
|
235
|
+
plan: str
|
|
236
|
+
msg: t.Literal['job'] = 'job'
|
|
237
|
+
|
|
238
|
+
class OkResponse(pane.PaneBase):
|
|
239
|
+
"""Response from server indicating no action should be taken"""
|
|
240
|
+
msg: t.Literal['ok'] = 'ok'
|
|
241
|
+
|
|
242
|
+
class SignalResponse(pane.PaneBase):
|
|
243
|
+
"""Response from server signalling some action (cancellation, shutdown, or reload)"""
|
|
244
|
+
signal: t.Literal['shutdown', 'cancel', 'reload'] = 'cancel'
|
|
245
|
+
urgent: bool = False
|
|
246
|
+
msg: t.Literal['signal'] = 'signal'
|
|
247
|
+
|
|
248
|
+
WorkerMessage: t.TypeAlias = t.Annotated[t.Union[
|
|
249
|
+
PollMessage, ConnectMessage, PingMessage, UpdateMessage,
|
|
250
|
+
LogMessage, JobResultMessage, WorkerShutdownMessage,
|
|
251
|
+
], Tagged('msg')]
|
|
252
|
+
|
|
253
|
+
ServerResponse: t.TypeAlias = t.Annotated[t.Union[
|
|
254
|
+
JobResponse, SignalResponse, OkResponse
|
|
255
|
+
], Tagged('msg')]
|
phaser/web/util.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import dataclasses
|
|
4
|
+
import typing as t
|
|
5
|
+
|
|
6
|
+
import numpy
|
|
7
|
+
from pane.converters import Converter
|
|
8
|
+
from pane.errors import ProductErrorNode, WrongTypeError, ParseInterrupt
|
|
9
|
+
|
|
10
|
+
T = t.TypeVar('T')
|
|
11
|
+
|
|
12
|
+
class _array_dummy():
|
|
13
|
+
def __init__(self, array_interface: t.Any):
|
|
14
|
+
self.__array_interface__ = array_interface
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def merge_streams(
|
|
18
|
+
*its: t.AsyncIterable[T]
|
|
19
|
+
) -> t.AsyncIterator[T]:
|
|
20
|
+
# TODO: better error handling here
|
|
21
|
+
queue = asyncio.Queue(1)
|
|
22
|
+
|
|
23
|
+
async def task(it: t.AsyncIterable[T]):
|
|
24
|
+
async for item in it:
|
|
25
|
+
await queue.put(item)
|
|
26
|
+
|
|
27
|
+
async with asyncio.TaskGroup() as tg:
|
|
28
|
+
tasks = [tg.create_task(task(it)) for it in its]
|
|
29
|
+
|
|
30
|
+
while not all(task.done() for task in tasks):
|
|
31
|
+
yield await queue.get()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def encode_obj(obj: t.Any, to_numpy: bool = True) -> t.Any:
|
|
35
|
+
if isinstance(obj, numpy.ndarray):
|
|
36
|
+
if not to_numpy:
|
|
37
|
+
return obj.tolist()
|
|
38
|
+
d = obj.__array_interface__
|
|
39
|
+
d['data'] = base64.urlsafe_b64encode(obj.tobytes()).decode('ascii')
|
|
40
|
+
d['_ty'] = 'numpy'
|
|
41
|
+
return d
|
|
42
|
+
|
|
43
|
+
if isinstance(obj, bytes):
|
|
44
|
+
return {
|
|
45
|
+
'_ty': 'bytes',
|
|
46
|
+
'data': base64.urlsafe_b64encode(obj).decode('ascii')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if isinstance(obj, dict):
|
|
50
|
+
d = obj
|
|
51
|
+
elif dataclasses.is_dataclass(obj):
|
|
52
|
+
d = obj.asdict() # type: ignore
|
|
53
|
+
else:
|
|
54
|
+
return obj
|
|
55
|
+
|
|
56
|
+
return {k: encode_obj(v, to_numpy and k not in ('sampling',)) for (k, v) in d.items()}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def decode_obj(obj: t.Any) -> t.Any:
|
|
60
|
+
if isinstance(obj, dict):
|
|
61
|
+
d = obj
|
|
62
|
+
elif dataclasses.is_dataclass(obj):
|
|
63
|
+
d = obj.asdict() # type: ignore
|
|
64
|
+
else:
|
|
65
|
+
return obj
|
|
66
|
+
|
|
67
|
+
if '_ty' not in d:
|
|
68
|
+
return {k: decode_obj(v) for (k, v) in d.items()}
|
|
69
|
+
ty = d.pop('_ty')
|
|
70
|
+
|
|
71
|
+
if ty == 'numpy':
|
|
72
|
+
d['data'] = base64.urlsafe_b64decode(d['data'].encode('utf-8'))
|
|
73
|
+
d['shape'] = tuple(d['shape'])
|
|
74
|
+
return numpy.array(_array_dummy(d))
|
|
75
|
+
|
|
76
|
+
if ty == 'bytes':
|
|
77
|
+
return base64.urlsafe_b64decode(d['data'].encode('utf-8'))
|
|
78
|
+
|
|
79
|
+
raise ValueError(f"Unknown custom type '{ty}', while parsing reconstruction state update")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ReconsStateConverter(Converter[t.Dict[str, t.Any]]):
|
|
83
|
+
def __init__(self):
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
def expected(self, plural: bool = False) -> str:
|
|
87
|
+
return "reconstruction state"
|
|
88
|
+
|
|
89
|
+
def into_data(self, val: t.Any) -> t.Any:
|
|
90
|
+
return encode_obj(val)
|
|
91
|
+
|
|
92
|
+
def try_convert(self, val: t.Any) -> t.Dict[str, t.Any]:
|
|
93
|
+
try:
|
|
94
|
+
val = decode_obj(val)
|
|
95
|
+
except Exception:
|
|
96
|
+
raise ParseInterrupt()
|
|
97
|
+
#if not isinstance(val, t.Mapping) or ({'iter', 'wavelength'} - val.keys()):
|
|
98
|
+
# raise ParseInterrupt()
|
|
99
|
+
|
|
100
|
+
return val # type: ignore
|
|
101
|
+
|
|
102
|
+
def collect_errors(self, val: t.Any) -> t.Union[ProductErrorNode, WrongTypeError, None]:
|
|
103
|
+
try:
|
|
104
|
+
val = decode_obj(val)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
import traceback
|
|
107
|
+
|
|
108
|
+
tb = e.__traceback__.tb_next # type: ignore
|
|
109
|
+
tb = traceback.TracebackException(type(e), e, tb)
|
|
110
|
+
return WrongTypeError(self.expected(), val, tb)
|
|
111
|
+
if not isinstance(val, t.Mapping):
|
|
112
|
+
return WrongTypeError(self.expected(), val)
|
|
113
|
+
#if (missing := {'iter', 'wavelength'} - val.keys()):
|
|
114
|
+
# return ProductErrorNode(self.expected(), {}, val, missing)
|
|
115
|
+
|
|
116
|
+
return None
|