Skip to content

Instantly share code, notes, and snippets.

@jarulsamy
Created November 23, 2022 20:48
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jarulsamy/4d9baefa59f451a15d2ca8f5efd9a576 to your computer and use it in GitHub Desktop.
Save jarulsamy/4d9baefa59f451a15d2ca8f5efd9a576 to your computer and use it in GitHub Desktop.
Plot powder diffraction data directly to an Excel document.
"""
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