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.
Files changed (37) hide show
  1. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/PKG-INFO +1 -1
  2. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/PyGeoModel.egg-info/PKG-INFO +1 -1
  3. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/__init__.py +1 -1
  4. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/modeler.py +1 -0
  5. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/models.py +8 -2
  6. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/results.py +377 -52
  7. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/setup.py +1 -1
  8. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/tests/test_core_api.py +139 -1
  9. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/LICENSE +0 -0
  10. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/MANIFEST.in +0 -0
  11. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/PyGeoModel.egg-info/SOURCES.txt +0 -0
  12. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/PyGeoModel.egg-info/dependency_links.txt +0 -0
  13. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/PyGeoModel.egg-info/requires.txt +0 -0
  14. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/PyGeoModel.egg-info/top_level.txt +0 -0
  15. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/README.md +0 -0
  16. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/__init__.py +0 -0
  17. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/base.py +0 -0
  18. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/constants.py +0 -0
  19. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openModel.py +0 -0
  20. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openUtils/__init__.py +0 -0
  21. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openUtils/exceptions.py +0 -0
  22. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openUtils/http_client.py +0 -0
  23. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openUtils/mdlUtils.py +0 -0
  24. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openUtils/parameterValidator.py +0 -0
  25. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/ogmsServer2/openUtils/stateManager.py +0 -0
  26. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/client.py +0 -0
  27. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/config.py +0 -0
  28. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/consensus.py +0 -0
  29. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/context.py +0 -0
  30. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/data/__init__.py +0 -0
  31. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/data/computeModel.json +0 -0
  32. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/data/modelContext.txt +0 -0
  33. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/notebook.py +0 -0
  34. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/qa.py +0 -0
  35. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/pygeomodel/recommendation.py +0 -0
  36. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/scripts.py +0 -0
  37. {pygeomodel-1.0.8 → pygeomodel-1.0.10}/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.10
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.10
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.10"
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)
@@ -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
- return float(value)
204
+ try:
205
+ return float(value)
206
+ except (TypeError, ValueError):
207
+ return value
205
208
  if dtype in {"INT", "INTEGER"}:
206
- return int(value)
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.8"
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
- 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
+ ) -> 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
- 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,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-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;
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-candidate-top {{
266
- display: flex;
267
- gap: 8px;
268
- align-items: flex-start;
269
- justify-content: space-between;
270
- margin-bottom: 7px;
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-candidate-name {{
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-badge {{
279
- flex: 0 0 auto;
280
- border-radius: 999px;
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-candidates">{candidate_cards}</div>
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 _recommendation_candidate_card(candidate: dict[str, Any]) -> str:
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
- badge = '<span class="pygeomodel-rec-badge">Recommended</span>' if is_primary else ""
693
+ rank_label = f"&#9733; Rank {rank}" if is_primary else f"Rank {rank}"
368
694
 
369
695
  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>
376
- {badge}
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
- {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 ''}
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
 
@@ -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.10",
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.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("Recommended", html)
543
+ self.assertIn("&#9733; 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