From 4b326fdf87f68a4f9d93b14d1e2edf729602abdf Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Fri, 20 Mar 2026 17:36:02 +0100 Subject: [PATCH 01/25] update lazzy loading --- python_magnetgeo/__init__.py | 111 ++++++++--------------------------- 1 file changed, 25 insertions(+), 86 deletions(-) diff --git a/python_magnetgeo/__init__.py b/python_magnetgeo/__init__.py index c570907..1f923b7 100644 --- a/python_magnetgeo/__init__.py +++ b/python_magnetgeo/__init__.py @@ -52,6 +52,30 @@ from .validation import ValidationError, ValidationWarning, GeometryValidator from .utils import getObject as load, loadObject, ObjectLoadError, UnsupportedTypeError +# Import all geometry classes eagerly so their YAML constructors are registered +# before any yaml.load() call is made. Lazy loading breaks YAML deserialization +# because constructors are only registered when the class is first imported. +from .Insert import Insert +from .Helix import Helix +from .Ring import Ring +from .Bitter import Bitter +from .Bitters import Bitters +from .Supra import Supra +from .Supras import Supras +from .Screen import Screen +from .MSite import MSite +from .Probe import Probe +from .Shape import Shape +from .ModelAxi import ModelAxi +from .Model3D import Model3D +from .InnerCurrentLead import InnerCurrentLead +from .OuterCurrentLead import OuterCurrentLead +from .Contour2D import Contour2D +from .Chamfer import Chamfer +from .Groove import Groove +from .tierod import Tierod +from .coolingslit import CoolingSlit + # Define what gets imported with "from python_magnetgeo import *" __all__ = [ # Core functionality @@ -79,7 +103,7 @@ # Exceptions "ObjectLoadError", "UnsupportedTypeError", - # Geometry classes (lazy loaded) + # Geometry classes "Insert", "Helix", "Ring", @@ -102,91 +126,6 @@ "CoolingSlit", ] -# Lazy loading map: maps class names to their module paths -_LAZY_IMPORTS = { - "Insert": "Insert", - "Helix": "Helix", - "Ring": "Ring", - "Bitter": "Bitter", - "Supra": "Supra", - "Supras": "Supra", - "Bitters": "Bitter", - "Screen": "Screen", - "MSite": "MSite", - "Probe": "Probe", - "Shape": "Shape", - "ModelAxi": "ModelAxi", - "Model3D": "Model3D", - "InnerCurrentLead": "CurrentLead", - "OuterCurrentLead": "CurrentLead", - "Contour2D": "Contour2D", - "Chamfer": "Chamfer", - "Groove": "Groove", - "Tierod": "Tierod", - "CoolingSlit": "CoolingSlit", -} - -# Cache for loaded modules -_loaded_classes = {} - - -def __getattr__(name): - """ - Lazy loading implementation. - - This function is called when an attribute is not found in the module. - We use it to lazily import geometry classes only when they're accessed. - - Args: - name: Attribute name being accessed - - Returns: - The requested class or raises AttributeError - - Example: - >>> import python_magnetgeo as pmg - >>> helix = pmg.Helix(...) # Helix is imported here, not at initial import - """ - # Check if it's a known geometry class - if name in _LAZY_IMPORTS: - # Check cache first - if name in _loaded_classes: - return _loaded_classes[name] - - # Import the module - module_name = _LAZY_IMPORTS[name] - try: - module = __import__(f"python_magnetgeo.{module_name}", fromlist=[name]) - cls = getattr(module, name) - - # Cache it - _loaded_classes[name] = cls - return cls - - except (ImportError, AttributeError) as e: - raise AttributeError( - f"Failed to lazy load class '{name}' from module " f"'{module_name}': {e}" - ) from e - - # Not a lazy import - raise normal AttributeError - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") - - -def __dir__(): - """ - Return list of available attributes for tab-completion. - - This ensures that IDEs and interactive shells can see all available - classes even though they're lazy loaded. - """ - # Start with standard module attributes - attrs = list(globals().keys()) - - # Add all lazy-loadable classes - attrs.extend(_LAZY_IMPORTS.keys()) - - return sorted(set(attrs)) - def list_registered_classes(): """ From 7061b1f39b8cef2a148a55a091a408165d111843 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Fri, 20 Mar 2026 17:37:39 +0100 Subject: [PATCH 02/25] defaut contour2d type --- python_magnetgeo/tierod.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/python_magnetgeo/tierod.py b/python_magnetgeo/tierod.py index 0e1bda7..c431f78 100644 --- a/python_magnetgeo/tierod.py +++ b/python_magnetgeo/tierod.py @@ -9,7 +9,7 @@ class Tierod(YAMLObjectBase): yaml_tag = "Tierod" def __init__( - self, name: str, r: float, n: int, dh: float, sh: float, contour2d: str | Contour2D + self, name: str, r: float, n: int, dh: float, sh: float, contour2d: Contour2D ) -> None: """ Initialize a tie rod configuration for Bitter disk magnets. @@ -33,8 +33,7 @@ def __init__( sh: Cross-sectional area of a single tie rod hole in mm². Total structural area removed = n * sh. Set to 0.0 if tie rods are purely structural. - contour2d: Contour2D object defining the tie rod hole cross-section, - or string reference to Contour2D YAML file, or None. + contour2d: Contour2D object defining the tie rod hole cross-section. Describes the actual 2D shape of each hole (typically circular). Raises: From 842867570bd06c758f89a2b0753821ff29c9b874 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Fri, 20 Mar 2026 17:38:31 +0100 Subject: [PATCH 03/25] update yaml cfg --- data/HL-31_H1.yaml | 7 +++---- data/HL-31_H2.yaml | 4 ++-- data/HL-31_H3.yaml | 4 ++-- data/HL-31_H4.yaml | 4 ++-- data/HL-31_H5.yaml | 4 ++-- data/HL-31_H6.yaml | 4 ++-- data/HL-31_H7.yaml | 4 ++-- data/HL-31_H8.yaml | 4 ++-- data/HL-31_H9.yaml | 4 ++-- 9 files changed, 19 insertions(+), 20 deletions(-) diff --git a/data/HL-31_H1.yaml b/data/HL-31_H1.yaml index 552d47c..1bc0570 100644 --- a/data/HL-31_H1.yaml +++ b/data/HL-31_H1.yaml @@ -9,7 +9,7 @@ z: - 108 name: HL-31_H1 cutwidth: 0.22 -axi: ! +modelaxi: ! name: "HL-31.d" pitch: - 29.59376923780156 @@ -54,15 +54,14 @@ axi: ! - 0.3573277722202946 - 0.2873803014774448 - 0.2923250885741825 -m3d: ! +model3d: ! cad: "HL-31-202MC" with_shapes: False with_channels: False shape: ! - name: profile: "" length: 0 angle: 0 onturns: 0 position: ABOVE -materials: + diff --git a/data/HL-31_H2.yaml b/data/HL-31_H2.yaml index fa53f96..16afb86 100644 --- a/data/HL-31_H2.yaml +++ b/data/HL-31_H2.yaml @@ -9,7 +9,7 @@ z: - -108 - 108 cutwidth: 0.22 -axi: ! +modelaxi: ! name: "HL-31.d" h: 91.7 pitch: @@ -54,7 +54,7 @@ axi: ! - 0.418437672341563 - 0.3431296695896973 - 0.2823535494503169 -m3d: ! +model3d: ! cad: "HL-31-204MC" with_shapes: false with_channels: false diff --git a/data/HL-31_H3.yaml b/data/HL-31_H3.yaml index 387c7e3..f2c4372 100644 --- a/data/HL-31_H3.yaml +++ b/data/HL-31_H3.yaml @@ -9,7 +9,7 @@ z: - 125.0 dble: true cutwidth: 0.22 -axi: ! +modelaxi: ! name: "HL-31.d" h: 95 turns: @@ -54,7 +54,7 @@ axi: ! - 19.58177777239263 - 23.29705899795419 - 25.53105659056708 -m3d: ! +model3d: ! cad: "HL-31-206MC" with_shapes: false with_channels: false diff --git a/data/HL-31_H4.yaml b/data/HL-31_H4.yaml index e9716e8..2b01bfa 100644 --- a/data/HL-31_H4.yaml +++ b/data/HL-31_H4.yaml @@ -8,7 +8,7 @@ z: - -125.0 - 125.0 name: HL-31_H4 -axi: ! +modelaxi: ! name: "HL-31.d" h: 109 pitch: @@ -54,7 +54,7 @@ axi: ! - 0.4370008715377303 - 0.3536370722895749 cutwidth: 0.26 -m3d: ! +model3d: ! cad: "HL-31-008MC" with_shapes: false with_channels: false diff --git a/data/HL-31_H5.yaml b/data/HL-31_H5.yaml index 5849fdc..c6dd167 100644 --- a/data/HL-31_H5.yaml +++ b/data/HL-31_H5.yaml @@ -5,7 +5,7 @@ r: - 47.2 - 55.3 name: HL-31_H5 -axi: ! +modelaxi: ! name: "HL-31.d" turns: - 0.4602599952964374 @@ -51,7 +51,7 @@ axi: ! - 21.18181368853585 - 22.99306298207053 cutwidth: 0.26 -m3d: ! +model3d: ! cad: "HL-31-010MC" with_shapes: false with_channels: false diff --git a/data/HL-31_H6.yaml b/data/HL-31_H6.yaml index 4f19de3..464527f 100644 --- a/data/HL-31_H6.yaml +++ b/data/HL-31_H6.yaml @@ -7,7 +7,7 @@ shape: ! length: 0 profile: "" onturns: 0 -m3d: ! +model3d: ! with_channels: false cad: "HL-31-012MC" with_shapes: false @@ -15,7 +15,7 @@ dble: true r: - 56.2 - 65.6 -axi: ! +modelaxi: ! name: "HL-31.d" h: 115.1071 pitch: diff --git a/data/HL-31_H7.yaml b/data/HL-31_H7.yaml index c306803..d519067 100644 --- a/data/HL-31_H7.yaml +++ b/data/HL-31_H7.yaml @@ -10,7 +10,7 @@ dble: true r: - 66.5 - 77.2 -axi: ! +modelaxi: ! name: "HL-31.d" h: 117.2992 turns: @@ -57,7 +57,7 @@ axi: ! - 19.56797679056729 odd: true name: HL-31_H7 -m3d: ! +model3d: ! with_channels: false cad: "HL-31-014MC" with_shapes: false diff --git a/data/HL-31_H8.yaml b/data/HL-31_H8.yaml index 420f94c..8d9622e 100644 --- a/data/HL-31_H8.yaml +++ b/data/HL-31_H8.yaml @@ -15,11 +15,11 @@ cutwidth: 0.26 r: - 78.10000000000001 - 90 -m3d: ! +model3d: ! cad: "HL-31-016MC" with_channels: false with_shapes: false -axi: ! +modelaxi: ! name: "HL-31.d" turns: - 0.626349246760017 diff --git a/data/HL-31_H9.yaml b/data/HL-31_H9.yaml index 69758f0..7f7a6e2 100644 --- a/data/HL-31_H9.yaml +++ b/data/HL-31_H9.yaml @@ -1,5 +1,5 @@ ! -axi: ! +modelaxi: ! name: "HL-31.d" h: 129.7051 pitch: @@ -57,7 +57,7 @@ z: - -157.0 - 168.0 dble: true -m3d: ! +model3d: ! with_shapes: false with_channels: false cad: "HL-31-018MC" From 4daabfdbd1c00f3d384efdb15908b5b3cb97d52b Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Fri, 20 Mar 2026 17:38:45 +0100 Subject: [PATCH 04/25] update yaml cfg --- data/HL-31_H10.yaml | 4 ++-- data/HL-31_H11.yaml | 4 ++-- data/HL-31_H12.yaml | 4 ++-- data/HL-31_H13.yaml | 4 ++-- data/HL-31_H14.yaml | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/data/HL-31_H10.yaml b/data/HL-31_H10.yaml index 80cea37..99a3e51 100644 --- a/data/HL-31_H10.yaml +++ b/data/HL-31_H10.yaml @@ -4,7 +4,7 @@ name: HL-31_H10 r: - 104.8 - 118.9 -axi: ! +modelaxi: ! name: "HL-31.d" pitch: - 20.33093266283109 @@ -54,7 +54,7 @@ z: - 168.0 odd: false cutwidth: 0.26 -m3d: ! +model3d: ! with_shapes: false cad: "HL-31-020MC" with_channels: false diff --git a/data/HL-31_H11.yaml b/data/HL-31_H11.yaml index f59de17..aef375b 100644 --- a/data/HL-31_H11.yaml +++ b/data/HL-31_H11.yaml @@ -7,7 +7,7 @@ shape: ! position: ABOVE profile: "" name: "" -axi: ! +modelaxi: ! name: "HL-31.d" turns: - 0.7089719947246353 @@ -58,7 +58,7 @@ r: - 135 odd: true name: HL-31_H11 -m3d: ! +model3d: ! with_channels: false with_shapes: false cad: "HL-31-022MC" diff --git a/data/HL-31_H12.yaml b/data/HL-31_H12.yaml index 2a76ac4..3144033 100644 --- a/data/HL-31_H12.yaml +++ b/data/HL-31_H12.yaml @@ -1,5 +1,5 @@ ! -axi: ! +modelaxi: ! name: "HL-31.d" h: 149.3813 turns: @@ -45,7 +45,7 @@ axi: ! - 21.45931051401753 - 29.03667644291524 cutwidth: 0.26 -m3d: ! +model3d: ! with_shapes: false with_channels: false cad: "HL-31-024MC" diff --git a/data/HL-31_H13.yaml b/data/HL-31_H13.yaml index 48ae200..5f0e2e5 100644 --- a/data/HL-31_H13.yaml +++ b/data/HL-31_H13.yaml @@ -7,7 +7,7 @@ shape: ! position: ABOVE length: 0 onturns: 0 -axi: ! +modelaxi: ! name: "HL-31.d" h: 149.2674 pitch: @@ -53,7 +53,7 @@ axi: ! - 0.656955263051511 - 0.6638843127993697 name: HL-31_H13 -m3d: ! +model3d: ! with_channels: false with_shapes: false cad: "HL-31-026MC" diff --git a/data/HL-31_H14.yaml b/data/HL-31_H14.yaml index 866e332..49c9a0f 100644 --- a/data/HL-31_H14.yaml +++ b/data/HL-31_H14.yaml @@ -9,7 +9,7 @@ z: - -190.0 - 190.0 cutwidth: 0.26 -axi: ! +modelaxi: ! name: "HL-31.d" pitch: - 27.51728484458655 @@ -54,7 +54,7 @@ axi: ! - 0.6550832500291629 - 0.5955465857750388 - 0.5734374548312133 -m3d: ! +model3d: ! with_channels: false with_shapes: false cad: "HL-31-028MC" From f6db5157879441ae6b79fa7bcb7cfa4f793ec492 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Fri, 20 Mar 2026 17:39:02 +0100 Subject: [PATCH 05/25] update yaml cfg --- data/HL-31_H1_shapes.yaml | 4 ++-- data/HL-31_H2_shapes.yaml | 4 ++-- data/HL-31_H3_shapes.yaml | 4 ++-- data/HL-31_H4_shapes.yaml | 4 ++-- data/HL-31_H5_shapes.yaml | 4 ++-- data/HL-31_H6_shapes.yaml | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/data/HL-31_H1_shapes.yaml b/data/HL-31_H1_shapes.yaml index 5ac2377..e964816 100644 --- a/data/HL-31_H1_shapes.yaml +++ b/data/HL-31_H1_shapes.yaml @@ -9,7 +9,7 @@ z: - 108 name: HL-31_H1 cutwidth: 0.22 -axi: ! +modelaxi: ! name: "HL-31.d" pitch: - 29.59376923780156 @@ -54,7 +54,7 @@ axi: ! - 0.3573277722202946 - 0.2873803014774448 - 0.2923250885741825 -m3d: ! +model3d: ! cad: "HL-31-202MC" with_shapes: true with_channels: false diff --git a/data/HL-31_H2_shapes.yaml b/data/HL-31_H2_shapes.yaml index 199ee2e..96d3cb3 100644 --- a/data/HL-31_H2_shapes.yaml +++ b/data/HL-31_H2_shapes.yaml @@ -9,7 +9,7 @@ z: - -108 - 108 cutwidth: 0.22 -axi: ! +modelaxi: ! name: "HL-31.d" h: 91.7 pitch: @@ -54,7 +54,7 @@ axi: ! - 0.418437672341563 - 0.3431296695896973 - 0.2823535494503169 -m3d: ! +model3d: ! cad: "HL-31-204MC" with_shapes: true with_channels: false diff --git a/data/HL-31_H3_shapes.yaml b/data/HL-31_H3_shapes.yaml index bacdca4..9506478 100644 --- a/data/HL-31_H3_shapes.yaml +++ b/data/HL-31_H3_shapes.yaml @@ -9,7 +9,7 @@ z: - 125.0 dble: true cutwidth: 0.22 -axi: ! +modelaxi: ! name: "HL-31.d" h: 95 turns: @@ -54,7 +54,7 @@ axi: ! - 19.58177777239263 - 23.29705899795419 - 25.53105659056708 -m3d: ! +model3d: ! cad: "HL-31-206MC" with_shapes: true with_channels: false diff --git a/data/HL-31_H4_shapes.yaml b/data/HL-31_H4_shapes.yaml index 9f563e7..8919f9b 100644 --- a/data/HL-31_H4_shapes.yaml +++ b/data/HL-31_H4_shapes.yaml @@ -8,7 +8,7 @@ z: - -125.0 - 125.0 name: HL-31_H4 -axi: ! +modelaxi: ! name: "HL-31.d" h: 109 pitch: @@ -54,7 +54,7 @@ axi: ! - 0.4370008715377303 - 0.3536370722895749 cutwidth: 0.26 -m3d: ! +model3d: ! cad: "HL-31-008MC" with_shapes: true with_channels: false diff --git a/data/HL-31_H5_shapes.yaml b/data/HL-31_H5_shapes.yaml index 3e30076..35c3d15 100644 --- a/data/HL-31_H5_shapes.yaml +++ b/data/HL-31_H5_shapes.yaml @@ -5,7 +5,7 @@ r: - 47.2 - 55.3 name: HL-31_H5 -axi: ! +modelaxi: ! name: "HL-31.d" turns: - 0.4602599952964374 @@ -51,7 +51,7 @@ axi: ! - 21.18181368853585 - 22.99306298207053 cutwidth: 0.26 -m3d: ! +model3d: ! cad: "HL-31-010MC" with_shapes: true with_channels: false diff --git a/data/HL-31_H6_shapes.yaml b/data/HL-31_H6_shapes.yaml index 540150b..6db81a7 100644 --- a/data/HL-31_H6_shapes.yaml +++ b/data/HL-31_H6_shapes.yaml @@ -7,7 +7,7 @@ shape: ! length: 6 profile: "HL-31-993" onturns: 0 -m3d: ! +model3d: ! with_channels: false cad: "HL-31-012MC" with_shapes: true @@ -15,7 +15,7 @@ dble: true r: - 56.2 - 65.6 -axi: ! +modelaxi: ! name: "HL-31.d" h: 115.1071 pitch: From bff1c06afe6910d878b36e614ecff4ebc103ffcc Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Fri, 20 Mar 2026 17:39:12 +0100 Subject: [PATCH 06/25] up --- BREAKING_CHANGES.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index e7c5aeb..58d7f56 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -150,17 +150,38 @@ name: "HL-31_H1" axi: name: "HL-31.d" h: 86.51 + ... +m3d: + ... +shape: ``` **New Format:** ```yaml ! name: "HL-31_H1" -axi: ! +modelaxi: ! name: "HL-31.d" h: 86.51 +model3d: ! + ... +shape: ! + ... + ``` +#### Complete Field Name Migration History + +| Field (v0.5.x) | Field (v0.7.0) | Field (v1.0.0) | Status in v1.0.0 | +|----------------|----------------|----------------|------------------| +| `axi` | `modelaxi` | `modelaxi` | ✓ Required | +| `m3d` | `model3d` | `model3d` | ✓ Required | +| `shape` | `shape` | `shape` | ✓ Required | + +**Migration Notes:** +- **v0.5.x → v0.7.0**: Rename field names +- **v0.7.0 → v1.0.0**: Use nested objects with explicit type annotations + **Breaking Changes:** - Nested objects require explicit type annotations - All nested structures must follow new format From 44d4394435cbf7f8de8da4e6165b6dfdab39e718 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Tue, 24 Mar 2026 08:21:07 +0100 Subject: [PATCH 07/25] up examples --- examples/README.md | 109 ++++++++++++ examples/check_magnetgeo_yaml.py | 111 ++++++++++++ examples/find_cadref_in_yaml.py | 162 ++++++++++++++++++ .../helix_visualization.py | 0 .../insert_visualization.py | 0 .../visualization.py | 0 python_magnetgeo/examples/test-yamlload.py | 27 +++ 7 files changed, 409 insertions(+) create mode 100644 examples/README.md create mode 100755 examples/check_magnetgeo_yaml.py create mode 100644 examples/find_cadref_in_yaml.py rename example_helix_visualization.py => examples/helix_visualization.py (100%) rename example_insert_visualization.py => examples/insert_visualization.py (100%) rename example_visualization.py => examples/visualization.py (100%) create mode 100644 python_magnetgeo/examples/test-yamlload.py diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..223d414 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,109 @@ +# python_magnetgeo examples + +This directory contains standalone scripts demonstrating various features of `python_magnetgeo`. + +## Requirements + +```bash +pip install python_magnetgeo +pip install matplotlib # for visualization scripts +pip install rich # for find_cadref_in_yaml.py +``` + +--- + +## Scripts + +### `check_magnetgeo_yaml.py` + +Load and validate one or more `python_magnetgeo` YAML configuration files. + +```bash +python check_magnetgeo_yaml.py data/HL-31_H1.yaml +python check_magnetgeo_yaml.py data/*.yaml +``` + +Prints the loaded object type and its string representation. Exits with a non-zero status if any file fails to load. + +--- + +### `find_cadref_in_yaml.py` + +Search a directory of YAML config files for Part definitions whose CAD reference matches a given value. +Results are displayed as a styled table (via `rich`) and saved to a CSV file. + +```bash +# List all CAD references found in a directory +python find_cadref_in_yaml.py --yaml_dir /path/to/configs + +# Search for a specific CAD reference (substring match) +python find_cadref_in_yaml.py --yaml_dir /path/to/configs --cad_ref "HL-31-xxx.brep" + +# Restrict to a specific part type +python find_cadref_in_yaml.py --yaml_dir /path/to/configs --type helix + +# Restrict to a specific CAD field +python find_cadref_in_yaml.py --yaml_dir /path/to/configs --field model3d.cad + +# Walk subdirectories and save results to a custom CSV file +python find_cadref_in_yaml.py --yaml_dir /path/to/configs --recursive --output results.csv +``` + +**Options:** + +| Flag | Default | Description | +|------|---------|-------------| +| `--yaml_dir` | *(required)* | Directory containing YAML config files | +| `--cad_ref` | *(none — list all)* | CAD reference string to search for (substring match) | +| `--type` | `all` | Restrict to part type: `helix`, `ring`, `bitter`, `screen`, `lead`, `supra` | +| `--field` | *(all fields)* | Restrict to a specific attribute path (e.g. `model3d.cad`) | +| `--recursive` | off | Walk subdirectories | +| `--output` | `cad_refs.csv` | Output CSV file path | + +**Output:** + +- Terminal: a `rich` table with columns **File**, **Name**, **Part Type**, **CAD Value** +- File: a CSV with the same four columns for later use + +--- + +### `visualization.py` + +Demonstrates axisymmetric visualization of `Ring` and `Screen` objects using `matplotlib`. + +```bash +python visualization.py +``` + +Generates: +- `example_ring.png` — single Ring cross-section +- `example_screen.png` — single Screen cross-section +- `example_combined.png` — Ring + Screen overlay + +--- + +### `helix_visualization.py` + +Demonstrates axisymmetric visualization of a `Helix` with its `ModelAxi` zone, plus a full magnet assembly. + +```bash +python helix_visualization.py +``` + +Generates: +- `example_helix_with_modelaxi.png` — Helix with modelaxi zone highlighted +- `example_helix_without_modelaxi.png` — Helix body only +- `example_complete_assembly.png` — Helix + Ring + Screens combined + +--- + +### `insert_visualization.py` + +Demonstrates axisymmetric visualization of a multi-helix `Insert` assembly. + +```bash +python insert_visualization.py +``` + +Generates: +- `example_insert.png` — three concentric helices with their modelaxi zones diff --git a/examples/check_magnetgeo_yaml.py b/examples/check_magnetgeo_yaml.py new file mode 100755 index 0000000..f668945 --- /dev/null +++ b/examples/check_magnetgeo_yaml.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +""" +Script to split an Helix YAML file into separate files for modelaxi and shape objects. + +This script: +1. Loads an Helix YAML file +2. Writes separate YAML files for: + - Helix.modelaxi object (saved as _modelaxi.yaml) + - Helix.shape object (saved as _shape.yaml) +3. Creates a new Helix YAML file where: + - modelaxi is the name of the corresponding modelaxi yaml file without extension + - shape is the name of the corresponding shape yaml file without extension + +Usage: + python split_helix_yaml.py + +Example: + python split_helix_yaml.py data/HL-31_H1.yaml +""" + +import sys +import yaml +import os +import glob +import argparse + +import python_magnetgeo as pmg +pmg.verify_class_registration() # Required for YAML loading + + +from python_magnetgeo.logging_config import get_logger + +# Get logger for this module +logger = get_logger(__name__) + +def check_yaml(input_file): + """ + Load magnetgeo YAML file. + + Args: + input_file: Path to the input YAML file + + Returns: + """ + # Split input_file into basedir and basename + basedir = os.path.dirname(input_file) + basename = os.path.basename(input_file) + + # Change to basedir if it's not empty and not '.' + if basedir and basedir != '.': + print(f"Changing directory to: {basedir}") + os.chdir(basedir) + input_path = basename + else: + input_path = input_file + + print(f"Loading: {input_path}") + + # Load the object using getObject from utils + object = pmg.load(input_path) + logger.debug(object) + + print(f"Loaded: {type(object)}") + print(f"Object: {object}") + + +def main(): + """Main function to handle command line arguments.""" + parser = argparse.ArgumentParser( + description='Check an YAML file.', + epilog='Example: %(prog)s data/HL-31_H1.yaml data/*.yaml' + ) + parser.add_argument( + 'input_files', + nargs='+', + help='Path(s) to input YAML file(s); glob patterns (e.g. "data/*.yaml") are supported' + ) + + args = parser.parse_args() + + # Expand glob patterns and collect all matching files + files = [] + for pattern in args.input_files: + matched = glob.glob(pattern) + if matched: + files.extend(matched) + else: + print(f"Warning: no files matched: {pattern}", file=sys.stderr) + + if not files: + print("Error: no input files found.", file=sys.stderr) + sys.exit(1) + + errors = 0 + for input_file in files: + try: + check_yaml(input_file) + except Exception as e: + print(f"Error processing {input_file}: {e}") + import traceback + traceback.print_exc() + errors += 1 + + if errors: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/find_cadref_in_yaml.py b/examples/find_cadref_in_yaml.py new file mode 100644 index 0000000..e6f6781 --- /dev/null +++ b/examples/find_cadref_in_yaml.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +find_helix_by_cad.py + +Search a directory of python_magnetgeo YAML config files for Helix (or other Part) +definitions whose CAD reference matches a given value. + +Usage: + python find_helix_by_cad.py [cad_reference] + python find_helix_by_cad.py /data/magnetgeo/configs "HL-31-xxx.brep" + python find_helix_by_cad.py /data/magnetgeo/configs # list all CAD refs + +Optional flags: + --type helix|ring|bitter|all restrict search to a Part type (default: all) + --field cad|model3d|modelaxi restrict which CAD field to inspect (default: all) + --recursive walk subdirectories +""" + +import argparse +import csv +import sys +from pathlib import Path + +from rich.console import Console +from rich.table import Table + +import python_magnetgeo as pmg +pmg.verify_class_registration() # Required for YAML loading + + +# Map of Part type → where to look for a CAD reference in the object attributes. +# Each entry is a list of dotted attribute paths tried in order. +CAD_FIELD_MAP = { + "helix": ["model3d.cad", "modelaxi.cad", "cad"], + "ring": ["cad"], + "bitter": ["model3d.cad", "modelaxi.cad", "cad"], + "screen": ["cad"], + "lead": ["cad"], + "supra": ["cad"], +} + +CLASSNAME_TO_TYPE = { + "Helix": "helix", + "Ring": "ring", + "Bitter": "bitter", + "BitterMagnet": "bitter", + "Screen": "screen", + "InnerCurrentLead": "lead", + "CurrentLead": "lead", + "Supra": "supra", +} + + +def get_nested_attr(obj, dotted_path: str): + """Traverse nested object attributes with a dotted path. Returns None if missing.""" + node = obj + for part in dotted_path.split("."): + if node is None: + return None + node = getattr(node, part, None) + return node + + +def extract_cad_refs(obj, part_type: str) -> dict[str, str]: + """Return {field_path: value} for all CAD fields found in obj for the given part_type.""" + found = {} + for path in CAD_FIELD_MAP.get(part_type, ["cad"]): + val = get_nested_attr(obj, path) + if val: + found[path] = val + return found + + +def detect_part_type(obj) -> str | None: + """Infer Part type from object class name.""" + return CLASSNAME_TO_TYPE.get(type(obj).__name__) + + +def search(yaml_dir: Path, cad_ref: str | None, type_filter: str, recursive: bool) -> list[dict]: + pattern = "**/*.yaml" if recursive else "*.yaml" + matches = [] + + for yaml_file in sorted(yaml_dir.glob(pattern)): + try: + obj = pmg.load(str(yaml_file.resolve())) + except Exception as e: + print(f" [warn] could not load {yaml_file}: {e}", file=sys.stderr) + continue + + part_type = detect_part_type(obj) + if part_type is None: + continue + + if type_filter != "all" and part_type != type_filter: + continue + + cad_refs = extract_cad_refs(obj, part_type) + for field_path, value in cad_refs.items(): + # If no cad_ref given, collect all; otherwise support substring match + if cad_ref is None or cad_ref in value or value in cad_ref: + matches.append({ + "file": yaml_file, + "part_type": part_type, + "field": field_path, + "cad_value": value, + "name": getattr(obj, "name", ""), + }) + + return matches + + +def main(): + parser = argparse.ArgumentParser(description="Find YAML configs by CAD reference") + parser.add_argument("--yaml_dir", help="Directory containing YAML config files") + parser.add_argument("--cad_ref", nargs="?", default=None, + help="CAD reference string to look for (omit to list all)") + parser.add_argument("--type", default="all", + choices=["helix", "ring", "bitter", "screen", "lead", "supra", "all"], + help="Restrict search to a specific Part type") + parser.add_argument("--field", default=None, + help="Restrict to a specific field path (e.g. model3d.cad)") + parser.add_argument("--recursive", action="store_true", + help="Walk subdirectories") + parser.add_argument("--output", default="cad_refs.csv", + help="Output CSV file (default: cad_refs.csv)") + args = parser.parse_args() + + yaml_dir = Path(args.yaml_dir) + if not yaml_dir.is_dir(): + print(f"Error: {yaml_dir} is not a directory", file=sys.stderr) + sys.exit(1) + + results = search(yaml_dir, args.cad_ref, args.type, args.recursive) + + # Optional: filter by specific field + if args.field: + results = [r for r in results if r["field"] == args.field] + + if not results: + msg = f"No matches found for CAD ref: {args.cad_ref!r}" if args.cad_ref else "No CAD refs found." + print(msg) + sys.exit(0) + + title = f"Found {len(results)} match(es) for {args.cad_ref!r}" if args.cad_ref else f"Found {len(results)} CAD ref(s)" + table = Table(title=title, show_lines=True) + table.add_column("File", style="cyan", no_wrap=False) + table.add_column("Name", style="green") + table.add_column("Part Type", style="magenta") + table.add_column("CAD Value", style="yellow") + for r in results: + table.add_row(str(r["file"]), r["name"], r["part_type"], r["cad_value"]) + Console().print(table) + + csv_path = Path(args.output) + with csv_path.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["file", "name", "part_type", "cad_value"]) + writer.writeheader() + writer.writerows({"file": r["file"], "name": r["name"], "part_type": r["part_type"], "cad_value": r["cad_value"]} for r in results) + print(f"Results saved to {csv_path}") + +if __name__ == "__main__": + main() diff --git a/example_helix_visualization.py b/examples/helix_visualization.py similarity index 100% rename from example_helix_visualization.py rename to examples/helix_visualization.py diff --git a/example_insert_visualization.py b/examples/insert_visualization.py similarity index 100% rename from example_insert_visualization.py rename to examples/insert_visualization.py diff --git a/example_visualization.py b/examples/visualization.py similarity index 100% rename from example_visualization.py rename to examples/visualization.py diff --git a/python_magnetgeo/examples/test-yamlload.py b/python_magnetgeo/examples/test-yamlload.py new file mode 100644 index 0000000..c06dff0 --- /dev/null +++ b/python_magnetgeo/examples/test-yamlload.py @@ -0,0 +1,27 @@ + +""" +""" +from python_magnetgeo.utils import getObject + + + +Object = getObject("HL-31-H1H2.yaml") +print(f"Object={Object}, type={type(Object)}") +Object = getObject("M9Bitters.yaml") +print(f"Object={Object}, type={type(Object)}") +Object = getObject("Oxford.yaml") +print(f"Object={Object}, type={type(Object)}") +Object = getObject("Nougat.yaml") +print(f"Object={Object}, type={type(Object)}") + +Object = getObject("M9_HL-31-H1.yaml") +print(f"Object={Object}, type={type(Object)}") +""" +Object = getObject("msite.yaml") +print(f"Object={Object}, type={type(Object)}") +""" + +Object.name = "newtest" +Object.write_to_yaml() +Obect = getObject("newtest.yaml") +print(f"nObject={Object}, type={type(Object)}") From 89efd27d157fc6b24d3f53575a3e7383ccbe0228 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Tue, 24 Mar 2026 08:21:52 +0100 Subject: [PATCH 08/25] up version 1.0.1 --- archive.sh | 2 +- debian/changelog | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/archive.sh b/archive.sh index c1e02e6..d2a3b3f 100755 --- a/archive.sh +++ b/archive.sh @@ -33,7 +33,7 @@ done shift $((OPTIND - 1)) # add parameters -: ${VERSION:="1.0.0"} +: ${VERSION:="1.0.1"} : ${DIST:="trixie"} # cleanup source diff --git a/debian/changelog b/debian/changelog index 50d0495..0baddb6 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -python-magnetgeo (1.0.0-7) UNRELEASED; urgency=medium +python-magnetgeo (1.0.1-7) UNRELEASED; urgency=medium * add logging support * fix helper to create Profile from Shape_profile.dat diff --git a/pyproject.toml b/pyproject.toml index debc033..6bfdc7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-magnetgeo" -version = "1.0.0" +version = "1.0.1" description = "Python helpers to create HiFiMagnet cads and meshes" readme = "README.rst" requires-python = ">=3.11" From 83d6a37c0dc5d31f7ac79113d76e32be6b097b7c Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Wed, 25 Mar 2026 09:37:00 +0100 Subject: [PATCH 09/25] add normalize_cad_ref to strip cad_ref id --- examples/find_cadref_in_yaml.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/examples/find_cadref_in_yaml.py b/examples/find_cadref_in_yaml.py index e6f6781..7826d48 100644 --- a/examples/find_cadref_in_yaml.py +++ b/examples/find_cadref_in_yaml.py @@ -18,6 +18,7 @@ import argparse import csv +import re import sys from pathlib import Path @@ -51,6 +52,23 @@ } +_BASE_CAD_RE = re.compile(r'^([A-Z]+-\d+-\d+)', re.IGNORECASE) + +def normalize_cad_ref(value: str) -> str: + """Strip suffix from a CAD reference, returning only the base (e.g. 'HL-34-020'). + + Examples: + 'HL-34-020-A' -> 'HL-34-020' + 'HL-34-020A' -> 'HL-34-020' + 'HL-34-020MC' -> 'HL-34-020' + 'HL-34-020.brep'-> 'HL-34-020' + """ + # Strip file extension first + stem = Path(value).stem + m = _BASE_CAD_RE.match(stem) + return m.group(1).upper() if m else stem + + def get_nested_attr(obj, dotted_path: str): """Traverse nested object attributes with a dotted path. Returns None if missing.""" node = obj @@ -62,12 +80,12 @@ def get_nested_attr(obj, dotted_path: str): def extract_cad_refs(obj, part_type: str) -> dict[str, str]: - """Return {field_path: value} for all CAD fields found in obj for the given part_type.""" + """Return {field_path: normalized_base} for all CAD fields found in obj for the given part_type.""" found = {} for path in CAD_FIELD_MAP.get(part_type, ["cad"]): val = get_nested_attr(obj, path) if val: - found[path] = val + found[path] = normalize_cad_ref(val) return found @@ -80,7 +98,11 @@ def search(yaml_dir: Path, cad_ref: str | None, type_filter: str, recursive: boo pattern = "**/*.yaml" if recursive else "*.yaml" matches = [] + normalized_query = normalize_cad_ref(cad_ref) if cad_ref else None + for yaml_file in sorted(yaml_dir.glob(pattern)): + if "tmp" in yaml_file.parts: + continue try: obj = pmg.load(str(yaml_file.resolve())) except Exception as e: @@ -96,8 +118,8 @@ def search(yaml_dir: Path, cad_ref: str | None, type_filter: str, recursive: boo cad_refs = extract_cad_refs(obj, part_type) for field_path, value in cad_refs.items(): - # If no cad_ref given, collect all; otherwise support substring match - if cad_ref is None or cad_ref in value or value in cad_ref: + # value is already normalized; compare against normalized query + if normalized_query is None or normalized_query == value: matches.append({ "file": yaml_file, "part_type": part_type, From 4a21d334ee95b30dd64cb32f9080f2b57c9270c4 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Wed, 25 Mar 2026 14:18:33 +0100 Subject: [PATCH 10/25] up examples --- examples/README.md | 4 + examples/check_magnetgeo_yaml.py | 10 +-- examples/find_cadref_in_yaml.py | 128 ++++++++++++++++++++++--------- 3 files changed, 101 insertions(+), 41 deletions(-) diff --git a/examples/README.md b/examples/README.md index 223d414..bedf234 100644 --- a/examples/README.md +++ b/examples/README.md @@ -47,6 +47,9 @@ python find_cadref_in_yaml.py --yaml_dir /path/to/configs --field model3d.cad # Walk subdirectories and save results to a custom CSV file python find_cadref_in_yaml.py --yaml_dir /path/to/configs --recursive --output results.csv + +# Exclude specific directories from the search +python find_cadref_in_yaml.py --yaml_dir /path/to/configs --recursive --exclude-dirs archive deprecated old ``` **Options:** @@ -58,6 +61,7 @@ python find_cadref_in_yaml.py --yaml_dir /path/to/configs --recursive --output r | `--type` | `all` | Restrict to part type: `helix`, `ring`, `bitter`, `screen`, `lead`, `supra` | | `--field` | *(all fields)* | Restrict to a specific attribute path (e.g. `model3d.cad`) | | `--recursive` | off | Walk subdirectories | +| `--exclude-dirs` | *(none)* | Directory names to skip (e.g. `--exclude-dirs archive deprecated`) | | `--output` | `cad_refs.csv` | Output CSV file path | **Output:** diff --git a/examples/check_magnetgeo_yaml.py b/examples/check_magnetgeo_yaml.py index f668945..4e54059 100755 --- a/examples/check_magnetgeo_yaml.py +++ b/examples/check_magnetgeo_yaml.py @@ -50,20 +50,20 @@ def check_yaml(input_file): # Change to basedir if it's not empty and not '.' if basedir and basedir != '.': - print(f"Changing directory to: {basedir}") + logger.debug(f"Changing directory to: {basedir}") os.chdir(basedir) input_path = basename else: input_path = input_file - print(f"Loading: {input_path}") + logger.debug(f"Loading: {input_path}") # Load the object using getObject from utils object = pmg.load(input_path) logger.debug(object) - print(f"Loaded: {type(object)}") - print(f"Object: {object}") + # print(f"Loaded: {type(object)}") + # print(f"Object: {object}") def main(): @@ -98,7 +98,7 @@ def main(): try: check_yaml(input_file) except Exception as e: - print(f"Error processing {input_file}: {e}") + logger.error(f"Error processing {input_file}: {e}") import traceback traceback.print_exc() errors += 1 diff --git a/examples/find_cadref_in_yaml.py b/examples/find_cadref_in_yaml.py index 7826d48..c8cbce7 100644 --- a/examples/find_cadref_in_yaml.py +++ b/examples/find_cadref_in_yaml.py @@ -26,33 +26,35 @@ from rich.table import Table import python_magnetgeo as pmg + pmg.verify_class_registration() # Required for YAML loading # Map of Part type → where to look for a CAD reference in the object attributes. # Each entry is a list of dotted attribute paths tried in order. CAD_FIELD_MAP = { - "helix": ["model3d.cad", "modelaxi.cad", "cad"], - "ring": ["cad"], + "helix": ["model3d.cad", "modelaxi.cad", "cad"], + "ring": ["cad"], "bitter": ["model3d.cad", "modelaxi.cad", "cad"], "screen": ["cad"], - "lead": ["cad"], - "supra": ["cad"], + "lead": ["cad"], + "supra": ["cad"], } CLASSNAME_TO_TYPE = { - "Helix": "helix", - "Ring": "ring", - "Bitter": "bitter", - "BitterMagnet": "bitter", - "Screen": "screen", + "Helix": "helix", + "Ring": "ring", + "Bitter": "bitter", + "BitterMagnet": "bitter", + "Screen": "screen", "InnerCurrentLead": "lead", - "CurrentLead": "lead", - "Supra": "supra", + "CurrentLead": "lead", + "Supra": "supra", } -_BASE_CAD_RE = re.compile(r'^([A-Z]+-\d+-\d+)', re.IGNORECASE) +_BASE_CAD_RE = re.compile(r"^([A-Z]+-\d+-\d+)", re.IGNORECASE) + def normalize_cad_ref(value: str) -> str: """Strip suffix from a CAD reference, returning only the base (e.g. 'HL-34-020'). @@ -94,15 +96,27 @@ def detect_part_type(obj) -> str | None: return CLASSNAME_TO_TYPE.get(type(obj).__name__) -def search(yaml_dir: Path, cad_ref: str | None, type_filter: str, recursive: bool) -> list[dict]: +def search( + yaml_dir: Path, + cad_ref: str | None, + type_filter: str, + recursive: bool, + exclude_dirs: list[str] | None = None, +) -> list[dict]: pattern = "**/*.yaml" if recursive else "*.yaml" matches = [] + excluded = set(exclude_dirs) if exclude_dirs else set() normalized_query = normalize_cad_ref(cad_ref) if cad_ref else None for yaml_file in sorted(yaml_dir.glob(pattern)): if "tmp" in yaml_file.parts: continue + if excluded and any(part in excluded for part in yaml_file.parts): + continue + if "meshdata" in yaml_file.stem: + print(f" [skip] skipping mesdata file: {yaml_file}") + continue try: obj = pmg.load(str(yaml_file.resolve())) except Exception as e: @@ -120,31 +134,56 @@ def search(yaml_dir: Path, cad_ref: str | None, type_filter: str, recursive: boo for field_path, value in cad_refs.items(): # value is already normalized; compare against normalized query if normalized_query is None or normalized_query == value: - matches.append({ - "file": yaml_file, - "part_type": part_type, - "field": field_path, - "cad_value": value, - "name": getattr(obj, "name", ""), - }) + existing = next((m for m in matches if m["cad_value"] == value), None) + if existing: + print( + f" [warn] duplicate CAD ref {value!r}: " + f"{yaml_file} (already seen in {existing['file']})", + flush=True, + ) + else: + matches.append( + { + "file": yaml_file, + "part_type": part_type, + "field": field_path, + "cad_value": value, + "name": getattr(obj, "name", ""), + } + ) return matches def main(): parser = argparse.ArgumentParser(description="Find YAML configs by CAD reference") - parser.add_argument("--yaml_dir", help="Directory containing YAML config files") - parser.add_argument("--cad_ref", nargs="?", default=None, - help="CAD reference string to look for (omit to list all)") - parser.add_argument("--type", default="all", - choices=["helix", "ring", "bitter", "screen", "lead", "supra", "all"], - help="Restrict search to a specific Part type") - parser.add_argument("--field", default=None, - help="Restrict to a specific field path (e.g. model3d.cad)") - parser.add_argument("--recursive", action="store_true", - help="Walk subdirectories") - parser.add_argument("--output", default="cad_refs.csv", - help="Output CSV file (default: cad_refs.csv)") + parser.add_argument("--yaml_dir", help="Directory containing YAML config files") + parser.add_argument( + "--cad_ref", + nargs="?", + default=None, + help="CAD reference string to look for (omit to list all)", + ) + parser.add_argument( + "--type", + default="all", + choices=["helix", "ring", "bitter", "screen", "lead", "supra", "all"], + help="Restrict search to a specific Part type", + ) + parser.add_argument( + "--field", default=None, help="Restrict to a specific field path (e.g. model3d.cad)" + ) + parser.add_argument("--recursive", action="store_true", help="Walk subdirectories") + parser.add_argument( + "--exclude-dirs", + nargs="+", + default=None, + metavar="DIR", + help="Directory names to exclude from search (e.g. --exclude-dirs archive deprecated)", + ) + parser.add_argument( + "--output", default="cad_refs.csv", help="Output CSV file (default: cad_refs.csv)" + ) args = parser.parse_args() yaml_dir = Path(args.yaml_dir) @@ -152,18 +191,26 @@ def main(): print(f"Error: {yaml_dir} is not a directory", file=sys.stderr) sys.exit(1) - results = search(yaml_dir, args.cad_ref, args.type, args.recursive) + results = search(yaml_dir, args.cad_ref, args.type, args.recursive, args.exclude_dirs) # Optional: filter by specific field if args.field: results = [r for r in results if r["field"] == args.field] if not results: - msg = f"No matches found for CAD ref: {args.cad_ref!r}" if args.cad_ref else "No CAD refs found." + msg = ( + f"No matches found for CAD ref: {args.cad_ref!r}" + if args.cad_ref + else "No CAD refs found." + ) print(msg) sys.exit(0) - title = f"Found {len(results)} match(es) for {args.cad_ref!r}" if args.cad_ref else f"Found {len(results)} CAD ref(s)" + title = ( + f"Found {len(results)} match(es) for {args.cad_ref!r}" + if args.cad_ref + else f"Found {len(results)} CAD ref(s)" + ) table = Table(title=title, show_lines=True) table.add_column("File", style="cyan", no_wrap=False) table.add_column("Name", style="green") @@ -177,8 +224,17 @@ def main(): with csv_path.open("w", newline="") as f: writer = csv.DictWriter(f, fieldnames=["file", "name", "part_type", "cad_value"]) writer.writeheader() - writer.writerows({"file": r["file"], "name": r["name"], "part_type": r["part_type"], "cad_value": r["cad_value"]} for r in results) + writer.writerows( + { + "file": r["file"], + "name": r["name"], + "part_type": r["part_type"], + "cad_value": r["cad_value"], + } + for r in results + ) print(f"Results saved to {csv_path}") + if __name__ == "__main__": main() From 2dd3c8284918145c37d3ffc3f4baf0177a55104e Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Wed, 25 Mar 2026 15:42:13 +0100 Subject: [PATCH 11/25] fix broken script --- start-venv.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/start-venv.sh b/start-venv.sh index 65d8cbb..d4be59b 100755 --- a/start-venv.sh +++ b/start-venv.sh @@ -13,10 +13,10 @@ if [ ! -d $VENVDIR ]; then python -m venv --system-site-packages $VENVDIR else python -m venv $VENVDIR - pip install black fi . $VENVDIR/bin/activate - python -m pip install -e + pip install black + python -m pip install -e ".[dev]" deactivate fi From 36b72753182d462944b8a4771b2bc32f7f905612 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Wed, 25 Mar 2026 15:42:53 +0100 Subject: [PATCH 12/25] Add json to yaml helper script --- examples/README.md | 23 +++++++ examples/json_to_yaml.py | 128 ++++++++++++++++++++++++++++++++++++ python_magnetgeo/Model3D.py | 8 ++- 3 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 examples/json_to_yaml.py diff --git a/examples/README.md b/examples/README.md index bedf234..98e1c76 100644 --- a/examples/README.md +++ b/examples/README.md @@ -27,6 +27,24 @@ Prints the loaded object type and its string representation. Exits with a non-ze --- +### `json_to_yaml.py` + +Load one or more `python_magnetgeo` objects from JSON files and write them as YAML files. + +```bash +python json_to_yaml.py helix.json +python json_to_yaml.py data/*.json +python json_to_yaml.py file1.json file2.json file3.json +``` + +For each input file, the script deserializes the object and writes `.yaml` alongside it. +Glob patterns are supported. Exits with a non-zero status if any file fails to convert. + +> **Note:** The JSON file must contain a `__classname__` field (e.g. `"__classname__": "Helix"`) +> to identify the object type. This field is produced automatically by `obj.write_to_json()`. + +--- + ### `find_cadref_in_yaml.py` Search a directory of YAML config files for Part definitions whose CAD reference matches a given value. @@ -52,6 +70,11 @@ python find_cadref_in_yaml.py --yaml_dir /path/to/configs --recursive --output r python find_cadref_in_yaml.py --yaml_dir /path/to/configs --recursive --exclude-dirs archive deprecated old ``` +> A practical use case is to search the `hifimagnet-projects/` directory for all CAD references, excluding certain subdirectories: +> ```bash +> python find_cadref_in_yaml.py --yaml_dir hifimagnet-projects/ --recursive --exclude-dirs BancMesure H1H4 H1H8 +> ``` + **Options:** | Flag | Default | Description | diff --git a/examples/json_to_yaml.py b/examples/json_to_yaml.py new file mode 100644 index 0000000..1b45ca5 --- /dev/null +++ b/examples/json_to_yaml.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +""" +Script to load a python_magnetgeo object from a JSON file and dump it to a YAML file. + +The JSON file must contain a '__classname__' field to identify the object type. + +Usage: + python json_to_yaml.py file1.json [file2.json ...] + +Example: + python json_to_yaml.py data/helix.json + python json_to_yaml.py data/*.json +""" + +import sys +import json +import os +import glob +import argparse + +import python_magnetgeo as pmg + +pmg.verify_class_registration() # Required for YAML loading + +from python_magnetgeo.logging_config import get_logger + +logger = get_logger(__name__) + + +def json_to_yaml(input_file: str) -> str: + """ + Load a python_magnetgeo object from a JSON file and write it to a YAML file. + + Args: + input_file: Path to the input JSON file (must contain '__classname__' field) + + Returns: + Path to the output YAML file + + Raises: + ValueError: If the JSON file does not contain a '__classname__' field + Exception: If loading or writing fails + """ + from python_magnetgeo.deserialize import unserialize_object + + basedir = os.path.dirname(os.path.abspath(input_file)) + basename = os.path.basename(input_file) + + cwd = os.getcwd() + try: + if basedir and basedir != cwd: + logger.debug(f"Changing directory to: {basedir}") + os.chdir(basedir) + + logger.debug(f"Loading JSON: {basename}") + with open(basename, "r") as f: + data = json.load(f) + print( + f"Loaded JSON data:\n{json.dumps(data, indent=2)}" + ) # Debug print to verify content + + if "__classname__" not in data: + raise ValueError( + f"JSON file '{input_file}' does not contain a '__classname__' field. " + "This field is required to identify the python_magnetgeo object type." + ) + + obj = unserialize_object(data) + logger.debug(f"Loaded object of type: {type(obj).__name__}") + + name = getattr(obj, "name", None) or os.path.splitext(basename)[0] + output_file = os.path.join(basedir, f"{name}.yaml") + + obj.write_to_yaml(directory=basedir) + logger.info(f"Written YAML: {output_file}") + return output_file + + finally: + os.chdir(cwd) + + +def main(): + """Main function to handle command line arguments.""" + parser = argparse.ArgumentParser( + description="Load python_magnetgeo object(s) from JSON file(s) and dump to YAML.", + epilog="Example: %(prog)s helix.json data/*.json", + ) + parser.add_argument( + "input_files", + nargs="+", + help="Path(s) to input JSON file(s); glob patterns (e.g. 'data/*.json') are supported", + ) + + args = parser.parse_args() + + # Expand glob patterns and collect all matching files + files = [] + for pattern in args.input_files: + matched = glob.glob(pattern) + if matched: + files.extend(matched) + else: + print(f"Warning: no files matched: {pattern}", file=sys.stderr) + + if not files: + print("Error: no input files found.", file=sys.stderr) + sys.exit(1) + + errors = 0 + for input_file in files: + try: + output = json_to_yaml(input_file) + print(f"{input_file} -> {output}") + except Exception as e: + logger.error(f"Error processing {input_file}: {e}") + import traceback + + traceback.print_exc() + errors += 1 + + if errors: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/python_magnetgeo/Model3D.py b/python_magnetgeo/Model3D.py index 26171c3..166234d 100755 --- a/python_magnetgeo/Model3D.py +++ b/python_magnetgeo/Model3D.py @@ -23,7 +23,7 @@ class Model3D(YAMLObjectBase): yaml_tag = "Model3D" def __init__( - self, name: str, cad: str, with_shapes: bool = False, with_channels: bool = False + self, name: str = "", cad: str = "", with_shapes: bool = False, with_channels: bool = False ) -> None: """ Initialize a 3D CAD model configuration. @@ -92,7 +92,11 @@ def __repr__(self): Model3D(name='helix_cad', cad='SALOME', with_shapes=True, with_channels=False) """ - return f"{self.__class__.__name__}(name={self.name!r}, cad={self.cad!r}, with_shapes={self.with_shapes!r}, with_channels={self.with_channels!r})" + name = getattr(self, "name", None) + cad = getattr(self, "cad", None) + with_shapes = getattr(self, "with_shapes", False) + with_channels = getattr(self, "with_channels", False) + return f"{self.__class__.__name__}(name={name!r}, cad={cad!r}, with_shapes={with_shapes!r}, with_channels={with_channels!r})" @classmethod def from_dict(cls, values: dict, debug: bool = False): From 2eba583813437a2d50065d21d12a074e9ffdb399 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Wed, 25 Mar 2026 15:43:50 +0100 Subject: [PATCH 13/25] up d/changelog --- debian/changelog | 2 ++ 1 file changed, 2 insertions(+) diff --git a/debian/changelog b/debian/changelog index 0baddb6..f76219d 100644 --- a/debian/changelog +++ b/debian/changelog @@ -5,6 +5,8 @@ python-magnetgeo (1.0.1-7) UNRELEASED; urgency=medium * add helper to prepare helix YAML files for magnetdb * add helper to check yaml files * add support for lazzy loading + * add json_to_yaml helper to convert JSON objects to YAML + * fix Model3D.__repr__ and __init__ to handle optional name attribute -- Christophe Trophime Tue, 03 Feb 2026 15:52:42 +0100 From 249d0b2fdf00ab7603b56c1dc115dff341ea5148 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Thu, 30 Apr 2026 15:02:39 +0200 Subject: [PATCH 14/25] add compress-hcut example -create hcut and shrink iit eventually --- examples/compress-hcut.py | 114 ++++++++++++++++++++ pyproject.toml | 3 +- python_magnetgeo/Helix.py | 139 ++++++++++++++++++------- python_magnetgeo/Profile.py | 69 +------------ python_magnetgeo/hcuts.py | 131 +++++++++++++++++++++-- tests/test_profile.py | 200 +++++++++++++++++++++--------------- 6 files changed, 463 insertions(+), 193 deletions(-) create mode 100644 examples/compress-hcut.py diff --git a/examples/compress-hcut.py b/examples/compress-hcut.py new file mode 100644 index 0000000..341cb88 --- /dev/null +++ b/examples/compress-hcut.py @@ -0,0 +1,114 @@ +import argparse +import glob +import sys +import os + +from python_magnetgeo.ModelAxi import ModelAxi +from python_magnetgeo.Helix import Helix + + +def main(): + """Main function to handle command line arguments.""" + parser = argparse.ArgumentParser( + description="Compress hcut.", + epilog="Example: %(prog)s data/HL-31_H1.yaml data/*.yaml", + ) + parser.add_argument( + "input_files", + nargs="+", + help='Path(s) to input YAML file(s); glob patterns (e.g. "data/*.yaml") are supported', + ) + parser.add_argument("--ratio", help="Compress ratio", type=float, default=0.9) + + args = parser.parse_args() + + cwd = os.getcwd() + + # Import after parsing so `--help` does not trigger package init logging. + import python_magnetgeo as pmg + + # Expand glob patterns and collect all matching files + files = [] + for pattern in args.input_files: + matched = glob.glob(pattern) + if matched: + files.extend(matched) + else: + print(f"Warning: no files matched: {pattern}", file=sys.stderr) + + if not files: + print("Error: no input files found.", file=sys.stderr) + sys.exit(1) + + pmg.verify_class_registration() + + errors = 0 + for input_file in files: + # basename = + # dirname = + obj = pmg.load(input_file) + print(f"\nLoad {input_file}", flush=True) + + # test if obj is an Helix + # if not quit + + hcut = obj.getModelAxi() + turns, pitch = hcut.compact() + print(f"Original hcut: {len(hcut.pitch)}", flush=True) + print(f"Compacted hcut: {len(pitch)}", flush=True) + nhcut = ModelAxi(name="compacted", h=hcut.h, turns=turns, pitch=pitch) + assert hcut.get_Nturns() == nhcut.get_Nturns() + + print(f"Compact hcut: {len(nhcut.pitch)}", flush=True) + + nhelix = Helix( + f"{obj.name}-compressed", + obj.r, + obj.z, + obj.cutwidth, + obj.odd, + obj.dble, + nhcut, + obj.model3d, + obj.shape, + obj.chamfers, + obj.grooves, + ) + print(f"Create new helix with compacted hcut") + + # save as yaml + + new_pitch = [args.ratio * p for p in nhcut.pitch] + + # create new heliw with compressed hcut + new_modelaxi = ModelAxi( + name="compressed", + h=args.ratio * obj.modelaxi.h, + turns=nhcut.turns, + pitch=new_pitch, + ) + new_helix = Helix( + f"{obj.name}-compressed", + obj.r, + obj.z, + obj.cutwidth, + obj.odd, + obj.dble, + new_modelaxi, + obj.model3d, + obj.shape, + obj.chamfers, + obj.grooves, + ) + + # save as yaml + new_helix.write_to_yaml(directory=cwd) + + # create hcut without shape + # create hcut with shape for Salome, Catia and CAD + for format in ["lncmi", "salome", "catia"]: + new_helix.generate_cut(format=format) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 6bfdc7d..6ed64f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ classifiers = [ keywords = ["magnet", "geometry", "cad", "mesh", "simulation", "hifimagnet"] dependencies = [ "pyyaml>=6.0.2,<7.0.0", - "pandas>=1.5.3" + "pandas>=1.5.3", + "xlwt>=1.3.0" ] [project.optional-dependencies] diff --git a/python_magnetgeo/Helix.py b/python_magnetgeo/Helix.py index 5c4715c..1421f90 100644 --- a/python_magnetgeo/Helix.py +++ b/python_magnetgeo/Helix.py @@ -13,6 +13,8 @@ import math import os +import subprocess +import sys from .base import YAMLObjectBase from .Chamfer import Chamfer @@ -28,6 +30,7 @@ # Get logger for this module logger = get_logger(__name__) + class Helix(YAMLObjectBase): """ Helix geometry class representing a helical magnet coil. @@ -379,30 +382,95 @@ def generate_cut(self, format: str = "SALOME"): if model3d.with_shapes is enabled. Uses external MagnetTools utilities. """ + format = format.upper() create_cut(self, format, self.name) + + # create inputs required by add_shape: aka R[mm], + stdin_data = ( + "\n".join( + [ + str(self.r[-1]), + str(self.modelaxi.h), + ] + ) + + "\n" + ) + if self.model3d.with_shapes: # if Profile class is used: self.shape.profile.generate_dat_file() if self.shape is not None and self.shape.profile is not None: - self.shape.profile.generate_dat_file(self._basedir) - shape_profile = f"{self._basedir}/Shape_{self.shape.cad}.dat" + shape_profile = str(self.shape.profile.generate_dat_file(self._basedir)) + shape_profile = os.path.splitext(os.path.basename(shape_profile))[ + 0 + ] # Use stem for add_shape + shape_profile = shape_profile.replace( + "Shape_", "" + ) # Sanitize name for command line else: return if self.get_type() == "HL": angles = " ".join(f"{t:4.2f}" for t in self.shape.angle if t != 0) - cmd = f'add_shape --angle="{angles}" --shape_angular_length={self.shape.length} --shape={shape_profile} --format={format} --position="{self.shape.position} {self.name}"' - logger.info(f"create_cut: with_shapes not implemented - shall run {cmd}") + cmd = f'add_shape --angle="{angles}" --shape_angular_length={self.shape.length} --shape={shape_profile} --format={format} --position="{self.shape.position}" {self.name}' + logger.info(f"create_cut: HL with_shapes - run {cmd}") else: angles = " ".join(f"{t:4.2f}" for t in self.shape.angle if t != 0) - cmd = f'add_shape --angle="{angles[0]}" --shape_angular_length={self.shape.length[0]} --shape={shape_profile} --format={format} --position="{self.shape.position} {self.name}"' - logger.info(f"create_cut: with_shapes not implemented - shall run {cmd}") + cmd = f'add_shape --angle="{angles[0]}" --shape_angular_length={self.shape.length[0]} --shape={shape_profile} --format={format} --position="{self.shape.position}" {self.name}' + logger.info(f"create_cut: HR with_shapes - run {cmd}") try: import subprocess - subprocess.run(cmd, shell=True, check=True) - except RuntimeError as e: - raise Exception(f"cannot run add_shape properly: {e}") from e + result = subprocess.run( + cmd, + shell=True, + check=True, + input=stdin_data, + text=True, + capture_output=True, + ) + logger.debug(result.stdout) + except subprocess.CalledProcessError as e: + raise RuntimeError( + f"add_shape failed (exit {e.returncode}):\n" + f" cmd: {e.cmd}\n" + f" stdout: {e.stdout}\n" + f" stderr: {e.stderr}" + ) from e + + # when format is catia, we need to convert the created xls to excel + # using Scripts/write_excel.py from magnettools + # ex: HL-41_H1-compressed_cut_with_shapes.xls + if format == "CATIA": + xls_file = f"{self.name}-cut_with_shapes.xls" + excel_file = f"{self.name}-cut_with_shapes-v5R19.xlsx" + logger.info( + "create_cut: convert to Excel - run %s -m magnettools.scripts.write_excel %s %s", + sys.executable, + xls_file, + excel_file, + ) + try: + result = subprocess.run( + [ + sys.executable, + "-m", + "magnettools.scripts.write_excel", + xls_file, + excel_file, + ], + check=True, + text=True, + capture_output=True, + ) + logger.debug(result.stdout) + except subprocess.CalledProcessError as e: + raise RuntimeError( + f"Excel conversion failed (exit {e.returncode}):\n" + f" cmd: {' '.join(map(str, e.cmd))}\n" + f" stdout: {e.stdout}\n" + f" stderr: {e.stderr}" + ) from e def intersect(self, r: list[float], z: list[float]) -> bool: """ @@ -499,16 +567,16 @@ def _plot_geometry(self, ax, show_labels: bool = True, **kwargs): z_min, z_max = z_bounds[0], z_bounds[1] # Extract styling parameters with defaults - color = kwargs.get('color', 'darkgreen') - alpha = kwargs.get('alpha', 0.6) - edgecolor = kwargs.get('edgecolor', 'black') - linewidth = kwargs.get('linewidth', 1.5) - label = kwargs.get('label', self.name if show_labels else None) - + color = kwargs.get("color", "darkgreen") + alpha = kwargs.get("alpha", 0.6) + edgecolor = kwargs.get("edgecolor", "black") + linewidth = kwargs.get("linewidth", 1.5) + label = kwargs.get("label", self.name if show_labels else None) + # ModelAxi zone parameters - show_modelaxi = kwargs.get('show_modelaxi', True) - modelaxi_color = kwargs.get('modelaxi_color', 'orange') - modelaxi_alpha = kwargs.get('modelaxi_alpha', 0.3) + show_modelaxi = kwargs.get("show_modelaxi", True) + modelaxi_color = kwargs.get("modelaxi_color", "orange") + modelaxi_alpha = kwargs.get("modelaxi_alpha", 0.3) # Create rectangle patch for main helix width = r_max - r_min @@ -521,12 +589,12 @@ def _plot_geometry(self, ax, show_labels: bool = True, **kwargs): alpha=alpha, edgecolor=edgecolor, linewidth=linewidth, - label=label + label=label, ) ax.add_patch(rect) # Plot modelaxi zone if requested and available - if show_modelaxi and self.modelaxi is not None and hasattr(self.modelaxi, 'h'): + if show_modelaxi and self.modelaxi is not None and hasattr(self.modelaxi, "h"): h = self.modelaxi.h # ModelAxi zone: from -h to +h on z-axis, same r dimensions modelaxi_rect = Rectangle( @@ -535,29 +603,29 @@ def _plot_geometry(self, ax, show_labels: bool = True, **kwargs): 2 * h, # Total height from -h to +h facecolor=modelaxi_color, alpha=modelaxi_alpha, - edgecolor='darkorange', + edgecolor="darkorange", linewidth=1.0, - linestyle='--', - label=f'{self.name}_modelaxi' if show_labels else None + linestyle="--", + label=f"{self.name}_modelaxi" if show_labels else None, ) ax.add_patch(modelaxi_rect) # Update axis limits to include this geometry with some padding current_xlim = ax.get_xlim() current_ylim = ax.get_ylim() - + # Calculate padding (5% of geometry size) r_padding = width * 0.05 z_padding = height * 0.05 - + # Also consider modelaxi zone for y limits - if show_modelaxi and self.modelaxi is not None and hasattr(self.modelaxi, 'h'): + if show_modelaxi and self.modelaxi is not None and hasattr(self.modelaxi, "h"): z_min_total = min(z_min, -self.modelaxi.h) z_max_total = max(z_max, self.modelaxi.h) else: z_min_total = z_min z_max_total = z_max - + # Expand limits if needed (check if limits are default) if current_xlim == (0.0, 1.0): # Default limits, set based on geometry @@ -565,10 +633,9 @@ def _plot_geometry(self, ax, show_labels: bool = True, **kwargs): else: # Expand existing limits ax.set_xlim( - min(current_xlim[0], r_min - r_padding), - max(current_xlim[1], r_max + r_padding) + min(current_xlim[0], r_min - r_padding), max(current_xlim[1], r_max + r_padding) ) - + if current_ylim == (0.0, 1.0): # Default limits, set based on geometry ax.set_ylim(z_min_total - z_padding, z_max_total + z_padding) @@ -576,20 +643,20 @@ def _plot_geometry(self, ax, show_labels: bool = True, **kwargs): # Expand existing limits ax.set_ylim( min(current_ylim[0], z_min_total - z_padding), - max(current_ylim[1], z_max_total + z_padding) + max(current_ylim[1], z_max_total + z_padding), ) # Add text label at center if requested and no custom label - if show_labels and 'label' not in kwargs: + if show_labels and "label" not in kwargs: center_r = (r_min + r_max) / 2 center_z = (z_min + z_max) / 2 ax.text( center_r, center_z, self.name, - ha='center', - va='center', + ha="center", + va="center", fontsize=9, - fontweight='bold', - color='white' if alpha > 0.5 else 'black' + fontweight="bold", + color="white" if alpha > 0.5 else "black", ) diff --git a/python_magnetgeo/Profile.py b/python_magnetgeo/Profile.py index 5560b7e..e510096 100644 --- a/python_magnetgeo/Profile.py +++ b/python_magnetgeo/Profile.py @@ -20,7 +20,10 @@ # Module logger from .logging_config import get_logger + logger = get_logger(__name__) + + class Profile(YAMLObjectBase): """ Represents a profile defined by 2D points and labels. @@ -80,7 +83,7 @@ def __init__(self, cad: str, points: list[list[float]], labels: Optional[list[in ... ) """ # Validate CAD identifier - #GeometryValidator.validate_name(cad) + # GeometryValidator.validate_name(cad) # Validate labels length if provided if labels is not None and len(labels) != len(points): @@ -248,67 +251,5 @@ def generate_dat_file(self, output_dir: str = ".") -> Path: for x, y in self.points: f.write(f"{x:.2f} {y:.2f}\n") + print(f"Generated DAT file: {output_path}") return output_path - - -# Example usage -if __name__ == "__main__": - print("=== Example 1: Profile with labels ===") - # Create a profile with region labels - profile_with_labels = Profile( - cad="HR-54-116", - points=[ - [-5.34, 0.0], - [-3.34, 0.0], - [-2.01, 0.9], - [0.0, 0.9], - [2.01, 0.9], - [3.34, 0.0], - [5.34, 0.0], - ], - labels=[0, 0, 0, 1, 0, 0, 0], - ) - - # Generate the DAT file with labels - output_file = profile_with_labels.generate_dat_file() - print(f"Generated file with labels: {output_file}") - - print("\n=== Example 2: Profile without labels ===") - # Create a simple profile without labels - profile_no_labels = Profile( - cad="SIMPLE-AIRFOIL", - points=[ - [0.0, 0.0], - [0.5, 0.05], - [1.0, 0.03], - [1.5, 0.0], - [1.0, -0.02], - [0.5, -0.03], - ], - labels=None, # Explicitly no labels - ) - - # Generate the DAT file without labels - output_file_simple = profile_no_labels.generate_dat_file() - print(f"Generated file without labels: {output_file_simple}") - - print("\n=== Example 3: Profile with all-zero labels (treated as no labels) ===") - # Create a profile where all labels are zero - profile_zero_labels = Profile( - cad="ZERO-LABELS", - points=[[0, 0], [1, 0.5], [2, 0]], - labels=[0, 0, 0], # All zeros - file won't include Id_i column - ) - - output_file_zeros = profile_zero_labels.generate_dat_file() - print(f"Generated file (all-zero labels, no Id_i column): {output_file_zeros}") - - # Demonstrate YAML serialization - print("\n=== YAML Export (with labels) ===") - yaml_str = profile_with_labels.dump() - print(yaml_str) - - # Demonstrate JSON serialization - print("\n=== JSON Export (without labels) ===") - json_str = profile_no_labels.to_json() - print(json_str) diff --git a/python_magnetgeo/hcuts.py b/python_magnetgeo/hcuts.py index da796da..2cfa90e 100644 --- a/python_magnetgeo/hcuts.py +++ b/python_magnetgeo/hcuts.py @@ -69,7 +69,7 @@ def lncmi_cut(object, filename: str, append: bool = False, z0: float = 0): See Also: MagnetTools/MagnetField/Stack.cc write_lncmi_paramfile L136 """ - print(f'lncmi_cut: filename={filename}') + print(f"lncmi_cut: filename={filename}, append={append}, z0={z0}") from math import pi sign = 1 @@ -77,7 +77,7 @@ def lncmi_cut(object, filename: str, append: bool = False, z0: float = 0): sign *= -1 # force units (mm, deg) - units = 1.e+3 + units = 1 # entries -- aka r,z -- are stored in mm angle_units = 180 / pi z = z0 @@ -107,21 +107,26 @@ def lncmi_cut(object, filename: str, append: bool = False, z0: float = 0): f.write("G0X-0.000\n") f.write("G0A0.\n") + print(f"lncmi_cut: Starting point: theta={theta}, z={z}") # Generate toolpath points from helix geometry for i, (turn, pitch) in enumerate(zip(object.modelaxi.turns, object.modelaxi.pitch)): theta += turn * (2 * pi) * sign z -= turn * pitch + logger.debug( + f"lncmi_cut: i={i}, turn={turn}, pitch={pitch}, sign={sign}, theta={-sign * theta * angle_units}, z={z}, angle_units={angle_units}" + ) f.write(f"N{i+1}") - if i == len(object.modelaxi.turns)-1: + if i == len(object.modelaxi.turns) - 1: f.write("G01") - f.write("\t"); + f.write("\t") f.write(f"X {-z * units:12.4f}\t") f.write(f"W {-sign * theta * angle_units:12.3f}\n") f.write("M50\nM29\nM30") f.write("%") + def salome_cut(object, filename: str, append: bool = False, z0: float = 0): """ Generate helical cut file in SALOME format. @@ -177,7 +182,7 @@ def salome_cut(object, filename: str, append: bool = False, z0: float = 0): See Also: MagnetTools/MagnetField/Stack.cc write_salome_paramfile L1011 """ - print(f'salome_cut: filename={filename}') + logger.info(f"salome_cut: filename={filename}") from math import pi sign = 1 @@ -193,7 +198,7 @@ def salome_cut(object, filename: str, append: bool = False, z0: float = 0): flag = "x" if append: flag = "a" - print(f'flag={flag}') + print(f"flag={flag}") with open(filename, flag) as f: # Write header f.write(f"#theta[rad]{tab}Shape_id[]{tab}tZ[mm]\n") @@ -208,9 +213,112 @@ def salome_cut(object, filename: str, append: bool = False, z0: float = 0): f.write(f"{theta*(-sign):12.8f}{tab}{shape_id:8}{tab}{z:12.8f}\n") -def create_cut( - object, format: str, name: str, append: bool = False, z0: float = 0 -): +def catia_cut(object, filename: str, append: bool = False, z0: float = 0): + """ + Generate helical cut file in CATIA-style tabular format. + + The output format mirrors HL-41/write_catia.cpp (write_catia_paramfile): + a short commented header followed by a ``StartLoft/StartCurve`` block and + one row per curve point with columns: + ``R*theta[mm.rad]``, ``R[mm]``, ``Z[mm]``. + + Args: + object: Helix or Bitter object containing the geometry definition. + Must expose ``modelaxi.turns``, ``modelaxi.pitch`` and ``r``. + filename: Base output filename. + append: If True, append to output file; if False, create new file. + z0: Reference axial origin in millimeters. If 0, uses the initial + generated point (``modelaxi.h``). + """ + logger.info(f"catia_cut: filename={filename}") + + sign = 1 + if not object.odd: + sign = -1 + + # C++ version receives R and Z0 from caller; here we derive sensible defaults. + radius = (object.r[0] + object.r[1]) / 2.0 + z = object.modelaxi.h + z_ref = z0 if z0 != 0 else z + theta_deg = 0.0 + + points = [(z, theta_deg)] + for turn, pitch in zip(object.modelaxi.turns, object.modelaxi.pitch): + theta_deg += turn * 360.0 * sign + z -= turn * pitch + points.append((z, theta_deg)) + + try: + import importlib + + xlwt = importlib.import_module("xlwt") + except ImportError as exc: + raise RuntimeError( + "catia_cut requires xlwt to write a real .xls file. Install it with: pip install xlwt" + ) from exc + + if append: + raise RuntimeError("catia_cut append=True is not supported for .xls export") + + workbook = xlwt.Workbook() + sheet = workbook.add_sheet("CATIA") + + row = 0 + sheet.write(row, 0, "# ---- Cut from here -----") + row += 1 + sheet.write(row, 0, "# Fichier de decoupe Catia V5 R19") + row += 1 + sheet.write(row, 0, f"# Fichier de decoupe Machine : {filename}") + row += 1 + sheet.write(row, 0, f"# Decoupe helice {'gauche' if sign > 0 else 'droite'}") + row += 2 + + sheet.write(row, 0, "# Params") + row += 1 + sheet.write(row, 0, f"# R = {radius} mm") + row += 1 + sheet.write(row, 0, f"# Z0 = {z_ref} mm") + row += 1 + sheet.write(row, 0, "#") + row += 1 + sheet.write(row, 0, "# R*theta[mm.rad]") + sheet.write(row, 1, "R[mm]") + sheet.write(row, 2, "Z[mm]") + row += 1 + sheet.write(row, 0, "# ---- To here -----") + row += 1 + sheet.write(row, 0, "StartLoft") + row += 1 + sheet.write(row, 0, "StartCurve") + row += 1 + + previous_theta = None + for i, (z_point, theta_point_deg) in enumerate(points): + if previous_theta is not None and abs(theta_point_deg) < abs(previous_theta): + print( + "CATIA: Warning Point[%d] dropped: %s,%s (%s,%s)" + % (i, z_point, theta_point_deg, points[i - 1][0], previous_theta) + ) + + x_coord = theta_point_deg * radius * pi / 180.0 * (-sign) + y_coord = radius + z_coord = -(z_point - z_ref) + sheet.write(row, 0, x_coord) + sheet.write(row, 1, y_coord) + sheet.write(row, 2, z_coord) + row += 1 + previous_theta = theta_point_deg + + sheet.write(row, 0, "EndCurve") + row += 1 + sheet.write(row, 0, "EndLoft") + row += 1 + sheet.write(row, 0, "End") + + workbook.save(filename) + + +def create_cut(object, format: str, name: str, append: bool = False, z0: float = 0): """ Create helical cut file in the specified format. @@ -266,11 +374,13 @@ def create_cut( See Also: lncmi_cut: For LNCMI format details salome_cut: For SALOME format details + catia_cut: For CATIA format details """ dformat = { - "lncmi": {"run": lncmi_cut, "extension": "_lncmi.iso"}, + "lncmi": {"run": lncmi_cut, "extension": "_cut.iso"}, "salome": {"run": salome_cut, "extension": "_cut_salome.dat"}, + "catia": {"run": catia_cut, "extension": "_cut_catia-V5R19.xls"}, } try: @@ -291,4 +401,3 @@ def create_cut( ext = format_cut["extension"] filename = f"{name}{ext}" write_cut(object, filename, append, z0) - diff --git a/tests/test_profile.py b/tests/test_profile.py index fc43922..f716ffe 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -19,11 +19,7 @@ class TestProfileCreation: def test_basic_creation_with_labels(self): """Test creating a profile with explicit labels""" - profile = Profile( - cad="TEST-001", - points=[[0, 0], [1, 0.5], [2, 0]], - labels=[0, 1, 0] - ) + profile = Profile(cad="TEST-001", points=[[0, 0], [1, 0.5], [2, 0]], labels=[0, 1, 0]) assert profile.cad == "TEST-001" assert len(profile.points) == 3 @@ -32,10 +28,7 @@ def test_basic_creation_with_labels(self): def test_creation_without_labels(self): """Test creating a profile without labels (should default to zeros)""" - profile = Profile( - cad="TEST-002", - points=[[0, 0], [5, 2], [10, 0]] - ) + profile = Profile(cad="TEST-002", points=[[0, 0], [5, 2], [10, 0]]) assert profile.cad == "TEST-002" assert len(profile.points) == 3 @@ -43,11 +36,7 @@ def test_creation_without_labels(self): def test_creation_with_none_labels(self): """Test creating a profile with labels=None""" - profile = Profile( - cad="TEST-003", - points=[[0, 0], [1, 1]], - labels=None - ) + profile = Profile(cad="TEST-003", points=[[0, 0], [1, 1]], labels=None) assert profile.labels == [0, 0] @@ -57,7 +46,7 @@ def test_labels_length_mismatch_raises_error(self): Profile( cad="TEST-004", points=[[0, 0], [1, 1], [2, 2]], - labels=[0, 1] # Only 2 labels for 3 points + labels=[0, 1], # Only 2 labels for 3 points ) @@ -66,11 +55,7 @@ class TestProfileRepr: def test_repr_with_labels(self): """Test __repr__ with explicit labels""" - profile = Profile( - cad="REPR-001", - points=[[0, 0], [1, 1]], - labels=[0, 1] - ) + profile = Profile(cad="REPR-001", points=[[0, 0], [1, 1]], labels=[0, 1]) repr_str = repr(profile) assert "Profile" in repr_str @@ -80,10 +65,7 @@ def test_repr_with_labels(self): def test_repr_without_labels(self): """Test __repr__ with default labels""" - profile = Profile( - cad="REPR-002", - points=[[0, 0], [1, 1]] - ) + profile = Profile(cad="REPR-002", points=[[0, 0], [1, 1]]) repr_str = repr(profile) assert "Profile" in repr_str @@ -96,9 +78,7 @@ class TestProfileSerialization: def test_to_json(self): """Test JSON serialization""" profile = Profile( - cad="JSON-001", - points=[[-5.34, 0], [0, 0.9], [5.34, 0]], - labels=[0, 1, 0] + cad="JSON-001", points=[[-5.34, 0], [0, 0.9], [5.34, 0]], labels=[0, 1, 0] ) json_str = profile.to_json() @@ -111,10 +91,7 @@ def test_to_json(self): def test_to_json_without_labels(self): """Test JSON serialization with default labels""" - profile = Profile( - cad="JSON-002", - points=[[0, 0], [1, 0.5], [2, 0]] - ) + profile = Profile(cad="JSON-002", points=[[0, 0], [1, 0.5], [2, 0]]) json_str = profile.to_json() parsed = json.loads(json_str) @@ -124,11 +101,7 @@ def test_to_json_without_labels(self): def test_from_dict_with_labels(self): """Test creating Profile from dictionary with labels""" - data = { - "cad": "DICT-001", - "points": [[0, 0], [5, 2], [10, 0]], - "labels": [0, 1, 0] - } + data = {"cad": "DICT-001", "points": [[0, 0], [5, 2], [10, 0]], "labels": [0, 1, 0]} profile = Profile.from_dict(data) @@ -138,10 +111,7 @@ def test_from_dict_with_labels(self): def test_from_dict_without_labels(self): """Test creating Profile from dictionary without labels""" - data = { - "cad": "DICT-002", - "points": [[0, 0], [1, 1], [2, 0]] - } + data = {"cad": "DICT-002", "points": [[0, 0], [1, 1], [2, 0]]} profile = Profile.from_dict(data) @@ -153,7 +123,7 @@ def test_json_round_trip(self): original = Profile( cad="ROUNDTRIP-001", points=[[-5.34, 0], [-3.34, 0], [0, 0.9], [3.34, 0], [5.34, 0]], - labels=[0, 0, 1, 0, 0] + labels=[0, 0, 1, 0, 0], ) json_str = original.to_json() @@ -166,11 +136,7 @@ def test_json_round_trip(self): def test_yaml_round_trip(self): """Test YAML serialization round trip""" - original = Profile( - cad="Profile0", - points=[[0, 0], [5, 2], [10, 0]], - labels=[0, 1, 0] - ) + original = Profile(cad="Profile0", points=[[0, 0], [5, 2], [10, 0]], labels=[0, 1, 0]) # Write to file and load back with tempfile.TemporaryDirectory() as tmpdir: @@ -201,7 +167,7 @@ def test_generate_dat_with_labels(self): profile = Profile( cad="HR-54-116", points=[[-5.34, 0], [-3.34, 0], [0, 0.9], [3.34, 0], [5.34, 0]], - labels=[0, 0, 1, 0, 0] + labels=[0, 0, 1, 0, 0], ) with tempfile.TemporaryDirectory() as tmpdir: @@ -224,16 +190,14 @@ def test_generate_dat_with_labels(self): assert "5" in content # Check data points with labels - lines = content.split('\n') - data_lines = [l for l in lines if l and not l.startswith('#')] + lines = content.split("\n") + data_lines = [l for l in lines if l and not l.startswith("#")] assert len(data_lines) >= 5 # 1 for count, 5 for points def test_generate_dat_without_labels(self): """Test DAT file generation without labels (or all-zero labels)""" profile = Profile( - cad="SIMPLE-AIRFOIL", - points=[[0, 0], [0.5, 0.05], [1, 0.03]], - labels=None # No labels + cad="SIMPLE-AIRFOIL", points=[[0, 0], [0.5, 0.05], [1, 0.03]], labels=None # No labels ) with tempfile.TemporaryDirectory() as tmpdir: @@ -254,22 +218,22 @@ def test_generate_dat_without_labels(self): assert "Id_i" not in content # Verify data lines don't have labels - lines = content.split('\n') - data_lines = [l for l in lines if l and not l.startswith('#') and len(l.strip()) > 0] + lines = content.split("\n") + data_lines = [l for l in lines if l and not l.startswith("#") and len(l.strip()) > 0] # First data line is the count # Subsequent lines should have 2 values only (X, F) for line in data_lines[1:]: parts = line.split() if len(parts) > 0: # Skip empty lines # Should be 2 values (X, F), not 3 - assert len(parts) <= 2, f"Expected 2 values without labels, got {len(parts)}: {line}" + assert ( + len(parts) <= 2 + ), f"Expected 2 values without labels, got {len(parts)}: {line}" def test_generate_dat_all_zero_labels(self): """Test that all-zero labels are treated as no labels""" profile = Profile( - cad="ZERO-LABELS", - points=[[0, 0], [1, 0.5], [2, 0]], - labels=[0, 0, 0] # All zeros + cad="ZERO-LABELS", points=[[0, 0], [1, 0.5], [2, 0]], labels=[0, 0, 0] # All zeros ) with tempfile.TemporaryDirectory() as tmpdir: @@ -286,7 +250,7 @@ def test_generate_dat_mixed_labels(self): profile = Profile( cad="MIXED-LABELS", points=[[0, 0], [1, 0.5], [2, 0.3], [3, 0]], - labels=[0, 1, 2, 0] # Has non-zero labels + labels=[0, 1, 2, 0], # Has non-zero labels ) with tempfile.TemporaryDirectory() as tmpdir: @@ -299,10 +263,7 @@ def test_generate_dat_mixed_labels(self): def test_generate_dat_custom_directory(self): """Test DAT file generation in custom directory""" - profile = Profile( - cad="CUSTOM-DIR", - points=[[0, 0], [1, 1]] - ) + profile = Profile(cad="CUSTOM-DIR", points=[[0, 0], [1, 1]]) with tempfile.TemporaryDirectory() as tmpdir: custom_dir = Path(tmpdir) / "custom" / "path" @@ -316,9 +277,7 @@ def test_generate_dat_custom_directory(self): def test_dat_file_format_precision(self): """Test that DAT file uses correct precision (2 decimal places)""" profile = Profile( - cad="PRECISION-TEST", - points=[[1.234567, 2.345678], [3.456789, 4.567890]], - labels=[0, 1] + cad="PRECISION-TEST", points=[[1.234567, 2.345678], [3.456789, 4.567890]], labels=[0, 1] ) with tempfile.TemporaryDirectory() as tmpdir: @@ -338,20 +297,13 @@ class TestProfileValidation: def test_empty_points_list(self): """Test that empty points list is handled""" # Should work but create empty labels - profile = Profile( - cad="EMPTY-POINTS", - points=[] - ) + profile = Profile(cad="EMPTY-POINTS", points=[]) assert profile.points == [] assert profile.labels == [] def test_single_point(self): """Test profile with single point""" - profile = Profile( - cad="SINGLE-POINT", - points=[[0, 0]], - labels=[1] - ) + profile = Profile(cad="SINGLE-POINT", points=[[0, 0]], labels=[1]) assert len(profile.points) == 1 assert len(profile.labels) == 1 @@ -363,17 +315,103 @@ def test_has_yaml_methods(self): """Test that Profile has all YAML serialization methods""" profile = Profile(cad="TEST", points=[[0, 0]]) - assert hasattr(profile, 'write_to_yaml') - assert hasattr(profile, 'to_json') - assert hasattr(profile, 'write_to_json') - assert hasattr(Profile, 'from_yaml') - assert hasattr(Profile, 'from_json') - assert hasattr(Profile, 'from_dict') + assert hasattr(profile, "write_to_yaml") + assert hasattr(profile, "to_json") + assert hasattr(profile, "write_to_json") + assert hasattr(Profile, "from_yaml") + assert hasattr(Profile, "from_json") + assert hasattr(Profile, "from_dict") def test_yaml_tag(self): """Test that Profile has correct YAML tag""" assert Profile.yaml_tag == "Profile" +class TestProfileExamples: + """Integration tests based on the canonical usage examples""" + + def test_example1_profile_with_labels(self): + """Example 1: Profile with region labels - creates DAT file with Id_i column""" + profile = Profile( + cad="HR-54-116", + points=[ + [-5.34, 0.0], + [-3.34, 0.0], + [-2.01, 0.9], + [0.0, 0.9], + [2.01, 0.9], + [3.34, 0.0], + [5.34, 0.0], + ], + labels=[0, 0, 0, 1, 0, 0, 0], + ) + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = profile.generate_dat_file(tmpdir) + + assert output_path.exists() + assert output_path.name == "Shape_HR-54-116.dat" + content = output_path.read_text() + assert "#X_i F_i\tId_i" in content + + def test_example2_profile_without_labels(self): + """Example 2: Profile without labels - DAT file has no Id_i column""" + profile = Profile( + cad="SIMPLE-AIRFOIL", + points=[ + [0.0, 0.0], + [0.5, 0.05], + [1.0, 0.03], + [1.5, 0.0], + [1.0, -0.02], + [0.5, -0.03], + ], + labels=None, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = profile.generate_dat_file(tmpdir) + + assert output_path.exists() + assert output_path.name == "Shape_SIMPLE-AIRFOIL.dat" + content = output_path.read_text() + assert "Id_i" not in content + + def test_example3_all_zero_labels_treated_as_no_labels(self): + """Example 3: All-zero labels are treated as no labels - no Id_i column""" + profile = Profile( + cad="ZERO-LABELS", + points=[[0, 0], [1, 0.5], [2, 0]], + labels=[0, 0, 0], + ) + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = profile.generate_dat_file(tmpdir) + content = output_path.read_text() + assert "Id_i" not in content + + def test_yaml_export_with_labels(self): + """YAML export produces valid YAML string containing profile data""" + profile = Profile( + cad="HR-54-116", + points=[[-5.34, 0.0], [0.0, 0.9], [5.34, 0.0]], + labels=[0, 1, 0], + ) + yaml_str = profile.dump() + assert "HR-54-116" in yaml_str + + def test_json_export_without_labels(self): + """JSON export produces parseable JSON with correct fields""" + profile = Profile( + cad="SIMPLE-AIRFOIL", + points=[[0.0, 0.0], [0.5, 0.05], [1.0, 0.0]], + labels=None, + ) + json_str = profile.to_json() + parsed = json.loads(json_str) + assert parsed["cad"] == "SIMPLE-AIRFOIL" + assert "points" in parsed + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From 505219f5b5dd135068a2e6f5e42f331d9a5f89ca Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Thu, 30 Apr 2026 15:03:33 +0200 Subject: [PATCH 15/25] add prompt for code review --- prompts/package-assessment-prompt.md | 77 ++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 prompts/package-assessment-prompt.md diff --git a/prompts/package-assessment-prompt.md b/prompts/package-assessment-prompt.md new file mode 100644 index 0000000..e7c2a6c --- /dev/null +++ b/prompts/package-assessment-prompt.md @@ -0,0 +1,77 @@ +# Package Assessment Prompt + +Use this prompt to assess the readiness of a Python package for use in the MagnetDB ecosystem. +Run it in each package repository — either by pasting it into a Claude conversation with the +codebase in context, or by using it as a checklist for manual review. + +--- + +## Prompt + +Analyse this repository and answer the following questions as concisely as possible. +Return your answer as a **YAML block** with the exact keys shown below. + +```yaml +package: +description: +tests: + present: + kind: + coverage_estimate: 70%) | unknown> +documentation: + readme: + api_docs: + usage_examples: +ci: + present: + runs_tests: + builds_package: + platform: +installability: + pip_install: + extra_dependencies: + packaging: +api_stability: + status: + breaking_changes_risk: +maintenance: + active: + last_commit_approx: + bus_factor: +overall_readiness: + score: <1–5> # 1=prototype, 3=usable with caveats, 5=production-ready + blocker: +``` + +--- + +## Scoring guide for `overall_readiness.score` + +| Score | Meaning | +|-------|---------| +| 1 | Prototype — exploratory code, not intended for reuse yet | +| 2 | Early-stage — works for the author, not reliably usable by others | +| 3 | Usable with caveats — installable, functional, but gaps in tests or docs | +| 4 | Good — tested, documented, stable API; minor rough edges | +| 5 | Production-ready — CI green, docs complete, API stable, actively maintained | + +--- + +## Usage + +Once you have YAML blocks for all packages in the MagnetDB ecosystem, paste them +together into a single message to generate the maturity heatmap visual. + +Packages to assess: + +- `python_magnetgeo` +- `python-magnettools` +- `python_magnetrun` +- `python_magnetgmsh` +- `hifimagnet-salome` +- `python_magnetsetup` +- `python_magnetworkflow` +- `hifimagnet-paraview` +- `magnet-scipy` +- `python_magnetcooling` +- `python_magnetapi` From 8e73cec05d6e9abee9a6d4a7b3f081b3f28acdec Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Thu, 30 Apr 2026 15:12:07 +0200 Subject: [PATCH 16/25] add dump to fix pytest --- python_magnetgeo/base.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/python_magnetgeo/base.py b/python_magnetgeo/base.py index b28dc24..584ffeb 100644 --- a/python_magnetgeo/base.py +++ b/python_magnetgeo/base.py @@ -139,6 +139,19 @@ def to_yaml(self) -> str: """ return yaml.dump(self, default_flow_style=False, sort_keys=False) + def dump(self) -> str: + """ + Backward-compatible alias for YAML string serialization. + + Returns: + str: YAML string representation of the object + + Notes: + - Kept for compatibility with legacy code/tests that still call dump(). + - Prefer to_yaml() in new code. + """ + return self.to_yaml() + def to_json(self) -> str: """ Convert object to JSON string representation. From 1bf9dff476116bbbc0d14a09f18297f8d003f726 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Thu, 30 Apr 2026 16:34:58 +0200 Subject: [PATCH 17/25] fix ruff errors and up docs to md --- .travis.yml | 29 -- BREAKING_CHANGES.md | 71 ++++- Makefile | 5 +- README.md | 15 +- docs/api_usage.md | 9 + docs/authors.md | 2 + docs/authors.rst | 1 - docs/breaking_changes.md | 2 + docs/conf.py | 6 +- docs/contributing.md | 2 + docs/contributing.rst | 1 - docs/history.md | 5 + docs/history.rst | 1 - docs/index.md | 22 ++ docs/index.rst | 20 -- docs/installation.md | 41 +++ docs/installation.rst | 51 ---- docs/modules.md | 7 + docs/modules.rst | 7 - docs/python_magnetgeo.hts.md | 43 +++ docs/python_magnetgeo.hts.rst | 45 --- docs/python_magnetgeo.md | 259 ++++++++++++++++++ docs/python_magnetgeo.rst | 229 ---------------- docs/readme.md | 2 + docs/readme.rst | 1 - docs/usage.md | 5 + docs/usage.rst | 7 - .../FEATURE_DRY_RUN_ANALYSIS.md | 0 .../examples => examples}/__init__.py | 0 examples/check_magnetgeo_yaml.py | 26 +- examples/compress-hcut.py | 9 +- .../examples => examples}/create_H1.py | 10 +- .../example_dependency_analysis.py | 0 .../examples => examples}/helix-cut.py | 11 +- examples/helix_visualization.py | 36 +-- examples/insert_visualization.py | 20 +- examples/json_to_yaml.py | 12 +- .../lazy_loading_demo.py | 2 - .../load_profile_from_dat.py | 10 +- .../examples => examples}/logging_examples.py | 71 ++--- examples/migrate_chamfer_l_to_length.py | 122 +++++++++ .../examples => examples}/probe_example.py | 3 +- .../probe_usage_python.py | 64 +++-- .../quick_reference_get_required_files.py | 0 examples/salome_export.py | 179 ++++++++++++ .../examples => examples}/split_helix_yaml.py | 12 +- .../examples => examples}/test-yamlload.py | 2 - examples/visualization.py | 18 +- .../yaml_json_roundtrip.py | 8 +- pyproject.toml | 19 +- pytest.ini | 21 -- python_magnetgeo/Bitter.py | 2 +- python_magnetgeo/Bitters.py | 9 +- python_magnetgeo/Chamfer.py | 14 +- python_magnetgeo/Helix.py | 4 +- python_magnetgeo/Insert.py | 28 +- python_magnetgeo/MSite.py | 11 +- python_magnetgeo/ModelAxi.py | 23 +- python_magnetgeo/Profile.py | 4 +- python_magnetgeo/Ring.py | 6 +- python_magnetgeo/Screen.py | 6 +- python_magnetgeo/Shape.py | 5 +- python_magnetgeo/Supra.py | 2 +- python_magnetgeo/SupraStructure.py | 13 +- python_magnetgeo/Supras.py | 7 +- python_magnetgeo/__init__.py | 66 ++--- python_magnetgeo/base.py | 14 +- python_magnetgeo/deserialize.py | 27 +- python_magnetgeo/enums.py | 12 +- .../examples/check_magnetgeo_yaml.py | 96 ------- python_magnetgeo/examples/hts.json | 30 -- .../examples/probe_usage_examples.txt | 88 ------ python_magnetgeo/hcuts.py | 33 ++- python_magnetgeo/hts/isolation.py | 6 +- python_magnetgeo/hts/pancake.py | 13 +- python_magnetgeo/logging_config.py | 79 +++--- python_magnetgeo/utils.py | 25 +- python_magnetgeo/validation.py | 12 +- python_magnetgeo/visualization.py | 16 +- tests/conftest.py | 29 +- tests/test-refactor-ring.py | 8 +- tests/test_auto_registration.py | 51 ++-- tests/test_get_required_files.py | 1 + tests/test_helix_refactor.py | 40 +-- tests/test_insert_refactor.py | 7 +- tests/test_insert_validation.py | 40 +-- tests/test_magnet_refactor.py | 8 +- tests/test_model3d_refactor.py | 19 +- tests/test_msite_refactor.py | 21 +- tests/test_nested_loading.py | 24 +- tests/test_phase4.py | 27 +- tests/test_phase4_leads.py | 7 +- tests/test_profile.py | 7 +- tests/test_refactor_bitter.py | 17 +- tests/test_refactor_bitters.py | 11 +- tests/test_refactor_coolingslits.py | 11 +- tests/test_refactor_shape.py | 11 +- tests/test_refactor_supra.py | 17 +- tests/test_refactor_supras.py | 14 +- tests/test_refactor_tierod.py | 11 +- tests/test_yaml_nested_objects.py | 3 +- 101 files changed, 1347 insertions(+), 1231 deletions(-) delete mode 100644 .travis.yml create mode 100644 docs/api_usage.md create mode 100644 docs/authors.md delete mode 100644 docs/authors.rst create mode 100644 docs/breaking_changes.md create mode 100644 docs/contributing.md delete mode 100644 docs/contributing.rst create mode 100644 docs/history.md delete mode 100644 docs/history.rst create mode 100644 docs/index.md delete mode 100644 docs/index.rst create mode 100644 docs/installation.md delete mode 100644 docs/installation.rst create mode 100644 docs/modules.md delete mode 100644 docs/modules.rst create mode 100644 docs/python_magnetgeo.hts.md delete mode 100644 docs/python_magnetgeo.hts.rst create mode 100644 docs/python_magnetgeo.md delete mode 100644 docs/python_magnetgeo.rst create mode 100644 docs/readme.md delete mode 100644 docs/readme.rst create mode 100644 docs/usage.md delete mode 100644 docs/usage.rst rename {python_magnetgeo/examples => examples}/FEATURE_DRY_RUN_ANALYSIS.md (100%) rename {python_magnetgeo/examples => examples}/__init__.py (100%) rename {python_magnetgeo/examples => examples}/create_H1.py (91%) rename {python_magnetgeo/examples => examples}/example_dependency_analysis.py (100%) rename {python_magnetgeo/examples => examples}/helix-cut.py (95%) rename {python_magnetgeo/examples => examples}/lazy_loading_demo.py (99%) rename {python_magnetgeo/examples => examples}/load_profile_from_dat.py (97%) rename {python_magnetgeo/examples => examples}/logging_examples.py (96%) create mode 100644 examples/migrate_chamfer_l_to_length.py rename {python_magnetgeo/examples => examples}/probe_example.py (97%) rename {python_magnetgeo/examples => examples}/probe_usage_python.py (92%) rename {python_magnetgeo/examples => examples}/quick_reference_get_required_files.py (100%) create mode 100644 examples/salome_export.py rename {python_magnetgeo/examples => examples}/split_helix_yaml.py (95%) rename {python_magnetgeo/examples => examples}/test-yamlload.py (99%) rename {python_magnetgeo/examples => examples}/yaml_json_roundtrip.py (98%) delete mode 100644 pytest.ini delete mode 100755 python_magnetgeo/examples/check_magnetgeo_yaml.py delete mode 100644 python_magnetgeo/examples/hts.json delete mode 100644 python_magnetgeo/examples/probe_usage_examples.txt diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 8560b30..0000000 --- a/.travis.yml +++ /dev/null @@ -1,29 +0,0 @@ -# Config file for automatic testing at travis-ci.com - -language: python -python: - - 3.8 - - 3.7 - - 3.6 - - 3.5 - -# Command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors -install: pip install -U tox-travis - -# Command to run tests, e.g. python setup.py test -script: tox - -# Assuming you have installed the travis-ci CLI tool, after you -# create the Github repo and add it to Travis, run the -# following command to finish PyPI deployment setup: -# $ travis encrypt --add deploy.password -deploy: - provider: pypi - distributions: sdist bdist_wheel - user: Trophime - password: - secure: PLEASE_REPLACE_ME - on: - tags: true - repo: Trophime/python_magnetgeo - python: 3.8 diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index 145b95b..90d09e4 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -1,12 +1,15 @@ -# API Breaking Changes - python_magnetgeo v1.0.0 +# API Breaking Changes - python_magnetgeo ## Overview -This document details all API breaking changes introduced in version 1.0.0 compared to previous versions (0.3.x - 0.7.x). **No backward compatibility is provided.** +This document details all API breaking changes introduced across versions. **No backward compatibility is provided.** ## Version History Summary -### v1.0.0 (Current) - Major Architectural Overhaul +### v1.0.1 (Current) +- **`Chamfer` constructor**: parameter `l` renamed to `length` (see [v1.0.0 → v1.0.1](#v100--v101)) + +### v1.0.0 - Major Architectural Overhaul - Complete rewrite with base class architecture - Enhanced type safety and validation system - **New Profile class** for bump profile management @@ -41,6 +44,68 @@ This document details all API breaking changes introduced in version 1.0.0 compa --- +## v1.0.0 → v1.0.1 + +### Chamfer: parameter `l` renamed to `length` + +The `l` parameter of `Chamfer.__init__` was renamed to `length` to resolve the +E741 linting error (ambiguous variable name). + +**Old:** +```python +Chamfer(name, side, rside, alpha=None, dr=None, l=None) +``` + +**New:** +```python +Chamfer(name, side, rside, alpha=None, dr=None, length=None) +``` + +**Migration — update any call that passes `l` as a keyword argument:** +```python +# Before +chamfer = Chamfer("c1", "HP", "rext", alpha=45.0, l=10.0) + +# After +chamfer = Chamfer("c1", "HP", "rext", alpha=45.0, length=10.0) +``` + +Positional calls (`Chamfer("c1", "HP", "rext", 45.0, None, 10.0)`) are +unaffected. The YAML/JSON serialization key also changes from `"l"` to `"length"`. + +**YAML migration:** +```yaml +# Before +! +name: c1 +side: HP +rside: rext +alpha: 45.0 +l: 10.0 + +# After +! +name: c1 +side: HP +rside: rext +alpha: 45.0 +length: 10.0 +``` + +A helper script is provided to automate the YAML migration: + +```bash +# Preview (no files written) +python examples/migrate_chamfer_l_to_length.py --dry-run path/to/yaml/dir/ + +# Apply +python examples/migrate_chamfer_l_to_length.py path/to/yaml/dir/ +``` + +--- + +## v1.0.0 Breaking Changes + ## 1. YAML Format Changes ### Evolution Across Versions diff --git a/Makefile b/Makefile index 9f8322a..42dc264 100644 --- a/Makefile +++ b/Makefile @@ -63,15 +63,12 @@ coverage: ## check code coverage quickly with the default Python $(BROWSER) htmlcov/index.html docs: ## generate Sphinx HTML documentation, including API docs - rm -f docs/python_magnetgeo.rst - rm -f docs/modules.rst - sphinx-apidoc -o docs/ python_magnetgeo $(MAKE) -C docs clean SPHINX_BUILD=1 $(MAKE) -C docs html $(BROWSER) docs/_build/html/index.html servedocs: docs ## compile the docs watching for changes - watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . + watchmedo shell-command -p '*.md' -c '$(MAKE) -C docs html' -R -D . release: dist ## package and upload a release twine upload dist/* diff --git a/README.md b/README.md index 6502eff..2c22f14 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ # Python Magnet Geometry Python Magnet Geometry contains magnet geometrical models for high-field magnet design and simulation. @@ -41,7 +40,7 @@ poetry add python_magnetgeo@^1.0.0 ### Development installation ```bash -git clone https://github.com/Trophime/python_magnetgeo.git +git clone https://github.com/MagnetDB/python_magnetgeo.git cd python_magnetgeo git checkout v1.0.0 python -m venv --system-site-packages magnetgeo-env @@ -487,7 +486,7 @@ python -m python_magnetgeo.xao HL-31-Axi.xao mesh --group CoolingChannels --geo - Enum types (e.g., `ShapePosition`) - Automatic YAML constructor registration -**See [BREAKING_CHANGES.md](BREAKING_CHANGES.md) for complete migration guide and migration scripts.** +**See [BREAKING_CHANGES.md](https://github.com/MagnetDB/python_magnetgeo/blob/main/BREAKING_CHANGES.md) for complete migration guide and migration scripts.** ### Version History @@ -530,7 +529,7 @@ python -m python_magnetgeo.xao HL-31-Axi.xao mesh --group CoolingChannels --geo ### Quick Assessment: Which Version Are You Using? -```python +```yaml # Check your YAML files # If they look like this, you're on v0.5.x or earlier: name: "HL-31" @@ -578,7 +577,7 @@ except ValidationError as e: ### Migration Path 2: From v0.5.x (or earlier) to v1.0.0 -Use the provided migration scripts in [BREAKING_CHANGES.md](BREAKING_CHANGES.md): +Use the provided migration scripts in [BREAKING_CHANGES.md](https://github.com/MagnetDB/python_magnetgeo/blob/main/BREAKING_CHANGES.md): ```bash # Migrate a single YAML file @@ -1476,7 +1475,7 @@ When reporting issues, please include: ## License -MIT License - see [LICENSE](LICENSE) file for details. +MIT License - see [LICENSE](https://github.com/Trophime/python_magnetgeo/blob/main/LICENSE) file for details. ## Documentation @@ -1563,4 +1562,4 @@ If you use python_magnetgeo in your research, please cite: --- -**Version 1.0.0** | Released: 2025 | [Changelog](CHANGELOG.md) | [Breaking Changes](BREAKING_CHANGES.md) +**Version 1.0.0** | Released: 2025 | [Changelog](https://github.com/MagnetDB/python_magnetgeo/blob/main/HISTORY.md) | [Breaking Changes](https://github.com/MagnetDB/python_magnetgeo/blob/main/BREAKING_CHANGES.md) diff --git a/docs/api_usage.md b/docs/api_usage.md new file mode 100644 index 0000000..27cfcba --- /dev/null +++ b/docs/api_usage.md @@ -0,0 +1,9 @@ +# API Usage + +```{toctree} +:maxdepth: 2 + +dependency_analysis +lazy_loading +logging +``` diff --git a/docs/authors.md b/docs/authors.md new file mode 100644 index 0000000..73a1374 --- /dev/null +++ b/docs/authors.md @@ -0,0 +1,2 @@ +```{include} ../AUTHORS.md +``` diff --git a/docs/authors.rst b/docs/authors.rst deleted file mode 100644 index e122f91..0000000 --- a/docs/authors.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../AUTHORS.rst diff --git a/docs/breaking_changes.md b/docs/breaking_changes.md new file mode 100644 index 0000000..e02ba81 --- /dev/null +++ b/docs/breaking_changes.md @@ -0,0 +1,2 @@ +```{include} ../BREAKING_CHANGES.md +``` diff --git a/docs/conf.py b/docs/conf.py index d5acf33..f3b4591 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,9 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'myst_parser'] + +myst_heading_anchors = 4 # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -40,7 +42,7 @@ # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ['.rst', '.md'] # The master toctree document. master_doc = 'index' diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..78caf34 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,2 @@ +```{include} ../CONTRIBUTING.md +``` diff --git a/docs/contributing.rst b/docs/contributing.rst deleted file mode 100644 index e582053..0000000 --- a/docs/contributing.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../CONTRIBUTING.rst diff --git a/docs/history.md b/docs/history.md new file mode 100644 index 0000000..4b2f749 --- /dev/null +++ b/docs/history.md @@ -0,0 +1,5 @@ +# History + +```{include} ../HISTORY.md +:heading-offset: 1 +``` diff --git a/docs/history.rst b/docs/history.rst deleted file mode 100644 index 2506499..0000000 --- a/docs/history.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../HISTORY.rst diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..8150c20 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,22 @@ +# Welcome to Python Magnet Geometry's documentation! + +```{toctree} +:maxdepth: 2 +:caption: Contents: + +readme +installation +usage +modules +contributing +authors +history +breaking_changes +api_usage +``` + +## Indices and tables + +- {ref}`genindex` +- {ref}`modindex` +- {ref}`search` diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 96f7476..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,20 +0,0 @@ -Welcome to Python Magnet Geometry's documentation! -================================================== - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - readme - installation - usage - modules - contributing - authors - history - -Indices and tables -================== -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..b89dab9 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,41 @@ +# Installation + +## Stable release + +To install Python Magnet Geometry, run this command in your terminal: + +``` console +$ pip install python_magnetgeo +``` + +This is the preferred method to install Python Magnet Geometry, as it +will always install the most recent stable release. + +If you don\'t have [pip](https://pip.pypa.io) installed, this [Python +installation +guide](http://docs.python-guide.org/en/latest/starting/installation/) +can guide you through the process. + +## From sources + +The sources for Python Magnet Geometry can be downloaded from the +[Github repo](https://github.com/Trophime/python_magnetgeo). + +You can either clone the public repository: + +``` console +$ git clone git://github.com/Trophime/python_magnetgeo +``` + +Or download the +[tarball](https://github.com/Trophime/python_magnetgeo/tarball/master): + +``` console +$ curl -OJL https://github.com/Trophime/python_magnetgeo/tarball/master +``` + +Once you have a copy of the source, you can install it with: + +``` console +$ python setup.py install +``` diff --git a/docs/installation.rst b/docs/installation.rst deleted file mode 100644 index 8114700..0000000 --- a/docs/installation.rst +++ /dev/null @@ -1,51 +0,0 @@ -.. highlight:: shell - -============ -Installation -============ - - -Stable release --------------- - -To install Python Magnet Geometry, run this command in your terminal: - -.. code-block:: console - - $ pip install python_magnetgeo - -This is the preferred method to install Python Magnet Geometry, as it will always install the most recent stable release. - -If you don't have `pip`_ installed, this `Python installation guide`_ can guide -you through the process. - -.. _pip: https://pip.pypa.io -.. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ - - -From sources ------------- - -The sources for Python Magnet Geometry can be downloaded from the `Github repo`_. - -You can either clone the public repository: - -.. code-block:: console - - $ git clone git://github.com/Trophime/python_magnetgeo - -Or download the `tarball`_: - -.. code-block:: console - - $ curl -OJL https://github.com/Trophime/python_magnetgeo/tarball/master - -Once you have a copy of the source, you can install it with: - -.. code-block:: console - - $ python setup.py install - - -.. _Github repo: https://github.com/Trophime/python_magnetgeo -.. _tarball: https://github.com/Trophime/python_magnetgeo/tarball/master diff --git a/docs/modules.md b/docs/modules.md new file mode 100644 index 0000000..1ade69c --- /dev/null +++ b/docs/modules.md @@ -0,0 +1,7 @@ +# python_magnetgeo + +```{toctree} +:maxdepth: 4 + +python_magnetgeo +``` diff --git a/docs/modules.rst b/docs/modules.rst deleted file mode 100644 index eaca3a6..0000000 --- a/docs/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -python_magnetgeo -================ - -.. toctree:: - :maxdepth: 4 - - python_magnetgeo diff --git a/docs/python_magnetgeo.hts.md b/docs/python_magnetgeo.hts.md new file mode 100644 index 0000000..24bf01e --- /dev/null +++ b/docs/python_magnetgeo.hts.md @@ -0,0 +1,43 @@ +# python_magnetgeo.hts package + +## Submodules + +### python_magnetgeo.hts.dblpancake module + +```{automodule} python_magnetgeo.hts.dblpancake +:members: +:undoc-members: +:show-inheritance: +``` + +### python_magnetgeo.hts.isolation module + +```{automodule} python_magnetgeo.hts.isolation +:members: +:undoc-members: +:show-inheritance: +``` + +### python_magnetgeo.hts.pancake module + +```{automodule} python_magnetgeo.hts.pancake +:members: +:undoc-members: +:show-inheritance: +``` + +### python_magnetgeo.hts.tape module + +```{automodule} python_magnetgeo.hts.tape +:members: +:undoc-members: +:show-inheritance: +``` + +## Module contents + +```{automodule} python_magnetgeo.hts +:members: +:undoc-members: +:show-inheritance: +``` diff --git a/docs/python_magnetgeo.hts.rst b/docs/python_magnetgeo.hts.rst deleted file mode 100644 index 082c49c..0000000 --- a/docs/python_magnetgeo.hts.rst +++ /dev/null @@ -1,45 +0,0 @@ -python\_magnetgeo.hts package -============================= - -Submodules ----------- - -python\_magnetgeo.hts.dblpancake module ---------------------------------------- - -.. automodule:: python_magnetgeo.hts.dblpancake - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.hts.isolation module --------------------------------------- - -.. automodule:: python_magnetgeo.hts.isolation - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.hts.pancake module ------------------------------------- - -.. automodule:: python_magnetgeo.hts.pancake - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.hts.tape module ---------------------------------- - -.. automodule:: python_magnetgeo.hts.tape - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: python_magnetgeo.hts - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/python_magnetgeo.md b/docs/python_magnetgeo.md new file mode 100644 index 0000000..48c34da --- /dev/null +++ b/docs/python_magnetgeo.md @@ -0,0 +1,259 @@ +# python_magnetgeo package + +## Subpackages + +```{toctree} +:maxdepth: 4 + +python_magnetgeo.hts +``` + +## Submodules + +### python_magnetgeo.Bitter module + +```{automodule} python_magnetgeo.Bitter +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Bitters module + +```{automodule} python_magnetgeo.Bitters +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Chamfer module + +```{automodule} python_magnetgeo.Chamfer +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Contour2D module + +```{automodule} python_magnetgeo.Contour2D +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Groove module + +```{automodule} python_magnetgeo.Groove +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Helix module + +```{automodule} python_magnetgeo.Helix +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.InnerCurrentLead module + +```{automodule} python_magnetgeo.InnerCurrentLead +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Insert module + +```{automodule} python_magnetgeo.Insert +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.MSite module + +```{automodule} python_magnetgeo.MSite +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Model3D module + +```{automodule} python_magnetgeo.Model3D +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.ModelAxi module + +```{automodule} python_magnetgeo.ModelAxi +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.OuterCurrentLead module + +```{automodule} python_magnetgeo.OuterCurrentLead +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Probe module + +```{automodule} python_magnetgeo.Probe +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Profile module + +```{automodule} python_magnetgeo.Profile +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Ring module + +```{automodule} python_magnetgeo.Ring +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Screen module + +```{automodule} python_magnetgeo.Screen +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Shape module + +```{automodule} python_magnetgeo.Shape +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Supra module + +```{automodule} python_magnetgeo.Supra +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.SupraStructure module + +```{automodule} python_magnetgeo.SupraStructure +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.Supras module + +```{automodule} python_magnetgeo.Supras +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.base module + +```{automodule} python_magnetgeo.base +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.coolingslit module + +```{automodule} python_magnetgeo.coolingslit +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.deserialize module + +```{automodule} python_magnetgeo.deserialize +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.enums module + +```{automodule} python_magnetgeo.enums +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.hcuts module + +```{automodule} python_magnetgeo.hcuts +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.logging_config module + +```{automodule} python_magnetgeo.logging_config +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.tierod module + +```{automodule} python_magnetgeo.tierod +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.utils module + +```{automodule} python_magnetgeo.utils +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.validation module + +```{automodule} python_magnetgeo.validation +:members: +:show-inheritance: +:undoc-members: +``` + +### python_magnetgeo.visualization module + +```{automodule} python_magnetgeo.visualization +:members: +:show-inheritance: +:undoc-members: +``` + +## Module contents + +```{automodule} python_magnetgeo +:members: +:show-inheritance: +:undoc-members: +``` diff --git a/docs/python_magnetgeo.rst b/docs/python_magnetgeo.rst deleted file mode 100644 index 70ae65b..0000000 --- a/docs/python_magnetgeo.rst +++ /dev/null @@ -1,229 +0,0 @@ -python\_magnetgeo package -========================= - -Subpackages ------------ - -.. toctree:: - :maxdepth: 4 - - python_magnetgeo.hts - -Submodules ----------- - -python\_magnetgeo.Bitter module -------------------------------- - -.. automodule:: python_magnetgeo.Bitter - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.Bitters module --------------------------------- - -.. automodule:: python_magnetgeo.Bitters - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.Chamfer module --------------------------------- - -.. automodule:: python_magnetgeo.Chamfer - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.Contour2D module ----------------------------------- - -.. automodule:: python_magnetgeo.Contour2D - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.Groove module -------------------------------- - -.. automodule:: python_magnetgeo.Groove - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.Helix module ------------------------------- - -.. automodule:: python_magnetgeo.Helix - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.InnerCurrentLead module ------------------------------------------ - -.. automodule:: python_magnetgeo.InnerCurrentLead - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.Insert module -------------------------------- - -.. automodule:: python_magnetgeo.Insert - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.MSite module ------------------------------- - -.. automodule:: python_magnetgeo.MSite - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.Model3D module --------------------------------- - -.. automodule:: python_magnetgeo.Model3D - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.ModelAxi module ---------------------------------- - -.. automodule:: python_magnetgeo.ModelAxi - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.OuterCurrentLead module ------------------------------------------ - -.. automodule:: python_magnetgeo.OuterCurrentLead - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.Probe module ------------------------------- - -.. automodule:: python_magnetgeo.Probe - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.Ring module ------------------------------ - -.. automodule:: python_magnetgeo.Ring - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.Screen module -------------------------------- - -.. automodule:: python_magnetgeo.Screen - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.Shape module ------------------------------- - -.. automodule:: python_magnetgeo.Shape - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.Supra module ------------------------------- - -.. automodule:: python_magnetgeo.Supra - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.SupraStructure module ---------------------------------------- - -.. automodule:: python_magnetgeo.SupraStructure - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.Supras module -------------------------------- - -.. automodule:: python_magnetgeo.Supras - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.base module ------------------------------ - -.. automodule:: python_magnetgeo.base - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.coolingslit module ------------------------------------- - -.. automodule:: python_magnetgeo.coolingslit - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.deserialize module ------------------------------------- - -.. automodule:: python_magnetgeo.deserialize - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.hcuts module ------------------------------- - -.. automodule:: python_magnetgeo.hcuts - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.tierod module -------------------------------- - -.. automodule:: python_magnetgeo.tierod - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.utils module ------------------------------- - -.. automodule:: python_magnetgeo.utils - :members: - :undoc-members: - :show-inheritance: - -python\_magnetgeo.validation module ------------------------------------ - -.. automodule:: python_magnetgeo.validation - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: python_magnetgeo - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/readme.md b/docs/readme.md new file mode 100644 index 0000000..451beda --- /dev/null +++ b/docs/readme.md @@ -0,0 +1,2 @@ +```{include} ../README.md +``` diff --git a/docs/readme.rst b/docs/readme.rst deleted file mode 100644 index 72a3355..0000000 --- a/docs/readme.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../README.rst diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..56b45ac --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,5 @@ +# Usage + +To use Python Magnet Geometry in a project: + + import python_magnetgeo diff --git a/docs/usage.rst b/docs/usage.rst deleted file mode 100644 index 8f631f4..0000000 --- a/docs/usage.rst +++ /dev/null @@ -1,7 +0,0 @@ -===== -Usage -===== - -To use Python Magnet Geometry in a project:: - - import python_magnetgeo diff --git a/python_magnetgeo/examples/FEATURE_DRY_RUN_ANALYSIS.md b/examples/FEATURE_DRY_RUN_ANALYSIS.md similarity index 100% rename from python_magnetgeo/examples/FEATURE_DRY_RUN_ANALYSIS.md rename to examples/FEATURE_DRY_RUN_ANALYSIS.md diff --git a/python_magnetgeo/examples/__init__.py b/examples/__init__.py similarity index 100% rename from python_magnetgeo/examples/__init__.py rename to examples/__init__.py diff --git a/examples/check_magnetgeo_yaml.py b/examples/check_magnetgeo_yaml.py index 4e54059..18906f9 100755 --- a/examples/check_magnetgeo_yaml.py +++ b/examples/check_magnetgeo_yaml.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding:utf-8 -*- """ Script to split an Helix YAML file into separate files for modelaxi and shape objects. @@ -20,21 +19,20 @@ python split_helix_yaml.py data/HL-31_H1.yaml """ -import sys -import yaml -import os -import glob import argparse +import glob +import os +import sys import python_magnetgeo as pmg -pmg.verify_class_registration() # Required for YAML loading - - from python_magnetgeo.logging_config import get_logger +pmg.verify_class_registration() # Required for YAML loading + # Get logger for this module logger = get_logger(__name__) + def check_yaml(input_file): """ Load magnetgeo YAML file. @@ -49,7 +47,7 @@ def check_yaml(input_file): basename = os.path.basename(input_file) # Change to basedir if it's not empty and not '.' - if basedir and basedir != '.': + if basedir and basedir != ".": logger.debug(f"Changing directory to: {basedir}") os.chdir(basedir) input_path = basename @@ -69,13 +67,12 @@ def check_yaml(input_file): def main(): """Main function to handle command line arguments.""" parser = argparse.ArgumentParser( - description='Check an YAML file.', - epilog='Example: %(prog)s data/HL-31_H1.yaml data/*.yaml' + description="Check an YAML file.", epilog="Example: %(prog)s data/HL-31_H1.yaml data/*.yaml" ) parser.add_argument( - 'input_files', - nargs='+', - help='Path(s) to input YAML file(s); glob patterns (e.g. "data/*.yaml") are supported' + "input_files", + nargs="+", + help='Path(s) to input YAML file(s); glob patterns (e.g. "data/*.yaml") are supported', ) args = parser.parse_args() @@ -100,6 +97,7 @@ def main(): except Exception as e: logger.error(f"Error processing {input_file}: {e}") import traceback + traceback.print_exc() errors += 1 diff --git a/examples/compress-hcut.py b/examples/compress-hcut.py index 341cb88..c340bb2 100644 --- a/examples/compress-hcut.py +++ b/examples/compress-hcut.py @@ -1,10 +1,10 @@ import argparse import glob -import sys import os +import sys -from python_magnetgeo.ModelAxi import ModelAxi from python_magnetgeo.Helix import Helix +from python_magnetgeo.ModelAxi import ModelAxi def main(): @@ -42,7 +42,6 @@ def main(): pmg.verify_class_registration() - errors = 0 for input_file in files: # basename = # dirname = @@ -61,7 +60,7 @@ def main(): print(f"Compact hcut: {len(nhcut.pitch)}", flush=True) - nhelix = Helix( + Helix( f"{obj.name}-compressed", obj.r, obj.z, @@ -74,7 +73,7 @@ def main(): obj.chamfers, obj.grooves, ) - print(f"Create new helix with compacted hcut") + print("Create new helix with compacted hcut") # save as yaml diff --git a/python_magnetgeo/examples/create_H1.py b/examples/create_H1.py similarity index 91% rename from python_magnetgeo/examples/create_H1.py rename to examples/create_H1.py index efd87a2..b8b66da 100644 --- a/python_magnetgeo/examples/create_H1.py +++ b/examples/create_H1.py @@ -1,26 +1,28 @@ import yaml + from python_magnetgeo.Helix import Helix + def create_test_yaml(): """Create test YAML files to debug the issue""" - + # Create a simple helix helix = Helix( name="H1", r=[10, 15], z=[0, 50], cutwidth=2.0, odd=False, dble=True, modelaxi=None, model3d=None, shape=None ) - + print("1. Creating YAML with default yaml.dump():") with open("H1_default.yaml", "w") as f: yaml.dump(helix, f) # Read it back to see what it looks like - with open("H1_default.yaml", "r") as f: + with open("H1_default.yaml") as f: content = f.read() print("Content:") print(content) print("-" * 50) - + if __name__ == "__main__": create_test_yaml() diff --git a/python_magnetgeo/examples/example_dependency_analysis.py b/examples/example_dependency_analysis.py similarity index 100% rename from python_magnetgeo/examples/example_dependency_analysis.py rename to examples/example_dependency_analysis.py diff --git a/python_magnetgeo/examples/helix-cut.py b/examples/helix-cut.py similarity index 95% rename from python_magnetgeo/examples/helix-cut.py rename to examples/helix-cut.py index efa435a..9628afb 100644 --- a/python_magnetgeo/examples/helix-cut.py +++ b/examples/helix-cut.py @@ -1,11 +1,10 @@ +import argparse + import yaml -from python_magnetgeo.Shape import Shape -from python_magnetgeo.ModelAxi import ModelAxi -from python_magnetgeo.Model3D import Model3D -from python_magnetgeo.Helix import Helix -import json -import argparse +from python_magnetgeo.Helix import Helix +from python_magnetgeo.Model3D import Model3D +from python_magnetgeo.ModelAxi import ModelAxi r = [38.6 / 2.0, 48.4 / 2.0] z = [] diff --git a/examples/helix_visualization.py b/examples/helix_visualization.py index 8345892..21b16ce 100644 --- a/examples/helix_visualization.py +++ b/examples/helix_visualization.py @@ -9,10 +9,10 @@ """ from python_magnetgeo.Helix import Helix +from python_magnetgeo.Model3D import Model3D +from python_magnetgeo.ModelAxi import ModelAxi from python_magnetgeo.Ring import Ring from python_magnetgeo.Screen import Screen -from python_magnetgeo.ModelAxi import ModelAxi -from python_magnetgeo.Model3D import Model3D print("="*60) print("Helix + ModelAxi Visualization Example") @@ -56,7 +56,7 @@ try: import matplotlib.pyplot as plt - + # Example 1: Helix with modelaxi zone print("Example 1: Helix with ModelAxi zone") ax = helix.plot_axisymmetric( @@ -66,7 +66,7 @@ plt.savefig("example_helix_with_modelaxi.png", dpi=150, bbox_inches='tight') print(" ✓ Saved to example_helix_with_modelaxi.png") plt.close() - + # Example 2: Helix without modelaxi zone print("\nExample 2: Helix without ModelAxi zone") ax = helix.plot_axisymmetric( @@ -77,33 +77,33 @@ plt.savefig("example_helix_without_modelaxi.png", dpi=150, bbox_inches='tight') print(" ✓ Saved to example_helix_without_modelaxi.png") plt.close() - + # Example 3: Complete assembly with Helix, Ring, and Screen print("\nExample 3: Complete magnet assembly") - + # Create Ring ring = Ring( name="Ring_H1H2", r=[50.0, 55.0, 60.0, 65.0], z=[110.0, 130.0] ) - + # Create Screen inner_screen = Screen( name="Inner_Shield", r=[40.0, 45.0], z=[-20.0, 150.0] ) - + outer_screen = Screen( name="Outer_Shield", r=[70.0, 75.0], z=[-20.0, 150.0] ) - + # Combined plot fig, ax = plt.subplots(figsize=(12, 14)) - + # Plot screens (background) inner_screen.plot_axisymmetric( ax=ax, @@ -117,7 +117,7 @@ alpha=0.3, show_legend=False ) - + # Plot helix with modelaxi zone helix.plot_axisymmetric( ax=ax, @@ -127,7 +127,7 @@ modelaxi_alpha=0.25, show_legend=False ) - + # Plot ring ring.plot_axisymmetric( ax=ax, @@ -135,10 +135,10 @@ alpha=0.6, show_legend=False ) - - ax.set_title("Magnet Assembly: Helix + Ring + Screens", + + ax.set_title("Magnet Assembly: Helix + Ring + Screens", fontsize=14, fontweight='bold') - + # Add legend manually from matplotlib.patches import Patch legend_elements = [ @@ -148,11 +148,11 @@ Patch(facecolor='lightgray', alpha=0.3, hatch='///', label='Screen') ] ax.legend(handles=legend_elements, loc='upper right', fontsize=10) - + plt.savefig("example_complete_assembly.png", dpi=150, bbox_inches='tight') print(" ✓ Saved to example_complete_assembly.png") plt.close() - + print("\n" + "="*60) print("✓ All examples completed successfully!") print("Check the generated PNG files:") @@ -160,7 +160,7 @@ print(" - example_helix_without_modelaxi.png") print(" - example_complete_assembly.png") print("="*60) - + except ImportError: print("! Matplotlib not installed") print(" Install with: pip install matplotlib") diff --git a/examples/insert_visualization.py b/examples/insert_visualization.py index 870ad21..d68fc0a 100644 --- a/examples/insert_visualization.py +++ b/examples/insert_visualization.py @@ -6,10 +6,10 @@ with multiple helical coils and their modelaxi zones. """ -from python_magnetgeo.Insert import Insert from python_magnetgeo.Helix import Helix -from python_magnetgeo.ModelAxi import ModelAxi +from python_magnetgeo.Insert import Insert from python_magnetgeo.Model3D import Model3D +from python_magnetgeo.ModelAxi import ModelAxi print("="*60) print("Insert Visualization Example") @@ -23,16 +23,16 @@ # h=50-(i*5) → sum = 100-(i*10) h_val = 50.0 - (i * 5.0) n_middle_turns = 16 - (i * 2) - + modelaxi = ModelAxi( name=f"modelaxi_H{i+1}", h=h_val, turns=[2, n_middle_turns, 2], pitch=[5.0, 5.0, 5.0] ) - + model3d = Model3D(name="", cad=f"H{i+1}", with_channels=False, with_shapes=False) - + helix = Helix( name=f"H{i+1}", r=[30.0 + i*15.0, 40.0 + i*15.0], # Concentric layers @@ -63,7 +63,7 @@ try: import matplotlib.pyplot as plt - + # Visualize the insert print("Generating visualization...") ax = insert.plot_axisymmetric( @@ -71,16 +71,16 @@ figsize=(12, 14), show_modelaxi=True ) - + plt.savefig("example_insert.png", dpi=150, bbox_inches='tight') print("✓ Saved visualization to example_insert.png") - + plt.close() - + print("\n" + "="*60) print("✓ Example completed successfully!") print("="*60) - + except ImportError: print("! Matplotlib not installed") print(" Install with: pip install matplotlib") diff --git a/examples/json_to_yaml.py b/examples/json_to_yaml.py index 1b45ca5..bde5f69 100644 --- a/examples/json_to_yaml.py +++ b/examples/json_to_yaml.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding:utf-8 -*- """ Script to load a python_magnetgeo object from a JSON file and dump it to a YAML file. @@ -14,18 +13,17 @@ python json_to_yaml.py data/*.json """ -import sys +import argparse +import glob import json import os -import glob -import argparse +import sys import python_magnetgeo as pmg +from python_magnetgeo.logging_config import get_logger pmg.verify_class_registration() # Required for YAML loading -from python_magnetgeo.logging_config import get_logger - logger = get_logger(__name__) @@ -55,7 +53,7 @@ def json_to_yaml(input_file: str) -> str: os.chdir(basedir) logger.debug(f"Loading JSON: {basename}") - with open(basename, "r") as f: + with open(basename) as f: data = json.load(f) print( f"Loaded JSON data:\n{json.dumps(data, indent=2)}" diff --git a/python_magnetgeo/examples/lazy_loading_demo.py b/examples/lazy_loading_demo.py similarity index 99% rename from python_magnetgeo/examples/lazy_loading_demo.py rename to examples/lazy_loading_demo.py index 88003ab..e9fa387 100755 --- a/python_magnetgeo/examples/lazy_loading_demo.py +++ b/examples/lazy_loading_demo.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding:utf-8 -*- """ Demonstration of lazy loading in python_magnetgeo. @@ -14,7 +13,6 @@ python lazy_loading_demo.py """ -import sys import time diff --git a/python_magnetgeo/examples/load_profile_from_dat.py b/examples/load_profile_from_dat.py similarity index 97% rename from python_magnetgeo/examples/load_profile_from_dat.py rename to examples/load_profile_from_dat.py index 54d5c54..b4ea954 100644 --- a/python_magnetgeo/examples/load_profile_from_dat.py +++ b/examples/load_profile_from_dat.py @@ -18,11 +18,9 @@ import sys from pathlib import Path -from typing import Optional # Add the current directory to the Python path to import python_magnetgeo #sys.path.insert(0, str(Path(__file__).parent)) - from python_magnetgeo.Profile import Profile @@ -69,7 +67,7 @@ def load_profile_from_dat(dat_file_path: str) -> Profile: for encoding in encodings: try: - with open(dat_path, "r", encoding=encoding, errors='strict') as f: + with open(dat_path, encoding=encoding, errors='strict') as f: file_content = f.readlines() successful_encoding = encoding print(f'{dat_file_path}: Successfully read file with encoding: {encoding}', flush=True) @@ -255,15 +253,15 @@ def main(): print(f"Saved JSON to: {args.save_json}/{profile.cad}.json") else: # Default: show profile information - print(f"Profile loaded successfully!") + print("Profile loaded successfully!") print(f" CAD: {profile.cad}") print(f" Points: {len(profile.points)}") print(f" Has labels: {profile.labels is not None and any(label != 0 for label in profile.labels)}") if args.verbose: - print(f"\nProfile representation:") + print("\nProfile representation:") print(f" {profile!r}") - print(f"\nFirst 5 points:") + print("\nFirst 5 points:") for i, (point, label) in enumerate(zip(profile.points[:5], profile.labels[:5]), 1): print(f" {i}. [{point[0]:.2f}, {point[1]:.2f}] label={label}") if len(profile.points) > 5: diff --git a/python_magnetgeo/examples/logging_examples.py b/examples/logging_examples.py similarity index 96% rename from python_magnetgeo/examples/logging_examples.py rename to examples/logging_examples.py index 6cc6b09..157ae67 100644 --- a/python_magnetgeo/examples/logging_examples.py +++ b/examples/logging_examples.py @@ -7,27 +7,28 @@ import python_magnetgeo as pmg + def example_basic_logging(): """Basic logging example""" print("\n=== Example 1: Basic Logging (INFO level) ===") - + # Configure with default INFO level pmg.configure_logging(level='INFO') - + # These operations will generate INFO-level logs helix = pmg.Helix(name="example_helix", r=[10, 20], z=[0, 50]) print(f"Created: {helix.name}") - + # Save to file (will log) helix.write_to_yaml() def example_debug_logging(): """Debug logging example""" print("\n=== Example 2: Debug Logging (shows all details) ===") - + # Enable DEBUG level for detailed information pmg.configure_logging(level='DEBUG') - + # Load a YAML file (shows detailed loading process) try: obj = pmg.load("data/HL-31_H1.yaml") @@ -38,34 +39,34 @@ def example_debug_logging(): def example_file_logging(): """Log to file example""" print("\n=== Example 3: Logging to File ===") - + # Log to both console and file pmg.configure_logging( level='INFO', log_file='magnetgeo_example.log' ) - + print("Logs will be written to 'magnetgeo_example.log'") - + # Create some objects ring = pmg.Ring(name="example_ring", r=[5, 15], z=[0, 10]) ring.write_to_yaml() - + print("Check magnetgeo_example.log for detailed logs") def example_different_levels(): """Example with different levels for console and file""" print("\n=== Example 4: Different Levels for Console and File ===") - + # INFO on console, DEBUG in file pmg.configure_logging( console_level='INFO', file_level='DEBUG', log_file='detailed_debug.log' ) - + print("Console shows INFO, file contains DEBUG details") - + # This will show INFO on console but DEBUG details in file try: helix = pmg.Helix(name="test", r=[10, 20], z=[0, 50]) @@ -76,24 +77,24 @@ def example_different_levels(): def example_validation_logging(): """Example showing validation error logging""" print("\n=== Example 5: Validation Error Logging ===") - + # Use DEBUG to see validation details pmg.configure_logging(level='DEBUG') - + print("Attempting to create invalid objects (will log errors):") - + # Empty name try: helix = pmg.Helix(name="", r=[10, 20], z=[0, 50]) except pmg.ValidationError as e: print(f" Caught: {e}") - + # Wrong order try: helix = pmg.Helix(name="test", r=[20, 10], z=[0, 50]) # r values descending except pmg.ValidationError as e: print(f" Caught: {e}") - + # Wrong type try: helix = pmg.Helix(name="test", r="not a list", z=[0, 50]) @@ -103,58 +104,58 @@ def example_validation_logging(): def example_runtime_level_change(): """Example of changing log level at runtime""" print("\n=== Example 6: Changing Log Level at Runtime ===") - + # Start with INFO pmg.configure_logging(level='INFO') print("Starting with INFO level") - + helix = pmg.Helix(name="test1", r=[10, 20], z=[0, 50]) - + # Switch to DEBUG for detailed inspection print("\nSwitching to DEBUG level") pmg.set_level('DEBUG') - + helix2 = pmg.Helix(name="test2", r=[15, 25], z=[5, 55]) - + # Back to WARNING (minimal output) print("\nSwitching to WARNING level (minimal output)") pmg.set_level('WARNING') - + helix3 = pmg.Helix(name="test3", r=[20, 30], z=[10, 60]) print("Created test3 (no logs because WARNING level)") def example_custom_format(): """Example with custom log format""" print("\n=== Example 7: Custom Log Format ===") - + # Use detailed format with function names and line numbers from python_magnetgeo.logging_config import DETAILED_FORMAT - + pmg.configure_logging( level='DEBUG', log_format=DETAILED_FORMAT ) - + print("Using detailed format (includes function:line)") - + helix = pmg.Helix(name="formatted", r=[10, 20], z=[0, 50]) def example_silent_mode(): """Example of completely silent operation""" print("\n=== Example 8: Silent Mode (no logging) ===") - + # Configure logging first pmg.configure_logging(level='INFO') - + # Then disable it pmg.disable_logging() print("Logging disabled - no log output:") - + helix = pmg.Helix(name="silent", r=[10, 20], z=[0, 50]) helix.write_to_yaml() - + print("Operations completed without logs") - + # Re-enable pmg.enable_logging() print("Logging re-enabled") @@ -164,7 +165,7 @@ def main(): print("=" * 60) print("python_magnetgeo Logging Examples") print("=" * 60) - + try: example_basic_logging() example_debug_logging() @@ -174,11 +175,11 @@ def main(): example_runtime_level_change() example_custom_format() example_silent_mode() - + print("\n" + "=" * 60) print("All examples completed!") print("=" * 60) - + except Exception as e: print(f"\nExample failed with error: {e}") import traceback diff --git a/examples/migrate_chamfer_l_to_length.py b/examples/migrate_chamfer_l_to_length.py new file mode 100644 index 0000000..2e8d981 --- /dev/null +++ b/examples/migrate_chamfer_l_to_length.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Migrate Chamfer YAML files: rename the 'l' key to 'length'. + +Applies only inside ! blocks, leaving all other YAML content +untouched. Run with --dry-run first to preview changes. + +Usage: + python migrate_chamfer_l_to_length.py path/to/file.yaml + python migrate_chamfer_l_to_length.py path/to/dir/ + python migrate_chamfer_l_to_length.py --dry-run path/to/dir/ +""" + +import argparse +import re +import sys +from pathlib import Path + + +def migrate_content(content: str) -> tuple[str, int]: + """ + Replace 'l:' with 'length:' inside every ! block. + + Returns (new_content, number_of_replacements). + """ + lines = content.splitlines(keepends=True) + result: list[str] = [] + changes = 0 + + in_chamfer = False + content_indent: int | None = None # indent of the first key inside the block + + for line in lines: + stripped = line.lstrip() + line_indent = len(line) - len(stripped) + + # ── enter a Chamfer block ────────────────────────────────────────── + # Matches both "!" and "- !" (list items) + if "!" in line: + in_chamfer = True + content_indent = None + result.append(line) + continue + + # ── track / exit the current Chamfer block ───────────────────────── + if in_chamfer and stripped and not stripped.startswith("#"): + if content_indent is None: + content_indent = line_indent # first real key sets the baseline + + if stripped.startswith("!<") or line_indent < content_indent: + in_chamfer = False + content_indent = None + + # ── apply replacement inside the block ──────────────────────────── + if in_chamfer: + new_line = re.sub(r"^(\s*)l:\s", r"\1length: ", line) + if new_line != line: + changes += 1 + line = new_line + + result.append(line) + + return "".join(result), changes + + +def migrate_file(path: Path, dry_run: bool) -> int: + """Migrate one file; return number of replacements made.""" + try: + content = path.read_text(encoding="utf-8") + except OSError as exc: + print(f" ERROR reading {path}: {exc}", file=sys.stderr) + return 0 + + new_content, n = migrate_content(content) + if n == 0: + return 0 + + label = "Would update" if dry_run else "Updated" + suffix = "s" if n != 1 else "" + print(f"{label}: {path} ({n} replacement{suffix})") + + if not dry_run: + path.write_text(new_content, encoding="utf-8") + + return n + + +def collect_yaml_files(paths: list[str]) -> list[Path]: + files: list[Path] = [] + for p in paths: + path = Path(p) + if path.is_dir(): + files.extend(path.rglob("*.yaml")) + files.extend(path.rglob("*.yml")) + else: + files.append(path) + return files + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("paths", nargs="+", metavar="PATH", help="YAML files or directories") + parser.add_argument("--dry-run", action="store_true", help="Preview changes without writing") + args = parser.parse_args() + + files = collect_yaml_files(args.paths) + if not files: + print("No YAML files found.") + return + + total = sum(migrate_file(f, dry_run=args.dry_run) for f in files) + + if total == 0: + print("No Chamfer 'l:' keys found — nothing to do.") + elif args.dry_run: + print(f"\n{total} replacement(s) would be made. Re-run without --dry-run to apply.") + else: + print(f"\n{total} replacement(s) applied.") + + +if __name__ == "__main__": + main() diff --git a/python_magnetgeo/examples/probe_example.py b/examples/probe_example.py similarity index 97% rename from python_magnetgeo/examples/probe_example.py rename to examples/probe_example.py index 99c10bc..6825f69 100644 --- a/python_magnetgeo/examples/probe_example.py +++ b/examples/probe_example.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding:utf-8 -*- """ Example usage of the Probe class @@ -82,4 +81,4 @@ """ print("Example YAML structure:") -print(yaml_example) \ No newline at end of file +print(yaml_example) diff --git a/python_magnetgeo/examples/probe_usage_python.py b/examples/probe_usage_python.py similarity index 92% rename from python_magnetgeo/examples/probe_usage_python.py rename to examples/probe_usage_python.py index 729d19a..7c8c396 100644 --- a/python_magnetgeo/examples/probe_usage_python.py +++ b/examples/probe_usage_python.py @@ -1,16 +1,14 @@ #!/usr/bin/env python3 -# -*- coding:utf-8 -*- """ Examples of using the updated Insert, Bitters, and Supras classes with probes """ -from python_magnetgeo.Insert import Insert from python_magnetgeo.Bitters import Bitters -from python_magnetgeo.Supras import Supras +from python_magnetgeo.Insert import Insert from python_magnetgeo.Probe import Probe -from python_magnetgeo.Helix import Helix -from python_magnetgeo.Ring import Ring +from python_magnetgeo.Supras import Supras + # Example 1: Create an Insert with embedded probes def create_insert_with_probes(): @@ -21,14 +19,14 @@ def create_insert_with_probes(): index=["V1", "V2", "V3"], locations=[[15.2, 0.0, 10.5], [18.7, 0.0, 12.3], [22.1, 0.0, 14.1]] ) - + temp_probes = Probe( - name="H1_temperature", + name="H1_temperature", probe_type="temperature", index=[1, 2], locations=[[16.5, 5.2, 11.0], [20.0, -3.1, 13.5]] ) - + # Create insert with probes insert = Insert( name="M9_Insert", @@ -41,12 +39,12 @@ def create_insert_with_probes(): outerbore=45.2, probes=[voltage_probes, temp_probes] # Embedded probe objects ) - + print("Insert with probes created:") print(f" Number of probes: {len(insert.probes)}") for probe in insert.probes: print(f" {probe.name}: {probe.get_probe_count()} {probe.probe_type} probes") - + return insert # Example 2: Create Insert with probe references (strings) @@ -54,7 +52,7 @@ def create_insert_with_probe_references(): insert = Insert( name="M9_Insert_Refs", helices=["H1", "H2"], - rings=["R1"], + rings=["R1"], currentleads=["inner_lead"], hangles=[0.0, 180.0], rangles=[0.0, 90.0, 180.0, 270.0], @@ -62,10 +60,10 @@ def create_insert_with_probe_references(): outerbore=45.2, probes=["voltage_probes", "temp_probes"] # String references to YAML files ) - + # When update() is called, these strings will be converted to Probe objects # insert.update() # This would load voltage_probes.yaml and temp_probes.yaml - + print("Insert with probe references created") return insert @@ -73,11 +71,11 @@ def create_insert_with_probe_references(): def create_bitters_with_probes(): bitter_probes = Probe( name="bitter_monitoring", - probe_type="voltage_taps", + probe_type="voltage_taps", index=["BV1", "BV2"], locations=[[20.0, 0.0, 5.0], [30.0, 0.0, -5.0]] ) - + bitters = Bitters( name="Bitter_Stack", magnets=["B1", "B2", "B3"], @@ -85,10 +83,10 @@ def create_bitters_with_probes(): outerbore=35.0, probes=[bitter_probes] ) - + print("Bitters with probes created:") print(f" Number of probes: {len(bitters.probes)}") - + return bitters # Example 4: Create Supras with multiple probe types @@ -99,14 +97,14 @@ def create_supras_with_probes(): index=["Q1", "Q2", "Q3"], locations=[[18.0, 0.0, 10.0], [20.0, 0.0, 0.0], [22.0, 0.0, -10.0]] ) - + temp_probes = Probe( name="hts_temperature", - probe_type="temperature", + probe_type="temperature", index=["T_HTS1", "T_HTS2"], locations=[[19.0, 2.0, 5.0], [21.0, -2.0, -5.0]] ) - + supras = Supras( name="HTS_Stack", magnets=["S1", "S2"], @@ -114,19 +112,19 @@ def create_supras_with_probes(): outerbore=25.0, probes=[quench_probes, temp_probes] ) - + print("Supras with probes created:") print(f" Number of probes: {len(supras.probes)}") for probe in supras.probes: print(f" {probe.name}: {probe.get_probe_count()} {probe.probe_type} probes") - + return supras # Example 5: Load from YAML with mixed probe types def load_from_yaml_example(): # This would load an Insert from YAML that contains both embedded probes # and string references to external probe files - + yaml_content = """ name: M9_Mixed_Probes helices: ["H1", "H2"] @@ -146,33 +144,33 @@ def load_from_yaml_example(): - [20.0, -3.1, 13.5] - [24.8, 2.7, 16.2] """ - + # insert = Insert.from_yaml("mixed_probes.yaml") # insert.update() # This would convert string references to Probe objects - + print("Example YAML structure for mixed probes shown above") if __name__ == "__main__": print("=== Probe Integration Examples ===\n") - + # Run examples insert1 = create_insert_with_probes() print() - + insert2 = create_insert_with_probe_references() print() - + bitters = create_bitters_with_probes() print() - + supras = create_supras_with_probes() print() - + load_from_yaml_example() - + # Save examples to YAML print("\n=== Saving to YAML ===") insert1.write_to_yaml() - bitters.write_to_yaml() + bitters.write_to_yaml() supras.write_to_yaml() - print("YAML files created: M9_Insert.yaml, Bitter_Stack.yaml, HTS_Stack.yaml") \ No newline at end of file + print("YAML files created: M9_Insert.yaml, Bitter_Stack.yaml, HTS_Stack.yaml") diff --git a/python_magnetgeo/examples/quick_reference_get_required_files.py b/examples/quick_reference_get_required_files.py similarity index 100% rename from python_magnetgeo/examples/quick_reference_get_required_files.py rename to examples/quick_reference_get_required_files.py diff --git a/examples/salome_export.py b/examples/salome_export.py new file mode 100644 index 0000000..2a00875 --- /dev/null +++ b/examples/salome_export.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python + +### +### This file is generated automatically by SALOME v9.9.0 with dump python functionality +### + +import os + +import salome + +salome.salome_init() + +os.chdir("/tmp") + +### +### SHAPER component +### + +from salome.shaper import model + +model.begin() +partSet = model.moduleDocument() + +### Create Part +Part_1 = model.addPart(partSet) +Part_1_doc = Part_1.document() + +### Create Sketch +Sketch_1 = model.addSketch(Part_1_doc, model.defaultPlane("XOY")) + +### Create SketchLine +SketchLine_1 = Sketch_1.addLine(59.34894259818733, 0, 0, 0) + +### Create SketchProjection +SketchProjection_1 = Sketch_1.addProjection(model.selection("VERTEX", "PartSet/Origin"), False) +SketchPoint_1 = SketchProjection_1.createdFeature() +Sketch_1.setCoincident(SketchLine_1.endPoint(), SketchPoint_1.result()) + +### Create SketchLine +SketchLine_2 = Sketch_1.addLine(0, 0, 0, 41.49848942598186) + +### Create SketchLine +SketchLine_3 = Sketch_1.addLine(0, 41.49848942598186, 59.34894259818733, 41.49848942598186) + +### Create SketchLine +SketchLine_4 = Sketch_1.addLine(59.34894259818733, 41.49848942598186, 59.34894259818733, 0) +Sketch_1.setCoincident(SketchLine_4.endPoint(), SketchLine_1.startPoint()) +Sketch_1.setCoincident(SketchLine_1.endPoint(), SketchLine_2.startPoint()) +Sketch_1.setCoincident(SketchLine_2.endPoint(), SketchLine_3.startPoint()) +Sketch_1.setCoincident(SketchLine_3.endPoint(), SketchLine_4.startPoint()) +Sketch_1.setHorizontal(SketchLine_1.result()) +Sketch_1.setVertical(SketchLine_2.result()) +Sketch_1.setHorizontal(SketchLine_3.result()) +Sketch_1.setVertical(SketchLine_4.result()) + +### Create SketchLine +SketchLine_5 = Sketch_1.addLine(59.95921450151058, -33.56495468277945, -10.22205438066466, -33.56495468277945) + +### Create SketchLine +SketchLine_6 = Sketch_1.addLine(-10.22205438066466, -33.56495468277945, -10.22205438066466, -59.19637462235649) + +### Create SketchLine +SketchLine_7 = Sketch_1.addLine(-10.22205438066466, -59.19637462235649, 59.95921450151058, -59.19637462235649) + +### Create SketchLine +SketchLine_8 = Sketch_1.addLine(59.95921450151058, -59.19637462235649, 59.95921450151058, -33.56495468277945) +Sketch_1.setCoincident(SketchLine_8.endPoint(), SketchLine_5.startPoint()) +Sketch_1.setCoincident(SketchLine_5.endPoint(), SketchLine_6.startPoint()) +Sketch_1.setCoincident(SketchLine_6.endPoint(), SketchLine_7.startPoint()) +Sketch_1.setCoincident(SketchLine_7.endPoint(), SketchLine_8.startPoint()) +Sketch_1.setHorizontal(SketchLine_5.result()) +Sketch_1.setVertical(SketchLine_6.result()) +Sketch_1.setHorizontal(SketchLine_7.result()) +Sketch_1.setVertical(SketchLine_8.result()) + +### Create SketchLine +SketchLine_9 = Sketch_1.addLine(-123.7326283987915, -10.6797583081571, -64.23111782477342, -10.6797583081571) + +### Create SketchLine +SketchLine_10 = Sketch_1.addLine(-64.23111782477342, -10.6797583081571, -64.23111782477342, 21.3595166163142) + +### Create SketchLine +SketchLine_11 = Sketch_1.addLine(-64.23111782477342, 21.3595166163142, -123.7326283987915, 21.3595166163142) + +### Create SketchLine +SketchLine_12 = Sketch_1.addLine(-123.7326283987915, 21.3595166163142, -123.7326283987915, -10.6797583081571) +Sketch_1.setCoincident(SketchLine_12.endPoint(), SketchLine_9.startPoint()) +Sketch_1.setCoincident(SketchLine_9.endPoint(), SketchLine_10.startPoint()) +Sketch_1.setCoincident(SketchLine_10.endPoint(), SketchLine_11.startPoint()) +Sketch_1.setCoincident(SketchLine_11.endPoint(), SketchLine_12.startPoint()) +Sketch_1.setHorizontal(SketchLine_9.result()) +Sketch_1.setVertical(SketchLine_10.result()) +Sketch_1.setHorizontal(SketchLine_11.result()) +Sketch_1.setVertical(SketchLine_12.result()) + +### Create SketchLine +SketchLine_13 = Sketch_1.addLine(-141.7356495468278, 31.73413897280967, -180.487915407855, 31.73413897280967) + +### Create SketchLine +SketchLine_14 = Sketch_1.addLine(-180.487915407855, 31.73413897280967, -180.487915407855, -14.03625377643504) + +### Create SketchLine +SketchLine_15 = Sketch_1.addLine(-180.487915407855, -14.03625377643504, -141.7356495468278, -14.03625377643504) + +### Create SketchLine +SketchLine_16 = Sketch_1.addLine(-141.7356495468278, -14.03625377643504, -141.7356495468278, 31.73413897280967) +Sketch_1.setCoincident(SketchLine_16.endPoint(), SketchLine_13.startPoint()) +Sketch_1.setCoincident(SketchLine_13.endPoint(), SketchLine_14.startPoint()) +Sketch_1.setCoincident(SketchLine_14.endPoint(), SketchLine_15.startPoint()) +Sketch_1.setCoincident(SketchLine_15.endPoint(), SketchLine_16.startPoint()) +Sketch_1.setHorizontal(SketchLine_13.result()) +Sketch_1.setVertical(SketchLine_14.result()) +Sketch_1.setHorizontal(SketchLine_15.result()) +Sketch_1.setVertical(SketchLine_16.result()) +model.do() + +### Create Face +Face_1_objects = [model.selection("FACE", "Sketch_1/Face-SketchLine_5f-SketchLine_6f-SketchLine_7f-SketchLine_8f"), + model.selection("FACE", "Sketch_1/Face-SketchLine_4r-SketchLine_3r-SketchLine_2r-SketchLine_1r"), + model.selection("FACE", "Sketch_1/Face-SketchLine_9f-SketchLine_10f-SketchLine_11f-SketchLine_12f"), + model.selection("FACE", "Sketch_1/Face-SketchLine_13f-SketchLine_14f-SketchLine_15f-SketchLine_16f")] +Face_1 = model.addFace(Part_1_doc, Face_1_objects) + +### Create Export +Export_1 = model.exportToXAO(Part_1_doc, 'shaper_95169b12.xao', model.selection("FACE", "Face_1_1"), 'XAO') + +### Create Export +Export_2 = model.exportToXAO(Part_1_doc, 'shaper__ylaz8rk.xao', model.selection("FACE", "Face_1_2"), 'XAO') + +### Create Export +Export_3 = model.exportToXAO(Part_1_doc, 'shaper_tfnq0q05.xao', model.selection("FACE", "Face_1_3"), 'XAO') + +### Create Export +Export_4 = model.exportToXAO(Part_1_doc, 'shaper_qienyxeo.xao', model.selection("FACE", "Face_1_4"), 'XAO') + +model.end() + +### +### SHAPERSTUDY component +### + +model.publishToShaperStudy() +import SHAPERSTUDY + +Face_1_1, = SHAPERSTUDY.shape(model.featureStringId(Face_1)) +Face_1_2, = SHAPERSTUDY.shape(model.featureStringId(Face_1, 1)) +Face_1_3, = SHAPERSTUDY.shape(model.featureStringId(Face_1, 2)) +Face_1_4, = SHAPERSTUDY.shape(model.featureStringId(Face_1, 3)) +### +### GEOM component +### + +from salome.geom import geomBuilder + +geompy = geomBuilder.New() + +O = geompy.MakeVertex(0, 0, 0) +OX = geompy.MakeVectorDXDYDZ(1, 0, 0) +OY = geompy.MakeVectorDXDYDZ(0, 1, 0) +OZ = geompy.MakeVectorDXDYDZ(0, 0, 1) +(imported, Face_1_1, [], [], []) = geompy.ImportXAO("/tmp/shaper_95169b12.xao") +(imported, Face_1_2, [], [], []) = geompy.ImportXAO("/tmp/shaper__ylaz8rk.xao") +(imported, Face_1_3, [], [], []) = geompy.ImportXAO("/tmp/shaper_tfnq0q05.xao") +(imported, Face_1_4, [], [], []) = geompy.ImportXAO("/tmp/shaper_qienyxeo.xao") +Compound_1 = geompy.MakeCompound([Face_1_1, Face_1_2, Face_1_3, Face_1_4]) +geompy.ExportBREP(Compound_1, "test_geom.brep" ) +geompy.addToStudy( O, 'O' ) +geompy.addToStudy( OX, 'OX' ) +geompy.addToStudy( OY, 'OY' ) +geompy.addToStudy( OZ, 'OZ' ) +geompy.addToStudy( Face_1_1, 'Face_1_1' ) +geompy.addToStudy( Face_1_2, 'Face_1_2' ) +geompy.addToStudy( Face_1_3, 'Face_1_3' ) +geompy.addToStudy( Face_1_4, 'Face_1_4' ) +geompy.addToStudy( Compound_1, 'Compound_1' ) + + +if salome.sg.hasDesktop(): + salome.sg.updateObjBrowser() diff --git a/python_magnetgeo/examples/split_helix_yaml.py b/examples/split_helix_yaml.py similarity index 95% rename from python_magnetgeo/examples/split_helix_yaml.py rename to examples/split_helix_yaml.py index b361492..e792eb1 100755 --- a/python_magnetgeo/examples/split_helix_yaml.py +++ b/examples/split_helix_yaml.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding:utf-8 -*- """ Script to split an Helix YAML file into separate files for modelaxi and shape objects. @@ -20,17 +19,14 @@ python split_helix_yaml.py data/HL-31_H1.yaml """ +import argparse +import os import sys + import yaml -import os -import argparse -from python_magnetgeo.Helix import Helix -from python_magnetgeo.ModelAxi import ModelAxi -from python_magnetgeo.Shape import Shape -from python_magnetgeo.Model3D import Model3D -from python_magnetgeo.utils import getObject from python_magnetgeo.logging_config import get_logger +from python_magnetgeo.utils import getObject # Get logger for this module logger = get_logger(__name__) diff --git a/python_magnetgeo/examples/test-yamlload.py b/examples/test-yamlload.py similarity index 99% rename from python_magnetgeo/examples/test-yamlload.py rename to examples/test-yamlload.py index c06dff0..10668fa 100644 --- a/python_magnetgeo/examples/test-yamlload.py +++ b/examples/test-yamlload.py @@ -3,8 +3,6 @@ """ from python_magnetgeo.utils import getObject - - Object = getObject("HL-31-H1H2.yaml") print(f"Object={Object}, type={type(Object)}") Object = getObject("M9Bitters.yaml") diff --git a/examples/visualization.py b/examples/visualization.py index 04918b3..6d4e926 100644 --- a/examples/visualization.py +++ b/examples/visualization.py @@ -50,31 +50,31 @@ print("\nExample 3: Combined Ring + Screen visualization") try: import matplotlib.pyplot as plt - + # Create figure fig, ax = plt.subplots(figsize=(10, 12)) - + # Plot screen (background) screen.plot_axisymmetric( - ax=ax, - color='lightgray', + ax=ax, + color='lightgray', alpha=0.3, show_legend=False ) - + # Plot ring (foreground) ring.plot_axisymmetric( - ax=ax, - color='steelblue', + ax=ax, + color='steelblue', alpha=0.7, show_legend=False ) - + ax.set_title("Magnet Assembly", fontsize=14, fontweight='bold') plt.savefig("example_combined.png", dpi=150, bbox_inches='tight') print(" ✓ Saved visualization to example_combined.png") plt.close() - + except ImportError: print(" ! Matplotlib not installed - skipping visualization") diff --git a/python_magnetgeo/examples/yaml_json_roundtrip.py b/examples/yaml_json_roundtrip.py similarity index 98% rename from python_magnetgeo/examples/yaml_json_roundtrip.py rename to examples/yaml_json_roundtrip.py index 7f504a5..83f2cdc 100644 --- a/python_magnetgeo/examples/yaml_json_roundtrip.py +++ b/examples/yaml_json_roundtrip.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding:utf-8 -*- """ Script to split an Helix YAML file into separate files for modelaxi and shape objects. @@ -20,13 +19,12 @@ python split_helix_yaml.py data/HL-31_H1.yaml """ -import sys -import yaml +import argparse import json import os -import argparse -import python_magnetgeo as pmg +import sys +import python_magnetgeo as pmg from python_magnetgeo.logging_config import get_logger # Get logger for this module diff --git a/pyproject.toml b/pyproject.toml index 6ed64f0..3b1bb9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "python-magnetgeo" version = "1.0.1" description = "Python helpers to create HiFiMagnet cads and meshes" -readme = "README.rst" +readme = "README.md" requires-python = ">=3.11" license = {text = "MIT"} authors = [ @@ -49,6 +49,7 @@ docs = [ "sphinx>=6.0.0", "sphinx-rtd-theme>=1.2.0", "sphinx-autodoc-typehints>=1.22.0", + "myst-parser>=0.18.0", ] test = [ "pytest>=8.2.0", @@ -56,11 +57,11 @@ test = [ ] [project.urls] -Homepage = "https://github.com/Trophime/python_magnetgeo" +Homepage = "https://github.com/MagnetDB/python_magnetgeo" Documentation = "https://python-magnetgeo.readthedocs.io" -Repository = "https://github.com/Trophime/python_magnetgeo" -Issues = "https://github.com/Trophime/python_magnetgeo/issues" -Changelog = "https://github.com/Trophime/python_magnetgeo/blob/main/HISTORY.rst" +Repository = "https://github.com/MagnetDB/python_magnetgeo" +Issues = "https://github.com/MagnetDB/python_magnetgeo/issues" +Changelog = "https://github.com/MagnetDB/python_magnetgeo/blob/main/HISTORY.md" [project.scripts] load-profile-from-dat = "python_magnetgeo.examples.load_profile_from_dat:main" @@ -111,6 +112,7 @@ filterwarnings = [ # Coverage configuration [tool.coverage.run] +branch = true source = ["python_magnetgeo"] omit = [ "*/tests/*", @@ -211,6 +213,7 @@ commands = black --check python_magnetgeo tests deps = sphinx>=6.0.0 sphinx-rtd-theme>=1.2.0 + myst-parser>=0.18.0 changedir = docs commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html """ @@ -219,6 +222,8 @@ commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html [tool.ruff] line-length = 100 target-version = "py311" + +[tool.ruff.lint] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings @@ -234,9 +239,9 @@ ignore = [ "C901", # too complex ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] "tests/*" = ["F401", "F811"] -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["python_magnetgeo"] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index c0bd85d..0000000 --- a/pytest.ini +++ /dev/null @@ -1,21 +0,0 @@ -[tool:pytest] -testpaths = tests -python_files = test_*.py -python_classes = Test* -python_functions = test_* -addopts = - -v - --tb=short - --strict-markers - --disable-warnings -markers = - unit: Unit tests for individual classes - integration: Integration tests across multiple classes - serialization: Tests for JSON/YAML serialization - geometric: Tests for geometric operations - probe: Tests for probe system functionality - slow: Tests that take longer to run -filterwarnings = - ignore::DeprecationWarning - ignore::PendingDeprecationWarning - error::UserWarning \ No newline at end of file diff --git a/python_magnetgeo/Bitter.py b/python_magnetgeo/Bitter.py index f7198c4..1ce04ef 100644 --- a/python_magnetgeo/Bitter.py +++ b/python_magnetgeo/Bitter.py @@ -13,11 +13,11 @@ from .base import YAMLObjectBase from .coolingslit import CoolingSlit +from .logging_config import get_logger from .ModelAxi import ModelAxi from .tierod import Tierod from .validation import GeometryValidator, ValidationError -from .logging_config import get_logger logger = get_logger(__name__) class Bitter(YAMLObjectBase): diff --git a/python_magnetgeo/Bitters.py b/python_magnetgeo/Bitters.py index 02a58d3..852bc77 100644 --- a/python_magnetgeo/Bitters.py +++ b/python_magnetgeo/Bitters.py @@ -8,12 +8,13 @@ # Add import at the top from .Bitter import Bitter + +# Module logger +from .logging_config import get_logger from .Probe import Probe from .utils import getObject from .validation import GeometryValidator, ValidationError -# Module logger -from .logging_config import get_logger logger = get_logger(__name__) class Bitters(YAMLObjectBase): @@ -129,7 +130,7 @@ def __init__( raise ValidationError( f"innerbore ({innerbore}) must be less than ({min([magnet.r[0] for magnet in self.magnets])})" ) - + # Handle case where outerbore is not specified (0) if self.magnets and outerbore == 0: outerbore = max([magnet.r[1] for magnet in self.magnets]) + eps @@ -138,7 +139,7 @@ def __init__( f"outerbore was not specified (0), setting it to maximum magnet outer radius plus eps: " f"{outerbore:.3f} mm (= {max([magnet.r[1] for magnet in self.magnets]):.3f} + {eps})" ) - + if self.magnets and outerbore < max([magnet.r[1] for magnet in self.magnets]): raise ValidationError( f"outerbore ({outerbore}) must be greater than last bitter outer radius ({max([magnet.r[1] for magnet in self.magnets])})" diff --git a/python_magnetgeo/Chamfer.py b/python_magnetgeo/Chamfer.py index 76d0224..5074829 100644 --- a/python_magnetgeo/Chamfer.py +++ b/python_magnetgeo/Chamfer.py @@ -74,7 +74,7 @@ def __init__( rside: str, alpha: float = None, dr: float = None, - l: float = None, + length: float = None, ): """ Initialize a Chamfer object. @@ -124,7 +124,7 @@ def __init__( self.rside = rside self.alpha = alpha self.dr = dr - self.l = l + self.length = length # TODO: data validation # at least alpha or dr must be given @@ -151,7 +151,7 @@ def __repr__(self): msg += f", alpha={self.alpha}" if hasattr(self, "dr"): msg += f", dr={self.dr}" - msg += f",l={self.l})" + msg += f",length={self.length})" return msg @classmethod @@ -207,9 +207,9 @@ def from_dict(cls, values: dict, debug: bool = False): alpha = values.get("alpha", None) dr = values.get("dr", None) - l = values["l"] + length = values["length"] - return cls(name, side, rside, alpha, dr, l) + return cls(name, side, rside, alpha, dr, length) def getDr(self): """ @@ -243,7 +243,7 @@ def getDr(self): else: return self.dr - dr = self.l * math.tan(math.pi / 180.0 * self.alpha) + dr = self.length * math.tan(math.pi / 180.0 * self.alpha) return dr def getAngle(self): @@ -278,5 +278,5 @@ def getAngle(self): else: return self.alpha - angle = math.atan2(self.dr, self.l) + angle = math.atan2(self.dr, self.length) return angle * 180 / math.pi diff --git a/python_magnetgeo/Helix.py b/python_magnetgeo/Helix.py index 1421f90..c5b2014 100644 --- a/python_magnetgeo/Helix.py +++ b/python_magnetgeo/Helix.py @@ -13,20 +13,18 @@ import math import os -import subprocess import sys from .base import YAMLObjectBase from .Chamfer import Chamfer from .Groove import Groove from .hcuts import create_cut +from .logging_config import get_logger from .Model3D import Model3D from .ModelAxi import ModelAxi from .Shape import Shape from .validation import GeometryValidator, ValidationError -from .logging_config import get_logger - # Get logger for this module logger = get_logger(__name__) diff --git a/python_magnetgeo/Insert.py b/python_magnetgeo/Insert.py index 185fa07..9c3a2aa 100644 --- a/python_magnetgeo/Insert.py +++ b/python_magnetgeo/Insert.py @@ -9,14 +9,15 @@ from .base import YAMLObjectBase from .Helix import Helix from .InnerCurrentLead import InnerCurrentLead + +# Module logger +from .logging_config import get_logger from .OuterCurrentLead import OuterCurrentLead from .Probe import Probe from .Ring import Ring from .utils import flatten, getObject from .validation import GeometryValidator, ValidationError -# Module logger -from .logging_config import get_logger logger = get_logger(__name__) def filter(data: list[float], tol: float = 1.0e-6) -> list[float]: @@ -350,7 +351,6 @@ def get_channels(self, mname: str, hideIsolant: bool = True, debug: bool = False for i in range(0, NChannels): names = [] - inames = [] if i == 0: if self.rings: names.append(f"{prefix}R{i+1}_rInt") # check ring numerotation @@ -450,11 +450,7 @@ def get_names(self, mname: str, is2D: bool = False, verbose: bool = False) -> li prefix = f"{mname}_" solid_names = [] - Nhelices = len(self.helices) - NChannels = Nhelices + 1 # To be updated if there is any htype==HR in Insert - NIsolants = [] # To be computed depend on htype and dble for i, helix in enumerate(self.helices): - Ninsulators = 0 if is2D: h_solid_names = helix.get_names(f"{prefix}H{i+1}", is2D, verbose) solid_names += h_solid_names @@ -833,11 +829,11 @@ def _plot_geometry(self, ax, show_labels: bool = True, **kwargs): show_modelaxi = kwargs.get('show_modelaxi', True) helix_colors = kwargs.get('helix_colors', None) helix_alpha = kwargs.get('helix_alpha', 0.6) - + # Default color palette for helices - default_colors = ['darkgreen', 'forestgreen', 'seagreen', 'mediumseagreen', + default_colors = ['darkgreen', 'forestgreen', 'seagreen', 'mediumseagreen', 'springgreen', 'limegreen', 'olivedrab', 'yellowgreen'] - + # Plot all helices for i, helix in enumerate(self.helices): # Determine color for this helix @@ -847,7 +843,7 @@ def _plot_geometry(self, ax, show_labels: bool = True, **kwargs): color = helix_colors[-1] # Use last color if list too short else: color = default_colors[i % len(default_colors)] - + # Plot the helix helix._plot_geometry( ax, @@ -855,20 +851,20 @@ def _plot_geometry(self, ax, show_labels: bool = True, **kwargs): color=color, alpha=helix_alpha, show_modelaxi=show_modelaxi, - **{k: v for k, v in kwargs.items() + **{k: v for k, v in kwargs.items() if k not in ['show_modelaxi', 'helix_colors', 'helix_alpha']} ) - + # Update axis limits to encompass entire insert if self.helices: rb, zb = self.boundingBox() current_xlim = ax.get_xlim() current_ylim = ax.get_ylim() - + # Calculate padding (5% of geometry size) r_padding = (rb[1] - rb[0]) * 0.05 z_padding = (zb[1] - zb[0]) * 0.05 - + # Expand limits if needed if current_xlim == (0.0, 1.0): ax.set_xlim(rb[0] - r_padding, rb[1] + r_padding) @@ -877,7 +873,7 @@ def _plot_geometry(self, ax, show_labels: bool = True, **kwargs): min(current_xlim[0], rb[0] - r_padding), max(current_xlim[1], rb[1] + r_padding) ) - + if current_ylim == (0.0, 1.0): ax.set_ylim(zb[0] - z_padding, zb[1] + z_padding) else: diff --git a/python_magnetgeo/MSite.py b/python_magnetgeo/MSite.py index ef867c0..b80b780 100644 --- a/python_magnetgeo/MSite.py +++ b/python_magnetgeo/MSite.py @@ -6,7 +6,6 @@ """ import os -from typing import Optional from .base import YAMLObjectBase from .Bitter import Bitter @@ -32,10 +31,10 @@ def __init__( self, name: str, magnets: str | list | dict, - screens: Optional[str | list | dict], - z_offset: Optional[list[float]], - r_offset: Optional[list[float]], - paralax: Optional[list[float]], + screens: str | list | dict | None, + z_offset: list[float] | None, + r_offset: list[float] | None, + paralax: list[float] | None, ) -> None: """ Initialize a measurement site (MSite) assembly. @@ -270,7 +269,7 @@ def get_names(self, mname: str, is2D: bool = False, verbose: bool = False) -> li print(f"MSite/get_names: solid_names {len(solid_names)}") return solid_names - def get_magnet(self, name: str) -> Optional[Insert | Bitter | Supra]: + def get_magnet(self, name: str) -> Insert | Bitter | Supra | None: """ Retrieve a specific magnet by name from the site assembly. diff --git a/python_magnetgeo/ModelAxi.py b/python_magnetgeo/ModelAxi.py index b15d7df..518b66c 100755 --- a/python_magnetgeo/ModelAxi.py +++ b/python_magnetgeo/ModelAxi.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding:utf-8 -*- """ Provides definiton for Helix: @@ -29,8 +28,8 @@ def __init__( self, name: str = "", h: float = 0.0, - turns: list[float] = [], - pitch: list[float] = [], + turns: list[float] | None = None, + pitch: list[float] | None = None, ) -> None: """ Initialize an axisymmetric helical cut model. @@ -98,6 +97,10 @@ def __init__( ... pitch=[] ... ) """ + if turns is None: + turns = [] + if pitch is None: + pitch = [] GeometryValidator.validate_name(name) if pitch and turns: if len(pitch) != len(turns): @@ -107,12 +110,12 @@ def __init__( self.name = name self.h = h - self.turns = turns - self.pitch = pitch + self.turns: list[float] = turns + self.pitch: list[float] = pitch # sum of pitch*turns must be equal to 2*h if pitch: - total_height = sum(p * t for p, t in zip(pitch, turns)) + total_height = sum(p * t for p, t in zip(pitch, turns, strict=False)) error = abs(1 - total_height / (2 * self.h)) threshold = 1.e-6 if error > threshold: @@ -154,13 +157,7 @@ def __repr__(self): >>> print(repr(empty)) ModelAxi(name='empty', h=50.0, turns=[], pitch=[]) """ - return "%s(name=%r, h=%r, turns=%r, pitch=%r)" % ( - self.__class__.__name__, - self.name, - self.h, - self.turns, - self.pitch, - ) + return f"{self.__class__.__name__}(name={self.name!r}, h={self.h!r}, turns={self.turns!r}, pitch={self.pitch!r})" @classmethod def from_dict(cls, values: dict, debug: bool = False): diff --git a/python_magnetgeo/Profile.py b/python_magnetgeo/Profile.py index e510096..3b30af1 100644 --- a/python_magnetgeo/Profile.py +++ b/python_magnetgeo/Profile.py @@ -13,10 +13,8 @@ """ from pathlib import Path -from typing import Optional from .base import YAMLObjectBase -from .validation import GeometryValidator # Module logger from .logging_config import get_logger @@ -58,7 +56,7 @@ class Profile(YAMLObjectBase): yaml_tag = "Profile" - def __init__(self, cad: str, points: list[list[float]], labels: Optional[list[int]] = None): + def __init__(self, cad: str, points: list[list[float]], labels: list[int] | None = None): """ Initialize a Profile object. diff --git a/python_magnetgeo/Ring.py b/python_magnetgeo/Ring.py index 106a33c..e270a2c 100755 --- a/python_magnetgeo/Ring.py +++ b/python_magnetgeo/Ring.py @@ -353,11 +353,11 @@ def _plot_geometry(self, ax, show_labels: bool = True, **kwargs): # Update axis limits to include this geometry with some padding current_xlim = ax.get_xlim() current_ylim = ax.get_ylim() - + # Calculate padding (5% of geometry size) r_padding = width * 0.05 z_padding = height * 0.05 - + # Expand limits if needed (check if limits are default) if current_xlim == (0.0, 1.0): # Default limits, set based on geometry @@ -368,7 +368,7 @@ def _plot_geometry(self, ax, show_labels: bool = True, **kwargs): min(current_xlim[0], r_min - r_padding), max(current_xlim[1], r_max + r_padding) ) - + if current_ylim == (0.0, 1.0): # Default limits, set based on geometry ax.set_ylim(z_min - z_padding, z_max + z_padding) diff --git a/python_magnetgeo/Screen.py b/python_magnetgeo/Screen.py index 6f36c21..076a4b9 100644 --- a/python_magnetgeo/Screen.py +++ b/python_magnetgeo/Screen.py @@ -288,11 +288,11 @@ def _plot_geometry(self, ax, show_labels: bool = True, **kwargs): # Update axis limits to include this geometry with some padding current_xlim = ax.get_xlim() current_ylim = ax.get_ylim() - + # Calculate padding (5% of geometry size) r_padding = width * 0.05 z_padding = height * 0.05 - + # Expand limits if needed (check if limits are default) if current_xlim == (0.0, 1.0): # Default limits, set based on geometry @@ -303,7 +303,7 @@ def _plot_geometry(self, ax, show_labels: bool = True, **kwargs): min(current_xlim[0], r_min - r_padding), max(current_xlim[1], r_max + r_padding) ) - + if current_ylim == (0.0, 1.0): # Default limits, set based on geometry ax.set_ylim(z_min - z_padding, z_max + z_padding) diff --git a/python_magnetgeo/Shape.py b/python_magnetgeo/Shape.py index b3c50d3..d7875d6 100644 --- a/python_magnetgeo/Shape.py +++ b/python_magnetgeo/Shape.py @@ -8,10 +8,9 @@ from enum import Enum from .base import YAMLObjectBase -from .Profile import Profile -from .validation import GeometryValidator, ValidationError - from .logging_config import get_logger +from .Profile import Profile +from .validation import ValidationError # Get logger for this module logger = get_logger(__name__) diff --git a/python_magnetgeo/Supra.py b/python_magnetgeo/Supra.py index f396ab3..27dd372 100644 --- a/python_magnetgeo/Supra.py +++ b/python_magnetgeo/Supra.py @@ -314,7 +314,7 @@ def from_dict(cls, values: dict, debug: bool = False): if isinstance(detail_value, str): detail = DetailLevel(detail_value.upper()) else: - detail = detail_valueobject = cls(name, r, z, n, struct) + detail = cls(name, r, z, n, struct) return cls(name, r, z, n, struct, detail) diff --git a/python_magnetgeo/SupraStructure.py b/python_magnetgeo/SupraStructure.py index 2f66803..284ae28 100644 --- a/python_magnetgeo/SupraStructure.py +++ b/python_magnetgeo/SupraStructure.py @@ -4,17 +4,17 @@ """ Define HTS insert geometry with DetailLevel enum support """ -from typing import Optional from .enums import DetailLevel from .hts.dblpancake import dblpancake from .hts.isolation import isolation from .hts.pancake import pancake from .hts.tape import tape -from .utils import flatten # Module logger from .logging_config import get_logger +from .utils import flatten + logger = get_logger(__name__) class HTSInsert: @@ -53,8 +53,8 @@ def __init__( def fromcfg( cls, inputcfg: str, - directory: Optional[str] = None, - debug: Optional[bool] = False, + directory: str | None = None, + debug: bool | None = False, ): """create from a file""" import json @@ -68,9 +68,8 @@ def fromcfg( data = json.load(f) logger.debug(f"HTSinsert data: {data}") - mytape = None if "tape" in data: - mytape = tape.from_data(data["tape"]) + tape.from_data(data["tape"]) mypancake = pancake() if "pancake" in data: @@ -287,7 +286,7 @@ def getNpancakes(self) -> list[int]: for dp in self.dblpancakes: n_.append(dp.getPancake().getN()) return n_ - + def getWMandrin(self) -> list: """ returns the width of Mandrin as a list diff --git a/python_magnetgeo/Supras.py b/python_magnetgeo/Supras.py index e251c43..14c5616 100644 --- a/python_magnetgeo/Supras.py +++ b/python_magnetgeo/Supras.py @@ -6,13 +6,14 @@ import os from .base import YAMLObjectBase + +# Module logger +from .logging_config import get_logger from .Probe import Probe from .Supra import Supra from .utils import getObject from .validation import GeometryValidator, ValidationError -# Module logger -from .logging_config import get_logger logger = get_logger(__name__) class Supras(YAMLObjectBase): @@ -126,7 +127,7 @@ def __init__( # check that magnets are not intersecting for i in range(1, len(self.magnets)): rb, zb = self.magnets[i - 1].boundingBox() - for j in range(i + 1, len(self.magnets)): + for _ in range(i + 1, len(self.magnets)): if self.magnets[i].intersect(rb, zb): raise ValidationError( f"magnets intersect: magnet[{i}] intersect magnet[{i-1}]: /n{self.magnets[i]} /n{self.magnets[i-1]}" diff --git a/python_magnetgeo/__init__.py b/python_magnetgeo/__init__.py index 1f923b7..7a62966 100644 --- a/python_magnetgeo/__init__.py +++ b/python_magnetgeo/__init__.py @@ -21,10 +21,10 @@ # Version is read from package metadata (defined in pyproject.toml) # This ensures a single source of truth for the version number try: - from importlib.metadata import version, PackageNotFoundError + from importlib.metadata import PackageNotFoundError, version except ImportError: # Fallback for Python < 3.8 (though we require 3.11+) - from importlib_metadata import version, PackageNotFoundError + from importlib_metadata import PackageNotFoundError, version try: __version__ = version("python-magnetgeo") @@ -34,47 +34,47 @@ __version__ = "0.0.0+unknown" # Import logging configuration -from .logging_config import ( - configure_logging, - get_logger, - set_level, - disable_logging, - enable_logging, - DEBUG, - INFO, - WARNING, - ERROR, - CRITICAL, -) - # Import core utilities and base classes immediately -from .base import YAMLObjectBase, SerializableMixin -from .validation import ValidationError, ValidationWarning, GeometryValidator -from .utils import getObject as load, loadObject, ObjectLoadError, UnsupportedTypeError +from .base import SerializableMixin, YAMLObjectBase +from .Bitter import Bitter +from .Bitters import Bitters +from .Chamfer import Chamfer +from .Contour2D import Contour2D +from .coolingslit import CoolingSlit +from .Groove import Groove +from .Helix import Helix +from .InnerCurrentLead import InnerCurrentLead # Import all geometry classes eagerly so their YAML constructors are registered # before any yaml.load() call is made. Lazy loading breaks YAML deserialization # because constructors are only registered when the class is first imported. from .Insert import Insert -from .Helix import Helix -from .Ring import Ring -from .Bitter import Bitter -from .Bitters import Bitters -from .Supra import Supra -from .Supras import Supras -from .Screen import Screen +from .logging_config import ( + CRITICAL, + DEBUG, + ERROR, + INFO, + WARNING, + configure_logging, + disable_logging, + enable_logging, + get_logger, + set_level, +) +from .Model3D import Model3D +from .ModelAxi import ModelAxi from .MSite import MSite +from .OuterCurrentLead import OuterCurrentLead from .Probe import Probe +from .Ring import Ring +from .Screen import Screen from .Shape import Shape -from .ModelAxi import ModelAxi -from .Model3D import Model3D -from .InnerCurrentLead import InnerCurrentLead -from .OuterCurrentLead import OuterCurrentLead -from .Contour2D import Contour2D -from .Chamfer import Chamfer -from .Groove import Groove +from .Supra import Supra +from .Supras import Supras from .tierod import Tierod -from .coolingslit import CoolingSlit +from .utils import ObjectLoadError, UnsupportedTypeError, loadObject +from .utils import getObject as load +from .validation import GeometryValidator, ValidationError, ValidationWarning # Define what gets imported with "from python_magnetgeo import *" __all__ = [ diff --git a/python_magnetgeo/base.py b/python_magnetgeo/base.py index 584ffeb..2cd6659 100644 --- a/python_magnetgeo/base.py +++ b/python_magnetgeo/base.py @@ -46,7 +46,7 @@ import json from abc import abstractmethod -from typing import Any, Type, TypeVar +from typing import Any, TypeVar import yaml @@ -227,7 +227,7 @@ def write_to_json(self, filename: str | None = None, directory: str | None = Non raise Exception(f"Failed to write {self.__class__.__name__} to {filename}: {e}") from e @classmethod - def load_from_yaml(cls: Type[T], filename: str, debug: bool = True) -> T: + def load_from_yaml(cls: type[T], filename: str, debug: bool = True) -> T: """ Load object from YAML file. @@ -267,7 +267,7 @@ def load_from_yaml(cls: Type[T], filename: str, debug: bool = True) -> T: return loadYaml(cls.__name__, filename, cls, debug) @classmethod - def load_from_json(cls: Type[T], filename: str, debug: bool = False) -> T: + def load_from_json(cls: type[T], filename: str, debug: bool = False) -> T: """ Load object from JSON file. @@ -302,7 +302,7 @@ def load_from_json(cls: Type[T], filename: str, debug: bool = False) -> T: @classmethod @abstractmethod - def from_dict(cls: Type[T], values: dict[str, Any], debug: bool = False) -> T: + def from_dict(cls: type[T], values: dict[str, Any], debug: bool = False) -> T: """ Create instance from dictionary representation. @@ -466,7 +466,7 @@ def get_all_classes(cls): return cls._class_registry.copy() @classmethod - def from_yaml(cls: Type[T], filename: str, debug: bool = False) -> T: + def from_yaml(cls: type[T], filename: str, debug: bool = False) -> T: """ Create object from YAML file. @@ -484,7 +484,7 @@ def from_yaml(cls: Type[T], filename: str, debug: bool = False) -> T: return cls.load_from_yaml(filename, debug) @classmethod - def from_json(cls: Type[T], filename: str, debug: bool = False) -> T: + def from_json(cls: type[T], filename: str, debug: bool = False) -> T: """ Create object from JSON file. @@ -653,7 +653,7 @@ def _load_nested_single(cls, data, object_class, debug=False): return data @classmethod - def get_required_files(cls: Type[T], values: dict, debug: bool = False) -> set[str]: + def get_required_files(cls: type[T], values: dict, debug: bool = False) -> set[str]: """ Perform a dry run analysis to identify all files required to create an object. diff --git a/python_magnetgeo/deserialize.py b/python_magnetgeo/deserialize.py index ff65bd9..560678c 100755 --- a/python_magnetgeo/deserialize.py +++ b/python_magnetgeo/deserialize.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding:utf-8 -*- """ Provides tools to un/serialize data from json @@ -7,32 +6,12 @@ from .base import YAMLObjectBase +# Module logger +from .logging_config import get_logger + # Import all classes to ensure they're registered # (importing triggers __init_subclass__ which registers them) -from .Probe import Probe -from .Shape import Shape -from .ModelAxi import ModelAxi -from .Model3D import Model3D -from .Helix import Helix -from .Ring import Ring -from .InnerCurrentLead import InnerCurrentLead -from .OuterCurrentLead import OuterCurrentLead -from .Insert import Insert -from .Bitter import Bitter -from .Supra import Supra -from .Screen import Screen -from .MSite import MSite -from .Bitters import Bitters -from .Supras import Supras -from .Contour2D import Contour2D -from .Chamfer import Chamfer -from .Groove import Groove -from .tierod import Tierod -from .coolingslit import CoolingSlit - -# Module logger -from .logging_config import get_logger logger = get_logger(__name__) # From : http://chimera.labs.oreilly.com/books/1230000000393/ch06.html#_discussion_95 diff --git a/python_magnetgeo/enums.py b/python_magnetgeo/enums.py index a52fd2b..980223d 100644 --- a/python_magnetgeo/enums.py +++ b/python_magnetgeo/enums.py @@ -1,21 +1,21 @@ -from enum import Enum +from enum import StrEnum -class DetailLevel(str, Enum): + +class DetailLevel(StrEnum): """ Level of detail for structural modeling of Supra components. - + Attributes: NONE: Simplified single-region model DBLPANCAKE: Model at double-pancake level PANCAKE: Model individual pancakes TAPE: Model individual tape windings - + Notes: - Inherits from str to maintain YAML serialization compatibility. + Inherits from StrEnum to maintain YAML serialization compatibility. Higher detail levels increase mesh complexity and solve time. """ NONE = "NONE" DBLPANCAKE = "DBLPANCAKE" PANCAKE = "PANCAKE" TAPE = "TAPE" - diff --git a/python_magnetgeo/examples/check_magnetgeo_yaml.py b/python_magnetgeo/examples/check_magnetgeo_yaml.py deleted file mode 100755 index eba15f0..0000000 --- a/python_magnetgeo/examples/check_magnetgeo_yaml.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -""" -Script to split an Helix YAML file into separate files for modelaxi and shape objects. - -This script: -1. Loads an Helix YAML file -2. Writes separate YAML files for: - - Helix.modelaxi object (saved as _modelaxi.yaml) - - Helix.shape object (saved as _shape.yaml) -3. Creates a new Helix YAML file where: - - modelaxi is the name of the corresponding modelaxi yaml file without extension - - shape is the name of the corresponding shape yaml file without extension - -Usage: - python split_helix_yaml.py - -Example: - python split_helix_yaml.py data/HL-31_H1.yaml -""" - -import sys -import yaml -import os -import argparse -import python_magnetgeo as pmg - -from python_magnetgeo.logging_config import get_logger - -# Get logger for this module -logger = get_logger(__name__) - -def check_yaml(input_file): - """ - Load magnetgeo YAML file. - - Args: - input_file: Path to the input YAML file - - Returns: - """ - # Ensure all YAML constructors are registered - # This is needed because lazy loading doesn't import classes until accessed - pmg.verify_class_registration() - - # Split input_file into basedir and basename - basedir = os.path.dirname(input_file) - basename = os.path.basename(input_file) - - # Change to basedir if it's not empty and not '.' - if basedir and basedir != '.': - print(f"Changing directory to: {basedir}") - os.chdir(basedir) - input_path = basename - else: - input_path = input_file - - print(f"Loading: {input_path}") - - # Load the object using getObject from utils - object = pmg.load(input_path) - logger.debug(object) - - print(f"Loaded: {type(object)}") - print(f"Object: {object}") - - -def main(): - """Main function to handle command line arguments.""" - parser = argparse.ArgumentParser( - description='Check an YAML file.', - epilog='Example: %(prog)s data/HL-31_H1.yaml' - ) - parser.add_argument( - 'input_file', - help='Path to the input Helix YAML file' - ) - - args = parser.parse_args() - - if not os.path.exists(args.input_file): - print(f"Error: File not found: {args.input_file}") - sys.exit(1) - - try: - check_yaml(args.input_file) - except Exception as e: - print(f"Error: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/python_magnetgeo/examples/hts.json b/python_magnetgeo/examples/hts.json deleted file mode 100644 index 4b5d18d..0000000 --- a/python_magnetgeo/examples/hts.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "pancake": - { - "r0": 25, - "mandrin": 1.0, - "ntapes": 292, - "tape": - { - "w": 0.07616438356164383, - "h": 6, - "e": 0.03 - } - }, - "isolation": - { - "r0": 24.5, - "w": [5, 5.15, 5], - "h": [0.2125, 0.3, 0.2125] - }, - "dblpancakes": - { - "n": 9, - "isolation": - { - "r0": 24.5, - "w": [10, 10.15, 10], - "h": [0.2125, 0.3, 0.2125] - } - } -} diff --git a/python_magnetgeo/examples/probe_usage_examples.txt b/python_magnetgeo/examples/probe_usage_examples.txt deleted file mode 100644 index 7474d52..0000000 --- a/python_magnetgeo/examples/probe_usage_examples.txt +++ /dev/null @@ -1,88 +0,0 @@ -# Example 1: Insert with embedded probes -name: M9_Insert -helices: - - H1 - - H2 -rings: - - R1 -currentleads: - - inner_lead -hangles: [0.0, 180.0] -rangles: [0.0, 90.0, 180.0, 270.0] -innerbore: 12.5 -outerbore: 45.2 -probes: - - name: H1_voltage_taps - probe_type: voltage_taps - index: ["V1", "V2", "V3", "V4"] - locations: - - [15.2, 0.0, 10.5] - - [18.7, 0.0, 12.3] - - [22.1, 0.0, 14.1] - - [25.6, 0.0, 15.9] - - name: H1_temperature - probe_type: temperature - index: [1, 2, 3] - locations: - - [16.5, 5.2, 11.0] - - [20.0, -3.1, 13.5] - - [24.8, 2.7, 16.2] - ---- -# Example 2: Insert with probes loaded from external files -name: M9_Insert_External -helices: ["H1", "H2"] -rings: ["R1"] -currentleads: ["inner_lead"] -hangles: [0.0, 180.0] -rangles: [0.0, 90.0, 180.0, 270.0] -innerbore: 12.5 -outerbore: 45.2 -probes: ["voltage_probes", "temp_probes", "field_probes"] - ---- -# Example 3: Bitters with probes -name: Bitter_Stack -magnets: ["B1", "B2", "B3"] -innerbore: 8.0 -outerbore: 35.0 -probes: - - name: bitter_monitoring - probe_type: voltage_taps - index: ["BV1", "BV2"] - locations: - - [20.0, 0.0, 5.0] - - [30.0, 0.0, -5.0] - ---- -# Example 4: Supras with probes -name: HTS_Stack -magnets: ["S1", "S2"] -innerbore: 15.0 -outerbore: 25.0 -probes: - - name: quench_detection - probe_type: voltage_taps - index: ["Q1", "Q2", "Q3"] - locations: - - [18.0, 0.0, 10.0] - - [20.0, 0.0, 0.0] - - [22.0, 0.0, -10.0] - - name: hts_temperature - probe_type: temperature - index: ["T_HTS1", "T_HTS2"] - locations: - - [19.0, 2.0, 5.0] - - [21.0, -2.0, -5.0] - ---- -# External probe file example: voltage_probes.yaml -name: voltage_probes -probe_type: voltage_taps -index: ["V1", "V2", "V3", "V4", "V5"] -locations: - - [10.5, 0.0, 15.2] - - [12.3, 0.0, 18.7] - - [14.1, 0.0, 22.1] - - [15.9, 0.0, 25.6] - - [17.8, 0.0, 29.1] \ No newline at end of file diff --git a/python_magnetgeo/hcuts.py b/python_magnetgeo/hcuts.py index 2cfa90e..8d54963 100644 --- a/python_magnetgeo/hcuts.py +++ b/python_magnetgeo/hcuts.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding:utf-8 -*- """ Utilities for generating helical cut files for magnet manufacturing. @@ -20,6 +19,10 @@ from math import pi +from .logging_config import get_logger + +logger = get_logger(__name__) + def lncmi_cut(object, filename: str, append: bool = False, z0: float = 0): """ @@ -69,7 +72,7 @@ def lncmi_cut(object, filename: str, append: bool = False, z0: float = 0): See Also: MagnetTools/MagnetField/Stack.cc write_lncmi_paramfile L136 """ - print(f"lncmi_cut: filename={filename}, append={append}, z0={z0}") + logger.info(f"lncmi_cut: filename={filename}, append={append}, z0={z0}") from math import pi sign = 1 @@ -82,8 +85,6 @@ def lncmi_cut(object, filename: str, append: bool = False, z0: float = 0): z = z0 theta = 0 - shape_id = 0 - tab = "\t" # 'x' create file, 'a' append to file flag = "x" @@ -107,9 +108,11 @@ def lncmi_cut(object, filename: str, append: bool = False, z0: float = 0): f.write("G0X-0.000\n") f.write("G0A0.\n") - print(f"lncmi_cut: Starting point: theta={theta}, z={z}") + logger.debug(f"lncmi_cut: starting point theta={theta}, z={z}") # Generate toolpath points from helix geometry - for i, (turn, pitch) in enumerate(zip(object.modelaxi.turns, object.modelaxi.pitch)): + for i, (turn, pitch) in enumerate( + zip(object.modelaxi.turns, object.modelaxi.pitch, strict=False) + ): theta += turn * (2 * pi) * sign z -= turn * pitch logger.debug( @@ -198,7 +201,7 @@ def salome_cut(object, filename: str, append: bool = False, z0: float = 0): flag = "x" if append: flag = "a" - print(f"flag={flag}") + logger.debug(f"salome_cut: open mode={flag}") with open(filename, flag) as f: # Write header f.write(f"#theta[rad]{tab}Shape_id[]{tab}tZ[mm]\n") @@ -207,7 +210,9 @@ def salome_cut(object, filename: str, append: bool = False, z0: float = 0): f.write(f"{theta*(-sign):12.8f}{tab}{shape_id:8}{tab}{z:12.8f}\n") # Generate subsequent points from helix geometry - for i, (turn, pitch) in enumerate(zip(object.modelaxi.turns, object.modelaxi.pitch)): + for _, (turn, pitch) in enumerate( + zip(object.modelaxi.turns, object.modelaxi.pitch, strict=False) + ): theta += turn * (2 * pi) * sign z -= turn * pitch f.write(f"{theta*(-sign):12.8f}{tab}{shape_id:8}{tab}{z:12.8f}\n") @@ -243,7 +248,7 @@ def catia_cut(object, filename: str, append: bool = False, z0: float = 0): theta_deg = 0.0 points = [(z, theta_deg)] - for turn, pitch in zip(object.modelaxi.turns, object.modelaxi.pitch): + for turn, pitch in zip(object.modelaxi.turns, object.modelaxi.pitch, strict=False): theta_deg += turn * 360.0 * sign z -= turn * pitch points.append((z, theta_deg)) @@ -295,9 +300,9 @@ def catia_cut(object, filename: str, append: bool = False, z0: float = 0): previous_theta = None for i, (z_point, theta_point_deg) in enumerate(points): if previous_theta is not None and abs(theta_point_deg) < abs(previous_theta): - print( - "CATIA: Warning Point[%d] dropped: %s,%s (%s,%s)" - % (i, z_point, theta_point_deg, points[i - 1][0], previous_theta) + logger.warning( + f"CATIA: Warning Point[{i}] dropped: {z_point},{theta_point_deg} " + f"({points[i - 1][0]},{previous_theta})" ) x_coord = theta_point_deg * radius * pi / 180.0 * (-sign) @@ -385,10 +390,10 @@ def create_cut(object, format: str, name: str, append: bool = False, z0: float = try: format_cut = dformat[format.lower()] - except: + except KeyError as e: raise RuntimeError( f"create_cut: format={format} unsupported\nallowed formats are: {dformat.keys()}" - ) + ) from e # create file for shape: Shape_name.dat shape = getattr(object, "shape", None) diff --git a/python_magnetgeo/hts/isolation.py b/python_magnetgeo/hts/isolation.py index 473972a..112a979 100644 --- a/python_magnetgeo/hts/isolation.py +++ b/python_magnetgeo/hts/isolation.py @@ -13,7 +13,11 @@ class isolation: h: heights of the different layers """ - def __init__(self, r0: float = 0, w: list = [], h: list = []): + def __init__(self, r0: float = 0, w: list | None = None, h: list | None = None): + if w is None: + w = [] + if h is None: + h = [] self.r0 = r0 self.w = w self.h = h diff --git a/python_magnetgeo/hts/pancake.py b/python_magnetgeo/hts/pancake.py index 037176b..434c034 100644 --- a/python_magnetgeo/hts/pancake.py +++ b/python_magnetgeo/hts/pancake.py @@ -23,7 +23,9 @@ def __init__(self, r0: float = 0, tape: tape = tape(), n: int = 0, mandrin: int self.r0 = r0 @classmethod - def from_data(cls, data={}) -> Self: + def from_data(cls, data=None) -> Self: + if data is None: + data = {} r0 = 0 n = 0 t_ = tape() @@ -42,12 +44,7 @@ def __repr__(self) -> str: """ representation of object """ - return "pancake(r0=%r, n=%r, tape=%r, mandrin=%r)" % ( - self.r0, - self.n, - self.tape, - self.mandrin, - ) + return f"pancake(r0={self.r0!r}, n={self.n!r}, tape={self.tape!r}, mandrin={self.mandrin!r})" def __str__(self) -> str: msg = "\n" @@ -126,7 +123,7 @@ def getR(self) -> list[float]: r = [] ri = self.getR0() dr = self.tape.w + self.tape.e - for i in range(self.n): + for _ in range(self.n): r.append(ri) ri += dr return r diff --git a/python_magnetgeo/logging_config.py b/python_magnetgeo/logging_config.py index a9f6cb0..9ffebdf 100644 --- a/python_magnetgeo/logging_config.py +++ b/python_magnetgeo/logging_config.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding:utf-8 -*- """ Logging configuration for python_magnetgeo package. @@ -11,11 +10,11 @@ # Get logger in any module from python_magnetgeo.logging_config import get_logger logger = get_logger(__name__) - + # Configure logging (typically done once at application startup) from python_magnetgeo.logging_config import configure_logging configure_logging(level='DEBUG', log_file='magnetgeo.log') - + # Use logger logger.info("Processing geometry data") logger.debug("Detailed debug information") @@ -26,8 +25,6 @@ import logging import sys from pathlib import Path -from typing import Optional, Union - # Default format for log messages DEFAULT_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" @@ -46,45 +43,45 @@ def get_logger(name: str = PACKAGE_NAME) -> logging.Logger: """ Get a logger instance for the specified module. - + This is the primary interface for getting loggers throughout the package. Each module should call this with __name__ to get its own logger. - + Args: name: Logger name, typically __name__ of the calling module - + Returns: Logger instance configured for the package - + Example: >>> from python_magnetgeo.logging_config import get_logger >>> logger = get_logger(__name__) >>> logger.info("Module initialized") """ logger = logging.getLogger(name) - + # If not configured yet, use basic configuration if not _configured: configure_logging() - + return logger def configure_logging( - level: Union[str, int] = logging.INFO, - log_file: Optional[Union[str, Path]] = None, + level: str | int = logging.INFO, + log_file: str | Path | None = None, log_format: str = DEFAULT_FORMAT, console: bool = True, - file_level: Optional[Union[str, int]] = None, - console_level: Optional[Union[str, int]] = None, + file_level: str | int | None = None, + console_level: str | int | None = None, propagate: bool = True, ) -> None: """ Configure logging for the python_magnetgeo package. - + This should typically be called once at application startup. It sets up handlers for console and/or file logging with appropriate formatters. - + Args: level: Default logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) log_file: Optional path to log file. If provided, enables file logging @@ -97,14 +94,14 @@ def configure_logging( file_level: Logging level for file handler (defaults to 'level') console_level: Logging level for console handler (defaults to 'level') propagate: Whether to propagate logs to parent loggers - + Example: >>> # Basic configuration - console only at INFO level >>> configure_logging() - + >>> # Debug to console and file >>> configure_logging(level='DEBUG', log_file='app.log') - + >>> # Different levels for console and file >>> configure_logging( ... console_level='INFO', @@ -113,7 +110,7 @@ def configure_logging( ... ) """ global _configured, _log_level, _handlers - + # Convert string levels to logging constants if isinstance(level, str): level = getattr(logging, level.upper()) @@ -121,28 +118,28 @@ def configure_logging( file_level = getattr(logging, file_level.upper()) if console_level is not None and isinstance(console_level, str): console_level = getattr(logging, console_level.upper()) - + # Set default levels if not specified if file_level is None: file_level = level if console_level is None: console_level = level - + _log_level = level - + # Get the root logger for this package logger = logging.getLogger(PACKAGE_NAME) logger.setLevel(logging.DEBUG) # Set to DEBUG to allow handlers to filter logger.propagate = propagate - + # Remove existing handlers to avoid duplicates on reconfiguration for handler in _handlers: logger.removeHandler(handler) _handlers.clear() - + # Create formatter formatter = logging.Formatter(log_format) - + # Add console handler if requested if console: console_handler = logging.StreamHandler(sys.stderr) @@ -150,51 +147,51 @@ def configure_logging( console_handler.setFormatter(formatter) logger.addHandler(console_handler) _handlers.append(console_handler) - + # Add file handler if log_file is specified if log_file: log_path = Path(log_file) # Create parent directories if they don't exist log_path.parent.mkdir(parents=True, exist_ok=True) - + file_handler = logging.FileHandler(log_path, mode='a') file_handler.setLevel(file_level) file_handler.setFormatter(formatter) logger.addHandler(file_handler) _handlers.append(file_handler) - + _configured = True - + # Log that logging has been configured logger.debug(f"Logging configured: level={logging.getLevelName(level)}, " f"console={console}, log_file={log_file}") -def set_level(level: Union[str, int], logger_name: Optional[str] = None) -> None: +def set_level(level: str | int, logger_name: str | None = None) -> None: """ Change logging level for package or specific logger. - + Args: level: New logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) logger_name: Optional specific logger name. If None, sets package level - + Example: >>> # Set package level to DEBUG >>> set_level('DEBUG') - + >>> # Set specific module to WARNING >>> set_level('WARNING', 'python_magnetgeo.utils') """ if isinstance(level, str): level = getattr(logging, level.upper()) - + if logger_name: logger = logging.getLogger(logger_name) else: logger = logging.getLogger(PACKAGE_NAME) - + logger.setLevel(level) - + # Also update handlers if setting package level if not logger_name or logger_name == PACKAGE_NAME: for handler in _handlers: @@ -204,7 +201,7 @@ def set_level(level: Union[str, int], logger_name: Optional[str] = None) -> None def disable_logging() -> None: """ Disable all logging from the package. - + Useful for testing or when you want complete silence. """ logger = logging.getLogger(PACKAGE_NAME) @@ -222,7 +219,7 @@ def enable_logging() -> None: def get_log_level() -> int: """ Get current logging level. - + Returns: Current logging level constant (e.g., logging.INFO) """ @@ -232,7 +229,7 @@ def get_log_level() -> int: def is_configured() -> bool: """ Check if logging has been configured. - + Returns: True if configure_logging has been called """ diff --git a/python_magnetgeo/utils.py b/python_magnetgeo/utils.py index a036232..acd2f7c 100644 --- a/python_magnetgeo/utils.py +++ b/python_magnetgeo/utils.py @@ -1,16 +1,15 @@ #!/usr/bin/env python3 -# -*- coding:utf-8 -*- """ Utility functions for python_magnetgeo Fixed version compatible with both original and refactored classes """ +import json import os +from typing import Any + import yaml -import json -from typing import Any, Type -from pathlib import Path from .logging_config import get_logger @@ -31,7 +30,7 @@ class UnsupportedTypeError(Exception): def writeYaml( - comment: str, obj: Any, obj_class: Type = None, debug: bool = True, directory: str | None = None + comment: str, obj: Any, obj_class: type = None, debug: bool = True, directory: str | None = None ): """ Write object to YAML file. @@ -66,7 +65,7 @@ def writeYaml( except Exception as e: logger.error(f"Failed to write {comment} to {filename}: {e}", exc_info=True) - raise Exception(f"Failed to {comment} dump - {filename} - {e}") + raise Exception(f"Failed to {comment} dump - {filename} - {e}") from e def writeJson(comment: str, obj: Any, debug: bool = True): @@ -97,10 +96,10 @@ def writeJson(comment: str, obj: Any, debug: bool = True): except Exception as e: logger.error(f"Failed to write {comment} to {filename}: {e}", exc_info=True) - raise Exception(f"Failed to {comment} dump - {filename} - {e}") + raise Exception(f"Failed to {comment} dump - {filename} - {e}") from e -def loadYaml(comment: str, filename: str, supported_type: Type = None, debug: bool = False) -> Any: +def loadYaml(comment: str, filename: str, supported_type: type = None, debug: bool = False) -> Any: """ Load object from YAML file. @@ -134,7 +133,7 @@ def loadYaml(comment: str, filename: str, supported_type: Type = None, debug: bo try: # Load YAML file logger.debug(f"looking for file: {basename}, supported_type={supported_type}") - with open(basename, "r") as istream: # Potential FileNotFoundError happens here + with open(basename) as istream: # Potential FileNotFoundError happens here obj = yaml.load(stream=istream, Loader=yaml.FullLoader) obj._basedir = cwd if basedir and basedir != ".": @@ -169,13 +168,13 @@ def loadYaml(comment: str, filename: str, supported_type: Type = None, debug: bo ) error_msg = f"{error_type}: {filename}. Details: {e}" logger.error(error_msg) - raise ObjectLoadError(error_msg) + raise ObjectLoadError(error_msg) from e except Exception as e: # Catch all others, but now the file not found is handled above. error_msg = f"Failed to load {comment} data from {filename} due to an unexpected error: {e}" logger.error(error_msg, exc_info=True) - raise ObjectLoadError(error_msg) + raise ObjectLoadError(error_msg) from e finally: # Always restore original directory if basedir and basedir != ".": @@ -214,7 +213,7 @@ def loadJson(comment: str, filename: str, debug: bool = False) -> Any: try: logger.debug(f"Loading JSON from: {basename}") - with open(basename, "r") as istream: + with open(basename) as istream: obj = json.loads(istream.read(), object_hook=deserialize.unserialize_object) obj._basedir = cwd if basedir and basedir != ".": @@ -231,7 +230,7 @@ def loadJson(comment: str, filename: str, debug: bool = False) -> Any: ) error_msg = f"{error_type}: {filename}. Details: {e}" logger.error(error_msg) - raise ObjectLoadError(error_msg) + raise ObjectLoadError(error_msg) from e finally: if basedir and basedir != ".": os.chdir(cwd) diff --git a/python_magnetgeo/validation.py b/python_magnetgeo/validation.py index 2e2a913..30fe4bb 100644 --- a/python_magnetgeo/validation.py +++ b/python_magnetgeo/validation.py @@ -118,7 +118,7 @@ def validate_name(name: str) -> None: if not name.strip(): logger.error("Validation failed: Name cannot be whitespace only") raise ValidationError("Name cannot be whitespace only") - + logger.debug(f"Name validation passed: '{name}'") @staticmethod @@ -130,7 +130,7 @@ def validate_positive(r: float, name: str) -> None: if r < 0: logger.error(f"Validation failed: {name}={r} must be positive or null") raise ValidationError(f"{name} must be positive or null") - + logger.debug(f"Positive validation passed: {name}={r}") @staticmethod @@ -139,7 +139,7 @@ def validate_integer(n: int, name: str) -> None: if not isinstance(n, int): logger.error(f"Validation failed: {name} must be an integer, got {type(n)}") raise ValidationError(f"{name} must be an integer") - + logger.debug(f"Integer validation passed: {name}={n}") @staticmethod @@ -148,7 +148,7 @@ def validate_numeric(n: int | float, name: str) -> None: if not isinstance(n, (int, float)): logger.error(f"Validation failed: {name} must be numeric, got {type(n)}") raise ValidationError(f"{name} must be an integer or a float") - + logger.debug(f"Numeric validation passed: {name}={n}") @staticmethod @@ -167,7 +167,7 @@ def validate_numeric_list(values: list[float], name: str, expected_length: int = raise ValidationError( f"{name} must have exactly {expected_length} values, got {len(values)}" ) - + logger.debug(f"Numeric list validation passed: {name} with {len(values)} elements") @staticmethod @@ -177,5 +177,5 @@ def validate_ascending_order(values: list[float], name: str) -> None: if values[i] <= values[i - 1]: logger.error(f"Validation failed: {name} values not in ascending order at index {i}: {values}") raise ValidationError(f"{name} values must be in ascending order: {values}") - + logger.debug(f"Ascending order validation passed: {name}={values}") diff --git a/python_magnetgeo/visualization.py b/python_magnetgeo/visualization.py index 763c3be..714da02 100644 --- a/python_magnetgeo/visualization.py +++ b/python_magnetgeo/visualization.py @@ -25,7 +25,7 @@ """ from abc import abstractmethod -from typing import Any, Optional +from typing import Any from .logging_config import get_logger @@ -74,10 +74,10 @@ class VisualizableMixin: def plot_axisymmetric( self, - ax: Optional[Any] = None, + ax: Any | None = None, show_labels: bool = True, show_legend: bool = True, - title: Optional[str] = None, + title: str | None = None, figsize: tuple[float, float] = (10, 12), **kwargs ) -> Any: @@ -174,7 +174,7 @@ def plot_axisymmetric( title = f"{self.__class__.__name__}: {self.name}" elif title is None: title = f"{self.__class__.__name__}" - + if title: ax.set_title(title, fontsize=14, fontweight='bold') @@ -185,7 +185,7 @@ def plot_axisymmetric( # Configure axes ax.set_xlabel('Radius r (mm)', fontsize=12) ax.set_ylabel('Axial Position z (mm)', fontsize=12) - + # Set aspect ratio after plotting (when limits are established) ax.set_aspect('equal', adjustable='box') ax.grid(True, alpha=0.3, linestyle='--', linewidth=0.5) @@ -228,11 +228,11 @@ def _plot_geometry(self, ax: Any, show_labels: bool = True, **kwargs) -> None: ... r, z = self.boundingBox() ... width = r[1] - r[0] ... height = z[1] - z[0] - ... + ... ... # Default styling ... color = kwargs.get('color', 'steelblue') ... alpha = kwargs.get('alpha', 0.6) - ... + ... ... # Create and add rectangle ... rect = Rectangle( ... (r[0], z[0]), width, height, @@ -240,7 +240,7 @@ def _plot_geometry(self, ax: Any, show_labels: bool = True, **kwargs) -> None: ... edgecolor='black', linewidth=1 ... ) ... ax.add_patch(rect) - ... + ... ... # Add label if requested ... if show_labels: ... ax.text( diff --git a/tests/conftest.py b/tests/conftest.py index a93383f..4f2e1ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,30 +1,31 @@ -import pytest import json -import yaml +import os +import sys import tempfile from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Optional from unittest.mock import Mock -import sys -import os +import pytest +import yaml + # Add the parent directory to Python path so we can import from python_magnetgeo sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Import all classes for testing -from python_magnetgeo.Insert import Insert -from python_magnetgeo.Helix import Helix -from python_magnetgeo.Ring import Ring -from python_magnetgeo.Supra import Supra -from python_magnetgeo.Supras import Supras from python_magnetgeo.Bitter import Bitter from python_magnetgeo.Bitters import Bitters -from python_magnetgeo.Screen import Screen +from python_magnetgeo.Helix import Helix +from python_magnetgeo.Insert import Insert +from python_magnetgeo.Model3D import Model3D +from python_magnetgeo.ModelAxi import ModelAxi from python_magnetgeo.MSite import MSite from python_magnetgeo.Probe import Probe +from python_magnetgeo.Ring import Ring +from python_magnetgeo.Screen import Screen from python_magnetgeo.Shape import Shape -from python_magnetgeo.ModelAxi import ModelAxi -from python_magnetgeo.Model3D import Model3D +from python_magnetgeo.Supra import Supra +from python_magnetgeo.Supras import Supras @pytest.fixture @@ -144,4 +145,4 @@ def temp_json_file(): """Fixture providing a temporary JSON file""" with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: yield f.name - Path(f.name).unlink(missing_ok=True) \ No newline at end of file + Path(f.name).unlink(missing_ok=True) diff --git a/tests/test-refactor-ring.py b/tests/test-refactor-ring.py index ec28a4a..eccdc17 100644 --- a/tests/test-refactor-ring.py +++ b/tests/test-refactor-ring.py @@ -3,11 +3,13 @@ Fixed test script for refactored Ring """ -import os import json +import os import tempfile + from python_magnetgeo.Ring import Ring + def test_refactored_ring_functionality(): """Test that refactored Ring has identical functionality""" print("Testing refactored Ring functionality...") @@ -75,13 +77,13 @@ def test_refactored_ring_functionality(): # Test validation try: Ring(name="", r=[1.0, 2.0, 3.0, 4.0], z=[0.0, 1.0]) - assert False, "Should have raised ValidationError for empty name" + raise AssertionError("Should have raised ValidationError for empty name") except Exception as e: print(f"✓ Validation works: {e}") try: Ring(name="bad_ring", r=[2.0, 1.0, 3.0, 4.0], z=[0.0, 1.0]) # inner > outer - assert False, "Should have raised ValidationError for bad radii" + raise AssertionError("Should have raised ValidationError for bad radii") except Exception as e: print(f"✓ Validation works: {e}") diff --git a/tests/test_auto_registration.py b/tests/test_auto_registration.py index 7a4237a..4022d0f 100644 --- a/tests/test_auto_registration.py +++ b/tests/test_auto_registration.py @@ -1,38 +1,39 @@ import pytest + +from python_magnetgeo import list_registered_classes, verify_class_registration from python_magnetgeo.base import YAMLObjectBase -from python_magnetgeo.Probe import Probe -from python_magnetgeo.Shape import Shape -from python_magnetgeo.ModelAxi import ModelAxi -from python_magnetgeo.Model3D import Model3D +from python_magnetgeo.Bitter import Bitter +from python_magnetgeo.Bitters import Bitters +from python_magnetgeo.Chamfer import Chamfer +from python_magnetgeo.Contour2D import Contour2D +from python_magnetgeo.coolingslit import CoolingSlit +from python_magnetgeo.Groove import Groove from python_magnetgeo.Helix import Helix -from python_magnetgeo.Ring import Ring from python_magnetgeo.InnerCurrentLead import InnerCurrentLead -from python_magnetgeo.OuterCurrentLead import OuterCurrentLead from python_magnetgeo.Insert import Insert -from python_magnetgeo.Bitter import Bitter -from python_magnetgeo.Supra import Supra -from python_magnetgeo.Screen import Screen +from python_magnetgeo.Model3D import Model3D +from python_magnetgeo.ModelAxi import ModelAxi from python_magnetgeo.MSite import MSite -from python_magnetgeo.Bitters import Bitters +from python_magnetgeo.OuterCurrentLead import OuterCurrentLead +from python_magnetgeo.Probe import Probe +from python_magnetgeo.Ring import Ring +from python_magnetgeo.Screen import Screen +from python_magnetgeo.Shape import Shape +from python_magnetgeo.Supra import Supra from python_magnetgeo.Supras import Supras -from python_magnetgeo.Contour2D import Contour2D -from python_magnetgeo.Chamfer import Chamfer -from python_magnetgeo.Groove import Groove from python_magnetgeo.tierod import Tierod -from python_magnetgeo.coolingslit import CoolingSlit -from python_magnetgeo import list_registered_classes, verify_class_registration def test_classes_auto_registered(): """Test that classes are automatically registered""" registry = YAMLObjectBase.get_all_classes() - + # Check key classes are present assert 'Insert' in registry assert 'Helix' in registry assert 'Ring' in registry assert 'Bitter' in registry - + # Verify they're the correct classes assert registry['Insert'] is Insert assert registry['Helix'] is Helix @@ -41,7 +42,7 @@ def test_get_class_by_name(): """Test retrieving classes by name""" Ring_class = YAMLObjectBase.get_class('Ring') assert Ring_class is Ring - + # Can create instance ring = Ring_class( name="test", @@ -53,7 +54,7 @@ def test_get_class_by_name(): def test_list_registered_classes(): """Test utility function""" classes = list_registered_classes() - + assert isinstance(classes, dict) assert len(classes) >= 15 # Should have at least 15 classes assert 'Insert' in classes @@ -66,24 +67,24 @@ def test_verify_class_registration(): def test_unknown_class_error(): """Test error for unknown class""" from python_magnetgeo.deserialize import unserialize_object - + with pytest.raises(ValueError, match="Unknown class 'FakeClass'"): unserialize_object({'__classname__': 'FakeClass'}, debug=False) def test_custom_class_auto_registers(): """Test that custom classes auto-register""" - + class MyCustomGeometry(YAMLObjectBase): yaml_tag = "MyCustomGeometry" - + def __init__(self, name): self.name = name - + @classmethod def from_dict(cls, values, debug=False): return cls(values['name']) - + # Should be auto-registered registry = YAMLObjectBase.get_all_classes() assert 'MyCustomGeometry' in registry - assert registry['MyCustomGeometry'] is MyCustomGeometry \ No newline at end of file + assert registry['MyCustomGeometry'] is MyCustomGeometry diff --git a/tests/test_get_required_files.py b/tests/test_get_required_files.py index 5e24858..10da705 100644 --- a/tests/test_get_required_files.py +++ b/tests/test_get_required_files.py @@ -4,6 +4,7 @@ """ import unittest + from python_magnetgeo.Helix import Helix diff --git a/tests/test_helix_refactor.py b/tests/test_helix_refactor.py index d373a43..f14d063 100644 --- a/tests/test_helix_refactor.py +++ b/tests/test_helix_refactor.py @@ -7,18 +7,20 @@ the new YAMLObjectBase and validation framework. """ -import os import json -import yaml +import os import tempfile + import pytest +import yaml + +from python_magnetgeo.Chamfer import Chamfer +from python_magnetgeo.Groove import Groove from python_magnetgeo.Helix import Helix -from python_magnetgeo.ModelAxi import ModelAxi from python_magnetgeo.Model3D import Model3D -from python_magnetgeo.Shape import Shape -from python_magnetgeo.Groove import Groove -from python_magnetgeo.Chamfer import Chamfer +from python_magnetgeo.ModelAxi import ModelAxi from python_magnetgeo.Profile import Profile +from python_magnetgeo.Shape import Shape from python_magnetgeo.validation import ValidationError @@ -133,8 +135,8 @@ def test_refactored_helix_basic_functionality(): assert helix.r == [20.0, 40.0] assert helix.z == [10.0, 90.0] assert helix.cutwidth == 2.5 - assert helix.odd == True - assert helix.dble == False + assert helix.odd + assert not helix.dble assert helix.modelaxi is not None assert helix.model3d is not None assert helix.shape is not None @@ -172,8 +174,8 @@ def test_helix_json_serialization(): assert parsed['r'] == [15.0, 35.0] assert parsed['z'] == [5.0, 85.0] assert parsed['cutwidth'] == 3.0 - assert parsed['odd'] == False - assert parsed['dble'] == True + assert not parsed['odd'] + assert parsed['dble'] # Verify nested objects are serialized assert 'modelaxi' in parsed @@ -244,8 +246,8 @@ def test_helix_with_chamfers_and_grooves(): shape = Shape("groove_shape", "rectangular", [15.0], [90.0, 90.0, 90.0, 90.0], [2], "ALTERNATE") # Create chamfers - chamfer1 = Chamfer(name="chamfer1", side="HP", rside="rint", alpha=45.0, dr=None, l=1.0) - chamfer2 = Chamfer(name="chamfer2", side="BP", rside="rext", alpha=None, dr=0.5, l=1.0) + chamfer1 = Chamfer(name="chamfer1", side="HP", rside="rint", alpha=45.0, dr=None, length=1.0) + chamfer2 = Chamfer(name="chamfer2", side="BP", rside="rext", alpha=None, dr=0.5, length=1.0) # Create groove groove = Groove(name="test_groove", gtype="rint", n=4, eps=1.5) @@ -298,8 +300,8 @@ def test_helix_default_values(): helix = Helix.from_dict(test_dict) # Check defaults - assert helix.odd == True # default - assert helix.dble == False # default + assert helix.odd # default + assert not helix.dble # default assert helix.chamfers == [] # default empty list assert (helix.grooves is None) # default Groove object @@ -327,7 +329,7 @@ def test_helix_validation(): model3d=model3d, shape=shape ) - assert False, "Should have raised ValidationError for empty name" + raise AssertionError("Should have raised ValidationError for empty name") except (ValidationError, ValueError) as e: print(f"✓ Name validation works: {e}") @@ -344,7 +346,7 @@ def test_helix_validation(): model3d=model3d, shape=shape ) - assert False, "Should have raised ValidationError for bad radial bounds" + raise AssertionError("Should have raised ValidationError for bad radial bounds") except (ValidationError, ValueError) as e: print(f"✓ Radial bounds validation works: {e}") @@ -361,7 +363,7 @@ def test_helix_validation(): model3d=model3d, shape=shape ) - assert False, "Should have raised ValidationError for bad axial bounds" + raise AssertionError("Should have raised ValidationError for bad axial bounds") except (ValidationError, ValueError) as e: print(f"✓ Axial bounds validation works: {e}") @@ -423,8 +425,8 @@ def test_helix_complex_serialization(): model3d = Model3D("complex_model3d", "GMSH", True, True) shape = Shape("complex_shape", "rectangular", [15.0, 15.0, 15.0] , [45.0, 45.0, 45.0], [3], "BELOW") - chamfer1 = Chamfer(name="chamfer1", side="HP", rside="rint", alpha=45.0, dr=None, l=1.0) - chamfer2 = Chamfer(name="chamfer2", side="BP", rside="rext", alpha=None, dr=0.5, l=1.0) + chamfer1 = Chamfer(name="chamfer1", side="HP", rside="rint", alpha=45.0, dr=None, length=1.0) + chamfer2 = Chamfer(name="chamfer2", side="BP", rside="rext", alpha=None, dr=0.5, length=1.0) groove = Groove(name="test_groove", gtype="rint", n=4, eps=1.5) helix = Helix( diff --git a/tests/test_insert_refactor.py b/tests/test_insert_refactor.py index 51a7922..ffd1197 100644 --- a/tests/test_insert_refactor.py +++ b/tests/test_insert_refactor.py @@ -4,14 +4,15 @@ Corrects the YAML round-trip test to avoid FileNotFoundError with string references """ -import os import json -from python_magnetgeo.Insert import Insert +import os + from python_magnetgeo.Helix import Helix -from python_magnetgeo.Ring import Ring from python_magnetgeo.InnerCurrentLead import InnerCurrentLead +from python_magnetgeo.Insert import Insert from python_magnetgeo.OuterCurrentLead import OuterCurrentLead from python_magnetgeo.Probe import Probe +from python_magnetgeo.Ring import Ring from python_magnetgeo.validation import ValidationError diff --git a/tests/test_insert_validation.py b/tests/test_insert_validation.py index de4cf95..1a8c2f7 100644 --- a/tests/test_insert_validation.py +++ b/tests/test_insert_validation.py @@ -1,15 +1,17 @@ import pytest -from python_magnetgeo.ModelAxi import ModelAxi -from python_magnetgeo.Model3D import Model3D -from python_magnetgeo.Shape import Shape + from python_magnetgeo.Helix import Helix -from python_magnetgeo.Ring import Ring from python_magnetgeo.Insert import Insert +from python_magnetgeo.Model3D import Model3D +from python_magnetgeo.ModelAxi import ModelAxi +from python_magnetgeo.Ring import Ring +from python_magnetgeo.Shape import Shape from python_magnetgeo.validation import ValidationError + def test_insert_valid_no_rings(): """Test Insert with helices but no rings is valid""" - + # Create nested objects modelaxi = ModelAxi( name="test_helix", @@ -17,10 +19,10 @@ def test_insert_valid_no_rings(): turns=[5, 7], pitch=[0.008, 0.008] ) - + h1 = Helix("H1", [10, 20], [0, 50], 0.2, True, False, modelaxi=modelaxi) h2 = Helix("H2", [25, 35], [0, 50], 0.2, True, False, modelaxi=modelaxi) - + # Should succeed - rings are optional insert = Insert( name="test", @@ -44,14 +46,14 @@ def test_insert_valid_with_rings(): turns=[5, 7], pitch=[0.008, 0.008] ) - + h1 = Helix("H1", [10, 20], [0, 50], 0.2, True, False, modelaxi=modelaxi) h2 = Helix("H2", [25, 35], [0, 50], 0.2, True, False, modelaxi=modelaxi) h3 = Helix("H3", [40, 50], [0, 50], 0.2, True, False, modelaxi=modelaxi) - + r1 = Ring("R1", [10, 20, 25, 35], [50, 55]) # connects H1 and H2 r2 = Ring("R2", [25, 35, 40, 50], [50, 55]) # connects H2 and H3 - + # 3 helices need 2 connecting rings - should succeed insert = Insert( name="test", @@ -75,13 +77,13 @@ def test_insert_invalid_ring_count(): turns=[5, 7], pitch=[0.008, 0.008] ) - + h1 = Helix("H1", [10, 20], [0, 50], 0.2, True, False, modelaxi=modelaxi) h2 = Helix("H2", [25, 35], [0, 50], 0.2, True, False, modelaxi=modelaxi) - + r1 = Ring("R1", [20, 22, 25, 27], [50, 55]) r2 = Ring("R2", [35, 37, 40, 42], [50, 55]) - + # 2 helices need 1 ring, but providing 2 - should fail with pytest.raises(ValidationError, match="must be equal to number of helices"): Insert( @@ -102,10 +104,10 @@ def test_insert_invalid_single_helix_with_rings(): turns=[5, 7], pitch=[0.008, 0.008] ) - + h1 = Helix("H1", [10, 20], [0, 50], 0.2, True, False, modelaxi=modelaxi) r1 = Ring("R1", [20, 22, 25, 27], [50, 55]) - + # Can't connect 1 helix with ring - need at least 2 with pytest.raises(ValidationError, match="must be equal to number of helices"): Insert( @@ -129,7 +131,7 @@ def test_insert_invalid_hangles_count(): h1 = Helix("H1", [10, 20], [0, 50], 0.2, True, False, modelaxi=modelaxi) h2 = Helix("H2", [25, 35], [0, 50], 0.2, True, False, modelaxi=modelaxi) - + with pytest.raises(ValidationError, match="Number of hangles.*must match"): Insert( name="test", @@ -149,11 +151,11 @@ def test_insert_invalid_rangles_count(): turns=[5, 7], pitch=[0.008, 0.008] ) - + h1 = Helix("H1", [10, 20], [0, 50], 0.2, True, False, modelaxi=modelaxi) h2 = Helix("H2", [25, 35], [0, 50], 0.2, True, False, modelaxi=modelaxi) r1 = Ring("R1", [20, 22, 25, 27], [50, 55]) - + with pytest.raises(ValidationError, match="Number of rangles.*must match"): Insert( name="test", @@ -162,4 +164,4 @@ def test_insert_invalid_rangles_count(): currentleads=[], hangles=[], rangles=[90.0, 180.0] # 1 ring but 2 angles! - ) \ No newline at end of file + ) diff --git a/tests/test_magnet_refactor.py b/tests/test_magnet_refactor.py index f472828..2e53f9b 100644 --- a/tests/test_magnet_refactor.py +++ b/tests/test_magnet_refactor.py @@ -7,17 +7,17 @@ """ import json -import sys import os +import sys from pathlib import Path # Add python_magnetgeo to path test_dir = Path(__file__).parent sys.path.insert(0, str(test_dir.parent)) -from python_magnetgeo.Insert import Insert -from python_magnetgeo.Supras import Supras -from python_magnetgeo.Bitters import Bitters +from python_magnetgeo.Bitters import Bitters # noqa: E402 +from python_magnetgeo.Insert import Insert # noqa: E402 +from python_magnetgeo.Supras import Supras # noqa: E402 def test_insert_serialization(): diff --git a/tests/test_model3d_refactor.py b/tests/test_model3d_refactor.py index 04e39b6..debf3ef 100644 --- a/tests/test_model3d_refactor.py +++ b/tests/test_model3d_refactor.py @@ -1,29 +1,30 @@ import json + def test_model3d_refactor(): """Test Model3D refactor""" print("Testing Model3D refactor...") - + from python_magnetgeo.Model3D import Model3D - + # Test creation model = Model3D( name="test_model", - cad="SALOME", + cad="SALOME", with_shapes=True, with_channels=False ) - + print(f"✓ Model3D created: {model}") - + # Test inherited methods json_str = model.to_json() parsed = json.loads(json_str) assert parsed['name'] == 'test_model' assert parsed['cad'] == 'SALOME' - + print("✓ Model3D JSON serialization works") - + # Test from_dict dict_data = { 'name': 'dict_model', @@ -31,11 +32,11 @@ def test_model3d_refactor(): 'with_shapes': False, 'with_channels': True } - + dict_model = Model3D.from_dict(dict_data) assert dict_model.name == 'dict_model' assert dict_model.cad == 'GMSH' - + print("✓ Model3D from_dict works") print("Model3D successfully refactored!\n") diff --git a/tests/test_msite_refactor.py b/tests/test_msite_refactor.py index 0fd1053..9917ee5 100644 --- a/tests/test_msite_refactor.py +++ b/tests/test_msite_refactor.py @@ -15,19 +15,20 @@ 9. Get methods and operations """ -import os import json +import os import tempfile -from python_magnetgeo.MSite import MSite -from python_magnetgeo.Insert import Insert -from python_magnetgeo.Supras import Supras -from python_magnetgeo.Supra import Supra + from python_magnetgeo.Helix import Helix -from python_magnetgeo.Screen import Screen -from python_magnetgeo.ModelAxi import ModelAxi +from python_magnetgeo.Insert import Insert from python_magnetgeo.Model3D import Model3D -from python_magnetgeo.Shape import Shape +from python_magnetgeo.ModelAxi import ModelAxi +from python_magnetgeo.MSite import MSite from python_magnetgeo.Profile import Profile +from python_magnetgeo.Screen import Screen +from python_magnetgeo.Shape import Shape +from python_magnetgeo.Supra import Supra +from python_magnetgeo.Supras import Supras from python_magnetgeo.validation import ValidationError @@ -196,7 +197,7 @@ def test_validation(): try: MSite(name="", magnets=[insert], screens=None, z_offset=None, r_offset=None, paralax=None) - assert False, "Should have raised ValidationError for empty name" + raise AssertionError("Should have raised ValidationError for empty name") except (ValidationError, ValueError) as e: print(f"✓ Name validation works: {e}") @@ -204,7 +205,7 @@ def test_validation(): try: MSite(name=None, magnets=[insert], screens=None, z_offset=None, r_offset=None, paralax=None) - assert False, "Should have raised ValidationError for None name" + raise AssertionError("Should have raised ValidationError for None name") except (ValidationError, ValueError, TypeError) as e: print(f"✓ Name validation works for None: {e}") diff --git a/tests/test_nested_loading.py b/tests/test_nested_loading.py index 5831bb5..cffc63e 100644 --- a/tests/test_nested_loading.py +++ b/tests/test_nested_loading.py @@ -1,10 +1,12 @@ import pytest -from python_magnetgeo.Insert import Insert -from python_magnetgeo.Helix import Helix -from python_magnetgeo.Ring import Ring + from python_magnetgeo.Bitter import Bitter -from python_magnetgeo.ModelAxi import ModelAxi from python_magnetgeo.coolingslit import CoolingSlit +from python_magnetgeo.Helix import Helix +from python_magnetgeo.Insert import Insert +from python_magnetgeo.ModelAxi import ModelAxi +from python_magnetgeo.Ring import Ring + def test_load_nested_list_from_dicts(): """Test loading list of objects from inline dicts""" @@ -12,9 +14,9 @@ def test_load_nested_list_from_dicts(): {'name': 'H1', 'r': [10, 20], 'z': [0, 50], 'cutwidth': 0.2, 'odd': True, 'dble': False}, {'name': 'H2', 'r': [25, 35], 'z': [0, 50], 'cutwidth': 0.2, 'odd': True, 'dble': False} ] - + helices = Insert._load_nested_list(data, Helix) - + assert len(helices) == 2 assert all(isinstance(h, Helix) for h in helices) assert helices[0].name == 'H1' @@ -23,9 +25,9 @@ def test_load_nested_list_from_dicts(): def test_load_nested_single_from_dict(): """Test loading single object from inline dict""" data = {'name': 'test_axi', 'h': 15.0, 'turns': [3.0], 'pitch': [10.0]} - + modelaxi = Bitter._load_nested_single(data, ModelAxi) - + assert isinstance(modelaxi, ModelAxi) assert modelaxi.name == 'test_axi' @@ -48,10 +50,10 @@ def test_load_nested_mixed_inputs(): """Test loading with mix of dicts and objects""" h1_dict = {'name': 'H1', 'r': [10, 20], 'z': [0, 50], 'cutwidth': 0.2, 'odd': True, 'dble': False} h2_obj = Helix('H2', [25, 35], [0, 50], 0.2, True, False) - + data = [h1_dict, h2_obj] helices = Insert._load_nested_list(data, Helix) - + assert len(helices) == 2 assert helices[0].name == 'H1' - assert helices[1].name == 'H2' \ No newline at end of file + assert helices[1].name == 'H2' diff --git a/tests/test_phase4.py b/tests/test_phase4.py index 79e1b17..7f491c0 100644 --- a/tests/test_phase4.py +++ b/tests/test_phase4.py @@ -6,10 +6,11 @@ and validation framework, following the spirit of test-refactor-ring.py """ -import os import json -import tempfile import math +import os +import tempfile + from python_magnetgeo.Chamfer import Chamfer from python_magnetgeo.Groove import Groove from python_magnetgeo.ModelAxi import ModelAxi @@ -24,14 +25,14 @@ def test_refactored_chamfer(): # Test basic creation with alpha chamfer_alpha = Chamfer( - name="test_chamfer_alpha", side="HP", rside="rint", alpha=30.0, dr=None, l=10.0 + name="test_chamfer_alpha", side="HP", rside="rint", alpha=30.0, dr=None, length=10.0 ) print(f"✓ Chamfer (alpha) created: {chamfer_alpha}") # Test basic creation with dr chamfer_dr = Chamfer( - name="test_chamfer_dr", side="BP", rside="rext", alpha=None, dr=5.0, l=10.0 + name="test_chamfer_dr", side="BP", rside="rext", alpha=None, dr=5.0, length=10.0 ) print(f"✓ Chamfer (dr) created: {chamfer_dr}") @@ -53,7 +54,7 @@ def test_refactored_chamfer(): assert parsed["side"] == "HP" assert parsed["rside"] == "rint" assert parsed["alpha"] == 30.0 - assert parsed["l"] == 10.0 + assert parsed["length"] == 10.0 assert parsed["__classname__"] == "Chamfer" print("✓ JSON serialization works") @@ -64,7 +65,7 @@ def test_refactored_chamfer(): "side": "HP", "rside": "rint", "alpha": 45.0, - "l": 15.0, + "length": 15.0, } dict_chamfer = Chamfer.from_dict(dict_alpha) @@ -75,7 +76,7 @@ def test_refactored_chamfer(): print("✓ from_dict works (alpha)") # Test from_dict with dr - dict_dr = {"name": "dict_chamfer_dr", "side": "BP", "rside": "rext", "dr": 7.5, "l": 20.0} + dict_dr = {"name": "dict_chamfer_dr", "side": "BP", "rside": "rext", "dr": 7.5, "length": 20.0} dict_chamfer_dr = Chamfer.from_dict(dict_dr) assert dict_chamfer_dr.name == "dict_chamfer_dr" @@ -208,8 +209,8 @@ def test_refactored_modelaxi(): # Test default constructor try: - empty_modelaxi = ModelAxi() - assert False, "Should have raised ValidationError for empty name" + ModelAxi() + raise AssertionError("Should have raised ValidationError for empty name") except ValidationError as e: print(f"✓ Empty name validation: {e}") @@ -296,7 +297,7 @@ def test_cross_class_integration(): # Create instances of all three classes chamfer = Chamfer( - name="integration_chamfer", side="HP", rside="rint", alpha=45.0, dr=None, l=10.0 + name="integration_chamfer", side="HP", rside="rint", alpha=45.0, dr=None, length=10.0 ) groove = Groove(name="integration_groove", gtype="rint", n=4, eps=2.0) @@ -339,19 +340,19 @@ def test_validation_edge_cases(): # Chamfer: Test that getDr() fails when neither alpha nor dr is set chamfer_no_params = Chamfer( - name="invalid_chamfer", side="HP", rside="rint", alpha=None, dr=None, l=10.0 + name="invalid_chamfer", side="HP", rside="rint", alpha=None, dr=None, length=10.0 ) try: chamfer_no_params.getDr() - assert False, "Should have raised ValueError" + raise AssertionError("Should have raised ValueError") except ValueError as e: print(f"✓ Chamfer validation works: {e}") # Test that getAngle() fails when neither alpha nor dr is set try: chamfer_no_params.getAngle() - assert False, "Should have raised ValueError" + raise AssertionError("Should have raised ValueError") except ValueError as e: print(f"✓ Chamfer validation works: {e}") diff --git a/tests/test_phase4_leads.py b/tests/test_phase4_leads.py index 48a7782..d6c81cd 100644 --- a/tests/test_phase4_leads.py +++ b/tests/test_phase4_leads.py @@ -7,9 +7,10 @@ adding validation and improved error handling. """ -import os import json +import os import tempfile + from python_magnetgeo.InnerCurrentLead import InnerCurrentLead from python_magnetgeo.OuterCurrentLead import OuterCurrentLead from python_magnetgeo.validation import ValidationError @@ -114,7 +115,7 @@ def test_inner_lead_json_serialization(): print("✓ JSON serialization works correctly") print(f" - __classname__: {parsed['__classname__']}") - print(f" - All attributes serialized properly") + print(" - All attributes serialized properly") def test_inner_lead_from_dict(): @@ -473,7 +474,7 @@ def test_comparison_with_original_functionality(): print("✓ JSON file round-trip successful") print(f" - Inner lead: {inner_loaded}") print(f" - Outer lead: {outer_loaded}") - except Exception as e: + except Exception: print("✗ JSON file round-trip successful") print(f" - Inner lead: {inner_loaded}") print(f" - Outer lead: {outer_loaded}") diff --git a/tests/test_profile.py b/tests/test_profile.py index f716ffe..70422d0 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -4,10 +4,11 @@ Tests creation, serialization, validation, and DAT file generation """ -import os import json +import os import tempfile from pathlib import Path + import pytest from python_magnetgeo.Profile import Profile @@ -191,7 +192,7 @@ def test_generate_dat_with_labels(self): # Check data points with labels lines = content.split("\n") - data_lines = [l for l in lines if l and not l.startswith("#")] + data_lines = [line for line in lines if line and not line.startswith("#")] assert len(data_lines) >= 5 # 1 for count, 5 for points def test_generate_dat_without_labels(self): @@ -219,7 +220,7 @@ def test_generate_dat_without_labels(self): # Verify data lines don't have labels lines = content.split("\n") - data_lines = [l for l in lines if l and not l.startswith("#") and len(l.strip()) > 0] + data_lines = [line for line in lines if line and not line.startswith("#") and len(line.strip()) > 0] # First data line is the count # Subsequent lines should have 2 values only (X, F) for line in data_lines[1:]: diff --git a/tests/test_refactor_bitter.py b/tests/test_refactor_bitter.py index 1e784ec..5e6ff34 100644 --- a/tests/test_refactor_bitter.py +++ b/tests/test_refactor_bitter.py @@ -8,14 +8,15 @@ Similar to test-refactor-ring.py approach - focused on core functionality. """ -import os import json +import os import tempfile + from python_magnetgeo.Bitter import Bitter -from python_magnetgeo.ModelAxi import ModelAxi +from python_magnetgeo.Contour2D import Contour2D from python_magnetgeo.coolingslit import CoolingSlit +from python_magnetgeo.ModelAxi import ModelAxi from python_magnetgeo.tierod import Tierod -from python_magnetgeo.Contour2D import Contour2D from python_magnetgeo.validation import ValidationError @@ -54,7 +55,7 @@ def test_refactored_bitter_functionality(): assert parsed['name'] == 'test_bitter' assert parsed['r'] == [0.10, 0.15] assert parsed['z'] == [-0.05, 0.05] - assert parsed['odd'] == True + assert parsed['odd'] print("✓ JSON serialization works") @@ -75,7 +76,7 @@ def test_refactored_bitter_functionality(): assert dict_bitter.name == 'dict_bitter' assert dict_bitter.r == [0.12, 0.17] assert dict_bitter.z == [-0.06, 0.06] - assert dict_bitter.odd == False + assert not dict_bitter.odd print("✓ from_dict works") @@ -104,21 +105,21 @@ def test_enhanced_validation(): # Test validation catches empty name try: Bitter(name="", r=[0.1, 0.15], z=[-0.05, 0.05], odd=True, modelaxi=None) - assert False, "Should have raised ValidationError for empty name" + raise AssertionError("Should have raised ValidationError for empty name") except ValidationError as e: print(f"✓ Empty name validation: {e}") # Test validation catches invalid r coordinates try: Bitter(name="test", r=[0.15, 0.1], z=[-0.05, 0.05], odd=True, modelaxi=None) # Wrong order - assert False, "Should have raised ValidationError for wrong radial order" + raise AssertionError("Should have raised ValidationError for wrong radial order") except ValidationError as e: print(f"✓ Radial order validation: {e}") # Test validation catches invalid z coordinates try: Bitter(name="test", r=[0.1, 0.15], z=[0.05, -0.05], odd=True, modelaxi=None) # Wrong order - assert False, "Should have raised ValidationError for wrong z order" + raise AssertionError("Should have raised ValidationError for wrong z order") except ValidationError as e: print(f"✓ Axial order validation: {e}") diff --git a/tests/test_refactor_bitters.py b/tests/test_refactor_bitters.py index 9ff4ed8..9bc2968 100644 --- a/tests/test_refactor_bitters.py +++ b/tests/test_refactor_bitters.py @@ -6,10 +6,11 @@ Tests that the migrated Bitters collection class works correctly with the new base classes. """ -import os import json -from python_magnetgeo.Bitters import Bitters +import os + from python_magnetgeo.Bitter import Bitter +from python_magnetgeo.Bitters import Bitters from python_magnetgeo.validation import ValidationError @@ -97,15 +98,15 @@ def test_refactored_bitters_functionality(): print("✓ boundingBox works") # Test intersect - assert bitters.intersect([0.12, 0.14], [-0.02, 0.02]) == True - assert bitters.intersect([0.20, 0.25], [0.0, 0.1]) == False + assert bitters.intersect([0.12, 0.14], [-0.02, 0.02]) + assert not bitters.intersect([0.20, 0.25], [0.0, 0.1]) print("✓ intersect works") # Test validation try: Bitters(name="", magnets=[], innerbore=0.1, outerbore=0.2) - assert False, "Should have raised ValidationError for empty name" + raise AssertionError("Should have raised ValidationError for empty name") except ValidationError as e: print(f"✓ Validation works: {e}") diff --git a/tests/test_refactor_coolingslits.py b/tests/test_refactor_coolingslits.py index b543a63..562c5d6 100644 --- a/tests/test_refactor_coolingslits.py +++ b/tests/test_refactor_coolingslits.py @@ -9,11 +9,12 @@ CoolingSlit represents cooling channels in magnets with nested Contour2D geometry. """ -import os import json +import os import tempfile -from python_magnetgeo.coolingslit import CoolingSlit + from python_magnetgeo.Contour2D import Contour2D +from python_magnetgeo.coolingslit import CoolingSlit from python_magnetgeo.validation import ValidationError @@ -141,7 +142,7 @@ def test_coolingslit_json_serialization(): print("✓ JSON serialization works correctly") print(f" - __classname__: {parsed['__classname__']}") - print(f" - All attributes serialized properly") + print(" - All attributes serialized properly") print(f" - Nested Contour2D serialized: {parsed['contour2d']['name']}") @@ -361,7 +362,7 @@ def test_coolingslit_in_bitter_context(): print("✓ CoolingSlits work correctly in Bitter context") print(f" - Slit 1: {slit1.name} at r={slit1.r}, angle={slit1.angle}°") print(f" - Slit 2: {slit2.name} at r={slit2.r}, angle={slit2.angle}°") - print(f" - List serialization works correctly") + print(" - List serialization works correctly") def test_coolingslit_repr(): @@ -487,7 +488,7 @@ def test_coolingslit_comprehensive_functionality(): assert slit_from_json.name == slit.name assert slit_from_json.r == slit.r print("✓ JSON round-trip works correctly") - except Exception as e: + except Exception: print("✗ JSON round-trip works correctly") finally: if os.path.exists(json_file): diff --git a/tests/test_refactor_shape.py b/tests/test_refactor_shape.py index f19957e..02e7061 100644 --- a/tests/test_refactor_shape.py +++ b/tests/test_refactor_shape.py @@ -4,11 +4,12 @@ Following the pattern from test-refactor-ring.py """ -import os import json +import os import tempfile -from python_magnetgeo.Shape import Shape, ShapePosition + from python_magnetgeo.Profile import Profile +from python_magnetgeo.Shape import Shape, ShapePosition from python_magnetgeo.validation import ValidationError @@ -171,7 +172,7 @@ def test_shape_validation(): # Test position validation which is active try: Shape(name="test_invalid_position", profile=test_profile, position="INVALID_POS") - assert False, "Should have raised ValidationError for invalid position" + raise AssertionError("Should have raised ValidationError for invalid position") except ValidationError as e: assert "Invalid position" in str(e) print(f"✓ Invalid position validation works: {e}") @@ -284,7 +285,7 @@ def test_shape_json_file_operations(): # Verify file exists and contains correct data assert os.path.exists(filename) - with open(filename, 'r') as f: + with open(filename) as f: content = json.load(f) assert content['name'] == 'json_file_test' assert 'profile' in content # Profile is a nested object @@ -364,7 +365,7 @@ def test_shape_position_values(): profile=test_profile, position="INVALID" ) - assert False, "Should have raised ValidationError for invalid position" + raise AssertionError("Should have raised ValidationError for invalid position") except ValidationError as e: assert "Invalid position" in str(e) assert "ABOVE, BELOW, ALTERNATE" in str(e) diff --git a/tests/test_refactor_supra.py b/tests/test_refactor_supra.py index 71df38a..f9da6f7 100644 --- a/tests/test_refactor_supra.py +++ b/tests/test_refactor_supra.py @@ -4,9 +4,10 @@ Follows the pattern from test-refactor-ring.py. """ -import os import json +import os import tempfile + from python_magnetgeo.Supra import Supra from python_magnetgeo.Supras import Supras from python_magnetgeo.validation import ValidationError @@ -76,7 +77,7 @@ def test_supra_basic_functionality(): minimal_supra = Supra.from_dict(minimal_dict) assert minimal_supra.n == 0, "Default n should be 0" - assert minimal_supra.struct == None, "Default struct should be empty" + assert minimal_supra.struct is None, "Default struct should be empty" print("✓ Default values work correctly") @@ -92,35 +93,35 @@ def test_supra_validation(): # Test validation: empty name try: Supra(name="", r=[20.0, 30.0], z=[10.0, 80.0], n=5, struct="") - assert False, "Should have raised ValidationError for empty name" + raise AssertionError("Should have raised ValidationError for empty name") except ValidationError as e: print(f"✓ Empty name validation: {e}") # Test validation: invalid radial bounds (r[0] > r[1]) try: Supra(name="bad_supra", r=[30.0, 20.0], z=[10.0, 80.0], n=5, struct="") - assert False, "Should have raised ValidationError for invalid r" + raise AssertionError("Should have raised ValidationError for invalid r") except ValidationError as e: print(f"✓ Radial bounds validation: {e}") # Test validation: invalid axial bounds (z[0] > z[1]) try: Supra(name="bad_supra", r=[20.0, 30.0], z=[80.0, 10.0], n=5, struct="") - assert False, "Should have raised ValidationError for invalid z" + raise AssertionError("Should have raised ValidationError for invalid z") except ValidationError as e: print(f"✓ Axial bounds validation: {e}") # Test validation: wrong list length for r try: Supra(name="bad_supra", r=[20.0], z=[10.0, 80.0], n=5, struct="") - assert False, "Should have raised ValidationError for wrong r length" + raise AssertionError("Should have raised ValidationError for wrong r length") except ValidationError as e: print(f"✓ Radial list length validation: {e}") # Test validation: wrong list length for z try: Supra(name="bad_supra", r=[20.0, 30.0], z=[10.0], n=5, struct="") - assert False, "Should have raised ValidationError for wrong z length" + raise AssertionError("Should have raised ValidationError for wrong z length") except ValidationError as e: print(f"✓ Axial list length validation: {e}") @@ -266,7 +267,7 @@ def test_supras_validation(): # Test empty name try: Supras(name="", magnets=[supra], innerbore=18.0, outerbore=32.0) - assert False, "Should have raised ValidationError for empty name" + raise AssertionError("Should have raised ValidationError for empty name") except ValidationError as e: print(f"✓ Empty name validation: {e}") diff --git a/tests/test_refactor_supras.py b/tests/test_refactor_supras.py index 352ab76..7bc1bf8 100644 --- a/tests/test_refactor_supras.py +++ b/tests/test_refactor_supras.py @@ -7,13 +7,13 @@ python test_refactor_supras.py """ -import os import json +import os import tempfile -from python_magnetgeo.Supras import Supras -from python_magnetgeo.Supra import Supra + from python_magnetgeo.Probe import Probe -from python_magnetgeo.Supra import DetailLevel +from python_magnetgeo.Supra import DetailLevel, Supra +from python_magnetgeo.Supras import Supras from python_magnetgeo.validation import ValidationError @@ -275,21 +275,21 @@ def test_supras_validation(): # Test 1: Empty name try: Supras(name="", magnets=[supra], innerbore=18.0, outerbore=32.0) - assert False, "Should have raised ValidationError for empty name" + raise AssertionError("Should have raised ValidationError for empty name") except ValidationError as e: print(f"✓ Empty name validation: {e}") # Test 2: Invalid bore dimensions (inner >= outer) try: Supras(name="bad_supras", magnets=[supra], innerbore=32.0, outerbore=18.0) - assert False, "Should have raised ValidationError for invalid bores" + raise AssertionError("Should have raised ValidationError for invalid bores") except ValidationError as e: print(f"✓ Bore dimensions validation: {e}") # Test 3: Equal bore dimensions try: Supras(name="bad_supras", magnets=[supra], innerbore=25.0, outerbore=25.0) - assert False, "Should have raised ValidationError for equal bores" + raise AssertionError("Should have raised ValidationError for equal bores") except ValidationError as e: print(f"✓ Equal bore validation: {e}") diff --git a/tests/test_refactor_tierod.py b/tests/test_refactor_tierod.py index 752b5e0..f8e1fec 100644 --- a/tests/test_refactor_tierod.py +++ b/tests/test_refactor_tierod.py @@ -6,12 +6,13 @@ and validation framework, similar to test-refactor-ring.py approach. """ -import os import json +import os import tempfile + +from python_magnetgeo.Contour2D import Contour2D from python_magnetgeo.tierod import Tierod from python_magnetgeo.validation import ValidationError -from python_magnetgeo.Contour2D import Contour2D def test_refactored_tierod_functionality(): @@ -87,14 +88,14 @@ def test_enhanced_validation(): # Test validation catches negative radius try: Tierod(name="test", r=-5.0, n=8, dh=10.0, sh=5.0, contour2d=None) - assert False, "Should have raised ValidationError for negative radius" + raise AssertionError("Should have raised ValidationError for negative radius") except ValidationError as e: print(f"✓ Negative radius validation: {e}") # Test validation catches invalid n type try: Tierod(name="test", r=12.5, n="invalid", dh=10.0, sh=5.0, contour2d=None) - assert False, "Should have raised ValidationError for invalid n" + raise AssertionError("Should have raised ValidationError for invalid n") except ValidationError as e: print(f"✓ Invalid n type validation: {e}") @@ -376,7 +377,7 @@ def test_json_file_operations(): # Verify file exists and contains correct data assert os.path.exists(filename) - with open(filename, 'r') as f: + with open(filename) as f: content = f.read() assert 'json_test' in content assert '25.0' in content diff --git a/tests/test_yaml_nested_objects.py b/tests/test_yaml_nested_objects.py index 9387567..e8413a1 100644 --- a/tests/test_yaml_nested_objects.py +++ b/tests/test_yaml_nested_objects.py @@ -8,10 +8,11 @@ import pytest import yaml + from python_magnetgeo.Bitter import Bitter -from python_magnetgeo.ModelAxi import ModelAxi from python_magnetgeo.Helix import Helix from python_magnetgeo.Insert import Insert +from python_magnetgeo.ModelAxi import ModelAxi from python_magnetgeo.Ring import Ring From 5a4907a16f3d02c6ff95862c9159508c9c4e1882 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Thu, 30 Apr 2026 16:35:28 +0200 Subject: [PATCH 18/25] add docs workflow --- .github/workflows/docs.yml | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..72dd1ba --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,51 @@ +name: Build and Deploy Docs + +on: + push: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[docs]" + + - name: Build docs + run: sphinx-build -W -b html docs docs/_build/html + + - name: Upload pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_build/html + + deploy: + needs: build + runs-on: ubuntu-24.04 + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 From 15933eb1d3fbedab508dddcaecab20ac9f3a3d80 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Mon, 4 May 2026 19:37:03 +0200 Subject: [PATCH 19/25] create xls for Catia after adding shapes --- python_magnetgeo/Helix.py | 54 +++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/python_magnetgeo/Helix.py b/python_magnetgeo/Helix.py index c5b2014..6fd647c 100644 --- a/python_magnetgeo/Helix.py +++ b/python_magnetgeo/Helix.py @@ -437,38 +437,32 @@ def generate_cut(self, format: str = "SALOME"): ) from e # when format is catia, we need to convert the created xls to excel - # using Scripts/write_excel.py from magnettools - # ex: HL-41_H1-compressed_cut_with_shapes.xls if format == "CATIA": - xls_file = f"{self.name}-cut_with_shapes.xls" - excel_file = f"{self.name}-cut_with_shapes-v5R19.xlsx" - logger.info( - "create_cut: convert to Excel - run %s -m magnettools.scripts.write_excel %s %s", - sys.executable, - xls_file, - excel_file, - ) + from tempfile import TemporaryFile + + from xlwt import Workbook + + xls_file = f"{self.name}_cut_with_shapes.xls" + excel_file = f"{self.name}_cut_with_shapes_V5R19.xls" + logger.info("create_cut: convert %s to Excel %s", xls_file, excel_file) try: - result = subprocess.run( - [ - sys.executable, - "-m", - "magnettools.scripts.write_excel", - xls_file, - excel_file, - ], - check=True, - text=True, - capture_output=True, - ) - logger.debug(result.stdout) - except subprocess.CalledProcessError as e: - raise RuntimeError( - f"Excel conversion failed (exit {e.returncode}):\n" - f" cmd: {' '.join(map(str, e.cmd))}\n" - f" stdout: {e.stdout}\n" - f" stderr: {e.stderr}" - ) from e + book = Workbook() + sheet1 = book.add_sheet("Sheet 1") + num = 0 + with open(xls_file, "r") as input_file: + for line in input_file: + if line[0] != "#" and len(line.strip()): + data = line.split() + row = sheet1.row(num) + if len(data) == 3: + data = [float(v) for v in data] + for n, value in enumerate(data): + row.write(n, value) + num += 1 + book.save(excel_file) + book.save(TemporaryFile()) + except Exception as e: + raise RuntimeError(f"Excel conversion failed for {xls_file}: {e}") from e def intersect(self, r: list[float], z: list[float]) -> bool: """ From f421462261ea41619795ef2a563dcffb5f23aeb1 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Tue, 5 May 2026 07:38:34 +0200 Subject: [PATCH 20/25] fix ruff lintings --- python_magnetgeo/Helix.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python_magnetgeo/Helix.py b/python_magnetgeo/Helix.py index 6fd647c..9a2dd2a 100644 --- a/python_magnetgeo/Helix.py +++ b/python_magnetgeo/Helix.py @@ -13,7 +13,6 @@ import math import os -import sys from .base import YAMLObjectBase from .Chamfer import Chamfer @@ -449,7 +448,7 @@ def generate_cut(self, format: str = "SALOME"): book = Workbook() sheet1 = book.add_sheet("Sheet 1") num = 0 - with open(xls_file, "r") as input_file: + with open(xls_file) as input_file: for line in input_file: if line[0] != "#" and len(line.strip()): data = line.split() From b1a16b646b713bd1e8936b2df6c7f66967e916e5 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Tue, 5 May 2026 16:48:27 +0200 Subject: [PATCH 21/25] add message for assert --- examples/compress-hcut.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/examples/compress-hcut.py b/examples/compress-hcut.py index c340bb2..00ecc0f 100644 --- a/examples/compress-hcut.py +++ b/examples/compress-hcut.py @@ -5,6 +5,8 @@ from python_magnetgeo.Helix import Helix from python_magnetgeo.ModelAxi import ModelAxi +from python_magnetgeo.Shape import Shape +from python_magnetgeo.Profile import Profile def main(): @@ -18,7 +20,7 @@ def main(): nargs="+", help='Path(s) to input YAML file(s); glob patterns (e.g. "data/*.yaml") are supported', ) - parser.add_argument("--ratio", help="Compress ratio", type=float, default=0.9) + parser.add_argument("--ratio", help="Compress ratio", type=float, default=1.0) args = parser.parse_args() @@ -56,7 +58,9 @@ def main(): print(f"Original hcut: {len(hcut.pitch)}", flush=True) print(f"Compacted hcut: {len(pitch)}", flush=True) nhcut = ModelAxi(name="compacted", h=hcut.h, turns=turns, pitch=pitch) - assert hcut.get_Nturns() == nhcut.get_Nturns() + assert ( + abs(1 - hcut.get_Nturns() / nhcut.get_Nturns()) < 1e-6 + ), f"Nturns mismatch after compaction: original={hcut.get_Nturns()}, compacted={nhcut.get_Nturns()}" print(f"Compact hcut: {len(nhcut.pitch)}", flush=True) @@ -75,10 +79,29 @@ def main(): ) print("Create new helix with compacted hcut") - # save as yaml - + # compress hcut new_pitch = [args.ratio * p for p in nhcut.pitch] + # compress shape + print(f"Shape: {obj.shape}") + new_shape = None + if obj.shape is not None: + profile = obj.shape.profile + print(f"Profile: {profile}") + new_points = [[point[0], point[1] * args.ratio] for point in profile.points] + new_profile = Profile( + cad=f"{profile.cad}-compressed", points=new_points, labels=profile.labels + ) + + new_shape = Shape( + name=f"{obj.shape.name}-compressed", + profile=new_profile, + length=obj.shape.length, + angle=obj.shape.angle, + onturns=obj.shape.onturns, + position=obj.shape.position, + ) + # create new heliw with compressed hcut new_modelaxi = ModelAxi( name="compressed", @@ -95,7 +118,7 @@ def main(): obj.dble, new_modelaxi, obj.model3d, - obj.shape, + obj.shape if new_shape is None else new_shape, obj.chamfers, obj.grooves, ) From 7b3c0b7e47195700b2d105a56e071ee839a9628d Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Tue, 5 May 2026 16:58:56 +0200 Subject: [PATCH 22/25] add compress-hcut to cli entrypoint --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 3b1bb9d..ff04ad3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ Changelog = "https://github.com/MagnetDB/python_magnetgeo/blob/main/HISTORY.md" load-profile-from-dat = "python_magnetgeo.examples.load_profile_from_dat:main" split-helix-yaml = "python_magnetgeo.examples.split_helix_yaml:main" check-magnetgeo-yaml = "python_magnetgeo.examples.check_magnetgeo_yaml:main" +compress-hcut = "python_magnetgeo.examples.compress_hcut:main" [tool.setuptools] zip-safe = false From 85de12851589366f6e870af4071da019786e5261 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Thu, 7 May 2026 08:57:56 +0200 Subject: [PATCH 23/25] overwrite dh_auto_test to suppress coverage --- debian/rules | 3 +++ 1 file changed, 3 insertions(+) diff --git a/debian/rules b/debian/rules index 12d2ca2..162e78e 100755 --- a/debian/rules +++ b/debian/rules @@ -8,6 +8,9 @@ export PYBUILD_NAME=magnetgeo %: dh $@ --buildsystem=pybuild +override_dh_auto_test: + PYBUILD_TEST_ARGS='--override-ini=addopts= -v --tb=short --strict-markers' dh_auto_test + # If you need to rebuild the Sphinx documentation # Add sphinxdoc to the dh --with line # From 726bf4944b2afe6bff386010667d28c22bb18717 Mon Sep 17 00:00:00 2001 From: Christophe Trophime Date: Thu, 11 Jun 2026 13:42:56 +0200 Subject: [PATCH 24/25] add helper for Bitter stacking --- archive.sh | 1 + debian/changelog | 8 +- scripts/Bitter_stacking/README.md | 292 +++++++++++++++ scripts/Bitter_stacking/plan.json | 33 ++ scripts/Bitter_stacking/stacking_plan.py | 430 +++++++++++++++++++++++ 5 files changed, 763 insertions(+), 1 deletion(-) create mode 100644 scripts/Bitter_stacking/README.md create mode 100644 scripts/Bitter_stacking/plan.json create mode 100644 scripts/Bitter_stacking/stacking_plan.py diff --git a/archive.sh b/archive.sh index d2a3b3f..8968510 100755 --- a/archive.sh +++ b/archive.sh @@ -52,6 +52,7 @@ tar \ --exclude=.pc \ --exclude=.devcontainer \ --exclude=.vscode \ + --exclude=.ruff_cache \ --exclude=*.sif \ --exclude=*.crt \ --exclude=*.pem \ diff --git a/debian/changelog b/debian/changelog index f76219d..299715f 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,10 @@ -python-magnetgeo (1.0.1-7) UNRELEASED; urgency=medium +python-magnetgeo (1.0.1-8) UNRELEASED; urgency=medium + + * d/rules: overwrite dh_auto_test to skip coverage + + -- Christophe Trophime Thu, 07 May 2026 08:46:23 +0200 + +python-magnetgeo (1.0.1-7) unstable; urgency=medium * add logging support * fix helper to create Profile from Shape_profile.dat diff --git a/scripts/Bitter_stacking/README.md b/scripts/Bitter_stacking/README.md new file mode 100644 index 0000000..6daeb9a --- /dev/null +++ b/scripts/Bitter_stacking/README.md @@ -0,0 +1,292 @@ +# Tolerance stacking for magnet stacking plans + +`stacking_plan.py` estimates the total axial height of a stack of plates and +kapton insulation sheets, given a *stacking plan* and the width tolerances of +each component. It compares three estimates — worst case, analytic (RSS) and +Monte Carlo — and reports a variance budget showing which component drives the +uncertainty. + +## Theory + +### The problem + +A stack is built from $N$ pieces (plates and kapton sheets) whose individual +widths $w_i$ are not exactly known: each piece has a nominal width $\mu_i$ and +a manufacturing dispersion described either by a standard deviation +$\sigma_i$ or by a tolerance band $\mu_i \pm \Delta_i$. The total height is +simply + +$$H = \sum_{i=1}^{N} w_i$$ + +and the question is how the individual dispersions combine into a dispersion +on $H$. + +### Worst case (linear stacking) + +The pessimistic answer assumes every piece sits at its tolerance limit, all in +the same direction: + +$$H \in \Big[\sum_i (\mu_i - \Delta_i),\ \sum_i (\mu_i + \Delta_i)\Big]$$ + +The spread grows *linearly* with the number of pieces. For a stack of several +hundred plates this bound is extremely conservative: the probability that 728 +independent plates are all simultaneously at the same tolerance extreme is +essentially zero. Use it only when the stack must fit a hard mechanical +envelope with absolute certainty. The trade-off between worst-case and +statistical stacking is the founding subject of tolerance analysis +[Fortini 1967; Greenwood & Chase 1987]. + +### Statistical stacking (RSS, root-sum-square) + +If the widths are independent random variables, variances — not standard +deviations — add: + +$$\mathbb{E}[H] = \sum_i \mu_i, \qquad + \sigma_H^2 = \sum_i \sigma_i^2$$ + +For a group of $N$ identical pieces with common $(\mu, \sigma)$ this gives the +familiar square-root law + +$$\sigma_H = \sigma\sqrt{N}$$ + +so the *relative* dispersion of the stack shrinks as +$\sigma_H / (N\mu) = \sigma / (\mu\sqrt{N})$: a stack of 728 plates each known +to 2.5 % is known to about 0.09 %. + +By the **central limit theorem**, $H$ is very nearly Gaussian as soon as a few +tens of pieces are stacked, *whatever* the distribution of the individual +widths (normal, uniform, ...). One can therefore quote confidence intervals + +$$H = \mathbb{E}[H] \pm k\,\sigma_H$$ + +with $k = 1, 2, 3$ covering approximately 68 %, 95 % and 99.7 % of stacks. +Engineering practice usually quotes $k = 3$ (RSS or "statistical +tolerancing") [Evans 1974; Greenwood & Chase 1987]. The quadrature law is +the linear case of the general propagation of uncertainty used throughout +experimental physics [Taylor 1997, ch. 3; JCGM 2008, §5.1], and the Gaussian +limit is the central limit theorem [Feller 1968, ch. X]. + +### Converting a tolerance band into a standard deviation + +Drawings usually specify a tolerance half-width $\Delta$ ($\mu \pm \Delta$) +rather than a $\sigma$. The conversion depends on the assumed distribution of +pieces inside the band: + +| distribution | assumption | conversion | +|---|---|---| +| `normal` | $\Delta$ is a $k\sigma$ limit (default $k=3$) | $\sigma = \Delta / k$ | +| `uniform` | any value in the band equally likely | $\sigma = \Delta / \sqrt{3}$ | +| `triangular` | values pile up at nominal, vanish at limits | $\sigma = \Delta / \sqrt{6}$ | + +The uniform assumption is the conservative choice when nothing is known about +the process; the normal assumption suits a controlled machining process whose +capability is matched to the tolerance. The $\Delta/\sqrt{3}$ (rectangular) +and $\Delta/\sqrt{6}$ (triangular) conversions are the standard "type B" +evaluations of measurement uncertainty [JCGM 2008, §4.3.7 and §4.3.9]. + +### Correlated errors: batch bias + +The $\sqrt{N}$ law requires *independent* widths. If all plates come from the +same rolling batch or machining setup, they may share a common systematic +offset $b \sim \mathcal{N}(0, \sigma_{\text{syst}}^2)$. The mixed model + +$$w_i = \mu + b + e_i, \qquad e_i \sim \mathcal{N}(0, \sigma^2)$$ + +gives + +$$\sigma_H^2 = N\sigma^2 + (N\sigma_{\text{syst}})^2$$ + +The systematic term scales as $N^2$, so even a small batch bias dominates a +long stack: with $N = 728$, a batch bias of only +$\sigma_{\text{syst}} = 0.01$ mm contributes +$728 \times 0.01 = 7.3$ mm to $\sigma_H$, versus +$0.05\sqrt{728} \approx 1.35$ mm from a per-plate scatter five times larger. +For long stacks, batch-to-batch reproducibility matters far more than +individual plate scatter. This is what the `sigma_syst` field models. Such +mean shifts and drifts of the process distribution are the classic failure +mode of naive RSS tolerancing [Evans 1975b; Greenwood & Chase 1987]. + +### Monte Carlo + +The script also simulates the stack directly: each component group is sampled +from its declared distribution (plus one batch offset per simulated stack), +and the reported interval comes from empirical quantiles. This is an +independent cross-check of the analytic formulas and remains valid for +non-Gaussian inputs, small counts, or correlated cases. Monte Carlo is one +of the four canonical methods of statistical tolerancing, alongside linear +(stack) propagation, non-linear propagation and quadrature [Evans 1975a; +Chase & Parkinson 1991]. + +## The stacking plan + +The plan is an ordered list of segments, each made of `n_turns` turns of +`plates_per_turn` plates. The axial height of a turn is the **sum** of its +plate widths, and one kapton sheet is inserted between each pair of +consecutive turns (`n_kaptons = total_turns - 1`, overridable). + +The default plan is + +| segment | turns | plates/turn | plates | +|---|---|---|---| +| end (bottom) | 6 | 8 | 48 | +| body | 158 | 4 | 632 | +| end (top) | 6 | 8 | 48 | +| **total** | **170** | | **728** (+ 169 kaptons) | + +## Installation + +Requires Python ≥ 3.10 with `numpy`, `scipy` and (for `--plot`) +`matplotlib`: + +```bash +pip install numpy scipy matplotlib +``` + +## Usage + +Generate a starter configuration, edit it, then run: + +```bash +python stacking_plan.py --write-example plan.json +python stacking_plan.py --config plan.json --plot stack.png +``` + +Options: + +```text +--config JSON stacking plan configuration file +--write-example F write an example configuration to F and exit +--samples N Monte Carlo sample count (default 100000) +--coverage K coverage factor k for reported intervals (default 3) +--plot PNG save a histogram of simulated heights +--seed N random seed for reproducibility +``` + +Diagnostics go to stderr (`logging`), results to stdout. + +## Configuration file + +```json +{ + "components": { + "plate": {"mu": 2.0, "tolerance": 0.15, "distribution": "normal"}, + "kapton": {"mu": 0.125, "tolerance": 0.02, "distribution": "uniform"} + }, + "segments": [ + {"n_turns": 6, "plates_per_turn": 8}, + {"n_turns": 158, "plates_per_turn": 4}, + {"n_turns": 6, "plates_per_turn": 8} + ], + "kapton": "kapton", + "n_kaptons": null +} +``` + +### `components` + +A catalogue of component types. Each entry accepts: + +| field | type | meaning | +|---|---|---| +| `mu` | float, required | nominal (mean) width | +| `sigma` | float | standard deviation of the random part | +| `tolerance` | float | tolerance half-width $\Delta$ (alternative to `sigma`) | +| `distribution` | string | `normal` (default), `uniform` or `triangular` | +| `tolerance_k` | float | $k$ in $\sigma = \Delta/k$ for `normal` (default 3) | +| `sigma_syst` | float | systematic (batch) standard deviation, default 0 | + +Exactly one of `sigma` / `tolerance` is needed; the other is derived using the +table in the theory section. The `tolerance` value is also used as the hard +limit in the worst-case bound. + +### `segments`, `kapton`, `n_kaptons` + +Each segment needs `n_turns` and `plates_per_turn`; an optional `"plate"` +field names the component to use (default: the component called `plate`, so +with a single plate type the field can be omitted). `kapton` names the +insulation component, and `n_kaptons` overrides the default count of +`total_turns - 1` (e.g. if the ends are also insulated). + +## Example output + +```text +plan: [6x8(plate) + 158x4(plate) + 6x8(plate)] -> 170 turns, 728 plates, 169 kaptons + plate x728 mu=2.0, sigma=0.05000, tol=+/-0.15000, dist=normal + kapton x169 mu=0.125, sigma=0.01155, tol=+/-0.02000, dist=uniform + worst case : H = 1477.1250 +/- 3*37.5267 -> [1364.5450, 1589.7050] + analytic RSS : H = 1477.1250 +/- 3*1.3574 -> [1473.0528, 1481.1972] + Monte Carlo : H = 1477.1300 +/- 3*1.3570 -> [1473.1172, 1481.1315] (100000 samples) + relative dispersion: sigma_H/H = 0.0919% + variance budget: + plate x728 (random) 1.820000 ( 98.8%) -> sigma contrib 1.3491 + kapton x169 (random) 0.022533 ( 1.2%) -> sigma contrib 0.1501 + total 1.842533 -> sigma_H = 1.3574 +``` + +Reading the result: the nominal height is 1477.1 mm; at 99.7 % confidence the +real stack lies within ±4.1 mm of nominal, while the worst-case envelope is +±112.6 mm — a factor 27 more pessimistic. The variance budget shows the plates +account for 98.8 % of the uncertainty, so tightening the kapton tolerance +would gain essentially nothing. + +## Limitations and possible extensions + +The model assumes widths are independent within a component group except for +the optional common batch offset; intermediate correlation structures (e.g. +per-sub-batch biases) would need an extra grouping level. It also ignores any +compression of the kapton under axial preload — if the stack is clamped, the +effective kapton width under load is the quantity to put in the config. If +measured width distributions are available, the Gaussian/uniform sampling in +`monte_carlo` can be replaced by a bootstrap (`rng.choice` over the measured +values) to drop the distributional assumption entirely. + +## References + +Textbooks: + +- Fortini, E. T. (1967). *Dimensioning for Interchangeable Manufacture*. + Industrial Press, New York. — The classic textbook on dimensional + tolerancing of assemblies, including statistical (equal-likelihood) + treatment of dimension chains. +- Feller, W. (1968). *An Introduction to Probability Theory and Its + Applications*, Vol. 1, 3rd ed. Wiley, New York. — Chapter X for the + central limit theorem underlying the Gaussian approximation of the stack + height. +- Taylor, J. R. (1997). *An Introduction to Error Analysis: The Study of + Uncertainties in Physical Measurements*, 2nd ed. University Science + Books, Sausalito. — Quadrature addition of independent uncertainties + (chapter 3); the physicist's view of the same mathematics. + +Peer-reviewed articles: + +- Evans, D. H. (1974). Statistical tolerancing: The state of the art. + Part I: Background. *Journal of Quality Technology*, 6(4), 188–195. + doi:10.1080/00224065.1974.11980646 +- Evans, D. H. (1975a). Statistical tolerancing: The state of the art. + Part II: Methods for estimating moments. *Journal of Quality + Technology*, 7(1), 1–12. doi:10.1080/00224065.1975.11980657 — + Reviews the four canonical methods: linear (stack) propagation, + non-linear propagation, quadrature, and Monte Carlo. +- Evans, D. H. (1975b). Statistical tolerancing: The state of the art. + Part III: Shifts and drifts. *Journal of Quality Technology*, 7(2), + 72–76. doi:10.1080/00224065.1975.11980672 — Treats exactly the + systematic-offset problem modelled here by `sigma_syst`. +- Greenwood, W. H., & Chase, K. W. (1987). A new tolerance analysis + method for designers and manufacturers. *ASME Journal of Engineering + for Industry*, 109(2), 112–116. doi:10.1115/1.3187099 — Compares + worst-case and RSS stacking and proposes a correction for biased + (mean-shifted) component distributions. +- Chase, K. W., & Parkinson, A. R. (1991). A survey of research in the + application of tolerance analysis to the design of mechanical + assemblies. *Research in Engineering Design*, 3(1), 23–37. + doi:10.1007/BF01580066 — Survey of the field, entry point to the + wider literature. + +Standard (supplementary, freely available): + +- JCGM (2008). *Evaluation of measurement data — Guide to the expression + of uncertainty in measurement* (GUM), JCGM 100:2008. BIPM. + Available at . + — Not a textbook or journal article, but the authoritative metrology + reference for quadrature propagation (§5.1) and the rectangular / + triangular type-B conversions (§4.3.7, §4.3.9). diff --git a/scripts/Bitter_stacking/plan.json b/scripts/Bitter_stacking/plan.json new file mode 100644 index 0000000..91ef7d8 --- /dev/null +++ b/scripts/Bitter_stacking/plan.json @@ -0,0 +1,33 @@ +{ + "components": { + "plate": { + "mu": 0.775, + "tolerance": 0.005, + "distribution": "normal" + }, + "kapton": { + "mu": 0.125, + "tolerance": 0.00001, + "distribution": "uniform" + } + }, + "segments": [ + { + "n_turns": 14, + "plates_per_turn": 8, + "plate": "plate" + }, + { + "n_turns": 122, + "plates_per_turn": 4, + "plate": "plate" + }, + { + "n_turns": 14, + "plates_per_turn": 8, + "plate": "plate" + } + ], + "kapton": "kapton", + "n_kaptons": null +} diff --git a/scripts/Bitter_stacking/stacking_plan.py b/scripts/Bitter_stacking/stacking_plan.py new file mode 100644 index 0000000..3376af2 --- /dev/null +++ b/scripts/Bitter_stacking/stacking_plan.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +"""Tolerance stacking for a magnet stacking plan, driven by a JSON config. + +The stacking schema, component widths, tolerances and tolerance +distributions are all read from a JSON file (``--config``). Example:: + + { + "components": { + "plate_end": {"mu": 2.0, "tolerance": 0.15, "distribution": "normal"}, + "plate_body": {"mu": 2.0, "sigma": 0.05, "distribution": "normal"}, + "kapton": {"mu": 0.125, "tolerance": 0.02, "distribution": "uniform"} + }, + "segments": [ + {"n_turns": 6, "plates_per_turn": 8, "plate": "plate_end"}, + {"n_turns": 158, "plates_per_turn": 4, "plate": "plate_body"}, + {"n_turns": 6, "plates_per_turn": 8, "plate": "plate_end"} + ], + "kapton": "kapton", + "n_kaptons": null + } + +Component fields +---------------- +mu : float + Mean (nominal) width. +sigma : float, optional + Standard deviation of the random, piece-to-piece part. Either + ``sigma`` or ``tolerance`` must be given. +tolerance : float, optional + Half-width of the tolerance band (mu +/- tolerance). Converted to a + standard deviation according to ``distribution``: + + * ``normal`` : sigma = tolerance / tolerance_k (default k=3) + * ``uniform`` : sigma = tolerance / sqrt(3) + * ``triangular`` : sigma = tolerance / sqrt(6) +sigma_syst : float, optional + Standard deviation of a systematic offset shared by *all* pieces of + this component type in one stack (batch bias). Default 0. +distribution : {"normal", "uniform", "triangular"}, optional + Distribution of the random part. Default ``"normal"``. +tolerance_k : float, optional + Coverage factor used to convert ``tolerance`` to ``sigma`` for the + normal distribution (default 3, i.e. tolerance = 3 sigma). + +Plan fields +----------- +segments : list + Ordered groups of turns. Each entry: ``n_turns``, + ``plates_per_turn`` and ``plate`` (component name). The axial + height of a turn is the sum of its plate widths. +kapton : str + Component name used for the inter-turn insulation. +n_kaptons : int or null, optional + Number of kapton sheets; null means one between each pair of + consecutive turns (``total_turns - 1``). + +Analytic model +-------------- +With independent pieces, contributions add in quadrature per component +group g (N_g pieces, sigma_g): + + E[H] = sum_g N_g * mu_g + var[H] = sum_g ( N_g * sigma_g^2 + (N_g * sigma_syst_g)^2 ) + +H is approximately Gaussian (CLT), whatever the per-piece distribution. +The worst-case bound uses the hard tolerance limit of each piece +(``tolerance`` if given, else k*sigma) all in the same direction. A +Monte Carlo simulation cross-checks both, sampling each component from +its declared distribution. + +Usage +----- + python stacking_plan.py --config plan.json [--samples 100000] + [--coverage 3] [--plot stack.png] [--seed 42] + python stacking_plan.py --write-example plan.json # starter config +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from dataclasses import dataclass + +import numpy as np + +logger = logging.getLogger(__name__) + +_TOL_TO_SIGMA = { + "normal": lambda tol, k: tol / k, + "uniform": lambda tol, k: tol / np.sqrt(3.0), + "triangular": lambda tol, k: tol / np.sqrt(6.0), +} + +EXAMPLE_CONFIG: dict = { + "components": { + "plate": {"mu": 2.0, "tolerance": 0.15, "distribution": "normal"}, + "kapton": {"mu": 0.125, "tolerance": 0.02, "distribution": "uniform"}, + }, + "segments": [ + {"n_turns": 6, "plates_per_turn": 8, "plate": "plate"}, + {"n_turns": 158, "plates_per_turn": 4, "plate": "plate"}, + {"n_turns": 6, "plates_per_turn": 8, "plate": "plate"}, + ], + "kapton": "kapton", + "n_kaptons": None, +} + + +@dataclass(frozen=True) +class Component: + """A stacked component type (plate or kapton sheet). + + Attributes + ---------- + name : str + Component label. + mu : float + Mean width. + sigma : float + Random (piece-to-piece) standard deviation. + tolerance : float + Hard half-width of the tolerance band (used for the worst-case + bound; equals k*sigma if not given explicitly). + sigma_syst : float + Systematic (batch) standard deviation. + distribution : str + Random-part distribution: 'normal', 'uniform' or 'triangular'. + """ + + name: str + mu: float + sigma: float + tolerance: float + sigma_syst: float = 0.0 + distribution: str = "normal" + + @classmethod + def from_dict(cls, name: str, d: dict, default_k: float = 3.0) -> "Component": + """Build a Component from a JSON dictionary entry.""" + dist = d.get("distribution", "normal") + if dist not in _TOL_TO_SIGMA: + raise ValueError( + f"component {name!r}: unknown distribution {dist!r} " + f"(expected one of {sorted(_TOL_TO_SIGMA)})" + ) + k = float(d.get("tolerance_k", default_k)) + sigma = d.get("sigma") + tol = d.get("tolerance") + if sigma is None and tol is None: + raise ValueError(f"component {name!r}: need 'sigma' or 'tolerance'") + if sigma is None: + sigma = float(_TOL_TO_SIGMA[dist](float(tol), k)) + if tol is None: + tol = k * float(sigma) + return cls( + name=name, + mu=float(d["mu"]), + sigma=float(sigma), + tolerance=float(tol), + sigma_syst=float(d.get("sigma_syst", 0.0)), + distribution=dist, + ) + + +@dataclass(frozen=True) +class Group: + """N identical pieces of one component type in the stack.""" + + component: Component + count: int + + +@dataclass(frozen=True) +class StackingPlan: + """Resolved stacking plan: component groups + bookkeeping.""" + + groups: list[Group] + n_turns: int + n_plates: int + n_kaptons: int + label: str + + @classmethod + def from_config(cls, cfg: dict) -> "StackingPlan": + """Build the plan from a parsed JSON configuration.""" + components = { + name: Component.from_dict(name, d) + for name, d in cfg["components"].items() + } + + plate_counts: dict[str, int] = {} + n_turns = 0 + n_plates = 0 + parts = [] + for seg in cfg["segments"]: + nt, ppt = int(seg["n_turns"]), int(seg["plates_per_turn"]) + pname = seg.get("plate", "plate") + if pname not in components: + raise ValueError(f"segment references unknown component {pname!r}") + plate_counts[pname] = plate_counts.get(pname, 0) + nt * ppt + n_turns += nt + n_plates += nt * ppt + parts.append(f"{nt}x{ppt}({pname})") + + kname = cfg["kapton"] + if kname not in components: + raise ValueError(f"'kapton' references unknown component {kname!r}") + n_kaptons = cfg.get("n_kaptons") + n_kaptons = int(n_kaptons) if n_kaptons is not None else n_turns - 1 + + groups = [Group(components[n], c) for n, c in plate_counts.items()] + groups.append(Group(components[kname], n_kaptons)) + label = ( + f"[{' + '.join(parts)}] -> {n_turns} turns, " + f"{n_plates} plates, {n_kaptons} kaptons" + ) + return cls(groups, n_turns, n_plates, n_kaptons, label) + + +@dataclass(frozen=True) +class StackResult: + """Summary statistics of the total stack height.""" + + mean: float + std: float + lo: float + hi: float + coverage_k: float + + def __str__(self) -> str: + return ( + f"H = {self.mean:.4f} +/- {self.coverage_k:g}*{self.std:.4f}" + f" -> [{self.lo:.4f}, {self.hi:.4f}]" + ) + + +def analytic_rss(plan: StackingPlan, k: float = 3.0) -> StackResult: + """Analytic (RSS) estimate: quadrature sum over component groups.""" + mean = sum(g.count * g.component.mu for g in plan.groups) + var = sum( + g.count * g.component.sigma**2 + (g.count * g.component.sigma_syst) ** 2 + for g in plan.groups + ) + std = float(np.sqrt(var)) + return StackResult(mean, std, mean - k * std, mean + k * std, k) + + +def worst_case(plan: StackingPlan, k: float = 3.0) -> StackResult: + """Worst-case bound: every piece at its hard tolerance limit.""" + mean = sum(g.count * g.component.mu for g in plan.groups) + half = sum( + g.count * (g.component.tolerance + k * g.component.sigma_syst) + for g in plan.groups + ) + return StackResult(mean, half / k, mean - half, mean + half, k) + + +def _sample_group( + g: Group, n_samples: int, rng: np.random.Generator +) -> np.ndarray: + """Sample the total width of one component group, shape (n_samples,).""" + c = g.component + size = (n_samples, g.count) + if c.distribution == "normal": + widths = rng.normal(c.mu, c.sigma, size=size) + elif c.distribution == "uniform": + half = np.sqrt(3.0) * c.sigma + widths = rng.uniform(c.mu - half, c.mu + half, size=size) + elif c.distribution == "triangular": + half = np.sqrt(6.0) * c.sigma + widths = rng.triangular(c.mu - half, c.mu, c.mu + half, size=size) + else: # pragma: no cover - validated at load time + raise ValueError(f"unknown distribution: {c.distribution!r}") + if c.sigma_syst > 0.0: + widths += rng.normal(0.0, c.sigma_syst, size=(n_samples, 1)) + return widths.sum(axis=1) + + +def monte_carlo( + plan: StackingPlan, + n_samples: int = 100_000, + k: float = 3.0, + rng: np.random.Generator | None = None, +) -> tuple[StackResult, np.ndarray]: + """Monte Carlo simulation of the total stack height. + + Each component group is sampled from its declared distribution; + batch biases are drawn once per simulated stack. The reported + interval uses empirical quantiles with the same coverage + probability as +/- k sigma. + """ + if rng is None: + rng = np.random.default_rng() + heights = np.zeros(n_samples) + for g in plan.groups: + heights += _sample_group(g, n_samples, rng) + + from scipy.stats import norm + + p = norm.cdf(k) - norm.cdf(-k) + lo, hi = np.quantile(heights, [(1 - p) / 2, (1 + p) / 2]) + return ( + StackResult( + float(heights.mean()), float(heights.std(ddof=1)), + float(lo), float(hi), k, + ), + heights, + ) + + +def budget_table(plan: StackingPlan) -> str: + """Variance budget: contribution of each component group to sigma_H^2.""" + rows: list[tuple[str, float]] = [] + for g in plan.groups: + rows.append((f"{g.component.name} x{g.count} (random)", + g.count * g.component.sigma**2)) + if g.component.sigma_syst > 0.0: + rows.append((f"{g.component.name} x{g.count} (batch)", + (g.count * g.component.sigma_syst) ** 2)) + total = sum(v for _, v in rows) + lines = [" variance budget:"] + for name, v in rows: + lines.append( + f" {name:<28} {v:10.6f} ({v / total:6.1%})" + f" -> sigma contrib {np.sqrt(v):.4f}" + ) + lines.append( + f" {'total':<28} {total:10.6f} -> sigma_H = {np.sqrt(total):.4f}" + ) + return "\n".join(lines) + + +def plot_results( + plan: StackingPlan, heights: np.ndarray, rss: StackResult, path: str +) -> None: + """Plot the Monte Carlo histogram against the analytic Gaussian.""" + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from scipy.stats import norm + + fig, ax = plt.subplots(figsize=(8, 5)) + ax.hist(heights, bins=120, density=True, alpha=0.55, + color="steelblue", label="Monte Carlo") + x = np.linspace(heights.min(), heights.max(), 400) + ax.plot(x, norm.pdf(x, rss.mean, rss.std), "k-", lw=2, + label=rf"Analytic ($\sigma_H$={rss.std:.3f})") + ax.axvline(rss.lo, color="k", ls="--", lw=1) + ax.axvline(rss.hi, color="k", ls="--", lw=1, + label=rf"$\pm{rss.coverage_k:g}\sigma$") + ax.set_xlabel("total stack height") + ax.set_ylabel("probability density") + ax.set_title(plan.label, fontsize=10) + ax.legend() + fig.tight_layout() + fig.savefig(path, dpi=150) + logger.info("plot saved to %s", path) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Tolerance stacking for a stacking plan defined in JSON." + ) + parser.add_argument("--config", metavar="JSON", default=None, + help="stacking plan configuration file") + parser.add_argument("--write-example", metavar="JSON", default=None, + help="write an example configuration file and exit") + parser.add_argument("--samples", type=int, default=100_000, + help="number of Monte Carlo samples") + parser.add_argument("--coverage", type=float, default=3.0, + help="coverage factor k for reported intervals") + parser.add_argument("--plot", metavar="PNG", default=None, + help="save a histogram plot to this file") + parser.add_argument("--seed", type=int, default=None, + help="random seed for reproducibility") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Entry point: evaluate the stacking plan tolerance budget.""" + logging.basicConfig( + stream=sys.stderr, level=logging.INFO, format="%(levelname)s: %(message)s" + ) + args = parse_args(argv) + + if args.write_example: + with open(args.write_example, "w", encoding="utf-8") as f: + json.dump(EXAMPLE_CONFIG, f, indent=2) + f.write("\n") + logger.info("example configuration written to %s", args.write_example) + return 0 + + if args.config is None: + logger.warning("no --config given, using built-in example plan") + cfg = EXAMPLE_CONFIG + else: + with open(args.config, encoding="utf-8") as f: + cfg = json.load(f) + + plan = StackingPlan.from_config(cfg) + k = args.coverage + rng = np.random.default_rng(args.seed) + + rss = analytic_rss(plan, k) + wc = worst_case(plan, k) + mc, heights = monte_carlo(plan, args.samples, k, rng) + + print(f"plan: {plan.label}") + for g in plan.groups: + c = g.component + print(f" {c.name:<12} x{g.count:<4} mu={c.mu}, sigma={c.sigma:.5f}, " + f"tol=+/-{c.tolerance:.5f}, dist={c.distribution}" + + (f", sigma_syst={c.sigma_syst}" if c.sigma_syst else "")) + print(f" worst case : {wc}") + print(f" analytic RSS : {rss}") + print(f" Monte Carlo : {mc} ({args.samples} samples)") + print(f" relative dispersion: sigma_H/H = {rss.std / rss.mean:.4%}") + print(budget_table(plan)) + + if args.plot: + plot_results(plan, heights, rss, args.plot) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 8aa315ac62d09badd40392d32a1db3e87ba3b287 Mon Sep 17 00:00:00 2001 From: Christophe TROPHIME Date: Fri, 26 Jun 2026 07:58:22 +0200 Subject: [PATCH 25/25] Add envrc to gitignore" --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index c388ceb..18f5ca2 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,5 @@ ENV/ # images *.png + +.envrc