PyGeoModel 1.0.8__tar.gz → 1.0.10__tar.gz
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.
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/PKG-INFO +1 -1
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/PyGeoModel.egg-info/PKG-INFO +1 -1
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/__init__.py +1 -1
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/modeler.py +1 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/models.py +8 -2
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/results.py +377 -52
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/setup.py +1 -1
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/tests/test_core_api.py +139 -1
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/LICENSE +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/MANIFEST.in +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/PyGeoModel.egg-info/SOURCES.txt +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/PyGeoModel.egg-info/dependency_links.txt +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/PyGeoModel.egg-info/requires.txt +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/PyGeoModel.egg-info/top_level.txt +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/README.md +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/__init__.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/base.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/constants.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openModel.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openUtils/__init__.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openUtils/exceptions.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openUtils/http_client.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openUtils/mdlUtils.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openUtils/parameterValidator.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openUtils/stateManager.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/client.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/config.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/consensus.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/context.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/data/__init__.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/data/computeModel.json +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/data/modelContext.txt +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/notebook.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/qa.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/recommendation.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/scripts.py +0 -0
- {pygeomodel-1.0.8 → pygeomodel-1.0.10}/setup.cfg +0 -0
|
@@ -90,6 +90,7 @@ class GeoModeler:
|
|
|
90
90
|
outputs=response.get("outputs", []),
|
|
91
91
|
execution_time=response.get("execution_time", time.time() - started),
|
|
92
92
|
endpoint=getattr(self.client, "manager_url", None),
|
|
93
|
+
record_path=str(record_path) if record_path else None,
|
|
93
94
|
)
|
|
94
95
|
if output_dir:
|
|
95
96
|
result.download(output_dir)
|
|
@@ -201,9 +201,15 @@ class ModelService:
|
|
|
201
201
|
return str(value)
|
|
202
202
|
dtype = (item.data_type or "").upper()
|
|
203
203
|
if dtype in {"REAL", "DOUBLE", "FLOAT"}:
|
|
204
|
-
|
|
204
|
+
try:
|
|
205
|
+
return float(value)
|
|
206
|
+
except (TypeError, ValueError):
|
|
207
|
+
return value
|
|
205
208
|
if dtype in {"INT", "INTEGER"}:
|
|
206
|
-
|
|
209
|
+
try:
|
|
210
|
+
return int(value)
|
|
211
|
+
except (TypeError, ValueError):
|
|
212
|
+
return value
|
|
207
213
|
if dtype in {"BOOL", "BOOLEAN"}:
|
|
208
214
|
if isinstance(value, str):
|
|
209
215
|
return value.strip().lower() in {"true", "1", "yes", "y"}
|
|
@@ -23,26 +23,47 @@ class TaskResult:
|
|
|
23
23
|
model_name: str
|
|
24
24
|
status: str = "unknown"
|
|
25
25
|
outputs: list[dict[str, Any]] = field(default_factory=list)
|
|
26
|
+
downloaded_outputs: list[str] = field(default_factory=list)
|
|
26
27
|
task_id: str | None = None
|
|
27
28
|
model_id: str | None = None
|
|
28
29
|
model_md5: str | None = None
|
|
29
30
|
params: dict[str, Any] = field(default_factory=dict)
|
|
30
31
|
uploaded_inputs: dict[str, Any] = field(default_factory=dict)
|
|
31
32
|
endpoint: str | None = None
|
|
32
|
-
pygeomodel_version: str = "1.0.
|
|
33
|
+
pygeomodel_version: str = "1.0.10"
|
|
33
34
|
execution_time: float | None = None
|
|
35
|
+
record_path: str | None = None
|
|
34
36
|
created_at: float = field(default_factory=time.time)
|
|
35
37
|
|
|
36
38
|
def to_dict(self) -> dict[str, Any]:
|
|
37
39
|
return asdict(self)
|
|
38
40
|
|
|
39
41
|
def to_json(self, path: str | Path) -> str:
|
|
42
|
+
self.record_path = str(path)
|
|
40
43
|
return _write_json(self.to_dict(), path)
|
|
41
44
|
|
|
42
45
|
def download(self, output_dir: str | Path) -> list[str]:
|
|
43
46
|
from .client import download_output_files
|
|
44
47
|
|
|
45
|
-
|
|
48
|
+
self.downloaded_outputs = download_output_files(self.outputs, output_dir)
|
|
49
|
+
return self.downloaded_outputs
|
|
50
|
+
|
|
51
|
+
def save(
|
|
52
|
+
self,
|
|
53
|
+
output_dir: str | Path | None = None,
|
|
54
|
+
record_path: str | Path | None = None,
|
|
55
|
+
) -> list[str]:
|
|
56
|
+
if output_dir is not None:
|
|
57
|
+
self.download(output_dir)
|
|
58
|
+
if record_path is not None:
|
|
59
|
+
self.to_json(record_path)
|
|
60
|
+
return self.downloaded_outputs
|
|
61
|
+
|
|
62
|
+
def _repr_markdown_(self) -> str:
|
|
63
|
+
return f"Model run: {self.model_name}\n\nStatus: {self.status}\n\nTask ID: {self.task_id or 'Not returned'}"
|
|
64
|
+
|
|
65
|
+
def _repr_html_(self) -> str:
|
|
66
|
+
return _task_result_to_html(self)
|
|
46
67
|
|
|
47
68
|
|
|
48
69
|
@dataclass
|
|
@@ -207,13 +228,287 @@ class QAResult:
|
|
|
207
228
|
"""
|
|
208
229
|
|
|
209
230
|
|
|
231
|
+
def _task_result_to_html(result: TaskResult) -> str:
|
|
232
|
+
status = html.escape(str(result.status or "unknown"))
|
|
233
|
+
status_class = "success" if status.lower() == "completed" else "neutral"
|
|
234
|
+
task_id = html.escape(str(result.task_id or "Not returned"))
|
|
235
|
+
runtime = _format_seconds(result.execution_time)
|
|
236
|
+
record_path = html.escape(str(result.record_path or "Not saved"))
|
|
237
|
+
endpoint = html.escape(str(result.endpoint or "Not recorded"))
|
|
238
|
+
output_count = len(result.outputs or [])
|
|
239
|
+
downloaded_count = len(result.downloaded_outputs or [])
|
|
240
|
+
outputs_html = _task_outputs_to_html(result.outputs or [])
|
|
241
|
+
downloaded_html = _downloaded_outputs_to_html(result.downloaded_outputs or [])
|
|
242
|
+
params_json = html.escape(json.dumps(result.params or {}, ensure_ascii=False, indent=2), quote=False)
|
|
243
|
+
uploaded_json = html.escape(json.dumps(result.uploaded_inputs or {}, ensure_ascii=False, indent=2), quote=False)
|
|
244
|
+
|
|
245
|
+
return f"""
|
|
246
|
+
<style>
|
|
247
|
+
.pygeomodel-task-card {{
|
|
248
|
+
font-family: PingFang SC, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
249
|
+
color: #1e293b;
|
|
250
|
+
border: 1px solid #dbe3ef;
|
|
251
|
+
border-radius: 8px;
|
|
252
|
+
background: #ffffff;
|
|
253
|
+
overflow: hidden;
|
|
254
|
+
max-width: 100%;
|
|
255
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
256
|
+
}}
|
|
257
|
+
.pygeomodel-task-card * {{ box-sizing: border-box; }}
|
|
258
|
+
.pygeomodel-task-head {{
|
|
259
|
+
display: flex;
|
|
260
|
+
align-items: flex-start;
|
|
261
|
+
gap: 14px;
|
|
262
|
+
padding: 16px 18px;
|
|
263
|
+
border-bottom: 1px solid #e5edf6;
|
|
264
|
+
background: #fbfdff;
|
|
265
|
+
}}
|
|
266
|
+
.pygeomodel-task-status {{
|
|
267
|
+
flex: 0 0 auto;
|
|
268
|
+
min-width: 88px;
|
|
269
|
+
text-align: center;
|
|
270
|
+
border-radius: 999px;
|
|
271
|
+
padding: 4px 10px;
|
|
272
|
+
font-size: 12px;
|
|
273
|
+
line-height: 1.5;
|
|
274
|
+
font-weight: 750;
|
|
275
|
+
border: 1px solid #cbd5e1;
|
|
276
|
+
color: #475569;
|
|
277
|
+
background: #f8fafc;
|
|
278
|
+
}}
|
|
279
|
+
.pygeomodel-task-status.success {{
|
|
280
|
+
border-color: #bbf7d0;
|
|
281
|
+
color: #15803d;
|
|
282
|
+
background: #f0fdf4;
|
|
283
|
+
}}
|
|
284
|
+
.pygeomodel-task-title {{
|
|
285
|
+
min-width: 0;
|
|
286
|
+
}}
|
|
287
|
+
.pygeomodel-task-title h4 {{
|
|
288
|
+
margin: 0;
|
|
289
|
+
color: #0f172a;
|
|
290
|
+
font-size: 15px;
|
|
291
|
+
line-height: 1.4;
|
|
292
|
+
font-weight: 750;
|
|
293
|
+
}}
|
|
294
|
+
.pygeomodel-task-title p {{
|
|
295
|
+
margin: 4px 0 0 0;
|
|
296
|
+
color: #64748b;
|
|
297
|
+
font-size: 12px;
|
|
298
|
+
line-height: 1.5;
|
|
299
|
+
}}
|
|
300
|
+
.pygeomodel-task-body {{
|
|
301
|
+
padding: 14px 18px 16px 18px;
|
|
302
|
+
}}
|
|
303
|
+
.pygeomodel-task-meta {{
|
|
304
|
+
display: grid;
|
|
305
|
+
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
|
306
|
+
gap: 10px;
|
|
307
|
+
margin-bottom: 16px;
|
|
308
|
+
}}
|
|
309
|
+
.pygeomodel-task-meta div {{
|
|
310
|
+
border-top: 1px solid #e5edf6;
|
|
311
|
+
padding-top: 8px;
|
|
312
|
+
}}
|
|
313
|
+
.pygeomodel-task-meta b {{
|
|
314
|
+
display: block;
|
|
315
|
+
color: #475569;
|
|
316
|
+
font-size: 11px;
|
|
317
|
+
line-height: 1.4;
|
|
318
|
+
font-weight: 750;
|
|
319
|
+
text-transform: uppercase;
|
|
320
|
+
letter-spacing: 0.02em;
|
|
321
|
+
}}
|
|
322
|
+
.pygeomodel-task-meta span {{
|
|
323
|
+
display: block;
|
|
324
|
+
margin-top: 3px;
|
|
325
|
+
color: #0f172a;
|
|
326
|
+
font-size: 12px;
|
|
327
|
+
line-height: 1.45;
|
|
328
|
+
overflow-wrap: anywhere;
|
|
329
|
+
}}
|
|
330
|
+
.pygeomodel-task-section {{
|
|
331
|
+
margin-top: 14px;
|
|
332
|
+
}}
|
|
333
|
+
.pygeomodel-task-section h4 {{
|
|
334
|
+
margin: 0 0 8px 0;
|
|
335
|
+
color: #0f172a;
|
|
336
|
+
font-size: 14px;
|
|
337
|
+
line-height: 1.4;
|
|
338
|
+
font-weight: 750;
|
|
339
|
+
}}
|
|
340
|
+
.pygeomodel-task-table {{
|
|
341
|
+
display: grid;
|
|
342
|
+
grid-template-columns: minmax(180px, 1.1fr) minmax(140px, 0.8fr) minmax(100px, 0.55fr) minmax(150px, 0.8fr);
|
|
343
|
+
border: 1px solid #dbe3ef;
|
|
344
|
+
border-radius: 8px;
|
|
345
|
+
overflow: hidden;
|
|
346
|
+
font-size: 12px;
|
|
347
|
+
line-height: 1.45;
|
|
348
|
+
}}
|
|
349
|
+
.pygeomodel-task-table > div {{
|
|
350
|
+
padding: 9px 11px;
|
|
351
|
+
border-bottom: 1px solid #e8eef6;
|
|
352
|
+
overflow-wrap: anywhere;
|
|
353
|
+
}}
|
|
354
|
+
.pygeomodel-task-table .head {{
|
|
355
|
+
background: #f8fafc;
|
|
356
|
+
color: #475569;
|
|
357
|
+
font-weight: 750;
|
|
358
|
+
text-transform: uppercase;
|
|
359
|
+
letter-spacing: 0.02em;
|
|
360
|
+
font-size: 11px;
|
|
361
|
+
}}
|
|
362
|
+
.pygeomodel-task-table .muted {{
|
|
363
|
+
color: #94a3b8;
|
|
364
|
+
}}
|
|
365
|
+
.pygeomodel-task-table a {{
|
|
366
|
+
color: #1d4ed8;
|
|
367
|
+
text-decoration: none;
|
|
368
|
+
}}
|
|
369
|
+
.pygeomodel-task-table a:hover {{ text-decoration: underline; }}
|
|
370
|
+
.pygeomodel-task-details {{
|
|
371
|
+
margin-top: 14px;
|
|
372
|
+
border-top: 1px solid #e5edf6;
|
|
373
|
+
padding-top: 10px;
|
|
374
|
+
color: #334155;
|
|
375
|
+
font-size: 12px;
|
|
376
|
+
}}
|
|
377
|
+
.pygeomodel-task-details summary {{
|
|
378
|
+
cursor: pointer;
|
|
379
|
+
font-weight: 700;
|
|
380
|
+
}}
|
|
381
|
+
.pygeomodel-task-details pre {{
|
|
382
|
+
margin: 10px 0 0 0;
|
|
383
|
+
padding: 10px;
|
|
384
|
+
border-radius: 6px;
|
|
385
|
+
background: #f8fafc;
|
|
386
|
+
border: 1px solid #e5edf6;
|
|
387
|
+
color: #0f172a;
|
|
388
|
+
font-size: 11px;
|
|
389
|
+
line-height: 1.45;
|
|
390
|
+
overflow-x: auto;
|
|
391
|
+
white-space: pre-wrap;
|
|
392
|
+
}}
|
|
393
|
+
@media (max-width: 840px) {{
|
|
394
|
+
.pygeomodel-task-head {{
|
|
395
|
+
display: block;
|
|
396
|
+
}}
|
|
397
|
+
.pygeomodel-task-status {{
|
|
398
|
+
display: inline-block;
|
|
399
|
+
margin-bottom: 10px;
|
|
400
|
+
}}
|
|
401
|
+
.pygeomodel-task-table {{
|
|
402
|
+
grid-template-columns: minmax(120px, 1fr) minmax(120px, 1fr);
|
|
403
|
+
}}
|
|
404
|
+
}}
|
|
405
|
+
</style>
|
|
406
|
+
<div class="pygeomodel-task-card">
|
|
407
|
+
<div class="pygeomodel-task-head">
|
|
408
|
+
<span class="pygeomodel-task-status {status_class}">{status}</span>
|
|
409
|
+
<div class="pygeomodel-task-title">
|
|
410
|
+
<h4>Model Execution Summary</h4>
|
|
411
|
+
<p>{html.escape(result.model_name)}</p>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
<div class="pygeomodel-task-body">
|
|
415
|
+
<div class="pygeomodel-task-meta">
|
|
416
|
+
<div><b>Task ID</b><span>{task_id}</span></div>
|
|
417
|
+
<div><b>Runtime</b><span>{html.escape(runtime)}</span></div>
|
|
418
|
+
<div><b>Output resources</b><span>{output_count}</span></div>
|
|
419
|
+
<div><b>Saved output files</b><span>{downloaded_count}</span></div>
|
|
420
|
+
<div><b>Execution record</b><span>{record_path}</span></div>
|
|
421
|
+
<div><b>Endpoint</b><span>{endpoint}</span></div>
|
|
422
|
+
<div><b>PyGeoModel</b><span>{html.escape(str(result.pygeomodel_version))}</span></div>
|
|
423
|
+
</div>
|
|
424
|
+
<div class="pygeomodel-task-section">
|
|
425
|
+
<h4>Output resources</h4>
|
|
426
|
+
{outputs_html}
|
|
427
|
+
</div>
|
|
428
|
+
<div class="pygeomodel-task-section">
|
|
429
|
+
<h4>Saved output files</h4>
|
|
430
|
+
{downloaded_html}
|
|
431
|
+
</div>
|
|
432
|
+
<details class="pygeomodel-task-details">
|
|
433
|
+
<summary>Input parameters</summary>
|
|
434
|
+
<pre>{params_json}</pre>
|
|
435
|
+
</details>
|
|
436
|
+
<details class="pygeomodel-task-details">
|
|
437
|
+
<summary>Uploaded input structure</summary>
|
|
438
|
+
<pre>{uploaded_json}</pre>
|
|
439
|
+
</details>
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _task_outputs_to_html(outputs: list[dict[str, Any]]) -> str:
|
|
446
|
+
if not outputs:
|
|
447
|
+
return '<div class="pygeomodel-task-table"><div class="muted">No output resources were returned.</div></div>'
|
|
448
|
+
|
|
449
|
+
rows = [
|
|
450
|
+
'<div class="head">Name</div>',
|
|
451
|
+
'<div class="head">State</div>',
|
|
452
|
+
'<div class="head">Format</div>',
|
|
453
|
+
'<div class="head">Access</div>',
|
|
454
|
+
]
|
|
455
|
+
for output in outputs:
|
|
456
|
+
name = html.escape(str(output.get("tag") or output.get("event") or "Output"))
|
|
457
|
+
state = html.escape(str(output.get("statename") or ""))
|
|
458
|
+
suffix = html.escape(str(output.get("suffix") or "Not specified"))
|
|
459
|
+
url = str(output.get("url") or "")
|
|
460
|
+
if url:
|
|
461
|
+
safe_url = html.escape(url)
|
|
462
|
+
access = f'<a href="{safe_url}" target="_blank" rel="noopener noreferrer">Open output</a>'
|
|
463
|
+
else:
|
|
464
|
+
access = '<span class="muted">No downloadable URL returned</span>'
|
|
465
|
+
rows.extend([f"<div>{name}</div>", f"<div>{state}</div>", f"<div>{suffix}</div>", f"<div>{access}</div>"])
|
|
466
|
+
return f'<div class="pygeomodel-task-table">{"".join(rows)}</div>'
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _downloaded_outputs_to_html(paths: list[str]) -> str:
|
|
470
|
+
if not paths:
|
|
471
|
+
return (
|
|
472
|
+
'<div class="pygeomodel-task-table">'
|
|
473
|
+
'<div class="muted">No output files were downloaded. This can happen when the OpenGMS service returns output metadata without a downloadable URL.</div>'
|
|
474
|
+
'</div>'
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
rows = [
|
|
478
|
+
'<div class="head">File</div>',
|
|
479
|
+
'<div class="head">Location</div>',
|
|
480
|
+
'<div class="head">Status</div>',
|
|
481
|
+
'<div class="head">Access</div>',
|
|
482
|
+
]
|
|
483
|
+
for path in paths:
|
|
484
|
+
safe_path = html.escape(str(path))
|
|
485
|
+
name = html.escape(Path(path).name)
|
|
486
|
+
rows.extend([
|
|
487
|
+
f"<div>{name}</div>",
|
|
488
|
+
f"<div>{safe_path}</div>",
|
|
489
|
+
"<div>Saved</div>",
|
|
490
|
+
"<div>Local file</div>",
|
|
491
|
+
])
|
|
492
|
+
return f'<div class="pygeomodel-task-table">{"".join(rows)}</div>'
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _format_seconds(value: float | None) -> str:
|
|
496
|
+
if value is None:
|
|
497
|
+
return "Not recorded"
|
|
498
|
+
if value < 60:
|
|
499
|
+
return f"{value:.2f} s"
|
|
500
|
+
minutes = int(value // 60)
|
|
501
|
+
seconds = value - minutes * 60
|
|
502
|
+
return f"{minutes} min {seconds:.1f} s"
|
|
503
|
+
|
|
504
|
+
|
|
210
505
|
def _recommendation_to_html(result: RecommendationResult) -> str:
|
|
211
506
|
candidates = result.candidates or []
|
|
212
507
|
recommended_data = result.recommended_data or {}
|
|
213
508
|
|
|
214
|
-
|
|
215
|
-
if not
|
|
216
|
-
|
|
509
|
+
candidate_rows = "".join(_recommendation_candidate_row(candidate) for candidate in candidates)
|
|
510
|
+
if not candidate_rows:
|
|
511
|
+
candidate_rows = '<div class="pygeomodel-rec-empty">No candidate models were returned.</div>'
|
|
217
512
|
|
|
218
513
|
local_data_html = _recommendation_data_items(recommended_data.get("local_data", []), label_key="location")
|
|
219
514
|
kb_data_html = _recommendation_data_items(recommended_data.get("knowledge_base_data", []), label_key="url")
|
|
@@ -247,56 +542,66 @@ def _recommendation_to_html(result: RecommendationResult) -> str:
|
|
|
247
542
|
line-height: 1.4;
|
|
248
543
|
font-weight: 750;
|
|
249
544
|
}}
|
|
250
|
-
.pygeomodel-rec-
|
|
251
|
-
display: grid;
|
|
252
|
-
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
253
|
-
gap: 10px;
|
|
254
|
-
}}
|
|
255
|
-
.pygeomodel-rec-candidate {{
|
|
545
|
+
.pygeomodel-rec-list {{
|
|
256
546
|
border: 1px solid #dbe3ef;
|
|
257
547
|
border-radius: 8px;
|
|
258
|
-
|
|
548
|
+
overflow: hidden;
|
|
259
549
|
background: #ffffff;
|
|
260
550
|
}}
|
|
261
|
-
.pygeomodel-rec-
|
|
262
|
-
|
|
551
|
+
.pygeomodel-rec-list-head,
|
|
552
|
+
.pygeomodel-rec-row {{
|
|
553
|
+
display: grid;
|
|
554
|
+
grid-template-columns: 90px minmax(220px, 0.95fr) minmax(280px, 1.25fr) minmax(220px, 1fr);
|
|
555
|
+
column-gap: 18px;
|
|
556
|
+
align-items: start;
|
|
557
|
+
}}
|
|
558
|
+
.pygeomodel-rec-list-head {{
|
|
559
|
+
padding: 9px 14px;
|
|
560
|
+
background: #f8fafc;
|
|
561
|
+
border-bottom: 1px solid #dbe3ef;
|
|
562
|
+
color: #475569;
|
|
563
|
+
font-size: 11px;
|
|
564
|
+
font-weight: 750;
|
|
565
|
+
letter-spacing: 0.02em;
|
|
566
|
+
text-transform: uppercase;
|
|
567
|
+
}}
|
|
568
|
+
.pygeomodel-rec-row {{
|
|
569
|
+
padding: 13px 14px;
|
|
570
|
+
border-bottom: 1px solid #e8eef6;
|
|
571
|
+
}}
|
|
572
|
+
.pygeomodel-rec-row:last-child {{
|
|
573
|
+
border-bottom: 0;
|
|
574
|
+
}}
|
|
575
|
+
.pygeomodel-rec-row.primary {{
|
|
263
576
|
background: #f8fbff;
|
|
577
|
+
box-shadow: inset 3px 0 0 #60a5fa;
|
|
264
578
|
}}
|
|
265
|
-
.pygeomodel-rec-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
579
|
+
.pygeomodel-rec-rank {{
|
|
580
|
+
color: #64748b;
|
|
581
|
+
font-size: 12px;
|
|
582
|
+
font-weight: 750;
|
|
583
|
+
line-height: 1.4;
|
|
584
|
+
white-space: nowrap;
|
|
271
585
|
}}
|
|
272
|
-
.pygeomodel-rec-
|
|
586
|
+
.pygeomodel-rec-name {{
|
|
273
587
|
color: #0f172a;
|
|
274
588
|
font-size: 13px;
|
|
275
589
|
line-height: 1.35;
|
|
276
590
|
font-weight: 750;
|
|
277
591
|
}}
|
|
278
|
-
.pygeomodel-rec-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
border: 1px solid #bfdbfe;
|
|
282
|
-
background: #dbeafe;
|
|
283
|
-
color: #1d4ed8;
|
|
284
|
-
padding: 2px 7px;
|
|
285
|
-
font-size: 11px;
|
|
286
|
-
font-weight: 700;
|
|
287
|
-
line-height: 1.4;
|
|
288
|
-
}}
|
|
289
|
-
.pygeomodel-rec-rank {{
|
|
290
|
-
color: #64748b;
|
|
291
|
-
font-size: 11px;
|
|
292
|
-
font-weight: 700;
|
|
293
|
-
}}
|
|
294
|
-
.pygeomodel-rec-candidate p {{
|
|
295
|
-
margin: 6px 0 0 0;
|
|
592
|
+
.pygeomodel-rec-description,
|
|
593
|
+
.pygeomodel-rec-text {{
|
|
594
|
+
margin: 0;
|
|
296
595
|
color: #475569;
|
|
297
596
|
font-size: 12px;
|
|
298
597
|
line-height: 1.5;
|
|
299
598
|
}}
|
|
599
|
+
.pygeomodel-rec-description {{
|
|
600
|
+
margin-top: 5px;
|
|
601
|
+
}}
|
|
602
|
+
.pygeomodel-rec-muted {{
|
|
603
|
+
color: #94a3b8;
|
|
604
|
+
}}
|
|
300
605
|
.pygeomodel-rec-data-grid {{
|
|
301
606
|
display: grid;
|
|
302
607
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
@@ -332,11 +637,32 @@ def _recommendation_to_html(result: RecommendationResult) -> str:
|
|
|
332
637
|
padding: 12px;
|
|
333
638
|
background: #f8fafc;
|
|
334
639
|
}}
|
|
640
|
+
@media (max-width: 900px) {{
|
|
641
|
+
.pygeomodel-rec-list-head {{
|
|
642
|
+
display: none;
|
|
643
|
+
}}
|
|
644
|
+
.pygeomodel-rec-row {{
|
|
645
|
+
grid-template-columns: 76px minmax(0, 1fr);
|
|
646
|
+
row-gap: 8px;
|
|
647
|
+
}}
|
|
648
|
+
.pygeomodel-rec-row > div:nth-child(3),
|
|
649
|
+
.pygeomodel-rec-row > div:nth-child(4) {{
|
|
650
|
+
grid-column: 2;
|
|
651
|
+
}}
|
|
652
|
+
}}
|
|
335
653
|
</style>
|
|
336
654
|
<div class="pygeomodel-rec-card">
|
|
337
655
|
<div class="pygeomodel-rec-body">
|
|
338
656
|
<div class="pygeomodel-rec-section">
|
|
339
|
-
<div class="pygeomodel-rec-
|
|
657
|
+
<div class="pygeomodel-rec-list">
|
|
658
|
+
<div class="pygeomodel-rec-list-head">
|
|
659
|
+
<div>Rank</div>
|
|
660
|
+
<div>Candidate model</div>
|
|
661
|
+
<div>Recommendation rationale</div>
|
|
662
|
+
<div>Input data</div>
|
|
663
|
+
</div>
|
|
664
|
+
{candidate_rows}
|
|
665
|
+
</div>
|
|
340
666
|
</div>
|
|
341
667
|
<div class="pygeomodel-rec-section">
|
|
342
668
|
<h4>Relevant Data</h4>
|
|
@@ -356,7 +682,7 @@ def _recommendation_to_html(result: RecommendationResult) -> str:
|
|
|
356
682
|
"""
|
|
357
683
|
|
|
358
684
|
|
|
359
|
-
def
|
|
685
|
+
def _recommendation_candidate_row(candidate: dict[str, Any]) -> str:
|
|
360
686
|
name = html.escape(str(candidate.get("name") or "Unnamed model"))
|
|
361
687
|
description = html.escape(str(candidate.get("description") or candidate.get("desc") or ""))
|
|
362
688
|
reason = html.escape(str(candidate.get("reason") or candidate.get("recommendation_reason") or ""))
|
|
@@ -364,20 +690,19 @@ def _recommendation_candidate_card(candidate: dict[str, Any]) -> str:
|
|
|
364
690
|
rank = html.escape(str(candidate.get("rank") or ""))
|
|
365
691
|
is_primary = bool(candidate.get("is_primary"))
|
|
366
692
|
primary_class = " primary" if is_primary else ""
|
|
367
|
-
|
|
693
|
+
rank_label = f"★ Rank {rank}" if is_primary else f"Rank {rank}"
|
|
368
694
|
|
|
369
695
|
return f"""
|
|
370
|
-
<div class="pygeomodel-rec-
|
|
371
|
-
<div class="pygeomodel-rec-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
</div>
|
|
376
|
-
{
|
|
696
|
+
<div class="pygeomodel-rec-row{primary_class}">
|
|
697
|
+
<div class="pygeomodel-rec-rank">
|
|
698
|
+
{rank_label}
|
|
699
|
+
</div>
|
|
700
|
+
<div>
|
|
701
|
+
<div class="pygeomodel-rec-name">{name}</div>
|
|
702
|
+
{f'<p class="pygeomodel-rec-description">{description}</p>' if description else ''}
|
|
377
703
|
</div>
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
{f'<p><b>Input data:</b> {input_desc}</p>' if input_desc else ''}
|
|
704
|
+
<p class="pygeomodel-rec-text">{reason if reason else '<span class="pygeomodel-rec-muted">No recommendation rationale recorded.</span>'}</p>
|
|
705
|
+
<p class="pygeomodel-rec-text">{input_desc if input_desc else '<span class="pygeomodel-rec-muted">No input-data description recorded.</span>'}</p>
|
|
381
706
|
</div>
|
|
382
707
|
"""
|
|
383
708
|
|
|
@@ -97,6 +97,36 @@ class CoreApiTests(unittest.TestCase):
|
|
|
97
97
|
self.assertEqual(normalized["SpatialAnalysis"]["end_time"], 201812)
|
|
98
98
|
self.assertTrue(normalized["SolarCalculation"]["roof_vector_path"].endswith("rooftops.zip"))
|
|
99
99
|
|
|
100
|
+
def test_model_invocation_records_downloaded_output_paths(self):
|
|
101
|
+
import pygeomodel.client as client_module
|
|
102
|
+
from pygeomodel import GeoModeler
|
|
103
|
+
|
|
104
|
+
original_downloader = client_module.download_output_files
|
|
105
|
+
try:
|
|
106
|
+
client_module.download_output_files = lambda outputs, output_dir: [
|
|
107
|
+
str(Path(output_dir) / "SolarCalculation-roofSloar.zip")
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
111
|
+
modeler = GeoModeler(client=FakeClient())
|
|
112
|
+
result = modeler.invoke(
|
|
113
|
+
"Roof Photovoltaic Carbon Emission Reduction Potential Assessment Model",
|
|
114
|
+
params={
|
|
115
|
+
"system_efficiency": 0.8,
|
|
116
|
+
"start_time": 201801,
|
|
117
|
+
"end_time": 201812,
|
|
118
|
+
"roof_vector_path": str(Path(tmpdir) / "rooftops.zip"),
|
|
119
|
+
},
|
|
120
|
+
output_dir=Path(tmpdir) / "outputs",
|
|
121
|
+
)
|
|
122
|
+
finally:
|
|
123
|
+
client_module.download_output_files = original_downloader
|
|
124
|
+
|
|
125
|
+
self.assertEqual(
|
|
126
|
+
result.downloaded_outputs,
|
|
127
|
+
[str(Path(tmpdir) / "outputs" / "SolarCalculation-roofSloar.zip")],
|
|
128
|
+
)
|
|
129
|
+
|
|
100
130
|
def test_model_invocation_preserves_multi_child_internal_params(self):
|
|
101
131
|
from pygeomodel import GeoModeler
|
|
102
132
|
|
|
@@ -298,6 +328,113 @@ class CoreApiTests(unittest.TestCase):
|
|
|
298
328
|
qa_path = qa.to_json(tmpdir / "qa.json")
|
|
299
329
|
self.assertEqual(json.loads(Path(qa_path).read_text())["answer"], "a")
|
|
300
330
|
|
|
331
|
+
def test_task_result_save_records_metadata_and_outputs(self):
|
|
332
|
+
import pygeomodel.client as client_module
|
|
333
|
+
from pygeomodel import TaskResult
|
|
334
|
+
|
|
335
|
+
original_downloader = client_module.download_output_files
|
|
336
|
+
try:
|
|
337
|
+
client_module.download_output_files = lambda outputs, output_dir: [
|
|
338
|
+
str(Path(output_dir) / "SolarCalculation-roofSloar.zip")
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
342
|
+
tmpdir = Path(tmpdir)
|
|
343
|
+
result = TaskResult(
|
|
344
|
+
model_name="m",
|
|
345
|
+
status="completed",
|
|
346
|
+
task_id="task-123",
|
|
347
|
+
outputs=[{"url": "http://example.test/result.zip", "tag": "roofSloar", "suffix": "zip"}],
|
|
348
|
+
params={"system_efficiency": 0.8},
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
returned = result.save(
|
|
352
|
+
output_dir=tmpdir / "outputs",
|
|
353
|
+
record_path=tmpdir / "records" / "execution_record.json",
|
|
354
|
+
)
|
|
355
|
+
record = json.loads((tmpdir / "records" / "execution_record.json").read_text())
|
|
356
|
+
finally:
|
|
357
|
+
client_module.download_output_files = original_downloader
|
|
358
|
+
|
|
359
|
+
self.assertEqual(returned, [str(tmpdir / "outputs" / "SolarCalculation-roofSloar.zip")])
|
|
360
|
+
self.assertEqual(
|
|
361
|
+
result.downloaded_outputs,
|
|
362
|
+
[str(tmpdir / "outputs" / "SolarCalculation-roofSloar.zip")],
|
|
363
|
+
)
|
|
364
|
+
self.assertEqual(record["task_id"], "task-123")
|
|
365
|
+
self.assertEqual(record["record_path"], str(tmpdir / "records" / "execution_record.json"))
|
|
366
|
+
self.assertEqual(record["downloaded_outputs"], result.downloaded_outputs)
|
|
367
|
+
|
|
368
|
+
def test_task_result_save_only_outputs_when_no_record_path_is_given(self):
|
|
369
|
+
import pygeomodel.client as client_module
|
|
370
|
+
from pygeomodel import TaskResult
|
|
371
|
+
|
|
372
|
+
original_downloader = client_module.download_output_files
|
|
373
|
+
try:
|
|
374
|
+
client_module.download_output_files = lambda outputs, output_dir: [
|
|
375
|
+
str(Path(output_dir) / "SolarCalculation-roofSloar.zip")
|
|
376
|
+
]
|
|
377
|
+
|
|
378
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
379
|
+
tmpdir = Path(tmpdir)
|
|
380
|
+
result = TaskResult(
|
|
381
|
+
model_name="m",
|
|
382
|
+
status="completed",
|
|
383
|
+
task_id="task-123",
|
|
384
|
+
outputs=[{"url": "http://example.test/result.zip", "tag": "roofSloar", "suffix": "zip"}],
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
returned = result.save(output_dir=tmpdir / "outputs")
|
|
388
|
+
implicit_record = tmpdir / "outputs" / "execution_record.json"
|
|
389
|
+
finally:
|
|
390
|
+
client_module.download_output_files = original_downloader
|
|
391
|
+
|
|
392
|
+
self.assertEqual(returned, [str(tmpdir / "outputs" / "SolarCalculation-roofSloar.zip")])
|
|
393
|
+
self.assertEqual(
|
|
394
|
+
result.downloaded_outputs,
|
|
395
|
+
[str(tmpdir / "outputs" / "SolarCalculation-roofSloar.zip")],
|
|
396
|
+
)
|
|
397
|
+
self.assertIsNone(result.record_path)
|
|
398
|
+
self.assertFalse(implicit_record.exists())
|
|
399
|
+
|
|
400
|
+
def test_task_result_has_notebook_rich_display(self):
|
|
401
|
+
from pygeomodel import TaskResult
|
|
402
|
+
|
|
403
|
+
result = TaskResult(
|
|
404
|
+
model_name="Roof Photovoltaic Carbon Emission Reduction Potential Assessment Model",
|
|
405
|
+
status="completed",
|
|
406
|
+
task_id="task-123",
|
|
407
|
+
outputs=[
|
|
408
|
+
{
|
|
409
|
+
"statename": "SolarCalculation",
|
|
410
|
+
"event": "roofSloar",
|
|
411
|
+
"tag": "SolarCalculation-roofSloar",
|
|
412
|
+
"suffix": "",
|
|
413
|
+
"url": "",
|
|
414
|
+
}
|
|
415
|
+
],
|
|
416
|
+
params={
|
|
417
|
+
"system_efficiency": 0.8,
|
|
418
|
+
"start_time": 201801,
|
|
419
|
+
"end_time": 201812,
|
|
420
|
+
"roof_vector_path": "data/xuanwu_rooftop.zip",
|
|
421
|
+
},
|
|
422
|
+
record_path="records/execution_record.json",
|
|
423
|
+
execution_time=103.39,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
html = result._repr_html_()
|
|
427
|
+
|
|
428
|
+
self.assertIn("Model Execution Summary", html)
|
|
429
|
+
self.assertIn("completed", html)
|
|
430
|
+
self.assertIn("task-123", html)
|
|
431
|
+
self.assertIn("records/execution_record.json", html)
|
|
432
|
+
self.assertIn("Input parameters", html)
|
|
433
|
+
self.assertIn("roof_vector_path", html)
|
|
434
|
+
self.assertIn("Output resources", html)
|
|
435
|
+
self.assertIn("No downloadable URL returned", html)
|
|
436
|
+
self.assertNotIn("TaskResult(", html)
|
|
437
|
+
|
|
301
438
|
def test_opengms_config_uses_public_demo_token_as_fallback(self):
|
|
302
439
|
import pygeomodel.config as config_module
|
|
303
440
|
|
|
@@ -403,7 +540,8 @@ class CoreApiTests(unittest.TestCase):
|
|
|
403
540
|
|
|
404
541
|
html = result._repr_html_()
|
|
405
542
|
|
|
406
|
-
self.assertIn("
|
|
543
|
+
self.assertIn("★ Rank 1", html)
|
|
544
|
+
self.assertNotIn("Recommended</span>", html)
|
|
407
545
|
self.assertIn("Solar Potential Analysis Model", html)
|
|
408
546
|
self.assertIn("Rooftop Suitability Model", html)
|
|
409
547
|
self.assertIn("Relevant Data", html)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|