pam-python 0.2.13__tar.gz → 0.2.15__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 (118) hide show
  1. {pam_python-0.2.13/pam_python.egg-info → pam_python-0.2.15}/PKG-INFO +4 -3
  2. {pam_python-0.2.13 → pam_python-0.2.15}/README.md +3 -2
  3. {pam_python-0.2.13 → pam_python-0.2.15}/pam/__init__.py +1 -1
  4. {pam_python-0.2.13 → pam_python-0.2.15}/pam/models/data_input_v2.py +11 -1
  5. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/axis.py +28 -2
  6. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/bar.py +38 -2
  7. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/box_plot.py +19 -2
  8. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/heatmap.py +34 -2
  9. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/range_bar.py +37 -1
  10. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/store.py +15 -2
  11. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/init/AGENT.md +4 -3
  12. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/init/REPORTS-th.md +10 -0
  13. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/init/REPORTS.md +18 -1
  14. {pam_python-0.2.13 → pam_python-0.2.15/pam_python.egg-info}/PKG-INFO +4 -3
  15. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_data_input_v2.py +52 -0
  16. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_bar_report.py +32 -0
  17. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_box_plot_report.py +21 -0
  18. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_heatmap_report.py +20 -0
  19. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_range_bar_report.py +21 -0
  20. {pam_python-0.2.13 → pam_python-0.2.15}/LICENSE.txt +0 -0
  21. {pam_python-0.2.13 → pam_python-0.2.15}/pam/api.py +0 -0
  22. {pam_python-0.2.13 → pam_python-0.2.15}/pam/cli.py +0 -0
  23. {pam_python-0.2.13 → pam_python-0.2.15}/pam/datetime_utils.py +0 -0
  24. {pam_python-0.2.13 → pam_python-0.2.15}/pam/interface_task_manager.py +0 -0
  25. {pam_python-0.2.13 → pam_python-0.2.15}/pam/logger.py +0 -0
  26. {pam_python-0.2.13 → pam_python-0.2.15}/pam/models/__init__.py +0 -0
  27. {pam_python-0.2.13 → pam_python-0.2.15}/pam/models/request_command.py +0 -0
  28. {pam_python-0.2.13 → pam_python-0.2.15}/pam/models/sqlite_download.py +0 -0
  29. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/__init__.py +0 -0
  30. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/area.py +0 -0
  31. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/bubble.py +0 -0
  32. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/candlestick.py +0 -0
  33. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/collection.py +0 -0
  34. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/definition.py +0 -0
  35. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/donut.py +0 -0
  36. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/errors.py +0 -0
  37. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/fields.py +0 -0
  38. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/line.py +0 -0
  39. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/manager.py +0 -0
  40. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/metric.py +0 -0
  41. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/pie.py +0 -0
  42. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/polar_area.py +0 -0
  43. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/radar.py +0 -0
  44. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/radial_bar.py +0 -0
  45. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/range_area.py +0 -0
  46. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/range_transform.py +0 -0
  47. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/record.py +0 -0
  48. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/report.py +0 -0
  49. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/scatter.py +0 -0
  50. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/schema.py +0 -0
  51. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/table.py +0 -0
  52. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/temporal.py +0 -0
  53. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/transport.py +0 -0
  54. {pam_python-0.2.13 → pam_python-0.2.15}/pam/reports/treemap.py +0 -0
  55. {pam_python-0.2.13 → pam_python-0.2.15}/pam/request_file_format.py +0 -0
  56. {pam_python-0.2.13 → pam_python-0.2.15}/pam/result_batch_uploader.py +0 -0
  57. {pam_python-0.2.13 → pam_python-0.2.15}/pam/server.py +0 -0
  58. {pam_python-0.2.13 → pam_python-0.2.15}/pam/service.py +0 -0
  59. {pam_python-0.2.13 → pam_python-0.2.15}/pam/sqlite.py +0 -0
  60. {pam_python-0.2.13 → pam_python-0.2.15}/pam/task_manager.py +0 -0
  61. {pam_python-0.2.13 → pam_python-0.2.15}/pam/temp_file_utils.py +0 -0
  62. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/buildcmd/pamb +0 -0
  63. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/buildcmd/pamb-base.sh +0 -0
  64. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/init/dockerignore.tmpl +0 -0
  65. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/init/gitignore.tmpl +0 -0
  66. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/init/main.tmpl +0 -0
  67. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/init/pylintrc.tmpl +0 -0
  68. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/init/run_unit_test.bat +0 -0
  69. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/init/run_unit_test.ps1 +0 -0
  70. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/init/run_unit_test.sh +0 -0
  71. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/project/pip/Dockerfile +0 -0
  72. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/project/pip/requirements.txt +0 -0
  73. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/project/uv/Dockerfile +0 -0
  74. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/project/uv/pyproject.toml +0 -0
  75. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/project/uv/python-version +0 -0
  76. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/service/common/service.yaml +0 -0
  77. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/service/pandas/functions.tmpl +0 -0
  78. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/service/pandas/service.test.tmpl +0 -0
  79. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/service/pandas/service_class.tmpl +0 -0
  80. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/service/polars/functions.tmpl +0 -0
  81. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/service/polars/service.test.tmpl +0 -0
  82. {pam_python-0.2.13 → pam_python-0.2.15}/pam/templates/service/polars/service_class.tmpl +0 -0
  83. {pam_python-0.2.13 → pam_python-0.2.15}/pam/tester_task.py +0 -0
  84. {pam_python-0.2.13 → pam_python-0.2.15}/pam/utils.py +0 -0
  85. {pam_python-0.2.13 → pam_python-0.2.15}/pam_python.egg-info/SOURCES.txt +0 -0
  86. {pam_python-0.2.13 → pam_python-0.2.15}/pam_python.egg-info/dependency_links.txt +0 -0
  87. {pam_python-0.2.13 → pam_python-0.2.15}/pam_python.egg-info/entry_points.txt +0 -0
  88. {pam_python-0.2.13 → pam_python-0.2.15}/pam_python.egg-info/requires.txt +0 -0
  89. {pam_python-0.2.13 → pam_python-0.2.15}/pam_python.egg-info/top_level.txt +0 -0
  90. {pam_python-0.2.13 → pam_python-0.2.15}/pyproject.toml +0 -0
  91. {pam_python-0.2.13 → pam_python-0.2.15}/setup.cfg +0 -0
  92. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_api.py +0 -0
  93. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_cli_templates.py +0 -0
  94. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_data_error_v2.py +0 -0
  95. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_data_timeout_v2.py +0 -0
  96. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_datetime_utils.py +0 -0
  97. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_range_transform.py +0 -0
  98. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_report_store.py +0 -0
  99. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_request_data_v2.py +0 -0
  100. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_result_batch_uploader.py +0 -0
  101. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_service_dataframe_output.py +0 -0
  102. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_sqlite_download_v2.py +0 -0
  103. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_temp_file_utils.py +0 -0
  104. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_temporal_mode.py +0 -0
  105. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_area_report.py +0 -0
  106. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_bubble_report.py +0 -0
  107. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_candlestick_report.py +0 -0
  108. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_donut_report.py +0 -0
  109. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_line_report.py +0 -0
  110. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_metric_report.py +0 -0
  111. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_pie_report.py +0 -0
  112. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_polar_area_report.py +0 -0
  113. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_radar_report.py +0 -0
  114. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_radial_bar_report.py +0 -0
  115. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_range_area_report.py +0 -0
  116. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_scatter_report.py +0 -0
  117. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_table_report.py +0 -0
  118. {pam_python-0.2.13 → pam_python-0.2.15}/tests/test_typed_treemap_report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pam-python
