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.
- {processbehavior-0.3.0 → processbehavior-0.3.2}/CHANGELOG.md +63 -1
- {processbehavior-0.3.0 → processbehavior-0.3.2}/PKG-INFO +1 -1
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/__init__.py +1 -1
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/derivations.py +205 -81
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/renderers.py +53 -1
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_derivations.py +13 -6
- processbehavior-0.3.2/tests/test_derivations_robustness.py +329 -0
- processbehavior-0.3.2/tests/test_lane_connectors.py +103 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/.gitignore +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/CITATION.cff +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/CODE_OF_CONDUCT.md +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/CONTRIBUTING.md +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/LICENSE +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/README.md +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/SECURITY.md +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/examples/README.md +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/analysis.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/analysis_dataset.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/analysis_result.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/calibration.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/capability.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/data_preparation.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/datasets/__init__.py +0 -0
- {processbehavior-0.3.0/tests/fixtures → processbehavior-0.3.2/processbehavior/datasets}/data/coffee_shop_demo_long.csv +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/datasets/loaders.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/datasets/synthetic.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/effects_calculator.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/exceptions.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/exporters/__init__.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/exporters/excel_exporter.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/focused_result.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/formulation_spec.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/loss_function.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/maximum_information.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/__init__.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/_math.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/capability_chart.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/contracts.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/control_chart.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/effects_charts.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/lane_boundaries.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/limits.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/maximum_information_chart.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/plotter.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/report.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/residuals.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/run_rules_viz.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/stats_box.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/themes.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/x_axis_layout.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/plotting/zones.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/process_behavior.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/py.typed +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/residual_calculator.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/result_signals.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/result_tabular.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/sds_detector.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/series_length.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/signals/__init__.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/signals/config.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/signals/detector.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/signals/detectors.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/signals/result.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/spc_constants.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/study.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/processbehavior/types.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/pyproject.toml +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/__init__.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/conftest.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/bishop_analyses/vassds1analysis.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/bishop_analyses/vassds2analysis.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/bishop_analyses/vassds3analysis.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/coffee_capability_full_baseline.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/data/FILLWEIGHTDATA_800.csv +0 -0
- {processbehavior-0.3.0/processbehavior/datasets → processbehavior-0.3.2/tests/fixtures}/data/coffee_shop_demo_long.csv +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/missing_values/X_data.parquet +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/missing_values/X_metadata.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/missing_values/X_statistics.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/missing_values/mR_data.parquet +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/missing_values/mR_metadata.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/missing_values/mR_statistics.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xbar_s/S_data.parquet +0 -0
- {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
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xbar_s/S_statistics.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xbar_s/Xbar_data.parquet +0 -0
- {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
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xbar_s/Xbar_statistics.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xmr_r/X_data.parquet +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xmr_r/X_metadata.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xmr_r/X_statistics.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xmr_r/mR_data.parquet +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xmr_r/mR_metadata.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/paired_xmr_r/mR_statistics.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/pathological_ordering/X_data.parquet +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/pathological_ordering/X_metadata.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/pathological_ordering/X_statistics.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/pathological_ordering/mR_data.parquet +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/pathological_ordering/mR_metadata.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/pathological_ordering/mR_statistics.json +0 -0
- {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
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/residual_chart/S_metadata.json +0 -0
- {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
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/single_obs_strata/X_data.parquet +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/single_obs_strata/X_metadata.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/single_obs_strata/X_statistics.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/single_obs_strata/mR_data.parquet +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/single_obs_strata/mR_metadata.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/single_obs_strata/mR_statistics.json +0 -0
- {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
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_balanced/S_metadata.json +0 -0
- {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
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_balanced/Xbar_data.parquet +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_balanced/Xbar_metadata.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_balanced/Xbar_statistics.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_uneven/S_data.parquet +0 -0
- {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
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_uneven/S_statistics.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_uneven/Xbar_data.parquet +0 -0
- {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
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/stratified_uneven/Xbar_statistics.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/unstratified_small/X_data.parquet +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/unstratified_small/X_metadata.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/unstratified_small/X_statistics.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/unstratified_small/mR_data.parquet +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/unstratified_small/mR_metadata.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/fixtures/golden_masters/unstratified_small/mR_statistics.json +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_analysis_bugs.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_analysis_dataset.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_analysis_golden_master.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_analysis_result_contract.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_analysis_specifications.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_api_reference.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_beyond_limits_vectorized.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_bin_extremes.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_bishop_reference.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_by_parameter.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_by_permutations.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_calibration.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_capability.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_capability_chart.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_capability_window.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_chart_availability.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_chart_column_contract.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_chart_parsing.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_control_chart_figure.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_data_preparation.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_datasets.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_design_state_lineage.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_documented_rule_coverage.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_edge_cases.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_effects_calculator.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_effects_plotting.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_encode_rsg_vectorized.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_excel_export.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_execute_validation.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_focus.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_garbage_character_handling.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_init_cleaning.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_lane_boundaries.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_limits_vectorized.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_loss_function.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_maximum_information.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_na_in_factor_columns.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_notebook_ergonomics.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_p0_p2_regressions.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_performance.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_phased_limits.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_plotting.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_plotting_decorations.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_plotting_hardening.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_plotting_xaxis.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_process_behavior.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_recentered_mr_fix.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_reference_integrity.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_release_gate_0_1_0_checklist.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_residual_aliases.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_residual_calculator.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_residual_labels.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_residual_scope.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_resolve_by_grouping.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_result_signals.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_result_tabular.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_sampling_plan.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_sds.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_sds_detector.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_series_length.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_signal_detectors.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_signal_result_surface.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_signals.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_single_detection.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_solo_mr_residuals.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_spc_constants.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_srp_refactor.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_strata_companion.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_strata_sufficiency.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_stratified_insufficiency.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_supports_calibration.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_synthetic.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_types.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_validate_by_parameter.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_weco_integration.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_why_not.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_x_axis_layout.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_xbar_residual_center.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_xbar_s_options.py +0 -0
- {processbehavior-0.3.0 → processbehavior-0.3.2}/tests/test_xbar_stratified.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
+
_require_finite_real(shift, 'shift')
|
|
268
|
+
params['shift'] = float(shift)
|
|
233
269
|
if exponent is not None:
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'< {
|
|
464
|
+
labels.append(f'< {fmt(b)}' if not right else f'<= {fmt(b)}')
|
|
403
465
|
elif math.isinf(b):
|
|
404
|
-
labels.append(f'>= {
|
|
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}{
|
|
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
|
-
|
|
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)],
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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(
|
|
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=
|
|
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),
|