From 5c25a3342996c73f3a56e6c67e0f4494c06dfbd0 Mon Sep 17 00:00:00 2001
From: AndrewTrepagnier
Date: Mon, 18 Aug 2025 19:14:35 -0500
Subject: [PATCH 01/10] wip: initial attempt at new report generator
---
.DS_Store | Bin 6148 -> 8196 bytes
tmin/core.py | 282 ++++++------------------
tmin/report_generator.py | 234 ++++++++++++++++----
tmin/report_generator.sublime-workspace | 172 +++++++++++++++
4 files changed, 431 insertions(+), 257 deletions(-)
create mode 100644 tmin/report_generator.sublime-workspace
diff --git a/.DS_Store b/.DS_Store
index afe3b0d89c96ba4f45e2bd35c70aa75ae890883a..e0d12c7f69a5a843acfbf29e601e9948f8ad65f7 100644
GIT binary patch
literal 8196
zcmeHMTWl0n7(U;0OJ@dZPs^?B1lZ6>u@os)ih#oQ1|?vM-IkUDuDd$}OqkBpo!MQG
z5F3dP5^wRv2Nf@`MiU>r5jE-yyo!yH2Tjz(7fnPTjfwte&McwZ^5TObGAEhypYvbN
zIsbpYf6ks+#u)la<`%|U8DlCvg7U>wEl`A9JoAbYaq5YJ>=`Svfcg1~Kb?2xX~&E>
z0&xW52*eSHBM?X6c8CC-*`lboIQOMAuHy*A5x65GAl?rtdIU{JbVkzf)j?Hw1t6Lr
zc37xQ_khqQ714A=XCw_xX-s*0KxB&OiUDa(@{~{~nvUpyyS=-Mnp=C$ozv9iZJT$FWKTI$uJ?@e1JS#HHP-lb
zVZKw2U+&L4!l}AdRj0nKI;H80jj==eM3%RdY30Myf$h2eA*<*U&GIInJlt3C+=E3+
z$j1wwQ<`%9EN?F4oI=UT1y--)Wb7v_e2ck9Ud&QtXRLpW-@~shz^z5=VK-&I<
zO~VPbWi?BO_U@A9tJkgH*tK=XuKvCIhi4n_YU0f;x;{x^as&IgZ578Vwi%o}obwBo
zYufIK@v`mPdB+-cb5m9`IyG&!amC8kRY}9RmFh$(Z$DnLgEPEMRl_;ftTptbCN1t^
z&lIzm{C$5!3&-K1gbGgM44%RT
zJd5Y>JYL2dcoT2oZCt`9_!OVvGOprl%;G!zhU@qpe<}&3URk6xDO_n$mMZI&cBM;s
zNZBRVtyI(I@LcREd#PXZE}*obZ+RkZlwhH>Ne=GOhi;3sxwb?m%F<=a*R*Ze)ZMdl
z_YFWv1Xe+JGn7O8&e`7(zhNTPU~g7tl)k!}ZMCU2M!>gDt0Mn+gVavx>r?_hub17s
z^>&pY&lkyVN3v6;B;!ih)i)=%Xp~C4PIfzzTQ!wKb{^1oXp~euA-jgYOVcZ&
zh%9G{uH(5UNFs2YpfAo-%w}#4f9^nWZ=nd(NoK3K{}0~!`~O0wF)xll9D)By1dtfW
z4D^vR)gK16eksIA(eoHRq6obiNkbE=go@yY*KwjZ{xGC=Dw3rmIwL7bsQl+20^Iog
PpZAM3KL0~3-bvLz7)dts
delta 151
zcmZp1XfcprU|?W$DortDU=RQ@Ie-{MGpJ516t-jpGFTW&7;+gh8S=`4i}G^v^U{H0
zAaN!j1_2Nohy@ssc+zF%#- str
return f"{year}-{month_str}"
- # Old functions commented out for clarity and reference
-
-
- # def percent_RL(self, actual_thickness):
- # """
- # Calculate percentage of retirement limit (Table 5) remaining
- # RL = actual thickness / retirement limit from Table 5
- # """
- # RL = self.table5_1_RL.get(self.nps)
- # if RL is None:
- # raise ValueError(f"No retirement limit available for NPS {self.nps}")
- # percent_remaining = (actual_thickness / RL) * 100
- # return percent_remaining
-
- # def check_RL_status(self, actual_thickness):
- # """
- # Check if pipe meets retirement limit requirements
- # Returns status and percentage remaining
- # """
- # RL = self.table5_1_RL.get(self.nps)
- # if RL is None:
- # return {"status": "No RL data", "percent_remaining": None}
- # percent_remaining = (actual_thickness / RL) * 100
- # if actual_thickness >= RL:
- # status = "Above RL"
- # else:
- # status = "Below RL - Consider Retirement"
- # return {
- # "status": status,
- # "retirement_limit": RL,
- # "actual_thickness": actual_thickness,
- # "percent_remaining": percent_remaining
- # }
-
- # def compare_thickness(self, actual_thickness, temp_f=1000, joint_type='Seamless'):
- # """
- # Comprehensive thickness analysis comparing actual vs calculated t-min and RL
- # Returns comparison metrics for both pressure design and retirement limit
- # """
- # # Calculate pressure design requirements
- # tmin = self.calculate_tmin_pressure(temp_f, joint_type)
- # tmin_excess = actual_thickness - tmin
- # tmin_percent_excess = (tmin_excess / actual_thickness) * 100
- # # Check retirement limit
- # rl_status = self.check_RL_status(actual_thickness)
- # return {
- # 'actual_thickness': actual_thickness,
- # 'calculated_tmin': tmin,
- # 'tmin_excess': tmin_excess,
- # 'tmin_percent_excess': tmin_percent_excess,
- # 'rl_status': rl_status['status'],
- # 'retirement_limit': rl_status['retirement_limit'],
- # 'rl_percent_remaining': rl_status['percent_remaining'],
- # 'pressure_design_adequate': tmin_excess > 0,
- # 'rl_adequate': actual_thickness >= rl_status['retirement_limit'] if rl_status['retirement_limit'] else None
- # }
- #####################################################################################
- # ANALYSIS
- ####################################################################################
-
- # def analysis(self, measured_thickness: float, year_inspected: Optional[int] = None,
- # month_inspected: Optional[Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]] = None,
- # joint_type: Literal['Seamless', 'Welded'] = 'Seamless'):
- # """
- # Analyze pipe thickness against pressure and structural requirements
-
- # Args:
- # measured_thickness: Thickness measured during inspection (inches)
- # year_inspected: Year when thickness was measured (e.g., 2020)
- # month_inspected: Month when thickness was measured (1-12, e.g., 1 for January, 12 for December)
- # joint_type: Joint type for calculations ('Seamless' or 'Welded')
-
- # Returns:
- # Dict with analysis results and governing factor
-
- # Raises:
- # ValueError: If inspection year is in the future or invalid parameters
-
- # Example:
- # >>> pipe = PIPE(nps=2.0, pressure_class=300, corrosion_rate=15.0, ...)
- # >>> # High corrosion rate - month precision matters
- # >>> results = pipe.analysis(0.25, 2024, 6) # June 2024 inspection
- # >>> # vs
- # >>> results = pipe.analysis(0.25, 2024, 12) # December 2024 inspection
- # >>> # 6 months difference with 15 MPY = 7.5 mils difference in degradation
- # """
-
- # # Edge Case Error Messages
- # if month_inspected is not None and not (1 <= month_inspected <= 12):
- # raise ValueError(f"Month must be between 1 and 12, got {month_inspected}")
-
- # if year_inspected is not None and year_inspected < 1900:
- # raise ValueError(f"Year must be reasonable, got {year_inspected}")
-
- # # Calculate present-day actual thickness based on inspection year and corrosion rate
- # if year_inspected is not None and self.corrosion_rate is not None:
- # # Calculate precise time elapsed including months for high corrosion rates
- # time_elapsed = self._calculate_time_elapsed(year_inspected, month_inspected)
- # inspection_date_str = self._format_inspection_date(year_inspected, month_inspected)
-
- # degradation = self.corrosion_rate * time_elapsed # Returns the amount of degradation (in Mils) since last inspection, assuming uniform, linear corrosion
-
- # actual_thickness = measured_thickness - self.mils_to_inches(degradation) # Last known thickness minus the amount of degradation gives true, present-day pipe wall thickness
-
- # print(f"Time-based corrosion calculation:")
- # if time_elapsed < 0.1: # Less than ~1.2 months
- # print(f" Measured thickness: {measured_thickness:.4f} inches ({inspection_date_str})")
- # print(f" Corrosion rate: {self.corrosion_rate} MPY")
- # else:
- # print(f" Measured thickness: {measured_thickness:.4f} inches ({inspection_date_str})")
- # print(f" Corrosion rate: {self.corrosion_rate} MPY")
- # print(f" Time elapsed: {time_elapsed:.3f} years ({time_elapsed*12:.1f} months)")
- # print(f" Amount of Degradation since {inspection_date_str}: {self.mils_to_inches(degradation):.4f} inches (or {degradation:.1f} Mils)")
- # print(f" Present-day thickness: {actual_thickness:.4f} inches (or {self.inches_to_mils(actual_thickness)} Mils)")
-
- # else:
- # actual_thickness = measured_thickness
- # print(f"Using measured thickness as present-day thickness: {actual_thickness:.4f} inches")
-
- # #### Checked
- # tmin_pressure = self.get_pressure_thickness_requirement(joint_type)
- # tmin_structural = self.get_structural_thickness_requirement()
-
- # default_retirement_limit = self.default_retirement_limit # Optional user-defined, company-specific retirement limit
-
- # limits = {
- # "pressure": tmin_pressure,
- # "structural": tmin_structural,
- # }
- # # This conditional take the larger of the two required thicknesses, this will determine whether the pipe is structurally governed or pressure governed
- # if limits["pressure"] >= limits["structural"]:
- # governing_thickness = limits["pressure"]
- # governing_type = "pressure"
-
- # else:
- # governing_thickness = limits["structural"]
- # governing_type = "structural"
-
- # print("----------- THICKNESS REQUIREMENT ----------")
- # print(f"The pipe is {governing_type} thickness governed, pipe retirement is required at {governing_thickness} inches ({self.inches_to_mils(governing_thickness)} Mils)")
- # print("-----------------------------------------------------")
-
- # if default_retirement_limit is not None:
- # if default_retirement_limit - actual_thickness >= 0:
- # below_defaultRL = default_retirement_limit - actual_thickness
- # else:
- # print(f"The actual thickness is greater than default (company-specified) retirement limit by {actual_thickness - default_retirement_limit}")
- # below_defaultRL = None
- # else:
- # below_defaultRL = None
-
- # if governing_type == "structural":
-
- # api574_value = self.get_structural_thickness_requirement()
- # if api574_value < actual_thickness:
- # corosion_allowance = actual_thickness - api574_value
- # print(f"There is {corosion_allowance} inches ({self.inches_to_mils(corosion_allowance)} Mils) of corrosion allowance remaining")
- # else:
- # print(f"Actual Thickness is {api574_value - actual_thickness} inches below corresponding API 574 Structural Retirement Limit for {self.metallurgy}, Retirement Recommended, Fit For Service assessment is needed")
- # corosion_allowance = None
-
- # else:
- # if governing_thickness >= actual_thickness:
- # print(f"Actual Thickness is {governing_thickness - actual_thickness} inches ({self.inches_to_mils(governing_thickness - actual_thickness)} Mils), Retire Pipe Immediately, miniumum pressure containing thickness is not satisfied")
-
- # return {
- # "measured_thickness": measured_thickness,
- # "year_inspected": year_inspected,
- # "actual_thickness": actual_thickness,
- # "tmin_pressure": tmin_pressure,
- # "tmin_structural": tmin_structural,
- # "default_retirement_limit": default_retirement_limit,
- # "below_defaultRL": below_defaultRL,
- # "api574_RL": self.get_structural_thickness_requirement(),
- # "above_api574RL": corosion_allowance,
- # "life_span": self.calculate_corrosion_allowance(corosion_allowance, self.corrosion_rate) if corosion_allowance is not None and self.corrosion_rate is not None else None,
- # "governing_thickness": governing_thickness,
- # "governing_type": governing_type,
- # }
def analysis(self, measured_thickness : float, year_inspected : Optional[int] = None, month_inspected : Optional[Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]] = None,
- joint_type = 'Seamless'):
+ joint_type = 'Seamless') -> Dict:
"""
The intent of analysis is to determine the present-day nominal thickness lies on the overall, 'like-new' pipe wall thickness and compare it to industry standards to assess if it is safe to lower the retirement limit and elongate the pipe's operational life.
@@ -522,11 +343,11 @@ def analysis(self, measured_thickness : float, year_inspected : Optional[int] =
governing_thickness = limits["structural"]
governing_type = "structural"
- ###############################
- # Raise Flag (Green, Yellow, Red)
- ###############################
+ #######################################
+ # Pack Analysis Results in a Dictionary
+ #######################################
- # Store analysis data for flag methods
+ # Stores the analysis data in a dict, we can unpack this as needed for flag methods deeper in the class
analysis_data = {
"measured_thickness": measured_thickness,
"year_inspected": year_inspected,
@@ -540,32 +361,49 @@ def analysis(self, measured_thickness : float, year_inspected : Optional[int] =
"limits": limits
}
+
+ ######################################################################################
+ # Raise Flag (Green, Yellow, Red), this is where the logic decides if the pipe is safe
+ ######################################################################################
+
+
# RED FLAG: Below pressure minimum - Immediate retirement required
if actual_thickness <= limits["pressure"]:
- result = self.red_flag(analysis_data)
+ raised_flag = self.red_flag(analysis_data)
# YELLOW FLAG: Above pressure but below structural or default retirement limit
elif (actual_thickness > limits["pressure"] and
(actual_thickness <= limits["structural"] or
(default_retirement_limit is not None and actual_thickness <= default_retirement_limit and actual_thickness > limits["pressure"]))):
- result = self.yellow_flag(analysis_data)
+ raised_flag = self.yellow_flag(analysis_data)
# GREEN FLAG: Above all limits - Safe to continue operation
else:
- result = self.green_flag(analysis_data)
+ raised_flag = self.green_flag(analysis_data)
# Store results for caching
self._last_analysis_results = result
+
return result
+
+
+
+ #############################################################################################
+ # GREEN FLAG METHOD
+ #############################################################################################
+
+
+
+
+
+
def green_flag(self, analysis_data):
"""Green Flag: All criteria satisfied - pipe can safely continue in operation"""
- print(f"###################################")
- print(f"GREEN FLAG")
- print(f"###################################")
+
- print(f"Criteria Satisfied per API {self.API_table} / ASME Code")
- print(f"Piping can safely continue in operation")
+ # Unpack the actual thickness, governing thickness, and default RL to identify green flag's retirement limit
+ # This is only applicable to green flags since red and yellow require more specialized considerations (e.g. FFS)
actual_thickness = analysis_data["actual_thickness"]
governing_thickness = analysis_data["governing_thickness"]
@@ -580,10 +418,9 @@ def green_flag(self, analysis_data):
retirement_type = f"New Retirement limit is governed by {analysis_data['governing_type']} design"
corrosion_allowance = actual_thickness - next_retirement_limit
-
- print(f"Current thickness: {actual_thickness:.4f} inches ({self.inches_to_mils(actual_thickness):.1f} mils)")
- print(f"Next retirement limit ({retirement_type}): {next_retirement_limit:.4f} inches ({self.inches_to_mils(next_retirement_limit):.1f} mils)")
- print(f"Corrosion allowance: {corrosion_allowance:.4f} inches ({self.inches_to_mils(corrosion_allowance):.1f} mils)")
+
+
+
# Calculate remaining life if corrosion rate is provided
if self.corrosion_rate is not None:
@@ -600,15 +437,23 @@ def green_flag(self, analysis_data):
"remaining_life_years": remaining_life_years if self.corrosion_rate is not None else None,
**analysis_data
}
+
+
+
+
+
+ #############################################################################################
+ # YELLOW FLAG METHOD
+ #############################################################################################
+
+
+
+
+
def yellow_flag(self, analysis_data):
"""Yellow Flag: Not all criteria satisfied - FFS assessment recommended"""
- print(f"###################################")
- print(f"YELLOW FLAG")
- print(f"###################################")
- print(f"Not all code criteria satisfied per API {self.API_table} / ASME Code")
- print(f"Fitness for Service (FFS) assessment recommended")
actual_thickness = analysis_data["actual_thickness"]
tmin_pressure = analysis_data["tmin_pressure"]
@@ -619,17 +464,16 @@ def yellow_flag(self, analysis_data):
below_structural = actual_thickness <= tmin_structural
below_default = default_retirement_limit is not None and actual_thickness <= default_retirement_limit
- print(f"Current thickness: {actual_thickness:.4f} inches ({self.inches_to_mils(actual_thickness):.1f} mils)")
+
if below_structural:
structural_deficit = tmin_structural - actual_thickness
- print(f"Below structural minimum by: {structural_deficit:.4f} inches ({self.inches_to_mils(structural_deficit):.1f} mils)")
+
if below_default:
default_deficit = default_retirement_limit - actual_thickness
- print(f"Below company retirement limit by: {default_deficit:.4f} inches ({self.inches_to_mils(default_deficit):.1f} mils)")
+
- print(f"Engineering judgment and FFS assessment required for continued operation")
return {
"flag": "YELLOW",
@@ -642,24 +486,30 @@ def yellow_flag(self, analysis_data):
**analysis_data
}
+
+
+
+
+
+ #############################################################################################
+ # RED FLAG METHOD
+ #############################################################################################
+
+
+
+
+
+
+
def red_flag(self, analysis_data):
"""Red Flag: Below pressure minimum - Immediate retirement required"""
- print(f"###################################")
- print(f"RED FLAG")
- print(f"###################################")
- print(f"CRITICAL: Below pressure minimum thickness requirement")
- print(f"IMMEDIATE RETIREMENT REQUIRED")
actual_thickness = analysis_data["actual_thickness"]
tmin_pressure = analysis_data["tmin_pressure"]
pressure_deficit = tmin_pressure - actual_thickness
- print(f"Current thickness: {actual_thickness:.4f} inches ({self.inches_to_mils(actual_thickness):.1f} mils)")
- print(f"Pressure minimum required: {tmin_pressure:.4f} inches ({self.inches_to_mils(tmin_pressure):.1f} mils)")
- print(f"Deficit: {pressure_deficit:.4f} inches ({self.inches_to_mils(pressure_deficit):.1f} mils)")
- print(f"Rigorous analysis required for any continued operations")
- print(f"Consider temporary leak detection devices if operation must continue")
+
return {
"flag": "RED",
@@ -671,6 +521,8 @@ def red_flag(self, analysis_data):
+
+
def report(self, report_format: Literal["CSV", "JSON", "TXT", "IPYNB"] = "TXT",
filename: Optional[str] = None, **analysis_kwargs) -> Dict[str, Any]:
"""
diff --git a/tmin/report_generator.py b/tmin/report_generator.py
index 370f567..a6560fc 100644
--- a/tmin/report_generator.py
+++ b/tmin/report_generator.py
@@ -1,61 +1,211 @@
from typing import Dict, Any, Optional
+from dataclasses import dataclass
from datetime import datetime
import os
+
+@dataclass
class ReportGenerator:
"""
Generates text reports for pipe thickness analysis
+
+
+ # print(f"###################################")
+ # print(f"GREEN FLAG")
+ # print(f"###################################")
+
+ # print(f"Criteria Satisfied per API {self.API_table} / ASME Code")
+ # print(f"Piping can safely continue in operation")
+
+
+ # print(f"Current thickness: {actual_thickness:.4f} inches ({self.inches_to_mils(actual_thickness):.1f} mils)")
+ # print(f"Next retirement limit ({retirement_type}): {next_retirement_limit:.4f} inches ({self.inches_to_mils(next_retirement_limit):.1f} mils)")
+ # print(f"Corrosion allowance: {corrosion_allowance:.4f} inches ({self.inches_to_mils(corrosion_allowance):.1f} mils)")
+
+
+
+ print(f"###################################")
+ print(f"YELLOW FLAG")
+ print(f"###################################")
+
+ print(f"Not all code criteria satisfied per API {self.API_table} / ASME Code")
+ print(f"Fitness for Service (FFS) assessment recommended")
+ print(f"Current thickness: {actual_thickness:.4f} inches ({self.inches_to_mils(actual_thickness):.1f} mils)")
+ print(f"Below structural minimum by: {structural_deficit:.4f} inches ({self.inches_to_mils(structural_deficit):.1f} mils)")
+ print(f"Below company retirement limit by: {default_deficit:.4f} inches ({self.inches_to_mils(default_deficit):.1f} mils)")
+ print(f"Engineering judgment and FFS assessment required for continued operation")
+
+
+
+
+ print(f"###################################")
+ print(f"RED FLAG")
+ print(f"###################################")
+
+ print(f"CRITICAL: Below pressure minimum thickness requirement")
+ print(f"IMMEDIATE RETIREMENT REQUIRED")
+
+ print(f"Current thickness: {actual_thickness:.4f} inches ({self.inches_to_mils(actual_thickness):.1f} mils)")
+ print(f"Pressure minimum required: {tmin_pressure:.4f} inches ({self.inches_to_mils(tmin_pressure):.1f} mils)")
+ print(f"Deficit: {pressure_deficit:.4f} inches ({self.inches_to_mils(pressure_deficit):.1f} mils)")
+ print(f"Rigorous analysis required for any continued operations")
+ print(f"Consider temporary leak detection devices if operation must continue")
+
"""
-
- def __init__(self):
- # Get the root directory of the package (where pyproject.toml is located)
- # This ensures reports are always generated in the package root
- current_dir = os.path.dirname(os.path.abspath(__file__)) # tmin/
- package_root = os.path.dirname(current_dir) # Go up one level to package root
- self.reports_dir = os.path.join(package_root, "Reports")
- os.makedirs(self.reports_dir, exist_ok=True)
- self.report_template = """
-TMIN - PIPE THICKNESS ANALYSIS REPORT
-=====================================
-Report Generated: {timestamp}
-Analysis ID: {analysis_id}
+ reports_folder : str # this is the specified folder name you want to create
+ data_analysis : Dict
-FLAG STATUS: {flag_status}
-Status: {status}
-EXECUTIVE SUMMARY
------------------
-{message}
+ def filehandler(self, reports_filepath : Optional[str] = None):
+ """
+ Example reports_filepath = "/Users/andrewtrepagnier/projects/my_project/"
-KEY FINDINGS
-------------
-• Actual Thickness: {actual_thickness:.4f} inches
-• Governing Thickness: {governing_thickness:.4f} inches ({governing_type})
-• Corrosion Allowance: {corrosion_allowance} inches
-• Estimated Remaining Life: {remaining_life} years
+ This is optional. Leaving it blank will automatically populate
+ the reports in the current project root directory.
+ """
-PIPE SPECIFICATIONS
--------------------
-NPS: {nps}" Schedule {schedule}, Class {pressure_class}
-Metallurgy: {metallurgy}
-Design Pressure: {pressure} psi
-Corrosion Rate: {corrosion_rate} mpy
+ if reports_filepath is None:
-THICKNESS REQUIREMENTS
-----------------------
-Pressure Minimum: {tmin_pressure:.4f} inches
-Structural Minimum: {tmin_structural:.4f} inches
-API 574 RL: {api574_RL:.4f} inches
+ # Get the folder of this script as the project root
-RECOMMENDATIONS
----------------
-{recommendations}
+ current_dir = os.path.dirname(os.path.abspath(__file__))
-NOTES
------
-{notes}
-"""
+ self.reports_folder = os.path.join(current_dir, "reports")
+
+ else:
+ # Use the user-specified path
+
+ self.reports_folder = os.path.join(reports_filepath, "reports")
+
+ # Create the directory if it doesn’t exist
+
+ os.makedirs(self.reports_folder, exist_ok=True)
+
+ return self.reports_folder
+
+
+
+
+ def quick_report(self) -> str:
+
+ #filehandler method must be ran prior to this
+
+ retrieve_str = """
+
+
+ Report Generation Sucessful!
+
+ Comprehensive Reports and Visualizations are available in {self.filehandler}
+
+ ****************************************************************************
+ Quick Summary
+ ****************************************************************************
+
+ Report Generated: {timestamp}
+ Analysis ID: {analysis_id}
+
+ FLAG STATUS: {flag_status}
+ Status: {status}
+
+ #metadata should be formated in a table
+
+
+ ANALYSIS FINDINGS:
+ ------------------
+ • Actual Thickness: {actual_thickness:.4f} inches
+ • Governing Thickness: {governing_thickness:.4f} inches ({governing_type})
+ • Corrosion Allowance: {corrosion_allowance} inches
+ • Estimated Remaining Life: {remaining_life} years
+
+ THICKNESS REQUIREMENTS
+ ----------------------
+ Pressure Minimum: {tmin_pressure:.4f} inches
+ Structural Minimum: {tmin_structural:.4f} inches
+ API 574 RL: {api574_RL:.4f} inches
+
+
+ FLAG RECOMMENDATIONS
+ --------------------
+ {self.flag_recommendations}
+
+ DISCLAIMER
+ --------------------
+
+ TMIN is a decision support tool that should be paired with proffesional engineering judgement. Users should refer to the design document to understand the
+ limitations and assumptions made in the analysis and implementation of the industry codes before considering the analysis' results. TMIN's authors are not liable for
+ the misuse of the software.
+
+
+ ===========================================================================
+ """
+ return retrieve_str
+
+
+ def flag_recommendations(self) -> str:
+
+ if self.flag_status == 'GREEN':
+
+
+
+
+
+ elif self.flag_status == 'YELLOW':
+
+ else: #self.flag_status == 'RED':
+
+
+
+
+# def __init__(self):
+# # Get the root directory of the package (where pyproject.toml is located)
+# # This ensures reports are always generated in the package root
+# current_dir = os.path.dirname(os.path.abspath(__file__)) # tmin/
+# package_root = os.path.dirname(current_dir) # Go up one level to package root
+# self.reports_dir = os.path.join(package_root, "Reports")
+# os.makedirs(self.reports_dir, exist_ok=True)
+# self.report_template = """
+# TMIN - PIPE THICKNESS ANALYSIS REPORT
+# =====================================
+
+# Report Generated: {timestamp}
+# Analysis ID: {analysis_id}
+
+# FLAG STATUS: {flag_status}
+# Status: {status}
+
+# EXECUTIVE SUMMARY
+# -----------------
+# {message}
+
+# KEY FINDINGS
+# ------------
+# • Actual Thickness: {actual_thickness:.4f} inches
+# • Governing Thickness: {governing_thickness:.4f} inches ({governing_type})
+# • Corrosion Allowance: {corrosion_allowance} inches
+# • Estimated Remaining Life: {remaining_life} years
+
+# PIPE SPECIFICATIONS
+# -------------------
+# NPS: {nps}" Schedule {schedule}, Class {pressure_class}
+# Metallurgy: {metallurgy}
+# Design Pressure: {pressure} psi
+# Corrosion Rate: {corrosion_rate} mpy
+
+# THICKNESS REQUIREMENTS
+# ----------------------
+# Pressure Minimum: {tmin_pressure:.4f} inches
+# Structural Minimum: {tmin_structural:.4f} inches
+# API 574 RL: {api574_RL:.4f} inches
+
+# RECOMMENDATIONS
+# ---------------
+# {recommendations}
+
+# NOTES
+# -----
+# {notes}
+# """
def _get_filename_with_date(self, base_name: str, filename: Optional[str] = None) -> str:
"""Generate filename with date prefix"""
diff --git a/tmin/report_generator.sublime-workspace b/tmin/report_generator.sublime-workspace
new file mode 100644
index 0000000..35ca2d3
--- /dev/null
+++ b/tmin/report_generator.sublime-workspace
@@ -0,0 +1,172 @@
+{
+ "auto_complete":
+ {
+ "selected_items":
+ [
+ ]
+ },
+ "buffers":
+ [
+ ],
+ "build_system": "",
+ "build_system_choices":
+ [
+ ],
+ "build_varint": "",
+ "command_palette":
+ {
+ "height": 0.0,
+ "last_filter": "",
+ "selected_items":
+ [
+ ],
+ "width": 0.0
+ },
+ "console":
+ {
+ "height": 387.0,
+ "history":
+ [
+ "ls",
+ "cd ",
+ "console"
+ ]
+ },
+ "distraction_free":
+ {
+ "menu_visible": true,
+ "show_minimap": false,
+ "show_open_files": false,
+ "show_tabs": false,
+ "side_bar_visible": false,
+ "status_bar_visible": false
+ },
+ "file_history":
+ [
+ ],
+ "find":
+ {
+ "height": 0.0
+ },
+ "find_in_files":
+ {
+ "height": 0.0,
+ "where_history":
+ [
+ ]
+ },
+ "find_state":
+ {
+ "case_sensitive": false,
+ "find_history":
+ [
+ ],
+ "highlight": true,
+ "in_selection": false,
+ "preserve_case": false,
+ "regex": false,
+ "replace_history":
+ [
+ ],
+ "reverse": false,
+ "scrollbar_highlights": true,
+ "show_context": true,
+ "use_buffer2": true,
+ "use_gitignore": true,
+ "whole_word": false,
+ "wrap": true
+ },
+ "groups":
+ [
+ {
+ "sheets":
+ [
+ ]
+ }
+ ],
+ "incremental_find":
+ {
+ "height": 0.0
+ },
+ "input":
+ {
+ "height": 0.0
+ },
+ "layout":
+ {
+ "cells":
+ [
+ [
+ 0,
+ 0,
+ 1,
+ 1
+ ]
+ ],
+ "cols":
+ [
+ 0.0,
+ 1.0
+ ],
+ "rows":
+ [
+ 0.0,
+ 1.0
+ ]
+ },
+ "menu_visible": true,
+ "output.find_results":
+ {
+ "height": 0.0,
+ "history":
+ [
+ ]
+ },
+ "pinned_build_system": "",
+ "project": "report_generator.sublime-project",
+ "replace":
+ {
+ "height": 0.0
+ },
+ "save_all_on_build": true,
+ "select_file":
+ {
+ "height": 0.0,
+ "last_filter": "",
+ "selected_items":
+ [
+ ],
+ "width": 0.0
+ },
+ "select_project":
+ {
+ "height": 0.0,
+ "last_filter": "",
+ "selected_items":
+ [
+ ],
+ "width": 0.0
+ },
+ "select_symbol":
+ {
+ "height": 0.0,
+ "last_filter": "",
+ "selected_items":
+ [
+ ],
+ "width": 0.0
+ },
+ "selected_group": 0,
+ "settings":
+ {
+ },
+ "show_minimap": true,
+ "show_open_files": false,
+ "show_tabs": true,
+ "side_bar_visible": true,
+ "side_bar_width": 50.0,
+ "status_bar_visible": true,
+ "template_settings":
+ {
+ }
+}
From 97f03814c161fcefdcde139d91f0ea59a91f7311 Mon Sep 17 00:00:00 2001
From: AndrewTrepagnier
Date: Mon, 18 Aug 2025 19:46:36 -0500
Subject: [PATCH 02/10] wip:added green flag recommendations, next yellow with
struct_deficit logic
---
tmin/core.py | 2 +-
tmin/report_generator.py | 15 +++++++++++----
2 files changed, 12 insertions(+), 5 deletions(-)
diff --git a/tmin/core.py b/tmin/core.py
index 036cfbc..5543ffc 100644
--- a/tmin/core.py
+++ b/tmin/core.py
@@ -425,7 +425,7 @@ def green_flag(self, analysis_data):
# Calculate remaining life if corrosion rate is provided
if self.corrosion_rate is not None:
remaining_life_years = self.calculate_corrosion_allowance(corrosion_allowance, self.corrosion_rate)
- print(f"Estimated remaining life: {remaining_life_years:.1f} years (at {self.corrosion_rate} MPY)")
+ # print(f"Estimated remaining life: {remaining_life_years:.1f} years (at {self.corrosion_rate} MPY)")
return {
"flag": "GREEN",
diff --git a/tmin/report_generator.py b/tmin/report_generator.py
index a6560fc..e8a8625 100644
--- a/tmin/report_generator.py
+++ b/tmin/report_generator.py
@@ -89,7 +89,8 @@ def filehandler(self, reports_filepath : Optional[str] = None):
def quick_report(self) -> str:
- #filehandler method must be ran prior to this
+ # filehandler method must be ran prior to this
+ # This is a standard template that works for any flag and will provide users with a brief output in additon to the full reports
retrieve_str = """
@@ -113,7 +114,7 @@ def quick_report(self) -> str:
ANALYSIS FINDINGS:
------------------
- • Actual Thickness: {actual_thickness:.4f} inches
+ • Actual (Current-day) Thickness: {actual_thickness:.4f} inches
• Governing Thickness: {governing_thickness:.4f} inches ({governing_type})
• Corrosion Allowance: {corrosion_allowance} inches
• Estimated Remaining Life: {remaining_life} years
@@ -146,12 +147,18 @@ def flag_recommendations(self) -> str:
if self.flag_status == 'GREEN':
-
+ print(f"Criteria Satisfied per API {self.API_table} / ASME Code")
+ print(f"Piping can safely continue in operation")
+ print(f"The next retirement limit is governed by {retirement_type} at {next_retirement_limit} inches (or {self.inches_to_mils(self.next_retirement_limit)} MPY)")
-
elif self.flag_status == 'YELLOW':
+ print(f"Not all code criteria satisfied per API {self.API_table} / ASME Code")
+ print(f"Fitness for Service (FFS) assessment may be needed for higher confidence")
+
+######################################################################################################## TODO: write logic to handle struct deficit if available, if not ignore
+
else: #self.flag_status == 'RED':
From bd6ce6a1f7110c587e6d1c5bfdb631aa3682b84f Mon Sep 17 00:00:00 2001
From: AndrewTrepagnier
Date: Mon, 18 Aug 2025 19:58:59 -0500
Subject: [PATCH 03/10] wip: error handling for measure thickness arguments
greater than pipe wall thickness
---
tmin/core.py | 3 +++
tmin/report_generator.py | 2 +-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/tmin/core.py b/tmin/core.py
index 5543ffc..cc7d312 100644
--- a/tmin/core.py
+++ b/tmin/core.py
@@ -301,6 +301,9 @@ def analysis(self, measured_thickness : float, year_inspected : Optional[int] =
if default_retirement_limit is not None and default_retirement_limit < self.get_structural_thickness_requirement():
raise ValueError(f"Default, company-specific retirement limits shall not be below API {self.API_table} appointed structural thickness requirements")
+ if measured_thickness > self.outer_diameter - self.inner_diameter:
+ raise ValueError(f"Measured thickness cannot be greater than nominal pipe wall thickness: units should be in inches")
+
###############################
# Time-Based Corrosion Analysis
###############################
diff --git a/tmin/report_generator.py b/tmin/report_generator.py
index e8a8625..7d65f91 100644
--- a/tmin/report_generator.py
+++ b/tmin/report_generator.py
@@ -156,7 +156,7 @@ def flag_recommendations(self) -> str:
print(f"Not all code criteria satisfied per API {self.API_table} / ASME Code")
print(f"Fitness for Service (FFS) assessment may be needed for higher confidence")
-
+
######################################################################################################## TODO: write logic to handle struct deficit if available, if not ignore
else: #self.flag_status == 'RED':
From 6e0ca5453e83d9bb4547a7c5e2813d2fa1a8a58f Mon Sep 17 00:00:00 2001
From: AndrewTrepagnier
Date: Mon, 18 Aug 2025 21:01:51 -0500
Subject: [PATCH 04/10] wip: restructured dictionary packing around the
analysis and flag functions for more intuitive flow
---
tmin/core.py | 61 ++++++++++++++++++++--------------------
tmin/report_generator.py | 7 +++--
2 files changed, 35 insertions(+), 33 deletions(-)
diff --git a/tmin/core.py b/tmin/core.py
index cc7d312..0573982 100644
--- a/tmin/core.py
+++ b/tmin/core.py
@@ -115,7 +115,7 @@ def mils_to_inches(self, mils_value: float) -> float:
# Pressure Contianing Thickness Requirements Per ASME B31.3 Codes
#####################################################################################
- def get_pressure_thickness_requirement(self, joint_type='Seamless') -> float:
+ def tmin_pressure(self, joint_type='Seamless') -> float:
"""
Calculate minimum wall thickness for pressure design
Based on ASME B31.1 Para. 304.1.2a Eq. 3a
@@ -167,7 +167,7 @@ def get_pressure_thickness_requirement(self, joint_type='Seamless') -> float:
# Structural Thickness Requirements Per API 574 Codes
#####################################################################################
- def get_structural_thickness_requirement(self) -> float:
+ def tmin_structural(self) -> float:
"""
Get minimum structural thickness requirement from API 574 tables.
@@ -303,7 +303,7 @@ def analysis(self, measured_thickness : float, year_inspected : Optional[int] =
if measured_thickness > self.outer_diameter - self.inner_diameter:
raise ValueError(f"Measured thickness cannot be greater than nominal pipe wall thickness: units should be in inches")
-
+
###############################
# Time-Based Corrosion Analysis
###############################
@@ -316,10 +316,10 @@ def analysis(self, measured_thickness : float, year_inspected : Optional[int] =
degradation = self.corrosion_rate * time_elapsed # Returns the amount of degradation (in Mils) since last inspection, assuming uniform, linear corrosion
- actual_thickness = measured_thickness - self.mils_to_inches(degradation) # Last known thickness minus the amount of degradation gives true, present-day pipe wall thickness
+ self.actual_thickness = measured_thickness - self.mils_to_inches(degradation) # Last known thickness minus the amount of degradation gives true, present-day pipe wall thickness
else:
- actual_thickness = measured_thickness
+ self.actual_thickness = measured_thickness
# print(f"Using measured thickness as present-day thickness: {actual_thickness:.4f} inches")
@@ -327,42 +327,28 @@ def analysis(self, measured_thickness : float, year_inspected : Optional[int] =
# Governing Thickness Analysis (Pressure Vs. Structural)
###############################
+ #Running the tmin methods and saving them as arguments that can be used globally now
+ self.tmin_pressure = tmin_pressure('seamless')
+ self.tmin_structural = tmin_structural()
- tmin_pressure = self.get_pressure_thickness_requirement(joint_type)
- tmin_structural = self.get_structural_thickness_requirement()
-
-
- limits = {
- "pressure": tmin_pressure,
- "structural": tmin_structural,
- }
# This conditional take the larger of the two required thicknesses, this will determine whether the pipe is structurally governed or pressure governed
- if limits["pressure"] >= limits["structural"]:
- governing_thickness = limits["pressure"]
- governing_type = "pressure"
+
+ if self.tmin_pressure >= self.tmin_structural:
+ self.governing_thickness = self.tmin_pressure
+ self.governing_type = "pressure"
else:
- governing_thickness = limits["structural"]
- governing_type = "structural"
+ self.governing_thickness = self.tmin_structural
+ self.governing_type = "structural"
+
+
+
#######################################
# Pack Analysis Results in a Dictionary
#######################################
- # Stores the analysis data in a dict, we can unpack this as needed for flag methods deeper in the class
- analysis_data = {
- "measured_thickness": measured_thickness,
- "year_inspected": year_inspected,
- "month_inspected": month_inspected,
- "actual_thickness": actual_thickness,
- "tmin_pressure": tmin_pressure,
- "tmin_structural": tmin_structural,
- "default_retirement_limit": default_retirement_limit,
- "governing_thickness": governing_thickness,
- "governing_type": governing_type,
- "limits": limits
- }
######################################################################################
@@ -389,6 +375,19 @@ def analysis(self, measured_thickness : float, year_inspected : Optional[int] =
return result
+ return pack_analysis_data = {
+ "measured_thickness": measured_thickness,
+ "year_inspected": year_inspected,
+ "month_inspected": month_inspected,
+ "actual_thickness": actual_thickness,
+ "tmin_pressure": tmin_pressure,
+ "tmin_structural": tmin_structural,
+ "default_retirement_limit": default_retirement_limit,
+ "governing_thickness": governing_thickness,
+ "governing_type": governing_type,
+ "limits": limits,
+ **raised_flag}
+
diff --git a/tmin/report_generator.py b/tmin/report_generator.py
index 7d65f91..1f59903 100644
--- a/tmin/report_generator.py
+++ b/tmin/report_generator.py
@@ -154,13 +154,16 @@ def flag_recommendations(self) -> str:
elif self.flag_status == 'YELLOW':
+
+
print(f"Not all code criteria satisfied per API {self.API_table} / ASME Code")
print(f"Fitness for Service (FFS) assessment may be needed for higher confidence")
-
-######################################################################################################## TODO: write logic to handle struct deficit if available, if not ignore
+
else: #self.flag_status == 'RED':
+ print(f"CRITICAL: Below pressure minimum thickness requirement")
+ print(f"Immediate Retirement is recommended. Risk-based assessment and FFS is needed for continued operation.")
From 4b3418a2582eef3818274bb7056ba223ce62f92c Mon Sep 17 00:00:00 2001
From: AndrewTrepagnier
Date: Tue, 19 Aug 2025 19:29:12 -0500
Subject: [PATCH 05/10] ENH: Major refactor of PIPE class, stateless system to
reduce instances(selfs) in script
---
tmin/core.py | 24 +-
tmin/core_dev.py | 562 +++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 573 insertions(+), 13 deletions(-)
create mode 100644 tmin/core_dev.py
diff --git a/tmin/core.py b/tmin/core.py
index 0573982..0241998 100644
--- a/tmin/core.py
+++ b/tmin/core.py
@@ -333,7 +333,7 @@ def analysis(self, measured_thickness : float, year_inspected : Optional[int] =
# This conditional take the larger of the two required thicknesses, this will determine whether the pipe is structurally governed or pressure governed
-
+
if self.tmin_pressure >= self.tmin_structural:
self.governing_thickness = self.tmin_pressure
self.governing_type = "pressure"
@@ -343,7 +343,7 @@ def analysis(self, measured_thickness : float, year_inspected : Optional[int] =
self.governing_type = "structural"
-
+################################################################# TODO rewrite in a stateless function form
#######################################
# Pack Analysis Results in a Dictionary
@@ -373,8 +373,7 @@ def analysis(self, measured_thickness : float, year_inspected : Optional[int] =
# Store results for caching
self._last_analysis_results = result
- return result
-
+ # TODO: This still requires extensive work
return pack_analysis_data = {
"measured_thickness": measured_thickness,
"year_inspected": year_inspected,
@@ -400,28 +399,27 @@ def analysis(self, measured_thickness : float, year_inspected : Optional[int] =
- def green_flag(self, analysis_data):
+ def green_flag(self):
"""Green Flag: All criteria satisfied - pipe can safely continue in operation"""
# Unpack the actual thickness, governing thickness, and default RL to identify green flag's retirement limit
# This is only applicable to green flags since red and yellow require more specialized considerations (e.g. FFS)
- actual_thickness = analysis_data["actual_thickness"]
- governing_thickness = analysis_data["governing_thickness"]
- default_retirement_limit = analysis_data["default_retirement_limit"]
+ # actual_thickness, efault_retirement limit, governing_thickness, governing_type, tmin_pressure, and tmin_struct need are the attributes that require selfs to use in this method
# Determine next retirement limit (whichever is higher: governing thickness or default RL)
- if default_retirement_limit is not None and default_retirement_limit > governing_thickness:
- next_retirement_limit = default_retirement_limit
+ if self.default_retirement_limit is not None and self.default_retirement_limit > self.governing_thickness:
+ next_retirement_limit = self.default_retirement_limit
retirement_type = "company-specified"
+
else:
- next_retirement_limit = governing_thickness
+ next_retirement_limit = self.governing_thickness
retirement_type = f"New Retirement limit is governed by {analysis_data['governing_type']} design"
- corrosion_allowance = actual_thickness - next_retirement_limit
-
+ self.corrosion_allowance = self.actual_thickness - next_retirement_limit
+######################## TODO: beginning of the method, just say self.something = something instead of clutering everything
# Calculate remaining life if corrosion rate is provided
diff --git a/tmin/core_dev.py b/tmin/core_dev.py
new file mode 100644
index 0000000..68f0935
--- /dev/null
+++ b/tmin/core_dev.py
@@ -0,0 +1,562 @@
+from .tables.od_table import trueOD
+from .tables.wsrf import WSRF
+from .tables.y_coeff import ferritic_steels_y, austenitic_steels_y, other_metals_y, nickel_alloy_N06690_y, nickel_alloys_N06617_N08800_N08810_N08825_y, cast_iron_y
+from .tables.api_574_2025 import API574_CS_400F, API574_SS_400F
+from .tables.api_574_2009 import API574_2009_TABLE_6
+from .tables.ANSI_radii import ANSI_radii
+
+import numpy as np
+from dataclasses import dataclass
+from typing import Literal, Optional, Dict, Any
+import matplotlib.pyplot as plt
+from datetime import datetime
+
+
+
+@dataclass
+class PIPE:
+
+ #########Initialize Characteristics of the Pipe and Service###############
+
+ pressure: float # Design pressure (psi)
+ design_temp: Literal["<900" ,900, 950, 1000, 1050, 1100, 1150, 1200, 1250, "1250+" ] = 900 #Design temp in Fahrenheit
+ nps: float # Nominal pipe size in decimal form (e.g. '0.75', '1.5', '2')
+ schedule: float # Pipe schedule (10, 40, 80, 120, 160)
+ pressure_class: Literal[150, 300, 600, 900, 1500, 2500]
+ metallurgy: Literal["Intermediate/Low CS", "SS 316/316L", "SS 304/304L", "Inconel 625", "Other"]
+ yield_stress: float # psi, yield stresses vary from year to year of manufactured piping, ensure the correct year's yield stress is used
+ pipe_config: Literal["straight", "90LR - Inner Elbow", "90LR - Outer Elbow"] = "straight"
+ corrosion_rate: Optional[float] = None #Mils per Year (MPY)
+
+
+ # Optional fields with defaults
+ default_retirement_limit: Optional[float] = None
+ API_table : Literal["2025", "2009"] = "2025" # Optional, year 2025 is assumed if left blank
+ joint_type: Literal["seamless"] = "seamless" # As of now, TMIN only supports seamless piping analysis, TODO: Add arc butt welded pipe configs
+
+ trueOD = trueOD
+ WSRF = WSRF
+ ferritic_steels_y = ferritic_steels_y
+ austenitic_steels_y = austenitic_steels_y
+ other_metals_y = other_metals_y
+ nickel_alloy_N06690_y = nickel_alloy_N06690_y
+ nickel_alloys_N06617_N08800_N08810_N08825_y = nickel_alloys_N06617_N08800_N08810_N08825_y
+ cast_iron_y = cast_iron_y
+ API574_CS_400F = API574_CS_400F
+ API574_SS_400F = API574_SS_400F
+ API574_2009_TABLE_6 = API574_2009_TABLE_6
+ ANSI_radii = ANSI_radii
+
+ data_storage : Dict[str, Any] = None
+
+ def pipe_info(self, data_storage, table_info: Optional[Dict[str, Any]] = None,
+ flag_info: Optional[Dict[str, Any]] = None,
+ result_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ """
+ pipe_info is a property of the PIPE instance that will store all of the information as it is recieved, parsed, or computed. It is packed into a python dict called
+ named after whatever it is called as (e.g. in analysis, it is saved as all_data = pipe_info(metadata, table_info, result_data) in its final form)
+
+ this is in an attempt to reduce instance referals like selfs from cluttering the script. It is essentially a way to pack and unpack all the data through the workflow of
+ core workflow. In the beginning, the metadata is an empty py dict that is initialized. It is then populated with the initilized kwargs(pressure, nps, ect.). After which,
+ get functions will parse all of the tables using those kwargs to get more information such as outer diameters, inner diameters, WSRFs, ect. , this can then be populated into the original pipe_info dict by recalling the
+ old dict with another argument, table_info, it should use ** to add it in and overwrite the None values set before it. Now you have a new packed dict that can statefully be called into flags, then updated, then run in analysis.
+ """
+
+ """
+ Progressive data enrichment through workflow stages:
+
+ 1. metadata = pipe.pipe_info() # Basic pipe properties
+ 2. enriched = pipe.pipe_info(metadata, get_table_info()) # Add table lookups
+ 3. flagged = pipe.pipe_info(enriched, flag_info) # Add flag analysis
+ 4. final = pipe.pipe_info(flagged, None, result_data) # Add analysis results
+ """
+
+ data_storage = {
+ 'pressure': self.pressure,
+ 'design_temp': self.design_temp,
+ 'nps': self.nps,
+ 'schedule': self.schedule,
+ 'pressure_class': self.pressure_class,
+ 'metallurgy': self.metallurgy,
+ 'yield_stress': self.yield_stress,
+ 'pipe_config': self.pipe_config,
+ 'corrosion_rate': self.corrosion_rate,
+ 'default_retirement_limit': self.default_retirement_limit,
+ 'API_table': self.API_table,
+ 'joint_type': self.joint_type,
+ }
+
+
+ # Stage 1: Merge in table_info (dimensions, material properties, etc.)
+ if table_info:
+ data_storage.update(table_info)
+
+ # Stage 2: Merge in flag_info (analysis flags and status)
+ if flag_info:
+ data_storage.update(flag_info)
+
+ # Stage 3: Merge in result_data (final analysis calculations)
+ if result_data:
+ data_storage.update(result_data)
+
+ return data_storage
+
+
+
+ def get_allowable_stress(self) -> float:
+ """Get allowable stress based on yield stress (2/3 of yield stress)"""
+ return self.yield_stress * (2/3)
+
+ def get_outer_diameter(self) -> float:
+ """Get outside diameter based on NPS"""
+ return trueOD[self.nps]
+
+ def get_inner_diameter(self) -> float:
+ """Get nominal inside diameter based on schedule and NPS"""
+ from .tables.id_table import trueID_10, trueID_40, trueID_80, trueID_120, trueID_160
+
+ if self.schedule == 10:
+ return trueID_10.get(self.nps)
+ elif self.schedule == 40:
+ return trueID_40.get(self.nps)
+ elif self.schedule == 80:
+ return trueID_80.get(self.nps)
+ elif self.schedule == 120:
+ return trueID_120.get(self.nps)
+ elif self.schedule == 160:
+ return trueID_160.get(self.nps)
+ else:
+ raise ValueError(f"Invalid schedule: {self.schedule}")
+
+ def get_y_coefficient(self) -> float:
+ """Get Y coefficient from ASME B31.1 Table 104.1.2-1"""
+ if self.metallurgy =='CS A106 GR B':
+ return self.ferritic_steels_y[self.round_temperature()]
+ elif self.metallurgy =='SS 316/316S':
+ return self.austenitic_steels_y[self.round_temperature()]
+ elif self.metallurgy =='Other':
+ return self.other_metals_y[self.round_temperature()]
+ elif self.metallurgy =='Nickel Alloy':
+ return self.nickel_alloy_N06690_y[self.round_temperature()]
+ elif self.metallurgy =='Nickel Alloys':
+ return self.nickel_alloys_N06617_N08800_N08810_N08825_y[self.round_temperature()]
+ elif self.metallurgy =='Cast Iron':
+ return self.cast_iron_y[self.round_temperature()]
+ else:
+ return 0.4 # Default Y value for unknown metallurgy
+
+
+ def round_temperature(self) -> int:
+ """Round design temperature to nearest table value for lookups"""
+ if self.design_temp == "<900":
+ return 900
+ elif self.design_temp == "1250+":
+ return 1250
+ else:
+ return self.design_temp
+
+
+ def get_centerline_radius(self) -> float:
+ """Get centerline radius for the pipe's NPS from ANSI standard"""
+ return ANSI_radii[self.nps]
+
+ def get_joint_type(self) -> Dict[str, float]:
+ """Get joint efficiency and weld strength reduction factors"""
+ if self.joint_type == 'seamless':
+ return {
+ 'joint_efficiency': 1.0,
+ 'weld_strength_reduction': 1.0
+ }
+ else:
+ raise ValueError(f"Welded pipe analysis (joint_type='{self.joint_type}') is not yet supported. "
+ f"Currently only seamless pipe analysis is available.")
+
+
+ ##########################
+ # Static methods
+ ##########################
+
+
+ @staticmethod
+ def inches_to_mils(self, inches_value: float) -> float:
+ """Convert inches to mils (1 inch = 1000 mils)"""
+ return inches_value * 1000
+
+ @staticmethod
+ def mils_to_inches(self, mils_value: float) -> float:
+ """Convert mils to inches (1000 mils = 1 inch)"""
+ return mils_value * 0.001
+
+ @staticmethod
+ def _calculate_time_elapsed(self, year_inspected: int, month_inspected: Optional[int] = None) -> float:
+ """
+ Calculate precise time elapsed since inspection date
+
+ Args:
+ year_inspected: Year of inspection
+ month_inspected: Month of inspection (1-12), defaults to January if None
+
+ Returns:
+ float: Time elapsed in years (including fractional years for months)
+
+ Raises:
+ ValueError: If inspection date is in the future
+ """
+ current_year = datetime.now().year
+ current_month = datetime.now().month
+
+ # Use January(1) if no month provided (most conservative assumption)
+ inspection_month = month_inspected if month_inspected is not None else 1
+
+ years_diff = current_year - year_inspected
+ months_diff = current_month - inspection_month
+
+ # Convert to total years elapsed
+ time_elapsed = years_diff + (months_diff / 12)
+
+ if time_elapsed < 0:
+ raise ValueError(f"Inspection date {year_inspected}-{inspection_month:02d} cannot be in the future")
+
+ return time_elapsed
+
+ @staticmethod
+ def _format_inspection_date(self, year: int, month: Optional[int] = None) -> str:
+ """Format inspection date for display"""
+ month_str = f"{month:02d}" if month is not None else "01"
+ return f"{year}-{month_str}"
+
+
+
+ def get_table_info(self):
+
+ outer_diameter = self.get_outer_diameter()
+ inner_diameter = self.get_inner_diameter()
+ allowable_stress = self.get_allowable_stress()
+ joint_type = self.get_joint_type()
+ y_coefficient = self.get_y_coefficient()
+ centerline_radius = self.get_centerline_radius()
+
+ table_info = {
+ 'outer_diameter': outer_diameter,
+ 'inner_diameter': inner_diameter,
+ 'allowable_stress': allowable_stress,
+ 'joint_type': joint_type,
+ 'y_coefficient' : y_coefficient,
+ 'centerline_radius': centerline_radius
+ }
+ return table_info
+
+ """
+ See the get_table_info above, this can be tacked into the the pipe info function to make a new dict with more data in it.
+ Next, we can run analysis, we have all the data we need with that new dictionary we made, it should run and tack on its findings into an even newer dict with more
+ information that analysis() calculated for us, like
+ "measured_thickness": measured_thickness,
+ "year_inspected": year_inspected,
+ "month_inspected": month_inspected,
+ "actual_thickness": actual_thickness,
+ "tmin_pressure": tmin_pressure,
+ "tmin_structural": tmin_structural,
+ "default_retirement_limit": default_retirement_limit,
+ "governing_thickness": governing_thickness,
+ "governing_type": governing_type,
+
+ After which, when the new dict is run, we can send it to the flags function below.
+ """
+
+ def get_flag_info(self):
+ pass
+
+
+
+ #####################################################################################
+ # Pressure Contianing Thickness Requirements Per ASME B31.3 Codes
+ #####################################################################################
+
+
+ def tmin_pressure(self, pipe_data: Dict[str, Any] -> float):
+
+ # passes the enriched data dictionaries through each method instead of using self references. This approach is for eliminating state dependencies.
+
+ outer_diameter = pipe_data['outer_diameter']
+ pressure = pipe_data['pressure']
+ pipe_config = pipe_data['pipe_config']
+ allowable_stress = pipe_data['allowable_stress']
+ y_coefficient = pipe_data['y_coefficient']
+ centerline_radius = pipe_data['centerline_radius']
+
+ joint_info = pipe_data['joint_type']
+ joint_efficiency = joint_info['joint_efficiency']
+ weld_strength_reduction = joint_info['weld_strength_reduction']
+
+
+ if outer_diameter is None:
+ raise ValueError(f"Invalid NPS {pipe_data['nps']} for schedule {pipe_data['schedule']}")
+ if y_coefficient is None:
+ raise ValueError(f"No Y coefficient available for NPS {pipe_data['nps']}")
+
+ """
+ Calculate minimum wall thickness for pressure design
+ Based on ASME B31.1 Para. 304.1.2a Eq. 3a
+
+ For seamless pipe (most common): E = 1.0, W = 1.0
+ For welded pipe: E and W depend on temperature and weld type
+ """
+
+ if pipe_config == 'straight':
+ pressure_thickness = (pressure * outer_diameter) / ((2 * (allowable_stress * joint_efficiency * weld_strength_reduction) + (pressure * y_coefficient)))
+ return pressure_thickness
+ elif pipe_config == '90LR - Inner Elbow':
+ radius = centerline_radius - outer_diameter/2
+ intrados_factor = (4*(radius/outer_diameter) - 1) / (4*(radius/outer_diameter) - 2)
+ return (pressure * outer_diameter) / (2 * ((allowable_stress*joint_efficiency*weld_strength_reduction)/(intrados_factor) + pressure * y_coefficient))
+
+ elif pipe_config == '90LR - Outer Elbow':
+ radius = centerline_radius + outer_diameter/2
+ extrados_factor = (4*(radius/outer_diameter) + 1) / (4*(radius/outer_diameter) + 2)
+ return (pressure * outer_diameter) / (2 * ((allowable_stress*joint_efficiency*weld_strength_reduction)/(extrados_factor) + pressure * y_coefficient))
+
+ else:
+ raise ValueError(f"Unable to calculate minimum thickness for invalid pipe configuration: {pipe_config}")
+
+
+
+
+ #####################################################################################
+ # Structural Thickness Requirements Per API 574 Codes
+ #####################################################################################
+
+
+ def tmin_structural(self, pipe_data: Dict[str, Any]) -> float:
+ """
+ Get minimum structural thickness requirement using enriched pipe data
+ """
+ # Extract values from the enriched pipe_data dict
+ nps = pipe_data['nps']
+ pressure_class = pipe_data['pressure_class']
+ metallurgy = pipe_data['metallurgy']
+ API_table = pipe_data['API_table']
+
+ if API_table == "2025":
+ if metallurgy == "Intermediate/Low CS":
+ structural_requirement = pipe_data['API574_CS_400F'][nps][pressure_class]
+ elif metallurgy in ["SS 316/316L", "SS 304/304L"]:
+ structural_requirement = pipe_data['API574_SS_400F'][nps][pressure_class]
+ else:
+ structural_requirement = pipe_data['API574_CS_400F'][nps][pressure_class]
+ elif API_table == "2009":
+ structural_requirement = pipe_data['API574_2009_TABLE_6'][nps]["default_minimum_structural_thickness"]
+ else:
+ raise ValueError(f"Invalid API table version: {API_table}. Must be '2025' or '2009'")
+
+ if structural_requirement is None:
+ raise ValueError(f"No structural thickness requirement found for NPS {nps}, "
+ f"pressure class {pressure_class}, metallurgy {metallurgy}")
+
+ return structural_requirement
+
+
+
+
+
+ def calculate_all(self, pipe_data: Dict[str, Any], measured_thickness: float,
+ year_inspected: Optional[int] = None,
+ month_inspected: Optional[int] = None) -> Dict[str, Any]:
+ """ Determines the governing thickness based on metadata and raises flags(Green, yellow, or Red) to assess safety and compliance of continued pipe operation"""
+
+ #Unpack our pipe_data to use for calculations
+ corrosion_rate = pipe_data["corrosion_rate"]
+ default_retirement_limit = pipe_data["default_retirement_limit"]
+
+
+
+
+ ####################
+ # Edge Case Handling
+ ####################
+
+ # Edge Case Error Messages
+ if month_inspected is not None and not (1 <= month_inspected <= 12):
+ raise ValueError(f"Month must be between 1 and 12, got {month_inspected}")
+
+ if year_inspected is not None and year_inspected < 1900:
+ raise ValueError(f"Year must be reasonable, got {year_inspected}")
+
+ # Get default retirement limit early for validation
+ default_retirement_limit = self.default_retirement_limit
+
+ if default_retirement_limit is not None and default_retirement_limit < self.get_structural_thickness_requirement():
+ raise ValueError(f"Default, company-specific retirement limits shall not be below API {self.API_table} appointed structural thickness requirements")
+
+ if measured_thickness > self.outer_diameter - self.inner_diameter:
+ raise ValueError(f"Measured thickness cannot be greater than nominal pipe wall thickness: units should be in inches")
+
+
+
+
+
+ # selfs in here are utility functions, do not remove
+ if year_inspected is not None and corrosion_rate is not None:
+
+ """Need to make error handling and more if statements to ensure that if there is any time difference between current year and month and
+ inspection year and month, that a time-elapsed and degradation operation is excecuted, otherwise current_thickness = measured thickness. Same with None values, this
+ assumes it is all current readins so they can pass to current_thickness = measured thickness"""
+
+ time_elapsed = self._calculate_time_elapsed(year_inspected, month_inspected)
+ degradation = corrosion_rate * time_elapsed
+ current_thickness = measured_thickness - self.mils_to_inches(degradation)
+
+ else:
+ current_thickness = measured_thickness #if no is some time difference between the current year and month and inspection year and month
+
+ # Calculate thickness requirements using enriched pipe data
+ tmin_pressure = self.tmin_pressure(pipe_data) #float, inches
+ tmin_structural = self.tmin_structural(pipe_data)#float, inches
+
+
+ # Determine governing thickness
+ if tmin_pressure >= tmin_structural:
+ governing_thickness = tmin_pressure
+ governing_type = "pressure"
+ else:
+ governing_thickness = tmin_structural
+ governing_type = "structural"
+
+
+
+ # Calculate remaining life if corrosion rate is provided
+ if corrosion_rate is not None:
+ # Define corrosion_allowance here for GREEN flag case
+ corrosion_allowance = current_thickness - governing_thickness
+ remaining_life_years = self.calculate_corrosion_allowance(corrosion_allowance, corrosion_rate)
+ else:
+ remaining_life_years = None
+ corrosion_allowance = None
+
+
+ #################################
+ # Flag Logic
+ #################################
+
+
+ #Red flag:
+ if current_thickness <= tmin_pressure:
+ pressure_thick_deficit = tmin_pressure - current_thickness
+ raised_flag = {
+ "flag": "RED",
+ "status": "IMMEDIATE_RETIREMENT",
+ "message": "Below pressure minimum - risk-based analysis needed to continue operation",
+ "pressure_deficit": pressure_thick_deficit
+ }
+
+ #Yellow Flag:
+ elif (current_thickness > tmin_pressure and
+ (current_thickness <= tmin_structural or
+ (default_retirement_limit is not None and current_thickness <= default_retirement_limit and current_thickness > tmin_pressure))):
+
+ # Check which limits are not met
+ below_structural = current_thickness <= tmin_structural
+ below_default = default_retirement_limit is not None and current_thickness <= default_retirement_limit
+
+
+ if below_structural:
+ structural_deficit = tmin_structural - current_thickness
+
+
+ if below_default:
+ default_deficit = default_retirement_limit - current_thickness
+
+ raised_flag = {
+ "flag": "YELLOW",
+ "status": "FFS_RECOMMENDED",
+ "message": "Not all criteria satisfied - FFS assessment recommended",
+ "below_structural": below_structural,
+ "below_default": below_default,
+ "structural_deficit": structural_deficit if below_structural else None,
+ "default_deficit": default_deficit if below_default else None,
+ }
+
+ #Green Flag
+ else:
+
+ if default_retirement_limit is not None and default_retirement_limit > governing_thickness:
+ next_retirement_limit = default_retirement_limit
+ retirement_type = "company-specified"
+ else:
+ next_retirement_limit = governing_thickness
+ retirement_type = f"New Retirement limit is governed by {governing_type} design"
+
+ corrosion_allowance = current_thickness - next_retirement_limit
+
+ raised_flag = {
+ "flag": "GREEN",
+ "status": "SAFE_TO_CONTINUE",
+ "message": "All criteria satisfied - pipe can safely continue in operation",
+ "corrosion_allowance": corrosion_allowance,
+ "next_retirement_limit": next_retirement_limit,
+ "retirement_type": retirement_type,
+ "remaining_life_years": remaining_life_years, # Fixed: was conditional
+ }
+
+
+ #The flag conditonal will return a small python dictionary "raised_flag" contianing key and values of the final verdict if the pipe is safe or not
+ # we can append this one last time into the overall pipe_info dictionary, completeing all computations with large, easily-callable python dictionary of the instance
+
+ # Pack all analysis results and flag information into one dictionary
+ analysis_results = {
+ # Basic analysis inputs
+ "measured_thickness": measured_thickness,
+ "year_inspected": year_inspected,
+ "month_inspected": month_inspected,
+
+ # Calculated thickness values
+ "current_thickness": current_thickness,
+ "tmin_pressure": tmin_pressure,
+ "tmin_structural": tmin_structural,
+
+ # Governing thickness information
+ "governing_thickness": governing_thickness,
+ "governing_type": governing_type,
+
+ # Time and corrosion analysis
+ "time_elapsed": time_elapsed if year_inspected is not None and corrosion_rate is not None else None,
+ "degradation_mils": degradation if year_inspected is not None and corrosion_rate is not None else None,
+
+ # Flag analysis results (this spreads in all the flag-specific keys)
+ **raised_flag
+ }
+
+ return analysis_results
+
+
+
+ def analyze(self, measured_thickness: float, year_inspected: Optional[int] = None,
+ month_inspected: Optional[int] = None) -> Dict[str, Any]:
+ """
+ Main analysis method that automatically orchestrates the entire dict enrichment process.
+ This is the one-stop method users call to get a complete analysis package.
+
+ Usage:
+ tml_161 = PIPE(kwargs)
+ complete_analysis = tml_161.analyze(measured_thickness, year_inspected, month_inspected)
+
+ Returns:
+ Complete dictionary containing all pipe information, analysis results, and flags
+ """
+ # Stage 1: Get basic pipe metadata
+ metadata = self.pipe_info()
+
+ # Stage 2: Enrich with table lookups (dimensions, material properties, etc.)
+ table_info = self.get_table_info()
+ enriched_data = self.pipe_info(metadata, table_info)
+
+ # Stage 3: Run analysis calculations and get results (including flags)
+ analysis_results = self.calculate_all(enriched_data, measured_thickness, year_inspected, month_inspected)
+
+ # Stage 4: Final enrichment - combine everything into one complete package
+ final_data = self.pipe_info(enriched_data, analysis_results)
+
+ return final_data
+
+
+
+
From 9bfa15f8d9b0017ae925a5972eecdc6edf9f119c Mon Sep 17 00:00:00 2001
From: Andrew Trepagnier <160799843+AndrewTrepagnier@users.noreply.github.com>
Date: Mon, 25 Aug 2025 06:28:47 -0500
Subject: [PATCH 06/10] wip: uploaded tex file of the technical design doc
---
official_design_doc.tex | 184 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 184 insertions(+)
create mode 100644 official_design_doc.tex
diff --git a/official_design_doc.tex b/official_design_doc.tex
new file mode 100644
index 0000000..874cf9b
--- /dev/null
+++ b/official_design_doc.tex
@@ -0,0 +1,184 @@
+\documentclass[11pt]{article}
+
+%--------------------------------------------------
+% Packages
+%--------------------------------------------------
+\usepackage[a4paper,margin=1in]{geometry}
+\usepackage{graphicx}
+\usepackage{amsmath, amssymb}
+\usepackage{hyperref}
+\usepackage{fancyhdr}
+\usepackage{color}
+\usepackage{listings}
+\usepackage{float}
+\usepackage{titlesec}
+\usepackage{setspace}
+\usepackage{lmodern}
+\usepackage{titling}
+
+%--------------------------------------------------
+% Metadata
+%--------------------------------------------------
+\newcommand{\doctitle}{TMIN: The Fast Pipe Thickness Analysis Tool}
+\newcommand{\docsubtitle}{A Technical Design Document}
+\newcommand{\authorname}{Andrew Trepagnier}
+\newcommand{\organization}{Author Affiliations: ExxonMobil Corporation \\ Mechanical Integrity Engineering Group, BMRF}
+
+%--------------------------------------------------
+% Code Listings Style
+%--------------------------------------------------
+\lstset{
+ basicstyle=\ttfamily\small,
+ backgroundcolor=\color{gray!10},
+ frame=single,
+ breaklines=true,
+ captionpos=b,
+ numbers=left,
+ numberstyle=\tiny,
+ keywordstyle=\color{blue},
+ commentstyle=\color{gray},
+ stringstyle=\color{red},
+ showstringspaces=false
+}
+
+%--------------------------------------------------
+% Header & Footer
+%--------------------------------------------------
+\pagestyle{fancy}
+\fancyhf{}
+\rhead{TMIN Design Document}
+\lhead{}
+\rfoot{Page \thepage}
+
+%--------------------------------------------------
+% Title Formatting
+%--------------------------------------------------
+\titleformat{\section}
+ {\normalfont\Large\bfseries}{\thesection}{1em}{}
+
+\titleformat{\subsection}
+ {\normalfont\large\bfseries}{\thesubsection}{1em}{}
+
+%--------------------------------------------------
+% Begin Document
+%--------------------------------------------------
+\begin{document}
+
+%--------------------------------------------------
+% Cover Page
+%--------------------------------------------------
+\begin{titlepage}
+ \centering
+ \vspace*{4cm}
+ {\Huge \bfseries \doctitle \par}
+ \vspace{0.5cm}
+ {\Large \docsubtitle \par}
+ \vfill
+ {\large \today \par}
+ \vspace{14cm}
+ \rule{\linewidth}{0.5pt}
+ \vspace{0.3cm}
+ {\small \authorname \par}
+ {\small \organization \par}
+\end{titlepage}
+
+%--------------------------------------------------
+% Table of Contents
+%--------------------------------------------------
+\tableofcontents
+\newpage
+
+%--------------------------------------------------
+% Sections
+%--------------------------------------------------
+\section{Purpose}
+The purpose of this document is to provide users with the mathematical approaches and procedures used within the computations of the open source software package, TMIN. After reading, users should have a thorough understanding of how minimum thickness calculations are determined according to industry code as well as what the TMIN package's limitations are.
+
+
+\section{What is TMIN}
+
+TMIN is an abbreviated term for "minimum thickness" within the process piping inspection and engineering. Minimum thickness is a pipe's lowest allowable wall thickness before mechanical/structural failure, such as bursting or folding under it's own weight, becomes a hazard. Most process pipes experience some degree of internal corrosion, thus, causing thinning over time and forcing pipe retirement before a potential loss of containment.
+
+\section{Scientific Basis}
+TMIN is based on established mechanical engineering principles from ASME B31.3 and API 574. It evaluates hoop stress, corrosion allowances, and pressure limits based on user-defined material and geometry parameters.
+
+Thinned pipes can fail structurally or due to pressure. Section 3.1 discusses the mathematical approach to solving the minimum allowable pressure-containing thickness and Section 3.2 discusses structural considerations, followed by Section 4 and 5, the limits and assumptions of the TMIN package.
+
+\subsection{Minimum Pressure-Containing Thickness }
+\begin{equation}
+ t_{\text{min}} = \frac{P D}{2( S E W + P Y)}
+\end{equation}
+
+Where:
+\begin{itemize}
+ \item $P$ = Design Pressure
+ \item $D$ = Outside Diameter
+ \item $S$ = Allowable Stress
+ \item $E$ = Weld Joint Efficiency
+ \item $Y$ = Coefficient for Material
+ \item $C$ = Corrosion Allowance
+\end{itemize}
+
+\section{Core Features}
+\begin{itemize}
+ \item Simple CLI and API interface
+ \item Standards-based calculations (ASME)
+ \item Fast performance for batch evaluations
+ \item Extensible and modular Python codebase
+ \item Includes error-checking and input validation
+\end{itemize}
+
+\section{Installation and Usage}
+\subsection*{Installation}
+\begin{lstlisting}[language=bash]
+pip install tmin
+\end{lstlisting}
+
+\subsection*{Example Usage}
+\begin{lstlisting}[language=Python]
+from tmin import calculate_tmin
+
+t = calculate_tmin(P=150, D=10.75, S=20000, E=1.0, Y=0.4, C=0.125)
+print(f"Minimum thickness: {t:.3f} in")
+\end{lstlisting}
+
+\section{Limitations and Assumptions}
+\begin{itemize}
+ \item Assumes linear elastic material behavior
+ \item Only applicable to cylindrical geometries
+ \item Requires accurate material properties from user
+ \item Not a substitute for full FEA or regulatory analysis
+\end{itemize}
+
+\section{Design Decisions}
+\begin{itemize}
+ \item Written in pure Python for accessibility
+ \item No external dependencies for core functions
+ \item Open-source with permissive license
+ \item Design favors speed over generality
+\end{itemize}
+
+\section{Use Cases}
+\begin{itemize}
+ \item Rapid design checks during piping layout
+ \item Automation of pipe thickness reports
+ \item Educational tool for engineering students
+ \item Batch processing of pipe specifications
+\end{itemize}
+
+\section{Future Work}
+\begin{itemize}
+ \item Add support for additional design codes (e.g., B31.1, EN)
+ \item Build a web front-end for visualization
+ \item Add sensitivity analysis / uncertainty quantification
+ \item Export capabilities to Excel and PDFs
+\end{itemize}
+
+\section{References}
+\begin{itemize}
+ \item ASME B31.3 – Process Piping Code
+ \item Roark's Formulas for Stress and Strain
+ \item Python Software Foundation Documentation
+\end{itemize}
+
+\end{document}
From 9a83bde66ce5a76b538748e5c345ed7851494d14 Mon Sep 17 00:00:00 2001
From: AndrewTrepagnier
Date: Mon, 25 Aug 2025 06:38:06 -0500
Subject: [PATCH 07/10] maint: cleaned up gitignore files .DS_Store,
README_EXAMPLES, example_pipe_config.toml, and tmin.egg-info
---
.DS_Store | Bin 8196 -> 0 bytes
.gitignore | 1 +
README_EXAMPLES.md | 1 -
example_pipe_config.toml | 21 ---
tmin.egg-info/PKG-INFO | 208 -----------------------------
tmin.egg-info/SOURCES.txt | 23 ----
tmin.egg-info/dependency_links.txt | 1 -
tmin.egg-info/entry_points.txt | 2 -
tmin.egg-info/requires.txt | 9 --
tmin.egg-info/top_level.txt | 1 -
10 files changed, 1 insertion(+), 266 deletions(-)
delete mode 100644 .DS_Store
delete mode 100644 README_EXAMPLES.md
delete mode 100644 example_pipe_config.toml
delete mode 100644 tmin.egg-info/PKG-INFO
delete mode 100644 tmin.egg-info/SOURCES.txt
delete mode 100644 tmin.egg-info/dependency_links.txt
delete mode 100644 tmin.egg-info/entry_points.txt
delete mode 100644 tmin.egg-info/requires.txt
delete mode 100644 tmin.egg-info/top_level.txt
diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index e0d12c7f69a5a843acfbf29e601e9948f8ad65f7..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 8196
zcmeHMTWl0n7(U;0OJ@dZPs^?B1lZ6>u@os)ih#oQ1|?vM-IkUDuDd$}OqkBpo!MQG
z5F3dP5^wRv2Nf@`MiU>r5jE-yyo!yH2Tjz(7fnPTjfwte&McwZ^5TObGAEhypYvbN
zIsbpYf6ks+#u)la<`%|U8DlCvg7U>wEl`A9JoAbYaq5YJ>=`Svfcg1~Kb?2xX~&E>
z0&xW52*eSHBM?X6c8CC-*`lboIQOMAuHy*A5x65GAl?rtdIU{JbVkzf)j?Hw1t6Lr
zc37xQ_khqQ714A=XCw_xX-s*0KxB&OiUDa(@{~{~nvUpyyS=-Mnp=C$ozv9iZJT$FWKTI$uJ?@e1JS#HHP-lb
zVZKw2U+&L4!l}AdRj0nKI;H80jj==eM3%RdY30Myf$h2eA*<*U&GIInJlt3C+=E3+
z$j1wwQ<`%9EN?F4oI=UT1y--)Wb7v_e2ck9Ud&QtXRLpW-@~shz^z5=VK-&I<
zO~VPbWi?BO_U@A9tJkgH*tK=XuKvCIhi4n_YU0f;x;{x^as&IgZ578Vwi%o}obwBo
zYufIK@v`mPdB+-cb5m9`IyG&!amC8kRY}9RmFh$(Z$DnLgEPEMRl_;ftTptbCN1t^
z&lIzm{C$5!3&-K1gbGgM44%RT
zJd5Y>JYL2dcoT2oZCt`9_!OVvGOprl%;G!zhU@qpe<}&3URk6xDO_n$mMZI&cBM;s
zNZBRVtyI(I@LcREd#PXZE}*obZ+RkZlwhH>Ne=GOhi;3sxwb?m%F<=a*R*Ze)ZMdl
z_YFWv1Xe+JGn7O8&e`7(zhNTPU~g7tl)k!}ZMCU2M!>gDt0Mn+gVavx>r?_hub17s
z^>&pY&lkyVN3v6;B;!ih)i)=%Xp~C4PIfzzTQ!wKb{^1oXp~euA-jgYOVcZ&
zh%9G{uH(5UNFs2YpfAo-%w}#4f9^nWZ=nd(NoK3K{}0~!`~O0wF)xll9D)By1dtfW
z4D^vR)gK16eksIA(eoHRq6obiNkbE=go@yY*KwjZ{xGC=Dw3rmIwL7bsQl+20^Iog
PpZAM3KL0~3-bvLz7)dts
diff --git a/.gitignore b/.gitignore
index dfc4f38..2e61275 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@ sdist/
var/
wheels/
*.egg-info/
+
.installed.cfg
*.egg
diff --git a/README_EXAMPLES.md b/README_EXAMPLES.md
deleted file mode 100644
index 0519ecb..0000000
--- a/README_EXAMPLES.md
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/example_pipe_config.toml b/example_pipe_config.toml
deleted file mode 100644
index e8197df..0000000
--- a/example_pipe_config.toml
+++ /dev/null
@@ -1,21 +0,0 @@
-# TMIN Pipe Configuration Example
-# Save this as pipe_config.toml and use: tmin -f pipe_config.toml -t 0.060
-
-# Required parameters
-schedule = "40"
-nps = "2"
-pressure = 50.0
-pressure_class = 150
-metallurgy = "Intermediate/Low CS"
-allowable_stress = 23333.0 # 35000 * 2/3 for A106 GR B
-
-# Optional parameters with defaults
-design_temp = "900"
-pipe_config = "straight"
-corrosion_rate = 10.0 # MPY
-default_retirement_limit = 0.050 # inches
-api_table = "2025"
-year_inspected = 2023
-
-# Note: measured_thickness must be provided on command line
-# Example: tmin -f pipe_config.toml -t 0.060
\ No newline at end of file
diff --git a/tmin.egg-info/PKG-INFO b/tmin.egg-info/PKG-INFO
deleted file mode 100644
index 556a138..0000000
--- a/tmin.egg-info/PKG-INFO
+++ /dev/null
@@ -1,208 +0,0 @@
-Metadata-Version: 2.4
-Name: tmin
-Version: 1.0.0
-Summary: A comprehensive pipe thickness analysis tool for mechanical integrity engineering
-Author-email: Andrew Trepagnier
-License: MIT
-Project-URL: Homepage, https://github.com/AndrewTrepagnier/tmin
-Project-URL: Repository, https://github.com/AndrewTrepagnier/tmin
-Project-URL: Issues, https://github.com/AndrewTrepagnier/tmin/issues
-Classifier: Development Status :: 4 - Beta
-Classifier: Intended Audience :: Science/Research
-Classifier: License :: OSI Approved :: MIT License
-Classifier: Operating System :: OS Independent
-Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Programming Language :: Python :: 3.10
-Classifier: Programming Language :: Python :: 3.11
-Classifier: Topic :: Scientific/Engineering
-Classifier: Topic :: Scientific/Engineering :: Physics
-Requires-Python: >=3.8
-Description-Content-Type: text/markdown
-License-File: LICENSE
-Requires-Dist: numpy>=1.23
-Requires-Dist: matplotlib>=3.5
-Requires-Dist: toml>=0.10.0
-Requires-Dist: jupyter>=1.0.0
-Requires-Dist: notebook>=6.0.0
-Provides-Extra: dev
-Requires-Dist: pytest>=7.0; extra == "dev"
-Requires-Dist: pytest-cov>=4.0; extra == "dev"
-Dynamic: license-file
-
-# TMIN: The Fast Pipe Thickness Analysis Tool
-
-
-
-
-
-[](https://pepy.tech/project/tmin)
-[](https://badge.fury.io/py/tmin)
-
-[](https://your-blog-link.com)
-[](https://github.com/AndrewTrepagnier/tmin/wiki)
-[](https://your-blog-link.com)
-
-TMIN is an open source software designed to help engineers determine if corroded process piping in refineries and pertrochemical plants are **safe** and **API-compliant** — in seconds.
-
-Many oil and gas companies are faced with maintaining thousands of miles of 100+ year old piping networks supporting multi-million dollar/year processing operations. There is rarely a simple solution to immediately shutdown a process pipe - as these shutdowns more often than not impact other units and cost companies millions in time and resources.
-
-***This is more than a python package, it is a comprehensive engineering decision support system for critical infrastructure safety and operational continuity.***
-
----
-
-# Getting Started
-
-### Installation:
-
-```bash
-pip install tmin
-```
-
-### Basic Example:
-
-```python
-from tmin.core import PIPE
-
-# Create pipe instance
-pipe = PIPE(
- nps="2",
- schedule="40",
- pressure=50.0,
- pressure_class=150,
- metallurgy="Intermediate/Low CS",
- corrosion_rate=10.0
-)
-
-# Analyze thickness and generate report
-results = pipe.analysis(measured_thickness=0.188)
-report = pipe.report("TXT") # Options: "CSV", "JSON", "TXT", "IPYNB"
-
-print(f"Flag: {results['flag']}")
-print(f"Status: {results['status']}")
-print(f"Report saved: {report['file_path']}")
-```
-
-### Suppose the following scenario:
-
-
-
-RT findings show your 2" Schedule 40 pipe has 0.060" wall thickness. You need to know if it's safe to operate and how much time remains before pipe retirement.
-
-**The Previous Way:** time consuming handwritten calculations, tedious code book lookups, and hours of typing full assessment reports
-
-**With TMIN:** One Python script, instant answers.
-
-```python
-import tmin
-
-# Create pipe instance
-pipe = tmin.PIPE(
- schedule="40",
- nps="2",
- pressure=50.0,
- pressure_class=150,
- metallurgy="Intermediate/Low CS",
- allowable_stress=23333.0
-)
-
-# Analyze thickness
-results = pipe.analysis(measured_thickness=0.060)
-print(f"Safe to operate: {results['actual_thickness'] > results['governing_thickness']}")
-print(f"Remaining life: {results['life_span']} years")
-```
-
-**Result:** Professional report with compliance status, remaining life, and visual analysis in under 30 seconds.
-
-
-## Why TMIN
-
-**Speed**
-30 seconds from inspection data to compliance report. No manual calculations or code book lookups. Instant visual analysis.
-
-**Accuracy**
-Built on ASME B31.1 and API 574 standards. Automatic governing factor determination. Time-based corrosion adjustment.
-
-**Professional Output**
-Auto-generated reports with timestamps. Visual thickness analysis charts. Compliance documentation for audits.
-
-**Real-World Ready**
-Handles corrosion rates and inspection dates. Supports multiple metallurgies and pipe schedules. TOML configuration for batch analysis.
-
----
-
-## What TMIN Analyzes
-
-**Pressure Design (ASME B31.1)**
-Minimum wall thickness for pressure containment. Temperature effects and material properties. Y-coefficient calculations.
-
-**Structural Requirements (API 574)**
-Minimum thickness for structural integrity. Pipe deflection and weight loading. Table D.2 compliance.
-
-**Corrosion Analysis**
-Time-based thickness adjustment. Remaining life prediction. Corrosion allowance calculations.
-
-**Compliance Reporting**
-Governing factor identification. Safety margin analysis. Professional documentation.
-
----
-
-## Supported Specifications
-
-| Schedules | NPS Sizes | Pressure Classes | Metallurgies |
-|-----------|-----------|------------------|--------------|
-| 10, 40, 80, 120, 160 | 0.5" to 24" | 150, 300, 600, 900, 1500, 2500 | Carbon Steel, Stainless Steel, Nickel Alloys |
-
-
-## Command Line Interface
-
-**Basic Analysis**
-```bash
-tmin -s 40 -n "2" -p 50 -c 150 -m "Intermediate/Low CS" -a 23333 -t 0.060
-```
-
-**With Corrosion Rate**
-```bash
-tmin -s 40 -n "2" -p 50 -c 150 -m "Intermediate/Low CS" -a 23333 -t 0.060 -r 10 -y 2023
-```
-
-**Using Configuration File**
-```bash
-# Create pipe_config.toml with your parameters
-tmin -f pipe_config.toml -t 0.060
-```
-
-**Custom Output Directory**
-```bash
-tmin -s 40 -n "2" -p 50 -c 150 -m "Intermediate/Low CS" -a 23333 -t 0.060 -o ./my_reports
-```
-
----
-
-## Test It Yourself
-
-```bash
-# Install and test
-pip install tmin
-python -m pytest tests/test_core.py -v
-```
-
----
-
-## Need Help?
-
-**Documentation:** Built-in help with `tmin --help`
-**Examples:** See `tutorials/python_scripts/basic_example.py`
-**Contact:** andrew[dot]trepagnier[at]icloud[dot]com
-
----
-
-## Disclaimer
-
-TMIN is a decision support tool for qualified engineers. Always use professional judgment and follow applicable codes and standards.
-
-**License:** MIT
-
-
-
diff --git a/tmin.egg-info/SOURCES.txt b/tmin.egg-info/SOURCES.txt
deleted file mode 100644
index 1da3ee6..0000000
--- a/tmin.egg-info/SOURCES.txt
+++ /dev/null
@@ -1,23 +0,0 @@
-LICENSE
-README.md
-pyproject.toml
-tests/test_core.py
-tmin/__init__.py
-tmin/cli.py
-tmin/core.py
-tmin/report_generator.py
-tmin/visualization.py
-tmin.egg-info/PKG-INFO
-tmin.egg-info/SOURCES.txt
-tmin.egg-info/dependency_links.txt
-tmin.egg-info/entry_points.txt
-tmin.egg-info/requires.txt
-tmin.egg-info/top_level.txt
-tmin/tables/ANSI_radii.py
-tmin/tables/__init__.py
-tmin/tables/api_574_2009.py
-tmin/tables/api_574_2025.py
-tmin/tables/id_table.py
-tmin/tables/od_table.py
-tmin/tables/wsrf.py
-tmin/tables/y_coeff.py
\ No newline at end of file
diff --git a/tmin.egg-info/dependency_links.txt b/tmin.egg-info/dependency_links.txt
deleted file mode 100644
index 8b13789..0000000
--- a/tmin.egg-info/dependency_links.txt
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/tmin.egg-info/entry_points.txt b/tmin.egg-info/entry_points.txt
deleted file mode 100644
index 838e80f..0000000
--- a/tmin.egg-info/entry_points.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-[console_scripts]
-tmin = tmin.cli:main
diff --git a/tmin.egg-info/requires.txt b/tmin.egg-info/requires.txt
deleted file mode 100644
index 9e53f35..0000000
--- a/tmin.egg-info/requires.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-numpy>=1.23
-matplotlib>=3.5
-toml>=0.10.0
-jupyter>=1.0.0
-notebook>=6.0.0
-
-[dev]
-pytest>=7.0
-pytest-cov>=4.0
diff --git a/tmin.egg-info/top_level.txt b/tmin.egg-info/top_level.txt
deleted file mode 100644
index 51faaa5..0000000
--- a/tmin.egg-info/top_level.txt
+++ /dev/null
@@ -1 +0,0 @@
-tmin
From 9692632497384b17a729caab2414290b9b491aea Mon Sep 17 00:00:00 2001
From: Andrew Trepagnier <160799843+AndrewTrepagnier@users.noreply.github.com>
Date: Tue, 26 Aug 2025 06:34:07 -0500
Subject: [PATCH 08/10] ENH: new set up instructions for README
---
README.md | 59 +++++++++++++------------------------------------------
1 file changed, 14 insertions(+), 45 deletions(-)
diff --git a/README.md b/README.md
index d0c89aa..2409c6a 100644
--- a/README.md
+++ b/README.md
@@ -4,22 +4,22 @@
-[](https://pepy.tech/project/tmin)
+[](https://pepy.tech/project/tmin)
[](https://badge.fury.io/py/tmin)

[](https://your-blog-link.com)
[](https://github.com/AndrewTrepagnier/tmin/wiki)
[](https://your-blog-link.com)
-TMIN (an abbreviation for "minimum thickness") is an open source software designed to help engineers determine if corroded process piping in refineries and pertrochemical plants are **safe** and **API-compliant** — in seconds.
+TMIN (an abbreviation for "minimum thickness") is an open source python package designed to help engineers determine if corroded process piping in refineries and pertrochemical plants are **safe** and **API-compliant** — in seconds.
Many oil and gas companies are faced with maintaining thousands of miles of 100+ year old piping networks supporting multi-million dollar/year processing operations. There is rarely a simple solution to immediately shutdown a process pipe - as these shutdowns more often than not impact other units and cost companies millions in time and resources.
-***This is more than a python package, it is a comprehensive engineering decision support system for critical infrastructure safety and operational continuity.***
+***TMIN can be used as a conservative and rapid engineering support tool for assessing piping inspection data and determine how close the pipe is to its end of service life.***
---
-# Getting Started
+# How to install and get started
### Installation:
@@ -31,47 +31,16 @@ pip install tmin
Suppose the following scenario:
-
-
-RT findings show your 2" Schedule 40 pipe has 0.060" wall thickness. You need to know if it's safe to operate and how much time remains before pipe retirement.
-
-TMIN can will preform calculations, create visuals, and generate a full assessment report - with only three python functions.
-
-```python
-from tmin.core import PIPE
-from tmin.visualization import ThicknessVisualizer
-
-# Create pipe instance
-pipe = PIPE(
- nps=2,
- schedule=40,
- pressure=300.0,
- pressure_class=150,
- metallurgy="Intermediate/Low CS",
- corrosion_rate=12.0,
- yield_stress=33000.0, # 33 ksi for ~22 ksi allowable stress
- design_temp=600.0
-)
-
-# Analyze thickness and generate report
-results = pipe.analysis(
- measured_thickness=0.188,
- year_inspected=2024,
- month_inspected=6
-)
-report = pipe.report("TXT") # Options: "CSV", "JSON", "TXT", "IPYNB"
-
-# Create visualizations
-visualizer = ThicknessVisualizer()
-comparison_chart = visualizer.create_comparison_chart(results, 0.188)
-number_line = visualizer.create_thickness_number_line(pipe, results, 0.188)
-
-print(f"Flag: {results['flag']}")
-print(f"Status: {results['status']}")
-print(f"Report saved: {report['file_path']}")
-print(f"Comparison chart: {comparison_chart}")
-print(f"Number line visualization: {number_line}")
-```
+
+
+
+RT Inspection findings show your 3/4" S/160 pipe has 0.092" (or 2.36 mm) wall thickness. When it was first installed in 2012, it had a nominal thickness of 0.219", indicating that it has corroded 0.126" (or 126 Mils) in 13 years.
+
+You need to know if the pipe can safely remain in service until TA 2027 when it can be replaced.
+
+TMIN will preform calculations, create visuals, and generate a full assessment report - with only three python functions.
+
+
**Results:** Professional report with compliance status, remaining life, and visual analysis in under 30 seconds.
From 1f2729f8b4709cb8d878a7b55a023fd44e2b70ec Mon Sep 17 00:00:00 2001
From: Andrew Trepagnier
Date: Sat, 13 Sep 2025 11:56:58 -0500
Subject: [PATCH 09/10] ENH: Major additions to testing suiteand generator
function in analyze
---
tmin/core.py | 4 +-
tmin/core_dev.py | 214 ++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 189 insertions(+), 29 deletions(-)
diff --git a/tmin/core.py b/tmin/core.py
index 0241998..370236b 100644
--- a/tmin/core.py
+++ b/tmin/core.py
@@ -374,7 +374,7 @@ def analysis(self, measured_thickness : float, year_inspected : Optional[int] =
self._last_analysis_results = result
# TODO: This still requires extensive work
- return pack_analysis_data = {
+ pack_analysis_data = {
"measured_thickness": measured_thickness,
"year_inspected": year_inspected,
"month_inspected": month_inspected,
@@ -386,6 +386,8 @@ def analysis(self, measured_thickness : float, year_inspected : Optional[int] =
"governing_type": governing_type,
"limits": limits,
**raised_flag}
+
+ return pack_analysis_data
diff --git a/tmin/core_dev.py b/tmin/core_dev.py
index 68f0935..15bdef7 100644
--- a/tmin/core_dev.py
+++ b/tmin/core_dev.py
@@ -10,6 +10,8 @@
from typing import Literal, Optional, Dict, Any
import matplotlib.pyplot as plt
from datetime import datetime
+import sympy as sp
+from sympy import symbols, latex, pprint
@@ -19,17 +21,16 @@ class PIPE:
#########Initialize Characteristics of the Pipe and Service###############
pressure: float # Design pressure (psi)
- design_temp: Literal["<900" ,900, 950, 1000, 1050, 1100, 1150, 1200, 1250, "1250+" ] = 900 #Design temp in Fahrenheit
nps: float # Nominal pipe size in decimal form (e.g. '0.75', '1.5', '2')
schedule: float # Pipe schedule (10, 40, 80, 120, 160)
pressure_class: Literal[150, 300, 600, 900, 1500, 2500]
metallurgy: Literal["Intermediate/Low CS", "SS 316/316L", "SS 304/304L", "Inconel 625", "Other"]
yield_stress: float # psi, yield stresses vary from year to year of manufactured piping, ensure the correct year's yield stress is used
- pipe_config: Literal["straight", "90LR - Inner Elbow", "90LR - Outer Elbow"] = "straight"
- corrosion_rate: Optional[float] = None #Mils per Year (MPY)
-
# Optional fields with defaults
+ design_temp: Literal["<900" ,900, 950, 1000, 1050, 1100, 1150, 1200, 1250, "1250+" ] = 900 #Design temp in Fahrenheit
+ pipe_config: Literal["straight", "90LR - Inner Elbow", "90LR - Outer Elbow"] = "straight"
+ corrosion_rate: Optional[float] = None #Mils per Year (MPY)
default_retirement_limit: Optional[float] = None
API_table : Literal["2025", "2009"] = "2025" # Optional, year 2025 is assumed if left blank
joint_type: Literal["seamless"] = "seamless" # As of now, TMIN only supports seamless piping analysis, TODO: Add arc butt welded pipe configs
@@ -71,20 +72,21 @@ def pipe_info(self, data_storage, table_info: Optional[Dict[str, Any]] = None,
4. final = pipe.pipe_info(flagged, None, result_data) # Add analysis results
"""
- data_storage = {
- 'pressure': self.pressure,
- 'design_temp': self.design_temp,
- 'nps': self.nps,
- 'schedule': self.schedule,
- 'pressure_class': self.pressure_class,
- 'metallurgy': self.metallurgy,
- 'yield_stress': self.yield_stress,
- 'pipe_config': self.pipe_config,
- 'corrosion_rate': self.corrosion_rate,
- 'default_retirement_limit': self.default_retirement_limit,
- 'API_table': self.API_table,
- 'joint_type': self.joint_type,
- }
+ if data_storage is None:
+ data_storage = {
+ 'pressure': self.pressure,
+ 'design_temp': self.design_temp,
+ 'nps': self.nps,
+ 'schedule': self.schedule,
+ 'pressure_class': self.pressure_class,
+ 'metallurgy': self.metallurgy,
+ 'yield_stress': self.yield_stress,
+ 'pipe_config': self.pipe_config,
+ 'corrosion_rate': self.corrosion_rate,
+ 'default_retirement_limit': self.default_retirement_limit,
+ 'API_table': self.API_table,
+ 'joint_type': self.joint_type,
+ }
# Stage 1: Merge in table_info (dimensions, material properties, etc.)
@@ -178,17 +180,17 @@ def get_joint_type(self) -> Dict[str, float]:
@staticmethod
- def inches_to_mils(self, inches_value: float) -> float:
+ def inches_to_mils(inches_value: float) -> float:
"""Convert inches to mils (1 inch = 1000 mils)"""
return inches_value * 1000
@staticmethod
- def mils_to_inches(self, mils_value: float) -> float:
+ def mils_to_inches(mils_value: float) -> float:
"""Convert mils to inches (1000 mils = 1 inch)"""
return mils_value * 0.001
@staticmethod
- def _calculate_time_elapsed(self, year_inspected: int, month_inspected: Optional[int] = None) -> float:
+ def _calculate_time_elapsed(year_inspected: int, month_inspected: Optional[int] = None) -> float:
"""
Calculate precise time elapsed since inspection date
@@ -220,7 +222,7 @@ def _calculate_time_elapsed(self, year_inspected: int, month_inspected: Optional
return time_elapsed
@staticmethod
- def _format_inspection_date(self, year: int, month: Optional[int] = None) -> str:
+ def _format_inspection_date(year: int, month: Optional[int] = None) -> str:
"""Format inspection date for display"""
month_str = f"{month:02d}" if month is not None else "01"
return f"{year}-{month_str}"
@@ -242,7 +244,10 @@ def get_table_info(self):
'allowable_stress': allowable_stress,
'joint_type': joint_type,
'y_coefficient' : y_coefficient,
- 'centerline_radius': centerline_radius
+ 'centerline_radius': centerline_radius,
+ 'API574_CS_400F': self.API574_CS_400F,
+ 'API574_SS_400F': self.API574_SS_400F,
+ 'API574_2009_TABLE_6': self.API574_2009_TABLE_6
}
return table_info
@@ -273,7 +278,7 @@ def get_flag_info(self):
#####################################################################################
- def tmin_pressure(self, pipe_data: Dict[str, Any] -> float):
+ def tmin_pressure(self, pipe_data: Dict[str, Any]) -> float:
# passes the enriched data dictionaries through each method instead of using self references. This approach is for eliminating state dependencies.
@@ -384,10 +389,10 @@ def calculate_all(self, pipe_data: Dict[str, Any], measured_thickness: float,
# Get default retirement limit early for validation
default_retirement_limit = self.default_retirement_limit
- if default_retirement_limit is not None and default_retirement_limit < self.get_structural_thickness_requirement():
+ if default_retirement_limit is not None and default_retirement_limit < self.tmin_structural(pipe_data):
raise ValueError(f"Default, company-specific retirement limits shall not be below API {self.API_table} appointed structural thickness requirements")
- if measured_thickness > self.outer_diameter - self.inner_diameter:
+ if measured_thickness > pipe_data['outer_diameter'] - pipe_data['inner_diameter']:
raise ValueError(f"Measured thickness cannot be greater than nominal pipe wall thickness: units should be in inches")
@@ -543,7 +548,7 @@ def analyze(self, measured_thickness: float, year_inspected: Optional[int] = Non
Complete dictionary containing all pipe information, analysis results, and flags
"""
# Stage 1: Get basic pipe metadata
- metadata = self.pipe_info()
+ metadata = self.pipe_info(None)
# Stage 2: Enrich with table lookups (dimensions, material properties, etc.)
table_info = self.get_table_info()
@@ -553,10 +558,163 @@ def analyze(self, measured_thickness: float, year_inspected: Optional[int] = Non
analysis_results = self.calculate_all(enriched_data, measured_thickness, year_inspected, month_inspected)
# Stage 4: Final enrichment - combine everything into one complete package
- final_data = self.pipe_info(enriched_data, analysis_results)
+ final_data = self.pipe_info(enriched_data, None, None, analysis_results)
+
+ # Stage 5: Generate SymPy mathematical report
+ sympy_report = self.quick_report(final_data)
+ final_data['sympy_report'] = sympy_report
return final_data
+
+ def calculate_corrosion_allowance(self, excess_thickness: float, corrosion_rate: float) -> float:
+ """Calculate remaining life based on excess thickness and corrosion rate"""
+ return self.inches_to_mils(excess_thickness) / corrosion_rate
+
+
+
+ def quick_report(self, analysis_data: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Generate a lightweight SymPy-based report showing equations and calculations.
+
+ This creates a symbolic representation of the pressure design equation and displays
+ input parameters and computed outputs in a clean, mathematical format.
+
+ Args:
+ analysis_data: Complete analysis data from analyze() or analyze_generator()
+
+ Returns:
+ Dict containing SymPy equations, input table, and output table
+ """
+
+ # Define symbolic variables
+ P, D, S, E, W, Y = symbols('P D S E W Y')
+
+ # ASME B31.1 Pressure Design Equation (Eq. 3a)
+ # t = (P * D) / (2 * (S * E * W) + (P * Y))
+ pressure_eq = (P * D) / (2 * (S * E * W) + (P * Y))
+
+ # Substitute actual values
+ pressure_eq_substituted = pressure_eq.subs({
+ P: analysis_data['pressure'],
+ D: analysis_data['outer_diameter'],
+ S: analysis_data['allowable_stress'],
+ E: analysis_data['joint_type']['joint_efficiency'],
+ W: analysis_data['joint_type']['weld_strength_reduction'],
+ Y: analysis_data['y_coefficient']
+ })
+
+ # Create input parameters table
+ input_table = {
+ 'Parameter': ['Design Pressure (P)', 'Outside Diameter (D)', 'Allowable Stress (S)',
+ 'Joint Efficiency (E)', 'Weld Strength Reduction (W)', 'Y Coefficient (Y)'],
+ 'Symbol': ['P', 'D', 'S', 'E', 'W', 'Y'],
+ 'Value': [
+ f"{analysis_data['pressure']:.1f} psi",
+ f"{analysis_data['outer_diameter']:.3f} in",
+ f"{analysis_data['allowable_stress']:.0f} psi",
+ f"{analysis_data['joint_type']['joint_efficiency']:.1f}",
+ f"{analysis_data['joint_type']['weld_strength_reduction']:.1f}",
+ f"{analysis_data['y_coefficient']:.3f}"
+ ],
+ 'Units': ['psi', 'in', 'psi', '-', '-', '-']
+ }
+
+ # Create computed outputs table
+ output_table = {
+ 'Result': ['Pressure Minimum Thickness', 'Structural Minimum Thickness',
+ 'Governing Thickness', 'Actual Thickness', 'Corrosion Allowance',
+ 'Flag Status', 'Governing Type'],
+ 'Value': [
+ f"{analysis_data['tmin_pressure']:.4f} in",
+ f"{analysis_data['tmin_structural']:.4f} in",
+ f"{analysis_data['governing_thickness']:.4f} in",
+ f"{analysis_data['current_thickness']:.4f} in",
+ f"{analysis_data.get('corrosion_allowance', 'N/A')}",
+ f"{analysis_data['flag']}",
+ f"{analysis_data['governing_type']}"
+ ],
+ 'Units': ['in', 'in', 'in', 'in', 'in', '-', '-']
+ }
+
+ # Create SymPy report
+ sympy_report = {
+ 'equation': {
+ 'symbolic': pressure_eq,
+ 'substituted': pressure_eq_substituted,
+ 'latex': latex(pressure_eq),
+ 'latex_substituted': latex(pressure_eq_substituted)
+ },
+ 'input_parameters': input_table,
+ 'computed_outputs': output_table,
+ 'pipe_specs': {
+ 'NPS': f"{analysis_data['nps']}\"",
+ 'Schedule': f"{analysis_data['schedule']}",
+ 'Pressure Class': f"{analysis_data['pressure_class']}",
+ 'Metallurgy': analysis_data['metallurgy'],
+ 'Pipe Config': analysis_data['pipe_config']
+ },
+ 'analysis_summary': {
+ 'flag': analysis_data['flag'],
+ 'status': analysis_data['status'],
+ 'message': analysis_data['message'],
+ 'governing_factor': analysis_data['governing_type']
+ }
+ }
+
+ return sympy_report
+
+ def analyze_generator(self, measured_thickness: float, year_inspected: Optional[int] = None,
+ month_inspected: Optional[int] = None):
+ """
+ Generator version of analyze() that yields each stage of the analysis pipeline.
+
+ This method provides the same functionality as analyze() but orchestrates each data enrichment stage iteratively
+ so the results can be seen at each stage if needed for debugging.
+
+ Stages:
+ - "metadata": Basic pipe properties
+ - "enriched": Pipe properties + table lookups (dimensions, material properties)
+ - "analysis_results": Analysis calculations and flag determination
+ - "final": Complete analysis package with all data
+ - "sympy_report": SymPy mathematical equations and formatted tables
+
+ Example:
+ >>> pipe = PIPE(...)
+ >>> for stage_name, data in pipe.analyze_generator(0.25, 2024):
+ ... print(f"Stage: {stage_name}")
+ ... if stage_name == "enriched":
+ ... print(f"OD: {data['outer_diameter']}")
+ ... elif stage_name == "final":
+ ... print(f"Flag: {data['flag']}")
+ ... elif stage_name == "sympy_report":
+ ... print(f"Equation: {data['equation']['latex']}")
+ """
+
+ # Stage 1: Basic metadata
+ metadata = self.pipe_info(None)
+ yield "metadata", metadata
+
+ # Stage 2: Add table lookups (dimensions, material properties, etc.)
+ table_info = self.get_table_info()
+ enriched_data = self.pipe_info(metadata, table_info)
+ yield "enriched", enriched_data
+
+ # Stage 3: Run analysis calculations and get results (including flags)
+ analysis_results = self.calculate_all(enriched_data, measured_thickness, year_inspected, month_inspected)
+ yield "analysis_results", analysis_results
+
+ # Stage 4: Final enrichment - combine everything into one complete package
+ final_data = self.pipe_info(enriched_data, analysis_results)
+ yield "final", final_data
+
+ # Stage 5: SymPy mathematical report
+ sympy_report = self.quick_report(final_data)
+ yield "sympy_report", sympy_report
+
+
+
+
From b154c3c80d33d19a345809908c7b99b49e829b32 Mon Sep 17 00:00:00 2001
From: Andrew Trepagnier
Date: Sat, 13 Sep 2025 11:59:36 -0500
Subject: [PATCH 10/10] Add comprehensive testing infrastructure
- Set up pytest framework with fixtures and parametrization
- Add comprehensive test suite
- Integrate SymPy reporting
- Add GitHub Actions CI/CD pipeline
- Fix core_dev.py dataclass field ordering and method signatures
---
.github/workflows/tests.yml | 86 +++++++++
.gitignore | 125 ++++++++++++-
Makefile | 43 +++++
README.md | 3 +
pyproject.toml | 42 ++++-
run_tests.py | 41 +++++
tests/conftest.py | 47 +++++
tests/test_core_dev.py | 358 ++++++++++++++++++++++++++++++++++++
8 files changed, 743 insertions(+), 2 deletions(-)
create mode 100644 .github/workflows/tests.yml
create mode 100644 Makefile
create mode 100755 run_tests.py
create mode 100644 tests/conftest.py
create mode 100644 tests/test_core_dev.py
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..946d11c
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,86 @@
+name: Tests
+
+on:
+ push:
+ branches: [ main, development ]
+ pull_request:
+ branches: [ main, development ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: [3.8, 3.9, "3.10", "3.11"]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+
+ - name: Lint with flake8
+ run: |
+ flake8 tmin tests --count --select=E9,F63,F7,F82 --show-source --statistics
+ flake8 tmin tests --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+
+ - name: Type check with mypy
+ run: |
+ mypy tmin --ignore-missing-imports
+
+ - name: Test with pytest
+ run: |
+ pytest tests/ --cov=tmin --cov-report=xml --cov-report=html
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v3
+ with:
+ file: ./coverage.xml
+ flags: unittests
+ name: codecov-umbrella
+ fail_ci_if_error: false
+
+ test-windows:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python 3.10
+ uses: actions/setup-python@v4
+ with:
+ python-version: "3.10"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+
+ - name: Test with pytest
+ run: |
+ pytest tests/ --cov=tmin
+
+ test-macos:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python 3.10
+ uses: actions/setup-python@v4
+ with:
+ python-version: "3.10"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+
+ - name: Test with pytest
+ run: |
+ pytest tests/ --cov=tmin
diff --git a/.gitignore b/.gitignore
index 2e61275..1ea1c95 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,8 +57,131 @@ Reports/
.pytest_cache/
.coverage
htmlcov/
+coverage.xml
+.coverage.*
+.pytest_cache/
# mypy
.mypy_cache/
.dmypy.json
-dmypy.json
\ No newline at end of file
+dmypy.json
+
+# Coverage reports
+htmlcov/
+.coverage
+coverage.xml
+*.cover
+.hypothesis/
+
+# pyenv
+.python-version
+
+# pipenv
+Pipfile.lock
+
+# PEP 582
+__pypackages__/
+
+# Celery
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# Black formatter
+.black
+
+# Flake8
+.flake8
+
+# Tox
+.tox/
+.nox/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# IPython
+profile_default/
+ipython_config.py
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# Sublime Text
+*.sublime-project
+*.sublime-workspace
+
+# Emacs
+*~
+\#*\#
+/.emacs.desktop
+/.emacs.desktop.lock
+*.elc
+auto-save-list
+tramp
+.\#*
+
+# Windows
+Thumbs.db
+ehthumbs.db
+Desktop.ini
+$RECYCLE.BIN/
+
+# Linux
+*~
+
+# Temporary files
+*.tmp
+*.temp
+*.bak
+*.backup
+
+# Test artifacts
+test-results/
+test-reports/
+
+# Documentation builds
+docs/_build/
+docs/build/
+
+# Development tools
+.pre-commit-config.yaml
+.pre-commit-hooks.yaml
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1d0e79b
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,43 @@
+.PHONY: help install install-dev test test-cov lint format clean build
+
+help: ## Show this help message
+ @echo "TMIN Development Commands:"
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
+
+install: ## Install package in production mode
+ pip install -e .
+
+install-dev: ## Install package with development dependencies
+ pip install -e ".[dev]"
+
+test: ## Run tests
+ python -m pytest tests/ -v
+
+test-cov: ## Run tests with coverage
+ python -m pytest tests/ --cov=tmin --cov-report=term-missing --cov-report=html -v
+
+test-fast: ## Run only fast tests (exclude slow/integration)
+ python -m pytest tests/ -m "not slow" -v
+
+lint: ## Run linting checks
+ flake8 tmin tests
+ mypy tmin --ignore-missing-imports
+
+format: ## Format code with black
+ black tmin tests
+
+clean: ## Clean up build artifacts
+ rm -rf build/
+ rm -rf dist/
+ rm -rf *.egg-info/
+ rm -rf htmlcov/
+ rm -rf .coverage
+ rm -rf .pytest_cache/
+ find . -type d -name __pycache__ -delete
+ find . -type f -name "*.pyc" -delete
+
+build: ## Build package
+ python -m build
+
+run-tests: ## Run tests using the test runner script
+ python run_tests.py
diff --git a/README.md b/README.md
index d0c89aa..25e7ab3 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,9 @@
[](https://pepy.tech/project/tmin)
[](https://badge.fury.io/py/tmin)

+[](https://github.com/AndrewTrepagnier/tmin/actions)
+[](https://codecov.io/gh/AndrewTrepagnier/tmin)
+[](https://pypi.org/project/tmin/)
[](https://your-blog-link.com)
[](https://github.com/AndrewTrepagnier/tmin/wiki)
[](https://your-blog-link.com)
diff --git a/pyproject.toml b/pyproject.toml
index 6d691bd..a390aad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,7 @@ requires-python = ">=3.8"
dependencies = [
"numpy>=1.23",
"matplotlib>=3.5",
+ "sympy>=1.11",
"toml>=0.10.0",
"jupyter>=1.0.0",
"notebook>=6.0.0",
@@ -37,6 +38,10 @@ dependencies = [
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
+ "pytest-xdist>=3.0",
+ "black>=22.0",
+ "flake8>=5.0",
+ "mypy>=1.0",
]
[project.scripts]
@@ -51,4 +56,39 @@ Issues = "https://github.com/AndrewTrepagnier/tmin/issues"
include = ["tmin*"]
[tool.setuptools.package-data]
-tmin = ["asmetables/*.py"]
\ No newline at end of file
+tmin = ["asmetables/*.py"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_classes = ["Test*"]
+python_functions = ["test_*"]
+addopts = [
+ "--strict-markers",
+ "--strict-config",
+ "--cov=tmin",
+ "--cov-report=term-missing",
+ "--cov-report=html",
+ "--cov-report=xml",
+]
+markers = [
+ "slow: marks tests as slow (deselect with '-m \"not slow\"')",
+ "integration: marks tests as integration tests",
+ "unit: marks tests as unit tests",
+]
+
+[tool.coverage.run]
+source = ["tmin"]
+omit = [
+ "*/tests/*",
+ "*/test_*",
+ "*/__pycache__/*",
+]
+
+[tool.coverage.report]
+exclude_lines = [
+ "pragma: no cover",
+ "def __repr__",
+ "raise AssertionError",
+ "raise NotImplementedError",
+]
\ No newline at end of file
diff --git a/run_tests.py b/run_tests.py
new file mode 100755
index 0000000..028b1a3
--- /dev/null
+++ b/run_tests.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+"""
+Simple test runner for TMIN package
+"""
+import subprocess
+import sys
+import os
+
+def run_tests():
+ """Run the test suite"""
+ print("🧪 Running TMIN Test Suite")
+ print("=" * 50)
+
+ # Change to project root
+ os.chdir(os.path.dirname(os.path.abspath(__file__)))
+
+ try:
+ # Run pytest with coverage
+ result = subprocess.run([
+ sys.executable, "-m", "pytest",
+ "tests/",
+ "--cov=tmin",
+ "--cov-report=term-missing",
+ "--cov-report=html",
+ "-v"
+ ], check=True)
+
+ print("\n✅ All tests passed!")
+ print("📊 Coverage report generated in htmlcov/index.html")
+ return True
+
+ except subprocess.CalledProcessError as e:
+ print(f"\n❌ Tests failed with exit code {e.returncode}")
+ return False
+ except FileNotFoundError:
+ print("❌ pytest not found. Install with: pip install pytest pytest-cov")
+ return False
+
+if __name__ == "__main__":
+ success = run_tests()
+ sys.exit(0 if success else 1)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..7f152da
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,47 @@
+# Test configuration and fixtures for TMIN package
+import pytest
+import numpy as np
+from tmin.core_dev import PIPE
+
+
+@pytest.fixture
+def sample_pipe():
+ """Create a standard pipe instance for testing"""
+ return PIPE(
+ pressure=150.0,
+ nps=2.0,
+ schedule=40,
+ pressure_class=300,
+ metallurgy="Intermediate/Low CS",
+ yield_stress=30000,
+ corrosion_rate=5.0
+ )
+
+
+@pytest.fixture
+def sample_pipe_ss():
+ """Create a stainless steel pipe instance for testing"""
+ return PIPE(
+ pressure=200.0,
+ nps=1.5,
+ schedule=80,
+ pressure_class=600,
+ metallurgy="SS 316/316L",
+ yield_stress=25000,
+ corrosion_rate=2.0
+ )
+
+
+@pytest.fixture
+def sample_pipe_elbow():
+ """Create an elbow pipe instance for testing"""
+ return PIPE(
+ pressure=100.0,
+ nps=3.0,
+ schedule=40,
+ pressure_class=150,
+ metallurgy="Intermediate/Low CS",
+ yield_stress=30000,
+ pipe_config="90LR - Inner Elbow",
+ corrosion_rate=3.0
+ )
diff --git a/tests/test_core_dev.py b/tests/test_core_dev.py
new file mode 100644
index 0000000..8e8dbf0
--- /dev/null
+++ b/tests/test_core_dev.py
@@ -0,0 +1,358 @@
+"""
+Test suite for core_dev.py - Development version of TMIN core functionality
+"""
+import pytest
+import numpy as np
+import sympy as sp
+from tmin.core_dev import PIPE
+
+
+class TestPIPECoreDev:
+ """Test the core PIPE class functionality in core_dev.py"""
+
+ def test_pipe_creation(self, sample_pipe):
+ """Test basic pipe instance creation"""
+ assert sample_pipe.pressure == 150.0
+ assert sample_pipe.nps == 2.0
+ assert sample_pipe.schedule == 40
+ assert sample_pipe.pressure_class == 300
+ assert sample_pipe.metallurgy == "Intermediate/Low CS"
+ assert sample_pipe.yield_stress == 30000
+ assert sample_pipe.corrosion_rate == 5.0
+
+ def test_pipe_info_basic(self, sample_pipe):
+ """Test basic pipe_info method"""
+ metadata = sample_pipe.pipe_info(None)
+
+ assert metadata['pressure'] == 150.0
+ assert metadata['nps'] == 2.0
+ assert metadata['schedule'] == 40
+ assert metadata['metallurgy'] == "Intermediate/Low CS"
+ assert metadata['yield_stress'] == 30000
+
+ def test_get_table_info(self, sample_pipe):
+ """Test table information retrieval"""
+ table_info = sample_pipe.get_table_info()
+
+ # Check that all expected keys are present
+ expected_keys = [
+ 'outer_diameter', 'inner_diameter', 'allowable_stress',
+ 'joint_type', 'y_coefficient', 'centerline_radius'
+ ]
+ for key in expected_keys:
+ assert key in table_info
+
+ # Check that values are reasonable
+ assert table_info['outer_diameter'] > 0
+ assert table_info['inner_diameter'] > 0
+ assert table_info['allowable_stress'] > 0
+ assert table_info['y_coefficient'] > 0
+
+ def test_allowable_stress_calculation(self, sample_pipe):
+ """Test allowable stress calculation (2/3 of yield stress)"""
+ allowable_stress = sample_pipe.get_allowable_stress()
+ expected = 30000 * (2/3)
+ assert abs(allowable_stress - expected) < 1e-6
+
+ def test_outer_diameter_lookup(self, sample_pipe):
+ """Test outer diameter lookup from NPS"""
+ od = sample_pipe.get_outer_diameter()
+ assert od > 0
+ assert isinstance(od, float)
+
+ def test_inner_diameter_lookup(self, sample_pipe):
+ """Test inner diameter lookup from schedule and NPS"""
+ id_val = sample_pipe.get_inner_diameter()
+ assert id_val > 0
+ assert isinstance(id_val, float)
+
+ def test_y_coefficient_lookup(self, sample_pipe):
+ """Test Y coefficient lookup"""
+ y_coeff = sample_pipe.get_y_coefficient()
+ assert y_coeff > 0
+ assert isinstance(y_coeff, float)
+
+ def test_centerline_radius_lookup(self, sample_pipe):
+ """Test centerline radius lookup"""
+ radius = sample_pipe.get_centerline_radius()
+ assert radius > 0
+ assert isinstance(radius, float)
+
+ def test_joint_type_info(self, sample_pipe):
+ """Test joint type information"""
+ joint_info = sample_pipe.get_joint_type()
+
+ assert 'joint_efficiency' in joint_info
+ assert 'weld_strength_reduction' in joint_info
+ assert joint_info['joint_efficiency'] == 1.0
+ assert joint_info['weld_strength_reduction'] == 1.0
+
+ def test_static_methods(self, sample_pipe):
+ """Test static utility methods"""
+ # Test inches to mils conversion
+ inches_val = 0.1
+ mils_val = sample_pipe.inches_to_mils(inches_val)
+ assert abs(mils_val - 100.0) < 1e-6
+
+ # Test mils to inches conversion
+ mils_val = 100.0
+ inches_val = sample_pipe.mils_to_inches(mils_val)
+ assert abs(inches_val - 0.1) < 1e-6
+
+ def test_time_elapsed_calculation(self, sample_pipe):
+ """Test time elapsed calculation"""
+ # Test with year only
+ time_elapsed = sample_pipe._calculate_time_elapsed(2020)
+ assert time_elapsed > 0
+
+ # Test with year and month
+ time_elapsed = sample_pipe._calculate_time_elapsed(2020, 6)
+ assert time_elapsed > 0
+
+ def test_format_inspection_date(self, sample_pipe):
+ """Test inspection date formatting"""
+ date_str = sample_pipe._format_inspection_date(2020)
+ assert date_str == "2020-01"
+
+ date_str = sample_pipe._format_inspection_date(2020, 6)
+ assert date_str == "2020-06"
+
+
+class TestAnalysisMethods:
+ """Test analysis calculation methods"""
+
+ def test_tmin_pressure_straight(self, sample_pipe):
+ """Test pressure thickness calculation for straight pipe"""
+ # Get enriched data
+ metadata = sample_pipe.pipe_info(None)
+ table_info = sample_pipe.get_table_info()
+ enriched_data = sample_pipe.pipe_info(metadata, table_info)
+
+ # Calculate pressure thickness
+ tmin_pressure = sample_pipe.tmin_pressure(enriched_data)
+
+ assert tmin_pressure > 0
+ assert isinstance(tmin_pressure, float)
+
+ def test_tmin_pressure_elbow(self, sample_pipe_elbow):
+ """Test pressure thickness calculation for elbow pipe"""
+ # Get enriched data
+ metadata = sample_pipe_elbow.pipe_info(None)
+ table_info = sample_pipe_elbow.get_table_info()
+ enriched_data = sample_pipe_elbow.pipe_info(metadata, table_info)
+
+ # Calculate pressure thickness
+ tmin_pressure = sample_pipe_elbow.tmin_pressure(enriched_data)
+
+ assert tmin_pressure > 0
+ assert isinstance(tmin_pressure, float)
+
+ def test_tmin_structural(self, sample_pipe):
+ """Test structural thickness calculation"""
+ # Get enriched data
+ metadata = sample_pipe.pipe_info(None)
+ table_info = sample_pipe.get_table_info()
+ enriched_data = sample_pipe.pipe_info(metadata, table_info)
+
+ # Calculate structural thickness
+ tmin_structural = sample_pipe.tmin_structural(enriched_data)
+
+ assert tmin_structural > 0
+ assert isinstance(tmin_structural, float)
+
+ def test_calculate_corrosion_allowance(self, sample_pipe):
+ """Test corrosion allowance calculation"""
+ excess_thickness = 0.05 # inches
+ corrosion_rate = 5.0 # MPY
+
+ remaining_life = sample_pipe.calculate_corrosion_allowance(excess_thickness, corrosion_rate)
+
+ assert remaining_life > 0
+ assert isinstance(remaining_life, float)
+
+
+class TestCompleteAnalysis:
+ """Test complete analysis workflow"""
+
+ def test_analyze_method(self, sample_pipe):
+ """Test the main analyze method"""
+ result = sample_pipe.analyze(measured_thickness=0.25, year_inspected=2024)
+
+ # Check that all expected keys are present
+ expected_keys = [
+ 'pressure', 'nps', 'schedule', 'metallurgy', 'yield_stress',
+ 'outer_diameter', 'inner_diameter', 'allowable_stress',
+ 'measured_thickness', 'current_thickness', 'tmin_pressure',
+ 'tmin_structural', 'governing_thickness', 'governing_type',
+ 'flag', 'status', 'message', 'sympy_report'
+ ]
+
+ for key in expected_keys:
+ assert key in result, f"Missing key: {key}"
+
+ # Check flag values
+ assert result['flag'] in ['GREEN', 'YELLOW', 'RED']
+ assert result['status'] in ['SAFE_TO_CONTINUE', 'FFS_RECOMMENDED', 'IMMEDIATE_RETIREMENT']
+
+ # Check thickness values
+ assert result['tmin_pressure'] > 0
+ assert result['tmin_structural'] > 0
+ assert result['governing_thickness'] > 0
+ assert result['current_thickness'] > 0
+
+ def test_analyze_generator(self, sample_pipe):
+ """Test the analyze_generator method"""
+ stages = []
+ for stage_name, data in sample_pipe.analyze_generator(measured_thickness=0.25, year_inspected=2024):
+ stages.append(stage_name)
+
+ # Check that all expected stages are present
+ expected_stages = ["metadata", "enriched", "analysis_results", "final", "sympy_report"]
+ assert stages == expected_stages
+
+ # Test that we can get the final result
+ final_result = list(sample_pipe.analyze_generator(measured_thickness=0.25, year_inspected=2024))[-1][1]
+ assert 'equation' in final_result # SymPy report should have equation
+ assert 'input_parameters' in final_result # SymPy report should have input parameters
+
+
+class TestSymPyIntegration:
+ """Test SymPy mathematical report integration"""
+
+ def test_quick_report_structure(self, sample_pipe):
+ """Test SymPy report structure"""
+ # Get analysis data
+ analysis_data = sample_pipe.analyze(measured_thickness=0.25, year_inspected=2024)
+
+ # Check SymPy report is included
+ assert 'sympy_report' in analysis_data
+ sympy_report = analysis_data['sympy_report']
+
+ # Check report structure
+ expected_keys = ['equation', 'input_parameters', 'computed_outputs', 'pipe_specs', 'analysis_summary']
+ for key in expected_keys:
+ assert key in sympy_report
+
+ def test_sympy_equation_generation(self, sample_pipe):
+ """Test SymPy equation generation"""
+ analysis_data = sample_pipe.analyze(measured_thickness=0.25, year_inspected=2024)
+ sympy_report = analysis_data['sympy_report']
+
+ # Check equation components
+ equation = sympy_report['equation']
+ assert 'symbolic' in equation
+ assert 'substituted' in equation
+ assert 'latex' in equation
+ assert 'latex_substituted' in equation
+
+ # Check that LaTeX is generated
+ assert isinstance(equation['latex'], str)
+ assert len(equation['latex']) > 0
+
+ def test_input_parameters_table(self, sample_pipe):
+ """Test input parameters table"""
+ analysis_data = sample_pipe.analyze(measured_thickness=0.25, year_inspected=2024)
+ sympy_report = analysis_data['sympy_report']
+
+ input_table = sympy_report['input_parameters']
+
+ # Check table structure
+ assert 'Parameter' in input_table
+ assert 'Symbol' in input_table
+ assert 'Value' in input_table
+ assert 'Units' in input_table
+
+ # Check that all lists have same length
+ lengths = [len(input_table[key]) for key in input_table.keys()]
+ assert all(length == lengths[0] for length in lengths)
+
+ def test_computed_outputs_table(self, sample_pipe):
+ """Test computed outputs table"""
+ analysis_data = sample_pipe.analyze(measured_thickness=0.25, year_inspected=2024)
+ sympy_report = analysis_data['sympy_report']
+
+ output_table = sympy_report['computed_outputs']
+
+ # Check table structure
+ assert 'Result' in output_table
+ assert 'Value' in output_table
+ assert 'Units' in output_table
+
+ # Check that all lists have same length
+ lengths = [len(output_table[key]) for key in output_table.keys()]
+ assert all(length == lengths[0] for length in lengths)
+
+
+class TestEdgeCases:
+ """Test edge cases and error handling"""
+
+ def test_invalid_schedule(self):
+ """Test error handling for invalid schedule"""
+ pipe = PIPE(
+ pressure=150.0,
+ nps=2.0,
+ schedule=999, # Invalid schedule
+ pressure_class=300,
+ metallurgy="Intermediate/Low CS",
+ yield_stress=30000
+ )
+
+ with pytest.raises(ValueError):
+ pipe.get_inner_diameter()
+
+ def test_future_inspection_date(self, sample_pipe):
+ """Test error handling for future inspection date"""
+ with pytest.raises(ValueError):
+ sample_pipe._calculate_time_elapsed(2030) # Future date
+
+ def test_invalid_month(self, sample_pipe):
+ """Test error handling for invalid month"""
+ with pytest.raises(ValueError):
+ sample_pipe.analyze(measured_thickness=0.25, year_inspected=2020, month_inspected=13) # Invalid month
+
+ def test_measured_thickness_too_large(self, sample_pipe):
+ """Test error handling for measured thickness larger than wall thickness"""
+ with pytest.raises(ValueError):
+ sample_pipe.analyze(measured_thickness=10.0) # Unreasonably large
+
+
+@pytest.mark.integration
+class TestIntegrationScenarios:
+ """Integration tests for realistic scenarios"""
+
+ def test_carbon_steel_pipe_scenario(self):
+ """Test realistic carbon steel pipe scenario"""
+ pipe = PIPE(
+ pressure=200.0,
+ nps=4.0,
+ schedule=40,
+ pressure_class=300,
+ metallurgy="Intermediate/Low CS",
+ yield_stress=35000,
+ corrosion_rate=8.0
+ )
+
+ result = pipe.analyze(measured_thickness=0.28, year_inspected=2022, month_inspected=6)
+
+ # Should be a reasonable analysis
+ assert result['flag'] in ['GREEN', 'YELLOW', 'RED']
+ assert result['tmin_pressure'] > 0
+ assert result['tmin_structural'] > 0
+
+ def test_stainless_steel_pipe_scenario(self, sample_pipe_ss):
+ """Test realistic stainless steel pipe scenario"""
+ result = sample_pipe_ss.analyze(measured_thickness=0.15, year_inspected=2023)
+
+ # Should be a reasonable analysis
+ assert result['flag'] in ['GREEN', 'YELLOW', 'RED']
+ assert result['tmin_pressure'] > 0
+ assert result['tmin_structural'] > 0
+
+ def test_elbow_pipe_scenario(self, sample_pipe_elbow):
+ """Test realistic elbow pipe scenario"""
+ result = sample_pipe_elbow.analyze(measured_thickness=0.22, year_inspected=2021, month_inspected=3)
+
+ # Should be a reasonable analysis
+ assert result['flag'] in ['GREEN', 'YELLOW', 'RED']
+ assert result['tmin_pressure'] > 0
+ assert result['tmin_structural'] > 0