3
- Version: 0.2.13
3
+ Version: 0.2.15
4
4
  Summary: Pam Python Library
5
5
  Author-email: Narongrit Kanhanoi <narongrit@pams.ai>
6
6
  Project-URL: Homepage, https://github.com/heart/pam-python
@@ -134,8 +134,9 @@ See [Data Plugin Input V2](docs/data-plugin-input-v2.md).
134
134
 
135
135
  New Pandas and Polars service projects are generated on the V2 lane by default;
136
136
  legacy source can continue using V1 unchanged. For materialized Pandas frames,
137
- use `data.read_population_pandas()` and
138
- `data.dataset_at(position).read_pandas()`.
137
+ use `data.read_population_pandas()` and either
138
+ `data.dataset_at(position).read_pandas()` or
139
+ `data.dataset_named(name).read_pandas()`.
139
140
 
140
141
  When your service is done:
141
142
 
@@ -114,8 +114,9 @@ See [Data Plugin Input V2](docs/data-plugin-input-v2.md).
114
114
 
115
115
  New Pandas and Polars service projects are generated on the V2 lane by default;
116
116
  legacy source can continue using V1 unchanged. For materialized Pandas frames,
117
- use `data.read_population_pandas()` and
118
- `data.dataset_at(position).read_pandas()`.
117
+ use `data.read_population_pandas()` and either
118
+ `data.dataset_at(position).read_pandas()` or
119
+ `data.dataset_named(name).read_pandas()`.
119
120
 
