From f0128a25efb222f695dce3481bcbc6cf74ac9de2 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Thu, 2 Oct 2025 19:56:42 -0400 Subject: [PATCH 01/25] converting XDSM to a Pydantic BaseModel. --- pyxdsm/XDSM.py | 966 ++++++++++++++++++++++++++----------------------- 1 file changed, 508 insertions(+), 458 deletions(-) diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index cd3a1cc..b6a33fb 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -1,12 +1,19 @@ +""" +pyXDSM with Pydantic models for validation and serialization +""" + from __future__ import print_function import os import numpy as np import json import subprocess -from collections import namedtuple +from typing import Literal, Optional, Tuple, List, Dict, Set, Union +from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict +import plotly.graph_objects as go from pyxdsm import __version__ as pyxdsm_version +# Constants OPT = "Optimization" SUBOPT = "SubOptimization" SOLVER = "MDA" @@ -19,6 +26,20 @@ LEFT = "left" RIGHT = "right" +# Type definitions - these match the TikZ styles in diagram_styles +NodeType = Literal['Optimization', 'SubOptimization', 'MDA', 'DOE', 'ImplicitFunction', + 'Function', 'Group', 'ImplicitGroup', 'Metamodel'] +ConnectionStyle = Literal['DataInter', 'DataIO'] +Side = Literal['left', 'right'] +AutoFadeOption = Literal['all', 'connected', 'none', 'incoming', 'outgoing'] + +# Valid TikZ node styles (from diagram_styles.tikzstyles) +VALID_NODE_STYLES = { + 'Optimization', 'SubOptimization', 'MDA', 'DOE', 'ImplicitFunction', + 'Function', 'Group', 'ImplicitGroup', 'Metamodel', 'DataInter', 'DataIO' +} + +# LaTeX templates tikzpicture_template = r""" %%% Preamble Requirements %%% % \usepackage{{geometry}} @@ -82,12 +103,12 @@ def chunk_label(label, n_chunks): - # looping till length l for i in range(0, len(label), n_chunks): yield label[i : i + n_chunks] -def _parse_label(label, label_width=None): +def _parse_label(label: Union[str, List[str], Tuple[str, ...]], label_width: Optional[int] = None) -> str: + """Parse label into LaTeX format.""" if isinstance(label, (tuple, list)): if label_width is None: return r"$\begin{array}{c}" + r" \\ ".join(label) + r"\end{array}$" @@ -100,572 +121,547 @@ def _parse_label(label, label_width=None): return r"${}$".format(label) -def _label_to_spec(label, spec): +def _label_to_spec(label: Union[str, List[str], Tuple[str, ...]], spec: Set[str]) -> None: + """Add label variables to spec set.""" if isinstance(label, str): - label = [ - label, - ] + label = [label] for var in label: if var: spec.add(var) -System = namedtuple("System", "node_name style label stack faded label_width spec_name") -Input = namedtuple("Input", "node_name label label_width style stack faded") -Output = namedtuple("Output", "node_name label label_width style stack faded side") -Connection = namedtuple("Connection", "src target label label_width style stack faded src_faded target_faded") -Process = namedtuple("Process", "systems arrow faded") - - -class XDSM: - def __init__(self, use_sfmath=True, optional_latex_packages=None, auto_fade=None): - """Initialize XDSM object - - Parameters - ---------- - use_sfmath : bool, optional - Whether to use the sfmath latex package, by default True - optional_latex_packages : string or list of strings, optional - Additional latex packages to use when creating the pdf and tex versions of the diagram, by default None - auto_fade : dictionary, optional - Controls the automatic fading of inputs, outputs, connections and processes based on the fading of diagonal blocks. For each key "inputs", "outputs", "connections", and "processes", the value can be one of: - - "all" : fade all blocks - - "connected" : fade all components connected to faded blocks (both source and target must be faded for a conncection to be faded) - - "none" : do not auto-fade anything - For connections there are two additional options: - - "incoming" : Fade all connections that are incoming to faded blocks. - - "outgoing" : Fade all connections that are outgoing from faded blocks. - """ - self.systems = [] - self.connections = [] - self.left_outs = {} - self.right_outs = {} - self.ins = {} - self.processes = [] - - self.use_sfmath = use_sfmath - if optional_latex_packages is None: - self.optional_packages = [] - else: - if isinstance(optional_latex_packages, str): - self.optional_packages = [optional_latex_packages] - elif isinstance(optional_latex_packages, list): - self.optional_packages = optional_latex_packages - else: - raise ValueError("optional_latex_packages must be a string or a list of strings") - - self.auto_fade = {"inputs": "none", "outputs": "none", "connections": "none", "processes": "none"} - fade_options = ["all", "connected", "none"] - if auto_fade is not None: - if any([key not in self.auto_fade for key in auto_fade.keys()]): - raise ValueError( - "The supplied 'auto_fade' dictionary contains keys that are not recognized. " - + "valid keys are 'inputs', 'outputs', 'connections', 'processes'." - ) - - self.auto_fade.update(auto_fade) - for key in self.auto_fade.keys(): - option_is_valid = self.auto_fade[key] in fade_options or ( - key == "connections" and self.auto_fade[key] in ["incoming", "outgoing"] +class SystemNode(BaseModel): + """System node on the diagonal of XDSM diagram.""" + + node_name: str = Field(..., description="Unique name for the system") + style: str = Field(..., description="Type/style of the system") + label: Union[str, List[str], Tuple[str, ...]] = Field(..., description="Display label") + stack: bool = Field(default=False, description="Display as stacked rectangles") + faded: bool = Field(default=False, description="Fade the component") + label_width: Optional[int] = Field(default=None, description="Number of items per line") + spec_name: Optional[str] = Field(default=None, description="Name for spec file") + + model_config = ConfigDict(arbitrary_types_allowed=True) + + @field_validator('node_name') + @classmethod + def validate_node_name(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("Node name cannot be empty") + return v.strip() + + @field_validator('style') + @classmethod + def validate_style(cls, v: str) -> str: + """Validate that style is a known TikZ style.""" + if v not in VALID_NODE_STYLES: + raise ValueError( + f"Style '{v}' is not a valid TikZ style. " + f"Valid styles are: {', '.join(sorted(VALID_NODE_STYLES))}" ) - if not option_is_valid: - raise ValueError( - f"The supplied 'auto_fade' dictionary contains an invalid value: '{key}'. " - + "valid values are 'all', 'connected', 'none', 'incoming', 'outgoing'." - ) - - def add_system( - self, - node_name, - style, - label, - stack=False, - faded=False, - label_width=None, - spec_name=None, - ): - r""" - Add a "system" block, which will be placed on the diagonal of the XDSM diagram. - - Parameters - ---------- - node_name : str - The unique name given to this component - - style : str - The type of the component - - label : str or list/tuple of strings - The label to appear on the diagram. There are two options for this: - - a single string - - a list or tuple of strings, which is used for line breaking - In either case, they should probably be enclosed in \text{} declarations to make sure - the font is upright. - - stack : bool - If true, the system will be displayed as several stacked rectangles, - indicating the component is executed in parallel. - - faded : bool - If true, the component will be faded, in order to highlight some other system. - - label_width : int or None - If not None, AND if ``label`` is given as either a tuple or list, then this parameter - controls how many items in the tuple/list will be displayed per line. - If None, the label will be printed one item per line if given as a tuple or list, - otherwise the string will be printed on a single line. - - spec_name : str - The spec name used for the spec file. - + return v + + def __init__(self, **data): + super().__init__(**data) + if self.spec_name is None: + self.spec_name = self.node_name + + +class InputNode(BaseModel): + """Input node at top of XDSM diagram.""" + + node_name: str = Field(..., description="Internal node name") + label: Union[str, List[str], Tuple[str, ...]] = Field(..., description="Display label") + label_width: Optional[int] = Field(default=None, description="Number of items per line") + style: str = Field(default="DataIO", description="Node style") + stack: bool = Field(default=False, description="Display as stacked rectangles") + faded: bool = Field(default=False, description="Fade the component") + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class OutputNode(BaseModel): + """Output node on left or right side of XDSM diagram.""" + + node_name: str = Field(..., description="Internal node name") + label: Union[str, List[str], Tuple[str, ...]] = Field(..., description="Display label") + label_width: Optional[int] = Field(default=None, description="Number of items per line") + style: str = Field(default="DataIO", description="Node style") + stack: bool = Field(default=False, description="Display as stacked rectangles") + faded: bool = Field(default=False, description="Fade the component") + side: Side = Field(..., description="Which side (left or right)") + + model_config = ConfigDict(arbitrary_types_allowed=True) + + @field_validator('side') + @classmethod + def validate_side(cls, v: str) -> str: + if v not in ['left', 'right']: + raise ValueError("Side must be 'left' or 'right'") + return v + + +class ConnectionEdge(BaseModel): + """Connection between two nodes.""" + + src: str = Field(..., description="Source node name") + target: str = Field(..., description="Target node name") + label: Union[str, List[str], Tuple[str, ...]] = Field(..., description="Connection label") + label_width: Optional[int] = Field(default=None, description="Number of items per line") + style: str = Field(default="DataInter", description="Connection style") + stack: bool = Field(default=False, description="Display as stacked") + faded: bool = Field(default=False, description="Fade the connection") + src_faded: bool = Field(default=False, description="Source node is faded") + target_faded: bool = Field(default=False, description="Target node is faded") + + model_config = ConfigDict(arbitrary_types_allowed=True) + + @field_validator('label_width') + @classmethod + def validate_label_width(cls, v: Optional[int]) -> Optional[int]: + if v is not None and not isinstance(v, int): + raise ValueError("label_width must be an integer") + return v + + @model_validator(mode='after') + def validate_no_self_connection(self): + if self.src == self.target: + raise ValueError("Cannot connect component to itself") + return self + + +class ProcessChain(BaseModel): + """Process flow chain between systems.""" + + systems: List[str] = Field(..., description="List of system names in order") + arrow: bool = Field(default=True, description="Show arrows on process lines") + faded: bool = Field(default=False, description="Fade the process chain") + + @field_validator('systems') + @classmethod + def validate_systems(cls, v: List[str]) -> List[str]: + if len(v) < 2: + raise ValueError("Process chain must contain at least 2 systems") + return v + + +class AutoFadeConfig(BaseModel): + """Configuration for automatic fading of components.""" + + inputs: AutoFadeOption = Field(default='none', description="Auto-fade inputs") + outputs: AutoFadeOption = Field(default='none', description="Auto-fade outputs") + connections: AutoFadeOption = Field(default='none', description="Auto-fade connections") + processes: AutoFadeOption = Field(default='none', description="Auto-fade processes") + + @field_validator('inputs', 'outputs', 'processes') + @classmethod + def validate_basic_options(cls, v: str) -> str: + valid = ['all', 'connected', 'none'] + if v not in valid: + raise ValueError(f"Must be one of {valid}") + return v + + @field_validator('connections') + @classmethod + def validate_connection_options(cls, v: str) -> str: + valid = ['all', 'connected', 'none', 'incoming', 'outgoing'] + if v not in valid: + raise ValueError(f"Must be one of {valid}") + return v + + +class XDSM(BaseModel): + """ + XDSM diagram specification and renderer using Pydantic validation. + """ + + systems: List[SystemNode] = Field(default_factory=list, description="System nodes") + connections: List[ConnectionEdge] = Field(default_factory=list, description="Connections") + ins: Dict[str, InputNode] = Field(default_factory=dict, description="Input nodes") + left_outs: Dict[str, OutputNode] = Field(default_factory=dict, description="Left output nodes") + right_outs: Dict[str, OutputNode] = Field(default_factory=dict, description="Right output nodes") + processes: List[ProcessChain] = Field(default_factory=list, description="Process chains") + + use_sfmath: bool = Field(default=True, description="Use sfmath LaTeX package") + optional_packages: List[str] = Field(default_factory=list, description="Additional LaTeX packages") + auto_fade: AutoFadeConfig = Field(default_factory=AutoFadeConfig, description="Auto-fade configuration") + + model_config = ConfigDict(arbitrary_types_allowed=True) + + def __init__(self, use_sfmath: bool = True, + optional_latex_packages: Optional[Union[str, List[str]]] = None, + auto_fade: Optional[Dict[str, str]] = None, + **data): """ - if spec_name is None: - spec_name = node_name - - sys = System(node_name, style, label, stack, faded, label_width, spec_name) - self.systems.append(sys) - - def add_input(self, name, label, label_width=None, style="DataIO", stack=False, faded=False): - r""" - Add an input, which will appear in the top row of the diagram. + Initialize XDSM object. Parameters ---------- - name : str - The unique name given to this component - - label : str or list/tuple of strings - The label to appear on the diagram. There are two options for this: - - a single string - - a list or tuple of strings, which is used for line breaking - In either case, they should probably be enclosed in \text{} declarations to make sure - the font is upright. - - label_width : int or None - If not None, AND if ``label`` is given as either a tuple or list, then this parameter - controls how many items in the tuple/list will be displayed per line. - If None, the label will be printed one item per line if given as a tuple or list, - otherwise the string will be printed on a single line. - - style : str - The style given to this component. Can be one of ['DataInter', 'DataIO'] - - stack : bool - If true, the system will be displayed as several stacked rectangles, - indicating the component is executed in parallel. - - faded : bool - If true, the component will be faded, in order to highlight some other system. + use_sfmath : bool + Whether to use the sfmath latex package + optional_latex_packages : str or list of strings + Additional latex packages for PDF/TEX generation + auto_fade : dict + Auto-fade configuration with keys: inputs, outputs, connections, processes """ - sys_faded = {} - for s in self.systems: - sys_faded[s.node_name] = s.faded - if (self.auto_fade["inputs"] == "all") or ( - self.auto_fade["inputs"] == "connected" and name in sys_faded and sys_faded[name] - ): + # Only process if these aren't already in data (from deserialization) + if 'optional_packages' not in data: + # Process optional packages + packages = [] + if optional_latex_packages is not None: + if isinstance(optional_latex_packages, str): + packages = [optional_latex_packages] + elif isinstance(optional_latex_packages, list): + packages = optional_latex_packages + else: + raise ValueError("optional_latex_packages must be a string or list of strings") + data['optional_packages'] = packages + + if 'auto_fade' not in data: + # Process auto_fade + fade_config = AutoFadeConfig() + if auto_fade is not None: + fade_config = AutoFadeConfig(**auto_fade) + data['auto_fade'] = fade_config + + if 'use_sfmath' not in data: + data['use_sfmath'] = use_sfmath + + super().__init__(**data) + + @model_validator(mode='after') + def validate_unique_system_names(self): + """Ensure all system names are unique.""" + names = [sys.node_name for sys in self.systems] + duplicates = [n for n in names if names.count(n) > 1] + if duplicates: + raise ValueError(f"Duplicate system names: {set(duplicates)}") + return self + + def add_system(self, node_name: str, style: str, label: Union[str, List[str], Tuple[str, ...]], + stack: bool = False, faded: bool = False, label_width: Optional[int] = None, + spec_name: Optional[str] = None) -> None: + """Add a system block on the diagonal.""" + system = SystemNode( + node_name=node_name, + style=style, + label=label, + stack=stack, + faded=faded, + label_width=label_width, + spec_name=spec_name + ) + self.systems.append(system) + + def add_input(self, name: str, label: Union[str, List[str], Tuple[str, ...]], + label_width: Optional[int] = None, style: str = "DataIO", + stack: bool = False, faded: bool = False) -> None: + """Add an input node at the top.""" + sys_faded = {s.node_name: s.faded for s in self.systems} + + if (self.auto_fade.inputs == "all") or \ + (self.auto_fade.inputs == "connected" and name in sys_faded and sys_faded[name]): faded = True - self.ins[name] = Input("output_" + name, label, label_width, style, stack, faded) - - def add_output(self, name, label, label_width=None, style="DataIO", stack=False, faded=False, side="left"): - r""" - Add an output, which will appear in the left or right-most column of the diagram. - - Parameters - ---------- - name : str - The unique name given to this component - - label : str or list/tuple of strings - The label to appear on the diagram. There are two options for this: - - a single string - - a list or tuple of strings, which is used for line breaking - In either case, they should probably be enclosed in \text{} declarations to make sure - the font is upright. - - label_width : int or None - If not None, AND if ``label`` is given as either a tuple or list, then this parameter - controls how many items in the tuple/list will be displayed per line. - If None, the label will be printed one item per line if given as a tuple or list, - otherwise the string will be printed on a single line. - - style : str - The style given to this component. Can be one of ``['DataInter', 'DataIO']`` - - stack : bool - If true, the system will be displayed as several stacked rectangles, - indicating the component is executed in parallel. - - faded : bool - If true, the component will be faded, in order to highlight some other system. - - side : str - Must be one of ``['left', 'right']``. This parameter controls whether the output - is placed on the left-most column or the right-most column of the diagram. - """ - sys_faded = {} - for s in self.systems: - sys_faded[s.node_name] = s.faded - if (self.auto_fade["outputs"] == "all") or ( - self.auto_fade["outputs"] == "connected" and name in sys_faded and sys_faded[name] - ): + + self.ins[name] = InputNode( + node_name="output_" + name, + label=label, + label_width=label_width, + style=style, + stack=stack, + faded=faded + ) + + def add_output(self, name: str, label: Union[str, List[str], Tuple[str, ...]], + label_width: Optional[int] = None, style: str = "DataIO", + stack: bool = False, faded: bool = False, side: str = "left") -> None: + """Add an output node on the left or right side.""" + sys_faded = {s.node_name: s.faded for s in self.systems} + + if (self.auto_fade.outputs == "all") or \ + (self.auto_fade.outputs == "connected" and name in sys_faded and sys_faded[name]): faded = True + + output = OutputNode( + node_name=f"{side}_output_{name}", + label=label, + label_width=label_width, + style=style, + stack=stack, + faded=faded, + side=side + ) + if side == "left": - self.left_outs[name] = Output("left_output_" + name, label, label_width, style, stack, faded, side) + self.left_outs[name] = output elif side == "right": - self.right_outs[name] = Output("right_output_" + name, label, label_width, style, stack, faded, side) + self.right_outs[name] = output else: - raise ValueError("The option 'side' must be given as either 'left' or 'right'!") - - def connect( - self, - src, - target, - label, - label_width=None, - style="DataInter", - stack=False, - faded=False, - ): - r""" - Connects two components with a data line, and adds a label to indicate - the data being transferred. - - Parameters - ---------- - src : str - The name of the source component. - - target : str - The name of the target component. - - label : str or list/tuple of strings - The label to appear on the diagram. There are two options for this: - - a single string - - a list or tuple of strings, which is used for line breaking - In either case, they should probably be enclosed in \text{} declarations to make sure - the font is upright. - - label_width : int or None - If not None, AND if ``label`` is given as either a tuple or list, then this parameter - controls how many items in the tuple/list will be displayed per line. - If None, the label will be printed one item per line if given as a tuple or list, - otherwise the string will be printed on a single line. - - style : str - The style given to this component. Can be one of ``['DataInter', 'DataIO']`` - - stack : bool - If true, the system will be displayed as several stacked rectangles, - indicating the component is executed in parallel. - - faded : bool - If true, the component will be faded, in order to highlight some other system. - """ - if src == target: - raise ValueError("Can not connect component to itself") - - if (not isinstance(label_width, int)) and (label_width is not None): - raise ValueError("label_width argument must be an integer") - - sys_faded = {} - for s in self.systems: - sys_faded[s.node_name] = s.faded - - allFaded = self.auto_fade["connections"] == "all" - srcFaded = src in sys_faded and sys_faded[src] - targetFaded = target in sys_faded and sys_faded[target] - if ( - allFaded - or (self.auto_fade["connections"] == "connected" and (srcFaded and targetFaded)) - or (self.auto_fade["connections"] == "incoming" and targetFaded) - or (self.auto_fade["connections"] == "outgoing" and srcFaded) - ): + raise ValueError("Side must be 'left' or 'right'") + + def connect(self, src: str, target: str, label: Union[str, List[str], Tuple[str, ...]], + label_width: Optional[int] = None, style: str = "DataInter", + stack: bool = False, faded: bool = False) -> None: + """Connect two components with a data line.""" + sys_faded = {s.node_name: s.faded for s in self.systems} + + src_faded = src in sys_faded and sys_faded[src] + target_faded = target in sys_faded and sys_faded[target] + + all_faded = self.auto_fade.connections == "all" + if (all_faded or + (self.auto_fade.connections == "connected" and src_faded and target_faded) or + (self.auto_fade.connections == "incoming" and target_faded) or + (self.auto_fade.connections == "outgoing" and src_faded)): faded = True - - self.connections.append(Connection(src, target, label, label_width, style, stack, faded, srcFaded, targetFaded)) - - def add_process(self, systems, arrow=True, faded=False): - """ - Add a process line between a list of systems, to indicate process flow. - - Parameters - ---------- - systems : list - The names of the components, in the order in which they should be connected. - For a complete cycle, repeat the first component as the last component. - - arrow : bool - If true, arrows will be added to the process lines to indicate the direction - of the process flow. - """ - sys_faded = {} - for s in self.systems: - sys_faded[s.node_name] = s.faded - if (self.auto_fade["processes"] == "all") or ( - self.auto_fade["processes"] == "connected" - and any( - [sys_faded[s] for s in systems if s in sys_faded.keys()] - ) # sometimes a process may contain off-diagonal blocks - ): + + connection = ConnectionEdge( + src=src, + target=target, + label=label, + label_width=label_width, + style=style, + stack=stack, + faded=faded, + src_faded=src_faded, + target_faded=target_faded + ) + self.connections.append(connection) + + def add_process(self, systems: List[str], arrow: bool = True, faded: bool = False) -> None: + """Add a process line between systems.""" + sys_faded = {s.node_name: s.faded for s in self.systems} + + if (self.auto_fade.processes == "all") or \ + (self.auto_fade.processes == "connected" and + any([sys_faded.get(s, False) for s in systems])): faded = True - self.processes.append(Process(systems, arrow, faded)) - - def _build_node_grid(self): + + process = ProcessChain(systems=systems, arrow=arrow, faded=faded) + self.processes.append(process) + + def _build_node_grid(self) -> str: + """Build the TikZ node grid.""" size = len(self.systems) - comps_rows = np.arange(size) comps_cols = np.arange(size) - + if self.ins: size += 1 - # move all comps down one row comps_rows += 1 - + if self.left_outs: size += 1 - # shift all comps to the right by one, to make room for inputs comps_cols += 1 - + if self.right_outs: size += 1 - # don't need to shift anything in this case - - # build a map between comp node_names and row idx for ordering calculations + row_idx_map = {} col_idx_map = {} - + node_str = r"\node [{style}] ({node_name}) {{{node_label}}};" - grid = np.empty((size, size), dtype=object) grid[:] = "" - - # add all the components on the diagonal + + # Add diagonal systems for i_row, j_col, comp in zip(comps_rows, comps_cols, self.systems): style = comp.style if comp.stack: style += ",stack" if comp.faded: style += ",faded" - + label = _parse_label(comp.label, comp.label_width) node = node_str.format(style=style, node_name=comp.node_name, node_label=label) grid[i_row, j_col] = node - + row_idx_map[comp.node_name] = i_row col_idx_map[comp.node_name] = j_col - - # add all the off diagonal nodes from components + + # Add off-diagonal connection nodes for conn in self.connections: - # src, target, style, label, stack, faded, label_width src_row = row_idx_map[conn.src] target_col = col_idx_map[conn.target] - - loc = (src_row, target_col) - + style = conn.style if conn.stack: style += ",stack" if conn.faded: style += ",faded" - + label = _parse_label(conn.label, conn.label_width) - - node_name = "{}-{}".format(conn.src, conn.target) - + node_name = f"{conn.src}-{conn.target}" node = node_str.format(style=style, node_name=node_name, node_label=label) - - grid[loc] = node - - # add the nodes for left outputs + + grid[src_row, target_col] = node + + # Add left outputs for comp_name, out in self.left_outs.items(): style = out.style if out.stack: style += ",stack" if out.faded: style += ",faded" - + i_row = row_idx_map[comp_name] - loc = (i_row, 0) - label = _parse_label(out.label, out.label_width) node = node_str.format(style=style, node_name=out.node_name, node_label=label) - - grid[loc] = node - - # add the nodes for right outputs + grid[i_row, 0] = node + + # Add right outputs for comp_name, out in self.right_outs.items(): style = out.style if out.stack: style += ",stack" if out.faded: style += ",faded" - + i_row = row_idx_map[comp_name] - loc = (i_row, -1) label = _parse_label(out.label, out.label_width) node = node_str.format(style=style, node_name=out.node_name, node_label=label) - - grid[loc] = node - - # add the inputs to the top of the grid + grid[i_row, -1] = node + + # Add inputs for comp_name, inp in self.ins.items(): - # node_name, style, label, stack = in_data style = inp.style if inp.stack: style += ",stack" if inp.faded: style += ",faded" - + j_col = col_idx_map[comp_name] - loc = (0, j_col) - label = _parse_label(inp.label, label_width=inp.label_width) + label = _parse_label(inp.label, inp.label_width) node = node_str.format(style=style, node_name=inp.node_name, node_label=label) - - grid[loc] = node - - # mash the grid data into a string + grid[0, j_col] = node + + # Convert grid to string rows_str = "" for i, row in enumerate(grid): - rows_str += "%Row {}\n".format(i) + "&\n".join(row) + r"\\" + "\n" - + rows_str += f"%Row {i}\n" + "&\n".join(row) + r"\\" + "\n" + return rows_str - - def _build_edges(self): + + def _build_edges(self) -> str: + """Build the TikZ edge definitions.""" h_edges = [] v_edges = [] - - edge_format_string = "({start}) edge [{style}] ({end})" + + edge_format = "({start}) edge [{style}] ({end})" + for conn in self.connections: - h_edge_style = "DataLine" - v_edge_style = "DataLine" + h_style = "DataLine" + v_style = "DataLine" + if conn.src_faded or conn.faded: - h_edge_style += ",faded" + h_style += ",faded" if conn.target_faded or conn.faded: - v_edge_style += ",faded" - od_node_name = "{}-{}".format(conn.src, conn.target) - - h_edges.append(edge_format_string.format(start=conn.src, end=od_node_name, style=h_edge_style)) - v_edges.append(edge_format_string.format(start=od_node_name, end=conn.target, style=v_edge_style)) - + v_style += ",faded" + + od_node = f"{conn.src}-{conn.target}" + h_edges.append(edge_format.format(start=conn.src, end=od_node, style=h_style)) + v_edges.append(edge_format.format(start=od_node, end=conn.target, style=v_style)) + for comp_name, out in self.left_outs.items(): style = "DataLine" if out.faded: style += ",faded" - node_name = out.node_name - h_edges.append(edge_format_string.format(start=comp_name, end=node_name, style=style)) - + h_edges.append(edge_format.format(start=comp_name, end=out.node_name, style=style)) + for comp_name, out in self.right_outs.items(): style = "DataLine" if out.faded: style += ",faded" - node_name = out.node_name - h_edges.append(edge_format_string.format(start=comp_name, end=node_name, style=style)) - + h_edges.append(edge_format.format(start=comp_name, end=out.node_name, style=style)) + for comp_name, inp in self.ins.items(): style = "DataLine" if inp.faded: style += ",faded" - node_name = inp.node_name - v_edges.append(edge_format_string.format(start=comp_name, end=node_name, style=style)) - + v_edges.append(edge_format.format(start=comp_name, end=inp.node_name, style=style)) + h_edges = sorted(h_edges, key=lambda s: "faded" in s) v_edges = sorted(v_edges, key=lambda s: "faded" in s) - + paths_str = "% Horizontal edges\n" + "\n".join(h_edges) + "\n" paths_str += "% Vertical edges\n" + "\n".join(v_edges) + ";" - + return paths_str - - def _build_process_chain(self): + + def _build_process_chain(self) -> str: + """Build the TikZ process chain definitions.""" sys_names = [s.node_name for s in self.systems] output_names = ( - [data[0] for _, data in self.ins.items()] - + [data[0] for _, data in self.left_outs.items()] - + [data[0] for _, data in self.right_outs.items()] + [inp.node_name for inp in self.ins.values()] + + [out.node_name for out in self.left_outs.values()] + + [out.node_name for out in self.right_outs.values()] ) - # comp_name, in_data in self.ins.items(): - # node_name, style, label, stack = in_data + chain_str = "" - + for proc in self.processes: chain_str += "{ [start chain=process]\n \\begin{pgfonlayer}{process} \n" start_tip = False + for i, sys in enumerate(proc.systems): if sys not in sys_names and sys not in output_names: - raise ValueError( - 'process includes a system named "{}" but no system with that name exists.'.format(sys) - ) + raise ValueError(f'Process includes system "{sys}" but no such system exists') + if sys in output_names and i == 0: start_tip = True + if i == 0: - chain_str += "\\chainin ({});\n".format(sys) + chain_str += f"\\chainin ({sys});\n" else: if sys in output_names or (i == 1 and start_tip): - if proc.arrow: - style = "ProcessTipA" - else: - style = "ProcessTip" + style = "ProcessTipA" if proc.arrow else "ProcessTip" else: - if proc.arrow: - style = "ProcessHVA" - else: - style = "ProcessHV" + style = "ProcessHVA" if proc.arrow else "ProcessHV" + if proc.faded: style = "Faded" + style - chain_str += "\\chainin ({}) [join=by {}];\n".format(sys, style) + + chain_str += f"\\chainin ({sys}) [join=by {style}];\n" + chain_str += "\\end{pgfonlayer}\n}" - + return chain_str - - def _compose_optional_package_list(self): - # Check for optional LaTeX packages - optional_packages_list = self.optional_packages + + def _compose_optional_package_list(self) -> str: + """Compose the optional LaTeX package list.""" + packages = self.optional_packages.copy() if self.use_sfmath: - optional_packages_list.append("sfmath") - - # Join all packages into one string separated by comma - optional_packages_str = ",".join(optional_packages_list) - - return optional_packages_str - - def write(self, file_name, build=True, cleanup=True, quiet=False, outdir="."): + packages.append("sfmath") + return ",".join(packages) + + def write(self, file_name: str, build: bool = True, cleanup: bool = True, + quiet: bool = False, outdir: str = ".") -> None: """ - Write output files for the XDSM diagram. This produces the following: - - - {file_name}.tikz - A file containing the TikZ definition of the XDSM diagram. - - {file_name}.tex - A standalone document wrapped around an include of the TikZ file which can - be compiled to a pdf. - - {file_name}.pdf - An optional compiled version of the standalone tex file. + Write output files for the XDSM diagram. Parameters ---------- file_name : str - The prefix to be used for the output files + Prefix for output files build : bool - Flag that determines whether the standalone PDF of the XDSM will be compiled. - Default is True. + Whether to compile the PDF cleanup : bool - Flag that determines if pdflatex build files will be deleted after build is complete + Whether to delete build files after compilation quiet : bool - Set to True to suppress output from pdflatex. + Suppress pdflatex output outdir : str - Path to an existing directory in which to place output files. If a relative - path is given, it is interpreted relative to the current working directory. + Output directory path """ nodes = self._build_node_grid() edges = self._build_edges() process = self._build_process_chain() - + module_path = os.path.dirname(__file__) diagram_styles_path = os.path.join(module_path, "diagram_styles") - # Hack for Windows. MiKTeX needs Linux style paths. diagram_styles_path = diagram_styles_path.replace("\\", "/") - + optional_packages_str = self._compose_optional_package_list() - + tikzpicture_str = tikzpicture_template.format( nodes=nodes, edges=edges, @@ -673,11 +669,11 @@ def write(self, file_name, build=True, cleanup=True, quiet=False, outdir="."): diagram_styles_path=diagram_styles_path, optional_packages=optional_packages_str, ) - + base_output_fp = os.path.join(outdir, file_name) with open(base_output_fp + ".tikz", "w") as f: f.write(tikzpicture_str) - + tex_str = tex_template.format( nodes=nodes, edges=edges, @@ -686,73 +682,61 @@ def write(self, file_name, build=True, cleanup=True, quiet=False, outdir="."): optional_packages=optional_packages_str, version=pyxdsm_version, ) - + with open(base_output_fp + ".tex", "w") as f: f.write(tex_str) - + if build: command = [ "pdflatex", "-halt-on-error", "-interaction=nonstopmode", - "-output-directory={}".format(outdir), + f"-output-directory={outdir}", ] if quiet: command += ["-interaction=batchmode", "-halt-on-error"] command += [f"{file_name}.tex"] subprocess.run(command, check=True) + if cleanup: for ext in ["aux", "fdb_latexmk", "fls", "log"]: - f_name = "{}.{}".format(base_output_fp, ext) + f_name = f"{base_output_fp}.{ext}" if os.path.exists(f_name): os.remove(f_name) - - def write_sys_specs(self, folder_name): + + def write_sys_specs(self, folder_name: str) -> None: """ - Write I/O spec json files for systems to specified folder - - An I/O spec of a system is the collection of all variables going into and out of it. - That includes any variables being passed between systems, as well as all inputs and outputs. - This information is useful for comparing implementations (such as components and groups in OpenMDAO) - to the XDSM diagrams. - - The json spec files can be used to write testing utilities that compare the inputs/outputs of an implementation - to the XDSM, and thus allow you to verify that your codes match the XDSM diagram precisely. - This technique is especially useful when large engineering teams are collaborating on - model development. It allows them to use the XDSM as a shared contract between team members - so everyone can be sure that their codes will sync up. + Write I/O spec JSON files for systems. Parameters ---------- - folder_name: str - name of the folder, which will be created if it doesn't exist, to put spec files into + folder_name : str + Folder to write spec files into """ - - # find un-connected to each system by looking at Inputs specs = {} for sys in self.systems: specs[sys.node_name] = {"inputs": set(), "outputs": set()} - + + # Add inputs from Input nodes for sys_name, inp in self.ins.items(): _label_to_spec(inp.label, specs[sys_name]["inputs"]) - - # find connected inputs/outputs to each system by looking at Connections + + # Add inputs/outputs from Connections for conn in self.connections: _label_to_spec(conn.label, specs[conn.target]["inputs"]) - _label_to_spec(conn.label, specs[conn.src]["outputs"]) - - # find unconnected outputs to each system by looking at Outputs + + # Add outputs from Output nodes for sys_name, out in self.left_outs.items(): _label_to_spec(out.label, specs[sys_name]["outputs"]) for sys_name, out in self.right_outs.items(): _label_to_spec(out.label, specs[sys_name]["outputs"]) - + if not os.path.isdir(folder_name): os.mkdir(folder_name) - + for sys in self.systems: - if sys.spec_name is not False: + if sys.spec_name is not False and sys.spec_name is not None: path = os.path.join(folder_name, sys.spec_name + ".json") with open(path, "w") as f: spec = specs[sys.node_name] @@ -760,3 +744,69 @@ def write_sys_specs(self, folder_name): spec["outputs"] = list(spec["outputs"]) json_str = json.dumps(spec, indent=2) f.write(json_str) + + def to_dict(self) -> dict: + """Export XDSM specification to dictionary.""" + return self.model_dump() + + def to_json(self, filename: Optional[str] = None) -> str: + """Export XDSM specification to JSON.""" + json_str = self.model_dump_json(indent=2) + if filename: + with open(filename, 'w') as f: + f.write(json_str) + return json_str + + @classmethod + def from_dict(cls, data: dict) -> 'XDSM': + """Load XDSM from dictionary.""" + return cls.model_validate(data) + + @classmethod + def from_json(cls, filename: str) -> 'XDSM': + """Load XDSM from JSON file.""" + with open(filename, 'r') as f: + data = json.load(f) + return cls.model_validate(data) + + +# Example usage +if __name__ == "__main__": + # Create XDSM with validation + xdsm = XDSM(use_sfmath=True, auto_fade={'connections': 'connected'}) + + # Add systems - note: use the proper style constants + xdsm.add_system('opt', OPT, r'\text{Optimizer}') + xdsm.add_system('d1', FUNC, r'\text{Discipline 1}') # Changed to FUNC which is valid + xdsm.add_system('d2', FUNC, r'\text{Discipline 2}') + xdsm.add_system('func', FUNC, r'\text{Objective}') + + # Add connections + xdsm.connect('opt', 'd1', r'x_1') + xdsm.connect('opt', 'd2', r'x_2') + xdsm.connect('d1', 'd2', r'y_1') + xdsm.connect('d2', 'd1', r'y_2') + xdsm.connect('d1', 'func', r'f_1') + xdsm.connect('d2', 'func', r'f_2') + xdsm.connect('func', 'opt', r'F') + + # Add process + xdsm.add_process(['opt', 'd1', 'd2', 'func', 'opt']) + + # Export to JSON + xdsm.to_json('xdsm_spec.json') + + # Write LaTeX files + xdsm.write('example_xdsm', build=True) + + # Load from JSON + xdsm_loaded = XDSM.from_json('xdsm_spec.json') + print("Successfully loaded XDSM from JSON") + + # # Validate example - this will raise an error + # try: + # bad_xdsm = XDSM() + # bad_xdsm.add_system('sys1', OPT, 'System 1') + # bad_xdsm.connect('sys1', 'sys1', 'Invalid') # Self-connection error + # except ValueError as e: + # print(f"Validation caught error: {e}") \ No newline at end of file From 37c58f032107e7772c2aba737763b704a37f8ff9 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Fri, 10 Oct 2025 18:24:38 -0400 Subject: [PATCH 02/25] Separated latex writer. --- examples/kitchen_sink.py | 5 + pyxdsm/XDSM.py | 458 ++++++++---------------------------- pyxdsm/matrix_eqn.py | 199 +++++++++------- pyxdsm/xdsm_latex_writer.py | 412 ++++++++++++++++++++++++++++++++ 4 files changed, 626 insertions(+), 448 deletions(-) create mode 100644 pyxdsm/xdsm_latex_writer.py diff --git a/examples/kitchen_sink.py b/examples/kitchen_sink.py index 95638ab..afef79f 100644 --- a/examples/kitchen_sink.py +++ b/examples/kitchen_sink.py @@ -87,3 +87,8 @@ x.write("kitchen_sink", cleanup=False) x.write_sys_specs("sink_specs") +x.to_json("kitchen_sink.json") + +x2 = XDSM.from_json("kitchen_sink.json") +x2.write("kitchen_sink2", cleanup=True) + diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index b6a33fb..2e93f81 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -6,12 +6,11 @@ import os import numpy as np import json -import subprocess from typing import Literal, Optional, Tuple, List, Dict, Set, Union from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict import plotly.graph_objects as go -from pyxdsm import __version__ as pyxdsm_version +from pyxdsm.xdsm_latex_writer import XDSMLatexWriter # Constants OPT = "Optimization" @@ -39,96 +38,6 @@ 'Function', 'Group', 'ImplicitGroup', 'Metamodel', 'DataInter', 'DataIO' } -# LaTeX templates -tikzpicture_template = r""" -%%% Preamble Requirements %%% -% \usepackage{{geometry}} -% \usepackage{{amsfonts}} -% \usepackage{{amsmath}} -% \usepackage{{amssymb}} -% \usepackage{{tikz}} - -% Optional packages such as sfmath set through python interface -% \usepackage{{{optional_packages}}} - -% \usetikzlibrary{{arrows,chains,positioning,scopes,shapes.geometric,shapes.misc,shadows}} - -%%% End Preamble Requirements %%% - -\input{{"{diagram_styles_path}"}} -\begin{{tikzpicture}} - -\matrix[MatrixSetup]{{ -{nodes}}}; - -% XDSM process chains -{process} - -\begin{{pgfonlayer}}{{data}} -\path -{edges} -\end{{pgfonlayer}} - -\end{{tikzpicture}} -""" - -tex_template = r""" -% XDSM diagram created with pyXDSM {version}. -\documentclass{{article}} -\usepackage{{geometry}} -\usepackage{{amsfonts}} -\usepackage{{amsmath}} -\usepackage{{amssymb}} -\usepackage{{tikz}} - -% Optional packages such as sfmath set through python interface -\usepackage{{{optional_packages}}} - -% Define the set of TikZ packages to be included in the architecture diagram document -\usetikzlibrary{{arrows,chains,positioning,scopes,shapes.geometric,shapes.misc,shadows}} - - -% Set the border around all of the architecture diagrams to be tight to the diagrams themselves -% (i.e. no longer need to tinker with page size parameters) -\usepackage[active,tightpage]{{preview}} -\PreviewEnvironment{{tikzpicture}} -\setlength{{\PreviewBorder}}{{5pt}} - -\begin{{document}} - -\input{{"{tikzpicture_path}"}} - -\end{{document}} -""" - - -def chunk_label(label, n_chunks): - for i in range(0, len(label), n_chunks): - yield label[i : i + n_chunks] - - -def _parse_label(label: Union[str, List[str], Tuple[str, ...]], label_width: Optional[int] = None) -> str: - """Parse label into LaTeX format.""" - if isinstance(label, (tuple, list)): - if label_width is None: - return r"$\begin{array}{c}" + r" \\ ".join(label) + r"\end{array}$" - else: - labels = [] - for chunk in chunk_label(label, label_width): - labels.append(", ".join(chunk)) - return r"$\begin{array}{c}" + r" \\ ".join(labels) + r"\end{array}$" - else: - return r"${}$".format(label) - - -def _label_to_spec(label: Union[str, List[str], Tuple[str, ...]], spec: Set[str]) -> None: - """Add label variables to spec set.""" - if isinstance(label, str): - label = [label] - for var in label: - if var: - spec.add(var) - class SystemNode(BaseModel): """System node on the diagonal of XDSM diagram.""" @@ -232,11 +141,11 @@ def validate_no_self_connection(self): class ProcessChain(BaseModel): """Process flow chain between systems.""" - + systems: List[str] = Field(..., description="List of system names in order") arrow: bool = Field(default=True, description="Show arrows on process lines") faded: bool = Field(default=False, description="Fade the process chain") - + @field_validator('systems') @classmethod def validate_systems(cls, v: List[str]) -> List[str]: @@ -277,11 +186,10 @@ class XDSM(BaseModel): systems: List[SystemNode] = Field(default_factory=list, description="System nodes") connections: List[ConnectionEdge] = Field(default_factory=list, description="Connections") - ins: Dict[str, InputNode] = Field(default_factory=dict, description="Input nodes") - left_outs: Dict[str, OutputNode] = Field(default_factory=dict, description="Left output nodes") - right_outs: Dict[str, OutputNode] = Field(default_factory=dict, description="Right output nodes") + inputs: Dict[str, InputNode] = Field(default_factory=dict, description="Input nodes") + outputs: Dict[str, OutputNode] = Field(default_factory=dict, description="Left output nodes") processes: List[ProcessChain] = Field(default_factory=list, description="Process chains") - + use_sfmath: bool = Field(default=True, description="Use sfmath LaTeX package") optional_packages: List[str] = Field(default_factory=list, description="Additional LaTeX packages") auto_fade: AutoFadeConfig = Field(default_factory=AutoFadeConfig, description="Auto-fade configuration") @@ -328,7 +236,28 @@ def __init__(self, use_sfmath: bool = True, data['use_sfmath'] = use_sfmath super().__init__(**data) - + + @model_validator(mode='before') + @classmethod + def set_defaults_for_missing_fields(cls, data): + """Ensure missing or null collection fields get empty defaults.""" + if not isinstance(data, dict): + return data + + # Set empty defaults for missing or null collection fields + if 'inputs' not in data or data.get('inputs') is None: + data['inputs'] = {} + if 'outputs' not in data or data.get('outputs') is None: + data['outputs'] = {} + if 'systems' not in data or data.get('systems') is None: + data['systems'] = [] + if 'connections' not in data or data.get('connections') is None: + data['connections'] = [] + if 'processes' not in data or data.get('processes') is None: + data['processes'] = [] + + return data + @model_validator(mode='after') def validate_unique_system_names(self): """Ensure all system names are unique.""" @@ -363,7 +292,7 @@ def add_input(self, name: str, label: Union[str, List[str], Tuple[str, ...]], (self.auto_fade.inputs == "connected" and name in sys_faded and sys_faded[name]): faded = True - self.ins[name] = InputNode( + self.inputs[name] = InputNode( node_name="output_" + name, label=label, label_width=label_width, @@ -392,12 +321,7 @@ def add_output(self, name: str, label: Union[str, List[str], Tuple[str, ...]], side=side ) - if side == "left": - self.left_outs[name] = output - elif side == "right": - self.right_outs[name] = output - else: - raise ValueError("Side must be 'left' or 'right'") + self.outputs[name] = output def connect(self, src: str, target: str, label: Union[str, List[str], Tuple[str, ...]], label_width: Optional[int] = None, style: str = "DataInter", @@ -431,213 +355,19 @@ def connect(self, src: str, target: str, label: Union[str, List[str], Tuple[str, def add_process(self, systems: List[str], arrow: bool = True, faded: bool = False) -> None: """Add a process line between systems.""" sys_faded = {s.node_name: s.faded for s in self.systems} - + if (self.auto_fade.processes == "all") or \ - (self.auto_fade.processes == "connected" and + (self.auto_fade.processes == "connected" and any([sys_faded.get(s, False) for s in systems])): faded = True - + process = ProcessChain(systems=systems, arrow=arrow, faded=faded) self.processes.append(process) - - def _build_node_grid(self) -> str: - """Build the TikZ node grid.""" - size = len(self.systems) - comps_rows = np.arange(size) - comps_cols = np.arange(size) - - if self.ins: - size += 1 - comps_rows += 1 - - if self.left_outs: - size += 1 - comps_cols += 1 - - if self.right_outs: - size += 1 - - row_idx_map = {} - col_idx_map = {} - - node_str = r"\node [{style}] ({node_name}) {{{node_label}}};" - grid = np.empty((size, size), dtype=object) - grid[:] = "" - - # Add diagonal systems - for i_row, j_col, comp in zip(comps_rows, comps_cols, self.systems): - style = comp.style - if comp.stack: - style += ",stack" - if comp.faded: - style += ",faded" - - label = _parse_label(comp.label, comp.label_width) - node = node_str.format(style=style, node_name=comp.node_name, node_label=label) - grid[i_row, j_col] = node - - row_idx_map[comp.node_name] = i_row - col_idx_map[comp.node_name] = j_col - - # Add off-diagonal connection nodes - for conn in self.connections: - src_row = row_idx_map[conn.src] - target_col = col_idx_map[conn.target] - - style = conn.style - if conn.stack: - style += ",stack" - if conn.faded: - style += ",faded" - - label = _parse_label(conn.label, conn.label_width) - node_name = f"{conn.src}-{conn.target}" - node = node_str.format(style=style, node_name=node_name, node_label=label) - - grid[src_row, target_col] = node - - # Add left outputs - for comp_name, out in self.left_outs.items(): - style = out.style - if out.stack: - style += ",stack" - if out.faded: - style += ",faded" - - i_row = row_idx_map[comp_name] - label = _parse_label(out.label, out.label_width) - node = node_str.format(style=style, node_name=out.node_name, node_label=label) - grid[i_row, 0] = node - - # Add right outputs - for comp_name, out in self.right_outs.items(): - style = out.style - if out.stack: - style += ",stack" - if out.faded: - style += ",faded" - - i_row = row_idx_map[comp_name] - label = _parse_label(out.label, out.label_width) - node = node_str.format(style=style, node_name=out.node_name, node_label=label) - grid[i_row, -1] = node - - # Add inputs - for comp_name, inp in self.ins.items(): - style = inp.style - if inp.stack: - style += ",stack" - if inp.faded: - style += ",faded" - - j_col = col_idx_map[comp_name] - label = _parse_label(inp.label, inp.label_width) - node = node_str.format(style=style, node_name=inp.node_name, node_label=label) - grid[0, j_col] = node - - # Convert grid to string - rows_str = "" - for i, row in enumerate(grid): - rows_str += f"%Row {i}\n" + "&\n".join(row) + r"\\" + "\n" - - return rows_str - - def _build_edges(self) -> str: - """Build the TikZ edge definitions.""" - h_edges = [] - v_edges = [] - - edge_format = "({start}) edge [{style}] ({end})" - - for conn in self.connections: - h_style = "DataLine" - v_style = "DataLine" - - if conn.src_faded or conn.faded: - h_style += ",faded" - if conn.target_faded or conn.faded: - v_style += ",faded" - - od_node = f"{conn.src}-{conn.target}" - h_edges.append(edge_format.format(start=conn.src, end=od_node, style=h_style)) - v_edges.append(edge_format.format(start=od_node, end=conn.target, style=v_style)) - - for comp_name, out in self.left_outs.items(): - style = "DataLine" - if out.faded: - style += ",faded" - h_edges.append(edge_format.format(start=comp_name, end=out.node_name, style=style)) - - for comp_name, out in self.right_outs.items(): - style = "DataLine" - if out.faded: - style += ",faded" - h_edges.append(edge_format.format(start=comp_name, end=out.node_name, style=style)) - - for comp_name, inp in self.ins.items(): - style = "DataLine" - if inp.faded: - style += ",faded" - v_edges.append(edge_format.format(start=comp_name, end=inp.node_name, style=style)) - - h_edges = sorted(h_edges, key=lambda s: "faded" in s) - v_edges = sorted(v_edges, key=lambda s: "faded" in s) - - paths_str = "% Horizontal edges\n" + "\n".join(h_edges) + "\n" - paths_str += "% Vertical edges\n" + "\n".join(v_edges) + ";" - - return paths_str - - def _build_process_chain(self) -> str: - """Build the TikZ process chain definitions.""" - sys_names = [s.node_name for s in self.systems] - output_names = ( - [inp.node_name for inp in self.ins.values()] + - [out.node_name for out in self.left_outs.values()] + - [out.node_name for out in self.right_outs.values()] - ) - - chain_str = "" - - for proc in self.processes: - chain_str += "{ [start chain=process]\n \\begin{pgfonlayer}{process} \n" - start_tip = False - - for i, sys in enumerate(proc.systems): - if sys not in sys_names and sys not in output_names: - raise ValueError(f'Process includes system "{sys}" but no such system exists') - - if sys in output_names and i == 0: - start_tip = True - - if i == 0: - chain_str += f"\\chainin ({sys});\n" - else: - if sys in output_names or (i == 1 and start_tip): - style = "ProcessTipA" if proc.arrow else "ProcessTip" - else: - style = "ProcessHVA" if proc.arrow else "ProcessHV" - - if proc.faded: - style = "Faded" + style - - chain_str += f"\\chainin ({sys}) [join=by {style}];\n" - - chain_str += "\\end{pgfonlayer}\n}" - - return chain_str - - def _compose_optional_package_list(self) -> str: - """Compose the optional LaTeX package list.""" - packages = self.optional_packages.copy() - if self.use_sfmath: - packages.append("sfmath") - return ",".join(packages) - + def write(self, file_name: str, build: bool = True, cleanup: bool = True, quiet: bool = False, outdir: str = ".") -> None: """ - Write output files for the XDSM diagram. + Write output files for the XDSM diagram (delegates to XDSMLatexWriter). Parameters ---------- @@ -652,58 +382,49 @@ def write(self, file_name: str, build: bool = True, cleanup: bool = True, outdir : str Output directory path """ - nodes = self._build_node_grid() - edges = self._build_edges() - process = self._build_process_chain() - - module_path = os.path.dirname(__file__) - diagram_styles_path = os.path.join(module_path, "diagram_styles") - diagram_styles_path = diagram_styles_path.replace("\\", "/") - - optional_packages_str = self._compose_optional_package_list() - - tikzpicture_str = tikzpicture_template.format( - nodes=nodes, - edges=edges, - process=process, - diagram_styles_path=diagram_styles_path, - optional_packages=optional_packages_str, - ) - - base_output_fp = os.path.join(outdir, file_name) - with open(base_output_fp + ".tikz", "w") as f: - f.write(tikzpicture_str) - - tex_str = tex_template.format( - nodes=nodes, - edges=edges, - tikzpicture_path=file_name + ".tikz", - diagram_styles_path=diagram_styles_path, - optional_packages=optional_packages_str, - version=pyxdsm_version, - ) + XDSMLatexWriter.write(self, file_name, build, cleanup, quiet, outdir) + + def to_latex(self, file_name: str, build: bool = True, cleanup: bool = True, + quiet: bool = False, outdir: str = ".") -> None: + """ + Export XDSM diagram to LaTeX/TikZ format. - with open(base_output_fp + ".tex", "w") as f: - f.write(tex_str) + Alias for write() method for clarity when exporting to LaTeX. + + Parameters + ---------- + file_name : str + Prefix for output files + build : bool + Whether to compile the PDF + cleanup : bool + Whether to delete build files after compilation + quiet : bool + Suppress pdflatex output + outdir : str + Output directory path + """ + XDSMLatexWriter.write(self, file_name, build, cleanup, quiet, outdir) + + def write_html(self, file_name: str, title: str = "XDSM Diagram", + show_browser: bool = False) -> None: + """ + Export XDSM diagram to HTML with TikZ rendered in browser using TikZJax. + This produces output identical to the LaTeX/PDF version but viewable in a browser. - if build: - command = [ - "pdflatex", - "-halt-on-error", - "-interaction=nonstopmode", - f"-output-directory={outdir}", - ] - if quiet: - command += ["-interaction=batchmode", "-halt-on-error"] - command += [f"{file_name}.tex"] - subprocess.run(command, check=True) - - if cleanup: - for ext in ["aux", "fdb_latexmk", "fls", "log"]: - f_name = f"{base_output_fp}.{ext}" - if os.path.exists(f_name): - os.remove(f_name) + Parameters + ---------- + file_name : str + Output HTML file name (with or without .html extension) + title : str + Title for the diagram + show_browser : bool + Whether to open the HTML file in browser after creation + """ + from pyxdsm.xdsm_tikzjax_writer import XDSMTikZJaxWriter + XDSMTikZJaxWriter.write(self, file_name, title, show_browser) + def write_sys_specs(self, folder_name: str) -> None: """ Write I/O spec JSON files for systems. @@ -713,12 +434,20 @@ def write_sys_specs(self, folder_name: str) -> None: folder_name : str Folder to write spec files into """ + def _label_to_spec(label: Union[str, List[str], Tuple[str, ...]], spec: Set[str]) -> None: + """Add label variables to spec set.""" + if isinstance(label, str): + label = [label] + for var in label: + if var: + spec.add(var) + specs = {} for sys in self.systems: specs[sys.node_name] = {"inputs": set(), "outputs": set()} # Add inputs from Input nodes - for sys_name, inp in self.ins.items(): + for sys_name, inp in self.inputs.items(): _label_to_spec(inp.label, specs[sys_name]["inputs"]) # Add inputs/outputs from Connections @@ -727,9 +456,7 @@ def write_sys_specs(self, folder_name: str) -> None: _label_to_spec(conn.label, specs[conn.src]["outputs"]) # Add outputs from Output nodes - for sys_name, out in self.left_outs.items(): - _label_to_spec(out.label, specs[sys_name]["outputs"]) - for sys_name, out in self.right_outs.items(): + for sys_name, out in self.outputs.items(): _label_to_spec(out.label, specs[sys_name]["outputs"]) if not os.path.isdir(folder_name): @@ -798,15 +525,16 @@ def from_json(cls, filename: str) -> 'XDSM': # Write LaTeX files xdsm.write('example_xdsm', build=True) + xdsm.write_html('example_xdsm') # Load from JSON xdsm_loaded = XDSM.from_json('xdsm_spec.json') print("Successfully loaded XDSM from JSON") - # # Validate example - this will raise an error - # try: - # bad_xdsm = XDSM() - # bad_xdsm.add_system('sys1', OPT, 'System 1') - # bad_xdsm.connect('sys1', 'sys1', 'Invalid') # Self-connection error - # except ValueError as e: - # print(f"Validation caught error: {e}") \ No newline at end of file + # Validate example - this will raise an error + try: + bad_xdsm = XDSM() + bad_xdsm.add_system('sys1', OPT, 'System 1') + bad_xdsm.connect('sys1', 'sys1', 'Invalid') # Self-connection error + except ValueError as e: + print(f"Validation caught error: {e}") diff --git a/pyxdsm/matrix_eqn.py b/pyxdsm/matrix_eqn.py index ab6957c..2905305 100644 --- a/pyxdsm/matrix_eqn.py +++ b/pyxdsm/matrix_eqn.py @@ -1,7 +1,8 @@ import os import subprocess -from collections import namedtuple +from typing import Optional, Dict, List, Union, Tuple import numpy as np +from pydantic import BaseModel, Field, field_validator, ConfigDict # color pallette link: http://paletton.com/#uid=72Q1j0kllllkS5tKC9H96KClOKC @@ -208,9 +209,35 @@ \end{document}""" -Variable = namedtuple("Variable", field_names=["size", "idx", "text", "color"]) +class Variable(BaseModel): + """Variable in matrix equation.""" -CellData = namedtuple("CellData", field_names=["text", "color", "highlight"]) + size: int = Field(..., description="Size/dimension of the variable") + idx: int = Field(..., description="Index in the matrix") + text: str = Field(default="", description="Display text/label") + color: Optional[str] = Field(default=None, description="Color for the variable") + + @field_validator('size') + @classmethod + def validate_size(cls, v: int) -> int: + if v < 1: + raise ValueError("Variable size must be at least 1") + return v + + @field_validator('idx') + @classmethod + def validate_idx(cls, v: int) -> int: + if v < 0: + raise ValueError("Variable index must be non-negative") + return v + + +class CellData(BaseModel): + """Data for a cell in matrix equation.""" + + text: str = Field(default="", description="Cell text/label") + color: Optional[str] = Field(default=None, description="Cell color") + highlight: Union[int, str] = Field(default=1, description="Highlight level or type") def _color(base_color, h_light): @@ -247,49 +274,52 @@ def _write_tikz(tikz, out_file, build=True, cleanup=True): os.remove(f_name) -class TotalJacobian(object): - def __init__(self): - self._variables = {} - self._j_inputs = {} - self._n_inputs = 0 +class TotalJacobian(BaseModel): + """Total Jacobian matrix representation.""" - self._i_outputs = {} - self._n_outputs = 0 + variables: Dict[str, Variable] = Field(default_factory=dict) + j_inputs: Dict[int, Variable] = Field(default_factory=dict) + n_inputs: int = Field(default=0) - self._connections = {} - self._ij_connections = {} + i_outputs: Dict[int, Variable] = Field(default_factory=dict) + n_outputs: int = Field(default=0) - self._setup = False + connections: Dict[Tuple[str, str], CellData] = Field(default_factory=dict) + ij_connections: Dict[Tuple[int, int], CellData] = Field(default_factory=dict) + + setup: bool = Field(default=False) + + model_config = ConfigDict(arbitrary_types_allowed=True) def add_input(self, name, size=1, text=""): - self._variables[name] = Variable(size=size, idx=self._n_inputs, text=text, color=None) - self._j_inputs[self._n_inputs] = self._variables[name] - self._n_inputs += 1 + self.variables[name] = Variable(size=size, idx=self.n_inputs, text=text, color=None) + self.j_inputs[self.n_inputs] = self.variables[name] + self.n_inputs += 1 def add_output(self, name, size=1, text=""): - self._variables[name] = Variable(size=size, idx=self._n_outputs, text=text, color=None) - self._i_outputs[self._n_outputs] = self._variables[name] - self._n_outputs += 1 + self.variables[name] = Variable(size=size, idx=self.n_outputs, text=text, color=None) + self.i_outputs[self.n_outputs] = self.variables[name] + self.n_outputs += 1 def connect(self, src, target, text="", color="tableau0"): if isinstance(target, (list, tuple)): for t in target: - self._connections[src, t] = CellData(text=text, color=color, highlight="diag") + self.connections[src, t] = CellData(text=text, color=color, highlight="diag") else: - self._connections[src, target] = CellData(text=text, color=color, highlight="diag") + self.connections[src, target] = CellData(text=text, color=color, highlight="diag") def _process_vars(self): - if self._setup: + if self.setup: return # deal with connections - for (src, target), cell_data in self._connections.items(): - i_src = self._variables[src].idx - j_target = self._variables[target].idx + for (src, target), cell_data in self.connections.items(): + i_src = self.variables[src].idx + j_target = self.variables[target].idx - self._ij_connections[i_src, j_target] = cell_data + self.ij_connections[i_src, j_target] = cell_data - self._setup = True + self.setup = True def write(self, out_file=None, build=True, cleanup=True): """ @@ -323,16 +353,16 @@ def write(self, out_file=None, build=True, cleanup=True): tikz.append(r" \blockcol{") tikz.append(r" \blockempty{%s*\comp}{%s*\comp}{%s}\\" % (1, 1, "")) tikz.append(r" }") - for j in range(self._n_inputs): - var = self._j_inputs[j] + for j in range(self.n_inputs): + var = self.j_inputs[j] col_size = var.size tikz.append(r" \blockcol{") tikz.append(r" \blockempty{%s*\comp}{%s*\comp}{%s}\\" % (col_size, 1, var.text)) tikz.append(r" }") tikz.append(r"}") - for i in range(self._n_outputs): - output = self._i_outputs[i] + for i in range(self.n_outputs): + output = self.i_outputs[i] row_size = output.size tikz.append(r"\blockrow{") @@ -342,12 +372,12 @@ def write(self, out_file=None, build=True, cleanup=True): tikz.append(r" \blockempty{%s*\comp}{%s*\comp}{%s}\\" % (1, row_size, output.text)) tikz.append(r" }") - for j in range(self._n_inputs): - var = self._j_inputs[j] + for j in range(self.n_inputs): + var = self.j_inputs[j] col_size = var.size tikz.append(r" \blockcol{") - if (j, i) in self._ij_connections: - cell_data = self._ij_connections[(j, i)] + if (j, i) in self.ij_connections: + cell_data = self.ij_connections[(j, i)] conn_color = "T{}".format(var.color) if cell_data.color is not None: conn_color = _color(cell_data.color, cell_data.highlight) @@ -366,78 +396,81 @@ def write(self, out_file=None, build=True, cleanup=True): _write_tikz(jac_tikz, out_file, build, cleanup) -class MatrixEquation(object): - def __init__(self): - self._variables = {} - self._ij_variables = {} +class MatrixEquation(BaseModel): + """Matrix equation representation.""" + + variables: Dict[str, Variable] = Field(default_factory=dict) + ij_variables: Dict[int, Variable] = Field(default_factory=dict) + + n_vars: int = Field(default=0) - self._n_vars = 0 + connections: Dict[Tuple[str, str], CellData] = Field(default_factory=dict) + ij_connections: Dict[Tuple[int, int], CellData] = Field(default_factory=dict) - self._connections = {} - self._ij_connections = {} + text_data: Dict[Tuple[str, str], CellData] = Field(default_factory=dict) + ij_text: Dict[Tuple[int, int], CellData] = Field(default_factory=dict) - self._text = {} - self._ij_text = {} + total_size: int = Field(default=0) - self._total_size = 0 + setup: bool = Field(default=False) - self._setup = False + terms: List[str] = Field(default_factory=list) - self._terms = [] + model_config = ConfigDict(arbitrary_types_allowed=True) def clear_terms(self): - self._terms = [] + self.terms = [] def add_variable(self, name, size=1, text="", color="blue"): - self._variables[name] = Variable(size=size, idx=self._n_vars, text=text, color=color) - self._ij_variables[self._n_vars] = self._variables[name] - self._n_vars += 1 + self.variables[name] = Variable(size=size, idx=self.n_vars, text=text, color=color) + self.ij_variables[self.n_vars] = self.variables[name] + self.n_vars += 1 - self._total_size += size + self.total_size += size def connect(self, src, target, text="", color=None, highlight=1): if isinstance(target, (list, tuple)): for t in target: - self._connections[src, t] = CellData(text=text, color=color, highlight=highlight) + self.connections[src, t] = CellData(text=text, color=color, highlight=highlight) else: - self._connections[src, target] = CellData(text=text, color=color, highlight=highlight) + self.connections[src, target] = CellData(text=text, color=color, highlight=highlight) def text(self, src, target, text): """Don't connect the src and target, but put some text where a connection would be""" - self._text[src, target] = CellData(text=text, color=None, highlight=-1) + self.text_data[src, target] = CellData(text=text, color=None, highlight=-1) def _process_vars(self): """Map all the data onto i,j grid""" - if self._setup: + if self.setup: return # deal with connections - for (src, target), cell_data in self._connections.items(): - i_src = self._variables[src].idx - i_target = self._variables[target].idx + for (src, target), cell_data in self.connections.items(): + i_src = self.variables[src].idx + i_target = self.variables[target].idx - self._ij_connections[i_src, i_target] = cell_data + self.ij_connections[i_src, i_target] = cell_data - for (src, target), cell_data in self._text.items(): - i_src = self._variables[src].idx - j_target = self._variables[target].idx + for (src, target), cell_data in self.text_data.items(): + i_src = self.variables[src].idx + j_target = self.variables[target].idx - self._ij_text[i_src, j_target] = cell_data + self.ij_text[i_src, j_target] = cell_data - self._setup = True + self.setup = True def jacobian(self, transpose=False): self._process_vars() tikz = [] - for i in range(self._n_vars): + for i in range(self.n_vars): tikz.append(r"\blockrow{") - row_size = self._ij_variables[i].size - for j in range(self._n_vars): - var = self._ij_variables[j] + row_size = self.ij_variables[i].size + for j in range(self.n_vars): + var = self.ij_variables[j] col_size = var.size tikz.append(r" \blockcol{") @@ -451,8 +484,8 @@ def jacobian(self, transpose=False): r" \blockmat{%s*\comp}{%s*\comp}{%s}{draw=white,fill=D%s}{}\\" % (col_size, row_size, var.text, var.color) ) - elif location in self._ij_connections: - cell_data = self._ij_connections[location] + elif location in self.ij_connections: + cell_data = self.ij_connections[location] conn_color = "T{}".format(var.color) if cell_data.color is not None: conn_color = _color(cell_data.color, cell_data.highlight) @@ -460,8 +493,8 @@ def jacobian(self, transpose=False): r" \blockmat{%s*\comp}{%s*\comp}{%s}{draw=white,fill=%s}{}\\" % (col_size, row_size, cell_data.text, conn_color) ) - elif location in self._ij_text: - cell_data = self._ij_text[location] + elif location in self.ij_text: + cell_data = self.ij_text[location] tikz.append(r" \blockempty{%s*\comp}{%s*\comp}{%s}\\" % (col_size, row_size, cell_data.text)) else: tikz.append(r" \blockempty{%s*\comp}{%s*\comp}{}\\" % (col_size, row_size)) @@ -471,7 +504,7 @@ def jacobian(self, transpose=False): lhs_tikz = "\n".join(tikz) - self._terms.append(lhs_tikz) + self.terms.append(lhs_tikz) return lhs_tikz def vector(self, base_color="red", highlight=None): @@ -480,12 +513,12 @@ def vector(self, base_color="red", highlight=None): tikz = [] if highlight is None: - highlight = np.ones(self._n_vars) + highlight = np.ones(self.n_vars) for i, h_light in enumerate(highlight): color = _color(base_color, h_light) - row_size = self._ij_variables[i].size + row_size = self.ij_variables[i].size tikz.append(r"\blockrow{\blockcol{") if h_light == "diag": @@ -500,7 +533,7 @@ def vector(self, base_color="red", highlight=None): vec_tikz = "\n".join(tikz) - self._terms.append(vec_tikz) + self.terms.append(vec_tikz) return vec_tikz def operator(self, opperator="="): @@ -508,7 +541,7 @@ def operator(self, opperator="="): tikz = [] - padding_size = (self._total_size - 1) / 2 + padding_size = (self.total_size - 1) / 2 tikz.append(r"\blockrow{") tikz.append(r" \blockempty{\mwid}{%s*\comp}{} \\" % (padding_size)) @@ -518,7 +551,7 @@ def operator(self, opperator="="): op_tikz = "\n".join(tikz) - self._terms.append(op_tikz) + self.terms.append(op_tikz) return op_tikz def spacer(self): @@ -526,8 +559,8 @@ def spacer(self): tikz = [] - for i in range(self._n_vars): - row_size = self._ij_variables[i].size + for i in range(self.n_vars): + row_size = self.ij_variables[i].size tikz.append(r"\blockrow{\blockcol{") tikz.append(r" \blockmat{.25*\mwid}{%s*\comp}{}{draw=white,fill=white}{}\\" % (row_size)) @@ -535,7 +568,7 @@ def spacer(self): spacer_tikz = "\n".join(tikz) - self._terms.append(spacer_tikz) + self.terms.append(spacer_tikz) return spacer_tikz def write(self, out_file=None, build=True, cleanup=True): @@ -563,7 +596,7 @@ def write(self, out_file=None, build=True, cleanup=True): tikz = [] tikz.append(r"\blockrow{") - for term in self._terms: + for term in self.terms: tikz.append(r"\blockcol{") tikz.append(term) tikz.append(r"}") diff --git a/pyxdsm/xdsm_latex_writer.py b/pyxdsm/xdsm_latex_writer.py new file mode 100644 index 0000000..0989863 --- /dev/null +++ b/pyxdsm/xdsm_latex_writer.py @@ -0,0 +1,412 @@ +import os +import re +import subprocess + +from typing import Optional, Tuple, List, Union + +import numpy as np + +from pyxdsm import __version__ as pyxdsm_version +from pyxdsm.util import chunk_label + + +# LaTeX templates +tikzpicture_template = r""" +%%% Preamble Requirements %%% +% \usepackage{{geometry}} +% \usepackage{{amsfonts}} +% \usepackage{{amsmath}} +% \usepackage{{amssymb}} +% \usepackage{{tikz}} + +% Optional packages such as sfmath set through python interface +% \usepackage{{{optional_packages}}} + +% \usetikzlibrary{{arrows,chains,positioning,scopes,shapes.geometric,shapes.misc,shadows}} + +%%% End Preamble Requirements %%% + +\input{{"{diagram_styles_path}"}} +\begin{{tikzpicture}} + +\matrix[MatrixSetup]{{ +{nodes}}}; + +% XDSM process chains +{process} + +\begin{{pgfonlayer}}{{data}} +\path +{edges} +\end{{pgfonlayer}} + +\end{{tikzpicture}} +""" + +tex_template = r""" +% XDSM diagram created with pyXDSM {version}. +\documentclass{{article}} +\usepackage{{geometry}} +\usepackage{{amsfonts}} +\usepackage{{amsmath}} +\usepackage{{amssymb}} +\usepackage{{tikz}} + +% Optional packages such as sfmath set through python interface +\usepackage{{{optional_packages}}} + +% Define the set of TikZ packages to be included in the architecture diagram document +\usetikzlibrary{{arrows,chains,positioning,scopes,shapes.geometric,shapes.misc,shadows}} + + +% Set the border around all of the architecture diagrams to be tight to the diagrams themselves +% (i.e. no longer need to tinker with page size parameters) +\usepackage[active,tightpage]{{preview}} +\PreviewEnvironment{{tikzpicture}} +\setlength{{\PreviewBorder}}{{5pt}} + +\begin{{document}} + +\input{{"{tikzpicture_path}"}} + +\end{{document}} +""" + +def _sanitize_tikz_name(name: str) -> str: + """ + Sanitize a node name to be TikZ-compatible. + + TikZ node names cannot contain certain characters like periods, spaces, + and other special characters. This function replaces them with safe alternatives. + + Parameters + ---------- + name : str + Original node name (may contain periods, spaces, etc.) + + Returns + ------- + str + Sanitized name safe for use as TikZ node identifier + """ + # Replace periods with underscores + sanitized = name.replace('.', '_') + # Replace spaces with underscores + sanitized = sanitized.replace(' ', '_') + # Replace other problematic characters with underscores + sanitized = re.sub(r'[^\w\-]', '_', sanitized) + return sanitized + + +def _parse_label(label: Union[str, List[str], Tuple[str, ...]], label_width: Optional[int] = None) -> str: + """Parse label into LaTeX format.""" + if isinstance(label, (tuple, list)): + if label_width is None: + return r"$\begin{array}{c}" + r" \\ ".join(label) + r"\end{array}$" + else: + labels = [] + for chunk in chunk_label(label, label_width): + labels.append(", ".join(chunk)) + return r"$\begin{array}{c}" + r" \\ ".join(labels) + r"\end{array}$" + else: + return r"${}$".format(label) + + +class XDSMLatexWriter: + """ + Writer class for generating LaTeX/TikZ output from XDSM diagrams. + """ + + @staticmethod + def _build_node_grid(xdsm: 'XDSM') -> str: + """Build the TikZ node grid.""" + size = len(xdsm.systems) + comps_rows = np.arange(size) + comps_cols = np.arange(size) + + if xdsm.inputs: + size += 1 + comps_rows += 1 + + if any(out.side == "left" for out in xdsm.outputs.values()): + size += 1 + comps_cols += 1 + + if any(out.side == "right" for out in xdsm.outputs.values()): + size += 1 + + row_idx_map = {} + col_idx_map = {} + + node_str = r"\node [{style}] ({node_name}) {{{node_label}}};" + grid = np.empty((size, size), dtype=object) + grid[:] = "" + + # Add diagonal systems + for i_row, j_col, comp in zip(comps_rows, comps_cols, xdsm.systems): + style = comp.style + if comp.stack: + style += ",stack" + if comp.faded: + style += ",faded" + + label = _parse_label(comp.label, comp.label_width) + sanitized_name = _sanitize_tikz_name(comp.node_name) + node = node_str.format(style=style, node_name=sanitized_name, node_label=label) + grid[i_row, j_col] = node + + row_idx_map[comp.node_name] = i_row + col_idx_map[comp.node_name] = j_col + + # Add off-diagonal connection nodes + for conn in xdsm.connections: + src_row = row_idx_map[conn.src] + target_col = col_idx_map[conn.target] + + style = conn.style + if conn.stack: + style += ",stack" + if conn.faded: + style += ",faded" + + label = _parse_label(conn.label, conn.label_width) + node_name = f"{_sanitize_tikz_name(conn.src)}-{_sanitize_tikz_name(conn.target)}" + node = node_str.format(style=style, node_name=node_name, node_label=label) + + grid[src_row, target_col] = node + + # Add left outputs + for comp_name, out in xdsm.outputs.items(): + if out.side != "left": + continue + style = out.style + if out.stack: + style += ",stack" + if out.faded: + style += ",faded" + + i_row = row_idx_map[comp_name] + label = _parse_label(out.label, out.label_width) + sanitized_name = _sanitize_tikz_name(out.node_name) + node = node_str.format(style=style, node_name=sanitized_name, node_label=label) + grid[i_row, 0] = node + + # Add right outputs + for comp_name, out in xdsm.outputs.items(): + if out.side != "right": + continue + style = out.style + if out.stack: + style += ",stack" + if out.faded: + style += ",faded" + + i_row = row_idx_map[comp_name] + label = _parse_label(out.label, out.label_width) + sanitized_name = _sanitize_tikz_name(out.node_name) + node = node_str.format(style=style, node_name=sanitized_name, node_label=label) + grid[i_row, -1] = node + + # Add inputs + for comp_name, inp in xdsm.inputs.items(): + style = inp.style + if inp.stack: + style += ",stack" + if inp.faded: + style += ",faded" + + j_col = col_idx_map[comp_name] + label = _parse_label(inp.label, inp.label_width) + sanitized_name = _sanitize_tikz_name(inp.node_name) + node = node_str.format(style=style, node_name=sanitized_name, node_label=label) + grid[0, j_col] = node + + # Convert grid to string + rows_str = "" + for i, row in enumerate(grid): + rows_str += f"%Row {i}\n" + "&\n".join(row) + r"\\" + "\n" + + return rows_str + + @staticmethod + def _build_edges(xdsm: 'XDSM') -> str: + """Build the TikZ edge definitions.""" + h_edges = [] + v_edges = [] + + edge_format = "({start}) edge [{style}] ({end})" + + for conn in xdsm.connections: + h_style = "DataLine" + v_style = "DataLine" + + if conn.src_faded or conn.faded: + h_style += ",faded" + if conn.target_faded or conn.faded: + v_style += ",faded" + + src_sanitized = _sanitize_tikz_name(conn.src) + target_sanitized = _sanitize_tikz_name(conn.target) + od_node = f"{src_sanitized}-{target_sanitized}" + h_edges.append(edge_format.format(start=src_sanitized, end=od_node, style=h_style)) + v_edges.append(edge_format.format(start=od_node, end=target_sanitized, style=v_style)) + + for comp_name, out in xdsm.outputs.items(): + if out.side != "left": + continue + style = "DataLine" + if out.faded: + style += ",faded" + comp_sanitized = _sanitize_tikz_name(comp_name) + out_sanitized = _sanitize_tikz_name(out.node_name) + h_edges.append(edge_format.format(start=comp_sanitized, end=out_sanitized, style=style)) + + for comp_name, out in xdsm.outputs.items(): + if out.side != "right": + continue + style = "DataLine" + if out.faded: + style += ",faded" + comp_sanitized = _sanitize_tikz_name(comp_name) + out_sanitized = _sanitize_tikz_name(out.node_name) + h_edges.append(edge_format.format(start=comp_sanitized, end=out_sanitized, style=style)) + + for comp_name, inp in xdsm.inputs.items(): + style = "DataLine" + if inp.faded: + style += ",faded" + comp_sanitized = _sanitize_tikz_name(comp_name) + inp_sanitized = _sanitize_tikz_name(inp.node_name) + v_edges.append(edge_format.format(start=comp_sanitized, end=inp_sanitized, style=style)) + + h_edges = sorted(h_edges, key=lambda s: "faded" in s) + v_edges = sorted(v_edges, key=lambda s: "faded" in s) + + paths_str = "% Horizontal edges\n" + "\n".join(h_edges) + "\n" + paths_str += "% Vertical edges\n" + "\n".join(v_edges) + ";" + + return paths_str + + @staticmethod + def _build_process_chain(xdsm: 'XDSM') -> str: + """Build the TikZ process chain definitions.""" + sys_names = [s.node_name for s in xdsm.systems] + output_names = ( + [inp.node_name for inp in xdsm.inputs.values()] + + [out.node_name for out in xdsm.outputs.values()] + ) + + chain_str = "" + + for proc in xdsm.processes: + chain_str += "\\begin{scope}[start chain=process]\n" + chain_str += "\\begin{pgfonlayer}{process}\n" + start_tip = False + + for i, sys in enumerate(proc.systems): + if sys not in sys_names and sys not in output_names: + raise ValueError(f'Process includes system "{sys}" but no such system exists') + + if sys in output_names and i == 0: + start_tip = True + + sys_sanitized = _sanitize_tikz_name(sys) + + if i == 0: + chain_str += f"\\chainin ({sys_sanitized});\n" + else: + if sys in output_names or (i == 1 and start_tip): + style = "ProcessTipA" if proc.arrow else "ProcessTip" + else: + style = "ProcessHVA" if proc.arrow else "ProcessHV" + + if proc.faded: + style = "Faded" + style + + chain_str += f"\\chainin ({sys_sanitized}) [join=by {style}];\n" + + chain_str += "\\end{pgfonlayer}\n" + chain_str += "\\end{scope}\n" + + return chain_str + + @staticmethod + def _compose_optional_package_list(xdsm: 'XDSM') -> str: + """Compose the optional LaTeX package list.""" + packages = xdsm.optional_packages.copy() + if xdsm.use_sfmath: + packages.append("sfmath") + return ",".join(packages) + + @staticmethod + def write(xdsm: 'XDSM', file_name: str, build: bool = True, cleanup: bool = True, + quiet: bool = False, outdir: str = ".") -> None: + """ + Write output files for the XDSM diagram. + + Parameters + ---------- + xdsm : XDSM + The XDSM diagram object to write + file_name : str + Prefix for output files + build : bool + Whether to compile the PDF + cleanup : bool + Whether to delete build files after compilation + quiet : bool + Suppress pdflatex output + outdir : str + Output directory path + """ + nodes = XDSMLatexWriter._build_node_grid(xdsm) + edges = XDSMLatexWriter._build_edges(xdsm) + process = XDSMLatexWriter._build_process_chain(xdsm) + + module_path = os.path.dirname(__file__) + diagram_styles_path = os.path.join(module_path, "diagram_styles") + diagram_styles_path = diagram_styles_path.replace("\\", "/") + + optional_packages_str = XDSMLatexWriter._compose_optional_package_list(xdsm) + + tikzpicture_str = tikzpicture_template.format( + nodes=nodes, + edges=edges, + process=process, + diagram_styles_path=diagram_styles_path, + optional_packages=optional_packages_str, + ) + + base_output_fp = os.path.join(outdir, file_name) + with open(base_output_fp + ".tikz", "w") as f: + f.write(tikzpicture_str) + + tex_str = tex_template.format( + nodes=nodes, + edges=edges, + tikzpicture_path=file_name + ".tikz", + diagram_styles_path=diagram_styles_path, + optional_packages=optional_packages_str, + version=pyxdsm_version, + ) + + with open(base_output_fp + ".tex", "w") as f: + f.write(tex_str) + + if build: + command = [ + "pdflatex", + "-halt-on-error", + "-interaction=nonstopmode", + f"-output-directory={outdir}", + ] + if quiet: + command += ["-interaction=batchmode", "-halt-on-error"] + command += [f"{file_name}.tex"] + subprocess.run(command, check=True) + + if cleanup: + for ext in ["aux", "fdb_latexmk", "fls", "log"]: + f_name = f"{base_output_fp}.{ext}" + if os.path.exists(f_name): + os.remove(f_name) From 6bdb751da18bb727cd5809e121c51508de44eb1f Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Fri, 10 Oct 2025 18:28:29 -0400 Subject: [PATCH 03/25] util.py --- pyxdsm/util.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 pyxdsm/util.py diff --git a/pyxdsm/util.py b/pyxdsm/util.py new file mode 100644 index 0000000..04369f7 --- /dev/null +++ b/pyxdsm/util.py @@ -0,0 +1,3 @@ +def chunk_label(label, n_chunks): + for i in range(0, len(label), n_chunks): + yield label[i : i + n_chunks] \ No newline at end of file From eb2a89e23acfa0704eaf4754cdf5d7917c068ce0 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Thu, 23 Oct 2025 09:38:10 -0400 Subject: [PATCH 04/25] added __main__ with exporting of json to other formats. --- pyxdsm/__main__.py | 99 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 pyxdsm/__main__.py diff --git a/pyxdsm/__main__.py b/pyxdsm/__main__.py new file mode 100644 index 0000000..c24b9bc --- /dev/null +++ b/pyxdsm/__main__.py @@ -0,0 +1,99 @@ +""" +Command-line interface for pyXDSM. + +Allows running pyXDSM from the command line: + python -m pyxdsm input.json -o output.pdf +""" +import argparse +import sys +from pathlib import Path + +from .XDSM import XDSM + + +def main(): + """Main entry point for the pyXDSM CLI.""" + parser = argparse.ArgumentParser( + description="Generate XDSM diagrams from JSON specification files", + prog="python -m pyxdsm" + ) + + parser.add_argument( + "input", + type=str, + help="Input JSON specification file" + ) + + parser.add_argument( + "-o", "--output", + type=str, + required=True, + help="Output file path (e.g., output.pdf or output.tikz)" + ) + + parser.add_argument( + "--cleanup", + action="store_true", + default=True, + help="Clean up auxiliary files after PDF build (default: True)" + ) + + parser.add_argument( + "--no-cleanup", + action="store_false", + dest="cleanup", + help="Keep auxiliary files after PDF build" + ) + + parser.add_argument( + "--quiet", + action="store_true", + default=False, + help="Suppress pdflatex output" + ) + + args = parser.parse_args() + + # Load XDSM from JSON + try: + xdsm = XDSM.from_json(args.input) + except FileNotFoundError: + print(f"Error: Input file '{args.input}' not found", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error loading JSON: {e}", file=sys.stderr) + sys.exit(1) + + # Parse output path + output_path = Path(args.output) + outdir = str(output_path.parent) if output_path.parent != Path(".") else "." + file_name = output_path.stem + extension = output_path.suffix.lower() + + # Determine build flag based on output extension + if extension == ".pdf": + build = True + elif extension == ".tikz": + build = False + else: + print(f"Warning: Unknown extension '{extension}'. Defaulting to PDF build.", + file=sys.stderr) + build = True + + # Build the diagram + try: + xdsm.write( + file_name=file_name, + build=build, + cleanup=args.cleanup, + quiet=args.quiet, + outdir=outdir + ) + print(f"Successfully generated {args.output}") + except Exception as e: + print(f"Error generating output: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() From 41b2b7a311eae04706d2a9a806588081c65065ec Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Thu, 23 Oct 2025 09:54:40 -0400 Subject: [PATCH 05/25] removed notional html output --- pyxdsm/XDSM.py | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index 2e93f81..8a2d760 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -406,25 +406,6 @@ def to_latex(self, file_name: str, build: bool = True, cleanup: bool = True, """ XDSMLatexWriter.write(self, file_name, build, cleanup, quiet, outdir) - def write_html(self, file_name: str, title: str = "XDSM Diagram", - show_browser: bool = False) -> None: - """ - Export XDSM diagram to HTML with TikZ rendered in browser using TikZJax. - This produces output identical to the LaTeX/PDF version but viewable in a browser. - - Parameters - ---------- - file_name : str - Output HTML file name (with or without .html extension) - title : str - Title for the diagram - show_browser : bool - Whether to open the HTML file in browser after creation - """ - from pyxdsm.xdsm_tikzjax_writer import XDSMTikZJaxWriter - XDSMTikZJaxWriter.write(self, file_name, title, show_browser) - - def write_sys_specs(self, folder_name: str) -> None: """ Write I/O spec JSON files for systems. @@ -525,8 +506,7 @@ def from_json(cls, filename: str) -> 'XDSM': # Write LaTeX files xdsm.write('example_xdsm', build=True) - xdsm.write_html('example_xdsm') - + # Load from JSON xdsm_loaded = XDSM.from_json('xdsm_spec.json') print("Successfully loaded XDSM from JSON") From f8021daf10f8eb65cb5163a272f4966b4e32cd4a Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Thu, 23 Oct 2025 10:18:42 -0400 Subject: [PATCH 06/25] removed deprecated numpy distutils. --- tests/test_xdsm.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/test_xdsm.py b/tests/test_xdsm.py index 9a2def3..d2abe22 100644 --- a/tests/test_xdsm.py +++ b/tests/test_xdsm.py @@ -4,7 +4,7 @@ import tempfile import subprocess from pyxdsm.XDSM import XDSM, OPT, FUNC, SOLVER, LEFT, RIGHT -from numpy.distutils.exec_command import find_executable + basedir = os.path.dirname(os.path.abspath(__file__)) @@ -44,7 +44,7 @@ def test_examples(self): self.assertTrue(os.path.isfile(f + ".tikz")) self.assertTrue(os.path.isfile(f + ".tex")) # look for the pdflatex executable - pdflatex = find_executable("pdflatex") is not None + pdflatex = shutil.which("pdflatex") is not None # if no pdflatex, then do not assert that the pdf was compiled self.assertTrue(not pdflatex or os.path.isfile(f + ".pdf")) subprocess.run(["python", "mat_eqn.py"], check=True) @@ -53,16 +53,20 @@ def test_examples(self): os.chdir(self.tempdir) def test_connect(self): + from pydantic import ValidationError + x = XDSM(use_sfmath=False) x.add_system("D1", FUNC, "D_1", label_width=2) x.add_system("D2", FUNC, "D_2", stack=False) - try: + # Pydantic raises ValidationError when validation fails + with self.assertRaises(ValidationError) as context: x.connect("D1", "D2", r"\mathcal{R}(y_1)", "foobar") - except ValueError as err: - self.assertEquals(str(err), "label_width argument must be an integer") - else: - self.fail("Expected ValueError") + + # Check that the error is about label_width + error_msg = str(context.exception) + self.assertIn("label_width", error_msg) + self.assertIn("Input should be a valid integer", error_msg) def test_options(self): filename = "xdsm_test_options" From 5b1f95015678d80e6f0d674883b9f1bc285bd7d4 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Thu, 23 Oct 2025 16:07:04 -0400 Subject: [PATCH 07/25] Docs and pyproject.toml --- .gitignore | 1 + doc/API.rst | 55 ++++++++++- doc/conf.py | 29 +++++- doc/examples.rst | 5 + examples/mdf.json | 238 ++++++++++++++++++++++++++++++++++++++++++++++ examples/mdf.py | 2 + pyproject.toml | 91 ++++++++++++++++++ pyxdsm/XDSM.py | 20 ++-- setup.py | 40 -------- 9 files changed, 427 insertions(+), 54 deletions(-) create mode 100644 examples/mdf.json create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/.gitignore b/.gitignore index 7857b93..36ffcdb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ examples/* !examples/*.py +!examples/*.json *.so *.o diff --git a/doc/API.rst b/doc/API.rst index 561a013..7201faa 100644 --- a/doc/API.rst +++ b/doc/API.rst @@ -1,8 +1,59 @@ .. _pyXDSM_API: pyXDSM API +========== + +XDSM Class ---------- -.. currentmodule:: pyxdsm.XDSM -.. autoclass:: pyxdsm.XDSM.XDSM +The main XDSM class for creating Extended Design Structure Matrix diagrams. + +.. autopydantic_model:: pyxdsm.XDSM.XDSM + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + :exclude-members: model_config, model_fields, model_computed_fields + +Pydantic Models +--------------- + +SystemNode +^^^^^^^^^^ + +.. autopydantic_model:: pyxdsm.XDSM.SystemNode + :members: + :undoc-members: + :show-inheritance: + +ConnectionEdge +^^^^^^^^^^^^^^ + +.. autopydantic_model:: pyxdsm.XDSM.ConnectionEdge + :members: + :undoc-members: + :show-inheritance: + +OutputNode +^^^^^^^^^^ + +.. autopydantic_model:: pyxdsm.XDSM.OutputNode + :members: + :undoc-members: + :show-inheritance: + +ProcessChain +^^^^^^^^^^^^ + +.. autopydantic_model:: pyxdsm.XDSM.ProcessChain + :members: + :undoc-members: + :show-inheritance: + +AutoFade +^^^^^^^^ + +.. autopydantic_model:: pyxdsm.XDSM.AutoFade :members: + :undoc-members: + :show-inheritance: diff --git a/doc/conf.py b/doc/conf.py index 674e5ae..b390c5b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -20,8 +20,33 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions.extend(["numpydoc"]) +extensions.extend([ + "numpydoc", + "sphinxcontrib.autodoc_pydantic", +]) numpydoc_show_class_members = False +# -- autodoc_pydantic configuration ------------------------------------------- + +# Show all configuration options for Pydantic models +autodoc_pydantic_model_show_json = True +autodoc_pydantic_model_show_config_summary = True +autodoc_pydantic_model_show_config_member = True +autodoc_pydantic_model_show_validator_members = True +autodoc_pydantic_model_show_field_summary = True +autodoc_pydantic_model_members = True +autodoc_pydantic_model_undoc_members = True + +# Settings for fields +autodoc_pydantic_field_list_validators = True +autodoc_pydantic_field_doc_policy = "both" # Show both docstring and description +autodoc_pydantic_field_show_constraints = True +autodoc_pydantic_field_show_alias = True +autodoc_pydantic_field_show_default = True + +# Validator settings +autodoc_pydantic_validator_replace_signature = True +autodoc_pydantic_validator_list_fields = True + # mock import for autodoc -autodoc_mock_imports = ["numpy"] +autodoc_mock_imports = ["numpy", "pydantic"] diff --git a/doc/examples.rst b/doc/examples.rst index a037520..5565e86 100644 --- a/doc/examples.rst +++ b/doc/examples.rst @@ -15,6 +15,11 @@ This will output ``mdf.tex``, a standalone tex document that (by default) is als :scale: 30 +This example uses the `.to_json` method to serialize the XDSM to a JSON file: + +.. literalinclude:: ../examples/mdf.json + + More complicated example ------------------------ diff --git a/examples/mdf.json b/examples/mdf.json new file mode 100644 index 0000000..52c8256 --- /dev/null +++ b/examples/mdf.json @@ -0,0 +1,238 @@ +{ + "systems": [ + { + "node_name": "opt", + "style": "Optimization", + "label": "\\text{Optimizer}", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "opt" + }, + { + "node_name": "solver", + "style": "MDA", + "label": "\\text{Newton}", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "solver" + }, + { + "node_name": "D1", + "style": "Function", + "label": "D_1", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "D1" + }, + { + "node_name": "D2", + "style": "Function", + "label": "D_2", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "D2" + }, + { + "node_name": "F", + "style": "Function", + "label": "F", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "F" + }, + { + "node_name": "G", + "style": "Function", + "label": "G", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "G" + } + ], + "connections": [ + { + "src": "opt", + "target": "D1", + "label": "x, z", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "opt", + "target": "D2", + "label": "z", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "opt", + "target": "F", + "label": "x, z", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "solver", + "target": "D1", + "label": "y_2", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "solver", + "target": "D2", + "label": "y_1", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "D1", + "target": "solver", + "label": "\\mathcal{R}(y_1)", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "solver", + "target": "F", + "label": "y_1, y_2", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "D2", + "target": "solver", + "label": "\\mathcal{R}(y_2)", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "solver", + "target": "G", + "label": "y_1, y_2", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "F", + "target": "opt", + "label": "f", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "G", + "target": "opt", + "label": "g", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + } + ], + "inputs": {}, + "outputs": { + "opt": { + "node_name": "left_output_opt", + "label": "x^*, z^*", + "label_width": null, + "style": "DataIO", + "stack": false, + "faded": false, + "side": "left" + }, + "D1": { + "node_name": "left_output_D1", + "label": "y_1^*", + "label_width": null, + "style": "DataIO", + "stack": false, + "faded": false, + "side": "left" + }, + "D2": { + "node_name": "left_output_D2", + "label": "y_2^*", + "label_width": null, + "style": "DataIO", + "stack": false, + "faded": false, + "side": "left" + }, + "F": { + "node_name": "left_output_F", + "label": "f^*", + "label_width": null, + "style": "DataIO", + "stack": false, + "faded": false, + "side": "left" + }, + "G": { + "node_name": "left_output_G", + "label": "g^*", + "label_width": null, + "style": "DataIO", + "stack": false, + "faded": false, + "side": "left" + } + }, + "processes": [], + "use_sfmath": true, + "optional_packages": [], + "auto_fade": { + "inputs": "none", + "outputs": "none", + "connections": "none", + "processes": "none" + } +} \ No newline at end of file diff --git a/examples/mdf.py b/examples/mdf.py index 65c89f1..6983f03 100644 --- a/examples/mdf.py +++ b/examples/mdf.py @@ -29,3 +29,5 @@ x.add_output("F", "f^*", side=LEFT) x.add_output("G", "g^*", side=LEFT) x.write("mdf") + +x.to_json("mdf.json") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a59915e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,91 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyXDSM" +dynamic = ["version"] +description = "Python script to generate PDF XDSM diagrams using TikZ and LaTeX" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "Apache License Version 2.0"} +keywords = ["optimization", "multidisciplinary", "multi-disciplinary", "analysis", "n2", "xdsm"] +authors = [ + {name = "MDO Lab", email = ""}, +] +maintainers = [ + {name = "MDO Lab", email = ""}, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering", +] + +dependencies = [ + "numpy>=1.21", + "pydantic>=2.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=6.0", + "pytest-cov", + "ruff", +] +docs = [ + "sphinx", + "sphinx-mdolab-theme", + "numpydoc", + "sphinxcontrib-autodoc-pydantic>=2.0", +] +all = ["pyXDSM[dev,docs]"] + +[project.urls] +Homepage = "https://github.com/mdolab/pyXDSM" +Documentation = "https://mdolab-pyxdsm.readthedocs-hosted.com" +Repository = "https://github.com/mdolab/pyXDSM" +Issues = "https://github.com/mdolab/pyXDSM/issues" + +[tool.setuptools] +packages = ["pyxdsm"] + +[tool.setuptools.dynamic] +version = {attr = "pyxdsm.__version__"} + +[tool.setuptools.package-data] +pyxdsm = ["*.tex"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +addopts = "-v --tb=short" + +[tool.ruff] +line-length = 120 +indent-width = 4 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index 8a2d760..5bcafca 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -54,14 +54,14 @@ class SystemNode(BaseModel): @field_validator('node_name') @classmethod - def validate_node_name(cls, v: str) -> str: + def _validate_node_name(cls, v: str) -> str: if not v or not v.strip(): raise ValueError("Node name cannot be empty") return v.strip() @field_validator('style') @classmethod - def validate_style(cls, v: str) -> str: + def _validate_style(cls, v: str) -> str: """Validate that style is a known TikZ style.""" if v not in VALID_NODE_STYLES: raise ValueError( @@ -104,7 +104,7 @@ class OutputNode(BaseModel): @field_validator('side') @classmethod - def validate_side(cls, v: str) -> str: + def _validate_side(cls, v: str) -> str: if v not in ['left', 'right']: raise ValueError("Side must be 'left' or 'right'") return v @@ -127,13 +127,13 @@ class ConnectionEdge(BaseModel): @field_validator('label_width') @classmethod - def validate_label_width(cls, v: Optional[int]) -> Optional[int]: + def _validate_label_width(cls, v: Optional[int]) -> Optional[int]: if v is not None and not isinstance(v, int): raise ValueError("label_width must be an integer") return v @model_validator(mode='after') - def validate_no_self_connection(self): + def _validate_no_self_connection(self): if self.src == self.target: raise ValueError("Cannot connect component to itself") return self @@ -148,7 +148,7 @@ class ProcessChain(BaseModel): @field_validator('systems') @classmethod - def validate_systems(cls, v: List[str]) -> List[str]: + def _validate_systems(cls, v: List[str]) -> List[str]: if len(v) < 2: raise ValueError("Process chain must contain at least 2 systems") return v @@ -164,7 +164,7 @@ class AutoFadeConfig(BaseModel): @field_validator('inputs', 'outputs', 'processes') @classmethod - def validate_basic_options(cls, v: str) -> str: + def _validate_basic_options(cls, v: str) -> str: valid = ['all', 'connected', 'none'] if v not in valid: raise ValueError(f"Must be one of {valid}") @@ -172,7 +172,7 @@ def validate_basic_options(cls, v: str) -> str: @field_validator('connections') @classmethod - def validate_connection_options(cls, v: str) -> str: + def _validate_connection_options(cls, v: str) -> str: valid = ['all', 'connected', 'none', 'incoming', 'outgoing'] if v not in valid: raise ValueError(f"Must be one of {valid}") @@ -239,7 +239,7 @@ def __init__(self, use_sfmath: bool = True, @model_validator(mode='before') @classmethod - def set_defaults_for_missing_fields(cls, data): + def _set_defaults_for_missing_fields(cls, data): """Ensure missing or null collection fields get empty defaults.""" if not isinstance(data, dict): return data @@ -259,7 +259,7 @@ def set_defaults_for_missing_fields(cls, data): return data @model_validator(mode='after') - def validate_unique_system_names(self): + def _validate_unique_system_names(self): """Ensure all system names are unique.""" names = [sys.node_name for sys in self.systems] duplicates = [n for n in names if names.count(n) > 1] diff --git a/setup.py b/setup.py deleted file mode 100644 index ea8d36a..0000000 --- a/setup.py +++ /dev/null @@ -1,40 +0,0 @@ -from setuptools import setup -import re -from os import path - -__version__ = re.findall( - r"""__version__ = ["']+([0-9\.]*)["']+""", - open("pyxdsm/__init__.py").read(), -)[0] - -this_directory = path.abspath(path.dirname(__file__)) -with open(path.join(this_directory, "README.md"), encoding="utf-8") as f: - long_description = f.read() - -setup( - name="pyXDSM", - version=__version__, - description="Python script to generate PDF XDSM diagrams using TikZ and LaTeX", - long_description=long_description, - long_description_content_type="text/markdown", - keywords="optimization multidisciplinary multi-disciplinary analysis n2 xdsm", - author="", - author_email="", - url="https://github.com/mdolab/pyXDSM", - license="Apache License Version 2.0", - packages=[ - "pyxdsm", - ], - package_data={"pyxdsm": ["*.tex"]}, - install_requires=["numpy>=1.21"], - python_requires=">=3", - classifiers=[ - "Operating System :: OS Independent", - "Programming Language :: Python", - "Topic :: Scientific/Engineering", - "Programming Language :: Python", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - ], -) From 1208d39a11928991bd834450bd96b3290c71977d Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Thu, 23 Oct 2025 16:31:10 -0400 Subject: [PATCH 08/25] More documentation, tests, and some cleanup. --- doc/examples.rst | 31 +++++++++++++++++++++++++++++ pyxdsm/XDSM.py | 13 ++---------- tests/test_xdsm.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/doc/examples.rst b/doc/examples.rst index 5565e86..e3b0d3c 100644 --- a/doc/examples.rst +++ b/doc/examples.rst @@ -19,6 +19,37 @@ This example uses the `.to_json` method to serialize the XDSM to a JSON file: .. literalinclude:: ../examples/mdf.json +This can be loaded programmatically using the static :meth:`~pyxdsm.XDSM.XDSM.from_json` method. +Alternatively a command-line tool can be used to write the JSON to a PDF, a tikz file, or another JSON file. + +Command-line Usage +------------------ + +The JSON file can be used directly from the command line: + +.. code-block:: bash + + python -m pyxdsm mdf.json -o mdf.pdf + +This generates a PDF from the JSON specification. Other output formats are also supported: + +.. code-block:: bash + + # Generate only TikZ (no PDF compilation) + python -m pyxdsm mdf.json -o mdf.tikz + + # Export to a different JSON file + python -m pyxdsm mdf.json -o output.json + + # Generate PDF with default name (mdf.pdf) + python -m pyxdsm mdf.json + +For more options, use the ``--help`` flag: + +.. code-block:: bash + + python -m pyxdsm --help + More complicated example ------------------------ diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index 5bcafca..04eebcd 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -452,11 +452,7 @@ def _label_to_spec(label: Union[str, List[str], Tuple[str, ...]], spec: Set[str] spec["outputs"] = list(spec["outputs"]) json_str = json.dumps(spec, indent=2) f.write(json_str) - - def to_dict(self) -> dict: - """Export XDSM specification to dictionary.""" - return self.model_dump() - + def to_json(self, filename: Optional[str] = None) -> str: """Export XDSM specification to JSON.""" json_str = self.model_dump_json(indent=2) @@ -464,12 +460,7 @@ def to_json(self, filename: Optional[str] = None) -> str: with open(filename, 'w') as f: f.write(json_str) return json_str - - @classmethod - def from_dict(cls, data: dict) -> 'XDSM': - """Load XDSM from dictionary.""" - return cls.model_validate(data) - + @classmethod def from_json(cls, filename: str) -> 'XDSM': """Load XDSM from JSON file.""" diff --git a/tests/test_xdsm.py b/tests/test_xdsm.py index d2abe22..4c9d607 100644 --- a/tests/test_xdsm.py +++ b/tests/test_xdsm.py @@ -325,6 +325,55 @@ def test_write_outdir(self): # no files outside the subdirs self.assertFalse(any(os.path.isfile(fp) for fp in os.listdir(self.tempdir))) + def test_serialize_deserialize(self): + filename = "xdsm_test_ser_deser" + + # Change `use_sfmath` to False to use computer modern + x = XDSM(use_sfmath=False) + + x.add_system("opt", OPT, r"\text{Optimizer}") + x.add_system("solver", SOLVER, r"\text{Newton}") + x.add_system("D1", FUNC, "D_1", label_width=2) + x.add_system("D2", FUNC, "D_2", stack=False) + x.add_system("F", FUNC, "F", faded=True) + x.add_system("G", FUNC, "G", spec_name="G_spec") + + x.connect("opt", "D1", "x, z") + x.connect("opt", "D2", "z") + x.connect("opt", "F", "x, z") + x.connect("solver", "D1", "y_2") + x.connect("solver", "D2", "y_1") + x.connect("D1", "solver", r"\mathcal{R}(y_1)") + x.connect("solver", "F", "y_1, y_2") + x.connect("D2", "solver", r"\mathcal{R}(y_2)") + x.connect("solver", "G", "y_1, y_2") + + x.connect("F", "opt", "f") + x.connect("G", "opt", "g") + + x.add_output("opt", "x^*, z^*", side=RIGHT) + x.add_output("D1", "y_1^*", side=LEFT, stack=True) + x.add_output("D2", "y_2^*", side=LEFT) + x.add_output("F", "f^*", side=LEFT) + x.add_output("G", "g^*") + + # Save to JSON file + json_file = filename + ".json" + x.to_json(json_file) + + # Verify JSON file was created + self.assertTrue(os.path.isfile(json_file)) + + # Load from JSON file + x_loaded = XDSM.from_json(json_file) + + # Verify the loaded XDSM is equivalent to the original + # Compare the model_dump() output (which gives us the full state) + original_dict = x.model_dump() + loaded_dict = x_loaded.model_dump() + + self.assertEqual(original_dict, loaded_dict) + if __name__ == "__main__": unittest.main() From 0db435a8627b3e6b67d9885b0589c37e7c0ba383 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Thu, 23 Oct 2025 17:02:00 -0400 Subject: [PATCH 09/25] ruff passes with a few ignores for string formatting. --- pyproject.toml | 8 ++- pyxdsm/XDSM.py | 116 ++++++++++++++++++------------------ pyxdsm/matrix_eqn.py | 6 +- pyxdsm/util.py | 3 - pyxdsm/xdsm_latex_writer.py | 68 +++++++++++---------- 5 files changed, 102 insertions(+), 99 deletions(-) delete mode 100644 pyxdsm/util.py diff --git a/pyproject.toml b/pyproject.toml index a59915e..b33a63c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,10 +11,10 @@ requires-python = ">=3.8" license = {text = "Apache License Version 2.0"} keywords = ["optimization", "multidisciplinary", "multi-disciplinary", "analysis", "n2", "xdsm"] authors = [ - {name = "MDO Lab", email = ""}, + {name = "MDO Lab"}, ] maintainers = [ - {name = "MDO Lab", email = ""}, + {name = "MDO Lab"}, ] classifiers = [ "Development Status :: 5 - Production/Stable", @@ -83,7 +83,9 @@ select = [ "UP", # pyupgrade ] ignore = [ - "E501", # line too long (handled by formatter) + "E501", # line too long (handled by formatter) + "UP031", # use format specifiers instead of percent format + "UP032", # use f-string instead of format call ] [tool.ruff.format] diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index 04eebcd..b1cedb2 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -2,13 +2,11 @@ pyXDSM with Pydantic models for validation and serialization """ -from __future__ import print_function -import os -import numpy as np import json -from typing import Literal, Optional, Tuple, List, Dict, Set, Union -from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict -import plotly.graph_objects as go +import os +from typing import Dict, List, Literal, Optional, Set, Tuple, Union + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pyxdsm.xdsm_latex_writer import XDSMLatexWriter @@ -26,7 +24,7 @@ RIGHT = "right" # Type definitions - these match the TikZ styles in diagram_styles -NodeType = Literal['Optimization', 'SubOptimization', 'MDA', 'DOE', 'ImplicitFunction', +NodeType = Literal['Optimization', 'SubOptimization', 'MDA', 'DOE', 'ImplicitFunction', 'Function', 'Group', 'ImplicitGroup', 'Metamodel'] ConnectionStyle = Literal['DataInter', 'DataIO'] Side = Literal['left', 'right'] @@ -41,7 +39,7 @@ class SystemNode(BaseModel): """System node on the diagonal of XDSM diagram.""" - + node_name: str = Field(..., description="Unique name for the system") style: str = Field(..., description="Type/style of the system") label: Union[str, List[str], Tuple[str, ...]] = Field(..., description="Display label") @@ -49,16 +47,16 @@ class SystemNode(BaseModel): faded: bool = Field(default=False, description="Fade the component") label_width: Optional[int] = Field(default=None, description="Number of items per line") spec_name: Optional[str] = Field(default=None, description="Name for spec file") - + model_config = ConfigDict(arbitrary_types_allowed=True) - + @field_validator('node_name') @classmethod def _validate_node_name(cls, v: str) -> str: if not v or not v.strip(): raise ValueError("Node name cannot be empty") return v.strip() - + @field_validator('style') @classmethod def _validate_style(cls, v: str) -> str: @@ -69,7 +67,7 @@ def _validate_style(cls, v: str) -> str: f"Valid styles are: {', '.join(sorted(VALID_NODE_STYLES))}" ) return v - + def __init__(self, **data): super().__init__(**data) if self.spec_name is None: @@ -78,20 +76,20 @@ def __init__(self, **data): class InputNode(BaseModel): """Input node at top of XDSM diagram.""" - + node_name: str = Field(..., description="Internal node name") label: Union[str, List[str], Tuple[str, ...]] = Field(..., description="Display label") label_width: Optional[int] = Field(default=None, description="Number of items per line") style: str = Field(default="DataIO", description="Node style") stack: bool = Field(default=False, description="Display as stacked rectangles") faded: bool = Field(default=False, description="Fade the component") - + model_config = ConfigDict(arbitrary_types_allowed=True) class OutputNode(BaseModel): """Output node on left or right side of XDSM diagram.""" - + node_name: str = Field(..., description="Internal node name") label: Union[str, List[str], Tuple[str, ...]] = Field(..., description="Display label") label_width: Optional[int] = Field(default=None, description="Number of items per line") @@ -99,9 +97,9 @@ class OutputNode(BaseModel): stack: bool = Field(default=False, description="Display as stacked rectangles") faded: bool = Field(default=False, description="Fade the component") side: Side = Field(..., description="Which side (left or right)") - + model_config = ConfigDict(arbitrary_types_allowed=True) - + @field_validator('side') @classmethod def _validate_side(cls, v: str) -> str: @@ -112,7 +110,7 @@ def _validate_side(cls, v: str) -> str: class ConnectionEdge(BaseModel): """Connection between two nodes.""" - + src: str = Field(..., description="Source node name") target: str = Field(..., description="Target node name") label: Union[str, List[str], Tuple[str, ...]] = Field(..., description="Connection label") @@ -122,16 +120,16 @@ class ConnectionEdge(BaseModel): faded: bool = Field(default=False, description="Fade the connection") src_faded: bool = Field(default=False, description="Source node is faded") target_faded: bool = Field(default=False, description="Target node is faded") - + model_config = ConfigDict(arbitrary_types_allowed=True) - + @field_validator('label_width') @classmethod def _validate_label_width(cls, v: Optional[int]) -> Optional[int]: if v is not None and not isinstance(v, int): raise ValueError("label_width must be an integer") return v - + @model_validator(mode='after') def _validate_no_self_connection(self): if self.src == self.target: @@ -156,12 +154,12 @@ def _validate_systems(cls, v: List[str]) -> List[str]: class AutoFadeConfig(BaseModel): """Configuration for automatic fading of components.""" - + inputs: AutoFadeOption = Field(default='none', description="Auto-fade inputs") outputs: AutoFadeOption = Field(default='none', description="Auto-fade outputs") connections: AutoFadeOption = Field(default='none', description="Auto-fade connections") processes: AutoFadeOption = Field(default='none', description="Auto-fade processes") - + @field_validator('inputs', 'outputs', 'processes') @classmethod def _validate_basic_options(cls, v: str) -> str: @@ -169,7 +167,7 @@ def _validate_basic_options(cls, v: str) -> str: if v not in valid: raise ValueError(f"Must be one of {valid}") return v - + @field_validator('connections') @classmethod def _validate_connection_options(cls, v: str) -> str: @@ -183,7 +181,7 @@ class XDSM(BaseModel): """ XDSM diagram specification and renderer using Pydantic validation. """ - + systems: List[SystemNode] = Field(default_factory=list, description="System nodes") connections: List[ConnectionEdge] = Field(default_factory=list, description="Connections") inputs: Dict[str, InputNode] = Field(default_factory=dict, description="Input nodes") @@ -193,10 +191,10 @@ class XDSM(BaseModel): use_sfmath: bool = Field(default=True, description="Use sfmath LaTeX package") optional_packages: List[str] = Field(default_factory=list, description="Additional LaTeX packages") auto_fade: AutoFadeConfig = Field(default_factory=AutoFadeConfig, description="Auto-fade configuration") - + model_config = ConfigDict(arbitrary_types_allowed=True) - - def __init__(self, use_sfmath: bool = True, + + def __init__(self, use_sfmath: bool = True, optional_latex_packages: Optional[Union[str, List[str]]] = None, auto_fade: Optional[Dict[str, str]] = None, **data): @@ -224,17 +222,17 @@ def __init__(self, use_sfmath: bool = True, else: raise ValueError("optional_latex_packages must be a string or list of strings") data['optional_packages'] = packages - + if 'auto_fade' not in data: # Process auto_fade fade_config = AutoFadeConfig() if auto_fade is not None: fade_config = AutoFadeConfig(**auto_fade) data['auto_fade'] = fade_config - + if 'use_sfmath' not in data: data['use_sfmath'] = use_sfmath - + super().__init__(**data) @model_validator(mode='before') @@ -266,7 +264,7 @@ def _validate_unique_system_names(self): if duplicates: raise ValueError(f"Duplicate system names: {set(duplicates)}") return self - + def add_system(self, node_name: str, style: str, label: Union[str, List[str], Tuple[str, ...]], stack: bool = False, faded: bool = False, label_width: Optional[int] = None, spec_name: Optional[str] = None) -> None: @@ -281,17 +279,17 @@ def add_system(self, node_name: str, style: str, label: Union[str, List[str], Tu spec_name=spec_name ) self.systems.append(system) - + def add_input(self, name: str, label: Union[str, List[str], Tuple[str, ...]], label_width: Optional[int] = None, style: str = "DataIO", stack: bool = False, faded: bool = False) -> None: """Add an input node at the top.""" sys_faded = {s.node_name: s.faded for s in self.systems} - + if (self.auto_fade.inputs == "all") or \ (self.auto_fade.inputs == "connected" and name in sys_faded and sys_faded[name]): faded = True - + self.inputs[name] = InputNode( node_name="output_" + name, label=label, @@ -300,17 +298,17 @@ def add_input(self, name: str, label: Union[str, List[str], Tuple[str, ...]], stack=stack, faded=faded ) - + def add_output(self, name: str, label: Union[str, List[str], Tuple[str, ...]], label_width: Optional[int] = None, style: str = "DataIO", stack: bool = False, faded: bool = False, side: str = "left") -> None: """Add an output node on the left or right side.""" sys_faded = {s.node_name: s.faded for s in self.systems} - + if (self.auto_fade.outputs == "all") or \ (self.auto_fade.outputs == "connected" and name in sys_faded and sys_faded[name]): faded = True - + output = OutputNode( node_name=f"{side}_output_{name}", label=label, @@ -320,25 +318,25 @@ def add_output(self, name: str, label: Union[str, List[str], Tuple[str, ...]], faded=faded, side=side ) - + self.outputs[name] = output - + def connect(self, src: str, target: str, label: Union[str, List[str], Tuple[str, ...]], label_width: Optional[int] = None, style: str = "DataInter", stack: bool = False, faded: bool = False) -> None: """Connect two components with a data line.""" sys_faded = {s.node_name: s.faded for s in self.systems} - + src_faded = src in sys_faded and sys_faded[src] target_faded = target in sys_faded and sys_faded[target] - + all_faded = self.auto_fade.connections == "all" if (all_faded or (self.auto_fade.connections == "connected" and src_faded and target_faded) or (self.auto_fade.connections == "incoming" and target_faded) or (self.auto_fade.connections == "outgoing" and src_faded)): faded = True - + connection = ConnectionEdge( src=src, target=target, @@ -351,7 +349,7 @@ def connect(self, src: str, target: str, label: Union[str, List[str], Tuple[str, target_faded=target_faded ) self.connections.append(connection) - + def add_process(self, systems: List[str], arrow: bool = True, faded: bool = False) -> None: """Add a process line between systems.""" sys_faded = {s.node_name: s.faded for s in self.systems} @@ -383,12 +381,12 @@ def write(self, file_name: str, build: bool = True, cleanup: bool = True, Output directory path """ XDSMLatexWriter.write(self, file_name, build, cleanup, quiet, outdir) - + def to_latex(self, file_name: str, build: bool = True, cleanup: bool = True, quiet: bool = False, outdir: str = ".") -> None: """ Export XDSM diagram to LaTeX/TikZ format. - + Alias for write() method for clarity when exporting to LaTeX. Parameters @@ -426,23 +424,23 @@ def _label_to_spec(label: Union[str, List[str], Tuple[str, ...]], spec: Set[str] specs = {} for sys in self.systems: specs[sys.node_name] = {"inputs": set(), "outputs": set()} - + # Add inputs from Input nodes for sys_name, inp in self.inputs.items(): _label_to_spec(inp.label, specs[sys_name]["inputs"]) - + # Add inputs/outputs from Connections for conn in self.connections: _label_to_spec(conn.label, specs[conn.target]["inputs"]) _label_to_spec(conn.label, specs[conn.src]["outputs"]) - + # Add outputs from Output nodes for sys_name, out in self.outputs.items(): _label_to_spec(out.label, specs[sys_name]["outputs"]) - + if not os.path.isdir(folder_name): os.mkdir(folder_name) - + for sys in self.systems: if sys.spec_name is not False and sys.spec_name is not None: path = os.path.join(folder_name, sys.spec_name + ".json") @@ -452,7 +450,7 @@ def _label_to_spec(label: Union[str, List[str], Tuple[str, ...]], spec: Set[str] spec["outputs"] = list(spec["outputs"]) json_str = json.dumps(spec, indent=2) f.write(json_str) - + def to_json(self, filename: Optional[str] = None) -> str: """Export XDSM specification to JSON.""" json_str = self.model_dump_json(indent=2) @@ -464,7 +462,7 @@ def to_json(self, filename: Optional[str] = None) -> str: @classmethod def from_json(cls, filename: str) -> 'XDSM': """Load XDSM from JSON file.""" - with open(filename, 'r') as f: + with open(filename) as f: data = json.load(f) return cls.model_validate(data) @@ -473,13 +471,13 @@ def from_json(cls, filename: str) -> 'XDSM': if __name__ == "__main__": # Create XDSM with validation xdsm = XDSM(use_sfmath=True, auto_fade={'connections': 'connected'}) - + # Add systems - note: use the proper style constants xdsm.add_system('opt', OPT, r'\text{Optimizer}') xdsm.add_system('d1', FUNC, r'\text{Discipline 1}') # Changed to FUNC which is valid xdsm.add_system('d2', FUNC, r'\text{Discipline 2}') xdsm.add_system('func', FUNC, r'\text{Objective}') - + # Add connections xdsm.connect('opt', 'd1', r'x_1') xdsm.connect('opt', 'd2', r'x_2') @@ -488,20 +486,20 @@ def from_json(cls, filename: str) -> 'XDSM': xdsm.connect('d1', 'func', r'f_1') xdsm.connect('d2', 'func', r'f_2') xdsm.connect('func', 'opt', r'F') - + # Add process xdsm.add_process(['opt', 'd1', 'd2', 'func', 'opt']) - + # Export to JSON xdsm.to_json('xdsm_spec.json') - + # Write LaTeX files xdsm.write('example_xdsm', build=True) # Load from JSON xdsm_loaded = XDSM.from_json('xdsm_spec.json') print("Successfully loaded XDSM from JSON") - + # Validate example - this will raise an error try: bad_xdsm = XDSM() diff --git a/pyxdsm/matrix_eqn.py b/pyxdsm/matrix_eqn.py index 2905305..80e4746 100644 --- a/pyxdsm/matrix_eqn.py +++ b/pyxdsm/matrix_eqn.py @@ -1,9 +1,9 @@ import os import subprocess -from typing import Optional, Dict, List, Union, Tuple -import numpy as np -from pydantic import BaseModel, Field, field_validator, ConfigDict +from typing import Dict, List, Optional, Tuple, Union +import numpy as np +from pydantic import BaseModel, ConfigDict, Field, field_validator # color pallette link: http://paletton.com/#uid=72Q1j0kllllkS5tKC9H96KClOKC diff --git a/pyxdsm/util.py b/pyxdsm/util.py deleted file mode 100644 index 04369f7..0000000 --- a/pyxdsm/util.py +++ /dev/null @@ -1,3 +0,0 @@ -def chunk_label(label, n_chunks): - for i in range(0, len(label), n_chunks): - yield label[i : i + n_chunks] \ No newline at end of file diff --git a/pyxdsm/xdsm_latex_writer.py b/pyxdsm/xdsm_latex_writer.py index 0989863..b56a3dc 100644 --- a/pyxdsm/xdsm_latex_writer.py +++ b/pyxdsm/xdsm_latex_writer.py @@ -1,14 +1,14 @@ import os import re import subprocess - -from typing import Optional, Tuple, List, Union +from typing import TYPE_CHECKING, List, Optional, Tuple, Union import numpy as np from pyxdsm import __version__ as pyxdsm_version -from pyxdsm.util import chunk_label +if TYPE_CHECKING: + from pyxdsm.XDSM import XDSM # LaTeX templates tikzpicture_template = r""" @@ -72,6 +72,12 @@ \end{{document}} """ + +def _chunk_label(label, n_chunks): + for i in range(0, len(label), n_chunks): + yield label[i : i + n_chunks] + + def _sanitize_tikz_name(name: str) -> str: """ Sanitize a node name to be TikZ-compatible. @@ -105,43 +111,43 @@ def _parse_label(label: Union[str, List[str], Tuple[str, ...]], label_width: Opt return r"$\begin{array}{c}" + r" \\ ".join(label) + r"\end{array}$" else: labels = [] - for chunk in chunk_label(label, label_width): + for chunk in _chunk_label(label, label_width): labels.append(", ".join(chunk)) return r"$\begin{array}{c}" + r" \\ ".join(labels) + r"\end{array}$" else: - return r"${}$".format(label) + return rf"${label}$" class XDSMLatexWriter: """ Writer class for generating LaTeX/TikZ output from XDSM diagrams. """ - + @staticmethod def _build_node_grid(xdsm: 'XDSM') -> str: """Build the TikZ node grid.""" size = len(xdsm.systems) comps_rows = np.arange(size) comps_cols = np.arange(size) - + if xdsm.inputs: size += 1 comps_rows += 1 - + if any(out.side == "left" for out in xdsm.outputs.values()): size += 1 comps_cols += 1 - + if any(out.side == "right" for out in xdsm.outputs.values()): size += 1 - + row_idx_map = {} col_idx_map = {} - + node_str = r"\node [{style}] ({node_name}) {{{node_label}}};" grid = np.empty((size, size), dtype=object) grid[:] = "" - + # Add diagonal systems for i_row, j_col, comp in zip(comps_rows, comps_cols, xdsm.systems): style = comp.style @@ -157,7 +163,7 @@ def _build_node_grid(xdsm: 'XDSM') -> str: row_idx_map[comp.node_name] = i_row col_idx_map[comp.node_name] = j_col - + # Add off-diagonal connection nodes for conn in xdsm.connections: src_row = row_idx_map[conn.src] @@ -174,7 +180,7 @@ def _build_node_grid(xdsm: 'XDSM') -> str: node = node_str.format(style=style, node_name=node_name, node_label=label) grid[src_row, target_col] = node - + # Add left outputs for comp_name, out in xdsm.outputs.items(): if out.side != "left": @@ -190,7 +196,7 @@ def _build_node_grid(xdsm: 'XDSM') -> str: sanitized_name = _sanitize_tikz_name(out.node_name) node = node_str.format(style=style, node_name=sanitized_name, node_label=label) grid[i_row, 0] = node - + # Add right outputs for comp_name, out in xdsm.outputs.items(): if out.side != "right": @@ -206,7 +212,7 @@ def _build_node_grid(xdsm: 'XDSM') -> str: sanitized_name = _sanitize_tikz_name(out.node_name) node = node_str.format(style=style, node_name=sanitized_name, node_label=label) grid[i_row, -1] = node - + # Add inputs for comp_name, inp in xdsm.inputs.items(): style = inp.style @@ -220,22 +226,22 @@ def _build_node_grid(xdsm: 'XDSM') -> str: sanitized_name = _sanitize_tikz_name(inp.node_name) node = node_str.format(style=style, node_name=sanitized_name, node_label=label) grid[0, j_col] = node - + # Convert grid to string rows_str = "" for i, row in enumerate(grid): rows_str += f"%Row {i}\n" + "&\n".join(row) + r"\\" + "\n" - + return rows_str - + @staticmethod def _build_edges(xdsm: 'XDSM') -> str: """Build the TikZ edge definitions.""" h_edges = [] v_edges = [] - + edge_format = "({start}) edge [{style}] ({end})" - + for conn in xdsm.connections: h_style = "DataLine" v_style = "DataLine" @@ -278,15 +284,15 @@ def _build_edges(xdsm: 'XDSM') -> str: comp_sanitized = _sanitize_tikz_name(comp_name) inp_sanitized = _sanitize_tikz_name(inp.node_name) v_edges.append(edge_format.format(start=comp_sanitized, end=inp_sanitized, style=style)) - + h_edges = sorted(h_edges, key=lambda s: "faded" in s) v_edges = sorted(v_edges, key=lambda s: "faded" in s) - + paths_str = "% Horizontal edges\n" + "\n".join(h_edges) + "\n" paths_str += "% Vertical edges\n" + "\n".join(v_edges) + ";" - + return paths_str - + @staticmethod def _build_process_chain(xdsm: 'XDSM') -> str: """Build the TikZ process chain definitions.""" @@ -337,7 +343,7 @@ def _compose_optional_package_list(xdsm: 'XDSM') -> str: if xdsm.use_sfmath: packages.append("sfmath") return ",".join(packages) - + @staticmethod def write(xdsm: 'XDSM', file_name: str, build: bool = True, cleanup: bool = True, quiet: bool = False, outdir: str = ".") -> None: @@ -376,11 +382,11 @@ def write(xdsm: 'XDSM', file_name: str, build: bool = True, cleanup: bool = True diagram_styles_path=diagram_styles_path, optional_packages=optional_packages_str, ) - + base_output_fp = os.path.join(outdir, file_name) with open(base_output_fp + ".tikz", "w") as f: f.write(tikzpicture_str) - + tex_str = tex_template.format( nodes=nodes, edges=edges, @@ -389,10 +395,10 @@ def write(xdsm: 'XDSM', file_name: str, build: bool = True, cleanup: bool = True optional_packages=optional_packages_str, version=pyxdsm_version, ) - + with open(base_output_fp + ".tex", "w") as f: f.write(tex_str) - + if build: command = [ "pdflatex", @@ -404,7 +410,7 @@ def write(xdsm: 'XDSM', file_name: str, build: bool = True, cleanup: bool = True command += ["-interaction=batchmode", "-halt-on-error"] command += [f"{file_name}.tex"] subprocess.run(command, check=True) - + if cleanup: for ext in ["aux", "fdb_latexmk", "fls", "log"]: f_name = f"{base_output_fp}.{ext}" From eca0d662e55cb8dcbb2543997d3e83d4b1a389fc Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Fri, 24 Oct 2025 07:45:31 -0400 Subject: [PATCH 10/25] test of example json files. cli cleanup. --- examples/kitchen_sink.json | 432 +++++++++++++++++++++++++++++++++++++ examples/kitchen_sink.py | 4 - pyxdsm/__main__.py | 49 ++--- tests/test_xdsm.py | 22 ++ 4 files changed, 474 insertions(+), 33 deletions(-) create mode 100644 examples/kitchen_sink.json diff --git a/examples/kitchen_sink.json b/examples/kitchen_sink.json new file mode 100644 index 0000000..03dd98f --- /dev/null +++ b/examples/kitchen_sink.json @@ -0,0 +1,432 @@ +{ + "systems": [ + { + "node_name": "opt", + "style": "Optimization", + "label": "\\text{Optimizer}", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "opt" + }, + { + "node_name": "DOE", + "style": "DOE", + "label": "\\text{DOE}", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "DOE" + }, + { + "node_name": "MDA", + "style": "MDA", + "label": "\\text{Newton}", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "MDA" + }, + { + "node_name": "D1", + "style": "Function", + "label": "D_1", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "D1" + }, + { + "node_name": "D2", + "style": "ImplicitFunction", + "label": "D_2", + "stack": false, + "faded": true, + "label_width": null, + "spec_name": "D2" + }, + { + "node_name": "D3", + "style": "ImplicitFunction", + "label": "D_3", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "D3" + }, + { + "node_name": "subopt", + "style": "SubOptimization", + "label": "SubOpt", + "stack": false, + "faded": true, + "label_width": null, + "spec_name": "subopt" + }, + { + "node_name": "G1", + "style": "Group", + "label": "G_1", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "G1" + }, + { + "node_name": "G2", + "style": "ImplicitGroup", + "label": "G_2", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "G2" + }, + { + "node_name": "MM", + "style": "Metamodel", + "label": "MM", + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "MM" + }, + { + "node_name": "F", + "style": "Function", + "label": [ + "F", + "\\text{Functional}" + ], + "stack": false, + "faded": false, + "label_width": null, + "spec_name": "F" + }, + { + "node_name": "H", + "style": "Function", + "label": "H", + "stack": true, + "faded": false, + "label_width": null, + "spec_name": "H" + } + ], + "connections": [ + { + "src": "opt", + "target": "D1", + "label": [ + "x", + "z", + "y_2" + ], + "label_width": 2, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "opt", + "target": "D2", + "label": [ + "z", + "y_1" + ], + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": true + }, + { + "src": "opt", + "target": "D3", + "label": "z, y_1", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "opt", + "target": "subopt", + "label": "z, y_1", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": true + }, + { + "src": "D3", + "target": "G1", + "label": "y_3", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "subopt", + "target": "G1", + "label": "z_2", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": true, + "src_faded": true, + "target_faded": false + }, + { + "src": "subopt", + "target": "G2", + "label": "z_2", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": true, + "src_faded": true, + "target_faded": false + }, + { + "src": "subopt", + "target": "MM", + "label": "z_2", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": true, + "src_faded": true, + "target_faded": false + }, + { + "src": "subopt", + "target": "F", + "label": "f", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": true, + "src_faded": true, + "target_faded": false + }, + { + "src": "MM", + "target": "subopt", + "label": "f", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": true + }, + { + "src": "opt", + "target": "G2", + "label": "z", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "opt", + "target": "F", + "label": "x, z", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "opt", + "target": "F", + "label": "y_1, y_2", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "opt", + "target": "H", + "label": "y_1, y_2", + "label_width": null, + "style": "DataInter", + "stack": true, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "D1", + "target": "opt", + "label": "\\mathcal{R}(y_1)", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "D2", + "target": "opt", + "label": "\\mathcal{R}(y_2)", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": true, + "src_faded": true, + "target_faded": false + }, + { + "src": "F", + "target": "opt", + "label": "f", + "label_width": null, + "style": "DataInter", + "stack": false, + "faded": false, + "src_faded": false, + "target_faded": false + }, + { + "src": "H", + "target": "opt", + "label": "h", + "label_width": null, + "style": "DataInter", + "stack": true, + "faded": false, + "src_faded": false, + "target_faded": false + } + ], + "inputs": { + "D1": { + "node_name": "output_D1", + "label": "P_1", + "label_width": null, + "style": "DataIO", + "stack": false, + "faded": false + }, + "D2": { + "node_name": "output_D2", + "label": "P_2", + "label_width": null, + "style": "DataIO", + "stack": false, + "faded": false + }, + "opt": { + "node_name": "output_opt", + "label": "x_0", + "label_width": null, + "style": "DataIO", + "stack": true, + "faded": false + } + }, + "outputs": { + "opt": { + "node_name": "left_output_opt", + "label": "y^*", + "label_width": null, + "style": "DataIO", + "stack": false, + "faded": false, + "side": "left" + }, + "D1": { + "node_name": "left_output_D1", + "label": "y_1^*", + "label_width": null, + "style": "DataIO", + "stack": false, + "faded": false, + "side": "left" + }, + "D2": { + "node_name": "left_output_D2", + "label": "y_2^*", + "label_width": null, + "style": "DataIO", + "stack": false, + "faded": true, + "side": "left" + }, + "F": { + "node_name": "right_output_F", + "label": "f^*", + "label_width": null, + "style": "DataIO", + "stack": false, + "faded": false, + "side": "right" + }, + "H": { + "node_name": "right_output_H", + "label": "h^*", + "label_width": null, + "style": "DataIO", + "stack": false, + "faded": false, + "side": "right" + } + }, + "processes": [ + { + "systems": [ + "opt", + "DOE", + "MDA", + "D1", + "D2", + "subopt", + "G1", + "G2", + "MM", + "F", + "H", + "opt" + ], + "arrow": true, + "faded": false + }, + { + "systems": [ + "output_opt", + "opt", + "left_output_opt" + ], + "arrow": true, + "faded": false + } + ], + "use_sfmath": true, + "optional_packages": [], + "auto_fade": { + "inputs": "none", + "outputs": "connected", + "connections": "outgoing", + "processes": "none" + } +} \ No newline at end of file diff --git a/examples/kitchen_sink.py b/examples/kitchen_sink.py index afef79f..0d968ca 100644 --- a/examples/kitchen_sink.py +++ b/examples/kitchen_sink.py @@ -88,7 +88,3 @@ x.write("kitchen_sink", cleanup=False) x.write_sys_specs("sink_specs") x.to_json("kitchen_sink.json") - -x2 = XDSM.from_json("kitchen_sink.json") -x2.write("kitchen_sink2", cleanup=True) - diff --git a/pyxdsm/__main__.py b/pyxdsm/__main__.py index c24b9bc..c04e99a 100644 --- a/pyxdsm/__main__.py +++ b/pyxdsm/__main__.py @@ -12,7 +12,7 @@ def main(): - """Main entry point for the pyXDSM CLI.""" + """Main entry point for the pyxdsm CLI.""" parser = argparse.ArgumentParser( description="Generate XDSM diagrams from JSON specification files", prog="python -m pyxdsm" @@ -27,26 +27,20 @@ def main(): parser.add_argument( "-o", "--output", type=str, - required=True, - help="Output file path (e.g., output.pdf or output.tikz)" + required=False, + default=None, + help="Output file path (e.g., output.pdf or output.tikz). If not provided, defaults to input filename with .pdf extension" ) parser.add_argument( - "--cleanup", + "-c", "--cleanup", action="store_true", default=True, help="Clean up auxiliary files after PDF build (default: True)" ) parser.add_argument( - "--no-cleanup", - action="store_false", - dest="cleanup", - help="Keep auxiliary files after PDF build" - ) - - parser.add_argument( - "--quiet", + "-q", "--quiet", action="store_true", default=False, help="Suppress pdflatex output" @@ -64,35 +58,32 @@ def main(): print(f"Error loading JSON: {e}", file=sys.stderr) sys.exit(1) - # Parse output path - output_path = Path(args.output) + if args.output is None: + input_path = Path(args.input) + output_path = input_path.with_suffix('.pdf') + else: + output_path = Path(args.output) + outdir = str(output_path.parent) if output_path.parent != Path(".") else "." file_name = output_path.stem extension = output_path.suffix.lower() - # Determine build flag based on output extension - if extension == ".pdf": - build = True - elif extension == ".tikz": - build = False + if extension.lower() == ".json": + xdsm.to_json(output_path) + print(f"Successfully generated {output_path}") else: - print(f"Warning: Unknown extension '{extension}'. Defaulting to PDF build.", - file=sys.stderr) - build = True - - # Build the diagram - try: + if extension.lower() not in (".pdf", ".tikz"): + print(f"Warning: Unknown output extension '{extension}'. Defaulting to PDF build.", + file=sys.stderr) + extension = ".pdf" xdsm.write( file_name=file_name, - build=build, + build=extension.lower() == ".pdf", cleanup=args.cleanup, quiet=args.quiet, outdir=outdir ) print(f"Successfully generated {args.output}") - except Exception as e: - print(f"Error generating output: {e}", file=sys.stderr) - sys.exit(1) if __name__ == "__main__": diff --git a/tests/test_xdsm.py b/tests/test_xdsm.py index 4c9d607..ff4132b 100644 --- a/tests/test_xdsm.py +++ b/tests/test_xdsm.py @@ -52,6 +52,28 @@ def test_examples(self): # change back to previous directory os.chdir(self.tempdir) + def test_examples_json(self): + """ + This test just builds the three examples, and assert that the output files exist. + Unlike the other tests, this one requires LaTeX to be available. + """ + # we first copy the examples to the temp dir + shutil.copytree(os.path.join(basedir, "../examples"), os.path.join(self.tempdir, "examples_json")) + os.chdir(os.path.join(self.tempdir, "examples_json")) + + filenames = ["kitchen_sink", "mdf"] + for f in filenames: + subprocess.run(["python", "-m", "pyxdsm", f"{f}.json"], check=True) + self.assertTrue(os.path.isfile(f + ".tikz")) + self.assertTrue(os.path.isfile(f + ".tex")) + # look for the pdflatex executable + pdflatex = shutil.which("pdflatex") is not None + # if no pdflatex, then do not assert that the pdf was compiled + self.assertTrue(not pdflatex or os.path.isfile(f + ".pdf")) + + # change back to previous directory + os.chdir(self.tempdir) + def test_connect(self): from pydantic import ValidationError From 795f6f4eee2da02e46eb86fa70157eb5452ca29a Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Fri, 24 Oct 2025 08:15:40 -0400 Subject: [PATCH 11/25] ruff check passing --- examples/kitchen_sink.py | 12 ++++++------ examples/mdf.py | 2 +- pyproject.toml | 1 + tests/test_xdsm.py | 10 +++++----- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/examples/kitchen_sink.py b/examples/kitchen_sink.py index 0d968ca..9342ad6 100644 --- a/examples/kitchen_sink.py +++ b/examples/kitchen_sink.py @@ -1,16 +1,16 @@ from pyxdsm.XDSM import ( - XDSM, - OPT, - SUBOPT, - SOLVER, DOE, - IFUNC, FUNC, GROUP, + IFUNC, IGROUP, - METAMODEL, LEFT, + METAMODEL, + OPT, RIGHT, + SOLVER, + SUBOPT, + XDSM, ) x = XDSM( diff --git a/examples/mdf.py b/examples/mdf.py index 6983f03..7359ebb 100644 --- a/examples/mdf.py +++ b/examples/mdf.py @@ -1,4 +1,4 @@ -from pyxdsm.XDSM import XDSM, OPT, SOLVER, FUNC, LEFT +from pyxdsm.XDSM import FUNC, LEFT, OPT, SOLVER, XDSM # Change `use_sfmath` to False to use computer modern x = XDSM(use_sfmath=True) diff --git a/pyproject.toml b/pyproject.toml index b33a63c..a8e6f41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ addopts = "-v --tb=short" [tool.ruff] line-length = 120 indent-width = 4 +exclude = ["doc/conf.py"] [tool.ruff.lint] select = [ diff --git a/tests/test_xdsm.py b/tests/test_xdsm.py index ff4132b..1142825 100644 --- a/tests/test_xdsm.py +++ b/tests/test_xdsm.py @@ -1,10 +1,10 @@ -import unittest import os import shutil -import tempfile import subprocess -from pyxdsm.XDSM import XDSM, OPT, FUNC, SOLVER, LEFT, RIGHT +import tempfile +import unittest +from pyxdsm.XDSM import FUNC, LEFT, OPT, RIGHT, SOLVER, XDSM basedir = os.path.dirname(os.path.abspath(__file__)) @@ -141,7 +141,7 @@ def test_stacked_system(self): x.write(file_name) tikz_file = file_name + ".tikz" - with open(tikz_file, "r") as f: + with open(tikz_file) as f: tikz = f.read() self.assertIn(r"\node [Optimization,stack]", tikz) @@ -305,7 +305,7 @@ def test_tikz_content(self): sample_lines = sample_txt.split("\n") sample_lines = filter_lines(sample_lines) - with open(tikz_file, "r") as f: + with open(tikz_file) as f: new_lines = filter_lines(f.readlines()) sample_no_match = [] # Sample text From 3bf2caf1a5b852884911ccca5a38705005d8e17e Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Fri, 24 Oct 2025 08:16:55 -0400 Subject: [PATCH 12/25] ruff format --- pyxdsm/XDSM.py | 253 +++++++++++++++++++++--------------- pyxdsm/__main__.py | 34 ++--- pyxdsm/matrix_eqn.py | 4 +- pyxdsm/xdsm_latex_writer.py | 26 ++-- 4 files changed, 179 insertions(+), 138 deletions(-) diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index b1cedb2..914973d 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -24,16 +24,34 @@ RIGHT = "right" # Type definitions - these match the TikZ styles in diagram_styles -NodeType = Literal['Optimization', 'SubOptimization', 'MDA', 'DOE', 'ImplicitFunction', - 'Function', 'Group', 'ImplicitGroup', 'Metamodel'] -ConnectionStyle = Literal['DataInter', 'DataIO'] -Side = Literal['left', 'right'] -AutoFadeOption = Literal['all', 'connected', 'none', 'incoming', 'outgoing'] +NodeType = Literal[ + "Optimization", + "SubOptimization", + "MDA", + "DOE", + "ImplicitFunction", + "Function", + "Group", + "ImplicitGroup", + "Metamodel", +] +ConnectionStyle = Literal["DataInter", "DataIO"] +Side = Literal["left", "right"] +AutoFadeOption = Literal["all", "connected", "none", "incoming", "outgoing"] # Valid TikZ node styles (from diagram_styles.tikzstyles) VALID_NODE_STYLES = { - 'Optimization', 'SubOptimization', 'MDA', 'DOE', 'ImplicitFunction', - 'Function', 'Group', 'ImplicitGroup', 'Metamodel', 'DataInter', 'DataIO' + "Optimization", + "SubOptimization", + "MDA", + "DOE", + "ImplicitFunction", + "Function", + "Group", + "ImplicitGroup", + "Metamodel", + "DataInter", + "DataIO", } @@ -50,21 +68,20 @@ class SystemNode(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - @field_validator('node_name') + @field_validator("node_name") @classmethod def _validate_node_name(cls, v: str) -> str: if not v or not v.strip(): raise ValueError("Node name cannot be empty") return v.strip() - @field_validator('style') + @field_validator("style") @classmethod def _validate_style(cls, v: str) -> str: """Validate that style is a known TikZ style.""" if v not in VALID_NODE_STYLES: raise ValueError( - f"Style '{v}' is not a valid TikZ style. " - f"Valid styles are: {', '.join(sorted(VALID_NODE_STYLES))}" + f"Style '{v}' is not a valid TikZ style. Valid styles are: {', '.join(sorted(VALID_NODE_STYLES))}" ) return v @@ -100,10 +117,10 @@ class OutputNode(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - @field_validator('side') + @field_validator("side") @classmethod def _validate_side(cls, v: str) -> str: - if v not in ['left', 'right']: + if v not in ["left", "right"]: raise ValueError("Side must be 'left' or 'right'") return v @@ -123,14 +140,14 @@ class ConnectionEdge(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - @field_validator('label_width') + @field_validator("label_width") @classmethod def _validate_label_width(cls, v: Optional[int]) -> Optional[int]: if v is not None and not isinstance(v, int): raise ValueError("label_width must be an integer") return v - @model_validator(mode='after') + @model_validator(mode="after") def _validate_no_self_connection(self): if self.src == self.target: raise ValueError("Cannot connect component to itself") @@ -144,7 +161,7 @@ class ProcessChain(BaseModel): arrow: bool = Field(default=True, description="Show arrows on process lines") faded: bool = Field(default=False, description="Fade the process chain") - @field_validator('systems') + @field_validator("systems") @classmethod def _validate_systems(cls, v: List[str]) -> List[str]: if len(v) < 2: @@ -155,23 +172,23 @@ def _validate_systems(cls, v: List[str]) -> List[str]: class AutoFadeConfig(BaseModel): """Configuration for automatic fading of components.""" - inputs: AutoFadeOption = Field(default='none', description="Auto-fade inputs") - outputs: AutoFadeOption = Field(default='none', description="Auto-fade outputs") - connections: AutoFadeOption = Field(default='none', description="Auto-fade connections") - processes: AutoFadeOption = Field(default='none', description="Auto-fade processes") + inputs: AutoFadeOption = Field(default="none", description="Auto-fade inputs") + outputs: AutoFadeOption = Field(default="none", description="Auto-fade outputs") + connections: AutoFadeOption = Field(default="none", description="Auto-fade connections") + processes: AutoFadeOption = Field(default="none", description="Auto-fade processes") - @field_validator('inputs', 'outputs', 'processes') + @field_validator("inputs", "outputs", "processes") @classmethod def _validate_basic_options(cls, v: str) -> str: - valid = ['all', 'connected', 'none'] + valid = ["all", "connected", "none"] if v not in valid: raise ValueError(f"Must be one of {valid}") return v - @field_validator('connections') + @field_validator("connections") @classmethod def _validate_connection_options(cls, v: str) -> str: - valid = ['all', 'connected', 'none', 'incoming', 'outgoing'] + valid = ["all", "connected", "none", "incoming", "outgoing"] if v not in valid: raise ValueError(f"Must be one of {valid}") return v @@ -194,10 +211,13 @@ class XDSM(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - def __init__(self, use_sfmath: bool = True, - optional_latex_packages: Optional[Union[str, List[str]]] = None, - auto_fade: Optional[Dict[str, str]] = None, - **data): + def __init__( + self, + use_sfmath: bool = True, + optional_latex_packages: Optional[Union[str, List[str]]] = None, + auto_fade: Optional[Dict[str, str]] = None, + **data, + ): """ Initialize XDSM object. @@ -211,7 +231,7 @@ def __init__(self, use_sfmath: bool = True, Auto-fade configuration with keys: inputs, outputs, connections, processes """ # Only process if these aren't already in data (from deserialization) - if 'optional_packages' not in data: + if "optional_packages" not in data: # Process optional packages packages = [] if optional_latex_packages is not None: @@ -221,21 +241,21 @@ def __init__(self, use_sfmath: bool = True, packages = optional_latex_packages else: raise ValueError("optional_latex_packages must be a string or list of strings") - data['optional_packages'] = packages + data["optional_packages"] = packages - if 'auto_fade' not in data: + if "auto_fade" not in data: # Process auto_fade fade_config = AutoFadeConfig() if auto_fade is not None: fade_config = AutoFadeConfig(**auto_fade) - data['auto_fade'] = fade_config + data["auto_fade"] = fade_config - if 'use_sfmath' not in data: - data['use_sfmath'] = use_sfmath + if "use_sfmath" not in data: + data["use_sfmath"] = use_sfmath super().__init__(**data) - @model_validator(mode='before') + @model_validator(mode="before") @classmethod def _set_defaults_for_missing_fields(cls, data): """Ensure missing or null collection fields get empty defaults.""" @@ -243,20 +263,20 @@ def _set_defaults_for_missing_fields(cls, data): return data # Set empty defaults for missing or null collection fields - if 'inputs' not in data or data.get('inputs') is None: - data['inputs'] = {} - if 'outputs' not in data or data.get('outputs') is None: - data['outputs'] = {} - if 'systems' not in data or data.get('systems') is None: - data['systems'] = [] - if 'connections' not in data or data.get('connections') is None: - data['connections'] = [] - if 'processes' not in data or data.get('processes') is None: - data['processes'] = [] + if "inputs" not in data or data.get("inputs") is None: + data["inputs"] = {} + if "outputs" not in data or data.get("outputs") is None: + data["outputs"] = {} + if "systems" not in data or data.get("systems") is None: + data["systems"] = [] + if "connections" not in data or data.get("connections") is None: + data["connections"] = [] + if "processes" not in data or data.get("processes") is None: + data["processes"] = [] return data - @model_validator(mode='after') + @model_validator(mode="after") def _validate_unique_system_names(self): """Ensure all system names are unique.""" names = [sys.node_name for sys in self.systems] @@ -265,9 +285,16 @@ def _validate_unique_system_names(self): raise ValueError(f"Duplicate system names: {set(duplicates)}") return self - def add_system(self, node_name: str, style: str, label: Union[str, List[str], Tuple[str, ...]], - stack: bool = False, faded: bool = False, label_width: Optional[int] = None, - spec_name: Optional[str] = None) -> None: + def add_system( + self, + node_name: str, + style: str, + label: Union[str, List[str], Tuple[str, ...]], + stack: bool = False, + faded: bool = False, + label_width: Optional[int] = None, + spec_name: Optional[str] = None, + ) -> None: """Add a system block on the diagonal.""" system = SystemNode( node_name=node_name, @@ -276,37 +303,47 @@ def add_system(self, node_name: str, style: str, label: Union[str, List[str], Tu stack=stack, faded=faded, label_width=label_width, - spec_name=spec_name + spec_name=spec_name, ) self.systems.append(system) - def add_input(self, name: str, label: Union[str, List[str], Tuple[str, ...]], - label_width: Optional[int] = None, style: str = "DataIO", - stack: bool = False, faded: bool = False) -> None: + def add_input( + self, + name: str, + label: Union[str, List[str], Tuple[str, ...]], + label_width: Optional[int] = None, + style: str = "DataIO", + stack: bool = False, + faded: bool = False, + ) -> None: """Add an input node at the top.""" sys_faded = {s.node_name: s.faded for s in self.systems} - if (self.auto_fade.inputs == "all") or \ - (self.auto_fade.inputs == "connected" and name in sys_faded and sys_faded[name]): + if (self.auto_fade.inputs == "all") or ( + self.auto_fade.inputs == "connected" and name in sys_faded and sys_faded[name] + ): faded = True self.inputs[name] = InputNode( - node_name="output_" + name, - label=label, - label_width=label_width, - style=style, - stack=stack, - faded=faded + node_name="output_" + name, label=label, label_width=label_width, style=style, stack=stack, faded=faded ) - def add_output(self, name: str, label: Union[str, List[str], Tuple[str, ...]], - label_width: Optional[int] = None, style: str = "DataIO", - stack: bool = False, faded: bool = False, side: str = "left") -> None: + def add_output( + self, + name: str, + label: Union[str, List[str], Tuple[str, ...]], + label_width: Optional[int] = None, + style: str = "DataIO", + stack: bool = False, + faded: bool = False, + side: str = "left", + ) -> None: """Add an output node on the left or right side.""" sys_faded = {s.node_name: s.faded for s in self.systems} - if (self.auto_fade.outputs == "all") or \ - (self.auto_fade.outputs == "connected" and name in sys_faded and sys_faded[name]): + if (self.auto_fade.outputs == "all") or ( + self.auto_fade.outputs == "connected" and name in sys_faded and sys_faded[name] + ): faded = True output = OutputNode( @@ -316,14 +353,21 @@ def add_output(self, name: str, label: Union[str, List[str], Tuple[str, ...]], style=style, stack=stack, faded=faded, - side=side + side=side, ) self.outputs[name] = output - def connect(self, src: str, target: str, label: Union[str, List[str], Tuple[str, ...]], - label_width: Optional[int] = None, style: str = "DataInter", - stack: bool = False, faded: bool = False) -> None: + def connect( + self, + src: str, + target: str, + label: Union[str, List[str], Tuple[str, ...]], + label_width: Optional[int] = None, + style: str = "DataInter", + stack: bool = False, + faded: bool = False, + ) -> None: """Connect two components with a data line.""" sys_faded = {s.node_name: s.faded for s in self.systems} @@ -331,10 +375,12 @@ def connect(self, src: str, target: str, label: Union[str, List[str], Tuple[str, target_faded = target in sys_faded and sys_faded[target] all_faded = self.auto_fade.connections == "all" - if (all_faded or - (self.auto_fade.connections == "connected" and src_faded and target_faded) or - (self.auto_fade.connections == "incoming" and target_faded) or - (self.auto_fade.connections == "outgoing" and src_faded)): + if ( + all_faded + or (self.auto_fade.connections == "connected" and src_faded and target_faded) + or (self.auto_fade.connections == "incoming" and target_faded) + or (self.auto_fade.connections == "outgoing" and src_faded) + ): faded = True connection = ConnectionEdge( @@ -346,7 +392,7 @@ def connect(self, src: str, target: str, label: Union[str, List[str], Tuple[str, stack=stack, faded=faded, src_faded=src_faded, - target_faded=target_faded + target_faded=target_faded, ) self.connections.append(connection) @@ -354,16 +400,17 @@ def add_process(self, systems: List[str], arrow: bool = True, faded: bool = Fals """Add a process line between systems.""" sys_faded = {s.node_name: s.faded for s in self.systems} - if (self.auto_fade.processes == "all") or \ - (self.auto_fade.processes == "connected" and - any([sys_faded.get(s, False) for s in systems])): + if (self.auto_fade.processes == "all") or ( + self.auto_fade.processes == "connected" and any([sys_faded.get(s, False) for s in systems]) + ): faded = True process = ProcessChain(systems=systems, arrow=arrow, faded=faded) self.processes.append(process) - def write(self, file_name: str, build: bool = True, cleanup: bool = True, - quiet: bool = False, outdir: str = ".") -> None: + def write( + self, file_name: str, build: bool = True, cleanup: bool = True, quiet: bool = False, outdir: str = "." + ) -> None: """ Write output files for the XDSM diagram (delegates to XDSMLatexWriter). @@ -382,8 +429,9 @@ def write(self, file_name: str, build: bool = True, cleanup: bool = True, """ XDSMLatexWriter.write(self, file_name, build, cleanup, quiet, outdir) - def to_latex(self, file_name: str, build: bool = True, cleanup: bool = True, - quiet: bool = False, outdir: str = ".") -> None: + def to_latex( + self, file_name: str, build: bool = True, cleanup: bool = True, quiet: bool = False, outdir: str = "." + ) -> None: """ Export XDSM diagram to LaTeX/TikZ format. @@ -413,6 +461,7 @@ def write_sys_specs(self, folder_name: str) -> None: folder_name : str Folder to write spec files into """ + def _label_to_spec(label: Union[str, List[str], Tuple[str, ...]], spec: Set[str]) -> None: """Add label variables to spec set.""" if isinstance(label, str): @@ -455,12 +504,12 @@ def to_json(self, filename: Optional[str] = None) -> str: """Export XDSM specification to JSON.""" json_str = self.model_dump_json(indent=2) if filename: - with open(filename, 'w') as f: + with open(filename, "w") as f: f.write(json_str) return json_str @classmethod - def from_json(cls, filename: str) -> 'XDSM': + def from_json(cls, filename: str) -> "XDSM": """Load XDSM from JSON file.""" with open(filename) as f: data = json.load(f) @@ -470,40 +519,40 @@ def from_json(cls, filename: str) -> 'XDSM': # Example usage if __name__ == "__main__": # Create XDSM with validation - xdsm = XDSM(use_sfmath=True, auto_fade={'connections': 'connected'}) + xdsm = XDSM(use_sfmath=True, auto_fade={"connections": "connected"}) # Add systems - note: use the proper style constants - xdsm.add_system('opt', OPT, r'\text{Optimizer}') - xdsm.add_system('d1', FUNC, r'\text{Discipline 1}') # Changed to FUNC which is valid - xdsm.add_system('d2', FUNC, r'\text{Discipline 2}') - xdsm.add_system('func', FUNC, r'\text{Objective}') + xdsm.add_system("opt", OPT, r"\text{Optimizer}") + xdsm.add_system("d1", FUNC, r"\text{Discipline 1}") # Changed to FUNC which is valid + xdsm.add_system("d2", FUNC, r"\text{Discipline 2}") + xdsm.add_system("func", FUNC, r"\text{Objective}") # Add connections - xdsm.connect('opt', 'd1', r'x_1') - xdsm.connect('opt', 'd2', r'x_2') - xdsm.connect('d1', 'd2', r'y_1') - xdsm.connect('d2', 'd1', r'y_2') - xdsm.connect('d1', 'func', r'f_1') - xdsm.connect('d2', 'func', r'f_2') - xdsm.connect('func', 'opt', r'F') + xdsm.connect("opt", "d1", r"x_1") + xdsm.connect("opt", "d2", r"x_2") + xdsm.connect("d1", "d2", r"y_1") + xdsm.connect("d2", "d1", r"y_2") + xdsm.connect("d1", "func", r"f_1") + xdsm.connect("d2", "func", r"f_2") + xdsm.connect("func", "opt", r"F") # Add process - xdsm.add_process(['opt', 'd1', 'd2', 'func', 'opt']) + xdsm.add_process(["opt", "d1", "d2", "func", "opt"]) # Export to JSON - xdsm.to_json('xdsm_spec.json') + xdsm.to_json("xdsm_spec.json") # Write LaTeX files - xdsm.write('example_xdsm', build=True) + xdsm.write("example_xdsm", build=True) # Load from JSON - xdsm_loaded = XDSM.from_json('xdsm_spec.json') + xdsm_loaded = XDSM.from_json("xdsm_spec.json") print("Successfully loaded XDSM from JSON") # Validate example - this will raise an error try: bad_xdsm = XDSM() - bad_xdsm.add_system('sys1', OPT, 'System 1') - bad_xdsm.connect('sys1', 'sys1', 'Invalid') # Self-connection error + bad_xdsm.add_system("sys1", OPT, "System 1") + bad_xdsm.connect("sys1", "sys1", "Invalid") # Self-connection error except ValueError as e: print(f"Validation caught error: {e}") diff --git a/pyxdsm/__main__.py b/pyxdsm/__main__.py index c04e99a..bb95984 100644 --- a/pyxdsm/__main__.py +++ b/pyxdsm/__main__.py @@ -4,6 +4,7 @@ Allows running pyXDSM from the command line: python -m pyxdsm input.json -o output.pdf """ + import argparse import sys from pathlib import Path @@ -14,37 +15,29 @@ def main(): """Main entry point for the pyxdsm CLI.""" parser = argparse.ArgumentParser( - description="Generate XDSM diagrams from JSON specification files", - prog="python -m pyxdsm" + description="Generate XDSM diagrams from JSON specification files", prog="python -m pyxdsm" ) - parser.add_argument( - "input", - type=str, - help="Input JSON specification file" - ) + parser.add_argument("input", type=str, help="Input JSON specification file") parser.add_argument( - "-o", "--output", + "-o", + "--output", type=str, required=False, default=None, - help="Output file path (e.g., output.pdf or output.tikz). If not provided, defaults to input filename with .pdf extension" + help="Output file path (e.g., output.pdf or output.tikz). If not provided, defaults to input filename with .pdf extension", ) parser.add_argument( - "-c", "--cleanup", + "-c", + "--cleanup", action="store_true", default=True, - help="Clean up auxiliary files after PDF build (default: True)" + help="Clean up auxiliary files after PDF build (default: True)", ) - parser.add_argument( - "-q", "--quiet", - action="store_true", - default=False, - help="Suppress pdflatex output" - ) + parser.add_argument("-q", "--quiet", action="store_true", default=False, help="Suppress pdflatex output") args = parser.parse_args() @@ -60,7 +53,7 @@ def main(): if args.output is None: input_path = Path(args.input) - output_path = input_path.with_suffix('.pdf') + output_path = input_path.with_suffix(".pdf") else: output_path = Path(args.output) @@ -73,15 +66,14 @@ def main(): print(f"Successfully generated {output_path}") else: if extension.lower() not in (".pdf", ".tikz"): - print(f"Warning: Unknown output extension '{extension}'. Defaulting to PDF build.", - file=sys.stderr) + print(f"Warning: Unknown output extension '{extension}'. Defaulting to PDF build.", file=sys.stderr) extension = ".pdf" xdsm.write( file_name=file_name, build=extension.lower() == ".pdf", cleanup=args.cleanup, quiet=args.quiet, - outdir=outdir + outdir=outdir, ) print(f"Successfully generated {args.output}") diff --git a/pyxdsm/matrix_eqn.py b/pyxdsm/matrix_eqn.py index 80e4746..38ac97b 100644 --- a/pyxdsm/matrix_eqn.py +++ b/pyxdsm/matrix_eqn.py @@ -217,14 +217,14 @@ class Variable(BaseModel): text: str = Field(default="", description="Display text/label") color: Optional[str] = Field(default=None, description="Color for the variable") - @field_validator('size') + @field_validator("size") @classmethod def validate_size(cls, v: int) -> int: if v < 1: raise ValueError("Variable size must be at least 1") return v - @field_validator('idx') + @field_validator("idx") @classmethod def validate_idx(cls, v: int) -> int: if v < 0: diff --git a/pyxdsm/xdsm_latex_writer.py b/pyxdsm/xdsm_latex_writer.py index b56a3dc..258801e 100644 --- a/pyxdsm/xdsm_latex_writer.py +++ b/pyxdsm/xdsm_latex_writer.py @@ -96,11 +96,11 @@ def _sanitize_tikz_name(name: str) -> str: Sanitized name safe for use as TikZ node identifier """ # Replace periods with underscores - sanitized = name.replace('.', '_') + sanitized = name.replace(".", "_") # Replace spaces with underscores - sanitized = sanitized.replace(' ', '_') + sanitized = sanitized.replace(" ", "_") # Replace other problematic characters with underscores - sanitized = re.sub(r'[^\w\-]', '_', sanitized) + sanitized = re.sub(r"[^\w\-]", "_", sanitized) return sanitized @@ -124,7 +124,7 @@ class XDSMLatexWriter: """ @staticmethod - def _build_node_grid(xdsm: 'XDSM') -> str: + def _build_node_grid(xdsm: "XDSM") -> str: """Build the TikZ node grid.""" size = len(xdsm.systems) comps_rows = np.arange(size) @@ -235,7 +235,7 @@ def _build_node_grid(xdsm: 'XDSM') -> str: return rows_str @staticmethod - def _build_edges(xdsm: 'XDSM') -> str: + def _build_edges(xdsm: "XDSM") -> str: """Build the TikZ edge definitions.""" h_edges = [] v_edges = [] @@ -294,13 +294,12 @@ def _build_edges(xdsm: 'XDSM') -> str: return paths_str @staticmethod - def _build_process_chain(xdsm: 'XDSM') -> str: + def _build_process_chain(xdsm: "XDSM") -> str: """Build the TikZ process chain definitions.""" sys_names = [s.node_name for s in xdsm.systems] - output_names = ( - [inp.node_name for inp in xdsm.inputs.values()] + - [out.node_name for out in xdsm.outputs.values()] - ) + output_names = [inp.node_name for inp in xdsm.inputs.values()] + [ + out.node_name for out in xdsm.outputs.values() + ] chain_str = "" @@ -337,7 +336,7 @@ def _build_process_chain(xdsm: 'XDSM') -> str: return chain_str @staticmethod - def _compose_optional_package_list(xdsm: 'XDSM') -> str: + def _compose_optional_package_list(xdsm: "XDSM") -> str: """Compose the optional LaTeX package list.""" packages = xdsm.optional_packages.copy() if xdsm.use_sfmath: @@ -345,8 +344,9 @@ def _compose_optional_package_list(xdsm: 'XDSM') -> str: return ",".join(packages) @staticmethod - def write(xdsm: 'XDSM', file_name: str, build: bool = True, cleanup: bool = True, - quiet: bool = False, outdir: str = ".") -> None: + def write( + xdsm: "XDSM", file_name: str, build: bool = True, cleanup: bool = True, quiet: bool = False, outdir: str = "." + ) -> None: """ Write output files for the XDSM diagram. From 24d183e3e816d6597534a1fc9c2789a3e72a5cee Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Thu, 13 Nov 2025 10:19:38 -0500 Subject: [PATCH 13/25] cleanup of MatrixEqn and Jacobian schema --- pyxdsm/matrix_eqn.py | 158 ++++++++++++++++++++++++------------------- 1 file changed, 87 insertions(+), 71 deletions(-) diff --git a/pyxdsm/matrix_eqn.py b/pyxdsm/matrix_eqn.py index 38ac97b..5758b1d 100644 --- a/pyxdsm/matrix_eqn.py +++ b/pyxdsm/matrix_eqn.py @@ -1,9 +1,9 @@ import os import subprocess -from typing import Dict, List, Optional, Tuple, Union +from typing import Any, Optional, Union import numpy as np -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator # color pallette link: http://paletton.com/#uid=72Q1j0kllllkS5tKC9H96KClOKC @@ -277,29 +277,43 @@ def _write_tikz(tikz, out_file, build=True, cleanup=True): class TotalJacobian(BaseModel): """Total Jacobian matrix representation.""" - variables: Dict[str, Variable] = Field(default_factory=dict) - j_inputs: Dict[int, Variable] = Field(default_factory=dict) - n_inputs: int = Field(default=0) + inputs: dict[str, Variable] = Field(default_factory=dict) + outputs: dict[str, Variable] = Field(default_factory=dict) + connections: dict[tuple[str, str], CellData] = Field(default_factory=dict) - i_outputs: Dict[int, Variable] = Field(default_factory=dict) - n_outputs: int = Field(default=0) + _variables: dict[str, Variable] = PrivateAttr(default_factory=dict) + _j_inputs: dict[int, Variable] = PrivateAttr(default_factory=dict) + _n_inputs: int = PrivateAttr(default=0) - connections: Dict[Tuple[str, str], CellData] = Field(default_factory=dict) - ij_connections: Dict[Tuple[int, int], CellData] = Field(default_factory=dict) + _i_outputs: dict[int, Variable] = PrivateAttr(default_factory=dict) + _n_outputs: int = PrivateAttr(default=0) - setup: bool = Field(default=False) + _ij_connections: dict[tuple[int, int], CellData] = PrivateAttr(default_factory=dict) + + _setup: bool = PrivateAttr(default=False) model_config = ConfigDict(arbitrary_types_allowed=True) + def model_post_init(self, context: Any) -> None: + for name, var in self.inputs: + self._variables[name] = var + self._j_inputs[self._n_inputs] = self._variables[name] + self._n_inputs += 1 + + for name, var in self.outputs: + self._variables[name] = var + self._i_outputs[self._n_outputs] = self._variables[name] + self._n_outputs += 1 + def add_input(self, name, size=1, text=""): - self.variables[name] = Variable(size=size, idx=self.n_inputs, text=text, color=None) - self.j_inputs[self.n_inputs] = self.variables[name] - self.n_inputs += 1 + self.inputs[name] = self._variables[name] = Variable(size=size, idx=self._n_inputs, text=text, color=None) + self._j_inputs[self._n_inputs] = self._variables[name] + self._n_inputs += 1 def add_output(self, name, size=1, text=""): - self.variables[name] = Variable(size=size, idx=self.n_outputs, text=text, color=None) - self.i_outputs[self.n_outputs] = self.variables[name] - self.n_outputs += 1 + self.outputs[name] = self._variables[name] = Variable(size=size, idx=self._n_outputs, text=text, color=None) + self._i_outputs[self._n_outputs] = self._variables[name] + self._n_outputs += 1 def connect(self, src, target, text="", color="tableau0"): if isinstance(target, (list, tuple)): @@ -309,17 +323,17 @@ def connect(self, src, target, text="", color="tableau0"): self.connections[src, target] = CellData(text=text, color=color, highlight="diag") def _process_vars(self): - if self.setup: + if self._setup: return # deal with connections for (src, target), cell_data in self.connections.items(): - i_src = self.variables[src].idx - j_target = self.variables[target].idx + i_src = self._variables[src].idx + j_target = self._variables[target].idx - self.ij_connections[i_src, j_target] = cell_data + self._ij_connections[i_src, j_target] = cell_data - self.setup = True + self._setup = True def write(self, out_file=None, build=True, cleanup=True): """ @@ -353,16 +367,16 @@ def write(self, out_file=None, build=True, cleanup=True): tikz.append(r" \blockcol{") tikz.append(r" \blockempty{%s*\comp}{%s*\comp}{%s}\\" % (1, 1, "")) tikz.append(r" }") - for j in range(self.n_inputs): - var = self.j_inputs[j] + for j in range(self._n_inputs): + var = self._j_inputs[j] col_size = var.size tikz.append(r" \blockcol{") tikz.append(r" \blockempty{%s*\comp}{%s*\comp}{%s}\\" % (col_size, 1, var.text)) tikz.append(r" }") tikz.append(r"}") - for i in range(self.n_outputs): - output = self.i_outputs[i] + for i in range(self._n_outputs): + output = self._i_outputs[i] row_size = output.size tikz.append(r"\blockrow{") @@ -372,12 +386,12 @@ def write(self, out_file=None, build=True, cleanup=True): tikz.append(r" \blockempty{%s*\comp}{%s*\comp}{%s}\\" % (1, row_size, output.text)) tikz.append(r" }") - for j in range(self.n_inputs): - var = self.j_inputs[j] + for j in range(self._n_inputs): + var = self._j_inputs[j] col_size = var.size tikz.append(r" \blockcol{") - if (j, i) in self.ij_connections: - cell_data = self.ij_connections[(j, i)] + if (j, i) in self._ij_connections: + cell_data = self._ij_connections[(j, i)] conn_color = "T{}".format(var.color) if cell_data.color is not None: conn_color = _color(cell_data.color, cell_data.highlight) @@ -399,34 +413,36 @@ def write(self, out_file=None, build=True, cleanup=True): class MatrixEquation(BaseModel): """Matrix equation representation.""" - variables: Dict[str, Variable] = Field(default_factory=dict) - ij_variables: Dict[int, Variable] = Field(default_factory=dict) - - n_vars: int = Field(default=0) + variables: dict[str, Variable] = Field(default_factory=dict) + connections: dict[tuple[str, str], CellData] = Field(default_factory=dict) + text_data: dict[tuple[str, str], CellData] = Field(default_factory=dict) - connections: Dict[Tuple[str, str], CellData] = Field(default_factory=dict) - ij_connections: Dict[Tuple[int, int], CellData] = Field(default_factory=dict) - - text_data: Dict[Tuple[str, str], CellData] = Field(default_factory=dict) - ij_text: Dict[Tuple[int, int], CellData] = Field(default_factory=dict) - - total_size: int = Field(default=0) - - setup: bool = Field(default=False) - - terms: List[str] = Field(default_factory=list) + _ij_variables: dict[int, Variable] = PrivateAttr(default_factory=dict) + _n_vars: int = PrivateAttr(default=0) + _ij_connections: dict[tuple[int, int], CellData] = PrivateAttr(default_factory=dict) + _ij_text: dict[tuple[int, int], CellData] = PrivateAttr(default_factory=dict) + _total_size: int = PrivateAttr(default=0) + _setup: bool = PrivateAttr(default=False) + _terms: list[str] = PrivateAttr(default_factory=list) model_config = ConfigDict(arbitrary_types_allowed=True) + def model_post_init(self, context: Any) -> None: + """Set internal variables after a model is loaded.""" + for name, var in self.variables: + self._ij_variables[self._n_vars] = self.variables[name] + self._n_vars += 1 + self._total_size += var.size + def clear_terms(self): - self.terms = [] + self._terms = [] def add_variable(self, name, size=1, text="", color="blue"): - self.variables[name] = Variable(size=size, idx=self.n_vars, text=text, color=color) - self.ij_variables[self.n_vars] = self.variables[name] - self.n_vars += 1 + self.variables[name] = Variable(size=size, idx=self._n_vars, text=text, color=color) + self._ij_variables[self._n_vars] = self.variables[name] + self._n_vars += 1 - self.total_size += size + self._total_size += size def connect(self, src, target, text="", color=None, highlight=1): if isinstance(target, (list, tuple)): @@ -442,7 +458,7 @@ def text(self, src, target, text): def _process_vars(self): """Map all the data onto i,j grid""" - if self.setup: + if self._setup: return # deal with connections @@ -450,27 +466,27 @@ def _process_vars(self): i_src = self.variables[src].idx i_target = self.variables[target].idx - self.ij_connections[i_src, i_target] = cell_data + self._ij_connections[i_src, i_target] = cell_data for (src, target), cell_data in self.text_data.items(): i_src = self.variables[src].idx j_target = self.variables[target].idx - self.ij_text[i_src, j_target] = cell_data + self._ij_text[i_src, j_target] = cell_data - self.setup = True + self._setup = True def jacobian(self, transpose=False): self._process_vars() tikz = [] - for i in range(self.n_vars): + for i in range(self._n_vars): tikz.append(r"\blockrow{") - row_size = self.ij_variables[i].size - for j in range(self.n_vars): - var = self.ij_variables[j] + row_size = self._ij_variables[i].size + for j in range(self._n_vars): + var = self._ij_variables[j] col_size = var.size tikz.append(r" \blockcol{") @@ -484,8 +500,8 @@ def jacobian(self, transpose=False): r" \blockmat{%s*\comp}{%s*\comp}{%s}{draw=white,fill=D%s}{}\\" % (col_size, row_size, var.text, var.color) ) - elif location in self.ij_connections: - cell_data = self.ij_connections[location] + elif location in self._ij_connections: + cell_data = self._ij_connections[location] conn_color = "T{}".format(var.color) if cell_data.color is not None: conn_color = _color(cell_data.color, cell_data.highlight) @@ -493,8 +509,8 @@ def jacobian(self, transpose=False): r" \blockmat{%s*\comp}{%s*\comp}{%s}{draw=white,fill=%s}{}\\" % (col_size, row_size, cell_data.text, conn_color) ) - elif location in self.ij_text: - cell_data = self.ij_text[location] + elif location in self._ij_text: + cell_data = self._ij_text[location] tikz.append(r" \blockempty{%s*\comp}{%s*\comp}{%s}\\" % (col_size, row_size, cell_data.text)) else: tikz.append(r" \blockempty{%s*\comp}{%s*\comp}{}\\" % (col_size, row_size)) @@ -504,7 +520,7 @@ def jacobian(self, transpose=False): lhs_tikz = "\n".join(tikz) - self.terms.append(lhs_tikz) + self._terms.append(lhs_tikz) return lhs_tikz def vector(self, base_color="red", highlight=None): @@ -513,12 +529,12 @@ def vector(self, base_color="red", highlight=None): tikz = [] if highlight is None: - highlight = np.ones(self.n_vars) + highlight = np.ones(self._n_vars) for i, h_light in enumerate(highlight): color = _color(base_color, h_light) - row_size = self.ij_variables[i].size + row_size = self._ij_variables[i].size tikz.append(r"\blockrow{\blockcol{") if h_light == "diag": @@ -533,7 +549,7 @@ def vector(self, base_color="red", highlight=None): vec_tikz = "\n".join(tikz) - self.terms.append(vec_tikz) + self._terms.append(vec_tikz) return vec_tikz def operator(self, opperator="="): @@ -541,7 +557,7 @@ def operator(self, opperator="="): tikz = [] - padding_size = (self.total_size - 1) / 2 + padding_size = (self._total_size - 1) / 2 tikz.append(r"\blockrow{") tikz.append(r" \blockempty{\mwid}{%s*\comp}{} \\" % (padding_size)) @@ -551,7 +567,7 @@ def operator(self, opperator="="): op_tikz = "\n".join(tikz) - self.terms.append(op_tikz) + self._terms.append(op_tikz) return op_tikz def spacer(self): @@ -559,8 +575,8 @@ def spacer(self): tikz = [] - for i in range(self.n_vars): - row_size = self.ij_variables[i].size + for i in range(self._n_vars): + row_size = self._ij_variables[i].size tikz.append(r"\blockrow{\blockcol{") tikz.append(r" \blockmat{.25*\mwid}{%s*\comp}{}{draw=white,fill=white}{}\\" % (row_size)) @@ -568,7 +584,7 @@ def spacer(self): spacer_tikz = "\n".join(tikz) - self.terms.append(spacer_tikz) + self._terms.append(spacer_tikz) return spacer_tikz def write(self, out_file=None, build=True, cleanup=True): @@ -596,7 +612,7 @@ def write(self, out_file=None, build=True, cleanup=True): tikz = [] tikz.append(r"\blockrow{") - for term in self.terms: + for term in self._terms: tikz.append(r"\blockcol{") tikz.append(term) tikz.append(r"}") From fa4c408b54eb72ea3dfd4d22cdc3c4b4031a69d8 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Mon, 1 Dec 2025 06:51:36 -0500 Subject: [PATCH 14/25] Update JSON output to add terminal line ending. Revert matrix_eqn.py to non-pydantic version for now. --- examples/kitchen_sink.json | 2 +- examples/mdf.json | 2 +- pyxdsm/XDSM.py | 2 +- pyxdsm/matrix_eqn.py | 137 ++++++++++++------------------------- 4 files changed, 47 insertions(+), 96 deletions(-) diff --git a/examples/kitchen_sink.json b/examples/kitchen_sink.json index 03dd98f..af68e59 100644 --- a/examples/kitchen_sink.json +++ b/examples/kitchen_sink.json @@ -429,4 +429,4 @@ "connections": "outgoing", "processes": "none" } -} \ No newline at end of file +} diff --git a/examples/mdf.json b/examples/mdf.json index 52c8256..572619a 100644 --- a/examples/mdf.json +++ b/examples/mdf.json @@ -235,4 +235,4 @@ "connections": "none", "processes": "none" } -} \ No newline at end of file +} diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index 914973d..85dede0 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -505,7 +505,7 @@ def to_json(self, filename: Optional[str] = None) -> str: json_str = self.model_dump_json(indent=2) if filename: with open(filename, "w") as f: - f.write(json_str) + f.write(f"{json_str}\n") return json_str @classmethod diff --git a/pyxdsm/matrix_eqn.py b/pyxdsm/matrix_eqn.py index 5758b1d..ab6957c 100644 --- a/pyxdsm/matrix_eqn.py +++ b/pyxdsm/matrix_eqn.py @@ -1,9 +1,8 @@ import os import subprocess -from typing import Any, Optional, Union - +from collections import namedtuple import numpy as np -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator + # color pallette link: http://paletton.com/#uid=72Q1j0kllllkS5tKC9H96KClOKC @@ -209,35 +208,9 @@ \end{document}""" -class Variable(BaseModel): - """Variable in matrix equation.""" - - size: int = Field(..., description="Size/dimension of the variable") - idx: int = Field(..., description="Index in the matrix") - text: str = Field(default="", description="Display text/label") - color: Optional[str] = Field(default=None, description="Color for the variable") - - @field_validator("size") - @classmethod - def validate_size(cls, v: int) -> int: - if v < 1: - raise ValueError("Variable size must be at least 1") - return v - - @field_validator("idx") - @classmethod - def validate_idx(cls, v: int) -> int: - if v < 0: - raise ValueError("Variable index must be non-negative") - return v +Variable = namedtuple("Variable", field_names=["size", "idx", "text", "color"]) - -class CellData(BaseModel): - """Data for a cell in matrix equation.""" - - text: str = Field(default="", description="Cell text/label") - color: Optional[str] = Field(default=None, description="Cell color") - highlight: Union[int, str] = Field(default=1, description="Highlight level or type") +CellData = namedtuple("CellData", field_names=["text", "color", "highlight"]) def _color(base_color, h_light): @@ -274,60 +247,43 @@ def _write_tikz(tikz, out_file, build=True, cleanup=True): os.remove(f_name) -class TotalJacobian(BaseModel): - """Total Jacobian matrix representation.""" - - inputs: dict[str, Variable] = Field(default_factory=dict) - outputs: dict[str, Variable] = Field(default_factory=dict) - connections: dict[tuple[str, str], CellData] = Field(default_factory=dict) - - _variables: dict[str, Variable] = PrivateAttr(default_factory=dict) - _j_inputs: dict[int, Variable] = PrivateAttr(default_factory=dict) - _n_inputs: int = PrivateAttr(default=0) +class TotalJacobian(object): + def __init__(self): + self._variables = {} + self._j_inputs = {} + self._n_inputs = 0 - _i_outputs: dict[int, Variable] = PrivateAttr(default_factory=dict) - _n_outputs: int = PrivateAttr(default=0) + self._i_outputs = {} + self._n_outputs = 0 - _ij_connections: dict[tuple[int, int], CellData] = PrivateAttr(default_factory=dict) + self._connections = {} + self._ij_connections = {} - _setup: bool = PrivateAttr(default=False) - - model_config = ConfigDict(arbitrary_types_allowed=True) - - def model_post_init(self, context: Any) -> None: - for name, var in self.inputs: - self._variables[name] = var - self._j_inputs[self._n_inputs] = self._variables[name] - self._n_inputs += 1 - - for name, var in self.outputs: - self._variables[name] = var - self._i_outputs[self._n_outputs] = self._variables[name] - self._n_outputs += 1 + self._setup = False def add_input(self, name, size=1, text=""): - self.inputs[name] = self._variables[name] = Variable(size=size, idx=self._n_inputs, text=text, color=None) + self._variables[name] = Variable(size=size, idx=self._n_inputs, text=text, color=None) self._j_inputs[self._n_inputs] = self._variables[name] self._n_inputs += 1 def add_output(self, name, size=1, text=""): - self.outputs[name] = self._variables[name] = Variable(size=size, idx=self._n_outputs, text=text, color=None) + self._variables[name] = Variable(size=size, idx=self._n_outputs, text=text, color=None) self._i_outputs[self._n_outputs] = self._variables[name] self._n_outputs += 1 def connect(self, src, target, text="", color="tableau0"): if isinstance(target, (list, tuple)): for t in target: - self.connections[src, t] = CellData(text=text, color=color, highlight="diag") + self._connections[src, t] = CellData(text=text, color=color, highlight="diag") else: - self.connections[src, target] = CellData(text=text, color=color, highlight="diag") + self._connections[src, target] = CellData(text=text, color=color, highlight="diag") def _process_vars(self): if self._setup: return # deal with connections - for (src, target), cell_data in self.connections.items(): + for (src, target), cell_data in self._connections.items(): i_src = self._variables[src].idx j_target = self._variables[target].idx @@ -410,36 +366,31 @@ def write(self, out_file=None, build=True, cleanup=True): _write_tikz(jac_tikz, out_file, build, cleanup) -class MatrixEquation(BaseModel): - """Matrix equation representation.""" +class MatrixEquation(object): + def __init__(self): + self._variables = {} + self._ij_variables = {} - variables: dict[str, Variable] = Field(default_factory=dict) - connections: dict[tuple[str, str], CellData] = Field(default_factory=dict) - text_data: dict[tuple[str, str], CellData] = Field(default_factory=dict) + self._n_vars = 0 - _ij_variables: dict[int, Variable] = PrivateAttr(default_factory=dict) - _n_vars: int = PrivateAttr(default=0) - _ij_connections: dict[tuple[int, int], CellData] = PrivateAttr(default_factory=dict) - _ij_text: dict[tuple[int, int], CellData] = PrivateAttr(default_factory=dict) - _total_size: int = PrivateAttr(default=0) - _setup: bool = PrivateAttr(default=False) - _terms: list[str] = PrivateAttr(default_factory=list) + self._connections = {} + self._ij_connections = {} - model_config = ConfigDict(arbitrary_types_allowed=True) + self._text = {} + self._ij_text = {} - def model_post_init(self, context: Any) -> None: - """Set internal variables after a model is loaded.""" - for name, var in self.variables: - self._ij_variables[self._n_vars] = self.variables[name] - self._n_vars += 1 - self._total_size += var.size + self._total_size = 0 + + self._setup = False + + self._terms = [] def clear_terms(self): self._terms = [] def add_variable(self, name, size=1, text="", color="blue"): - self.variables[name] = Variable(size=size, idx=self._n_vars, text=text, color=color) - self._ij_variables[self._n_vars] = self.variables[name] + self._variables[name] = Variable(size=size, idx=self._n_vars, text=text, color=color) + self._ij_variables[self._n_vars] = self._variables[name] self._n_vars += 1 self._total_size += size @@ -447,13 +398,13 @@ def add_variable(self, name, size=1, text="", color="blue"): def connect(self, src, target, text="", color=None, highlight=1): if isinstance(target, (list, tuple)): for t in target: - self.connections[src, t] = CellData(text=text, color=color, highlight=highlight) + self._connections[src, t] = CellData(text=text, color=color, highlight=highlight) else: - self.connections[src, target] = CellData(text=text, color=color, highlight=highlight) + self._connections[src, target] = CellData(text=text, color=color, highlight=highlight) def text(self, src, target, text): """Don't connect the src and target, but put some text where a connection would be""" - self.text_data[src, target] = CellData(text=text, color=None, highlight=-1) + self._text[src, target] = CellData(text=text, color=None, highlight=-1) def _process_vars(self): """Map all the data onto i,j grid""" @@ -462,15 +413,15 @@ def _process_vars(self): return # deal with connections - for (src, target), cell_data in self.connections.items(): - i_src = self.variables[src].idx - i_target = self.variables[target].idx + for (src, target), cell_data in self._connections.items(): + i_src = self._variables[src].idx + i_target = self._variables[target].idx self._ij_connections[i_src, i_target] = cell_data - for (src, target), cell_data in self.text_data.items(): - i_src = self.variables[src].idx - j_target = self.variables[target].idx + for (src, target), cell_data in self._text.items(): + i_src = self._variables[src].idx + j_target = self._variables[target].idx self._ij_text[i_src, j_target] = cell_data From de0b50ab2f82f527f4b63a5f36c51c3b73398153 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Mon, 1 Dec 2025 09:12:28 -0500 Subject: [PATCH 15/25] Restored docstrings/comments in XDSM. --- pyxdsm/XDSM.py | 192 +++++++++++++++++++++++++++++++++--- pyxdsm/xdsm_latex_writer.py | 16 ++- 2 files changed, 191 insertions(+), 17 deletions(-) diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index 85dede0..ac07246 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -218,17 +218,22 @@ def __init__( auto_fade: Optional[Dict[str, str]] = None, **data, ): - """ - Initialize XDSM object. + """Initialize XDSM object Parameters ---------- - use_sfmath : bool - Whether to use the sfmath latex package - optional_latex_packages : str or list of strings - Additional latex packages for PDF/TEX generation - auto_fade : dict - Auto-fade configuration with keys: inputs, outputs, connections, processes + use_sfmath : bool, optional + Whether to use the sfmath latex package, by default True + optional_latex_packages : string or list of strings, optional + Additional latex packages to use when creating the pdf and tex versions of the diagram, by default None + auto_fade : dictionary, optional + Controls the automatic fading of inputs, outputs, connections and processes based on the fading of diagonal blocks. For each key "inputs", "outputs", "connections", and "processes", the value can be one of: + - "all" : fade all blocks + - "connected" : fade all components connected to faded blocks (both source and target must be faded for a conncection to be faded) + - "none" : do not auto-fade anything + For connections there are two additional options: + - "incoming" : Fade all connections that are incoming to faded blocks. + - "outgoing" : Fade all connections that are outgoing from faded blocks. """ # Only process if these aren't already in data (from deserialization) if "optional_packages" not in data: @@ -295,7 +300,41 @@ def add_system( label_width: Optional[int] = None, spec_name: Optional[str] = None, ) -> None: - """Add a system block on the diagonal.""" + r""" + Add a "system" block, which will be placed on the diagonal of the XDSM diagram. + + Parameters + ---------- + node_name : str + The unique name given to this component + + style : str + The type of the component + + label : str or list/tuple of strings + The label to appear on the diagram. There are two options for this: + - a single string + - a list or tuple of strings, which is used for line breaking + In either case, they should probably be enclosed in \text{} declarations to make sure + the font is upright. + + stack : bool + If true, the system will be displayed as several stacked rectangles, + indicating the component is executed in parallel. + + faded : bool + If true, the component will be faded, in order to highlight some other system. + + label_width : int or None + If not None, AND if ``label`` is given as either a tuple or list, then this parameter + controls how many items in the tuple/list will be displayed per line. + If None, the label will be printed one item per line if given as a tuple or list, + otherwise the string will be printed on a single line. + + spec_name : str + The spec name used for the spec file. + + """ system = SystemNode( node_name=node_name, style=style, @@ -316,7 +355,37 @@ def add_input( stack: bool = False, faded: bool = False, ) -> None: - """Add an input node at the top.""" + r""" + Add an input, which will appear in the top row of the diagram. + + Parameters + ---------- + name : str + The unique name given to this component + + label : str or list/tuple of strings + The label to appear on the diagram. There are two options for this: + - a single string + - a list or tuple of strings, which is used for line breaking + In either case, they should probably be enclosed in \text{} declarations to make sure + the font is upright. + + label_width : int or None + If not None, AND if ``label`` is given as either a tuple or list, then this parameter + controls how many items in the tuple/list will be displayed per line. + If None, the label will be printed one item per line if given as a tuple or list, + otherwise the string will be printed on a single line. + + style : str + The style given to this component. Can be one of ['DataInter', 'DataIO'] + + stack : bool + If true, the system will be displayed as several stacked rectangles, + indicating the component is executed in parallel. + + faded : bool + If true, the component will be faded, in order to highlight some other system. + """ sys_faded = {s.node_name: s.faded for s in self.systems} if (self.auto_fade.inputs == "all") or ( @@ -338,7 +407,41 @@ def add_output( faded: bool = False, side: str = "left", ) -> None: - """Add an output node on the left or right side.""" + r""" + Add an output, which will appear in the left or right-most column of the diagram. + + Parameters + ---------- + name : str + The unique name given to this component + + label : str or list/tuple of strings + The label to appear on the diagram. There are two options for this: + - a single string + - a list or tuple of strings, which is used for line breaking + In either case, they should probably be enclosed in \text{} declarations to make sure + the font is upright. + + label_width : int or None + If not None, AND if ``label`` is given as either a tuple or list, then this parameter + controls how many items in the tuple/list will be displayed per line. + If None, the label will be printed one item per line if given as a tuple or list, + otherwise the string will be printed on a single line. + + style : str + The style given to this component. Can be one of ``['DataInter', 'DataIO']`` + + stack : bool + If true, the system will be displayed as several stacked rectangles, + indicating the component is executed in parallel. + + faded : bool + If true, the component will be faded, in order to highlight some other system. + + side : str + Must be one of ``['left', 'right']``. This parameter controls whether the output + is placed on the left-most column or the right-most column of the diagram. + """ sys_faded = {s.node_name: s.faded for s in self.systems} if (self.auto_fade.outputs == "all") or ( @@ -368,7 +471,41 @@ def connect( stack: bool = False, faded: bool = False, ) -> None: - """Connect two components with a data line.""" + r""" + Connects two components with a data line, and adds a label to indicate + the data being transferred. + + Parameters + ---------- + src : str + The name of the source component. + + target : str + The name of the target component. + + label : str or list/tuple of strings + The label to appear on the diagram. There are two options for this: + - a single string + - a list or tuple of strings, which is used for line breaking + In either case, they should probably be enclosed in \text{} declarations to make sure + the font is upright. + + label_width : int or None + If not None, AND if ``label`` is given as either a tuple or list, then this parameter + controls how many items in the tuple/list will be displayed per line. + If None, the label will be printed one item per line if given as a tuple or list, + otherwise the string will be printed on a single line. + + style : str + The style given to this component. Can be one of ``['DataInter', 'DataIO']`` + + stack : bool + If true, the system will be displayed as several stacked rectangles, + indicating the component is executed in parallel. + + faded : bool + If true, the component will be faded, in order to highlight some other system. + """ sys_faded = {s.node_name: s.faded for s in self.systems} src_faded = src in sys_faded and sys_faded[src] @@ -397,7 +534,19 @@ def connect( self.connections.append(connection) def add_process(self, systems: List[str], arrow: bool = True, faded: bool = False) -> None: - """Add a process line between systems.""" + """ + Add a process line between a list of systems, to indicate process flow. + + Parameters + ---------- + systems : list + The names of the components, in the order in which they should be connected. + For a complete cycle, repeat the first component as the last component. + + arrow : bool + If true, arrows will be added to the process lines to indicate the direction + of the process flow. + """ sys_faded = {s.node_name: s.faded for s in self.systems} if (self.auto_fade.processes == "all") or ( @@ -454,12 +603,23 @@ def to_latex( def write_sys_specs(self, folder_name: str) -> None: """ - Write I/O spec JSON files for systems. + Write I/O spec json files for systems to specified folder + + An I/O spec of a system is the collection of all variables going into and out of it. + That includes any variables being passed between systems, as well as all inputs and outputs. + This information is useful for comparing implementations (such as components and groups in OpenMDAO) + to the XDSM diagrams. + + The json spec files can be used to write testing utilities that compare the inputs/outputs of an implementation + to the XDSM, and thus allow you to verify that your codes match the XDSM diagram precisely. + This technique is especially useful when large engineering teams are collaborating on + model development. It allows them to use the XDSM as a shared contract between team members + so everyone can be sure that their codes will sync up. Parameters ---------- - folder_name : str - Folder to write spec files into + folder_name: str + name of the folder, which will be created if it doesn't exist, to put spec files into """ def _label_to_spec(label: Union[str, List[str], Tuple[str, ...]], spec: Set[str]) -> None: diff --git a/pyxdsm/xdsm_latex_writer.py b/pyxdsm/xdsm_latex_writer.py index 258801e..89bff44 100644 --- a/pyxdsm/xdsm_latex_writer.py +++ b/pyxdsm/xdsm_latex_writer.py @@ -338,9 +338,13 @@ def _build_process_chain(xdsm: "XDSM") -> str: @staticmethod def _compose_optional_package_list(xdsm: "XDSM") -> str: """Compose the optional LaTeX package list.""" + # Check for optional LaTeX packages packages = xdsm.optional_packages.copy() + if xdsm.use_sfmath: packages.append("sfmath") + + # Join all packages into one string separated by comma return ",".join(packages) @staticmethod @@ -348,7 +352,17 @@ def write( xdsm: "XDSM", file_name: str, build: bool = True, cleanup: bool = True, quiet: bool = False, outdir: str = "." ) -> None: """ - Write output files for the XDSM diagram. + Write latex output files for the XDSM diagram. + + This produces the following: + + - {file_name}.tikz + A file containing the TikZ definition of the XDSM diagram. + - {file_name}.tex + A standalone document wrapped around an include of the TikZ file which can + be compiled to a pdf. + - {file_name}.pdf + An optional compiled version of the standalone tex file. Parameters ---------- From dbc5e18732c4201dd82ba10f2ccff1cf42eb8ec1 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Mon, 1 Dec 2025 09:24:20 -0500 Subject: [PATCH 16/25] ruff fixes for matrix_eqn --- pyxdsm/matrix_eqn.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyxdsm/matrix_eqn.py b/pyxdsm/matrix_eqn.py index ab6957c..fadbac4 100644 --- a/pyxdsm/matrix_eqn.py +++ b/pyxdsm/matrix_eqn.py @@ -1,8 +1,8 @@ import os import subprocess from collections import namedtuple -import numpy as np +import numpy as np # color pallette link: http://paletton.com/#uid=72Q1j0kllllkS5tKC9H96KClOKC @@ -247,7 +247,7 @@ def _write_tikz(tikz, out_file, build=True, cleanup=True): os.remove(f_name) -class TotalJacobian(object): +class TotalJacobian: def __init__(self): self._variables = {} self._j_inputs = {} @@ -366,7 +366,7 @@ def write(self, out_file=None, build=True, cleanup=True): _write_tikz(jac_tikz, out_file, build, cleanup) -class MatrixEquation(object): +class MatrixEquation: def __init__(self): self._variables = {} self._ij_variables = {} From 1a644550bbc7b982987f18ea1ddd6fb02e8d42e6 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Tue, 2 Dec 2025 09:45:59 -0500 Subject: [PATCH 17/25] Better docstrings for to_json/from_json. from_json can now handle strings or filenames. --- pyxdsm/XDSM.py | 120 ++++++++++++++++++++------------------------- tests/test_xdsm.py | 21 ++++++++ 2 files changed, 73 insertions(+), 68 deletions(-) diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index ac07246..eff6e52 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -4,7 +4,8 @@ import json import os -from typing import Dict, List, Literal, Optional, Set, Tuple, Union +from pathlib import Path +from typing import Literal, Optional, Union from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -60,7 +61,7 @@ class SystemNode(BaseModel): node_name: str = Field(..., description="Unique name for the system") style: str = Field(..., description="Type/style of the system") - label: Union[str, List[str], Tuple[str, ...]] = Field(..., description="Display label") + label: Union[str, list[str], tuple[str, ...]] = Field(..., description="Display label") stack: bool = Field(default=False, description="Display as stacked rectangles") faded: bool = Field(default=False, description="Fade the component") label_width: Optional[int] = Field(default=None, description="Number of items per line") @@ -95,7 +96,7 @@ class InputNode(BaseModel): """Input node at top of XDSM diagram.""" node_name: str = Field(..., description="Internal node name") - label: Union[str, List[str], Tuple[str, ...]] = Field(..., description="Display label") + label: Union[str, list[str], tuple[str, ...]] = Field(..., description="Display label") label_width: Optional[int] = Field(default=None, description="Number of items per line") style: str = Field(default="DataIO", description="Node style") stack: bool = Field(default=False, description="Display as stacked rectangles") @@ -108,7 +109,7 @@ class OutputNode(BaseModel): """Output node on left or right side of XDSM diagram.""" node_name: str = Field(..., description="Internal node name") - label: Union[str, List[str], Tuple[str, ...]] = Field(..., description="Display label") + label: Union[str, list[str], tuple[str, ...]] = Field(..., description="Display label") label_width: Optional[int] = Field(default=None, description="Number of items per line") style: str = Field(default="DataIO", description="Node style") stack: bool = Field(default=False, description="Display as stacked rectangles") @@ -130,7 +131,7 @@ class ConnectionEdge(BaseModel): src: str = Field(..., description="Source node name") target: str = Field(..., description="Target node name") - label: Union[str, List[str], Tuple[str, ...]] = Field(..., description="Connection label") + label: Union[str, list[str], tuple[str, ...]] = Field(..., description="Connection label") label_width: Optional[int] = Field(default=None, description="Number of items per line") style: str = Field(default="DataInter", description="Connection style") stack: bool = Field(default=False, description="Display as stacked") @@ -157,13 +158,13 @@ def _validate_no_self_connection(self): class ProcessChain(BaseModel): """Process flow chain between systems.""" - systems: List[str] = Field(..., description="List of system names in order") + systems: list[str] = Field(..., description="List of system names in order") arrow: bool = Field(default=True, description="Show arrows on process lines") faded: bool = Field(default=False, description="Fade the process chain") @field_validator("systems") @classmethod - def _validate_systems(cls, v: List[str]) -> List[str]: + def _validate_systems(cls, v: list[str]) -> list[str]: if len(v) < 2: raise ValueError("Process chain must contain at least 2 systems") return v @@ -199,14 +200,14 @@ class XDSM(BaseModel): XDSM diagram specification and renderer using Pydantic validation. """ - systems: List[SystemNode] = Field(default_factory=list, description="System nodes") - connections: List[ConnectionEdge] = Field(default_factory=list, description="Connections") - inputs: Dict[str, InputNode] = Field(default_factory=dict, description="Input nodes") - outputs: Dict[str, OutputNode] = Field(default_factory=dict, description="Left output nodes") - processes: List[ProcessChain] = Field(default_factory=list, description="Process chains") + systems: list[SystemNode] = Field(default_factory=list, description="System nodes") + connections: list[ConnectionEdge] = Field(default_factory=list, description="Connections") + inputs: dict[str, InputNode] = Field(default_factory=dict, description="Input nodes") + outputs: dict[str, OutputNode] = Field(default_factory=dict, description="Left output nodes") + processes: list[ProcessChain] = Field(default_factory=list, description="Process chains") use_sfmath: bool = Field(default=True, description="Use sfmath LaTeX package") - optional_packages: List[str] = Field(default_factory=list, description="Additional LaTeX packages") + optional_packages: list[str] = Field(default_factory=list, description="Additional LaTeX packages") auto_fade: AutoFadeConfig = Field(default_factory=AutoFadeConfig, description="Auto-fade configuration") model_config = ConfigDict(arbitrary_types_allowed=True) @@ -214,8 +215,8 @@ class XDSM(BaseModel): def __init__( self, use_sfmath: bool = True, - optional_latex_packages: Optional[Union[str, List[str]]] = None, - auto_fade: Optional[Dict[str, str]] = None, + optional_latex_packages: Optional[Union[str, list[str]]] = None, + auto_fade: Optional[dict[str, str]] = None, **data, ): """Initialize XDSM object @@ -294,7 +295,7 @@ def add_system( self, node_name: str, style: str, - label: Union[str, List[str], Tuple[str, ...]], + label: Union[str, list[str], tuple[str, ...]], stack: bool = False, faded: bool = False, label_width: Optional[int] = None, @@ -349,7 +350,7 @@ def add_system( def add_input( self, name: str, - label: Union[str, List[str], Tuple[str, ...]], + label: Union[str, list[str], tuple[str, ...]], label_width: Optional[int] = None, style: str = "DataIO", stack: bool = False, @@ -400,7 +401,7 @@ def add_input( def add_output( self, name: str, - label: Union[str, List[str], Tuple[str, ...]], + label: Union[str, list[str], tuple[str, ...]], label_width: Optional[int] = None, style: str = "DataIO", stack: bool = False, @@ -465,7 +466,7 @@ def connect( self, src: str, target: str, - label: Union[str, List[str], Tuple[str, ...]], + label: Union[str, list[str], tuple[str, ...]], label_width: Optional[int] = None, style: str = "DataInter", stack: bool = False, @@ -533,7 +534,7 @@ def connect( ) self.connections.append(connection) - def add_process(self, systems: List[str], arrow: bool = True, faded: bool = False) -> None: + def add_process(self, systems: list[str], arrow: bool = True, faded: bool = False) -> None: """ Add a process line between a list of systems, to indicate process flow. @@ -622,7 +623,7 @@ def write_sys_specs(self, folder_name: str) -> None: name of the folder, which will be created if it doesn't exist, to put spec files into """ - def _label_to_spec(label: Union[str, List[str], Tuple[str, ...]], spec: Set[str]) -> None: + def _label_to_spec(label: Union[str, list[str], tuple[str, ...]], spec: set[str]) -> None: """Add label variables to spec set.""" if isinstance(label, str): label = [label] @@ -661,7 +662,13 @@ def _label_to_spec(label: Union[str, List[str], Tuple[str, ...]], spec: Set[str] f.write(json_str) def to_json(self, filename: Optional[str] = None) -> str: - """Export XDSM specification to JSON.""" + """ + Get the JSON representation of the XDSM, and optioally write to file. + + Parameters + ---------- + filename : str + The filename to which to write the JSON representation of the XDSM""" json_str = self.model_dump_json(indent=2) if filename: with open(filename, "w") as f: @@ -669,50 +676,27 @@ def to_json(self, filename: Optional[str] = None) -> str: return json_str @classmethod - def from_json(cls, filename: str) -> "XDSM": - """Load XDSM from JSON file.""" - with open(filename) as f: - data = json.load(f) - return cls.model_validate(data) + def from_json(cls, s: str) -> "XDSM": + """Instantiate an XDSM from the given JSON data. - -# Example usage -if __name__ == "__main__": - # Create XDSM with validation - xdsm = XDSM(use_sfmath=True, auto_fade={"connections": "connected"}) - - # Add systems - note: use the proper style constants - xdsm.add_system("opt", OPT, r"\text{Optimizer}") - xdsm.add_system("d1", FUNC, r"\text{Discipline 1}") # Changed to FUNC which is valid - xdsm.add_system("d2", FUNC, r"\text{Discipline 2}") - xdsm.add_system("func", FUNC, r"\text{Objective}") - - # Add connections - xdsm.connect("opt", "d1", r"x_1") - xdsm.connect("opt", "d2", r"x_2") - xdsm.connect("d1", "d2", r"y_1") - xdsm.connect("d2", "d1", r"y_2") - xdsm.connect("d1", "func", r"f_1") - xdsm.connect("d2", "func", r"f_2") - xdsm.connect("func", "opt", r"F") - - # Add process - xdsm.add_process(["opt", "d1", "d2", "func", "opt"]) - - # Export to JSON - xdsm.to_json("xdsm_spec.json") - - # Write LaTeX files - xdsm.write("example_xdsm", build=True) - - # Load from JSON - xdsm_loaded = XDSM.from_json("xdsm_spec.json") - print("Successfully loaded XDSM from JSON") - - # Validate example - this will raise an error - try: - bad_xdsm = XDSM() - bad_xdsm.add_system("sys1", OPT, "System 1") - bad_xdsm.connect("sys1", "sys1", "Invalid") # Self-connection error - except ValueError as e: - print(f"Validation caught error: {e}") + Parameters + ---------- + f : str + A filename or string of JSON data from which + the XDSM should be instantiated. + """ + if Path(s).is_file(): + with open(s) as f: + try: + data = json.load(f) + except Exception as e: + raise RuntimeError('Unable to load JSON ' + f'from file: {s}') from e + else: + try: + data = json.loads(s) + except (json.JSONDecodeError, TypeError) as e: + raise RuntimeError('Given string is neither ' + 'an existing filename nor ' + 'valid JSON.') from e + return cls.model_validate(data) diff --git a/tests/test_xdsm.py b/tests/test_xdsm.py index 1142825..6b292fd 100644 --- a/tests/test_xdsm.py +++ b/tests/test_xdsm.py @@ -396,6 +396,27 @@ def test_serialize_deserialize(self): self.assertEqual(original_dict, loaded_dict) + # Corrupt the file to test a json load failure + shutil.copyfile(json_file, 'corrupt.json') + with open('corrupt.json', 'a') as f: + f.write(':lk23jr22091k') + + with self.assertRaises(RuntimeError) as e: + XDSM.from_json('corrupt.json') + + expected = ('Unable to load JSON from file') + self.assertIn(expected, str(e.exception)) + + def test_invalid_json(self): + + # Load from invalid JSON + with self.assertRaises(RuntimeError) as e: + XDSM.from_json(':11234_invalid_json') + + expected = ('Given string is neither an ' + 'existing filename nor valid JSON.') + self.assertIn(expected, str(e.exception)) + if __name__ == "__main__": unittest.main() From 004f009052d4738ea5bab80a882b42d2607d5f49 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Tue, 2 Dec 2025 09:54:23 -0500 Subject: [PATCH 18/25] lint --- pyxdsm/XDSM.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index eff6e52..647ea80 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -690,13 +690,10 @@ def from_json(cls, s: str) -> "XDSM": try: data = json.load(f) except Exception as e: - raise RuntimeError('Unable to load JSON ' - f'from file: {s}') from e + raise RuntimeError('Unable to load JSON from file: {s}') from e else: try: data = json.loads(s) except (json.JSONDecodeError, TypeError) as e: - raise RuntimeError('Given string is neither ' - 'an existing filename nor ' - 'valid JSON.') from e + raise RuntimeError('Given string is neither an existing filename nor valid JSON.') from e return cls.model_validate(data) From 6d6e0980ed0080d8e4e4217ce691844d2d164d9e Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Thu, 11 Dec 2025 16:42:21 -0500 Subject: [PATCH 19/25] cleanup based on feedback from ewu63 --- pyxdsm/XDSM.py | 111 ++++++++++++++++++++----------------------------- 1 file changed, 45 insertions(+), 66 deletions(-) diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index 647ea80..ed9b4fc 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Literal, Optional, Union -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, field_validator, model_validator from pyxdsm.xdsm_latex_writer import XDSMLatexWriter @@ -59,15 +59,14 @@ class SystemNode(BaseModel): """System node on the diagonal of XDSM diagram.""" - node_name: str = Field(..., description="Unique name for the system") - style: str = Field(..., description="Type/style of the system") - label: Union[str, list[str], tuple[str, ...]] = Field(..., description="Display label") - stack: bool = Field(default=False, description="Display as stacked rectangles") - faded: bool = Field(default=False, description="Fade the component") - label_width: Optional[int] = Field(default=None, description="Number of items per line") - spec_name: Optional[str] = Field(default=None, description="Name for spec file") + node_name: str + style: str + label: Union[str, list[str], tuple[str, ...]] + stack: bool = False + faded: bool = False + label_width: Optional[int] = None + spec_name: Optional[str] = None - model_config = ConfigDict(arbitrary_types_allowed=True) @field_validator("node_name") @classmethod @@ -95,58 +94,39 @@ def __init__(self, **data): class InputNode(BaseModel): """Input node at top of XDSM diagram.""" - node_name: str = Field(..., description="Internal node name") - label: Union[str, list[str], tuple[str, ...]] = Field(..., description="Display label") - label_width: Optional[int] = Field(default=None, description="Number of items per line") - style: str = Field(default="DataIO", description="Node style") - stack: bool = Field(default=False, description="Display as stacked rectangles") - faded: bool = Field(default=False, description="Fade the component") - - model_config = ConfigDict(arbitrary_types_allowed=True) + node_name: str + label: Union[str, list[str], tuple[str, ...]] + label_width: Optional[int] = None + style: str = "DataIO" + stack: bool = False + faded: bool = False class OutputNode(BaseModel): """Output node on left or right side of XDSM diagram.""" - node_name: str = Field(..., description="Internal node name") - label: Union[str, list[str], tuple[str, ...]] = Field(..., description="Display label") - label_width: Optional[int] = Field(default=None, description="Number of items per line") - style: str = Field(default="DataIO", description="Node style") - stack: bool = Field(default=False, description="Display as stacked rectangles") - faded: bool = Field(default=False, description="Fade the component") - side: Side = Field(..., description="Which side (left or right)") - - model_config = ConfigDict(arbitrary_types_allowed=True) - - @field_validator("side") - @classmethod - def _validate_side(cls, v: str) -> str: - if v not in ["left", "right"]: - raise ValueError("Side must be 'left' or 'right'") - return v + node_name: str + label: Union[str, list[str], tuple[str, ...]] + label_width: Optional[int] = None + style: ConnectionStyle = "DataIO" + stack: bool = False + faded: bool = False + side: Side = "right" class ConnectionEdge(BaseModel): """Connection between two nodes.""" - src: str = Field(..., description="Source node name") - target: str = Field(..., description="Target node name") - label: Union[str, list[str], tuple[str, ...]] = Field(..., description="Connection label") - label_width: Optional[int] = Field(default=None, description="Number of items per line") - style: str = Field(default="DataInter", description="Connection style") - stack: bool = Field(default=False, description="Display as stacked") - faded: bool = Field(default=False, description="Fade the connection") - src_faded: bool = Field(default=False, description="Source node is faded") - target_faded: bool = Field(default=False, description="Target node is faded") - - model_config = ConfigDict(arbitrary_types_allowed=True) + src: str + target: str + label: Union[str, list[str], tuple[str, ...]] + label_width: Optional[int] = None + style: ConnectionStyle = "DataInter" + stack: bool = False + faded: bool = False + src_faded: bool = False + target_faded: bool = False - @field_validator("label_width") - @classmethod - def _validate_label_width(cls, v: Optional[int]) -> Optional[int]: - if v is not None and not isinstance(v, int): - raise ValueError("label_width must be an integer") - return v @model_validator(mode="after") def _validate_no_self_connection(self): @@ -158,9 +138,9 @@ def _validate_no_self_connection(self): class ProcessChain(BaseModel): """Process flow chain between systems.""" - systems: list[str] = Field(..., description="List of system names in order") - arrow: bool = Field(default=True, description="Show arrows on process lines") - faded: bool = Field(default=False, description="Fade the process chain") + systems: list[str] + arrow: bool = True + faded: bool = False @field_validator("systems") @classmethod @@ -173,10 +153,10 @@ def _validate_systems(cls, v: list[str]) -> list[str]: class AutoFadeConfig(BaseModel): """Configuration for automatic fading of components.""" - inputs: AutoFadeOption = Field(default="none", description="Auto-fade inputs") - outputs: AutoFadeOption = Field(default="none", description="Auto-fade outputs") - connections: AutoFadeOption = Field(default="none", description="Auto-fade connections") - processes: AutoFadeOption = Field(default="none", description="Auto-fade processes") + inputs: AutoFadeOption = "none" + outputs: AutoFadeOption = "none" + connections: AutoFadeOption = "none" + processes: AutoFadeOption = "none" @field_validator("inputs", "outputs", "processes") @classmethod @@ -200,17 +180,16 @@ class XDSM(BaseModel): XDSM diagram specification and renderer using Pydantic validation. """ - systems: list[SystemNode] = Field(default_factory=list, description="System nodes") - connections: list[ConnectionEdge] = Field(default_factory=list, description="Connections") - inputs: dict[str, InputNode] = Field(default_factory=dict, description="Input nodes") - outputs: dict[str, OutputNode] = Field(default_factory=dict, description="Left output nodes") - processes: list[ProcessChain] = Field(default_factory=list, description="Process chains") + systems: list[SystemNode] = [] + connections: list[ConnectionEdge] = [] + inputs: dict[str, InputNode] = {} + outputs: dict[str, OutputNode] = {} + processes: list[ProcessChain] = [] - use_sfmath: bool = Field(default=True, description="Use sfmath LaTeX package") - optional_packages: list[str] = Field(default_factory=list, description="Additional LaTeX packages") - auto_fade: AutoFadeConfig = Field(default_factory=AutoFadeConfig, description="Auto-fade configuration") + use_sfmath: bool = True + optional_packages: list[str] = [] + auto_fade: AutoFadeConfig = AutoFadeConfig() - model_config = ConfigDict(arbitrary_types_allowed=True) def __init__( self, From 9186a34d1d6952933d8e6f878111b5779f3ce14f Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Fri, 12 Dec 2025 09:23:43 -0500 Subject: [PATCH 20/25] Removed unneeded __init__. Removed unused NodeType. --- pyxdsm/XDSM.py | 104 +++++++++++++------------------------------------ 1 file changed, 27 insertions(+), 77 deletions(-) diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index ed9b4fc..4edc948 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -24,24 +24,12 @@ LEFT = "left" RIGHT = "right" -# Type definitions - these match the TikZ styles in diagram_styles -NodeType = Literal[ - "Optimization", - "SubOptimization", - "MDA", - "DOE", - "ImplicitFunction", - "Function", - "Group", - "ImplicitGroup", - "Metamodel", -] ConnectionStyle = Literal["DataInter", "DataIO"] Side = Literal["left", "right"] AutoFadeOption = Literal["all", "connected", "none", "incoming", "outgoing"] # Valid TikZ node styles (from diagram_styles.tikzstyles) -VALID_NODE_STYLES = { +NodeStyle = Literal[ "Optimization", "SubOptimization", "MDA", @@ -53,14 +41,14 @@ "Metamodel", "DataInter", "DataIO", -} +] class SystemNode(BaseModel): """System node on the diagonal of XDSM diagram.""" node_name: str - style: str + style: NodeStyle label: Union[str, list[str], tuple[str, ...]] stack: bool = False faded: bool = False @@ -75,20 +63,12 @@ def _validate_node_name(cls, v: str) -> str: raise ValueError("Node name cannot be empty") return v.strip() - @field_validator("style") - @classmethod - def _validate_style(cls, v: str) -> str: - """Validate that style is a known TikZ style.""" - if v not in VALID_NODE_STYLES: - raise ValueError( - f"Style '{v}' is not a valid TikZ style. Valid styles are: {', '.join(sorted(VALID_NODE_STYLES))}" - ) - return v - - def __init__(self, **data): - super().__init__(**data) + @model_validator(mode='after') + def set_defaults(self) -> 'SystemNode': + """Set spec_name to node_name if not provided.""" if self.spec_name is None: self.spec_name = self.node_name + return self class InputNode(BaseModel): @@ -187,58 +167,28 @@ class XDSM(BaseModel): processes: list[ProcessChain] = [] use_sfmath: bool = True - optional_packages: list[str] = [] - auto_fade: AutoFadeConfig = AutoFadeConfig() + optional_packages: Union[str, list[str]] = [] + auto_fade: Union[dict[str, str], AutoFadeConfig] = AutoFadeConfig() + @field_validator('optional_packages', mode='before') + @classmethod + def _validate_optional_packages(cls, v): + """Accept string or list, convert to list.""" + if v is None: + return [] + if isinstance(v, str): + return [v] + return v - def __init__( - self, - use_sfmath: bool = True, - optional_latex_packages: Optional[Union[str, list[str]]] = None, - auto_fade: Optional[dict[str, str]] = None, - **data, - ): - """Initialize XDSM object - - Parameters - ---------- - use_sfmath : bool, optional - Whether to use the sfmath latex package, by default True - optional_latex_packages : string or list of strings, optional - Additional latex packages to use when creating the pdf and tex versions of the diagram, by default None - auto_fade : dictionary, optional - Controls the automatic fading of inputs, outputs, connections and processes based on the fading of diagonal blocks. For each key "inputs", "outputs", "connections", and "processes", the value can be one of: - - "all" : fade all blocks - - "connected" : fade all components connected to faded blocks (both source and target must be faded for a conncection to be faded) - - "none" : do not auto-fade anything - For connections there are two additional options: - - "incoming" : Fade all connections that are incoming to faded blocks. - - "outgoing" : Fade all connections that are outgoing from faded blocks. - """ - # Only process if these aren't already in data (from deserialization) - if "optional_packages" not in data: - # Process optional packages - packages = [] - if optional_latex_packages is not None: - if isinstance(optional_latex_packages, str): - packages = [optional_latex_packages] - elif isinstance(optional_latex_packages, list): - packages = optional_latex_packages - else: - raise ValueError("optional_latex_packages must be a string or list of strings") - data["optional_packages"] = packages - - if "auto_fade" not in data: - # Process auto_fade - fade_config = AutoFadeConfig() - if auto_fade is not None: - fade_config = AutoFadeConfig(**auto_fade) - data["auto_fade"] = fade_config - - if "use_sfmath" not in data: - data["use_sfmath"] = use_sfmath - - super().__init__(**data) + @field_validator('auto_fade', mode='before') + @classmethod + def _validate_auto_fade(cls, v): + """Accept dict or AutoFadeConfig, convert to AutoFadeConfig.""" + if v is None: + return AutoFadeConfig() + if isinstance(v, dict): + return AutoFadeConfig(**v) + return v @model_validator(mode="before") @classmethod From ad4b6c37f8e8221e223d99d90ab384a439bb7163 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Fri, 12 Dec 2025 09:30:50 -0500 Subject: [PATCH 21/25] ruff check/format --- pyxdsm/XDSM.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/pyxdsm/XDSM.py b/pyxdsm/XDSM.py index 4edc948..978683d 100644 --- a/pyxdsm/XDSM.py +++ b/pyxdsm/XDSM.py @@ -55,7 +55,6 @@ class SystemNode(BaseModel): label_width: Optional[int] = None spec_name: Optional[str] = None - @field_validator("node_name") @classmethod def _validate_node_name(cls, v: str) -> str: @@ -63,8 +62,8 @@ def _validate_node_name(cls, v: str) -> str: raise ValueError("Node name cannot be empty") return v.strip() - @model_validator(mode='after') - def set_defaults(self) -> 'SystemNode': + @model_validator(mode="after") + def set_defaults(self) -> "SystemNode": """Set spec_name to node_name if not provided.""" if self.spec_name is None: self.spec_name = self.node_name @@ -107,7 +106,6 @@ class ConnectionEdge(BaseModel): src_faded: bool = False target_faded: bool = False - @model_validator(mode="after") def _validate_no_self_connection(self): if self.src == self.target: @@ -170,7 +168,7 @@ class XDSM(BaseModel): optional_packages: Union[str, list[str]] = [] auto_fade: Union[dict[str, str], AutoFadeConfig] = AutoFadeConfig() - @field_validator('optional_packages', mode='before') + @field_validator("optional_packages", mode="before") @classmethod def _validate_optional_packages(cls, v): """Accept string or list, convert to list.""" @@ -180,7 +178,7 @@ def _validate_optional_packages(cls, v): return [v] return v - @field_validator('auto_fade', mode='before') + @field_validator("auto_fade", mode="before") @classmethod def _validate_auto_fade(cls, v): """Accept dict or AutoFadeConfig, convert to AutoFadeConfig.""" @@ -619,10 +617,10 @@ def from_json(cls, s: str) -> "XDSM": try: data = json.load(f) except Exception as e: - raise RuntimeError('Unable to load JSON from file: {s}') from e + raise RuntimeError("Unable to load JSON from file: {s}") from e else: try: data = json.loads(s) except (json.JSONDecodeError, TypeError) as e: - raise RuntimeError('Given string is neither an existing filename nor valid JSON.') from e + raise RuntimeError("Given string is neither an existing filename nor valid JSON.") from e return cls.model_validate(data) From 059437a4c257f0bb84d85f3ddd186fe4f46f511f Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Fri, 12 Dec 2025 09:37:06 -0500 Subject: [PATCH 22/25] Ruff format on test --- tests/test_xdsm.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/test_xdsm.py b/tests/test_xdsm.py index 6b292fd..3850d90 100644 --- a/tests/test_xdsm.py +++ b/tests/test_xdsm.py @@ -397,24 +397,22 @@ def test_serialize_deserialize(self): self.assertEqual(original_dict, loaded_dict) # Corrupt the file to test a json load failure - shutil.copyfile(json_file, 'corrupt.json') - with open('corrupt.json', 'a') as f: - f.write(':lk23jr22091k') + shutil.copyfile(json_file, "corrupt.json") + with open("corrupt.json", "a") as f: + f.write(":lk23jr22091k") with self.assertRaises(RuntimeError) as e: - XDSM.from_json('corrupt.json') + XDSM.from_json("corrupt.json") - expected = ('Unable to load JSON from file') + expected = "Unable to load JSON from file" self.assertIn(expected, str(e.exception)) def test_invalid_json(self): - # Load from invalid JSON with self.assertRaises(RuntimeError) as e: - XDSM.from_json(':11234_invalid_json') + XDSM.from_json(":11234_invalid_json") - expected = ('Given string is neither an ' - 'existing filename nor valid JSON.') + expected = "Given string is neither an existing filename nor valid JSON." self.assertIn(expected, str(e.exception)) From aee422ee3a6afd034bd4802fc3cc591a2d18c5d4 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Tue, 23 Dec 2025 10:47:10 -0500 Subject: [PATCH 23/25] Add autodoc_pydantic to doc requirements.txt. --- doc/requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/requirements.txt b/doc/requirements.txt index b03c968..1752031 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,2 +1,4 @@ numpydoc sphinx_mdolab_theme +autodoc-pydantic + From 359441fb261aad901c6ef3efdd9f97454d52db45 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Tue, 23 Dec 2025 10:50:54 -0500 Subject: [PATCH 24/25] eof fix --- doc/requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/requirements.txt b/doc/requirements.txt index 1752031..c1e8477 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,4 +1,3 @@ numpydoc sphinx_mdolab_theme autodoc-pydantic - From 8c6cd13ce3a1e68411cf2856313b1da18132a207 Mon Sep 17 00:00:00 2001 From: Rob Falck Date: Tue, 23 Dec 2025 11:07:42 -0500 Subject: [PATCH 25/25] docs build without warning --- doc/API.rst | 6 +++--- doc/conf.py | 21 +++++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/doc/API.rst b/doc/API.rst index 7201faa..082c59f 100644 --- a/doc/API.rst +++ b/doc/API.rst @@ -50,10 +50,10 @@ ProcessChain :undoc-members: :show-inheritance: -AutoFade -^^^^^^^^ +AutoFadeConfig +^^^^^^^^^^^^^^ -.. autopydantic_model:: pyxdsm.XDSM.AutoFade +.. autopydantic_model:: pyxdsm.XDSM.AutoFadeConfig :members: :undoc-members: :show-inheritance: diff --git a/doc/conf.py b/doc/conf.py index b390c5b..9575010 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -22,23 +22,32 @@ # ones. extensions.extend([ "numpydoc", + "sphinx.ext.intersphinx", "sphinxcontrib.autodoc_pydantic", ]) numpydoc_show_class_members = False +# -- intersphinx configuration ------------------------------------------------ + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "pydantic": ("https://docs.pydantic.dev/latest/", None), +} + # -- autodoc_pydantic configuration ------------------------------------------- # Show all configuration options for Pydantic models -autodoc_pydantic_model_show_json = True -autodoc_pydantic_model_show_config_summary = True -autodoc_pydantic_model_show_config_member = True -autodoc_pydantic_model_show_validator_members = True +autodoc_pydantic_model_show_json = False +autodoc_pydantic_model_show_config_summary = False +autodoc_pydantic_model_show_config_member = False +autodoc_pydantic_model_show_validator_members = False +autodoc_pydantic_model_show_validator_summary = False autodoc_pydantic_model_show_field_summary = True autodoc_pydantic_model_members = True autodoc_pydantic_model_undoc_members = True # Settings for fields -autodoc_pydantic_field_list_validators = True +autodoc_pydantic_field_list_validators = False autodoc_pydantic_field_doc_policy = "both" # Show both docstring and description autodoc_pydantic_field_show_constraints = True autodoc_pydantic_field_show_alias = True @@ -49,4 +58,4 @@ autodoc_pydantic_validator_list_fields = True # mock import for autodoc -autodoc_mock_imports = ["numpy", "pydantic"] +autodoc_mock_imports = ["numpy"]