├── .flake8 ├── .github └── workflows │ ├── pythonpublish.yml │ └── pythontests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.rst ├── examples.png ├── histoprint ├── __init__.py ├── cli.py ├── formatter.py ├── rich.py └── version.pyi ├── noxfile.py ├── pyproject.toml └── tests ├── data ├── 1D.txt ├── 2D.txt ├── 3D.csv ├── 3D.txt └── histograms.root └── test_basic.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore = E501 3 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # See https://scikit-hep.org/developer/gha_pure 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | workflow_dispatch: 8 | release: 9 | types: 10 | - published 11 | 12 | jobs: 13 | dist: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Build SDist and wheel 20 | run: pipx run build 21 | 22 | - uses: actions/upload-artifact@v4 23 | with: 24 | name: artifact 25 | path: dist/* 26 | 27 | - name: Check metadata 28 | run: pipx run twine check dist/* 29 | 30 | publish: 31 | needs: [dist] 32 | runs-on: ubuntu-latest 33 | if: github.event_name == 'release' && github.event.action == 'published' 34 | 35 | environment: release 36 | permissions: 37 | # IMPORTANT: this permission is mandatory for trusted publishing 38 | id-token: write 39 | 40 | steps: 41 | - uses: actions/download-artifact@v4 42 | with: 43 | name: artifact 44 | path: dist 45 | 46 | - uses: pypa/gh-action-pypi-publish@release/v1 47 | -------------------------------------------------------------------------------- /.github/workflows/pythontests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python Tests 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | 11 | jobs: 12 | test: 13 | 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v3 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Setup uv 26 | uses: astral-sh/setup-uv@v3 27 | - name: Install dependencies 28 | run: uv pip install --system -e .[test,boost,uproot,rich] pandas 29 | - name: Run tests 30 | run: python -m pytest -s 31 | - name: Run CLI 32 | run: | 33 | histoprint tests/data/1D.txt 34 | histoprint tests/data/1D.txt -c 40 -l 40 35 | histoprint tests/data/2D.txt 36 | histoprint tests/data/3D.txt -s -l A -l B -l C -t "HISTOPRINT" 37 | histoprint tests/data/3D.txt -s -f 0 -l A -f 2 -l C -C 'data[1] > 2.' 38 | histoprint tests/data/3D.csv -s -f x -f z -C 'y > 2.' 39 | wget --quiet http://scikit-hep.org/uproot3/examples/Event.root 40 | histoprint -s Event.root -f T/event/fTracks/fTracks.fYfirst -f T/event/fTracks/fTracks.fYlast[:,0] 41 | histoprint -s Event.root -f T/fTracks.fYfirst -f T/event/fTracks/fTracks.fYlast[:,0] -C 'fNtrack > 5' 42 | histoprint -s Event.root -f htime 43 | 44 | pre-commit: 45 | 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v3 50 | - uses: actions/setup-python@v3 51 | with: 52 | python-version: '3.x' 53 | - uses: pre-commit/action@v3.0.1 54 | with: 55 | extra_args: --all-files --hook-stage manual 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | histoprint/version.py 131 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | # Mypy fails for some checks on Numpy (!), but only on the pre-commit.ci 3 | # Is still tested in the GitHub CI, so save to just disable here 4 | skip: [mypy] 5 | 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: "v5.0.0" 9 | hooks: 10 | - id: check-added-large-files 11 | - id: check-case-conflict 12 | - id: check-merge-conflict 13 | - id: check-symlinks 14 | - id: check-yaml 15 | - id: debug-statements 16 | - id: end-of-file-fixer 17 | - id: mixed-line-ending 18 | - id: requirements-txt-fixer 19 | - id: trailing-whitespace 20 | 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | rev: "v0.11.8" 23 | hooks: 24 | - id: ruff 25 | args: [--fix, --show-fixes] 26 | - id: ruff-format 27 | 28 | - repo: https://github.com/pre-commit/mirrors-mypy 29 | rev: "v1.15.0" 30 | hooks: 31 | - id: mypy 32 | files: histoprint 33 | args: [] 34 | additional_dependencies: [numpy, types-click, uhi, rich] 35 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [2.6.0] 10 | 11 | ### Removed 12 | - Support for Python <= 3.8 13 | 14 | ## [2.5.0] 15 | 16 | ### Added 17 | - Support dor Python up to 3.13. 18 | 19 | ## [2.4.0] 20 | 21 | ### Addded 22 | - RichHistogram class for use with the `rich` package. 23 | - Argument to set maximum count in bins for plotting. 24 | 25 | ### Changed 26 | - Changed tick formatting. 27 | 28 | ## [2.3.0] 29 | 30 | ### Changed 31 | - Change name of optional extra requirement from 'root' to 'uproot'. 32 | 33 | ## [2.2.0] 34 | 35 | ### Added 36 | - Added support for stacks of PlottableHistograms. 37 | 38 | ## [2.1.0] 39 | 40 | ### Added 41 | - Added a `--cut` option to the command line tool to filter the plotted data. 42 | 43 | ### Fixed 44 | - Now handles empty data gracefully. 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Lukas Koch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======================================================================== 2 | histoprint - pretty print of NumPy (and other) histograms to the console 3 | ======================================================================== 4 | 5 | |Scikit-HEP| |PyPI| |Conda-forge| |Zenodo-DOI| 6 | 7 | 8 | How does it work? 9 | ----------------- 10 | 11 | ``Histoprint`` uses a mix of terminal color codes and Unicode trickery (i.e. 12 | combining characters) to plot overlaying histograms. Some terminals are not 13 | able to display Unicode combining characters correctly. ``Histoprint`` can still be 14 | used in those terminals, but the character set needs to be constrained to the 15 | non-combining ones (see below). 16 | 17 | 18 | Installation 19 | ------------ 20 | 21 | :: 22 | 23 | $ python -m pip install histoprint 24 | 25 | :: 26 | 27 | $ conda install -c conda-forge histoprint 28 | 29 | 30 | Getting started 31 | --------------- 32 | 33 | Basic examples:: 34 | 35 | import numpy as np 36 | from histoprint import text_hist, print_hist 37 | 38 | A = np.random.randn(1000) - 2 39 | B = np.random.randn(1000) 40 | C = np.random.randn(1000) + 2 41 | D = np.random.randn(500) * 2 42 | 43 | # text_hist is a thin wrapper for numpy.histogram 44 | text_hist( 45 | B, bins=[-5, -3, -2, -1, -0.5, 0, 0.5, 1, 2, 3, 5], title="Variable bin widths" 46 | ) 47 | 48 | histA = np.histogram(A, bins=15, range=(-5, 5)) 49 | histB = np.histogram(B, bins=15, range=(-5, 5)) 50 | histC = np.histogram(C, bins=15, range=(-5, 5)) 51 | histD = np.histogram(D, bins=15, range=(-5, 5)) 52 | histAll = ([histA[0], histB[0], histC[0], histD[0]], histA[1]) 53 | 54 | # print_hist can be used to print multiple histograms at once 55 | # (or just to print a single one as returned by numpy.histogram) 56 | print_hist(histAll, title="Overlays", labels="ABCDE") 57 | print_hist( 58 | histAll, 59 | title="Stacks", 60 | stack=True, 61 | symbols=" ", 62 | bg_colors="rgbcmy", 63 | labels="ABCDE", 64 | ) 65 | print_hist( 66 | histAll, 67 | title="Summaries", 68 | symbols=r"=|\/", 69 | fg_colors="0", 70 | bg_colors="0", 71 | labels=["AAAAAAAAAAAAAAAA", "B", "CCCCCCCCCCCCC", "D"], 72 | summary=True, 73 | ) 74 | 75 | .. image:: examples.png 76 | 77 | The last example does not use terminal colors, so it can be copied as text:: 78 | 79 | Summaries 80 | -5.00e+00 _ 81 | -4.33e+00 _═⃫ 82 | -3.67e+00 _═⃫═⃫═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏ 83 | -3.00e+00 _═⃫═⃫═⃫═⃫═⃫═⃫═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏ 84 | -2.33e+00 _╪⃫╪⃫═⃫═⃫═⃫═⃫═⃫═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏ 85 | -1.67e+00 _╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫═⃫═⃫═⃫═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏ 86 | -1.00e+00 _╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏═͏ 87 | -3.33e-01 _╪⃥⃫╪⃥⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪⃫╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏╪͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏ 88 | 3.33e-01 _╪⃥⃫╪⃥⃫╪⃥⃫╪⃥⃫╪⃥⃫╪⃥⃫╪⃫╪⃫╪⃫╪⃫│⃫│⃫│⃫│⃫│⃫│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏ 89 | 1.00e+00 _╪⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏│͏ 90 | 1.67e+00 _│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥│⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ 91 | 2.33e+00 _│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥⃫│⃥│⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ 92 | 3.00e+00 _│⃥⃫│⃥⃫ ⃥⃫ ⃥⃫ ⃥⃫ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ 93 | 3.67e+00 _ ⃥⃫ ⃥⃫ ⃥⃫ ⃥⃫ ⃥⃫ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ 94 | 4.33e+00 _ ⃥⃫ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ ⃥ 95 | 5.00e+00 _ ⃥⃫ ⃥ 96 | ═͏ AAAAAAAAAAAAAAAA │͏ B ⃥ CCCCCCCCCCCCC ⃫ D 97 | Sum: 9.99e+02 1.00e+03 9.99e+02 4.92e+02 98 | Avg: -1.98e+00 1.13e-02 1.99e+00 -1.71e-01 99 | Std: 1.03e+00 1.03e+00 9.94e-01 2.00e+00 100 | 101 | 102 | Support for other histogram types 103 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 104 | 105 | ``Histoprint`` can directly plot other (more fancy) types of histograms if they 106 | follow the `PlottableProtocol` conventions, or offer a way of being converted to 107 | the NumPy format. Currently this means they have to expose a ``numpy()`` or 108 | ``to_numpy()`` method. Both the ROOT ``TH1`` histograms of `uproot4 `__, 109 | as well as the histograms of `boost-histogram `__, 110 | are supported:: 111 | 112 | import boost_histogram as bh 113 | hist = bh.Histogram(bh.axis.Regular(20, -3, 3)) 114 | hist.fill(np.random.randn(1000)) 115 | print_hist(hist, title="Boost Histogram") 116 | 117 | import uproot 118 | file = uproot.open("http://scikit-hep.org/uproot3/examples/Event.root") 119 | hist = file["htime"] 120 | print_hist(hist, title="uproot TH1") 121 | 122 | 123 | Disabling Unicode combining characters 124 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 125 | 126 | Some terminals are not able to display Unicode combining characters correctly. 127 | To disable the use of combining characters, simply do not use them when calling 128 | ``print_hist``:: 129 | 130 | print_hist(some_histograms, symbols=" =|") 131 | 132 | The combining characters are ``/`` and ``\``. Note that they are used in the 133 | default set of characters for the 4th and 5th histogram if they are present. 134 | 135 | 136 | Command line interface 137 | ---------------------- 138 | 139 | ``Histoprint`` also comes with a simple command-line interface to create 140 | histograms of tabulated data or even ROOT files (with the help of ``uproot``). 141 | It can read in files or take data directly from STDIN:: 142 | 143 | $ histoprint --help 144 | Usage: histoprint [OPTIONS] INFILE 145 | 146 | Read INFILE and print a histogram of the contained columns. 147 | 148 | INFILE can be '-', in which case the data is read from STDIN. 149 | 150 | Options: 151 | -b, --bins TEXT Number of bins or space-separated bin edges. 152 | -t, --title TEXT Title of the histogram. 153 | --stack / --nostack Stack the histograms. 154 | -s, --summary / -S, --nosummary 155 | Print summary statistics. 156 | -l, --label TEXT Labels for the data, one for each column. 157 | --symbols TEXT Symbol cycle for multiple histograms. 158 | Choices & default: ' |=/\' 159 | 160 | --fg-colors TEXT Colour cycle for foreground colours. 161 | Default: 'WWWWW', Choices: 162 | '0rgbcmykwRGBCMYKW' 163 | 164 | --bg-colors TEXT Colour cycle for background colours. 165 | Default: 'K0000', Choices: 166 | '0rgbcmykwRGBCMYKW' 167 | 168 | -f, --field TEXT Which fields to histogram. Interpretation of 169 | the fields depends on the file format. TXT 170 | files only support integers for column 171 | numbers starting at 0. For CSV files, the 172 | fields must be the names of the columns as 173 | specified in the first line of the file. 174 | When plotting from ROOT files, at least one 175 | field must be specified. This can either be 176 | the path to a single TH1, or one or more 177 | paths to TTree branches. Also supports 178 | slicing of array-like branches, e.g. use 179 | 'tree/branch[:,2]' to histogram the 3rd 180 | elements of a vector-like branch. 181 | 182 | -C, --cut TEXT Filter the data to be plotted by a cut 183 | condition. For ROOT files, variables must be 184 | referenced by their branch name within the 185 | TTree, e.g. 'momentum > 100.' rather than 186 | 'tree/object/momentum > 100.'. For text 187 | files, the fields are referred to as 188 | 'data[i]', where 'i' is the field number. 189 | The variables used in the cut do not have to 190 | be part of the plotted fields. 191 | 192 | -c, --columns INTEGER Total width of the displayed histogram in 193 | characters. Defaults to width of the 194 | terminal. 195 | 196 | -r, --lines INTEGER Approximate total height of the displayed 197 | histogram in characters. Calculated from 198 | number of columns by default. 199 | 200 | --version Show the version and exit. 201 | --help Show this message and exit. 202 | 203 | $ histoprint -t "Title" -s -b "0.5 1.5 2.5 3.5 4.5" -l A -l B --fg-colors "0" --bg-colors "0" --symbols "|=" - < 100.' rather than 'tree/object/momentum > 100.'. For text " 84 | "files, the fields are referred to as 'data[i]', where 'i' is the field " 85 | "number. The variables used in the cut do not have to be part of the " 86 | "plotted fields.", 87 | ) 88 | @click.option( 89 | "-c", 90 | "--columns", 91 | type=int, 92 | default=None, 93 | help="Total width of the displayed histogram in characters. Defaults to " 94 | "width of the terminal.", 95 | ) 96 | @click.option( 97 | "-r", 98 | "--lines", 99 | type=int, 100 | default=None, 101 | help="Approximate total height of the displayed histogram in characters. " 102 | "Calculated from number of columns by default.", 103 | ) 104 | @click.version_option() 105 | def histoprint(infile, **kwargs): 106 | """Read INFILE and print a histogram of the contained columns. 107 | 108 | INFILE can be '-', in which case the data is read from STDIN. 109 | """ 110 | 111 | # Read file into buffer for use by implementations 112 | try: 113 | with click.open_file(infile, "rt") as f: 114 | data = f.read(-1) 115 | except UnicodeDecodeError: 116 | # Probably some binary file 117 | data = "" 118 | 119 | if len(data) > 0: 120 | data_handle = StringIO(data) 121 | del data 122 | 123 | # Try to interpret file as textfile 124 | try: 125 | data_handle.seek(0) 126 | _histoprint_txt(data_handle, **kwargs) 127 | return 128 | except ValueError: 129 | pass 130 | 131 | # Try to interpret file as CSV file 132 | try: 133 | data_handle.seek(0) 134 | _histoprint_csv(data_handle, **kwargs) 135 | raise SystemExit(0) 136 | except ImportError: 137 | click.echo("Cannot try CSV file format. Pandas module not found.", err=True) 138 | 139 | # Try to interpret file as ROOT file 140 | try: 141 | _histoprint_root(infile, **kwargs) 142 | return 143 | except ImportError: 144 | pass 145 | 146 | raise click.FileError(infile, "Could not interpret the file format") 147 | 148 | 149 | def _bin_edges(kwargs, data): 150 | """Get the desired bin edges.""" 151 | bins = kwargs.pop("bins", "10") 152 | bins = np.fromiter(bins.split(), dtype=float) 153 | if len(bins) == 1: 154 | bins = int(bins[0]) 155 | if isinstance(bins, int): 156 | minval = np.inf 157 | maxval = -np.inf 158 | for d in data: 159 | try: 160 | minval = min(minval, np.nanmin(d)) 161 | maxval = max(maxval, np.nanmax(d)) 162 | except ValueError: 163 | # Empty data? 164 | pass 165 | bins = np.linspace(minval, maxval, bins + 1) 166 | return bins 167 | 168 | 169 | def _histoprint_txt(infile, **kwargs): 170 | """Interpret file as as simple whitespace separated table.""" 171 | 172 | # Read the data 173 | data = np.loadtxt(infile, ndmin=2) 174 | data = data.T 175 | cut = kwargs.pop("cut", "") 176 | if cut is not None and len(cut) > 0: 177 | try: 178 | data = data[:, eval(cut)] 179 | except Exception as e: 180 | click.echo("Error interpreting the cut string:", err=True) 181 | click.echo(e, err=True) 182 | raise SystemExit(1) from None 183 | 184 | # Interpret field numbers 185 | fields = kwargs.pop("fields", []) 186 | if len(fields) > 0: 187 | try: 188 | fields = [int(f) for f in fields] 189 | except ValueError: 190 | click.echo("Fields for a TXT file must be integers.", err=True) 191 | raise SystemExit(1) from None 192 | try: 193 | data = data[fields] 194 | except KeyError: 195 | click.echo("Field out of bounds.", err=True) 196 | raise SystemExit(1) from None 197 | 198 | # Interpret bins 199 | bins = _bin_edges(kwargs, data) 200 | 201 | # Create the histogram(s) 202 | hist: Tuple[Any, Any] = ([], bins) 203 | for d in data: 204 | hist[0].append(np.histogram(d, bins=bins)[0]) 205 | 206 | # Print the histogram 207 | hp.print_hist(hist, **kwargs) 208 | 209 | 210 | def _histoprint_csv(infile, **kwargs): 211 | """Interpret file as as CSV file.""" 212 | 213 | import pandas as pd 214 | 215 | # Read the data 216 | data = pd.read_csv(infile) 217 | cut = kwargs.pop("cut", "") 218 | if cut is not None and len(cut) > 0: 219 | try: 220 | data = data[data.eval(cut)] 221 | except Exception as e: 222 | click.echo("Error interpreting the cut string:", err=True) 223 | click.echo(e, err=True) 224 | raise SystemExit(1) from None 225 | 226 | # Interpret field numbers/names 227 | fields = list(kwargs.pop("fields", [])) 228 | if len(fields) > 0: 229 | try: 230 | data = data[fields] 231 | except KeyError: 232 | click.echo("Unknown column name.", err=True) 233 | raise SystemExit(1) from None 234 | 235 | # Get default columns labels 236 | if kwargs.get("labels", ("",)) == ("",): 237 | kwargs["labels"] = data.columns 238 | 239 | # Convert to array 240 | data = data.to_numpy().T 241 | 242 | # Interpret bins 243 | bins = _bin_edges(kwargs, data) 244 | 245 | # Create the histogram(s) 246 | hist: Tuple[Any, Any] = ([], bins) 247 | for d in data: 248 | hist[0].append(np.histogram(d, bins=bins)[0]) 249 | 250 | # Print the histogram 251 | hp.print_hist(hist, **kwargs) 252 | 253 | 254 | def _histoprint_root(infile, **kwargs): 255 | """Interpret file as as ROOT file.""" 256 | 257 | # Import uproot 258 | try: 259 | import uproot as up 260 | except ImportError: 261 | click.echo("Cannot try ROOT file format. Uproot module not found.", err=True) 262 | raise 263 | # Import awkward 264 | try: 265 | import awkward as ak 266 | except ImportError: 267 | click.echo("Cannot try ROOT file format. Awkward module not found.", err=True) 268 | raise 269 | 270 | # Open root file 271 | F = up.open(infile) 272 | 273 | # Interpret field names 274 | fields = list(kwargs.pop("fields", [])) 275 | if len(fields) == 0: 276 | click.echo("Must specify at least one field for ROOT files.", err=True) 277 | click.echo(F.keys(), err=True) 278 | raise SystemExit(1) 279 | 280 | # Get default columns labels 281 | if kwargs.get("labels", ("",)) == ("",): 282 | kwargs["labels"] = [field.split("/")[-1] for field in fields] 283 | labels = kwargs.pop("labels") 284 | 285 | # Get possible cut expression 286 | cut = kwargs.pop("cut", "") 287 | 288 | # Possibly a single histogram 289 | if len(fields) == 1: 290 | try: 291 | hist = F[fields[0]] 292 | except KeyError: 293 | pass # Deal with key error further down 294 | else: 295 | try: 296 | hist = F[fields[0]].to_numpy() 297 | except AttributeError: 298 | pass 299 | else: 300 | kwargs.pop("bins", None) # Get rid of useless parameter 301 | hp.print_hist(hist, **kwargs) 302 | return 303 | 304 | data = [] 305 | # Find TTrees 306 | trees: List[up.models.TTree.Model_TTree_v19] = [] 307 | tree_fields: List[List[Dict[str, Any]]] = [] 308 | for field, label in zip(fields, labels): 309 | branch = F 310 | splitfield = field.split("/") 311 | for i, key in enumerate(splitfield): 312 | try: 313 | branch = branch[key] 314 | except KeyError: 315 | click.echo( 316 | f"Could not find key '{key}'. Possible values: {branch.keys()}", 317 | err=True, 318 | ) 319 | raise SystemExit(1) from None 320 | # Has `arrays` method? 321 | if hasattr(branch, "arrays"): 322 | # Found it 323 | path = "/".join(splitfield[i + 1 :]) 324 | if branch in trees: 325 | tree_fields[trees.index(branch)].append( 326 | {"label": label, "path": path} 327 | ) 328 | else: 329 | trees.append(branch) 330 | tree_fields.append([{"label": label, "path": path}]) 331 | 332 | break 333 | 334 | # Reassign labels in correct order 335 | labels = [] 336 | # Get and flatten the data 337 | for tree, fields in zip(trees, tree_fields): 338 | aliases = {} 339 | d = [] 340 | for field in fields: 341 | labels.append(field["label"]) 342 | split = field["path"].split("[") 343 | path = split[0] 344 | slic = "[" + "[".join(split[1:]) if len(split) > 1 else "" 345 | aliases[field["label"]] = path 346 | # Get the branches 347 | try: 348 | d.append(eval("tree[path].array()" + slic)) 349 | except up.KeyInFileError as e: 350 | click.echo(e, err=True) 351 | click.echo(f"Possible keys: {tree.keys()}", err=True) 352 | raise SystemExit(1) from None 353 | 354 | # Cut on values 355 | if cut is not None: 356 | try: 357 | index = tree.arrays("cut", aliases={"cut": cut}).cut 358 | except up.KeyInFileError as e: 359 | click.echo(e, err=True) 360 | click.echo(f"Possible keys: {tree.keys()}", err=True) 361 | raise SystemExit(1) from None 362 | except Exception as e: 363 | click.echo("Error interpreting the cut string:", err=True) 364 | click.echo(e, err=True) 365 | raise SystemExit(1) from None 366 | 367 | for i in range(len(d)): 368 | d[i] = d[i][index] 369 | 370 | # Flatten if necessary 371 | for i in range(len(d)): 372 | with contextlib.suppress(ValueError): 373 | d[i] = ak.flatten(d[i]) 374 | 375 | # Turn into flat numpy array 376 | d[i] = ak.to_numpy(d[i]) 377 | 378 | data.extend(d) 379 | 380 | # Assign new label order 381 | kwargs["labels"] = labels 382 | 383 | # Interpret bins 384 | bins = _bin_edges(kwargs, data) 385 | 386 | # Create the histogram(s) 387 | hist = ([], bins) 388 | for d in data: 389 | hist[0].append(np.histogram(d, bins=bins)[0]) 390 | 391 | # Print the histogram 392 | hp.print_hist(hist, **kwargs) 393 | -------------------------------------------------------------------------------- /histoprint/formatter.py: -------------------------------------------------------------------------------- 1 | """Module for plotting Numpy-like 1D histograms to the terminal.""" 2 | 3 | import shutil 4 | import sys 5 | from collections.abc import Sequence 6 | from itertools import cycle 7 | from typing import Optional 8 | 9 | import numpy as np 10 | from uhi.numpy_plottable import ensure_plottable_histogram 11 | from uhi.typing.plottable import PlottableHistogram 12 | 13 | DEFAULT_SYMBOLS = " |=/\\" 14 | COMPOSING_SYMBOLS = "/\\" 15 | DEFAULT_FG_COLORS = "WWWWW" 16 | DEFAULT_BG_COLORS = "K0000" 17 | 18 | __all__ = ["HistFormatter", "print_hist", "text_hist"] 19 | 20 | 21 | class Hixel: 22 | """The smallest unit of a histogram plot.""" 23 | 24 | def __init__(self, char=" ", fg="0", bg="0", use_color=True, compose=" "): 25 | self.character = " " 26 | self.compose: Optional[str] = compose 27 | self.fg_color = fg 28 | self.bg_color = bg 29 | self.use_color = use_color 30 | self.add(char, fg, bg) 31 | 32 | def add(self, char=" ", fg="0", bg="0"): 33 | """Add another element on top.""" 34 | 35 | allowed = r" =|\/" 36 | if char not in allowed: 37 | msg = f"Symbol not one of the allowed: '{allowed!r}'" 38 | raise ValueError(msg) 39 | 40 | if fg == self.fg_color: 41 | # Combine characters if possible 42 | char_combinations = { 43 | ("|", "="): "#", 44 | ("=", "|"): "#", 45 | ("#", "="): "#", 46 | ("#", "|"): "#", 47 | } 48 | compose_chars = r"\/" 49 | compose_combinations = { 50 | ("/", "\\"): "X", 51 | ("\\", "/"): "X", 52 | (" ", "\\"): "\\", 53 | (" ", "/"): "/", 54 | } 55 | if char in compose_chars: 56 | if self.compose is not None: 57 | self.compose = compose_combinations.get((self.compose, char)) 58 | else: 59 | self.character = char_combinations.get((self.character, char), char) 60 | elif char != " ": 61 | # Otherwise overwrite if it is not a " " 62 | self.character = " " 63 | self.fg_color = fg 64 | self.add(char, fg, bg) 65 | 66 | if bg != "0": 67 | self.bg_color = bg 68 | 69 | def render(self, reset=True): 70 | """Render the Hixel as a string.""" 71 | ret = "" 72 | if ( 73 | self.character == " " 74 | and (self.compose == " " or self.compose is None) 75 | and self.bg_color != "0" 76 | ): 77 | # Instead of printing a space with BG color, 78 | # print a full block with same FG color, 79 | # so the histogram can be copied to text editors. 80 | # Replace BG colour with opposite brightness, 81 | # so it shows when the text is selected in a terminal. 82 | ret += self.ansi_color_string(self.bg_color, self.bg_color.swapcase()) 83 | ret += "\u2588" 84 | else: 85 | ret += self.ansi_color_string(self.fg_color, self.bg_color) 86 | ret += self.substitute_character(self.character, self.compose) 87 | if reset: 88 | ret += self.ansi_color_string("0", "0") 89 | return ret 90 | 91 | @staticmethod 92 | def substitute_character(char, compose): 93 | r"""Replace some ASCII characters with better looking Unicode. 94 | 95 | Substitutions:: 96 | 97 | "|" -> "\u2502" 98 | "=" -> "\u2550" 99 | "#" -> "\u256A" 100 | "\" -> " \u20e5" 101 | "/" -> " \u20eb" 102 | "X" -> " \u20e5\u20eb" 103 | 104 | """ 105 | 106 | subs = { 107 | "|": "\u2502", 108 | "=": "\u2550", 109 | "#": "\u256a", 110 | "\\": "\u20e5", 111 | "/": "\u20eb", 112 | "X": "\u20e5\u20eb", 113 | None: "", 114 | } 115 | 116 | ret = subs.get(char, char) 117 | # Characters can be displayed differently when they have a composing character on top. 118 | # This looks ugly if some Hixels of a histogram are covered by another and some are not. 119 | # Unless explicitly asked for no compositio, if no composition is added, 120 | # use empty composition character to make them all display the same. 121 | ret += subs.get(compose, "\u034f") 122 | 123 | return ret 124 | 125 | def ansi_color_string(self, fg, bg): 126 | """Set the terminal color.""" 127 | ret = "" 128 | if self.use_color: 129 | # The ANSI color codes 130 | subs = { 131 | "k": 30, 132 | "r": 31, 133 | "g": 32, 134 | "y": 33, 135 | "b": 34, 136 | "m": 35, 137 | "c": 36, 138 | "w": 37, 139 | "0": 39, # Reset to default 140 | "K": 90, 141 | "R": 91, 142 | "G": 92, 143 | "Y": 93, 144 | "B": 94, 145 | "M": 95, 146 | "C": 96, 147 | "W": 97, 148 | } 149 | fg = subs[fg] 150 | bg = subs[bg] + 10 151 | 152 | ret += f"\033[{fg:d};{bg:d}m" 153 | return ret 154 | 155 | 156 | class BinFormatter: 157 | """Class that turns bin contents into text. 158 | 159 | Parameters 160 | ---------- 161 | 162 | scale : float 163 | The scale of the histogram, i.e. one text character corresponds to 164 | `scale` counts. 165 | count_area : bool, optional 166 | Whether the bin content is represented by the area or height of the bin. 167 | tick_format : str, optional 168 | The format string for tick values. 169 | tick_mark : str, optional 170 | Character of the tick mark. 171 | no_tick_mark : str, optional 172 | Character to be printed for axis without tick. 173 | print_top_edge : bool, optional 174 | Whether to print the top or bottom edge of the bin. 175 | symbols : iterable, optional 176 | The foreground symbols to be used for the histograms. 177 | fg_colors : iterable, optional 178 | The foreground colours to be used. 179 | bg_colors : iterable, optional 180 | The background colours to be used. 181 | stack : bool, optional 182 | Whether to stack the histograms, or overlay them. 183 | use_color : bool, optional 184 | Whether to use color output. 185 | 186 | Notes 187 | ----- 188 | 189 | Colours are specified as one character out of "0rgbcmykwRGBCMYKW". These 190 | are mapped to the terminal's default colour scheme. A "0" denotes the 191 | default foreground colour or "transparent" background. The capital letters 192 | denote the bright versions of the lower case ones. 193 | 194 | """ 195 | 196 | def __init__( 197 | self, 198 | scale=1.0, 199 | count_area=True, 200 | tick_format="{: #7.3f} ", 201 | tick_mark="_", 202 | no_tick_mark=" ", 203 | print_top_edge=False, 204 | symbols=DEFAULT_SYMBOLS, 205 | fg_colors=DEFAULT_FG_COLORS, 206 | bg_colors=DEFAULT_BG_COLORS, 207 | stack=False, 208 | use_color=None, 209 | ): 210 | self.scale = scale 211 | self.count_area = count_area 212 | self.tick_format = tick_format 213 | self.tick_format_width = len(tick_format.format(0.0)) 214 | self.tick_mark = tick_mark 215 | self.no_tick_mark = no_tick_mark 216 | self.print_top_edge = print_top_edge 217 | self.symbols = symbols 218 | if self.symbols == "": 219 | self.symbols = " " 220 | self.fg_colors = fg_colors 221 | if self.fg_colors == "": 222 | self.fg_colors = "0" 223 | self.bg_colors = bg_colors 224 | if self.bg_colors == "": 225 | self.bg_colors = "0" 226 | self.stack = stack 227 | if use_color is None: 228 | if any(c != "0" for c in fg_colors) or any(c != "0" for c in bg_colors): 229 | self.use_color = True 230 | else: 231 | self.use_color = False 232 | else: 233 | self.use_color = use_color 234 | self.compose: Optional[str] = None 235 | 236 | def format_bin(self, top, bottom, counts, width=1): 237 | """Return a string that represents the bin. 238 | 239 | Parameters 240 | ---------- 241 | 242 | top : float 243 | The top edge of the bin 244 | bottom : float 245 | The bottom edge of the bin 246 | counts : iterable 247 | The counts for each histogram in the bin 248 | width : int 249 | The width of the bin in lines 250 | 251 | """ 252 | 253 | # Adjust scale by width if area represents counts 254 | scale = self.scale * (width if self.count_area else 1) 255 | 256 | if scale == 0: 257 | scale = 1.0 258 | 259 | # Calculate bin heights in characters 260 | heights = [int(c // scale) for c in counts] 261 | 262 | # Decide whether to use composing characters 263 | for s in self.symbols[: len(counts)]: 264 | if s in COMPOSING_SYMBOLS: 265 | self.compose = " " 266 | break 267 | else: 268 | self.compose = None 269 | 270 | # Format bin 271 | bin_string = "" 272 | for i_line in range(width): 273 | # Print axis 274 | if self.print_top_edge and i_line == 0: 275 | bin_string += self.tick(top) 276 | elif not self.print_top_edge and i_line == (width - 1): 277 | bin_string += self.tick(bottom) 278 | else: 279 | bin_string += self.no_tick() 280 | 281 | # Print symbols 282 | line = [] 283 | for h, s, fg, bg in zip( 284 | heights, 285 | cycle(self.symbols), 286 | cycle(self.fg_colors), 287 | cycle(self.bg_colors), 288 | ): 289 | if h: 290 | if self.stack: 291 | # Just print them all afer one another 292 | line += [ 293 | Hixel(s, fg, bg, self.use_color, self.compose) 294 | for _ in range(h) 295 | ] 296 | # Overlay histograms 297 | elif h > len(line): 298 | for hix in line: 299 | hix.add(s, fg, bg) 300 | line += [ 301 | Hixel(s, fg, bg, self.use_color, self.compose) 302 | for _ in range(h - len(line)) 303 | ] 304 | else: 305 | for hix in line[:h]: 306 | hix.add(s, fg, bg) 307 | 308 | for hix in line: 309 | bin_string += hix.render() 310 | 311 | # New line 312 | bin_string += "\n" 313 | 314 | return bin_string 315 | 316 | def tick(self, edge): 317 | """Format the tick mark of a bin.""" 318 | return self.tick_format.format(edge) + self.tick_mark 319 | 320 | def no_tick(self): 321 | """Format the axis without a tick mark.""" 322 | return " " * self.tick_format_width + self.no_tick_mark 323 | 324 | 325 | class HistFormatter: 326 | """Class that handles the formating of histograms. 327 | 328 | Parameters 329 | ---------- 330 | 331 | lines, columns : int, optional 332 | The number of lines and maximum numbre of columns of the output. 333 | count_area : bool, optional 334 | Whether the bin content is represented by the area or height of the bin. 335 | scale_bin_width : bool, optional 336 | Whether the lines per bin should scale with the bin width. 337 | title : str, optional 338 | Title string to print over the histogram. 339 | labels : iterable, optional 340 | Labels the histograms. 341 | summary : bool, optional 342 | Whether to print a summary of the histograms. 343 | max_count : float / int, optional 344 | Set the maximum bin content for plotting. Otherwise largest bin will 345 | fill the available width. Should be a count density of counts per row 346 | if `count_area` is set. 347 | **kwargs : optional 348 | Additional keyword arguments are passed to the `BinFormatter` 349 | 350 | Notes 351 | ----- 352 | 353 | The `HistFormatter` will try to fit everything within the requested number 354 | of lines, but this is not guaranteed. The final number of lines can be 355 | bigger or smaller, depending on the number of bins and their widths. 356 | 357 | """ 358 | 359 | def __init__( 360 | self, 361 | edges, 362 | lines=None, 363 | columns=None, 364 | scale_bin_width=True, 365 | title="", 366 | labels=("",), 367 | summary=False, 368 | max_count=None, 369 | **kwargs, 370 | ): 371 | self.title = title 372 | self.edges = edges 373 | # Fit histograms into the terminal, unless otherwise specified 374 | term_size = shutil.get_terminal_size() 375 | if columns is None: 376 | columns = term_size[0] - 1 377 | if lines is None: 378 | # Try to keep a constant aspect ratio 379 | lines = min(int(columns / 3.5) + 1, term_size[1] - 1) 380 | self.lines = lines 381 | self.columns = columns 382 | self.summary = summary 383 | self.labels = labels 384 | self.max_count = max_count 385 | 386 | self.hist_lines = lines - 1 # Less one line for the first tick 387 | if len(self.title): 388 | # Make room for the title 389 | self.hist_lines -= 1 390 | 391 | self.summary = summary 392 | if self.summary: 393 | # Make room for a summary at the bottom 394 | self.hist_lines -= 4 395 | elif any(len(lab) > 0 for lab in self.labels): 396 | # Make room for a legend at the bottom 397 | self.hist_lines -= 1 398 | 399 | if scale_bin_width: 400 | # Try to scale bins so the number of lines is 401 | # roughly proportional to the bin width 402 | line_scale = ( 403 | (edges[-1] - edges[0]) / self.hist_lines 404 | ) * 0.999 # <- avoid rounding issues 405 | else: 406 | # Choose the largest bin as scale, 407 | # so all bins will scale to <= 1 lines 408 | # and be rendered with one line 409 | line_scale = np.max(edges[1:] - edges[:-1]) 410 | if line_scale == 0.0: 411 | line_scale = 1.0 412 | self.bin_lines = ((edges[1:] - edges[:-1]) // line_scale).astype(int) 413 | self.bin_lines = np.where(self.bin_lines, self.bin_lines, 1) 414 | self.bin_formatter = BinFormatter(**kwargs) 415 | 416 | def format_histogram(self, counts): 417 | """Format (a set of) histogram counts. 418 | 419 | Paramters 420 | --------- 421 | 422 | counts : ndarray 423 | The histogram entries to be plotted. 424 | 425 | """ 426 | 427 | axis_width = self.bin_formatter.tick_format_width + len( 428 | self.bin_formatter.tick_mark 429 | ) 430 | hist_width = self.columns - axis_width 431 | 432 | counts = np.nan_to_num(np.array(counts)) 433 | while counts.ndim < 2: 434 | # Make sure counts is a 2D array 435 | counts = counts[np.newaxis, ...] 436 | 437 | # Get max or total counts in each bin 438 | if self.bin_formatter.stack: 439 | # Bin content will be sum of all histograms 440 | tot_c = np.sum(counts, axis=0) 441 | else: 442 | # Bin content will be maximum of all histograms 443 | tot_c = np.max(counts, axis=0) 444 | 445 | # Get bin lengths 446 | # Bin content will be divided by number of lines 447 | c = tot_c / self.bin_lines if self.bin_formatter.count_area else tot_c 448 | 449 | # Set a scale so that largest bin fills width of allocated area 450 | max_c = np.max(c) if self.max_count is None else self.max_count 451 | 452 | symbol_scale = max_c / hist_width 453 | self.bin_formatter.scale = symbol_scale * 0.999 # <- avoid rounding issues 454 | 455 | hist_string = "" 456 | 457 | # Write the title line 458 | if len(self.title): 459 | hist_string += f"{self.title: ^{self.columns:d}s}\n" 460 | 461 | # Get bin edges 462 | top = np.array(self.edges[:-1]) 463 | bottom = np.array(self.edges[1:]) 464 | # Caclucate common exponent 465 | common_exponent = np.floor(np.log10(np.max(np.abs(self.edges)))) 466 | top /= 10**common_exponent 467 | bottom /= 10**common_exponent 468 | 469 | # Write the first tick, common exponent and horizontal axis 470 | hist_string += self.bin_formatter.tick(top[0]) 471 | ce_string = f" x 10^{common_exponent:+03.0f}" if common_exponent != 0 else "" 472 | 473 | hist_string += ce_string 474 | if self.bin_formatter.count_area: 475 | longest_count = f"{max_c:g}/row" 476 | else: 477 | longest_count = f"{max_c:g}" 478 | hist_string += f"{longest_count:>{hist_width - len(ce_string) - 2:d}s} \u2577\n" 479 | 480 | # Write the bins 481 | for c, t, b, w in zip(counts.T, top, bottom, self.bin_lines): 482 | hist_string += self.bin_formatter.format_bin(t, b, c, w) 483 | 484 | if self.summary: 485 | hist_string += self.summarize(counts, top, bottom) 486 | elif any(len(lab) > 0 for lab in self.labels): 487 | hist_string += self.summarize(counts, top, bottom, legend_only=True) 488 | 489 | return hist_string 490 | 491 | def summarize(self, counts, top, bottom, legend_only=False): 492 | """Calculate some summary statistics.""" 493 | 494 | summary = "" 495 | bin_values = (top + bottom) / 2 496 | 497 | label_widths = [] 498 | 499 | # First line: symbol, label 500 | summary += " " 501 | for _, lab, s, fg, bg in zip( 502 | counts, 503 | cycle(self.labels), 504 | cycle(self.bin_formatter.symbols), 505 | cycle(self.bin_formatter.fg_colors), 506 | cycle(self.bin_formatter.bg_colors), 507 | ): 508 | # Pad label to make room for numbers below 509 | pad_lab = f"{lab:<9}" 510 | label = " " 511 | label += Hixel( 512 | s, fg, bg, self.bin_formatter.use_color, self.bin_formatter.compose 513 | ).render() 514 | label += " " + pad_lab 515 | label_widths.append(3 + len(pad_lab)) 516 | summary += label 517 | pad = max(self.columns - (5 + np.sum(label_widths)), 0) // 2 518 | summary = " " * pad + summary 519 | summary += "\n" 520 | 521 | if legend_only: 522 | return summary 523 | 524 | # Second line: Total 525 | summary += " " * pad + "Tot:" 526 | for c, w in zip(counts, label_widths): 527 | tot = float(np.sum(c)) 528 | summary += f" {tot: .2e}" + " " * (w - 10) 529 | summary += "\n" 530 | 531 | # Third line: Average 532 | summary += " " * pad + "Avg:" 533 | for c, w in zip(counts, label_widths): 534 | try: 535 | average = float(np.average(bin_values, weights=c)) 536 | except ZeroDivisionError: 537 | average = np.nan 538 | summary += f" {average: .2e}" + " " * (w - 10) 539 | summary += "\n" 540 | 541 | # Fourth line: std 542 | summary += " " * pad + "Std:" 543 | for c, w in zip(counts, label_widths): 544 | try: 545 | average = float(np.average(bin_values, weights=c)) 546 | std = np.sqrt(np.average((bin_values - average) ** 2, weights=c)) 547 | except ZeroDivisionError: 548 | std = np.nan 549 | summary += f" {std: .2e}" + " " * (w - 10) 550 | summary += "\n" 551 | 552 | return summary 553 | 554 | 555 | def get_plottable_protocol_bin_edges(axis): 556 | """Get histogram bin edges from PlottableAxis. 557 | 558 | Borrowed from ``mplhep.utils``. 559 | 560 | """ 561 | 562 | out = np.empty(len(axis) + 1) 563 | assert isinstance(axis[0], tuple), ( 564 | f"Currently only support non-discrete axes {axis}" 565 | ) 566 | # TODO: Support discreete axes 567 | out[0] = axis[0][0] 568 | out[1:] = [axis[i][1] for i in range(len(axis))] 569 | return out 570 | 571 | 572 | def get_count_edges(hist): 573 | """Get bin contents and edges from a compatible histogram.""" 574 | 575 | # Support sequence of histograms 576 | if isinstance(hist, Sequence) and isinstance(hist[0], PlottableHistogram): 577 | count = np.stack([h.values() for h in hist]) 578 | edges = get_plottable_protocol_bin_edges(hist[0].axes[0]) 579 | for other_edges in ( 580 | get_plottable_protocol_bin_edges(h.axes[0]) for h in hist[1:] 581 | ): 582 | np.testing.assert_allclose(edges, other_edges) 583 | 584 | else: 585 | # Single histogram or (a,b,c, edges) tuple: 586 | # Make sure we have a PlottableProtocol histogram 587 | hist = ensure_plottable_histogram(hist) 588 | 589 | # Use the PlottableProtocol 590 | count = hist.values() 591 | edges = get_plottable_protocol_bin_edges(hist.axes[0]) 592 | 593 | return count, edges 594 | 595 | 596 | def print_hist(hist, file=None, **kwargs): 597 | """Plot the output of ``numpy.histogram`` to the console. 598 | 599 | Parameters 600 | ---------- 601 | 602 | file : optional 603 | File like object to print to. 604 | **kwargs : 605 | Additional keyword arguments are passed to the `HistFormatter`. 606 | 607 | """ 608 | if file is None: 609 | file = sys.stdout 610 | count, edges = get_count_edges(hist) 611 | hist_formatter = HistFormatter(edges, **kwargs) 612 | file.write(hist_formatter.format_histogram(count)) 613 | file.flush() 614 | 615 | 616 | def text_hist(*args, density=None, **kwargs): 617 | """Thin wrapper around ``numpy.histogram``.""" 618 | 619 | print_kwargs = { 620 | "file": kwargs.pop("file", sys.stdout), 621 | "title": kwargs.pop("title", ""), 622 | "stack": kwargs.pop("stack", False), 623 | "symbols": kwargs.pop("symbols", DEFAULT_SYMBOLS), 624 | "fg_colors": kwargs.pop("fg_colors", DEFAULT_FG_COLORS), 625 | "bg_colors": kwargs.pop("bg_colors", DEFAULT_BG_COLORS), 626 | "count_area": kwargs.pop("count_area", True), 627 | } 628 | if density: 629 | print_kwargs["count_area"] = False 630 | kwargs["density"] = density 631 | hist = np.histogram(*args, **kwargs) 632 | print_hist(hist, **print_kwargs) 633 | return hist 634 | -------------------------------------------------------------------------------- /histoprint/rich.py: -------------------------------------------------------------------------------- 1 | """Module montaining classes that support the `rich` console protocol.""" 2 | 3 | from rich.text import Text 4 | 5 | from histoprint import formatter 6 | 7 | __all__ = ["RichHistogram"] 8 | 9 | 10 | class RichHistogram: 11 | """Histogram object that supports `Rich`'s `Console Protocol`. 12 | 13 | Ths provided `hist` is kept as a reference, so it is possible to update its 14 | contents after the creation of the RichHistogram. 15 | 16 | Parameters 17 | ---------- 18 | 19 | hist : 20 | A compatible histogram type. 21 | **kwargs : optional 22 | Additional keyword arguments are passed to the `HistFormatter`. 23 | 24 | """ 25 | 26 | def __init__(self, hist, **kwargs): 27 | self.hist = hist 28 | self.kwargs = kwargs 29 | 30 | def __rich__(self): 31 | """Output rich formatted histogram.""" 32 | 33 | count, edges = formatter.get_count_edges(self.hist) 34 | hist_formatter = formatter.HistFormatter(edges, **self.kwargs) 35 | 36 | text = Text.from_ansi(hist_formatter.format_histogram(count)) 37 | 38 | # Make sure lines are never wrapped or right-justified 39 | text.justify = "left" 40 | text.overflow = "ellipsis" 41 | text.no_wrap = True 42 | 43 | return text 44 | -------------------------------------------------------------------------------- /histoprint/version.pyi: -------------------------------------------------------------------------------- 1 | version: str 2 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import nox 4 | 5 | nox.needs_version = ">=2024.4.15" 6 | nox.options.default_venv_backend = "uv|virtualenv" 7 | 8 | ALL_PYTHONS = [ 9 | c.split()[-1] 10 | for c in nox.project.load_toml("pyproject.toml")["project"]["classifiers"] 11 | if c.startswith("Programming Language :: Python :: 3.") 12 | ] 13 | 14 | 15 | @nox.session 16 | def lint(session): 17 | """ 18 | Run the linter. 19 | """ 20 | session.install("pre-commit") 21 | session.run("pre-commit", "run", "--all-files", *session.posargs) 22 | 23 | 24 | @nox.session(python=ALL_PYTHONS) 25 | def tests(session): 26 | """ 27 | Run the unit and regular tests. 28 | """ 29 | session.install(".[test,boost,uproot,rich]") 30 | session.run("pytest", "-s", *session.posargs) 31 | 32 | 33 | @nox.session(default=False) 34 | def build(session): 35 | """ 36 | Build an SDist and wheel. 37 | """ 38 | 39 | session.install("build") 40 | session.run("python", "-m", "build") 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "histoprint" 7 | dynamic = ["version"] 8 | description = "Pretty print of NumPy (and other) histograms to the console" 9 | readme = "README.rst" 10 | license = "MIT" 11 | requires-python = ">=3.8" 12 | authors = [ 13 | { name = "Lukas Koch", email = "lukas.koch@mailbox.org" }, 14 | ] 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Intended Audience :: Developers", 18 | "Intended Audience :: Science/Research", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Topic :: Scientific/Engineering", 29 | ] 30 | dependencies = [ 31 | "click>=7.0.0", 32 | "numpy", 33 | "uhi>=0.2.1", 34 | ] 35 | 36 | [project.optional-dependencies] 37 | boost = [ 38 | "boost-histogram>=1.1", 39 | ] 40 | rich = [ 41 | "rich>=12", 42 | ] 43 | test = [ 44 | "pytest>=6.0", 45 | ] 46 | uproot = [ 47 | "awkward>=1", 48 | "uproot>=4", 49 | ] 50 | 51 | [project.scripts] 52 | histoprint = "histoprint.cli:histoprint" 53 | 54 | [project.urls] 55 | Homepage = "https://github.com/scikit-hep/histoprint" 56 | 57 | 58 | [tool.hatch] 59 | version.source = "vcs" 60 | build.hooks.vcs.version-file = "histoprint/version.py" 61 | 62 | 63 | [tool.setuptools_scm] 64 | write_to = "histoprint/version.py" 65 | 66 | 67 | [tool.uv] 68 | dev-dependencies = ["histoprint[boost,rich,test,uproot]", "pandas"] 69 | environments = ["python_version >= '3.11'"] 70 | 71 | 72 | [tool.pytest.ini_options] 73 | minversion = "6.0" 74 | addopts = "-ra --strict-markers --showlocals --color=yes" 75 | testpaths = [ 76 | "tests", 77 | ] 78 | 79 | 80 | [tool.mypy] 81 | files = ["histoprint"] 82 | python_version = "3.8" 83 | show_error_codes = true 84 | enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] 85 | warn_unused_configs = true 86 | check_untyped_defs = true 87 | 88 | [[tool.mypy.overrides]] 89 | module = [ 90 | "pandas", 91 | "uproot", 92 | "awkward", 93 | ] 94 | ignore_missing_imports = true 95 | 96 | 97 | [tool.ruff] 98 | lint.extend-select = [ 99 | "B", # flake8-bugbear 100 | "I", # isort 101 | "ARG", # flake8-unused-arguments 102 | "C4", # flake8-comprehensions 103 | "EM", # flake8-errmsg 104 | "ICN", # flake8-import-conventions 105 | "ISC", # flake8-implicit-str-concat 106 | "G", # flake8-logging-format 107 | "PGH", # pygrep-hooks 108 | "PIE", # flake8-pie 109 | "PL", # pylint 110 | "PT", # flake8-pytest-style 111 | "PTH", # flake8-use-pathlib 112 | "RET", # flake8-return 113 | "RUF", # Ruff-specific 114 | "SIM", # flake8-simplify 115 | "T20", # flake8-print 116 | "UP", # pyupgrade 117 | "YTT", # flake8-2020 118 | "EXE", # flake8-executable 119 | "NPY", # NumPy specific rules 120 | "PD", # pandas-vet 121 | ] 122 | lint.ignore = [ 123 | "PLR09", # Too many x 124 | "PLR2004", # Magic value in comparison 125 | "ISC001", # Conflicts with formatter 126 | ] 127 | 128 | [tool.ruff.lint.per-file-ignores] 129 | "tests/**" = ["T20", "NPY002"] 130 | -------------------------------------------------------------------------------- /tests/data/1D.txt: -------------------------------------------------------------------------------- 1 | 1 2 | 2 3 | 2 4 | 3 5 | 3 6 | 3 7 | 4 8 | 4 9 | 5 10 | -------------------------------------------------------------------------------- /tests/data/2D.txt: -------------------------------------------------------------------------------- 1 | 1 4 2 | 2 5 3 | 2 5 4 | 3 6 5 | 3 6 6 | 3 6 7 | 4 7 8 | 4 7 9 | 5 8 10 | -------------------------------------------------------------------------------- /tests/data/3D.csv: -------------------------------------------------------------------------------- 1 | x,y,z 2 | 1,4,7 3 | 2,5,8 4 | 2,5,8 5 | 3,6,9 6 | 3,nan,9 7 | 3,6,9 8 | 4,7,10 9 | 4,7,10 10 | 5,8,11 11 | -------------------------------------------------------------------------------- /tests/data/3D.txt: -------------------------------------------------------------------------------- 1 | 1 4 7 2 | 2 5 8 3 | 2 5 8 4 | 3 6 9 5 | 3 nan 9 6 | 3 6 9 7 | 4 7 10 8 | 4 7 10 9 | 5 8 11 10 | -------------------------------------------------------------------------------- /tests/data/histograms.root: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scikit-hep/histoprint/2ba91a37e7a54112d6ac6aefd120090bd0edcc34/tests/data/histograms.root -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import io 3 | 4 | import numpy as np 5 | import pytest 6 | from uhi.numpy_plottable import ensure_plottable_histogram 7 | 8 | import histoprint as hp 9 | 10 | 11 | def test_hist(): 12 | """Poor man's unit tests.""" 13 | 14 | A = np.random.randn(1000) - 2 15 | B = np.random.randn(1000) 16 | C = np.random.randn(1000) + 2 17 | D = np.random.randn(500) * 2 18 | 19 | hp.text_hist(B) 20 | hp.text_hist( 21 | B, bins=[-5, -3, -2, -1, -0.5, 0, 0.5, 1, 2, 3, 5], title="Variable bin widths" 22 | ) 23 | 24 | histA = np.histogram(A, bins=15, range=(-5, 5)) 25 | histB = np.histogram(B, bins=15, range=(-5, 5)) 26 | histC = np.histogram(C, bins=15, range=(-5, 5)) 27 | histD = np.histogram(D, bins=15, range=(-5, 5)) 28 | histAll = ([histA[0], histB[0], histC[0], histD[0]], histA[1]) 29 | 30 | hp.print_hist(histAll, title="Overlays", labels="ABCDE") 31 | hp.print_hist( 32 | histAll, 33 | title="Stacks", 34 | stack=True, 35 | symbols=" ", 36 | bg_colors="rgbcmy", 37 | labels="ABCDE", 38 | max_count=600.0, 39 | ) 40 | hp.print_hist( 41 | histAll, 42 | title="Summaries", 43 | symbols=r"=|\/", 44 | use_color=False, 45 | labels=["AAAAAAAAAAAAAAAA", "B", "CCCCCCCCCCCCC", "D"], 46 | summary=True, 47 | ) 48 | hp.print_hist( 49 | (histAll[0][:3], histAll[1]), 50 | title="No composition", 51 | labels=["A", "B", "C"], 52 | ) 53 | 54 | 55 | def test_width(): 56 | """Test output width.""" 57 | hist = np.histogram(np.random.randn(100) / 1000) 58 | f = io.StringIO() 59 | hp.print_hist(hist, file=f, columns=30, use_color=False) 60 | f.flush() 61 | f.seek(0) 62 | n_max = 0 63 | for line in f: 64 | print(line, end="") 65 | n = len(line.rstrip()) 66 | n_max = np.max((n, n_max)) 67 | assert n_max == 30 68 | 69 | hist = (np.array((100.5, 17.5)), np.array((0, 1, 2))) 70 | f = io.StringIO() 71 | hp.print_hist(hist, file=f, columns=30, use_color=False) 72 | f.flush() 73 | f.seek(0) 74 | n_max = 0 75 | for line in f: 76 | print(line, end="") 77 | n = len(line.rstrip()) 78 | n_max = np.max((n, n_max)) 79 | assert n_max == 30 80 | 81 | 82 | def test_nan(): 83 | """Test dealing with nan values.""" 84 | 85 | data = np.arange(7, dtype=float) 86 | data[5] = np.nan 87 | bins = np.arange(8) 88 | hp.print_hist((data, bins)) 89 | 90 | 91 | def test_boost(): 92 | """Test boost-histogram if it is available.""" 93 | 94 | bh = pytest.importorskip("boost_histogram") 95 | 96 | hist = bh.Histogram(bh.axis.Regular(20, -3, 3)) 97 | hist.fill(np.random.randn(1000)) 98 | hp.print_hist(hist, title="Boost Histogram") 99 | 100 | 101 | def test_uproot(): 102 | """Test uproot histograms if it is available.""" 103 | 104 | pytest.importorskip("awkward") 105 | uproot = pytest.importorskip("uproot") 106 | 107 | with uproot.open("tests/data/histograms.root") as F: 108 | hist = F["one"] 109 | 110 | with contextlib.suppress(Exception): 111 | # Works with uproot 3 112 | hist.show() 113 | hp.print_hist(hist, title="uproot TH1") 114 | 115 | 116 | def test_stack(): 117 | A = np.random.randn(1000) - 2 118 | B = np.random.randn(1000) 119 | C = np.random.randn(1000) + 2 120 | D = np.random.randn(500) * 2 121 | 122 | hp.text_hist(B) 123 | hp.text_hist( 124 | B, bins=[-5, -3, -2, -1, -0.5, 0, 0.5, 1, 2, 3, 5], title="Variable bin widths" 125 | ) 126 | 127 | histA = np.histogram(A, bins=15, range=(-5, 5)) 128 | histB = np.histogram(B, bins=15, range=(-5, 5)) 129 | histC = np.histogram(C, bins=15, range=(-5, 5)) 130 | histD = np.histogram(D, bins=15, range=(-5, 5)) 131 | histAll = ([histA[0], histB[0], histC[0], histD[0]], histA[1]) 132 | 133 | hA = ensure_plottable_histogram(histA) 134 | hB = ensure_plottable_histogram(histB) 135 | hC = ensure_plottable_histogram(histC) 136 | hD = ensure_plottable_histogram(histD) 137 | 138 | hist_stack = [hA, hB, hC, hD] 139 | 140 | out1 = io.StringIO() 141 | out2 = io.StringIO() 142 | 143 | hp.print_hist(histAll, file=out1, title="Overlays", labels="ABCD") 144 | hp.print_hist(hist_stack, file=out2, title="Overlays", labels="ABCD") 145 | 146 | assert out1.getvalue() == out2.getvalue() 147 | 148 | 149 | def test_rich_histogram(): 150 | """Test updating the values of a histogram object.""" 151 | rich = pytest.importorskip("rich") 152 | 153 | from histoprint.rich import RichHistogram 154 | 155 | A = np.random.randn(1000) - 2 156 | B = np.random.randn(1000) + 2 157 | 158 | hA = np.histogram(A, bins=np.linspace(-5, 5, 11)) 159 | hB = np.histogram(B, bins=hA[1]) 160 | 161 | hist = RichHistogram(hA, columns=30) 162 | 163 | rich.print(hist) 164 | 165 | # Update values 166 | count = hA[0] 167 | count += hB[0] 168 | 169 | rich.print(hist) 170 | 171 | from rich.table import Table 172 | 173 | tab = Table(title="Test table") 174 | tab.add_column("left justify", justify="left", width=29) 175 | tab.add_column("center justify", justify="center", width=35) 176 | tab.add_column("right justify", justify="right", width=35) 177 | 178 | from rich.align import Align 179 | 180 | tab.add_row(hist, Align.center(hist), Align.right(hist)) 181 | 182 | rich.print(tab) 183 | --------------------------------------------------------------------------------