120
121
  When your service is done:
121
122
 
@@ -1,3 +1,3 @@
1
1
  """PAM Data Plugin framework."""
2
2
 
3
- __version__ = "0.2.13"
3
+ __version__ = "0.2.15"
@@ -142,6 +142,15 @@ class DataInputV2:
142
142
  raise IndexError(f"V2 dataset position is out of range: {position}")
143
143
  return self.datasets[position]
144
144
 
145
+ def dataset_named(self, name: str) -> DataInputV2Dataset:
146
+ """Bind one configured dataset by an unambiguous manifest name."""
147
+ matches = [dataset for dataset in self.datasets if dataset.name == name]
148
+ if not matches:
149
+ raise KeyError(f"V2 dataset name is unknown: {name}")
150
+ if len(matches) > 1:
151
+ raise ValueError(f"V2 dataset name is ambiguous: {name}")
152
+ return matches[0]
153
+
145
154
 
146
155
  def parse_data_input_v2(input_files: list[str]) -> DataInputV2:
147
156
  files_by_name = _validate_flat_input_files(input_files)
@@ -202,6 +211,7 @@ def parse_data_input_v2(input_files: list[str]) -> DataInputV2:
202
211
  if position in positions:
203
212
  raise ValueError("V2 dataset position is duplicated")
204
213
  positions.add(position)
214
+ dataset_name = _required_string(raw_dataset, "name")
205
215
  dataset_format = _required_format(raw_dataset, "format")
206
216
  raw_parts = raw_dataset.get("parts")
207
217
  if not isinstance(raw_parts, list):
