Created
November 23, 2022 20:48
-
-
Save jarulsamy/4d9baefa59f451a15d2ca8f5efd9a576 to your computer and use it in GitHub Desktop.
Plot powder diffraction data directly to an Excel document.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
MIT License | |
Copyright (c) 2022 Joshua Arulsamy | |
Permission is hereby granted, free of charge, to any person obtaining a copy | |
of this software and associated documentation files (the "Software"), to deal | |
in the Software without restriction, including without limitation the rights | |
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
copies of the Software, and to permit persons to whom the Software is | |
furnished to do so, subject to the following conditions: | |
The above copyright notice and this permission notice shall be included in all | |
copies or substantial portions of the Software. | |
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
SOFTWARE. | |
Plot powder diffraction data directly to an Excel document. | |
Author: Joshua Arulsamy | |
Requirements: | |
matplotlib ~= 3.6.2 | |
pandas ~= 1.5.1 | |
xlsxwriter ~= 3.0.3 | |
Changelog: | |
2022-11-18: Initial version with basic plotting support. | |
2022-11-23: Add logging and xdg-open support. | |
""" | |
import argparse | |
import csv | |
import logging | |
import math | |
import os | |
import platform | |
import subprocess | |
import sys | |
from datetime import datetime | |
from pathlib import Path | |
from typing import Optional | |
import matplotlib.pyplot as plt | |
import pandas as pd | |
import xlsxwriter | |
from pandas.errors import ParserError | |
# Setup logger | |
logging.basicConfig( | |
format="%(asctime)s %(name)-16s %(levelname)-8s %(threadName)s %(message)s" | |
) | |
logger = logging.getLogger("Plot") | |
logger.setLevel(logging.WARNING) | |
logger.debug("Initialized") | |
# fmt: off | |
class Color: | |
"""ANSI Keycodes for colored terminal output.""" | |
RESET = "\033[0m" | |
BOLD = "\033[01m" | |
DISABLE = "\033[02m" | |
UNDERLINE = "\033[04m" | |
BLINK1 = "\033[05m" | |
BLINK2 = "\033[06m" | |
REVERSE = "\033[07m" | |
STRIKETHROUGH = "\033[09m" | |
INVISIBLE = "\033[08m" | |
class FG: | |
BLACK = "\033[30m" | |
BLUE = "\033[34m" | |
CYAN = "\033[36m" | |
DARKGREY = "\033[90m" | |
GREEN = "\033[32m" | |
LIGHTBLUE = "\033[94m" | |
LIGHTCYAN = "\033[96m" | |
LIGHTGREEN = "\033[92m" | |
LIGHTGREY = "\033[37m" | |
LIGHTRED = "\033[91m" | |
ORANGE = "\033[33m" | |
PINK = "\033[95m" | |
PURPLE = "\033[35m" | |
RED = "\033[31m" | |
WHITE = '\033[37m' | |
YELLOW = "\033[93m" | |
class BG: | |
BLACK = "\033[40m" | |
BLUE = "\033[44m" | |
CYAN = "\033[46m" | |
GREEN = "\033[42m" | |
LIGHTGREY = "\033[47m" | |
ORANGE = "\033[43m" | |
PURPLE = "\033[45m" | |
RED = "\033[41m" | |
# fmt: on | |
def colored(s: str, fg: Color, bg: Color, blink=False) -> str: | |
"""Color a string for terminal output. | |
:param fg: Foreground color. | |
:param bg: background color. | |
:param blink: Blink the text. | |
:return: String with ANSI escapes for colored output. | |
""" | |
if blink: | |
return f"{Color.BLINK1}{fg}{bg}{Color.BLINK2}{s}{Color.RESET}" | |
return f"{fg}{bg}{s}{Color.RESET}" | |
def open_file_default_app(filename: Path) -> None: | |
"""Open a file with the default application based on MIMETYPE. | |
:param filename: Path to file to open. | |
""" | |
if platform.system() == "Windows": | |
os.startfile(str(filename.absolute())) | |
else: | |
subprocess.call(("xdg-open", str(filename.absolute()))) | |
def generate_excel_notebook( | |
infile: Path, outfile: Path, base_name: Optional[str] = None | |
) -> None: | |
"""Generate an Excel notebook from the powder diffraction plot CSV. | |
:param infile: Path to input powder diffraction CSV. | |
:param outfile: Path to output XLSX document. | |
:param base_name: Name to derive plot title from. Default: Current datetime. | |
""" | |
if base_name is None: | |
base_name = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") | |
# Read the CSV from disk into data frame | |
try: | |
df = pd.read_csv(infile, sep=r"\s+", names=["x", "y"]) | |
except ParserError as e: | |
logging.error( | |
colored( | |
f"Can't parse input text file '{infile}'", | |
Color.FG.WHITE, | |
Color.BG.RED, | |
blink=True, | |
) | |
) | |
raise e from e | |
n = len(df["x"]) | |
# Setup the workbook | |
wb = xlsxwriter.Workbook(outfile) | |
ws = wb.add_worksheet() | |
# Write the data to the sheet | |
ws.write_column("A1", df["x"]) | |
ws.write_column("B1", df["y"]) | |
# Create the chart | |
chart = wb.add_chart({"type": "scatter", "subtype": "smooth"}) | |
# Add the data | |
chart.add_series( | |
{ | |
"categories": f"=Sheet1!$A$1:$A${n}", | |
"values": f"=Sheet1!$B$1:$B${n}", | |
"line": {"color": "red", "width": 0.5}, | |
} | |
) | |
chart.set_title( | |
{ | |
"name": base_name, | |
"name_font": {"size": 14, "bold": False}, | |
} | |
) | |
# Set some axis parameters | |
chart.set_x_axis( | |
{ | |
"name": "2Theta", | |
"name_font": {"size": 9, "bold": False}, | |
"num_font": {"size": 9, "bold": False}, | |
"line": {"color": "black"}, | |
"min": math.floor(df["x"].min()), | |
"max": math.ceil(df["x"].max()), | |
"major_tick_mark": "inside", | |
"minor_tick_mark": "inside", | |
"major_gridlines": {"visible": False}, | |
"minor_gridlines": {"visible": False}, | |
} | |
) | |
chart.set_y_axis( | |
{ | |
"name": "Counts", | |
"name_font": {"size": 9, "bold": False}, | |
"num_font": {"size": 9, "bold": False}, | |
"line": {"color": "black"}, | |
"min": round(math.floor(df["y"].min()), -2), | |
"max": round(math.ceil(df["y"].max()), -2), | |
"major_tick_mark": "inside", | |
"minor_tick_mark": "inside", | |
"major_gridlines": {"visible": False}, | |
"minor_gridlines": {"visible": False}, | |
} | |
) | |
# Adjust size | |
DPI = 96 | |
chart.set_size({"height": 6 * DPI, "width": 8 * DPI}) | |
# Remove the legend | |
chart.set_legend({"none": True}) | |
ws.insert_chart("D2", chart) | |
wb.close() | |
def gui() -> None: | |
"""Prompt the user for input file and generate notebook.""" | |
# Started without arguments, start the GUI. | |
from tkinter import Tk | |
from tkinter.filedialog import askopenfilename | |
Tk().withdraw() | |
# Locate default directory to prompt | |
initial_dir = Path("C:\\frames\\guest") | |
if not initial_dir.is_dir(): | |
initial_dir = Path.home() | |
filename = askopenfilename( | |
initialdir=str(initial_dir), filetypes=[("Text File", "*.txt")] | |
) | |
if len(filename) <= 2: | |
raise FileNotFoundError(f"Invalid filename: '{filename}'") | |
infile = Path(filename) | |
base_name = str(infile.stem) | |
outfile = infile.parent / Path(f"{base_name}.xlsx") | |
# Find a unique filename | |
fn = 1 | |
while outfile.exists(): | |
name = outfile.stem | |
if "_" in name: | |
name = name.split("_")[0] | |
outfile = outfile.parent / f"{name}_{fn}.xlsx" | |
fn += 1 | |
generate_excel_notebook(infile, outfile, base_name) | |
open_file_default_app(outfile) | |
def main(argv: list[str]) -> int: | |
if len(sys.argv) > 1: | |
# Started with arguments, skip the GUI. | |
parser = argparse.ArgumentParser() | |
parser.add_argument("INFILE", nargs="+") | |
parser.add_argument( | |
"-t", | |
"--title", | |
action="store", | |
metavar="TITLE", | |
help="Specify the name of the chart." | |
"Default: Infer the name based on the input filename", | |
) | |
args = parser.parse_args() | |
args = vars(args) | |
# Generate filenames based on some patterns | |
for i in args["INFILE"]: | |
infile = Path(i) | |
base_name = str(infile.name) | |
if "_" in base_name: | |
base_name = base_name.split("_")[0] | |
else: | |
base_name = str(infile.stem) | |
outfile = infile.parent / Path(f"{base_name}.xlsx") | |
# Find a unique filename | |
fn = 1 | |
while outfile.exists(): | |
name = outfile.stem | |
if "_" in name: | |
name = name.split("_")[0] | |
outfile = outfile.parent / f"{name}_{fn}.xlsx" | |
fn += 1 | |
logger.debug("%s -> %s", infile, outfile) | |
generate_excel_notebook(infile, outfile, base_name) | |
open_file_default_app(outfile) | |
else: | |
gui() | |
if __name__ == "__main__": | |
try: | |
main(sys.argv) | |
except ParserError as e: | |
logging.error(e, exc_info=True) | |
input("Press Enter to continue...") | |
except Exception as e: | |
logging.error(e, exc_info=True) | |
print(colored("Unknown Error!", Color.FG.WHITE, Color.BG.RED, blink=True)) | |
input("Press Enter to continue...") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment