PyGeoModel 1.0.8__tar.gz → 1.0.9__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.
Files changed (37) hide show
  1. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/PKG-INFO +1 -1
  2. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/PyGeoModel.egg-info/PKG-INFO +1 -1
  3. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/__init__.py +1 -1
  4. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/modeler.py +1 -0
  5. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/results.py +383 -43
  6. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/setup.py +1 -1
  7. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/tests/test_core_api.py +137 -0
  8. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/LICENSE +0 -0
  9. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/MANIFEST.in +0 -0
  10. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/PyGeoModel.egg-info/SOURCES.txt +0 -0
  11. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/PyGeoModel.egg-info/dependency_links.txt +0 -0
  12. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/PyGeoModel.egg-info/requires.txt +0 -0
  13. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/PyGeoModel.egg-info/top_level.txt +0 -0
  14. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/README.md +0 -0
  15. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/ogmsServer2/__init__.py +0 -0
  16. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/ogmsServer2/base.py +0 -0
  17. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/ogmsServer2/constants.py +0 -0
  18. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/ogmsServer2/openModel.py +0 -0
  19. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/ogmsServer2/openUtils/__init__.py +0 -0
  20. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/ogmsServer2/openUtils/exceptions.py +0 -0
  21. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/ogmsServer2/openUtils/http_client.py +0 -0
  22. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/ogmsServer2/openUtils/mdlUtils.py +0 -0
  23. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/ogmsServer2/openUtils/parameterValidator.py +0 -0
  24. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/ogmsServer2/openUtils/stateManager.py +0 -0
  25. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/client.py +0 -0
  26. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/config.py +0 -0
  27. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/consensus.py +0 -0
  28. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/context.py +0 -0
  29. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/data/__init__.py +0 -0
  30. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/data/computeModel.json +0 -0
  31. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/data/modelContext.txt +0 -0
  32. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/models.py +0 -0
  33. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/notebook.py +0 -0
  34. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/qa.py +0 -0
  35. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/pygeomodel/recommendation.py +0 -0
  36. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/scripts.py +0 -0
  37. {pygeomodel-1.0.8 → pygeomodel-1.0.9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyGeoModel
3
- Version: 1.0.8
3
+ Version: 1.0.9
4
4
  Summary: A Python package for integrating OpenGMS geographic model services.
5
5
  Home-page: https://github.com/MpLebron/PyGeoModel
6
6
  Author: Peilong Ma
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyGeoModel
3
- Version: 1.0.8
3
+ Version: 1.0.9
4
4
  Summary: A Python package for integrating OpenGMS geographic model services.
5
5
  Home-page: https://github.com/MpLebron/PyGeoModel
6
6
  Author: Peilong Ma
@@ -5,7 +5,7 @@ from .modeler import GeoModeler
5
5
  from .models import ModelInput, ModelOutput, ModelService, ModelSummary
6
6
  from .results import QAResult, RecommendationResult, TaskResult
7
7
 
8
- __version__ = "1.0.8"
8
+ __version__ = "1.0.9"
9
9
 
10
10
  __all__ = [
11
11
  "GeoModeler",
@@ -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)
@@ -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.8"
33
+ pygeomodel_version: str = "1.0.9"
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
- return download_output_files(self.outputs, output_dir)
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
+ ) -> "TaskResult":
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
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
- candidate_cards = "".join(_recommendation_candidate_card(candidate) for candidate in candidates)
215
- if not candidate_cards:
216
- candidate_cards = '<div class="pygeomodel-rec-empty">No candidate models were returned.</div>'
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,36 +542,50 @@ def _recommendation_to_html(result: RecommendationResult) -> str:
247
542
  line-height: 1.4;
248
543
  font-weight: 750;
249
544
  }}
250
- .pygeomodel-rec-candidates {{
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
- padding: 12px;
548
+ overflow: hidden;
259
549
  background: #ffffff;
260
550
  }}
261
- .pygeomodel-rec-candidate.primary {{
262
- border-color: #93c5fd;
263
- background: #f8fbff;
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;
264
557
  }}
265
- .pygeomodel-rec-candidate-top {{
266
- display: flex;
267
- gap: 8px;
268
- align-items: flex-start;
269
- justify-content: space-between;
270
- margin-bottom: 7px;
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;
271
567
  }}