@@ -226,7 +236,7 @@ def parse_data_input_v2(input_files: list[str]) -> DataInputV2:
226
236
  datasets.append(
227
237
  DataInputV2Dataset(
228
238
  position=position,
229
- name=_required_string(raw_dataset, "name"),
239
+ name=dataset_name,
230
240
  type=_required_string(raw_dataset, "type"),
231
241
  format=dataset_format,
232
242
  row_count=row_count,
@@ -1,6 +1,6 @@
1
- """Shared declarations for partition-axis reports."""
1
+ """Shared declarations for reports with ordered axes."""
2
2
 
3
- from typing import Iterable, Optional, Tuple
3
+ from typing import Any, Iterable, Optional, Tuple
4
4
 
5
5
  from pam.reports.errors import ReportStoreValidationError
6
6
  from pam.reports.fields import Dimension, Measure
@@ -9,6 +9,32 @@ from pam.reports.fields import Dimension, Measure
9
9
  NUMERIC_TYPES = {"integer", "number"}
10
10
 
11
11
 
12
+ def normalize_value_order(
13
+ report_name: str,
14
+ option_name: str,
15
+ dimension: Dimension,
16
+ values: Optional[Iterable[Any]],
17
+ ) -> Optional[Tuple[Any, ...]]:
18
+ """Normalize an optional complete order for a categorical dimension."""
19
+ if values is None:
20
+ return None
21
+ if isinstance(values, (str, bytes)):
22
+ raise ReportStoreValidationError(
23
+ f"{report_name} {option_name} must be an iterable of values"
24
+ )
25
+ try:
26
+ normalized = tuple(dimension.normalize_value(value) for value in values)
27
+ except TypeError as exc:
28
+ raise ReportStoreValidationError(
29
+ f"{report_name} {option_name} must be an iterable of values"
30
+ ) from exc
31
+ if not normalized or len(normalized) != len(set(normalized)):
32
+ raise ReportStoreValidationError(
33
+ f"{report_name} {option_name} must be non-empty and unique"
34
+ )
35
+ return normalized
36
+
37
+
12
38
  def normalize_partition_series_declaration(
13
39
  report_name: str,
14
40
  values: Iterable[Measure],
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import Any, Iterable, Mapping, Optional, Sequence
4
4
 
5
- from pam.reports.axis import NUMERIC_TYPES
5
+ from pam.reports.axis import NUMERIC_TYPES, normalize_value_order
6
6
  from pam.reports.collection import TypedCollectionReport
7
7
  from pam.reports.definition import ReportDefinition
8
8
  from pam.reports.errors import ReportStoreValidationError
@@ -29,21 +29,36 @@ class BarChartReport(TypedCollectionReport):
29
29
  series: Optional[Dimension],
30
30
  orientation: str,
31
31
  stacked: bool,
32
+ category_order: Optional[Sequence[Any]],
33
+ series_order: Optional[Sequence[Any]],
32
34
  ):
33
35
  fields = (category,) + ((series,) if series is not None else ()) + tuple(values)
34
36
  identity_fields = (category,) + ((series,) if series is not None else ())
37
+ def validate_order(item: Mapping[str, Any], _index: int) -> None:
38
+ if category_order is not None and item[category.name] not in category_order:
39
+ raise ReportStoreValidationError(
40
+ f"Bar Chart category value '{item[category.name]}' is not declared in category_order"
41
+ )
42
+ if series_order is not None and item[series.name] not in series_order:
43
+ raise ReportStoreValidationError(
44
+ f"Bar Chart series value '{item[series.name]}' is not declared in series_order"
45
+ )
46
+
35
47
  super().__init__(
36
48
  report=report,
37
49
  granularity=granularity,
38
50
  fields=fields,
39
51
  identity_fields=identity_fields,
40
52
  item_name="bar",
53
+ item_validator=validate_order,
41
54
  )
42
55
  self.category = category
43
56
  self.values = tuple(values)
44
57
  self.series = series
45
58
  self.orientation = orientation
46
59
  self.stacked = stacked
60
+ self.category_order = category_order
61
+ self.series_order = series_order
47
62
 
48
63
  @classmethod
49
64
  def declare(
@@ -58,6 +73,8 @@ class BarChartReport(TypedCollectionReport):
58
73
  stacked: bool = False,
59
74
  range_transform: Optional[TimeRangeTransform] = None,
60
75
  temporal_mode: Optional[TemporalMode] = None,
76
+ category_order: Optional[Iterable[Any]] = None,
77
+ series_order: Optional[Iterable[Any]] = None,
61
78
  ):
62
79
  if not isinstance(category, Dimension):
63
80
  raise ReportStoreValidationError(
@@ -107,6 +124,18 @@ class BarChartReport(TypedCollectionReport):
107
124
  )
108
125
  if not isinstance(stacked, bool):
109
126
  raise ReportStoreValidationError("Bar Chart stacked must be boolean")
127
+ normalized_category_order = normalize_value_order(
128
+ "Bar Chart", "category_order", category, category_order
129
+ )
130
+ if series_order is not None and series is None:
131
+ raise ReportStoreValidationError(
132
+ "Bar Chart series_order requires a series Dimension"
133
+ )
134
+ normalized_series_order = (
135
+ normalize_value_order("Bar Chart", "series_order", series, series_order)
136
+ if series is not None
137
+ else None
138
+ )
110
139
 
111
140
  fields = (category,) + ((series,) if series is not None else ()) + normalized_values
112
141
  normalized_range_transform = normalize_range_transform(
@@ -119,6 +148,11 @@ class BarChartReport(TypedCollectionReport):
119
148
  "Bar Chart",
120
149
  range_transform,
121
150
  )
151
+ options = {"orientation": orientation, "stacked": stacked}
152
+ if normalized_category_order is not None:
153
+ options["category_order"] = list(normalized_category_order)
154
+ if normalized_series_order is not None:
155
+ options["series_order"] = list(normalized_series_order)
122
156
  definition = ReportDefinition.create(
123
157
  report_type="bar",
124
158
  contract_version=BAR_CONTRACT_VERSION,
@@ -128,7 +162,7 @@ class BarChartReport(TypedCollectionReport):
128
162
  "series": series.name if series is not None else None,
129
163
  "values": [value.name for value in normalized_values],
130
164
  },
131
- options={"orientation": orientation, "stacked": stacked},
165
+ options=options,
132
166
  range_transform=normalized_range_transform,
133
167
  temporal_mode=normalized_temporal_mode,
134
168
  )
@@ -147,6 +181,8 @@ class BarChartReport(TypedCollectionReport):
147
181
  series,
148
182
  orientation,
149
183
  stacked,
184
+ normalized_category_order,
185
+ normalized_series_order,
150
186
  )
151
187
 
