processbehavior 0.3.0__tar.gz → 0.3.2__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 (207) hide show
  1. {processbehavior-0.3.0 → processbehavior-0.3.2}/CHANGELOG.md +63 -1
  2. {processbehavior-0.3.0 → processbehavior-0.3.2}/PKG-INFO +1 -1
  3. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/__init__.py +1 -1
  4. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/derivations.py +205 -81
  5. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/renderers.py +53 -1
  6. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_derivations.py +13 -6
  7. processbehavior-0.3.2/tests/test_derivations_robustness.py +329 -0
  8. processbehavior-0.3.2/tests/test_lane_connectors.py +103 -0
  9. {processbehavior-0.3.0 → processbehavior-0.3.2}/.gitignore +0 -0
  10. {processbehavior-0.3.0 → processbehavior-0.3.2}/CITATION.cff +0 -0
  11. {processbehavior-0.3.0 → processbehavior-0.3.2}/CODE_OF_CONDUCT.md +0 -0
  12. {processbehavior-0.3.0 → processbehavior-0.3.2}/CONTRIBUTING.md +0 -0
  13. {processbehavior-0.3.0 → processbehavior-0.3.2}/LICENSE +0 -0
  14. {processbehavior-0.3.0 → processbehavior-0.3.2}/README.md +0 -0
  15. {processbehavior-0.3.0 → processbehavior-0.3.2}/SECURITY.md +0 -0
  16. {processbehavior-0.3.0 → processbehavior-0.3.2}/examples/README.md +0 -0
  17. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/analysis.py +0 -0
  18. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/analysis_dataset.py +0 -0
  19. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/analysis_result.py +0 -0
  20. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/calibration.py +0 -0
  21. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/capability.py +0 -0
  22. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/data_preparation.py +0 -0
  23. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/datasets/__init__.py +0 -0
  24. {processbehavior-0.3.0/tests/fixtures → processbehavior-0.3.2/processbehavior/datasets}/data/coffee_shop_demo_long.csv +0 -0
  25. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/datasets/loaders.py +0 -0
  26. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/datasets/synthetic.py +0 -0
  27. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/effects_calculator.py +0 -0
  28. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/exceptions.py +0 -0
  29. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/exporters/__init__.py +0 -0
  30. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/exporters/excel_exporter.py +0 -0
  31. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/focused_result.py +0 -0
  32. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/formulation_spec.py +0 -0
  33. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/loss_function.py +0 -0
  34. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/maximum_information.py +0 -0
  35. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/__init__.py +0 -0
  36. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/_math.py +0 -0
  37. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/capability_chart.py +0 -0
  38. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/contracts.py +0 -0
  39. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/control_chart.py +0 -0
  40. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/effects_charts.py +0 -0
  41. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/lane_boundaries.py +0 -0
  42. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/limits.py +0 -0
  43. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/maximum_information_chart.py +0 -0
  44. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/plotter.py +0 -0
  45. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/report.py +0 -0
  46. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/residuals.py +0 -0
  47. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/run_rules_viz.py +0 -0
  48. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/stats_box.py +0 -0
  49. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/themes.py +0 -0
  50. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/x_axis_layout.py +0 -0
  51. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/zones.py +0 -0
  52. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/process_behavior.py +0 -0
  53. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/py.typed +0 -0
  54. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/residual_calculator.py +0 -0
  55. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/result_signals.py +0 -0
  56. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/result_tabular.py +0 -0
  57. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/sds_detector.py +0 -0
  58. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/series_length.py +0 -0
  59. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/signals/__init__.py +0 -0
  60. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/signals/config.py +0 -0
  61. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/signals/detector.py +0 -0
  62. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/signals/detectors.py +0 -0
  63. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/signals/result.py +0 -0
  64. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/spc_constants.py +0 -0
  65. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/study.py +0 -0
  66. {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/types.py +0 -0
  67. {processbehavior-0.3.0 → processbehavior-0.3.2}/pyproject.toml +0 -0
  68. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/__init__.py +0 -0
  69. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/conftest.py +0 -0
  70. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/bishop_analyses/vassds1analysis.json +0 -0
  71. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/bishop_analyses/vassds2analysis.json +0 -0
  72. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/bishop_analyses/vassds3analysis.json +0 -0
  73. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/coffee_capability_full_baseline.json +0 -0
  74. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/data/FILLWEIGHTDATA_800.csv +0 -0
  75. {processbehavior-0.3.0/processbehavior/datasets → processbehavior-0.3.2/tests/fixtures}/data/coffee_shop_demo_long.csv +0 -0
  76. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/missing_values/X_data.parquet +0 -0
  77. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/missing_values/X_metadata.json +0 -0
  78. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/missing_values/X_statistics.json +0 -0
  79. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/missing_values/mR_data.parquet +0 -0
  80. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/missing_values/mR_metadata.json +0 -0
  81. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/missing_values/mR_statistics.json +0 -0
  82. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xbar_s/S_data.parquet +0 -0
  83. {processbehavior-0.3.0/tests/fixtures/golden_masters/stratified_uneven → processbehavior-0.3.2/tests/fixtures/golden_masters/paired_xbar_s}/S_metadata.json +0 -0
  84. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xbar_s/S_statistics.json +0 -0
  85. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xbar_s/Xbar_data.parquet +0 -0
  86. {processbehavior-0.3.0/tests/fixtures/golden_masters/stratified_uneven → processbehavior-0.3.2/tests/fixtures/golden_masters/paired_xbar_s}/Xbar_metadata.json +0 -0
  87. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xbar_s/Xbar_statistics.json +0 -0
  88. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xmr_r/X_data.parquet +0 -0
  89. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xmr_r/X_metadata.json +0 -0
  90. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xmr_r/X_statistics.json +0 -0
  91. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xmr_r/mR_data.parquet +0 -0
  92. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xmr_r/mR_metadata.json +0 -0
  93. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xmr_r/mR_statistics.json +0 -0
  94. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/pathological_ordering/X_data.parquet +0 -0
  95. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/pathological_ordering/X_metadata.json +0 -0
  96. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/pathological_ordering/X_statistics.json +0 -0
  97. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/pathological_ordering/mR_data.parquet +0 -0
  98. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/pathological_ordering/mR_metadata.json +0 -0
  99. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/pathological_ordering/mR_statistics.json +0 -0
  100. {processbehavior-0.3.0/tests/fixtures/golden_masters/stratified_balanced → processbehavior-0.3.2/tests/fixtures/golden_masters/residual_chart}/S_data.parquet +0 -0
  101. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/residual_chart/S_metadata.json +0 -0
  102. {processbehavior-0.3.0/tests/fixtures/golden_masters/stratified_balanced → processbehavior-0.3.2/tests/fixtures/golden_masters/residual_chart}/S_statistics.json +0 -0
  103. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/single_obs_strata/X_data.parquet +0 -0
  104. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/single_obs_strata/X_metadata.json +0 -0
  105. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/single_obs_strata/X_statistics.json +0 -0
  106. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/single_obs_strata/mR_data.parquet +0 -0
  107. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/single_obs_strata/mR_metadata.json +0 -0
  108. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/single_obs_strata/mR_statistics.json +0 -0
  109. {processbehavior-0.3.0/tests/fixtures/golden_masters/residual_chart → processbehavior-0.3.2/tests/fixtures/golden_masters/stratified_balanced}/S_data.parquet +0 -0
  110. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_balanced/S_metadata.json +0 -0
  111. {processbehavior-0.3.0/tests/fixtures/golden_masters/residual_chart → processbehavior-0.3.2/tests/fixtures/golden_masters/stratified_balanced}/S_statistics.json +0 -0
  112. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_balanced/Xbar_data.parquet +0 -0
  113. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_balanced/Xbar_metadata.json +0 -0
  114. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_balanced/Xbar_statistics.json +0 -0
  115. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_uneven/S_data.parquet +0 -0
  116. {processbehavior-0.3.0/tests/fixtures/golden_masters/paired_xbar_s → processbehavior-0.3.2/tests/fixtures/golden_masters/stratified_uneven}/S_metadata.json +0 -0
  117. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_uneven/S_statistics.json +0 -0
  118. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_uneven/Xbar_data.parquet +0 -0
  119. {processbehavior-0.3.0/tests/fixtures/golden_masters/paired_xbar_s → processbehavior-0.3.2/tests/fixtures/golden_masters/stratified_uneven}/Xbar_metadata.json +0 -0
  120. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_uneven/Xbar_statistics.json +0 -0
  121. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/unstratified_small/X_data.parquet +0 -0
  122. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/unstratified_small/X_metadata.json +0 -0
  123. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/unstratified_small/X_statistics.json +0 -0
  124. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/unstratified_small/mR_data.parquet +0 -0
  125. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/unstratified_small/mR_metadata.json +0 -0
  126. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/unstratified_small/mR_statistics.json +0 -0
  127. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_analysis_bugs.py +0 -0
  128. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_analysis_dataset.py +0 -0
  129. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_analysis_golden_master.py +0 -0
  130. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_analysis_result_contract.py +0 -0
  131. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_analysis_specifications.py +0 -0
  132. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_api_reference.py +0 -0
  133. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_beyond_limits_vectorized.py +0 -0
  134. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_bin_extremes.py +0 -0
  135. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_bishop_reference.py +0 -0
  136. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_by_parameter.py +0 -0
  137. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_by_permutations.py +0 -0
  138. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_calibration.py +0 -0
  139. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_capability.py +0 -0
  140. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_capability_chart.py +0 -0
  141. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_capability_window.py +0 -0
  142. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_chart_availability.py +0 -0
  143. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_chart_column_contract.py +0 -0
  144. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_chart_parsing.py +0 -0
  145. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_control_chart_figure.py +0 -0
  146. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_data_preparation.py +0 -0
  147. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_datasets.py +0 -0
  148. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_design_state_lineage.py +0 -0
  149. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_documented_rule_coverage.py +0 -0
  150. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_edge_cases.py +0 -0
  151. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_effects_calculator.py +0 -0
  152. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_effects_plotting.py +0 -0
  153. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_encode_rsg_vectorized.py +0 -0
  154. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_excel_export.py +0 -0
  155. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_execute_validation.py +0 -0
  156. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_focus.py +0 -0
  157. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_garbage_character_handling.py +0 -0
  158. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_init_cleaning.py +0 -0
  159. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_lane_boundaries.py +0 -0
  160. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_limits_vectorized.py +0 -0
  161. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_loss_function.py +0 -0
  162. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_maximum_information.py +0 -0
  163. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_na_in_factor_columns.py +0 -0
  164. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_notebook_ergonomics.py +0 -0
  165. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_p0_p2_regressions.py +0 -0
  166. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_performance.py +0 -0
  167. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_phased_limits.py +0 -0
  168. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_plotting.py +0 -0
  169. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_plotting_decorations.py +0 -0
  170. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_plotting_hardening.py +0 -0
  171. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_plotting_xaxis.py +0 -0
  172. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_process_behavior.py +0 -0
  173. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_recentered_mr_fix.py +0 -0
  174. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_reference_integrity.py +0 -0
  175. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_release_gate_0_1_0_checklist.py +0 -0
  176. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_residual_aliases.py +0 -0
  177. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_residual_calculator.py +0 -0
  178. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_residual_labels.py +0 -0
  179. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_residual_scope.py +0 -0
  180. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_resolve_by_grouping.py +0 -0
  181. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_result_signals.py +0 -0
  182. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_result_tabular.py +0 -0
  183. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_sampling_plan.py +0 -0
  184. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_sds.py +0 -0
  185. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_sds_detector.py +0 -0
  186. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_series_length.py +0 -0
  187. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_signal_detectors.py +0 -0
  188. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_signal_result_surface.py +0 -0
  189. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_signals.py +0 -0
  190. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_single_detection.py +0 -0
  191. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_solo_mr_residuals.py +0 -0
  192. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_spc_constants.py +0 -0
  193. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_srp_refactor.py +0 -0
  194. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_strata_companion.py +0 -0
  195. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_strata_sufficiency.py +0 -0
  196. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_stratified_insufficiency.py +0 -0
  197. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_supports_calibration.py +0 -0
  198. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_synthetic.py +0 -0
  199. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_types.py +0 -0
  200. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_validate_by_parameter.py +0 -0
  201. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_weco_integration.py +0 -0
  202. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_why_not.py +0 -0
  203. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_x_axis_layout.py +0 -0
  204. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_xbar_residual_center.py +0 -0
  205. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_xbar_s_options.py +0 -0
  206. {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_xbar_stratified.py +0 -0
  207. {processbehavior-0.3.0 → processbehavior-0.3.2}/validation/PBTESTDATABASE_T100.csv +0 -0
@@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.2] - 2026-09-15
11
+
12
+ ### Fixed
13
+ - **Binning a heavily tied column no longer collapses to one bin.** 0.3.1 capped the
14
+ requested bin count at the number of distinct values *before* taking the quantiles, and on
15
+ a column of 3,000 ones and 1,000 twos the halved quantile positions both landed on 1, so
16
+ one bin fitted where 0.3.0 fitted two. When at least as many bins are requested as there
17
+ are distinct values, the fit now cuts at the midpoints between neighbouring values: one
18
+ bin per distinct value, every value in its own bin, with the message ``requested n bins,
19
+ ties produced k (one per distinct value)``. Requesting fewer bins than distinct values is
20
+ untouched (the original quantile / equal-width path). Found by the app's preview test.
21
+ - **The connecting line on a lane chart no longer crosses lane boundaries.** On an X or mR
22
+ chart with factors and ``by=[]`` (one chart, subgroups side by side), the last point of
23
+ each subgroup was joined to the first point of the next, so every boundary drew a steep
24
+ rise or drop that was only the sort order. The line is now its own trace with a gap at
25
+ each boundary, drawn under the markers; the markers trace keeps hover, legend and
26
+ signal highlighting exactly as before. Single-series and faceted charts are unchanged. #121
27
+
28
+ ### Added
29
+ - Docs: a user-guide page on series length and where sigma comes from (the position, the
30
+ n = 3..30 precision table from ``validation/short_series_bands.py`` with credit to
31
+ @rabujamra, the trending-series and not-one-process cautions, the formulation lesson from
32
+ #114, and the two-audiences position on run rules), and a user-guide page stating the
33
+ derived-variables contract (never raises, what counts as a violation, what bins promise,
34
+ what construction rejects).
35
+
36
+ ## [0.3.1] - 2026-09-14
37
+
38
+ ### Fixed
39
+ - **Derived variables: ``evaluate`` and ``validate`` now keep their never-raises promise.**
40
+ An adversarial pass (36,000 fuzzed evaluations) found four ways to make them raise from
41
+ pandas: ±inf in the source column (``equal_freq`` / ``equal_width``: "bins must increase
42
+ monotonically"), range labels that collide at six significant digits on a tiny range or a
43
+ near-constant ``sd`` fit ("labels must be unique"), duplicate or null explicit
44
+ ``bin_labels``, and a string ``shift`` / ``exponent`` reaching numpy. Now: ±inf leaves the
45
+ bin fit, becomes NaN, and is counted in ``n_invalid`` with a message; range labels gain
46
+ precision until distinct (numbered bins as the last resort, with a message); explicit
47
+ labels must be non-empty, null-free and unique; ``shift``, ``exponent`` and ``breaks`` must
48
+ be finite numbers; ``on_invalid`` must be ``'error'`` or ``'na'``; ``n`` must be a positive
49
+ integer (numpy integers accepted, booleans rejected) — all at construction, as
50
+ ``ValidationError``. A datetime or categorical source now evaluates to an all-NA result
51
+ with a message instead of transforming nanoseconds or category codes. A permanent seeded
52
+ fuzz test guards the contract.
53
+
54
+ ### Changed
55
+ - **Every non-finite transform result is a domain violation.** ``log``/``sqrt`` of ``inf``,
56
+ ``square`` overflow and ``inverse`` of a denormal used to return ``inf`` with
57
+ ``n_invalid=0`` (only ``power`` overflow was flagged); all are now violations, so
58
+ ``on_invalid`` applies and an ``inf`` can no longer reach the limits. A z-score of a
59
+ constant (or single-value) column is now a violation for every value rather than a silent
60
+ all-NaN column, so ``on_invalid='error'`` names the derivation at ``formulate``.
61
+ ``arcsin`` on values in (1, 100] adds the hint "if these are percentages, divide by 100".
62
+ Requesting more equal-frequency / equal-width bins than there are distinct values now fits
63
+ one bin per distinct value and says so. ``'ordinal'`` labels above five bins say they fell
64
+ back to numbered bins. ``to_dict`` converts numpy scalars and arrays (it was documented
65
+ JSON-safe but was not); ``from_dict`` decodes the ``Infinity``/``NaN`` tags only under
66
+ numeric keys, so a bin labelled ``"NaN"`` stays a string; a missing ``id`` is a
67
+ ``ValidationError``, not a ``KeyError``. Finite inputs with distinct labels produce
68
+ byte-identical edges, labels and values.
69
+
10
70
  ## [0.3.0] - 2026-09-14
11
71
 
12
72
  ### Added
@@ -869,7 +929,9 @@ and may change between minor versions until 1.0.
869
929
  - `natsort >= 8.0`
870
930
  - `plotly >= 5.18, < 7`
871
931
 
872
- [Unreleased]: https://github.com/cnicholas/processbehavior/compare/v0.3.0...HEAD
932
+ [Unreleased]: https://github.com/cnicholas/processbehavior/compare/v0.3.2...HEAD
933
+ [0.3.2]: https://github.com/cnicholas/processbehavior/compare/v0.3.1...v0.3.2
934
+ [0.3.1]: https://github.com/cnicholas/processbehavior/compare/v0.3.0...v0.3.1
873
935
  [0.3.0]: https://github.com/cnicholas/processbehavior/compare/v0.2.1...v0.3.0
874
936
  [0.2.1]: https://github.com/cnicholas/processbehavior/compare/v0.2.0...v0.2.1
875
937
  [0.2.0]: https://github.com/cnicholas/processbehavior/compare/v0.1.1...v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: processbehavior
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Python-native Process Behavior & Control Charts (spec-driven SPC with tidy outputs)
5
5
  Project-URL: Homepage, https://github.com/cnicholas/processbehavior
6
6
  Project-URL: Repository, https://github.com/cnicholas/processbehavior
@@ -47,7 +47,7 @@ its ``.sds`` field. Internally some modules still use the legacy "SDS"
47
47
  prefix (e.g. ``sds_detector.py``); the model itself is three-state.
48
48
  """
49
49
 
50
- __version__ = '0.3.0'
50
+ __version__ = '0.3.2'
51
51
 
52
52
  # Result object
53
53
  from processbehavior.analysis_result import AnalysisResult
@@ -27,6 +27,7 @@ registry leaves a clean slot to add it behind an optional import later.
27
27
  from __future__ import annotations
28
28
 
29
29
  import math
30
+ import numbers
30
31
  import uuid
31
32
  from collections.abc import Callable
32
33
  from dataclasses import dataclass, field, replace
@@ -66,9 +67,14 @@ _ORDINAL_LABELS: dict[int, list[str]] = {
66
67
 
67
68
 
68
69
  def _jsonify(obj):
69
- """Recursively make a value JSON-safe, encoding non-finite floats as tags."""
70
+ """Recursively make a value JSON-safe: numpy scalars/arrays become Python values,
71
+ non-finite floats become the tags ``'Infinity'`` / ``'-Infinity'`` / ``'NaN'``."""
70
72
  if isinstance(obj, bool):
71
73
  return obj
74
+ if isinstance(obj, np.ndarray):
75
+ return _jsonify(obj.tolist())
76
+ if isinstance(obj, np.generic):
77
+ return _jsonify(obj.item())
72
78
  if isinstance(obj, float):
73
79
  if math.isinf(obj):
74
80
  return 'Infinity' if obj > 0 else '-Infinity'
@@ -82,18 +88,20 @@ def _jsonify(obj):
82
88
  return obj
83
89
 
84
90
 
85
- def _dejsonify(obj):
86
- """Inverse of :func:`_jsonify`."""
87
- if obj == 'Infinity':
88
- return math.inf
89
- if obj == '-Infinity':
90
- return -math.inf
91
- if obj == 'NaN':
92
- return math.nan
91
+ # Keys whose values are numbers (so the non-finite tags decode there, and only there).
92
+ # Label lists are never decoded: a bin labelled "NaN" or "Infinity" stays a string.
93
+ _NUMERIC_KEYS = frozenset({'edges', 'breaks', 'mu', 'sigma', 'shift', 'exponent', 'n_bins'})
94
+ _FLOAT_TAGS = {'Infinity': math.inf, '-Infinity': -math.inf, 'NaN': math.nan}
95
+
96
+
97
+ def _dejsonify(obj, numeric: bool = False):
98
+ """Inverse of :func:`_jsonify`. Tags decode only under numeric keys."""
99
+ if isinstance(obj, str):
100
+ return _FLOAT_TAGS[obj] if numeric and obj in _FLOAT_TAGS else obj
93
101
  if isinstance(obj, dict):
94
- return {k: _dejsonify(v) for k, v in obj.items()}
102
+ return {k: _dejsonify(v, numeric=(k in _NUMERIC_KEYS)) for k, v in obj.items()}
95
103
  if isinstance(obj, list):
96
- return [_dejsonify(v) for v in obj]
104
+ return [_dejsonify(v, numeric=numeric) for v in obj]
97
105
  return obj
98
106
 
99
107
 
@@ -143,6 +151,32 @@ class ValidationResult:
143
151
  return '; '.join(i.get('message', i.get('code', '')) for i in self.issues)
144
152
 
145
153
 
154
+ def _validate_bin_labels(bl) -> None:
155
+ """A style name, or an explicit list of names that is non-empty, null-free and unique."""
156
+ if isinstance(bl, str):
157
+ if bl not in BIN_LABEL_STYLES:
158
+ raise ValidationError(
159
+ f'Unknown bin_labels style {bl!r}. Use one of {list(BIN_LABEL_STYLES)} '
160
+ 'or an explicit list of names.'
161
+ )
162
+ return
163
+ if not isinstance(bl, (list, tuple)):
164
+ raise ValidationError(f'bin_labels must be a style name or a list of names, got {type(bl).__name__}.')
165
+ names = list(bl)
166
+ if not names:
167
+ raise ValidationError('Explicit bin_labels must not be empty.')
168
+ if any(v is None or (isinstance(v, float) and math.isnan(v)) for v in names):
169
+ raise ValidationError('Explicit bin_labels must not contain null values.')
170
+ if len({str(v) for v in names}) != len(names):
171
+ raise ValidationError(f'Explicit bin_labels must be unique, got {names!r}.')
172
+
173
+
174
+ def _require_finite_real(value, name: str) -> None:
175
+ """Raise ValidationError unless ``value`` is a finite real number (bools excluded)."""
176
+ if isinstance(value, bool) or not isinstance(value, numbers.Real) or not math.isfinite(float(value)):
177
+ raise ValidationError(f'{name} must be a finite number, got {value!r}.')
178
+
179
+
146
180
  # ============================================================================
147
181
  # Derivation spec
148
182
  # ============================================================================
@@ -170,49 +204,50 @@ class Derivation:
170
204
  def __post_init__(self) -> None:
171
205
  if not self.column or not isinstance(self.column, str):
172
206
  raise ValidationError('Derivation.column must be a non-empty string.')
173
-
174
207
  if self.family == 'transform':
175
- if self.function not in TRANSFORM_FUNCTIONS:
176
- raise ValidationError(
177
- f"Unknown transform function {self.function!r}. "
178
- f'Supported: {list(TRANSFORM_FUNCTIONS)} (ln is an alias for log).'
179
- )
180
- if self.function == 'power' and 'exponent' not in self.params:
181
- raise ValidationError("transform 'power' requires an 'exponent' param.")
182
-
208
+ self._validate_transform()
183
209
  elif self.family == 'bin':
184
- if self.function != 'bin':
185
- raise ValidationError(
186
- f"bin derivations must have function='bin', got {self.function!r}."
187
- )
188
- method = self.params.get('method')
189
- if method not in BIN_METHODS:
190
- raise ValidationError(
191
- f"Unknown bin method {method!r}. Supported: {list(BIN_METHODS)}."
192
- )
193
- if method == 'breaks':
194
- breaks = self.params.get('breaks')
195
- if not breaks or len(breaks) < 1 or not _ascending(breaks):
196
- raise ValidationError(
197
- "bin method 'breaks' requires an ascending list of cut points."
198
- )
199
- elif method in ('equal_freq', 'equal_width'):
200
- n = self.params.get('n')
201
- if not (isinstance(n, int) and n > 0):
202
- raise ValidationError(
203
- f"bin method {method!r} requires an integer n > 0, got {n!r}."
204
- )
205
- bl = self.params.get('bin_labels', 'range')
206
- if isinstance(bl, str) and bl not in BIN_LABEL_STYLES:
207
- raise ValidationError(
208
- f"Unknown bin_labels style {bl!r}. Use one of {list(BIN_LABEL_STYLES)} "
209
- 'or an explicit list of names.'
210
- )
210
+ self._validate_bin()
211
211
  else:
212
212
  raise ValidationError(
213
213
  f"Derivation.family must be 'transform' or 'bin', got {self.family!r}."
214
214
  )
215
215
 
216
+ def _validate_transform(self) -> None:
217
+ if self.function not in TRANSFORM_FUNCTIONS:
218
+ raise ValidationError(
219
+ f"Unknown transform function {self.function!r}. "
220
+ f'Supported: {list(TRANSFORM_FUNCTIONS)} (ln is an alias for log).'
221
+ )
222
+ if self.function == 'power' and 'exponent' not in self.params:
223
+ raise ValidationError("transform 'power' requires an 'exponent' param.")
224
+ on_invalid = self.params.get('on_invalid', 'error')
225
+ if on_invalid not in ('error', 'na'):
226
+ raise ValidationError(f"on_invalid must be 'error' or 'na', got {on_invalid!r}.")
227
+ for key in ('shift', 'exponent'):
228
+ if self.params.get(key) is not None:
229
+ _require_finite_real(self.params[key], key)
230
+
231
+ def _validate_bin(self) -> None:
232
+ if self.function != 'bin':
233
+ raise ValidationError(f"bin derivations must have function='bin', got {self.function!r}.")
234
+ method = self.params.get('method')
235
+ if method not in BIN_METHODS:
236
+ raise ValidationError(f'Unknown bin method {method!r}. Supported: {list(BIN_METHODS)}.')
237
+ if method == 'breaks':
238
+ breaks = self.params.get('breaks')
239
+ if not breaks:
240
+ raise ValidationError("bin method 'breaks' requires an ascending list of cut points.")
241
+ for b in breaks:
242
+ _require_finite_real(b, 'breaks')
243
+ if not _ascending(breaks):
244
+ raise ValidationError("bin method 'breaks' requires an ascending list of cut points.")
245
+ elif method in ('equal_freq', 'equal_width'):
246
+ n = self.params.get('n')
247
+ if isinstance(n, bool) or not isinstance(n, numbers.Integral) or n <= 0:
248
+ raise ValidationError(f'bin method {method!r} requires an integer n > 0, got {n!r}.')
249
+ _validate_bin_labels(self.params.get('bin_labels', 'range'))
250
+
216
251
  # -- factories ---------------------------------------------------------
217
252
  @classmethod
218
253
  def transform(
@@ -229,9 +264,11 @@ class Derivation:
229
264
  fn = 'log' if function == 'ln' else function
230
265
  params: dict = {'on_invalid': on_invalid}
231
266
  if shift is not None:
232
- params['shift'] = shift
267
+ _require_finite_real(shift, 'shift')
268
+ params['shift'] = float(shift)
233
269
  if exponent is not None:
234
- params['exponent'] = exponent
270
+ _require_finite_real(exponent, 'exponent')
271
+ params['exponent'] = float(exponent)
235
272
  return cls(family='transform', column=column, function=fn, label=label, params=params)
236
273
 
237
274
  @classmethod
@@ -247,11 +284,20 @@ class Derivation:
247
284
  right: bool = False,
248
285
  ) -> Derivation:
249
286
  """Build a binning spec."""
250
- params: dict = {'method': method, 'bin_labels': bin_labels, 'right': right}
287
+ if isinstance(bin_labels, (list, tuple)):
288
+ bin_labels = [v.item() if isinstance(v, np.generic) else v for v in bin_labels]
289
+ params: dict = {'method': method, 'bin_labels': bin_labels, 'right': bool(right)}
251
290
  if method == 'breaks':
252
- params['breaks'] = list(breaks) if breaks is not None else None
291
+ if breaks is not None:
292
+ for b in breaks:
293
+ _require_finite_real(b, 'breaks')
294
+ params['breaks'] = [float(b) for b in breaks]
295
+ else:
296
+ params['breaks'] = None
253
297
  else:
254
- params['n'] = n
298
+ if isinstance(n, bool) or not isinstance(n, numbers.Integral):
299
+ raise ValidationError(f'bin method {method!r} requires an integer n > 0, got {n!r}.')
300
+ params['n'] = int(n)
255
301
  return cls(family='bin', column=column, function='bin', label=label, params=params)
256
302
 
257
303
  # -- naming / serialization -------------------------------------------
@@ -275,6 +321,8 @@ class Derivation:
275
321
  @classmethod
276
322
  def from_dict(cls, d: dict) -> Derivation:
277
323
  """Reconstruct from :meth:`to_dict`. Takes ``id`` from the dict (never mints)."""
324
+ if 'id' not in d:
325
+ raise ValidationError("Derivation.from_dict needs an 'id' (use to_dict() output).")
278
326
  return cls(
279
327
  family=d['family'],
280
328
  column=d['column'],
@@ -341,7 +389,11 @@ _TRANSFORM_REGISTRY: dict[str, tuple[Callable, Callable]] = {
341
389
 
342
390
  def _evaluate_transform(spec: Derivation, col: pd.Series) -> EvalResult:
343
391
  x = pd.to_numeric(col, errors='coerce').astype('float64')
392
+ finite = np.isfinite(x.to_numpy())
344
393
  present = x.notna()
394
+ # ±inf is a domain violation for every transform: no finite result exists.
395
+ nonfinite_in = present & ~finite
396
+ present = present & finite
345
397
  params = spec.params
346
398
  shift = params.get('shift')
347
399
  if shift is not None:
@@ -351,7 +403,7 @@ def _evaluate_transform(spec: Derivation, col: pd.Series) -> EvalResult:
351
403
  message: str | None = None
352
404
 
353
405
  if spec.function == 'zscore':
354
- clamped, violation = x, pd.Series(False, index=x.index)
406
+ violation = pd.Series(False, index=x.index)
355
407
  vals = x[present]
356
408
  mu = float(vals.mean()) if present.any() else math.nan
357
409
  sigma = float(vals.std(ddof=1)) if present.sum() > 1 else math.nan
@@ -359,21 +411,30 @@ def _evaluate_transform(spec: Derivation, col: pd.Series) -> EvalResult:
359
411
  with np.errstate(all='ignore'):
360
412
  y = (x - mu) / sigma
361
413
  if not (sigma and math.isfinite(sigma) and sigma > 0):
414
+ # No z-score exists for any value, so every present value is a violation
415
+ # (on_invalid then applies) rather than a silent all-NaN column.
362
416
  message = 'zero or undefined variance; zscore is undefined'
417
+ violation = present.copy()
363
418
 
364
419
  elif spec.function == 'power':
365
420
  exponent = params['exponent']
366
421
  with np.errstate(all='ignore'):
367
422
  y = pd.Series(np.power(x.to_numpy(), exponent), index=x.index)
368
- # A power is a domain violation where it cannot be represented as a
369
- # finite real (negative base to a fractional power, 0 to a negative power).
370
- violation = present & ~np.isfinite(y)
423
+ violation = pd.Series(False, index=x.index)
371
424
 
372
425
  else:
373
426
  fn, domain = _TRANSFORM_REGISTRY[spec.function]
374
427
  clamped, violation = domain(x, present)
375
428
  with np.errstate(all='ignore'):
376
429
  y = pd.Series(fn(clamped.to_numpy()), index=x.index)
430
+ if spec.function == 'arcsin' and violation.any():
431
+ xv = x[present]
432
+ if len(xv) and xv.max() > 1.0 and xv.min() >= 0.0 and xv.max() <= 100.0:
433
+ message = 'values exceed 1; if these are percentages, divide by 100 first'
434
+
435
+ # A result that is not a finite real is a domain violation, for every function:
436
+ # negative base to a fractional power, 0 to a negative power, overflow to inf.
437
+ violation = violation | (present & ~np.isfinite(y.to_numpy(dtype=float))) | nonfinite_in
377
438
 
378
439
  # Violations -> NaN in the output (NA inputs already produce NaN).
379
440
  y = y.where(~violation)
@@ -393,15 +454,16 @@ def _fmt(v: float) -> str:
393
454
  return f'{v:g}'
394
455
 
395
456
 
396
- def _range_labels(edges, right: bool) -> list[str]:
457
+ def _range_labels(edges, right: bool, digits: int = 6) -> list[str]:
397
458
  labels = []
398
459
  count = len(edges) - 1
460
+ fmt = lambda v: f'{v:.{digits}g}' # noqa: E731
399
461
  for i in range(count):
400
462
  a, b = edges[i], edges[i + 1]
401
463
  if math.isinf(a):
402
- labels.append(f'< {_fmt(b)}' if not right else f'<= {_fmt(b)}')
464
+ labels.append(f'< {fmt(b)}' if not right else f'<= {fmt(b)}')
403
465
  elif math.isinf(b):
404
- labels.append(f'>= {_fmt(a)}' if not right else f'> {_fmt(a)}')
466
+ labels.append(f'>= {fmt(a)}' if not right else f'> {fmt(a)}')
405
467
  else:
406
468
  left = '[' if not right else '('
407
469
  rb = ')' if not right else ']'
@@ -411,30 +473,49 @@ def _range_labels(edges, right: bool) -> list[str]:
411
473
  rb = ']'
412
474
  if right and i == 0:
413
475
  left = '['
414
- labels.append(f'{left}{_fmt(a)}, {_fmt(b)}{rb}')
476
+ labels.append(f'{left}{fmt(a)}, {fmt(b)}{rb}')
415
477
  return labels
416
478
 
417
479
 
480
+ def _unique_range_labels(edges, right: bool):
481
+ """Range labels that are guaranteed distinct. Returns (labels, message).
482
+
483
+ Six significant digits read well but collide on a tiny range (edges 1.00000001 and
484
+ 1.00000002 both print as ``1``), and pd.cut refuses duplicate labels. Add precision
485
+ until the labels differ; if even 17 digits cannot separate them, number the bins.
486
+ """
487
+ for digits in range(6, 18):
488
+ labels = _range_labels(edges, right, digits)
489
+ if len(set(labels)) == len(labels):
490
+ return labels, None
491
+ return [f'Bin {i + 1}' for i in range(len(edges) - 1)], (
492
+ 'bin edges too close to label as ranges; using numbered bins'
493
+ )
494
+
495
+
418
496
  def _bin_label_names(bin_labels, edges, right: bool):
419
497
  """Resolve the ordered label names for the fitted edges. Returns (labels, message)."""
420
498
  count = len(edges) - 1
421
499
  if isinstance(bin_labels, (list, tuple)):
422
500
  if len(bin_labels) != count:
423
- return _range_labels(edges, right), (
501
+ labels, _ = _unique_range_labels(edges, right)
502
+ return labels, (
424
503
  f'{len(bin_labels)} labels supplied but {count} bins fitted; using range labels'
425
504
  )
426
505
  return list(bin_labels), None
427
506
  if bin_labels == 'ordinal':
428
507
  if count in _ORDINAL_LABELS:
429
508
  return list(_ORDINAL_LABELS[count]), None
430
- return [f'Bin {i + 1}' for i in range(count)], None # fall back to number
509
+ return [f'Bin {i + 1}' for i in range(count)], (
510
+ f'ordinal labels are defined for 2 to 5 bins; {count} fitted, using numbered bins'
511
+ )
431
512
  if bin_labels == 'number':
432
513
  return [f'Bin {i + 1}' for i in range(count)], None
433
- return _range_labels(edges, right), None
514
+ return _unique_range_labels(edges, right)
434
515
 
435
516
 
436
517
  def _fit_edges(spec: Derivation, present_vals: pd.Series):
437
- """Return (edges, message) for the requested method, fitted on non-NA values."""
518
+ """Return (edges, message) for the requested method, fitted on finite non-NA values."""
438
519
  params = spec.params
439
520
  method = params['method']
440
521
  message = None
@@ -445,30 +526,47 @@ def _fit_edges(spec: Derivation, present_vals: pd.Series):
445
526
  mu = float(present_vals.mean())
446
527
  sigma = float(present_vals.std(ddof=1)) if len(present_vals) > 1 else math.nan
447
528
  edges = [-math.inf, mu - 2 * sigma, mu - sigma, mu + sigma, mu + 2 * sigma, math.inf]
448
- elif method == 'equal_width':
449
- n = params['n']
450
- lo, hi = float(present_vals.min()), float(present_vals.max())
451
- edges = list(np.linspace(lo, hi, n + 1))
452
- else: # equal_freq
529
+ else:
453
530
  n = params['n']
454
- qs = np.linspace(0.0, 1.0, n + 1)
455
- edges = list(np.unique(np.quantile(present_vals, qs)))
456
- if len(edges) - 1 != n:
457
- message = f'requested {n} bins, ties produced {len(edges) - 1}'
531
+ distinct = np.unique(present_vals.to_numpy(dtype=float))
532
+ if len(distinct) >= 2 and n >= len(distinct):
533
+ # More bins than distinct values: one bin per distinct value, cut at the
534
+ # midpoints between neighbours. Capping n and re-taking quantiles instead
535
+ # collapsed heavily tied columns (3,000 ones and 1,000 twos fitted one bin);
536
+ # the requested-n quantiles interpolated between the values and left mostly
537
+ # empty bins when n was large.
538
+ mids = (distinct[:-1] + distinct[1:]) / 2.0
539
+ edges = [float(distinct[0]), *[float(m) for m in mids], float(distinct[-1])]
540
+ message = f'requested {n} bins, ties produced {len(distinct)} (one per distinct value)'
541
+ elif method == 'equal_width':
542
+ lo, hi = float(present_vals.min()), float(present_vals.max())
543
+ edges = list(np.linspace(lo, hi, n + 1))
544
+ else: # equal_freq
545
+ qs = np.linspace(0.0, 1.0, n + 1)
546
+ edges = list(np.unique(np.quantile(present_vals, qs)))
547
+ if len(edges) - 1 != n:
548
+ message = f'requested {n} bins, ties produced {len(edges) - 1}'
458
549
  return edges, message
459
550
 
460
551
 
461
552
  def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult:
462
553
  x = pd.to_numeric(col, errors='coerce').astype('float64')
463
- present = x.notna()
554
+ finite = np.isfinite(x.to_numpy())
555
+ # ±inf cannot be placed in any finite bin: it leaves the fit, becomes NaN in the
556
+ # output, and is counted (the bin analogue of a transform's domain violation).
557
+ nonfinite = x.notna() & ~finite
558
+ present = x.notna() & finite
559
+ n_nonfinite = int(nonfinite.sum())
560
+ nonfinite_msg = f'{n_nonfinite} non-finite value(s) cannot be binned' if n_nonfinite else None
464
561
  params = spec.params
465
562
  right = params.get('right', False)
466
- empty_index = x.index[[]]
467
563
 
468
564
  if present.sum() == 0:
469
565
  return EvalResult(
470
566
  values=pd.Series(pd.Categorical([np.nan] * len(x)), index=x.index),
471
- n_invalid=0, invalid_index=empty_index, fitted={}, message='no non-NA values to bin',
567
+ n_invalid=n_nonfinite, invalid_index=x.index[nonfinite],
568
+ fitted={'method': params['method'], 'n_bins': 0, 'edges': [], 'labels': []},
569
+ message='; '.join(m for m in ('no finite values to bin', nonfinite_msg) if m),
472
570
  )
473
571
 
474
572
  edges, fit_msg = _fit_edges(spec, x[present])
@@ -488,9 +586,9 @@ def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult:
488
586
  if degenerate:
489
587
  return EvalResult(
490
588
  values=pd.Series(pd.Categorical([np.nan] * len(x)), index=x.index),
491
- n_invalid=0, invalid_index=empty_index,
589
+ n_invalid=n_nonfinite, invalid_index=x.index[nonfinite],
492
590
  fitted={'method': params['method'], 'n_bins': 0, 'edges': [], 'labels': []},
493
- message='column has no spread; cannot bin',
591
+ message='; '.join(m for m in ('column has no spread; cannot bin', nonfinite_msg) if m),
494
592
  )
495
593
 
496
594
  labels, label_msg = _bin_label_names(params.get('bin_labels', 'range'), edges, right)
@@ -507,7 +605,9 @@ def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult:
507
605
  if not right and math.isfinite(cut_edges[-1]):
508
606
  cut_edges[-1] = float(np.nextafter(cut_edges[-1], math.inf))
509
607
 
510
- cats = pd.cut(x, bins=cut_edges, right=right, labels=labels, include_lowest=True, ordered=True)
608
+ cats = pd.cut(
609
+ x.where(present), bins=cut_edges, right=right, labels=labels, include_lowest=True, ordered=True
610
+ )
511
611
  values = pd.Series(cats, index=x.index)
512
612
 
513
613
  fitted = {
@@ -521,9 +621,10 @@ def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult:
521
621
  fitted['mu'] = float(x[present].mean())
522
622
  fitted['sigma'] = float(x[present].std(ddof=1)) if present.sum() > 1 else math.nan
523
623
 
524
- message = '; '.join(m for m in (fit_msg, label_msg) if m) or None
624
+ message = '; '.join(m for m in (fit_msg, label_msg, nonfinite_msg) if m) or None
525
625
  return EvalResult(
526
- values=values, n_invalid=0, invalid_index=empty_index, fitted=fitted, message=message
626
+ values=values, n_invalid=n_nonfinite, invalid_index=x.index[nonfinite], fitted=fitted,
627
+ message=message,
527
628
  )
528
629
 
529
630
 
@@ -538,11 +639,34 @@ def evaluate(spec: Derivation, column: pd.Series) -> EvalResult:
538
639
  Pre-existing NA passes through as NA; ``n_invalid`` counts only domain
539
640
  violations among non-NA values. Never raises.
540
641
  """
642
+ if not _is_numeric_source(column):
643
+ empty = column.index[[]]
644
+ if spec.family == 'bin':
645
+ values = pd.Series(pd.Categorical([np.nan] * len(column)), index=column.index)
646
+ fitted = {'method': spec.params['method'], 'n_bins': 0, 'edges': [], 'labels': []}
647
+ else:
648
+ values = pd.Series(np.nan, index=column.index, dtype='float64')
649
+ fitted = {}
650
+ return EvalResult(
651
+ values=values, n_invalid=0, invalid_index=empty, fitted=fitted,
652
+ message=f'column {column.name!r} is not numeric; nothing derived',
653
+ )
541
654
  if spec.family == 'transform':
542
655
  return _evaluate_transform(spec, column)
543
656
  return _evaluate_bin(spec, column)
544
657
 
545
658
 
659
+ def _is_numeric_source(column: pd.Series) -> bool:
660
+ """Numeric (bool included) or object/string that coerces to numbers; not datetime/categorical."""
661
+ dtype = column.dtype
662
+ if pd.api.types.is_bool_dtype(dtype) or pd.api.types.is_numeric_dtype(dtype):
663
+ return True
664
+ if pd.api.types.is_object_dtype(dtype) or pd.api.types.is_string_dtype(dtype):
665
+ coerced = pd.to_numeric(column, errors='coerce')
666
+ return bool(coerced.notna().any()) or not bool(column.notna().any())
667
+ return False
668
+
669
+
546
670
  def validate(spec: Derivation, dataset: pd.DataFrame, existing_names=None) -> ValidationResult:
547
671
  """Structured pre-commit check (label collision, dtype, bin-label count, breaks order).
548
672
 
@@ -35,6 +35,36 @@ if TYPE_CHECKING:
35
35
  # ---------------------------------------------------------------------------
36
36
 
37
37
 
38
+ def _lane_positions(lane_boundaries, chart_name: str, n_rows: int) -> list[int]:
39
+ """0-based row positions where a new lane starts (excluding row 0), for this chart.
40
+
41
+ Accepts the flat ``list[dict]`` form or the per-stratum ``dict`` form of the
42
+ ``lane_boundaries`` metadata; returns ``[]`` when there are no boundaries.
43
+ """
44
+ if not lane_boundaries:
45
+ return []
46
+ if isinstance(lane_boundaries, dict):
47
+ lane_boundaries = lane_boundaries.get(chart_name) or []
48
+ positions = sorted({int(b['position']) for b in lane_boundaries if 0 < int(b['position']) < n_rows})
49
+ return positions
50
+
51
+
52
+ def _with_gaps(values: list, positions: list[int], repeat: bool = False) -> list:
53
+ """Insert a gap entry before each position so Plotly breaks the line there.
54
+
55
+ For ``y`` the gap is ``None`` (a null y breaks the line). For ``x`` pass
56
+ ``repeat=True`` to insert the position's own x value instead, so the x array
57
+ stays numeric/categorical throughout and axis consumers never see a null.
58
+ """
59
+ out: list = []
60
+ cut = set(positions)
61
+ for i, v in enumerate(values):
62
+ if i in cut:
63
+ out.append(v if repeat else None)
64
+ out.append(v)
65
+ return out
66
+
67
+
38
68
  def _build_hover(
39
69
  data: pd.DataFrame,
40
70
  ctx: RenderContext,
@@ -136,10 +166,32 @@ def render_control_chart(
136
166
 
137
167
  # 2. Main data trace
138
168
  customdata, hovertemplate = _build_hover(data, ctx, value_col)
169
+ lane_positions = _lane_positions(spec.lane_boundaries, ctx.chart_name, len(data))
170
+ if lane_positions:
171
+ # Lane chart: the connecting line must not cross a lane boundary. The last point
172
+ # of one subgroup joined to the first of the next drew a steep "event" at every
173
+ # boundary that was only the sort order (#121). Draw the line as its own trace
174
+ # with a gap at each boundary, under the markers; the markers trace keeps the
175
+ # hover, customdata and legend exactly as before.
176
+ _add_trace(
177
+ fig,
178
+ go.Scatter(
179
+ x=_with_gaps(list(x_data), lane_positions, repeat=True),
180
+ y=_with_gaps(list(data[value_col]), lane_positions),
181
+ mode='lines',
182
+ line=dict(color=theme.data_color, width=ctx.line_width),
183
+ opacity=theme.data_opacity,
184
+ hoverinfo='skip',
185
+ showlegend=False,
186
+ name=f'{ctx.chart_name} (lanes)',
187
+ ),
188
+ row,
189
+ col,
190
+ )
139
191
  trace_kw = dict(
140
192
  x=x_data,
141
193
  y=data[value_col],
142
- mode='lines+markers',
194
+ mode='markers' if lane_positions else 'lines+markers',
143
195
  name=ctx.chart_name,
144
196
  marker=dict(size=ctx.marker_size, color=theme.data_color),
145
197
  line=dict(color=theme.data_color, width=ctx.line_width),