Decoupling Business Logic with Python and Streamlit: A Metallurgical Calculator Case Study
Introduction
Ever found yourself wrestling with a monolithic frontend script where UI presentation and core business logic are hopelessly entangled? This common scenario makes maintenance, testing, and future development a nightmare. In the metallurgy-smart-calcs project, we faced just such a challenge with an existing JavaScript-based metallurgical calculator.
The motivation was clear: disentangle the core calculation logic from its presentation layer. This post details our approach to porting the calculator's intricate logic to Python, encapsulating it in pure, typed functions, introducing structured output models, and building a simple, yet effective, Streamlit user interface for validation and quick deployment. This strategic move paves the way for a more robust, testable, and future-proof backend service.
Prerequisites
- Basic understanding of Python and its data structures.
- Familiarity with the
streamlitlibrary for building simple UIs. - A grasp of functional programming concepts, particularly pure functions.
Step 1: Translating Core Calculation Logic to Python
The first critical step involved meticulously translating the existing JavaScript calculations into Python. The goal was to create pure functions – functions that, given the same inputs, will always produce the same outputs without causing side effects. This significantly enhances testability and predictability.
We implemented core functions such as cobre_hidrometalurgia (copper hydrometallurgy), cobre_pirometalurgia (copper pyrometallurgy), produccion_caliza (limestone production), produccion_sal (salt production), and utility conversions like porcentaje_a_ppm (percentage to ppm) and ppm_a_porcentaje (ppm to percentage).
# Example of a basic conversion function
def porcentaje_a_ppm(porcentaje: float) -> float:
"""Converts a percentage to parts per million (ppm)."""
# A simple validation could be applied here as well
if not (0 <= porcentaje <= 100):
raise ValueError("Percentage must be between 0 and 100.")
return porcentaje * 10000
# Illustrative complex calculation (simplified for example)
def cobre_hidrometalurgia(
sulfato_cobre_kg: float,
pureza_cobre_sulfato_porc: float,
recuperacion_porc: float
) -> float:
"""Calculates copper production from hydrometallurgy process."""
if not all(valor >= 0 for valor in [sulfato_cobre_kg, pureza_cobre_sulfato_porc, recuperacion_porc]):
raise ValueError("All input values must be non-negative.")
# Translated complex logic goes here
cobre_puro_kg = sulfato_cobre_kg * (pureza_cobre_sulfato_porc / 100)
produccion_final_kg = cobre_puro_kg * (recuperacion_porc / 100)
return produccion_final_kg / 1000 # Convert kg to tons for result
Step 2: Structuring Outputs and Validations
To ensure clear, consistent, and type-safe data handling, we introduced @dataclass for output models. This allows us to define the structure of calculation results explicitly, making them easier to consume and understand. For instance, ResultadoHidro, ResultadoPiro, and ResultadoSimple provide distinct structures for different calculation outcomes.
Furthermore, reusable validation functions like validar_positivo and validar_no_negativo were implemented. These functions enforce basic business rules at the entry point of our calculation logic, preventing invalid data from corrupting results.
from dataclasses import dataclass
@dataclass
class ResultadoHidro:
produccion_cobre_ton: float
# Potentially other fields like impurities, cost, etc.
@dataclass
class ResultadoSimple:
valor_calculado: float
def validar_positivo(valor: float) -> bool:
"""Validates that a value is strictly positive."""
return valor > 0
def validar_no_negativo(valor: float) -> bool:
"""Validates that a value is not negative."""
return valor >= 0
Step 3: Building the Streamlit Interface
With the core logic and data models in place, a minimal Streamlit UI was developed. Streamlit is an excellent tool for rapidly creating interactive web applications with pure Python, ideal for testing, demonstrations, or internal tools. Our ui_streamlit function mirrors the original web form's user experience, providing selectors for elements and processes, dynamic input fields, and calculation/conversion buttons.
This immediate feedback loop allowed for quick manual verification against the original calculator's behavior, ensuring functional parity as a starting point for further refactorization.
import streamlit as st
def ui_streamlit():
st.title("Metallurgical Calculator")
st.header("Production Calculations")
elemento = st.selectbox("Select Element", ["Copper", "Limestone", "Salt"])
proceso = st.selectbox("Select Process", ["Hydrometallurgy", "Pyrometallurgy"])
# Dynamic input fields based on selection
if elemento == "Copper" and proceso == "Hydrometallurgy":
st.subheader("Copper Hydrometallurgy")
sulfato_cobre = st.number_input("Copper Sulfate (kg)", min_value=0.0, value=100.0)
pureza = st.number_input("Purity of Sulfate (%)", min_value=0.0, max_value=100.0, value=98.0)
recuperacion = st.number_input("Recovery (%)", min_value=0.0, max_value=100.0, value=95.0)
if st.button("Calculate Copper Production"):
try:
# Using our defined calculation function
result = cobre_hidrometalurgia(sulfato_cobre, pureza, recuperacion)
st.success(f"Calculated Copper Production: {result:.2f} tons")
except ValueError as e:
st.error(f"Input Error: {e}")
st.header("Conversion Tools")
conversion_type = st.radio("Choose Conversion", ["% to ppm", "ppm to %"])
value_to_convert = st.number_input("Value to convert", min_value=0.0, value=10.0, key="conv_val")
if st.button("Perform Conversion"):
try:
if conversion_type == "% to ppm":
converted_value = porcentaje_a_ppm(value_to_convert)
st.info(f"{value_to_convert}% is equal to {converted_value:.2f} ppm")
else:
# Assuming a ppm_a_porcentaje function exists
converted_value = value_to_convert / 10000
st.info(f"{value_to_convert} ppm is equal to {converted_value:.4f}%")
except ValueError as e:
st.error(f"Conversion Error: {e}")
if __name__ == "__main__":
ui_streamlit()
Results
This refactoring effort successfully achieved a clear separation of concerns. The core metallurgical calculation logic now resides in pure Python functions, making it highly testable, reusable, and independent of any presentation framework. The @dataclass output models provide clear, structured results, while the Streamlit UI offers an intuitive and easily deployable interface for interacting with and validating the new backend logic.
This foundational work transforms a difficult-to-maintain monolithic script into a modular system ready for further evolution, whether into a dedicated microservice or integration into a larger application.
Next Steps
To build upon this solid foundation, the immediate next step is to implement comprehensive unit tests for all core calculation and validation functions using a framework like pytest. Additionally, integrating these tests into a Continuous Integration (CI) pipeline will automate verification and maintain code quality. Long-term, this Python logic can evolve into a robust backend service, consumable by various frontend applications, further leveraging the benefits of decoupled architecture.
Generated with Gitvlg.com