152
188
  def append_bars(
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import Any, Iterable, Mapping, Optional
4
4
 
5
- from pam.reports.axis import NUMERIC_TYPES
5
+ from pam.reports.axis import NUMERIC_TYPES, normalize_value_order
6
6
  from pam.reports.collection import TypedCollectionReport
7
7
  from pam.reports.definition import ReportDefinition
8
8
  from pam.reports.errors import ReportStoreValidationError
@@ -27,6 +27,7 @@ class BoxPlotReport(TypedCollectionReport):
27
27
  median: Measure,
28
28
  q3: Measure,
29
29
  maximum: Measure,
30
+ category_order: Optional[Iterable[Any]],
30
31
  ):
31
32
  values = (minimum, q1, median, q3, maximum)
32
33
  fields = (category,) + values
@@ -37,6 +38,10 @@ class BoxPlotReport(TypedCollectionReport):
37
38
  raise ReportStoreValidationError(
38
39
  f"Box Plot distribution {index} must satisfy minimum <= q1 <= median <= q3 <= maximum"
39
40
  )
41
+ if category_order is not None and item[category.name] not in category_order:
42
+ raise ReportStoreValidationError(
43
+ f"Box Plot category value '{item[category.name]}' is not declared in category_order"
44
+ )
40
45
 