272
- .pygeomodel-rec-candidate-name {{
273
- color: #0f172a;
274
- font-size: 13px;
275
- line-height: 1.35;
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 {{
576
+ background: #f8fbff;
577
+ box-shadow: inset 3px 0 0 #60a5fa;
578
+ }}
579
+ .pygeomodel-rec-rank {{
580
+ color: #64748b;
581
+ font-size: 12px;
276
582
  font-weight: 750;
583
+ line-height: 1.4;
584
+ white-space: nowrap;
277
585
  }}
278
586
  .pygeomodel-rec-badge {{
279
- flex: 0 0 auto;
587
+ display: inline-block;
588
+ margin-top: 6px;
280
589
  border-radius: 999px;
281
590
  border: 1px solid #bfdbfe;
282
591
  background: #dbeafe;
@@ -285,18 +594,28 @@ def _recommendation_to_html(result: RecommendationResult) -> str:
285
594
  font-size: 11px;
286
595
  font-weight: 700;
287
596
  line-height: 1.4;
597
+ text-transform: none;
598
+ letter-spacing: 0;
288
599
  }}
289
- .pygeomodel-rec-rank {{
290
- color: #64748b;
291
- font-size: 11px;
292
- font-weight: 700;
600
+ .pygeomodel-rec-name {{
601
+ color: #0f172a;
602
+ font-size: 13px;
603
+ line-height: 1.35;
604
+ font-weight: 750;
293
605
  }}
294
- .pygeomodel-rec-candidate p {{
295
- margin: 6px 0 0 0;
606
+ .pygeomodel-rec-description,
607
+ .pygeomodel-rec-text {{
608
+ margin: 0;
296
609
  color: #475569;
297
610
  font-size: 12px;
298
611
  line-height: 1.5;
299
612
  }}
613
+ .pygeomodel-rec-description {{
614
+ margin-top: 5px;
615
+ }}
616
+ .pygeomodel-rec-muted {{
617
+ color: #94a3b8;
618
+ }}
300
619
  .pygeomodel-rec-data-grid {{
301
620
  display: grid;
302
621
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
@@ -332,11 +651,32 @@ def _recommendation_to_html(result: RecommendationResult) -> str:
332
651
  padding: 12px;
333
652
  background: #f8fafc;
334
653
  }}
654
+ @media (max-width: 900px) {{
655
+ .pygeomodel-rec-list-head {{
656
+ display: none;
657
+ }}
658
+ .pygeomodel-rec-row {{
659
+ grid-template-columns: 76px minmax(0, 1fr);
660
+ row-gap: 8px;
661
+ }}
662
+ .pygeomodel-rec-row > div:nth-child(3),
663
+ .pygeomodel-rec-row > div:nth-child(4) {{
664
+ grid-column: 2;
665
+ }}
666
+ }}
335
667
  </style>
336
668
  <div class="pygeomodel-rec-card">
337
669
  <div class="pygeomodel-rec-body">
338
670
  <div class="pygeomodel-rec-section">
339
- <div class="pygeomodel-rec-candidates">{candidate_cards}</div>
671
+ <div class="pygeomodel-rec-list">
672
+ <div class="pygeomodel-rec-list-head">
673
+ <div>Rank</div>
674
+ <div>Candidate model</div>
675
+ <div>Recommendation rationale</div>
676
+ <div>Input data</div>
677
+ </div>
678
+ {candidate_rows}
679
+ </div>
340
680
  </div>
341
681
  <div class="pygeomodel-rec-section">
342
682
  <h4>Relevant Data</h4>
@@ -356,7 +696,7 @@ def _recommendation_to_html(result: RecommendationResult) -> str:
356
696
  """
357
697
 
358
698
 
359
- def _recommendation_candidate_card(candidate: dict[str, Any]) -> str:
699
+ def _recommendation_candidate_row(candidate: dict[str, Any]) -> str:
360
700
  name = html.escape(str(candidate.get("name") or "Unnamed model"))
361
701
  description = html.escape(str(candidate.get("description") or candidate.get("desc") or ""))
362
702
  reason = html.escape(str(candidate.get("reason") or candidate.get("recommendation_reason") or ""))
@@ -367,17 +707,17 @@ def _recommendation_candidate_card(candidate: dict[str, Any]) -> str:
367
707
  badge = '<span class="pygeomodel-rec-badge">Recommended</span>' if is_primary else ""
368
708
 
369
709
  return f"""
370
- <div class="pygeomodel-rec-candidate{primary_class}">
371
- <div class="pygeomodel-rec-candidate-top">
372
- <div>
373
- <div class="pygeomodel-rec-rank">Rank {rank}</div>
374
- <div class="pygeomodel-rec-candidate-name">{name}</div>
375
- </div>
710
+ <div class="pygeomodel-rec-row{primary_class}">
711
+ <div class="pygeomodel-rec-rank">
712
+ Rank {rank}
376
713
  {badge}
377
714
  </div>
378
- {f'<p>{description}</p>' if description else ''}
379
- {f'<p><b>Why:</b> {reason}</p>' if reason else ''}
380
- {f'<p><b>Input data:</b> {input_desc}</p>' if input_desc else ''}
715
+ <div>
716
+ <div class="pygeomodel-rec-name">{name}</div>
717
+ {f'<p class="pygeomodel-rec-description">{description}</p>' if description else ''}
718
+ </div>
719
+ <p class="pygeomodel-rec-text">{reason if reason else '<span class="pygeomodel-rec-muted">No recommendation rationale recorded.</span>'}</p>
720
+ <p class="pygeomodel-rec-text">{input_desc if input_desc else '<span class="pygeomodel-rec-muted">No input-data description recorded.</span>'}</p>
381
721
  </div>
382
722
  """
383
723
 
@@ -11,7 +11,7 @@ def read_readme():
11
11
 
12
12
  setup(
13
13
  name="PyGeoModel",
14
- version="1.0.8",
14
+ version="1.0.9",
15
15
  author="Peilong Ma",
16
16
  author_email="mpl_gis@nnu.edu.cn",
17
17
  description="A Python package for integrating OpenGMS geographic model services.",
@@ -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.assertIs(returned, result)
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.assertIs(returned, result)
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
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes