tabsight 0.1.0__py3-none-any.whl
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.
- tabsight/__init__.py +89 -0
- tabsight/__main__.py +3 -0
- tabsight/_version.py +1 -0
- tabsight/accessor.py +114 -0
- tabsight/aggregate.py +759 -0
- tabsight/analytics.py +275 -0
- tabsight/auto.py +249 -0
- tabsight/charts/__init__.py +737 -0
- tabsight/charts/_build.py +359 -0
- tabsight/charts/_common.py +124 -0
- tabsight/charts/extra.py +236 -0
- tabsight/charts/facets.py +369 -0
- tabsight/charts/grids.py +202 -0
- tabsight/charts/maps.py +669 -0
- tabsight/charts/pane.py +336 -0
- tabsight/charts/parts.py +319 -0
- tabsight/charts/relation.py +530 -0
- tabsight/check.py +318 -0
- tabsight/clean.py +409 -0
- tabsight/cli.py +295 -0
- tabsight/colors.py +155 -0
- tabsight/dashboard.py +1436 -0
- tabsight/figure.py +241 -0
- tabsight/formats.py +487 -0
- tabsight/formulas.py +754 -0
- tabsight/geo/__init__.py +109 -0
- tabsight/geo/data/__init__.py +1 -0
- tabsight/geo/data/admin1/ABW.topo.json.gz +0 -0
- tabsight/geo/data/admin1/AFG.topo.json.gz +0 -0
- tabsight/geo/data/admin1/AGO.topo.json.gz +0 -0
- tabsight/geo/data/admin1/AIA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ALB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ALD.topo.json.gz +0 -0
- tabsight/geo/data/admin1/AND.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ARE.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ARG.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ARM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ASM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ATA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ATC.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ATF.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ATG.topo.json.gz +0 -0
- tabsight/geo/data/admin1/AUS.topo.json.gz +0 -0
- tabsight/geo/data/admin1/AUT.topo.json.gz +0 -0
- tabsight/geo/data/admin1/AZE.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BDI.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BEL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BEN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BFA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BGD.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BGR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BHR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BHS.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BIH.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BLM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BLR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BLZ.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BMU.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BOL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BRA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BRB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BRN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BTN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/BWA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CAF.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CAN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CHE.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CHL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CHN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CIV.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CLP.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CMR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/COD.topo.json.gz +0 -0
- tabsight/geo/data/admin1/COG.topo.json.gz +0 -0
- tabsight/geo/data/admin1/COK.topo.json.gz +0 -0
- tabsight/geo/data/admin1/COL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/COM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CPV.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CRI.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CSI.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CUB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CUW.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CYM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CYN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CYP.topo.json.gz +0 -0
- tabsight/geo/data/admin1/CZE.topo.json.gz +0 -0
- tabsight/geo/data/admin1/DEU.topo.json.gz +0 -0
- tabsight/geo/data/admin1/DJI.topo.json.gz +0 -0
- tabsight/geo/data/admin1/DMA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/DNK.topo.json.gz +0 -0
- tabsight/geo/data/admin1/DOM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/DZA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ECU.topo.json.gz +0 -0
- tabsight/geo/data/admin1/EGY.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ERI.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ESB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ESP.topo.json.gz +0 -0
- tabsight/geo/data/admin1/EST.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ETH.topo.json.gz +0 -0
- tabsight/geo/data/admin1/FIN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/FJI.topo.json.gz +0 -0
- tabsight/geo/data/admin1/FLK.topo.json.gz +0 -0
- tabsight/geo/data/admin1/FRA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/FRO.topo.json.gz +0 -0
- tabsight/geo/data/admin1/FSM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GAB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GBR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GEO.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GGY.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GHA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GIB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GIN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GMB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GNB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GNQ.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GRC.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GRD.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GRL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GTM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GUM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/GUY.topo.json.gz +0 -0
- tabsight/geo/data/admin1/HKG.topo.json.gz +0 -0
- tabsight/geo/data/admin1/HMD.topo.json.gz +0 -0
- tabsight/geo/data/admin1/HND.topo.json.gz +0 -0
- tabsight/geo/data/admin1/HRV.topo.json.gz +0 -0
- tabsight/geo/data/admin1/HTI.topo.json.gz +0 -0
- tabsight/geo/data/admin1/HUN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/IDN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/IMN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/IND.topo.json.gz +0 -0
- tabsight/geo/data/admin1/IOA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/IOT.topo.json.gz +0 -0
- tabsight/geo/data/admin1/IRL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/IRN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/IRQ.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ISL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ISR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ITA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/JAM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/JEY.topo.json.gz +0 -0
- tabsight/geo/data/admin1/JOR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/JPN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/KAB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/KAS.topo.json.gz +0 -0
- tabsight/geo/data/admin1/KAZ.topo.json.gz +0 -0
- tabsight/geo/data/admin1/KEN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/KGZ.topo.json.gz +0 -0
- tabsight/geo/data/admin1/KHM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/KIR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/KNA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/KOR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/KOS.topo.json.gz +0 -0
- tabsight/geo/data/admin1/KWT.topo.json.gz +0 -0
- tabsight/geo/data/admin1/LAO.topo.json.gz +0 -0
- tabsight/geo/data/admin1/LBN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/LBR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/LBY.topo.json.gz +0 -0
- tabsight/geo/data/admin1/LCA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/LIE.topo.json.gz +0 -0
- tabsight/geo/data/admin1/LKA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/LSO.topo.json.gz +0 -0
- tabsight/geo/data/admin1/LTU.topo.json.gz +0 -0
- tabsight/geo/data/admin1/LUX.topo.json.gz +0 -0
- tabsight/geo/data/admin1/LVA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MAC.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MAF.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MAR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MCO.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MDA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MDG.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MDV.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MEX.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MHL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MKD.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MLI.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MLT.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MMR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MNE.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MNG.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MNP.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MOZ.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MRT.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MSR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MUS.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MWI.topo.json.gz +0 -0
- tabsight/geo/data/admin1/MYS.topo.json.gz +0 -0
- tabsight/geo/data/admin1/NAM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/NCL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/NER.topo.json.gz +0 -0
- tabsight/geo/data/admin1/NFK.topo.json.gz +0 -0
- tabsight/geo/data/admin1/NGA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/NIC.topo.json.gz +0 -0
- tabsight/geo/data/admin1/NIU.topo.json.gz +0 -0
- tabsight/geo/data/admin1/NLD.topo.json.gz +0 -0
- tabsight/geo/data/admin1/NOR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/NPL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/NRU.topo.json.gz +0 -0
- tabsight/geo/data/admin1/NZL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/OMN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PAK.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PAN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PCN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PER.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PGA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PHL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PLW.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PNG.topo.json.gz +0 -0
- tabsight/geo/data/admin1/POL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PRI.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PRK.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PRT.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PRY.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PSX.topo.json.gz +0 -0
- tabsight/geo/data/admin1/PYF.topo.json.gz +0 -0
- tabsight/geo/data/admin1/QAT.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ROU.topo.json.gz +0 -0
- tabsight/geo/data/admin1/RUS.topo.json.gz +0 -0
- tabsight/geo/data/admin1/RWA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SAH.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SAU.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SDN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SDS.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SEN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SGP.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SGS.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SHN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SLB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SLE.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SLV.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SMR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SOL.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SOM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SPM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SRB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/STP.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SUR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SVK.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SVN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SWE.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SWZ.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SXM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SYC.topo.json.gz +0 -0
- tabsight/geo/data/admin1/SYR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/TCA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/TCD.topo.json.gz +0 -0
- tabsight/geo/data/admin1/TGO.topo.json.gz +0 -0
- tabsight/geo/data/admin1/THA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/TJK.topo.json.gz +0 -0
- tabsight/geo/data/admin1/TKM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/TLS.topo.json.gz +0 -0
- tabsight/geo/data/admin1/TON.topo.json.gz +0 -0
- tabsight/geo/data/admin1/TTO.topo.json.gz +0 -0
- tabsight/geo/data/admin1/TUN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/TUR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/TUV.topo.json.gz +0 -0
- tabsight/geo/data/admin1/TWN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/TZA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/UGA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/UKR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/UMI.topo.json.gz +0 -0
- tabsight/geo/data/admin1/URY.topo.json.gz +0 -0
- tabsight/geo/data/admin1/USA.topo.json.gz +0 -0
- tabsight/geo/data/admin1/USG.topo.json.gz +0 -0
- tabsight/geo/data/admin1/UZB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/VAT.topo.json.gz +0 -0
- tabsight/geo/data/admin1/VCT.topo.json.gz +0 -0
- tabsight/geo/data/admin1/VEN.topo.json.gz +0 -0
- tabsight/geo/data/admin1/VGB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/VIR.topo.json.gz +0 -0
- tabsight/geo/data/admin1/VNM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/VUT.topo.json.gz +0 -0
- tabsight/geo/data/admin1/WLF.topo.json.gz +0 -0
- tabsight/geo/data/admin1/WSB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/WSM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/YEM.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ZAF.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ZMB.topo.json.gz +0 -0
- tabsight/geo/data/admin1/ZWE.topo.json.gz +0 -0
- tabsight/geo/data/cities.json.gz +0 -0
- tabsight/geo/data/names.json.gz +0 -0
- tabsight/geo/data/version.json +6 -0
- tabsight/geo/data/world.topo.json.gz +0 -0
- tabsight/geo/detection.py +505 -0
- tabsight/geo/names.py +198 -0
- tabsight/i18n/__init__.py +55 -0
- tabsight/i18n/de.json +50 -0
- tabsight/i18n/en.json +90 -0
- tabsight/i18n/es.json +50 -0
- tabsight/i18n/fr.json +50 -0
- tabsight/i18n/it.json +50 -0
- tabsight/i18n/nl.json +50 -0
- tabsight/i18n/pt.json +50 -0
- tabsight/insight.py +490 -0
- tabsight/io.py +279 -0
- tabsight/lint.py +243 -0
- tabsight/localserver.py +113 -0
- tabsight/periods.py +237 -0
- tabsight/plotting.py +173 -0
- tabsight/profile.py +743 -0
- tabsight/py.typed +0 -0
- tabsight/read.py +271 -0
- tabsight/recommend.py +595 -0
- tabsight/render/__init__.py +1 -0
- tabsight/render/assets.py +24 -0
- tabsight/render/dashboard_html.py +164 -0
- tabsight/render/html.py +160 -0
- tabsight/render/picture.py +235 -0
- tabsight/render/static/__init__.py +0 -0
- tabsight/render/static/dashboard.js +2914 -0
- tabsight/render/static/echarts.min.js +45 -0
- tabsight/render/static/tabsight.js +588 -0
- tabsight/render/static/topojson-client.min.js +2 -0
- tabsight/server.py +272 -0
- tabsight/settings.py +195 -0
- tabsight/spec.py +350 -0
- tabsight/tablecalc.py +122 -0
- tabsight/theme.py +120 -0
- tabsight/warnings.py +113 -0
- tabsight/web.py +183 -0
- tabsight-0.1.0.dist-info/METADATA +384 -0
- tabsight-0.1.0.dist-info/RECORD +328 -0
- tabsight-0.1.0.dist-info/WHEEL +4 -0
- tabsight-0.1.0.dist-info/entry_points.txt +5 -0
- tabsight-0.1.0.dist-info/licenses/LICENSE +21 -0
- tabsight-0.1.0.dist-info/licenses/NOTICE +28 -0
- tabsight-0.1.0.dist-info/licenses/licenses/echarts-LICENSE.txt +222 -0
- tabsight-0.1.0.dist-info/licenses/licenses/echarts-NOTICE.txt +5 -0
- tabsight-0.1.0.dist-info/licenses/licenses/topojson-client-LICENSE.txt +13 -0
tabsight/aggregate.py
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
"""Group-by with exact re-aggregation, date buckets, top N + "Other", and missing periods.
|
|
2
|
+
|
|
3
|
+
Every result is computed through *parts*: sums and counts for
|
|
4
|
+
means, Σ(w·x) and Σw for weighted averages, raw values for medians and distinct counts.
|
|
5
|
+
Combining parts again after filtering gives exactly the same numbers as computing from
|
|
6
|
+
the raw rows, which is what the browser runtime will rely on. One code path is used for
|
|
7
|
+
both, so they cannot drift apart.
|
|
8
|
+
|
|
9
|
+
The input DataFrame is never changed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import dataclasses
|
|
15
|
+
import difflib
|
|
16
|
+
import math
|
|
17
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from tabsight.colors import natural_key
|
|
24
|
+
from tabsight.formulas import (
|
|
25
|
+
AGGREGATES,
|
|
26
|
+
CalcField,
|
|
27
|
+
Call,
|
|
28
|
+
evaluate_aggregate,
|
|
29
|
+
evaluate_node,
|
|
30
|
+
validate_params,
|
|
31
|
+
)
|
|
32
|
+
from tabsight.i18n import text
|
|
33
|
+
from tabsight.io import as_frame
|
|
34
|
+
from tabsight.profile import AGGS, Profile, _prepare, profile
|
|
35
|
+
from tabsight.read import read_dates, read_numbers
|
|
36
|
+
from tabsight.settings import resolve
|
|
37
|
+
from tabsight.warnings import DataWarning, InvalidSpecError, safe_repr, warn
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"GRAINS",
|
|
41
|
+
"PARTS",
|
|
42
|
+
"Aggregation",
|
|
43
|
+
"Measure",
|
|
44
|
+
"aggregate",
|
|
45
|
+
"bucket",
|
|
46
|
+
"build_parts",
|
|
47
|
+
"combine_parts",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
GRAINS = ("year", "quarter", "month", "week", "day", "hour", "minute", "second")
|
|
51
|
+
PARTS: dict[str, tuple[str, ...]] = {
|
|
52
|
+
"sum": ("sum",),
|
|
53
|
+
"count": ("count",),
|
|
54
|
+
"mean": ("sum", "count"),
|
|
55
|
+
"min": ("min",),
|
|
56
|
+
"max": ("max",),
|
|
57
|
+
"wavg": ("wsum", "wtotal"),
|
|
58
|
+
"countd": ("values",),
|
|
59
|
+
"median": ("values",),
|
|
60
|
+
}
|
|
61
|
+
ROWS = "__rows"
|
|
62
|
+
OTHER_FLAG = "__other"
|
|
63
|
+
SUPPRESSED_FLAG = "__suppressed"
|
|
64
|
+
MAX_FILL_ROWS = 1_000_000
|
|
65
|
+
MAX_PERIODS = 100_000
|
|
66
|
+
_NUMERIC_AGGS = {"sum", "mean", "median", "wavg"}
|
|
67
|
+
_ZERO_WHEN_EMPTY = {"sum", "count", "countd"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclasses.dataclass(frozen=True)
|
|
71
|
+
class Measure:
|
|
72
|
+
"""One measure: a column (or ``None`` for a row count) and how to aggregate it."""
|
|
73
|
+
|
|
74
|
+
column: str | None
|
|
75
|
+
agg: str
|
|
76
|
+
weight: str | None = None
|
|
77
|
+
name: str | None = None
|
|
78
|
+
|
|
79
|
+
def __post_init__(self) -> None:
|
|
80
|
+
if self.agg not in PARTS:
|
|
81
|
+
raise InvalidSpecError(f"agg {safe_repr(self.agg)} must be one of {tuple(PARTS)}")
|
|
82
|
+
if self.agg == "wavg" and not self.weight:
|
|
83
|
+
raise InvalidSpecError("agg='wavg' needs weight=<column>")
|
|
84
|
+
if self.column is None and self.agg != "count":
|
|
85
|
+
raise InvalidSpecError("a measure without a column can only count rows")
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def label(self) -> str:
|
|
89
|
+
if self.name:
|
|
90
|
+
return self.name
|
|
91
|
+
if self.column is None:
|
|
92
|
+
return "Rows"
|
|
93
|
+
return self.column
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclasses.dataclass(frozen=True)
|
|
97
|
+
class Aggregation:
|
|
98
|
+
"""The result of :func:`aggregate`: tidy rows plus what was assumed."""
|
|
99
|
+
|
|
100
|
+
frame: pd.DataFrame
|
|
101
|
+
dims: tuple[str, ...]
|
|
102
|
+
measures: tuple[Measure, ...]
|
|
103
|
+
other: Mapping[str, tuple[Any, ...]] = dataclasses.field(default_factory=dict)
|
|
104
|
+
categories: Mapping[str, int] = dataclasses.field(default_factory=dict)
|
|
105
|
+
notes: tuple[str, ...] = ()
|
|
106
|
+
suppressed: int = 0
|
|
107
|
+
outputs: tuple[str, ...] = () # measure and calculated-field columns, in the order asked for
|
|
108
|
+
partial_period: bool = False # the last date period isn't over (see notes)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def values(self) -> pd.DataFrame:
|
|
112
|
+
"""Only the dimension and output columns (no internal helper columns)."""
|
|
113
|
+
outputs = self.outputs or tuple(
|
|
114
|
+
m.label for m in self.measures if not m.label.startswith("__")
|
|
115
|
+
)
|
|
116
|
+
return self.frame[[*self.dims, *outputs]]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------- date buckets
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def bucket(
|
|
123
|
+
values: pd.Series, grain: str, *, fiscal_start: int = 1, week_start: str = "monday"
|
|
124
|
+
) -> pd.Series:
|
|
125
|
+
"""Start of the period each date falls in (``2017-02-14`` by month → ``2017-02-01``).
|
|
126
|
+
|
|
127
|
+
Fiscal years start in month ``fiscal_start``; quarters follow the fiscal year.
|
|
128
|
+
"""
|
|
129
|
+
if grain not in GRAINS:
|
|
130
|
+
raise InvalidSpecError(f"grain must be one of {GRAINS}")
|
|
131
|
+
v = pd.to_datetime(values, errors="coerce")
|
|
132
|
+
if grain in ("year", "quarter"):
|
|
133
|
+
valid = v.notna()
|
|
134
|
+
months = (
|
|
135
|
+
(v.dt.year.fillna(2000).astype("int64") * 12) + v.dt.month.fillna(1).astype("int64") - 1
|
|
136
|
+
)
|
|
137
|
+
offset = (v.dt.month.fillna(1).astype("int64") - fiscal_start) % 12
|
|
138
|
+
months = months - (offset % 3 if grain == "quarter" else offset)
|
|
139
|
+
out = pd.to_datetime(
|
|
140
|
+
pd.DataFrame({"year": months // 12, "month": months % 12 + 1, "day": 1}),
|
|
141
|
+
errors="coerce",
|
|
142
|
+
)
|
|
143
|
+
out = out.where(valid)
|
|
144
|
+
out.index = values.index
|
|
145
|
+
return out
|
|
146
|
+
if grain == "month":
|
|
147
|
+
return v.dt.to_period("M").dt.start_time.where(v.notna())
|
|
148
|
+
if grain == "week":
|
|
149
|
+
shift = {"monday": 0, "sunday": 1, "saturday": 2}[week_start]
|
|
150
|
+
days = (v.dt.day_of_week + shift) % 7
|
|
151
|
+
return v.dt.normalize() - pd.to_timedelta(days, unit="D")
|
|
152
|
+
if grain == "day":
|
|
153
|
+
return v.dt.normalize()
|
|
154
|
+
return v.dt.floor({"hour": "h", "minute": "min", "second": "s"}[grain])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _period_range(start: pd.Timestamp, end: pd.Timestamp, grain: str) -> pd.DatetimeIndex:
|
|
158
|
+
freq = {
|
|
159
|
+
"year": "12MS", "quarter": "3MS", "month": "MS", "week": "7D",
|
|
160
|
+
"day": "D", "hour": "h", "minute": "min", "second": "s",
|
|
161
|
+
}[grain] # fmt: skip
|
|
162
|
+
return pd.date_range(start, end, freq=freq)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------- parts
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _part_name(i: int, part: str) -> str:
|
|
169
|
+
return f"__m{i}_{part}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def build_parts(
|
|
173
|
+
frame: pd.DataFrame, dims: Sequence[str], measures: Sequence[Measure]
|
|
174
|
+
) -> pd.DataFrame:
|
|
175
|
+
"""Group ``frame`` by ``dims`` and store the re-aggregatable parts of each measure.
|
|
176
|
+
|
|
177
|
+
``frame`` must already hold numeric measure columns (see :func:`aggregate`).
|
|
178
|
+
"""
|
|
179
|
+
work = pd.DataFrame(index=frame.index)
|
|
180
|
+
for d in dims:
|
|
181
|
+
work[d] = frame[d]
|
|
182
|
+
for i, m in enumerate(measures):
|
|
183
|
+
if m.column is None:
|
|
184
|
+
continue
|
|
185
|
+
col = frame[m.column]
|
|
186
|
+
if m.agg == "wavg":
|
|
187
|
+
if m.weight is None: # also rejected by Measure itself
|
|
188
|
+
raise InvalidSpecError("agg='wavg' needs weight=<column>")
|
|
189
|
+
w = pd.to_numeric(frame[m.weight], errors="coerce")
|
|
190
|
+
both = col.notna() & w.notna()
|
|
191
|
+
work[_part_name(i, "wsum")] = (col * w).where(both)
|
|
192
|
+
work[_part_name(i, "wtotal")] = w.where(both)
|
|
193
|
+
else:
|
|
194
|
+
work[_part_name(i, "src")] = col
|
|
195
|
+
work[ROWS] = 1
|
|
196
|
+
|
|
197
|
+
if dims:
|
|
198
|
+
grouped = work.groupby(list(dims), dropna=False, sort=False, observed=True)
|
|
199
|
+
else:
|
|
200
|
+
grouped = work.groupby(np.zeros(len(work), dtype=np.int8), sort=False)
|
|
201
|
+
out = pd.DataFrame({ROWS: grouped[ROWS].sum()})
|
|
202
|
+
for i, m in enumerate(measures):
|
|
203
|
+
src = _part_name(i, "src")
|
|
204
|
+
if m.column is None:
|
|
205
|
+
out[_part_name(i, "count")] = out[ROWS]
|
|
206
|
+
elif m.agg == "wavg":
|
|
207
|
+
out[_part_name(i, "wsum")] = grouped[_part_name(i, "wsum")].sum(min_count=1)
|
|
208
|
+
out[_part_name(i, "wtotal")] = grouped[_part_name(i, "wtotal")].sum(min_count=1)
|
|
209
|
+
elif m.agg in ("sum", "mean"):
|
|
210
|
+
out[_part_name(i, "sum")] = grouped[src].sum(min_count=1)
|
|
211
|
+
if m.agg == "mean":
|
|
212
|
+
out[_part_name(i, "count")] = grouped[src].count()
|
|
213
|
+
elif m.agg == "count":
|
|
214
|
+
out[_part_name(i, "count")] = grouped[src].count()
|
|
215
|
+
elif m.agg in ("min", "max"):
|
|
216
|
+
out[_part_name(i, m.agg)] = getattr(grouped[src], m.agg)()
|
|
217
|
+
else: # countd, median: keep the values themselves
|
|
218
|
+
# pandas-stubs does not model tuple results from agg; behaviour is tested.
|
|
219
|
+
out[_part_name(i, "values")] = grouped[src].agg( # type: ignore[type-var]
|
|
220
|
+
lambda s: tuple(s.dropna().tolist())
|
|
221
|
+
)
|
|
222
|
+
out = out.reset_index(drop=not dims)
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def combine_parts(
|
|
227
|
+
parts: pd.DataFrame,
|
|
228
|
+
dims: Sequence[str],
|
|
229
|
+
measures: Sequence[Measure],
|
|
230
|
+
min_group_size: int | None = None,
|
|
231
|
+
) -> pd.DataFrame:
|
|
232
|
+
"""Re-aggregate parts to ``dims`` and compute the final measure values exactly."""
|
|
233
|
+
cols = [
|
|
234
|
+
c for c in parts.columns if c.startswith("__") and c not in dims
|
|
235
|
+
] # a user column may start with __ too
|
|
236
|
+
if dims:
|
|
237
|
+
grouped = parts.groupby(list(dims), dropna=False, sort=False, observed=True)
|
|
238
|
+
else:
|
|
239
|
+
grouped = parts.groupby(np.zeros(len(parts), dtype=np.int8), sort=False)
|
|
240
|
+
agg: dict[str, Any] = {}
|
|
241
|
+
for c in cols:
|
|
242
|
+
if c.endswith("_min"):
|
|
243
|
+
agg[c] = "min"
|
|
244
|
+
elif c.endswith("_max"):
|
|
245
|
+
agg[c] = "max"
|
|
246
|
+
elif c.endswith("_values"):
|
|
247
|
+
agg[c] = lambda s: tuple(v for t in s for v in t)
|
|
248
|
+
elif c in (OTHER_FLAG, SUPPRESSED_FLAG):
|
|
249
|
+
agg[c] = "max"
|
|
250
|
+
else:
|
|
251
|
+
agg[c] = lambda s: s.sum(min_count=1)
|
|
252
|
+
combined = grouped.agg(agg) if agg else grouped.size().to_frame(ROWS)
|
|
253
|
+
combined = combined.reset_index(drop=not dims)
|
|
254
|
+
for i, m in enumerate(measures):
|
|
255
|
+
combined[m.label] = _finish(combined, i, m)
|
|
256
|
+
if min_group_size is not None:
|
|
257
|
+
small = combined[ROWS].fillna(0) < min_group_size
|
|
258
|
+
for m in measures:
|
|
259
|
+
combined[m.label] = combined[m.label].where(~small)
|
|
260
|
+
combined[SUPPRESSED_FLAG] = small
|
|
261
|
+
return combined
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _finish(frame: pd.DataFrame, i: int, m: Measure) -> pd.Series:
|
|
265
|
+
def part(name: str) -> pd.Series:
|
|
266
|
+
return frame[_part_name(i, name)]
|
|
267
|
+
|
|
268
|
+
if m.agg == "sum":
|
|
269
|
+
return part("sum").astype("float64")
|
|
270
|
+
if m.agg == "count":
|
|
271
|
+
return part("count").fillna(0).astype("int64")
|
|
272
|
+
if m.agg == "mean":
|
|
273
|
+
count = part("count").astype("float64")
|
|
274
|
+
return (part("sum").astype("float64") / count).where(count > 0)
|
|
275
|
+
if m.agg in ("min", "max"):
|
|
276
|
+
return part(m.agg)
|
|
277
|
+
if m.agg == "wavg":
|
|
278
|
+
total = part("wtotal").astype("float64")
|
|
279
|
+
return (part("wsum").astype("float64") / total).where(total != 0)
|
|
280
|
+
if m.agg == "countd":
|
|
281
|
+
return part("values").map(lambda t: len(set(t))).astype("int64")
|
|
282
|
+
return part("values").map(lambda t: float(np.median(t)) if len(t) else math.nan)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------- preparing inputs
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _unknown(name: object, names: Iterable[str]) -> InvalidSpecError:
|
|
289
|
+
names = list(names)
|
|
290
|
+
hint = difflib.get_close_matches(str(name)[:200], names, n=1)
|
|
291
|
+
return InvalidSpecError(
|
|
292
|
+
f"column {safe_repr(name)} not found"
|
|
293
|
+
+ (f"; did you mean {safe_repr(hint[0])}?" if hint else "")
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _as_measures(measures: Any, prof: Profile) -> list[Measure]:
|
|
298
|
+
if measures is None:
|
|
299
|
+
return []
|
|
300
|
+
if isinstance(measures, str | Measure | Mapping):
|
|
301
|
+
measures = [measures]
|
|
302
|
+
out = []
|
|
303
|
+
for m in measures:
|
|
304
|
+
if isinstance(m, Measure):
|
|
305
|
+
out.append(m)
|
|
306
|
+
elif isinstance(m, str):
|
|
307
|
+
if m not in prof:
|
|
308
|
+
raise _unknown(m, prof.names())
|
|
309
|
+
agg = (
|
|
310
|
+
prof[m].agg
|
|
311
|
+
if prof[m].agg in PARTS
|
|
312
|
+
else ("countd" if prof[m].role == "id" else "count")
|
|
313
|
+
)
|
|
314
|
+
out.append(Measure(m, agg))
|
|
315
|
+
elif isinstance(m, Mapping):
|
|
316
|
+
extra = set(m) - {"column", "agg", "weight", "name"}
|
|
317
|
+
if extra:
|
|
318
|
+
raise InvalidSpecError(f"unknown measure key(s) {sorted(map(safe_repr, extra))}")
|
|
319
|
+
column = m.get("column")
|
|
320
|
+
if column is not None and not isinstance(column, str):
|
|
321
|
+
raise InvalidSpecError("measure 'column' must be a column name")
|
|
322
|
+
agg = m.get("agg") or (
|
|
323
|
+
prof[column].agg if column is not None and column in prof else "count"
|
|
324
|
+
)
|
|
325
|
+
if agg not in AGGS or agg == "none":
|
|
326
|
+
raise InvalidSpecError(f"agg {safe_repr(agg)} is not allowed for a measure")
|
|
327
|
+
out.append(Measure(column, agg, m.get("weight"), m.get("name")))
|
|
328
|
+
else:
|
|
329
|
+
raise InvalidSpecError(f"cannot use {type(m).__name__} as a measure")
|
|
330
|
+
labels = [m.label for m in out]
|
|
331
|
+
if len(set(labels)) != len(labels):
|
|
332
|
+
out = [
|
|
333
|
+
dataclasses.replace(m, name=f"{m.label} ({m.agg})")
|
|
334
|
+
if labels.count(m.label) > 1 and not m.name
|
|
335
|
+
else m
|
|
336
|
+
for m in out
|
|
337
|
+
]
|
|
338
|
+
return out
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _numeric(col: pd.Series, field_source: str) -> pd.Series:
|
|
342
|
+
if field_source == "text-number":
|
|
343
|
+
reading = read_numbers(col, threshold=0.0)
|
|
344
|
+
if reading is not None:
|
|
345
|
+
return reading.values
|
|
346
|
+
values = pd.to_numeric(col, errors="coerce").astype("float64")
|
|
347
|
+
return values.where(np.isfinite(values))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _time_values(col: pd.Series, prof: Profile, name: str) -> pd.Series:
|
|
351
|
+
field = prof[name]
|
|
352
|
+
if field.source == "integer-year":
|
|
353
|
+
years = pd.to_numeric(col, errors="coerce")
|
|
354
|
+
return pd.to_datetime(years.astype("Int64").astype("string") + "-01-01", errors="coerce")
|
|
355
|
+
if pd.api.types.is_datetime64_any_dtype(col):
|
|
356
|
+
return _local_time(col)
|
|
357
|
+
reading = read_dates(col, dayfirst=field.dayfirst, threshold=0.0)
|
|
358
|
+
return _local_time(
|
|
359
|
+
reading.values if reading is not None else pd.to_datetime(col, errors="coerce")
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _local_time(values: pd.Series) -> pd.Series:
|
|
364
|
+
"""Time-zone-aware dates are grouped by their wall-clock date: in the ``tz`` setting
|
|
365
|
+
if one is set, otherwise in their own time zone."""
|
|
366
|
+
if getattr(values.dt, "tz", None) is None:
|
|
367
|
+
return values
|
|
368
|
+
from tabsight.settings import current
|
|
369
|
+
|
|
370
|
+
tz = current().tz
|
|
371
|
+
if tz is not None:
|
|
372
|
+
try:
|
|
373
|
+
values = values.dt.tz_convert(tz)
|
|
374
|
+
except Exception as err: # an unknown zone name
|
|
375
|
+
raise InvalidSpecError(f"unknown time zone {safe_repr(tz)}") from err
|
|
376
|
+
naive: pd.Series = values.dt.tz_localize(None)
|
|
377
|
+
return naive
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ---------------------------------------------------------------- the main entry point
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def aggregate(
|
|
384
|
+
data: Any,
|
|
385
|
+
dims: str | Sequence[str] | None = None,
|
|
386
|
+
measures: Any = None,
|
|
387
|
+
*,
|
|
388
|
+
freq: str | Mapping[str, str] | None = None,
|
|
389
|
+
top: int | None = None,
|
|
390
|
+
sort: str | Sequence[str] | None = None,
|
|
391
|
+
missing: str | None = None,
|
|
392
|
+
min_group_size: int | None = None,
|
|
393
|
+
fiscal_start: int | None = None,
|
|
394
|
+
week_start: str | None = None,
|
|
395
|
+
params: Mapping[str, Any] | None = None,
|
|
396
|
+
prof: Profile | None = None,
|
|
397
|
+
) -> Aggregation:
|
|
398
|
+
"""Group ``data`` by ``dims`` and aggregate ``measures`` exactly.
|
|
399
|
+
|
|
400
|
+
- ``measures``: column names (aggregated by their profiled default), ``Measure``
|
|
401
|
+
objects or dicts like ``{"column": "Discount", "agg": "wavg", "weight": "Sales"}``.
|
|
402
|
+
- ``freq``: date grain for time dimensions (``"month"``), or a dict per column.
|
|
403
|
+
- ``top``: keep the top N values of the first category dimension (never a date
|
|
404
|
+
dimension); the rest become one exact "Other" row whose members are listed in
|
|
405
|
+
``result.other``. Ranking uses the first requested output.
|
|
406
|
+
- ``sort``: ``"desc"`` / ``"asc"`` by the first measure, ``"none"`` for natural
|
|
407
|
+
order, ``"by:<measure>"``, or an explicit list of values.
|
|
408
|
+
- ``missing``: missing time periods as ``"gap"`` (NaN), ``"zero"`` or ``"connect"``
|
|
409
|
+
(left out).
|
|
410
|
+
- ``min_group_size``: hide values of groups with fewer rows (privacy).
|
|
411
|
+
- ``measures`` may also hold calculated fields from ``ts.calc(...)``; ``params``
|
|
412
|
+
supplies their ``$Name`` parameter values.
|
|
413
|
+
"""
|
|
414
|
+
settings = resolve(
|
|
415
|
+
missing=missing,
|
|
416
|
+
min_group_size=min_group_size,
|
|
417
|
+
fiscal_start=fiscal_start,
|
|
418
|
+
week_start=week_start,
|
|
419
|
+
)
|
|
420
|
+
frame, _ = _prepare(as_frame(data))
|
|
421
|
+
prof = prof if prof is not None else profile(frame, quiet=True)
|
|
422
|
+
dim_list = [dims] if isinstance(dims, str) else list(dims or [])
|
|
423
|
+
for d in dim_list:
|
|
424
|
+
if d not in frame.columns:
|
|
425
|
+
raise _unknown(d, frame.columns)
|
|
426
|
+
if len(set(dim_list)) != len(dim_list):
|
|
427
|
+
raise InvalidSpecError("each dimension can only be used once")
|
|
428
|
+
if measures is None:
|
|
429
|
+
requested: list[Any] = []
|
|
430
|
+
elif isinstance(measures, list | tuple):
|
|
431
|
+
requested = list(measures)
|
|
432
|
+
else:
|
|
433
|
+
requested = [measures]
|
|
434
|
+
calcs = [m for m in requested if isinstance(m, CalcField)]
|
|
435
|
+
measure_list = _as_measures([m for m in requested if not isinstance(m, CalcField)], prof)
|
|
436
|
+
plain = iter(measure_list)
|
|
437
|
+
outputs = [m.name if isinstance(m, CalcField) else next(plain).label for m in requested]
|
|
438
|
+
if len(set(outputs)) != len(outputs):
|
|
439
|
+
raise InvalidSpecError(f"output names must be unique: {outputs}")
|
|
440
|
+
notes: list[str] = []
|
|
441
|
+
|
|
442
|
+
# Never add up different currencies: split by the currency column automatically.
|
|
443
|
+
codes = [f.name for f in prof.fields if f.currency_code_column and f.distinct > 1]
|
|
444
|
+
if (
|
|
445
|
+
codes
|
|
446
|
+
and codes[0] not in dim_list
|
|
447
|
+
and any(
|
|
448
|
+
m.column is not None and prof[m.column].unit == "currency" and m.agg in _NUMERIC_AGGS
|
|
449
|
+
for m in measure_list
|
|
450
|
+
if m.column in prof
|
|
451
|
+
)
|
|
452
|
+
):
|
|
453
|
+
dim_list.append(codes[0])
|
|
454
|
+
note = f"split by {safe_repr(codes[0])} so different currencies are never added together"
|
|
455
|
+
notes.append(note)
|
|
456
|
+
warn(note, DataWarning)
|
|
457
|
+
|
|
458
|
+
# Working copy: only the needed columns, read as proper types.
|
|
459
|
+
work: dict[str, pd.Series] = {}
|
|
460
|
+
grains: dict[str, str] = {}
|
|
461
|
+
partial_period = False
|
|
462
|
+
for d in dim_list:
|
|
463
|
+
col = frame[d]
|
|
464
|
+
if prof[d].role == "time":
|
|
465
|
+
grain = freq.get(d) if isinstance(freq, Mapping) else freq
|
|
466
|
+
grain = grain or prof[d].grain or "day"
|
|
467
|
+
if grain not in GRAINS:
|
|
468
|
+
raise InvalidSpecError(f"freq for {safe_repr(d)} must be one of {GRAINS}")
|
|
469
|
+
grains[d] = grain
|
|
470
|
+
dates = _time_values(col, prof, d)
|
|
471
|
+
work[d] = bucket(
|
|
472
|
+
dates, grain, fiscal_start=settings.fiscal_start, week_start=settings.week_start
|
|
473
|
+
)
|
|
474
|
+
partial = _incomplete_last_period(dates, work[d], grain)
|
|
475
|
+
if partial:
|
|
476
|
+
notes.append(partial)
|
|
477
|
+
partial_period = True
|
|
478
|
+
else:
|
|
479
|
+
work[d] = col
|
|
480
|
+
|
|
481
|
+
# Calculated fields: row-level ones become a column; each aggregate inside an
|
|
482
|
+
# aggregate formula becomes a hidden exact measure (so filtering stays exact).
|
|
483
|
+
param_values = validate_params(params, [p for c in calcs for p in c.params])
|
|
484
|
+
extra: dict[str, pd.Series] = {}
|
|
485
|
+
agg_calcs: list[tuple[CalcField, dict[Call, str]]] = []
|
|
486
|
+
if calcs:
|
|
487
|
+
used = {f for c in calcs for f in c.fields}
|
|
488
|
+
typed = _typed_frame(frame, prof, used)
|
|
489
|
+
for i, c in enumerate(calcs):
|
|
490
|
+
c.check_fields(list(frame.columns))
|
|
491
|
+
if c.name in frame.columns:
|
|
492
|
+
raise InvalidSpecError(
|
|
493
|
+
f"calculated field {safe_repr(c.name)} has the same name as a column"
|
|
494
|
+
)
|
|
495
|
+
if c.level == "row":
|
|
496
|
+
extra[c.name] = _calc_numbers(evaluate_node(c.tree, typed, param_values), c.name)
|
|
497
|
+
measure_list.append(Measure(c.name, "sum"))
|
|
498
|
+
continue
|
|
499
|
+
mapping: dict[Call, str] = {}
|
|
500
|
+
for j, call in enumerate(c.aggregates):
|
|
501
|
+
hidden = f"__calc{i}_{j}"
|
|
502
|
+
values = evaluate_node(call.args[0], typed, param_values)
|
|
503
|
+
agg_name = AGGREGATES[call.fn]
|
|
504
|
+
if agg_name not in ("count", "countd"):
|
|
505
|
+
values = _calc_numbers(values, f"{c.name}: {call.fn}")
|
|
506
|
+
extra[hidden] = values
|
|
507
|
+
measure_list.append(Measure(hidden, agg_name, name=hidden))
|
|
508
|
+
mapping[call] = hidden
|
|
509
|
+
agg_calcs.append((c, mapping))
|
|
510
|
+
|
|
511
|
+
def finish(result: pd.DataFrame) -> pd.DataFrame:
|
|
512
|
+
"""Evaluate aggregate formulas on combined values (and respect hidden groups)."""
|
|
513
|
+
if not agg_calcs:
|
|
514
|
+
return result
|
|
515
|
+
result = result.copy()
|
|
516
|
+
for c, mapping in agg_calcs:
|
|
517
|
+
values = evaluate_aggregate(
|
|
518
|
+
c, {call: result[h] for call, h in mapping.items()}, param_values
|
|
519
|
+
)
|
|
520
|
+
if SUPPRESSED_FLAG in result:
|
|
521
|
+
values = values.where(~result[SUPPRESSED_FLAG].astype(bool))
|
|
522
|
+
result[c.name] = values
|
|
523
|
+
return result
|
|
524
|
+
|
|
525
|
+
for m in measure_list:
|
|
526
|
+
for name in (m.column, m.weight):
|
|
527
|
+
if name is None or name in work:
|
|
528
|
+
continue
|
|
529
|
+
if name in extra:
|
|
530
|
+
work[name] = extra[name]
|
|
531
|
+
continue
|
|
532
|
+
if name not in frame.columns:
|
|
533
|
+
raise _unknown(name, frame.columns)
|
|
534
|
+
field = prof[name]
|
|
535
|
+
if m.agg in _NUMERIC_AGGS or name == m.weight:
|
|
536
|
+
if field.role in ("dimension", "text") and field.source not in ("text-number",):
|
|
537
|
+
raise InvalidSpecError(
|
|
538
|
+
f"{safe_repr(name)} is text; use agg='count' or 'countd' instead of {m.agg!r}"
|
|
539
|
+
)
|
|
540
|
+
if field.role == "id" and m.agg in ("sum", "mean"):
|
|
541
|
+
warn(
|
|
542
|
+
f"{safe_repr(name)} looks like an ID; adding up IDs is usually wrong",
|
|
543
|
+
DataWarning,
|
|
544
|
+
)
|
|
545
|
+
work[name] = _numeric(frame[name], field.source)
|
|
546
|
+
elif m.agg in ("min", "max") and field.role == "time":
|
|
547
|
+
work[name] = _time_values(frame[name], prof, name)
|
|
548
|
+
elif m.agg in ("min", "max"):
|
|
549
|
+
work[name] = _numeric(frame[name], field.source)
|
|
550
|
+
else:
|
|
551
|
+
work[name] = frame[name]
|
|
552
|
+
working = pd.DataFrame(work, index=frame.index)
|
|
553
|
+
|
|
554
|
+
parts = build_parts(working, dim_list, measure_list)
|
|
555
|
+
other: dict[str, tuple[Any, ...]] = {}
|
|
556
|
+
categories: dict[str, int] = {}
|
|
557
|
+
primary = outputs[0] if outputs else None
|
|
558
|
+
if top is not None and dim_list and primary is not None:
|
|
559
|
+
# Top N applies to the first category dimension (never to dates): with
|
|
560
|
+
# [Month, Region] it keeps the top regions across all months.
|
|
561
|
+
top_dim = next((d for d in dim_list if d not in grains), None)
|
|
562
|
+
if top_dim is None:
|
|
563
|
+
notes.append("top N ignored: every dimension is a date")
|
|
564
|
+
else:
|
|
565
|
+
parts, other, categories = _top_n(parts, top_dim, measure_list, top, finish, primary)
|
|
566
|
+
result = finish(combine_parts(parts, dim_list, measure_list, settings.min_group_size))
|
|
567
|
+
suppressed = int(result[SUPPRESSED_FLAG].sum()) if SUPPRESSED_FLAG in result else 0
|
|
568
|
+
if suppressed:
|
|
569
|
+
notes.append(
|
|
570
|
+
f"{suppressed} group(s) hidden: {text('fewer_than', n=settings.min_group_size)} rows"
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
time_dims = [d for d in dim_list if d in grains]
|
|
574
|
+
if time_dims and settings.missing != "connect":
|
|
575
|
+
result, fill_note = _fill_periods(
|
|
576
|
+
result, dim_list, time_dims[0], grains[time_dims[0]], measure_list, settings.missing
|
|
577
|
+
)
|
|
578
|
+
if fill_note:
|
|
579
|
+
notes.append(fill_note)
|
|
580
|
+
result = _sort(result, dim_list, primary, outputs, prof, grains, sort)
|
|
581
|
+
return Aggregation(
|
|
582
|
+
frame=result.reset_index(drop=True),
|
|
583
|
+
dims=tuple(dim_list),
|
|
584
|
+
measures=tuple(measure_list),
|
|
585
|
+
other=other,
|
|
586
|
+
categories=categories,
|
|
587
|
+
notes=tuple(notes),
|
|
588
|
+
suppressed=suppressed,
|
|
589
|
+
outputs=tuple(outputs),
|
|
590
|
+
partial_period=partial_period,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
_PERIOD_LENGTH = {"year": 365, "quarter": 91, "month": 30, "week": 7}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _incomplete_last_period(dates: pd.Series, buckets: pd.Series, grain: str) -> str | None:
|
|
598
|
+
"""A note when the last period holds only part of its time span (it will look low)."""
|
|
599
|
+
if grain not in _PERIOD_LENGTH or dates.dropna().empty:
|
|
600
|
+
return None
|
|
601
|
+
last_start = buckets.max()
|
|
602
|
+
latest = dates.max()
|
|
603
|
+
covered_days = (latest.normalize() - last_start).days + 1
|
|
604
|
+
if covered_days >= 0.5 * _PERIOD_LENGTH[grain]:
|
|
605
|
+
return None
|
|
606
|
+
from tabsight.formats import format_date
|
|
607
|
+
|
|
608
|
+
return (
|
|
609
|
+
f"the last {grain} ({format_date(last_start, grain)}) only has data up to "
|
|
610
|
+
f"{format_date(latest, 'day')}, so it looks low because it is incomplete"
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _typed_frame(frame: pd.DataFrame, prof: Profile, names: Iterable[str]) -> pd.DataFrame:
|
|
615
|
+
"""Columns used by formulas, read as numbers or dates where the profile says so."""
|
|
616
|
+
cols: dict[str, pd.Series] = {}
|
|
617
|
+
for name in names:
|
|
618
|
+
if name not in frame.columns:
|
|
619
|
+
continue
|
|
620
|
+
field = prof[name]
|
|
621
|
+
if field.role == "time":
|
|
622
|
+
cols[name] = _time_values(frame[name], prof, name)
|
|
623
|
+
elif field.role == "measure" or field.source == "text-number":
|
|
624
|
+
cols[name] = _numeric(frame[name], field.source)
|
|
625
|
+
else:
|
|
626
|
+
cols[name] = frame[name]
|
|
627
|
+
return pd.DataFrame(cols, index=frame.index)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _calc_numbers(values: pd.Series, what: str) -> pd.Series:
|
|
631
|
+
if pd.api.types.is_bool_dtype(values):
|
|
632
|
+
return values.astype("float64")
|
|
633
|
+
numbers = pd.to_numeric(values, errors="coerce")
|
|
634
|
+
if numbers.notna().sum() < values.notna().sum():
|
|
635
|
+
raise InvalidSpecError(f"{safe_repr(what)} gives text; use COUNT or COUNTD for text")
|
|
636
|
+
numbers = numbers.astype("float64")
|
|
637
|
+
return numbers.where(np.isfinite(numbers))
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _top_n(
|
|
641
|
+
parts: pd.DataFrame,
|
|
642
|
+
dim: str,
|
|
643
|
+
measures: list[Measure],
|
|
644
|
+
n: int,
|
|
645
|
+
finish: Callable[[pd.DataFrame], pd.DataFrame],
|
|
646
|
+
key: str,
|
|
647
|
+
) -> tuple[pd.DataFrame, dict[str, tuple[Any, ...]], dict[str, int]]:
|
|
648
|
+
if type(n) is not int or not 1 <= n <= 1000:
|
|
649
|
+
raise InvalidSpecError("top must be an integer 1-1000")
|
|
650
|
+
totals = finish(combine_parts(parts, [dim], measures))
|
|
651
|
+
totals = totals.assign(_k=totals[dim].astype("object").map(natural_key))
|
|
652
|
+
totals = totals.sort_values([key, "_k"], ascending=[False, True], na_position="last")
|
|
653
|
+
values = totals[dim].tolist()
|
|
654
|
+
count = len(values)
|
|
655
|
+
if count <= n + 1: # never hide a single value inside "Other"
|
|
656
|
+
return parts, {}, {dim: count}
|
|
657
|
+
keep, rest = values[:n], values[n:]
|
|
658
|
+
label = f"{text('other')} ({len(rest)})"
|
|
659
|
+
parts = parts.copy()
|
|
660
|
+
in_rest = ~parts[dim].isin(keep)
|
|
661
|
+
parts[dim] = parts[dim].astype("object").where(~in_rest, label)
|
|
662
|
+
parts[OTHER_FLAG] = in_rest
|
|
663
|
+
members = tuple(sorted(rest, key=natural_key))
|
|
664
|
+
return parts, {dim: members}, {dim: count}
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _fill_periods(
|
|
668
|
+
result: pd.DataFrame,
|
|
669
|
+
dims: list[str],
|
|
670
|
+
time_dim: str,
|
|
671
|
+
grain: str,
|
|
672
|
+
measures: list[Measure],
|
|
673
|
+
mode: str,
|
|
674
|
+
) -> tuple[pd.DataFrame, str | None]:
|
|
675
|
+
times = result[time_dim].dropna()
|
|
676
|
+
if times.empty:
|
|
677
|
+
return result, None
|
|
678
|
+
periods = _period_range(times.min(), times.max(), grain)
|
|
679
|
+
if len(periods) > MAX_PERIODS:
|
|
680
|
+
return result, f"too many {grain}s to fill gaps ({len(periods):,}); gaps left as they are"
|
|
681
|
+
others = [d for d in dims if d != time_dim]
|
|
682
|
+
if others:
|
|
683
|
+
combos = result[others].drop_duplicates()
|
|
684
|
+
if len(combos) * len(periods) > MAX_FILL_ROWS:
|
|
685
|
+
return result, "too many combinations to fill missing periods; gaps left as they are"
|
|
686
|
+
grid = combos.merge(pd.DataFrame({time_dim: periods}), how="cross")
|
|
687
|
+
else:
|
|
688
|
+
grid = pd.DataFrame({time_dim: periods})
|
|
689
|
+
filled = grid.merge(result, on=[*others, time_dim], how="left") if len(grid) else result
|
|
690
|
+
missing_rows = filled[ROWS].isna()
|
|
691
|
+
if mode == "zero":
|
|
692
|
+
for m in measures:
|
|
693
|
+
if m.agg in _ZERO_WHEN_EMPTY:
|
|
694
|
+
filled[m.label] = filled[m.label].where(~missing_rows, 0)
|
|
695
|
+
filled[ROWS] = filled[ROWS].fillna(0).astype("int64")
|
|
696
|
+
added = int(missing_rows.sum())
|
|
697
|
+
return filled, (
|
|
698
|
+
f"{added} empty {grain}(s) shown as {'0' if mode == 'zero' else 'gaps'}" if added else None
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _sort(
|
|
703
|
+
result: pd.DataFrame,
|
|
704
|
+
dims: list[str],
|
|
705
|
+
primary: str | None,
|
|
706
|
+
outputs: Sequence[str],
|
|
707
|
+
prof: Profile,
|
|
708
|
+
grains: Mapping[str, str],
|
|
709
|
+
sort: str | Sequence[str] | None,
|
|
710
|
+
) -> pd.DataFrame:
|
|
711
|
+
if not dims:
|
|
712
|
+
return result
|
|
713
|
+
keys: list[str] = []
|
|
714
|
+
asc: list[bool] = []
|
|
715
|
+
work = result.copy()
|
|
716
|
+
first = dims[0]
|
|
717
|
+
if isinstance(sort, list | tuple):
|
|
718
|
+
order = {v: i for i, v in enumerate(sort)}
|
|
719
|
+
work["_s"] = work[first].map(lambda v: order.get(v, len(order)))
|
|
720
|
+
keys.append("_s")
|
|
721
|
+
asc.append(True)
|
|
722
|
+
elif first in grains or (sort is None and prof[first].ordered) or sort == "none":
|
|
723
|
+
if first in grains:
|
|
724
|
+
work["_s"] = work[first]
|
|
725
|
+
elif prof[first].ordered:
|
|
726
|
+
order = {v: i for i, v in enumerate(prof[first].ordered or ())}
|
|
727
|
+
work["_s"] = work[first].map(lambda v: order.get(str(v), len(order)))
|
|
728
|
+
else:
|
|
729
|
+
work["_s"] = work[first].astype("object").map(natural_key)
|
|
730
|
+
keys.append("_s")
|
|
731
|
+
asc.append(True)
|
|
732
|
+
elif primary is not None:
|
|
733
|
+
by = primary
|
|
734
|
+
if isinstance(sort, str) and sort.startswith("by:"):
|
|
735
|
+
by = sort[3:]
|
|
736
|
+
if by not in outputs:
|
|
737
|
+
raise _unknown(by, outputs)
|
|
738
|
+
totals = work.groupby(first, dropna=False, sort=False, observed=True)[by].transform("sum")
|
|
739
|
+
work["_s"] = totals
|
|
740
|
+
keys.append("_s")
|
|
741
|
+
asc.append(sort == "asc")
|
|
742
|
+
if OTHER_FLAG in work:
|
|
743
|
+
# "Other" goes last: last overall, or last within each date when grouping by date first.
|
|
744
|
+
work["_other"] = work[OTHER_FLAG].eq(True)
|
|
745
|
+
at = 1 if first in grains else 0
|
|
746
|
+
keys.insert(at, "_other")
|
|
747
|
+
asc.insert(at, True)
|
|
748
|
+
work["_n"] = work[first].astype("object").map(natural_key)
|
|
749
|
+
keys.append("_n")
|
|
750
|
+
asc.append(True)
|
|
751
|
+
for d in dims[1:]:
|
|
752
|
+
col = f"_d_{d}"
|
|
753
|
+
work[col] = work[d] if d in grains else work[d].astype("object").map(natural_key)
|
|
754
|
+
keys.append(col)
|
|
755
|
+
asc.append(True)
|
|
756
|
+
work = work.sort_values(keys, ascending=asc, kind="mergesort", na_position="last")
|
|
757
|
+
return work.drop(
|
|
758
|
+
columns=[c for c in work.columns if c.startswith("_") and not c.startswith("__")]
|
|
759
|
+
)
|