41
46
  super().__init__(
42
47
  report=report,
@@ -52,6 +57,7 @@ class BoxPlotReport(TypedCollectionReport):
52
57
  self.median = median
53
58
  self.q3 = q3
54
59
  self.maximum = maximum
60
+ self.category_order = category_order
55
61
 
56
62
  @classmethod
57
63
  def declare(
@@ -65,6 +71,7 @@ class BoxPlotReport(TypedCollectionReport):
65
71
  median: Measure,
66
72
  q3: Measure,
67
73
  maximum: Measure,
74
+ category_order: Optional[Iterable[Any]] = None,
68
75
  ):
69
76
  if not isinstance(category, Dimension) or not category.required or category.nullable:
70
77
  raise ReportStoreValidationError(
@@ -85,7 +92,13 @@ class BoxPlotReport(TypedCollectionReport):
85
92
  names = [field.name for field in fields]
86
93
  if len(names) != len(set(names)):
87
94
  raise ReportStoreValidationError("Box Plot fields must use different names")
95
+ normalized_category_order = normalize_value_order(
96
+ "Box Plot", "category_order", category, category_order
97
+ )
88
98
 
99
+ options = {}
100
+ if normalized_category_order is not None:
101
+ options["category_order"] = list(normalized_category_order)
89
102
  definition = ReportDefinition.create(
90
103
  report_type="box_plot",
91
104
  contract_version=BOX_PLOT_CONTRACT_VERSION,
@@ -98,11 +111,15 @@ class BoxPlotReport(TypedCollectionReport):
98
111
  "q3": q3.name,
99
112
  "maximum": maximum.name,
100
113
  },
114
+ options=options,
101
115
  )
102
116
  report = store._declare_typed_report(
103
117
  key, BOX_PLOT_RENDERER, BOX_PLOT_SCHEMA, granularity, definition.to_json()
104
118
  )
105
- return cls(report, granularity, category, minimum, q1, median, q3, maximum)
119
+ return cls(
120
+ report, granularity, category, minimum, q1, median, q3, maximum,
121
+ normalized_category_order,
122
+ )
106
123
 
107
124
  def append_distributions(
108
125
  self,
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import Any, Iterable, Mapping, Optional
4
4
 
5
- from pam.reports.axis import NUMERIC_TYPES
5
+ from pam.reports.axis import NUMERIC_TYPES, normalize_value_order
6
6
  from pam.reports.collection import TypedCollectionReport
7
7
  from pam.reports.definition import ReportDefinition
8
8
  from pam.reports.errors import ReportStoreValidationError
@@ -22,17 +22,32 @@ class HeatmapReport(TypedCollectionReport):
22
22
  x: Dimension,
23
23
  y: Dimension,
24
24
  value: Measure,
25
+ x_order: Optional[Iterable[Any]],
26
+ y_order: Optional[Iterable[Any]],
25
27
  ):
28
+ def validate_order(item: Mapping[str, Any], _index: int) -> None:
29
+ if x_order is not None and item[x.name] not in x_order:
30
+ raise ReportStoreValidationError(
31
+ f"Heatmap x value '{item[x.name]}' is not declared in x_order"
32
+ )
33
+ if y_order is not None and item[y.name] not in y_order:
34
+ raise ReportStoreValidationError(
35
+ f"Heatmap y value '{item[y.name]}' is not declared in y_order"
36
+ )
37
+
26
38
  super().__init__(
27
39
  report=report,
28
40
  granularity=granularity,
29
41
  fields=(x, y, value),
30
42
  identity_fields=(x, y),
31
43
  item_name="cell",
44
+ item_validator=validate_order,
32
45
  )
33
46
  self.x = x
34
47
  self.y = y
35
48
  self.value = value
49
+ self.x_order = x_order
50
+ self.y_order = y_order
36
51
 
37
52
  @classmethod
38
53
  def declare(
@@ -43,6 +58,8 @@ class HeatmapReport(TypedCollectionReport):
43
58
  x: Dimension,
44
59
  y: Dimension,
45
60
  value: Measure,
61
+ x_order: Optional[Iterable[Any]] = None,
62
+ y_order: Optional[Iterable[Any]] = None,
46
63
  ):
47
64
  for name, field in (("x", x), ("y", y)):
48
65
  if not isinstance(field, Dimension):
@@ -61,17 +78,32 @@ class HeatmapReport(TypedCollectionReport):
61
78
  raise ReportStoreValidationError("Heatmap value must be required and non-nullable")
62
79
  if len({x.name, y.name, value.name}) != 3:
63
80
  raise ReportStoreValidationError("Heatmap fields must use different names")
81
+ normalized_x_order = normalize_value_order(
82
+ "Heatmap", "x_order", x, x_order
83
+ )
84
+ normalized_y_order = normalize_value_order(
85
+ "Heatmap", "y_order", y, y_order
86
+ )
64
87
 
88
+ options = {}
89
+ if normalized_x_order is not None:
90
+ options["x_order"] = list(normalized_x_order)
91
+ if normalized_y_order is not None:
92
+ options["y_order"] = list(normalized_y_order)
65
93
  definition = ReportDefinition.create(
66
94
  report_type="heatmap",
67
95
  contract_version=HEATMAP_CONTRACT_VERSION,
68
96
  fields=(x, y, value),
69
97
  binding={"x": x.name, "y": y.name, "value": value.name},
98
+ options=options,
70
99
  )
71
100
  report = store._declare_typed_report(
72
101
  key, HEATMAP_RENDERER, HEATMAP_SCHEMA, granularity, definition.to_json()
73
102
  )
74
- return cls(report, granularity, x, y, value)
103
+ return cls(
104
+ report, granularity, x, y, value,
105
+ normalized_x_order, normalized_y_order,
106
+ )
75
107
 
76
108
  def append_cells(
77
109
  self,
@@ -2,6 +2,7 @@
2
2
 
3
3
  from typing import Any, Iterable, Mapping, Optional
4
4
 
5
+ from pam.reports.axis import normalize_value_order
5
6
  from pam.reports.collection import TypedCollectionReport
6
7
  from pam.reports.definition import ReportDefinition
7
8
  from pam.reports.errors import ReportStoreValidationError
@@ -25,6 +26,8 @@ class RangeBarChartReport(TypedCollectionReport):
25
26
  start: Measure,
26
27
  end: Measure,
27
28
  series: Optional[Dimension],
29
+ category_order: Optional[Iterable[Any]],
30
+ series_order: Optional[Iterable[Any]],
28
31
  ):
29
32
  fields = (category,) + ((series,) if series is not None else ()) + (start, end)
30
33
  identity_fields = (
@@ -36,6 +39,14 @@ class RangeBarChartReport(TypedCollectionReport):
36
39
  raise ReportStoreValidationError(
37
40
  f"Range Bar range {index} field '{end.name}' must be greater than or equal to '{start.name}'"
38
41
  )
42
+ if category_order is not None and item[category.name] not in category_order:
43
+ raise ReportStoreValidationError(
44
+ f"Range Bar category value '{item[category.name]}' is not declared in category_order"
45
+ )
46
+ if series_order is not None and item[series.name] not in series_order:
47
+ raise ReportStoreValidationError(
48
+ f"Range Bar series value '{item[series.name]}' is not declared in series_order"
49
+ )
39
50
 
40
51
  super().__init__(
41
52
  report=report,
@@ -49,6 +60,8 @@ class RangeBarChartReport(TypedCollectionReport):
49
60
  self.start = start
50
61
  self.end = end
51
62
  self.series = series
63
+ self.category_order = category_order
64
+ self.series_order = series_order
52
65
 
53
66
  @classmethod
54
67
  def declare(
@@ -60,6 +73,8 @@ class RangeBarChartReport(TypedCollectionReport):
60
73
  start: Measure,
61
74
  end: Measure,
62
75
  series: Optional[Dimension] = None,
76
+ category_order: Optional[Iterable[Any]] = None,
77
+ series_order: Optional[Iterable[Any]] = None,
63
78
  ):
64
79
  if not isinstance(category, Dimension) or not category.required or category.nullable:
65
80
  raise ReportStoreValidationError(
@@ -89,7 +104,24 @@ class RangeBarChartReport(TypedCollectionReport):
89
104
  names = [field.name for field in fields]
90
105
  if len(names) != len(set(names)):
91
106
  raise ReportStoreValidationError("Range Bar fields must use different names")
107
+ normalized_category_order = normalize_value_order(
108
+ "Range Bar", "category_order", category, category_order
109
+ )
110
+ if series_order is not None and series is None:
111
+ raise ReportStoreValidationError(
112
+ "Range Bar series_order requires a series Dimension"
113
+ )
114
+ normalized_series_order = (
115
+ normalize_value_order("Range Bar", "series_order", series, series_order)
116
+ if series is not None
117
+ else None
118
+ )
92
119
 
120
+ options = {}
121
+ if normalized_category_order is not None:
122
+ options["category_order"] = list(normalized_category_order)
123
+ if normalized_series_order is not None:
124
+ options["series_order"] = list(normalized_series_order)
93
125
  definition = ReportDefinition.create(
94
126
  report_type="range_bar",
95
127
  contract_version=RANGE_BAR_CONTRACT_VERSION,
@@ -100,11 +132,15 @@ class RangeBarChartReport(TypedCollectionReport):
100
132
  "end": end.name,
101
133
  "series": series.name if series is not None else None,
102
134
  },
135
+ options=options,
103
136
  )
104
137
  report = store._declare_typed_report(
105
138
  key, RANGE_BAR_RENDERER, RANGE_BAR_SCHEMA, granularity, definition.to_json()
106
139
  )
107
- return cls(report, granularity, category, start, end, series)
140
+ return cls(
141
+ report, granularity, category, start, end, series,
142
+ normalized_category_order, normalized_series_order,
143
+ )
108
144
 
109
145
  def append_ranges(
110
146
  self,
@@ -221,6 +221,8 @@ class ReportStore:
221
221
  stacked: bool = False,
222
222
  range_transform: Optional[TimeRangeTransform] = None,
223
223
  temporal_mode: Optional[TemporalMode] = None,
224
+ category_order: Optional[Iterable[Any]] = None,
225
+ series_order: Optional[Iterable[Any]] = None,
224
226
  ) -> BarChartReport:
225
227
  """Declare a typed Bar/Column Chart report."""
226
228
  return BarChartReport.declare(
@@ -234,6 +236,8 @@ class ReportStore:
234
236
  stacked,
235
237
  range_transform,
236
238
  temporal_mode,
239
+ category_order,
240
+ series_order,
237
241
  )
238
242
 
239
243
  def pie_chart(
@@ -355,6 +359,7 @@ class ReportStore:
355
359
  median: Measure,
356
360
  q3: Measure,
357
361
  maximum: Measure,
362
+ category_order: Optional[Iterable[Any]] = None,
358
363
  ) -> BoxPlotReport:
359
364
  """Declare a typed Box Plot report."""
360
365
  return BoxPlotReport.declare(
@@ -367,6 +372,7 @@ class ReportStore:
367
372
  median,
368
373
  q3,
369
374
  maximum,
375
+ category_order,
370
376
  )
371
377
 
372
378
  def heatmap(
@@ -376,9 +382,13 @@ class ReportStore:
376
382
  x: Dimension,
377
383
  y: Dimension,
378
384
  value: Measure,
385
+ x_order: Optional[Iterable[Any]] = None,
386
+ y_order: Optional[Iterable[Any]] = None,
379
387
  ) -> HeatmapReport:
380
388
  """Declare a typed Heatmap report."""
381
- return HeatmapReport.declare(self, key, granularity, x, y, value)
389
+ return HeatmapReport.declare(
390
+ self, key, granularity, x, y, value, x_order, y_order
391
+ )
382
392
 
383
393
  def radar_chart(
384
394
  self,
@@ -402,10 +412,13 @@ class ReportStore:
402
412
  start: Measure,
403
413
  end: Measure,
404
414
  series: Optional[Dimension] = None,
415
+ category_order: Optional[Iterable[Any]] = None,
416
+ series_order: Optional[Iterable[Any]] = None,
405
417
  ) -> RangeBarChartReport:
406
418
  """Declare a typed Range Bar Chart report."""
407
419
  return RangeBarChartReport.declare(
408
- self, key, granularity, category, start, end, series
420
+ self, key, granularity, category, start, end, series,
421
+ category_order, series_order,
409
422
  )
410
423
 
411
424
  def range_area_chart(
@@ -215,15 +215,16 @@ V2 is a separate, explicit lane. Call `_request_data_v2(...)` with CSV or
215
215
  Parquet when the PAM adapter is configured for V2. The requested format applies
216
216
  to that page and may change on a later request. In `on_data_input`, require
217
217
  `req.data_input_v2`, validate the expected dataset count, and bind each dataset
218
- by its configured position:
218
+ by an unambiguous manifest name (or by its configured position when names are
219
+ not unique or the contract requires positional binding):
219
220
 
220
221
  ```python
221
222
  data = req.data_input_v2
222
223
  if data is None or len(data.datasets) != 2:
223
224
  raise ValueError("Expected V2 profile and purchase datasets")
224
225
 
225
- profile = data.dataset_at(0)
226
- purchase = data.dataset_at(1)
226
+ profile = data.dataset_named("profile")
227
+ purchase = data.dataset_named("purchase")
227
228
 
228
229
  population = data.scan_population_polars()
229
230
  profile_frame = profile.scan_polars()
@@ -496,12 +496,16 @@ bars = store.bar_chart(
496
496
  series=Dimension("branch", label="สาขา", value_type="string"),
497
497
  orientation="vertical",
498
498
  stacked=False,
499
+ category_order=["Online", "Store"],
500
+ series_order=["Bangkok", "Chiang Mai"],
499
501
  )
500
502
  ```
501
503
 
502
504
  ต้องมี category Dimension หนึ่งตัวและ numeric Measure อย่างน้อยหนึ่งตัว `series`
503
505
  ต้องเป็น Dimension, `orientation` เป็น `vertical` หรือ `horizontal` และห้ามมี
504
506
  category/series identity ซ้ำ Partition เป็น metadata ไม่ถูกเติมหน้าชื่อ category
507
+ กำหนดลำดับเองได้ด้วย `category_order` และ `series_order` โดยรายการที่ระบุถือเป็น
508
+ รายการค่าที่อนุญาตทั้งหมด หากไม่กำหนด CMS จะเรียง label แบบ ascending ตามเดิม
505
509
 
506
510
  ---
507
511
 
@@ -598,9 +602,13 @@ heatmap = store.heatmap(
598
602
  x=Dimension("hour", label="ชั่วโมง", value_type="integer"),
599
603
  y=Dimension("weekday", label="วัน", value_type="string"),
600
604
  value=Measure("events", label="เหตุการณ์", value_type="integer"),
605
+ x_order=[9, 10],
606
+ y_order=["Monday", "Tuesday"],
601
607
  )
602
608
  ```
603
609
 
610
+ `x_order` และ `y_order` ใช้กำหนดลำดับแกน โดยค่าที่เขียนต้องอยู่ในรายการที่ประกาศ
611
+
604
612
  ---
605
613
 
606
614
  # 16. Candlestick Chart Report
@@ -617,6 +625,7 @@ open, high, low, close โดยต้องเป็นไปตาม `low <=
617
625
  ต้องมี category Dimension หนึ่งตัวและ numeric Measure ห้าตัว: minimum, q1, median,
618
626
  q3, maximum โดยต้องเป็น `minimum <= q1 <= median <= q3 <= maximum` ห้าม category
619
627
  ซ้ำ และแต่ละ partition แสดง Box Plot แยกกัน ไม่ merge distribution ข้าม partition
628
+ กำหนดลำดับ category ได้ด้วย `category_order=[...]` และค่าที่เขียนต้องอยู่ในรายการ
620
629
 
621
630
  ---
622
631
 
@@ -640,6 +649,7 @@ category ซ้ำ `NONE` คือ current snapshot หากใช้ fixed ra
640
649
 
641
650
  ต้องมี category, start และ end โดย start/end ต้องเป็นชนิด numeric หรือ datetime
642
651
  ชนิดเดียวกัน และ `end >= start` identity ของ category/series/range ห้ามซ้ำ
652
+ กำหนดลำดับได้ด้วย `category_order` และ `series_order`; ค่าที่เขียนต้องอยู่ในรายการ
643
653
 
644
654
  ---
645
655