@@ -127,6 +127,41 @@ def _artifact_label(artifact: Dict[str, Any], *, missing: bool = False) -> str:
127127 return f"{ label } (missing)" if missing else label
128128
129129
130+ def _artifact_plot_options (artifact : Dict [str , Any ], path : Path ) -> tuple [str , str , bool ]:
131+ """Return ``(title, value_label, log_scale)`` for numeric previews.
132+
133+ Result artifacts already carry field metadata in several workflows. Keep
134+ that meaning when the file reaches the generic viewer instead of reducing
135+ every grid to anonymous rows, columns, and an unlabeled linear colour bar.
136+ """
137+ metadata = artifact .get ("metadata" )
138+ metadata = dict (metadata ) if isinstance (metadata , dict ) else {}
139+ kind = str (artifact .get ("kind" ) or "" )
140+ field = str (
141+ metadata .get ("label" )
142+ or metadata .get ("field" )
143+ or artifact .get ("label" )
144+ or (_pretty_kind (kind ) if kind else "Value" )
145+ ).strip ()
146+ units = str (metadata .get ("units" ) or "" ).strip ()
147+ tokens = " " .join ((kind , path .stem , field )).lower ()
148+ is_resistivity = any (token in tokens for token in ("resistiv" , "rhoa" , "rho_a" ))
149+ if not units and is_resistivity :
150+ units = "Ω·m"
151+ value_label = field or "Value"
152+ if units and units .lower () not in value_label .lower ():
153+ value_label = f"{ value_label } ({ units } )"
154+ explicit_log = metadata .get ("log_scale" )
155+ if isinstance (explicit_log , str ):
156+ log_scale = explicit_log .strip ().lower () in {"1" , "true" , "yes" , "log" , "log10" }
157+ elif explicit_log is None :
158+ log_scale = is_resistivity
159+ else :
160+ log_scale = bool (explicit_log )
161+ title = str (metadata .get ("title" ) or path .stem .replace ("_" , " " ).strip ()).strip ()
162+ return title , value_label , log_scale
163+
164+
130165def _run_title (record : "RunRecord" ) -> str :
131166 """Show the user's own name for a run, or a short stable one.
132167
@@ -789,11 +824,13 @@ def _render_selected_artifact(self, _index: int) -> None:
789824 renderer = select_renderer (artifact )
790825 try :
791826 if renderer in {"array" , "array_stack" , "curve" } and path .suffix .lower () in {".npy" , ".npz" }:
792- self ._render_numpy (path , renderer )
827+ self ._render_numpy (path , renderer , artifact )
793828 elif renderer == "curve" :
794829 self ._render_curve_file (path )
795830 elif renderer == "image" :
796- view = ZoomableImageView (); view .set_image_file (path )
831+ view = ZoomableImageView ()
832+ if not view .set_image_file (path ):
833+ raise ValueError ("the image decoder could not read this file" )
797834 self ._replace_visual (view )
798835 elif renderer == "vtk" :
799836 from PyHydroGeophysX .qt_apps .widgets .model3d_view import VTKVolumeView
@@ -824,7 +861,14 @@ def _replace_visual(
824861 self ._visual_resources .extend (resources or [])
825862 self ._visual_layout .addWidget (widget )
826863
827- def _render_numpy (self , path : Path , renderer : str ) -> None :
864+ def _render_numpy (
865+ self ,
866+ path : Path ,
867+ renderer : str ,
868+ artifact : Optional [Dict [str , Any ]] = None ,
869+ ) -> None :
870+ artifact = dict (artifact or {})
871+ title , value_label , log_scale = _artifact_plot_options (artifact , path )
828872 loaded = np .load (
829873 path ,
830874 allow_pickle = False ,
@@ -839,20 +883,43 @@ def _render_numpy(self, path: Path, renderer: str) -> None:
839883 ]
840884 if not names :
841885 raise ValueError ("NPZ contains no numeric arrays." )
842- array = np .array (loaded [names [0 ]], copy = True )
886+ metadata = artifact .get ("metadata" )
887+ metadata = dict (metadata ) if isinstance (metadata , dict ) else {}
888+ requested = str (metadata .get ("array_key" ) or "" )
889+ chosen = requested if requested in names else names [0 ]
890+ array = np .array (loaded [chosen ], copy = True )
843891 loaded .close ()
844892 else :
845893 array = loaded
846- renderer = select_renderer ({"path" : str (path )}, array .shape )
894+ # Keep semantic dispatch from the artifact (notably a 2-D curve
895+ # table). Shape only upgrades/downgrades the generic array modes.
896+ if renderer == "array" and array .ndim >= 3 :
897+ renderer = "array_stack"
898+ elif renderer == "array_stack" and array .ndim < 3 :
899+ renderer = "curve" if array .ndim == 1 else "array"
847900 if renderer == "curve" or array .ndim == 1 :
848- values = np .array (array , copy = True ).ravel ()
849- view = CurveViewer (); view .add_curve (
850- np .arange (values .size ), values , path .stem
851- )
901+ values = np .array (array , copy = True )
902+ view = CurveViewer ()
903+ if values .ndim == 2 and min (values .shape ) >= 2 :
904+ # A compact 2 x N / 3 x N array normally stores x and one
905+ # or two series by row; a tall N x K array stores columns.
906+ if values .shape [0 ] <= 4 and values .shape [1 ] > 2 * values .shape [0 ]:
907+ values = values .T
908+ x = values [:, 0 ]
909+ for column in range (1 , values .shape [1 ]):
910+ view .add_curve (x , values [:, column ], f"{ value_label } { column } " )
911+ else :
912+ values = values .ravel ()
913+ view .add_curve (np .arange (values .size ), values , value_label )
852914 self ._replace_visual (view ); return
853915 if array .ndim == 2 :
854916 values = np .array (array , copy = True )
855- view = ArrayViewer (); view .set_array (values )
917+ view = ArrayViewer (); view .set_array (
918+ values ,
919+ log = log_scale ,
920+ value_label = value_label ,
921+ title = title ,
922+ )
856923 self ._replace_visual (view ); return
857924 if array .ndim >= 3 :
858925 host = QWidget (); layout = QVBoxLayout (host )
@@ -861,17 +928,29 @@ def _render_numpy(self, path: Path, renderer: str) -> None:
861928 controls .addWidget (QLabel ("Step / slice:" )); controls .addWidget (step ); controls .addStretch (1 )
862929 view = ArrayViewer ()
863930 sample = np .asarray (array ).ravel ()[::max (1 , array .size // 250_000 )]
931+ if log_scale :
932+ with np .errstate (divide = "ignore" , invalid = "ignore" ):
933+ sample = np .log10 (np .where (sample > 0 , sample , np .nan ))
864934 finite = sample [np .isfinite (sample )]
865- limits = np .percentile (finite , [2 , 98 ]) if finite .size else None
935+ limits = None
936+ if finite .size :
937+ lo , hi = np .percentile (finite , [2 , 98 ])
938+ if hi <= lo :
939+ half_span = 0.5 if log_scale else max (abs (float (lo )) * 0.05 , 0.5 )
940+ lo , hi = float (lo ) - half_span , float (hi ) + half_span
941+ limits = (float (lo ), float (hi ))
866942 def show_slice (value : int ) -> None :
867943 # Copy only the visible slice: the widget never owns a view
868944 # of the file mapping, so Delete Run can close it first.
869945 view .set_array (
870946 np .array (array [value ], copy = True ),
871947 autoscale = limits is None ,
948+ log = log_scale ,
949+ value_label = value_label ,
950+ title = f"{ title } — slice { value + 1 } " ,
872951 )
873- if limits is not None and limits [ 1 ] > limits [ 0 ] :
874- view .set_levels (float ( limits [ 0 ]), float ( limits [ 1 ]) )
952+ if limits is not None :
953+ view .set_levels (* limits )
875954 step .valueChanged .connect (show_slice ); show_slice (0 )
876955 layout .addLayout (controls ); layout .addWidget (view , stretch = 1 )
877956 self ._replace_visual (host , resources = [loaded ])
0 commit comments